mirror of
https://github.com/cline/cline.git
synced 2026-09-01 23:19:18 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ec403e16a | |||
| 6155fc1d82 | |||
| bd4821a2ea |
+13
-9
@@ -140,10 +140,12 @@ Adding a new key to global state requires updates in multiple places. Missing an
|
||||
|
||||
Required steps:
|
||||
1. Type definition in `src/shared/storage/state-keys.ts` - Add to `GlobalState` or `Settings` interface
|
||||
2. Add any default value or transform in `src/shared/storage/state-keys.ts` if the key needs one
|
||||
3. Read and write the value through `StateManager` (`setGlobalState()` / `getGlobalStateKey()`) after initialization
|
||||
2. Read from globalState in `src/core/storage/utils/state-helpers.ts`:
|
||||
- Add `const myKey = context.globalState.get<GlobalStateAndSettings["myKey"]>("myKey")` in `readGlobalStateFromDisk()`
|
||||
- Add to the return object: `myKey: myKey ?? defaultValue,`
|
||||
3. StateManager handles read/write via `setGlobalState()`/`getGlobalStateKey()` after initialization
|
||||
|
||||
Persistent state is file-backed through `StateManager`; do not add new runtime reads or writes against VS Code `ExtensionContext` storage. That storage is only a legacy migration source.
|
||||
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
|
||||
|
||||
Settings plumbing gotcha: if a key is user-toggleable from settings, wire both controller update paths:
|
||||
- `src/core/controller/state/updateSettings.ts` for webview `updateSetting(...)`
|
||||
@@ -157,20 +159,22 @@ Webview toggle gotcha: settings changes must also round-trip back in state paylo
|
||||
If this round-trip wiring is missing, the backend value can update but the toggle in webview appears stuck or reverts.
|
||||
|
||||
## StateManager Cache vs Direct globalState Access
|
||||
StateManager uses an in-memory cache populated during `StateManager.initialize()` from file-backed storage. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
|
||||
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
|
||||
|
||||
Exception: host migration code may read legacy VS Code storage before file-backed storage is initialized.
|
||||
Exception: State needed immediately at extension startup (before cache is ready)
|
||||
|
||||
Example pattern:
|
||||
When Window A sets state and immediately opens Window B, the new window's StateManager cache is populated from `context.globalState` during initialization. If you need to read state in Window B right at startup (e.g., in `common.ts` during `initialize()`), read directly from `context.globalState.get()` instead of StateManager's cache.
|
||||
|
||||
Example pattern (see `lastShownAnnouncementId` and `worktreeAutoOpenPath`):
|
||||
```typescript
|
||||
// Writing (normal pattern)
|
||||
controller.stateManager.setGlobalState("myKey", value)
|
||||
|
||||
// Reading after initialization
|
||||
const value = controller.stateManager.getGlobalStateKey("myKey")
|
||||
// Reading at startup in common.ts (bypass cache)
|
||||
const value = context.globalState.get<string>("myKey")
|
||||
```
|
||||
|
||||
Use `context.globalState` only in VS Code migration code that copies legacy ExtensionContext values into the shared file-backed stores.
|
||||
This is only needed for cross-window state read during the brief startup window before StateManager cache is fully usable. Normal state access after initialization should use StateManager.
|
||||
|
||||
## ChatRow Cancelled/Interrupted States
|
||||
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @dominiccooney
|
||||
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @candieduniverse
|
||||
/README.md @saoudrizwan @juanpflores
|
||||
|
||||
@@ -7,10 +7,10 @@ body:
|
||||
value: |
|
||||
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
|
||||
- type: dropdown
|
||||
id: cline-surface
|
||||
id: plugin-type
|
||||
attributes:
|
||||
label: Cline Surface
|
||||
description: Which Cline surface are you reporting a bug for?
|
||||
label: Plugin Type
|
||||
description: Which plugin are you reporting a bug for?
|
||||
options:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
@@ -59,18 +59,6 @@ body:
|
||||
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: ide-diagnostics
|
||||
attributes:
|
||||
label: IDE / CLI Diagnostics
|
||||
description: |
|
||||
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
|
||||
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
|
||||
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
|
||||
- CLI: there is no About dialog. Run `cline --version` and paste the output.
|
||||
placeholder: Paste the copied About info or `cline --version` output here.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: system-info
|
||||
attributes:
|
||||
|
||||
@@ -44,7 +44,7 @@ For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/mod
|
||||
Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes.
|
||||
|
||||
## Global State Keys (silent failure risk)
|
||||
Adding a key requires updating the typed storage definitions in `src/shared/storage/state-keys.ts`; runtime reads and writes should go through `StateManager`, not VS Code `ExtensionContext` storage. Persistent state is file-backed so it works across VS Code, CLI, and JetBrains hosts.
|
||||
Adding a key requires: type in `src/shared/storage/state-keys.ts`, read via `context.globalState.get()` in `src/core/storage/utils/state-helpers.ts` `readGlobalStateFromDisk()`, and add to return object. Missing the `.get()` call compiles fine but value is always `undefined`.
|
||||
|
||||
## Slash Commands (3 places)
|
||||
- `src/core/slash-commands/index.ts` — definitions.
|
||||
|
||||
@@ -33,7 +33,7 @@ permissions:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
working-directory: sdk
|
||||
|
||||
jobs:
|
||||
publish-main:
|
||||
@@ -105,12 +105,12 @@ jobs:
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/cli/package.json').version")
|
||||
|
||||
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
|
||||
echo "apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
|
||||
echo "sdk/apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! printf "%s\n" "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "apps/cli/package.json has invalid version: ${VERSION}"
|
||||
echo "sdk/apps/cli/package.json has invalid version: ${VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
|
||||
- name: Build platform binaries
|
||||
run: bun script/build.ts --install-native-variants --skip-sdk-build
|
||||
working-directory: apps/cli
|
||||
working-directory: sdk/apps/cli
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
@@ -194,7 +194,7 @@ jobs:
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
run: bun script/publish-npm.ts --tag latest
|
||||
working-directory: apps/cli
|
||||
working-directory: sdk/apps/cli
|
||||
|
||||
- name: Get Previous CLI Tag
|
||||
id: prev_tag
|
||||
@@ -375,7 +375,7 @@ jobs:
|
||||
- name: Build platform binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun script/build.ts --install-native-variants --skip-sdk-build
|
||||
working-directory: apps/cli
|
||||
working-directory: sdk/apps/cli
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
@@ -424,7 +424,7 @@ jobs:
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
run: bun script/publish-npm.ts --tag nightly
|
||||
working-directory: apps/cli
|
||||
working-directory: sdk/apps/cli
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
|
||||
@@ -15,11 +15,9 @@ jobs:
|
||||
trigger-integration-test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
# Auto-run only for trusted PR authors. Anyone else needs a maintainer
|
||||
# to opt their PR in by commenting /test-jetbrains.
|
||||
# Run on PR open/reopen, or when someone comments /test-jetbrains on a PR
|
||||
if: |
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.pull_request.author_association)) ||
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/test-jetbrains') &&
|
||||
@@ -29,8 +27,8 @@ jobs:
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ vars.CLINE_JETBRAINS_APP_ID }}
|
||||
private-key: ${{ secrets.CLINE_JETBRAINS_APP_KEY }}
|
||||
app-id: 1998650
|
||||
private-key: ${{ secrets.CLINE_JETBRAINS_WORKFLOW_KEY }}
|
||||
owner: cline
|
||||
repositories: intellij-plugin
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# TODO: Fold this workflow's SDK login changes into ext-vscode-publish-nightly.yml
|
||||
# and delete this file. Pinned to dpc/sdk-migration-simpler-login while Max is iterating.
|
||||
# Owner: Max Paulus
|
||||
name: ext-vscode-publish-nightly-sdk
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 12 * * *' # 4 AM PST (UTC-8) = 12 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
# Keep the publish source pinned to one reviewed branch instead of accepting arbitrary refs.
|
||||
SDK_NIGHTLY_REF: dpc/sdk-migration-simpler-login
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish Cline New SDK Extension Nightly
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
- name: Checkout trusted SDK nightly branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.SDK_NIGHTLY_REF }}
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
|
||||
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
|
||||
node-version: 22
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Publish SDK nightly extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run publish:marketplace:nightly
|
||||
@@ -20,7 +20,6 @@ jobs:
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
|
||||
publish:
|
||||
@@ -31,9 +30,6 @@ jobs:
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
- name: Checkout selected branch
|
||||
@@ -57,13 +53,11 @@ jobs:
|
||||
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci --include=optional
|
||||
- name: Install root dependencies
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci --include=optional
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
@@ -114,13 +114,11 @@ jobs:
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode install --include=optional
|
||||
- name: Install root dependencies
|
||||
run: npm install --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
run: cd webview-ui && npm install --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
@@ -128,15 +128,13 @@ jobs:
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Install vsce
|
||||
run: npm install -g @vscode/vsce
|
||||
|
||||
@@ -91,15 +91,13 @@ jobs:
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: npm run ci:check-all
|
||||
@@ -132,19 +130,16 @@ jobs:
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Set up NPM on Windows
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
|
||||
@@ -234,15 +229,13 @@ jobs:
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
apps/vscode/testing-platform/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
@@ -251,8 +244,7 @@ jobs:
|
||||
run: npm run compile-standalone
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/testing-platform ci
|
||||
run: cd testing-platform && npm ci
|
||||
|
||||
- name: Running testing platform integration spec tests
|
||||
timeout-minutes: 7
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
const labels = context.payload.issue.labels.map(l => l.name);
|
||||
|
||||
// Check if JetBrains Plugin is selected
|
||||
if (body.match(/###\s*Cline Surface\s*\n+JetBrains Plugin/i)) {
|
||||
if (body.match(/###\s*Plugin Type\s*\n+JetBrains Plugin/i)) {
|
||||
if (!labels.includes('JetBrains')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
}
|
||||
|
||||
// Check if VSCode Extension is selected
|
||||
if (body.match(/###\s*Cline Surface\s*\n+VSCode Extension/i)) {
|
||||
if (body.match(/###\s*Plugin Type\s*\n+VSCode Extension/i)) {
|
||||
if (!labels.includes('VS Code')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
}
|
||||
|
||||
// Check if CLI is selected
|
||||
if (body.match(/###\s*Cline Surface\s*\n+CLI/i)) {
|
||||
if (body.match(/###\s*Plugin Type\s*\n+CLI/i)) {
|
||||
if (!labels.includes('CLI')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
|
||||
@@ -26,7 +26,7 @@ on:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
working-directory: sdk
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
env:
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
run: |
|
||||
BASE_VERSION=$(node -p "require('./sdk/packages/llms/package.json').version")
|
||||
BASE_VERSION=$(node -p "require('./packages/llms/package.json').version")
|
||||
|
||||
if [ "$CHANNEL" = "nightly" ]; then
|
||||
TIMESTAMP=$(date +%s)
|
||||
@@ -166,11 +166,11 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: bun sdk/scripts/version.ts "$VERSION"
|
||||
run: bun scripts/version.ts "$VERSION"
|
||||
|
||||
- name: Verify publishability
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun sdk/scripts/check-publish.ts
|
||||
run: bun scripts/check-publish.ts
|
||||
|
||||
- name: Prepare package tarball directory
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/shared@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/shared
|
||||
cd packages/shared
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/llms@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/llms
|
||||
cd packages/llms
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/agents@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/agents
|
||||
cd packages/agents
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/core@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/core
|
||||
cd packages/core
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
@@ -235,7 +235,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/sdk@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/sdk
|
||||
cd packages/sdk
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ permissions:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
working-directory: sdk
|
||||
|
||||
jobs:
|
||||
quality-checks:
|
||||
@@ -96,12 +96,12 @@ jobs:
|
||||
|
||||
- name: Run SDK Tests (Windows)
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'windows-latest' }}
|
||||
run: bun -F './sdk/packages/**' test
|
||||
run: bun -F './packages/**' test
|
||||
|
||||
- name: Smoke test SQLite under Node
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os != 'windows-latest' }}
|
||||
timeout-minutes: 10
|
||||
run: bun sdk/scripts/ci-node-smoke.ts
|
||||
run: bun scripts/ci-node-smoke.ts
|
||||
|
||||
- name: Run TUI e2e tests
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
|
||||
@@ -109,4 +109,4 @@ jobs:
|
||||
|
||||
- name: Verify packages are publishable
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
|
||||
run: bun sdk/scripts/check-publish.ts
|
||||
run: bun scripts/check-publish.ts
|
||||
|
||||
-11
@@ -61,17 +61,6 @@ tests/**/cache
|
||||
# Should never be committed: only exists if a publish aborts mid-swap.
|
||||
.README.github.bak
|
||||
|
||||
# Tauri generated code
|
||||
apps/*/src-tauri/gen
|
||||
apps/*/src-tauri/bin
|
||||
apps/examples/*/src-tauri/gen
|
||||
apps/examples/*/src-tauri/bin
|
||||
# Tauri UI test snapshots
|
||||
apps/*/src/tests/.tui-test
|
||||
apps/*/src/tests/tui-traces
|
||||
apps/vscode/webview-ui/src/**/*.js
|
||||
apps/vscode/webview-ui/src/**/*.js.map
|
||||
|
||||
|
||||
# SDK Session files / User data
|
||||
.cline/data
|
||||
|
||||
@@ -1,10 +1 @@
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo "gitleaks is required for the pre-commit secret scan."
|
||||
echo "Install it with: brew install gitleaks"
|
||||
echo "Other install options: https://github.com/gitleaks/gitleaks#installing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gitleaks git --pre-commit --redact --staged --verbose || exit 1
|
||||
|
||||
lint-staged
|
||||
Vendored
+21
-22
@@ -5,8 +5,8 @@
|
||||
"tasks": [
|
||||
{
|
||||
"label": "compile-standalone",
|
||||
"type": "shell",
|
||||
"command": "npm run compile-standalone",
|
||||
"type": "npm",
|
||||
"script": "compile-standalone",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
@@ -18,8 +18,8 @@
|
||||
},
|
||||
{
|
||||
"label": "npm: protos",
|
||||
"type": "shell",
|
||||
"command": "npm run protos",
|
||||
"type": "npm",
|
||||
"script": "protos",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"presentation": {
|
||||
@@ -64,8 +64,8 @@
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run build:webview",
|
||||
"type": "npm",
|
||||
"script": "build:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
@@ -85,8 +85,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run build:webview:test",
|
||||
"type": "npm",
|
||||
"script": "build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
@@ -107,8 +107,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run dev:webview",
|
||||
"type": "npm",
|
||||
"script": "dev:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [
|
||||
{
|
||||
@@ -144,8 +144,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch:esbuild",
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -183,8 +183,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch:esbuild:test",
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild:test",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -223,8 +223,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch:tsc",
|
||||
"type": "npm",
|
||||
"script": "watch:tsc",
|
||||
"group": "build",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
@@ -241,9 +241,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch-tests",
|
||||
"label": "npm: watch-tests",
|
||||
"type": "npm",
|
||||
"script": "watch-tests",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
"dependsOn": [
|
||||
@@ -281,8 +280,8 @@
|
||||
"command": "rm -rf ${workspaceFolder}/apps/vscode/dist/tmp/user && mkdir -p ${workspaceFolder}/apps/vscode/dist/tmp/user"
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run storybook",
|
||||
"type": "npm",
|
||||
"script": "storybook",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
@@ -309,7 +308,7 @@
|
||||
"$tsc"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
"cwd": "${workspaceFolder}/sdk"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,96 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [3.89.2]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Complete the fix for the Anthropic provider on VS Code 1.123 and later by upgrading the bundled Anthropic SDK to a release compatible with the Node 24 runtime.
|
||||
- Update the Vertex AI provider to a compatible Anthropic Vertex SDK release so it works with the upgraded Anthropic SDK.
|
||||
|
||||
## [3.89.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore the Anthropic provider on VS Code 1.123 and later, where the updated Node 24 runtime broke the bundled Anthropic SDK.
|
||||
- Handle the DeepSeek V4 reasoning format.
|
||||
|
||||
## [3.89.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Claude Fable 5 model support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix MiniMax M3 thinking controls across gateways.
|
||||
|
||||
### Changed
|
||||
|
||||
- Clean up the Codex model list.
|
||||
|
||||
## [3.88.1]
|
||||
|
||||
### Added
|
||||
|
||||
- Add a debug section in settings for Cline testers.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Include the walkthrough markdown files in the VS Code extension package so the first-run walkthrough steps load correctly.
|
||||
|
||||
## [3.88.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add the latest Fireworks AI serverless models and update the default Fireworks model to Kimi K2.6.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix MCP server delete/add flows so settings writes do not cause the MCP server list to be emptied by the file watcher.
|
||||
- Remove stale Fireworks AI models and correct Fireworks model metadata and cache pricing.
|
||||
|
||||
### Changed
|
||||
|
||||
- Always use the upstream Cline recommended models endpoint instead of gating it behind a feature flag.
|
||||
|
||||
## [3.87.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add MiniMax M3 model support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Update VS Code extension dependencies to resolve security issues in `@xmldom/xmldom`, `basic-ftp`, `axios`, `undici`, and other direct/transitive packages.
|
||||
|
||||
## [3.86.2]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix `@` file mentions and workspace file search on VS Code 1.122+ by resolving the new bundled `@vscode/ripgrep-universal` per-platform binary layout before falling back to legacy ripgrep paths.
|
||||
|
||||
## [3.86.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix `@` file mentions failing to find files in some environments (notably VS Code Remote SSH, and after certain VS Code updates) by keeping the file-search fallback alive when the workspace index or bundled ripgrep binary is unavailable.
|
||||
|
||||
## [3.86.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Claude Opus 4.8 provider support, including 1M-context variants where available.
|
||||
- Add Moonshot Kimi K2.6 model support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show prompt-cache support for Qwen 3.7 Max in the Cline provider.
|
||||
- Fix the VS Code nightly publish workflow startup permissions.
|
||||
|
||||
### Changed
|
||||
|
||||
- Move the VS Code extension project into `apps/vscode`.
|
||||
|
||||
## [3.85.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -51,7 +51,7 @@ for CI/CD and scripting.
|
||||
npm i -g cline
|
||||
```
|
||||
|
||||
<a href="./apps/cli/README.md">Learn more</a>
|
||||
<a href="./sdk/apps/cli/README.md">Learn more</a>
|
||||
<br><br>
|
||||
|
||||
</td>
|
||||
@@ -129,7 +129,7 @@ npm install @cline/sdk
|
||||
| Product | Description | Location | CHANGELOG |
|
||||
|---------|------------|--------------|--------------|
|
||||
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) |
|
||||
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`apps/cli/`](https://github.com/cline/cline/tree/main/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/cli/CHANGELOG.md) |
|
||||
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`sdk/apps/cli/`](https://github.com/cline/cline/tree/main/sdk/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/apps/cli/CHANGELOG.md) |
|
||||
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
|
||||
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
|
||||
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban) | [CHANGELOG.md](https://github.com/cline/kanban/blob/main/CHANGELOG.md) |
|
||||
@@ -212,12 +212,8 @@ cline schedule create "PR summary" \
|
||||
Chat with your agent from any messaging platform: Telegram, Slack, Discord, Google Chat, WhatsApp, and Linear. Each conversation thread maps to an agent session with full context. Set up access control to restrict who can interact with your agent.
|
||||
|
||||
```bash
|
||||
# Connect to Telegram
|
||||
cline connect telegram -k $BOT_TOKEN
|
||||
# Connect to Slack through webhook
|
||||
cline connect slack --bot-token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
# Connect to Slack using socket mode
|
||||
cline connect slack --bot-token $SLACK_TOKEN --app-token $SLACK_APP_TOKEN
|
||||
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
```
|
||||
|
||||
## Headless CLI for CI/CD
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"root": false,
|
||||
"extends": [
|
||||
"../sdk/biome.json"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"a11y": {
|
||||
"noStaticElementInteractions": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { loginAndSaveProviderOAuthCredentials } from "@cline/core";
|
||||
import { getPersistedProviderApiKey } from "../commands/auth";
|
||||
import { writeDiagnostic } from "../utils/output";
|
||||
|
||||
/**
|
||||
* Supported ACP OAuth provider IDs.
|
||||
*/
|
||||
export const ACP_AUTH_METHODS = [
|
||||
{ id: "cline", name: "Sign in with Cline" },
|
||||
{ id: "openai-codex", name: "Sign in with ChatGPT Subscription" },
|
||||
] as const;
|
||||
|
||||
export type AcpAuthMethodId = (typeof ACP_AUTH_METHODS)[number]["id"];
|
||||
|
||||
export function isAcpAuthMethodId(id: string): id is AcpAuthMethodId {
|
||||
return ACP_AUTH_METHODS.some((m) => m.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an OAuth login for the given provider in ACP mode.
|
||||
*
|
||||
* Since stdin/stdout are used for the JSON-RPC transport, all user-facing
|
||||
* output is written to stderr and URLs are opened via the `open` package.
|
||||
* If the OAuth flow requires interactive prompts (rare), defaults are used
|
||||
* when available; otherwise an error is thrown.
|
||||
*/
|
||||
async function performOAuthLogin(input: {
|
||||
providerId: AcpAuthMethodId;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
}): Promise<string> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }] = await Promise.all(
|
||||
[import("@cline/core"), import("open")],
|
||||
);
|
||||
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
onPrompt: ({ defaultValue }) => {
|
||||
if (defaultValue) {
|
||||
return Promise.resolve(defaultValue);
|
||||
}
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
"OAuth flow requires interactive input which is unavailable in ACP mode",
|
||||
),
|
||||
);
|
||||
},
|
||||
onOutput: (message) => writeDiagnostic(`[acp/auth] ${message}`),
|
||||
openUrl: (url) => open(url, { wait: false }).then(() => undefined),
|
||||
onOpenUrlError: ({ url }) => {
|
||||
writeDiagnostic(
|
||||
`[acp/auth] Could not open browser automatically. Open this URL manually:\n${url}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const settings = await loginAndSaveProviderOAuthCredentials(
|
||||
input.providerSettingsManager,
|
||||
input.providerId,
|
||||
{ callbacks },
|
||||
);
|
||||
const apiKey = getPersistedProviderApiKey(input.providerId, settings);
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
`OAuth login did not persist credentials for ${input.providerId}`,
|
||||
);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export interface AcpAuthResult {
|
||||
providerId: AcpAuthMethodId;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate via OAuth for the given ACP auth method.
|
||||
*
|
||||
* Uses `ProviderSettingsManager` to check for existing credentials first,
|
||||
* falling back to a fresh OAuth login if needed.
|
||||
*/
|
||||
export async function authenticateAcpProvider(
|
||||
methodId: AcpAuthMethodId,
|
||||
providerSettingsManager: ProviderSettingsManager,
|
||||
): Promise<AcpAuthResult> {
|
||||
const existing = providerSettingsManager.getProviderSettings(methodId);
|
||||
|
||||
// Check for already-stored credentials.
|
||||
const existingKey = getPersistedProviderApiKey(methodId, existing);
|
||||
if (existingKey) {
|
||||
writeDiagnostic(`[acp/auth] Using existing credentials for ${methodId}`);
|
||||
return { providerId: methodId, apiKey: existingKey };
|
||||
}
|
||||
|
||||
// Perform a fresh OAuth login.
|
||||
writeDiagnostic(`[acp/auth] Starting OAuth login for ${methodId}…`);
|
||||
const apiKey = await performOAuthLogin({
|
||||
providerId: methodId,
|
||||
providerSettingsManager,
|
||||
});
|
||||
writeDiagnostic(`[acp/auth] Successfully authenticated with ${methodId}`);
|
||||
return { providerId: methodId, apiKey };
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("runAcpMode", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("@agentclientprotocol/sdk");
|
||||
vi.doUnmock("./acpAgent");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("writes the startup diagnostic without labeling it as an error", async () => {
|
||||
const stderrWrite = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true);
|
||||
|
||||
vi.doMock("@agentclientprotocol/sdk", () => ({
|
||||
ndJsonStream: vi.fn(() => ({})),
|
||||
AgentSideConnection: class {
|
||||
closed = Promise.resolve();
|
||||
},
|
||||
}));
|
||||
vi.doMock("./acpAgent", () => ({
|
||||
AcpAgent: class {},
|
||||
}));
|
||||
|
||||
const { runAcpMode } = await import("./index");
|
||||
|
||||
await runAcpMode();
|
||||
|
||||
expect(stderrWrite).toHaveBeenCalledWith(
|
||||
"[acp] starting ACP mode over stdio…\n",
|
||||
);
|
||||
expect(stderrWrite).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("error:"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync } from "node:fs";
|
||||
import { arch, platform, tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"WORKSPACE_ROOT",
|
||||
"CLINE_DIR",
|
||||
"CLINE_SANDBOX",
|
||||
"CLINE_SANDBOX_DATA_DIR",
|
||||
"CLINE_DATA_DIR",
|
||||
"CLINE_DB_DATA_DIR",
|
||||
"CLINE_SESSION_DATA_DIR",
|
||||
"CLINE_TEAM_DATA_DIR",
|
||||
"CLINE_PROVIDER_SETTINGS_PATH",
|
||||
"CLINE_HOOKS_LOG_PATH",
|
||||
"HOST",
|
||||
"CLINE_HUB_DASHBOARD_PORT",
|
||||
"PUBLIC_URL",
|
||||
"ROOM_SECRET",
|
||||
"CLINE_HUB_WEBVIEW_DIST_DIR",
|
||||
"CLINE_WRAPPER_PATH",
|
||||
] as const;
|
||||
|
||||
const originalEnv = Object.fromEntries(
|
||||
ENV_KEYS.map((key) => [key, process.env[key]]),
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = originalEnv[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("runDashboardCommand", () => {
|
||||
it("starts the dashboard server, opens the invite URL, and waits for shutdown", async () => {
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const stop = vi.fn();
|
||||
let observedEnv:
|
||||
| {
|
||||
workspaceRoot: string | undefined;
|
||||
clineDir: string | undefined;
|
||||
clineDataDir: string | undefined;
|
||||
providerSettingsPath: string | undefined;
|
||||
host: string | undefined;
|
||||
port: string | undefined;
|
||||
publicUrl: string | undefined;
|
||||
roomSecret: string | undefined;
|
||||
webviewDistDir: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
const webviewDistDir = mkdtempSync(join(tmpdir(), "cline-webview-dist-"));
|
||||
mkdirSync(webviewDistDir, { recursive: true });
|
||||
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
configDir: "/tmp/cline-config",
|
||||
cwd: "sdk",
|
||||
dataDir: ".cline-dashboard-data",
|
||||
host: "127.0.0.1",
|
||||
port: "9090",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
roomSecret: "secret",
|
||||
io: {
|
||||
writeln: (text) => output.push(text ?? ""),
|
||||
writeErr: (text) => errors.push(text),
|
||||
},
|
||||
startServer: async () => {
|
||||
observedEnv = {
|
||||
workspaceRoot: process.env.WORKSPACE_ROOT,
|
||||
clineDir: process.env.CLINE_DIR,
|
||||
clineDataDir: process.env.CLINE_DATA_DIR,
|
||||
providerSettingsPath: process.env.CLINE_PROVIDER_SETTINGS_PATH,
|
||||
host: process.env.HOST,
|
||||
port: process.env.CLINE_HUB_DASHBOARD_PORT,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
roomSecret: process.env.ROOM_SECRET,
|
||||
webviewDistDir: process.env.CLINE_HUB_WEBVIEW_DIST_DIR,
|
||||
};
|
||||
return {
|
||||
listenUrl: "http://127.0.0.1:9090/",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
inviteUrl: "http://127.0.0.1:9090/?roomSecret=secret",
|
||||
hubUrl: "ws://127.0.0.1:25463/hub",
|
||||
stop,
|
||||
};
|
||||
},
|
||||
openUrl: async (url) => {
|
||||
opened.push(url);
|
||||
},
|
||||
waitForShutdown: async (server) => {
|
||||
await server.stop();
|
||||
},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(observedEnv).toEqual({
|
||||
workspaceRoot: resolve("sdk"),
|
||||
clineDir: "/tmp/cline-config",
|
||||
clineDataDir: resolve("sdk", ".cline-dashboard-data"),
|
||||
providerSettingsPath: join(
|
||||
resolve("sdk", ".cline-dashboard-data"),
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
host: "127.0.0.1",
|
||||
port: "9090",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
roomSecret: "secret",
|
||||
webviewDistDir,
|
||||
});
|
||||
expect(opened).toEqual(["http://127.0.0.1:9090/?roomSecret=secret"]);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(output.join("\n")).toContain("Cline dashboard listening at");
|
||||
expect(output.join("\n")).toContain("ws://127.0.0.1:25463/hub");
|
||||
expect(errors).toEqual([]);
|
||||
expect(process.env.WORKSPACE_ROOT).toBe(originalEnv.WORKSPACE_ROOT);
|
||||
expect(process.env.CLINE_HUB_WEBVIEW_DIST_DIR).toBe(webviewDistDir);
|
||||
});
|
||||
|
||||
it("honors --no-open behavior", async () => {
|
||||
const openUrl = vi.fn();
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
openBrowser: false,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
startServer: async () => ({
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop: vi.fn(),
|
||||
}),
|
||||
openUrl,
|
||||
waitForShutdown: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finds webview assets from the published wrapper package layout", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-wrapper-layout-"));
|
||||
const wrapperPath = join(root, "node_modules", "cline", "bin", "cline");
|
||||
const platformName = platform() === "win32" ? "windows" : platform();
|
||||
const webviewDistDir = join(
|
||||
root,
|
||||
"node_modules",
|
||||
"cline",
|
||||
"node_modules",
|
||||
"@cline",
|
||||
`cli-${platformName}-${arch()}`,
|
||||
"cline-hub",
|
||||
"webview",
|
||||
);
|
||||
mkdirSync(join(wrapperPath, ".."), { recursive: true });
|
||||
mkdirSync(webviewDistDir, { recursive: true });
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
delete process.env.CLINE_HUB_WEBVIEW_DIST_DIR;
|
||||
let observedWebviewDistDir: string | undefined;
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
openBrowser: false,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
startServer: async () => {
|
||||
observedWebviewDistDir = process.env.CLINE_HUB_WEBVIEW_DIST_DIR;
|
||||
return {
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop: vi.fn(),
|
||||
};
|
||||
},
|
||||
waitForShutdown: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(observedWebviewDistDir).toBe(webviewDistDir);
|
||||
});
|
||||
|
||||
it("settles shutdown when server stop rejects", async () => {
|
||||
const shutdown = waitForProcessShutdown({
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop: vi.fn(async () => {
|
||||
throw new Error("stop failed");
|
||||
}),
|
||||
});
|
||||
|
||||
process.emit("SIGINT", "SIGINT");
|
||||
|
||||
await expect(shutdown).rejects.toThrow("stop failed");
|
||||
});
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { arch, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import open from "open";
|
||||
import { configureSandboxEnvironment } from "../utils/helpers";
|
||||
import { c } from "../utils/output";
|
||||
|
||||
export interface DashboardServerHandle {
|
||||
listenUrl: string;
|
||||
publicUrl: string;
|
||||
inviteUrl: string;
|
||||
hubUrl?: string;
|
||||
stop: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface DashboardCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface RunDashboardCommandOptions {
|
||||
configDir?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
host?: string;
|
||||
port?: string;
|
||||
publicUrl?: string;
|
||||
roomSecret?: string;
|
||||
openBrowser?: boolean;
|
||||
io: DashboardCommandIo;
|
||||
startServer?: () => Promise<DashboardServerHandle>;
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
waitForShutdown?: (server: DashboardServerHandle) => Promise<void>;
|
||||
}
|
||||
|
||||
const DASHBOARD_PORT_ENV = "CLINE_HUB_DASHBOARD_PORT";
|
||||
const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
|
||||
|
||||
function setEnvValue(name: string, value: string | undefined): () => void {
|
||||
const previous = process.env[name];
|
||||
if (value !== undefined) {
|
||||
process.env[name] = value;
|
||||
}
|
||||
return () => {
|
||||
if (previous === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = previous;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const SANDBOX_ENV_KEYS = [
|
||||
"CLINE_SANDBOX",
|
||||
"CLINE_SANDBOX_DATA_DIR",
|
||||
"CLINE_DATA_DIR",
|
||||
"CLINE_DB_DATA_DIR",
|
||||
"CLINE_SESSION_DATA_DIR",
|
||||
"CLINE_TEAM_DATA_DIR",
|
||||
"CLINE_PROVIDER_SETTINGS_PATH",
|
||||
"CLINE_HOOKS_LOG_PATH",
|
||||
] as const;
|
||||
|
||||
async function withDashboardEnvironment<T>(
|
||||
options: RunDashboardCommandOptions,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
|
||||
const restore = [
|
||||
setEnvValue("WORKSPACE_ROOT", options.cwd ? cwd : undefined),
|
||||
setEnvValue("CLINE_DIR", options.configDir?.trim() || undefined),
|
||||
setEnvValue("HOST", options.host),
|
||||
setEnvValue(DASHBOARD_PORT_ENV, options.port),
|
||||
setEnvValue("PUBLIC_URL", options.publicUrl),
|
||||
setEnvValue("ROOM_SECRET", options.roomSecret),
|
||||
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
|
||||
...SANDBOX_ENV_KEYS.map((key) => setEnvValue(key, undefined)),
|
||||
];
|
||||
if (options.dataDir || process.env.CLINE_SANDBOX?.trim() === "1") {
|
||||
configureSandboxEnvironment({
|
||||
enabled: true,
|
||||
cwd,
|
||||
explicitDir: options.dataDir,
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
for (let i = restore.length - 1; i >= 0; i--) {
|
||||
restore[i]?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDefaultWebviewDistDir(): string | undefined {
|
||||
if (process.env[WEBVIEW_DIST_ENV]?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
const candidates = [
|
||||
...resolveInstalledPlatformPackageWebviewCandidates(),
|
||||
// Source checkout: apps/cli/src/commands/dashboard.ts
|
||||
join(moduleDir, "../../../cline-hub/dist/webview"),
|
||||
// Node bundle: apps/cli/dist/index.js
|
||||
join(moduleDir, "cline-hub/webview"),
|
||||
// Compiled platform package: apps/cli/dist/<platform>/bin/cline
|
||||
join(dirname(process.execPath), "../cline-hub/webview"),
|
||||
];
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate));
|
||||
}
|
||||
|
||||
function resolveInstalledPlatformPackageWebviewCandidates(): string[] {
|
||||
const packageName = resolvePlatformPackageName();
|
||||
const starts = [
|
||||
process.env.CLINE_WRAPPER_PATH
|
||||
? dirname(process.env.CLINE_WRAPPER_PATH)
|
||||
: undefined,
|
||||
dirname(process.execPath),
|
||||
].filter((value): value is string => !!value?.trim());
|
||||
const candidates: string[] = [];
|
||||
for (const start of starts) {
|
||||
let current = start;
|
||||
for (;;) {
|
||||
candidates.push(
|
||||
join(current, "node_modules", packageName, "cline-hub/webview"),
|
||||
);
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function resolvePlatformPackageName(): string {
|
||||
const platformName = platform() === "win32" ? "windows" : platform();
|
||||
return `@cline/cli-${platformName}-${arch()}`;
|
||||
}
|
||||
|
||||
async function startDefaultDashboardServer(): Promise<DashboardServerHandle> {
|
||||
const { startClineHubDashboardServer } = await import("@cline/cline-hub");
|
||||
return await startClineHubDashboardServer();
|
||||
}
|
||||
|
||||
async function openDefaultUrl(url: string): Promise<void> {
|
||||
await open(url, { wait: false });
|
||||
}
|
||||
|
||||
export function waitForProcessShutdown(
|
||||
server: DashboardServerHandle,
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolveShutdown, rejectShutdown) => {
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
process.off("SIGINT", handleSignal);
|
||||
process.off("SIGTERM", handleSignal);
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
try {
|
||||
await server.stop();
|
||||
resolveShutdown();
|
||||
} catch (error) {
|
||||
rejectShutdown(error);
|
||||
}
|
||||
};
|
||||
|
||||
function handleSignal() {
|
||||
void stop();
|
||||
}
|
||||
|
||||
process.on("SIGINT", handleSignal);
|
||||
process.on("SIGTERM", handleSignal);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runDashboardCommand(
|
||||
options: RunDashboardCommandOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const server = await withDashboardEnvironment(options, () =>
|
||||
(options.startServer ?? startDefaultDashboardServer)(),
|
||||
);
|
||||
const dashboardUrl =
|
||||
server.inviteUrl || server.publicUrl || server.listenUrl;
|
||||
options.io.writeln(
|
||||
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
|
||||
);
|
||||
if (server.hubUrl) {
|
||||
options.io.writeln(`${c.dim}Hub endpoint: ${server.hubUrl}${c.reset}`);
|
||||
}
|
||||
|
||||
if (options.openBrowser !== false) {
|
||||
try {
|
||||
await (options.openUrl ?? openDefaultUrl)(dashboardUrl);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io.writeErr(`Failed to open browser: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await (options.waitForShutdown ?? waitForProcessShutdown)(server);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
options.io.writeErr(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildMcpInstallDefaults, runMcpInstallCommand } from "./mcp";
|
||||
|
||||
describe("mcp install command", () => {
|
||||
it("builds stdio wizard defaults from command args", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "fs",
|
||||
targetArgs: [
|
||||
"npx",
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp/my dir",
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "fs",
|
||||
type: "stdio",
|
||||
command: 'npx -y @modelcontextprotocol/server-filesystem "/tmp/my dir"',
|
||||
});
|
||||
});
|
||||
|
||||
it("builds remote wizard defaults and normalizes http transport", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "ctx7",
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes streamable-http transport", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "docs",
|
||||
transport: "streamable-http",
|
||||
targetArgs: ["https://example.com/mcp"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "docs",
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds SSE wizard defaults", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "events",
|
||||
transport: "sse",
|
||||
targetArgs: ["https://example.com/sse"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "events",
|
||||
type: "sse",
|
||||
url: "https://example.com/sse",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing stdio command and invalid remote URL", () => {
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "fs",
|
||||
}),
|
||||
).toThrow(/requires a command/);
|
||||
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "bad",
|
||||
transport: "http",
|
||||
targetArgs: ["not-a-url"],
|
||||
}),
|
||||
).toThrow(/Invalid MCP server URL/);
|
||||
});
|
||||
|
||||
it("rejects remote URL schemes other than http and https", () => {
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "bad",
|
||||
transport: "http",
|
||||
targetArgs: ["file:///etc/passwd"],
|
||||
}),
|
||||
).toThrow(/only http and https are supported/);
|
||||
});
|
||||
|
||||
it("opens the add wizard with prefilled defaults", async () => {
|
||||
const runWizard = vi.fn(async () => 0);
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
isTty: true,
|
||||
runWizard,
|
||||
io: { writeErr: vi.fn() },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(runWizard).toHaveBeenCalledWith({
|
||||
name: "ctx7",
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a TTY because it opens the wizard", async () => {
|
||||
const writeErr = vi.fn();
|
||||
const runWizard = vi.fn(async () => 0);
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
isTty: false,
|
||||
runWizard,
|
||||
io: { writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(runWizard).not.toHaveBeenCalled();
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks for TTY before validating install arguments", async () => {
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "fs",
|
||||
isTty: false,
|
||||
io: { writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import type { McpAddDefaults } from "../wizards/mcp";
|
||||
|
||||
export interface McpCommandIo {
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface McpInstallOptions {
|
||||
name: string;
|
||||
targetArgs?: string[];
|
||||
transport?: string;
|
||||
io?: McpCommandIo;
|
||||
isTty?: boolean;
|
||||
runWizard?: (defaults: McpAddDefaults) => Promise<number>;
|
||||
}
|
||||
|
||||
function normalizeTransportType(
|
||||
value: string | undefined,
|
||||
): McpAddDefaults["type"] {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
}
|
||||
if (
|
||||
normalized === "stdio" ||
|
||||
normalized === "sse" ||
|
||||
normalized === "streamableHttp"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertValidUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid MCP server URL: ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`Invalid MCP server URL: ${url} (only http and https are supported)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function quoteCommandArg(arg: string): string {
|
||||
if (/^[^\s"'\\]+$/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
return `"${arg.replace(/(["\\])/g, "\\$1")}"`;
|
||||
}
|
||||
|
||||
export function buildMcpInstallDefaults(options: {
|
||||
name: string;
|
||||
targetArgs?: string[];
|
||||
transport?: string;
|
||||
}): McpAddDefaults {
|
||||
const name = options.name.trim();
|
||||
if (!name) {
|
||||
throw new Error("MCP server name is required");
|
||||
}
|
||||
const type = normalizeTransportType(options.transport);
|
||||
const targetArgs = options.targetArgs ?? [];
|
||||
if (type === "stdio") {
|
||||
if (targetArgs.length === 0) {
|
||||
throw new Error(
|
||||
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp",
|
||||
);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
command: targetArgs.map(quoteCommandArg).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
if (targetArgs.length !== 1) {
|
||||
throw new Error(
|
||||
"Remote MCP install requires exactly one URL argument after the server name.",
|
||||
);
|
||||
}
|
||||
const url = targetArgs[0]?.trim() ?? "";
|
||||
assertValidUrl(url);
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
async function runPrefilledWizard(defaults: McpAddDefaults): Promise<number> {
|
||||
const { runMcpWizard } = await import("../wizards/mcp");
|
||||
return runMcpWizard({
|
||||
initialAction: "add",
|
||||
addDefaults: defaults,
|
||||
exitAfterInitialAction: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runMcpInstallCommand(
|
||||
options: McpInstallOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const isTty =
|
||||
options.isTty ?? (process.stdin.isTTY && process.stdout.isTTY);
|
||||
if (!isTty) {
|
||||
throw new Error(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
);
|
||||
}
|
||||
const defaults = buildMcpInstallDefaults(options);
|
||||
return await (options.runWizard ?? runPrefilledWizard)(defaults);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSkillsArgs } from "./skill";
|
||||
|
||||
describe("buildSkillsArgs", () => {
|
||||
it("runs the skills package through npx with -y", () => {
|
||||
expect(buildSkillsArgs(["list"])).toEqual(["-y", "skills@latest", "list"]);
|
||||
});
|
||||
|
||||
it("injects --agent cline for install-style subcommands", () => {
|
||||
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"add",
|
||||
"owner/repo",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
expect(buildSkillsArgs(["add", "owner/repo"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["i", "owner/repo"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["update", "owner/repo"])).toContain("cline");
|
||||
});
|
||||
|
||||
it("aliases uninstall to the skills remove subcommand", () => {
|
||||
expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"my-skill",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not inject when the user already targeted an agent", () => {
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "--agent", "cursor"]),
|
||||
).not.toContain("cline");
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "-a", "cursor"]),
|
||||
).not.toContain("cline");
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "--agent=cursor"]),
|
||||
).not.toContain("cline");
|
||||
});
|
||||
|
||||
it("aliases install and uninstall when agent options come before the subcommand", () => {
|
||||
expect(
|
||||
buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"--agent",
|
||||
"cursor",
|
||||
"add",
|
||||
"owner/repo",
|
||||
]);
|
||||
expect(
|
||||
buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]),
|
||||
).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]);
|
||||
});
|
||||
|
||||
it("does not scope non-install subcommands to cline", () => {
|
||||
expect(buildSkillsArgs(["use", "owner/repo"])).not.toContain("--agent");
|
||||
expect(buildSkillsArgs(["list"])).not.toContain("--agent");
|
||||
});
|
||||
|
||||
it("scopes remove-style subcommands to cline", () => {
|
||||
expect(buildSkillsArgs(["remove"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
expect(buildSkillsArgs(["rm", "my-skill"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["r", "my-skill"])).toContain("cline");
|
||||
});
|
||||
|
||||
it("ignores leading flags when detecting the subcommand", () => {
|
||||
expect(buildSkillsArgs(["--global", "install", "owner/repo"])).toContain(
|
||||
"cline",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards an empty arg list unchanged", () => {
|
||||
expect(buildSkillsArgs([])).toEqual(["-y", "skills@latest"]);
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import { type SpawnOptions, spawn } from "node:child_process";
|
||||
|
||||
export interface SkillCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
// `cline skill` is a thin wrapper around the open skills CLI
|
||||
// (https://www.npmjs.com/package/skills). We run it through `npx` so users
|
||||
// don't need a separate global install. Pin the version here if we ever need to
|
||||
// lock behavior to a known-good release.
|
||||
const SKILLS_PACKAGE = "skills@latest";
|
||||
|
||||
// Subcommands that write skill files into an agent's skills directory. For a
|
||||
// `cline skill` command we default these to Cline unless the user picked their
|
||||
// own agent. `use` is intentionally excluded: without --agent it prints the
|
||||
// generated prompt to stdout, whereas adding --agent would launch that agent
|
||||
// interactively instead — not what someone scoping to Cline would expect.
|
||||
const CLINE_SCOPED_SUBCOMMANDS = new Set([
|
||||
"add",
|
||||
"install",
|
||||
"i",
|
||||
"update",
|
||||
"remove",
|
||||
"rm",
|
||||
"r",
|
||||
"uninstall",
|
||||
]);
|
||||
|
||||
const SKILLS_SUBCOMMAND_ALIASES = new Map([
|
||||
["install", "add"],
|
||||
["uninstall", "remove"],
|
||||
]);
|
||||
|
||||
function hasAgentFlag(args: readonly string[]): boolean {
|
||||
return args.some(
|
||||
(arg) => arg === "-a" || arg === "--agent" || arg.startsWith("--agent="),
|
||||
);
|
||||
}
|
||||
|
||||
function optionConsumesNextValue(arg: string): boolean {
|
||||
return arg === "-a" || arg === "--agent";
|
||||
}
|
||||
|
||||
function findSubcommandIndex(args: readonly string[]): number {
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
if (arg.startsWith("-")) {
|
||||
if (optionConsumesNextValue(arg)) {
|
||||
index++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findSubcommand(args: readonly string[]): string | undefined {
|
||||
const index = findSubcommandIndex(args);
|
||||
return index >= 0 ? args[index] : undefined;
|
||||
}
|
||||
|
||||
function normalizeSkillsSubcommandAliases(args: string[]): void {
|
||||
const index = findSubcommandIndex(args);
|
||||
if (index < 0) return;
|
||||
const alias = SKILLS_SUBCOMMAND_ALIASES.get(args[index]);
|
||||
if (alias) {
|
||||
args[index] = alias;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the argument list passed to `npx`, injecting `--agent cline` for
|
||||
* install-style subcommands unless the user already targeted an agent.
|
||||
*/
|
||||
export function buildSkillsArgs(userArgs: readonly string[]): string[] {
|
||||
const args = [...userArgs];
|
||||
const subcommand = findSubcommand(args);
|
||||
normalizeSkillsSubcommandAliases(args);
|
||||
if (
|
||||
subcommand &&
|
||||
CLINE_SCOPED_SUBCOMMANDS.has(subcommand) &&
|
||||
!hasAgentFlag(args)
|
||||
) {
|
||||
args.push("--agent", "cline");
|
||||
}
|
||||
return ["-y", SKILLS_PACKAGE, ...args];
|
||||
}
|
||||
|
||||
function resolveExitCode(
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): number {
|
||||
if (code !== null) {
|
||||
return code;
|
||||
}
|
||||
switch (signal) {
|
||||
case "SIGINT":
|
||||
return 130;
|
||||
case "SIGTERM":
|
||||
return 143;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward all arguments to the open skills CLI via `npx skills`.
|
||||
*
|
||||
* Returns the child process exit code, or 1 if npx is unavailable or fails to
|
||||
* spawn. stdio is inherited so the skills CLI's interactive prompts and output
|
||||
* pass straight through to the user's terminal.
|
||||
*/
|
||||
export async function runSkillCommand(
|
||||
userArgs: readonly string[],
|
||||
io: SkillCommandIo,
|
||||
): Promise<number> {
|
||||
const args = buildSkillsArgs(userArgs);
|
||||
const isWindows = process.platform === "win32";
|
||||
const options: SpawnOptions = {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
...(isWindows ? { shell: true } : {}),
|
||||
};
|
||||
|
||||
return new Promise<number>((resolve) => {
|
||||
const child = spawn("npx", args, options);
|
||||
|
||||
const forward = (signal: NodeJS.Signals) => {
|
||||
child.kill(signal);
|
||||
};
|
||||
const handleSigint = () => forward("SIGINT");
|
||||
const handleSigterm = () => forward("SIGTERM");
|
||||
process.on("SIGINT", handleSigint);
|
||||
process.on("SIGTERM", handleSigterm);
|
||||
const cleanup = () => {
|
||||
process.off("SIGINT", handleSigint);
|
||||
process.off("SIGTERM", handleSigterm);
|
||||
};
|
||||
|
||||
child.once("error", (error: NodeJS.ErrnoException) => {
|
||||
cleanup();
|
||||
if (error.code === "ENOENT") {
|
||||
io.writeErr(
|
||||
'npx was not found. Install Node.js (which includes npx) to use "cline skill".',
|
||||
);
|
||||
} else {
|
||||
io.writeErr(`Failed to run npx ${SKILLS_PACKAGE}: ${error.message}`);
|
||||
}
|
||||
resolve(1);
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
cleanup();
|
||||
resolve(resolveExitCode(code, signal));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
autoUpdateOnStartup,
|
||||
checkForUpdates,
|
||||
getInstallationInfo,
|
||||
PackageManager,
|
||||
resolveCliHubOwnerContext,
|
||||
withMinimumReleaseAgeBypass,
|
||||
} from "./update";
|
||||
|
||||
const originalArgv = [...process.argv];
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
const originalDataDir = process.env.CLINE_DATA_DIR;
|
||||
const originalHubDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
|
||||
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
const originalIsDev = process.env.IS_DEV;
|
||||
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createFile(path: string): string {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, "");
|
||||
return path;
|
||||
}
|
||||
|
||||
function createTempFile(pathSuffix: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
|
||||
tempDirs.push(root);
|
||||
return createFile(join(root, pathSuffix));
|
||||
}
|
||||
|
||||
describe("getInstallationInfo", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
|
||||
}
|
||||
if (originalGlobalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
|
||||
}
|
||||
if (originalIsDev === undefined) {
|
||||
delete process.env.IS_DEV;
|
||||
} else {
|
||||
process.env.IS_DEV = originalIsDev;
|
||||
}
|
||||
if (originalNoAutoUpdate === undefined) {
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
} else {
|
||||
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("detects npm installs from the wrapper path passed to the compiled binary", () => {
|
||||
const wrapperPath = createTempFile("lib/node_modules/cline/bin/cline");
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: "cline",
|
||||
updateCommand: "npm update -g cline --tag latest",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the nightly tag when the current CLI version is nightly", () => {
|
||||
const wrapperPath = createTempFile("lib/node_modules/cline/bin/cline");
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3-nightly.456")).toEqual({
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: "cline",
|
||||
updateCommand: "npm update -g cline --tag nightly",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to unknown when only Bun's virtual compiled path is available", () => {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.UNKNOWN,
|
||||
packageName: "cline",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto update settings", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
|
||||
}
|
||||
if (originalGlobalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
|
||||
}
|
||||
if (originalIsDev === undefined) {
|
||||
delete process.env.IS_DEV;
|
||||
} else {
|
||||
process.env.IS_DEV = originalIsDev;
|
||||
}
|
||||
if (originalNoAutoUpdate === undefined) {
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
} else {
|
||||
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips startup auto update when disabled globally", () => {
|
||||
const settingsPath = createTempFile("data/global-settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
|
||||
delete process.env.IS_DEV;
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockRejectedValue(new Error("should not fetch"));
|
||||
|
||||
autoUpdateOnStartup();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still lets manual update checks run when startup auto update is disabled", async () => {
|
||||
const settingsPath = createTempFile("data/global-settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: "0.0.0" }),
|
||||
} as Response);
|
||||
|
||||
await checkForUpdates({ includeKanban: false });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hub restart owner selection", () => {
|
||||
afterEach(() => {
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the shared hub owner outside production builds", () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-update-test-data";
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
|
||||
expect(owner.discoveryPath).toContain("/locks/hub/owners/");
|
||||
expect(owner.discoveryPath).not.toBe(
|
||||
"/tmp/cline-update-test-data/locks/hub/production.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withMinimumReleaseAgeBypass", () => {
|
||||
it("adds the package-manager-specific cooldown bypass", () => {
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"npm update -g cline --tag latest",
|
||||
PackageManager.NPM,
|
||||
).command,
|
||||
).toBe("npm update -g cline --tag latest --min-release-age=0");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass("bun add -g cline@latest", PackageManager.BUN)
|
||||
.command,
|
||||
).toBe("bun add -g cline@latest --minimum-release-age=0");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"yarn global add cline@latest",
|
||||
PackageManager.YARN,
|
||||
).command,
|
||||
).toBe("yarn global add cline@latest");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"yarn global add cline@latest",
|
||||
PackageManager.YARN,
|
||||
).env?.YARN_NPM_MINIMAL_AGE_GATE,
|
||||
).toBe("0");
|
||||
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"pnpm add -g cline@latest",
|
||||
PackageManager.PNPM,
|
||||
).env?.pnpm_config_minimum_release_age,
|
||||
).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -1,628 +0,0 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ConnectDiscordOptions } from "@cline/shared";
|
||||
import type { Thread } from "chat";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readBindings, writeBindings } from "../thread-bindings";
|
||||
import { __test__, discordConnector } from "./discord";
|
||||
|
||||
const parseDiscordArgs = (rawArgs: string[]): ConnectDiscordOptions =>
|
||||
(
|
||||
discordConnector as unknown as {
|
||||
parseArgs(rawArgs: string[]): ConnectDiscordOptions;
|
||||
}
|
||||
).parseArgs(rawArgs);
|
||||
|
||||
type TestDiscordState = {
|
||||
sessionId?: string;
|
||||
enableTools?: boolean;
|
||||
autoApproveTools?: boolean;
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
systemPrompt?: string;
|
||||
participantKey?: string;
|
||||
participantLabel?: string;
|
||||
welcomeSentAt?: string;
|
||||
};
|
||||
|
||||
function createThread(
|
||||
initialState: TestDiscordState,
|
||||
): Thread<TestDiscordState> {
|
||||
let state = { ...initialState };
|
||||
return {
|
||||
id: "discord:guild:channel:thread",
|
||||
channelId: "discord:guild:channel",
|
||||
isDM: false,
|
||||
get state() {
|
||||
return Promise.resolve(state);
|
||||
},
|
||||
async setState(nextState: TestDiscordState) {
|
||||
state = { ...nextState };
|
||||
},
|
||||
toJSON() {
|
||||
return {
|
||||
id: "discord:guild:channel:thread",
|
||||
channelId: "discord:guild:channel",
|
||||
isDM: false,
|
||||
state,
|
||||
};
|
||||
},
|
||||
} as unknown as Thread<TestDiscordState>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("discordConnector", () => {
|
||||
it("accepts the documented app id and token aliases", () => {
|
||||
const options = parseDiscordArgs([
|
||||
"--app-id",
|
||||
"app-123",
|
||||
"--token",
|
||||
"bot-token",
|
||||
"--public-key",
|
||||
"public-key",
|
||||
"--base-url",
|
||||
"https://example.test",
|
||||
]);
|
||||
|
||||
expect(options.applicationId).toBe("app-123");
|
||||
expect(options.botToken).toBe("bot-token");
|
||||
expect(options.publicKey).toBe("public-key");
|
||||
expect(options.baseUrl).toBe("https://example.test");
|
||||
});
|
||||
|
||||
it("keeps accepting the explicit application id and bot token options", () => {
|
||||
const options = parseDiscordArgs([
|
||||
"--application-id",
|
||||
"app-456",
|
||||
"--bot-token",
|
||||
"other-token",
|
||||
"--public-key",
|
||||
"public-key",
|
||||
"--owner-user-id",
|
||||
"owner-123",
|
||||
]);
|
||||
|
||||
expect(options.applicationId).toBe("app-456");
|
||||
expect(options.botToken).toBe("other-token");
|
||||
expect(options.ownerUserId).toBe("owner-123");
|
||||
expect(options.allowBotAuthors).toBe(true);
|
||||
});
|
||||
|
||||
it("can explicitly ignore bot-authored Discord messages", () => {
|
||||
const options = parseDiscordArgs([
|
||||
"--application-id",
|
||||
"app-456",
|
||||
"--bot-token",
|
||||
"other-token",
|
||||
"--public-key",
|
||||
"public-key",
|
||||
"--ignore-bot-authors",
|
||||
]);
|
||||
|
||||
expect(options.allowBotAuthors).toBe(false);
|
||||
});
|
||||
|
||||
it("builds empty-runtime fallback replies from the current Discord turn", async () => {
|
||||
const priorMessages = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "previous question" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Previous reply." }],
|
||||
},
|
||||
];
|
||||
const currentMessages = [
|
||||
...priorMessages,
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "read README.md" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Summary from saved session." }],
|
||||
},
|
||||
];
|
||||
const client = {
|
||||
readMessages: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(priorMessages)
|
||||
.mockResolvedValueOnce(currentMessages),
|
||||
};
|
||||
|
||||
const resolveFallbackText =
|
||||
await __test__.createDiscordEmptyRuntimeReplyResolver({
|
||||
client: client as never,
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
await expect(resolveFallbackText?.()).resolves.toBe(
|
||||
"Summary from saved session.",
|
||||
);
|
||||
expect(client.readMessages).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not reuse prior Discord replies as empty-runtime fallback text", async () => {
|
||||
const priorMessages = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "previous question" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Previous reply." }],
|
||||
},
|
||||
];
|
||||
const currentMessages = [
|
||||
...priorMessages,
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "run ls /tmp" }],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [{ type: "text", text: "tool output" }],
|
||||
},
|
||||
];
|
||||
const client = {
|
||||
readMessages: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(priorMessages)
|
||||
.mockResolvedValueOnce(currentMessages),
|
||||
};
|
||||
|
||||
const resolveFallbackText =
|
||||
await __test__.createDiscordEmptyRuntimeReplyResolver({
|
||||
client: client as never,
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
await expect(resolveFallbackText?.()).resolves.toBeUndefined();
|
||||
expect(client.readMessages).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("resolves Discord participants from normalized gateway message authors", () => {
|
||||
expect(
|
||||
__test__.resolveDiscordParticipant(
|
||||
{
|
||||
content: "<@1509620637721821224> Heyo",
|
||||
author: {
|
||||
id: "bot-message-author-should-not-win",
|
||||
username: "beebot",
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: "850213762576810065",
|
||||
userName: "alice",
|
||||
fullName: "Alice Example",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
key: "discord:user:850213762576810065",
|
||||
label: "Alice Example",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves Discord interaction users even when raw.data is command data", () => {
|
||||
expect(
|
||||
__test__.resolveDiscordParticipant({
|
||||
id: "interaction-1",
|
||||
data: { name: "ask" },
|
||||
member: {
|
||||
user: {
|
||||
id: "488220547356950529",
|
||||
username: "bob",
|
||||
global_name: "Bob Example",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
key: "discord:user:488220547356950529",
|
||||
label: "Bob Example",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates Discord participant metadata without changing the thread session", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "discord-participants-"));
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const thread = createThread({
|
||||
sessionId: "session-alice",
|
||||
participantKey: "discord:user:alice",
|
||||
participantLabel: "Alice",
|
||||
});
|
||||
writeBindings<TestDiscordState>(bindingsPath, {
|
||||
"discord:user:alice": {
|
||||
channelId: thread.channelId,
|
||||
isDM: thread.isDM,
|
||||
participantKey: "discord:user:alice",
|
||||
participantLabel: "Alice",
|
||||
serializedThread: JSON.stringify(thread.toJSON()),
|
||||
sessionId: "session-alice",
|
||||
state: {
|
||||
sessionId: "session-alice",
|
||||
participantKey: "discord:user:alice",
|
||||
participantLabel: "Alice",
|
||||
},
|
||||
updatedAt: "2026-05-26T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
await __test__.persistDiscordThreadContext({
|
||||
thread,
|
||||
bindingsPath,
|
||||
baseStartRequest: {
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
systemPrompt: "system",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
mode: "act",
|
||||
},
|
||||
message: {
|
||||
raw: {
|
||||
author: {
|
||||
id: "bob",
|
||||
username: "bob",
|
||||
global_name: "Bob",
|
||||
},
|
||||
},
|
||||
},
|
||||
errorLabel: "Discord",
|
||||
});
|
||||
|
||||
const binding =
|
||||
readBindings<TestDiscordState>(bindingsPath)[
|
||||
"discord:guild:channel:thread"
|
||||
];
|
||||
expect(binding?.state?.participantKey).toBe("discord:user:bob");
|
||||
expect(binding?.state?.participantLabel).toBe("Bob");
|
||||
expect(binding?.state?.sessionId).toBe("session-alice");
|
||||
expect(
|
||||
readBindings<TestDiscordState>(bindingsPath)["discord:user:alice"]?.state
|
||||
?.sessionId,
|
||||
).toBe("session-alice");
|
||||
});
|
||||
|
||||
it("adds Discord author context to runtime turns", () => {
|
||||
expect(
|
||||
__test__.formatDiscordRuntimeText(
|
||||
"Heyo",
|
||||
{
|
||||
key: "discord:user:850213762576810065",
|
||||
label: "Alice Example",
|
||||
},
|
||||
{
|
||||
ownerUserId: "850213762576810065",
|
||||
isDirectMention: false,
|
||||
isSubscribedThreadMessage: true,
|
||||
},
|
||||
),
|
||||
).toContain("authorId: 850213762576810065");
|
||||
expect(
|
||||
__test__.formatDiscordRuntimeText(
|
||||
"Heyo",
|
||||
{
|
||||
key: "discord:user:850213762576810065",
|
||||
label: "Alice Example",
|
||||
},
|
||||
{ ownerUserId: "850213762576810065" },
|
||||
),
|
||||
).toContain("isOwner: true");
|
||||
expect(
|
||||
__test__.formatDiscordRuntimeText(
|
||||
"Heyo",
|
||||
{
|
||||
key: "discord:user:850213762576810065",
|
||||
label: "Alice Example",
|
||||
},
|
||||
{
|
||||
isDirectMention: false,
|
||||
isSubscribedThreadMessage: true,
|
||||
},
|
||||
),
|
||||
).toContain("isDirectMention: false");
|
||||
expect(
|
||||
__test__.formatDiscordRuntimeText(
|
||||
"Heyo",
|
||||
{
|
||||
key: "discord:user:850213762576810065",
|
||||
label: "Alice Example",
|
||||
},
|
||||
{
|
||||
isDirectMention: false,
|
||||
isSubscribedThreadMessage: true,
|
||||
},
|
||||
),
|
||||
).toContain("isSubscribedThreadMessage: true");
|
||||
});
|
||||
|
||||
it("instructs Discord agents to use /idle for unrelated subscribed thread messages", () => {
|
||||
expect(__test__.DISCORD_SYSTEM_RULES).toContain("reply exactly /idle");
|
||||
expect(__test__.DISCORD_SYSTEM_RULES).toContain("isDirectMention is false");
|
||||
expect(__test__.DISCORD_SYSTEM_RULES).toContain("send /mute@BotName");
|
||||
expect(__test__.DISCORD_SYSTEM_RULES).toContain("send /unmute@BotName");
|
||||
expect(__test__.DISCORD_SYSTEM_RULES).toContain(
|
||||
"/mute@BotName @user-or-bot",
|
||||
);
|
||||
expect(__test__.DISCORD_SYSTEM_RULES).toContain(
|
||||
"/unmute@BotName @user-or-bot",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves Discord mute targets from user mentions and ids", () => {
|
||||
expect(__test__.resolveDiscordMuteTarget("<@123456789012345678>")).toEqual({
|
||||
participantKey: "discord:user:123456789012345678",
|
||||
participantLabel: "<@123456789012345678>",
|
||||
});
|
||||
expect(__test__.resolveDiscordMuteTarget("<@!123456789012345678>")).toEqual(
|
||||
{
|
||||
participantKey: "discord:user:123456789012345678",
|
||||
participantLabel: "<@123456789012345678>",
|
||||
},
|
||||
);
|
||||
expect(__test__.resolveDiscordMuteTarget("@123456789012345678")).toEqual({
|
||||
participantKey: "discord:user:123456789012345678",
|
||||
participantLabel: "<@123456789012345678>",
|
||||
});
|
||||
expect(__test__.resolveDiscordMuteTarget("@not-a-user-id")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves outbound Discord mention names to user mention ids", async () => {
|
||||
const fetchMock = vi.fn(async (url: string | URL) => {
|
||||
expect(String(url)).toContain(
|
||||
"/guilds/guild-123/members/search?query=cline-test-bot&limit=10",
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
nick: "cline-test-bot",
|
||||
user: {
|
||||
id: "1509620637721821224",
|
||||
username: "clinetestbot",
|
||||
bot: true,
|
||||
},
|
||||
},
|
||||
]),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
__test__.resolveDiscordOutboundMentions({
|
||||
botToken: "token",
|
||||
threadId: "discord:guild-123:channel-123:thread-123",
|
||||
text: "@cline-test-bot how is your day?",
|
||||
}),
|
||||
).resolves.toBe("<@1509620637721821224> how is your day?");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("repairs adapter-split hyphenated Discord mention names before resolving", async () => {
|
||||
const fetchMock = vi.fn(async (url: string | URL) => {
|
||||
expect(String(url)).toContain("query=cline-test-bot");
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
nick: "cline-test-bot",
|
||||
user: {
|
||||
id: "1509620637721821224",
|
||||
username: "clinetestbot",
|
||||
bot: true,
|
||||
},
|
||||
},
|
||||
]),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
__test__.resolveDiscordOutboundMentions({
|
||||
botToken: "token",
|
||||
threadId: "discord:guild-123:channel-123:thread-123",
|
||||
text: "<@cline>-test-bot how is your day?",
|
||||
}),
|
||||
).resolves.toBe("<@1509620637721821224> how is your day?");
|
||||
});
|
||||
|
||||
it("does not resolve outbound mentions from non-exact Discord member search results", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
nick: "team-alice-bot",
|
||||
user: {
|
||||
id: "wrong-user",
|
||||
username: "team-alice-bot",
|
||||
bot: true,
|
||||
},
|
||||
},
|
||||
]),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
__test__.resolveDiscordOutboundMentions({
|
||||
botToken: "token",
|
||||
threadId: "discord:guild-123:channel-123:thread-123",
|
||||
text: "@alice can you check this?",
|
||||
}),
|
||||
).resolves.toBe("@alice can you check this?");
|
||||
});
|
||||
|
||||
it("normalizes forwarded bot-role mentions as Discord mentions", async () => {
|
||||
const fetchMock = vi.fn(async (url: string | URL) => {
|
||||
expect(String(url)).toContain("/guilds/guild-role-test/members/app-123");
|
||||
return new Response(JSON.stringify({ roles: ["role-123"] }), {
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("https://example.test/api/webhooks/discord", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "GATEWAY_MESSAGE_CREATE",
|
||||
data: {
|
||||
id: "message-1",
|
||||
guild_id: "guild-role-test",
|
||||
channel_id: "channel-1",
|
||||
content: "<@&role-123> hello",
|
||||
mention_roles: ["role-123"],
|
||||
mentions: [],
|
||||
author: {
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
bot: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const normalized = await __test__.normalizeDiscordForwardedGatewayRequest({
|
||||
request,
|
||||
botToken: "token",
|
||||
applicationId: "app-123",
|
||||
});
|
||||
const event = (await normalized.json()) as {
|
||||
data: { is_mention?: boolean };
|
||||
};
|
||||
|
||||
expect(event.data.is_mention).toBe(true);
|
||||
});
|
||||
|
||||
it("retries bot role lookups after transient Discord API failures", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("temporary", { status: 500 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ roles: ["role-123"] }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const buildRequest = () =>
|
||||
new Request("https://example.test/api/webhooks/discord", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "GATEWAY_MESSAGE_CREATE",
|
||||
data: {
|
||||
id: "message-1",
|
||||
guild_id: "guild-retry-test",
|
||||
channel_id: "channel-1",
|
||||
content: "<@&role-123> hello",
|
||||
mention_roles: ["role-123"],
|
||||
mentions: [],
|
||||
author: {
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
bot: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const failed = await __test__.normalizeDiscordForwardedGatewayRequest({
|
||||
request: buildRequest(),
|
||||
botToken: "token",
|
||||
applicationId: "app-retry",
|
||||
});
|
||||
const failedEvent = (await failed.json()) as {
|
||||
data: { is_mention?: boolean };
|
||||
};
|
||||
expect(failedEvent.data.is_mention).toBeUndefined();
|
||||
|
||||
const retried = await __test__.normalizeDiscordForwardedGatewayRequest({
|
||||
request: buildRequest(),
|
||||
botToken: "token",
|
||||
applicationId: "app-retry",
|
||||
});
|
||||
const retriedEvent = (await retried.json()) as {
|
||||
data: { is_mention?: boolean };
|
||||
};
|
||||
|
||||
expect(retriedEvent.data.is_mention).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("restores persisted thread subscriptions once on startup", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "discord-bindings-"));
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const subscribe = vi.fn(async () => undefined);
|
||||
const threads = new Map([
|
||||
[
|
||||
"thread-1",
|
||||
{
|
||||
id: "thread-1",
|
||||
subscribe,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const bot = {
|
||||
reviver: () => (_key: string, value: unknown) => {
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
(value as { _type?: string })._type === "chat:Thread"
|
||||
) {
|
||||
return threads.get((value as { id: string }).id) ?? value;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
};
|
||||
const logger = {
|
||||
core: { log: vi.fn() },
|
||||
} as unknown as Parameters<
|
||||
typeof __test__.restoreDiscordThreadSubscriptions
|
||||
>[0]["logger"];
|
||||
|
||||
writeFileSync(
|
||||
bindingsPath,
|
||||
JSON.stringify({
|
||||
"discord:user:1": {
|
||||
channelId: "discord:g:c",
|
||||
isDM: false,
|
||||
participantKey: "discord:user:1",
|
||||
serializedThread: JSON.stringify({
|
||||
_type: "chat:Thread",
|
||||
id: "thread-1",
|
||||
}),
|
||||
updatedAt: "2026-05-26T00:00:00.000Z",
|
||||
},
|
||||
duplicate: {
|
||||
channelId: "discord:g:c",
|
||||
isDM: false,
|
||||
serializedThread: JSON.stringify({
|
||||
_type: "chat:Thread",
|
||||
id: "thread-1",
|
||||
}),
|
||||
updatedAt: "2026-05-26T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const restored = await __test__.restoreDiscordThreadSubscriptions({
|
||||
bot,
|
||||
bindingsPath,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(restored).toBe(1);
|
||||
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(logger.core.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { ConnectorCatalogEntry } from "@cline/shared";
|
||||
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getConnector, listConnectors } from "./registry";
|
||||
|
||||
describe("connector registry", () => {
|
||||
it("registers the Discord connector", async () => {
|
||||
expect(listConnectors().map((connector) => connector.name)).toContain(
|
||||
"discord",
|
||||
);
|
||||
|
||||
await expect(getConnector("discord")).resolves.toMatchObject({
|
||||
name: "discord",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockGetLastUsedProviderSettings,
|
||||
mockGetProviderSettings,
|
||||
mockResolveSystemPrompt,
|
||||
mockGetProviderCollection,
|
||||
mockGetBooleanFlagEnabled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetLastUsedProviderSettings: vi.fn(),
|
||||
mockGetProviderSettings: vi.fn(),
|
||||
mockResolveSystemPrompt: vi.fn(),
|
||||
mockGetProviderCollection: vi.fn(),
|
||||
mockGetBooleanFlagEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings(options?: unknown) {
|
||||
return mockGetLastUsedProviderSettings(options);
|
||||
}
|
||||
|
||||
getProviderSettings(providerId: string) {
|
||||
return mockGetProviderSettings(providerId);
|
||||
}
|
||||
},
|
||||
CoreSessionService: class {},
|
||||
SqliteSessionStore: class {},
|
||||
Llms: {
|
||||
...actual.Llms,
|
||||
getProviderCollection: mockGetProviderCollection,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../runtime/prompt", () => ({
|
||||
resolveSystemPrompt: mockResolveSystemPrompt,
|
||||
}));
|
||||
|
||||
vi.mock("../utils/helpers", () => ({
|
||||
resolveWorkspaceRoot: vi.fn((cwd: string) => cwd),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mockGetBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../commands/auth")>(
|
||||
"../commands/auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureOAuthProviderApiKey: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { buildConnectorStartRequest } from "./session-runtime";
|
||||
|
||||
describe("buildConnectorStartRequest", () => {
|
||||
beforeEach(() => {
|
||||
mockGetBooleanFlagEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
});
|
||||
|
||||
it("falls back to provider env vars when persisted settings have no api key", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "openrouter" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "openrouter",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["OPENROUTER_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
process.env.OPENROUTER_API_KEY = "env-openrouter-key";
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("openrouter");
|
||||
expect(request.apiKey).toBe("env-openrouter-key");
|
||||
expect(request.model).toBe("anthropic/claude-sonnet-4.6");
|
||||
expect(mockGetLastUsedProviderSettings).toHaveBeenCalledWith({
|
||||
isClinePassEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "cline-pass",
|
||||
auth: { accessToken: "workos:resolved-token" },
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["CLINE_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "cline-pass",
|
||||
auth: { accessToken: "workos:resolved-token" },
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["CLINE_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
});
|
||||
});
|
||||
@@ -1,196 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/core";
|
||||
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
if (!Number.isInteger(pid) || pid <= 0) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type ActiveConnectorRecord = {
|
||||
id: string;
|
||||
type: string;
|
||||
pid: number;
|
||||
hubUrl: string;
|
||||
startedAt?: string;
|
||||
applicationId?: string;
|
||||
botUsername?: string;
|
||||
userName?: string;
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
connectionMode?: string;
|
||||
};
|
||||
|
||||
function listConnectorStatePaths(
|
||||
type: ActiveConnectorRecord["type"],
|
||||
): string[] {
|
||||
const dir = join(resolveClineDataDir(), "connectors", type);
|
||||
if (!existsSync(dir)) {
|
||||
return [];
|
||||
}
|
||||
return readdirSync(dir)
|
||||
.filter((name) => name.endsWith(".json") && !name.endsWith(".threads.json"))
|
||||
.map((name) => join(dir, name));
|
||||
}
|
||||
|
||||
function readJsonRecord(path: string): Record<string, unknown> | undefined {
|
||||
if (!existsSync(path)) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const raw = readFileSync(path, "utf8");
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed connector state.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type ConnectorFieldKey = keyof Omit<
|
||||
ActiveConnectorRecord,
|
||||
"id" | "type" | "pid" | "hubUrl"
|
||||
>;
|
||||
|
||||
const connectorFieldExtractors: Record<
|
||||
ConnectorFieldKey,
|
||||
(p: Record<string, unknown>) => string | number | undefined
|
||||
> = {
|
||||
startedAt: (p) => (typeof p.startedAt === "string" ? p.startedAt : undefined),
|
||||
port: (p) => (typeof p.port === "number" ? p.port : undefined),
|
||||
baseUrl: (p) => (typeof p.baseUrl === "string" ? p.baseUrl : undefined),
|
||||
connectionMode: (p) =>
|
||||
typeof p.connectionMode === "string" ? p.connectionMode : undefined,
|
||||
userName: (p) => (typeof p.userName === "string" ? p.userName : undefined),
|
||||
botUsername: (p) =>
|
||||
typeof p.botUsername === "string" ? p.botUsername : undefined,
|
||||
applicationId: (p) =>
|
||||
typeof p.applicationId === "string" ? p.applicationId : undefined,
|
||||
phoneNumberId: (p) =>
|
||||
typeof p.phoneNumberId === "string" ? p.phoneNumberId : undefined,
|
||||
};
|
||||
|
||||
const connectorConfigs: Record<
|
||||
string,
|
||||
{ required: ConnectorFieldKey[]; optional: ConnectorFieldKey[] }
|
||||
> = {
|
||||
discord: {
|
||||
required: ["userName", "applicationId"],
|
||||
optional: ["startedAt", "port", "baseUrl"],
|
||||
},
|
||||
telegram: { required: ["botUsername"], optional: ["startedAt"] },
|
||||
gchat: { required: ["userName"], optional: ["startedAt", "port", "baseUrl"] },
|
||||
linear: {
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "port", "baseUrl"],
|
||||
},
|
||||
slack: {
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "connectionMode", "port", "baseUrl"],
|
||||
},
|
||||
whatsapp: {
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "phoneNumberId", "port", "baseUrl"],
|
||||
},
|
||||
};
|
||||
|
||||
function connectorRecordId(
|
||||
type: ActiveConnectorRecord["type"],
|
||||
fields: Partial<
|
||||
Omit<ActiveConnectorRecord, "id" | "type" | "pid" | "hubUrl">
|
||||
>,
|
||||
pid: number,
|
||||
): string {
|
||||
const identity =
|
||||
fields.botUsername ??
|
||||
fields.userName ??
|
||||
fields.applicationId ??
|
||||
fields.phoneNumberId ??
|
||||
String(pid);
|
||||
return `${type}:${identity}`;
|
||||
}
|
||||
|
||||
function readActiveConnectorRecord(
|
||||
type: ActiveConnectorRecord["type"],
|
||||
statePath: string,
|
||||
): ActiveConnectorRecord | undefined {
|
||||
const parsed = readJsonRecord(statePath);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
const pid = typeof parsed.pid === "number" ? parsed.pid : undefined;
|
||||
const hubUrl =
|
||||
typeof parsed.hubUrl === "string"
|
||||
? parsed.hubUrl
|
||||
: typeof parsed.rpcAddress === "string"
|
||||
? parsed.rpcAddress
|
||||
: undefined;
|
||||
if (!pid || !hubUrl || !isProcessRunning(pid)) {
|
||||
return undefined;
|
||||
}
|
||||
const config = connectorConfigs[type];
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
const fields: Partial<
|
||||
Omit<ActiveConnectorRecord, "id" | "type" | "pid" | "hubUrl">
|
||||
> = {};
|
||||
for (const key of config.required) {
|
||||
const value = connectorFieldExtractors[key](parsed);
|
||||
if (!value || (typeof value === "string" && !value.trim())) {
|
||||
return undefined;
|
||||
}
|
||||
(fields as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
for (const key of config.optional) {
|
||||
const value = connectorFieldExtractors[key](parsed);
|
||||
if (value !== undefined) {
|
||||
(fields as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: connectorRecordId(type, fields, pid),
|
||||
type,
|
||||
pid,
|
||||
hubUrl,
|
||||
...fields,
|
||||
} as ActiveConnectorRecord;
|
||||
}
|
||||
|
||||
export function listActiveConnectors(): ActiveConnectorRecord[] {
|
||||
const connectorTypes: ActiveConnectorRecord["type"][] = [
|
||||
"discord",
|
||||
"telegram",
|
||||
"gchat",
|
||||
"linear",
|
||||
"slack",
|
||||
"whatsapp",
|
||||
];
|
||||
const records: ActiveConnectorRecord[] = [];
|
||||
for (const type of connectorTypes) {
|
||||
for (const statePath of listConnectorStatePaths(type)) {
|
||||
const record = readActiveConnectorRecord(type, statePath);
|
||||
if (record) {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
}
|
||||
return records.sort((left, right) => {
|
||||
if (left.type !== right.type) {
|
||||
return left.type.localeCompare(right.type);
|
||||
}
|
||||
const leftName = left.botUsername ?? left.userName ?? "";
|
||||
const rightName = right.botUsername ?? right.userName ?? "";
|
||||
return leftName.localeCompare(rightName);
|
||||
});
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Thread } from "chat";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForDeliveryTarget,
|
||||
isParticipantMuted,
|
||||
isThreadMuted,
|
||||
readBindingForThread,
|
||||
readBindings,
|
||||
setParticipantMuted,
|
||||
setThreadMuted,
|
||||
writeBindings,
|
||||
} from "./thread-bindings";
|
||||
|
||||
type TestState = ConnectorThreadState & {
|
||||
teamId?: string;
|
||||
};
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createBindingsPath(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "thread-bindings-"));
|
||||
tempDirs.push(dir);
|
||||
return join(dir, "bindings.json");
|
||||
}
|
||||
|
||||
function createThread(input: {
|
||||
id: string;
|
||||
channelId: string;
|
||||
isDM: boolean;
|
||||
participantKey?: string;
|
||||
}): Thread<TestState> {
|
||||
return {
|
||||
id: input.id,
|
||||
channelId: input.channelId,
|
||||
isDM: input.isDM,
|
||||
toJSON: () => ({
|
||||
id: input.id,
|
||||
channelId: input.channelId,
|
||||
isDM: input.isDM,
|
||||
}),
|
||||
} as unknown as Thread<TestState>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("thread binding refresh", () => {
|
||||
it("refreshes the serialized thread immediately when DM channel fallback rebinds a thread id", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
legacy_thread_id: {
|
||||
channelId: "slack:C123",
|
||||
isDM: true,
|
||||
serializedThread: JSON.stringify({
|
||||
id: "legacy_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: true,
|
||||
}),
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", teamId: "T123" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const binding = readBindingForThread<TestState>(
|
||||
path,
|
||||
createThread({
|
||||
id: "new_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: true,
|
||||
}),
|
||||
"Slack",
|
||||
);
|
||||
|
||||
expect(binding?.serializedThread).toContain("new_thread_id");
|
||||
const bindings = readBindings<TestState>(path);
|
||||
expect(bindings.legacy_thread_id).toBeUndefined();
|
||||
expect(bindings.new_thread_id?.serializedThread).toContain("new_thread_id");
|
||||
});
|
||||
|
||||
it("does not rebind a different thread by participant key", () => {
|
||||
const path = createBindingsPath();
|
||||
const participantKey = "slack:team:T123:user:U123";
|
||||
writeBindings<TestState>(path, {
|
||||
[participantKey]: {
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
participantKey,
|
||||
serializedThread: JSON.stringify({
|
||||
id: "legacy_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
}),
|
||||
sessionId: "sess-1",
|
||||
state: {
|
||||
sessionId: "sess-1",
|
||||
teamId: "T123",
|
||||
participantKey,
|
||||
},
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const binding = readBindingForThread<TestState>(
|
||||
path,
|
||||
createThread({
|
||||
id: "new_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
}),
|
||||
"Slack",
|
||||
participantKey,
|
||||
);
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
expect(
|
||||
readBindings<TestState>(path)[participantKey]?.serializedThread,
|
||||
).toContain("legacy_thread_id");
|
||||
});
|
||||
|
||||
it("resolves schedule delivery targets by exact binding key before participant metadata", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
"slack:C123:111.222": {
|
||||
kind: "conversation",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-thread",
|
||||
state: {
|
||||
sessionId: "sess-thread",
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const match = findBindingForDeliveryTarget<TestState>(
|
||||
readBindings<TestState>(path),
|
||||
{
|
||||
bindingKey: "slack:C123:111.222",
|
||||
threadId: "slack:C123:111.222",
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
);
|
||||
|
||||
expect(match?.key).toBe("slack:C123:111.222");
|
||||
expect(match?.binding.sessionId).toBe("sess-thread");
|
||||
});
|
||||
|
||||
it("resolves schedule delivery targets by participant key when no exact thread binding exists", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
"slack:team:T123:user:U123": {
|
||||
channelId: "slack:C123",
|
||||
isDM: true,
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-participant",
|
||||
state: {
|
||||
sessionId: "sess-participant",
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const match = findBindingForDeliveryTarget<TestState>(
|
||||
readBindings<TestState>(path),
|
||||
{
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
);
|
||||
|
||||
expect(match?.key).toBe("slack:team:T123:user:U123");
|
||||
expect(match?.binding.sessionId).toBe("sess-participant");
|
||||
});
|
||||
|
||||
it("stores mute state at thread scope instead of participant scope", () => {
|
||||
const path = createBindingsPath();
|
||||
const thread = createThread({
|
||||
id: "thread-1",
|
||||
channelId: "discord:guild:channel",
|
||||
isDM: false,
|
||||
participantKey: "discord:user:alice",
|
||||
});
|
||||
|
||||
setThreadMuted(path, thread, true, "Discord");
|
||||
|
||||
expect(
|
||||
isThreadMuted(
|
||||
path,
|
||||
createThread({
|
||||
id: "thread-1",
|
||||
channelId: "discord:guild:channel",
|
||||
isDM: false,
|
||||
participantKey: "discord:user:bob",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const binding = readBindingForThread<TestState>(
|
||||
path,
|
||||
thread,
|
||||
"Discord",
|
||||
"discord:user:alice",
|
||||
);
|
||||
expect(binding).toBeUndefined();
|
||||
|
||||
setThreadMuted(path, thread, false, "Discord");
|
||||
|
||||
expect(isThreadMuted(path, thread)).toBe(false);
|
||||
});
|
||||
|
||||
it("stores participant mute state scoped to the current thread", () => {
|
||||
const path = createBindingsPath();
|
||||
const thread = createThread({
|
||||
id: "thread-1",
|
||||
channelId: "discord:guild:channel",
|
||||
isDM: false,
|
||||
});
|
||||
const otherThread = createThread({
|
||||
id: "thread-2",
|
||||
channelId: "discord:guild:channel",
|
||||
isDM: false,
|
||||
});
|
||||
|
||||
setParticipantMuted(
|
||||
path,
|
||||
thread,
|
||||
{
|
||||
participantKey: "discord:user:bob",
|
||||
participantLabel: "Bob",
|
||||
},
|
||||
true,
|
||||
"Discord",
|
||||
);
|
||||
|
||||
expect(isParticipantMuted(path, thread, "discord:user:bob")).toBe(true);
|
||||
expect(isParticipantMuted(path, thread, "discord:user:alice")).toBe(false);
|
||||
expect(isParticipantMuted(path, otherThread, "discord:user:bob")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
readBindingForThread<TestState>(
|
||||
path,
|
||||
thread,
|
||||
"Discord",
|
||||
"discord:user:bob",
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
setParticipantMuted(
|
||||
path,
|
||||
thread,
|
||||
{ participantKey: "discord:user:bob" },
|
||||
false,
|
||||
"Discord",
|
||||
);
|
||||
|
||||
expect(isParticipantMuted(path, thread, "discord:user:bob")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearBindingSessionIds", () => {
|
||||
it("clears session ids from bindings and serialized thread state", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
thread_1: {
|
||||
channelId: "discord:C123",
|
||||
isDM: false,
|
||||
serializedThread: JSON.stringify({
|
||||
id: "thread_1",
|
||||
channelId: "discord:C123",
|
||||
isDM: false,
|
||||
sessionId: "legacy-root-session",
|
||||
state: {
|
||||
sessionId: "sess-1",
|
||||
cwd: "/tmp/work",
|
||||
teamId: "T123",
|
||||
},
|
||||
}),
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
clearBindingSessionIds<TestState>(path);
|
||||
|
||||
const binding = readBindings<TestState>(path).thread_1;
|
||||
expect(binding?.sessionId).toBeUndefined();
|
||||
expect(binding?.state?.sessionId).toBeUndefined();
|
||||
expect(binding?.state?.cwd).toBe("/tmp/work");
|
||||
const serializedThread = JSON.parse(binding?.serializedThread ?? "{}") as {
|
||||
sessionId?: string;
|
||||
state?: TestState;
|
||||
};
|
||||
expect(serializedThread.sessionId).toBeUndefined();
|
||||
expect(serializedThread.state?.sessionId).toBeUndefined();
|
||||
expect(serializedThread.state?.cwd).toBe("/tmp/work");
|
||||
});
|
||||
});
|
||||
@@ -1,251 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
const coreMocks = vi.hoisted(() => {
|
||||
const serviceOptions: Array<{
|
||||
apiBaseUrl: string;
|
||||
getAuthToken: () => Promise<string | undefined | null>;
|
||||
}> = [];
|
||||
return {
|
||||
getProviderSettings: vi.fn(),
|
||||
saveProviderSettings: vi.fn(),
|
||||
fetchMe: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchOrganizationBalance: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
const telemetryMocks = vi.hoisted(() => ({
|
||||
identifyTelemetryAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
ClineAccountService: class {
|
||||
constructor(options: {
|
||||
apiBaseUrl: string;
|
||||
getAuthToken: () => Promise<string | undefined | null>;
|
||||
}) {
|
||||
coreMocks.serviceOptions.push(options);
|
||||
}
|
||||
fetchMe() {
|
||||
return coreMocks.fetchMe();
|
||||
}
|
||||
fetchBalance(userId?: string) {
|
||||
return coreMocks.fetchBalance(userId);
|
||||
}
|
||||
fetchOrganizationBalance(organizationId: string) {
|
||||
return coreMocks.fetchOrganizationBalance(organizationId);
|
||||
}
|
||||
},
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings(providerId: string) {
|
||||
return coreMocks.getProviderSettings(providerId);
|
||||
}
|
||||
saveProviderSettings(settings: unknown, options?: unknown) {
|
||||
coreMocks.saveProviderSettings(settings, options);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/telemetry", () => ({
|
||||
identifyTelemetryAccount: telemetryMocks.identifyTelemetryAccount,
|
||||
}));
|
||||
|
||||
function makeConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "",
|
||||
verbose: false,
|
||||
sandbox: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
mode: "act",
|
||||
defaultToolAutoApprove: false,
|
||||
toolPolicies: {},
|
||||
enableTools: true,
|
||||
cwd: "/tmp/workspace",
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
function mockFetchJson(body: unknown, status = 200): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch,
|
||||
);
|
||||
}
|
||||
|
||||
describe("createClineAccountService", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("refreshes persisted Cline OAuth credentials before creating the account service", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: "new-access",
|
||||
refreshToken: "new-refresh",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: "2096-10-02T07:06:40.000Z",
|
||||
userInfo: {
|
||||
subject: "sub-new",
|
||||
email: "new@example.com",
|
||||
name: "New User",
|
||||
clineUserId: "acct-new",
|
||||
accounts: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "workos:old-access",
|
||||
refreshToken: "refresh-token",
|
||||
accountId: "acct-old",
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
const service = await createClineAccountService({ config: makeConfig() });
|
||||
|
||||
expect(service).toBeDefined();
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
expect(coreMocks.saveProviderSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "cline",
|
||||
auth: expect.objectContaining({
|
||||
accessToken: "workos:new-access",
|
||||
refreshToken: "new-refresh",
|
||||
accountId: "acct-new",
|
||||
expiresAt: 4_000_000_000_000,
|
||||
}),
|
||||
}),
|
||||
{ setLastUsed: false, tokenSource: "oauth" },
|
||||
);
|
||||
expect(await coreMocks.serviceOptions[0]?.getAuthToken()).toBe(
|
||||
"workos:new-access",
|
||||
);
|
||||
});
|
||||
|
||||
it("asks the user to re-authenticate when Cline OAuth credentials cannot refresh", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson(
|
||||
{
|
||||
error: "invalid_grant",
|
||||
error_description: "refresh expired",
|
||||
},
|
||||
401,
|
||||
);
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "workos:old-access",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
|
||||
await expect(
|
||||
createClineAccountService({ config: makeConfig() }),
|
||||
).rejects.toThrow(
|
||||
"Cline account requires re-authentication. Run cline auth cline.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadClineAccountSnapshot", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("identifies the loaded Cline account for telemetry and feature flags", async () => {
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "account-token",
|
||||
});
|
||||
const { loadClineAccountSnapshot } = await import("./cline-account");
|
||||
coreMocks.fetchMe.mockResolvedValue({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
displayName: "User One",
|
||||
photoUrl: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Acme",
|
||||
organizationId: "org-1",
|
||||
roles: ["member"],
|
||||
},
|
||||
],
|
||||
});
|
||||
coreMocks.fetchBalance.mockResolvedValue({ balance: 10, userId: "user-1" });
|
||||
coreMocks.fetchOrganizationBalance.mockResolvedValue({
|
||||
balance: 20,
|
||||
organizationId: "org-1",
|
||||
});
|
||||
|
||||
await loadClineAccountSnapshot({ config: makeConfig() });
|
||||
|
||||
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
|
||||
{
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
provider: "cline",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
import { Llms } from "@cline/core";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import type { DialogActions } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type {
|
||||
InteractiveConfigData,
|
||||
InteractiveConfigItem,
|
||||
InteractiveConfigTab,
|
||||
LoadInteractiveConfigDataOptions,
|
||||
} from "../../tui/interactive-config";
|
||||
import type { CliCompactionMode, Config } from "../../utils/types";
|
||||
import {
|
||||
ConfigErrorContent,
|
||||
DeleteConfigItemConfirmContent,
|
||||
ExtDetailContent,
|
||||
} from "../components/dialogs/config-dialogs";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import { ConfigPanelContent } from "../views/config-view";
|
||||
import type { ConfigAction } from "../views/config-view-helpers";
|
||||
import type { OpenModelSelectorOptions } from "./use-model-selector";
|
||||
|
||||
export interface OpenConfigOptions {
|
||||
initialTab?: InteractiveConfigTab;
|
||||
}
|
||||
|
||||
export function useConfigPanel(opts: {
|
||||
dialog: DialogActions;
|
||||
config: Config;
|
||||
sessionUiMode: string;
|
||||
compactionMode: CliCompactionMode;
|
||||
toggleMode: () => void;
|
||||
toggleAutoApprove: () => void;
|
||||
setCompactionMode: (mode: CliCompactionMode) => void;
|
||||
termHeight: number;
|
||||
loadConfigData: (
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData>;
|
||||
onToggleConfigItem?: (
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
onDeleteConfigItem?: (
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
openModelSelector: (options?: OpenModelSelectorOptions) => Promise<void>;
|
||||
openMcpManager: (options?: { refocus?: boolean }) => Promise<boolean>;
|
||||
refocusTextarea: () => void;
|
||||
}) {
|
||||
const emptyConfigData = useMemo(
|
||||
() => ({
|
||||
workflows: [] as InteractiveConfigItem[],
|
||||
rules: [] as InteractiveConfigItem[],
|
||||
skills: [] as InteractiveConfigItem[],
|
||||
hooks: [] as InteractiveConfigItem[],
|
||||
agents: [] as InteractiveConfigItem[],
|
||||
plugins: [] as InteractiveConfigItem[],
|
||||
mcp: [] as InteractiveConfigItem[],
|
||||
tools: [] as InteractiveConfigItem[],
|
||||
workflowSlashCommands: [],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const openConfig = useCallback(
|
||||
async (options: OpenConfigOptions = {}) => {
|
||||
let keepOpen = true;
|
||||
let activeTab = options.initialTab;
|
||||
while (keepOpen) {
|
||||
const [data, providerInfo] = await withLoadingDialog(
|
||||
opts.dialog,
|
||||
"Loading settings...",
|
||||
async () =>
|
||||
await Promise.all([
|
||||
opts
|
||||
.loadConfigData({ includePluginTools: false })
|
||||
.catch(() => emptyConfigData),
|
||||
Llms.getProvider(opts.config.providerId).catch(() => undefined),
|
||||
]),
|
||||
);
|
||||
const providerDisplayName =
|
||||
providerInfo?.name ?? opts.config.providerId;
|
||||
const action = await opts.dialog.choice<ConfigAction>({
|
||||
size: "large",
|
||||
style: { maxHeight: opts.termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<ConfigAction>) => (
|
||||
<ConfigPanelContent
|
||||
{...ctx}
|
||||
config={opts.config}
|
||||
configData={data}
|
||||
loadConfigData={opts.loadConfigData}
|
||||
providerDisplayName={providerDisplayName}
|
||||
currentMode={opts.sessionUiMode}
|
||||
currentCompactionMode={opts.compactionMode}
|
||||
initialTab={activeTab}
|
||||
onActiveTabChange={(tab) => {
|
||||
activeTab = tab;
|
||||
}}
|
||||
onToggleConfigItem={opts.onToggleConfigItem}
|
||||
onDeleteConfigItem={opts.onDeleteConfigItem}
|
||||
onToggleMode={opts.toggleMode}
|
||||
onToggleAutoApprove={opts.toggleAutoApprove}
|
||||
onSetCompactionMode={opts.setCompactionMode}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
if (!action) {
|
||||
keepOpen = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action.kind === "open-provider") {
|
||||
await opts.openModelSelector({
|
||||
startWithProviderChange: true,
|
||||
onCancel: () => {},
|
||||
});
|
||||
} else if (action.kind === "open-model") {
|
||||
await opts.openModelSelector({ onCancel: () => {} });
|
||||
} else if (action.kind === "toggle-item") {
|
||||
await opts.onToggleConfigItem?.(action.item);
|
||||
} else if (action.kind === "delete-item") {
|
||||
const confirmed = await opts.dialog.choice<boolean>({
|
||||
closeOnEscape: true,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<DeleteConfigItemConfirmContent {...ctx} item={action.item} />
|
||||
),
|
||||
});
|
||||
if (confirmed && opts.onDeleteConfigItem) {
|
||||
try {
|
||||
await withLoadingDialog(
|
||||
opts.dialog,
|
||||
`Deleting ${action.item.name}...`,
|
||||
async () =>
|
||||
await opts.onDeleteConfigItem?.(action.item, {
|
||||
includePluginTools: false,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
await opts.dialog.choice<void>({
|
||||
closeOnEscape: true,
|
||||
content: (ctx: ChoiceContext<void>) => (
|
||||
<ConfigErrorContent
|
||||
{...ctx}
|
||||
title="Plugin delete failed"
|
||||
message={
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (action.kind === "ext-detail") {
|
||||
await opts.dialog.choice<void>({
|
||||
style: { maxHeight: opts.termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<void>) => (
|
||||
<ExtDetailContent
|
||||
{...ctx}
|
||||
item={action.item}
|
||||
onToggleConfigItem={opts.onToggleConfigItem}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else if (action.kind === "open-mcp") {
|
||||
const changed = await opts.openMcpManager({ refocus: false });
|
||||
if (changed) {
|
||||
keepOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
opts.refocusTextarea();
|
||||
},
|
||||
[opts, emptyConfigData],
|
||||
);
|
||||
|
||||
return openConfig;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { ProviderConfigFieldKey } from "@cline/core";
|
||||
import { resolveAwsRegion } from "../../utils/aws-region";
|
||||
|
||||
export type ProviderConfigValues = Partial<
|
||||
Record<ProviderConfigFieldKey, string>
|
||||
>;
|
||||
|
||||
const DEFAULT_AWS_REGION = "us-east-1";
|
||||
const DEFAULT_GCP_REGION = "us-central1";
|
||||
|
||||
export function getDefaultAwsRegion(profile?: string): string {
|
||||
return (
|
||||
resolveAwsRegion({ profile: profile?.trim() || undefined }) ??
|
||||
DEFAULT_AWS_REGION
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveProviderConfigAwsRegion(
|
||||
values: ProviderConfigValues,
|
||||
): string {
|
||||
return values.awsRegion?.trim() || getDefaultAwsRegion(values.awsProfile);
|
||||
}
|
||||
|
||||
export function resolveProviderConfigGcp(values: ProviderConfigValues):
|
||||
| {
|
||||
projectId?: string;
|
||||
region?: string;
|
||||
}
|
||||
| undefined {
|
||||
const projectId = values.gcpProjectId?.trim() || undefined;
|
||||
if (!projectId) return undefined;
|
||||
return {
|
||||
projectId,
|
||||
region: values.gcpRegion?.trim() || DEFAULT_GCP_REGION,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProviderConfigSap(values: ProviderConfigValues):
|
||||
| {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
tokenUrl?: string;
|
||||
resourceGroup?: string;
|
||||
deploymentId?: string;
|
||||
}
|
||||
| undefined {
|
||||
const sap = {
|
||||
clientId: values.sapClientId?.trim() || undefined,
|
||||
clientSecret: values.sapClientSecret?.trim() || undefined,
|
||||
tokenUrl: values.sapTokenUrl?.trim() || undefined,
|
||||
resourceGroup: values.sapResourceGroup?.trim() || undefined,
|
||||
deploymentId: values.sapDeploymentId?.trim() || undefined,
|
||||
};
|
||||
return Object.values(sap).some((value) => value !== undefined)
|
||||
? sap
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderConfigAzure(values: ProviderConfigValues): {
|
||||
apiVersion?: string;
|
||||
} {
|
||||
return { apiVersion: values.azureApiVersion?.trim() ?? "" };
|
||||
}
|
||||
|
||||
export function updateProviderConfigValue(
|
||||
previous: ProviderConfigValues,
|
||||
field: ProviderConfigFieldKey,
|
||||
value: string,
|
||||
): ProviderConfigValues {
|
||||
const next: ProviderConfigValues = { ...previous, [field]: value };
|
||||
if (field !== "awsProfile") {
|
||||
return next;
|
||||
}
|
||||
|
||||
const previousRegion = previous.awsRegion?.trim();
|
||||
const previousProfileRegion = getDefaultAwsRegion(previous.awsProfile);
|
||||
if (!previousRegion || previousRegion === previousProfileRegion) {
|
||||
next.awsRegion = getDefaultAwsRegion(value);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "./cline-pass-errors";
|
||||
|
||||
describe("cline-pass-errors", () => {
|
||||
it("recognizes both raw and formatted ClinePass subscription messages", () => {
|
||||
expect(
|
||||
isClinePassSubscriptionError(
|
||||
"the user is not subscribed to required model plan",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const formatted = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getClinePassSubscriptionUrl()}`;
|
||||
expect(isClinePassSubscriptionError(formatted)).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
|
||||
});
|
||||
|
||||
it("formats the ClinePass subscription URL", () => {
|
||||
expect(getClinePassSubscriptionUrl()).toBe(
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes and formats organization account individual subscription errors", () => {
|
||||
const raw =
|
||||
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
|
||||
const formatted = getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
|
||||
expect(isClineOrgIndividualInferenceSubscriptionErrorMessage(raw)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
new Error(formatted),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
|
||||
});
|
||||
});
|
||||
@@ -1,69 +0,0 @@
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
export {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
};
|
||||
|
||||
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
normalized.includes("no access to clinepass subscription models yet") &&
|
||||
normalized.includes("subscribe to clinepass")
|
||||
);
|
||||
}
|
||||
|
||||
export function isClinePassSubscriptionError(error: unknown): boolean {
|
||||
if (isClineNotSubscribedError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineNotSubscribedError" ||
|
||||
isClineNotSubscribedMessage(error.message) ||
|
||||
isFormattedClinePassSubscriptionMessage(error.message)
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(isClineNotSubscribedMessage(error) ||
|
||||
isFormattedClinePassSubscriptionMessage(error))
|
||||
);
|
||||
}
|
||||
|
||||
export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
error: unknown,
|
||||
): boolean {
|
||||
if (isClineOrgIndividualInferenceSubscriptionError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError" ||
|
||||
isClineOrgIndividualInferenceSubscriptionMessage(error.message) ||
|
||||
error.message === getClineOrgIndividualInferenceSubscriptionMessage()
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(isClineOrgIndividualInferenceSubscriptionMessage(error) ||
|
||||
error === getClineOrgIndividualInferenceSubscriptionMessage())
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
return getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
disposeCliFeatureFlagsService,
|
||||
getCliFeatureFlagsService,
|
||||
} from "./feature-flags";
|
||||
|
||||
describe("CLI feature flags singleton", () => {
|
||||
afterEach(async () => {
|
||||
await disposeCliFeatureFlagsService();
|
||||
});
|
||||
|
||||
it("recreates the singleton after disposal", async () => {
|
||||
const service = getCliFeatureFlagsService();
|
||||
|
||||
await disposeCliFeatureFlagsService();
|
||||
|
||||
expect(getCliFeatureFlagsService()).not.toBe(service);
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type BasicLogger,
|
||||
type FeatureFlagsContext,
|
||||
FeatureFlagsService,
|
||||
type ITelemetryService,
|
||||
NoOpFeatureFlagsProvider,
|
||||
registerDisposable,
|
||||
resolveCoreDistinctId,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
buildClinePostHogClient,
|
||||
PostHogFeatureFlagsProvider,
|
||||
} from "@cline/core/services/feature-flags/posthog";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
let cliFeatureFlagsContext: FeatureFlagsContext = { clientName: "cline-cli" };
|
||||
let cliFeatureFlagsService: FeatureFlagsService | undefined;
|
||||
|
||||
const CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function resolveCliFeatureFlagsCachePath(): string {
|
||||
return join(resolveClineDataDir(), "cache", "feature-flags.json");
|
||||
}
|
||||
|
||||
function ensureCliDistinctId(): string {
|
||||
const distinctId = cliFeatureFlagsContext.distinctId?.trim();
|
||||
if (distinctId) {
|
||||
return distinctId;
|
||||
}
|
||||
const resolved = resolveCoreDistinctId();
|
||||
cliFeatureFlagsContext.distinctId = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getCliFeatureFlagsContext(): FeatureFlagsContext {
|
||||
ensureCliDistinctId();
|
||||
return { ...cliFeatureFlagsContext };
|
||||
}
|
||||
|
||||
export function getCliFeatureFlagsService(options?: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
}): FeatureFlagsService {
|
||||
if (!cliFeatureFlagsService) {
|
||||
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
const provider =
|
||||
apiKey &&
|
||||
process.env.IS_TEST !== "true" &&
|
||||
process.env.E2E_TEST !== "true"
|
||||
? new PostHogFeatureFlagsProvider({
|
||||
client: buildClinePostHogClient(apiKey),
|
||||
config: {
|
||||
logger: options?.logger,
|
||||
},
|
||||
})
|
||||
: new NoOpFeatureFlagsProvider();
|
||||
|
||||
cliFeatureFlagsService = new FeatureFlagsService({
|
||||
provider,
|
||||
telemetry: options?.telemetry,
|
||||
logger: options?.logger,
|
||||
context: getCliFeatureFlagsContext(),
|
||||
cacheFilePath: resolveCliFeatureFlagsCachePath(),
|
||||
persistentCacheMaxAgeMs: CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
|
||||
});
|
||||
registerDisposable(disposeCliFeatureFlagsService);
|
||||
}
|
||||
|
||||
return cliFeatureFlagsService;
|
||||
}
|
||||
|
||||
export function refreshCliFeatureFlagsInBackground(logger?: BasicLogger): void {
|
||||
const service = getCliFeatureFlagsService({ logger });
|
||||
void service.poll().catch((error) => {
|
||||
logger?.error?.("Error refreshing CLI feature flags", { error });
|
||||
});
|
||||
}
|
||||
|
||||
export async function disposeCliFeatureFlagsService(): Promise<void> {
|
||||
if (!cliFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = cliFeatureFlagsService;
|
||||
cliFeatureFlagsService = undefined;
|
||||
await current.dispose();
|
||||
}
|
||||
|
||||
export function setCliFeatureFlagsAccountContext(account: {
|
||||
id?: string;
|
||||
email?: string;
|
||||
}): void {
|
||||
const accountId = account.id?.trim();
|
||||
cliFeatureFlagsContext = {
|
||||
...cliFeatureFlagsContext,
|
||||
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
|
||||
...(account.email?.trim() ? { email: account.email.trim() } : {}),
|
||||
};
|
||||
cliFeatureFlagsService?.setContext(getCliFeatureFlagsContext());
|
||||
}
|
||||
|
||||
export async function identifyFeatureFlagsAccount(
|
||||
account: { id?: string; email?: string },
|
||||
logger?: BasicLogger,
|
||||
): Promise<void> {
|
||||
setCliFeatureFlagsAccountContext(account);
|
||||
|
||||
if (!cliFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cliFeatureFlagsService.poll();
|
||||
} catch (error) {
|
||||
logger?.error?.("Error polling CLI feature flags", { error });
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearClineFreeModelCostCache,
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "./free-model-cost";
|
||||
|
||||
afterEach(() => {
|
||||
clearClineFreeModelCostCache();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("shouldZeroClineFreeModelCost", () => {
|
||||
it("uses the Cline free model list", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://cline.test/api/v1/ai/cline/recommended-models",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not zero non-Cline providers", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "openrouter",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not match a paid model by only the final path segment", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "acme/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("retries after a failed free model list fetch", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliUsageCost", () => {
|
||||
it("zeros total cost while preserving token usage", () => {
|
||||
expect(
|
||||
zeroCliUsageCost(
|
||||
{
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliAgentEventCost", () => {
|
||||
it("zeros usage event cost fields", () => {
|
||||
const event = {
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cost: 0.001,
|
||||
totalCost: 0.001,
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("zeros done event usage cost", () => {
|
||||
const event = {
|
||||
type: "done",
|
||||
reason: "completed",
|
||||
text: "ok",
|
||||
iterations: 1,
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
usage: { totalCost: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { Config } from "./types";
|
||||
|
||||
const CLINE_RECOMMENDED_MODELS_TIMEOUT_MS = 5_000;
|
||||
const freeModelIdsByBaseUrl = new Map<
|
||||
string,
|
||||
Promise<readonly string[] | undefined>
|
||||
>();
|
||||
|
||||
function normalizeModelId(modelId: string | undefined): string {
|
||||
return modelId?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function modelIdsMatch(selectedModelId: string, freeModelId: string): boolean {
|
||||
const selected = normalizeModelId(selectedModelId);
|
||||
const free = normalizeModelId(freeModelId);
|
||||
if (!selected || !free) return false;
|
||||
return selected === free;
|
||||
}
|
||||
|
||||
function resolveClineRecommendedModelsUrl(baseUrl: string): string {
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
const apiBaseUrl = normalizedBaseUrl.endsWith("/api/v1")
|
||||
? normalizedBaseUrl.slice(0, -"/api/v1".length)
|
||||
: normalizedBaseUrl;
|
||||
return `${apiBaseUrl}/api/v1/ai/cline/recommended-models`;
|
||||
}
|
||||
|
||||
async function fetchClineFreeModelIds(
|
||||
baseUrl: string,
|
||||
): Promise<readonly string[] | undefined> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
CLINE_RECOMMENDED_MODELS_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const json = (await response.json()) as { free?: unknown };
|
||||
return Array.isArray(json.free)
|
||||
? json.free
|
||||
.map((model) =>
|
||||
model && typeof model === "object"
|
||||
? (model as Record<string, unknown>).id
|
||||
: undefined,
|
||||
)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
: [];
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
|
||||
const cacheKey = baseUrl.trim();
|
||||
let cached = freeModelIdsByBaseUrl.get(cacheKey);
|
||||
if (!cached) {
|
||||
cached = fetchClineFreeModelIds(cacheKey).then((ids) => {
|
||||
if (!ids) freeModelIdsByBaseUrl.delete(cacheKey);
|
||||
return ids;
|
||||
});
|
||||
freeModelIdsByBaseUrl.set(cacheKey, cached);
|
||||
}
|
||||
return cached.then((ids) => ids ?? []);
|
||||
}
|
||||
|
||||
export async function shouldZeroClineFreeModelCost(
|
||||
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
|
||||
): Promise<boolean> {
|
||||
if (config.providerId !== "cline") return false;
|
||||
const modelId = normalizeModelId(config.modelId);
|
||||
if (!modelId) return false;
|
||||
|
||||
const baseUrl =
|
||||
config.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
|
||||
const freeModelIds = await getClineFreeModelIds(baseUrl);
|
||||
return freeModelIds.some((freeModelId) =>
|
||||
modelIdsMatch(modelId, freeModelId),
|
||||
);
|
||||
}
|
||||
|
||||
export function zeroCliUsageCost<T extends { totalCost?: number } | undefined>(
|
||||
usage: T,
|
||||
shouldZeroCost: boolean,
|
||||
): T {
|
||||
if (
|
||||
!shouldZeroCost ||
|
||||
!usage ||
|
||||
typeof usage.totalCost !== "number" ||
|
||||
usage.totalCost === 0
|
||||
) {
|
||||
return usage;
|
||||
}
|
||||
return { ...usage, totalCost: 0 } as T;
|
||||
}
|
||||
|
||||
export function zeroCliAgentEventCost(
|
||||
event: AgentEvent,
|
||||
shouldZeroCost: boolean,
|
||||
): AgentEvent {
|
||||
if (!shouldZeroCost) return event;
|
||||
if (event.type === "done" && event.usage) {
|
||||
return {
|
||||
...event,
|
||||
usage: zeroCliUsageCost(event.usage, true),
|
||||
};
|
||||
}
|
||||
if (event.type !== "usage") return event;
|
||||
const next = { ...event } as Record<string, unknown>;
|
||||
if (typeof next.cost === "number") next.cost = 0;
|
||||
if (typeof next.totalCost === "number") next.totalCost = 0;
|
||||
return next as unknown as AgentEvent;
|
||||
}
|
||||
|
||||
export function clearClineFreeModelCostCache(): void {
|
||||
freeModelIdsByBaseUrl.clear();
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHistoryResumeArgs } from "./history-resume";
|
||||
|
||||
describe("buildHistoryResumeArgs", () => {
|
||||
it("replaces the history subcommand with --id", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["history"],
|
||||
}),
|
||||
).toEqual(["--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("preserves global flags that precede the subcommand", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: [
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"history",
|
||||
"--limit",
|
||||
"5",
|
||||
],
|
||||
remainingArgs: ["history", "--limit", "5"],
|
||||
}),
|
||||
).toEqual([
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"--id",
|
||||
"sess_1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a global flag value that matches the subcommand alias", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["-m", "h", "h"],
|
||||
remainingArgs: ["h"],
|
||||
}),
|
||||
).toEqual(["-m", "h", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("forwards a config dir passed as a subcommand option", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history", "--config", "/tmp/conf"],
|
||||
remainingArgs: ["history", "--config", "/tmp/conf"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("does not duplicate a config dir already in the global flags", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["--config", "/tmp/conf", "history"],
|
||||
remainingArgs: ["history"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("recognizes the --config=<dir> spelling in global flags", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["--config=/tmp/conf", "history"],
|
||||
remainingArgs: ["history"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config=/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("returns undefined when remaining args are not a suffix of argv", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history", "--limit", "5"],
|
||||
remainingArgs: ["history", "--limit", "9"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["extra", "history"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { resolveCliLaunchSpec } from "./internal-launch";
|
||||
|
||||
export interface HistoryResumeCommand {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
}
|
||||
|
||||
export interface BuildHistoryResumeArgsInput {
|
||||
sessionId: string;
|
||||
/** Full normalized CLI args (process.argv.slice(2) after normalization). */
|
||||
normalizedArgs: string[];
|
||||
/**
|
||||
* Commander's `program.args` after parsing: the `history` subcommand token
|
||||
* and everything following it. Must be a suffix of `normalizedArgs`.
|
||||
*/
|
||||
remainingArgs: string[];
|
||||
/**
|
||||
* Config dir resolved from the full argv. Forwarded explicitly because
|
||||
* `--config` may have been passed as a `history` subcommand option, which
|
||||
* would otherwise be dropped with the rest of the subcommand args.
|
||||
*/
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds argv for relaunching the CLI as `cline <globalFlags> --id <sessionId>`
|
||||
* after a session is picked in `cline history`. Returns undefined when the
|
||||
* global-flag prefix cannot be derived safely (caller falls back to resuming
|
||||
* in-process).
|
||||
*/
|
||||
export function buildHistoryResumeArgs(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): string[] | undefined {
|
||||
const { sessionId, normalizedArgs, remainingArgs, configDir } = input;
|
||||
const splitIndex = normalizedArgs.length - remainingArgs.length;
|
||||
if (splitIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
for (let i = 0; i < remainingArgs.length; i++) {
|
||||
if (normalizedArgs[splitIndex + i] !== remainingArgs[i]) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const globalArgs = normalizedArgs.slice(0, splitIndex);
|
||||
const args = [...globalArgs];
|
||||
const hasConfigFlag = globalArgs.some(
|
||||
(arg) => arg === "--config" || arg.startsWith("--config="),
|
||||
);
|
||||
if (configDir && !hasConfigFlag) {
|
||||
args.push("--config", configDir);
|
||||
}
|
||||
args.push("--id", sessionId);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function buildHistoryResumeCommand(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): HistoryResumeCommand | undefined {
|
||||
const childArgs = buildHistoryResumeArgs(input);
|
||||
if (!childArgs) {
|
||||
return undefined;
|
||||
}
|
||||
const spec = resolveCliLaunchSpec();
|
||||
if (!spec) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
launcher: spec.launcher,
|
||||
childArgs: [...spec.childArgsPrefix, ...childArgs],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes a history-picked session in a fresh `cline --id <sessionId>` child
|
||||
* process with inherited stdio, and returns its exit code. Creating a second
|
||||
* OpenTUI renderer in the picker's process can crash natively during teardown
|
||||
* (Bun "panic(main thread): Segmentation fault" on Ctrl+C), so the resumed
|
||||
* interactive TUI must get a process of its own.
|
||||
*
|
||||
* Returns undefined when the child cannot be launched; the caller should fall
|
||||
* back to resuming in-process.
|
||||
*/
|
||||
export async function spawnHistoryResume(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): Promise<number | undefined> {
|
||||
const command = buildHistoryResumeCommand(input);
|
||||
if (!command) {
|
||||
return undefined;
|
||||
}
|
||||
const { spawn } = await import("node:child_process");
|
||||
return await new Promise<number | undefined>((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(command.launcher, command.childArgs, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
// The child shares this foreground process group, so terminal-generated
|
||||
// Ctrl+C already reaches it. Keep the parent alive to reap the child
|
||||
// without re-forwarding a second signal into the TUI teardown path.
|
||||
const suppressParentSignal = () => {};
|
||||
process.on("SIGINT", suppressParentSignal);
|
||||
process.on("SIGTERM", suppressParentSignal);
|
||||
const finish = (value: number | undefined) => {
|
||||
process.off("SIGINT", suppressParentSignal);
|
||||
process.off("SIGTERM", suppressParentSignal);
|
||||
resolve(value);
|
||||
};
|
||||
child.once("error", () => finish(undefined));
|
||||
child.once("exit", (code, signal) => finish(signal ? 1 : (code ?? 0)));
|
||||
});
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listLocalProviders: vi.fn(async () => ({ providers: [], settingsPath: "" })),
|
||||
getBooleanFlagEnabled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
listLocalProviders: mocks.listLocalProviders,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("listLocalProviders", () => {
|
||||
it("passes the ClinePass feature flag into the SDK provider list", async () => {
|
||||
const { listLocalProviders } = await import("./provider-catalog");
|
||||
const manager = {} as never;
|
||||
|
||||
await listLocalProviders(manager);
|
||||
|
||||
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith("ext-cline-pass");
|
||||
expect(mocks.listLocalProviders).toHaveBeenCalledWith(manager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import {
|
||||
listLocalProviders as internalListLocalProviders,
|
||||
type ProviderSettingsManager,
|
||||
} from "@cline/core";
|
||||
import { getCliFeatureFlagsService } from "./feature-flags";
|
||||
|
||||
export async function listLocalProviders(
|
||||
manager: ProviderSettingsManager,
|
||||
): ReturnType<typeof internalListLocalProviders> {
|
||||
return await internalListLocalProviders(manager, {
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PLATFORMS, shouldIncludeField } from "./platforms";
|
||||
|
||||
describe("connect wizard platform security fields", () => {
|
||||
it("does not ask Telegram users to re-enter the bot username", () => {
|
||||
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
|
||||
|
||||
expect(telegram?.fields.map((field) => field.label)).toEqual(["Bot token"]);
|
||||
expect(telegram?.fields.map((field) => field.flag)).toEqual(["-k"]);
|
||||
});
|
||||
|
||||
it("rejects unsafe Telegram and Slack access restriction identifiers", () => {
|
||||
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
|
||||
const slack = PLATFORMS.find((platform) => platform.id === "slack");
|
||||
|
||||
const telegramUser = telegram?.security?.fields.find(
|
||||
(field) => field.key === "userId",
|
||||
);
|
||||
const slackTeam = slack?.security?.fields.find(
|
||||
(field) => field.key === "teamId",
|
||||
);
|
||||
const slackUser = slack?.security?.fields.find(
|
||||
(field) => field.key === "userId",
|
||||
);
|
||||
|
||||
expect(telegramUser?.validate?.("123456")).toBeUndefined();
|
||||
expect(telegramUser?.validate?.("123; rm -rf /")).toContain("digits");
|
||||
expect(slackTeam?.validate?.("T01ABC123")).toBeUndefined();
|
||||
expect(slackTeam?.validate?.("T01;bad")).toContain("Slack workspace");
|
||||
expect(slackUser?.validate?.("U01ABC123")).toBeUndefined();
|
||||
expect(slackUser?.validate?.("U01$(bad)")).toContain("Slack member");
|
||||
});
|
||||
|
||||
it("uses the Telegram allowed user ID flag for wizard security", () => {
|
||||
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
|
||||
|
||||
const args = telegram?.security?.buildArgs({
|
||||
userId: "123456",
|
||||
});
|
||||
|
||||
expect(args).toEqual(["--allowed-user-id", "123456"]);
|
||||
});
|
||||
|
||||
it("builds an exact-match Slack authorization hook", () => {
|
||||
const slack = PLATFORMS.find((platform) => platform.id === "slack");
|
||||
|
||||
const args = slack?.security?.buildArgs({
|
||||
teamId: "T01ABC123",
|
||||
userId: "U01ABC123",
|
||||
});
|
||||
|
||||
expect(args).toEqual([
|
||||
"--hook-command",
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:T01ABC123:user:U01ABC123" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("asks Slack users for mode-specific setup fields", () => {
|
||||
const slack = PLATFORMS.find((platform) => platform.id === "slack");
|
||||
const fields = slack?.fields ?? [];
|
||||
const webhookValues = { "--base-url": "https://example.test" };
|
||||
const socketValues = { "--base-url": "" };
|
||||
|
||||
expect(fields.map((field) => field.flag)).toEqual([
|
||||
"--bot-token",
|
||||
"--base-url",
|
||||
"--signing-secret",
|
||||
"--app-token",
|
||||
]);
|
||||
expect(
|
||||
fields
|
||||
.filter((field) => shouldIncludeField(field, webhookValues))
|
||||
.map((field) => field.flag),
|
||||
).toEqual(["--bot-token", "--base-url", "--signing-secret"]);
|
||||
expect(
|
||||
fields
|
||||
.filter((field) => shouldIncludeField(field, socketValues))
|
||||
.map((field) => field.flag),
|
||||
).toEqual(["--bot-token", "--base-url", "--app-token"]);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import {
|
||||
CONNECTOR_PLATFORMS,
|
||||
shouldIncludeConnectorField,
|
||||
} from "@cline/shared";
|
||||
|
||||
export type {
|
||||
ConnectorFieldCondition as FieldCondition,
|
||||
ConnectorFieldDef as FieldDef,
|
||||
ConnectorPlatformDef as PlatformDef,
|
||||
ConnectorSecurityDef as SecurityDef,
|
||||
ConnectorSecurityFieldDef as SecurityFieldDef,
|
||||
} from "@cline/shared";
|
||||
export { CONNECTOR_PLATFORMS, shouldIncludeConnectorField };
|
||||
|
||||
export const PLATFORMS = CONNECTOR_PLATFORMS;
|
||||
export const shouldIncludeField = shouldIncludeConnectorField;
|
||||
@@ -1,45 +0,0 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import {
|
||||
authorizeMcpServerOAuth,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
} from "@cline/core";
|
||||
import open from "open";
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.trim();
|
||||
if (message.length > 0) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export async function authorizeMcpServerOAuthWithBrowser(
|
||||
name: string,
|
||||
options: { throwOnError?: boolean } = {},
|
||||
): Promise<void> {
|
||||
p.log.info("Opening browser for MCP OAuth authorization");
|
||||
try {
|
||||
const result = await authorizeMcpServerOAuth({
|
||||
serverName: name,
|
||||
filePath: resolveDefaultMcpSettingsPath(),
|
||||
openUrl: async (url) => {
|
||||
p.log.message(`Authorization URL: ${url}`);
|
||||
await open(url, { wait: false });
|
||||
},
|
||||
onServerListening: (info) => {
|
||||
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
|
||||
},
|
||||
});
|
||||
p.log.success(result.message);
|
||||
} catch (error) {
|
||||
if (options.throwOnError === true) {
|
||||
throw error instanceof Error ? error : new Error(toErrorMessage(error));
|
||||
}
|
||||
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
|
||||
p.log.warn(
|
||||
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
# Cline Hub
|
||||
|
||||
A browser dashboard for the local Cline hub. Open it to see who's connected, what sessions are running, drive a session from a chat box, and restart the hub when you need a fresh daemon.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- live list of connected hub clients (from `HubUIClient.subscribeUI`)
|
||||
- live list of active sessions with status, model, and titles
|
||||
- click a session to view its message history and stream new assistant output
|
||||
- start a new session from an initial prompt — workspace/provider/model are reused from the most recent session, or `CLINE_PROVIDER` / `CLINE_MODEL` env vars
|
||||
- send messages to the selected session and watch chunks stream back
|
||||
- **Restart Hub** button: gracefully stops the local detached hub and respawns a fresh one
|
||||
- optional LAN/tunnel exposure gated by a shared `ROOM_SECRET`
|
||||
|
||||
The dashboard registers two clients with the hub: a `cline-hub-server` (via `ClineCore`) for driving sessions and a `cline-hub-server` (via `HubUIClient`) for the admin view.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd apps/cline-hub
|
||||
bun run start
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:8787> and click **Connect**. The server will discover or spawn a local detached hub on startup; the hub endpoint is printed in the console and shown in the sidebar.
|
||||
|
||||
For webview development with Vite hot reload:
|
||||
|
||||
```bash
|
||||
cd apps/cline-hub
|
||||
bun run dev
|
||||
```
|
||||
|
||||
This starts the Vite webview server on <http://127.0.0.1:5173> and the hub dashboard on <http://127.0.0.1:8787>. Open the dashboard URL; the served page loads webview modules from Vite, so changes under `src/webview/src` hot reload without rebuilding. Use `CLINE_HUB_WEBVIEW_DEV_PORT` or `CLINE_HUB_WEBVIEW_DEV_HOST` to change the Vite bind address.
|
||||
|
||||
To start a brand-new session, the dashboard needs to know which provider and model to use. It picks them up automatically from the most recent session on the hub. If there are no recent sessions, set `CLINE_PROVIDER` and `CLINE_MODEL` in the environment before running.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `HOST` | `127.0.0.1` | Bind host for the dashboard. Use the default for same-machine development. Set `HOST=0.0.0.0` only when intentionally exposing the dashboard on a LAN/tunnel. |
|
||||
| `CLINE_HUB_DASHBOARD_PORT` | `8787` | Dashboard HTTP/WebSocket port. |
|
||||
| `PUBLIC_URL` | `http://<HOST>:<PORT>` (`127.0.0.1` when binding `0.0.0.0`) | URL printed for humans to open/copy. Set this to your LAN URL or tunnel URL. |
|
||||
| `ROOM_SECRET` | unset | Shared invite secret required for browser WebSocket connections when `HOST` is non-local. |
|
||||
| `WORKSPACE_ROOT` | current directory | Workspace passed to the hub on startup. |
|
||||
| `CLINE_PROVIDER` | unset | Fallback provider id when no recent session is available to copy from. |
|
||||
| `CLINE_MODEL` | unset | Fallback model id when no recent session is available to copy from. |
|
||||
|
||||
The server prints both the bind URL and the public/invite URL at startup. When `ROOM_SECRET` is set, the printed invite URL includes `?roomSecret=...`; the browser UI also lets you paste the secret manually.
|
||||
|
||||
Validate option parsing without starting a server:
|
||||
|
||||
```bash
|
||||
bun run smoke:options
|
||||
```
|
||||
|
||||
## LAN usage
|
||||
|
||||
Choose a strong room secret and bind explicitly to all interfaces:
|
||||
|
||||
```bash
|
||||
cd apps/cline-hub
|
||||
HOST=0.0.0.0 \
|
||||
CLINE_HUB_DASHBOARD_PORT=8787 \
|
||||
PUBLIC_URL=http://YOUR_LAN_IP:8787 \
|
||||
ROOM_SECRET='use-a-long-random-secret' \
|
||||
bun run start
|
||||
```
|
||||
|
||||
Share the printed invite URL with another machine on the same LAN.
|
||||
|
||||
`ROOM_SECRET` is required for `HOST=0.0.0.0`; without it the dashboard exits before listening.
|
||||
|
||||
## Tunnel usage
|
||||
|
||||
Start the dashboard locally with an explicit secret:
|
||||
|
||||
```bash
|
||||
cd apps/cline-hub
|
||||
ROOM_SECRET='use-a-long-random-secret' bun run start
|
||||
```
|
||||
|
||||
In another terminal, expose the local port with your tunnel provider, for example:
|
||||
|
||||
```bash
|
||||
ngrok http 8787
|
||||
```
|
||||
|
||||
Restart the dashboard with the tunnel URL as `PUBLIC_URL` so the printed invite URL is copyable:
|
||||
|
||||
```bash
|
||||
PUBLIC_URL=https://YOUR-TUNNEL.example \
|
||||
ROOM_SECRET='use-a-long-random-secret' \
|
||||
bun run start
|
||||
```
|
||||
|
||||
Share only the printed invite URL with trusted participants.
|
||||
|
||||
## Restarting the hub
|
||||
|
||||
Clicking **Restart Hub** in the sidebar:
|
||||
|
||||
1. Detaches the dashboard's `ClineCore` and `HubUIClient` from the current hub.
|
||||
2. Calls `stopLocalHubServerGracefully()` to shut the local detached hub down.
|
||||
3. Calls `ensureDetachedHubServer(workspaceRoot)` to spawn a fresh hub.
|
||||
4. Reconnects and broadcasts the new hub state to every open browser tab.
|
||||
|
||||
Sessions running on the previous hub are stopped along with the hub. Other clients connected to that hub (CLI, VS Code, menubar) will see their connection drop and reconnect to the new daemon on next request.
|
||||
|
||||
## Security warning
|
||||
|
||||
This is an example dashboard, not a production admin tool. Exposing it on a LAN or tunnel lets anyone with the invite secret list clients/sessions on your hub, drive sessions, and restart the hub. Use a long random `ROOM_SECRET`, only share the URL with trusted participants, and stop the process when you are done. The hub and agent runtime remain owned by the host machine.
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "@cline/cline-hub",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Browser dashboard for the Cline hub: live clients, sessions, streaming chat, and hub restart.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/server.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build:webview": "bun run --cwd src/webview build",
|
||||
"dev": "bun run src/dev.ts",
|
||||
"start": "bun run src/server.ts",
|
||||
"smoke:options": "bun run src/validate-options.ts",
|
||||
"test": "bunx vitest run --config vitest.config.ts",
|
||||
"typecheck": "bun tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cline/core": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const webviewHost =
|
||||
process.env.CLINE_HUB_WEBVIEW_DEV_HOST?.trim() || "127.0.0.1";
|
||||
const webviewPort = process.env.CLINE_HUB_WEBVIEW_DEV_PORT?.trim() || "5173";
|
||||
const webviewDevServerUrl =
|
||||
process.env.VITE_DEV_SERVER_URL?.trim() ||
|
||||
`http://${webviewHost}:${webviewPort}`;
|
||||
|
||||
const cwd = process.cwd();
|
||||
const webviewCwd = join(cwd, "src", "webview");
|
||||
|
||||
const children: Bun.Subprocess[] = [];
|
||||
let shuttingDown = false;
|
||||
|
||||
function spawn(
|
||||
name: string,
|
||||
command: string[],
|
||||
options: {
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
},
|
||||
): Bun.Subprocess {
|
||||
const child = Bun.spawn(command, {
|
||||
...options,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
children.push(child);
|
||||
void child.exited.then((code) => {
|
||||
if (!shuttingDown) {
|
||||
console.error(`[cline-hub:dev] ${name} exited with code ${code}`);
|
||||
shutdown(code === 0 ? 0 : 1);
|
||||
}
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
function shutdown(exitCode = 0): void {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
for (const child of children) {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// The process may have already exited.
|
||||
}
|
||||
}
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
|
||||
console.log(`[cline-hub:dev] Vite webview: ${webviewDevServerUrl}`);
|
||||
console.log("[cline-hub:dev] Hub dashboard: http://127.0.0.1:8787/");
|
||||
|
||||
spawn(
|
||||
"webview",
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
"dev",
|
||||
"--host",
|
||||
webviewHost,
|
||||
"--port",
|
||||
webviewPort,
|
||||
"--strictPort",
|
||||
],
|
||||
{
|
||||
cwd: webviewCwd,
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
|
||||
spawn("server", ["bun", "run", "src/server.ts"], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
VITE_DEV_SERVER_URL: webviewDevServerUrl,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.allSettled(children.map((child) => child.exited));
|
||||
@@ -1,115 +0,0 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export interface ClineHubServerOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
publicUrl: string;
|
||||
roomSecret?: string;
|
||||
workspaceRoot: string;
|
||||
}
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 8787;
|
||||
const DASHBOARD_PORT_ENV = "CLINE_HUB_DASHBOARD_PORT";
|
||||
|
||||
function parsePort(value: string | undefined): number {
|
||||
if (!value?.trim()) return DEFAULT_PORT;
|
||||
const port = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(
|
||||
`${DASHBOARD_PORT_ENV} must be an integer from 1 to 65535, got ${value}`,
|
||||
);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function normalizeHost(value: string | undefined): string {
|
||||
return value?.trim() || DEFAULT_HOST;
|
||||
}
|
||||
|
||||
function normalizePublicUrl(
|
||||
value: string | undefined,
|
||||
host: string,
|
||||
port: number,
|
||||
): string {
|
||||
const fallbackHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
||||
const raw = value?.trim() || `http://${fallbackHost}:${port}`;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`PUBLIC_URL must be a valid http(s) URL, got ${raw}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`PUBLIC_URL must use http: or https:, got ${parsed.protocol}`,
|
||||
);
|
||||
}
|
||||
if (shouldAddDashboardPortToPublicUrl(parsed, port)) {
|
||||
parsed.port = String(port);
|
||||
}
|
||||
parsed.hash = "";
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function normalizeRoomSecret(value: string | undefined): string | undefined {
|
||||
const secret = value?.trim();
|
||||
return secret ? secret : undefined;
|
||||
}
|
||||
|
||||
function isLocalBindHost(host: string): boolean {
|
||||
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
||||
}
|
||||
|
||||
export function isNonLocalBindHost(host: string): boolean {
|
||||
return !isLocalBindHost(host);
|
||||
}
|
||||
|
||||
export function resolveClineHubServerOptions(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ClineHubServerOptions {
|
||||
const host = normalizeHost(env.HOST);
|
||||
const port = parsePort(env[DASHBOARD_PORT_ENV]);
|
||||
const publicUrl = normalizePublicUrl(env.PUBLIC_URL, host, port);
|
||||
const roomSecret = normalizeRoomSecret(env.ROOM_SECRET);
|
||||
if (isNonLocalBindHost(host) && !roomSecret) {
|
||||
throw new Error(
|
||||
`ROOM_SECRET is required when HOST=${host}. Use HOST=127.0.0.1 for local-only development or set ROOM_SECRET before exposing this example on a LAN/tunnel.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
publicUrl,
|
||||
roomSecret,
|
||||
workspaceRoot: env.WORKSPACE_ROOT?.trim() || process.cwd(),
|
||||
};
|
||||
}
|
||||
|
||||
function isDefaultProtocolPort(url: URL, port: number): boolean {
|
||||
return (
|
||||
(url.protocol === "http:" && port === 80) ||
|
||||
(url.protocol === "https:" && port === 443)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldAddDashboardPortToPublicUrl(url: URL, port: number): boolean {
|
||||
if (url.port || isDefaultProtocolPort(url, port)) return false;
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
||||
return hostname === "localhost" || isIP(hostname) !== 0;
|
||||
}
|
||||
|
||||
export function buildInviteUrl(
|
||||
publicUrl: string,
|
||||
roomSecret: string | undefined,
|
||||
): string {
|
||||
const url = new URL(publicUrl);
|
||||
if (roomSecret) {
|
||||
url.searchParams.set("roomSecret", roomSecret);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
import { CORE_BUILD_VERSION } from "@cline/core";
|
||||
import { isNonLocalBindHost } from "./options";
|
||||
import {
|
||||
handleToolApprovalResponse,
|
||||
rejectOrphanedApprovals,
|
||||
} from "./server/approvals";
|
||||
import { isAuthorizedBrowserToDesktopRequest } from "./server/browser-auth";
|
||||
import {
|
||||
browserConfig,
|
||||
host,
|
||||
inviteUrl,
|
||||
port,
|
||||
publicUrl,
|
||||
roomSecret,
|
||||
webviewDistDir,
|
||||
} from "./server/deps";
|
||||
import { handleDesktopCommand } from "./server/desktop-commands";
|
||||
import {
|
||||
createJsonResponse,
|
||||
isWebviewRoute,
|
||||
WebviewAssets,
|
||||
} from "./server/http";
|
||||
import {
|
||||
attachHub,
|
||||
detachHub,
|
||||
restartHub,
|
||||
syncHubClientsAndSessions,
|
||||
syncHubHealth,
|
||||
} from "./server/hub";
|
||||
import { fetchMarketplaceCatalog } from "./server/marketplace";
|
||||
import {
|
||||
loadModels,
|
||||
runProviderOAuthLogin,
|
||||
saveProviderSettings,
|
||||
sendProviderCatalog,
|
||||
} from "./server/providers";
|
||||
import {
|
||||
abortPeerTurn,
|
||||
deleteSession,
|
||||
forkPeerSession,
|
||||
initializePeer,
|
||||
resetPeer,
|
||||
restorePeerSession,
|
||||
selectSession,
|
||||
sendMessage,
|
||||
} from "./server/sessions";
|
||||
import { HubContext } from "./server/state";
|
||||
import { broadcastHubState, hubStatusPayload } from "./server/state-payloads";
|
||||
import type { BrowserFrame, BrowserPeer } from "./server/types";
|
||||
|
||||
export interface ClineHubDashboardServer {
|
||||
listenUrl: string;
|
||||
publicUrl: string;
|
||||
inviteUrl: string;
|
||||
bindHost: string;
|
||||
inviteRequired: boolean;
|
||||
hubUrl: string | undefined;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PUBLIC_BROWSER_PATHS = new Set([
|
||||
"/version",
|
||||
"/health",
|
||||
"/config.json",
|
||||
"/api/marketplace/catalog",
|
||||
"/icon.png",
|
||||
"/icon.svg",
|
||||
"/icon.ico",
|
||||
"/32x32.png",
|
||||
"/cline-logo-filled.svg",
|
||||
"/favicon.svg",
|
||||
]);
|
||||
|
||||
function isPublicStaticAssetPath(pathname: string): boolean {
|
||||
return pathname.startsWith("/assets/") || PUBLIC_BROWSER_PATHS.has(pathname);
|
||||
}
|
||||
|
||||
function isPublicBrowserRoute(_req: Request, url: URL): boolean {
|
||||
return isWebviewRoute(url.pathname) || isPublicStaticAssetPath(url.pathname);
|
||||
}
|
||||
|
||||
export async function startClineHubDashboardServer(): Promise<ClineHubDashboardServer> {
|
||||
const ctx = new HubContext();
|
||||
const assets = new WebviewAssets(webviewDistDir);
|
||||
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
|
||||
let stopped = false;
|
||||
|
||||
await attachHub(ctx);
|
||||
const healthInterval = setInterval(() => {
|
||||
void (async () => {
|
||||
await syncHubHealth(ctx);
|
||||
broadcastHubState(ctx);
|
||||
})();
|
||||
}, 5_000);
|
||||
|
||||
const server = Bun.serve<BrowserPeer>({
|
||||
port,
|
||||
hostname: host,
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
if (
|
||||
!isAuthorizedBrowserToDesktopRequest(
|
||||
req,
|
||||
url,
|
||||
{
|
||||
bindHost: host,
|
||||
port,
|
||||
publicUrl,
|
||||
roomSecret,
|
||||
},
|
||||
isPublicBrowserRoute,
|
||||
)
|
||||
) {
|
||||
return createJsonResponse({ error: "unauthorized_browser" }, 403);
|
||||
}
|
||||
if (url.pathname === "/version") {
|
||||
return createJsonResponse({ coreVersion: CORE_BUILD_VERSION });
|
||||
}
|
||||
if (url.pathname === "/health") {
|
||||
await syncHubHealth(ctx);
|
||||
return createJsonResponse(hubStatusPayload(ctx));
|
||||
}
|
||||
if (url.pathname === "/browser") {
|
||||
const displayName = `Browser ${Math.random().toString(36).slice(2, 6)}`;
|
||||
const data = {
|
||||
socket: undefined as never,
|
||||
displayName,
|
||||
sending: false,
|
||||
};
|
||||
if (server.upgrade(req, { data })) return undefined;
|
||||
return new Response("upgrade failed", { status: 400 });
|
||||
}
|
||||
if (url.pathname === "/config.json") {
|
||||
return createJsonResponse(browserConfig);
|
||||
}
|
||||
if (url.pathname === "/api/marketplace/catalog") {
|
||||
try {
|
||||
return createJsonResponse(await fetchMarketplaceCatalog());
|
||||
} catch (error) {
|
||||
return createJsonResponse(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch marketplace catalog",
|
||||
},
|
||||
502,
|
||||
);
|
||||
}
|
||||
}
|
||||
return assets.serve(url.pathname);
|
||||
},
|
||||
websocket: {
|
||||
async open(socket) {
|
||||
const peer = socket.data;
|
||||
peer.socket = socket;
|
||||
ctx.peers.add(peer);
|
||||
},
|
||||
async message(socket, raw) {
|
||||
const peer = socket.data;
|
||||
try {
|
||||
const frame = JSON.parse(String(raw)) as BrowserFrame;
|
||||
if (frame.type === "desktopCommand") {
|
||||
try {
|
||||
const result = await handleDesktopCommand(
|
||||
ctx,
|
||||
frame.command,
|
||||
frame.args,
|
||||
);
|
||||
ctx.send(peer, {
|
||||
type: "desktopCommandResult",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.send(peer, {
|
||||
type: "desktopCommandResult",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
} else if (frame.type === "ready") {
|
||||
await initializePeer(ctx, peer, syncClientsAndSessions);
|
||||
} else if (frame.type === "loadModels") {
|
||||
await loadModels(ctx, peer, frame.providerId);
|
||||
} else if (frame.type === "loadProviderCatalog") {
|
||||
await sendProviderCatalog(ctx, peer);
|
||||
} else if (frame.type === "saveProviderSettings") {
|
||||
await saveProviderSettings(ctx, peer, frame);
|
||||
} else if (frame.type === "runProviderOAuthLogin") {
|
||||
await runProviderOAuthLogin(ctx, peer, frame.providerId);
|
||||
} else if (frame.type === "attachSession") {
|
||||
await selectSession(ctx, peer, frame.sessionId);
|
||||
} else if (frame.type === "deleteSession") {
|
||||
await deleteSession(ctx, peer, frame.sessionId);
|
||||
} else if (frame.type === "updateSessionMetadata") {
|
||||
if (!ctx.cline) throw new Error("Hub is not connected.");
|
||||
const session = await ctx.cline.get(frame.sessionId);
|
||||
const metadata =
|
||||
session?.metadata && typeof session.metadata === "object"
|
||||
? (session.metadata as Record<string, unknown>)
|
||||
: {};
|
||||
await ctx.cline.update(frame.sessionId, {
|
||||
metadata: { ...metadata, ...frame.metadata },
|
||||
});
|
||||
await syncHubClientsAndSessions(ctx);
|
||||
broadcastHubState(ctx);
|
||||
} else if (frame.type === "approval_response") {
|
||||
handleToolApprovalResponse(ctx, frame);
|
||||
} else if (frame.type === "abort") {
|
||||
await abortPeerTurn(ctx, peer);
|
||||
} else if (frame.type === "reset") {
|
||||
await resetPeer(ctx, peer);
|
||||
} else if (frame.type === "send") {
|
||||
if (peer.sending) {
|
||||
ctx.send(peer, {
|
||||
type: "status",
|
||||
text: "A turn is already in progress.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
peer.sending = true;
|
||||
try {
|
||||
await sendMessage(
|
||||
ctx,
|
||||
peer,
|
||||
frame.prompt,
|
||||
frame.config,
|
||||
frame.attachments,
|
||||
);
|
||||
} finally {
|
||||
peer.sending = false;
|
||||
}
|
||||
} else if (frame.type === "forkSession") {
|
||||
await forkPeerSession(ctx, peer, syncClientsAndSessions);
|
||||
} else if (frame.type === "restore") {
|
||||
await restorePeerSession(
|
||||
ctx,
|
||||
peer,
|
||||
frame.checkpointRunCount,
|
||||
syncClientsAndSessions,
|
||||
);
|
||||
} else if (frame.type === "restart_hub") {
|
||||
await restartHub(ctx);
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.send(peer, {
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
close(socket) {
|
||||
const peer = socket.data;
|
||||
peer.unsubscribeEvents?.();
|
||||
ctx.peers.delete(peer);
|
||||
rejectOrphanedApprovals(ctx);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
listenUrl: server.url.toString(),
|
||||
publicUrl,
|
||||
inviteUrl,
|
||||
bindHost: host,
|
||||
inviteRequired: Boolean(roomSecret),
|
||||
hubUrl: ctx.hubUrl,
|
||||
stop: async () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
clearInterval(healthInterval);
|
||||
try {
|
||||
server.stop(true);
|
||||
} finally {
|
||||
await detachHub(ctx);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function printClineHubDashboardServerInfo(
|
||||
server: ClineHubDashboardServer,
|
||||
): void {
|
||||
console.log(`Cline Hub dashboard listening: ${server.listenUrl}`);
|
||||
console.log(`Cline Hub public URL: ${server.publicUrl}`);
|
||||
console.log(`hub endpoint: ${server.hubUrl}`);
|
||||
if (server.inviteRequired) {
|
||||
console.log(`Cline Hub invite URL: ${server.inviteUrl}`);
|
||||
} else if (isNonLocalBindHost(server.bindHost)) {
|
||||
console.warn("WARNING: non-local bind without ROOM_SECRET is not allowed.");
|
||||
} else {
|
||||
console.log(
|
||||
"ROOM_SECRET is not set; this local-only instance accepts browser connections without an invite token.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const server = await startClineHubDashboardServer();
|
||||
printClineHubDashboardServerInfo(server);
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import type { CoreSessionEvent } from "@cline/core";
|
||||
import type { AgentEvent } from "@cline/shared";
|
||||
import type { WebviewToolEvent } from "../webview-protocol";
|
||||
import { rejectPendingApprovalsForSession } from "./approvals";
|
||||
import type { HubContext } from "./state";
|
||||
import { broadcastHubState } from "./state-payloads";
|
||||
import { asString, chunkText } from "./utils";
|
||||
|
||||
function agentEventText(event: AgentEvent): string {
|
||||
if (
|
||||
event.type === "content_start" &&
|
||||
event.contentType === "text" &&
|
||||
typeof event.text === "string"
|
||||
) {
|
||||
return event.text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function sendChunkToSelectedPeers(
|
||||
ctx: HubContext,
|
||||
sessionId: string,
|
||||
text: string,
|
||||
): void {
|
||||
if (!text) return;
|
||||
ctx.sendToSelectedPeers(sessionId, { type: "assistant_delta", text });
|
||||
}
|
||||
|
||||
function forwardAgentEvent(
|
||||
ctx: HubContext,
|
||||
sessionId: string,
|
||||
event: AgentEvent,
|
||||
): void {
|
||||
if (event.type === "content_start") {
|
||||
if (event.contentType === "reasoning") {
|
||||
ctx.sendToSelectedPeers(sessionId, {
|
||||
type: "reasoning_delta",
|
||||
text: event.reasoning ?? event.text ?? "",
|
||||
redacted: event.redacted,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.contentType === "tool") {
|
||||
ctx.sendToSelectedPeers(sessionId, {
|
||||
type: "tool_event",
|
||||
text: `Running ${event.toolName ?? "tool"}...`,
|
||||
event: {
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
status: "running",
|
||||
input: event.input,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
const text = agentEventText(event);
|
||||
if (text) sendChunkToSelectedPeers(ctx, sessionId, text);
|
||||
return;
|
||||
}
|
||||
if (event.type === "content_update" && event.contentType === "tool") {
|
||||
const toolEvent: WebviewToolEvent = {
|
||||
toolCallId: event.toolCallId,
|
||||
toolName: event.toolName,
|
||||
status: "running",
|
||||
output: event.update,
|
||||
};
|
||||
ctx.sendToSelectedPeers(sessionId, {
|
||||
type: "tool_event",
|
||||
text: `${event.toolName ?? "tool"} updated`,
|
||||
event: toolEvent,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.type === "content_end") {
|
||||
if (event.contentType === "reasoning") {
|
||||
ctx.sendToSelectedPeers(sessionId, {
|
||||
type: "reasoning_delta",
|
||||
text: event.reasoning ?? event.text ?? "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.contentType === "tool") {
|
||||
const toolName = event.toolName ?? "tool";
|
||||
ctx.sendToSelectedPeers(sessionId, {
|
||||
type: "tool_event",
|
||||
text: event.error
|
||||
? `${toolName} failed: ${event.error}`
|
||||
: `${toolName} completed`,
|
||||
event: {
|
||||
toolCallId: event.toolCallId,
|
||||
toolName,
|
||||
status: event.error ? "failed" : "completed",
|
||||
output: event.output,
|
||||
error: event.error,
|
||||
},
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.type === "notice") {
|
||||
ctx.sendToSelectedPeers(sessionId, { type: "status", text: event.message });
|
||||
return;
|
||||
}
|
||||
if (event.type === "done") {
|
||||
ctx.sendToSelectedPeers(sessionId, {
|
||||
type: "turn_done",
|
||||
finishReason: event.reason,
|
||||
iterations: event.iterations,
|
||||
usage: event.usage
|
||||
? {
|
||||
inputTokens: event.usage.inputTokens,
|
||||
outputTokens: event.usage.outputTokens,
|
||||
cacheCreationInputTokens: event.usage.cacheWriteTokens,
|
||||
cacheReadInputTokens: event.usage.cacheReadTokens,
|
||||
totalCost: event.usage.totalCost,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.type === "error") {
|
||||
ctx.sendToSelectedPeers(sessionId, {
|
||||
type: "error",
|
||||
text: event.error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function handleSessionEvent(
|
||||
ctx: HubContext,
|
||||
event: CoreSessionEvent,
|
||||
): void {
|
||||
const payload = event.payload as Record<string, unknown> | undefined;
|
||||
const sessionId = asString(payload?.sessionId);
|
||||
if (!sessionId) return;
|
||||
if (event.type === "chunk") {
|
||||
const text = chunkText((payload as Record<string, unknown>).chunk);
|
||||
sendChunkToSelectedPeers(ctx, sessionId, text);
|
||||
} else if (event.type === "agent_event") {
|
||||
if (event.payload.teamRole === "teammate") return;
|
||||
forwardAgentEvent(ctx, sessionId, event.payload.event);
|
||||
} else if (event.type === "status") {
|
||||
const status = asString((payload as Record<string, unknown>).status);
|
||||
const tracked = ctx.sessions.get(sessionId);
|
||||
if (tracked && status) {
|
||||
tracked.status = status;
|
||||
tracked.updatedAt = Date.now();
|
||||
}
|
||||
for (const peer of ctx.peers) {
|
||||
if (peer.selectedSessionId === sessionId) {
|
||||
ctx.send(peer, {
|
||||
type: "status",
|
||||
text: status ?? "Session status changed.",
|
||||
});
|
||||
}
|
||||
}
|
||||
broadcastHubState(ctx);
|
||||
} else if (event.type === "ended") {
|
||||
rejectPendingApprovalsForSession(
|
||||
ctx,
|
||||
sessionId,
|
||||
"Session ended before approval was resolved.",
|
||||
);
|
||||
const tracked = ctx.sessions.get(sessionId);
|
||||
if (tracked) {
|
||||
tracked.status = "completed";
|
||||
tracked.updatedAt = Date.now();
|
||||
}
|
||||
for (const peer of ctx.peers) {
|
||||
if (peer.selectedSessionId === sessionId) {
|
||||
ctx.send(peer, {
|
||||
type: "turn_done",
|
||||
finishReason: event.payload.reason,
|
||||
iterations: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
broadcastHubState(ctx);
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
|
||||
import type { WebviewInboundMessage } from "../webview-protocol";
|
||||
import type { HubContext } from "./state";
|
||||
import { broadcastHubState } from "./state-payloads";
|
||||
|
||||
function createApprovalId(): string {
|
||||
return `approval-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export function resolveToolApproval(
|
||||
ctx: HubContext,
|
||||
approvalId: string,
|
||||
result: ToolApprovalResult,
|
||||
): boolean {
|
||||
const pending = ctx.pendingToolApprovals.get(approvalId);
|
||||
if (!pending) return false;
|
||||
clearTimeout(pending.timeout);
|
||||
ctx.pendingToolApprovals.delete(approvalId);
|
||||
ctx.sendToSelectedPeers(pending.sessionId, {
|
||||
type: "approval_resolved",
|
||||
approvalId,
|
||||
approved: result.approved,
|
||||
reason: result.reason,
|
||||
});
|
||||
pending.resolve(result);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function rejectPendingApprovalsForSession(
|
||||
ctx: HubContext,
|
||||
sessionId: string,
|
||||
reason: string,
|
||||
): void {
|
||||
for (const [approvalId, pending] of [...ctx.pendingToolApprovals.entries()]) {
|
||||
if (pending.sessionId === sessionId) {
|
||||
resolveToolApproval(ctx, approvalId, { approved: false, reason });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function rejectAllPendingApprovals(
|
||||
ctx: HubContext,
|
||||
reason: string,
|
||||
): void {
|
||||
for (const approvalId of [...ctx.pendingToolApprovals.keys()]) {
|
||||
resolveToolApproval(ctx, approvalId, { approved: false, reason });
|
||||
}
|
||||
}
|
||||
|
||||
export function rejectOrphanedApprovals(ctx: HubContext): void {
|
||||
for (const [approvalId, pending] of [...ctx.pendingToolApprovals.entries()]) {
|
||||
if (!ctx.hasSelectedPeer(pending.sessionId)) {
|
||||
resolveToolApproval(ctx, approvalId, {
|
||||
approved: false,
|
||||
reason: "Cline Hub webview disconnected before approval was resolved.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function requestToolApprovalFromWebview(
|
||||
ctx: HubContext,
|
||||
request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> {
|
||||
if (!ctx.hasSelectedPeer(request.sessionId)) {
|
||||
return Promise.resolve({
|
||||
approved: false,
|
||||
reason: "No Cline Hub webview is attached to this session.",
|
||||
});
|
||||
}
|
||||
|
||||
const approvalId = createApprovalId();
|
||||
ctx.pushEvent(
|
||||
"Tool approval requested",
|
||||
`${request.toolName} is waiting for approval`,
|
||||
"warn",
|
||||
);
|
||||
broadcastHubState(ctx);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolveToolApproval(ctx, approvalId, {
|
||||
approved: false,
|
||||
reason: "Tool approval request timed out.",
|
||||
});
|
||||
}, 10 * 60_000);
|
||||
ctx.pendingToolApprovals.set(approvalId, {
|
||||
sessionId: request.sessionId,
|
||||
resolve,
|
||||
timeout,
|
||||
});
|
||||
ctx.sendToSelectedPeers(request.sessionId, {
|
||||
type: "approval_request",
|
||||
approvalId,
|
||||
sessionId: request.sessionId,
|
||||
agentId: request.agentId,
|
||||
conversationId: request.conversationId,
|
||||
iteration: request.iteration,
|
||||
toolCallId: request.toolCallId,
|
||||
toolName: request.toolName,
|
||||
input: request.input,
|
||||
policy: request.policy as Record<string, unknown> | undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function handleToolApprovalResponse(
|
||||
ctx: HubContext,
|
||||
frame: Extract<WebviewInboundMessage, { type: "approval_response" }>,
|
||||
): void {
|
||||
const approvalId = frame.approvalId.trim();
|
||||
if (!approvalId) return;
|
||||
const resolved = resolveToolApproval(ctx, approvalId, {
|
||||
approved: frame.approved,
|
||||
reason:
|
||||
frame.reason ??
|
||||
(frame.approved ? "Approved in Cline Hub." : "Rejected in Cline Hub."),
|
||||
});
|
||||
if (!resolved) {
|
||||
console.warn(`Ignoring unknown tool approval response: ${approvalId}`);
|
||||
}
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
allowedBrowserHosts,
|
||||
allowedBrowserOrigins,
|
||||
isAuthorizedBrowserRequest,
|
||||
isAuthorizedBrowserToDesktopRequest,
|
||||
requiresBrowserRequestAuth,
|
||||
} from "./browser-auth";
|
||||
|
||||
const defaultOptions = {
|
||||
bindHost: "127.0.0.1",
|
||||
port: 8787,
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
};
|
||||
|
||||
const publicRoute = (_req: Request, url: URL) => url.pathname === "/public";
|
||||
|
||||
function browserRequest(
|
||||
origin?: string,
|
||||
init?: Omit<RequestInit, "headers"> & {
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
): Request {
|
||||
return new Request("http://127.0.0.1:8787/browser", {
|
||||
...init,
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
...(origin === undefined ? {} : { origin }),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("allowedBrowserOrigins", () => {
|
||||
it("allows the configured public URL origin and local aliases for local binds", () => {
|
||||
expect([...allowedBrowserOrigins(defaultOptions)].sort()).toEqual([
|
||||
"http://127.0.0.1:8787",
|
||||
"http://[::1]:8787",
|
||||
"http://localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the configured public URL scheme for local aliases", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
...defaultOptions,
|
||||
publicUrl: "https://127.0.0.1:8787",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual([
|
||||
"https://127.0.0.1:8787",
|
||||
"https://[::1]:8787",
|
||||
"https://localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits default protocol ports for local alias origins", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 80,
|
||||
publicUrl: "http://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["http://127.0.0.1", "http://[::1]", "http://localhost"]);
|
||||
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 443,
|
||||
publicUrl: "https://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["https://127.0.0.1", "https://[::1]", "https://localhost"]);
|
||||
});
|
||||
|
||||
it("allows the configured public URL origin and explicit bind origin for non-local binds", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "https://example.ngrok-free.app",
|
||||
roomSecret: "secret",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["https://0.0.0.0:8787", "https://example.ngrok-free.app"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowedBrowserHosts", () => {
|
||||
it("allows the configured public URL host and local aliases for local binds", () => {
|
||||
expect([...allowedBrowserHosts(defaultOptions)].sort()).toEqual([
|
||||
"127.0.0.1:8787",
|
||||
"[::1]:8787",
|
||||
"localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits default protocol ports for local alias hosts", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 80,
|
||||
publicUrl: "http://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
|
||||
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 443,
|
||||
publicUrl: "https://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
|
||||
});
|
||||
|
||||
it("allows the configured public URL host and explicit bind host for non-local binds", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "https://example.ngrok-free.app",
|
||||
roomSecret: "secret",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["0.0.0.0:8787", "example.ngrok-free.app"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiresBrowserRequestAuth", () => {
|
||||
it("does not require browser auth for public GET routes", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/public"),
|
||||
new URL("http://127.0.0.1:8787/public"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires browser auth for unknown paths even when they use GET", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-api"),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for privileged paths even when they use GET", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/browser"),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for every WebSocket upgrade path", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-socket", {
|
||||
headers: { upgrade: "websocket" },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-socket"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for every unsafe HTTP method", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-api", { method: "POST" }),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedBrowserRequest", () => {
|
||||
it.each([
|
||||
"http://127.0.0.1:8787",
|
||||
"http://localhost:8787",
|
||||
"http://[::1]:8787",
|
||||
])("accepts local dashboard origin %s without a room secret", (origin) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest(origin),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"null",
|
||||
"not a url",
|
||||
"http://evil.attacker.example.com",
|
||||
"http://127.0.0.1:9999",
|
||||
"https://127.0.0.1:8787",
|
||||
])("rejects untrusted origin %s", (origin) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest(origin),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"evil.attacker.example.com",
|
||||
"127.0.0.1:9999",
|
||||
"localhost:9999",
|
||||
])("rejects untrusted host %s", (host) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787", {
|
||||
headers: host === undefined ? { host: "" } : { host },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows explicit wildcard bind host and origin when a room secret is configured", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://0.0.0.0:8787", {
|
||||
headers: { host: "0.0.0.0:8787" },
|
||||
}),
|
||||
new URL("http://0.0.0.0:8787/browser?roomSecret=invite-123"),
|
||||
{
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
roomSecret: "invite-123",
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires trusted origin, trusted host, and room secret when a room secret is configured", () => {
|
||||
const options = { ...defaultOptions, roomSecret: "invite-123" };
|
||||
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787"),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787"),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://evil.attacker.example.com"),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787", {
|
||||
headers: { host: "evil.attacker.example.com" },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedBrowserToDesktopRequest", () => {
|
||||
it("allows safe public GET routes without an origin", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/public"),
|
||||
new URL("http://127.0.0.1:8787/public"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects future WebSocket paths from untrusted origins by default", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-socket", {
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://evil.attacker.example.com",
|
||||
upgrade: "websocket",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-socket"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects future unsafe HTTP routes from untrusted origins by default", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://evil.attacker.example.com",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows future unsafe HTTP routes from trusted origins", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://127.0.0.1:8787",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import { isNonLocalBindHost } from "../options";
|
||||
|
||||
export interface BrowserRequestAuthOptions {
|
||||
bindHost: string;
|
||||
port: number;
|
||||
publicUrl: string;
|
||||
roomSecret?: string;
|
||||
}
|
||||
|
||||
const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
export type PublicBrowserRoutePredicate = (req: Request, url: URL) => boolean;
|
||||
|
||||
function isWebSocketUpgrade(req: Request): boolean {
|
||||
return req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
||||
}
|
||||
|
||||
function parseOrigin(value: string | null): string | undefined {
|
||||
const origin = parseHeader(value);
|
||||
try {
|
||||
return new URL(origin ?? "").origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHeader(value: string | null): string | undefined {
|
||||
const host = value?.trim().toLowerCase();
|
||||
return host || undefined;
|
||||
}
|
||||
|
||||
function formatHostForOrigin(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function isDefaultProtocolPort(protocol: string, port: number): boolean {
|
||||
return (
|
||||
(protocol === "http:" && port === 80) ||
|
||||
(protocol === "https:" && port === 443)
|
||||
);
|
||||
}
|
||||
|
||||
function originForHost(protocol: string, host: string, port: number): string {
|
||||
return new URL(`${protocol}//${formatHostForOrigin(host)}:${port}`).origin;
|
||||
}
|
||||
|
||||
function hostHeaderForHost(
|
||||
protocol: string,
|
||||
host: string,
|
||||
port: number,
|
||||
): string {
|
||||
const formattedHost = formatHostForOrigin(host).toLowerCase();
|
||||
return isDefaultProtocolPort(protocol, port)
|
||||
? formattedHost
|
||||
: `${formattedHost}:${port}`;
|
||||
}
|
||||
|
||||
export function allowedBrowserOrigins({
|
||||
bindHost,
|
||||
port,
|
||||
publicUrl,
|
||||
}: BrowserRequestAuthOptions): Set<string> {
|
||||
const publicUrlParts = new URL(publicUrl);
|
||||
const origins = new Set<string>();
|
||||
origins.add(publicUrlParts.origin);
|
||||
|
||||
origins.add(originForHost(publicUrlParts.protocol, bindHost, port));
|
||||
|
||||
if (!isNonLocalBindHost(bindHost)) {
|
||||
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
|
||||
origins.add(originForHost(publicUrlParts.protocol, hostname, port));
|
||||
}
|
||||
}
|
||||
|
||||
return origins;
|
||||
}
|
||||
|
||||
export function allowedBrowserHosts({
|
||||
bindHost,
|
||||
port,
|
||||
publicUrl,
|
||||
}: BrowserRequestAuthOptions): Set<string> {
|
||||
const publicUrlParts = new URL(publicUrl);
|
||||
const hosts = new Set<string>();
|
||||
const publicHost = publicUrlParts.host.toLowerCase();
|
||||
hosts.add(publicHost);
|
||||
|
||||
hosts.add(hostHeaderForHost(publicUrlParts.protocol, bindHost, port));
|
||||
|
||||
if (!isNonLocalBindHost(bindHost)) {
|
||||
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
|
||||
hosts.add(hostHeaderForHost(publicUrlParts.protocol, hostname, port));
|
||||
}
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
export function requiresBrowserRequestAuth(
|
||||
req: Request,
|
||||
url: URL,
|
||||
isPublicBrowserRoute: PublicBrowserRoutePredicate,
|
||||
): boolean {
|
||||
if (isWebSocketUpgrade(req)) return true;
|
||||
if (!SAFE_HTTP_METHODS.has(req.method.toUpperCase())) return true;
|
||||
return !isPublicBrowserRoute(req, url);
|
||||
}
|
||||
|
||||
export function isAuthorizedBrowserRequest(
|
||||
req: Request,
|
||||
url: URL,
|
||||
options: BrowserRequestAuthOptions,
|
||||
): boolean {
|
||||
const host = parseHeader(req.headers.get("host"));
|
||||
if (!host || !allowedBrowserHosts(options).has(host)) return false;
|
||||
|
||||
const origin = parseOrigin(req.headers.get("origin"));
|
||||
if (!origin || !allowedBrowserOrigins(options).has(origin)) return false;
|
||||
|
||||
if (!options.roomSecret) return true;
|
||||
return url.searchParams.get("roomSecret") === options.roomSecret;
|
||||
}
|
||||
|
||||
export function isAuthorizedBrowserToDesktopRequest(
|
||||
req: Request,
|
||||
url: URL,
|
||||
options: BrowserRequestAuthOptions,
|
||||
isPublicBrowserRoute: PublicBrowserRoutePredicate,
|
||||
): boolean {
|
||||
return (
|
||||
!requiresBrowserRequestAuth(req, url, isPublicBrowserRoute) ||
|
||||
isAuthorizedBrowserRequest(req, url, options)
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./connectors";
|
||||
|
||||
describe("connector launch command", () => {
|
||||
it("uses Bun conditions when launching the source CLI from Bun", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/Users/test/.bun/bin/bun",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "/Users/test/.bun/bin/bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"/repo/apps/cli/src/index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses compiled CLI subcommands without Bun flags", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/Applications/Cline/bin/cline",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "/Applications/Cline/bin/cline",
|
||||
childArgs: ["connect", "telegram", "--bot-token", "token"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Bun conditions when launching the source CLI from Node", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/usr/local/bin/node",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"/repo/apps/cli/src/index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("detects Windows Node when launching the source CLI", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "node.exe",
|
||||
cliPath: "C:\\repo\\apps\\cli\\src\\index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"C:\\repo\\apps\\cli\\src\\index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips terminal color codes from connector command failures", () => {
|
||||
expect(
|
||||
__test__.normalizeConnectorError(
|
||||
"\u001B[31merror:\u001B[0m error: unknown option '--conditions=development'",
|
||||
"connector start failed",
|
||||
),
|
||||
).toBe("unknown option '--conditions=development'");
|
||||
});
|
||||
|
||||
it("turns Telegram unauthorized responses into a token validation message", () => {
|
||||
expect(
|
||||
__test__.normalizeConnectorError(
|
||||
"\u001B[31merror:\u001B[0m Telegram getMe failed (401 Unauthorized): Unauthorized",
|
||||
"connector start failed",
|
||||
),
|
||||
).toBe(
|
||||
"Telegram rejected this bot token. Copy the token from @BotFather and try again.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import process from "node:process";
|
||||
import { withResolvedClineBuildEnv } from "@cline/shared";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import {
|
||||
PLATFORMS,
|
||||
shouldIncludeField,
|
||||
} from "../../../cli/src/wizards/connect/platforms";
|
||||
import type {
|
||||
WebviewConnectorChannel,
|
||||
WebviewConnectorChannelsResponse,
|
||||
} from "../webview-protocol";
|
||||
import { cliIndexPath, workspaceRoot } from "./deps";
|
||||
import { asRecord, asString } from "./utils";
|
||||
|
||||
type CliConnectCommand = {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
};
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(
|
||||
[
|
||||
"[\\u001B\\u009B][[\\]()#;?]*",
|
||||
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
|
||||
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
|
||||
].join(""),
|
||||
"g",
|
||||
);
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(ANSI_ESCAPE_PATTERN, "");
|
||||
}
|
||||
|
||||
function normalizeConnectorError(rawMessage: string, fallback: string): string {
|
||||
const message =
|
||||
stripAnsi(rawMessage)
|
||||
.replace(/\r\n/g, "\n")
|
||||
.trim()
|
||||
.replace(/^(?:error:\s*)+/i, "")
|
||||
.trim() || fallback;
|
||||
|
||||
if (
|
||||
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
|
||||
) {
|
||||
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
|
||||
}
|
||||
|
||||
return message.slice(0, 2_000);
|
||||
}
|
||||
|
||||
function buildCliConnectCommand(
|
||||
args: string[],
|
||||
options: {
|
||||
execPath?: string;
|
||||
cliPath?: string;
|
||||
exists?: (path: string) => boolean;
|
||||
} = {},
|
||||
): CliConnectCommand {
|
||||
const execPath = options.execPath ?? process.execPath;
|
||||
const cliPath = options.cliPath ?? cliIndexPath;
|
||||
const exists = options.exists ?? existsSync;
|
||||
const runtimeName = basename(execPath).toLowerCase();
|
||||
const isBunRuntime = runtimeName.includes("bun");
|
||||
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
|
||||
const useBunSourceEntrypoint =
|
||||
(isBunRuntime || isNodeRuntime) && exists(cliPath);
|
||||
const launcher = isBunRuntime
|
||||
? execPath
|
||||
: useBunSourceEntrypoint
|
||||
? "bun"
|
||||
: execPath;
|
||||
const childArgs = useBunSourceEntrypoint
|
||||
? ["--conditions=development", cliPath, "connect", ...args]
|
||||
: ["connect", ...args];
|
||||
return { launcher, childArgs };
|
||||
}
|
||||
|
||||
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
const available: WebviewConnectorChannel[] = PLATFORMS.filter((platform) =>
|
||||
supported.has(platform.id),
|
||||
).map((platform) => ({
|
||||
id: platform.id,
|
||||
name: platform.name,
|
||||
type: platform.type,
|
||||
hint: platform.hint,
|
||||
fields: platform.fields.map((field) => ({
|
||||
flag: field.flag,
|
||||
label: field.label,
|
||||
placeholder: field.placeholder,
|
||||
required: field.required,
|
||||
help: field.help,
|
||||
initialValue: field.initialValue,
|
||||
options: field.options,
|
||||
includeWhen: field.includeWhen,
|
||||
})),
|
||||
security: platform.security
|
||||
? {
|
||||
prompt: platform.security.prompt,
|
||||
fields: platform.security.fields.map((field) => ({
|
||||
key: field.key,
|
||||
label: field.label,
|
||||
placeholder: field.placeholder,
|
||||
help: field.help,
|
||||
requiredMessage: field.requiredMessage,
|
||||
})),
|
||||
}
|
||||
: undefined,
|
||||
}));
|
||||
return { available, active: listActiveConnectors() };
|
||||
}
|
||||
|
||||
async function runCliConnectCommand(args: string[]): Promise<{
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
const { launcher, childArgs } = buildCliConnectCommand(args);
|
||||
const child = spawn(launcher, childArgs, {
|
||||
cwd: workspaceRoot,
|
||||
env: withResolvedClineBuildEnv(process.env),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stderr?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
const code = await new Promise<number>((resolve, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("close", (exitCode) => resolve(exitCode ?? 0));
|
||||
});
|
||||
return { code, stdout, stderr };
|
||||
}
|
||||
|
||||
async function waitForConnectorState(
|
||||
predicate: () => boolean,
|
||||
timeoutMs = 5_000,
|
||||
): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (predicate()) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
}
|
||||
|
||||
function buildConnectorStartArgs(args?: Record<string, unknown>): string[] {
|
||||
const channel = asString(args?.channel);
|
||||
if (!channel) throw new Error("channel is required");
|
||||
const platform = PLATFORMS.find((entry) => entry.id === channel);
|
||||
if (!platform) throw new Error(`unknown connector channel: ${channel}`);
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
if (!supported.has(platform.id)) {
|
||||
throw new Error(`connector channel is not available: ${channel}`);
|
||||
}
|
||||
const values = asRecord(args?.values) ?? {};
|
||||
const fieldValues: Record<string, string> = {};
|
||||
for (const field of platform.fields) {
|
||||
const rawValue = values[field.flag];
|
||||
if (typeof rawValue === "string") {
|
||||
fieldValues[field.flag] = rawValue.trim();
|
||||
} else if (field.initialValue) {
|
||||
fieldValues[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
const cliArgs = [channel];
|
||||
for (const field of platform.fields) {
|
||||
if (!shouldIncludeField(field, fieldValues)) {
|
||||
continue;
|
||||
}
|
||||
const value = fieldValues[field.flag];
|
||||
if (!value) {
|
||||
if (field.required) throw new Error(`${field.label} is required`);
|
||||
continue;
|
||||
}
|
||||
cliArgs.push(field.flag, value);
|
||||
}
|
||||
const security = asRecord(args?.security);
|
||||
if (security?.enabled === true && platform.security) {
|
||||
const securityValues = asRecord(security.values) ?? {};
|
||||
const hookValues: Record<string, string> = {};
|
||||
for (const field of platform.security.fields) {
|
||||
const value = asString(securityValues[field.key]);
|
||||
if (!value) throw new Error(field.requiredMessage);
|
||||
const validationError = field.validate?.(value);
|
||||
if (validationError) throw new Error(validationError);
|
||||
hookValues[field.key] = value;
|
||||
}
|
||||
cliArgs.push(...platform.security.buildArgs(hookValues));
|
||||
}
|
||||
return cliArgs;
|
||||
}
|
||||
|
||||
export async function startConnectorChannel(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const cliArgs = buildConnectorStartArgs(args);
|
||||
const channel = cliArgs[0] ?? "";
|
||||
const result = await runCliConnectCommand(cliArgs);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector start failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(() =>
|
||||
listActiveConnectors().some((connector) => connector.type === channel),
|
||||
);
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
buildCliConnectCommand,
|
||||
normalizeConnectorError,
|
||||
};
|
||||
|
||||
export async function stopConnectorChannel(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
const channel = asString(args?.channel);
|
||||
if (!channel) throw new Error("channel is required");
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
);
|
||||
if (!supported.has(channel)) {
|
||||
throw new Error(`unknown connector channel: ${channel}`);
|
||||
}
|
||||
const result = await runCliConnectCommand([channel, "--stop"]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector stop failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(
|
||||
() =>
|
||||
!listActiveConnectors().some((connector) => connector.type === channel),
|
||||
);
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { dirname, join, normalize } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { ProviderSettingsManager } from "@cline/core";
|
||||
import { buildInviteUrl, resolveClineHubServerOptions } from "../options";
|
||||
import type { BrowserConfig } from "./types";
|
||||
|
||||
export const options = resolveClineHubServerOptions();
|
||||
export const { host, port, publicUrl, roomSecret, workspaceRoot } = options;
|
||||
export const inviteUrl = buildInviteUrl(publicUrl, roomSecret);
|
||||
|
||||
const serverDir = dirname(fileURLToPath(import.meta.url));
|
||||
/** server.ts lives one level up from this module, so resolve relative to it. */
|
||||
export const appSrcDir = join(serverDir, "..");
|
||||
export const webviewDistDir =
|
||||
process.env.CLINE_HUB_WEBVIEW_DIST_DIR?.trim() ||
|
||||
join(appSrcDir, "../dist/webview");
|
||||
export const cliIndexPath = normalize(
|
||||
join(appSrcDir, "../../cli/src/index.ts"),
|
||||
);
|
||||
|
||||
export const providerSettingsManager = new ProviderSettingsManager();
|
||||
|
||||
export const browserConfig: BrowserConfig = {
|
||||
inviteRequired: Boolean(roomSecret),
|
||||
publicUrl,
|
||||
};
|
||||
@@ -1,311 +0,0 @@
|
||||
import {
|
||||
addLocalProvider,
|
||||
type ClineAccountActionRequest,
|
||||
ClineAccountService,
|
||||
ensureCustomProvidersLoaded,
|
||||
executeClineAccountAction,
|
||||
formatProviderOAuthApiKey,
|
||||
getLocalProviderModels,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
getValidClineCredentials,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
type ProviderProtocol,
|
||||
type ProviderSettings,
|
||||
readGlobalSettings,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
toggleDisabledTool,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
stopConnectorChannel,
|
||||
} from "./connectors";
|
||||
import { providerSettingsManager, workspaceRoot } from "./deps";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
import {
|
||||
deleteMcpServer,
|
||||
ensureMcpSettingsFile,
|
||||
readMcpServersResponse,
|
||||
setMcpServerDisabled,
|
||||
upsertMcpServer,
|
||||
} from "./mcp";
|
||||
import { handleRoutineScheduleCommand } from "./schedules";
|
||||
import { toWebviewSessionSummary } from "./session-mapping";
|
||||
import type { HubContext } from "./state";
|
||||
import { broadcastHubState } from "./state-payloads";
|
||||
import type { JsonRecord } from "./types";
|
||||
import { listUserInstructionConfigs } from "./user-instructions";
|
||||
import { openExternalUrl, readProviderSettingsUpdate } from "./utils";
|
||||
|
||||
const ROUTINE_SCHEDULE_COMMANDS = new Set([
|
||||
"list_routine_schedules",
|
||||
"create_routine_schedule",
|
||||
"update_routine_schedule",
|
||||
"pause_routine_schedule",
|
||||
"resume_routine_schedule",
|
||||
"trigger_routine_schedule",
|
||||
"delete_routine_schedule",
|
||||
]);
|
||||
|
||||
async function resolveHubClineAccountAuthToken(input: {
|
||||
settings?: ProviderSettings;
|
||||
apiBaseUrl: string;
|
||||
}): Promise<string | undefined> {
|
||||
const credentials = input.settings
|
||||
? getProviderOAuthCredentialsFromSettings("cline", input.settings)
|
||||
: null;
|
||||
if (!credentials || !input.settings) {
|
||||
return getPersistedProviderApiKey("cline", input.settings);
|
||||
}
|
||||
|
||||
const nextCredentials = await getValidClineCredentials(credentials, {
|
||||
apiBaseUrl: input.apiBaseUrl,
|
||||
});
|
||||
if (!nextCredentials) {
|
||||
throw new Error(
|
||||
"Cline account requires re-authentication. Run cline auth cline.",
|
||||
);
|
||||
}
|
||||
|
||||
if (nextCredentials !== credentials) {
|
||||
saveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
"cline",
|
||||
input.settings,
|
||||
nextCredentials,
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
}
|
||||
|
||||
return formatProviderOAuthApiKey("cline", nextCredentials);
|
||||
}
|
||||
|
||||
export async function handleDesktopCommand(
|
||||
ctx: HubContext,
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
if (command === "list_provider_catalog") {
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
return await listLocalProviders(providerSettingsManager);
|
||||
}
|
||||
if (command === "list_provider_models") {
|
||||
const provider = String(args?.provider ?? "").trim();
|
||||
return await getLocalProviderModels(
|
||||
provider,
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
);
|
||||
}
|
||||
if (command === "save_provider_settings") {
|
||||
return saveLocalProviderSettings(providerSettingsManager, {
|
||||
...readProviderSettingsUpdate(args),
|
||||
providerId: String(args?.provider ?? ""),
|
||||
enabled: typeof args?.enabled === "boolean" ? args.enabled : undefined,
|
||||
apiKey: typeof args?.api_key === "string" ? args.api_key : undefined,
|
||||
baseUrl: typeof args?.base_url === "string" ? args.base_url : undefined,
|
||||
});
|
||||
}
|
||||
if (command === "add_provider") {
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
return await addLocalProvider(providerSettingsManager, {
|
||||
providerId: String(args?.provider_id ?? ""),
|
||||
name: String(args?.name ?? ""),
|
||||
baseUrl: String(args?.base_url ?? ""),
|
||||
apiKey: typeof args?.api_key === "string" ? args.api_key : undefined,
|
||||
headers:
|
||||
args?.headers && typeof args.headers === "object"
|
||||
? (args.headers as Record<string, string>)
|
||||
: undefined,
|
||||
timeoutMs:
|
||||
typeof args?.timeout_ms === "number" ? args.timeout_ms : undefined,
|
||||
models: Array.isArray(args?.models)
|
||||
? (args.models as string[])
|
||||
: undefined,
|
||||
defaultModelId:
|
||||
typeof args?.default_model_id === "string"
|
||||
? args.default_model_id
|
||||
: undefined,
|
||||
modelsSourceUrl:
|
||||
typeof args?.models_source_url === "string"
|
||||
? args.models_source_url
|
||||
: undefined,
|
||||
protocol:
|
||||
typeof args?.protocol === "string"
|
||||
? (args.protocol as ProviderProtocol)
|
||||
: undefined,
|
||||
client:
|
||||
typeof args?.client === "string"
|
||||
? (args.client as ProviderClient)
|
||||
: undefined,
|
||||
capabilities: Array.isArray(args?.capabilities)
|
||||
? (args.capabilities as ProviderCapability[])
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
if (command === "run_provider_oauth_login") {
|
||||
const providerId = normalizeOAuthProvider(String(args?.provider ?? ""));
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
openExternalUrl,
|
||||
);
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
};
|
||||
}
|
||||
if (command === "cline_account") {
|
||||
const settings = providerSettingsManager.getProviderSettings("cline");
|
||||
const apiBaseUrl =
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
|
||||
const authToken = await resolveHubClineAccountAuthToken({
|
||||
settings,
|
||||
apiBaseUrl,
|
||||
});
|
||||
if (!authToken) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
const accountService = new ClineAccountService({
|
||||
apiBaseUrl,
|
||||
getAuthToken: async () => authToken,
|
||||
});
|
||||
return await executeClineAccountAction(
|
||||
args as ClineAccountActionRequest,
|
||||
accountService,
|
||||
);
|
||||
}
|
||||
if (command === "get_global_settings") {
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "set_telemetry_opt_out") {
|
||||
if (typeof args?.telemetry_opt_out !== "boolean") {
|
||||
throw new Error("telemetry_opt_out must be a boolean");
|
||||
}
|
||||
setTelemetryOptOutGlobally(args.telemetry_opt_out);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "set_auto_update_enabled") {
|
||||
if (typeof args?.auto_update_enabled !== "boolean") {
|
||||
throw new Error("auto_update_enabled must be a boolean");
|
||||
}
|
||||
setAutoUpdateEnabledGlobally(args.auto_update_enabled);
|
||||
return readGlobalSettings();
|
||||
}
|
||||
if (command === "list_connector_channels") {
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
if (command === "start_connector_channel") {
|
||||
const response = await startConnectorChannel(args);
|
||||
broadcastHubState(ctx);
|
||||
return response;
|
||||
}
|
||||
if (command === "stop_connector_channel") {
|
||||
const response = await stopConnectorChannel(args);
|
||||
broadcastHubState(ctx);
|
||||
return response;
|
||||
}
|
||||
if (command === "list_mcp_servers") {
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
if (command === "set_mcp_server_disabled") {
|
||||
return setMcpServerDisabled(
|
||||
String(args?.name ?? "").trim(),
|
||||
Boolean(args?.disabled),
|
||||
);
|
||||
}
|
||||
if (command === "upsert_mcp_server") {
|
||||
const input =
|
||||
args?.input && typeof args.input === "object"
|
||||
? (args.input as JsonRecord)
|
||||
: ((args ?? {}) as JsonRecord);
|
||||
return upsertMcpServer(input);
|
||||
}
|
||||
if (command === "delete_mcp_server") {
|
||||
return deleteMcpServer(String(args?.name ?? "").trim());
|
||||
}
|
||||
if (command === "ensure_mcp_settings_file") {
|
||||
return ensureMcpSettingsFile();
|
||||
}
|
||||
if (command === "open_mcp_settings_file") {
|
||||
const path = ensureMcpSettingsFile();
|
||||
openExternalUrl(path);
|
||||
return path;
|
||||
}
|
||||
if (ROUTINE_SCHEDULE_COMMANDS.has(command)) {
|
||||
return await handleRoutineScheduleCommand(command, args);
|
||||
}
|
||||
if (command === "get_process_context") {
|
||||
return { workspaceRoot, cwd: workspaceRoot };
|
||||
}
|
||||
if (
|
||||
command === "list_cli_sessions" ||
|
||||
command === "list_discovered_sessions"
|
||||
) {
|
||||
return [...ctx.sessions.values()].map(toWebviewSessionSummary);
|
||||
}
|
||||
if (command === "read_session_hooks") {
|
||||
return [];
|
||||
}
|
||||
if (command === "list_user_instruction_configs") {
|
||||
return await listUserInstructionConfigs(workspaceRoot);
|
||||
}
|
||||
if (command === "list_marketplace_installed_entries") {
|
||||
return listMarketplaceInstalledEntries(
|
||||
args,
|
||||
await listUserInstructionConfigs(workspaceRoot),
|
||||
);
|
||||
}
|
||||
if (command === "install_marketplace_entry") {
|
||||
const result = await installMarketplaceEntryForDesktopCommand(args);
|
||||
broadcastHubState(ctx);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_marketplace_entry") {
|
||||
const result = await uninstallMarketplaceEntryForDesktopCommand(args);
|
||||
broadcastHubState(ctx);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_local_primitive") {
|
||||
const result = await uninstallLocalPrimitive(args, { workspaceRoot });
|
||||
broadcastHubState(ctx);
|
||||
return result;
|
||||
}
|
||||
if (command === "toggle_disabled_plugin_tool") {
|
||||
const toolName = String(args?.name ?? "").trim();
|
||||
if (!toolName) throw new Error("tool name is required");
|
||||
toggleDisabledTool(toolName);
|
||||
return await listUserInstructionConfigs(workspaceRoot);
|
||||
}
|
||||
if (command === "set_tool_disabled") {
|
||||
const rawNames = Array.isArray(args?.names) ? args.names : [args?.name];
|
||||
const toolNames = rawNames
|
||||
.map((name) => String(name ?? "").trim())
|
||||
.filter(Boolean);
|
||||
if (toolNames.length === 0) throw new Error("tool name is required");
|
||||
setDisabledTools(toolNames, args?.disabled === true);
|
||||
return await listUserInstructionConfigs(workspaceRoot);
|
||||
}
|
||||
if (command === "set_plugin_disabled") {
|
||||
const pluginPath = String(args?.path ?? "").trim();
|
||||
if (!pluginPath) throw new Error("plugin path is required");
|
||||
setDisabledPlugin(pluginPath, args?.disabled === true);
|
||||
return await listUserInstructionConfigs(workspaceRoot);
|
||||
}
|
||||
throw new Error(`unsupported desktop command: ${command}`);
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isWebviewRoute, normalizeWebviewIndexHtml } from "./http";
|
||||
|
||||
describe("isWebviewRoute", () => {
|
||||
it.each([
|
||||
"/",
|
||||
"/chat",
|
||||
"/sessions",
|
||||
"/models",
|
||||
"/customizations",
|
||||
"/rules",
|
||||
"/hooks",
|
||||
"/mcp",
|
||||
"/plugins",
|
||||
"/skills",
|
||||
"/agents",
|
||||
"/tools",
|
||||
"/marketplace",
|
||||
"/marketplace/mcp",
|
||||
"/marketplace/skills",
|
||||
"/marketplace/plugins",
|
||||
"/channels",
|
||||
"/schedules",
|
||||
"/settings",
|
||||
"/settings/providers",
|
||||
])("matches dashboard SPA route %s", (pathname) => {
|
||||
expect(isWebviewRoute(pathname)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat nested marketplace asset requests as SPA routes", () => {
|
||||
expect(isWebviewRoute("/marketplace/assets/index.js")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeWebviewIndexHtml", () => {
|
||||
it("rewrites relative built asset URLs so deep links can refresh", () => {
|
||||
expect(
|
||||
normalizeWebviewIndexHtml(
|
||||
'<script type="module" src="./assets/index.js"></script><link href="./assets/index.css">',
|
||||
),
|
||||
).toBe(
|
||||
'<script type="module" src="/assets/index.js"></script><link href="/assets/index.css">',
|
||||
);
|
||||
});
|
||||
|
||||
it("injects the persisted theme bootstrap once", () => {
|
||||
const normalized = normalizeWebviewIndexHtml(
|
||||
"<html><head></head><body></body></html>",
|
||||
);
|
||||
|
||||
expect(normalized).toContain('id="cline-hub-theme-bootstrap"');
|
||||
expect(normalizeWebviewIndexHtml(normalized)).toBe(normalized);
|
||||
});
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
import { extname, join, normalize, relative } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
export function createJsonResponse(payload: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
export function createTextResponse(text: string, status = 200): Response {
|
||||
return new Response(text, {
|
||||
status,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
const NO_STORE_HEADERS = {
|
||||
"cache-control": "no-store, no-cache, must-revalidate, proxy-revalidate",
|
||||
pragma: "no-cache",
|
||||
expires: "0",
|
||||
};
|
||||
|
||||
const IMMUTABLE_ASSET_CACHE = "public, max-age=31536000, immutable";
|
||||
const THEME_BOOTSTRAP_SCRIPT = `<script id="cline-hub-theme-bootstrap">
|
||||
(() => {
|
||||
try {
|
||||
const theme = window.localStorage.getItem("cline-hub-theme");
|
||||
if (theme === "dark" || theme === "light") {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
document.documentElement.dataset.clineHubTheme = theme;
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
function contentTypeFor(path: string): string {
|
||||
switch (extname(path)) {
|
||||
case ".html":
|
||||
return "text/html; charset=utf-8";
|
||||
case ".js":
|
||||
return "text/javascript; charset=utf-8";
|
||||
case ".css":
|
||||
return "text/css; charset=utf-8";
|
||||
case ".svg":
|
||||
return "image/svg+xml";
|
||||
case ".png":
|
||||
return "image/png";
|
||||
case ".ico":
|
||||
return "image/x-icon";
|
||||
case ".woff2":
|
||||
return "font/woff2";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
export function isWebviewRoute(pathname: string): boolean {
|
||||
return (
|
||||
pathname === "/" ||
|
||||
pathname === "/index.html" ||
|
||||
pathname === "/chat" ||
|
||||
pathname === "/sessions" ||
|
||||
pathname === "/models" ||
|
||||
pathname === "/customizations" ||
|
||||
pathname === "/rules" ||
|
||||
pathname === "/hooks" ||
|
||||
pathname === "/mcp" ||
|
||||
pathname === "/plugins" ||
|
||||
pathname === "/skills" ||
|
||||
pathname === "/agents" ||
|
||||
pathname === "/tools" ||
|
||||
pathname === "/marketplace" ||
|
||||
pathname === "/marketplace/mcp" ||
|
||||
pathname === "/marketplace/skills" ||
|
||||
pathname === "/marketplace/plugins" ||
|
||||
pathname === "/channels" ||
|
||||
pathname === "/schedules" ||
|
||||
pathname === "/settings" ||
|
||||
pathname.startsWith("/settings/")
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeWebviewIndexHtml(html: string): string {
|
||||
const normalized = html
|
||||
.replaceAll('src="./', 'src="/')
|
||||
.replaceAll('href="./', 'href="/');
|
||||
if (normalized.includes('id="cline-hub-theme-bootstrap"')) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.replace("<head>", `<head>\n${THEME_BOOTSTRAP_SCRIPT}`);
|
||||
}
|
||||
|
||||
function renderDevIndexHtml(devServerUrl: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
${THEME_BOOTSTRAP_SCRIPT}
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script type="module">
|
||||
import RefreshRuntime from "${devServerUrl}/@react-refresh";
|
||||
RefreshRuntime.injectIntoGlobalHook(window);
|
||||
window.$RefreshReg$ = () => {};
|
||||
window.$RefreshSig$ = () => (type) => type;
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
</script>
|
||||
<script type="module" src="${devServerUrl}/@vite/client"></script>
|
||||
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/cline-logo-filled.svg" />
|
||||
<title>Cline Hub</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="${devServerUrl}/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/** Serves the built webview SPA and its static assets out of `webviewDistDir`. */
|
||||
export class WebviewAssets {
|
||||
constructor(private readonly webviewDistDir: string) {}
|
||||
|
||||
private async resolveCurrentMainAssetPath(): Promise<string | undefined> {
|
||||
const indexFile = Bun.file(join(this.webviewDistDir, "index.html"));
|
||||
if (!(await indexFile.exists())) return undefined;
|
||||
const html = await indexFile.text();
|
||||
const match = html.match(/src="\.\/(assets\/index-[^"]+\.js)"/);
|
||||
return match?.[1] ? join(this.webviewDistDir, match[1]) : undefined;
|
||||
}
|
||||
|
||||
private resolveStaticPath(pathname: string): string | undefined {
|
||||
const decoded = decodeURIComponent(pathname);
|
||||
const requested = decoded === "/" ? "/index.html" : decoded;
|
||||
const normalized = normalize(requested).replace(/^(\.\.[/\\])+/, "");
|
||||
const relativePath = normalized.replace(/^[/\\]+/, "");
|
||||
const filePath = join(this.webviewDistDir, relativePath);
|
||||
if (relative(this.webviewDistDir, filePath).startsWith("..")) {
|
||||
return undefined;
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
private async serveIndex(): Promise<Response> {
|
||||
const indexFile = Bun.file(join(this.webviewDistDir, "index.html"));
|
||||
if (await indexFile.exists()) {
|
||||
return new Response(normalizeWebviewIndexHtml(await indexFile.text()), {
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
...NO_STORE_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
return createTextResponse(
|
||||
"Cline Hub webview is not built. Run `bun run build:webview` from apps/cline-hub.",
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
async serve(pathname: string): Promise<Response> {
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
|
||||
if (devServerUrl && isWebviewRoute(pathname)) {
|
||||
return new Response(renderDevIndexHtml(devServerUrl), {
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
...NO_STORE_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (isWebviewRoute(pathname)) {
|
||||
return this.serveIndex();
|
||||
}
|
||||
|
||||
const filePath = this.resolveStaticPath(pathname);
|
||||
if (!filePath) return createTextResponse("not found", 404);
|
||||
let responsePath = filePath;
|
||||
let file = Bun.file(responsePath);
|
||||
if (
|
||||
!(await file.exists()) &&
|
||||
/^\/assets\/index-[A-Za-z0-9_-]+\.js$/.test(pathname)
|
||||
) {
|
||||
const currentMainAssetPath = await this.resolveCurrentMainAssetPath();
|
||||
if (currentMainAssetPath) {
|
||||
responsePath = currentMainAssetPath;
|
||||
file = Bun.file(responsePath);
|
||||
}
|
||||
}
|
||||
if (!(await file.exists())) {
|
||||
return createTextResponse("not found", 404);
|
||||
}
|
||||
const isHashedAsset = /^\/assets\/.+-[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/.test(
|
||||
pathname,
|
||||
);
|
||||
return new Response(file, {
|
||||
headers: {
|
||||
"content-type": contentTypeFor(responsePath),
|
||||
"cache-control": isHashedAsset
|
||||
? IMMUTABLE_ASSET_CACHE
|
||||
: NO_STORE_HEADERS["cache-control"],
|
||||
...(isHashedAsset
|
||||
? {}
|
||||
: {
|
||||
pragma: NO_STORE_HEADERS.pragma,
|
||||
expires: NO_STORE_HEADERS.expires,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
import {
|
||||
ClineCore,
|
||||
ensureDetachedHubServer,
|
||||
type HubServerDiscoveryRecord,
|
||||
HubUIClient,
|
||||
stopLocalHubServerGracefully,
|
||||
toHubHealthUrl,
|
||||
} from "@cline/core";
|
||||
import type { HubUINotifyPayload } from "@cline/shared";
|
||||
import { handleSessionEvent } from "./agent-events";
|
||||
import {
|
||||
rejectAllPendingApprovals,
|
||||
requestToolApprovalFromWebview,
|
||||
} from "./approvals";
|
||||
import { workspaceRoot } from "./deps";
|
||||
import {
|
||||
formatClientName,
|
||||
formatSessionCreator,
|
||||
parseSessionContext,
|
||||
trackSession,
|
||||
} from "./session-mapping";
|
||||
import type { HubContext } from "./state";
|
||||
import { broadcastHubState } from "./state-payloads";
|
||||
import type { SessionContext } from "./types";
|
||||
import { asString, basename, isActiveSession, isVisibleClient } from "./utils";
|
||||
|
||||
export async function syncHubHealth(ctx: HubContext): Promise<void> {
|
||||
if (!ctx.hubUrl) {
|
||||
ctx.hubHealthy = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(toHubHealthUrl(ctx.hubUrl));
|
||||
if (!response.ok) {
|
||||
ctx.hubHealthy = false;
|
||||
return;
|
||||
}
|
||||
ctx.hubHealthy = true;
|
||||
const health = (await response.json()) as Partial<HubServerDiscoveryRecord>;
|
||||
if (typeof health.startedAt === "string")
|
||||
ctx.hubStartedAt = health.startedAt;
|
||||
if (typeof health.coreVersion === "string") {
|
||||
ctx.coreVersion = health.coreVersion;
|
||||
}
|
||||
} catch {
|
||||
ctx.hubHealthy = false;
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncHubClientsAndSessions(
|
||||
ctx: HubContext,
|
||||
): Promise<void> {
|
||||
if (!ctx.uiClient) return;
|
||||
const [knownClients, knownSessions] = await Promise.all([
|
||||
ctx.uiClient.listClients(),
|
||||
ctx.uiClient.listSessions(10),
|
||||
]);
|
||||
ctx.clients.clear();
|
||||
for (const client of knownClients) {
|
||||
if (!client.clientId || !isVisibleClient(client.clientType)) continue;
|
||||
ctx.clients.set(client.clientId, {
|
||||
clientId: client.clientId,
|
||||
displayName: client.displayName,
|
||||
clientType: client.clientType,
|
||||
connectedAt: client.connectedAt,
|
||||
});
|
||||
}
|
||||
ctx.sessions.clear();
|
||||
for (const session of knownSessions) {
|
||||
const tracked = trackSession(session);
|
||||
if (tracked) ctx.sessions.set(tracked.sessionId, tracked);
|
||||
}
|
||||
if (!ctx.initialHubEventEmitted) {
|
||||
const activeSessionCount = [...ctx.sessions.values()].filter((session) =>
|
||||
isActiveSession(session.title, session.status, session.participantCount),
|
||||
).length;
|
||||
ctx.pushEvent(
|
||||
"Hub monitor connected",
|
||||
`${ctx.clients.size} connected client${ctx.clients.size === 1 ? "" : "s"}, ${activeSessionCount} active session${activeSessionCount === 1 ? "" : "s"}`,
|
||||
"success",
|
||||
);
|
||||
ctx.initialHubEventEmitted = true;
|
||||
}
|
||||
const mostRecent = [...knownSessions]
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.map((s) => parseSessionContext(s))
|
||||
.find((c): c is SessionContext => Boolean(c));
|
||||
if (mostRecent) ctx.lastSessionContext = mostRecent;
|
||||
}
|
||||
|
||||
export async function attachHub(ctx: HubContext): Promise<void> {
|
||||
const hub = await ensureDetachedHubServer(workspaceRoot);
|
||||
ctx.hubUrl = hub.url;
|
||||
ctx.hubAuthToken = hub.authToken;
|
||||
|
||||
ctx.cline = await ClineCore.create({
|
||||
clientName: "cline-hub",
|
||||
backendMode: "hub",
|
||||
capabilities: {
|
||||
requestToolApproval: (request) =>
|
||||
requestToolApprovalFromWebview(ctx, request),
|
||||
},
|
||||
hub: {
|
||||
endpoint: ctx.hubUrl,
|
||||
authToken: ctx.hubAuthToken,
|
||||
clientType: "cline-hub-chat",
|
||||
displayName: "Cline Hub Chat",
|
||||
workspaceRoot,
|
||||
},
|
||||
});
|
||||
|
||||
ctx.uiClient = new HubUIClient({
|
||||
address: ctx.hubUrl,
|
||||
authToken: ctx.hubAuthToken,
|
||||
clientType: "cline-hub-server",
|
||||
displayName: "Cline Hub Server",
|
||||
});
|
||||
await ctx.uiClient.connect();
|
||||
|
||||
ctx.uiClient.subscribeUI({
|
||||
onNotify(payload: HubUINotifyPayload) {
|
||||
ctx.pushEvent(
|
||||
payload.title,
|
||||
payload.body,
|
||||
payload.severity === "error"
|
||||
? "error"
|
||||
: payload.severity === "warning"
|
||||
? "warn"
|
||||
: "info",
|
||||
);
|
||||
ctx.broadcast({
|
||||
type: "notification",
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
severity: payload.severity ?? "info",
|
||||
});
|
||||
},
|
||||
onClientRegistered(payload) {
|
||||
const clientId = asString(payload.clientId);
|
||||
const clientType = asString(payload.clientType) ?? "unknown";
|
||||
if (!clientId || !isVisibleClient(clientType)) return;
|
||||
ctx.clients.set(clientId, {
|
||||
clientId,
|
||||
displayName: asString(payload.displayName),
|
||||
clientType,
|
||||
connectedAt: Date.now(),
|
||||
});
|
||||
ctx.pushEvent(
|
||||
"Client connected",
|
||||
`${asString(payload.displayName) ?? clientType} joined the hub`,
|
||||
"success",
|
||||
);
|
||||
broadcastHubState(ctx);
|
||||
},
|
||||
onClientDisconnected(payload) {
|
||||
const clientId = asString(payload.clientId);
|
||||
if (!clientId) return;
|
||||
const client = ctx.clients.get(clientId);
|
||||
ctx.clients.delete(clientId);
|
||||
if (client) {
|
||||
ctx.pushEvent(
|
||||
"Client disconnected",
|
||||
`${formatClientName(client)} left the hub`,
|
||||
"info",
|
||||
);
|
||||
}
|
||||
broadcastHubState(ctx);
|
||||
},
|
||||
onSessionCreated(payload) {
|
||||
const record =
|
||||
payload.session && typeof payload.session === "object"
|
||||
? (payload.session as Record<string, unknown>)
|
||||
: (payload as unknown as Record<string, unknown>);
|
||||
const tracked = trackSession(record);
|
||||
if (tracked) {
|
||||
ctx.sessions.set(tracked.sessionId, tracked);
|
||||
const context = parseSessionContext(record);
|
||||
if (context) ctx.lastSessionContext = context;
|
||||
ctx.pushEvent(
|
||||
"Session started",
|
||||
`By ${formatSessionCreator(ctx, tracked)} at ${basename(tracked.workspaceRoot || tracked.cwd)}`,
|
||||
"success",
|
||||
);
|
||||
broadcastHubState(ctx);
|
||||
}
|
||||
},
|
||||
onSessionUpdated(payload) {
|
||||
const record =
|
||||
payload.session && typeof payload.session === "object"
|
||||
? (payload.session as Record<string, unknown>)
|
||||
: (payload as unknown as Record<string, unknown>);
|
||||
const tracked = trackSession(record);
|
||||
if (tracked) {
|
||||
ctx.sessions.set(tracked.sessionId, tracked);
|
||||
const context = parseSessionContext(record);
|
||||
if (context) ctx.lastSessionContext = context;
|
||||
broadcastHubState(ctx);
|
||||
}
|
||||
},
|
||||
onSessionDetached(payload) {
|
||||
const sessionId =
|
||||
asString((payload as Record<string, unknown>).sessionId) ??
|
||||
asString(
|
||||
(
|
||||
(payload as Record<string, unknown>).session as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.sessionId,
|
||||
);
|
||||
if (sessionId) {
|
||||
ctx.sessions.delete(sessionId);
|
||||
broadcastHubState(ctx);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
ctx.cline.subscribe((event) => handleSessionEvent(ctx, event));
|
||||
|
||||
await syncHubClientsAndSessions(ctx);
|
||||
await syncHubHealth(ctx);
|
||||
}
|
||||
|
||||
export async function detachHub(ctx: HubContext): Promise<void> {
|
||||
rejectAllPendingApprovals(
|
||||
ctx,
|
||||
"Hub disconnected before approval was resolved.",
|
||||
);
|
||||
for (const peer of ctx.peers) {
|
||||
peer.unsubscribeEvents?.();
|
||||
peer.unsubscribeEvents = undefined;
|
||||
}
|
||||
try {
|
||||
ctx.uiClient?.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
ctx.uiClient = undefined;
|
||||
try {
|
||||
await ctx.cline?.dispose();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
ctx.cline = undefined;
|
||||
ctx.clients.clear();
|
||||
ctx.sessions.clear();
|
||||
ctx.hubStartedAt = undefined;
|
||||
ctx.coreVersion = undefined;
|
||||
ctx.initialHubEventEmitted = false;
|
||||
}
|
||||
|
||||
export async function restartHub(ctx: HubContext): Promise<void> {
|
||||
ctx.broadcast({
|
||||
type: "notification",
|
||||
title: "Hub restarting",
|
||||
body: "Shutting down and respawning hub...",
|
||||
severity: "warn",
|
||||
});
|
||||
await detachHub(ctx);
|
||||
try {
|
||||
await stopLocalHubServerGracefully();
|
||||
} catch (error) {
|
||||
console.warn("stopLocalHubServerGracefully failed:", error);
|
||||
}
|
||||
await attachHub(ctx);
|
||||
broadcastHubState(ctx);
|
||||
ctx.broadcast({
|
||||
type: "notification",
|
||||
title: "Hub restarted",
|
||||
body: `Connected to ${ctx.hubUrl}`,
|
||||
severity: "info",
|
||||
});
|
||||
}
|
||||
@@ -1,870 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMarketplaceMcpInput,
|
||||
fetchMarketplaceCatalog,
|
||||
installMarketplaceEntry,
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntry,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
|
||||
describe("marketplace installer", () => {
|
||||
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
|
||||
const originalClineDir = process.env.CLINE_DIR;
|
||||
const originalHome = process.env.HOME;
|
||||
const originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
|
||||
}
|
||||
if (originalClineDir === undefined) {
|
||||
delete process.env.CLINE_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DIR = originalClineDir;
|
||||
}
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
if (originalMcpSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("maps remote MCP catalog args to the hub MCP upsert shape", () => {
|
||||
expect(
|
||||
buildMarketplaceMcpInput([
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
]),
|
||||
).toEqual({
|
||||
name: "context7",
|
||||
transportType: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps stdio MCP catalog args to command and args", () => {
|
||||
expect(
|
||||
buildMarketplaceMcpInput(["filesystem", "npx", "-y", "server", "/tmp"]),
|
||||
).toEqual({
|
||||
name: "filesystem",
|
||||
transportType: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "server", "/tmp"],
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves server flags after stdio MCP command args begin", () => {
|
||||
expect(
|
||||
buildMarketplaceMcpInput([
|
||||
"search",
|
||||
"npx",
|
||||
"-y",
|
||||
"server",
|
||||
"--transport",
|
||||
"stdio",
|
||||
]),
|
||||
).toEqual({
|
||||
name: "search",
|
||||
transportType: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "server", "--transport", "stdio"],
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("runs skills globally for Cline without prompts", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => {
|
||||
mkdirSync(join(homeDir, ".agents", "skills", "web-design-guidelines"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(homeDir, ".agents", "skills", "web-design-guidelines", "SKILL.md"),
|
||||
"---\nname: web-design-guidelines\n---\n",
|
||||
);
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "installed",
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
await installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "web-design-guidelines",
|
||||
type: "skill",
|
||||
name: "Web Design Guidelines",
|
||||
install: {
|
||||
args: [
|
||||
"vercel-labs/agent-skills",
|
||||
"--skill",
|
||||
"web-design-guidelines",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"add",
|
||||
"vercel-labs/agent-skills",
|
||||
"--skill",
|
||||
"web-design-guidelines",
|
||||
"-g",
|
||||
"-a",
|
||||
"cline",
|
||||
"-y",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips skill install commands when the global skill already exists", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
mkdirSync(join(homeDir, ".agents", "skills", "cline-sdk"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(homeDir, ".agents", "skills", "cline-sdk", "SKILL.md"),
|
||||
"---\nname: cline-sdk\n---\n",
|
||||
);
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "installed",
|
||||
message: "Cline SDK is already installed.",
|
||||
});
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports Cline global skills as marketplace-installed", () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-cline-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
|
||||
"---\nname: cline-sdk\n---\n",
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: ["skill:cline-sdk"] });
|
||||
});
|
||||
|
||||
it("accepts skill installs that create Cline global skills", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
const clineDir = join(homeDir, ".cline");
|
||||
process.env.HOME = homeDir;
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const spawnCommand = vi.fn(async () => {
|
||||
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
|
||||
"---\nname: cline-sdk\n---\n",
|
||||
);
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "installed",
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Cline SDK globally for Cline.",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes Cline global marketplace skills without prompts", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const skillDir = join(homeDir, ".agents", "skills", "cline-sdk");
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: cline-sdk\n---\n");
|
||||
const spawnCommand = vi.fn(async () => {
|
||||
rmSync(skillDir, { recursive: true, force: true });
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "removed",
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
uninstallMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Cline SDK.",
|
||||
});
|
||||
expect(spawnCommand).toHaveBeenCalledWith("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"cline-sdk",
|
||||
"-g",
|
||||
"-a",
|
||||
"cline",
|
||||
"-y",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not report project-local skills as marketplace-installed globals", () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries(
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
skills: [
|
||||
{
|
||||
id: "cline-sdk",
|
||||
name: "cline-sdk",
|
||||
path: "/workspace/project/.agents/skills/cline-sdk/SKILL.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
).toEqual({ installedKeys: [] });
|
||||
});
|
||||
|
||||
it("rejects skill installs that exit zero but report failure", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "Failed to install 1",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow("Skill install failed");
|
||||
});
|
||||
|
||||
it("redacts common secret formats from failed install output", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 1,
|
||||
stdout:
|
||||
"Authorization: Bearer stdout-token\napi key stdout-key\nOPENAI_API_KEY=compound-key",
|
||||
stderr:
|
||||
"TOKEN=stderr-token\npassword is stderr-password\nANTHROPIC_SECRET_KEY=anthropic-secret",
|
||||
}));
|
||||
|
||||
let message = "";
|
||||
try {
|
||||
await installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
);
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
expect(message).toContain("Authorization: [redacted]");
|
||||
expect(message).toContain("api key [redacted]");
|
||||
expect(message).toContain("OPENAI_API_KEY=[redacted]");
|
||||
expect(message).toContain("TOKEN=[redacted]");
|
||||
expect(message).toContain("password is [redacted]");
|
||||
expect(message).toContain("ANTHROPIC_SECRET_KEY=[redacted]");
|
||||
expect(message).not.toContain("stdout-token");
|
||||
expect(message).not.toContain("stdout-key");
|
||||
expect(message).not.toContain("compound-key");
|
||||
expect(message).not.toContain("stderr-token");
|
||||
expect(message).not.toContain("stderr-password");
|
||||
expect(message).not.toContain("anthropic-secret");
|
||||
});
|
||||
|
||||
it("rejects skill installs before spawning when the global skill directory is not writable", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
mkdirSync(join(homeDir, ".agents"), { recursive: true });
|
||||
writeFileSync(join(homeDir, ".agents", "skills"), "");
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Cannot install skill globally because ~/.agents/skills is not writable",
|
||||
);
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects skill installs that do not create a global skill", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "Installation complete",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow("was not found in Cline's global skills directories");
|
||||
});
|
||||
|
||||
it("runs official plugin installs through the current Cline CLI", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "marketplace-test-plugin",
|
||||
type: "plugin",
|
||||
name: "Marketplace Test Plugin",
|
||||
install: { args: ["marketplace-test-plugin"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"plugin",
|
||||
"install",
|
||||
"marketplace-test-plugin",
|
||||
"--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs official plugin uninstalls through the current Cline CLI", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({
|
||||
name: "goal",
|
||||
installPath: "/tmp/plugin",
|
||||
removedPaths: ["/tmp/plugin"],
|
||||
entryPaths: [],
|
||||
}),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
uninstallMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Goal.",
|
||||
});
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"plugin",
|
||||
"uninstall",
|
||||
"goal",
|
||||
"--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves desktop installs from the server catalog instead of browser-sent args", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await installMarketplaceEntryForDesktopCommand(
|
||||
{
|
||||
entry: {
|
||||
id: "marketplace-test-plugin",
|
||||
type: "plugin",
|
||||
name: "Tampered",
|
||||
install: { args: ["malicious-source"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
spawnCommand,
|
||||
loadCatalog: async () => ({
|
||||
entries: [
|
||||
{
|
||||
id: "marketplace-test-plugin",
|
||||
type: "plugin",
|
||||
name: "Marketplace Test Plugin",
|
||||
install: { args: ["marketplace-test-plugin"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"plugin",
|
||||
"install",
|
||||
"marketplace-test-plugin",
|
||||
"--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves desktop uninstalls from the server catalog instead of browser-sent args", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({
|
||||
name: "goal",
|
||||
installPath: "/tmp/plugin",
|
||||
removedPaths: ["/tmp/plugin"],
|
||||
entryPaths: [],
|
||||
}),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await uninstallMarketplaceEntryForDesktopCommand(
|
||||
{
|
||||
entry: {
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Tampered",
|
||||
install: { args: ["malicious-source"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
spawnCommand,
|
||||
loadCatalog: async () => ({
|
||||
entries: [
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"plugin",
|
||||
"uninstall",
|
||||
"goal",
|
||||
"--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uninstalls MCP marketplace entries from Cline MCP settings", async () => {
|
||||
const settingsPath = join(
|
||||
mkdtempSync(join(tmpdir(), "cline-marketplace-mcp-")),
|
||||
"cline_mcp_settings.json",
|
||||
);
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
context7: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uninstallMarketplaceEntry({
|
||||
entry: {
|
||||
id: "context7",
|
||||
type: "mcp",
|
||||
name: "Context7",
|
||||
install: {
|
||||
args: [
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Context7.",
|
||||
});
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "context7",
|
||||
type: "mcp",
|
||||
name: "Context7",
|
||||
install: {
|
||||
args: [
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: [] });
|
||||
});
|
||||
|
||||
it("uninstalls local MCP servers by name", async () => {
|
||||
const settingsPath = join(
|
||||
mkdtempSync(join(tmpdir(), "cline-local-mcp-")),
|
||||
"cline_mcp_settings.json",
|
||||
);
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
context7: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uninstallLocalPrimitive({
|
||||
type: "mcp",
|
||||
id: "context7",
|
||||
name: "context7",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled context7.",
|
||||
});
|
||||
expect(readFileSync(settingsPath, "utf8")).not.toContain("context7");
|
||||
});
|
||||
|
||||
it("uninstalls local skills by removing their configured skill directory", async () => {
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-local-skill-"));
|
||||
const skillDir = join(workspaceRoot, ".cline", "skills", "review");
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
const skillPath = join(skillDir, "SKILL.md");
|
||||
writeFileSync(skillPath, "---\nname: review\n---\nReview changes.");
|
||||
|
||||
await expect(
|
||||
uninstallLocalPrimitive(
|
||||
{
|
||||
type: "skill",
|
||||
id: "review",
|
||||
name: "Review",
|
||||
path: skillPath,
|
||||
},
|
||||
{ workspaceRoot },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Review.",
|
||||
});
|
||||
expect(existsSync(skillDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports official plugin marketplace entries installed from Cline home", () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const sourceKey =
|
||||
"official:https://github.com/cline/plugins.git#plugins/goal";
|
||||
const hash = createHash("sha256")
|
||||
.update(sourceKey)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
mkdirSync(
|
||||
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: ["plugin:goal"] });
|
||||
});
|
||||
|
||||
it("does not report plugin inventory substring matches as installed", () => {
|
||||
process.env.CLINE_DIR = mkdtempSync(
|
||||
join(tmpdir(), "cline-marketplace-test-"),
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries(
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
plugins: [
|
||||
{
|
||||
name: "goal-helper",
|
||||
path: "/workspace/.cline/plugins/goal-helper/index.ts",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
).toEqual({ installedKeys: [] });
|
||||
});
|
||||
|
||||
it("skips invalid marketplace entries during installed-status checks", () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const sourceKey =
|
||||
"official:https://github.com/cline/plugins.git#plugins/goal";
|
||||
const hash = createHash("sha256")
|
||||
.update(sourceKey)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
mkdirSync(
|
||||
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "broken-mcp",
|
||||
type: "mcp",
|
||||
name: "Broken MCP",
|
||||
install: {
|
||||
args: [
|
||||
"broken-mcp",
|
||||
"--transport",
|
||||
"ws",
|
||||
"https://example.com/mcp",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: ["plugin:goal"] });
|
||||
});
|
||||
|
||||
it("rejects invalid marketplace entries before spawning commands", async () => {
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "bad",
|
||||
type: "skill",
|
||||
install: { args: [] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow("marketplace install args are required");
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches the marketplace catalog through the server helper", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response(JSON.stringify({ version: 1, entries: [] }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
await expect(fetchMarketplaceCatalog(fetchImpl)).resolves.toEqual({
|
||||
version: 1,
|
||||
entries: [],
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://cline.github.io/marketplace/catalog.json",
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces marketplace catalog upstream failures", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response("nope", {
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
await expect(fetchMarketplaceCatalog(fetchImpl)).rejects.toThrow(
|
||||
"Failed to fetch marketplace catalog: 503 Service Unavailable",
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,145 +0,0 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { resolveMcpSettingsPath } from "@cline/shared/storage";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
export function readMcpServersResponse(): JsonRecord {
|
||||
const settingsPath = resolveMcpSettingsPath();
|
||||
if (!existsSync(settingsPath)) {
|
||||
return { settingsPath, hasSettingsFile: false, servers: [] };
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
|
||||
const servers = parsed.mcpServers as JsonRecord | undefined;
|
||||
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
|
||||
const record = body as JsonRecord;
|
||||
const transport =
|
||||
record.transport && typeof record.transport === "object"
|
||||
? (record.transport as JsonRecord)
|
||||
: undefined;
|
||||
const transportType = String(
|
||||
transport?.type ?? record.transportType ?? record.type ?? "stdio",
|
||||
).trim();
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
disabled: record.disabled === true,
|
||||
command:
|
||||
typeof transport?.command === "string"
|
||||
? transport.command
|
||||
: typeof record.command === "string"
|
||||
? record.command
|
||||
: undefined,
|
||||
args: Array.isArray(transport?.args)
|
||||
? transport.args
|
||||
: Array.isArray(record.args)
|
||||
? record.args
|
||||
: undefined,
|
||||
cwd:
|
||||
typeof transport?.cwd === "string"
|
||||
? transport.cwd
|
||||
: typeof record.cwd === "string"
|
||||
? record.cwd
|
||||
: undefined,
|
||||
env:
|
||||
transport?.env && typeof transport.env === "object"
|
||||
? transport.env
|
||||
: record.env && typeof record.env === "object"
|
||||
? record.env
|
||||
: undefined,
|
||||
url:
|
||||
typeof transport?.url === "string"
|
||||
? transport.url
|
||||
: typeof record.url === "string"
|
||||
? record.url
|
||||
: undefined,
|
||||
headers:
|
||||
transport?.headers && typeof transport.headers === "object"
|
||||
? transport.headers
|
||||
: record.headers && typeof record.headers === "object"
|
||||
? record.headers
|
||||
: undefined,
|
||||
metadata: record.metadata,
|
||||
};
|
||||
});
|
||||
return { settingsPath, hasSettingsFile: true, servers: entries };
|
||||
}
|
||||
|
||||
export function writeMcpServersMap(servers: JsonRecord): void {
|
||||
const path = resolveMcpSettingsPath();
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, `${JSON.stringify({ mcpServers: servers }, null, 2)}\n`);
|
||||
}
|
||||
|
||||
export function ensureMcpSettingsFile(): string {
|
||||
const path = resolveMcpSettingsPath();
|
||||
if (!existsSync(path)) {
|
||||
writeMcpServersMap({});
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function readServersMap(): { path: string; servers: JsonRecord } {
|
||||
const path = ensureMcpSettingsFile();
|
||||
const parsed = JSON.parse(readFileSync(path, "utf8")) as JsonRecord;
|
||||
return { path, servers: (parsed.mcpServers as JsonRecord | undefined) ?? {} };
|
||||
}
|
||||
|
||||
export function setMcpServerDisabled(
|
||||
name: string,
|
||||
disabled: boolean,
|
||||
): JsonRecord {
|
||||
const { servers } = readServersMap();
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
servers[name] = { ...(current as JsonRecord), disabled };
|
||||
writeMcpServersMap(servers);
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
|
||||
export function upsertMcpServer(input: JsonRecord): JsonRecord {
|
||||
const name = String(input.name ?? "").trim();
|
||||
if (!name) throw new Error("server name is required");
|
||||
const previousName = String(
|
||||
input.previousName ?? input.previous_name ?? "",
|
||||
).trim();
|
||||
const transportType = String(
|
||||
input.transportType ?? input.transport_type ?? "",
|
||||
).trim();
|
||||
const next: JsonRecord =
|
||||
transportType === "stdio"
|
||||
? {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
},
|
||||
disabled: input.disabled === true,
|
||||
}
|
||||
: {
|
||||
transport: {
|
||||
type: transportType === "sse" ? "sse" : "streamableHttp",
|
||||
url: input.url,
|
||||
headers: input.headers,
|
||||
},
|
||||
disabled: input.disabled === true,
|
||||
};
|
||||
const { servers } = readServersMap();
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = next;
|
||||
writeMcpServersMap(servers);
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
|
||||
export function deleteMcpServer(name: string): JsonRecord {
|
||||
if (!name) throw new Error("server name is required");
|
||||
const { servers } = readServersMap();
|
||||
delete servers[name];
|
||||
writeMcpServersMap(servers);
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import process from "node:process";
|
||||
import {
|
||||
ensureCustomProvidersLoaded,
|
||||
getLocalProviderModels,
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import type {
|
||||
WebviewInboundMessage,
|
||||
WebviewProviderModel,
|
||||
} from "../webview-protocol";
|
||||
import { providerSettingsManager, workspaceRoot } from "./deps";
|
||||
import type { HubContext } from "./state";
|
||||
import type { BrowserPeer } from "./types";
|
||||
import { openExternalUrl } from "./utils";
|
||||
|
||||
export function resolveBrowserDefaults(ctx: HubContext): {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
workspaceRoot: string;
|
||||
cwd: string;
|
||||
} {
|
||||
const lastUsed = providerSettingsManager.getLastUsedProviderSettings();
|
||||
return {
|
||||
provider:
|
||||
lastUsed?.provider ??
|
||||
ctx.lastSessionContext?.providerId ??
|
||||
process.env.CLINE_PROVIDER?.trim(),
|
||||
model:
|
||||
lastUsed?.model ??
|
||||
ctx.lastSessionContext?.modelId ??
|
||||
process.env.CLINE_MODEL?.trim(),
|
||||
workspaceRoot: ctx.lastSessionContext?.workspaceRoot ?? workspaceRoot,
|
||||
cwd:
|
||||
ctx.lastSessionContext?.cwd ??
|
||||
ctx.lastSessionContext?.workspaceRoot ??
|
||||
workspaceRoot,
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadProviders(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
): Promise<void> {
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
const state = providerSettingsManager.read();
|
||||
const defaults = resolveBrowserDefaults(ctx);
|
||||
const ids = Llms.getProviderIds().sort((a, b) => a.localeCompare(b));
|
||||
const providers = (
|
||||
await Promise.all(
|
||||
ids.map(async (id) => {
|
||||
const info = await Llms.getProvider(id);
|
||||
const enabled =
|
||||
Boolean(state.providers[id]?.settings) || id === defaults.provider;
|
||||
return {
|
||||
id,
|
||||
name: info?.name ?? id,
|
||||
enabled,
|
||||
defaultModelId: info?.defaultModelId,
|
||||
};
|
||||
}),
|
||||
)
|
||||
).filter((provider) => provider.enabled);
|
||||
ctx.send(peer, { type: "providers", providers });
|
||||
const selected =
|
||||
(defaults.provider &&
|
||||
providers.find((provider) => provider.id === defaults.provider)) ||
|
||||
providers[0];
|
||||
if (selected) {
|
||||
await loadModels(ctx, peer, selected.id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadModels(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
const provider = providerId.trim();
|
||||
if (!provider) return;
|
||||
const payload = await getLocalProviderModels(
|
||||
provider,
|
||||
providerSettingsManager.getProviderConfig(provider),
|
||||
);
|
||||
const models: WebviewProviderModel[] = payload.models.map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
supportsReasoning: model.supportsReasoning,
|
||||
supportsThinking: model.supportsReasoning,
|
||||
}));
|
||||
ctx.send(peer, { type: "models", providerId: provider, models });
|
||||
}
|
||||
|
||||
export async function sendProviderCatalog(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
): Promise<void> {
|
||||
await ensureCustomProvidersLoaded(providerSettingsManager);
|
||||
const payload = await listLocalProviders(providerSettingsManager);
|
||||
ctx.send(peer, {
|
||||
type: "provider_catalog",
|
||||
providers: payload.providers,
|
||||
settingsPath: payload.settingsPath,
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveProviderSettings(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
frame: Extract<WebviewInboundMessage, { type: "saveProviderSettings" }>,
|
||||
): Promise<void> {
|
||||
const result = saveLocalProviderSettings(providerSettingsManager, {
|
||||
providerId: frame.providerId,
|
||||
enabled: frame.enabled,
|
||||
apiKey: frame.apiKey,
|
||||
baseUrl: frame.baseUrl,
|
||||
});
|
||||
ctx.send(peer, {
|
||||
type: "provider_settings_saved",
|
||||
providerId: result.providerId,
|
||||
enabled: result.enabled,
|
||||
});
|
||||
await sendProviderCatalog(ctx, peer);
|
||||
await loadProviders(ctx, peer);
|
||||
}
|
||||
|
||||
export async function runProviderOAuthLogin(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
const normalized = normalizeOAuthProvider(providerId);
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
normalized,
|
||||
openExternalUrl,
|
||||
);
|
||||
ctx.send(peer, {
|
||||
type: "provider_oauth_login_done",
|
||||
providerId: normalized,
|
||||
accessTokenPresent:
|
||||
(saved.auth?.accessToken?.trim() ?? saved.apiKey?.trim() ?? "").length >
|
||||
0,
|
||||
});
|
||||
await sendProviderCatalog(ctx, peer);
|
||||
await loadProviders(ctx, peer);
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
import {
|
||||
createLocalHubScheduleRuntimeHandlers,
|
||||
HubScheduleCommandService,
|
||||
HubScheduleService,
|
||||
} from "@cline/core";
|
||||
import { asTrimmedString, toPositiveInt } from "./utils";
|
||||
|
||||
let scheduleService: HubScheduleService | undefined;
|
||||
let scheduleCommands: HubScheduleCommandService | undefined;
|
||||
|
||||
function getCommands(): HubScheduleCommandService {
|
||||
if (!scheduleService || !scheduleCommands) {
|
||||
scheduleService = new HubScheduleService({
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
});
|
||||
scheduleCommands = new HubScheduleCommandService(scheduleService);
|
||||
}
|
||||
return scheduleCommands;
|
||||
}
|
||||
|
||||
async function clientCommand(
|
||||
hubCommand: string,
|
||||
payload?: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const reply = await getCommands().handleCommand({
|
||||
version: "v1",
|
||||
clientId: "cline-hub-schedules",
|
||||
command: hubCommand as never,
|
||||
payload,
|
||||
});
|
||||
if (!reply.ok) {
|
||||
throw new Error(
|
||||
reply.error?.message ?? `hub command failed: ${hubCommand}`,
|
||||
);
|
||||
}
|
||||
return (reply.payload ?? {}) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function handleRoutineScheduleCommand(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
if (command === "list_routine_schedules") {
|
||||
const [schedules, activeExecutions, upcomingRuns] = await Promise.all([
|
||||
clientCommand("schedule.list", {
|
||||
limit: toPositiveInt(args?.limit) ?? 200,
|
||||
}),
|
||||
clientCommand("schedule.active"),
|
||||
clientCommand("schedule.upcoming", { limit: 30 }),
|
||||
]);
|
||||
const scheduleRows = Array.isArray(schedules.schedules)
|
||||
? schedules.schedules
|
||||
: [];
|
||||
const lastExecutions = await Promise.all(
|
||||
scheduleRows.map(async (schedule) => {
|
||||
const scheduleId = asTrimmedString(
|
||||
(schedule as Record<string, unknown>).scheduleId,
|
||||
);
|
||||
if (!scheduleId) return undefined;
|
||||
const reply = await clientCommand("schedule.list_executions", {
|
||||
scheduleId,
|
||||
limit: 1,
|
||||
});
|
||||
return Array.isArray(reply.executions)
|
||||
? reply.executions[0]
|
||||
: undefined;
|
||||
}),
|
||||
);
|
||||
return {
|
||||
schedules: scheduleRows,
|
||||
activeExecutions: activeExecutions.executions ?? [],
|
||||
upcomingRuns: upcomingRuns.runs ?? [],
|
||||
lastExecutions: lastExecutions.filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
if (command === "create_routine_schedule") {
|
||||
const name = asTrimmedString(args?.name);
|
||||
const cronPattern = asTrimmedString(args?.cron_pattern);
|
||||
const prompt = asTrimmedString(args?.prompt);
|
||||
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
|
||||
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
|
||||
throw new Error(
|
||||
"createSchedule requires name, cron_pattern, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const created = await clientCommand("schedule.create", {
|
||||
name,
|
||||
cronPattern,
|
||||
prompt,
|
||||
modelSelection: {
|
||||
providerId: asTrimmedString(args?.provider) ?? "cline",
|
||||
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
|
||||
},
|
||||
mode: args?.mode === "plan" ? "plan" : "act",
|
||||
workspaceRoot: routineWorkspaceRoot,
|
||||
cwd: asTrimmedString(args?.cwd),
|
||||
systemPrompt: asTrimmedString(args?.system_prompt),
|
||||
maxIterations: toPositiveInt(args?.max_iterations),
|
||||
timeoutSeconds: toPositiveInt(args?.timeout_seconds),
|
||||
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
|
||||
enabled: args?.enabled !== false,
|
||||
tags:
|
||||
Array.isArray(args?.tags) && args.tags.length > 0
|
||||
? (args.tags as string[])
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0)
|
||||
: undefined,
|
||||
});
|
||||
return { schedule: created.schedule ?? null };
|
||||
}
|
||||
|
||||
const scheduleId = asTrimmedString(args?.schedule_id);
|
||||
if (!scheduleId) throw new Error(`${command} requires schedule_id`);
|
||||
if (command === "update_routine_schedule") {
|
||||
const name = asTrimmedString(args?.name);
|
||||
const cronPattern = asTrimmedString(args?.cron_pattern);
|
||||
const prompt = asTrimmedString(args?.prompt);
|
||||
const routineWorkspaceRoot = asTrimmedString(args?.workspace_root);
|
||||
if (!name || !cronPattern || !prompt || !routineWorkspaceRoot) {
|
||||
throw new Error(
|
||||
"updateSchedule requires schedule_id, name, cron_pattern, prompt, and workspace_root",
|
||||
);
|
||||
}
|
||||
const reply = await clientCommand("schedule.update", {
|
||||
scheduleId,
|
||||
name,
|
||||
cronPattern,
|
||||
prompt,
|
||||
modelSelection: {
|
||||
providerId: asTrimmedString(args?.provider) ?? "cline",
|
||||
modelId: asTrimmedString(args?.model) ?? "openai/gpt-5.3-codex",
|
||||
},
|
||||
mode: args?.mode === "plan" ? "plan" : "act",
|
||||
workspaceRoot: routineWorkspaceRoot,
|
||||
cwd: asTrimmedString(args?.cwd) ?? null,
|
||||
systemPrompt:
|
||||
args?.system_prompt === null
|
||||
? null
|
||||
: asTrimmedString(args?.system_prompt),
|
||||
maxIterations:
|
||||
args?.max_iterations === null
|
||||
? null
|
||||
: toPositiveInt(args?.max_iterations),
|
||||
timeoutSeconds:
|
||||
args?.timeout_seconds === null
|
||||
? null
|
||||
: toPositiveInt(args?.timeout_seconds),
|
||||
maxParallel: toPositiveInt(args?.max_parallel) ?? 1,
|
||||
enabled: args?.enabled !== false,
|
||||
tags: Array.isArray(args?.tags)
|
||||
? (args.tags as string[])
|
||||
.map((v) => v.trim())
|
||||
.filter((v) => v.length > 0)
|
||||
: [],
|
||||
});
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "pause_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.disable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "resume_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.enable", { scheduleId });
|
||||
return { schedule: reply.schedule ?? null };
|
||||
}
|
||||
if (command === "trigger_routine_schedule") {
|
||||
const existing = await clientCommand("schedule.get", { scheduleId });
|
||||
if (!existing.schedule)
|
||||
throw new Error(`schedule not found: ${scheduleId}`);
|
||||
const reply = await clientCommand("schedule.trigger", {
|
||||
scheduleId,
|
||||
wait: false,
|
||||
});
|
||||
return { execution: reply.execution ?? null };
|
||||
}
|
||||
if (command === "delete_routine_schedule") {
|
||||
const reply = await clientCommand("schedule.delete", { scheduleId });
|
||||
return { deleted: reply.deleted === true };
|
||||
}
|
||||
throw new Error(`unsupported routine schedule command: ${command}`);
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
import type {
|
||||
WebviewActionSessionSummary,
|
||||
WebviewChatMessage,
|
||||
WebviewClientSummary,
|
||||
WebviewOutboundMessage,
|
||||
WebviewSessionSummary,
|
||||
} from "../webview-protocol";
|
||||
import type { HubContext } from "./state";
|
||||
import type { SessionContext, TrackedClient, TrackedSession } from "./types";
|
||||
import {
|
||||
asNumber,
|
||||
asString,
|
||||
asTimestamp,
|
||||
basename,
|
||||
formatClientLabel,
|
||||
isActiveSession,
|
||||
stringifyContent,
|
||||
} from "./utils";
|
||||
|
||||
function metadataFor(record: Record<string, unknown>): Record<string, unknown> {
|
||||
return (
|
||||
(record.metadata && typeof record.metadata === "object"
|
||||
? (record.metadata as Record<string, unknown>)
|
||||
: undefined) ?? {}
|
||||
);
|
||||
}
|
||||
|
||||
function usageFor(record: Record<string, unknown>): Record<string, unknown> {
|
||||
const metadata = metadataFor(record);
|
||||
const pick = (value: unknown): Record<string, unknown> | undefined =>
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
return (
|
||||
pick(record.aggregateUsage) ??
|
||||
pick(record.usage) ??
|
||||
pick(metadata.aggregateUsage) ??
|
||||
pick(metadata.usage) ??
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
function sessionTitle(record: Record<string, unknown>): string {
|
||||
const metadata = metadataFor(record);
|
||||
const title = asString(metadata.title);
|
||||
if (title) return title;
|
||||
const prompt = asString(record.prompt) ?? asString(metadata.prompt);
|
||||
if (prompt) return prompt.length > 34 ? `${prompt.slice(0, 31)}...` : prompt;
|
||||
return basename(asString(record.workspaceRoot) ?? asString(record.cwd));
|
||||
}
|
||||
|
||||
export function formatClientName(client: TrackedClient): string {
|
||||
return (
|
||||
client.displayName?.trim() ||
|
||||
client.clientType.trim() ||
|
||||
client.clientId.trim() ||
|
||||
"Unknown"
|
||||
);
|
||||
}
|
||||
|
||||
export function formatSessionCreator(
|
||||
ctx: HubContext,
|
||||
session: TrackedSession,
|
||||
): string {
|
||||
const clientId = session.createdByClientId?.trim();
|
||||
if (!clientId) return "Unknown client";
|
||||
const client = ctx.clients.get(clientId);
|
||||
return client ? formatClientName(client) : clientId;
|
||||
}
|
||||
|
||||
function summarizeClient(client: TrackedClient): {
|
||||
key: string;
|
||||
label: string;
|
||||
name: string;
|
||||
} {
|
||||
const normalizedType = client.clientType.trim().toLowerCase();
|
||||
if (
|
||||
normalizedType === "code-sidecar" ||
|
||||
normalizedType === "code-sidecar-approvals" ||
|
||||
normalizedType === "code-sidecar-list"
|
||||
) {
|
||||
return { key: "code-app", label: "Code App", name: "Code App" };
|
||||
}
|
||||
return {
|
||||
key: client.clientId,
|
||||
label: formatClientLabel(client.clientType),
|
||||
name: formatClientName(client),
|
||||
};
|
||||
}
|
||||
|
||||
export function mapHistoryToWebviewMessages(
|
||||
history: unknown[],
|
||||
): WebviewChatMessage[] {
|
||||
return history.map((entry, index) => {
|
||||
const record =
|
||||
entry && typeof entry === "object"
|
||||
? (entry as Record<string, unknown>)
|
||||
: { content: entry };
|
||||
const rawRole = asString(record.role)?.toLowerCase();
|
||||
const role: WebviewChatMessage["role"] =
|
||||
rawRole === "user" || rawRole === "assistant" || rawRole === "error"
|
||||
? rawRole
|
||||
: "meta";
|
||||
const text = stringifyContent(record.content ?? record.text ?? record);
|
||||
return {
|
||||
id: asString(record.id) ?? `history-${index}`,
|
||||
role,
|
||||
text,
|
||||
blocks: text ? [{ id: `history-${index}-text`, type: "text", text }] : [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function trackSession(record: unknown): TrackedSession | undefined {
|
||||
const raw =
|
||||
record && typeof record === "object"
|
||||
? (record as Record<string, unknown>)
|
||||
: {};
|
||||
const sessionId = asString(raw.sessionId);
|
||||
if (!sessionId) return undefined;
|
||||
const metadata = metadataFor(raw);
|
||||
const usage = usageFor(raw);
|
||||
const participantCount = Array.isArray(raw.participants)
|
||||
? raw.participants.length
|
||||
: 0;
|
||||
const createdAt =
|
||||
asTimestamp(raw.createdAt) ??
|
||||
asTimestamp(raw.startedAt) ??
|
||||
asTimestamp(metadata.createdAt) ??
|
||||
Date.now();
|
||||
return {
|
||||
sessionId,
|
||||
status: asString(raw.status) ?? "running",
|
||||
title: sessionTitle(raw),
|
||||
workspaceRoot: asString(raw.workspaceRoot) ?? asString(raw.cwd) ?? "",
|
||||
cwd: asString(raw.cwd),
|
||||
provider: asString(raw.provider) ?? asString(metadata.provider),
|
||||
model: asString(raw.model) ?? asString(metadata.model),
|
||||
source: asString(raw.source) ?? asString(metadata.source),
|
||||
createdAt,
|
||||
updatedAt:
|
||||
asTimestamp(raw.updatedAt) ??
|
||||
asTimestamp(raw.endedAt) ??
|
||||
asTimestamp(metadata.updatedAt) ??
|
||||
createdAt,
|
||||
createdByClientId: asString(raw.createdByClientId),
|
||||
prompt: asString(raw.prompt) ?? asString(metadata.prompt),
|
||||
inputTokens:
|
||||
asNumber(usage.inputTokens) ??
|
||||
asNumber(usage.input) ??
|
||||
asNumber(usage.totalInputTokens),
|
||||
outputTokens:
|
||||
asNumber(usage.outputTokens) ??
|
||||
asNumber(usage.output) ??
|
||||
asNumber(usage.totalOutputTokens),
|
||||
totalCost: asNumber(usage.totalCost) ?? asNumber(metadata.totalCost),
|
||||
agentCount: Math.max(1, participantCount),
|
||||
participantCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function toActionSessionSummary(
|
||||
session: TrackedSession,
|
||||
): WebviewActionSessionSummary {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
title: session.title || basename(session.workspaceRoot || session.cwd),
|
||||
status: session.status,
|
||||
workspaceRoot: session.workspaceRoot,
|
||||
workspaceName: basename(session.workspaceRoot || session.cwd),
|
||||
cwd: session.cwd,
|
||||
model: session.model,
|
||||
provider: session.provider,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
createdByClientId: session.createdByClientId,
|
||||
prompt: session.prompt,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
agentCount: session.agentCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function clientSummariesPayload(
|
||||
ctx: HubContext,
|
||||
): WebviewClientSummary[] {
|
||||
const sessionCounts = new Map<string, number>();
|
||||
for (const session of ctx.sessions.values()) {
|
||||
if (
|
||||
!isActiveSession(session.title, session.status, session.participantCount)
|
||||
)
|
||||
continue;
|
||||
const clientId = session.createdByClientId?.trim();
|
||||
if (!clientId) continue;
|
||||
sessionCounts.set(clientId, (sessionCounts.get(clientId) ?? 0) + 1);
|
||||
}
|
||||
const grouped = new Map<
|
||||
string,
|
||||
WebviewClientSummary & { firstConnectedAt: number }
|
||||
>();
|
||||
for (const client of [...ctx.clients.values()].sort(
|
||||
(a, b) => a.connectedAt - b.connectedAt,
|
||||
)) {
|
||||
const summary = summarizeClient(client);
|
||||
const existing = grouped.get(summary.key);
|
||||
if (existing) {
|
||||
existing.sessionCount += sessionCounts.get(client.clientId) ?? 0;
|
||||
existing.firstConnectedAt = Math.min(
|
||||
existing.firstConnectedAt,
|
||||
client.connectedAt,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
grouped.set(summary.key, {
|
||||
label: summary.label,
|
||||
name: summary.name,
|
||||
sessionCount: sessionCounts.get(client.clientId) ?? 0,
|
||||
firstConnectedAt: client.connectedAt,
|
||||
});
|
||||
}
|
||||
return [...grouped.values()]
|
||||
.sort((a, b) => a.firstConnectedAt - b.firstConnectedAt)
|
||||
.map(({ label, name, sessionCount }) => ({ label, name, sessionCount }));
|
||||
}
|
||||
|
||||
export function toWebviewSessionSummary(
|
||||
session: TrackedSession,
|
||||
): WebviewSessionSummary {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
title: session.title,
|
||||
status: session.status,
|
||||
source: session.source,
|
||||
providerId: session.provider,
|
||||
model: session.model,
|
||||
workspaceRoot: session.workspaceRoot,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
};
|
||||
}
|
||||
|
||||
export function webviewSessionsPayload(
|
||||
ctx: HubContext,
|
||||
): WebviewOutboundMessage {
|
||||
return {
|
||||
type: "sessions",
|
||||
sessions: [...ctx.sessions.values()]
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.map(toWebviewSessionSummary),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSessionContext(
|
||||
record: unknown,
|
||||
): SessionContext | undefined {
|
||||
const raw =
|
||||
record && typeof record === "object"
|
||||
? (record as Record<string, unknown>)
|
||||
: {};
|
||||
const metadata =
|
||||
raw.metadata && typeof raw.metadata === "object"
|
||||
? (raw.metadata as Record<string, unknown>)
|
||||
: {};
|
||||
const workspaceRootRaw = asString(raw.workspaceRoot);
|
||||
const providerId =
|
||||
asString(raw.providerId) ??
|
||||
asString(metadata.providerId) ??
|
||||
asString(raw.provider) ??
|
||||
asString(metadata.provider);
|
||||
const modelId =
|
||||
asString(raw.modelId) ??
|
||||
asString(metadata.modelId) ??
|
||||
asString(raw.model) ??
|
||||
asString(metadata.model);
|
||||
if (!workspaceRootRaw || !providerId || !modelId) return undefined;
|
||||
return {
|
||||
workspaceRoot: workspaceRootRaw,
|
||||
cwd: asString(raw.cwd) ?? workspaceRootRaw,
|
||||
providerId,
|
||||
modelId,
|
||||
};
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
import process from "node:process";
|
||||
import {
|
||||
type ClineCoreStartInput,
|
||||
type SessionRecord,
|
||||
SessionSource,
|
||||
} from "@cline/core";
|
||||
import type { Message } from "@cline/llms";
|
||||
import type { WebviewConfig, WebviewReasonLevel } from "../webview-protocol";
|
||||
import { rejectPendingApprovalsForSession } from "./approvals";
|
||||
import { providerSettingsManager, workspaceRoot } from "./deps";
|
||||
import {
|
||||
loadProviders,
|
||||
resolveBrowserDefaults,
|
||||
sendProviderCatalog,
|
||||
} from "./providers";
|
||||
import {
|
||||
mapHistoryToWebviewMessages,
|
||||
trackSession,
|
||||
webviewSessionsPayload,
|
||||
} from "./session-mapping";
|
||||
import type { HubContext } from "./state";
|
||||
import { broadcastHubState, hubStatePayload } from "./state-payloads";
|
||||
import type { BrowserPeer, SessionContext } from "./types";
|
||||
import { asNumber, asString } from "./utils";
|
||||
|
||||
function toRuntimeReasoningOptions(
|
||||
reasonLevel?: WebviewReasonLevel,
|
||||
): Pick<ClineCoreStartInput["config"], "reasoningEffort" | "thinking"> {
|
||||
if (reasonLevel === undefined) return {};
|
||||
if (reasonLevel === "none") return { thinking: false };
|
||||
return { thinking: true, reasoningEffort: reasonLevel };
|
||||
}
|
||||
|
||||
function asWebviewReasonLevel(value: unknown): WebviewReasonLevel | undefined {
|
||||
return value === "none" ||
|
||||
value === "low" ||
|
||||
value === "medium" ||
|
||||
value === "high"
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveLaunchContext(
|
||||
ctx: HubContext,
|
||||
override?: Partial<SessionContext> & WebviewConfig,
|
||||
): SessionContext {
|
||||
const providerId =
|
||||
override?.provider ??
|
||||
override?.providerId ??
|
||||
ctx.lastSessionContext?.providerId ??
|
||||
providerSettingsManager.getLastUsedProviderSettings()?.provider ??
|
||||
process.env.CLINE_PROVIDER?.trim() ??
|
||||
"";
|
||||
const modelId =
|
||||
override?.model ??
|
||||
override?.modelId ??
|
||||
ctx.lastSessionContext?.modelId ??
|
||||
providerSettingsManager.getLastUsedProviderSettings()?.model ??
|
||||
process.env.CLINE_MODEL?.trim() ??
|
||||
"";
|
||||
const root =
|
||||
override?.workspaceRoot ??
|
||||
ctx.lastSessionContext?.workspaceRoot ??
|
||||
workspaceRoot;
|
||||
if (!providerId || !modelId) {
|
||||
throw new Error(
|
||||
"No provider/model available. Start a session in another Cline client first, or set CLINE_PROVIDER and CLINE_MODEL.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
workspaceRoot: root,
|
||||
cwd: override?.cwd ?? ctx.lastSessionContext?.cwd ?? root,
|
||||
providerId,
|
||||
modelId,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSessionStartInput(
|
||||
context: SessionContext,
|
||||
options?: {
|
||||
mode?: "act" | "plan";
|
||||
systemPrompt?: string;
|
||||
maxIterations?: number;
|
||||
reasonLevel?: WebviewReasonLevel;
|
||||
enableTools?: boolean;
|
||||
enableSpawn?: boolean;
|
||||
enableTeams?: boolean;
|
||||
autoApproveTools?: boolean;
|
||||
teamName?: string;
|
||||
source?: SessionSource;
|
||||
sessionMetadata?: Record<string, unknown>;
|
||||
initialMessages?: Message[];
|
||||
},
|
||||
): ClineCoreStartInput {
|
||||
const mode = options?.mode === "plan" ? "plan" : "act";
|
||||
const reasoningOptions = toRuntimeReasoningOptions(options?.reasonLevel);
|
||||
return {
|
||||
source: options?.source ?? SessionSource.WEB,
|
||||
interactive: true,
|
||||
config: {
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
cwd: context.cwd,
|
||||
providerId: context.providerId,
|
||||
modelId: context.modelId,
|
||||
systemPrompt: options?.systemPrompt ?? "",
|
||||
mode,
|
||||
...reasoningOptions,
|
||||
maxIterations: options?.maxIterations,
|
||||
enableTools: options?.enableTools !== false,
|
||||
enableSpawnAgent: options?.enableSpawn !== false,
|
||||
enableAgentTeams: options?.enableTeams === true,
|
||||
teamName: options?.teamName ?? "cline-hub",
|
||||
missionLogIntervalSteps: 3,
|
||||
missionLogIntervalMs: 120000,
|
||||
checkpoint: { enabled: true },
|
||||
},
|
||||
sessionMetadata: {
|
||||
source: options?.source ?? SessionSource.WEB,
|
||||
mode,
|
||||
systemPrompt: options?.systemPrompt,
|
||||
maxIterations: options?.maxIterations,
|
||||
reasonLevel: options?.reasonLevel,
|
||||
autoApproveTools: options?.autoApproveTools,
|
||||
...(options?.sessionMetadata ?? {}),
|
||||
},
|
||||
...(options?.initialMessages
|
||||
? { initialMessages: options.initialMessages }
|
||||
: {}),
|
||||
toolPolicies:
|
||||
options?.autoApproveTools === false
|
||||
? { "*": { autoApprove: false } }
|
||||
: { "*": { autoApprove: true } },
|
||||
};
|
||||
}
|
||||
|
||||
function buildStartInputFromSession(
|
||||
session: SessionRecord,
|
||||
options?: {
|
||||
sessionMetadata?: Record<string, unknown>;
|
||||
initialMessages?: Message[];
|
||||
},
|
||||
) {
|
||||
const metadata =
|
||||
session.metadata && typeof session.metadata === "object"
|
||||
? session.metadata
|
||||
: {};
|
||||
const mode = metadata.mode === "plan" ? "plan" : "act";
|
||||
return buildSessionStartInput(
|
||||
{
|
||||
workspaceRoot: session.workspaceRoot,
|
||||
cwd: session.cwd,
|
||||
providerId: session.provider,
|
||||
modelId: session.model,
|
||||
},
|
||||
{
|
||||
mode,
|
||||
systemPrompt: asString(metadata.systemPrompt),
|
||||
maxIterations: asNumber(metadata.maxIterations),
|
||||
reasonLevel: asWebviewReasonLevel(metadata.reasonLevel),
|
||||
enableTools: session.enableTools,
|
||||
enableSpawn: session.enableSpawn,
|
||||
enableTeams: session.enableTeams,
|
||||
autoApproveTools:
|
||||
typeof metadata.autoApproveTools === "boolean"
|
||||
? metadata.autoApproveTools
|
||||
: undefined,
|
||||
teamName: session.teamName,
|
||||
source: session.source,
|
||||
sessionMetadata: { ...metadata, ...(options?.sessionMetadata ?? {}) },
|
||||
initialMessages: options?.initialMessages,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function loadHistoryFor(
|
||||
ctx: HubContext,
|
||||
sessionId: string,
|
||||
): Promise<unknown[]> {
|
||||
if (!ctx.cline) return [];
|
||||
try {
|
||||
return (await ctx.cline.readMessages(sessionId)) as unknown[];
|
||||
} catch (error) {
|
||||
console.warn(`readMessages(${sessionId}) failed:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function selectSession(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
peer.selectedSessionId = sessionId;
|
||||
const tracked = ctx.sessions.get(sessionId);
|
||||
const history = await loadHistoryFor(ctx, sessionId);
|
||||
ctx.send(peer, { type: "session_started", sessionId });
|
||||
ctx.send(peer, {
|
||||
type: "session_hydrated",
|
||||
sessionId,
|
||||
status: tracked?.status,
|
||||
providerId: tracked?.provider,
|
||||
modelId: tracked?.model,
|
||||
messages: mapHistoryToWebviewMessages(history),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSession(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
prompt: string,
|
||||
config?: WebviewConfig,
|
||||
attachments?: { userImages?: string[] },
|
||||
): Promise<void> {
|
||||
if (!ctx.cline) throw new Error("Hub is not connected.");
|
||||
const context = resolveLaunchContext(ctx, config);
|
||||
const mode = config?.mode === "plan" ? "plan" : "act";
|
||||
const result = await ctx.cline.start(
|
||||
buildSessionStartInput(context, {
|
||||
mode,
|
||||
systemPrompt: config?.systemPrompt,
|
||||
maxIterations: config?.maxIterations,
|
||||
reasonLevel: config?.reasonLevel,
|
||||
enableTools: config?.enableTools,
|
||||
enableSpawn: config?.enableSpawn,
|
||||
enableTeams: config?.enableTeams,
|
||||
autoApproveTools: config?.autoApproveTools,
|
||||
}),
|
||||
);
|
||||
peer.selectedSessionId = result.sessionId;
|
||||
ctx.sessions.set(result.sessionId, {
|
||||
sessionId: result.sessionId,
|
||||
status: "running",
|
||||
title: prompt.length > 34 ? `${prompt.slice(0, 31)}...` : prompt,
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
cwd: context.cwd,
|
||||
provider: context.providerId,
|
||||
model: context.modelId,
|
||||
source: SessionSource.WEB,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
prompt,
|
||||
agentCount: 1,
|
||||
participantCount: 1,
|
||||
});
|
||||
const tracked = ctx.sessions.get(result.sessionId);
|
||||
ctx.send(peer, { type: "session_started", sessionId: result.sessionId });
|
||||
ctx.send(peer, {
|
||||
type: "session_hydrated",
|
||||
sessionId: result.sessionId,
|
||||
status: tracked?.status,
|
||||
providerId: context.providerId,
|
||||
modelId: context.modelId,
|
||||
messages: [],
|
||||
});
|
||||
broadcastHubState(ctx);
|
||||
await ctx.cline.send({
|
||||
sessionId: result.sessionId,
|
||||
prompt,
|
||||
mode,
|
||||
userImages: attachments?.userImages,
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendMessage(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
text: string,
|
||||
config?: WebviewConfig,
|
||||
attachments?: { userImages?: string[] },
|
||||
): Promise<void> {
|
||||
if (!ctx.cline) throw new Error("Hub is not connected.");
|
||||
if (!peer.selectedSessionId) {
|
||||
await createSession(ctx, peer, text, config, attachments);
|
||||
return;
|
||||
}
|
||||
await ctx.cline.send({
|
||||
sessionId: peer.selectedSessionId,
|
||||
prompt: text,
|
||||
mode: config?.mode === "plan" ? "plan" : "act",
|
||||
userImages: attachments?.userImages,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteSession(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
if (!ctx.cline) throw new Error("Hub is not connected.");
|
||||
const deleted = await ctx.cline.delete(sessionId);
|
||||
if (!deleted) {
|
||||
ctx.send(peer, {
|
||||
type: "status",
|
||||
text: `Session ${sessionId} was not found.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
ctx.sessions.delete(sessionId);
|
||||
if (peer.selectedSessionId === sessionId) {
|
||||
peer.selectedSessionId = undefined;
|
||||
ctx.send(peer, { type: "reset_done" });
|
||||
}
|
||||
ctx.send(peer, { type: "status", text: `Deleted session ${sessionId}` });
|
||||
broadcastHubState(ctx);
|
||||
}
|
||||
|
||||
export async function resetPeer(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
): Promise<void> {
|
||||
if (peer.selectedSessionId) {
|
||||
rejectPendingApprovalsForSession(
|
||||
ctx,
|
||||
peer.selectedSessionId,
|
||||
"Session detached before approval was resolved.",
|
||||
);
|
||||
}
|
||||
peer.selectedSessionId = undefined;
|
||||
ctx.send(peer, { type: "reset_done" });
|
||||
ctx.send(peer, webviewSessionsPayload(ctx));
|
||||
}
|
||||
|
||||
export async function abortPeerTurn(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
): Promise<void> {
|
||||
if (!ctx.cline || !peer.selectedSessionId) return;
|
||||
rejectPendingApprovalsForSession(
|
||||
ctx,
|
||||
peer.selectedSessionId,
|
||||
"Turn aborted before approval was resolved.",
|
||||
);
|
||||
await ctx.cline.abort(peer.selectedSessionId);
|
||||
ctx.send(peer, { type: "status", text: "Abort requested." });
|
||||
}
|
||||
|
||||
export async function forkPeerSession(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
syncHubClientsAndSessions: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (!ctx.cline) throw new Error("Hub is not connected.");
|
||||
const forkedFromSessionId = peer.selectedSessionId;
|
||||
if (!forkedFromSessionId) {
|
||||
ctx.send(peer, { type: "fork_error", text: "No active session to fork." });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const rawMessages = (await ctx.cline.readMessages(
|
||||
forkedFromSessionId,
|
||||
)) as Message[];
|
||||
if (rawMessages.length === 0) {
|
||||
ctx.send(peer, {
|
||||
type: "fork_error",
|
||||
text: "Cannot fork an empty session.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const sourceSession = await ctx.cline.get(forkedFromSessionId);
|
||||
if (!sourceSession) {
|
||||
ctx.send(peer, {
|
||||
type: "fork_error",
|
||||
text: `Session ${forkedFromSessionId} was not found.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const checkpointMetadata = sourceSession.metadata?.checkpoint;
|
||||
const result = await ctx.cline.start(
|
||||
buildStartInputFromSession(sourceSession, {
|
||||
initialMessages: rawMessages,
|
||||
sessionMetadata: {
|
||||
...(sourceSession.metadata ?? {}),
|
||||
fork: {
|
||||
forkedFromSessionId,
|
||||
forkedAt: new Date().toISOString(),
|
||||
source: sourceSession.source,
|
||||
...(checkpointMetadata !== undefined
|
||||
? { checkpoints: checkpointMetadata }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
peer.selectedSessionId = result.sessionId;
|
||||
const newSession = await ctx.cline.get(result.sessionId);
|
||||
const tracked = newSession ? trackSession(newSession) : undefined;
|
||||
if (tracked) ctx.sessions.set(tracked.sessionId, tracked);
|
||||
ctx.send(peer, { type: "session_started", sessionId: result.sessionId });
|
||||
ctx.send(peer, {
|
||||
type: "session_hydrated",
|
||||
sessionId: result.sessionId,
|
||||
status: newSession?.status,
|
||||
providerId: newSession?.provider,
|
||||
modelId: newSession?.model,
|
||||
messages: mapHistoryToWebviewMessages(rawMessages),
|
||||
});
|
||||
ctx.send(peer, {
|
||||
type: "fork_done",
|
||||
forkedFromSessionId,
|
||||
newSessionId: result.sessionId,
|
||||
});
|
||||
await syncHubClientsAndSessions();
|
||||
broadcastHubState(ctx);
|
||||
} catch (error) {
|
||||
ctx.send(peer, {
|
||||
type: "fork_error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function restorePeerSession(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
checkpointRunCount: number,
|
||||
syncHubClientsAndSessions: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
if (!ctx.cline) throw new Error("Hub is not connected.");
|
||||
const sourceSessionId = peer.selectedSessionId;
|
||||
if (!sourceSessionId) {
|
||||
ctx.send(peer, { type: "error", text: "No active session to restore." });
|
||||
return;
|
||||
}
|
||||
const sourceSession = await ctx.cline.get(sourceSessionId);
|
||||
if (!sourceSession) {
|
||||
ctx.send(peer, {
|
||||
type: "error",
|
||||
text: `Session ${sourceSessionId} was not found.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await ctx.cline.restore({
|
||||
sessionId: sourceSessionId,
|
||||
checkpointRunCount,
|
||||
cwd: sourceSession.cwd,
|
||||
start: buildStartInputFromSession(sourceSession, {
|
||||
sessionMetadata: {
|
||||
...(sourceSession.metadata ?? {}),
|
||||
restoredFromSessionId: sourceSessionId,
|
||||
restoredCheckpointRunCount: checkpointRunCount,
|
||||
},
|
||||
}),
|
||||
restore: { messages: true, workspace: true },
|
||||
});
|
||||
if (!result.sessionId) {
|
||||
ctx.send(peer, {
|
||||
type: "error",
|
||||
text: "Checkpoint restore did not start a session.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
peer.selectedSessionId = result.sessionId;
|
||||
const restoredSession = await ctx.cline.get(result.sessionId);
|
||||
const tracked = restoredSession ? trackSession(restoredSession) : undefined;
|
||||
if (tracked) ctx.sessions.set(tracked.sessionId, tracked);
|
||||
const messages =
|
||||
result.messages ?? (await loadHistoryFor(ctx, result.sessionId));
|
||||
ctx.send(peer, { type: "session_started", sessionId: result.sessionId });
|
||||
ctx.send(peer, {
|
||||
type: "session_hydrated",
|
||||
sessionId: result.sessionId,
|
||||
status: restoredSession?.status,
|
||||
providerId: restoredSession?.provider,
|
||||
modelId: restoredSession?.model,
|
||||
messages: mapHistoryToWebviewMessages(messages),
|
||||
});
|
||||
await syncHubClientsAndSessions();
|
||||
broadcastHubState(ctx);
|
||||
}
|
||||
|
||||
export async function initializePeer(
|
||||
ctx: HubContext,
|
||||
peer: BrowserPeer,
|
||||
syncHubClientsAndSessions: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
await syncHubClientsAndSessions();
|
||||
ctx.send(peer, { type: "status", text: "Cline Hub is ready." });
|
||||
ctx.send(peer, { type: "defaults", defaults: resolveBrowserDefaults(ctx) });
|
||||
await loadProviders(ctx, peer);
|
||||
await sendProviderCatalog(ctx, peer);
|
||||
ctx.send(peer, webviewSessionsPayload(ctx));
|
||||
ctx.send(peer, hubStatePayload(ctx));
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import type { WebviewHubState } from "../webview-protocol";
|
||||
import {
|
||||
clientSummariesPayload,
|
||||
toActionSessionSummary,
|
||||
webviewSessionsPayload,
|
||||
} from "./session-mapping";
|
||||
import type { HubContext } from "./state";
|
||||
import { formatUptime, isActiveSession } from "./utils";
|
||||
|
||||
function activeSessionSummaries(ctx: HubContext) {
|
||||
return [...ctx.sessions.values()]
|
||||
.filter((session) =>
|
||||
isActiveSession(session.title, session.status, session.participantCount),
|
||||
)
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.map(toActionSessionSummary);
|
||||
}
|
||||
|
||||
export function hubStatePayload(ctx: HubContext): WebviewHubState {
|
||||
const sessionSummaries = activeSessionSummaries(ctx);
|
||||
const clientList = [...ctx.clients.values()].sort(
|
||||
(a, b) => a.connectedAt - b.connectedAt,
|
||||
);
|
||||
return {
|
||||
type: "hub_state",
|
||||
connected: Boolean(ctx.cline && ctx.uiClient),
|
||||
hubUrl: ctx.hubUrl,
|
||||
hubStartedAt: ctx.hubStartedAt,
|
||||
coreVersion: ctx.coreVersion,
|
||||
hubUptime: ctx.hubStartedAt
|
||||
? formatUptime(Date.now() - Date.parse(ctx.hubStartedAt))
|
||||
: undefined,
|
||||
clients: clientList,
|
||||
connectors: listActiveConnectors(),
|
||||
sessions: sessionSummaries,
|
||||
clientSummaries: clientSummariesPayload(ctx),
|
||||
sessionSummaries,
|
||||
events: ctx.events,
|
||||
lastWorkspaceRoot: ctx.lastSessionContext?.workspaceRoot,
|
||||
};
|
||||
}
|
||||
|
||||
export function hubStatusPayload(ctx: HubContext) {
|
||||
const clientList = [...ctx.clients.values()].sort(
|
||||
(a, b) => a.connectedAt - b.connectedAt,
|
||||
);
|
||||
const sessionSummaries = activeSessionSummaries(ctx);
|
||||
return {
|
||||
address: ctx.hubUrl,
|
||||
status: ctx.hubHealthy ? "healthy" : "unhealthy",
|
||||
healthy: ctx.hubHealthy,
|
||||
connected: Boolean(ctx.cline && ctx.uiClient),
|
||||
startedAt: ctx.hubStartedAt,
|
||||
uptime: ctx.hubStartedAt
|
||||
? formatUptime(Date.now() - Date.parse(ctx.hubStartedAt))
|
||||
: undefined,
|
||||
coreVersion: ctx.coreVersion,
|
||||
clients: clientList.map((client) => ({
|
||||
clientId: client.clientId,
|
||||
displayName: client.displayName,
|
||||
clientType: client.clientType,
|
||||
connectedAt: new Date(client.connectedAt).toISOString(),
|
||||
})),
|
||||
activeSessions: sessionSummaries.length,
|
||||
};
|
||||
}
|
||||
|
||||
export function broadcastHubState(ctx: HubContext): void {
|
||||
ctx.broadcast(hubStatePayload(ctx));
|
||||
ctx.broadcast(webviewSessionsPayload(ctx));
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import {
|
||||
type ClineCore,
|
||||
CORE_BUILD_VERSION,
|
||||
type HubUIClient,
|
||||
} from "@cline/core";
|
||||
import type { WebviewHubEvent } from "../webview-protocol";
|
||||
import type {
|
||||
BrowserPeer,
|
||||
PendingToolApproval,
|
||||
SessionContext,
|
||||
TrackedClient,
|
||||
TrackedSession,
|
||||
} from "./types";
|
||||
|
||||
/**
|
||||
* Shared mutable runtime state for the Cline Hub server. A single instance is
|
||||
* created in `server.ts` and threaded through the feature modules, replacing
|
||||
* what used to be a wall of module-level `let`s in the monolithic file.
|
||||
*/
|
||||
export class HubContext {
|
||||
readonly peers = new Set<BrowserPeer>();
|
||||
readonly clients = new Map<string, TrackedClient>();
|
||||
readonly sessions = new Map<string, TrackedSession>();
|
||||
readonly pendingToolApprovals = new Map<string, PendingToolApproval>();
|
||||
readonly events: WebviewHubEvent[] = [];
|
||||
|
||||
hubUrl = "";
|
||||
hubAuthToken = "";
|
||||
hubHealthy = false;
|
||||
cline: ClineCore | undefined;
|
||||
uiClient: HubUIClient | undefined;
|
||||
hubStartedAt: string | undefined;
|
||||
coreVersion: string | undefined = CORE_BUILD_VERSION;
|
||||
lastSessionContext: SessionContext | undefined;
|
||||
initialHubEventEmitted = false;
|
||||
|
||||
send(peer: BrowserPeer, payload: unknown): void {
|
||||
peer.socket.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
broadcast(payload: unknown): void {
|
||||
const data = JSON.stringify(payload);
|
||||
for (const peer of this.peers) {
|
||||
peer.socket.send(data);
|
||||
}
|
||||
}
|
||||
|
||||
pushEvent(
|
||||
title: string,
|
||||
body: string,
|
||||
severity: WebviewHubEvent["severity"] = "info",
|
||||
timestamp = Date.now(),
|
||||
): void {
|
||||
this.events.unshift({
|
||||
id: `${timestamp}-${this.events.length}-${title}`,
|
||||
title,
|
||||
body,
|
||||
severity,
|
||||
timestamp,
|
||||
});
|
||||
if (this.events.length > 30) this.events.length = 30;
|
||||
}
|
||||
|
||||
sendToSelectedPeers(sessionId: string, payload: unknown): void {
|
||||
for (const peer of this.peers) {
|
||||
if (peer.selectedSessionId === sessionId) {
|
||||
this.send(peer, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hasSelectedPeer(sessionId: string): boolean {
|
||||
for (const peer of this.peers) {
|
||||
if (peer.selectedSessionId === sessionId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import type { SaveProviderSettingsActionRequest } from "@cline/core";
|
||||
import type { ToolApprovalResult } from "@cline/shared";
|
||||
import type {
|
||||
WebviewInboundMessage,
|
||||
WebviewReasonLevel,
|
||||
} from "../webview-protocol";
|
||||
|
||||
export type BrowserFrame = WebviewInboundMessage | { type: "restart_hub" };
|
||||
|
||||
export type ProviderSettingsUpdate = Partial<
|
||||
Omit<SaveProviderSettingsActionRequest, "action" | "providerId">
|
||||
>;
|
||||
|
||||
export interface BrowserConfig {
|
||||
inviteRequired: boolean;
|
||||
publicUrl: string;
|
||||
}
|
||||
|
||||
export type TrackedClient = {
|
||||
clientId: string;
|
||||
displayName?: string;
|
||||
clientType: string;
|
||||
connectedAt: number;
|
||||
};
|
||||
|
||||
export type TrackedSession = {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
title: string;
|
||||
workspaceRoot: string;
|
||||
cwd?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
source?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdByClientId?: string;
|
||||
prompt?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCost?: number;
|
||||
agentCount: number;
|
||||
participantCount: number;
|
||||
};
|
||||
|
||||
export type SessionContext = {
|
||||
workspaceRoot: string;
|
||||
cwd: string;
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
};
|
||||
|
||||
export type BrowserPeer = {
|
||||
socket: Bun.ServerWebSocket<BrowserPeer>;
|
||||
displayName: string;
|
||||
selectedSessionId?: string;
|
||||
unsubscribeEvents?: () => void;
|
||||
sending: boolean;
|
||||
};
|
||||
|
||||
export type PendingToolApproval = {
|
||||
sessionId: string;
|
||||
resolve: (result: ToolApprovalResult) => void;
|
||||
timeout: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
export type JsonRecord = Record<string, unknown>;
|
||||
|
||||
export type { WebviewReasonLevel };
|
||||
@@ -1,70 +0,0 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { listUserInstructionConfigs } from "./user-instructions";
|
||||
|
||||
describe("listUserInstructionConfigs", () => {
|
||||
const tempRoots: string[] = [];
|
||||
const envSnapshot = {
|
||||
CLINE_GLOBAL_SETTINGS_PATH: process.env.CLINE_GLOBAL_SETTINGS_PATH,
|
||||
CLINE_MCP_SETTINGS_PATH: process.env.CLINE_MCP_SETTINGS_PATH,
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
if (envSnapshot.CLINE_GLOBAL_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH =
|
||||
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
}
|
||||
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
}
|
||||
await Promise.all(
|
||||
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
it("uses the package name for package-backed plugin entries", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cline-hub-config-"));
|
||||
tempRoots.push(tempRoot);
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(tempRoot, "settings.json");
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(tempRoot, "mcp.json");
|
||||
const packageDir = join(
|
||||
tempRoot,
|
||||
".cline",
|
||||
"plugins",
|
||||
"_installed",
|
||||
"git",
|
||||
"github.com",
|
||||
"demo",
|
||||
"package",
|
||||
);
|
||||
await mkdir(packageDir, { recursive: true });
|
||||
const pluginPath = join(packageDir, "index.ts");
|
||||
await writeFile(
|
||||
join(packageDir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "cline-sdk-portable-agents",
|
||||
cline: {
|
||||
plugins: [{ paths: ["./index.ts"] }],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await writeFile(pluginPath, "export default {};\n");
|
||||
|
||||
const data = await listUserInstructionConfigs(tempRoot);
|
||||
const plugins = data.plugins as Array<{ name: string; path: string }>;
|
||||
const plugin = plugins.find((item) => item.path === pluginPath);
|
||||
|
||||
expect(plugin?.name).toBe("cline-sdk-portable-agents");
|
||||
});
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
dirname,
|
||||
extname,
|
||||
isAbsolute,
|
||||
join,
|
||||
basename as pathBasename,
|
||||
relative,
|
||||
resolve,
|
||||
} from "node:path";
|
||||
import {
|
||||
createUserInstructionConfigService,
|
||||
discoverPluginModulePaths,
|
||||
getCoreBuiltinToolCatalog,
|
||||
listHookConfigFiles,
|
||||
listPluginTools,
|
||||
readGlobalSettings,
|
||||
resolvePluginConfigSearchPaths,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
} from "@cline/core";
|
||||
import { readMcpServersResponse } from "./mcp";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
function resolveAgentConfigSearchPaths(workspaceRoot?: string): string[] {
|
||||
return resolveSharedAgentConfigSearchPaths(workspaceRoot);
|
||||
}
|
||||
|
||||
function readPackageName(packageJsonPath: string): string | undefined {
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
|
||||
name?: unknown;
|
||||
};
|
||||
return typeof packageJson.name === "string" && packageJson.name.trim()
|
||||
? packageJson.name.trim()
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function isPathWithin(parentPath: string, childPath: string): boolean {
|
||||
const relativePath = relative(resolve(parentPath), resolve(childPath));
|
||||
return (
|
||||
relativePath === "" ||
|
||||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
function getPluginDisplayName(filePath: string, searchRoot: string): string {
|
||||
let current = dirname(filePath);
|
||||
const root = resolve(searchRoot);
|
||||
while (isPathWithin(root, current)) {
|
||||
const packageJsonPath = join(current, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageName = readPackageName(packageJsonPath);
|
||||
if (packageName) {
|
||||
return packageName;
|
||||
}
|
||||
break;
|
||||
}
|
||||
const parent = resolve(current, "..");
|
||||
if (parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
return pathBasename(filePath, extname(filePath));
|
||||
}
|
||||
|
||||
export async function listUserInstructionConfigs(
|
||||
targetWorkspaceRoot: string,
|
||||
): Promise<JsonRecord> {
|
||||
const warnings: string[] = [];
|
||||
|
||||
const loadUserInstructionSnapshot = async (
|
||||
type: "rule" | "skill" | "workflow",
|
||||
): Promise<unknown[]> => {
|
||||
const items: unknown[] = [];
|
||||
const service = createUserInstructionConfigService({
|
||||
skills: { workspacePath: targetWorkspaceRoot },
|
||||
rules: { workspacePath: targetWorkspaceRoot },
|
||||
workflows: { workspacePath: targetWorkspaceRoot },
|
||||
});
|
||||
try {
|
||||
await service.start();
|
||||
for (const record of service.listRecords(type)) {
|
||||
const item = record.item as unknown as JsonRecord;
|
||||
if (item.disabled === true) continue;
|
||||
items.push({
|
||||
id: record.id,
|
||||
name: item.name ?? record.id,
|
||||
instructions: item.instructions,
|
||||
path: record.filePath,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
warnings.push(`${type}: ${message}`);
|
||||
} finally {
|
||||
service.stop();
|
||||
}
|
||||
return items;
|
||||
};
|
||||
|
||||
const loadAgents = (): unknown[] => {
|
||||
const agentsById = new Map<string, { name: string; path: string }>();
|
||||
const directories = resolveAgentConfigSearchPaths(
|
||||
targetWorkspaceRoot,
|
||||
).filter((d) => existsSync(d));
|
||||
for (const directory of directories) {
|
||||
try {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (!entry.isFile()) continue;
|
||||
const ext = extname(entry.name).toLowerCase();
|
||||
if (ext !== ".yml" && ext !== ".yaml") continue;
|
||||
const filePath = join(directory, entry.name);
|
||||
const raw = readFileSync(filePath, "utf8");
|
||||
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
const fm = fmMatch?.[1] ?? "";
|
||||
const nameMatch = fm.match(/^\s*name:\s*(.+?)\s*$/m);
|
||||
const parsedName = nameMatch?.[1]?.replace(/^["']|["']$/g, "").trim();
|
||||
const name =
|
||||
parsedName && parsedName.length > 0
|
||||
? parsedName
|
||||
: pathBasename(entry.name, ext);
|
||||
const id = name.toLowerCase();
|
||||
if (!agentsById.has(id)) {
|
||||
agentsById.set(id, { name, path: filePath });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
return [...agentsById.values()].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
};
|
||||
|
||||
const loadHooks = (): unknown[] => {
|
||||
try {
|
||||
return listHookConfigFiles(targetWorkspaceRoot);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
warnings.push(`hooks: ${message}`);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const loadPlugins = (): Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
}> => {
|
||||
const disabledPlugins = new Set(readGlobalSettings().disabledPlugins ?? []);
|
||||
const pluginsByPath = new Map<
|
||||
string,
|
||||
{ name: string; path: string; enabled: boolean }
|
||||
>();
|
||||
const directories = resolvePluginConfigSearchPaths(
|
||||
targetWorkspaceRoot,
|
||||
).filter((d) => existsSync(d));
|
||||
for (const directory of directories) {
|
||||
try {
|
||||
for (const filePath of discoverPluginModulePaths(directory)) {
|
||||
if (pluginsByPath.has(filePath)) continue;
|
||||
pluginsByPath.set(filePath, {
|
||||
name: getPluginDisplayName(filePath, directory),
|
||||
path: filePath,
|
||||
enabled: !disabledPlugins.has(filePath),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
return [...pluginsByPath.values()].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
};
|
||||
|
||||
const [rules, workflows, skills, pluginTools] = await Promise.all([
|
||||
loadUserInstructionSnapshot("rule"),
|
||||
loadUserInstructionSnapshot("workflow"),
|
||||
loadUserInstructionSnapshot("skill"),
|
||||
listPluginTools({
|
||||
workspacePath: targetWorkspaceRoot,
|
||||
cwd: targetWorkspaceRoot,
|
||||
}),
|
||||
]);
|
||||
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
|
||||
const builtinToolCatalog = getCoreBuiltinToolCatalog({
|
||||
disabledToolIds: disabledTools,
|
||||
});
|
||||
|
||||
return {
|
||||
workspaceRoot: targetWorkspaceRoot,
|
||||
rules,
|
||||
workflows,
|
||||
skills,
|
||||
agents: loadAgents(),
|
||||
plugins: loadPlugins(),
|
||||
tools: [
|
||||
...builtinToolCatalog.map((tool) => ({
|
||||
id: tool.id,
|
||||
name: tool.id,
|
||||
description: tool.description,
|
||||
enabled:
|
||||
tool.defaultEnabled &&
|
||||
!tool.headlessToolNames.some((name) => disabledTools.has(name)),
|
||||
source: "builtin",
|
||||
headlessToolNames: tool.headlessToolNames,
|
||||
})),
|
||||
...pluginTools.map((tool) => ({
|
||||
id: `${tool.pluginName}:${tool.name}:${tool.path}`,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
enabled: tool.enabled,
|
||||
source: tool.source,
|
||||
path: tool.path,
|
||||
pluginName: tool.pluginName,
|
||||
})),
|
||||
],
|
||||
hooks: loadHooks(),
|
||||
mcp: readMcpServersResponse(),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import process from "node:process";
|
||||
import type { ProviderSettingsUpdate } from "./types";
|
||||
|
||||
export function readProviderSettingsUpdate(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): ProviderSettingsUpdate {
|
||||
return args?.settings && typeof args.settings === "object"
|
||||
? (args.settings as ProviderSettingsUpdate)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
export function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function asTimestamp(value: unknown): number | undefined {
|
||||
const numeric = asNumber(value);
|
||||
if (numeric !== undefined) return numeric;
|
||||
if (typeof value !== "string" || !value.trim()) return undefined;
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
}
|
||||
|
||||
export function basename(value: string | undefined): string {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) return "workspace";
|
||||
const parts = trimmed.split(/[\\/]+/).filter(Boolean);
|
||||
return parts.at(-1) ?? trimmed;
|
||||
}
|
||||
|
||||
export function toPositiveInt(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
||||
const rounded = Math.trunc(value);
|
||||
return rounded > 0 ? rounded : undefined;
|
||||
}
|
||||
|
||||
export function asTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function isVisibleClient(clientType: string): boolean {
|
||||
return clientType.trim().length > 0;
|
||||
}
|
||||
|
||||
export function isActiveSession(
|
||||
title: string | undefined,
|
||||
status: string | undefined,
|
||||
participantCount?: number,
|
||||
): boolean {
|
||||
if (!title || !status) return false;
|
||||
const normalized = status?.trim().toLowerCase();
|
||||
if (normalized !== "running" && normalized !== "idle") return false;
|
||||
return typeof participantCount === "number" ? participantCount > 0 : false;
|
||||
}
|
||||
|
||||
export function formatUptime(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const d = Math.floor(total / 86_400);
|
||||
const h = Math.floor((total % 86_400) / 3_600);
|
||||
const m = Math.floor((total % 3_600) / 60);
|
||||
const s = total % 60;
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m ${s}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
export function formatClientLabel(clientType: string | undefined): string {
|
||||
const normalized = clientType?.trim().toLowerCase() ?? "";
|
||||
if (!normalized || normalized === "unknown") return "Client";
|
||||
if (normalized.includes("cline")) return "Cline";
|
||||
return normalized
|
||||
.split(/[-_\s]+/)
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function stringifyContent(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (Array.isArray(value)) {
|
||||
return value
|
||||
.map((entry) => {
|
||||
if (typeof entry === "string") return entry;
|
||||
if (entry && typeof entry === "object") {
|
||||
const record = entry as Record<string, unknown>;
|
||||
return (
|
||||
asString(record.text) ??
|
||||
asString(record.content) ??
|
||||
asString(record.result) ??
|
||||
""
|
||||
);
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
if (value == null) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
export function chunkText(chunk: unknown): string {
|
||||
if (typeof chunk === "string") return chunk;
|
||||
if (chunk && typeof chunk === "object") {
|
||||
const record = chunk as Record<string, unknown>;
|
||||
if (typeof record.text === "string") return record.text;
|
||||
if (typeof record.content === "string") return record.content;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export function openExternalUrl(url: string): void {
|
||||
const platform = process.platform;
|
||||
const command =
|
||||
platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
||||
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
||||
const child = spawn(command, args, {
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
// Prevent a console window from flashing on Windows; the launched
|
||||
// browser/app still opens normally.
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { buildInviteUrl, resolveClineHubServerOptions } from "./options";
|
||||
|
||||
function expectEqual<T>(actual: T, expected: T, label: string): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`${label}: expected ${String(expected)}, got ${String(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function expectThrows(fn: () => unknown, label: string): void {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
throw new Error(`${label}: expected an error`);
|
||||
}
|
||||
|
||||
const defaults = resolveClineHubServerOptions({});
|
||||
expectEqual(defaults.host, "127.0.0.1", "default host");
|
||||
expectEqual(defaults.port, 8787, "default port");
|
||||
expectEqual(defaults.publicUrl, "http://127.0.0.1:8787", "default public URL");
|
||||
expectEqual(defaults.roomSecret, undefined, "default room secret");
|
||||
|
||||
const lan = resolveClineHubServerOptions({
|
||||
HOST: "0.0.0.0",
|
||||
CLINE_HUB_DASHBOARD_PORT: "9000",
|
||||
PUBLIC_URL: "https://example.ngrok-free.app/",
|
||||
ROOM_SECRET: "invite-123",
|
||||
WORKSPACE_ROOT: "/tmp/workspace",
|
||||
});
|
||||
expectEqual(lan.host, "0.0.0.0", "LAN host");
|
||||
expectEqual(lan.port, 9000, "LAN port");
|
||||
expectEqual(lan.publicUrl, "https://example.ngrok-free.app", "LAN public URL");
|
||||
expectEqual(lan.roomSecret, "invite-123", "LAN room secret");
|
||||
expectEqual(lan.workspaceRoot, "/tmp/workspace", "workspace root");
|
||||
expectEqual(
|
||||
buildInviteUrl(lan.publicUrl, lan.roomSecret),
|
||||
"https://example.ngrok-free.app/?roomSecret=invite-123",
|
||||
"invite URL",
|
||||
);
|
||||
|
||||
const tailscale = resolveClineHubServerOptions({
|
||||
HOST: "0.0.0.0",
|
||||
CLINE_HUB_DASHBOARD_PORT: "8787",
|
||||
PUBLIC_URL: "http://100.82.5.118",
|
||||
ROOM_SECRET: "invite-123",
|
||||
});
|
||||
expectEqual(
|
||||
tailscale.publicUrl,
|
||||
"http://100.82.5.118:8787",
|
||||
"direct IP public URL gets dashboard port",
|
||||
);
|
||||
expectEqual(
|
||||
buildInviteUrl(tailscale.publicUrl, tailscale.roomSecret),
|
||||
"http://100.82.5.118:8787/?roomSecret=invite-123",
|
||||
"invite URL for direct IP public URL",
|
||||
);
|
||||
|
||||
expectThrows(
|
||||
() => resolveClineHubServerOptions({ HOST: "0.0.0.0" }),
|
||||
"non-local bind without ROOM_SECRET",
|
||||
);
|
||||
expectThrows(
|
||||
() => resolveClineHubServerOptions({ CLINE_HUB_DASHBOARD_PORT: "70000" }),
|
||||
"invalid dashboard port",
|
||||
);
|
||||
expectThrows(
|
||||
() => resolveClineHubServerOptions({ PUBLIC_URL: "ftp://example.test" }),
|
||||
"invalid PUBLIC_URL protocol",
|
||||
);
|
||||
|
||||
console.log("cline-hub option validation passed");
|
||||
@@ -1,344 +0,0 @@
|
||||
import type {
|
||||
ChatMessage as CoreChatMessage,
|
||||
ProviderListItem,
|
||||
ProviderModel,
|
||||
} from "@cline/core";
|
||||
|
||||
export type WebviewUsage = {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
cacheCreationInputTokens?: number;
|
||||
cacheReadInputTokens?: number;
|
||||
totalCost?: number;
|
||||
};
|
||||
|
||||
export type WebviewProviderModel = Pick<
|
||||
ProviderModel,
|
||||
"id" | "name" | "supportsReasoning"
|
||||
> & {
|
||||
supportsThinking?: boolean;
|
||||
};
|
||||
|
||||
export type WebviewProviderCatalogItem = ProviderListItem;
|
||||
|
||||
export type WebviewReasonLevel = "none" | "low" | "medium" | "high";
|
||||
|
||||
export type WebviewToolEvent = {
|
||||
toolCallId?: string;
|
||||
toolName?: string;
|
||||
status: "running" | "completed" | "failed";
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type WebviewChatMessageBlock =
|
||||
| { id: string; type: "text"; text: string }
|
||||
| { id: string; type: "reasoning"; text: string; redacted?: boolean }
|
||||
| {
|
||||
id: string;
|
||||
type: "tool";
|
||||
toolEvent: NonNullable<WebviewChatMessage["toolEvents"]>[number];
|
||||
};
|
||||
|
||||
export type WebviewChatMessage = Omit<
|
||||
CoreChatMessage,
|
||||
"content" | "createdAt" | "meta" | "role" | "sessionId"
|
||||
> & {
|
||||
role:
|
||||
| Extract<CoreChatMessage["role"], "user" | "assistant" | "error">
|
||||
| "meta";
|
||||
text: string;
|
||||
reasoning?: string;
|
||||
reasoningRedacted?: boolean;
|
||||
checkpoint?: NonNullable<CoreChatMessage["meta"]>["checkpoint"];
|
||||
toolEvents?: Array<{
|
||||
id: string;
|
||||
toolCallId?: string;
|
||||
name: string;
|
||||
text: string;
|
||||
state: "input-available" | "output-available" | "output-error";
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
}>;
|
||||
blocks?: WebviewChatMessageBlock[];
|
||||
};
|
||||
|
||||
export type WebviewConfig = {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
mode?: "act" | "plan";
|
||||
systemPrompt?: string;
|
||||
maxIterations?: number;
|
||||
reasonLevel?: WebviewReasonLevel;
|
||||
enableTools?: boolean;
|
||||
enableSpawn?: boolean;
|
||||
enableTeams?: boolean;
|
||||
autoApproveTools?: boolean;
|
||||
};
|
||||
|
||||
export type WebviewChatAttachments = {
|
||||
userImages?: string[];
|
||||
};
|
||||
|
||||
export type WebviewToolApprovalRequest = {
|
||||
approvalId: string;
|
||||
sessionId: string;
|
||||
agentId: string;
|
||||
conversationId: string;
|
||||
iteration: number;
|
||||
toolCallId: string;
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
policy?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type WebviewDefaults = {
|
||||
provider?: string;
|
||||
model?: string;
|
||||
workspaceRoot: string;
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
export type WebviewSessionSummary = {
|
||||
sessionId: string;
|
||||
title?: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
workspaceRoot?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCost?: number;
|
||||
};
|
||||
|
||||
export type WebviewConnectedClient = {
|
||||
clientId: string;
|
||||
displayName?: string;
|
||||
clientType: string;
|
||||
connectedAt: number;
|
||||
};
|
||||
|
||||
export type WebviewClientSummary = {
|
||||
label: string;
|
||||
name: string;
|
||||
sessionCount: number;
|
||||
};
|
||||
|
||||
export type WebviewConnectorField = {
|
||||
flag: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type WebviewConnectorSecurityField = {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
help?: string[];
|
||||
requiredMessage: string;
|
||||
};
|
||||
|
||||
export type WebviewConnectorChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: WebviewConnectorField[];
|
||||
security?: {
|
||||
prompt: string;
|
||||
fields: WebviewConnectorSecurityField[];
|
||||
};
|
||||
};
|
||||
|
||||
export type WebviewActiveConnector = {
|
||||
id: string;
|
||||
type: string;
|
||||
pid: number;
|
||||
hubUrl: string;
|
||||
startedAt?: string;
|
||||
applicationId?: string;
|
||||
botUsername?: string;
|
||||
userName?: string;
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
connectionMode?: string;
|
||||
};
|
||||
|
||||
export type WebviewConnectorChannelsResponse = {
|
||||
available: WebviewConnectorChannel[];
|
||||
active: WebviewActiveConnector[];
|
||||
};
|
||||
|
||||
export type WebviewActionSessionSummary = {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
workspaceRoot: string;
|
||||
workspaceName: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdByClientId?: string;
|
||||
prompt?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCost?: number;
|
||||
agentCount: number;
|
||||
};
|
||||
|
||||
export type WebviewHubEvent = {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
severity: "info" | "success" | "warn" | "error";
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type WebviewHubState = {
|
||||
type: "hub_state";
|
||||
connected: boolean;
|
||||
hubUrl?: string;
|
||||
hubStartedAt?: string;
|
||||
coreVersion?: string;
|
||||
hubUptime?: string;
|
||||
clients: WebviewConnectedClient[];
|
||||
connectors: WebviewActiveConnector[];
|
||||
sessions: WebviewActionSessionSummary[];
|
||||
clientSummaries: WebviewClientSummary[];
|
||||
sessionSummaries: WebviewActionSessionSummary[];
|
||||
events: WebviewHubEvent[];
|
||||
lastWorkspaceRoot?: string;
|
||||
};
|
||||
|
||||
export type WebviewInboundMessage =
|
||||
| { type: "ready" }
|
||||
| { type: "restart_hub" }
|
||||
| {
|
||||
type: "desktopCommand";
|
||||
id: string;
|
||||
command: string;
|
||||
args?: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
type: "send";
|
||||
prompt: string;
|
||||
config?: WebviewConfig;
|
||||
attachments?: WebviewChatAttachments;
|
||||
}
|
||||
| { type: "abort" }
|
||||
| { type: "reset" }
|
||||
| {
|
||||
type: "approval_response";
|
||||
approvalId: string;
|
||||
approved: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
| { type: "loadModels"; providerId: string }
|
||||
| { type: "loadProviderCatalog" }
|
||||
| {
|
||||
type: "saveProviderSettings";
|
||||
providerId: string;
|
||||
enabled?: boolean;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
}
|
||||
| { type: "runProviderOAuthLogin"; providerId: string }
|
||||
| { type: "attachSession"; sessionId: string }
|
||||
| { type: "deleteSession"; sessionId: string }
|
||||
| {
|
||||
type: "updateSessionMetadata";
|
||||
sessionId: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
| { type: "restore"; checkpointRunCount: number }
|
||||
| { type: "forkSession" };
|
||||
|
||||
export type WebviewOutboundMessage =
|
||||
| { type: "status"; text: string }
|
||||
| { type: "error"; text: string }
|
||||
| {
|
||||
type: "desktopCommandResult";
|
||||
id: string;
|
||||
ok: true;
|
||||
result: unknown;
|
||||
}
|
||||
| {
|
||||
type: "desktopCommandResult";
|
||||
id: string;
|
||||
ok: false;
|
||||
error: string;
|
||||
}
|
||||
| { type: "session_started"; sessionId: string }
|
||||
| {
|
||||
type: "session_hydrated";
|
||||
sessionId: string;
|
||||
status?: string;
|
||||
providerId?: string;
|
||||
modelId?: string;
|
||||
messages: WebviewChatMessage[];
|
||||
}
|
||||
| { type: "assistant_delta"; text: string }
|
||||
| { type: "reasoning_delta"; text: string; redacted?: boolean }
|
||||
| { type: "tool_event"; text: string; event?: WebviewToolEvent }
|
||||
| ({ type: "approval_request" } & WebviewToolApprovalRequest)
|
||||
| {
|
||||
type: "approval_resolved";
|
||||
approvalId: string;
|
||||
approved: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
| {
|
||||
type: "turn_done";
|
||||
finishReason: string;
|
||||
iterations: number;
|
||||
usage?: WebviewUsage;
|
||||
}
|
||||
| {
|
||||
type: "providers";
|
||||
providers: Array<
|
||||
Pick<ProviderListItem, "defaultModelId" | "enabled" | "id" | "name">
|
||||
>;
|
||||
}
|
||||
| {
|
||||
type: "provider_catalog";
|
||||
providers: WebviewProviderCatalogItem[];
|
||||
settingsPath: string;
|
||||
}
|
||||
| {
|
||||
type: "provider_settings_saved";
|
||||
providerId: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
| {
|
||||
type: "provider_oauth_login_done";
|
||||
providerId: string;
|
||||
accessTokenPresent: boolean;
|
||||
}
|
||||
| { type: "models"; providerId: string; models: WebviewProviderModel[] }
|
||||
| { type: "sessions"; sessions: WebviewSessionSummary[] }
|
||||
| WebviewHubState
|
||||
| { type: "defaults"; defaults: WebviewDefaults }
|
||||
| { type: "reset_done" }
|
||||
| {
|
||||
type: "fork_done";
|
||||
forkedFromSessionId: string;
|
||||
newSessionId: string;
|
||||
}
|
||||
| { type: "fork_error"; text: string };
|
||||
@@ -1,15 +0,0 @@
|
||||
# v0 sandbox internal files
|
||||
__v0_runtime_loader.js
|
||||
__v0_devtools.tsx
|
||||
__v0_jsx-dev-runtime.ts
|
||||
.snowflake/
|
||||
.v0-trash/
|
||||
.vercel/
|
||||
|
||||
# Environment variables
|
||||
.env*.local
|
||||
|
||||
# Common ignores
|
||||
node_modules
|
||||
.next/
|
||||
.DS_Store
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/cline-logo-filled.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cline Hub</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,64 +0,0 @@
|
||||
{
|
||||
"name": "@cline/cline-hub-webview",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@rive-app/react-webgl2": "^4.27.2",
|
||||
"@shikijs/langs": "^4.2.0",
|
||||
"@shikijs/themes": "^4.2.0",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"ai": "^6.0.116",
|
||||
"ansi-to-react": "^6.2.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"media-chrome": "^4.18.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-jsx-parser": "^2.4.1",
|
||||
"recharts": "2.15.4",
|
||||
"shadcn": "^4.0.8",
|
||||
"shiki": "^4.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^2.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
"use-stick-to-bottom": "^1.1.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@types/node": "^24.12.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||
"esbuild": "^0.27.0",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^8.0.0"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 487.04">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<path d="M463.6,275.08l-29.26-58.75v-33.83c0-56.08-45.01-101.5-100.53-101.5h-50.01c3.62-7.43,5.61-15.79,5.61-24.61,0-31.17-25.08-56.39-56.07-56.39s-56.07,25.22-56.07,56.39c0,8.82,1.99,17.17,5.61,24.61h-50.01c-55.51,0-100.52,45.42-100.52,101.5v33.83l-29.87,58.59c-3.01,5.9-3.01,12.92,0,18.81l29.87,57.93v33.83c0,56.08,45.01,101.5,100.52,101.5h200.95c55.51,0,100.53-45.42,100.53-101.5v-33.83l29.21-58.13c2.9-5.79,2.9-12.61.05-18.46ZM202.75,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02ZM350.58,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 957 B |
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,566 +0,0 @@
|
||||
import {
|
||||
CheckIcon,
|
||||
HatGlassesIcon,
|
||||
PaperclipIcon,
|
||||
PlayIcon,
|
||||
Settings2Icon,
|
||||
SignalHigh,
|
||||
SignalLow,
|
||||
SignalMedium,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentPreview,
|
||||
AttachmentRemove,
|
||||
Attachments,
|
||||
} from "@/components/ai-elements/attachments";
|
||||
import {
|
||||
ModelSelector,
|
||||
ModelSelectorContent,
|
||||
ModelSelectorEmpty,
|
||||
ModelSelectorGroup,
|
||||
ModelSelectorInput,
|
||||
ModelSelectorItem,
|
||||
ModelSelectorList,
|
||||
ModelSelectorLogo,
|
||||
ModelSelectorLogoGroup,
|
||||
ModelSelectorName,
|
||||
ModelSelectorTrigger,
|
||||
} from "@/components/ai-elements/model-selector";
|
||||
import type { PromptInputMessage } from "@/components/ai-elements/prompt-input";
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputBody,
|
||||
PromptInputButton,
|
||||
PromptInputFooter,
|
||||
PromptInputHeader,
|
||||
PromptInputSubmit,
|
||||
PromptInputTextarea,
|
||||
PromptInputTools,
|
||||
usePromptInputAttachments,
|
||||
usePromptInputController,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type {
|
||||
WebviewChatAttachments,
|
||||
WebviewOutboundMessage,
|
||||
WebviewProviderModel,
|
||||
WebviewReasonLevel,
|
||||
} from "../../../webview-protocol";
|
||||
|
||||
type ProviderOption = Extract<
|
||||
WebviewOutboundMessage,
|
||||
{ type: "providers" }
|
||||
>["providers"][number];
|
||||
|
||||
function PromptAttachmentsDisplay() {
|
||||
const attachments = usePromptInputAttachments();
|
||||
|
||||
if (attachments.files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Attachments variant="inline">
|
||||
{attachments.files.map((attachment) => (
|
||||
<Attachment
|
||||
data={attachment}
|
||||
key={attachment.id}
|
||||
onRemove={() => attachments.remove(attachment.id)}
|
||||
>
|
||||
<AttachmentPreview />
|
||||
<AttachmentRemove />
|
||||
</Attachment>
|
||||
))}
|
||||
</Attachments>
|
||||
);
|
||||
}
|
||||
|
||||
function ComposerSettings({
|
||||
autoApproveTools,
|
||||
enableSpawn,
|
||||
enableTeams,
|
||||
model,
|
||||
modelSelectorOpen,
|
||||
models,
|
||||
onAutoApproveToolsChange,
|
||||
onEnableSpawnChange,
|
||||
onEnableTeamsChange,
|
||||
onModelChange,
|
||||
onModelSelectorOpenChange,
|
||||
onProviderChange,
|
||||
provider,
|
||||
providers,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
autoApproveTools: boolean;
|
||||
enableSpawn: boolean;
|
||||
enableTeams: boolean;
|
||||
enableTools: boolean;
|
||||
maxIterations: string;
|
||||
model: string;
|
||||
modelSelectorOpen: boolean;
|
||||
models: WebviewProviderModel[];
|
||||
onAutoApproveToolsChange: (value: boolean) => void;
|
||||
onEnableSpawnChange: (value: boolean) => void;
|
||||
onEnableTeamsChange: (value: boolean) => void;
|
||||
onEnableToolsChange: (value: boolean) => void;
|
||||
onMaxIterationsChange: (value: string) => void;
|
||||
onModelChange: (value: string) => void;
|
||||
onModelSelectorOpenChange: (value: boolean) => void;
|
||||
onProviderChange: (value: string) => void;
|
||||
onSystemPromptChange: (value: string) => void;
|
||||
provider: string;
|
||||
providers: ProviderOption[];
|
||||
systemPrompt: string;
|
||||
workspaceRoot: string;
|
||||
}) {
|
||||
const selectedProvider = providers.find((item) => item.id === provider);
|
||||
const selectedModel =
|
||||
models.find((item) => item.id === model) ?? models[0] ?? undefined;
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 bg-background/70 p-3">
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Provider
|
||||
</Label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
onProviderChange(value);
|
||||
}
|
||||
}}
|
||||
value={provider}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((item) => (
|
||||
<SelectItem key={item.id} value={item.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
{renderProviderLogo(item.id)}
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Model
|
||||
</Label>
|
||||
<ModelSelector
|
||||
onOpenChange={onModelSelectorOpenChange}
|
||||
open={modelSelectorOpen}
|
||||
>
|
||||
<ModelSelectorTrigger>
|
||||
<Button className="w-full justify-between" variant="outline">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{selectedProvider && renderProviderLogo(selectedProvider.id)}
|
||||
<span className="truncate">
|
||||
{selectedModel?.name || selectedModel?.id || "Select model"}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
</ModelSelectorTrigger>
|
||||
<ModelSelectorContent>
|
||||
<ModelSelectorInput placeholder="Search models..." />
|
||||
<ModelSelectorList>
|
||||
<ModelSelectorEmpty>No models found.</ModelSelectorEmpty>
|
||||
<ModelSelectorGroup
|
||||
heading={selectedProvider?.name || "Models"}
|
||||
>
|
||||
{models.map((item) => (
|
||||
<ModelSelectorItem
|
||||
key={item.id}
|
||||
onSelect={() => {
|
||||
onModelChange(item.id);
|
||||
onModelSelectorOpenChange(false);
|
||||
}}
|
||||
value={item.id}
|
||||
>
|
||||
{selectedProvider &&
|
||||
renderProviderLogo(selectedProvider.id)}
|
||||
<ModelSelectorName>
|
||||
{item.name || item.id}
|
||||
</ModelSelectorName>
|
||||
<ModelSelectorLogoGroup>
|
||||
{selectedProvider &&
|
||||
renderProviderLogo(selectedProvider.id)}
|
||||
</ModelSelectorLogoGroup>
|
||||
{model === item.id ? (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
) : (
|
||||
<div className="ml-auto size-4" />
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
))}
|
||||
</ModelSelectorGroup>
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelector>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label
|
||||
className="text-xs uppercase tracking-[0.16em] text-muted-foreground"
|
||||
htmlFor="workspace-root"
|
||||
>
|
||||
Workspace
|
||||
</Label>
|
||||
<Input id="workspace-root" readOnly value={workspaceRoot} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 md:grid-cols-2">
|
||||
<Toggle
|
||||
checked={enableSpawn}
|
||||
label="Subagents"
|
||||
onChange={onEnableSpawnChange}
|
||||
/>
|
||||
<Toggle
|
||||
checked={enableTeams}
|
||||
label="Agent Teams"
|
||||
onChange={onEnableTeamsChange}
|
||||
/>
|
||||
<Toggle
|
||||
checked={autoApproveTools}
|
||||
label="Auto-approves"
|
||||
onChange={onAutoApproveToolsChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderProviderLogo(providerId: string) {
|
||||
return (
|
||||
<ModelSelectorLogo className="size-3.5" provider={providerId || "openai"} />
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({
|
||||
checked,
|
||||
label,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
checked: boolean;
|
||||
label: string;
|
||||
onChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between rounded-lg border bg-background/60 px-3 py-2">
|
||||
<Label className="text-sm" htmlFor={label}>
|
||||
{label}
|
||||
</Label>
|
||||
<Switch
|
||||
checked={checked}
|
||||
id={label}
|
||||
onCheckedChange={(value) => onChange(value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const ReasonLevel = {
|
||||
None: "none",
|
||||
Low: "low",
|
||||
Medium: "medium",
|
||||
High: "high",
|
||||
} as const;
|
||||
|
||||
const reasonLevels = [
|
||||
{ value: ReasonLevel.None, label: "Thinking Off", icon: SignalHigh },
|
||||
{ value: ReasonLevel.Low, label: "Low", icon: SignalLow },
|
||||
{ value: ReasonLevel.Medium, label: "Medium", icon: SignalMedium },
|
||||
{ value: ReasonLevel.High, label: "High", icon: SignalHigh },
|
||||
];
|
||||
|
||||
export function Composer({
|
||||
autoApproveTools,
|
||||
disabled = false,
|
||||
enableSpawn,
|
||||
enableTeams,
|
||||
enableTools,
|
||||
maxIterations,
|
||||
model,
|
||||
mode,
|
||||
modelSelectorOpen,
|
||||
models,
|
||||
onAbort,
|
||||
onAutoApproveToolsChange,
|
||||
onEnableSpawnChange,
|
||||
onEnableTeamsChange,
|
||||
onEnableToolsChange,
|
||||
onModeChange,
|
||||
onMaxIterationsChange,
|
||||
onModelChange,
|
||||
onModelSelectorOpenChange,
|
||||
onProviderChange,
|
||||
onSend,
|
||||
onSystemPromptChange,
|
||||
onReasonLevelChange,
|
||||
provider,
|
||||
providers,
|
||||
sending,
|
||||
status,
|
||||
systemPrompt,
|
||||
reasonLevel,
|
||||
workspaceRoot,
|
||||
}: {
|
||||
autoApproveTools: boolean;
|
||||
disabled?: boolean;
|
||||
enableSpawn: boolean;
|
||||
enableTeams: boolean;
|
||||
enableTools: boolean;
|
||||
maxIterations: string;
|
||||
model: string;
|
||||
mode: "act" | "plan";
|
||||
modelSelectorOpen: boolean;
|
||||
models: WebviewProviderModel[];
|
||||
onAbort: () => void;
|
||||
onAutoApproveToolsChange: (value: boolean) => void;
|
||||
onEnableSpawnChange: (value: boolean) => void;
|
||||
onEnableTeamsChange: (value: boolean) => void;
|
||||
onEnableToolsChange: (value: boolean) => void;
|
||||
onModeChange: (value: "act" | "plan") => void;
|
||||
onMaxIterationsChange: (value: string) => void;
|
||||
onModelChange: (value: string) => void;
|
||||
onModelSelectorOpenChange: (value: boolean) => void;
|
||||
onProviderChange: (value: string) => void;
|
||||
onSend: (input: {
|
||||
prompt: string;
|
||||
attachments?: WebviewChatAttachments;
|
||||
attachmentCount: number;
|
||||
}) => void;
|
||||
onSystemPromptChange: (value: string) => void;
|
||||
onReasonLevelChange: (value: WebviewReasonLevel) => void;
|
||||
provider: string;
|
||||
providers: ProviderOption[];
|
||||
sending: boolean;
|
||||
status: string;
|
||||
systemPrompt: string;
|
||||
reasonLevel: WebviewReasonLevel;
|
||||
workspaceRoot: string;
|
||||
}) {
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const controller = usePromptInputController();
|
||||
const attachments = usePromptInputAttachments();
|
||||
const selectedModel = models.find((item) => item.id === model);
|
||||
const thinkingSupported = selectedModel?.supportsThinking === true;
|
||||
const activeReasonLevel = thinkingSupported ? reasonLevel : ReasonLevel.None;
|
||||
const reasonLevelOption = Math.max(
|
||||
reasonLevels.findIndex((item) => item.value === activeReasonLevel),
|
||||
0,
|
||||
);
|
||||
const ReasonIcon = reasonLevels[reasonLevelOption].icon;
|
||||
|
||||
return (
|
||||
<div className="border-t bg-background">
|
||||
<PromptInput
|
||||
accept="image/*,.txt,.md,.json,.ts,.tsx,.js,.jsx"
|
||||
globalDrop
|
||||
className="rounded-none [&>[data-slot=input-group]]:border-0! [&>[data-slot=input-group]]:ring-0! [&>[data-slot=input-group]]:has-[[data-slot=input-group-control]:focus-visible]:border-0! [&>[data-slot=input-group]]:has-[[data-slot=input-group-control]:focus-visible]:ring-0!"
|
||||
maxFiles={8}
|
||||
multiple
|
||||
onError={(error) => toast.error(error.message)}
|
||||
onSubmit={async (message: PromptInputMessage) => {
|
||||
const prompt = message.text.trim();
|
||||
if (!prompt && !message.files.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
let attachments: WebviewChatAttachments | undefined;
|
||||
if (message.files.length > 0) {
|
||||
const userImages = (
|
||||
await Promise.all(
|
||||
message.files.map((file) => toImageDataUrl(file.url)),
|
||||
)
|
||||
).filter((value): value is string => Boolean(value));
|
||||
if (userImages.length > 0) {
|
||||
attachments = { userImages };
|
||||
}
|
||||
if (userImages.length !== message.files.length) {
|
||||
toast.warning(
|
||||
"Only image attachments are currently sent in the VS Code chat runtime.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!prompt && !attachments?.userImages?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSend({
|
||||
prompt,
|
||||
attachments,
|
||||
attachmentCount: message.files.length,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<PromptInputHeader>
|
||||
<PromptAttachmentsDisplay />
|
||||
</PromptInputHeader>
|
||||
<PromptInputBody>
|
||||
<PromptInputTextarea
|
||||
disabled={disabled || status.includes("Failed")}
|
||||
onChange={(event) =>
|
||||
controller.textInput.setInput(event.target.value)
|
||||
}
|
||||
placeholder="Type @ for context and / for skills"
|
||||
value={controller.textInput.value}
|
||||
className="text-sm outline-none ring-0"
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter className="flex-col items-stretch gap-1 px-0">
|
||||
{settingsOpen ? (
|
||||
<ComposerSettings
|
||||
autoApproveTools={autoApproveTools}
|
||||
enableSpawn={enableSpawn}
|
||||
enableTeams={enableTeams}
|
||||
enableTools={enableTools}
|
||||
maxIterations={maxIterations}
|
||||
model={model}
|
||||
modelSelectorOpen={modelSelectorOpen}
|
||||
models={models}
|
||||
onAutoApproveToolsChange={onAutoApproveToolsChange}
|
||||
onEnableSpawnChange={onEnableSpawnChange}
|
||||
onEnableTeamsChange={onEnableTeamsChange}
|
||||
onEnableToolsChange={onEnableToolsChange}
|
||||
onMaxIterationsChange={onMaxIterationsChange}
|
||||
onModelChange={onModelChange}
|
||||
onModelSelectorOpenChange={onModelSelectorOpenChange}
|
||||
onProviderChange={onProviderChange}
|
||||
onSystemPromptChange={onSystemPromptChange}
|
||||
provider={provider}
|
||||
providers={providers}
|
||||
systemPrompt={systemPrompt}
|
||||
workspaceRoot={workspaceRoot}
|
||||
/>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<PromptInputTools className="shrink-0">
|
||||
<PromptInputButton
|
||||
disabled={disabled}
|
||||
onClick={() => attachments.openFileDialog()}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<PaperclipIcon className="size-3" />
|
||||
</PromptInputButton>
|
||||
<PromptInputButton
|
||||
disabled={disabled}
|
||||
onClick={() => setSettingsOpen((open) => !open)}
|
||||
type="button"
|
||||
variant={settingsOpen ? "default" : "ghost"}
|
||||
>
|
||||
<Settings2Icon className="size-3" />
|
||||
<span>
|
||||
{provider}:{model}
|
||||
</span>
|
||||
</PromptInputButton>
|
||||
<PromptInputButton
|
||||
disabled={disabled || !thinkingSupported}
|
||||
onClick={() => {
|
||||
const nextOption =
|
||||
(reasonLevelOption + 1) % reasonLevels.length;
|
||||
onReasonLevelChange(reasonLevels[nextOption].value);
|
||||
}}
|
||||
type="button"
|
||||
title={reasonLevels[reasonLevelOption].label}
|
||||
variant={
|
||||
activeReasonLevel !== ReasonLevel.None ? "default" : "ghost"
|
||||
}
|
||||
>
|
||||
<ReasonIcon className="size-3" />
|
||||
</PromptInputButton>
|
||||
<PromptInputButton
|
||||
disabled={disabled}
|
||||
onClick={() => onModeChange(mode === "act" ? "plan" : "act")}
|
||||
type="button"
|
||||
variant={mode === "plan" ? "default" : "ghost"}
|
||||
className="hidden"
|
||||
>
|
||||
{mode === "act" ? (
|
||||
<PlayIcon className="size-3" />
|
||||
) : (
|
||||
<HatGlassesIcon className="size-3" />
|
||||
)}
|
||||
{mode}
|
||||
</PromptInputButton>
|
||||
<Badge
|
||||
className="rounded-sm px-3 py-1 text-xs hidden"
|
||||
variant={status.includes("Error") ? "destructive" : "secondary"}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
</PromptInputTools>
|
||||
<div className="flex items-center gap-2">
|
||||
{sending ? (
|
||||
<Button onClick={onAbort} type="button" variant="destructive">
|
||||
Abort
|
||||
</Button>
|
||||
) : null}
|
||||
<PromptInputSubmit
|
||||
disabled={disabled || status.includes("Failed")}
|
||||
status={sending ? "submitted" : "ready"}
|
||||
variant="ghost"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
</PromptInput>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function toImageDataUrl(
|
||||
url: string | undefined,
|
||||
): Promise<string | undefined> {
|
||||
if (!url) {
|
||||
return undefined;
|
||||
}
|
||||
if (url.startsWith("data:image/")) {
|
||||
return url;
|
||||
}
|
||||
if (!url.startsWith("blob:")) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
if (!blob.type.startsWith("image/")) {
|
||||
return undefined;
|
||||
}
|
||||
return await new Promise((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
resolve(typeof reader.result === "string" ? reader.result : undefined);
|
||||
};
|
||||
reader.onerror = () => resolve(undefined);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -1,652 +0,0 @@
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import type { ComponentProps, CSSProperties, HTMLAttributes } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import type {
|
||||
HighlighterCore,
|
||||
LanguageRegistration,
|
||||
ThemedToken,
|
||||
ThemeRegistration,
|
||||
} from "shiki/core";
|
||||
import { createHighlighterCore } from "shiki/core";
|
||||
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Shiki uses bitflags for font styles: 1=italic, 2=bold, 4=underline
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
const isItalic = (fontStyle: number | undefined) => fontStyle && fontStyle & 1;
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
const isBold = (fontStyle: number | undefined) => fontStyle && fontStyle & 2;
|
||||
const isUnderline = (fontStyle: number | undefined) =>
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
fontStyle && fontStyle & 4;
|
||||
|
||||
const SUPPORTED_LANGUAGES = [
|
||||
"bash",
|
||||
"css",
|
||||
"diff",
|
||||
"html",
|
||||
"javascript",
|
||||
"json",
|
||||
"jsonc",
|
||||
"jsx",
|
||||
"markdown",
|
||||
"python",
|
||||
"shellscript",
|
||||
"tsx",
|
||||
"typescript",
|
||||
"yaml",
|
||||
] as const;
|
||||
|
||||
export type SupportedCodeLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
|
||||
const SUPPORTED_LANGUAGE_SET = new Set<string>(SUPPORTED_LANGUAGES);
|
||||
|
||||
const LANGUAGE_LOADERS: Record<
|
||||
SupportedCodeLanguage,
|
||||
() => Promise<LanguageRegistration[]>
|
||||
> = {
|
||||
bash: () => import("@shikijs/langs/bash").then((module) => module.default),
|
||||
css: () => import("@shikijs/langs/css").then((module) => module.default),
|
||||
diff: () => import("@shikijs/langs/diff").then((module) => module.default),
|
||||
html: () => import("@shikijs/langs/html").then((module) => module.default),
|
||||
javascript: () =>
|
||||
import("@shikijs/langs/javascript").then((module) => module.default),
|
||||
json: () => import("@shikijs/langs/json").then((module) => module.default),
|
||||
jsonc: () => import("@shikijs/langs/jsonc").then((module) => module.default),
|
||||
jsx: () => import("@shikijs/langs/jsx").then((module) => module.default),
|
||||
markdown: () =>
|
||||
import("@shikijs/langs/markdown").then((module) => module.default),
|
||||
python: () =>
|
||||
import("@shikijs/langs/python").then((module) => module.default),
|
||||
shellscript: () =>
|
||||
import("@shikijs/langs/shellscript").then((module) => module.default),
|
||||
tsx: () => import("@shikijs/langs/tsx").then((module) => module.default),
|
||||
typescript: () =>
|
||||
import("@shikijs/langs/typescript").then((module) => module.default),
|
||||
yaml: () => import("@shikijs/langs/yaml").then((module) => module.default),
|
||||
};
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, SupportedCodeLanguage> = {
|
||||
console: "shellscript",
|
||||
cjs: "javascript",
|
||||
htm: "html",
|
||||
js: "javascript",
|
||||
json5: "jsonc",
|
||||
md: "markdown",
|
||||
mjs: "javascript",
|
||||
py: "python",
|
||||
sh: "shellscript",
|
||||
shell: "shellscript",
|
||||
ts: "typescript",
|
||||
yml: "yaml",
|
||||
};
|
||||
|
||||
const normalizeLanguage = (
|
||||
language: string,
|
||||
): SupportedCodeLanguage | "text" => {
|
||||
const normalized = language.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "text";
|
||||
}
|
||||
const aliased = LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
return SUPPORTED_LANGUAGE_SET.has(aliased) ? aliased : "text";
|
||||
};
|
||||
|
||||
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
|
||||
interface KeyedToken {
|
||||
token: ThemedToken;
|
||||
key: string;
|
||||
}
|
||||
interface KeyedLine {
|
||||
tokens: KeyedToken[];
|
||||
key: string;
|
||||
}
|
||||
|
||||
const addKeysToTokens = (lines: ThemedToken[][]): KeyedLine[] =>
|
||||
lines.map((line, lineIdx) => ({
|
||||
key: `line-${lineIdx}`,
|
||||
tokens: line.map((token, tokenIdx) => ({
|
||||
key: `line-${lineIdx}-${tokenIdx}`,
|
||||
token,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Token rendering component
|
||||
const TokenSpan = ({ token }: { token: ThemedToken }) => (
|
||||
<span
|
||||
className="dark:bg-(--shiki-dark-bg)! dark:text-(--shiki-dark)!"
|
||||
style={
|
||||
{
|
||||
backgroundColor: token.bgColor,
|
||||
color: token.color,
|
||||
fontStyle: isItalic(token.fontStyle) ? "italic" : undefined,
|
||||
fontWeight: isBold(token.fontStyle) ? "bold" : undefined,
|
||||
textDecoration: isUnderline(token.fontStyle) ? "underline" : undefined,
|
||||
...token.htmlStyle,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{token.content}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Line number styles using CSS counters
|
||||
const LINE_NUMBER_CLASSES = cn(
|
||||
"block",
|
||||
"before:content-[counter(line)]",
|
||||
"before:inline-block",
|
||||
"before:[counter-increment:line]",
|
||||
"before:w-8",
|
||||
"before:mr-4",
|
||||
"before:text-right",
|
||||
"before:text-muted-foreground/50",
|
||||
"before:font-mono",
|
||||
"before:select-none",
|
||||
);
|
||||
|
||||
// Line rendering component
|
||||
const LineSpan = ({
|
||||
keyedLine,
|
||||
showLineNumbers,
|
||||
}: {
|
||||
keyedLine: KeyedLine;
|
||||
showLineNumbers: boolean;
|
||||
}) => (
|
||||
<span className={showLineNumbers ? LINE_NUMBER_CLASSES : "block"}>
|
||||
{keyedLine.tokens.length === 0
|
||||
? "\n"
|
||||
: keyedLine.tokens.map(({ token, key }) => (
|
||||
<TokenSpan key={key} token={token} />
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
|
||||
// Types
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: string;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
interface TokenizedCode {
|
||||
tokens: ThemedToken[][];
|
||||
fg: string;
|
||||
bg: string;
|
||||
}
|
||||
|
||||
interface CodeBlockContextType {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// Context
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
// Highlighter cache (singleton per language)
|
||||
let highlighterPromise: Promise<HighlighterCore> | undefined;
|
||||
let themesPromise: Promise<void> | undefined;
|
||||
const languagePromises = new Map<SupportedCodeLanguage, Promise<void>>();
|
||||
|
||||
// Token cache
|
||||
const tokensCache = new Map<string, TokenizedCode>();
|
||||
|
||||
// Subscribers for async token updates
|
||||
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
|
||||
|
||||
const getTokensCacheKey = (code: string, language: string) => {
|
||||
const start = code.slice(0, 100);
|
||||
const end = code.length > 100 ? code.slice(-100) : "";
|
||||
return `${language}:${code.length}:${start}:${end}`;
|
||||
};
|
||||
|
||||
const getHighlighter = (): Promise<HighlighterCore> => {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighterCore({
|
||||
engine: createJavaScriptRegexEngine({ forgiving: true }),
|
||||
});
|
||||
}
|
||||
return highlighterPromise;
|
||||
};
|
||||
|
||||
const ensureThemes = (highlighter: HighlighterCore): Promise<void> => {
|
||||
if (!themesPromise) {
|
||||
themesPromise = Promise.all([
|
||||
import("@shikijs/themes/github-light").then((module) => module.default),
|
||||
import("@shikijs/themes/github-dark").then((module) => module.default),
|
||||
]).then((themes: ThemeRegistration[]) => highlighter.loadTheme(...themes));
|
||||
}
|
||||
return themesPromise;
|
||||
};
|
||||
|
||||
const ensureLanguage = (
|
||||
highlighter: HighlighterCore,
|
||||
language: SupportedCodeLanguage,
|
||||
): Promise<void> => {
|
||||
const cached = languagePromises.get(language);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const languagePromise = LANGUAGE_LOADERS[language]().then((registrations) =>
|
||||
highlighter.loadLanguage(...registrations),
|
||||
);
|
||||
languagePromises.set(language, languagePromise);
|
||||
return languagePromise;
|
||||
};
|
||||
|
||||
// Create raw tokens for immediate display while highlighting loads
|
||||
const createRawTokens = (code: string): TokenizedCode => ({
|
||||
bg: "transparent",
|
||||
fg: "inherit",
|
||||
tokens: code.split("\n").map((line) =>
|
||||
line === ""
|
||||
? []
|
||||
: [
|
||||
{
|
||||
color: "inherit",
|
||||
content: line,
|
||||
} as ThemedToken,
|
||||
],
|
||||
),
|
||||
});
|
||||
|
||||
// Synchronous highlight with callback for async results
|
||||
export const highlightCode = (
|
||||
code: string,
|
||||
language: string,
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
callback?: (result: TokenizedCode) => void,
|
||||
): TokenizedCode | null => {
|
||||
const langToUse = normalizeLanguage(language);
|
||||
if (langToUse === "text") {
|
||||
return createRawTokens(code);
|
||||
}
|
||||
|
||||
const tokensCacheKey = getTokensCacheKey(code, langToUse);
|
||||
|
||||
// Return cached result if available
|
||||
const cached = tokensCache.get(tokensCacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Subscribe callback if provided
|
||||
if (callback) {
|
||||
if (!subscribers.has(tokensCacheKey)) {
|
||||
subscribers.set(tokensCacheKey, new Set());
|
||||
}
|
||||
subscribers.get(tokensCacheKey)?.add(callback);
|
||||
}
|
||||
|
||||
// Start highlighting in background - fire-and-forget async pattern
|
||||
getHighlighter()
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
|
||||
.then(async (highlighter) => {
|
||||
await ensureThemes(highlighter);
|
||||
await ensureLanguage(highlighter, langToUse);
|
||||
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: langToUse,
|
||||
themes: {
|
||||
dark: "github-dark",
|
||||
light: "github-light",
|
||||
},
|
||||
});
|
||||
|
||||
const tokenized: TokenizedCode = {
|
||||
bg: result.bg ?? "transparent",
|
||||
fg: result.fg ?? "inherit",
|
||||
tokens: result.tokens,
|
||||
};
|
||||
|
||||
// Cache the result
|
||||
tokensCache.set(tokensCacheKey, tokenized);
|
||||
|
||||
// Notify all subscribers
|
||||
const subs = subscribers.get(tokensCacheKey);
|
||||
if (subs) {
|
||||
for (const sub of subs) {
|
||||
sub(tokenized);
|
||||
}
|
||||
subscribers.delete(tokensCacheKey);
|
||||
}
|
||||
})
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then), eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
.catch((error) => {
|
||||
console.error("Failed to highlight code:", error);
|
||||
subscribers.delete(tokensCacheKey);
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const CodeBlockBody = memo(
|
||||
({
|
||||
tokenized,
|
||||
showLineNumbers,
|
||||
className,
|
||||
}: {
|
||||
tokenized: TokenizedCode;
|
||||
showLineNumbers: boolean;
|
||||
className?: string;
|
||||
}) => {
|
||||
const preStyle = useMemo(
|
||||
() => ({
|
||||
backgroundColor: tokenized.bg,
|
||||
color: tokenized.fg,
|
||||
}),
|
||||
[tokenized.bg, tokenized.fg],
|
||||
);
|
||||
|
||||
const keyedLines = useMemo(
|
||||
() => addKeysToTokens(tokenized.tokens),
|
||||
[tokenized.tokens],
|
||||
);
|
||||
|
||||
return (
|
||||
<pre
|
||||
className={cn(
|
||||
"dark:bg-(--shiki-dark-bg)! dark:text-(--shiki-dark)! m-0 p-4 text-sm",
|
||||
className,
|
||||
)}
|
||||
style={preStyle}
|
||||
>
|
||||
<code
|
||||
className={cn(
|
||||
"font-mono text-sm",
|
||||
showLineNumbers &&
|
||||
"[counter-increment:line_0] [counter-reset:line]",
|
||||
)}
|
||||
>
|
||||
{keyedLines.map((keyedLine) => (
|
||||
<LineSpan
|
||||
key={keyedLine.key}
|
||||
keyedLine={keyedLine}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
))}
|
||||
</code>
|
||||
</pre>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.tokenized === nextProps.tokenized &&
|
||||
prevProps.showLineNumbers === nextProps.showLineNumbers &&
|
||||
prevProps.className === nextProps.className,
|
||||
);
|
||||
|
||||
CodeBlockBody.displayName = "CodeBlockBody";
|
||||
|
||||
export const CodeBlockContainer = ({
|
||||
className,
|
||||
language,
|
||||
style,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement> & { language: string }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative w-full overflow-hidden rounded-sm border bg-background text-foreground",
|
||||
className,
|
||||
)}
|
||||
data-language={language}
|
||||
style={{
|
||||
containIntrinsicSize: "auto 200px",
|
||||
contentVisibility: "auto",
|
||||
...style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export const CodeBlockHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b bg-muted/80 px-3 py-2 text-muted-foreground text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockTitle = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockFilename = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span className={cn("font-mono", className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
|
||||
export const CodeBlockActions = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn("-my-1 -mr-1 flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const CodeBlockContent = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
}: {
|
||||
code: string;
|
||||
language: string;
|
||||
showLineNumbers?: boolean;
|
||||
}) => {
|
||||
// Memoized raw tokens for immediate display
|
||||
const rawTokens = useMemo(() => createRawTokens(code), [code]);
|
||||
|
||||
// Synchronous cache lookup — avoids setState in effect for cached results
|
||||
const syncTokens = useMemo(
|
||||
() => highlightCode(code, language) ?? rawTokens,
|
||||
[code, language, rawTokens],
|
||||
);
|
||||
|
||||
// Async highlighting — keyed by identity-stable memo so stale tokens are
|
||||
// discarded without reading a ref during render or setState in effect body.
|
||||
const asyncKey = useMemo(() => ({ code, language }), [code, language]);
|
||||
const [asyncState, setAsyncState] = useState<{
|
||||
key: { code: string; language: string };
|
||||
tokens: TokenizedCode | null;
|
||||
}>({ key: asyncKey, tokens: null });
|
||||
|
||||
const asyncTokens = asyncState.key === asyncKey ? asyncState.tokens : null;
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
highlightCode(code, language, (result) => {
|
||||
if (!cancelled) {
|
||||
setAsyncState({ key: asyncKey, tokens: result });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [code, language, asyncKey]);
|
||||
|
||||
const tokenized = asyncTokens ?? syncTokens;
|
||||
|
||||
return (
|
||||
<div className="relative overflow-auto">
|
||||
<CodeBlockBody showLineNumbers={showLineNumbers} tokenized={tokenized} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const contextValue = useMemo(() => ({ code }), [code]);
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={contextValue}>
|
||||
<CodeBlockContainer className={className} language={language} {...props}>
|
||||
{children}
|
||||
<CodeBlockContent
|
||||
code={code}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
/>
|
||||
</CodeBlockContainer>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const timeoutRef = useRef<number>(0);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = useCallback(async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isCopied) {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
timeoutRef.current = window.setTimeout(
|
||||
() => setIsCopied(false),
|
||||
timeout,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
}, [code, onCopy, onError, timeout, isCopied]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.clearTimeout(timeoutRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockLanguageSelectorProps = ComponentProps<typeof Select>;
|
||||
|
||||
export const CodeBlockLanguageSelector = (
|
||||
props: CodeBlockLanguageSelectorProps,
|
||||
) => <Select {...props} />;
|
||||
|
||||
export type CodeBlockLanguageSelectorTriggerProps = ComponentProps<
|
||||
typeof SelectTrigger
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorTrigger = ({
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockLanguageSelectorTriggerProps) => (
|
||||
<SelectTrigger
|
||||
className={cn(
|
||||
"h-7 border-none bg-transparent px-2 text-xs shadow-none",
|
||||
className,
|
||||
)}
|
||||
size="sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type CodeBlockLanguageSelectorValueProps = ComponentProps<
|
||||
typeof SelectValue
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorValue = (
|
||||
props: CodeBlockLanguageSelectorValueProps,
|
||||
) => <SelectValue {...props} />;
|
||||
|
||||
export type CodeBlockLanguageSelectorContentProps = ComponentProps<
|
||||
typeof SelectContent
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorContent = ({
|
||||
align = "end",
|
||||
...props
|
||||
}: CodeBlockLanguageSelectorContentProps) => (
|
||||
<SelectContent align={align} {...props} />
|
||||
);
|
||||
|
||||
export type CodeBlockLanguageSelectorItemProps = ComponentProps<
|
||||
typeof SelectItem
|
||||
>;
|
||||
|
||||
export const CodeBlockLanguageSelectorItem = (
|
||||
props: CodeBlockLanguageSelectorItemProps,
|
||||
) => <SelectItem {...props} />;
|
||||
@@ -1,347 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { UIMessage } from "ai";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HubStreamdown } from "./streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full flex-col gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex w-full flex-col gap-2 overflow-hidden text-sm",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
interface MessageBranchContextType {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
}
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch",
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = useCallback(
|
||||
(newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
},
|
||||
[onBranchChange],
|
||||
);
|
||||
|
||||
const goToPrevious = useCallback(() => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
}, [currentBranch, branches.length, handleBranchChange]);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
}, [currentBranch, branches.length, handleBranchChange]);
|
||||
|
||||
const contextValue = useMemo<MessageBranchContextType>(
|
||||
() => ({
|
||||
branches,
|
||||
currentBranch,
|
||||
goToNext,
|
||||
goToPrevious,
|
||||
setBranches,
|
||||
totalBranches: branches.length,
|
||||
}),
|
||||
[branches, currentBranch, goToNext, goToPrevious],
|
||||
);
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = useMemo(
|
||||
() => (Array.isArray(children) ? children : [children]),
|
||||
[children],
|
||||
);
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden",
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = ComponentProps<typeof ButtonGroup>;
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className={cn(
|
||||
"[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md",
|
||||
className,
|
||||
)}
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</ButtonGroupText>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof HubStreamdown>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<HubStreamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -1,220 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Shimmer } from "./shimmer";
|
||||
import { HubStreamdown } from "./streamdown";
|
||||
|
||||
interface ReasoningContextValue {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number | undefined;
|
||||
}
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
|
||||
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
|
||||
const isExplicitlyClosed = defaultOpen === false;
|
||||
|
||||
const [isOpen, setIsOpen] = useControllableState<boolean>({
|
||||
defaultProp: resolvedDefaultOpen,
|
||||
onChange: onOpenChange,
|
||||
prop: open,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState<number | undefined>({
|
||||
defaultProp: undefined,
|
||||
prop: durationProp,
|
||||
});
|
||||
|
||||
const hasEverStreamedRef = useRef(isStreaming);
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const startTimeRef = useRef<number | null>(null);
|
||||
|
||||
// Track when streaming starts and compute duration
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
hasEverStreamedRef.current = true;
|
||||
if (startTimeRef.current === null) {
|
||||
startTimeRef.current = Date.now();
|
||||
}
|
||||
} else if (startTimeRef.current !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
|
||||
startTimeRef.current = null;
|
||||
}
|
||||
}, [isStreaming, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts (unless explicitly closed)
|
||||
useEffect(() => {
|
||||
if (isStreaming && !isOpen && !isExplicitlyClosed) {
|
||||
setIsOpen(true);
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
|
||||
|
||||
// Auto-close when streaming ends (once only, and only if it ever streamed)
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasEverStreamedRef.current &&
|
||||
!isStreaming &&
|
||||
isOpen &&
|
||||
!hasAutoClosed
|
||||
) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
},
|
||||
[setIsOpen],
|
||||
);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({ duration, isOpen, isStreaming, setIsOpen }),
|
||||
[duration, isOpen, isStreaming, setIsOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider value={contextValue}>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
||||
};
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>Thought for a few seconds</p>;
|
||||
}
|
||||
return <p>Thought for {duration} seconds</p>;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<HubStreamdown>{children}</HubStreamdown>
|
||||
</CollapsibleContent>
|
||||
),
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
@@ -1,170 +0,0 @@
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import type { MermaidConfig } from "mermaid";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement, memo } from "react";
|
||||
import {
|
||||
type Components,
|
||||
type DiagramPlugin,
|
||||
Streamdown,
|
||||
type StreamdownProps,
|
||||
} from "streamdown";
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockActions,
|
||||
CodeBlockCopyButton,
|
||||
CodeBlockFilename,
|
||||
CodeBlockHeader,
|
||||
CodeBlockTitle,
|
||||
} from "@/components/ai-elements/code-block";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MarkdownCodeProps = ComponentProps<"code"> & {
|
||||
"data-block"?: boolean | string;
|
||||
node?: {
|
||||
properties?: {
|
||||
metastring?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const LANGUAGE_CLASS_PATTERN = /(?:^|\s)language-([^\s]+)/;
|
||||
const START_LINE_PATTERN = /startLine=(\d+)/;
|
||||
const NO_LINE_NUMBERS_PATTERN = /\bnoLineNumbers\b/;
|
||||
|
||||
function codeText(children: ReactNode): string {
|
||||
if (typeof children === "string" || typeof children === "number") {
|
||||
return String(children);
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(codeText).join("");
|
||||
}
|
||||
if (isValidElement<{ children?: ReactNode }>(children)) {
|
||||
return codeText(children.props.children);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const MarkdownCode = ({
|
||||
children,
|
||||
className,
|
||||
node,
|
||||
"data-block": dataBlock,
|
||||
...props
|
||||
}: MarkdownCodeProps) => {
|
||||
const language = className?.match(LANGUAGE_CLASS_PATTERN)?.[1] ?? "text";
|
||||
|
||||
if (!dataBlock) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"rounded bg-muted px-1.5 py-0.5 font-mono text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = node?.properties?.metastring;
|
||||
const startLineMatch = meta?.match(START_LINE_PATTERN);
|
||||
const startLine = startLineMatch ? Number.parseInt(startLineMatch[1], 10) : 1;
|
||||
const showLineNumbers = meta ? !NO_LINE_NUMBERS_PATTERN.test(meta) : true;
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
code={codeText(children)}
|
||||
data-start-line={startLine > 1 ? startLine : undefined}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>
|
||||
<CodeBlockFilename>{language}</CodeBlockFilename>
|
||||
</CodeBlockTitle>
|
||||
<CodeBlockActions>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlockActions>
|
||||
</CodeBlockHeader>
|
||||
</CodeBlock>
|
||||
);
|
||||
};
|
||||
|
||||
const markdownComponents = {
|
||||
code: MarkdownCode,
|
||||
} satisfies Components;
|
||||
|
||||
const DEFAULT_MERMAID_CONFIG = {
|
||||
fontFamily: "monospace",
|
||||
securityLevel: "strict",
|
||||
startOnLoad: false,
|
||||
suppressErrorRendering: true,
|
||||
theme: "default",
|
||||
} satisfies MermaidConfig;
|
||||
|
||||
interface LazyMermaidInstance {
|
||||
initialize: (config: MermaidConfig) => void;
|
||||
render: (
|
||||
id: string,
|
||||
source: string,
|
||||
) => Promise<{
|
||||
svg: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function createLazyMermaidPlugin(): DiagramPlugin {
|
||||
let config: MermaidConfig = DEFAULT_MERMAID_CONFIG;
|
||||
let initialized = false;
|
||||
|
||||
const instance: LazyMermaidInstance = {
|
||||
initialize(nextConfig: MermaidConfig) {
|
||||
config = { ...DEFAULT_MERMAID_CONFIG, ...config, ...nextConfig };
|
||||
initialized = false;
|
||||
},
|
||||
async render(id: string, source: string) {
|
||||
const mermaidModule = await import("mermaid");
|
||||
const mermaid = mermaidModule.default;
|
||||
if (!initialized) {
|
||||
mermaid.initialize(config);
|
||||
initialized = true;
|
||||
}
|
||||
return mermaid.render(id, source);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
getMermaid(nextConfig?: MermaidConfig) {
|
||||
if (nextConfig) {
|
||||
instance.initialize(nextConfig);
|
||||
}
|
||||
return instance;
|
||||
},
|
||||
language: "mermaid",
|
||||
name: "mermaid",
|
||||
type: "diagram",
|
||||
};
|
||||
}
|
||||
|
||||
const streamdownPlugins = { cjk, mermaid: createLazyMermaidPlugin() };
|
||||
|
||||
export type HubStreamdownProps = StreamdownProps;
|
||||
|
||||
export const HubStreamdown = memo(
|
||||
({ className, components, ...props }: HubStreamdownProps) => {
|
||||
const mergedComponents = components
|
||||
? { ...markdownComponents, ...components }
|
||||
: markdownComponents;
|
||||
|
||||
return (
|
||||
<Streamdown
|
||||
className={className}
|
||||
components={mergedComponents}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
HubStreamdown.displayName = "HubStreamdown";
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion";
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("flex w-full flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("not-last:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AccordionPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 cursor-pointer items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon
|
||||
data-slot="accordion-trigger-icon"
|
||||
className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden"
|
||||
/>
|
||||
<ChevronUpIcon
|
||||
data-slot="accordion-trigger-icon"
|
||||
className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
);
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AccordionPrimitive.Panel.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Panel
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Panel>
|
||||
);
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
|
||||
@@ -1,60 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button cursor-pointer inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted/50 hover:text-foreground aria-expanded:bg-muted/50 aria-expanded:text-foreground/50 dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -1,300 +0,0 @@
|
||||
import { Combobox as ComboboxPrimitive } from "@base-ui/react";
|
||||
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupInput,
|
||||
} from "@/components/ui/input-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Combobox = ComboboxPrimitive.Root;
|
||||
|
||||
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
|
||||
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />;
|
||||
}
|
||||
|
||||
function ComboboxTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Trigger
|
||||
data-slot="combobox-trigger"
|
||||
className={cn(
|
||||
"cursor-pointer data-disabled:cursor-not-allowed [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
</ComboboxPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Clear
|
||||
data-slot="combobox-clear"
|
||||
render={<InputGroupButton variant="ghost" size="icon-xs" />}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.Clear>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxInput({
|
||||
className,
|
||||
children,
|
||||
disabled = false,
|
||||
showTrigger = true,
|
||||
showClear = false,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props & {
|
||||
showTrigger?: boolean;
|
||||
showClear?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className={cn("w-auto", className)}>
|
||||
<ComboboxPrimitive.Input
|
||||
render={<InputGroupInput disabled={disabled} />}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{showTrigger && (
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
variant="ghost"
|
||||
render={<ComboboxTrigger />}
|
||||
data-slot="input-group-button"
|
||||
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
{showClear && <ComboboxClear disabled={disabled} />}
|
||||
</InputGroupAddon>
|
||||
{children}
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxContent({
|
||||
className,
|
||||
side = "bottom",
|
||||
sideOffset = 6,
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
anchor,
|
||||
...props
|
||||
}: ComboboxPrimitive.Popup.Props &
|
||||
Pick<
|
||||
ComboboxPrimitive.Positioner.Props,
|
||||
"side" | "align" | "sideOffset" | "alignOffset" | "anchor"
|
||||
>) {
|
||||
return (
|
||||
<ComboboxPrimitive.Portal>
|
||||
<ComboboxPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
anchor={anchor}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<ComboboxPrimitive.Popup
|
||||
data-slot="combobox-content"
|
||||
data-chips={!!anchor}
|
||||
className={cn(
|
||||
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-[calc(var(--anchor-width)+--spacing(7))] origin-(--transform-origin) overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[chips=true]:min-w-(--anchor-width) data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 *:data-[slot=input-group]:m-1 *:data-[slot=input-group]:mb-0 *:data-[slot=input-group]:h-8 *:data-[slot=input-group]:border-input/30 *:data-[slot=input-group]:bg-input/30 *:data-[slot=input-group]:shadow-none data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ComboboxPrimitive.Positioner>
|
||||
</ComboboxPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.List
|
||||
data-slot="combobox-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-[min(calc(--spacing(72)---spacing(9)),calc(var(--available-height)---spacing(9)))] scroll-py-1 overflow-y-auto overscroll-contain p-1 data-empty:p-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ComboboxPrimitive.Item.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Item
|
||||
data-slot="combobox-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ComboboxPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ItemIndicator>
|
||||
</ComboboxPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Group
|
||||
data-slot="combobox-group"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.GroupLabel
|
||||
data-slot="combobox-label"
|
||||
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Empty
|
||||
data-slot="combobox-empty"
|
||||
className={cn(
|
||||
"hidden w-full justify-center py-2 text-center text-sm text-muted-foreground group-data-empty/combobox-content:flex",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxSeparator({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Separator.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Separator
|
||||
data-slot="combobox-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChips({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> &
|
||||
ComboboxPrimitive.Chips.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chips
|
||||
data-slot="combobox-chips"
|
||||
className={cn(
|
||||
"flex min-h-8 flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent bg-clip-padding px-2.5 py-1 text-sm transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-data-[slot=combobox-chip]:px-1 dark:bg-input/30 dark:has-aria-invalid:border-destructive/50 dark:has-aria-invalid:ring-destructive/40",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChip({
|
||||
className,
|
||||
children,
|
||||
showRemove = true,
|
||||
...props
|
||||
}: ComboboxPrimitive.Chip.Props & {
|
||||
showRemove?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ComboboxPrimitive.Chip
|
||||
data-slot="combobox-chip"
|
||||
className={cn(
|
||||
"flex h-[calc(--spacing(5.25))] w-fit items-center justify-center gap-1 rounded-sm bg-muted px-1.5 text-xs font-medium whitespace-nowrap text-foreground has-disabled:pointer-events-none has-disabled:cursor-not-allowed has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pr-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showRemove && (
|
||||
<ComboboxPrimitive.ChipRemove
|
||||
render={<Button variant="ghost" size="icon-xs" />}
|
||||
className="-ml-1 cursor-pointer opacity-50 hover:opacity-100"
|
||||
data-slot="combobox-chip-remove"
|
||||
>
|
||||
<XIcon className="pointer-events-none" />
|
||||
</ComboboxPrimitive.ChipRemove>
|
||||
)}
|
||||
</ComboboxPrimitive.Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function ComboboxChipsInput({
|
||||
className,
|
||||
...props
|
||||
}: ComboboxPrimitive.Input.Props) {
|
||||
return (
|
||||
<ComboboxPrimitive.Input
|
||||
data-slot="combobox-chip-input"
|
||||
className={cn("min-w-16 flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function useComboboxAnchor() {
|
||||
return React.useRef<HTMLDivElement | null>(null);
|
||||
}
|
||||
|
||||
export {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxContent,
|
||||
ComboboxList,
|
||||
ComboboxItem,
|
||||
ComboboxGroup,
|
||||
ComboboxLabel,
|
||||
ComboboxCollection,
|
||||
ComboboxEmpty,
|
||||
ComboboxSeparator,
|
||||
ComboboxChips,
|
||||
ComboboxChip,
|
||||
ComboboxChipsInput,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxAnchor,
|
||||
};
|
||||
@@ -1,192 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { CheckIcon, SearchIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { InputGroup, InputGroupAddon } from "@/components/ui/input-group";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"flex size-full flex-col overflow-hidden rounded-xl! bg-popover p-1 text-popover-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = false,
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Dialog>, "children"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"top-1/3 translate-y-0 overflow-hidden rounded-xl! p-0",
|
||||
className,
|
||||
)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
{children}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div data-slot="command-input-wrapper" className="p-1 pb-0">
|
||||
<InputGroup className="h-8! rounded-lg! border-input/30 bg-input/30 shadow-none! *:data-[slot=input-group-addon]:pl-2!">
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"w-full text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"no-scrollbar max-h-72 scroll-py-1 overflow-x-hidden overflow-y-auto outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"overflow-hidden p-1 text-foreground **:[[cmdk-group-heading]]:px-2 **:[[cmdk-group-heading]]:py-1.5 **:[[cmdk-group-heading]]:text-xs **:[[cmdk-group-heading]]:font-medium **:[[cmdk-group-heading]]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"group/command-item relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<CheckIcon className="ml-auto opacity-0 group-has-data-[slot=command-shortcut]/command-item:hidden group-data-[checked=true]/command-item:opacity-100" />
|
||||
</CommandPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-data-selected/command-item:text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user