mirror of
https://github.com/cline/cline.git
synced 2026-09-02 07:42:19 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 85976d10cf |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: use correct base URL for Vertex AI global endpoint with Claude models
|
||||
@@ -1,266 +0,0 @@
|
||||
---
|
||||
name: publish-cli
|
||||
description: Use when preparing, tagging, and publishing an apps/cli npm release. Guides changelog drafting, apps/cli/package.json version bumps, cli-vX.Y.Z tags, local npm publishing, and the publish-cli GitHub workflow.
|
||||
---
|
||||
|
||||
# CLI Release
|
||||
|
||||
Use this skill when the user asks to release the CLI, publish `cline`, bump the CLI version, draft release notes, create a `cli-vX.Y.Z` tag, or trigger the CLI publish workflow.
|
||||
|
||||
The CLI is npm-only. Do not add alternate distribution or signing steps.
|
||||
|
||||
> Working directory: run every command below from the repository root. Paths and scripts (e.g. `apps/cli/package.json`, `sdk/packages/`, `bun release cli`, `bun run version`) are written relative to the repo root.
|
||||
|
||||
The skill should guide the user through one release preparation flow, then offer the publish path options. The two normal publish paths are GitHub Actions and local publishing from an authenticated machine.
|
||||
|
||||
## Release contract
|
||||
|
||||
- SDK prerequisite: the CLI depends on the SDK via `workspace:*` (`@cline/core`, `@cline/shared`, and friends). If the SDK changed since its last release, release the SDK first and wait for it to finish publishing before releasing the CLI. See "Step 0: Release the SDK first if it changed" below.
|
||||
- Version source: `apps/cli/package.json`.
|
||||
- Main release tag: `cli-vX.Y.Z`, where `X.Y.Z` matches `apps/cli/package.json`.
|
||||
- Nightly release version: `X.Y.Z-nightly.TIMESTAMP`.
|
||||
- Release prep includes approved release notes, a version bump, and an `apps/cli/CHANGELOG.md` update.
|
||||
- Publish paths:
|
||||
- GitHub workflow: `.github/workflows/cli-publish.yml`.
|
||||
- Local publish helper: `bun release cli`.
|
||||
- npm dist-tags and git tags are separate. `--tag latest` and `--tag nightly` are npm registry channels. `cli-vX.Y.Z` is a git tag for source history and GitHub releases.
|
||||
- The GitHub main release workflow runs from `main`, requires an existing `cli-vX.Y.Z` tag, checks out that tag, and publishes from it.
|
||||
- The GitHub nightly workflow publishes to npm with the `nightly` dist-tag and does not create a tag.
|
||||
- The local release helper requires a clean checkout and `cli-vX.Y.Z` to point at `HEAD` locally and on `origin` before publishing.
|
||||
- Local GitHub release creation requires `gh` to be authenticated with release permissions for the repo.
|
||||
- Always ask before pushing commits or tags.
|
||||
- Do not amend commits unless explicitly requested.
|
||||
|
||||
## Step 0: Release the SDK first if it changed
|
||||
|
||||
Do this before anything else in the Workflow below.
|
||||
|
||||
The CLI builds and ships against the SDK source in the monorepo (`workspace:*` for `@cline/core`, `@cline/shared`, and the rest), so a CLI release always contains the latest SDK code whether or not the SDK was released. The build and tests use that source too, not anything from npm. Releasing the SDK alongside the CLI is still worth doing for two reasons:
|
||||
|
||||
- Hub freshness. The hub daemon lives in `@cline/core` and stamps a `buildId` that defaults to the `@cline/core` package version (`resolveHubBuildId` in `sdk/packages/core/src/hub/discovery/index.ts`). A running hub is only retired and respawned when that `buildId` changes (`isCompatibleHubRecord` / `retireIncompatibleHub` in `sdk/packages/core/src/hub/daemon/index.ts`). So if the SDK code changed but the version did not, a user who upgrades the CLI keeps talking to their already-running hub, which is still executing the old SDK code. Bumping the SDK version makes the new CLI's `buildId` differ, so the stale hub is detected as incompatible and respawned with the fresh code.
|
||||
- Release hygiene. We want regular SDK releases; cutting one whenever we cut a CLI release keeps the published SDK in step with what the CLI ships.
|
||||
|
||||
So when the SDK has changed, release it first (which bumps the `@cline/core` version), then cut the CLI release on top of that bump. Leave the CLI's SDK dependency as `workspace:*` — the fix is to release the SDK, not to pin the CLI.
|
||||
|
||||
1. Check for unreleased SDK changes.
|
||||
|
||||
```sh
|
||||
git fetch origin --tags
|
||||
git tag --list 'sdk/sdk/v*' 'sdk-v*' --sort=-v:refname | head -1
|
||||
git log <last-sdk-tag>..origin/main --oneline --no-merges -- sdk/packages
|
||||
```
|
||||
|
||||
`sdk/<pkg>/v*` tags are created by the `sdk-publish.yml` workflow; `sdk-v*` tags are created by the local `bun release sdk` helper. Use whichever is newest as the baseline.
|
||||
|
||||
If `git log` prints no commits, the SDK is already up to date. Skip the rest of Step 0 and continue with the Workflow below.
|
||||
|
||||
If it prints commits, sanity-check the diff (ignore entries that are only the previous version-bump commit's lockfile or generated files), then release the SDK.
|
||||
|
||||
2. Decide the SDK version bump.
|
||||
|
||||
All SDK packages share one version, read from `sdk/packages/llms/package.json`. Ask whether this is patch, minor, major, or an explicit version. Patch is the default. Do not guess if the user has not made it clear.
|
||||
|
||||
3. Draft the SDK release notes and update the changelog.
|
||||
|
||||
Draft user-facing notes from the SDK commits found in step 1, translating commit messages into user-facing language (same approach as the CLI release notes below). Prepend a new `## <version>` section with those notes to the top of `sdk/CHANGELOG.md`, using the header format `## <version>` with no date — the same flat, newest-on-top format as `apps/cli/CHANGELOG.md`. This is the SDK changelog (all SDK packages share one version) and it is maintained by hand; the `sdk-publish.yml` workflow does not read it.
|
||||
|
||||
4. Bump versions and regenerate.
|
||||
|
||||
```sh
|
||||
bun run version <version>
|
||||
```
|
||||
|
||||
This bumps every SDK `package.json` to the new version, regenerates the lockfile and the generated model catalog, formats, and builds. Review the result.
|
||||
|
||||
5. Commit and push the bump to `main`.
|
||||
|
||||
The `sdk-publish.yml` workflow publishes the version that is committed on `main` and tags that commit, so the bump must land on `main` before the workflow runs.
|
||||
|
||||
```sh
|
||||
git add -A
|
||||
git commit -m "chore(sdk): release v<version>"
|
||||
```
|
||||
|
||||
Ask before pushing:
|
||||
|
||||
```sh
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
6. Trigger the SDK publish workflow on the `latest` channel.
|
||||
|
||||
```sh
|
||||
gh workflow run sdk-publish.yml -f channel=latest -f confirm_publish=publish
|
||||
gh run list --workflow=sdk-publish.yml --limit=1 --json databaseId,url,status,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
The workflow runs the SDK tests, publishes `@cline/shared`, `@cline/llms`, `@cline/agents`, `@cline/core`, and `@cline/sdk` to npm with the `latest` dist-tag in dependency order, and pushes `sdk/<pkg>/v<version>` git tags.
|
||||
|
||||
7. Wait for the SDK workflow to succeed before starting the CLI release.
|
||||
|
||||
```sh
|
||||
gh run watch <run-id> --exit-status
|
||||
```
|
||||
|
||||
Do not start the CLI release until this run has finished successfully. The CLI does not install the SDK from npm, but cutting the CLI release on top of a clean, completed SDK release keeps the two in step: the CLI release commit then sits on top of the `@cline/core` version bump, so the shipped CLI carries the new version that forces a running hub to respawn with the new code, and you are not building a CLI release on top of an SDK release that failed midway.
|
||||
|
||||
After the SDK release succeeds, pull `main` so the CLI release is prepared on top of the SDK version bump:
|
||||
|
||||
```sh
|
||||
git checkout main && git pull --ff-only
|
||||
```
|
||||
|
||||
Then continue with the Workflow below.
|
||||
|
||||
For a local SDK publish from an authenticated machine instead of the workflow, `bun release sdk <version>` exists, but prefer the `sdk-publish.yml` workflow for normal releases so the CLI release can gate on a single GitHub Actions run.
|
||||
|
||||
## Workflow
|
||||
|
||||
Complete Step 0 first. Only proceed once the SDK is released (or you confirmed no SDK release was needed).
|
||||
|
||||
1. Gather context.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
git fetch origin --tags
|
||||
git tag --list 'cli-v*' --sort=-v:refname | head -10
|
||||
node -p "require('./apps/cli/package.json').version"
|
||||
```
|
||||
|
||||
Find the latest CLI tag. If there is no `cli-v*` tag, use the first relevant CLI release commit as the baseline and say that the baseline is inferred.
|
||||
|
||||
2. Collect release commits.
|
||||
|
||||
```sh
|
||||
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli sdk/packages sdk/scripts .github/workflows/cli-publish.yml
|
||||
```
|
||||
|
||||
The `sdk/packages` commits matter here even though the SDK was released separately in Step 0: the CLI bundles the SDK, so SDK changes ship in this CLI release too. Read those commits and fold anything user-relevant to the CLI into the release notes (provider/model updates, behavior changes, fixes the CLI inherits). Skip SDK changes that are purely internal or have no CLI-visible effect.
|
||||
|
||||
3. Draft user-facing release notes.
|
||||
|
||||
Include user-facing features, fixes, behavior changes, compatibility changes, and notable install or release changes. Exclude pure refactors, tests, style, chores, and internal file moves unless they matter to users.
|
||||
|
||||
Write a flat bullet list. Translate commit messages into user-facing language. If a commit is unclear, read the full commit before summarizing it.
|
||||
|
||||
Present the draft and wait for approval before editing files.
|
||||
|
||||
4. Decide the version bump.
|
||||
|
||||
Ask whether this should be patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
|
||||
|
||||
5. Update release files.
|
||||
|
||||
Update `apps/cli/package.json` to the approved version.
|
||||
|
||||
Prepend a section to `apps/cli/CHANGELOG.md` for the approved version using the approved release notes. Use the header format `## X.Y.Z` with no date. The publish workflow extracts the top section of the changelog by matching `^## [0-9]` and pastes it verbatim into the GitHub release body and the Slack release announcement, so the section content is the release notes that get shipped.
|
||||
|
||||
6. Verify before committing.
|
||||
|
||||
Run focused checks first:
|
||||
|
||||
```sh
|
||||
bun -F @cline/cli typecheck
|
||||
bun -F @cline/cli test:unit
|
||||
```
|
||||
|
||||
For higher confidence, run:
|
||||
|
||||
```sh
|
||||
bun run types
|
||||
bun --cwd apps/cli run build:platforms:single
|
||||
```
|
||||
|
||||
If the user wants full release confidence before tagging, run:
|
||||
|
||||
```sh
|
||||
bun run test
|
||||
bun --cwd apps/cli run build:platforms
|
||||
```
|
||||
|
||||
Known local-only test failure: `src/commands/distribution-package.test.ts > rejects direct source package packing by default` will fail on machines that have `ignore-scripts=true` in `~/.npmrc` (set by the npm supply-chain hardening guide). Bun reads npm's `ignore-scripts` from `~/.npmrc`, so `bun pm pack --dry-run` skips the source-publish `prepack` guard and exits 0, which the test reads as a failure. CI does not set `ignore-scripts`, so the test passes there. Confirm by running `bun pm pack --dry-run` directly: with `~/.npmrc` in place it exits 0 with no guard output; with `~/.npmrc` moved aside it exits 1 and prints the guard message. This is not a release blocker by itself, but it does mean the local-publish path (`bun release cli`) will also bypass the source-publish guard on this machine; prefer the GitHub Actions publish path on machines with `ignore-scripts=true` set globally, or temporarily unset it (`npm config delete ignore-scripts` or `mv ~/.npmrc ~/.npmrc.bak`) for the duration of a local publish.
|
||||
|
||||
7. Commit release changes.
|
||||
|
||||
Only after the user approves the notes and version:
|
||||
|
||||
```sh
|
||||
git add apps/cli/package.json apps/cli/CHANGELOG.md
|
||||
git commit -m "chore(cli): release vX.Y.Z"
|
||||
```
|
||||
|
||||
Ask before pushing the release commit:
|
||||
|
||||
```sh
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
For the GitHub main release path, ask before creating and pushing the release tag:
|
||||
|
||||
```sh
|
||||
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
|
||||
git push origin refs/tags/cli-vX.Y.Z
|
||||
```
|
||||
|
||||
8. Publish.
|
||||
|
||||
Ask the user which path to use:
|
||||
|
||||
- GitHub main release. Use this after the release commit is on `main` and the matching `cli-vX.Y.Z` tag has been pushed. The workflow publishes to npm from that tag, creates the GitHub release, and posts to Slack.
|
||||
- Local release. Use this when the user wants to publish from this machine. The local machine must be authenticated to npm and GitHub.
|
||||
- GitHub nightly release.
|
||||
- Stop after the version commit.
|
||||
|
||||
For GitHub main release:
|
||||
|
||||
```sh
|
||||
gh workflow run cli-publish.yml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
|
||||
gh run list --workflow=cli-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
For GitHub nightly release:
|
||||
|
||||
```sh
|
||||
gh workflow run cli-publish.yml -f publish_target=nightly
|
||||
```
|
||||
|
||||
For forced GitHub nightly release:
|
||||
|
||||
```sh
|
||||
gh workflow run cli-publish.yml -f publish_target=nightly -f force_nightly_publish=true
|
||||
```
|
||||
|
||||
For local publish:
|
||||
|
||||
```sh
|
||||
gh auth status
|
||||
npm whoami
|
||||
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
|
||||
git push origin refs/tags/cli-vX.Y.Z
|
||||
bun release cli
|
||||
```
|
||||
|
||||
After a successful local publish, ask before running:
|
||||
|
||||
```sh
|
||||
gh release create cli-vX.Y.Z --verify-tag --title "CLI vX.Y.Z" --notes "Paste the approved release notes here."
|
||||
```
|
||||
|
||||
If publishing with another npm dist-tag:
|
||||
|
||||
```sh
|
||||
bun release cli --tag next
|
||||
```
|
||||
|
||||
9. Final response.
|
||||
|
||||
Report:
|
||||
|
||||
- version
|
||||
- tag
|
||||
- changelog file updated
|
||||
- commit hash
|
||||
- whether anything was pushed
|
||||
- publish path selected
|
||||
- workflow URL or local publish result
|
||||
- tests and builds run
|
||||
@@ -1,128 +0,0 @@
|
||||
# Debug Harness
|
||||
|
||||
HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/server.ts`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Build extension first if needed (protos + esbuild):
|
||||
npm run protos && IS_DEV=true node esbuild.mjs
|
||||
|
||||
# Launch (skip-build if already built):
|
||||
npx tsx src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
|
||||
# In another terminal:
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
```
|
||||
|
||||
## Data Isolation
|
||||
|
||||
The debugee runs with `CLINE_DIR=~/.cline2` by default, separate from your real `~/.cline`.
|
||||
This prevents the debugee's logout from logging out the debugger, and vice versa.
|
||||
Override with `--cline-dir /tmp/test-dir`. Check with `status()` → `clineDir`.
|
||||
|
||||
## Browser Capture & OAuth
|
||||
|
||||
The debugee runs with `CLINE_CAPTURE_BROWSER=1`, which intercepts `openExternal()` in
|
||||
`src/utils/env.ts`. URLs are captured instead of opening a real browser:
|
||||
|
||||
- Logged to `$CLINE_DIR/data/debug-captured-urls.jsonl`
|
||||
- POSTed in real-time to `/captured-url` on the harness server
|
||||
- Queryable via `oauth.captured_urls`
|
||||
|
||||
### OAuth API
|
||||
|
||||
- **`oauth.captured_urls`** `{clear?}` — URLs the debugee tried to open
|
||||
- **`oauth.read_stored_token`** — Check auth token presence in secrets.json
|
||||
- **`oauth.simulate_callback`** `{path, code?, state?, provider?, token?}` — Build vscode:// callback URI
|
||||
- **`oauth.read_captured_urls_file`** — Read on-disk JSONL of captured URLs
|
||||
|
||||
### OAuth testing flow
|
||||
|
||||
For **Cline OAuth** (SDK local callback): The SDK starts a local HTTP server, the auth URL
|
||||
is captured. To complete: open the captured URL in a real browser (it redirects back to the
|
||||
SDK's callback server), OR extract the callback port and `curl http://127.0.0.1:PORT/callback?code=...`.
|
||||
|
||||
For **MCP/Provider OAuth** (vscode:// URI): The redirect goes to a vscode:// URI.
|
||||
`oauth.simulate_callback` only *builds* the URI — it does not deliver it, and the ESM
|
||||
extension host can't `require()` the handler. To actually deliver the callback, call the
|
||||
debug-only hook via `ext.evaluate` (with `awaitPromise: true`):
|
||||
`globalThis.__clineHandleUri("vscode://saoudrizwan.claude-dev/...?code=...&state=...")`.
|
||||
It runs the same `SharedUriHandler.handleUri` as VSCode's real URI handler and exists only
|
||||
when `CLINE_CAPTURE_BROWSER` is set (the harness always sets it; never ships in prod).
|
||||
For end-to-end MCP OAuth, get a real `code` from the local MCP OAuth test server
|
||||
(`npm run dev:mcp-oauth-test-server`).
|
||||
|
||||
## Navigating Views — Use Commands, Not Clicks
|
||||
|
||||
Don't try to find/click small sidebar icons. Use VSCode commands via command palette.
|
||||
Registered in `src/registry.ts`:
|
||||
|
||||
| Command | View |
|
||||
|---------|------|
|
||||
| `cline.accountButtonClicked` | Account / sign-in |
|
||||
| `cline.historyButtonClicked` | Task history |
|
||||
| `cline.settingsButtonClicked` | Settings |
|
||||
| `cline.mcpButtonClicked` | MCP servers |
|
||||
| `cline.plusButtonClicked` | New task (chat) |
|
||||
| `cline.worktreesButtonClicked` | Worktrees |
|
||||
|
||||
```bash
|
||||
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
|
||||
```
|
||||
|
||||
## Key commands
|
||||
|
||||
All via `POST localhost:19229/api` with `{"method":"...", "params":{...}}`:
|
||||
|
||||
- **`launch`** / **`shutdown`** — lifecycle
|
||||
- **`ui.screenshot`** — screenshot to `/tmp/cline-debug/`; returns `{path}` — **use `read_file` on the path to examine, do NOT `open` the file** (Preview.app covers the VSCode window)
|
||||
- **`ui.open_sidebar`** — open the Cline sidebar
|
||||
- **`ext.set_breakpoint`** `{file, line, condition?}` — breakpoint by source file (sourcemap-resolved)
|
||||
- **`ext.evaluate`** `{expression, callFrameId?}` — eval in extension host
|
||||
- **`ext.resume`** / **`ext.step_over`** / **`ext.step_into`** — stepping
|
||||
- **`ext.call_stack`** — inspect when paused
|
||||
- **`web.evaluate`** `{expression}` — eval in webview
|
||||
- **`web.post_message`** `{message}` — send postMessage to extension host via exposed vsCodeApi
|
||||
- **`wait_for_pause`** `{timeout?}` — block until breakpoint hit
|
||||
- **`ui.locator`** `{role?, testId?, text?, frame?}` — Playwright locator (auto-retries on stale sidebar frame)
|
||||
- **`ui.react_input`** `{text, selector?, clear?, submit?}` — set React textarea value via `execCommand('insertText')`; works reliably across multiple tasks
|
||||
- **`ui.send_message`** `{text, images?, files?, responseType?}` — send chat message bypassing the textarea entirely (via gRPC postMessage)
|
||||
- **`ui.command_palette`** `{command}` — run VSCode command
|
||||
|
||||
## Typical Session
|
||||
|
||||
```bash
|
||||
# 1. Launch
|
||||
curl localhost:19229/api -d '{"method":"launch","params":{"skipBuild":true}}'
|
||||
|
||||
# 2. Open sidebar + dismiss overlays (ALWAYS do this first)
|
||||
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
|
||||
curl localhost:19229/api -d '{"method":"web.evaluate","params":{"expression":"document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
|
||||
|
||||
# 3. Navigate to view
|
||||
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
|
||||
|
||||
# 4. Check captured OAuth URLs if testing auth
|
||||
curl localhost:19229/api -d '{"method":"oauth.captured_urls"}'
|
||||
|
||||
# 5. Verify
|
||||
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
- **⚠️ Dismiss promotional overlays FIRST**: On fresh launches, full-screen promo overlays block the sidebar. **Dismiss immediately after `ui.open_sidebar`**, before any other interaction or screenshot. May need to run twice:
|
||||
```bash
|
||||
curl localhost:19229/api -d '{"method": "ui.open_sidebar"}'
|
||||
curl localhost:19229/api -d '{"method": "web.evaluate", "params": {"expression": "document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
|
||||
```
|
||||
- **Screenshots — don't open the file**: `ui.screenshot` and `ui.sidebar_screenshot` save PNGs to `/tmp/cline-debug/` and return the `{path}`. Use `read_file` on that path to examine screenshots. Running `open <path>` launches Preview.app on macOS which covers the VSCode window.
|
||||
- **Scripts count = 0 after launch**: CDP connects after extension host starts, so scripts parsed during startup aren't tracked. Breakpoints still work via sourcemap resolution.
|
||||
- **Port 9230**: Extension host inspector. If another VSCode instance uses this port, the harness will fail to connect. Kill other debug instances first.
|
||||
- **macOS only** for now (Playwright Electron launch behavior).
|
||||
- **Webview CDP**: `connect_webview` may fail depending on Electron version. `web.evaluate` still works via Playwright's `frame.evaluate()` fallback.
|
||||
- **Sourcemap paths**: esbuild outputs relative paths like `../src/extension.ts` in the sourcemap. The resolver handles this, but if a file isn't found, use `ext.source_files` to see exact paths.
|
||||
- **OAuth with fake codes**: Browser capture intercepts the URL but doesn't provide a valid auth code. For real OAuth testing, open the captured URL in a browser. For unit testing, mock the token exchange.
|
||||
|
||||
See `src/dev/debug-harness/README.md` for full API reference.
|
||||
+100
-98
@@ -13,55 +13,11 @@ This file is the secret sauce for working effectively in this codebase. It captu
|
||||
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
|
||||
|
||||
## Miscellaneous
|
||||
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
|
||||
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
## Searching the Codebase — Avoiding Build Output
|
||||
|
||||
Several directories contain build output or generated code that produces
|
||||
noisy or unusable results with `search_files` / `grep`:
|
||||
|
||||
| Directory | What it is | Why it's a problem |
|
||||
|-----------|-----------|-------------------|
|
||||
| `out/` | esbuild bundle output | Mirrors `src/` structure as minified JS — every search gets duplicate hits on single-line files |
|
||||
| `dist/` | Packaged extension | Entire extension bundled into one minified `extension.js` (~1 long line) |
|
||||
| `dist-standalone/` | Standalone build output | Same minification issue |
|
||||
| `src/generated/` | Generated protobuf code | Auto-generated from `proto/`; not the source of truth |
|
||||
| `src/shared/proto/` | Generated proto type defs | Auto-generated from `proto/`; not the source of truth |
|
||||
| `node_modules/` | Dependencies | Huge, not project source |
|
||||
|
||||
### How to skip build output
|
||||
|
||||
**`search_files`** — Point at `src/` (not the project root) and use `file_pattern`:
|
||||
```
|
||||
search_files(path="src/core", regex="myFunction", file_pattern="*.ts")
|
||||
```
|
||||
The `file_pattern` parameter is the most effective filter — e.g. `"*.ts"`,
|
||||
`"*.tsx"`, `"*.proto"`.
|
||||
|
||||
**`grep` directly** — Exclude build dirs and restrict to source extensions:
|
||||
```bash
|
||||
grep -rn "myFunction" src/ --include="*.ts" --exclude-dir={out,dist,node_modules,generated}
|
||||
```
|
||||
|
||||
### When you must search minified files
|
||||
|
||||
Sometimes you need to verify what got bundled (e.g., checking if a change
|
||||
made it into the build). Minified files are typically one long line, so
|
||||
normal `grep` shows the entire file as context. Use these approaches:
|
||||
|
||||
- **`grep -oP`** to extract just the match with limited surrounding context:
|
||||
```bash
|
||||
grep -oP '.{0,40}myFunction.{0,40}' dist/extension.js
|
||||
```
|
||||
- **`read_file`** on files in `out/src/` — these have source maps and are
|
||||
more readable than `dist/extension.js` (which is the fully bundled output).
|
||||
- **Source maps** — `out/src/*.js.map` and `dist/extension.js.map` can be
|
||||
used to trace minified output back to original source locations.
|
||||
|
||||
## gRPC/Protobuf Communication
|
||||
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
|
||||
|
||||
@@ -92,15 +48,104 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
|
||||
- `src/core/controller/task/explainChanges.ts` - Handler implementation
|
||||
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
|
||||
|
||||
## Adding a New API Provider
|
||||
When adding a new provider (e.g., "openai-codex"), you must update the proto conversion layer in THREE places or the provider will silently reset to Anthropic:
|
||||
|
||||
1. `proto/cline/models.proto` - Add to the `ApiProvider` enum (e.g., `OPENAI_CODEX = 40;`)
|
||||
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts` - Add case mapping string to proto enum
|
||||
3. `convertProtoToApiProvider()` in the same file - Add case mapping proto enum back to string
|
||||
|
||||
**Why this matters:** Without these, the provider string hits the `default` case and returns `ANTHROPIC`. The webview, provider list, and handler all work fine, but the state silently resets when it round-trips through proto serialization. No error is thrown.
|
||||
|
||||
**Other files to update when adding a provider:**
|
||||
- `src/shared/api.ts` - Add to `ApiProvider` union type, define models
|
||||
- `src/shared/providers/providers.json` - Add to provider list for dropdown
|
||||
- `src/core/api/index.ts` - Register handler in `createHandlerForProvider()`
|
||||
- `webview-ui/src/components/settings/utils/providerUtils.ts` - Add cases in `getModelsForProvider()` and `normalizeApiConfiguration()`
|
||||
- `webview-ui/src/utils/validate.ts` - Add validation case
|
||||
- `webview-ui/src/components/settings/ApiOptions.tsx` - Render provider component
|
||||
|
||||
## Responses API Providers (OpenAI Codex, OpenAI Native)
|
||||
Providers using OpenAI's Responses API require native tool calling. XML tools don't work with the Responses API.
|
||||
|
||||
**Symptoms of broken native tool calling:**
|
||||
- Tools get called multiple times (e.g., `ask_followup_question` asks the same question twice)
|
||||
- Tool arguments get duplicated or malformed
|
||||
- The model responds but tools aren't recognized
|
||||
|
||||
**Root causes to check:**
|
||||
1. **Provider missing from `isNextGenModelProvider()`** in `src/utils/model-utils.ts`. The native variant matchers (e.g., `native-gpt-5/config.ts`) call this function. If your provider isn't in the list, the matcher returns false and falls back to XML tools.
|
||||
|
||||
2. **Model missing `apiFormat: ApiFormat.OPENAI_RESPONSES`** in its model info (`src/shared/api.ts`). This property signals that the model requires native tool calling. The task runner in `src/core/task/index.ts` checks this and forces `enableNativeToolCalls: true` regardless of user settings.
|
||||
|
||||
**When adding a new Responses API provider:**
|
||||
1. Add provider to `isNextGenModelProvider()` list in `src/utils/model-utils.ts`
|
||||
2. Set `apiFormat: ApiFormat.OPENAI_RESPONSES` on all models that use the Responses API
|
||||
3. The variant matcher and task runner will handle the rest automatically
|
||||
|
||||
## Adding Tools to System Prompt
|
||||
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
|
||||
|
||||
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
|
||||
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
|
||||
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
|
||||
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
|
||||
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
|
||||
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
|
||||
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
|
||||
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
|
||||
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
|
||||
5. **Create handler** in `src/core/task/tools/handlers/`
|
||||
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
|
||||
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
|
||||
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
|
||||
|
||||
## Modifying System Prompt
|
||||
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
|
||||
|
||||
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
|
||||
|
||||
**Key directories:**
|
||||
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
|
||||
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
|
||||
- `templates/` - Template engine and placeholder definitions
|
||||
|
||||
**Variant tiers (ask user which to modify):**
|
||||
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
|
||||
- **Standard** (default fallback): `generic/`
|
||||
- **Local/small models**: `xs/`, `hermes/`, `glm/`
|
||||
|
||||
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
|
||||
|
||||
**Example: Adding a rule to RULES section**
|
||||
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
|
||||
2. If shared: modify `components/rules.ts`
|
||||
3. If overridden: modify that variant's template
|
||||
4. XS variant is special—has heavily condensed inline content in `template.ts`
|
||||
|
||||
**After any changes, regenerate snapshots:**
|
||||
```bash
|
||||
UPDATE_SNAPSHOTS=true npm run test:unit
|
||||
```
|
||||
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
|
||||
|
||||
## Modifying Default Slash Commands
|
||||
Three places need updates:
|
||||
- `src/core/slash-commands/index.ts` - Command definitions
|
||||
- `src/core/prompts/commands.ts` - System prompt integration
|
||||
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
|
||||
|
||||
## Adding New Global State Keys
|
||||
Adding a new key to global state requires updates in multiple places. Missing any step causes silent failures.
|
||||
|
||||
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(...)`
|
||||
@@ -114,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.
|
||||
@@ -156,48 +203,3 @@ const isGenerating = explanationInfo.status === "generating" && !wasCancelled
|
||||
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
|
||||
|
||||
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
|
||||
|
||||
## Debug Harness: clear inherited VSCode/Electron env vars before launching
|
||||
|
||||
The debug harness (`apps/vscode/src/dev/debug-harness/server.ts`) launches a child
|
||||
VSCode via Playwright's `_electron.launch({ env: { ...process.env, ... } })`. If you
|
||||
run the harness from a process that was itself spawned by VSCode (e.g. the Cline
|
||||
extension host, an integrated terminal, or an agent running inside VSCode), the
|
||||
parent's VSCode/Electron env vars leak into the child and break the launch.
|
||||
|
||||
The fatal one is **`ELECTRON_RUN_AS_NODE=1`**: it makes the child VSCode binary run
|
||||
as plain Node, so it rejects every VSCode CLI flag. Symptom:
|
||||
|
||||
```
|
||||
.../Visual Studio Code.app/Contents/MacOS/Code: bad option: --extensionDevelopmentPath=...
|
||||
Error: Process failed to launch! (Playwright _electron.launch)
|
||||
```
|
||||
|
||||
This is NOT the macOS Playwright flakiness mentioned in the harness README — it's
|
||||
env inheritance. Fix: strip the inherited vars before starting the harness:
|
||||
|
||||
```bash
|
||||
env -u ELECTRON_RUN_AS_NODE -u ELECTRON_NO_ATTACH_CONSOLE \
|
||||
-u VSCODE_CLI -u VSCODE_CODE_CACHE_PATH -u VSCODE_CRASH_REPORTER_PROCESS_TYPE \
|
||||
-u VSCODE_CWD -u VSCODE_ESM_ENTRYPOINT -u VSCODE_HANDLES_UNCAUGHT_ERRORS \
|
||||
-u VSCODE_IPC_HOOK -u VSCODE_NLS_CONFIG -u VSCODE_PID -u VSCODE_L10N_BUNDLE_LOCATION \
|
||||
npx tsx src/dev/debug-harness/server.ts --auto-launch --skip-build
|
||||
```
|
||||
|
||||
Check your own env with `env | grep -iE 'electron|vscode_'` first; `ELECTRON_RUN_AS_NODE=1`
|
||||
present means you must scrub before launching.
|
||||
|
||||
Other harness notes confirmed in practice:
|
||||
- The extension host is **ESM** (`VSCODE_ESM_ENTRYPOINT`), so `ext.evaluate` has no
|
||||
`require` and module-internal functions aren't reachable as globals. To inspect
|
||||
internal builders (e.g. `buildBedrockProviderConfig`), set a breakpoint with
|
||||
`ext.set_breakpoint` and read locals via `ext.evaluate` with the paused `callFrameId`
|
||||
— don't try to `require()` the bundle.
|
||||
- `web.evaluate` wraps the expression as a single returned expression; multi-statement
|
||||
snippets must be an IIFE `(() => { ...; return x; })()`, otherwise you get
|
||||
`SyntaxError: Unexpected token ';'`.
|
||||
- Webview settings inputs are `vscode-text-field` web components with debounced React
|
||||
onChange. Setting `.value` + dispatching events via `web.evaluate` is unreliable for
|
||||
some fields; focus the inner shadow `input` then use real keystrokes (`ui.type` +
|
||||
`ui.press Tab`, or click the dropdown option) to make the value persist.
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# SDK Adapter
|
||||
|
||||
The VSCode extension runs on the Cline SDK (`@cline/core`, `@cline/llms`,
|
||||
`@cline/shared`) through an adapter layer in `apps/vscode/src/sdk/`. The
|
||||
webview still talks gRPC; the adapter translates between gRPC handlers and SDK
|
||||
calls. See `apps/vscode/src/dev/debug-harness/README.md` for the debug harness.
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **Look up SDK APIs, don't guess.** Use `kb_search(name="sdk", query="...")`
|
||||
before implementing against an SDK surface.
|
||||
2. **Reference the pre-SDK implementation when replacing a module.** Add a
|
||||
`// Replaces classic src/core/... (see origin/main)` header and use
|
||||
`kb_search(name="cline", commit="origin/main")` or
|
||||
`git show origin/main:path` to consult the prior implementation.
|
||||
3. **Single entry point.** There is one codepath — the SDK adapter. No
|
||||
`CLINE_SDK` env flag.
|
||||
4. **Use `{appBaseUrl}`**, never hardcode `app.cline.bot`.
|
||||
5. **Avoid `as` casts.** Use explicit conversion functions with tests. The
|
||||
branded types in `apps/vscode/src/sdk/model-catalog/contracts.ts` exist so
|
||||
casts are unnecessary outside parse/compute boundaries.
|
||||
|
||||
## Debug harness
|
||||
|
||||
- **Dismiss the Kanban/promo overlay** before any debug harness interaction.
|
||||
- **Use the command palette** to navigate tabs in the debug harness.
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @dominiccooney
|
||||
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @candieduniverse
|
||||
/README.md @saoudrizwan @juanpflores
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -2,7 +2,7 @@ version: 2
|
||||
updates:
|
||||
# Main extension dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/vscode"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
# Group all updates into a single PR
|
||||
@@ -20,7 +20,7 @@ updates:
|
||||
|
||||
# Webview UI dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/vscode/webview-ui"
|
||||
directory: "/webview-ui"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -132,31 +132,13 @@ jobs:
|
||||
|
||||
- name: Build SDK packages
|
||||
run: bun run build:sdk
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
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 }}
|
||||
|
||||
- name: Run tests
|
||||
run: bun run test
|
||||
|
||||
- name: Build platform binaries
|
||||
run: bun script/build.ts --install-native-variants --skip-sdk-build
|
||||
working-directory: apps/cli
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
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 }}
|
||||
working-directory: sdk/apps/cli
|
||||
|
||||
- name: Verify build output
|
||||
env:
|
||||
@@ -194,7 +176,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
|
||||
@@ -331,15 +313,6 @@ jobs:
|
||||
- name: Build SDK packages
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun run build:sdk
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
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 }}
|
||||
|
||||
- name: Run tests
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
@@ -375,16 +348,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
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
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 }}
|
||||
working-directory: sdk/apps/cli
|
||||
|
||||
- name: Verify build output
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
@@ -424,7 +388,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,66 @@
|
||||
# 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
|
||||
|
||||
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,12 +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
|
||||
# The VS Code extension's package.json and lockfiles live under apps/vscode/
|
||||
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
|
||||
# and publish steps run in the correct workspace.
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
- name: Checkout selected branch
|
||||
@@ -47,7 +40,6 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show build source
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
echo "Building ref: $GITHUB_REF"
|
||||
echo "Building sha: $GITHUB_SHA"
|
||||
@@ -59,18 +51,12 @@ jobs:
|
||||
# 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
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
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 --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
|
||||
@@ -92,7 +78,6 @@ jobs:
|
||||
run: npm run publish:marketplace:nightly
|
||||
|
||||
- name: Tag published commit
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
|
||||
@@ -36,9 +36,6 @@ jobs:
|
||||
name: Publish Extension
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -50,7 +47,6 @@ jobs:
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
TAG: ${{ github.event.inputs.tag }}
|
||||
AUTO_CREATE: ${{ github.event.inputs.auto_create_tag_from_main }}
|
||||
@@ -114,13 +110,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
|
||||
@@ -177,7 +171,6 @@ jobs:
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
@@ -185,7 +178,6 @@ jobs:
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
@@ -197,7 +189,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.resolve_tag.outputs.tag }}
|
||||
files: "apps/vscode/*.vsix"
|
||||
files: "*.vsix"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
|
||||
@@ -36,24 +36,24 @@ jobs:
|
||||
with:
|
||||
filters: |
|
||||
e2e:
|
||||
- 'apps/vscode/src/**'
|
||||
- 'apps/vscode/webview-ui/**'
|
||||
- 'apps/vscode/proto/**'
|
||||
- 'apps/vscode/tests/**'
|
||||
- 'apps/vscode/scripts/**'
|
||||
- 'apps/vscode/standalone/**'
|
||||
- 'apps/vscode/assets/**'
|
||||
- 'apps/vscode/walkthrough/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/package-lock.json'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/biome.jsonc'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
- 'apps/vscode/.mocharc.json'
|
||||
- 'apps/vscode/.vscode-test.mjs'
|
||||
- 'apps/vscode/.vscodeignore'
|
||||
- 'apps/vscode/playwright*.ts'
|
||||
- 'src/**'
|
||||
- 'webview-ui/**'
|
||||
- 'proto/**'
|
||||
- 'tests/**'
|
||||
- 'scripts/**'
|
||||
- 'standalone/**'
|
||||
- 'assets/**'
|
||||
- 'walkthrough/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'buf.yaml'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- 'esbuild.mjs'
|
||||
- '.mocharc.json'
|
||||
- '.vscode-test.mjs'
|
||||
- '.vscodeignore'
|
||||
- 'playwright*.ts'
|
||||
- '.github/workflows/ext-vscode-test-e2e.yml'
|
||||
|
||||
matrix_prep:
|
||||
@@ -79,9 +79,6 @@ jobs:
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js environment
|
||||
@@ -94,24 +91,24 @@ jobs:
|
||||
uses: actions/cache@v4
|
||||
id: root-cache
|
||||
with:
|
||||
path: apps/vscode/node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('apps/vscode/package-lock.json') }}
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: apps/vscode/webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('apps/vscode/webview-ui/package-lock.json') }}
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
# Cache VS Code installation
|
||||
- name: Cache VS Code
|
||||
uses: actions/cache@v4
|
||||
id: vscode-cache
|
||||
with:
|
||||
path: apps/vscode/.vscode-test
|
||||
key: vscode-${{ runner.os }}-stable-${{ hashFiles('apps/vscode/.vscode-test.mjs', 'apps/vscode/package.json') }}
|
||||
path: .vscode-test
|
||||
key: vscode-${{ runner.os }}-stable-${{ hashFiles('.vscode-test.mjs', 'package.json') }}
|
||||
restore-keys: |
|
||||
vscode-${{ runner.os }}-stable-
|
||||
|
||||
@@ -124,19 +121,17 @@ jobs:
|
||||
~/.cache/ms-playwright
|
||||
~/Library/Caches/ms-playwright
|
||||
~/AppData/Local/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('apps/vscode/package-lock.json') }}
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
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
|
||||
|
||||
@@ -36,38 +36,38 @@ jobs:
|
||||
with:
|
||||
filters: |
|
||||
vscode:
|
||||
- 'apps/vscode/src/**'
|
||||
- 'apps/vscode/webview-ui/**'
|
||||
- 'apps/vscode/proto/**'
|
||||
- 'apps/vscode/tests/**'
|
||||
- 'apps/vscode/scripts/**'
|
||||
- 'apps/vscode/standalone/**'
|
||||
- 'apps/vscode/assets/**'
|
||||
- 'apps/vscode/walkthrough/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/package-lock.json'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/biome.jsonc'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
- 'apps/vscode/.mocharc.json'
|
||||
- 'apps/vscode/.nycrc*.json'
|
||||
- 'apps/vscode/.vscode-test.mjs'
|
||||
- 'apps/vscode/test-setup.js'
|
||||
- 'src/**'
|
||||
- 'webview-ui/**'
|
||||
- 'proto/**'
|
||||
- 'tests/**'
|
||||
- 'scripts/**'
|
||||
- 'standalone/**'
|
||||
- 'assets/**'
|
||||
- 'walkthrough/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'buf.yaml'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- 'esbuild.mjs'
|
||||
- '.mocharc.json'
|
||||
- '.nycrc*.json'
|
||||
- '.vscode-test.mjs'
|
||||
- 'test-setup.js'
|
||||
- '.github/workflows/ext-vscode-test.yml'
|
||||
testing_platform:
|
||||
- 'apps/vscode/src/**'
|
||||
- 'apps/vscode/proto/**'
|
||||
- 'apps/vscode/standalone/**'
|
||||
- 'apps/vscode/testing-platform/**'
|
||||
- 'apps/vscode/tests/specs/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/package-lock.json'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
- 'apps/vscode/.vscodeignore'
|
||||
- 'apps/vscode/scripts/**'
|
||||
- 'src/**'
|
||||
- 'proto/**'
|
||||
- 'standalone/**'
|
||||
- 'testing-platform/**'
|
||||
- 'tests/specs/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'buf.yaml'
|
||||
- 'tsconfig*.json'
|
||||
- 'esbuild.mjs'
|
||||
- '.vscodeignore'
|
||||
- 'scripts/**'
|
||||
- '.github/workflows/ext-vscode-test.yml'
|
||||
|
||||
quality-checks:
|
||||
@@ -75,9 +75,6 @@ jobs:
|
||||
if: needs.detect-changes.outputs.vscode == 'true' || needs.detect-changes.outputs.testing_platform == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
name: Quality Checks
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -88,18 +85,16 @@ jobs:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
package-lock.json
|
||||
webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci --include=optional
|
||||
- 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 --include=optional
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: npm run ci:check-all
|
||||
@@ -118,7 +113,6 @@ jobs:
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -129,22 +123,19 @@ jobs:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
package-lock.json
|
||||
webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci --include=optional
|
||||
- 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 --include=optional
|
||||
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"
|
||||
|
||||
@@ -160,11 +151,6 @@ jobs:
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Vitest Suites (SDK adapter + model catalog)
|
||||
id: vitest_tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:vitest
|
||||
|
||||
- name: Unit Tests with coverage - Linux
|
||||
id: unit_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
@@ -215,16 +201,13 @@ jobs:
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: |
|
||||
apps/vscode/coverage-unit/lcov.info
|
||||
apps/vscode/webview-ui/coverage/lcov.info
|
||||
coverage-unit/lcov.info
|
||||
webview-ui/coverage/lcov.info
|
||||
|
||||
test-platform-integration:
|
||||
needs: [detect-changes, quality-checks]
|
||||
if: needs.detect-changes.outputs.testing_platform == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@@ -235,19 +218,17 @@ jobs:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
apps/vscode/testing-platform/package-lock.json
|
||||
package-lock.json
|
||||
webview-ui/package-lock.json
|
||||
testing-platform/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci --include=optional
|
||||
- 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 --include=optional
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
@@ -256,8 +237,7 @@ jobs:
|
||||
run: npm run compile-standalone
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/testing-platform ci --include=optional
|
||||
run: cd testing-platform && npm ci
|
||||
|
||||
- name: Running testing platform integration spec tests
|
||||
timeout-minutes: 7
|
||||
@@ -267,7 +247,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-platform-integration-core-coverage
|
||||
path: apps/vscode/coverage/**/lcov.info
|
||||
path: coverage/**/lcov.info
|
||||
|
||||
# Keep the required "test" check as a tiny aggregate gate instead of the conditional
|
||||
# VS Code matrix. GitHub treats conditionally skipped jobs as successful required
|
||||
@@ -329,7 +309,7 @@ jobs:
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: apps/vscode
|
||||
path: .
|
||||
|
||||
- name: Upload core unit tests coverage to Qlty
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
@@ -338,7 +318,7 @@ jobs:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
# we can merge multiple files if necessary
|
||||
files: |
|
||||
apps/vscode/coverage-unit/lcov.info
|
||||
coverage-unit/lcov.info
|
||||
tag: unit:core
|
||||
|
||||
- name: Upload webview-ui unit tests coverage to Qlty
|
||||
@@ -348,7 +328,7 @@ jobs:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
# we can merge multiple files if necessary
|
||||
files: |
|
||||
apps/vscode/webview-ui/coverage/lcov.info
|
||||
webview-ui/coverage/lcov.info
|
||||
tag: unit:webview-ui
|
||||
add-prefix: webview-ui/
|
||||
|
||||
@@ -359,12 +339,12 @@ jobs:
|
||||
id: download-integration-coverage
|
||||
with:
|
||||
name: test-platform-integration-core-coverage
|
||||
path: apps/vscode/integration-core-coverage-reports
|
||||
path: integration-core-coverage-reports
|
||||
|
||||
- name: Upload core integration tests coverage to Qlty
|
||||
if: needs.detect-changes.outputs.testing_platform == 'true' && steps.download-integration-coverage.outcome == 'success'
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
files: apps/vscode/integration-core-coverage-reports/**/lcov.info
|
||||
files: integration-core-coverage-reports/**/lcov.info
|
||||
tag: integration:core
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-19
@@ -13,15 +13,12 @@ tmp
|
||||
pnpm-lock.yaml
|
||||
|
||||
.clineignore
|
||||
.cline/enterprise
|
||||
.cline/remote-config
|
||||
**/.cline/remote-config
|
||||
.venv
|
||||
.actrc
|
||||
CLAUDE.local.md
|
||||
|
||||
apps/vscode/webview-ui/src/**/*.js
|
||||
apps/vscode/webview-ui/src/**/*.js.map
|
||||
webview-ui/src/**/*.js
|
||||
webview-ui/src/**/*.js.map
|
||||
|
||||
# Ignore coverage directories and files
|
||||
coverage
|
||||
@@ -38,9 +35,9 @@ coverage-unit
|
||||
.worktrees
|
||||
|
||||
## Generated files ##
|
||||
apps/vscode/src/generated/
|
||||
apps/vscode/src/shared/proto/
|
||||
apps/vscode/webview-ui/src/services/grpc-client.ts
|
||||
src/generated/
|
||||
src/shared/proto/
|
||||
webview-ui/src/services/grpc-client.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
# E2E Tests
|
||||
@@ -64,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
-11
@@ -1,11 +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
|
||||
|
||||
cd apps/vscode && lint-staged
|
||||
|
||||
lint-staged
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extension": [
|
||||
"ts"
|
||||
],
|
||||
"spec": [
|
||||
"src/**/__tests__/*.ts",
|
||||
"src/test/services/**/*.test.ts"
|
||||
],
|
||||
"require": [
|
||||
"ts-node/register",
|
||||
"source-map-support/register",
|
||||
"./src/test/requires.ts"
|
||||
],
|
||||
"recursive": true,
|
||||
"exit": true
|
||||
}
|
||||
@@ -1,13 +1,9 @@
|
||||
import { defineConfig } from "@vscode/test-cli"
|
||||
import path from "path"
|
||||
|
||||
const vscodeTestVersion = process.env.VSCODE_TEST_VERSION ?? "stable"
|
||||
|
||||
export default defineConfig({
|
||||
files: [
|
||||
"out/src/{core,test,utils,shared,integrations,hosts,services}/**/*.test.js",
|
||||
"src/{core,test,utils,shared,integrations,hosts,services}/**/*.test.js",
|
||||
],
|
||||
files: "{out/**/*.test.js,src/**/*.test.js,!src/test/e2e/**/*.test.js,!out/src/test/e2e/**/*.test.js}",
|
||||
mocha: {
|
||||
ui: "bdd",
|
||||
timeout: 20000, // Maximum time (in ms) that a test can run before failing
|
||||
Vendored
+31
-31
@@ -10,23 +10,23 @@
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/apps/vscode",
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"${workspaceFolder}/apps/vscode",
|
||||
"${workspaceFolder}",
|
||||
"--disable-extensions"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/apps/vscode/dist/**/*.js"
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
"CLINE_ENVIRONMENT": "production"
|
||||
}
|
||||
},
|
||||
@@ -35,22 +35,22 @@
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/apps/vscode",
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"${workspaceFolder}/apps/vscode"
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/apps/vscode/dist/**/*.js"
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
"CLINE_ENVIRONMENT": "staging"
|
||||
}
|
||||
},
|
||||
@@ -59,22 +59,22 @@
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/apps/vscode",
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"${workspaceFolder}/apps/vscode"
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/apps/vscode/dist/**/*.js"
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
"CLINE_ENVIRONMENT": "local"
|
||||
}
|
||||
},
|
||||
@@ -84,27 +84,27 @@
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "${execPath}",
|
||||
"args": [
|
||||
"--user-data-dir=${workspaceFolder}/apps/vscode/dist/tmp/user",
|
||||
"--user-data-dir=${workspaceFolder}/dist/tmp/user",
|
||||
"--profile-temp",
|
||||
"--sync=off",
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/apps/vscode",
|
||||
"${workspaceFolder}/apps/vscode"
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/apps/vscode/dist/**/*.js"
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "clean-tmp-user",
|
||||
"internalConsoleOptions": "openOnSessionStart",
|
||||
"postDebugTask": "stop",
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"TEMP_PROFILE": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
"CLINE_ENVIRONMENT": "production"
|
||||
}
|
||||
},
|
||||
@@ -117,13 +117,13 @@
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/apps/vscode/**",
|
||||
"${workspaceFolder}/**",
|
||||
"!**/node_modules/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/apps/vscode/dist/**/*.js",
|
||||
"${workspaceFolder}/apps/vscode/dist-standalone/**/*.js"
|
||||
"${workspaceFolder}/dist/**/*.js",
|
||||
"${workspaceFolder}/dist-standalone/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "compile-standalone",
|
||||
"runtimeExecutable": "npx",
|
||||
@@ -131,11 +131,11 @@
|
||||
"tsx"
|
||||
],
|
||||
"program": "scripts/test-standalone-core-api-server.ts",
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"PROTOBUS_PORT": "26040",
|
||||
"HOSTBRIDGE_PORT": "26041",
|
||||
"WORKSPACE_DIR": "${workspaceFolder}/apps/vscode",
|
||||
"WORKSPACE_DIR": "${workspaceFolder}",
|
||||
"E2E_TEST": "true",
|
||||
"CLINE_ENVIRONMENT": "local"
|
||||
},
|
||||
@@ -151,10 +151,10 @@
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/apps/vscode/**",
|
||||
"${workspaceFolder}/**",
|
||||
"!**/node_modules/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": [
|
||||
"mocha"
|
||||
@@ -169,7 +169,7 @@
|
||||
"--exit",
|
||||
"${file}"
|
||||
],
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"TS_NODE_PROJECT": "./tsconfig.unit-test.json",
|
||||
"NODE_ENV": "test",
|
||||
@@ -188,7 +188,7 @@
|
||||
"run",
|
||||
"storybook"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/apps/vscode/webview-ui",
|
||||
"cwd": "${workspaceFolder}/webview-ui",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"serverReadyAction": {
|
||||
|
||||
Vendored
+1
-1
@@ -17,7 +17,7 @@
|
||||
// Protobuf settings
|
||||
"protoc": {
|
||||
"options": [
|
||||
"--proto_path=apps/vscode/proto"
|
||||
"--proto_path=proto"
|
||||
]
|
||||
},
|
||||
// Enable Lint and format using Biome
|
||||
|
||||
Vendored
+23
-40
@@ -5,28 +5,24 @@
|
||||
"tasks": [
|
||||
{
|
||||
"label": "compile-standalone",
|
||||
"type": "shell",
|
||||
"command": "npm run compile-standalone",
|
||||
"type": "npm",
|
||||
"script": "compile-standalone",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "npm: protos",
|
||||
"type": "shell",
|
||||
"command": "npm run protos",
|
||||
"type": "npm",
|
||||
"script": "protos",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"presentation": {
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
@@ -64,8 +60,8 @@
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run build:webview",
|
||||
"type": "npm",
|
||||
"script": "build:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
@@ -78,15 +74,14 @@
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run build:webview:test",
|
||||
"type": "npm",
|
||||
"script": "build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
@@ -99,7 +94,6 @@
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"IS_TEST": "true"
|
||||
@@ -107,8 +101,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run dev:webview",
|
||||
"type": "npm",
|
||||
"script": "dev:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [
|
||||
{
|
||||
@@ -137,15 +131,14 @@
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch:esbuild",
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -176,15 +169,14 @@
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch:esbuild:test",
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild:test",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -215,7 +207,6 @@
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"IS_TEST": "true"
|
||||
@@ -223,8 +214,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch:tsc",
|
||||
"type": "npm",
|
||||
"script": "watch:tsc",
|
||||
"group": "build",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
@@ -235,15 +226,11 @@
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run watch-tests",
|
||||
"label": "npm: watch-tests",
|
||||
"type": "npm",
|
||||
"script": "watch-tests",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
"dependsOn": [
|
||||
@@ -253,10 +240,7 @@
|
||||
"reveal": "always",
|
||||
"group": "watchers"
|
||||
},
|
||||
"group": "build",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode"
|
||||
}
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "tasks: watch-tests",
|
||||
@@ -278,11 +262,11 @@
|
||||
"dependsOn": [
|
||||
"watch"
|
||||
],
|
||||
"command": "rm -rf ${workspaceFolder}/apps/vscode/dist/tmp/user && mkdir -p ${workspaceFolder}/apps/vscode/dist/tmp/user"
|
||||
"command": "rm -rf ${workspaceFolder}/dist/tmp/user && mkdir -p ${workspaceFolder}/dist/tmp/user"
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "npm run storybook",
|
||||
"type": "npm",
|
||||
"script": "storybook",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
@@ -295,7 +279,6 @@
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
@@ -309,7 +292,7 @@
|
||||
"$tsc"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
"cwd": "${workspaceFolder}/sdk"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,61 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [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
|
||||
|
||||
- Add GPT-5.5 support to SAP AI Core.
|
||||
- Add DeepSeek V4 Flash and Pro models.
|
||||
- Add Gemini 3.5 Flash to Gemini and Vertex providers.
|
||||
- Add `/lg-task` URI webhook integration for LG dashboard flows.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix Vertex AI global endpoint handling for Claude models.
|
||||
- Route Poolside Laguna models through next-gen prompts and native tool calling.
|
||||
|
||||
### Changed
|
||||
|
||||
- Update `diff` and `protobufjs` dependencies.
|
||||
|
||||
## [3.84.0]
|
||||
|
||||
### Added
|
||||
|
||||
+2
-3
@@ -45,7 +45,7 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
3. Install [bun](https://bun.com)
|
||||
4. Install the necessary dependencies for the extension and webview-gui:
|
||||
```bash
|
||||
cd apps/vscode && npm run install:all && cd ../..
|
||||
npm run install:all
|
||||
cd sdk && bun run build && cd ..
|
||||
```
|
||||
5. Generate Protocol Buffer files (required before first build):
|
||||
@@ -61,7 +61,7 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
2. Push your branch and create a PR on GitHub. Our CI will:
|
||||
- Run tests and checks
|
||||
3. Testing
|
||||
- Run `cd apps/vscode && npm run test` to run tests locally.
|
||||
- Run `npm run test` to run tests locally.
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
|
||||
### Extension
|
||||
@@ -73,7 +73,6 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
- If you dismissed the prompts, you can install them manually from the Extensions panel
|
||||
|
||||
2. **Local Development**
|
||||
- cd into the vscode extension, `cd apps/vscode`
|
||||
- Run `npm run install:all` to install dependencies
|
||||
- Run `npm run protos` to generate Protocol Buffer files (required before first build)
|
||||
- Run `npm run test` to run tests locally
|
||||
|
||||
@@ -19,7 +19,7 @@ English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>Feature Requests</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://docs.cline.bot/getting-started/installing-cline" target="_blank"><strong>Getting Started</strong></a>
|
||||
<a href="https://docs.cline.bot/getting-started/for-new-coders" target="_blank"><strong>Getting Started</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -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 telegram -m my_bot -k $BOT_TOKEN
|
||||
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
```
|
||||
|
||||
## Headless CLI for CI/CD
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"root": false,
|
||||
"extends": ["../sdk/biome.json"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"a11y": {
|
||||
"noStaticElementInteractions": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,184 +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",
|
||||
"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;
|
||||
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({
|
||||
cwd: "sdk",
|
||||
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,
|
||||
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"),
|
||||
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,195 +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 { 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 {
|
||||
cwd?: 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) {
|
||||
return () => {};
|
||||
}
|
||||
process.env[name] = value;
|
||||
return () => {
|
||||
if (previous === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = previous;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function withDashboardEnvironment<T>(
|
||||
options: RunDashboardCommandOptions,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const restore = [
|
||||
setEnvValue(
|
||||
"WORKSPACE_ROOT",
|
||||
options.cwd ? resolve(options.cwd) : 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()),
|
||||
];
|
||||
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,738 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
discoverPluginModulePaths,
|
||||
resolvePluginConfigSearchPaths,
|
||||
setClineDir,
|
||||
setHomeDir,
|
||||
} from "@cline/shared/storage";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installPlugin,
|
||||
isOfficialPluginSlug,
|
||||
parsePluginSource,
|
||||
runPluginInstallCommand,
|
||||
runPluginUninstallCommand,
|
||||
} from "./plugin";
|
||||
|
||||
type FetchCall = (
|
||||
...args: Parameters<typeof fetch>
|
||||
) => ReturnType<typeof fetch>;
|
||||
|
||||
describe("plugin install command", () => {
|
||||
let root = "";
|
||||
let home = "";
|
||||
let workspace = "";
|
||||
let originalHome: string | undefined;
|
||||
let originalClineDir: string | undefined;
|
||||
let originalClineDataDir: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "cli-plugin-install-"));
|
||||
home = join(root, "home");
|
||||
workspace = join(root, "workspace");
|
||||
originalHome = process.env.HOME;
|
||||
originalClineDir = process.env.CLINE_DIR;
|
||||
originalClineDataDir = process.env.CLINE_DATA_DIR;
|
||||
process.env.HOME = home;
|
||||
process.env.CLINE_DIR = join(home, ".cline");
|
||||
process.env.CLINE_DATA_DIR = join(home, ".cline", "data");
|
||||
setHomeDir(home);
|
||||
setClineDir(process.env.CLINE_DIR);
|
||||
});
|
||||
|
||||
function runGitCommand(cwd: string, args: string[]): void {
|
||||
execFileSync("git", args, { cwd, stdio: "ignore" });
|
||||
}
|
||||
|
||||
async function createOfficialPluginsRepo(
|
||||
plugins: Record<string, Record<string, string>>,
|
||||
): Promise<string> {
|
||||
const repo = mkdtempSync(join(root, "official-plugins-"));
|
||||
for (const [slug, files] of Object.entries(plugins)) {
|
||||
const pluginRoot = join(repo, "plugins", slug);
|
||||
await mkdir(pluginRoot, { recursive: true });
|
||||
for (const [filename, content] of Object.entries(files)) {
|
||||
await writeFile(join(pluginRoot, filename), content, "utf8");
|
||||
}
|
||||
}
|
||||
runGitCommand(repo, ["init"]);
|
||||
runGitCommand(repo, ["config", "user.email", "test@example.com"]);
|
||||
runGitCommand(repo, ["config", "user.name", "Cline Test"]);
|
||||
runGitCommand(repo, ["add", "."]);
|
||||
runGitCommand(repo, ["commit", "-m", "seed plugins"]);
|
||||
return repo;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
if (originalClineDir === undefined) {
|
||||
delete process.env.CLINE_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DIR = originalClineDir;
|
||||
}
|
||||
if (originalClineDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalClineDataDir;
|
||||
}
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("parses explicit npm source type without the npm prefix", () => {
|
||||
expect(parsePluginSource("@scope/plugin@1.2.3", "npm")).toEqual({
|
||||
type: "npm",
|
||||
spec: "@scope/plugin@1.2.3",
|
||||
name: "@scope/plugin",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses explicit git source type without the git prefix", () => {
|
||||
expect(parsePluginSource("github.com/acme/plugin", "git")).toMatchObject({
|
||||
type: "git",
|
||||
repo: "https://github.com/acme/plugin",
|
||||
host: "github.com",
|
||||
path: "acme/plugin",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses bare official keywords as official plugin slugs", () => {
|
||||
expect(isOfficialPluginSlug("clickhouse")).toBe(true);
|
||||
expect(isOfficialPluginSlug("web-search")).toBe(true);
|
||||
expect(isOfficialPluginSlug("WebSearch")).toBe(false);
|
||||
expect(parsePluginSource("clickhouse")).toEqual({
|
||||
type: "official",
|
||||
slug: "clickhouse",
|
||||
});
|
||||
expect(parsePluginSource("web-search")).toEqual({
|
||||
type: "official",
|
||||
slug: "web-search",
|
||||
});
|
||||
expect(parsePluginSource("web-search", "npm")).toEqual({
|
||||
type: "npm",
|
||||
spec: "web-search",
|
||||
name: "web-search",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects hostname-style sources without --git guidance", () => {
|
||||
expect(() => parsePluginSource("github.com/acme/plugin")).toThrow(
|
||||
/Use --git/,
|
||||
);
|
||||
});
|
||||
|
||||
it("parses GitHub plugin file URLs as remote sources", () => {
|
||||
expect(
|
||||
parsePluginSource(
|
||||
"https://github.com/cline/cline/blob/main/sdk/examples/plugins/weather-metrics.ts",
|
||||
),
|
||||
).toEqual({
|
||||
type: "remote",
|
||||
url: "https://raw.githubusercontent.com/cline/cline/main/sdk/examples/plugins/weather-metrics.ts",
|
||||
filename: "weather-metrics.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses raw plugin file URLs as remote sources", () => {
|
||||
expect(
|
||||
parsePluginSource(
|
||||
"https://raw.githubusercontent.com/cline/cline/main/sdk/examples/plugins/weather-metrics.ts",
|
||||
),
|
||||
).toEqual({
|
||||
type: "remote",
|
||||
url: "https://raw.githubusercontent.com/cline/cline/main/sdk/examples/plugins/weather-metrics.ts",
|
||||
filename: "weather-metrics.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects HTTP plugin file URLs", () => {
|
||||
expect(() =>
|
||||
parsePluginSource("http://example.com/plugins/weather-metrics.ts"),
|
||||
).toThrow(/must use https/);
|
||||
});
|
||||
|
||||
it("installs a local plugin file into the global plugin root", async () => {
|
||||
const source = join(root, "weather.ts");
|
||||
writeFileSync(
|
||||
source,
|
||||
"export const plugin = { name: 'weather', manifest: { capabilities: ['tools'] } };",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await installPlugin({ source });
|
||||
|
||||
expect(result.installPath).toContain(join(home, ".cline", "plugins"));
|
||||
expect(result.entryPaths).toHaveLength(1);
|
||||
expect(existsSync(result.entryPaths[0] ?? "")).toBe(true);
|
||||
const discovered = discoverPluginModulePaths(
|
||||
join(home, ".cline", "plugins"),
|
||||
);
|
||||
expect(discovered).toEqual(result.entryPaths);
|
||||
});
|
||||
|
||||
it("installs a remote plugin file into the workspace plugin root", async () => {
|
||||
const source =
|
||||
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
|
||||
const fetchMock = vi.fn<FetchCall>(async (input) => {
|
||||
expect(String(input)).toBe(
|
||||
"https://raw.githubusercontent.com/acme/plugins/main/weather-metrics.ts",
|
||||
);
|
||||
return new Response(
|
||||
"export default { name: 'remote-weather', manifest: { capabilities: ['tools'] } };",
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await installPlugin({ source, cwd: workspace });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.installPath).toContain(
|
||||
join(workspace, ".cline", "plugins", "_installed", "remote"),
|
||||
);
|
||||
expect(result.entryPaths).toHaveLength(1);
|
||||
expect(existsSync(result.entryPaths[0] ?? "")).toBe(true);
|
||||
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
|
||||
"remote-weather",
|
||||
);
|
||||
expect(
|
||||
discoverPluginModulePaths(join(workspace, ".cline", "plugins")),
|
||||
).toEqual(result.entryPaths);
|
||||
});
|
||||
|
||||
it("installs an official plugin slug from the configured collection repo", async () => {
|
||||
const officialPluginsRepo = await createOfficialPluginsRepo({
|
||||
"web-search": {
|
||||
"index.ts":
|
||||
"export default { name: 'official-web-search', manifest: { capabilities: ['tools'] } };",
|
||||
},
|
||||
"other-plugin": {
|
||||
"index.ts":
|
||||
"export default { name: 'other-plugin', manifest: { capabilities: ['tools'] } };",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await installPlugin({
|
||||
source: "web-search",
|
||||
cwd: workspace,
|
||||
officialPluginsRepo,
|
||||
});
|
||||
|
||||
expect(result.installPath).toContain(
|
||||
join(workspace, ".cline", "plugins", "_installed", "official"),
|
||||
);
|
||||
expect(result.entryPaths).toHaveLength(1);
|
||||
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
|
||||
"official-web-search",
|
||||
);
|
||||
const wrapperManifest = JSON.parse(
|
||||
readFileSync(join(result.installPath, "package.json"), "utf8"),
|
||||
) as { name?: string };
|
||||
expect(wrapperManifest.name).toBe("web-search");
|
||||
expect(existsSync(join(result.installPath, "repo"))).toBe(false);
|
||||
expect(
|
||||
existsSync(join(result.installPath, "package", "other-plugin")),
|
||||
).toBe(false);
|
||||
expect(
|
||||
discoverPluginModulePaths(join(workspace, ".cline", "plugins")),
|
||||
).toEqual(result.entryPaths);
|
||||
});
|
||||
|
||||
it("installs an official package plugin and runs package dependency install", async () => {
|
||||
const officialPluginsRepo = await createOfficialPluginsRepo({
|
||||
"package-plugin": {
|
||||
"package.json": JSON.stringify(
|
||||
{
|
||||
name: "package-plugin",
|
||||
type: "module",
|
||||
cline: {
|
||||
plugins: [{ paths: ["./index.ts"] }],
|
||||
},
|
||||
dependencies: {
|
||||
yaml: "^2.8.1",
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"index.ts":
|
||||
"export default { name: 'package-plugin', manifest: { capabilities: ['tools'] } };",
|
||||
},
|
||||
});
|
||||
const npmLogPath = join(root, "official-npm-install.log");
|
||||
const npmCommandPath = join(root, "official-fake-npm.sh");
|
||||
writeFileSync(
|
||||
npmCommandPath,
|
||||
`#!/bin/sh\nprintf '%s\\n' "$PWD $*" >> "${npmLogPath}"\nexit 0\n`,
|
||||
{ encoding: "utf8", mode: 0o755 },
|
||||
);
|
||||
|
||||
const result = await installPlugin({
|
||||
source: "package-plugin",
|
||||
cwd: workspace,
|
||||
officialPluginsRepo,
|
||||
npmCommand: npmCommandPath,
|
||||
});
|
||||
|
||||
const npmLog = readFileSync(npmLogPath, "utf8");
|
||||
expect(npmLog).toContain("package install --omit=dev --omit=peer");
|
||||
expect(result.entryPaths).toHaveLength(1);
|
||||
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
|
||||
"package-plugin",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a clear error when an official plugin slug is missing", async () => {
|
||||
const officialPluginsRepo = await createOfficialPluginsRepo({
|
||||
"known-plugin": {
|
||||
"index.ts":
|
||||
"export default { name: 'known-plugin', manifest: { capabilities: ['tools'] } };",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
installPlugin({
|
||||
source: "missing-plugin",
|
||||
cwd: workspace,
|
||||
officialPluginsRepo,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Official Cline plugin "missing-plugin" was not found at plugins\/missing-plugin/,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps explicit relative paths as local plugin installs", async () => {
|
||||
const localPluginRoot = join(workspace, "web-search");
|
||||
await mkdir(localPluginRoot, { recursive: true });
|
||||
await writeFile(
|
||||
join(localPluginRoot, "index.ts"),
|
||||
"export default { name: 'local-web-search', manifest: { capabilities: ['tools'] } };",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await installPlugin({
|
||||
source: "./web-search",
|
||||
cwd: workspace,
|
||||
});
|
||||
|
||||
expect(result.installPath).toContain(
|
||||
join(workspace, ".cline", "plugins", "_installed", "local"),
|
||||
);
|
||||
const wrapperManifest = JSON.parse(
|
||||
readFileSync(join(result.installPath, "package.json"), "utf8"),
|
||||
) as { name?: string };
|
||||
expect(wrapperManifest.name).toBe("web-search");
|
||||
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
|
||||
"local-web-search",
|
||||
);
|
||||
});
|
||||
|
||||
it("times out stalled remote plugin downloads", async () => {
|
||||
vi.useFakeTimers();
|
||||
const source =
|
||||
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
|
||||
const fetchMock = vi.fn<FetchCall>((_input, init) => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
const error = new Error("Aborted");
|
||||
error.name = "AbortError";
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const install = installPlugin({ source, cwd: workspace });
|
||||
const rejection = expect(install).rejects.toThrow(/Timed out downloading/);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it("rejects remote plugin files with oversized content length", async () => {
|
||||
const source =
|
||||
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
|
||||
const fetchMock = vi.fn<FetchCall>(async () => {
|
||||
return new Response(
|
||||
"export default { name: 'remote-weather', manifest: { capabilities: ['tools'] } };",
|
||||
{
|
||||
headers: {
|
||||
"content-length": String(10 * 1024 * 1024 + 1),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(installPlugin({ source, cwd: workspace })).rejects.toThrow(
|
||||
/exceeds the 10485760 byte limit/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects remote plugin files that stream past the size limit", async () => {
|
||||
const source =
|
||||
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
|
||||
const fetchMock = vi.fn<FetchCall>(async () => {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(10 * 1024 * 1024 + 1));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(installPlugin({ source, cwd: workspace })).rejects.toThrow(
|
||||
/exceeds the 10485760 byte limit/,
|
||||
);
|
||||
});
|
||||
|
||||
it("installs into cwd plugin root when cwd is provided", async () => {
|
||||
const source = join(root, "plugin-package");
|
||||
const npmLogPath = join(root, "npm-install.log");
|
||||
const npmCommandPath = join(root, "fake-npm.sh");
|
||||
writeFileSync(
|
||||
npmCommandPath,
|
||||
`#!/bin/sh\nprintf '%s\\n' "$PWD $*" >> "${npmLogPath}"\nexit 0\n`,
|
||||
{ encoding: "utf8", mode: 0o755 },
|
||||
);
|
||||
await mkdir(join(source, "node_modules", "dependency"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(
|
||||
join(source, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "plugin-package",
|
||||
cline: {
|
||||
plugins: [{ paths: ["./index.ts"], capabilities: ["tools"] }],
|
||||
},
|
||||
dependencies: {
|
||||
"@cline/core": "latest",
|
||||
yaml: "^2.8.1",
|
||||
},
|
||||
peerDependencies: {
|
||||
"@cline/shared": "*",
|
||||
bun: ">=1.0.0",
|
||||
},
|
||||
peerDependenciesMeta: {
|
||||
"@cline/shared": {
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(source, "index.ts"),
|
||||
"export default { name: 'plugin-package', manifest: { capabilities: ['tools'] } };",
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(source, "node_modules", "dependency", "noise.ts"),
|
||||
"export default { name: 'noise', manifest: { capabilities: ['tools'] } };",
|
||||
"utf8",
|
||||
);
|
||||
await mkdir(join(source, ".git", "objects"), { recursive: true });
|
||||
await writeFile(join(source, ".git", "HEAD"), "ref: refs/heads/main\n");
|
||||
|
||||
const result = await installPlugin({
|
||||
source,
|
||||
cwd: workspace,
|
||||
npmCommand: npmCommandPath,
|
||||
});
|
||||
|
||||
const wrapperManifest = JSON.parse(
|
||||
readFileSync(join(result.installPath, "package.json"), "utf8"),
|
||||
) as { name?: string; cline?: { plugins?: Array<{ paths?: string[] }> } };
|
||||
expect(wrapperManifest.name).toBe("plugin-package");
|
||||
expect(wrapperManifest.cline?.plugins?.[0]?.paths).toHaveLength(1);
|
||||
expect(wrapperManifest.cline?.plugins?.[0]?.paths?.[0]).toContain(
|
||||
"package/index.ts",
|
||||
);
|
||||
const packageManifest = JSON.parse(
|
||||
readFileSync(join(result.installPath, "package", "package.json"), "utf8"),
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
peerDependenciesMeta?: Record<string, unknown>;
|
||||
};
|
||||
expect(packageManifest.dependencies).toEqual({ yaml: "^2.8.1" });
|
||||
expect(packageManifest.peerDependencies).toEqual({ bun: ">=1.0.0" });
|
||||
expect(packageManifest.peerDependenciesMeta).toBeUndefined();
|
||||
const npmLog = readFileSync(npmLogPath, "utf8");
|
||||
expect(npmLog).toContain(`${join(".tmp")}/`);
|
||||
expect(npmLog).toContain(
|
||||
"package install --omit=dev --omit=peer --legacy-peer-deps --no-audit --no-fund --package-lock=false",
|
||||
);
|
||||
expect(existsSync(join(result.installPath, "package", ".git"))).toBe(false);
|
||||
expect(
|
||||
existsSync(join(result.installPath, "package", "node_modules")),
|
||||
).toBe(false);
|
||||
const discovered = discoverPluginModulePaths(
|
||||
join(workspace, ".cline", "plugins"),
|
||||
);
|
||||
expect(discovered).toEqual(result.entryPaths);
|
||||
expect(discovered.some((path) => path.includes("noise.ts"))).toBe(false);
|
||||
});
|
||||
|
||||
it("omits and removes host SDK packages from npm-sourced installs", async () => {
|
||||
const npmLogPath = join(root, "npm-source-install.log");
|
||||
const npmCommandPath = join(root, "fake-npm-source.sh");
|
||||
writeFileSync(
|
||||
npmCommandPath,
|
||||
[
|
||||
"#!/bin/sh",
|
||||
`printf '%s\\n' "$*" >> "${npmLogPath}"`,
|
||||
"prefix=''",
|
||||
"while [ $# -gt 0 ]; do",
|
||||
" if [ \"$1\" = '--prefix' ]; then",
|
||||
" shift",
|
||||
' prefix="$1"',
|
||||
" fi",
|
||||
" shift",
|
||||
"done",
|
||||
'mkdir -p "$prefix/node_modules/published-plugin"',
|
||||
'mkdir -p "$prefix/node_modules/@cline/core"',
|
||||
'printf \'%s\\n\' \'{"name":"published-plugin","type":"module","cline":{"plugins":["index.ts"]}}\' > "$prefix/node_modules/published-plugin/package.json"',
|
||||
"printf '%s\\n' \"export default { name: 'published-plugin', manifest: { capabilities: ['tools'] } };\" > \"$prefix/node_modules/published-plugin/index.ts\"",
|
||||
'printf \'%s\\n\' \'{"name":"@cline/core"}\' > "$prefix/node_modules/@cline/core/package.json"',
|
||||
"exit 0",
|
||||
].join("\n"),
|
||||
{ encoding: "utf8", mode: 0o755 },
|
||||
);
|
||||
|
||||
const result = await installPlugin({
|
||||
source: "npm:published-plugin@1.0.0",
|
||||
npmCommand: npmCommandPath,
|
||||
});
|
||||
|
||||
const npmLog = readFileSync(npmLogPath, "utf8");
|
||||
expect(npmLog).toContain("install published-plugin@1.0.0");
|
||||
expect(npmLog).toContain("--omit=peer");
|
||||
expect(npmLog).toContain("--legacy-peer-deps");
|
||||
expect(
|
||||
existsSync(
|
||||
join(result.installPath, "package", "node_modules", "@cline", "core"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(existsSync(result.entryPaths[0] ?? "")).toBe(true);
|
||||
});
|
||||
|
||||
it("requires --force before replacing an existing install", async () => {
|
||||
const source = join(root, "replace.ts");
|
||||
writeFileSync(
|
||||
source,
|
||||
"export default { name: 'replace', manifest: { capabilities: ['tools'] } };",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const first = await installPlugin({ source });
|
||||
await expect(installPlugin({ source })).rejects.toThrow(/Use --force/);
|
||||
const second = await installPlugin({ source, force: true });
|
||||
|
||||
expect(second.installPath).toBe(first.installPath);
|
||||
});
|
||||
|
||||
it("keeps an existing install when a forced replacement fails during staging", async () => {
|
||||
const source = join(root, "replace-package");
|
||||
const npmCommandPath = join(root, "fake-npm.sh");
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(
|
||||
join(source, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "replace-package",
|
||||
cline: {
|
||||
plugins: [{ paths: ["./index.ts"], capabilities: ["tools"] }],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(source, "index.ts"),
|
||||
"export default { name: 'installed-v1', manifest: { capabilities: ['tools'] } };",
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(npmCommandPath, "#!/bin/sh\nexit 0\n", {
|
||||
encoding: "utf8",
|
||||
mode: 0o755,
|
||||
});
|
||||
|
||||
const first = await installPlugin({ source, npmCommand: npmCommandPath });
|
||||
await writeFile(
|
||||
join(source, "index.ts"),
|
||||
"export default { name: 'installed-v2', manifest: { capabilities: ['tools'] } };",
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(npmCommandPath, "#!/bin/sh\nprintf 'offline' >&2\nexit 1\n", {
|
||||
encoding: "utf8",
|
||||
mode: 0o755,
|
||||
});
|
||||
|
||||
await expect(
|
||||
installPlugin({ source, force: true, npmCommand: npmCommandPath }),
|
||||
).rejects.toThrow(/offline/);
|
||||
|
||||
expect(existsSync(first.installPath)).toBe(true);
|
||||
expect(
|
||||
readFileSync(join(first.installPath, "package", "index.ts"), "utf8"),
|
||||
).toContain("installed-v1");
|
||||
});
|
||||
|
||||
it("uninstalls a package plugin by package name", async () => {
|
||||
const source = join(root, "uninstall-package");
|
||||
const npmCommandPath = join(root, "fake-npm.sh");
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(
|
||||
join(source, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "cli-uninstall-plugin",
|
||||
cline: {
|
||||
plugins: [{ paths: ["./index.ts"], capabilities: ["tools"] }],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(source, "index.ts"),
|
||||
"export default { name: 'cli-uninstall-plugin', manifest: { capabilities: ['tools'] } };",
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(npmCommandPath, "#!/bin/sh\nexit 0\n", {
|
||||
encoding: "utf8",
|
||||
mode: 0o755,
|
||||
});
|
||||
|
||||
const installed = await installPlugin({
|
||||
source,
|
||||
npmCommand: npmCommandPath,
|
||||
});
|
||||
const output: string[] = [];
|
||||
const code = await runPluginUninstallCommand({
|
||||
name: "cli-uninstall-plugin",
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(existsSync(installed.installPath)).toBe(false);
|
||||
expect(output.join("\n")).toContain(
|
||||
"Uninstalled plugin cli-uninstall-plugin",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints JSON output for command callers", async () => {
|
||||
const source = join(root, "json.ts");
|
||||
writeFileSync(
|
||||
source,
|
||||
"export default { name: 'json', manifest: { capabilities: ['tools'] } };",
|
||||
"utf8",
|
||||
);
|
||||
const stdout: string[] = [];
|
||||
const originalWrite = process.stdout.write;
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
stdout.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
json: true,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
const parsed = JSON.parse(stdout.join("")) as { installPath: string };
|
||||
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
});
|
||||
|
||||
it("prints JSON output for official plugin installs", async () => {
|
||||
const officialPluginsRepo = await createOfficialPluginsRepo({
|
||||
"json-plugin": {
|
||||
"index.ts":
|
||||
"export default { name: 'json-plugin', manifest: { capabilities: ['tools'] } };",
|
||||
},
|
||||
});
|
||||
const stdout: string[] = [];
|
||||
const originalWrite = process.stdout.write;
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
stdout.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
const code = await runPluginInstallCommand({
|
||||
source: "json-plugin",
|
||||
cwd: workspace,
|
||||
officialPluginsRepo,
|
||||
json: true,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
const parsed = JSON.parse(stdout.join("")) as { installPath: string };
|
||||
expect(parsed.installPath).toContain(
|
||||
join(workspace, ".cline", "plugins", "_installed", "official"),
|
||||
);
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses shared search paths for cwd installs", async () => {
|
||||
const source = join(root, "workspace.ts");
|
||||
writeFileSync(
|
||||
source,
|
||||
"export default { name: 'workspace', manifest: { capabilities: ['tools'] } };",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
await installPlugin({
|
||||
source,
|
||||
cwd: workspace,
|
||||
});
|
||||
|
||||
expect(resolvePluginConfigSearchPaths(workspace)[0]).toBe(
|
||||
join(workspace, ".cline", "plugins"),
|
||||
);
|
||||
expect(
|
||||
discoverPluginModulePaths(join(workspace, ".cline", "plugins")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,626 +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("switches Discord thread state to the incoming participant without reusing the previous participant 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 bob =
|
||||
readBindings<TestDiscordState>(bindingsPath)["discord:user:bob"];
|
||||
expect(bob?.state?.participantKey).toBe("discord:user:bob");
|
||||
expect(bob?.state?.participantLabel).toBe("Bob");
|
||||
expect(bob?.state?.sessionId).toBeUndefined();
|
||||
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,395 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ConnectTelegramOptions } from "@cline/shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { __test__, telegramConnector } from "./telegram";
|
||||
|
||||
const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
|
||||
(
|
||||
telegramConnector as unknown as {
|
||||
parseArgs(rawArgs: string[]): ConnectTelegramOptions;
|
||||
}
|
||||
).parseArgs(rawArgs);
|
||||
|
||||
const originalClineDataDir = process.env.CLINE_DATA_DIR;
|
||||
const tempDataDirs: string[] = [];
|
||||
|
||||
function useTempClineDataDir(): string {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "cline-telegram-test-"));
|
||||
tempDataDirs.push(dataDir);
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
if (originalClineDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalClineDataDir;
|
||||
}
|
||||
for (const dir of tempDataDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("telegramConnector", () => {
|
||||
it("honors --no-tools", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-username",
|
||||
"test_bot",
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--no-tools",
|
||||
]);
|
||||
|
||||
expect(options.enableTools).toBe(false);
|
||||
});
|
||||
|
||||
it("enables tools by default", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-username",
|
||||
"test_bot",
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
]);
|
||||
|
||||
expect(options.enableTools).toBe(true);
|
||||
});
|
||||
|
||||
it("builds an authorization hook from --allowed-user-id", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"1201547643",
|
||||
]);
|
||||
|
||||
expect(options.hookCommand).toBe(
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "telegram:id:1201547643" && echo '{"action":"allow"}' || echo '{"action":"deny","message":"unauthorized","reason":"not_on_allowlist"}'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsafe --allowed-user-id values", () => {
|
||||
expect(() =>
|
||||
parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"123; rm -rf /",
|
||||
]),
|
||||
).toThrow("digits only");
|
||||
});
|
||||
|
||||
it("rejects mixing --allowed-user-id with --hook-command", () => {
|
||||
expect(() =>
|
||||
parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"1201547643",
|
||||
"--hook-command",
|
||||
"echo noop",
|
||||
]),
|
||||
).toThrow("either --allowed-user-id or --hook-command");
|
||||
});
|
||||
|
||||
it("rejects mixing --allowed-user-id with the hook command env var", () => {
|
||||
const originalHookCommand = process.env.CLINE_CONNECT_HOOK_COMMAND;
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND = "echo noop";
|
||||
try {
|
||||
expect(() =>
|
||||
parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"1201547643",
|
||||
]),
|
||||
).toThrow("either --allowed-user-id or --hook-command");
|
||||
} finally {
|
||||
if (originalHookCommand === undefined) {
|
||||
delete process.env.CLINE_CONNECT_HOOK_COMMAND;
|
||||
} else {
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND = originalHookCommand;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not require the bot username", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
]);
|
||||
|
||||
expect(options.botUsername).toBeUndefined();
|
||||
expect(options.botToken).toBe("123:test");
|
||||
});
|
||||
|
||||
it("normalizes an explicit bot username", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-username",
|
||||
" @test_bot ",
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
]);
|
||||
|
||||
expect(options.botUsername).toBe("test_bot");
|
||||
});
|
||||
|
||||
it("does not call getMe when the token-only connector is already running", async () => {
|
||||
const dataDir = useTempClineDataDir();
|
||||
const connectorDir = join(dataDir, "connectors", "telegram");
|
||||
mkdirSync(connectorDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(connectorDir, "resolved_bot.json"),
|
||||
JSON.stringify({
|
||||
botUsername: "resolved_bot",
|
||||
botId: "123",
|
||||
pid: process.pid,
|
||||
rpcAddress: "127.0.0.1:54321",
|
||||
startedAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error("unexpected getMe call");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchImpl);
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
await expect(
|
||||
telegramConnector.run(["--bot-token", "123:test", "--cwd", "/tmp/work"], {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => errors.push(text),
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual([
|
||||
`[telegram] connector already running pid=${process.pid} rpc=127.0.0.1:54321`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram bot username resolution", () => {
|
||||
it("reads the public Telegram bot id from a token", () => {
|
||||
expect(__test__.readTelegramBotId("123456:secret")).toBe("123456");
|
||||
expect(__test__.readTelegramBotId("not-a-token")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the configured username without calling Telegram", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error("unexpected fetch");
|
||||
});
|
||||
|
||||
await expect(
|
||||
__test__.resolveTelegramBotUsername(
|
||||
{
|
||||
botToken: "123:test",
|
||||
botUsername: "@configured_bot",
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
interactive: true,
|
||||
enableTools: true,
|
||||
rpcAddress: "127.0.0.1:0",
|
||||
},
|
||||
fetchImpl,
|
||||
),
|
||||
).resolves.toBe("configured_bot");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches the username from Telegram getMe when omitted", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
result: { username: "resolved_bot" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
__test__.fetchTelegramBotUsername("123:test", fetchImpl),
|
||||
).resolves.toBe("resolved_bot");
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://api.telegram.org/bot123:test/getMe",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces Telegram getMe failures", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
description: "Unauthorized",
|
||||
}),
|
||||
{ status: 401, statusText: "Unauthorized" },
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
__test__.fetchTelegramBotUsername("bad-token", fetchImpl),
|
||||
).rejects.toThrow("Telegram getMe failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram participant resolution", () => {
|
||||
it("uses the stable numeric Telegram user id when username is also present", () => {
|
||||
const result = __test__.resolveTelegramParticipant({
|
||||
message: {
|
||||
from: {
|
||||
id: 1201547643,
|
||||
username: "AraFatKatze",
|
||||
first_name: "Ara",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "telegram:id:1201547643",
|
||||
label: "arafatkatze",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to username when Telegram does not provide a numeric user id", () => {
|
||||
const result = __test__.resolveTelegramParticipant({
|
||||
message: {
|
||||
from: {
|
||||
username: "Alice",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "telegram:user:alice",
|
||||
label: "alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts string numeric user ids from raw Telegram payloads", () => {
|
||||
const result = __test__.resolveTelegramParticipant({
|
||||
message: {
|
||||
from: {
|
||||
id: "1201547643",
|
||||
username: "arafatkatze",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.key).toBe("telegram:id:1201547643");
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram binding lookup", () => {
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
legacy_thread_id: {
|
||||
channelId: "chat-123",
|
||||
isDM: true,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "new_thread_id",
|
||||
channelId: "chat-123",
|
||||
isDM: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "legacy_thread_id",
|
||||
binding: {
|
||||
channelId: "chat-123",
|
||||
isDM: true,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers an exact thread id match over a channel fallback", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
current_thread_id: {
|
||||
channelId: "chat-123",
|
||||
isDM: true,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-2",
|
||||
state: { sessionId: "sess-2" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
legacy_thread_id: {
|
||||
channelId: "chat-123",
|
||||
isDM: true,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "current_thread_id",
|
||||
channelId: "chat-123",
|
||||
isDM: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.key).toBe("current_thread_id");
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("reuses a binding by participant key across different chats", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
"telegram:user:alice": {
|
||||
channelId: "chat-123",
|
||||
isDM: true,
|
||||
participantKey: "telegram:user:alice",
|
||||
participantLabel: "alice",
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: {
|
||||
sessionId: "sess-1",
|
||||
participantKey: "telegram:user:alice",
|
||||
participantLabel: "alice",
|
||||
},
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "new_thread_id",
|
||||
channelId: "chat-999",
|
||||
isDM: true,
|
||||
participantKey: "telegram:user:alice",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.key).toBe("telegram:user:alice");
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
export type ConnectorCatalogEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
|
||||
{
|
||||
name: "discord",
|
||||
description:
|
||||
"Discord interactions and gateway bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "gchat",
|
||||
description: "Google Chat webhook bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "linear",
|
||||
description: "Linear webhook bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "slack",
|
||||
description: "Slack webhook/socket bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "telegram",
|
||||
description: "Bridge Telegram bot messages into RPC chat sessions",
|
||||
},
|
||||
{
|
||||
name: "whatsapp",
|
||||
description: "Bridge WhatsApp webhook messages into RPC chat sessions",
|
||||
},
|
||||
];
|
||||
|
||||
export function listConnectorCatalog(): ConnectorCatalogEntry[] {
|
||||
return CONNECTOR_CATALOG.map((entry) => ({ ...entry }));
|
||||
}
|
||||
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,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,252 +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,
|
||||
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 channel fallback rebinds a thread id", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
legacy_thread_id: {
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
serializedThread: JSON.stringify({
|
||||
id: "legacy_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
}),
|
||||
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: false,
|
||||
}),
|
||||
"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("refreshes the serialized thread when a participant-key binding matches a new thread id", () => {
|
||||
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?.serializedThread).toContain("new_thread_id");
|
||||
expect(
|
||||
readBindings<TestState>(path)[participantKey]?.serializedThread,
|
||||
).toContain("new_thread_id");
|
||||
});
|
||||
|
||||
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,340 +0,0 @@
|
||||
import type {
|
||||
AgentEvent,
|
||||
ProviderSettingsManager,
|
||||
TeamEvent,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import { SessionNotFoundError } from "@cline/core";
|
||||
import type { AgentTool, Message } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatCommandState } from "../../utils/chat-commands";
|
||||
import type { Config } from "../../utils/types";
|
||||
|
||||
const {
|
||||
mockCreateCliCore,
|
||||
mockCreateRuntimeHooks,
|
||||
mockLoadInteractiveResumeMessages,
|
||||
mockSetActiveCliSession,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateCliCore: vi.fn(),
|
||||
mockCreateRuntimeHooks: vi.fn(),
|
||||
mockLoadInteractiveResumeMessages: vi.fn(),
|
||||
mockSetActiveCliSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: mockCreateCliCore,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/hooks", () => ({
|
||||
createRuntimeHooks: mockCreateRuntimeHooks,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/output", () => ({
|
||||
setActiveCliSession: mockSetActiveCliSession,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/resume", () => ({
|
||||
loadInteractiveResumeMessages: mockLoadInteractiveResumeMessages,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/approval", () => ({
|
||||
submitAndExitInTerminal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../active-runtime", () => ({
|
||||
markAbortInProgress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../session-events", () => ({
|
||||
subscribeToAgentEvents: vi.fn(() => vi.fn()),
|
||||
subscribeToPendingPromptEvents: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
import { createInteractiveSessionRuntime } from "./session-runtime";
|
||||
|
||||
function makeConfig(): Config {
|
||||
return {
|
||||
apiKey: "",
|
||||
providerId: "cline",
|
||||
modelId: "openai/gpt-5.3-codex",
|
||||
verbose: false,
|
||||
sandbox: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
mode: "act",
|
||||
systemPrompt: "",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: false,
|
||||
defaultToolAutoApprove: false,
|
||||
toolPolicies: {},
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
};
|
||||
}
|
||||
|
||||
function makeChatCommandState(config: Config): ChatCommandState {
|
||||
return {
|
||||
enableTools: config.enableTools,
|
||||
autoApproveTools: config.defaultToolAutoApprove,
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot?.trim() || config.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSwitchToActModeTool(): AgentTool {
|
||||
return {
|
||||
name: "switch_to_act_mode",
|
||||
description: "Switch to act mode",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
execute: () => ({ ok: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function makeManager() {
|
||||
let startCount = 0;
|
||||
const start = vi.fn(async (_input?: unknown) => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: {
|
||||
session_id: sessionId,
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
start,
|
||||
stop: vi.fn(async () => {}),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
get: vi.fn(),
|
||||
readMessages: vi.fn(async (): Promise<Message[]> => []),
|
||||
readTranscript: vi.fn(),
|
||||
ingestHookEvent: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
updateSessionModel: vi.fn(),
|
||||
pendingPrompts: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
restore: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeTurnResult() {
|
||||
return {
|
||||
text: "ok",
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed" as const,
|
||||
model: { id: "openai/gpt-5.3-codex", provider: "cline" },
|
||||
startedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endedAt: new Date("2026-01-01T00:00:00.100Z"),
|
||||
durationMs: 100,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: { resumeSessionId?: string } = {},
|
||||
) {
|
||||
mockCreateCliCore.mockResolvedValue(manager);
|
||||
const config = makeConfig();
|
||||
return createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager: {} as ProviderSettingsManager,
|
||||
resumeSessionId: options.resumeSessionId,
|
||||
chatCommandState: makeChatCommandState(config),
|
||||
requestToolApproval: async (
|
||||
_request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => ({ approved: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: makeSwitchToActModeTool(),
|
||||
onAgentEvent: (_event: AgentEvent) => {},
|
||||
onTeamEvent: (_event: TeamEvent) => {},
|
||||
onPendingPrompts: () => {},
|
||||
onPendingPromptSubmitted: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("createInteractiveSessionRuntime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateRuntimeHooks.mockReturnValue({
|
||||
hooks: undefined,
|
||||
shutdown: vi.fn(async () => {}),
|
||||
});
|
||||
mockLoadInteractiveResumeMessages.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("defers creating the replacement session after a new-session reset", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
|
||||
await runtime.resetForNewSession();
|
||||
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
expect(runtime.getActiveSessionId()).toBe("");
|
||||
expect(mockSetActiveCliSession).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("starts fresh after resetting an initially resumed session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager, {
|
||||
resumeSessionId: "resumed-session",
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
manager,
|
||||
"resumed-session",
|
||||
);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "resumed-session",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await runtime.resetForNewSession();
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
manager,
|
||||
undefined,
|
||||
);
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
config: expect.not.objectContaining({
|
||||
sessionId: "resumed-session",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps explicit empty restarts eager for config-driven restarts", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("recovers and retries when the active interactive session disappeared", async () => {
|
||||
const manager = makeManager();
|
||||
const messages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: "hi" }],
|
||||
},
|
||||
];
|
||||
manager.readMessages.mockResolvedValue(messages);
|
||||
manager.send
|
||||
.mockRejectedValueOnce(new SessionNotFoundError("session-1"))
|
||||
.mockResolvedValueOnce(makeTurnResult());
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.sendCurrentTurn({
|
||||
prompt: "second hi",
|
||||
mode: "act",
|
||||
});
|
||||
|
||||
expect(result?.finishReason).toBe("completed");
|
||||
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
initialMessages: messages,
|
||||
}),
|
||||
);
|
||||
expect(manager.send).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
expect(manager.send).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ sessionId: "session-2" }),
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("waits for missing-session recovery before cleanup disposes the manager", async () => {
|
||||
const manager = makeManager();
|
||||
const recoveryRead = deferred<Message[]>();
|
||||
manager.readMessages
|
||||
.mockImplementationOnce(() => recoveryRead.promise)
|
||||
.mockResolvedValue([]);
|
||||
manager.get.mockResolvedValue(undefined);
|
||||
manager.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
manager.send.mockRejectedValueOnce(new SessionNotFoundError("session-1"));
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const sendPromise = runtime
|
||||
.sendCurrentTurn({
|
||||
prompt: "second hi",
|
||||
mode: "act",
|
||||
})
|
||||
.catch((error) => error);
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
|
||||
});
|
||||
|
||||
let cleanupSettled = false;
|
||||
const cleanupPromise = runtime.cleanup().finally(() => {
|
||||
cleanupSettled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(cleanupSettled).toBe(false);
|
||||
expect(manager.get).not.toHaveBeenCalled();
|
||||
expect(manager.dispose).not.toHaveBeenCalled();
|
||||
|
||||
recoveryRead.resolve([]);
|
||||
await cleanupPromise;
|
||||
const sendError = await sendPromise;
|
||||
|
||||
expect(sendError).toBeInstanceOf(SessionNotFoundError);
|
||||
expect(manager.dispose).toHaveBeenCalledWith("cli_interactive_shutdown");
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { DialogActions, DialogId } from "@opentui-ui/dialog/react";
|
||||
|
||||
export type LoadingDialogActions = Pick<DialogActions, "show" | "close">;
|
||||
|
||||
export async function withShownDialog<T>(
|
||||
dialog: Pick<DialogActions, "close">,
|
||||
show: () => DialogId,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const loadingDialogId = show();
|
||||
// Give OpenTUI a microtask to mount the loading dialog before work starts.
|
||||
await Promise.resolve();
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
dialog.close(loadingDialogId);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { DialogActions, DialogId } from "@opentui-ui/dialog/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withShownDialog } from "./loading-dialog-lifecycle";
|
||||
|
||||
type LoadingDialogCall =
|
||||
| {
|
||||
name: "show";
|
||||
}
|
||||
| {
|
||||
name: "close";
|
||||
id: DialogId | undefined;
|
||||
};
|
||||
|
||||
function createDialog(calls: LoadingDialogCall[]) {
|
||||
return {
|
||||
close: (id?: DialogId): DialogId | undefined => {
|
||||
calls.push({ name: "close", id });
|
||||
return id;
|
||||
},
|
||||
} satisfies Pick<DialogActions, "close">;
|
||||
}
|
||||
|
||||
function showLoading(calls: LoadingDialogCall[]): DialogId {
|
||||
calls.push({ name: "show" });
|
||||
return "loading-dialog";
|
||||
}
|
||||
|
||||
describe("withShownDialog", () => {
|
||||
it("shows a loading dialog while work runs", async () => {
|
||||
const calls: LoadingDialogCall[] = [];
|
||||
const events: string[] = [];
|
||||
const dialog = createDialog(calls);
|
||||
|
||||
const result = await withShownDialog(
|
||||
dialog,
|
||||
() => showLoading(calls),
|
||||
async () => {
|
||||
events.push("run");
|
||||
return 42;
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe(42);
|
||||
expect(events).toEqual(["run"]);
|
||||
expect(calls.map((call) => call.name)).toEqual(["show", "close"]);
|
||||
expect(calls[1]).toEqual({ name: "close", id: "loading-dialog" });
|
||||
});
|
||||
|
||||
it("closes the loading dialog when work fails", async () => {
|
||||
const calls: LoadingDialogCall[] = [];
|
||||
const dialog = createDialog(calls);
|
||||
|
||||
await expect(
|
||||
withShownDialog(
|
||||
dialog,
|
||||
() => showLoading(calls),
|
||||
async () => {
|
||||
throw new Error("failed");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("failed");
|
||||
|
||||
expect(calls.map((call) => call.name)).toEqual(["show", "close"]);
|
||||
expect(calls[1]).toEqual({ name: "close", id: "loading-dialog" });
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type {
|
||||
DialogId,
|
||||
DialogSize,
|
||||
DialogStyle,
|
||||
} from "@opentui-ui/dialog/react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
type LoadingDialogActions,
|
||||
withShownDialog,
|
||||
} from "./loading-dialog-lifecycle";
|
||||
|
||||
export interface LoadingDialogContentProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function LoadingDialogContent(props: LoadingDialogContentProps) {
|
||||
return (
|
||||
<box flexDirection="row" gap={1} paddingX={1}>
|
||||
<spinner name="dots" color="gray" />
|
||||
<text fg="gray">{props.message}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface LoadingDialogOptions {
|
||||
size?: DialogSize;
|
||||
style?: DialogStyle;
|
||||
}
|
||||
|
||||
export function showLoadingDialog(
|
||||
dialog: LoadingDialogActions,
|
||||
message: string,
|
||||
options?: LoadingDialogOptions,
|
||||
): DialogId {
|
||||
return dialog.show({
|
||||
size: options?.size ?? "small",
|
||||
style: options?.style,
|
||||
closeOnEscape: false,
|
||||
closeOnClickOutside: false,
|
||||
content: () => <LoadingDialogContent message={message} />,
|
||||
});
|
||||
}
|
||||
|
||||
export async function withLoadingDialog<T>(
|
||||
dialog: LoadingDialogActions,
|
||||
message: string,
|
||||
run: () => Promise<T>,
|
||||
options?: LoadingDialogOptions,
|
||||
): Promise<T> {
|
||||
return await withShownDialog(
|
||||
dialog,
|
||||
() => showLoadingDialog(dialog, message, options),
|
||||
run,
|
||||
);
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { palette } from "../palette";
|
||||
import type { RuntimeToolInteraction } from "../types";
|
||||
import { formatApprovalParams } from "./dialogs/tool-approval";
|
||||
|
||||
export interface InlineToolResponseProps {
|
||||
interaction: RuntimeToolInteraction;
|
||||
accent: string;
|
||||
inputBackground: string;
|
||||
inputForeground: string;
|
||||
inputPlaceholder: string;
|
||||
onResolveToolApproval: (id: number, approved: boolean) => void;
|
||||
onResolveAskQuestion: (id: number, answer: string | null) => void;
|
||||
}
|
||||
|
||||
function isPrintableKey(name: string): boolean {
|
||||
return name.length === 1 || name === "space";
|
||||
}
|
||||
|
||||
function keyToText(name: string): string {
|
||||
return name === "space" ? " " : name;
|
||||
}
|
||||
|
||||
function Shell(
|
||||
props: Pick<
|
||||
InlineToolResponseProps,
|
||||
"accent" | "inputBackground" | "inputForeground"
|
||||
> & {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
},
|
||||
) {
|
||||
const { height } = useTerminalDimensions();
|
||||
const maxHeight = Math.max(7, Math.min(14, Math.floor(height * 0.38)));
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
maxHeight={maxHeight}
|
||||
backgroundColor={props.inputBackground}
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
gap={1}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={palette.act}>{props.title}</text>
|
||||
</box>
|
||||
{props.children}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceButton(props: {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
selectedFg?: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<box
|
||||
paddingX={1}
|
||||
backgroundColor={props.selected ? palette.selection : undefined}
|
||||
onMouseDown={props.onPress}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
props.selected
|
||||
? (props.selectedFg ?? palette.textOnSelection)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{props.label}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolApprovalResponse(
|
||||
props: InlineToolResponseProps & {
|
||||
interaction: Extract<RuntimeToolInteraction, { kind: "tool_approval" }>;
|
||||
},
|
||||
) {
|
||||
const [selected, setSelected] = useState<"approve" | "deny">("approve");
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
const request = props.interaction.request;
|
||||
const interactionId = props.interaction.id;
|
||||
const onResolveToolApproval = props.onResolveToolApproval;
|
||||
const params = formatApprovalParams(request.toolName, request.input);
|
||||
|
||||
const resolve = useCallback(
|
||||
(approved: boolean) => {
|
||||
onResolveToolApproval(interactionId, approved);
|
||||
},
|
||||
[interactionId, onResolveToolApproval],
|
||||
);
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "y") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
if (key.name === "n" || key.name === "escape") {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (key.name === "left" || key.name === "right" || key.name === "tab") {
|
||||
setSelected((current) => (current === "approve" ? "deny" : "approve"));
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
resolve(selectedRef.current === "approve");
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Shell
|
||||
title="Cline needs permission"
|
||||
accent={props.accent}
|
||||
inputBackground={props.inputBackground}
|
||||
inputForeground={props.inputForeground}
|
||||
>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg="yellow">Approve tool call?</text>
|
||||
<text fg={props.accent} selectable>
|
||||
{request.toolName}
|
||||
</text>
|
||||
{params && (
|
||||
<box flexDirection="column" overflow="hidden">
|
||||
{params}
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<ChoiceButton
|
||||
label="[y] Approve"
|
||||
selected={selected === "approve"}
|
||||
onPress={() => resolve(true)}
|
||||
/>
|
||||
<ChoiceButton
|
||||
label="[n] Deny"
|
||||
selected={selected === "deny"}
|
||||
onPress={() => resolve(false)}
|
||||
/>
|
||||
</box>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function AskQuestionResponse(
|
||||
props: InlineToolResponseProps & {
|
||||
interaction: Extract<RuntimeToolInteraction, { kind: "ask_question" }>;
|
||||
},
|
||||
) {
|
||||
const { interaction } = props;
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
const [customEmptyAttempted, setCustomEmptyAttempted] = useState(false);
|
||||
const selectedRef = useRef(0);
|
||||
const customValueRef = useRef("");
|
||||
const interactionId = interaction.id;
|
||||
const onResolveAskQuestion = props.onResolveAskQuestion;
|
||||
const customIndex = interaction.options.length;
|
||||
const isTyping = selected === customIndex;
|
||||
const totalChoices = interaction.options.length + 1;
|
||||
|
||||
const selectIndex = useCallback(
|
||||
(index: number) => {
|
||||
selectedRef.current = index;
|
||||
setSelected(index);
|
||||
if (index !== customIndex) {
|
||||
setCustomEmptyAttempted(false);
|
||||
}
|
||||
},
|
||||
[customIndex],
|
||||
);
|
||||
|
||||
const setCustomText = useCallback((value: string) => {
|
||||
customValueRef.current = value;
|
||||
setCustomValue(value);
|
||||
if (value.trim()) {
|
||||
setCustomEmptyAttempted(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resolveAnswer = useCallback(
|
||||
(answer: string | null) => {
|
||||
onResolveAskQuestion(interactionId, answer);
|
||||
},
|
||||
[interactionId, onResolveAskQuestion],
|
||||
);
|
||||
|
||||
useKeyboard((key) => {
|
||||
const typing = selectedRef.current === customIndex;
|
||||
if (key.name === "escape") {
|
||||
if (typing && customValueRef.current) {
|
||||
setCustomText("");
|
||||
return;
|
||||
}
|
||||
resolveAnswer(null);
|
||||
return;
|
||||
}
|
||||
if (typing && key.name === "backspace") {
|
||||
setCustomText(customValueRef.current.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (typing && key.name === "delete") {
|
||||
setCustomText("");
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
if (typing) {
|
||||
const answer = customValueRef.current.trim();
|
||||
if (answer) {
|
||||
resolveAnswer(answer);
|
||||
return;
|
||||
}
|
||||
setCustomEmptyAttempted(true);
|
||||
return;
|
||||
}
|
||||
resolveAnswer(interaction.options[selectedRef.current] ?? "");
|
||||
return;
|
||||
}
|
||||
if (key.name === "up" || (key.ctrl && key.name === "p")) {
|
||||
const next =
|
||||
selectedRef.current <= 0 ? totalChoices - 1 : selectedRef.current - 1;
|
||||
selectIndex(next);
|
||||
return;
|
||||
}
|
||||
if (key.name === "down" || (key.ctrl && key.name === "n")) {
|
||||
const next =
|
||||
selectedRef.current >= totalChoices - 1 ? 0 : selectedRef.current + 1;
|
||||
selectIndex(next);
|
||||
return;
|
||||
}
|
||||
if (!typing && key.name >= "1" && key.name <= "9") {
|
||||
const index = Number.parseInt(key.name, 10) - 1;
|
||||
const option = interaction.options[index];
|
||||
if (option) {
|
||||
resolveAnswer(option);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (typing && !key.ctrl && !key.meta && isPrintableKey(key.name)) {
|
||||
const value = keyToText(key.name);
|
||||
setCustomText(`${customValueRef.current}${value}`);
|
||||
return;
|
||||
}
|
||||
if (!key.ctrl && !key.meta && isPrintableKey(key.name)) {
|
||||
const value = keyToText(key.name);
|
||||
setCustomText(value);
|
||||
selectIndex(customIndex);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Shell
|
||||
title="Cline is asking a question"
|
||||
accent={props.accent}
|
||||
inputBackground={props.inputBackground}
|
||||
inputForeground={props.inputForeground}
|
||||
>
|
||||
<text fg={props.inputForeground} selectable>
|
||||
{interaction.question}
|
||||
</text>
|
||||
|
||||
<box flexDirection="column">
|
||||
{interaction.options.map((option, index) => {
|
||||
const optionSelected = !isTyping && selected === index;
|
||||
return (
|
||||
<box
|
||||
key={`${index.toString()}:${option}`}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={optionSelected ? palette.selection : undefined}
|
||||
onMouseDown={() => resolveAnswer(option)}
|
||||
>
|
||||
<text
|
||||
fg={optionSelected ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{optionSelected ? ">" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
optionSelected
|
||||
? palette.textOnSelection
|
||||
: props.inputForeground
|
||||
}
|
||||
>
|
||||
{option}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
<box
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
backgroundColor={isTyping ? palette.selection : undefined}
|
||||
onMouseDown={() => selectIndex(customIndex)}
|
||||
>
|
||||
<text fg={isTyping ? palette.textOnSelection : "gray"} flexShrink={0}>
|
||||
{isTyping ? ">" : " "}
|
||||
</text>
|
||||
{isTyping ? (
|
||||
<text fg={palette.textOnSelection} flexGrow={1}>
|
||||
{customValue
|
||||
? `${customValue}|`
|
||||
: customEmptyAttempted
|
||||
? "Type a response first..."
|
||||
: "Type a response..."}
|
||||
</text>
|
||||
) : (
|
||||
<text fg={props.inputPlaceholder}>Type a response...</text>
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineToolResponse(props: InlineToolResponseProps) {
|
||||
if (props.interaction.kind === "tool_approval") {
|
||||
return <ToolApprovalResponse {...props} interaction={props.interaction} />;
|
||||
}
|
||||
|
||||
return <AskQuestionResponse {...props} interaction={props.interaction} />;
|
||||
}
|
||||
@@ -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,203 +0,0 @@
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { RuntimeToolInteraction, TuiProps } from "../types";
|
||||
|
||||
type PendingRuntimeToolInteraction =
|
||||
| {
|
||||
id: number;
|
||||
kind: "tool_approval";
|
||||
request: ToolApprovalRequest;
|
||||
resolve: (result: ToolApprovalResult) => void;
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
kind: "ask_question";
|
||||
question: string;
|
||||
options: string[];
|
||||
resolve: (answer: string) => void;
|
||||
};
|
||||
|
||||
function toRuntimeToolInteraction(
|
||||
pending: PendingRuntimeToolInteraction,
|
||||
): RuntimeToolInteraction {
|
||||
if (pending.kind === "tool_approval") {
|
||||
return {
|
||||
id: pending.id,
|
||||
kind: pending.kind,
|
||||
request: pending.request,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: pending.id,
|
||||
kind: pending.kind,
|
||||
question: pending.question,
|
||||
options: pending.options,
|
||||
};
|
||||
}
|
||||
|
||||
function deniedToolResult(request: ToolApprovalRequest): ToolApprovalResult {
|
||||
return {
|
||||
approved: false,
|
||||
reason: `Tool "${request.toolName}" was denied by user`,
|
||||
};
|
||||
}
|
||||
|
||||
function dismissPendingInteraction(pending: PendingRuntimeToolInteraction) {
|
||||
if (pending.kind === "tool_approval") {
|
||||
pending.resolve(deniedToolResult(pending.request));
|
||||
return;
|
||||
}
|
||||
pending.resolve("[User dismissed the question]");
|
||||
}
|
||||
|
||||
export function useRuntimeDialogBridge(input: {
|
||||
setToolApprover: TuiProps["setToolApprover"];
|
||||
setAskQuestion: TuiProps["setAskQuestion"];
|
||||
setModeChangeNotifier: TuiProps["setModeChangeNotifier"];
|
||||
setUiMode: (mode: AgentMode) => void;
|
||||
refocusTextarea: () => void;
|
||||
}) {
|
||||
const {
|
||||
setToolApprover,
|
||||
setAskQuestion,
|
||||
setModeChangeNotifier,
|
||||
setUiMode,
|
||||
refocusTextarea,
|
||||
} = input;
|
||||
const [interaction, setInteraction] = useState<RuntimeToolInteraction | null>(
|
||||
null,
|
||||
);
|
||||
const activeRef = useRef<PendingRuntimeToolInteraction | null>(null);
|
||||
const queueRef = useRef<PendingRuntimeToolInteraction[]>([]);
|
||||
const nextIdRef = useRef(1);
|
||||
|
||||
const activate = useCallback((pending: PendingRuntimeToolInteraction) => {
|
||||
activeRef.current = pending;
|
||||
setInteraction(toRuntimeToolInteraction(pending));
|
||||
}, []);
|
||||
|
||||
const enqueue = useCallback(
|
||||
(pending: PendingRuntimeToolInteraction) => {
|
||||
if (activeRef.current) {
|
||||
queueRef.current.push(pending);
|
||||
return;
|
||||
}
|
||||
activate(pending);
|
||||
},
|
||||
[activate],
|
||||
);
|
||||
|
||||
const finishActive = useCallback(
|
||||
(id: number) => {
|
||||
if (activeRef.current?.id !== id) {
|
||||
return false;
|
||||
}
|
||||
const next = queueRef.current.shift() ?? null;
|
||||
if (next) {
|
||||
activate(next);
|
||||
return true;
|
||||
}
|
||||
activeRef.current = null;
|
||||
setInteraction(null);
|
||||
return false;
|
||||
},
|
||||
[activate],
|
||||
);
|
||||
|
||||
const resolveToolApproval = useCallback(
|
||||
(id: number, approved: boolean) => {
|
||||
const pending = activeRef.current;
|
||||
if (!pending || pending.id !== id || pending.kind !== "tool_approval") {
|
||||
return;
|
||||
}
|
||||
pending.resolve(
|
||||
approved ? { approved: true } : deniedToolResult(pending.request),
|
||||
);
|
||||
const hasNext = finishActive(id);
|
||||
if (!hasNext) {
|
||||
refocusTextarea();
|
||||
}
|
||||
},
|
||||
[finishActive, refocusTextarea],
|
||||
);
|
||||
|
||||
const resolveAskQuestion = useCallback(
|
||||
(id: number, answer: string | null) => {
|
||||
const pending = activeRef.current;
|
||||
if (!pending || pending.id !== id || pending.kind !== "ask_question") {
|
||||
return;
|
||||
}
|
||||
pending.resolve(
|
||||
answer === null ? "[User dismissed the question]" : answer,
|
||||
);
|
||||
const hasNext = finishActive(id);
|
||||
if (!hasNext) {
|
||||
refocusTextarea();
|
||||
}
|
||||
},
|
||||
[finishActive, refocusTextarea],
|
||||
);
|
||||
|
||||
const dismissAll = useCallback(() => {
|
||||
if (activeRef.current) {
|
||||
dismissPendingInteraction(activeRef.current);
|
||||
activeRef.current = null;
|
||||
}
|
||||
for (const pending of queueRef.current) {
|
||||
dismissPendingInteraction(pending);
|
||||
}
|
||||
queueRef.current = [];
|
||||
setInteraction(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setToolApprover(
|
||||
(request) =>
|
||||
new Promise<ToolApprovalResult>((resolve) => {
|
||||
enqueue({
|
||||
id: nextIdRef.current,
|
||||
kind: "tool_approval",
|
||||
request,
|
||||
resolve,
|
||||
});
|
||||
nextIdRef.current += 1;
|
||||
}),
|
||||
);
|
||||
setAskQuestion(
|
||||
(question, options) =>
|
||||
new Promise<string>((resolve) => {
|
||||
enqueue({
|
||||
id: nextIdRef.current,
|
||||
kind: "ask_question",
|
||||
question,
|
||||
options,
|
||||
resolve,
|
||||
});
|
||||
nextIdRef.current += 1;
|
||||
}),
|
||||
);
|
||||
setModeChangeNotifier((mode) => {
|
||||
setUiMode(mode);
|
||||
});
|
||||
return () => {
|
||||
setToolApprover(null);
|
||||
setAskQuestion(null);
|
||||
setModeChangeNotifier(null);
|
||||
dismissAll();
|
||||
};
|
||||
}, [
|
||||
dismissAll,
|
||||
enqueue,
|
||||
setAskQuestion,
|
||||
setModeChangeNotifier,
|
||||
setToolApprover,
|
||||
setUiMode,
|
||||
]);
|
||||
|
||||
return {
|
||||
interaction,
|
||||
resolveToolApproval,
|
||||
resolveAskQuestion,
|
||||
};
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getToolErrorPresentation,
|
||||
isWarningToolError,
|
||||
unwrapToolError,
|
||||
} from "./tool-errors";
|
||||
|
||||
describe("tool error presentation", () => {
|
||||
it("unwraps JSON-encoded tool errors", () => {
|
||||
const raw = JSON.stringify({
|
||||
error: "Tool call run_commands was rejected before execution: nope",
|
||||
});
|
||||
|
||||
expect(unwrapToolError(raw)).toBe(
|
||||
"Tool call run_commands was rejected before execution: nope",
|
||||
);
|
||||
});
|
||||
|
||||
it("summarizes invalid tool input as a warning", () => {
|
||||
const raw = JSON.stringify({
|
||||
error:
|
||||
'Tool call run_commands was rejected before execution: Invalid input for tool run_commands: Type validation failed: Value: {"commands":[{"command":"cat file"}]}.\nError message: []',
|
||||
});
|
||||
|
||||
expect(getToolErrorPresentation(raw)).toMatchObject({
|
||||
severity: "warning",
|
||||
summary: "Invalid run_commands input; tool call skipped.",
|
||||
});
|
||||
expect(isWarningToolError(raw)).toBe(true);
|
||||
});
|
||||
|
||||
it("summarizes generic pre-execution rejections as warnings", () => {
|
||||
expect(
|
||||
getToolErrorPresentation(
|
||||
"Tool call editor was rejected before execution: approval request failed",
|
||||
),
|
||||
).toMatchObject({
|
||||
severity: "warning",
|
||||
summary: "editor call was skipped before execution.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-rejection failures as errors", () => {
|
||||
const presentation = getToolErrorPresentation("command failed with exit 1");
|
||||
|
||||
expect(presentation).toEqual({
|
||||
severity: "error",
|
||||
summary: "command failed with exit 1",
|
||||
detail: "command failed with exit 1",
|
||||
});
|
||||
});
|
||||
|
||||
it("summarizes JSON-wrapped hard errors without dumping stacks", () => {
|
||||
const raw = JSON.stringify({
|
||||
error:
|
||||
"Error: command failed with exit 1\n at runTool (/tmp/tool.ts:10:1)\n at async main (/tmp/main.ts:5:1)",
|
||||
});
|
||||
|
||||
expect(getToolErrorPresentation(raw)).toEqual({
|
||||
severity: "error",
|
||||
summary: "command failed with exit 1",
|
||||
detail:
|
||||
"Error: command failed with exit 1\n at runTool (/tmp/tool.ts:10:1)\n at async main (/tmp/main.ts:5:1)",
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses long one-line hard errors", () => {
|
||||
const detail = `Validation failed: ${"x".repeat(180)}`;
|
||||
const presentation = getToolErrorPresentation(detail);
|
||||
|
||||
expect(presentation.severity).toBe("error");
|
||||
expect(presentation.detail).toBe(detail);
|
||||
expect(presentation.summary.length).toBeLessThanOrEqual(140);
|
||||
expect(presentation.summary.endsWith("...")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a generic summary when no string error can be extracted", () => {
|
||||
const presentation = getToolErrorPresentation(
|
||||
JSON.stringify({ code: "E_TOOL", data: { value: 1 } }),
|
||||
);
|
||||
|
||||
expect(presentation).toEqual({
|
||||
severity: "error",
|
||||
summary: "Tool returned a structured error.",
|
||||
detail: JSON.stringify({ code: "E_TOOL", data: { value: 1 } }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
export interface ToolErrorPresentation {
|
||||
severity: "warning" | "error";
|
||||
summary: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const MAX_ERROR_SUMMARY_LENGTH = 140;
|
||||
|
||||
function extractStringError(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return value.trim() || undefined;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
extractStringError(record.error) ??
|
||||
extractStringError(record.message) ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function unwrapToolError(error: string): string {
|
||||
let current = error.trim();
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
if (!current.startsWith("{") && !current.startsWith("[")) break;
|
||||
try {
|
||||
const parsed = JSON.parse(current) as unknown;
|
||||
const next = extractStringError(parsed);
|
||||
if (!next || next === current) break;
|
||||
current = next.trim();
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function summarizeInvalidInput(message: string): string | undefined {
|
||||
const rejected = message.match(
|
||||
/^Tool call\s+([A-Za-z0-9_-]+)\s+was rejected before execution:\s+Invalid input for tool\s+([A-Za-z0-9_-]+):\s*([^.\n]+)(?:\.|\n|$)/,
|
||||
);
|
||||
if (rejected) {
|
||||
return `Invalid ${rejected[2]} input; tool call skipped.`;
|
||||
}
|
||||
|
||||
const invalid = message.match(
|
||||
/^Invalid input for tool\s+([A-Za-z0-9_-]+):\s*([^.\n]+)(?:\.|\n|$)/,
|
||||
);
|
||||
if (invalid) {
|
||||
return `Invalid ${invalid[1]} input; tool call skipped.`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function truncateSummary(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length <= MAX_ERROR_SUMMARY_LENGTH) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, MAX_ERROR_SUMMARY_LENGTH - 3).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function summarizeErrorDetail(detail: string): string {
|
||||
const trimmed = detail.trim();
|
||||
if (!trimmed) {
|
||||
return "Tool failed.";
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
return "Tool returned a structured error.";
|
||||
}
|
||||
|
||||
const firstLine =
|
||||
trimmed
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? "Tool failed.";
|
||||
const withoutGenericPrefix = firstLine.replace(/^Error:\s+/i, "");
|
||||
|
||||
return truncateSummary(withoutGenericPrefix.replace(/\s+/g, " "));
|
||||
}
|
||||
|
||||
export function getToolErrorPresentation(error: string): ToolErrorPresentation {
|
||||
const detail = unwrapToolError(error);
|
||||
const inputSummary = summarizeInvalidInput(detail);
|
||||
if (inputSummary) {
|
||||
return {
|
||||
severity: "warning",
|
||||
summary: inputSummary,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
const rejected = detail.match(
|
||||
/^Tool call\s+([A-Za-z0-9_-]+)\s+was rejected before execution:/,
|
||||
);
|
||||
if (rejected) {
|
||||
return {
|
||||
severity: "warning",
|
||||
summary: `${rejected[1]} call was skipped before execution.`,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
severity: "error",
|
||||
summary: summarizeErrorDetail(detail),
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
export function isWarningToolError(error: string | undefined): boolean {
|
||||
return error ? getToolErrorPresentation(error).severity === "warning" : false;
|
||||
}
|
||||
@@ -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,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,22 +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",
|
||||
"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,96 +0,0 @@
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildInviteUrl(
|
||||
publicUrl: string,
|
||||
roomSecret: string | undefined,
|
||||
): string {
|
||||
if (!roomSecret) return publicUrl;
|
||||
const url = new URL(publicUrl);
|
||||
url.searchParams.set("roomSecret", roomSecret);
|
||||
return url.toString();
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import { CORE_BUILD_VERSION } from "@cline/core";
|
||||
import { isNonLocalBindHost } from "./options";
|
||||
import {
|
||||
handleToolApprovalResponse,
|
||||
rejectOrphanedApprovals,
|
||||
} from "./server/approvals";
|
||||
import {
|
||||
browserConfig,
|
||||
host,
|
||||
inviteUrl,
|
||||
port,
|
||||
publicUrl,
|
||||
roomSecret,
|
||||
webviewDistDir,
|
||||
} from "./server/deps";
|
||||
import { handleDesktopCommand } from "./server/desktop-commands";
|
||||
import { createJsonResponse, WebviewAssets } from "./server/http";
|
||||
import {
|
||||
attachHub,
|
||||
detachHub,
|
||||
restartHub,
|
||||
syncHubClientsAndSessions,
|
||||
syncHubHealth,
|
||||
} from "./server/hub";
|
||||
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>;
|
||||
}
|
||||
|
||||
export async function startClineHubDashboardServer(): Promise<ClineHubDashboardServer> {
|
||||
const ctx = new HubContext();
|
||||
const assets = new WebviewAssets(webviewDistDir);
|
||||
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
|
||||
let stopped = false;
|
||||
|
||||
function isAuthorizedBrowserRequest(url: URL): boolean {
|
||||
if (!roomSecret) return true;
|
||||
return url.searchParams.get("roomSecret") === roomSecret;
|
||||
}
|
||||
|
||||
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 (url.pathname === "/version") {
|
||||
return createJsonResponse({ coreVersion: CORE_BUILD_VERSION });
|
||||
}
|
||||
if (url.pathname === "/health") {
|
||||
await syncHubHealth(ctx);
|
||||
return createJsonResponse(hubStatusPayload(ctx));
|
||||
}
|
||||
if (url.pathname === "/browser") {
|
||||
if (!isAuthorizedBrowserRequest(url)) {
|
||||
return createJsonResponse({ error: "invalid_room_secret" }, 401);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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,193 +0,0 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import process from "node:process";
|
||||
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";
|
||||
|
||||
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 = (process.versions as Record<string, string | undefined>).bun
|
||||
? process.execPath
|
||||
: "bun";
|
||||
const child = spawn(
|
||||
launcher,
|
||||
["--conditions=development", cliIndexPath, "connect", ...args],
|
||||
{
|
||||
cwd: workspaceRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV ?? "development",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
},
|
||||
);
|
||||
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(
|
||||
(result.stderr.trim() || result.stdout.trim() || "connector start failed")
|
||||
.trim()
|
||||
.slice(0, 2_000),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(() =>
|
||||
listActiveConnectors().some((connector) => connector.type === channel),
|
||||
);
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
|
||||
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(
|
||||
(result.stderr.trim() || result.stdout.trim() || "connector stop failed")
|
||||
.trim()
|
||||
.slice(0, 2_000),
|
||||
);
|
||||
}
|
||||
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,238 +0,0 @@
|
||||
import {
|
||||
addLocalProvider,
|
||||
type ClineAccountActionRequest,
|
||||
ClineAccountService,
|
||||
ensureCustomProvidersLoaded,
|
||||
executeClineAccountAction,
|
||||
getLocalProviderModels,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
type ProviderProtocol,
|
||||
readGlobalSettings,
|
||||
resolveLocalClineAuthToken,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setTelemetryOptOutGlobally,
|
||||
toggleDisabledTool,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import {
|
||||
connectorChannelsPayload,
|
||||
startConnectorChannel,
|
||||
stopConnectorChannel,
|
||||
} from "./connectors";
|
||||
import { providerSettingsManager, workspaceRoot } from "./deps";
|
||||
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",
|
||||
]);
|
||||
|
||||
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 existing = providerSettingsManager.getProviderSettings(providerId);
|
||||
const credentials = await loginLocalProvider(
|
||||
providerId,
|
||||
existing,
|
||||
openExternalUrl,
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
};
|
||||
}
|
||||
if (command === "cline_account") {
|
||||
const settings = providerSettingsManager.getProviderSettings("cline");
|
||||
const accountService = new ClineAccountService({
|
||||
apiBaseUrl:
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
getAuthToken: async () => resolveLocalClineAuthToken(settings),
|
||||
});
|
||||
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 === "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 === "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,123 +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" },
|
||||
});
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
||||
|
||||
function isWebviewRoute(pathname: string): boolean {
|
||||
return (
|
||||
pathname === "/" ||
|
||||
pathname === "/index.html" ||
|
||||
pathname === "/chat" ||
|
||||
pathname === "/settings" ||
|
||||
pathname.startsWith("/settings/")
|
||||
);
|
||||
}
|
||||
|
||||
function renderDevIndexHtml(devServerUrl: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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}/favicon.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 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(indexFile, {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
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" },
|
||||
});
|
||||
}
|
||||
if (isWebviewRoute(pathname)) {
|
||||
return this.serveIndex();
|
||||
}
|
||||
|
||||
const filePath = this.resolveStaticPath(pathname);
|
||||
if (!filePath) return createTextResponse("not found", 404);
|
||||
const file = Bun.file(filePath);
|
||||
if (!(await file.exists())) {
|
||||
return createTextResponse("not found", 404);
|
||||
}
|
||||
return new Response(file, {
|
||||
headers: { "content-type": contentTypeFor(filePath) },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,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,158 +0,0 @@
|
||||
import process from "node:process";
|
||||
import {
|
||||
ensureCustomProvidersLoaded,
|
||||
getLocalProviderModels,
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
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 existing = providerSettingsManager.getProviderSettings(normalized);
|
||||
const credentials = await loginLocalProvider(
|
||||
normalized,
|
||||
existing,
|
||||
openExternalUrl,
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
normalized,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
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,285 +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,
|
||||
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,178 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { extname, join, basename as pathBasename } 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);
|
||||
}
|
||||
|
||||
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: pathBasename(filePath, extname(filePath)),
|
||||
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,139 +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 });
|
||||
child.unref();
|
||||
}
|
||||
@@ -1,57 +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",
|
||||
);
|
||||
|
||||
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,343 +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;
|
||||
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,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/geist": "^5.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@rive-app/react-webgl2": "^4.27.2",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@streamdown/math": "^1.0.2",
|
||||
"@streamdown/mermaid": "^1.0.2",
|
||||
"@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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
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,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,271 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu";
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn(
|
||||
"z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none 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-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"w-auto min-w-24 rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-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-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,
|
||||
)}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon />
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Switch as SwitchPrimitive } from "@base-ui/react/switch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: SwitchPrimitive.Root.Props & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Switch };
|
||||
@@ -1,577 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
ClineAccountBalance,
|
||||
ClineAccountOrganization,
|
||||
ClineAccountOrganizationBalance,
|
||||
ClineAccountOrganizationUsageTransaction,
|
||||
ClineAccountPaymentTransaction,
|
||||
ClineAccountUsageTransaction,
|
||||
ClineAccountUser,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
Building,
|
||||
CreditCard,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
LogOut,
|
||||
Plus,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function normalizeAccountViewError(error: unknown): Error {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("unsupported desktop command: cline_account")) {
|
||||
return new Error(
|
||||
"The desktop sidecar is running an older build that does not support account commands. Restart the sidecar or reload the app, then try again.",
|
||||
);
|
||||
}
|
||||
return error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching helpers via sidecar command
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchAccountUser(): Promise<ClineAccountUser> {
|
||||
return await desktopClient.invoke<ClineAccountUser>("cline_account", {
|
||||
action: "clineAccount",
|
||||
operation: "fetchMe",
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAccountBalance(): Promise<ClineAccountBalance> {
|
||||
return await desktopClient.invoke<ClineAccountBalance>("cline_account", {
|
||||
action: "clineAccount",
|
||||
operation: "fetchBalance",
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAccountOrganizations(): Promise<
|
||||
ClineAccountOrganization[]
|
||||
> {
|
||||
return await desktopClient.invoke<ClineAccountOrganization[]>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
operation: "fetchUserOrganizations",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchOrganizationBalance(
|
||||
organizationId: string,
|
||||
): Promise<ClineAccountOrganizationBalance> {
|
||||
return await desktopClient.invoke<ClineAccountOrganizationBalance>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
operation: "fetchOrganizationBalance",
|
||||
organizationId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchUsageTransactions(): Promise<
|
||||
ClineAccountUsageTransaction[]
|
||||
> {
|
||||
return await desktopClient.invoke<ClineAccountUsageTransaction[]>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
operation: "fetchUsageTransactions",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchOrganizationUsageTransactions(
|
||||
organizationId: string,
|
||||
memberId?: string,
|
||||
): Promise<ClineAccountOrganizationUsageTransaction[]> {
|
||||
return await desktopClient.invoke<ClineAccountOrganizationUsageTransaction[]>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
operation: "fetchOrganizationUsageTransactions",
|
||||
organizationId,
|
||||
...(memberId?.trim() ? { memberId: memberId.trim() } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchPaymentTransactions(): Promise<
|
||||
ClineAccountPaymentTransaction[]
|
||||
> {
|
||||
return await desktopClient.invoke<ClineAccountPaymentTransaction[]>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
operation: "fetchPaymentTransactions",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AccountView() {
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "usage" | "billing">(
|
||||
"overview",
|
||||
);
|
||||
|
||||
// Overview data
|
||||
const [user, setUser] = useState<ClineAccountUser | null>(null);
|
||||
const [balance, setBalance] = useState<ClineAccountBalance | null>(null);
|
||||
const [organizationBalance, setOrganizationBalance] =
|
||||
useState<ClineAccountOrganizationBalance | null>(null);
|
||||
const [organizations, setOrganizations] = useState<
|
||||
ClineAccountOrganization[]
|
||||
>([]);
|
||||
const [overviewLoading, setOverviewLoading] = useState(true);
|
||||
const [overviewError, setOverviewError] = useState<string | null>(null);
|
||||
|
||||
// Usage data
|
||||
const [usageTransactions, setUsageTransactions] = useState<
|
||||
ClineAccountUsageTransaction[]
|
||||
>([]);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [usageError, setUsageError] = useState<string | null>(null);
|
||||
const [usageLoaded, setUsageLoaded] = useState(false);
|
||||
const usageGenerationRef = useRef(0);
|
||||
|
||||
// Billing data
|
||||
const [paymentTransactions, setPaymentTransactions] = useState<
|
||||
ClineAccountPaymentTransaction[]
|
||||
>([]);
|
||||
const [billingLoading, setBillingLoading] = useState(false);
|
||||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
const [billingLoaded, setBillingLoaded] = useState(false);
|
||||
const activeOrganization = organizations.find((org) => org.active) ?? null;
|
||||
|
||||
// -- Overview fetch --
|
||||
const loadOverview = useCallback(async () => {
|
||||
setOverviewLoading(true);
|
||||
setOverviewError(null);
|
||||
try {
|
||||
const [userData, balanceData, orgsData] = await Promise.all([
|
||||
fetchAccountUser(),
|
||||
fetchAccountBalance(),
|
||||
fetchAccountOrganizations(),
|
||||
]);
|
||||
const nextActiveOrganization =
|
||||
orgsData.find((organization) => organization.active) ?? null;
|
||||
const organizationBalanceData = nextActiveOrganization
|
||||
? await fetchOrganizationBalance(nextActiveOrganization.organizationId)
|
||||
: null;
|
||||
setUser(userData);
|
||||
setBalance(balanceData);
|
||||
setOrganizationBalance(organizationBalanceData);
|
||||
setOrganizations(orgsData);
|
||||
} catch (err) {
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setOverviewError(message);
|
||||
} finally {
|
||||
setOverviewLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
}, [loadOverview]);
|
||||
|
||||
// -- Usage fetch (lazy on tab switch) --
|
||||
const loadUsage = useCallback(async () => {
|
||||
const generation = usageGenerationRef.current;
|
||||
setUsageLoading(true);
|
||||
setUsageError(null);
|
||||
try {
|
||||
const data = activeOrganization
|
||||
? await fetchOrganizationUsageTransactions(
|
||||
activeOrganization.organizationId,
|
||||
activeOrganization.memberId,
|
||||
)
|
||||
: await fetchUsageTransactions();
|
||||
if (usageGenerationRef.current !== generation) return;
|
||||
setUsageTransactions(data);
|
||||
setUsageLoaded(true);
|
||||
} catch (err) {
|
||||
if (usageGenerationRef.current !== generation) return;
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setUsageError(message);
|
||||
} finally {
|
||||
if (usageGenerationRef.current === generation) {
|
||||
setUsageLoading(false);
|
||||
}
|
||||
}
|
||||
}, [activeOrganization]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we need to reset usage state when the organization changes
|
||||
useEffect(() => {
|
||||
usageGenerationRef.current += 1;
|
||||
setUsageTransactions([]);
|
||||
setUsageLoaded(false);
|
||||
setUsageError(null);
|
||||
}, [activeOrganization?.organizationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "usage" && !usageLoaded) {
|
||||
void loadUsage();
|
||||
}
|
||||
}, [activeTab, usageLoaded, loadUsage]);
|
||||
|
||||
// -- Billing fetch (lazy on tab switch) --
|
||||
const loadBilling = useCallback(async () => {
|
||||
setBillingLoading(true);
|
||||
setBillingError(null);
|
||||
try {
|
||||
const data = await fetchPaymentTransactions();
|
||||
setPaymentTransactions(data);
|
||||
setBillingLoaded(true);
|
||||
} catch (err) {
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setBillingError(message);
|
||||
} finally {
|
||||
setBillingLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "billing" && !billingLoaded) {
|
||||
void loadBilling();
|
||||
}
|
||||
}, [activeTab, billingLoaded, loadBilling]);
|
||||
|
||||
// -- Formatters --
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatCreditBalance = (value: number, decimalPlaces = 2) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: decimalPlaces,
|
||||
maximumFractionDigits: decimalPlaces,
|
||||
}).format(value / 1_000_000);
|
||||
};
|
||||
|
||||
const displayedBalance = activeOrganization
|
||||
? (organizationBalance?.balance ?? balance?.balance ?? null)
|
||||
: (balance?.balance ?? null);
|
||||
|
||||
const tabs = ["overview", "usage", "billing"] as const;
|
||||
|
||||
// -- Shared error / loading UI --
|
||||
|
||||
const renderError = (message: string, onRetry: () => void) => (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm text-muted-foreground max-w-md">{message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderLoading = () => (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">Account</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex items-center gap-0 border-b border-border">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"relative px-4 py-2.5 text-sm font-medium capitalize transition-colors",
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{overviewLoading && renderLoading()}
|
||||
{overviewError && renderError(overviewError, loadOverview)}
|
||||
{!overviewLoading && !overviewError && user && (
|
||||
<>
|
||||
{/* User Profile Card */}
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary/20 text-2xl font-bold text-primary">
|
||||
{user.displayName?.charAt(0) ??
|
||||
user.email?.charAt(0) ??
|
||||
"?"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{user.displayName || user.email}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Member since {formatDate(user.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="https://app.cline.bot/dashboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance Card */}
|
||||
{displayedBalance !== null && (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{activeOrganization
|
||||
? `${activeOrganization.name} Balance`
|
||||
: "Credits Balance"}
|
||||
</h3>
|
||||
</div>
|
||||
<a
|
||||
href="https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Credit
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
${formatCreditBalance(displayedBalance)}
|
||||
</span>
|
||||
</div>
|
||||
{activeOrganization && balance && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Personal account: {formatCreditBalance(balance.balance)}{" "}
|
||||
credits
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organizations */}
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Organizations
|
||||
</h3>
|
||||
</div>
|
||||
<a
|
||||
href="https://app.cline.bot/onboarding?step=1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create
|
||||
</a>
|
||||
</div>
|
||||
{organizations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No organizations yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{organizations.map((org) => (
|
||||
<div
|
||||
key={org.organizationId}
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-foreground">
|
||||
{org.name.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{org.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{org.roles.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
{org.active && (
|
||||
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Tab */}
|
||||
{activeTab === "usage" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{activeOrganization
|
||||
? `Recent API usage and token consumption for ${activeOrganization.name}.`
|
||||
: "Recent API usage and token consumption across all providers."}
|
||||
</p>
|
||||
{usageLoading && renderLoading()}
|
||||
{usageError && renderError(usageError, loadUsage)}
|
||||
{!usageLoading &&
|
||||
!usageError &&
|
||||
usageLoaded &&
|
||||
(usageTransactions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No usage transactions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Model</span>
|
||||
<span className="text-right">Tokens</span>
|
||||
<span className="text-right">Credits</span>
|
||||
<span className="text-right">Time</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{usageTransactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{tx.aiModelName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tx.aiInferenceProviderName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-muted-foreground">
|
||||
{tx.totalTokens.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
{formatCreditBalance(tx.creditsUsed)}
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p>{formatDate(tx.createdAt)}</p>
|
||||
<p>{formatTime(tx.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Tab */}
|
||||
{activeTab === "billing" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Payment history and credit purchases.
|
||||
</p>
|
||||
{billingLoading && renderLoading()}
|
||||
{billingError && renderError(billingError, loadBilling)}
|
||||
{!billingLoading &&
|
||||
!billingError &&
|
||||
billingLoaded &&
|
||||
(paymentTransactions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No payment transactions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Date</span>
|
||||
<span className="text-right">Amount</span>
|
||||
<span className="text-right">Credits</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{paymentTransactions.map((tx) => (
|
||||
<div
|
||||
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
|
||||
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-foreground">
|
||||
{formatDate(tx.paidAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
${(tx.amountCents / 100).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-right text-primary font-medium">
|
||||
+{formatCreditBalance(tx.credits)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -1,533 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const CAPABILITY_OPTIONS = [
|
||||
"streaming",
|
||||
"tools",
|
||||
"reasoning",
|
||||
"vision",
|
||||
"prompt-cache",
|
||||
] as const;
|
||||
|
||||
type Capability = (typeof CAPABILITY_OPTIONS)[number];
|
||||
|
||||
export interface AddProviderPayload {
|
||||
providerId: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
models: string[];
|
||||
defaultModelId?: string;
|
||||
modelsSourceUrl?: string;
|
||||
capabilities?: Capability[];
|
||||
}
|
||||
|
||||
interface NewProviderForm {
|
||||
providerId: string;
|
||||
name: string;
|
||||
models: string[];
|
||||
defaultModel: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
modelsSourceUrl: string;
|
||||
headers: Record<string, string>;
|
||||
timeoutMs: string;
|
||||
capabilities: Capability[];
|
||||
}
|
||||
|
||||
export function AddProviderContent({
|
||||
onBack,
|
||||
onSave,
|
||||
existingProviderIds,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onSave: (payload: AddProviderPayload) => Promise<void>;
|
||||
existingProviderIds: string[];
|
||||
}) {
|
||||
const [form, setForm] = useState<NewProviderForm>({
|
||||
providerId: "",
|
||||
name: "",
|
||||
models: [],
|
||||
defaultModel: "",
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
modelsSourceUrl: "",
|
||||
headers: {},
|
||||
timeoutMs: "",
|
||||
capabilities: ["streaming", "tools"],
|
||||
});
|
||||
const [modelInput, setModelInput] = useState("");
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const normalizedProviderId = useMemo(
|
||||
() => form.providerId.trim().toLowerCase().replace(/\s+/g, "-"),
|
||||
[form.providerId],
|
||||
);
|
||||
|
||||
const duplicateProviderId =
|
||||
existingProviderIds.includes(normalizedProviderId);
|
||||
const hasManualModels = form.models.length > 0;
|
||||
const hasModelsSource = form.modelsSourceUrl.trim().length > 0;
|
||||
const canSave =
|
||||
normalizedProviderId.length > 0 &&
|
||||
form.name.trim().length > 0 &&
|
||||
form.baseUrl.trim().length > 0 &&
|
||||
(hasManualModels || hasModelsSource) &&
|
||||
!duplicateProviderId;
|
||||
|
||||
const handleAddModel = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if ((e.key === "Enter" || e.key === ",") && modelInput.trim()) {
|
||||
e.preventDefault();
|
||||
const value = modelInput.trim().replace(/,/g, "");
|
||||
if (value && !form.models.includes(value)) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
models: [...prev.models, value],
|
||||
defaultModel: prev.defaultModel || value,
|
||||
}));
|
||||
}
|
||||
setModelInput("");
|
||||
} else if (e.key === "Backspace" && !modelInput && form.models.length > 0) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
models: prev.models.slice(0, -1),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const removeModel = (model: string) => {
|
||||
setForm((prev) => {
|
||||
const nextModels = prev.models.filter((m) => m !== model);
|
||||
return {
|
||||
...prev,
|
||||
models: nextModels,
|
||||
defaultModel:
|
||||
prev.defaultModel === model
|
||||
? (nextModels[0] ?? "")
|
||||
: prev.defaultModel,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCapability = (cap: Capability) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
capabilities: prev.capabilities.includes(cap)
|
||||
? prev.capabilities.filter((c) => c !== cap)
|
||||
: [...prev.capabilities, cap],
|
||||
}));
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
setForm((prev) => ({ ...prev, headers: { ...prev.headers, "": "" } }));
|
||||
};
|
||||
|
||||
const updateHeaderKey = (oldKey: string, newKey: string, idx: number) => {
|
||||
const entries = Object.entries(form.headers);
|
||||
const next: Record<string, string> = {};
|
||||
entries.forEach(([key, value], index) => {
|
||||
next[index === idx ? newKey : key] = value;
|
||||
});
|
||||
if (oldKey !== newKey) {
|
||||
delete next[oldKey];
|
||||
}
|
||||
setForm((prev) => ({ ...prev, headers: next }));
|
||||
};
|
||||
|
||||
const updateHeaderValue = (key: string, value: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
headers: { ...prev.headers, [key]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const removeHeader = (key: string) => {
|
||||
const next = { ...form.headers };
|
||||
delete next[key];
|
||||
setForm((prev) => ({ ...prev, headers: next }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSave || saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({
|
||||
providerId: normalizedProviderId,
|
||||
name: form.name.trim(),
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
apiKey: form.apiKey.trim() || undefined,
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(form.headers)
|
||||
.map(([key, value]) => [key.trim(), value])
|
||||
.filter(([key]) => key.length > 0),
|
||||
),
|
||||
timeoutMs:
|
||||
form.timeoutMs.trim().length > 0
|
||||
? Number.parseInt(form.timeoutMs.trim(), 10)
|
||||
: undefined,
|
||||
models: form.models,
|
||||
defaultModelId: form.defaultModel || form.models[0],
|
||||
modelsSourceUrl: form.modelsSourceUrl.trim() || undefined,
|
||||
capabilities:
|
||||
form.capabilities.length > 0 ? form.capabilities : undefined,
|
||||
});
|
||||
} catch (saveError) {
|
||||
setError(
|
||||
saveError instanceof Error ? saveError.message : String(saveError),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
variant="secondary"
|
||||
className="rounded-md p-1.5 transition-colors"
|
||||
aria-label="Back to providers"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Add Provider
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">
|
||||
OpenAI-Compatible Provider
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider ID
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.providerId}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, providerId: e.target.value }))
|
||||
}
|
||||
placeholder="my-provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Lowercase ID used in provider registry.
|
||||
</p>
|
||||
{duplicateProviderId ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
This provider ID already exists.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider Name
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="My Provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Base URL
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.baseUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Model Source URL (Optional)
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.modelsSourceUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelsSourceUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://api.example.com/v1/models"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Supported JSON: OpenAI `/models` shape with a `data` array, or a
|
||||
direct model array.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Models
|
||||
</Label>
|
||||
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
|
||||
{form.models.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
|
||||
>
|
||||
<span className="font-mono">{model}</span>
|
||||
<Button
|
||||
onClick={() => removeModel(model)}
|
||||
className="text-primary/60 hover:text-primary transition-colors"
|
||||
aria-label={`Remove ${model}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={modelInput}
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
onKeyDown={handleAddModel}
|
||||
placeholder={
|
||||
form.models.length === 0
|
||||
? "Type model ID and press Enter"
|
||||
: ""
|
||||
}
|
||||
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Add at least one model or set a Model Source URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.models.length > 1 ? (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Default Model
|
||||
</Label>
|
||||
<select
|
||||
value={form.defaultModel}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{form.models.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
API Key (Optional)
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={form.apiKey}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<Button
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigator.clipboard.writeText(form.apiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label="Copy API key"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
|
||||
Capabilities
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CAPABILITY_OPTIONS.map((cap) => (
|
||||
<Button
|
||||
key={cap}
|
||||
onClick={() => toggleCapability(cap)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||||
form.capabilities.includes(cap)
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{cap.replace(/-/g, " ")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<Button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
|
||||
variant="ghost"
|
||||
>
|
||||
Advanced Settings
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
showAdvanced && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{showAdvanced ? (
|
||||
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Timeout (ms)
|
||||
</Label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.timeoutMs}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
timeoutMs: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="30000"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Custom Headers
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(form.headers).map(([key, value], idx) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) =>
|
||||
updateHeaderKey(key, e.target.value, idx)
|
||||
}
|
||||
placeholder="Header name"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
updateHeaderValue(key, e.target.value)
|
||||
}
|
||||
placeholder="Value"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeHeader(key)}
|
||||
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Remove header"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={addHeader}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!canSave || saving}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
|
||||
canSave && !saving
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{saving ? "Saving..." : "Add Provider"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -1,644 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Circle, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ConnectorField = {
|
||||
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;
|
||||
};
|
||||
};
|
||||
|
||||
type ConnectorSecurityField = {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
help?: string[];
|
||||
requiredMessage: string;
|
||||
};
|
||||
|
||||
type ConnectorChannel = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: ConnectorField[];
|
||||
security?: {
|
||||
prompt: string;
|
||||
fields: ConnectorSecurityField[];
|
||||
};
|
||||
};
|
||||
|
||||
type ActiveConnector = {
|
||||
id: string;
|
||||
type: string;
|
||||
pid: number;
|
||||
hubUrl: string;
|
||||
startedAt?: string;
|
||||
applicationId?: string;
|
||||
botUsername?: string;
|
||||
userName?: string;
|
||||
phoneNumberId?: string;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
connectionMode?: string;
|
||||
};
|
||||
|
||||
type ConnectorChannelsResponse = {
|
||||
available: ConnectorChannel[];
|
||||
active: ActiveConnector[];
|
||||
};
|
||||
|
||||
type ConnectorFormState = {
|
||||
channelId: string;
|
||||
values: Record<string, string>;
|
||||
securityEnabled: boolean;
|
||||
securityValues: Record<string, string>;
|
||||
};
|
||||
|
||||
function connectorName(
|
||||
connector: ActiveConnector,
|
||||
channels: ConnectorChannel[],
|
||||
): string {
|
||||
return (
|
||||
channels.find((channel) => channel.id === connector.type)?.name ??
|
||||
connector.type
|
||||
);
|
||||
}
|
||||
|
||||
function connectorIdentity(connector: ActiveConnector): string {
|
||||
if (connector.botUsername) {
|
||||
return `@${connector.botUsername}`;
|
||||
}
|
||||
if (connector.userName) {
|
||||
return connector.userName;
|
||||
}
|
||||
if (connector.applicationId) {
|
||||
return connector.applicationId;
|
||||
}
|
||||
return `pid ${connector.pid}`;
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string): string {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function isSecretField(
|
||||
field: ConnectorField | ConnectorSecurityField,
|
||||
): boolean {
|
||||
const label = field.label.toLowerCase();
|
||||
const key =
|
||||
"flag" in field ? field.flag.toLowerCase() : field.key.toLowerCase();
|
||||
return (
|
||||
label.includes("token") ||
|
||||
label.includes("secret") ||
|
||||
label.includes("key") ||
|
||||
key.includes("token") ||
|
||||
key.includes("secret") ||
|
||||
key.includes("key")
|
||||
);
|
||||
}
|
||||
|
||||
function isMultilineField(field: ConnectorField): boolean {
|
||||
const label = field.label.toLowerCase();
|
||||
return label.includes("json") || field.flag.includes("credentials");
|
||||
}
|
||||
|
||||
function shouldIncludeField(
|
||||
field: ConnectorField,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
const condition = field.includeWhen;
|
||||
if (!condition) {
|
||||
return true;
|
||||
}
|
||||
const value = values[condition.flag] ?? "";
|
||||
if (condition.equals !== undefined && value !== condition.equals) {
|
||||
return false;
|
||||
}
|
||||
if (condition.notEquals !== undefined && value === condition.notEquals) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function initialValuesForChannel(
|
||||
channel?: ConnectorChannel,
|
||||
): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const field of channel?.fields ?? []) {
|
||||
if (field.initialValue) {
|
||||
values[field.flag] = field.initialValue;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function createFormState(channels: ConnectorChannel[]): ConnectorFormState {
|
||||
const channel = channels[0];
|
||||
return {
|
||||
channelId: channel?.id ?? "",
|
||||
values: initialValuesForChannel(channel),
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function ChannelsContent() {
|
||||
const [channels, setChannels] = useState<ConnectorChannel[]>([]);
|
||||
const [activeConnectors, setActiveConnectors] = useState<ActiveConnector[]>(
|
||||
[],
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [busyChannel, setBusyChannel] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [formState, setFormState] = useState<ConnectorFormState>({
|
||||
channelId: "",
|
||||
values: {},
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [removeTarget, setRemoveTarget] = useState<ActiveConnector | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const selectedChannel = useMemo(
|
||||
() => channels.find((channel) => channel.id === formState.channelId),
|
||||
[channels, formState.channelId],
|
||||
);
|
||||
const visibleFields = useMemo(() => {
|
||||
const values = {
|
||||
...initialValuesForChannel(selectedChannel),
|
||||
...formState.values,
|
||||
};
|
||||
return (selectedChannel?.fields ?? []).filter((field) =>
|
||||
shouldIncludeField(field, values),
|
||||
);
|
||||
}, [selectedChannel, formState.values]);
|
||||
|
||||
const applyResponse = useCallback((response: ConnectorChannelsResponse) => {
|
||||
setChannels(response.available);
|
||||
setActiveConnectors(response.active);
|
||||
setFormState((prev) =>
|
||||
prev.channelId ? prev : createFormState(response.available),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const refreshChannels = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
|
||||
"list_connector_channels",
|
||||
);
|
||||
applyResponse(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [applyResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void refreshChannels();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [refreshChannels]);
|
||||
|
||||
const openAddDialog = () => {
|
||||
setFormState(createFormState(channels));
|
||||
setFormError(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const updateFieldValue = (flag: string, value: string) => {
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
values: { ...prev.values, [flag]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const updateSecurityFieldValue = (key: string, value: string) => {
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
securityValues: { ...prev.securityValues, [key]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const startConnector = async () => {
|
||||
if (!selectedChannel) {
|
||||
setFormError("Choose a channel");
|
||||
return;
|
||||
}
|
||||
for (const field of selectedChannel.fields) {
|
||||
if (!visibleFields.includes(field)) {
|
||||
continue;
|
||||
}
|
||||
if (field.required && !formState.values[field.flag]?.trim()) {
|
||||
setFormError(`${field.label} is required`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (formState.securityEnabled && selectedChannel.security) {
|
||||
for (const field of selectedChannel.security.fields) {
|
||||
if (!formState.securityValues[field.key]?.trim()) {
|
||||
setFormError(field.requiredMessage);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
setBusyChannel(selectedChannel.id);
|
||||
setFormError(null);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
|
||||
"start_connector_channel",
|
||||
{
|
||||
channel: selectedChannel.id,
|
||||
values: formState.values,
|
||||
security: {
|
||||
enabled: formState.securityEnabled,
|
||||
values: formState.securityValues,
|
||||
},
|
||||
},
|
||||
);
|
||||
applyResponse(response);
|
||||
setDialogOpen(false);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setFormError(message);
|
||||
} finally {
|
||||
setBusyChannel(null);
|
||||
}
|
||||
};
|
||||
|
||||
const stopConnector = async (connector: ActiveConnector) => {
|
||||
setBusyChannel(connector.type);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await desktopClient.invoke<ConnectorChannelsResponse>(
|
||||
"stop_connector_channel",
|
||||
{ channel: connector.type },
|
||||
);
|
||||
applyResponse(response);
|
||||
setRemoveTarget(null);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setBusyChannel(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Channels</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeConnectors.length} connected
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
disabled={isLoading}
|
||||
onClick={() => void refreshChannels()}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-4", isLoading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
disabled={channels.length === 0}
|
||||
onClick={openAddDialog}
|
||||
size="sm"
|
||||
type="button"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add Channel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<section className="overflow-hidden rounded-lg border bg-card">
|
||||
<div className="grid gap-2 p-2.5">
|
||||
{isLoading ? (
|
||||
<p className="px-1 py-4 text-[13px] text-muted-foreground">
|
||||
Loading channels...
|
||||
</p>
|
||||
) : activeConnectors.length === 0 ? (
|
||||
<p className="px-1 py-4 text-[13px] text-muted-foreground">
|
||||
No channels connected.
|
||||
</p>
|
||||
) : (
|
||||
activeConnectors.map((connector) => (
|
||||
<div
|
||||
className="grid gap-3 border bg-[color-mix(in_oklch,var(--background)_70%,var(--card))] p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
|
||||
key={connector.id}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Circle className="size-2 fill-emerald-300 text-emerald-300" />
|
||||
<p className="truncate text-[13px] font-semibold leading-tight">
|
||||
{connectorName(connector, channels)}
|
||||
</p>
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
||||
{connectorIdentity(connector)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
pid={connector.pid}
|
||||
</span>
|
||||
<span
|
||||
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
|
||||
title={connector.hubUrl}
|
||||
>
|
||||
{connector.hubUrl}
|
||||
</span>
|
||||
{connector.baseUrl ? (
|
||||
<span
|
||||
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
|
||||
title={connector.baseUrl}
|
||||
>
|
||||
{connector.baseUrl}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{formatDateTime(connector.startedAt)}
|
||||
</span>
|
||||
{connector.connectionMode ? (
|
||||
<span className="rounded-md border bg-background px-1.5 py-0.5">
|
||||
{connector.connectionMode}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
disabled={busyChannel === connector.type}
|
||||
onClick={() => setRemoveTarget(connector)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Remove...
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Channel</DialogTitle>
|
||||
<DialogDescription>
|
||||
Start a connector channel for Cline Hub.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Channel</Label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
setFormState({
|
||||
channelId: value,
|
||||
values: initialValuesForChannel(
|
||||
channels.find((channel) => channel.id === value),
|
||||
),
|
||||
securityEnabled: false,
|
||||
securityValues: {},
|
||||
});
|
||||
}}
|
||||
value={formState.channelId}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select channel" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{channels.map((channel) => (
|
||||
<SelectItem key={channel.id} value={channel.id}>
|
||||
{channel.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{visibleFields.map((field) => (
|
||||
<div className="grid gap-2" key={field.flag}>
|
||||
<Label>
|
||||
{field.label}
|
||||
{field.required ? (
|
||||
<span className="text-destructive"> *</span>
|
||||
) : null}
|
||||
</Label>
|
||||
{field.options ? (
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
if (value) {
|
||||
updateFieldValue(field.flag, value);
|
||||
}
|
||||
}}
|
||||
value={
|
||||
formState.values[field.flag] ?? field.initialValue ?? ""
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={field.placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : isMultilineField(field) ? (
|
||||
<Textarea
|
||||
onChange={(event) =>
|
||||
updateFieldValue(field.flag, event.target.value)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
rows={5}
|
||||
value={formState.values[field.flag] ?? ""}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
onChange={(event) =>
|
||||
updateFieldValue(field.flag, event.target.value)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
type={isSecretField(field) ? "password" : "text"}
|
||||
value={formState.values[field.flag] ?? ""}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedChannel?.security ? (
|
||||
<div className="grid gap-3 rounded-lg border p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label className="text-sm">Restrict access</Label>
|
||||
<Switch
|
||||
checked={formState.securityEnabled}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setFormState((prev) => ({
|
||||
...prev,
|
||||
securityEnabled: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{formState.securityEnabled
|
||||
? selectedChannel.security.fields.map((field) => (
|
||||
<div className="grid gap-2" key={field.key}>
|
||||
<Label>{field.label}</Label>
|
||||
<Input
|
||||
onChange={(event) =>
|
||||
updateSecurityFieldValue(
|
||||
field.key,
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
type={isSecretField(field) ? "password" : "text"}
|
||||
value={formState.securityValues[field.key] ?? ""}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{formError ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{formError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
disabled={busyChannel !== null}
|
||||
onClick={() => setDialogOpen(false)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busyChannel !== null || !selectedChannel}
|
||||
onClick={() => void startConnector()}
|
||||
type="button"
|
||||
>
|
||||
{busyChannel ? "Starting..." : "Add Channel"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={removeTarget !== null}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
setRemoveTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Channel</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Confirm that you want to stop the active{" "}
|
||||
{removeTarget ? connectorName(removeTarget, channels) : "channel"}{" "}
|
||||
channel for {removeTarget ? connectorIdentity(removeTarget) : ""}.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busyChannel !== null}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={busyChannel !== null || !removeTarget}
|
||||
onClick={() => {
|
||||
if (removeTarget) {
|
||||
void stopConnector(removeTarget);
|
||||
}
|
||||
}}
|
||||
variant="destructive"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,859 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Circle, Minus, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type McpTransportType = "stdio" | "sse" | "streamableHttp";
|
||||
|
||||
interface McpServer {
|
||||
name: string;
|
||||
transportType: McpTransportType;
|
||||
disabled: boolean;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
interface McpServersResponse {
|
||||
settingsPath: string;
|
||||
hasSettingsFile: boolean;
|
||||
servers: McpServer[];
|
||||
}
|
||||
|
||||
interface McpServerUpsertInput {
|
||||
name: string;
|
||||
previousName?: string;
|
||||
transportType: McpTransportType;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
url?: string;
|
||||
headers?: Record<string, string>;
|
||||
disabled?: boolean;
|
||||
metadata?: unknown;
|
||||
}
|
||||
|
||||
type McpServerFormState = {
|
||||
name: string;
|
||||
previousName: string;
|
||||
transportType: McpTransportType;
|
||||
command: string;
|
||||
argsText: string;
|
||||
cwd: string;
|
||||
envEntries: Array<{ id: string; key: string; value: string }>;
|
||||
url: string;
|
||||
headersText: string;
|
||||
disabled: boolean;
|
||||
metadataText: string;
|
||||
};
|
||||
|
||||
function splitCsv(text: string): string[] {
|
||||
return text
|
||||
.split(",")
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0);
|
||||
}
|
||||
|
||||
function parseKeyValuePairs(text: string): Record<string, string> | undefined {
|
||||
const pairs = splitCsv(text);
|
||||
if (pairs.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const out: Record<string, string> = {};
|
||||
for (const pair of pairs) {
|
||||
const idx = pair.indexOf("=");
|
||||
if (idx <= 0) {
|
||||
continue;
|
||||
}
|
||||
const key = pair.slice(0, idx).trim();
|
||||
const value = pair.slice(idx + 1).trim();
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
out[key] = value;
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : undefined;
|
||||
}
|
||||
|
||||
function stringifyKeyValuePairs(input?: Record<string, string>): string {
|
||||
if (!input) {
|
||||
return "";
|
||||
}
|
||||
return Object.entries(input)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function stringifyRedactedKeyValuePairs(
|
||||
input?: Record<string, string>,
|
||||
): string {
|
||||
if (!input) {
|
||||
return "";
|
||||
}
|
||||
return Object.keys(input)
|
||||
.map((key) => `${key}=[REDACTED]`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function createEnvEntries(
|
||||
input?: Record<string, string>,
|
||||
): Array<{ id: string; key: string; value: string }> {
|
||||
if (!input || Object.keys(input).length === 0) {
|
||||
return [{ id: crypto.randomUUID(), key: "", value: "" }];
|
||||
}
|
||||
return Object.entries(input).map(([key, value]) => ({
|
||||
id: crypto.randomUUID(),
|
||||
key,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
function createServerFormState(existing?: McpServer): McpServerFormState {
|
||||
return {
|
||||
name: existing?.name ?? "",
|
||||
previousName: existing?.name ?? "",
|
||||
transportType: existing?.transportType ?? "stdio",
|
||||
command: existing?.command ?? "",
|
||||
argsText: existing?.args?.join(", ") ?? "",
|
||||
cwd: existing?.cwd ?? "",
|
||||
envEntries: createEnvEntries(existing?.env),
|
||||
url: existing?.url ?? "",
|
||||
headersText: stringifyKeyValuePairs(existing?.headers),
|
||||
disabled: existing?.disabled ?? false,
|
||||
metadataText:
|
||||
existing?.metadata === undefined
|
||||
? ""
|
||||
: JSON.stringify(existing.metadata, null, 2),
|
||||
};
|
||||
}
|
||||
|
||||
export function McpServersContent() {
|
||||
const [servers, setServers] = useState<McpServer[]>([]);
|
||||
const [settingsPath, setSettingsPath] = useState("");
|
||||
const [hasSettingsFile, setHasSettingsFile] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isOpeningSettingsFile, setIsOpeningSettingsFile] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [busyServerName, setBusyServerName] = useState<string | null>(null);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editorMode, setEditorMode] = useState<"create" | "edit">("create");
|
||||
const [formState, setFormState] = useState<McpServerFormState>(() =>
|
||||
createServerFormState(),
|
||||
);
|
||||
const [formErrorMessage, setFormErrorMessage] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<McpServer | null>(null);
|
||||
|
||||
const applyResponse = useCallback((response: McpServersResponse) => {
|
||||
setServers(response.servers);
|
||||
setSettingsPath(response.settingsPath);
|
||||
setHasSettingsFile(response.hasSettingsFile);
|
||||
}, []);
|
||||
|
||||
const refreshServers = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response =
|
||||
await desktopClient.invoke<McpServersResponse>("list_mcp_servers");
|
||||
applyResponse(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [applyResponse]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void refreshServers();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [refreshServers]);
|
||||
|
||||
const toggleServer = async (server: McpServer, disabled: boolean) => {
|
||||
setBusyServerName(server.name);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await desktopClient.invoke<McpServersResponse>(
|
||||
"set_mcp_server_disabled",
|
||||
{
|
||||
name: server.name,
|
||||
disabled,
|
||||
},
|
||||
);
|
||||
applyResponse(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setBusyServerName(null);
|
||||
}
|
||||
};
|
||||
|
||||
const upsertServer = async (input: McpServerUpsertInput) => {
|
||||
setBusyServerName(input.previousName ?? input.name);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await desktopClient.invoke<McpServersResponse>(
|
||||
"upsert_mcp_server",
|
||||
{
|
||||
input,
|
||||
},
|
||||
);
|
||||
applyResponse(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setBusyServerName(null);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteServer = async (serverName: string) => {
|
||||
setBusyServerName(serverName);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const response = await desktopClient.invoke<McpServersResponse>(
|
||||
"delete_mcp_server",
|
||||
{
|
||||
name: serverName,
|
||||
},
|
||||
);
|
||||
applyResponse(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setBusyServerName(null);
|
||||
}
|
||||
};
|
||||
|
||||
const buildServerInput = useCallback((form: McpServerFormState) => {
|
||||
const name = form.name.trim();
|
||||
if (!name) {
|
||||
throw new Error("Server name is required.");
|
||||
}
|
||||
const env = form.envEntries.reduce<Record<string, string>>((acc, entry) => {
|
||||
const key = entry.key.trim();
|
||||
if (!key) {
|
||||
return acc;
|
||||
}
|
||||
acc[key] = entry.value;
|
||||
return acc;
|
||||
}, {});
|
||||
const metadataText = form.metadataText.trim();
|
||||
const metadata =
|
||||
metadataText.length > 0 ? JSON.parse(metadataText) : undefined;
|
||||
if (form.transportType === "stdio") {
|
||||
const command = form.command.trim();
|
||||
if (!command) {
|
||||
throw new Error("Command is required for stdio transport.");
|
||||
}
|
||||
const args = splitCsv(form.argsText);
|
||||
return {
|
||||
name,
|
||||
previousName: form.previousName.trim() || undefined,
|
||||
transportType: form.transportType,
|
||||
command,
|
||||
args: args.length > 0 ? args : undefined,
|
||||
cwd: form.cwd.trim() || undefined,
|
||||
env: Object.keys(env).length > 0 ? env : undefined,
|
||||
disabled: form.disabled,
|
||||
metadata,
|
||||
} satisfies McpServerUpsertInput;
|
||||
}
|
||||
const url = form.url.trim();
|
||||
if (!url) {
|
||||
throw new Error("URL is required for sse and streamableHttp transport.");
|
||||
}
|
||||
return {
|
||||
name,
|
||||
previousName: form.previousName.trim() || undefined,
|
||||
transportType: form.transportType,
|
||||
url,
|
||||
headers: parseKeyValuePairs(form.headersText),
|
||||
disabled: form.disabled,
|
||||
metadata,
|
||||
} satisfies McpServerUpsertInput;
|
||||
}, []);
|
||||
|
||||
const openCreateDialog = () => {
|
||||
setEditorMode("create");
|
||||
setFormState(createServerFormState());
|
||||
setFormErrorMessage(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const openEditDialog = (server: McpServer) => {
|
||||
setEditorMode("edit");
|
||||
setFormState(createServerFormState(server));
|
||||
setFormErrorMessage(null);
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveServer = async () => {
|
||||
setFormErrorMessage(null);
|
||||
try {
|
||||
const input = buildServerInput(formState);
|
||||
await upsertServer(input);
|
||||
setEditorOpen(false);
|
||||
} catch (error) {
|
||||
setFormErrorMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const openSettingsFile = async () => {
|
||||
setIsOpeningSettingsFile(true);
|
||||
setErrorMessage(null);
|
||||
try {
|
||||
const openedPath = await desktopClient.invoke<string>(
|
||||
"open_mcp_settings_file",
|
||||
);
|
||||
if (openedPath.trim().length > 0) {
|
||||
setSettingsPath(openedPath);
|
||||
setHasSettingsFile(true);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setErrorMessage(message);
|
||||
} finally {
|
||||
setIsOpeningSettingsFile(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedServers = useMemo(
|
||||
() =>
|
||||
[...servers].sort((a, b) =>
|
||||
a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
|
||||
),
|
||||
[servers],
|
||||
);
|
||||
|
||||
const updateEnvEntry = (
|
||||
id: string,
|
||||
field: "key" | "value",
|
||||
value: string,
|
||||
) => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
envEntries: current.envEntries.map((entry) =>
|
||||
entry.id === id ? { ...entry, [field]: value } : entry,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const addEnvEntry = () => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
envEntries: [
|
||||
...current.envEntries,
|
||||
{ id: crypto.randomUUID(), key: "", value: "" },
|
||||
],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeEnvEntry = (id: string) => {
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
envEntries:
|
||||
current.envEntries.length === 1
|
||||
? [{ id: crypto.randomUUID(), key: "", value: "" }]
|
||||
: current.envEntries.filter((entry) => entry.id !== id),
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div className="mb-6 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<h2 className="truncate text-lg font-semibold text-foreground">
|
||||
MCP Servers
|
||||
</h2>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
From settings file
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refreshServers()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("h-4 w-4", isLoading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
<Button size="sm" onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add MCP Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>MCP settings path:</span>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 font-mono text-xs"
|
||||
onClick={() => void openSettingsFile()}
|
||||
disabled={isOpeningSettingsFile}
|
||||
>
|
||||
{settingsPath || "Open settings file"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mb-6 text-xs text-muted-foreground">
|
||||
{hasSettingsFile
|
||||
? "Editing this list updates cline_mcp_settings.json."
|
||||
: "No MCP settings file found yet. Add a server to create it."}
|
||||
</p>
|
||||
|
||||
{errorMessage && (
|
||||
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{errorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
Loading MCP servers...
|
||||
</div>
|
||||
) : sortedServers.length === 0 ? (
|
||||
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
|
||||
No MCP servers configured.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{sortedServers.map((server) => {
|
||||
const isBusy = busyServerName === server.name;
|
||||
return (
|
||||
<div
|
||||
key={server.name}
|
||||
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Circle
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 shrink-0",
|
||||
server.disabled
|
||||
? "fill-muted-foreground/40 text-muted-foreground/40"
|
||||
: "fill-primary text-primary",
|
||||
)}
|
||||
/>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{server.name}
|
||||
</h3>
|
||||
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{server.transportType}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Edit ${server.name}`}
|
||||
onClick={() => openEditDialog(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`Delete ${server.name}`}
|
||||
onClick={() => setDeleteTarget(server)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Switch
|
||||
checked={!server.disabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
toggleServer(server, !enabled)
|
||||
}
|
||||
disabled={isBusy}
|
||||
aria-label={`Enable ${server.name}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{server.command && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Command:
|
||||
</span>{" "}
|
||||
{server.command}
|
||||
</p>
|
||||
)}
|
||||
{server.args && server.args.length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Args:</span>{" "}
|
||||
{server.args.join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{server.cwd && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">CWD:</span>{" "}
|
||||
{server.cwd}
|
||||
</p>
|
||||
)}
|
||||
{server.url && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">URL:</span>{" "}
|
||||
{server.url}
|
||||
</p>
|
||||
)}
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">Env:</span>{" "}
|
||||
{stringifyRedactedKeyValuePairs(server.env)}
|
||||
</p>
|
||||
)}
|
||||
{server.headers &&
|
||||
Object.keys(server.headers).length > 0 && (
|
||||
<p>
|
||||
<span className="text-muted-foreground/70">
|
||||
Headers:
|
||||
</span>{" "}
|
||||
{stringifyKeyValuePairs(server.headers)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={editorOpen}
|
||||
onOpenChange={(open) => {
|
||||
setEditorOpen(open);
|
||||
if (!open) {
|
||||
setFormErrorMessage(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editorMode === "edit" ? "Edit MCP Server" : "Add MCP Server"}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the MCP server stored in{" "}
|
||||
<code className="font-mono">
|
||||
{settingsPath || "cline_mcp_settings.json"}
|
||||
</code>
|
||||
.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-name">Server name</Label>
|
||||
<Input
|
||||
id="mcp-name"
|
||||
value={formState.name}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="github"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Transport type</Label>
|
||||
<Select
|
||||
value={formState.transportType}
|
||||
onValueChange={(value) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
transportType: value as McpTransportType,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select transport" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="stdio">stdio</SelectItem>
|
||||
<SelectItem value="sse">sse</SelectItem>
|
||||
<SelectItem value="streamableHttp">streamableHttp</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formState.transportType === "stdio" ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-command">Command</Label>
|
||||
<Input
|
||||
id="mcp-command"
|
||||
value={formState.command}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
command: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="npx"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-args">Args</Label>
|
||||
<Textarea
|
||||
id="mcp-args"
|
||||
value={formState.argsText}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
argsText: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="-y, @modelcontextprotocol/server-github"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-cwd">Working directory</Label>
|
||||
<Input
|
||||
id="mcp-cwd"
|
||||
value={formState.cwd}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
cwd: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="/path/to/project"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Label>Environment variables</Label>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={addEnvEntry}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{formState.envEntries.map((entry) => (
|
||||
<div key={entry.id} className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={() => removeEnvEntry(entry.id)}
|
||||
aria-label={`Remove env var ${entry.key || "row"}`}
|
||||
>
|
||||
<Minus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Input
|
||||
value={entry.key}
|
||||
onChange={(event) =>
|
||||
updateEnvEntry(entry.id, "key", event.target.value)
|
||||
}
|
||||
placeholder="KEY"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
value={entry.value}
|
||||
onChange={(event) =>
|
||||
updateEnvEntry(
|
||||
entry.id,
|
||||
"value",
|
||||
event.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="VALUE"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-url">Server URL</Label>
|
||||
<Input
|
||||
id="mcp-url"
|
||||
value={formState.url}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
url: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://example.com/mcp"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-headers">Headers</Label>
|
||||
<Textarea
|
||||
id="mcp-headers"
|
||||
value={formState.headersText}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
headersText: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="Authorization=Bearer token"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="mcp-metadata">Metadata JSON</Label>
|
||||
<Textarea
|
||||
id="mcp-metadata"
|
||||
value={formState.metadataText}
|
||||
onChange={(event) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
metadataText: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder='{"key":"value"}'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-md border border-border px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Enabled</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disable the server without removing it from settings.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={!formState.disabled}
|
||||
onCheckedChange={(enabled) =>
|
||||
setFormState((current) => ({
|
||||
...current,
|
||||
disabled: !enabled,
|
||||
}))
|
||||
}
|
||||
aria-label="Enable MCP server"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formErrorMessage ? (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{formErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditorOpen(false)}
|
||||
disabled={busyServerName !== null}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSaveServer()}
|
||||
disabled={busyServerName !== null}
|
||||
>
|
||||
{busyServerName !== null
|
||||
? "Saving..."
|
||||
: editorMode === "edit"
|
||||
? "Save changes"
|
||||
: "Add server"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete MCP Server</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{deleteTarget
|
||||
? `Delete MCP server "${deleteTarget.name}" from settings?`
|
||||
: "Delete this MCP server from settings?"}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={busyServerName !== null}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={busyServerName !== null || !deleteTarget}
|
||||
onClick={() => {
|
||||
if (deleteTarget) {
|
||||
void deleteServer(deleteTarget.name);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -1,524 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type {
|
||||
Provider,
|
||||
ProviderConfigField,
|
||||
ProviderConfigFieldPrimitive,
|
||||
ProviderSettingsUpdate,
|
||||
} from "@/lib/provider-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Provider LIST content (the grid of all providers)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
function getInitialConfigValues(
|
||||
provider: Provider,
|
||||
): Record<string, ProviderConfigFieldPrimitive> {
|
||||
const values: Record<string, ProviderConfigFieldPrimitive> = {
|
||||
...(provider.configValues ?? {}),
|
||||
};
|
||||
if (provider.apiKey !== undefined && values.apiKey === undefined) {
|
||||
values.apiKey = provider.apiKey;
|
||||
}
|
||||
if (provider.baseUrl !== undefined && values.baseUrl === undefined) {
|
||||
values.baseUrl = provider.baseUrl;
|
||||
}
|
||||
for (const field of provider.configFields ?? []) {
|
||||
if (values[field.path] === undefined && field.defaultValue !== undefined) {
|
||||
values[field.path] = field.defaultValue;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function fieldValueToString(value: ProviderConfigFieldPrimitive | undefined) {
|
||||
if (value === undefined || value === null) return "";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function coerceFieldValue(
|
||||
field: ProviderConfigField,
|
||||
value: string | boolean,
|
||||
): ProviderConfigFieldPrimitive {
|
||||
if (field.type === "boolean") {
|
||||
return Boolean(value);
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (field.type === "select") {
|
||||
const option = field.options?.find((item) => String(item.value) === value);
|
||||
if (option) {
|
||||
return option.value;
|
||||
}
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (field.type === "number") {
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function ProviderListContent({
|
||||
providers,
|
||||
onToggle,
|
||||
onConfigure,
|
||||
onAddProvider,
|
||||
}: {
|
||||
providers: Provider[];
|
||||
onToggle: (id: string) => void;
|
||||
onConfigure: (id: string) => void;
|
||||
onAddProvider: () => void;
|
||||
}) {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
Model Providers
|
||||
</h2>
|
||||
<Button
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-accent px-3.5 py-2 text-sm font-medium text-foreground hover:bg-accent/80 transition-colors"
|
||||
onClick={onAddProvider}
|
||||
variant="ghost"
|
||||
>
|
||||
<PlusCircle className="h-4 w-4" />
|
||||
Add Provider
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col divide-y divide-border rounded-lg border border-border overflow-hidden">
|
||||
{providers.map((prov) => (
|
||||
<div
|
||||
className="flex items-center gap-4 px-5 py-4 transition-colors hover:bg-accent/30"
|
||||
key={prov.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{prov.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{prov.models === null
|
||||
? "Models load on demand"
|
||||
: `${prov.models} Model${prov.models !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={`Configure ${prov.name}`}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
variant="ghost"
|
||||
>
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${prov.name}`}
|
||||
checked={prov.enabled}
|
||||
onCheckedChange={() => onToggle(prov.id)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderDetailContent({
|
||||
provider,
|
||||
onBack,
|
||||
onUpdate,
|
||||
onLoadModels,
|
||||
modelsLoading = false,
|
||||
modelsError,
|
||||
onOAuthLogin,
|
||||
oauthLoginPending = false,
|
||||
}: {
|
||||
provider: Provider;
|
||||
onBack: () => void;
|
||||
onUpdate: (updates: ProviderSettingsUpdate) => void;
|
||||
onLoadModels?: () => void;
|
||||
modelsLoading?: boolean;
|
||||
modelsError?: string | null;
|
||||
onOAuthLogin?: () => void;
|
||||
oauthLoginPending?: boolean;
|
||||
}) {
|
||||
const [shownSecrets, setShownSecrets] = useState<Record<string, boolean>>({});
|
||||
const [localConfigValues, setLocalConfigValues] = useState<
|
||||
Record<string, ProviderConfigFieldPrimitive>
|
||||
>(() => getInitialConfigValues(provider));
|
||||
const [modelSearchState, setModelSearchState] = useState<{
|
||||
providerId: string;
|
||||
value: string;
|
||||
} | null>(null);
|
||||
const [copiedModelState, setCopiedModelState] = useState<{
|
||||
modelId: string;
|
||||
providerId: string;
|
||||
} | null>(null);
|
||||
const copiedModelTimeoutRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const configFields = provider.configFields ?? [];
|
||||
const apiKeyValue = fieldValueToString(localConfigValues.apiKey);
|
||||
const modelList = provider.modelList ?? [];
|
||||
const modelSearch =
|
||||
modelSearchState?.providerId === provider.id ? modelSearchState.value : "";
|
||||
const copiedModelId =
|
||||
copiedModelState?.providerId === provider.id
|
||||
? copiedModelState.modelId
|
||||
: null;
|
||||
const modelSearchQuery = modelSearch.trim().toLowerCase();
|
||||
const filteredModelList = modelSearchQuery
|
||||
? modelList.filter(
|
||||
(model) =>
|
||||
model.name.toLowerCase().includes(modelSearchQuery) ||
|
||||
model.id.toLowerCase().includes(modelSearchQuery),
|
||||
)
|
||||
: modelList;
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copiedModelTimeoutRef.current !== undefined) {
|
||||
window.clearTimeout(copiedModelTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const commitField = (
|
||||
field: ProviderConfigField,
|
||||
rawValue: string | boolean,
|
||||
) => {
|
||||
const value = coerceFieldValue(field, rawValue);
|
||||
const nextConfigValues = {
|
||||
...localConfigValues,
|
||||
[field.path]: value,
|
||||
};
|
||||
setLocalConfigValues(nextConfigValues);
|
||||
|
||||
const updates: ProviderSettingsUpdate = {
|
||||
configValues: { [field.path]: value },
|
||||
};
|
||||
if (field.path === "apiKey") {
|
||||
updates.apiKey = fieldValueToString(value);
|
||||
}
|
||||
if (field.path === "baseUrl") {
|
||||
updates.baseUrl = fieldValueToString(value);
|
||||
}
|
||||
onUpdate(updates);
|
||||
};
|
||||
|
||||
const copyModelId = (modelId: string) => {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard.writeText(modelId).then(() => {
|
||||
setCopiedModelState({ modelId, providerId: provider.id });
|
||||
if (copiedModelTimeoutRef.current !== undefined) {
|
||||
window.clearTimeout(copiedModelTimeoutRef.current);
|
||||
}
|
||||
copiedModelTimeoutRef.current = window.setTimeout(
|
||||
() => setCopiedModelState(null),
|
||||
1600,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
{/* Back + title */}
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<Button
|
||||
aria-label="Back to providers"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={onBack}
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{provider.name}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{configFields.length > 0 ? (
|
||||
<section className="mb-8">
|
||||
<div className="flex flex-col gap-5">
|
||||
{configFields.map((field) => {
|
||||
const value = localConfigValues[field.path];
|
||||
const valueText = fieldValueToString(value);
|
||||
const isSecret = field.type === "password" || field.secret;
|
||||
const isShown = shownSecrets[field.path] ?? false;
|
||||
return (
|
||||
<div key={field.path}>
|
||||
<header className="mb-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{field.label}
|
||||
</h3>
|
||||
{field.description ? (
|
||||
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
|
||||
{field.description}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
{field.type === "boolean" ? (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.label}
|
||||
</span>
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) =>
|
||||
commitField(field, checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : field.type === "select" ? (
|
||||
<select
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
onChange={(event) =>
|
||||
commitField(field, event.target.value)
|
||||
}
|
||||
value={valueText}
|
||||
>
|
||||
<option value="">Not set</option>
|
||||
{field.options?.map((option) => (
|
||||
<option
|
||||
key={String(option.value)}
|
||||
value={String(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-4 py-3">
|
||||
{field.type === "url" ? (
|
||||
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<Input
|
||||
className="flex-1 text-sm text-foreground placeholder:text-muted-foreground outline-none border-0"
|
||||
onBlur={() => commitField(field, valueText)}
|
||||
onChange={(event) =>
|
||||
setLocalConfigValues((current) => ({
|
||||
...current,
|
||||
[field.path]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
spellCheck={false}
|
||||
type={
|
||||
isSecret && !isShown
|
||||
? "password"
|
||||
: field.type === "number"
|
||||
? "number"
|
||||
: field.type === "url"
|
||||
? "url"
|
||||
: "text"
|
||||
}
|
||||
value={valueText}
|
||||
/>
|
||||
{isSecret ? (
|
||||
<>
|
||||
<Button
|
||||
aria-label={
|
||||
isShown ? "Hide secret" : "Show secret"
|
||||
}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() =>
|
||||
setShownSecrets((current) => ({
|
||||
...current,
|
||||
[field.path]: !isShown,
|
||||
}))
|
||||
}
|
||||
variant="ghost"
|
||||
>
|
||||
{isShown ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={`Copy ${field.label}`}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(valueText)
|
||||
}
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!apiKeyValue && !provider.oauthAccessTokenPresent && onOAuthLogin ? (
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
className="inline-flex items-center gap-2 w-full"
|
||||
disabled={oauthLoginPending}
|
||||
onClick={onOAuthLogin}
|
||||
variant="default"
|
||||
>
|
||||
{oauthLoginPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
<span>Login via Browser</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{provider.oauthAccessTokenPresent ? (
|
||||
<p className="mb-8 text-xs text-muted-foreground">
|
||||
OAuth is connected. Manual credentials remain available when this
|
||||
provider supports them.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Models section */}
|
||||
<section>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-foreground">Models</h3>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
aria-label="Refresh models"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
disabled={modelsLoading}
|
||||
onClick={onLoadModels}
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-3", modelsLoading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modelsError ? (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center">
|
||||
<p className="text-sm text-destructive">{modelsError}</p>
|
||||
</div>
|
||||
) : modelList.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-3 py-2">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search models"
|
||||
className="h-7 flex-1 border-0 text-sm text-foreground placeholder:text-muted-foreground"
|
||||
onChange={(event) =>
|
||||
setModelSearchState({
|
||||
providerId: provider.id,
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Search models by name or ID"
|
||||
spellCheck={false}
|
||||
value={modelSearch}
|
||||
/>
|
||||
</div>
|
||||
{filteredModelList.length > 0 ? (
|
||||
<div className="flex flex-col divide-y divide-border rounded-lg border border-border max-h-125 overflow-y-scroll">
|
||||
{filteredModelList.map((model) => (
|
||||
<div
|
||||
className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/30"
|
||||
key={model.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1 font-mono">
|
||||
<div className="flex min-w-0 items-center gap-1.5 px-1 text-sm text-foreground">
|
||||
<span className="truncate">{model.name}</span>
|
||||
{/* Capability icons */}
|
||||
{model.supportsAttachments && (
|
||||
<div title="File Support">
|
||||
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{model.supportsVision && (
|
||||
<div title="Image Support">
|
||||
<ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
aria-label={`Copy model ID ${model.id}`}
|
||||
className="mt-1 flex max-w-full items-center gap-1.5 px-1 text-left text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => copyModelId(model.id)}
|
||||
title="Copy model ID"
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 truncate">{model.id}</span>
|
||||
<Copy className="size-3 shrink-0" />
|
||||
{copiedModelId === model.id ? (
|
||||
<span className="shrink-0 text-foreground">
|
||||
Copied
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action icons */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
aria-label={`Favorite ${model.name}`}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
variant="ghost"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No models match "{modelSearch.trim()}".
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{modelsLoading
|
||||
? "Loading models..."
|
||||
: "No models available. Click refresh to load models."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
||||
import type { ProviderConfigFieldPrimitive } from "@/lib/provider-schema";
|
||||
|
||||
function assignSettingsPath(
|
||||
target: Record<string, unknown>,
|
||||
path: string,
|
||||
value: ProviderConfigFieldPrimitive,
|
||||
) {
|
||||
const segments = path.split(".").filter(Boolean);
|
||||
if (segments.length === 0) return;
|
||||
let cursor = target;
|
||||
for (const segment of segments.slice(0, -1)) {
|
||||
const existing = cursor[segment];
|
||||
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
||||
cursor[segment] = {};
|
||||
}
|
||||
cursor = cursor[segment] as Record<string, unknown>;
|
||||
}
|
||||
const last = segments.at(-1);
|
||||
if (last) {
|
||||
cursor[last] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export function toSettingsPatch(
|
||||
values: Record<string, ProviderConfigFieldPrimitive>,
|
||||
): Record<string, unknown> {
|
||||
const settings: Record<string, unknown> = {};
|
||||
for (const [path, value] of Object.entries(values)) {
|
||||
assignSettingsPath(settings, path, value);
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
@@ -1,632 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronRight, Moon, Sun, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import type {
|
||||
Provider,
|
||||
ProviderCatalogResponse,
|
||||
ProviderModelsResponse,
|
||||
ProviderSettingsUpdate,
|
||||
} from "@/lib/provider-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AccountView } from "./account-view";
|
||||
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
|
||||
import { ChannelsContent } from "./channels-view";
|
||||
import { RulesView } from "./extensions-view";
|
||||
import { McpServersContent } from "./mcp-view";
|
||||
import {
|
||||
ProviderDetailContent,
|
||||
ProviderListContent,
|
||||
} from "./provider-list-view";
|
||||
import { RoutineSchedulesContent } from "./routine-view";
|
||||
import { toSettingsPatch } from "./settings-patch";
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Settings nav categories
|
||||
// -----------------------------------------------------------
|
||||
|
||||
const navCategories = [
|
||||
"General",
|
||||
"Providers",
|
||||
"Customizations",
|
||||
"MCP",
|
||||
"Channels",
|
||||
"Schedules",
|
||||
"Account",
|
||||
] as const;
|
||||
|
||||
export type SettingsSection = (typeof navCategories)[number];
|
||||
type Theme = "dark" | "light";
|
||||
type GlobalSettingsResponse = {
|
||||
telemetryOptOut: boolean;
|
||||
};
|
||||
|
||||
const PROVIDER_CATALOG_CACHE_TTL_MS = 60_000;
|
||||
|
||||
let providerCatalogCache: {
|
||||
providers: Provider[];
|
||||
fetchedAt: number;
|
||||
} | null = null;
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Component
|
||||
// -----------------------------------------------------------
|
||||
|
||||
export function SettingsView({
|
||||
initialSection = "General",
|
||||
onClose,
|
||||
onNavigateSection,
|
||||
onThemeChange,
|
||||
theme,
|
||||
}: {
|
||||
initialSection?: SettingsSection;
|
||||
onClose: () => void;
|
||||
onNavigateSection?: (section: SettingsSection) => void;
|
||||
onThemeChange: (theme: Theme) => void;
|
||||
theme: Theme;
|
||||
}) {
|
||||
const [activeNav, setActiveNav] = useState<SettingsSection>(initialSection);
|
||||
const [providersExpanded, setProvidersExpanded] = useState(true);
|
||||
const [providers, setProviders] = useState<Provider[]>(
|
||||
() => providerCatalogCache?.providers ?? [],
|
||||
);
|
||||
const [providersLoading, setProvidersLoading] = useState(
|
||||
() => !providerCatalogCache,
|
||||
);
|
||||
const [providerCatalogError, setProviderCatalogError] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [modelsLoadingByProvider, setModelsLoadingByProvider] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
const [modelsErrorByProvider, setModelsErrorByProvider] = useState<
|
||||
Record<string, string | null>
|
||||
>({});
|
||||
const [oauthSigningProviderId, setOauthSigningProviderId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [selectedProviderId, setSelectedProviderId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [addingProvider, setAddingProvider] = useState(false);
|
||||
|
||||
const setProvidersWithCache = useCallback(
|
||||
(next: Provider[] | ((prev: Provider[]) => Provider[])) => {
|
||||
setProviders((prev) => {
|
||||
const resolved =
|
||||
typeof next === "function"
|
||||
? (next as (prev: Provider[]) => Provider[])(prev)
|
||||
: next;
|
||||
providerCatalogCache = {
|
||||
providers: resolved,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
return resolved;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadProviderCatalog = useCallback(async () => {
|
||||
const now = Date.now();
|
||||
if (
|
||||
providerCatalogCache &&
|
||||
now - providerCatalogCache.fetchedAt < PROVIDER_CATALOG_CACHE_TTL_MS
|
||||
) {
|
||||
setProviders(providerCatalogCache.providers);
|
||||
setProvidersLoading(false);
|
||||
setProviderCatalogError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setProvidersLoading(true);
|
||||
setProviderCatalogError(null);
|
||||
try {
|
||||
const payload = await desktopClient.invoke<ProviderCatalogResponse>(
|
||||
"list_provider_catalog",
|
||||
);
|
||||
setProvidersWithCache(payload.providers);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setProviderCatalogError(message);
|
||||
setProviders([]);
|
||||
} finally {
|
||||
setProvidersLoading(false);
|
||||
}
|
||||
}, [setProvidersWithCache]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadProviderCatalog();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [loadProviderCatalog]);
|
||||
|
||||
const persistProviderSettings = useCallback(
|
||||
async (
|
||||
id: string,
|
||||
updates: {
|
||||
enabled?: boolean;
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
configValues?: ProviderSettingsUpdate["configValues"];
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
await desktopClient.invoke("save_provider_settings", {
|
||||
provider: id,
|
||||
enabled: updates.enabled,
|
||||
api_key: updates.apiKey,
|
||||
base_url: updates.baseUrl,
|
||||
settings: updates.configValues
|
||||
? toSettingsPatch(updates.configValues)
|
||||
: undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
window.alert(`Failed to save provider settings for ${id}: ${message}`);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleProvider = useCallback(
|
||||
(id: string) => {
|
||||
setProvidersWithCache((prev) =>
|
||||
prev.map((p) => {
|
||||
if (p.id !== id) {
|
||||
return p;
|
||||
}
|
||||
const nextEnabled = !p.enabled;
|
||||
void persistProviderSettings(id, { enabled: nextEnabled });
|
||||
return { ...p, enabled: nextEnabled };
|
||||
}),
|
||||
);
|
||||
},
|
||||
[persistProviderSettings, setProvidersWithCache],
|
||||
);
|
||||
|
||||
const updateProvider = useCallback(
|
||||
(id: string, updates: ProviderSettingsUpdate) => {
|
||||
setProvidersWithCache((prev) =>
|
||||
prev.map((p) =>
|
||||
p.id === id
|
||||
? {
|
||||
...p,
|
||||
...updates,
|
||||
configValues: updates.configValues
|
||||
? {
|
||||
...(p.configValues ?? {}),
|
||||
...updates.configValues,
|
||||
}
|
||||
: p.configValues,
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
void persistProviderSettings(id, {
|
||||
apiKey: updates.apiKey,
|
||||
baseUrl: updates.baseUrl,
|
||||
configValues: updates.configValues,
|
||||
});
|
||||
},
|
||||
[persistProviderSettings, setProvidersWithCache],
|
||||
);
|
||||
|
||||
const loadProviderModels = useCallback(
|
||||
async (id: string) => {
|
||||
setModelsLoadingByProvider((prev) => ({ ...prev, [id]: true }));
|
||||
setModelsErrorByProvider((prev) => ({ ...prev, [id]: null }));
|
||||
try {
|
||||
const payload = await desktopClient.invoke<ProviderModelsResponse>(
|
||||
"list_provider_models",
|
||||
{
|
||||
provider: id,
|
||||
},
|
||||
);
|
||||
setProvidersWithCache((prev) =>
|
||||
prev.map((provider) =>
|
||||
provider.id === id
|
||||
? {
|
||||
...provider,
|
||||
modelList: payload.models,
|
||||
models: payload.models.length,
|
||||
}
|
||||
: provider,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setModelsErrorByProvider((prev) => ({ ...prev, [id]: message }));
|
||||
} finally {
|
||||
setModelsLoadingByProvider((prev) => ({ ...prev, [id]: false }));
|
||||
}
|
||||
},
|
||||
[setProvidersWithCache],
|
||||
);
|
||||
|
||||
const enabledProviders = providers.filter((p) => p.enabled);
|
||||
const selectedProvider = selectedProviderId
|
||||
? (providers.find((p) => p.id === selectedProviderId) ?? null)
|
||||
: null;
|
||||
|
||||
const isOAuthProvider = (id: string) =>
|
||||
id === "cline" || id === "oca" || id === "openai-codex";
|
||||
|
||||
const runOAuthProviderLogin = async (id: string) => {
|
||||
setOauthSigningProviderId(id);
|
||||
try {
|
||||
const result = await desktopClient.invoke<{
|
||||
provider: string;
|
||||
accessToken: string;
|
||||
}>("run_provider_oauth_login", {
|
||||
provider: id,
|
||||
});
|
||||
setProvidersWithCache((prev) =>
|
||||
prev.map((provider) =>
|
||||
provider.id === id
|
||||
? {
|
||||
...provider,
|
||||
enabled: true,
|
||||
oauthAccessTokenPresent: result.accessToken.trim().length > 0,
|
||||
}
|
||||
: provider,
|
||||
),
|
||||
);
|
||||
setSelectedProviderId(id);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
window.alert(`Failed to sign in to ${id}: ${message}`);
|
||||
} finally {
|
||||
setOauthSigningProviderId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openProviderDetail = (id: string) => {
|
||||
setActiveNav("Providers");
|
||||
onNavigateSection?.("Providers");
|
||||
setSelectedProviderId(id);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedProviderId) {
|
||||
return;
|
||||
}
|
||||
const selected = providers.find(
|
||||
(provider) => provider.id === selectedProviderId,
|
||||
);
|
||||
if (!selected || (selected.modelList?.length ?? 0) > 0) {
|
||||
return;
|
||||
}
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadProviderModels(selectedProviderId);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [loadProviderModels, providers, selectedProviderId]);
|
||||
|
||||
const backToProviderList = () => {
|
||||
onNavigateSection?.("Providers");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
};
|
||||
|
||||
const saveNewProvider = useCallback(
|
||||
async (payload: AddProviderPayload) => {
|
||||
await desktopClient.invoke("add_provider", {
|
||||
provider_id: payload.providerId,
|
||||
name: payload.name,
|
||||
base_url: payload.baseUrl,
|
||||
api_key: payload.apiKey,
|
||||
headers: payload.headers,
|
||||
timeout_ms: payload.timeoutMs,
|
||||
models: payload.models,
|
||||
default_model_id: payload.defaultModelId,
|
||||
models_source_url: payload.modelsSourceUrl,
|
||||
capabilities: payload.capabilities,
|
||||
});
|
||||
await loadProviderCatalog();
|
||||
setAddingProvider(false);
|
||||
setSelectedProviderId(payload.providerId);
|
||||
},
|
||||
[loadProviderCatalog],
|
||||
);
|
||||
|
||||
const openAddProvider = () => {
|
||||
onNavigateSection?.("Providers");
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(true);
|
||||
};
|
||||
|
||||
const selectSection = (section: SettingsSection) => {
|
||||
setActiveNav(section);
|
||||
onNavigateSection?.(section);
|
||||
setSelectedProviderId(null);
|
||||
setAddingProvider(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background">
|
||||
{/* Header bar */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border px-6 py-3">
|
||||
<h1 className="text-lg font-semibold text-foreground">Settings</h1>
|
||||
<Button
|
||||
aria-label="Close settings"
|
||||
className="justify-start"
|
||||
onClick={onClose}
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Settings sidebar nav */}
|
||||
<nav className="w-56 shrink-0 border-r border-border">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="flex flex-col gap-0.5 p-3">
|
||||
{navCategories.map((cat) => {
|
||||
if (cat === "Providers") {
|
||||
return (
|
||||
<div key={cat}>
|
||||
<Button
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors",
|
||||
activeNav === "Providers"
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
onClick={() => {
|
||||
selectSection("Providers");
|
||||
setProvidersExpanded((p) => !p);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
<span>Providers</span>
|
||||
{providersExpanded ? (
|
||||
<ChevronDown className="size-3" />
|
||||
) : (
|
||||
<ChevronRight className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
{providersExpanded && (
|
||||
<div className="ml-3 mt-0.5 flex flex-col gap-0.5 border-l border-border pl-2">
|
||||
{enabledProviders.map((prov) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"justify-start",
|
||||
selectedProviderId === prov.id
|
||||
? "bg-accent/80 text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-accent/30",
|
||||
)}
|
||||
disabled={oauthSigningProviderId === prov.id}
|
||||
key={prov.id}
|
||||
onClick={() => openProviderDetail(prov.id)}
|
||||
variant="ghost"
|
||||
>
|
||||
<span className="truncate">{prov.name}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"justify-start",
|
||||
activeNav === cat && !selectedProviderId
|
||||
? "bg-accent text-accent-foreground font-medium"
|
||||
: "text-muted-foreground hover:bg-accent/50 hover:text-foreground",
|
||||
)}
|
||||
key={cat}
|
||||
onClick={() => {
|
||||
selectSection(cat);
|
||||
}}
|
||||
variant="ghost"
|
||||
>
|
||||
{cat}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</nav>
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{activeNav === "Providers" && selectedProvider ? (
|
||||
<ProviderDetailContent
|
||||
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
|
||||
modelsLoading={
|
||||
modelsLoadingByProvider[selectedProvider.id] ?? false
|
||||
}
|
||||
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
|
||||
onBack={backToProviderList}
|
||||
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
|
||||
onOAuthLogin={
|
||||
isOAuthProvider(selectedProvider.id)
|
||||
? () => void runOAuthProviderLogin(selectedProvider.id)
|
||||
: undefined
|
||||
}
|
||||
onUpdate={(updates) =>
|
||||
updateProvider(selectedProvider.id, updates)
|
||||
}
|
||||
provider={selectedProvider}
|
||||
/>
|
||||
) : activeNav === "Providers" ? (
|
||||
addingProvider ? (
|
||||
<AddProviderContent
|
||||
existingProviderIds={providers.map((provider) => provider.id)}
|
||||
onBack={backToProviderList}
|
||||
onSave={saveNewProvider}
|
||||
/>
|
||||
) : providersLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading providers...
|
||||
</p>
|
||||
</div>
|
||||
) : providerCatalogError ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="max-w-xl px-4 text-center text-sm text-destructive">
|
||||
Failed to load providers: {providerCatalogError}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ProviderListContent
|
||||
onAddProvider={openAddProvider}
|
||||
onConfigure={openProviderDetail}
|
||||
onToggle={toggleProvider}
|
||||
providers={providers}
|
||||
/>
|
||||
)
|
||||
) : activeNav === "MCP" ? (
|
||||
<McpServersContent />
|
||||
) : activeNav === "Channels" ? (
|
||||
<ChannelsContent />
|
||||
) : activeNav === "Schedules" ? (
|
||||
<RoutineSchedulesContent />
|
||||
) : activeNav === "Customizations" ? (
|
||||
<RulesView />
|
||||
) : activeNav === "Account" ? (
|
||||
<AccountView />
|
||||
) : activeNav === "General" ? (
|
||||
<GeneralSettingsContent
|
||||
onThemeChange={onThemeChange}
|
||||
theme={theme}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activeNav} settings coming soon.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GeneralSettingsContent({
|
||||
onThemeChange,
|
||||
theme,
|
||||
}: {
|
||||
onThemeChange: (theme: Theme) => void;
|
||||
theme: Theme;
|
||||
}) {
|
||||
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
|
||||
const [telemetryLoading, setTelemetryLoading] = useState(true);
|
||||
const [telemetrySaving, setTelemetrySaving] = useState(false);
|
||||
const [telemetryError, setTelemetryError] = useState<string | null>(null);
|
||||
|
||||
const loadGlobalSettings = useCallback(async () => {
|
||||
setTelemetryLoading(true);
|
||||
setTelemetryError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"get_global_settings",
|
||||
);
|
||||
setTelemetryOptOut(settings.telemetryOptOut);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setTelemetryError(message);
|
||||
} finally {
|
||||
setTelemetryLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
void loadGlobalSettings();
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [loadGlobalSettings]);
|
||||
|
||||
const updateTelemetryOptOut = async (nextValue: boolean) => {
|
||||
const previousValue = telemetryOptOut;
|
||||
setTelemetryOptOut(nextValue);
|
||||
setTelemetrySaving(true);
|
||||
setTelemetryError(null);
|
||||
try {
|
||||
const settings = await desktopClient.invoke<GlobalSettingsResponse>(
|
||||
"set_telemetry_opt_out",
|
||||
{
|
||||
telemetry_opt_out: nextValue,
|
||||
},
|
||||
);
|
||||
setTelemetryOptOut(settings.telemetryOptOut);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setTelemetryOptOut(previousValue);
|
||||
setTelemetryError(message);
|
||||
} finally {
|
||||
setTelemetrySaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="mx-auto max-w-3xl px-8 py-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-foreground">General</h2>
|
||||
</div>
|
||||
<section className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Theme</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Use the light or dark Cline Hub interface.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 max-[720px]:justify-start">
|
||||
<Button
|
||||
onClick={() => onThemeChange("dark")}
|
||||
type="button"
|
||||
variant={theme === "dark" ? "default" : "outline"}
|
||||
>
|
||||
<Moon className="size-4" />
|
||||
Dark
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onThemeChange("light")}
|
||||
type="button"
|
||||
variant={theme === "light" ? "default" : "outline"}
|
||||
>
|
||||
<Sun className="size-4" />
|
||||
Light
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="mt-4 rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">Telemetry</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Enable error and usage report to help us improve Cline.
|
||||
</p>
|
||||
{telemetryError ? (
|
||||
<p className="mt-2 text-xs text-destructive">
|
||||
Failed to update telemetry setting: {telemetryError}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label="Telemetry opt-out"
|
||||
checked={!telemetryOptOut} // If opt-out is true, the switch should be off (unchecked)
|
||||
disabled={telemetryLoading || telemetrySaving}
|
||||
onCheckedChange={(checked) => void updateTelemetryOptOut(checked)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { WebviewOutboundMessage } from "../../../webview-protocol";
|
||||
import { postToHost } from "../vscode";
|
||||
|
||||
type PendingRequest = {
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 120_000;
|
||||
|
||||
class HubDesktopClient {
|
||||
private requestCounter = 0;
|
||||
private readonly pending = new Map<string, PendingRequest>();
|
||||
|
||||
constructor() {
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("message", (event) => {
|
||||
this.handleMessage(event as MessageEvent<WebviewOutboundMessage>);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent<WebviewOutboundMessage>) {
|
||||
const message = event.data;
|
||||
if (
|
||||
!message ||
|
||||
typeof message !== "object" ||
|
||||
message.type !== "desktopCommandResult"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pending.delete(message.id);
|
||||
if (message.ok) {
|
||||
pending.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
pending.reject(new Error(message.error));
|
||||
}
|
||||
|
||||
async invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const id = `desktop_${Date.now()}_${this.requestCounter++}`;
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Timed out waiting for desktop command: ${command}`));
|
||||
}, REQUEST_TIMEOUT_MS);
|
||||
this.pending.set(id, {
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
timeoutId,
|
||||
});
|
||||
postToHost({ type: "desktopCommand", id, command, args });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const desktopClient = new HubDesktopClient();
|
||||
@@ -1,70 +0,0 @@
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
name: string;
|
||||
supportsAttachments?: boolean;
|
||||
supportsVision?: boolean;
|
||||
supportsReasoning?: boolean;
|
||||
}
|
||||
|
||||
export type ProviderConfigFieldType =
|
||||
| "text"
|
||||
| "password"
|
||||
| "url"
|
||||
| "number"
|
||||
| "select"
|
||||
| "boolean";
|
||||
|
||||
export type ProviderConfigFieldPrimitive = string | number | boolean | null;
|
||||
|
||||
export interface ProviderConfigFieldOption {
|
||||
label: string;
|
||||
value: Exclude<ProviderConfigFieldPrimitive, null>;
|
||||
}
|
||||
|
||||
export interface ProviderConfigField {
|
||||
path: string;
|
||||
label: string;
|
||||
type: ProviderConfigFieldType;
|
||||
description?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
secret?: boolean;
|
||||
options?: ProviderConfigFieldOption[];
|
||||
defaultValue?: ProviderConfigFieldPrimitive;
|
||||
}
|
||||
|
||||
export interface Provider {
|
||||
id: string;
|
||||
name: string;
|
||||
models: number | null;
|
||||
color: string;
|
||||
letter: string;
|
||||
enabled: boolean;
|
||||
apiKey?: string;
|
||||
oauthAccessTokenPresent?: boolean;
|
||||
baseUrl?: string;
|
||||
docUrl?: string;
|
||||
docLabel?: string;
|
||||
defaultModelId?: string;
|
||||
authDescription?: string;
|
||||
baseUrlDescription?: string;
|
||||
configFields?: ProviderConfigField[];
|
||||
configValues?: Record<string, ProviderConfigFieldPrimitive>;
|
||||
modelList?: ProviderModel[];
|
||||
}
|
||||
|
||||
export interface ProviderSettingsUpdate {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
configValues?: Record<string, ProviderConfigFieldPrimitive>;
|
||||
}
|
||||
|
||||
export interface ProviderCatalogResponse {
|
||||
providers: Provider[];
|
||||
settingsPath: string;
|
||||
}
|
||||
|
||||
export interface ProviderModelsResponse {
|
||||
providerId: string;
|
||||
models: ProviderModel[];
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import type {
|
||||
WebviewInboundMessage,
|
||||
WebviewOutboundMessage,
|
||||
} from "../../webview-protocol";
|
||||
|
||||
type VsCodeApi = {
|
||||
postMessage(message: WebviewInboundMessage): void;
|
||||
getState(): unknown;
|
||||
setState(state: unknown): void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
acquireVsCodeApi?: () => VsCodeApi;
|
||||
}
|
||||
}
|
||||
|
||||
let cachedApi: VsCodeApi | undefined;
|
||||
let browserSocket: WebSocket | undefined;
|
||||
const pendingMessages: WebviewInboundMessage[] = [];
|
||||
const stateKey = "cline-hub-webview-state";
|
||||
|
||||
function dispatchHostMessage(message: WebviewOutboundMessage): void {
|
||||
window.dispatchEvent(new MessageEvent("message", { data: message }));
|
||||
}
|
||||
|
||||
function createBrowserSocket(): WebSocket {
|
||||
if (
|
||||
browserSocket &&
|
||||
(browserSocket.readyState === WebSocket.OPEN ||
|
||||
browserSocket.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
return browserSocket;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const params = new URLSearchParams();
|
||||
const roomSecret = new URLSearchParams(window.location.search)
|
||||
.get("roomSecret")
|
||||
?.trim();
|
||||
if (roomSecret) {
|
||||
params.set("roomSecret", roomSecret);
|
||||
}
|
||||
const query = params.toString();
|
||||
browserSocket = new WebSocket(
|
||||
`${protocol}//${window.location.host}/browser${query ? `?${query}` : ""}`,
|
||||
);
|
||||
browserSocket.addEventListener("open", () => {
|
||||
for (const message of pendingMessages.splice(0)) {
|
||||
browserSocket?.send(JSON.stringify(message));
|
||||
}
|
||||
});
|
||||
browserSocket.addEventListener("message", (event) => {
|
||||
try {
|
||||
dispatchHostMessage(
|
||||
JSON.parse(String(event.data)) as WebviewOutboundMessage,
|
||||
);
|
||||
} catch {
|
||||
dispatchHostMessage({
|
||||
type: "error",
|
||||
text: "Received an invalid message from the Cline Hub server.",
|
||||
});
|
||||
}
|
||||
});
|
||||
browserSocket.addEventListener("close", () => {
|
||||
dispatchHostMessage({
|
||||
type: "status",
|
||||
text: "Disconnected from the Cline Hub server.",
|
||||
});
|
||||
});
|
||||
browserSocket.addEventListener("error", () => {
|
||||
dispatchHostMessage({
|
||||
type: "error",
|
||||
text: "Failed to connect to the Cline Hub server.",
|
||||
});
|
||||
});
|
||||
return browserSocket;
|
||||
}
|
||||
|
||||
function createBrowserApi(): VsCodeApi {
|
||||
return {
|
||||
postMessage(message) {
|
||||
const socket = createBrowserSocket();
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify(message));
|
||||
return;
|
||||
}
|
||||
pendingMessages.push(message);
|
||||
},
|
||||
getState() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(stateKey);
|
||||
return raw ? JSON.parse(raw) : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
setState(state) {
|
||||
try {
|
||||
window.localStorage.setItem(stateKey, JSON.stringify(state ?? {}));
|
||||
} catch {
|
||||
// Browser persistence is best-effort.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getVsCodeApi(): VsCodeApi | undefined {
|
||||
if (cachedApi) {
|
||||
return cachedApi;
|
||||
}
|
||||
if (typeof window.acquireVsCodeApi === "function") {
|
||||
cachedApi = window.acquireVsCodeApi();
|
||||
return cachedApi;
|
||||
}
|
||||
cachedApi = createBrowserApi();
|
||||
return cachedApi;
|
||||
}
|
||||
|
||||
export function postToHost(message: WebviewInboundMessage): void {
|
||||
getVsCodeApi()?.postMessage(message);
|
||||
}
|
||||
|
||||
export type { WebviewOutboundMessage };
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"extends": "../../sdk/packages/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["bun", "node"],
|
||||
"paths": {
|
||||
"@cline/core": ["../../sdk/packages/core/src/index.ts"],
|
||||
"@cline/core/*": [
|
||||
"../../sdk/packages/core/src/*",
|
||||
"../../sdk/packages/core/src/*/index.ts"
|
||||
],
|
||||
"@cline/shared": ["../../sdk/packages/shared/src/index.ts"],
|
||||
"@cline/shared/*": [
|
||||
"../../sdk/packages/shared/src/*",
|
||||
"../../sdk/packages/shared/src/*/index.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/webview/**"]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@cline/agents": ["../../../sdk/packages/agents/src/index.ts"],
|
||||
"@cline/core": ["../../../sdk/packages/core/src/index.ts"],
|
||||
"@cline/core/hub": ["../../../sdk/packages/core/src/hub/index.ts"],
|
||||
"@cline/llms": ["../../../sdk/packages/llms/src/index.ts"],
|
||||
"@cline/shared": ["../../../sdk/packages/shared/src/index.ts"],
|
||||
"@cline/shared/storage": [
|
||||
"../../../sdk/packages/shared/src/storage/index.ts"
|
||||
],
|
||||
"@cline/shared/db": ["../../../sdk/packages/shared/src/db/index.ts"],
|
||||
"@cline/shared/*": [
|
||||
"../../../sdk/packages/shared/src/*",
|
||||
"../../../sdk/packages/shared/src/*/index.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": ["sidecar/**/*.ts", "scripts/**/*.ts", "global.d.ts", "bun.mts"],
|
||||
"exclude": ["node_modules", "webview"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user