Compare commits

...

55 Commits

Author SHA1 Message Date
abeatrix 133d2a449b fix: prevent thread-scoped binding reuse across Slack channels
Scope thread bindings by channel thread key instead of participant key
so distinct Slack channel threads no longer share a session, while
preserving participant-scoped reuse for DMs.

Also strip leading raw Slack app mentions before command handling and
honor an explicit addressedToBot flag for command routing and bot
mention requirements. Add handling for missing connector runtime
sessions.
2026-06-04 13:38:20 -07:00
Saoud Rizwan 4d15d16109 docs(cli): release the SDK before the CLI in the publish-cli skill (#11280)
* docs(cli): release the SDK before the CLI in the publish-cli skill

Add a Step 0 to the publish-cli skill that gates a CLI release on an SDK
release when the SDK changed since its last release, and relocate the
publish-cli skill to the repo root.

Why release the SDK alongside the CLI: the CLI bundles the SDK source via
workspace:*, so the CLI always ships the latest SDK code, but the hub
daemon stamps a buildId that defaults to the @cline/core version and a
running hub is only respawned when that buildId changes. Bumping the SDK
version forces a stale hub to be retired and respawned with the new code.
It also keeps SDK releases on a regular cadence in step with the CLI.

Step 0 covers detecting unreleased sdk/packages changes, bumping the
shared SDK version + llms CHANGELOG, committing to main, kicking off
sdk-publish.yml on the latest channel, and waiting for it before cutting
the CLI release. Also fixes the now-stale working-directory note (commands
run from the repo root, not sdk/) and updates the DEVELOPMENT.md path.

* chore: move opentui skill to repo root

Relocate the opentui TUI skill from apps/cli to the repo root, matching
the real-dir + symlink convention used by the other root skills (real dir
in .agents/skills, symlink from .claude/skills).

* docs(sdk): reformat the SDK changelog and move it to sdk/CHANGELOG.md

The changelog covers all SDK packages (they share one version and release
together), so move it from sdk/packages/llms/ to the SDK root, parallel to
apps/cli/CHANGELOG.md. Reformat to match the CLI changelog: a titled
header with flat, version-only sections, newest on top, no dates, and no
Next Release bucket. The unreleased entry that bucket held is captured
from commits when the next SDK release is drafted. Update the publish-cli
skill to draft SDK notes from commits and prepend a ## <version> section
at sdk/CHANGELOG.md.
2026-06-04 12:48:17 -07:00
Bee d2339f57f1 fix(slack): normalize channel mentions to original post thread (#11273)
* fix(slack): normalize channel mentions to original post thread

Route top-level Slack channel mentions to the originating post thread so
replies land in the correct conversation. Add `resolveSlackChannelMentionThread`
to rewrite non-DM mention threads using the message's `thread_ts`/`ts` and
channel, while preserving DM threads and already-correct threads.

Includes unit tests covering normalization, no-op, and DM cases.

* thread id
2026-06-04 11:49:56 -07:00
Ahmad Shahzad a209825116 Sync Fireworks AI model registry with current platform offerings (#11173)
* feat: update Fireworks model registry to improve Cline UX for Fireworks API users

The VS Code extension's Fireworks model list was significantly out of
date compared to the current active models available on the Fireworks
platform. This commit updates the registry to match the current model
lineup, ensuring users can select from the latest available models.

Changes:
- Default model: accounts/fireworks/models/kimi-k2p6 (was kimi-k2p5)
- SDK default: accounts/fireworks/models/kimi-k2p6 (was minimax-m2p5)

Removed 6 stale/phantom models no longer available:
  - qwen3-vl-30b-a3b-thinking
  - qwen3-vl-30b-a3b-instruct
  - deepseek-v3p2
  - glm-4p7
  - glm-5
  - minimax-m2p1

Added 9 missing models:
  - accounts/fireworks/models/kimi-k2p6
  - accounts/fireworks/routers/kimi-k2p6-turbo
  - accounts/fireworks/models/deepseek-v4-flash
  - accounts/fireworks/models/deepseek-v4-pro
  - accounts/fireworks/models/glm-5p1
  - accounts/fireworks/routers/glm-5p1-fast
  - accounts/fireworks/models/minimax-m2p7
  - accounts/fireworks/models/qwen3p6-plus
  - accounts/fireworks/models/gpt-oss-20b

Fixed metadata for 3 overlapping models:
  - kimi-k2p5: contextWindow 262144 → 256000, maxTokens 16384 → 256000
  - minimax-m2p5: maxTokens 16384 → 196608
  - gpt-oss-120b: maxTokens 16384 → 32768, cacheReadsPrice 0.01 → 0.015

All models now have cacheWritesPrice: 0 because Fireworks does not
charge a separate rate for prompt cache writes (cache writes are
billed at the standard input rate, matching the SDK catalog).

Three models remain in the UI but are scheduled for deprecation on
June 17 and will be removed then:
  - accounts/fireworks/models/kimi-k2p5
  - accounts/fireworks/models/minimax-m2p5
  - accounts/fireworks/models/qwen3p6-plus

Files:
- apps/vscode/src/shared/api.ts
- apps/vscode/webview-ui/src/components/settings/__tests__/APIOptions.spec.tsx
- sdk/packages/llms/src/providers/builtins.ts

* fix: update qwen3p6-plus context window and maxTokens
2026-06-04 19:19:26 +02:00
Mahesh Narayan acc1a25e51 Update agent storage guidance (#11025) 2026-06-04 09:53:18 -07:00
Saoud Rizwan 5f17a6963f fix(cli): clear abort indicator immediately (#11265) 2026-06-04 00:02:16 -07:00
Saoud Rizwan c7ddb96ddb chore(cli): release v3.0.17 2026-06-03 21:07:30 -07:00
Bee 107bce75b2 fix(cli): recover stale interactive sessions and suppress shutdown races (#11259)
* fix(cli): recover stale interactive sessions and suppress shutdown hook races

This fixes the CLI/TUI regression introduced between `3.0.14` and `3.0.15` where the interactive CLI could enter a broken state after stopping and restarting Cline Hub, then attempting to cancel a request with Escape.

The affected release window was:

- `49e8c1b32` / `v3.0.14`: known-good baseline
- `c33c3176e` / `v3.0.15`: release containing the regression
- `fad8271f4 feat: Cline Hub web app (#10969)`: relevant behavior change in the window

The Hub web app change introduced new Hub-backed runtime/session lifecycle behavior. After Ctrl+C or Hub shutdown, the CLI could still retain an `activeSessionId` that no longer existed in the Hub/runtime process. On the next interactive send, the CLI attempted to reuse that stale session and received `session not found`. Because cancellation also targeted the stale session, Escape stopped working and OpenTUI ended up receiving failures during input handling, which made the TUI look corrupted.

The same lifecycle issue also explains the Ctrl+C errors:

```text
error: hook dispatch failed: Hub connection closed (code=1006, reason=Connection ended)
error: WebSocket connection to 'ws://127.0.0.1:50168/hub' failed: Failed to connect
```

Those were caused by late hook dispatches racing against Hub shutdown. The CLI was still trying to send hook events over a Hub WebSocket that had already closed.

**What changed**

- Added missing-session recovery in the interactive runtime.
  - Detects `session not found` / stale session errors.
  - Reads any recoverable messages from the missing session.
  - Clears the stale active session state.
  - Starts a new interactive runtime session.
  - Retries the current turn once against the fresh session.

- Made hook dispatch shutdown-aware.
  - Runtime hooks now mark themselves as shutting down before session disposal.
  - Hook dispatches are skipped once shutdown begins.
  - Dispatch failures during shutdown are suppressed, since the Hub transport closing is expected at that point.

- Reordered CLI cleanup.
  - Hooks are shut down before stopping/disposing runtime sessions.
  - This prevents abort/stop lifecycle events from trying to dispatch over a closing Hub connection.

**Regression coverage**

Added tests for:

- Recovering from a disappeared active interactive session and retrying against a new session.
- Ensuring hook events are not dispatched after shutdown begins.

**Verification**

Passed:

```text
bunx vitest run apps/cli/src/utils/hooks.test.ts apps/cli/src/runtime/interactive/session-runtime.test.ts
bun -F @cline/cli typecheck
bun -F @cline/cli test:unit
bun -F @cline/cli test:e2e:cli:tui
git diff --check
```

* SessionNotFoundError

* fix(core): preserve stale session errors in hub runs

* clean up

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-03 21:00:19 -07:00
Saoud Rizwan 81792d20c6 fix(sdk): keep hub daemon alive on runtime abort (#11258) 2026-06-03 20:22:55 -07:00
Saoud Rizwan e8e2af705d feat(cli): improve Telegram connector with --allowed-user-id flag (#11256)
* feat(cli): add Telegram allowed user id flag

* fix(cli): tighten connector authorization hooks
2026-06-03 17:50:07 -07:00
Bee 8f00fcf3ed chore: bun run fix (#11252) 2026-06-03 13:45:06 -07:00
Saoud Rizwan a64d17734d chore(cli): release v3.0.16 2026-06-03 13:29:33 -07:00
Saoud Rizwan 8e621817c5 feat: add plugin uninstall ability (#11247)
* feat: add plugin uninstall flow

* fix: preserve disabled plugin settings on uninstall failure
2026-06-03 13:23:35 -07:00
Bee 423fde4828 feat(cli): add Slack socket mode support (#11245)
* feat(cli): add Slack socket mode support

Add socket mode as an alternative to webhook mode for Slack
connector, allowing connections without a public URL.

- Introduce `--connection` flag to select webhook or socket mode
- Add `--app-token` option for socket mode authentication
- Make signing secret and base URL conditional on webhook mode
- Add `parseSlackConnectionMode` with validation and tests
- Update CLI platform definition to support hybrid connection type
- Update README docs with socket mode usage examples

* use base-url and remove connection flag

* isSocketMode
2026-06-03 12:00:57 -07:00
Saoud Rizwan 76af785fa6 fix: allow skills plugin capability (#11244) 2026-06-03 10:42:12 -07:00
Bee dd7042fc7b fix(core): use union schema for read files tool input validation (#11225)
* fix(core): use union schema for read files tool input validation

Move the normalizeReadFileRequests helper logic directly into the
read_files tool executor, replacing the legacy helper with inline
validation against ReadFilesInputUnionSchema. This ensures invalid
union inputs are rejected before reaching the executor.

Update tests to reflect validation behavior and add coverage for
rejecting invalid union inputs.

* add new schema support
2026-06-03 10:20:54 -07:00
Tomás Barreiro 38e5b7c26b Fix VSCode CI (#11236)
* Fix VSCode CI

* fix nightly publish

* Fix install

* Fix biome

* fix tsc

* Fix windows set-up

* Ignore .vscode-test

* Add a LICENSE to the vscode extension

* Add type roots

* Remove workspaces

* remove vscode as a bun workspace
2026-06-03 18:58:27 +02:00
Dominic Cooney 8ae99cd69f feat(llms,core): route custom registered handlers through the agent runtime (#11235)
* feat(llms,core): route custom registered handlers through the agent runtime

Expose the handler-registry helpers (hasRegisteredHandler, getRegisteredHandler,
getRegisteredHandlerAsync, isRegisteredHandlerAsync) from @cline/llms, and have
core's createAgentModelFromConfig consult the registry: when a handler is
registered for a provider, build it via createHandler and adapt its ApiHandler
surface onto the AgentModel contract (the inverse of the gateway's
toApiStreamChunk).

This lets hosts register provider handlers that need host-only dependencies
(e.g. a vscode.lm-backed handler) and have them used by the main agent loop,
not just standalone createHandler callers.

* fix(core): resolve registered handlers lazily and avoid double finish

Address review feedback:
- createAgentModelFromConfig built the handler eagerly with the sync
  createHandler, which throws for providers registered via registerAsyncHandler.
  The adapter now accepts a handler factory and resolves it on the first stream
  via createHandlerAsync, supporting both sync- and async-registered handlers.
- Guard the adapter's catch-block finish with sawFinish so a handler that emits
  an explicit done chunk and then throws does not produce two finish events.

* fix(core): preserve thought signatures and finish-reason semantics in adapter

Further review feedback on the ApiHandler -> AgentModel adapter:
- Reasoning and tool-call thought signatures are now surfaced under
  metadata.thoughtSignature (the key downstream adapters read), instead of being
  stored as metadata.signature / dropped.
- A done chunk whose incompleteReason indicates max output tokens now maps to
  finish{reason:"max-tokens"} rather than "stop".
- A turn that ends with tool calls (no explicit done) now terminates as
  finish{reason:"tool-calls"}, matching the gateway/AI-SDK adapters.

* Apply remaining changes

* fix(core): report lazy handler-factory rejection as a finish(error) event

The lazy handler resolution (await source()) ran outside the adapter's
try/catch, so a rejecting factory (e.g. when the host API is unavailable at
stream time) escaped as a raw generator exception instead of a terminal
finish{reason:"error"} event. Move the resolution inside the try block so all
failure paths converge on the same terminal finish.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-04 01:40:59 +09:00
Max 845970ba7d improve cline provider migration (#11242)
- user's who are signed in with oauth in old extension were not properly
migrating their token. this commit handles that

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-03 09:38:18 -07:00
Max 444a9be6ec allow baseUrl field for anthropic vendor-type providers (#11227)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-02 20:01:49 -07:00
Saoud Rizwan ade7775337 feat(cli): install official plugins by slug (#11230)
* feat(cli): install official plugins by keyword

* fix(cli): harden official plugin clone

* fix(cli): remove official plugin repo env override
2026-06-02 18:24:37 -07:00
Tomás Barreiro e147682945 Set-up global greptile rules, kanban and other SDK files (#11233)
* Set-up global greptile rules, kanban and other SDK files

* Fix path

* Fix stale path

* Update vitest workspace config
2026-06-03 03:24:07 +02:00
Saoud Rizwan ae78fb422c docs(sdk): add custom model provider plugin example (#11234)
* docs(sdk): add custom model provider plugin example

Add an OpenRouter-backed example plugin demonstrating the providers
capability and registerProvider. It registers an OpenAI-compatible
provider plus its model catalog with the gateway so the agent can run
inference against an endpoint Cline does not bundle.

Registers under a distinct id (openrouter-plugin) to avoid colliding
with the built-in openrouter provider.

* docs(sdk): drop redundant provider section from plugin examples readme

* docs(sdk): drop provider demo line from plugin examples readme

* fix: support plugin model providers

* docs: remove provider plugin demo

* docs: address provider example review
2026-06-02 18:22:06 -07:00
Tomás Barreiro 220a21bdcf Move sdk/apps/ to apps/ (#11200)
* Move the apps to the root dir

* Update all references from sdk/apps/ to apps/

* Update dependencies

* Install bun types

* Fix types

* Fix types

* Fix linter

* Ingore apps from vscode

* Fix security warning

* Fix windows install

* Enable windows dev mode

* Revert "Enable windows dev mode"

This reverts commit a46c99282e.

* Revert "Ingore apps from vscode"

This reverts commit 47f7b265d2.

* Revert "Fix windows install"

This reverts commit 1dabba1556.

* update the repo root

* fix root dir

* fix path

* fix other path

* Fix unrelated changes

* fix: address apps move follow-up blockers (#11228)

* fix: update root app command paths

* fix: include moved apps in root checks

* fix: clean up moved app path references

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-03 01:49:57 +02:00
Saoud Rizwan 6aef7f5280 docs(cli): refine supply-chain scan alerts sample (#11224)
- Fold provider/model setup into a single `cline` run; drop the auth command
- Remove the /yolo on step from the Telegram setup
- Present scheduling as two clear options (Telegram chat vs terminal with
  delivery flags)
- Clarify how to find the schedule id before triggering a test run
2026-06-02 14:57:48 -07:00
Saoud Rizwan 4e56ed6922 docs(cli): add supply-chain scan alerts sample (#11222)
* docs(cli): add supply-chain scan alerts sample

Walkthrough for scheduling the Cline CLI to run Perplexity's Bumblebee
scanner and deliver compromise alerts to Telegram. Covers installing the
CLI, cloning/building Bumblebee and how it stays read-only, the Telegram
connector, and creating a scheduled scan that texts a clean/alert verdict.

* docs(cli): drop unsupported --delivery-thread from supply-chain sample
2026-06-02 14:04:33 -07:00
Saoud Rizwan 3a0f182408 fix(cli): show skills in slash autocomplete (#11220) 2026-06-02 13:18:30 -07:00
Saoud Rizwan 1f7adbd87e feat(cli): group plugin skills in settings (#11219) 2026-06-02 13:17:05 -07:00
Saoud Rizwan b0590554da feat: add skills bundled with plugins (#11161)
* feat: discover skills bundled with plugins

* fix: scope plugin bundled skills to active plugins

* fix: prevent ancestor skill discovery for plugins
2026-06-02 12:50:15 -07:00
Ara af2454f8d9 chore: bump version and update changelog (v3.87.0) (#11211) 2026-06-02 10:19:08 -07:00
Shantanu Gontia 1a4bf98e31 Update Sambanova Models (#11008)
* Update Sambanova Models

* moved to vscode/

* fix context windows

* Update Sambanova Models

* fix context windows

* Update api.ts

* Update sambanova prices
2026-06-02 18:52:00 +02:00
Ara 4139db4127 feat: add MiniMax M3 model (#11210) 2026-06-02 09:16:38 -07:00
Saoud Rizwan d55916e3ab fix(cli): show MCP OAuth errors in TUI (#11196)
* fix(cli): surface MCP OAuth errors in TUI

* chore(cli): reuse MCP status label helper
2026-06-01 20:08:23 -07:00
Bee 386ded5126 feat(cli): bundle and serve Cline Hub dashboard with cline dashboard (#11195)
* feat(cli): bundle and serve Cline Hub dashboard with cline dashboard

Add the @cline/cline-hub workspace dependency to the CLI and build the
Hub webview as part of CLI packaging. Copy the generated dashboard assets
into platform-specific CLI distributions so the dashboard is available in
built artifacts.

Refactor the Cline Hub server startup into an exported function so the CLI
can start and stop the dashboard server programmatically.

* fix(cli): resolve dashboard webview in wrapper installs

Detect the platform-specific CLI package from the published wrapper layout
and use its bundled cline-hub webview assets when no explicit dist path is
set. Add test coverage for resolving assets via CLINE_WRAPPER_PATH.

* patches

* patch

* fix server detachHub on stop

Imported detachHub.
Changed ClineHubDashboardServer.stop to () => Promise<void>.
Made stop() idempotent with a stopped guard.
Clears the health interval.
Calls server.stop(true).
Always calls await detachHub(ctx) in a finally, so hub client teardown still happens if the HTTP server stop throws.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-01 17:35:24 -07:00
WaylandYang 44e15319e4 fix(plugin-sandbox): expose CLINE_PLUGIN_IMPORT_TIMEOUT_MS env override (#11065) (#11084)
* fix(plugin-sandbox): expose CLINE_PLUGIN_IMPORT_TIMEOUT_MS env override

The hardcoded 4000 ms importTimeoutMs default is too tight on Windows
cold-start; the plugin-sandbox tests already use 30_000 ms for the same
reason. This patch lets hosts raise the ceiling via env var without
touching code or adding a CLI flag, with explicit options.importTimeoutMs
still taking precedence.

Precedence: options.importTimeoutMs > env var > 4000.

Refs: #11065

* fix(plugin-sandbox): tighten env parsing + use vi.stubEnv (PR feedback)

- Number.parseInt accepts trailing garbage ("4000ms" -> 4000); switch
  to Number() + Number.isInteger() so malformed env values fall back
  to the default instead of silently consuming the numeric prefix.
- Replace manual process.env save/restore in the regression test with
  the idiomatic vi.stubEnv() / vi.unstubAllEnvs() pattern.

Per Greptile review on #11084.
2026-06-01 17:34:13 -07:00
Saoud Rizwan e424b28702 feat(cli): add plugins slash command (#11193)
* feat(cli): add plugins slash command

* fix(cli): address plugins command review feedback
2026-06-01 16:59:38 -07:00
Robin Newhouse db9971890e Add SDK telemetry for run_commands timeouts (#11149)
* feat(sdk): add run_commands timeout telemetry

* docs(sdk): document timeout telemetry event

* docs(sdk): move telemetry catalog to core docs

* fix(sdk): omit undefined timeout telemetry fields

* fix(sdk): mark timed out run_commands unsuccessful

* docs(sdk): defer telemetry catalog entry

* fix(sdk): limit run_commands timeout success override

* fix(sdk): tighten timeout telemetry plumbing
2026-06-01 16:22:04 -07:00
Tomás Barreiro d7cc9b6155 Move bun from the sdk/ to root (#11104)
* Move bun to root

* Fix scripts and pre-commit

* Update scripts

* Update workflows

* Fix cd

* fix pre-commit

* fix cli publish
2026-06-01 22:29:41 +02:00
Saoud Rizwan 05042d3ff7 docs(sdk): add env-blocker plugin example (#11192)
* docs(sdk): add env-blocker plugin example

Adds a beforeTool hook plugin that deterministically blocks the agent
from reading .env secret files via read_files, editor, or run_commands
(e.g. cat .env), while leaving .env.example/.sample/.template readable.
Demonstrates moving a security policy out of an AGENTS.md rule (a
suggestion the model can ignore) and into the execution path.

* docs(sdk): install env-blocker globally in usage examples

A secret-protection guard is most useful applied to every project, so
drop the --cwd . project-scoped install in favor of the global default.

* docs(sdk): trim env-blocker usage docs

* docs(sdk): limit env-blocker to read paths only

It is a read blocker, so only guard read_files and run_commands.
Drop the editor case (and with it the symmetric apply_patch concern),
keeping the example focused and simple.

* docs(sdk): rename env-blocker helpers for readability

collectPaths -> extractFilePaths, collectCommands -> extractShellCommands
so the beforeTool call sites read clearly at a glance.

* docs(sdk): rename commandTouchesEnv to commandReadsEnv

* docs(sdk): drop console.error from env-blocker hook
2026-06-01 12:12:16 -07:00
aikido-autofix[bot] dc2c662de6 [Aikido] Fix 53 security issues in @xmldom/xmldom, basic-ftp, axios and 14 more (#11145)
* fix(security): update dependencies

* fix: set unbounded axios fetch adapter limits for 1.16.0

---------

Co-authored-by: aikido-autofix[bot] <119856028+aikido-autofix[bot]@users.noreply.github.com>
Co-authored-by: TheRealSpencer <spencer@cline.bot>
2026-06-01 10:15:40 -07:00
Max 4b68826cb5 update changelog and bump version (#11184)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-01 09:26:14 -07:00
Mikołaj Kondratek 85be70bb25 fix(vscode): probe @vscode/ripgrep-universal layout for VS Code ≥1.122.0 (#11178)
VS Code 1.122.0 migrated its bundled ripgrep from `@vscode/ripgrep` to
`@vscode/ripgrep-universal`, which ships per-platform/arch subdirectories
(`node_modules/@vscode/ripgrep-universal/bin/<platform>-<arch>/{rg|rg.exe}`)
instead of the previous flat `node_modules/@vscode/ripgrep/bin/{rg|rg.exe}`.
The migration commit (microsoft/vscode@bf19e5ca / @c4471e24) landed on
`release/1.122` and ships in stable 1.122.0+. Microsoft hit the identical
bug in their own sandbox engines and patched it in microsoft/vscode#317978;
our `getBinaryLocation` was still on the retired layout.

Symptom: on VS Code 1.122.x, all four `checkPath` probes in extension.ts
miss, `getBinaryLocation("rg")` throws `Could not find ripgrep binary`,
and `searchFiles` returns `{results: [], errorReason: "unknown"}`. The
@-mention picker shows "No results found" immediately, regardless of
workspace or query.

Telemetry confirmed the regression bisects cleanly to the VS Code
version boundary, not any Cline release — `mention_failed` events with
`errorType=unknown` jumped from ~300/week on 1.121.0 to ~100k/week on
1.122.0/1, while JetBrains versions (which don't use this code path)
stayed flat. Every historical Cline version is affected when the user
is on 1.122+.

Fix: probe the new `@vscode/ripgrep-universal/bin/<platform>-<arch>/`
layout first (both regular and `.asar.unpacked` variants), then fall
through to the four legacy probes so users on ≤1.121.x keep working.
`<platform>-<arch>` is `${process.platform}-${process.arch}`, matching
the directory naming Microsoft documented in #317978
(darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-arm,
linux-ia32, win32-x64, win32-arm64, win32-ia32, etc.).

This also closes the residual #11105 reports that survived #11166:
Yufeng's PR moved the failure mode from `unknown` to
`ripgrep_spawn_failed` (bare `rg`/`rg.exe` on PATH fallback) but didn't
restore actual functionality for users on 1.122.x — they got
spawn-ENOENT instead of file-not-found. With this patch the bundled
binary resolves correctly and ripgrep runs as before.

Refs: https://github.com/cline/cline/issues/11105
Refs: https://github.com/cline/cline/issues/11142
Refs: https://github.com/microsoft/vscode/pull/317978
2026-06-02 00:11:02 +09:00
Saoud Rizwan c824d8380a bump version and update changelog (#11172) 2026-05-31 23:33:45 -07:00
Yufeng He fb80840086 fix: keep file search fallback alive (#11166)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-05-31 23:28:28 -07:00
morning-verlu 42e4ea60db Fix marketplace getting started link (#11170)
Co-authored-by: morning-verlu <258725120+morning-verlu@users.noreply.github.com>
2026-05-31 23:12:08 -07:00
Saoud Rizwan 31a118fc0c test(core): expect Opus 4.8 default in legacy provider migration 2026-05-29 12:15:37 -07:00
Saoud Rizwan c33c3176ef chore(cli): release v3.0.15 2026-05-29 12:06:12 -07:00
Bee f5a3c591c8 chore(sdk): Model Catalog v1780081026557 (#11140)
Updated model catalog to v1780081026557 with `bun run build:models`

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-05-29 12:00:26 -07:00
Bee be930a69a8 feat(plugin): support rule contributions in sandbox (#11127)
* feat(plugin): support rule contributions in sandbox

Add plugin rule registration to the sandbox descriptor and handler state so
plugins can contribute static or dynamic rule content.

Update plugin installation to omit peer dependencies and use
legacy-peer-deps to avoid peer resolution failures during isolated installs.

* feat(cli): support participant mute targets in Discord

Resolve /mute and /unmute targets from Discord user mentions and raw
user IDs so a specific participant can be muted within a thread.

Update Discord system rules to guide agents toward thread-level and
participant-level mute commands, and add tests for target parsing.

* Update sdk/apps/cli/src/utils/chat-commands.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix(cli): normalize addressed bot command suffixes

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-05-29 11:59:58 -07:00
Ara 3651fe9a55 fix: stop Discord connector after repeated errors (#11121)
* fix(cli): clear connector sessions on hub shutdown

* fix: stop Discord connector after repeated errors

Added error tracking to Discord connector to prevent spam:
- Tracks errors by message within a 1-minute window
- After 3 identical errors, shuts down connector instead of posting
- Prevents repeated error messages flooding Discord channels
- Logs shutdown reason for debugging

* fix: address Greptile feedback

1. Error tracker now per-thread (includes thread.id in key)

   - Prevents cross-thread error aggregation

   - One thread having errors will not kill the whole connector

2. Use fixed time window instead of sliding window

   - Track firstSeen timestamp, not just lastSeen

   - Prevents indefinite spam from errors every 61s

   - Window properly resets after ERROR_WINDOW_MS from first error

3. Remove redundant delete in clearBindingSessionIds

   - binding.state.sessionId already deleted in earlier block

   - Cleanup was misleading/unnecessary
2026-05-29 11:52:17 -07:00
Saoud Rizwan 526d8e9c93 fix(cli): make oauth urls clickable in tui (#11139) 2026-05-29 11:50:12 -07:00
Bee fad8271f41 feat: Cline Hub web app (#10969)
* feat: Cline Hub web app

Add a Cline Hub app for monitoring connected clients, viewing and driving sessions, streaming assistant output, and restarting the local hub. Document local, LAN, and tunnel usage with room-secret gating, ignore generated Cline cache/config data, and update lockfile entries.

* feat: Cline Hub UI

* feat: provider config schema

* run command update

* Use Workspace versions

* fix: rename routines to schedules

* feat(schedule): add routine summary and update support

Include last execution data in routine schedule overviews and cache the
summary state in the UI to reduce unnecessary reloads.

Add support for updating routine schedules from the hub server, validate
required fields, and trigger schedules asynchronously after confirming they
exist.

* UI for Connectors

* Fix UI switch for telemetryOptOut

* Expands Recent Sessions UI - allow title update

* feat: Cline Hub routes

* Add "health" and "version" routes

* Refactor server.ts nto 17 focused modules

* Provider Model list search box

* Extensions -> Customizations

* fix: restore discord connector catalog

* fix

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-05-29 11:25:50 -07:00
Bee d6837edda8 feat(cli): support participant mute targets in Discord (#11126)
* feat(cli): support participant mute targets in Discord

Resolve /mute and /unmute targets from Discord user mentions and raw
user IDs so a specific participant can be muted within a thread.

Update Discord system rules to guide agents toward thread-level and
participant-level mute commands, and add tests for target parsing.

* patches

* update prompt
2026-05-29 11:16:10 -07:00
Ara d0f20ca135 fix(sdk): stabilize Windows test suite (#11128) 2026-05-29 10:57:09 -07:00
Robin Newhouse b2a113a2a5 test(sdk): fix Windows CI test failures (#11132) 2026-05-29 10:46:21 -07:00
1082 changed files with 43041 additions and 2626 deletions
@@ -9,12 +9,13 @@ Use this skill when the user asks to release the CLI, publish `cline`, bump the
The CLI is npm-only. Do not add alternate distribution or signing steps.
> Working directory: this skill lives in the SDK sub-monorepo. Run `cd sdk` (from the repo root) before any of the shell commands below. Paths in commands and instructions (e.g. `apps/cli/package.json`, `bun release cli`) are written relative to `sdk/`.
> 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`.
@@ -30,8 +31,93 @@ The skill should guide the user through one release preparation flow, then offer
- 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
@@ -46,10 +132,10 @@ Find the latest CLI tag. If there is no `cli-v*` tag, use the first relevant CLI
2. Collect release commits.
```sh
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli packages scripts .github/workflows/cli-publish.yml
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli sdk/packages sdk/scripts .github/workflows/cli-publish.yml
```
If the release includes broader SDK changes that affect the CLI, also inspect commits outside `apps/cli`.
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.
+9 -13
View File
@@ -140,12 +140,10 @@ Adding a new key to global state requires updates in multiple places. Missing an
Required steps:
1. Type definition in `src/shared/storage/state-keys.ts` - Add to `GlobalState` or `Settings` interface
2. 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
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
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
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.
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(...)`
@@ -159,22 +157,20 @@ 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(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
StateManager uses an in-memory cache populated during `StateManager.initialize()` from file-backed storage. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
Exception: State needed immediately at extension startup (before cache is ready)
Exception: host migration code may read legacy VS Code storage before file-backed storage is initialized.
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`):
Example pattern:
```typescript
// Writing (normal pattern)
controller.stateManager.setGlobalState("myKey", value)
// Reading at startup in common.ts (bypass cache)
const value = context.globalState.get<string>("myKey")
// Reading after initialization
const value = controller.stateManager.getGlobalStateKey("myKey")
```
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.
Use `context.globalState` only in VS Code migration code that copies legacy ExtensionContext values into the shared file-backed stores.
## ChatRow Cancelled/Interrupted States
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
+1 -1
View File
@@ -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: 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`.
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.
## Slash Commands (3 places)
- `src/core/slash-commands/index.ts` — definitions.
+7 -7
View File
@@ -33,7 +33,7 @@ permissions:
defaults:
run:
working-directory: sdk
working-directory: .
jobs:
publish-main:
@@ -105,12 +105,12 @@ jobs:
PACKAGE_VERSION=$(node -p "require('./apps/cli/package.json').version")
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
echo "sdk/apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
echo "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 "sdk/apps/cli/package.json has invalid version: ${VERSION}"
echo "apps/cli/package.json has invalid version: ${VERSION}"
exit 1
fi
@@ -147,7 +147,7 @@ jobs:
- name: Build platform binaries
run: bun script/build.ts --install-native-variants --skip-sdk-build
working-directory: sdk/apps/cli
working-directory: apps/cli
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
@@ -194,7 +194,7 @@ jobs:
env:
NPM_CONFIG_PROVENANCE: "true"
run: bun script/publish-npm.ts --tag latest
working-directory: sdk/apps/cli
working-directory: apps/cli
- name: Get Previous CLI Tag
id: prev_tag
@@ -375,7 +375,7 @@ jobs:
- name: Build platform binaries
if: steps.check_commits.outputs.skip != 'true'
run: bun script/build.ts --install-native-variants --skip-sdk-build
working-directory: sdk/apps/cli
working-directory: apps/cli
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
@@ -424,7 +424,7 @@ jobs:
env:
NPM_CONFIG_PROVENANCE: "true"
run: bun script/publish-npm.ts --tag nightly
working-directory: sdk/apps/cli
working-directory: apps/cli
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
@@ -31,6 +31,9 @@ jobs:
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
runs-on: ubuntu-latest
environment: PublishNightly
defaults:
run:
working-directory: apps/vscode
steps:
- name: Checkout selected branch
@@ -54,11 +57,13 @@ jobs:
# 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 extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci --include=optional
- name: Install webview-ui dependencies
run: cd webview-ui && npm ci --include=optional
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci --include=optional
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
@@ -114,11 +114,13 @@ jobs:
with:
node-version: 22
- name: Install root dependencies
run: npm install --include=optional
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode install --include=optional
- name: Install webview-ui dependencies
run: cd webview-ui && npm install --include=optional
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui install --include=optional
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
+5 -3
View File
@@ -128,13 +128,15 @@ jobs:
restore-keys: |
playwright-browsers-${{ runner.os }}-
- name: Install root dependencies
run: npm ci
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
run: cd webview-ui && npm ci
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
- name: Install vsce
run: npm install -g @vscode/vsce
+18 -10
View File
@@ -91,13 +91,15 @@ jobs:
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install root dependencies
run: npm ci
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
run: cd webview-ui && npm ci
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
- name: Run Quality Checks (Parallel)
run: npm run ci:check-all
@@ -130,16 +132,19 @@ jobs:
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install root dependencies
run: npm ci
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
run: cd webview-ui && npm ci
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui 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"
@@ -229,13 +234,15 @@ jobs:
apps/vscode/webview-ui/package-lock.json
apps/vscode/testing-platform/package-lock.json
- name: Install root dependencies
run: npm ci
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
run: cd webview-ui && npm ci
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
- name: Download ripgrep binaries
run: npm run download-ripgrep
@@ -244,7 +251,8 @@ jobs:
run: npm run compile-standalone
- name: Install testing platform dependencies
run: cd testing-platform && npm ci
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/testing-platform ci
- name: Running testing platform integration spec tests
timeout-minutes: 7
+9 -9
View File
@@ -26,7 +26,7 @@ on:
defaults:
run:
working-directory: sdk
working-directory: .
jobs:
test:
@@ -148,7 +148,7 @@ jobs:
env:
CHANNEL: ${{ steps.channel.outputs.channel }}
run: |
BASE_VERSION=$(node -p "require('./packages/llms/package.json').version")
BASE_VERSION=$(node -p "require('./sdk/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 scripts/version.ts "$VERSION"
run: bun sdk/scripts/version.ts "$VERSION"
- name: Verify publishability
if: steps.check_commits.outputs.skip != 'true'
run: bun scripts/check-publish.ts
run: bun sdk/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 packages/shared
cd sdk/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 packages/llms
cd sdk/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 packages/agents
cd sdk/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 packages/core
cd sdk/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 packages/sdk
cd sdk/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
+4 -4
View File
@@ -21,7 +21,7 @@ permissions:
defaults:
run:
working-directory: sdk
working-directory: .
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 './packages/**' test
run: bun -F './sdk/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 scripts/ci-node-smoke.ts
run: bun sdk/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 scripts/check-publish.ts
run: bun sdk/scripts/check-publish.ts
+11
View File
@@ -61,6 +61,17 @@ 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
@@ -6,15 +6,18 @@
{
"id": "sdk-tool-handler-telemetry",
"rule": "Any new tool handler added to packages/agents/src or packages/core/src that performs a user-visible action (writes files, executes commands, modifies state, calls external APIs) must include a call to captureToolUsage() from packages/core/src/services/telemetry/core-events.ts, or emit a task.tool_used event via telemetry.capture(). Pure read-only helpers and getters are exempt. When in doubt, prefer instrumentation.",
"scope": ["packages/agents/src/**", "packages/core/src/**"],
"scope": [
"sdk/packages/agents/src/**",
"sdk/packages/core/src/**"
],
"severity": "high"
},
{
"id": "sdk-session-lifecycle-telemetry",
"rule": "New session start, end, or state-transition code paths in packages/core/src must call the appropriate typed helper from packages/core/src/services/telemetry/core-events.ts (captureTaskCreated, captureTaskCompleted, captureConversationTurnEvent, captureTokenUsage, etc.). Do not inline raw telemetry.capture() calls for session lifecycle events — always use the typed helper, which guarantees a consistent payload shape.",
"scope": [
"packages/core/src/cline-core/**",
"packages/core/src/runtime/**"
"sdk/packages/core/src/cline-core/**",
"sdk/packages/core/src/runtime/**"
],
"severity": "high"
},
@@ -22,8 +25,8 @@
"id": "sdk-no-raw-event-strings",
"rule": "All telemetry event name strings must be sourced from CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts. If a PR introduces a string literal in a telemetry.capture(), telemetry.captureRequired(), or recordCounter()/recordHistogram()/recordGauge() call that does not reference CORE_TELEMETRY_EVENTS, flag it. New events must be added to CORE_TELEMETRY_EVENTS first, with a typed capture helper created alongside them.",
"scope": [
"packages/core/src/**",
"packages/agents/src/**",
"sdk/packages/core/src/**",
"sdk/packages/agents/src/**",
"apps/cli/src/**",
"apps/vscode/src/**"
],
@@ -32,13 +35,17 @@
{
"id": "sdk-auth-telemetry-completeness",
"rule": "Any new OAuth or authentication provider added under packages/core/src/auth must emit all four lifecycle events using the typed helpers from core-events.ts: captureAuthStarted (at flow entry), captureAuthSucceeded + identifyAccount (on token success), captureAuthFailed (on error), and captureAuthLoggedOut (on token invalidation or explicit logout). Flag PRs that introduce a new auth flow file without all four. Cross-reference packages/core/src/auth/cline.ts and packages/core/src/auth/codex.ts as canonical examples.",
"scope": ["packages/core/src/auth/**"],
"scope": [
"sdk/packages/core/src/auth/**"
],
"severity": "high"
},
{
"id": "sdk-telemetry-doc-update",
"rule": "Any PR that adds new event constants to CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts, adds new capture* helper functions, or changes the payload shape of an existing event must update the Event Catalog section in DOC.md. Flag PRs that modify core-events.ts without a corresponding change to DOC.md.",
"scope": ["packages/core/src/services/telemetry/core-events.ts"],
"scope": [
"sdk/packages/core/src/services/telemetry/core-events.ts"
],
"severity": "medium"
}
]
@@ -1,19 +1,19 @@
{
"files": [
{
"path": "packages/core/src/services/telemetry/core-events.ts",
"path": "sdk/packages/core/src/services/telemetry/core-events.ts",
"description": "Single source of truth for all telemetry event names (CORE_TELEMETRY_EVENTS) and their typed capture helper functions. Every PR touching telemetry must be evaluated against this catalog. New events must be defined here first."
},
{
"path": "packages/shared/src/services/telemetry.ts",
"path": "sdk/packages/shared/src/services/telemetry.ts",
"description": "ITelemetryService interface definition. Defines the contract all telemetry implementations must satisfy (capture, captureRequired, recordCounter, recordHistogram, recordGauge, flush, dispose)."
},
{
"path": "packages/core/src/services/telemetry/TelemetryService.ts",
"path": "sdk/packages/core/src/services/telemetry/TelemetryService.ts",
"description": "Reference implementation of ITelemetryService used by all hosts. Multi-adapter fan-out service that forwards events to OpenTelemetry."
},
{
"path": "packages/core/src/services/telemetry/OpenTelemetryProvider.ts",
"path": "sdk/packages/core/src/services/telemetry/OpenTelemetryProvider.ts",
"description": "OpenTelemetry-backed provider that wires logs/metrics/traces exporters. Contains createConfiguredTelemetryService and createConfiguredTelemetryHandle, the canonical factories every host should use."
},
{
@@ -21,11 +21,11 @@
"description": "Public API and event documentation. The Event Catalog and 'Activation funnel' sections must be kept in sync with core-events.ts. Host integration rules (CLI dir ordering, hub daemon metadata forwarding) are documented here."
},
{
"path": "ARCHITECTURE.md",
"path": "sdk/ARCHITECTURE.md",
"description": "Architecture reference. Telemetry design decisions, completion semantics (submit_and_exit anchoring), and hub-daemon telemetry forwarding are documented here. Use as ground truth for design intent."
},
{
"path": "AGENTS.md",
"path": "sdk/AGENTS.md",
"description": "Package boundary rules. Telemetry runtime services live in @cline/core; @cline/agents must not own stateful telemetry. Use to evaluate whether a telemetry change is being made in the correct package."
}
]
@@ -10,9 +10,9 @@ The SDK uses OpenTelemetry (OTEL) as its sole telemetry transport. Events flow t
```
core-events.ts (event catalog + typed helpers)
ITelemetryService (packages/shared) ← interface contract
ITelemetryService (sdk/packages/shared) ← interface contract
TelemetryService (packages/core) ← multi-adapter fan-out
TelemetryService (sdk/packages/core) ← multi-adapter fan-out
OpenTelemetryAdapter → OpenTelemetryProvider ← OTLP transport
@@ -24,7 +24,7 @@ parallel-but-independent stacks; this `.greptile/` config covers only the SDK.
## The Single Source of Truth
`packages/core/src/services/telemetry/core-events.ts` is the single source of truth for all
`sdk/packages/core/src/services/telemetry/core-events.ts` is the single source of truth for all
event names. It exports:
- `CORE_TELEMETRY_EVENTS` — a frozen const object grouped by family
@@ -60,8 +60,8 @@ Emission ownership:
emitter in `prepareLocalRuntimeBootstrap`. Hosts must NOT re-emit these.
- `workspace.path_resolved`: emitted from default tool executors **only when**
`WorkspaceManager` exposes more than one root.
- `task.*`: emitted by core session lifecycle code in `packages/core/src/cline-core/` and
`packages/core/src/runtime/`. Hosts must not duplicate this emission.
- `task.*`: emitted by core session lifecycle code in `sdk/packages/core/src/cline-core/` and
`sdk/packages/core/src/runtime/`. Hosts must not duplicate this emission.
## `task.completed` Semantics
@@ -105,7 +105,7 @@ forwarding, hub-backed sessions silently drop their lifecycle telemetry.
## Auth Lifecycle Completeness
Every authentication provider in `packages/core/src/auth/` must emit all four auth lifecycle
Every authentication provider in `sdk/packages/core/src/auth/` must emit all four auth lifecycle
events using the typed helpers:
| Phase | Helper | Where it fires |
@@ -115,7 +115,7 @@ events using the typed helpers:
| Token error | `captureAuthFailed(provider, errorMessage)` | In the catch block |
| Token invalidation | `captureAuthLoggedOut(provider, reason)` | On invalid_grant or explicit logout |
Cross-reference `packages/core/src/auth/cline.ts` and `packages/core/src/auth/codex.ts` as
Cross-reference `sdk/packages/core/src/auth/cline.ts` and `sdk/packages/core/src/auth/codex.ts` as
canonical examples of all four phases.
## Single Telemetry Service Per Host
+9
View File
@@ -1 +1,10 @@
if ! command -v gitleaks >/dev/null 2>&1; then
echo "gitleaks is required for the pre-commit secret scan."
echo "Install it with: brew install gitleaks"
echo "Other install options: https://github.com/gitleaks/gitleaks#installing"
exit 1
fi
gitleaks git --pre-commit --redact --staged --verbose || exit 1
lint-staged
+22 -21
View File
@@ -5,8 +5,8 @@
"tasks": [
{
"label": "compile-standalone",
"type": "npm",
"script": "compile-standalone",
"type": "shell",
"command": "npm run compile-standalone",
"group": "build",
"problemMatcher": [],
"presentation": {
@@ -18,8 +18,8 @@
},
{
"label": "npm: protos",
"type": "npm",
"script": "protos",
"type": "shell",
"command": "npm run protos",
"problemMatcher": [],
"isBackground": false,
"presentation": {
@@ -64,8 +64,8 @@
"group": "build"
},
{
"type": "npm",
"script": "build:webview",
"type": "shell",
"command": "npm run build:webview",
"group": "build",
"problemMatcher": [],
"isBackground": true,
@@ -85,8 +85,8 @@
}
},
{
"type": "npm",
"script": "build:webview:test",
"type": "shell",
"command": "npm run build:webview:test",
"group": "build",
"problemMatcher": [],
"isBackground": true,
@@ -107,8 +107,8 @@
}
},
{
"type": "npm",
"script": "dev:webview",
"type": "shell",
"command": "npm run dev:webview",
"group": "build",
"problemMatcher": [
{
@@ -144,8 +144,8 @@
}
},
{
"type": "npm",
"script": "watch:esbuild",
"type": "shell",
"command": "npm run watch:esbuild",
"group": "build",
"problemMatcher": {
"pattern": [
@@ -183,8 +183,8 @@
}
},
{
"type": "npm",
"script": "watch:esbuild:test",
"type": "shell",
"command": "npm run watch:esbuild:test",
"group": "build",
"problemMatcher": {
"pattern": [
@@ -223,8 +223,8 @@
}
},
{
"type": "npm",
"script": "watch:tsc",
"type": "shell",
"command": "npm run watch:tsc",
"group": "build",
"problemMatcher": "$tsc-watch",
"isBackground": true,
@@ -241,8 +241,9 @@
}
},
{
"type": "npm",
"script": "watch-tests",
"type": "shell",
"command": "npm run watch-tests",
"label": "npm: watch-tests",
"problemMatcher": "$tsc-watch",
"isBackground": true,
"dependsOn": [
@@ -280,8 +281,8 @@
"command": "rm -rf ${workspaceFolder}/apps/vscode/dist/tmp/user && mkdir -p ${workspaceFolder}/apps/vscode/dist/tmp/user"
},
{
"type": "npm",
"script": "storybook",
"type": "shell",
"command": "npm run storybook",
"group": "build",
"problemMatcher": [],
"isBackground": false,
@@ -308,7 +309,7 @@
"$tsc"
],
"options": {
"cwd": "${workspaceFolder}/sdk"
"cwd": "${workspaceFolder}"
}
}
],
+22
View File
@@ -1,5 +1,27 @@
# 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
+7 -3
View File
@@ -51,7 +51,7 @@ for CI/CD and scripting.
npm i -g cline
```
<a href="./sdk/apps/cli/README.md">Learn more</a>
<a href="./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. | [`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) |
| **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) |
| **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,8 +212,12 @@ 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
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
# 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
```
## Headless CLI for CI/CD
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"root": false,
"extends": ["../sdk/biome.json"],
"linter": {
"rules": {
"a11y": {
"noStaticElementInteractions": "warn"
}
}
}
}
@@ -1,5 +1,39 @@
# Cline CLI Changelog
## 3.0.17
- Fix a regression introduced in 3.0.15 where the interactive CLI could get stuck after stopping and restarting Cline Hub and then pressing Escape to cancel a request. The CLI now detects stale or missing sessions, recovers any pending messages, and starts a fresh session instead of failing with "session not found".
- Fix Ctrl+C and Hub shutdown races that surfaced as "hook dispatch failed" and WebSocket connection errors from late hook events racing against Hub shutdown.
- Fix the Hub daemon being shut down prematurely when a runtime request was aborted, so the daemon now stays alive.
- Improve the Telegram connector with a new `--allowed-user-id` flag to restrict which Telegram users are authorized to interact with the agent.
## 3.0.16
- Install official Cline plugins by slug off the new github.com/cline/plugins collection.
- Uninstall plugins using `cline plugin uninstall <plugin>` or in the TUI.
- Plugins can now bundle skills, and plugin skills are grouped together in settings.
- Add Slack socket mode support.
- Allow a custom base URL for Anthropic vendor-type providers.
- Fix OAuth token migration for users signed in through the old extension.
- Use a union schema for read-files tool input validation.
- Add a `CLINE_PLUGIN_IMPORT_TIMEOUT_MS` env override to control the plugin import timeout.
## 3.0.15
- Add Cline Hub, a web app for monitoring connected clients, viewing and driving sessions, streaming assistant output, and restarting the local hub, with local, LAN, and tunnel usage gated by a room secret.
- Support global AGENTS rules so agent rules can be applied across all sessions, not just per-project.
- Let plugins contribute static or dynamic rule content when installed in the sandbox.
- Bind Discord sessions to individual message authors so different Discord users no longer share chat state in a thread.
- Support participant mute targets in Discord: resolve `/mute` and `/unmute` from user mentions or raw user IDs to mute a specific participant in a thread.
- Make OAuth URLs clickable in the TUI.
- Refresh the bundled model catalog, adding Claude Opus 4.8, Moonshot Kimi K2.6, and Qwen3.7 Max (with cache support).
- Discover SDK skill directories that are symlinked, including handling circular symlinks.
- Steer active connector sessions across turn keys by matching on session ID, so replies continue the existing session instead of starting a duplicate.
- Stop the Discord connector after repeated identical errors (per thread, within a time window) to prevent error messages from flooding a channel.
- Fix Discord connector registration and reply fallback handling.
- Fix SAP AI Core to use the AI SDK community provider.
- Log ACP output as diagnostics instead of errors so normal output no longer appears as errors.
## 3.0.14
- Fix OTEL telemetry variable bundling so telemetry is correctly enabled in compiled CLI builds: guard against environments where `process.env` is undefined and remove optional chaining so bundlers can inline the values at build time.
@@ -416,7 +416,7 @@ Then attach VS Code or Chrome DevTools to `ws://127.0.0.1:6499`.
## Publishing
The CLI is published as the `cline` wrapper package on npm with platform-specific binaries under `@cline/cli-*`. The release flow lives in the `publish-cli` skill (`sdk/apps/cli/.cline/skills/publish-cli/SKILL.md`).
The CLI is published as the `cline` wrapper package on npm with platform-specific binaries under `@cline/cli-*`. The release flow lives in the `publish-cli` skill (`.cline/skills/publish-cli/SKILL.md` at the repo root).
From the `apps/cli` workspace:
@@ -174,6 +174,9 @@ cline connect telegram -k 123456:ABCDEF...
# Slack (webhook mode)
cline connect slack --bot-token $SLACK_BOT_TOKEN --signing-secret $SLACK_SIGNING_SECRET --base-url https://your-domain.com
# Slack (socket mode)
cline connect slack --bot-token $SLACK_BOT_TOKEN --app-token $SLACK_APP_TOKEN
# Google Chat (webhook mode)
cline connect gchat --base-url https://your-domain.com
+60 -3
View File
@@ -1,12 +1,65 @@
import { copyFileSync, mkdirSync } from "node:fs";
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readdirSync,
statSync,
} from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { $ } from "bun";
function defineProcessEnv(name: string): string {
return JSON.stringify(process.env[name] ?? "");
}
const sourcemap = Bun.env.CLINE_SOURCEMAPS === "1" ? "linked" : "none";
const rootDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(rootDir, "../../");
const hubWebviewSourcePath = join(repoRoot, "apps/cline-hub/src/webview");
const hubWebviewDistPath = join(repoRoot, "apps/cline-hub/dist/webview");
const hubWebviewIndexPath = join(hubWebviewDistPath, "index.html");
const cliHubWebviewDistPath = join(rootDir, "dist/cline-hub/webview");
function newestFileMtimeMs(dir: string): number {
let newest = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (
entry.name === "node_modules" ||
entry.name === "dist" ||
entry.name === ".turbo"
) {
continue;
}
const path = join(dir, entry.name);
if (entry.isDirectory()) {
newest = Math.max(newest, newestFileMtimeMs(path));
} else if (entry.isFile()) {
newest = Math.max(newest, statSync(path).mtimeMs);
}
}
return newest;
}
function shouldBuildHubWebview(): boolean {
if (!existsSync(hubWebviewIndexPath)) {
return true;
}
try {
return (
newestFileMtimeMs(hubWebviewSourcePath) >
statSync(hubWebviewIndexPath).mtimeMs
);
} catch {
return true;
}
}
if (shouldBuildHubWebview()) {
console.log("Building Cline Hub webview...");
await $`bun -F @cline/cline-hub build:webview`.cwd(repoRoot);
}
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
@@ -63,10 +116,9 @@ if (result.logs.length > 0) {
}
}
const rootDir = dirname(fileURLToPath(import.meta.url));
const coreBootstrapPath = join(
rootDir,
"../../packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
"../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
);
const cliBootstrapPath = join(
rootDir,
@@ -74,3 +126,8 @@ const cliBootstrapPath = join(
);
mkdirSync(dirname(cliBootstrapPath), { recursive: true });
copyFileSync(coreBootstrapPath, cliBootstrapPath);
if (existsSync(hubWebviewDistPath)) {
mkdirSync(dirname(cliHubWebviewDistPath), { recursive: true });
cpSync(hubWebviewDistPath, cliHubWebviewDistPath, { recursive: true });
}
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.14",
"version": "3.0.17",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -10,7 +10,7 @@
"repository": {
"type": "git",
"url": "git+https://github.com/cline/cline.git",
"directory": "sdk/apps/cli"
"directory": "apps/cli"
},
"keywords": [
"cline",
@@ -75,6 +75,7 @@
"@chat-adapter/telegram": "^4.23.0",
"@chat-adapter/whatsapp": "^4.23.0",
"@clack/prompts": "^1.2.0",
"@cline/cline-hub": "workspace:*",
"@gramio/format": "^0.7.0",
"@opentui-ui/dialog": "^0.1.2",
"@opentui/core": "0.1.102",
@@ -90,12 +91,15 @@
"react-devtools-core": "^7.0.1",
"react-reconciler": "0.32.0",
"yaml": "^2.8.2",
"nanoid": "^5.1.7",
"zod": "^4.1.11"
},
"devDependencies": {
"@cline/core": "workspace:*",
"@cline/shared": "workspace:*",
"@microsoft/tui-test": "^0.0.2",
"@types/react": "19.2.14"
"@types/react": "19.2.14",
"vitest": "^4.0.18",
"@types/bun": "^1.3.10"
}
}
@@ -1,6 +1,14 @@
#!/usr/bin/env bun
import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs";
import {
cpSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
statSync,
} from "node:fs";
import { join, relative, resolve } from "node:path";
import { $ } from "bun";
import {
@@ -95,6 +103,48 @@ if (!buildOptions.skipSdkBuild) {
await $`bun -F @cline/cli build`.cwd(rootDir);
}
const hubWebviewSource = join(cliDir, "../cline-hub/src/webview");
const hubWebviewDist = join(cliDir, "../cline-hub/dist/webview");
const hubWebviewIndex = join(hubWebviewDist, "index.html");
function newestFileMtimeMs(dir: string): number {
let newest = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (
entry.name === "node_modules" ||
entry.name === "dist" ||
entry.name === ".turbo"
) {
continue;
}
const path = join(dir, entry.name);
if (entry.isDirectory()) {
newest = Math.max(newest, newestFileMtimeMs(path));
} else if (entry.isFile()) {
newest = Math.max(newest, statSync(path).mtimeMs);
}
}
return newest;
}
function shouldBuildHubWebview(): boolean {
if (!existsSync(hubWebviewIndex)) {
return true;
}
try {
return (
newestFileMtimeMs(hubWebviewSource) > statSync(hubWebviewIndex).mtimeMs
);
} catch {
return true;
}
}
if (shouldBuildHubWebview()) {
console.log("Building Cline Hub webview...");
await $`bun -F @cline/cline-hub build:webview`.cwd(rootDir);
}
const binaries: Record<string, string> = {};
function findOpenTuiParserWorker(): string {
@@ -209,7 +259,7 @@ for (const item of targets) {
// Copy plugin sandbox bootstrap if it exists
const bootstrapSrc = join(
rootDir,
"packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
"sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
);
if (existsSync(bootstrapSrc)) {
const bootstrapDir = join(cliDir, `dist/${dirName}/extensions`);
@@ -218,6 +268,14 @@ for (const item of targets) {
await Bun.write(join(bootstrapDir, "plugin-sandbox-bootstrap.js"), content);
}
if (existsSync(hubWebviewDist)) {
const hubWebviewDest = join(cliDir, `dist/${dirName}/cline-hub/webview`);
mkdirSync(join(cliDir, `dist/${dirName}/cline-hub`), {
recursive: true,
});
cpSync(hubWebviewDist, hubWebviewDest, { recursive: true });
}
// Generate platform package.json
await Bun.write(
join(cliDir, `dist/${dirName}/package.json`),
@@ -90,7 +90,7 @@ function buildHostSdkDependencies(): Record<string, string> {
for (const pkg of hostSdkPackages) {
dependencies[pkg.name] = readPackageVersion(
pkg.name,
join(cliDir, "../../packages", pkg.directory, "package.json"),
join(cliDir, "../../sdk/packages", pkg.directory, "package.json"),
);
}
return dependencies;
@@ -30,6 +30,18 @@ function resolveCliAgentConfigSearchPaths(cwd: string): string[] {
return [join(cwd, ".cline", "agents"), join(clineDir, "agents")];
}
function createConfigUserInstructionService(cwd: string) {
return createUserInstructionConfigService({
skills: {
workspacePath: cwd,
includePluginSkills: true,
cwd,
},
rules: { workspacePath: cwd },
workflows: { workspacePath: cwd },
});
}
async function runWorkflowsConfigCommand(
cwd: string,
outputMode: CliOutputMode,
@@ -39,11 +51,7 @@ async function runWorkflowsConfigCommand(
string,
{ id: string; name: string; instructions: string; path: string }
>();
const service = createUserInstructionConfigService({
skills: { workspacePath: cwd },
rules: { workspacePath: cwd },
workflows: { workspacePath: cwd },
});
const service = createConfigUserInstructionService(cwd);
try {
await service.start();
for (const record of service.listRecords<WorkflowConfig>("workflow")) {
@@ -90,11 +98,7 @@ async function runRulesConfigCommand(
string,
{ name: string; instructions: string; path: string }
>();
const service = createUserInstructionConfigService({
skills: { workspacePath: cwd },
rules: { workspacePath: cwd },
workflows: { workspacePath: cwd },
});
const service = createConfigUserInstructionService(cwd);
try {
await service.start();
for (const record of service.listRecords<RuleConfig>("rule")) {
@@ -142,11 +146,7 @@ async function runSkillsConfigCommand(
path: string;
}
>();
const service = createUserInstructionConfigService({
skills: { workspacePath: cwd },
rules: { workspacePath: cwd },
workflows: { workspacePath: cwd },
});
const service = createConfigUserInstructionService(cwd);
try {
await service.start();
for (const record of service.listRecords<SkillConfig>("skill")) {
@@ -420,11 +420,7 @@ async function runToolsConfigCommand(
async function loadInteractiveConfigDataForCommand(
cwd: string,
): Promise<Awaited<ReturnType<typeof loadInteractiveConfigData>>> {
const userInstructionService = createUserInstructionConfigService({
skills: { workspacePath: cwd },
rules: { workspacePath: cwd },
workflows: { workspacePath: cwd },
});
const userInstructionService = createConfigUserInstructionService(cwd);
try {
await userInstructionService.start();
return await loadInteractiveConfigData({
+184
View File
@@ -0,0 +1,184 @@
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");
});
});
+195
View File
@@ -0,0 +1,195 @@
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;
}
}
@@ -115,8 +115,8 @@ describe("runDoctorCommand", () => {
return {
status: 0,
stdout: [
"50174 /Users/example/.bun/bin/bun /Users/example/dev/sdk/apps/cli/src/index.ts hub start --cwd /workspace",
"50190 /Users/example/.bun/bin/bun /Users/example/dev/sdk/apps/cli/src/index.ts hey",
"50174 /Users/example/.bun/bin/bun /Users/example/dev/apps/cli/src/index.ts hub start --cwd /workspace",
"50190 /Users/example/.bun/bin/bun /Users/example/dev/apps/cli/src/index.ts hey",
].join("\n"),
};
}
@@ -266,7 +266,7 @@ describe("runDoctorCommand", () => {
return {
status: 0,
stdout:
"60123 /Users/example/dev/sdk/apps/examples/desktop-app/src-tauri/bin/code-sidecar\n",
"60123 /Users/example/dev/apps/examples/desktop-app/src-tauri/bin/code-sidecar\n",
};
}
return { status: 1, stdout: "" };
@@ -1,5 +1,5 @@
import { spawnSync } from "node:child_process";
import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { existsSync, readFileSync, rmSync } from "node:fs";
import { dirname, join } from "node:path";
import {
clearHubDiscovery,
@@ -14,6 +14,10 @@ import { formatUptime } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { isProcessRunning } from "../connectors/common";
import {
type ActiveConnectorRecord,
listActiveConnectors,
} from "../connectors/status";
import { getCliBuildInfo } from "../utils/common";
import { c, writeln } from "../utils/output";
import { stopAllConnectors } from "./connect";
@@ -34,19 +38,6 @@ type StartupArtifact = {
stale: boolean;
};
type ActiveConnectorRecord = {
type: string;
pid: number;
hubUrl: string;
startedAt?: string;
applicationId?: string;
botUsername?: string;
userName?: string;
phoneNumberId?: string;
port?: number;
baseUrl?: string;
};
type SpawnedProcessRecord = {
timestamp?: string;
pid?: number;
@@ -287,142 +278,6 @@ async function clearHubStartupArtifacts(
};
}
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,
"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),
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"],
},
whatsapp: {
required: ["userName"],
optional: ["startedAt", "phoneNumberId", "port", "baseUrl"],
},
};
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, "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 { type, pid, hubUrl, ...fields } as ActiveConnectorRecord;
}
function listActiveConnectors(): ActiveConnectorRecord[] {
const connectorTypes: ActiveConnectorRecord["type"][] = [
"telegram",
"gchat",
"linear",
"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);
});
}
function formatHubUptimeFromStartedAt(
startedAt: string | undefined,
): string | undefined {
@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import {
existsSync,
mkdtempSync,
@@ -17,8 +18,10 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
installPlugin,
isOfficialPluginSlug,
parsePluginSource,
runPluginInstallCommand,
runPluginUninstallCommand,
} from "./plugin";
type FetchCall = (
@@ -47,6 +50,29 @@ describe("plugin install command", () => {
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();
@@ -85,6 +111,25 @@ describe("plugin install command", () => {
});
});
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/,
@@ -169,6 +214,125 @@ describe("plugin install command", () => {
).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",
);
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"),
);
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
"local-web-search",
);
});
it("times out stalled remote plugin downloads", async () => {
vi.useFakeTimers();
const source =
@@ -258,6 +422,7 @@ describe("plugin install command", () => {
},
peerDependencies: {
"@cline/shared": "*",
bun: ">=1.0.0",
},
peerDependenciesMeta: {
"@cline/shared": {
@@ -304,12 +469,12 @@ describe("plugin install command", () => {
peerDependenciesMeta?: Record<string, unknown>;
};
expect(packageManifest.dependencies).toEqual({ yaml: "^2.8.1" });
expect(packageManifest.peerDependencies).toBeUndefined();
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 --no-audit --no-fund --package-lock=false",
"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(
@@ -356,6 +521,7 @@ describe("plugin install command", () => {
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"),
@@ -428,6 +594,54 @@ describe("plugin install command", () => {
).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(
@@ -458,6 +672,40 @@ describe("plugin install command", () => {
}
});
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(
@@ -13,6 +13,7 @@ import {
import { cp, mkdir, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import { type PluginUninstallOptions, uninstallPlugin } from "@cline/core";
import {
isPluginModulePath,
resolveClineDir,
@@ -25,6 +26,7 @@ export interface PluginInstallOptions {
cwd?: string;
force?: boolean;
npmCommand?: string;
officialPluginsRepo?: string;
io?: PluginInstallIo;
}
@@ -60,6 +62,10 @@ type ParsedPluginSource =
| {
type: "local";
path: string;
}
| {
type: "official";
slug: string;
};
type PluginInstallSourceType = "npm" | "git" | "local" | "remote";
@@ -77,6 +83,7 @@ interface PluginPackageManifest {
const INSTALLS_DIRECTORY_NAME = "_installed";
const PACKAGE_DIRECTORY_NAME = "package";
const OFFICIAL_PLUGINS_REPO = "https://github.com/cline/plugins.git";
const REMOTE_PLUGIN_FETCH_TIMEOUT_MS = 30_000;
const REMOTE_PLUGIN_MAX_BYTES = 10 * 1024 * 1024;
const HOST_PROVIDED_SDK_PREFIX = "@cline/";
@@ -121,6 +128,14 @@ function sanitizeSegment(value: string): string {
return sanitized || "plugin";
}
export function isOfficialPluginSlug(source: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
}
function resolveOfficialPluginsRepo(override: string | undefined): string {
return override?.trim() || OFFICIAL_PLUGINS_REPO;
}
function parseNpmSpec(spec: string): { name: string } {
const trimmed = spec.trim();
const match = trimmed.match(/^(@?[^@/]+(?:\/[^@/]+)?)(?:@.+)?$/);
@@ -371,6 +386,9 @@ export function parsePluginSource(
if (git) {
return git;
}
if (isOfficialPluginSlug(trimmed)) {
return { type: "official", slug: trimmed };
}
if (looksLikeHostnamePath(trimmed)) {
throw new Error(
`Unrecognized plugin source "${source}". Use --git for hostname-style repositories or pass an explicit local path such as ./github.com/owner/repo.`,
@@ -415,6 +433,14 @@ function getInstallPath(
`${sanitizeSegment(parsed.filename)}-${hashSource(sourceKey)}`,
);
}
if (parsed.type === "official") {
return join(
pluginRoot,
INSTALLS_DIRECTORY_NAME,
"official",
`${sanitizeSegment(parsed.slug)}-${hashSource(sourceKey)}`,
);
}
return join(
pluginRoot,
INSTALLS_DIRECTORY_NAME,
@@ -423,7 +449,11 @@ function getInstallPath(
);
}
function getInstallSourceKey(parsed: ParsedPluginSource, cwd: string): string {
function getInstallSourceKey(
parsed: ParsedPluginSource,
cwd: string,
officialPluginsRepo: string,
): string {
if (parsed.type === "npm") {
return `npm:${parsed.spec}`;
}
@@ -433,6 +463,9 @@ function getInstallSourceKey(parsed: ParsedPluginSource, cwd: string): string {
if (parsed.type === "remote") {
return `remote:${parsed.url}`;
}
if (parsed.type === "official") {
return `official:${officialPluginsRepo}#plugins/${parsed.slug}`;
}
return `local:${resolve(cwd, resolveHomePath(parsed.path))}`;
}
@@ -671,6 +704,7 @@ async function installNpmPackage(
packageRoot,
"--omit=dev",
"--omit=peer",
"--legacy-peer-deps",
"--no-audit",
"--no-fund",
"--package-lock=false",
@@ -692,6 +726,8 @@ async function installPackageDependencies(
[
"install",
"--omit=dev",
"--omit=peer",
"--legacy-peer-deps",
"--no-audit",
"--no-fund",
"--package-lock=false",
@@ -729,6 +765,43 @@ async function installGitPackage(
return packageRoot;
}
async function installOfficialPlugin(
parsed: Extract<ParsedPluginSource, { type: "official" }>,
stagingRoot: string,
npmCommand: string,
officialPluginsRepo: string,
): Promise<string> {
const repoRoot = join(stagingRoot, "repo");
await runCommand("git", [
"clone",
"--filter=blob:none",
"--depth",
"1",
"--",
officialPluginsRepo,
repoRoot,
]);
const sourceRoot = join(repoRoot, "plugins", parsed.slug);
if (!existsSync(sourceRoot) || !statSync(sourceRoot).isDirectory()) {
throw new Error(
`Official Cline plugin "${parsed.slug}" was not found at plugins/${parsed.slug} in ${officialPluginsRepo}`,
);
}
const packageRoot = join(stagingRoot, PACKAGE_DIRECTORY_NAME);
await cp(sourceRoot, packageRoot, {
recursive: true,
filter: (sourcePath) => {
const name = basename(sourcePath);
return name !== ".git" && name !== "node_modules";
},
});
rmSync(repoRoot, { recursive: true, force: true });
await installPackageDependencies(packageRoot, npmCommand);
return packageRoot;
}
function remotePluginSizeLimitError(url: string): Error {
return new Error(
`Remote plugin file from ${url} exceeds the ${REMOTE_PLUGIN_MAX_BYTES} byte limit`,
@@ -910,7 +983,10 @@ export async function installPlugin(
const explicitCwd = options.cwd?.trim();
const cwd = explicitCwd ? resolve(explicitCwd) : process.cwd();
const pluginRoot = getPluginRoot(explicitCwd ? cwd : undefined);
const sourceKey = getInstallSourceKey(parsed, cwd);
const officialPluginsRepo = resolveOfficialPluginsRepo(
options.officialPluginsRepo,
);
const sourceKey = getInstallSourceKey(parsed, cwd, officialPluginsRepo);
const installPath = getInstallPath(pluginRoot, parsed, sourceKey);
const stagingParent = join(pluginRoot, INSTALLS_DIRECTORY_NAME, ".tmp");
const stagingRoot = join(
@@ -930,6 +1006,13 @@ export async function installPlugin(
packageRoot = await installNpmPackage(parsed, stagingRoot, npmCommand);
} else if (parsed.type === "git") {
packageRoot = await installGitPackage(parsed, stagingRoot, npmCommand);
} else if (parsed.type === "official") {
packageRoot = await installOfficialPlugin(
parsed,
stagingRoot,
npmCommand,
officialPluginsRepo,
);
} else if (parsed.type === "remote") {
packageRoot = await installRemoteFile(parsed, stagingRoot);
} else {
@@ -982,3 +1065,22 @@ export async function runPluginInstallCommand(
return 1;
}
}
export async function runPluginUninstallCommand(
options: PluginUninstallOptions & { json?: boolean; io?: PluginInstallIo },
): Promise<number> {
try {
const result = await uninstallPlugin(options);
if (options.json) {
process.stdout.write(JSON.stringify(result));
return 0;
}
options.io?.writeln(`Uninstalled plugin ${result.name}`);
options.io?.writeln(` Removed: ${result.installPath}`);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(message);
return 1;
}
}

Some files were not shown because too many files have changed in this diff Show More