Compare commits

..

88 Commits

Author SHA1 Message Date
abeatrix 2f2be67c79 feat: SendSessionConnectionUpdate 2026-05-26 10:04:48 -07:00
Saoud Rizwan 8a6441fddd chore(cli): release v3.0.13 2026-05-22 17:41:54 -07:00
Saoud Rizwan 7ef5b1e7af test(llms): add provider VCR smoke tests to prevent e.g. ChatGPT regressions (#11012)
* test(llms): add provider vcr smoke tests

* fix(llms): harden provider vcr recording cleanup
2026-05-22 17:37:20 -07:00
Saoud Rizwan 3e58e7b024 fix(cli): show loading dialog for history resume (#11013) 2026-05-22 17:20:59 -07:00
Willis d16667f0b8 fix: use correct base URL for Vertex AI global endpoint with Claude models (#10288)
* fix: use correct base URL for Vertex AI global endpoint with Claude models

The AnthropicVertex SDK constructs the API hostname as
`${region}-aiplatform.googleapis.com`, which produces
`global-aiplatform.googleapis.com` when region is "global".
This hostname does not exist and returns 404.

Per Google Cloud docs, the correct global endpoint hostname is
`aiplatform.googleapis.com` (no region prefix). This fix overrides
the baseURL when region is "global" to use the correct hostname.

Fixes #10287

* chore: add changeset for vertex global endpoint fix

* fix: rebase on main and re-apply global endpoint baseURL override
2026-05-22 17:11:15 -07:00
Saoud Rizwan a740c49524 fix(cli): defer empty session creation after reset (#11000)
* fix(cli): skip empty clear session restart

* fix(cli): defer empty session creation after reset

* fix(cli): clarify session start race guard

* fix(cli): avoid re-resuming after new session reset
2026-05-22 17:02:47 -07:00
Saoud Rizwan 3bc1ee8382 feat(shared): add VCR request body contracts (#10997)
* feat(shared): add VCR request body contracts

* fix(shared): tighten VCR request body contracts

* test(shared): cover legacy VCR cassette playback
2026-05-22 16:00:36 -07:00
Saoud Rizwan ecf354c753 chore(cli): release v3.0.12 2026-05-22 15:14:29 -07:00
TheRealSpencer a66c5ee973 chore(deps): pin protobuf to 7.5.8 via overrides (#10998) 2026-05-22 15:11:06 -07:00
Saoud Rizwan 46659eebeb fix(cli): show loading dialog during model settings transitions (#10999)
* fix(cli): show loading dialog during model settings transitions

* docs(cli): explain loading dialog render yield
2026-05-22 14:54:16 -07:00
Saoud Rizwan 899ee0bfea fix(cli): add inline ask question tool prompt (#10989)
* fix(cli): inline runtime tool prompts

* fix(cli): address inline prompt review
2026-05-22 13:57:01 -07:00
Bee db4598e310 Cline SDK 0.0.42 (#10994)
Version bump
2026-05-22 13:31:48 -07:00
Saoud Rizwan 627e539266 fix(cli): bypass release age gate for manual updates (#10987)
* fix(cli): bypass release age gate for manual updates

* fix(cli): use yarn env override for update age gate
2026-05-22 13:29:25 -07:00
Saoud Rizwan 87ff61915f chore(cli): release v3.0.11 2026-05-22 13:03:25 -07:00
Saoud Rizwan a305556452 fix(llms): revert implicit output cap regression (#10990)
* fix(llms): avoid implicit output token caps

* test(llms): cover ChatGPT OAuth output token regression
2026-05-22 12:59:43 -07:00
Robin Newhouse ca12e97288 fix(cli): make config footer toggle hint contextual (#10976) 2026-05-22 12:34:48 -07:00
Bee 0c89716849 feat(core): includes tool names in tool results across messages (#10975)
* fix(llms): Use Google auth for Vertex Gemini

- Pass `providerConfig.gcp.projectId` through to Vertex Gemini as `googleAuthOptions.projectId`
- Disable Vertex API-key express mode when GCP project config is present so `google-auth-library` handles auth
- Add coverage for `AgentConfig.providerConfig.gcp` forwarding and Vertex Gemini provider creation
- Fix vertex model list only contains Claude models issue

* feat(core): includes tool names in tool results across messages

Updated `tool_result` content blocks to consistently include the `name` of the tool being executed. This change propagates through provider helpers and applies the new schema across all associated unit and live tests, ensuring proper tracking and logging of tool interactions within messages.
2026-05-22 11:36:32 -07:00
Robin Newhouse 1c401dbe1a Fix SDK LLM live provider configs (#10977) 2026-05-22 11:04:33 -07:00
Bee ed78404e4b fix(llms): Use Google auth for Vertex Gemini (#10974)
- Pass `providerConfig.gcp.projectId` through to Vertex Gemini as `googleAuthOptions.projectId`
- Disable Vertex API-key express mode when GCP project config is present so `google-auth-library` handles auth
- Add coverage for `AgentConfig.providerConfig.gcp` forwarding and Vertex Gemini provider creation
- Fix vertex model list only contains Claude models issue
2026-05-22 11:02:35 -07:00
Saoud Rizwan 0157ed9efb chore(cli): release v3.0.10 2026-05-21 20:23:07 -07:00
Saoud Rizwan 5574c95ff2 docs(cli): note ignore-scripts local pack guard quirk in publish-cli skill 2026-05-21 20:22:44 -07:00
Saoud Rizwan 31ee8eb744 feat(cli): install plugins from file URLs (#10945)
* feat(cli): install plugins from file URLs

* fix(cli): harden remote plugin installs

* docs: document plugin file URL installs

* docs: simplify plugin file URL wording

* docs: trim CLI plugin example
2026-05-21 19:58:20 -07:00
Renee Huang 7952e230ae Add Ollama API key note in TUI settings (#10947)
* Add Ollama API key note in TUI settings

* refactor: centralize provider config field metadata

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-05-21 16:27:31 -07:00
Bee 6bcfa34ba9 feat(sdk): track idle and pending session states (#10959)
* feat(runtime): track idle and pending session states

Propagate idle and pending as non-terminal session statuses across the CLI, hub records, and active-session checks. Update runtime handling so interactive sessions remain active while idle or awaiting approval instead of being treated as ended.

Removed `status` from showing up in `cline history` for now.

* hydrate: false no longer reads message files for every session.
2026-05-21 16:09:45 -07:00
Ara cf18793434 feat(sdk): add Poolside provider (#10956) 2026-05-21 16:04:38 -07:00
Saoud Rizwan a7ba25d6e1 feat(uri): add /lg-task webhook integration for LG dashboard flow (#10194)
* feat(uri): add LG task deeplink webhook integration

* feat(uri): include prompt-file path in LG task prompt

* refactor(uri): move LG webhook setup into integration service
2026-05-21 16:03:21 -07:00
Ara 2af2028861 feat: add Gemini 3.5 Flash to Gemini providers (#10955) 2026-05-21 15:43:44 -07:00
Ara 932c8e68e1 Infer Telegram bot username from token (#10954)
* fix(cli): infer Telegram bot username from token

* fix(cli): address Telegram connector review feedback

* test(cli): confirm Telegram schedule delivery bot metadata

* fix(core): publish schedule completion events to connectors

* Revert "fix(core): publish schedule completion events to connectors"

This reverts commit 0d64f037b9.
2026-05-21 15:03:36 -07:00
Bee 3cd13352eb fix(cron): publish failed schedule execution events (#10937)
* fix(cron): publish failed schedule execution events

Add cron runner execution event publishing for completed and failed runs.
Update connector adapters to react to failed schedule executions so clients are notified when scheduled work does not complete successfully.

* add unit tests
2026-05-21 14:57:26 -07:00
Tomás Barreiro 2c842328a8 Inject OTEL variables into the cli at buildtime (#10958)
* Inject OTEL variables

* Add telemetry to the nightly

* Add variables to the build script
2026-05-21 23:43:30 +02:00
Saoud Rizwan d3b3ff1c33 fix(cli): satisfy config item hook lint (#10968) 2026-05-21 14:38:36 -07:00
Robin Newhouse 2508e76af8 Fix SDK model catalog token-limit semantics ENG-2100 (#10946)
* fix(llms): preserve catalog output limits

* chore(llms): regenerate model catalog
2026-05-21 14:19:35 -07:00
Ara a702bf9b38 fix(cli): soften rejected tool call display (#10871) 2026-05-21 14:00:55 -07:00
Robin Newhouse a8cea1dca3 Fix disabled skill availability in SDK CLI ENG-2058 (#10876)
* fix(sdk): hide skills tool when skills are disabled

* fix(cli): refresh skill slash commands after toggle
2026-05-21 11:23:14 -07:00
Bee 2a351ffdd5 fix: Bedrock legacy migration for awsProfile (#10943)
The new Bedrock path is not failing because credential_process is unsupported. Both old and new code use AWS SDK v3’s fromNodeProviderChain, which can load credential_process.

The practical difference is that the migrated config does not include the AWS profile name:

```
"aws": {
  "region": "us-east-1",
  "authentication": "profile"
}
```

There is no `"profile": "bedrock"` So the new llms provider never targets [profile bedrock]. It calls fromNodeProviderChain({ ignoreCache: true, clientConfig: { region } }), which means AWS SDK will use AWS_PROFILE if present, otherwise default.

## Cause

The migration code only migrates awsProfile when legacy awsUseProfile is true. But the old extension treats profile auth as active when awsAuthentication === "profile" too:

```
profile:
  legacyGlobalState.awsAuthentication === "profile" || legacyGlobalState.awsUseProfile
    ? trimNonEmpty(legacyGlobalState.awsProfile)
    : undefined
    ```

## Fixes

The migration now preserves awsProfile when awsAuthentication === "profile", even if the old awsUseProfile flag is missing. It also treats profile-based Bedrock settings as enough to migrate Bedrock without static AWS keys.
2026-05-20 16:46:38 -07:00
Bee 69f148bad9 refactor: cache global settings reads by file metadata (#10933)
* refactor: cache global settings reads by file metadata

Avoid repeated global settings file reads by caching parsed settings and
validating the cache with path, mtime, and size. Invalidate the cache after
writes so updates remain visible, and clarify the legacy skills config name.

* refactor for performance

mtime-keyed cache of the parsed GlobalSettings — repeated reads do statSync + 4 comparisons instead of readFile + JSON.parse + zod (~30-100× speedup on hot path).
statSync(filePath, { throwIfNoEntry: false }) — avoids exception construction on missing-file path.
Cache invalidated on write — doesn't rely on filesystem mtime resolution.
loadSettingsFromDisk helper — pulls the read/parse/validate flow into one place, eliminates the previous three duplicated settingsCache = {...} assignments.
toggleDisabledTool cleaned up — single set construction + single write call, no branched copy of writeGlobalSettings.

* add unit tests for caching logic

* object freeze
2026-05-20 12:11:46 -07:00
Bee 6ca92794e1 chore: model catalog updated 1779302019893 (#10934)
All files automatically changed and formatted by `cd sdk && bun run build:models`

Generated model catalog version updated to 1779251127504

This includes the new X AI build
2026-05-20 11:52:25 -07:00
Bee 27bd4c6c65 chore: generated model catalog update (#10921)
version 1779251127504
2026-05-20 09:03:39 -07:00
Saoud Rizwan 015b61924f chore(cli): release v3.0.9 2026-05-19 19:48:23 -07:00
Saoud Rizwan d238239a0f fix(core): handle hub abort cleanup failures (#10918) 2026-05-19 19:38:42 -07:00
Bee e81c35d7c0 fix: Speed up CLI plugin loading and config toggles ENG-2082 (#10884)
* fix: Speed up CLI plugin loading and config toggles

Load sandboxed plugins concurrently during initialization while preserving
existing duplicate override ordering. Update plugin tool discovery to use a
single sandbox per listing and cache descriptor results by plugin path stats,
provider, and model.

Make CLI plugin/tool config toggles persistence-only from the data loader and
update the TUI optimistically, avoiding full config reloads and repeated plugin
imports when users disable tools or plugins.

* patches

* fix: refresh plugin tools when config update lacks data

Reload config data with plugin tools included when a plugin action does not return updated data. This keeps the config view in sync and clears stale plugin tool errors after refresh.

* fix(cli): preserve config item state on missing toggle data

Only update the dialog item when toggle responses include a matching item. This avoids applying fallback enabled-state changes that can desync the UI when returned config data is missing or incomplete.

---------

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
2026-05-19 18:18:47 -07:00
Ara 1ac5525fee fix: route Poolside Laguna models through next-gen prompts (#10910) 2026-05-19 16:40:19 -07:00
Saoud Rizwan a4d0937030 fix(cli): restore fuzzy file mention ranking (#10909)
* fix(cli): restore fuzzy file mention ranking

* fix(cli): address mention autocomplete review feedback
2026-05-19 16:09:43 -07:00
Bee 6d5c61f044 chore: gitignore SDK session and database files (#10907)
Ignore .cline session data, temp directories, SQLite artifacts, and generated metadata to prevent local SDK state and user data from being committed.
2026-05-19 13:54:53 -07:00
Mark Percival 8a6d031e8f fix(cli): keep interactive session live after cancel (#10903)
* fix(cli): keep interactive session live after cancel

* fix(sdk): use shared finish reason type
2026-05-19 13:44:59 -07:00
Renee Huang 3f808b369e sdk: add ClineCore CLI agent example (#10895)
* add a separate CLI agent using ClineCore, in comparison to the one built using Agent

* update SDK lockfile for ClineCore CLI example
2026-05-19 13:22:04 -07:00
Robin Newhouse e3b3e2306e fix(cli): accept dash-prefixed prompts after separator (#10905) 2026-05-19 12:47:07 -07:00
Tomás Barreiro b601b6e623 Update diff to 8.0.4 (#10904)
* Update diff to 8.0.4

* Remove types diff
2026-05-19 21:30:18 +02:00
Robin Newhouse ed008bd36e Route GLM thinking via provider metadata ENG-2019 (#10692)
* Route GLM thinking via provider metadata

* Address GLM provider routing review feedback
2026-05-19 12:12:51 -07:00
Saoud Rizwan 6e964c3ef0 chore(cli): release v3.0.8 2026-05-19 10:39:16 -07:00
Max 2e8579ebec bump version and update changelog (#10898)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-05-19 09:54:36 -07:00
Ara cb0515d5f4 fix(cli): use Telegram numeric participant ids (#10879) 2026-05-19 08:16:38 -07:00
Bee 8c00eb9a77 fix(cli): keep failed plugins visible in config UI ENG-2073 (#10878)
* fix(cli): keep failed plugins visible in config UI ENG-2073

Preserve plugins that fail during load or setup and attach their
diagnostic phase and error message. Surface those errors in config detail
rows so users can identify and fix broken plugin definitions.

* fix(cli): preserve multiple plugin initialization failures

Aggregate all setup/load failures per plugin path instead of overwriting earlier errors. Summarize multiple errors in the config UI so rows remain readable while retaining the full failure details.
2026-05-18 22:06:57 -07:00
Saoud Rizwan 1a4122eb53 fix(cli): avoid option right fork shortcut (#10883) 2026-05-18 19:06:22 -07:00
Ara e6fa037c4c Add real PR dashboard to code review bot example (#10812)
* add real PR dashboard to code review bot example

* docs: add code review bot dashboard screenshot
2026-05-18 17:49:24 -07:00
Ara 6e0ae8db58 Add Hub Monitor UI to menubar example (#10688)
* add menubar hub monitor dashboard

* docs: add menubar preview screenshot
2026-05-18 17:44:32 -07:00
Bee 0b1fe6a982 fix: remove demo runner from weather metrics example (#10875)
Remove the embedded runDemo entry point, ClineCore import, and runDemo export so the weather metrics example behaves as a plugin-only module.
2026-05-18 16:39:13 -07:00
Tomás Barreiro c18eef5d4c Remove unused stuff on sdk (#10869)
* Clean-up root files

* Clean vscode duplicated stuff

* Remove duplicated license

* Remove duplicated tailwind recommendation

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-05-19 01:36:34 +02:00
Saoud Rizwan 65443a01ae fix(sdk): stage readme during publish (#10870)
* fix(sdk): publish readme with sdk package

* fix(sdk): use absolute readme links

* fix(sdk): stage readme during publish
2026-05-18 16:22:08 -07:00
Robin Newhouse 7d56dd1e69 refactor(sdk): remove unused codex model list helper (#10874) 2026-05-18 16:21:45 -07:00
Saoud Rizwan 501f512066 chore: remove Kanban launch modal (#10864)
* chore: remove Kanban launch modal

* chore: remove unused Kanban state key

* test: tolerate missing post-login modal
2026-05-18 16:19:52 -07:00
John Simone 5810a5d1e7 Fix AWS Bedrock region and auth configuration in CLI (#10807)
* fix AWS region and profile detection logic

* add additional bedrock config fields in provider config screens

* address greptile feedback

* remove .cline directory

* Fix Bedrock catalog and AWS region handling

---------

Co-authored-by: Robin Newhouse <robin@cline.bot>
2026-05-18 15:56:08 -07:00
Ara 6a262b395a Polish multi-agent example war room demo (#10690)
* polish multi-agent war room demo

* docs: add multi-agent war room screenshot
2026-05-18 15:45:09 -07:00
Robin Newhouse 5117b7b0b8 docs: clarify SDK agent test workflow (#10860) 2026-05-18 15:35:31 -07:00
Tomás Barreiro 30ce8b1e72 Clean-up package-lock (#10872) 2026-05-18 23:43:30 +02:00
Tomás Barreiro 2fcac58e24 Extract GH variables as env variables during workflows (#10862)
* Extract GH variables as env variables during workflows

* Update other variables
2026-05-18 13:41:34 -07:00
Saoud Rizwan 112dbaa15b ci(cli): align release changelog formatting (#10863) 2026-05-18 13:17:51 -07:00
Saoud Rizwan 996a2f6a9c fix(agents): reset usage at start of each execute() call (#10713)
AgentRuntime.execute() reset iteration, pendingToolCalls, and lastError
between run/continue calls but not usage. This caused result.usage to
accumulate across calls, and local-runtime-host then added the
already-cumulative result onto the session baseline, double-counting
every prior turn's tokens on each new turn.
2026-05-18 12:50:10 -07:00
Renee Huang 47f6d00f61 docs: add back memorybank; correct CLI install commands (#10830)
* add back memorybank; correct CLI install commands

* add more details on plugin

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-05-18 12:02:04 -07:00
Mikołaj Kondratek 9ba2c932b4 fix(mcp): disable Restart Server button when server is toggled off (#10852)
* fix(mcp): disable Restart Server button when server is toggled off

The expanded 'Restart Server' button in the MCP server row stayed clickable even when the server's toggle was off, but the backing restartMcpServer call is a no-op for disabled servers, so the click did nothing.

Bring the expanded button into parity with the collapsed icon-button (line 227 in the same file), which already gates on server.disabled. No state, label, or styling change beyond the disabled predicate.

Fixes cline/intellij-plugin#559.

* Update webview-ui/src/components/mcp/configuration/tabs/installed/server-row/ServerRow.tsx

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

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-05-18 18:55:47 +02:00
Robin Newhouse 01aaa5dd04 fix(e2e): harden VS Code teardown (#10796)
ENG-2064
2026-05-18 09:31:31 -07:00
Mikołaj Kondratek 38bac25721 fix(cli): satisfy useExhaustiveDependencies in ConfigPanelContent (#10850)
Destructure `loadConfigData` from `props` alongside the other props already
destructured at the top of `ConfigPanelContent`, and use the local binding in
the plugin-tools `useEffect` (both inside the body and in the dependency
array). This clears two Biome `lint/correctness/useExhaustiveDependencies`
errors introduced by #10819:

  sdk/apps/cli/src/tui/views/config-view.tsx:265:2
    × This hook specifies a dependency more specific than its captures:
      props.loadConfigData
    × This hook does not specify its dependency on props.

The previous shape mixed `props.loadConfigData` in the dep array with `props`
captures inside the effect body, which Biome flags because it cannot prove
that re-reading `props.loadConfigData` between renders is equivalent to
re-reading `props`. Destructuring once at the top resolves both diagnostics
without broadening the dep set to all of `props` (the rule's suggested
"unsafe fix") — the effect still only refires when `loadConfigData` itself
changes, matching the original intent.

No behavior change. The destructure pattern matches how `resolve`, `dismiss`,
`dialogId`, and `config` are already pulled off `props` on the same line.

The failure was first surfaced by the JetBrains-plugin integration workflow
on PR #10819, where `cline/intellij-plugin`'s `:buildClineCoreZip` task runs
`npm run lint` against the merged tree. Verified locally with:

    npx @biomejs/biome lint --diagnostic-level=error \
        sdk/apps/cli/src/tui/views/config-view.tsx

— clean, exit 0. Full repo `biome lint`: 2537 files, no errors.
2026-05-18 17:05:00 +02:00
Saoud Rizwan b4d1b83bad chore(cli): release v3.0.7 2026-05-17 17:58:53 -07:00
Saoud Rizwan 8e6780201c fix(sdk): align ChatGPT OAuth model catalog (#10841) 2026-05-17 17:56:03 -07:00
Saoud Rizwan 087c6449c3 fix(cli): avoid ChatGPT model refresh on startup (#10839)
* fix(cli): avoid ChatGPT model refresh on startup

* fix(cli): remove dead ChatGPT private model merge
2026-05-17 17:09:02 -07:00
Saoud Rizwan 5cd72e5892 chore(cli): release v3.0.6 2026-05-17 16:29:39 -07:00
Saoud Rizwan 2b2fa8a473 fix(sdk): fix ChatGPT provider models (#10836)
* fix(sdk): restore ChatGPT subscription models

* fix(sdk): tighten ChatGPT provider model tests
2026-05-17 16:26:38 -07:00
Saoud Rizwan acca9186f1 docs: clarify plugin SDK peer dependency (#10823) 2026-05-16 15:44:13 -07:00
Saoud Rizwan f3bddc4ec1 chore(cli): release v3.0.5 2026-05-16 15:28:24 -07:00
Saoud Rizwan 5c9cd557c7 fix(cli): hydrate plugin tools and commands in settings (#10819)
* fix(cli): list settings plugin tools through sandbox

* fix(cli): hydrate plugin slash commands asynchronously

* fix(cli): preserve config reload options on toggle

* fix(cli): preserve hydrated plugin tools on settings toggles
2026-05-16 15:16:33 -07:00
Saoud Rizwan 0383be5375 chore(cli): release v3.0.4 2026-05-16 11:59:08 -07:00
Saoud Rizwan 4ee1ce507e fix(cli): improve light theme TUI colors (#10817)
* fix(cli): improve light theme TUI colors

* fix(cli): address light theme review feedback
2026-05-16 11:54:27 -07:00
Saoud Rizwan 22b3d654db fix(cli): fix plugin tools in production build (#10809)
* fix(cli): ship sdk deps for plugins

* fix(core): load plugin sdk imports through jiti virtual modules

* fix(core): avoid transforming compiled plugin sdk deps

* test(core): cover packaged plugin sandbox bootstrap

* fix(cli): preflight sdk deps before wrapper publish

* test(core): keep plugin tla test lightweight
2026-05-16 10:27:45 -07:00
Saoud Rizwan 52493c5ba1 ci: add path filtering to root workflows (#10777)
* ci: add path filters to root workflows

* ci: simplify qlty workflow condition

* ci: run root workflows on main pushes

* ci: update path filters for renamed workflows

* ci: include missing workflow filter inputs

* fix(ci): include scripts in testing platform filter

* fix(ci): preserve required test gate
2026-05-15 13:19:56 -07:00
Saoud Rizwan e65e5d5900 chore: rename GitHub Actions workflows (#10793) 2026-05-15 11:58:45 -07:00
tjandy98 b144baf102 Add support for SAP AI Core Hosted LLMs to SAP AI Core Provider (#10751)
* add hosted llms

* Update sapaicore.ts

* Update sapaicore.ts

* Update sapaicore.ts

* Update sapaicore.ts
2026-05-15 02:26:34 -07:00
Renee Huang f70725dd57 nit: small improvements to readme (#10779) 2026-05-14 23:04:09 -07:00
Saoud Rizwan 3821f7e52e Add CHANGELOG links to product index in README 2026-05-14 16:59:58 -07:00
333 changed files with 21330 additions and 7616 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: use correct base URL for Vertex AI global endpoint with Claude models
+1 -1
View File
@@ -176,7 +176,7 @@ Present a final summary:
- Slack message copied to clipboard: yes
Remind the user to:
1. Manually trigger the publish release GitHub Action at: https://github.com/cline/cline/actions/workflows/publish.yml (paste `v{VERSION}` as the tag)
1. Manually trigger the publish release GitHub Action at: https://github.com/cline/cline/actions/workflows/ext-vscode-publish-stable.yml (paste `v{VERSION}` as the tag)
2. Post the Slack message to announce the hotfix
## Important Notes
+1 -1
View File
@@ -43,7 +43,7 @@ git push origin v<version>
### 4) Trigger publish workflow
Tell the maintainer to run:
https://github.com/cline/cline/actions/workflows/publish.yml
https://github.com/cline/cline/actions/workflows/ext-vscode-publish-stable.yml
Use `v<version>` as the release tag.
-2
View File
@@ -1,6 +1,4 @@
demo.gif filter=lfs diff=lfs merge=lfs -text
assets/docs/demo.gif filter=lfs diff=lfs merge=lfs -text
webview-ui/src/assets/cline_kanban_demo.mp4 filter=lfs diff=lfs merge=lfs -text
webview-ui/src/assets/cline_kanban_demo.webm filter=lfs diff=lfs merge=lfs -text
* text=auto eol=lf
@@ -1,4 +1,4 @@
name: Publish CLI to NPM
name: cli-publish
on:
schedule:
@@ -88,9 +88,9 @@ jobs:
- name: Validate release tag
id: version
env:
TAG: ${{ github.event.inputs.git_tag }}
run: |
TAG="${{ github.event.inputs.git_tag }}"
if [ -z "$TAG" ]; then
echo "git_tag is required when publish_target=main"
exit 1
@@ -132,6 +132,15 @@ jobs:
- name: Build SDK packages
run: bun run build:sdk
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Run tests
run: bun run test
@@ -139,10 +148,20 @@ jobs:
- name: Build platform binaries
run: bun script/build.ts --install-native-variants --skip-sdk-build
working-directory: sdk/apps/cli
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Verify build output
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
VERSION="${{ steps.version.outputs.version }}"
EXPECTED=(
"@cline/cli-darwin-arm64"
"@cline/cli-darwin-x64"
@@ -179,8 +198,9 @@ jobs:
- name: Get Previous CLI Tag
id: prev_tag
env:
CURRENT_TAG: ${{ steps.version.outputs.tag }}
run: |
CURRENT_TAG="${{ steps.version.outputs.tag }}"
PREV_TAG=$(git describe --tags --abbrev=0 --match 'cli-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
@@ -200,13 +220,15 @@ jobs:
name: "CLI v${{ steps.version.outputs.version }}"
body: |
${{ steps.changelog.outputs.content }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.tag) || '' }}
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.tag) || '' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Summary
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Published cline@${VERSION} to npm with dist-tag 'latest'"
echo "Install with: npm install -g cline"
@@ -309,6 +331,15 @@ jobs:
- name: Build SDK packages
if: steps.check_commits.outputs.skip != 'true'
run: bun run build:sdk
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Run tests
if: steps.check_commits.outputs.skip != 'true'
@@ -329,8 +360,9 @@ jobs:
- name: Update nightly package version
if: steps.check_commits.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
VERSION="${{ steps.version.outputs.version }}"
node -e '
const fs = require("node:fs");
const path = "apps/cli/package.json";
@@ -339,18 +371,26 @@ jobs:
fs.writeFileSync(path, `${JSON.stringify(pkg, null, "\t")}\n`);
'
cat apps/cli/package.json | grep '"version"'
env:
VERSION: ${{ steps.version.outputs.version }}
- 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
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
- name: Verify build output
if: steps.check_commits.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
VERSION="${{ steps.version.outputs.version }}"
EXPECTED=(
"@cline/cli-darwin-arm64"
"@cline/cli-darwin-x64"
@@ -388,7 +428,8 @@ jobs:
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Published cline@${VERSION} to npm with dist-tag 'nightly'"
echo "Install with: npm install -g cline@nightly"
@@ -1,4 +1,4 @@
name: Trigger Jetbrains Plugin <-> Cline Tests
name: ext-jb-test-integration
on:
pull_request_target:
types: [opened, reopened]
@@ -1,4 +1,7 @@
name: "Publish New SDK Extension Nightly"
# TODO: Fold this workflow's SDK login changes into ext-vscode-publish-nightly.yml
# and delete this file. Pinned to dpc/sdk-migration-simpler-login while Max is iterating.
# Owner: Max Paulus
name: ext-vscode-publish-nightly-sdk
on:
schedule:
@@ -46,15 +49,6 @@ jobs:
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Verify LFS media assets are resolved
run: |
for FILE in webview-ui/src/assets/cline_kanban_demo.mp4 webview-ui/src/assets/cline_kanban_demo.webm; do
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
exit 1
fi
done
- name: Publish SDK nightly extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
@@ -1,4 +1,4 @@
name: "Publish Nightly Release"
name: ext-vscode-publish-nightly
on:
workflow_dispatch:
@@ -10,7 +10,7 @@ run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
# runs on the same ref can collide on the same version and cause publish failures
# or inconsistent tagging. Runs on different branches proceed independently.
concurrency:
group: publish-nightly-${{ github.ref }}
group: ext-vscode-publish-nightly-${{ github.ref }}
cancel-in-progress: false
permissions: {}
@@ -20,7 +20,7 @@ jobs:
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
permissions:
contents: read
uses: ./.github/workflows/test.yml
uses: ./.github/workflows/ext-vscode-test.yml
publish:
needs: test
@@ -61,15 +61,6 @@ jobs:
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Verify LFS media assets are resolved
run: |
for FILE in webview-ui/src/assets/cline_kanban_demo.mp4 webview-ui/src/assets/cline_kanban_demo.webm; do
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
exit 1
fi
done
- name: Publish Nightly Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
@@ -1,4 +1,4 @@
name: "Publish Release"
name: ext-vscode-publish-stable
on:
workflow_dispatch:
@@ -29,7 +29,7 @@ permissions:
jobs:
test:
uses: ./.github/workflows/test.yml
uses: ./.github/workflows/ext-vscode-test.yml
publish:
needs: test
@@ -135,15 +135,6 @@ jobs:
fi
echo "Tag and package version match: $TAG"
- name: Verify LFS media assets are resolved
run: |
for FILE in webview-ui/src/assets/cline_kanban_demo.mp4 webview-ui/src/assets/cline_kanban_demo.webm; do
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
exit 1
fi
done
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
@@ -1,4 +1,4 @@
name: E2E Tests
name: ext-vscode-test-e2e
on:
push:
@@ -12,8 +12,53 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: read
jobs:
detect-changes:
runs-on: ubuntu-latest
name: Detect Changes
outputs:
e2e: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.e2e == 'true' }}
steps:
- id: force
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
run: echo "run_all=true" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
if: steps.force.outputs.run_all != 'true'
- uses: dorny/paths-filter@v3
if: steps.force.outputs.run_all != 'true'
id: filter
with:
filters: |
e2e:
- 'src/**'
- 'webview-ui/**'
- 'proto/**'
- 'tests/**'
- 'scripts/**'
- 'standalone/**'
- 'assets/**'
- 'walkthrough/**'
- 'package.json'
- 'package-lock.json'
- 'buf.yaml'
- 'tsconfig*.json'
- 'biome.jsonc'
- 'esbuild.mjs'
- '.mocharc.json'
- '.vscode-test.mjs'
- '.vscodeignore'
- 'playwright*.ts'
- '.github/workflows/ext-vscode-test-e2e.yml'
matrix_prep:
needs: detect-changes
if: needs.detect-changes.outputs.e2e == 'true'
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
@@ -23,7 +68,8 @@ jobs:
echo 'matrix=[{"runner":"ubuntu"},{"runner":"windows"},{"runner":"macos"}]' >> $GITHUB_OUTPUT
e2e:
needs: matrix_prep
needs: [detect-changes, matrix_prep]
if: needs.detect-changes.outputs.e2e == 'true'
strategy:
fail-fast: false
matrix:
@@ -1,4 +1,4 @@
name: Tests
name: ext-vscode-test
on:
push:
@@ -13,9 +13,66 @@ on:
# Set default permissions for all jobs
permissions:
contents: read # Needed to check out code
pull-requests: read # Needed for changed-file detection on pull requests
jobs:
detect-changes:
runs-on: ubuntu-latest
name: Detect Changes
outputs:
vscode: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.vscode == 'true' }}
testing_platform: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.testing_platform == 'true' }}
steps:
- id: force
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call'
run: echo "run_all=true" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v4
if: steps.force.outputs.run_all != 'true'
- uses: dorny/paths-filter@v3
if: steps.force.outputs.run_all != 'true'
id: filter
with:
filters: |
vscode:
- 'src/**'
- 'webview-ui/**'
- 'proto/**'
- 'tests/**'
- 'scripts/**'
- 'standalone/**'
- 'assets/**'
- 'walkthrough/**'
- 'package.json'
- 'package-lock.json'
- 'buf.yaml'
- 'tsconfig*.json'
- 'biome.jsonc'
- 'esbuild.mjs'
- '.mocharc.json'
- '.nycrc*.json'
- '.vscode-test.mjs'
- 'test-setup.js'
- '.github/workflows/ext-vscode-test.yml'
testing_platform:
- 'src/**'
- 'proto/**'
- 'standalone/**'
- 'testing-platform/**'
- 'tests/specs/**'
- 'package.json'
- 'package-lock.json'
- 'buf.yaml'
- 'tsconfig*.json'
- 'esbuild.mjs'
- '.vscodeignore'
- 'scripts/**'
- '.github/workflows/ext-vscode-test.yml'
quality-checks:
needs: detect-changes
if: needs.detect-changes.outputs.vscode == 'true' || needs.detect-changes.outputs.testing_platform == 'true'
runs-on: ubuntu-latest
name: Quality Checks
steps:
@@ -42,8 +99,9 @@ jobs:
- name: Run Quality Checks (Parallel)
run: npm run ci:check-all
test:
needs: quality-checks
vscode-test:
needs: [detect-changes, quality-checks]
if: needs.detect-changes.outputs.vscode == 'true'
env:
VSCODE_TEST_VERSION: 1.103.0
strategy:
@@ -51,7 +109,7 @@ jobs:
matrix:
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
name: ${{ matrix.os == 'ubuntu-latest' && 'test' || format('test ({0})', matrix.os) }}
name: ${{ matrix.os == 'ubuntu-latest' && 'vscode test' || format('vscode test ({0})', matrix.os) }}
defaults:
run:
shell: bash
@@ -147,7 +205,8 @@ jobs:
webview-ui/coverage/lcov.info
test-platform-integration:
needs: quality-checks
needs: [detect-changes, quality-checks]
if: needs.detect-changes.outputs.testing_platform == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout code
@@ -190,8 +249,55 @@ jobs:
name: test-platform-integration-core-coverage
path: coverage/**/lcov.info
# Keep the required "test" check as a tiny aggregate gate instead of the conditional
# VS Code matrix. GitHub treats conditionally skipped jobs as successful required
# checks, so the gate below preserves the old required check name while making sure
# whichever filtered test jobs were selected actually passed.
test:
needs: [detect-changes, quality-checks, vscode-test, test-platform-integration]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
name: test
steps:
- name: Verify selected test jobs
env:
DETECT_CHANGES_RESULT: ${{ needs.detect-changes.result }}
QUALITY_CHECKS_RESULT: ${{ needs.quality-checks.result }}
VSCODE_CHANGED: ${{ needs.detect-changes.outputs.vscode }}
TESTING_PLATFORM_CHANGED: ${{ needs.detect-changes.outputs.testing_platform }}
VSCODE_TEST_RESULT: ${{ needs.vscode-test.result }}
TEST_PLATFORM_RESULT: ${{ needs.test-platform-integration.result }}
run: |
if [ "$DETECT_CHANGES_RESULT" != "success" ]; then
echo "detect-changes did not succeed: $DETECT_CHANGES_RESULT"
exit 1
fi
if [ "$VSCODE_CHANGED" != "true" ] && [ "$TESTING_PLATFORM_CHANGED" != "true" ]; then
echo "No root test paths changed; skipping root test requirements."
exit 0
fi
if [ "$QUALITY_CHECKS_RESULT" != "success" ]; then
echo "quality-checks did not succeed: $QUALITY_CHECKS_RESULT"
exit 1
fi
if [ "$VSCODE_CHANGED" = "true" ] && [ "$VSCODE_TEST_RESULT" != "success" ]; then
echo "vscode-test did not succeed: $VSCODE_TEST_RESULT"
exit 1
fi
if [ "$TESTING_PLATFORM_CHANGED" = "true" ] && [ "$TEST_PLATFORM_RESULT" != "success" ]; then
echo "test-platform-integration did not succeed: $TEST_PLATFORM_RESULT"
exit 1
fi
echo "Selected root test jobs passed."
qlty:
needs: [test, test-platform-integration]
needs: [detect-changes, quality-checks, vscode-test, test-platform-integration]
if: ${{ !cancelled() && needs.quality-checks.result == 'success' && (needs.vscode-test.result == 'success' || needs.vscode-test.result == 'skipped') && (needs.test-platform-integration.result == 'success' || needs.test-platform-integration.result == 'skipped') && (needs.detect-changes.outputs.vscode == 'true' || needs.detect-changes.outputs.testing_platform == 'true') }}
runs-on: ubuntu-latest
# Run on PRs to main, pushes to main, and manual dispatches
steps:
@@ -199,12 +305,14 @@ jobs:
uses: actions/checkout@v4
- name: Download unit tests coverage reports
if: needs.detect-changes.outputs.vscode == 'true'
uses: actions/download-artifact@v4
with:
name: pr-coverage-reports
path: .
- name: Upload core unit tests coverage to Qlty
if: needs.detect-changes.outputs.vscode == 'true'
uses: qltysh/qlty-action/coverage@v2
with:
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
@@ -214,6 +322,7 @@ jobs:
tag: unit:core
- name: Upload webview-ui unit tests coverage to Qlty
if: needs.detect-changes.outputs.vscode == 'true'
uses: qltysh/qlty-action/coverage@v2
with:
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
@@ -224,6 +333,7 @@ jobs:
add-prefix: webview-ui/
- name: Download test platform integration core coverage artifact
if: needs.detect-changes.outputs.testing_platform == 'true'
uses: actions/download-artifact@v4
continue-on-error: true
id: download-integration-coverage
@@ -232,7 +342,7 @@ jobs:
path: integration-core-coverage-reports
- name: Upload core integration tests coverage to Qlty
if: steps.download-integration-coverage.outcome == 'success'
if: needs.detect-changes.outputs.testing_platform == 'true' && steps.download-integration-coverage.outcome == 'success'
uses: qltysh/qlty-action/coverage@v2
with:
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
@@ -1,4 +1,4 @@
name: Auto-label Issues
name: repo-label-issues
on:
issues:
@@ -15,7 +15,7 @@ jobs:
script: |
const body = context.payload.issue.body || '';
const labels = context.payload.issue.labels.map(l => l.name);
// Check if JetBrains Plugin is selected
if (body.match(/###\s*Plugin Type\s*\n+JetBrains Plugin/i)) {
if (!labels.includes('JetBrains')) {
@@ -1,6 +1,6 @@
# This workflow will only label and/or close 30 issues at a time in order to avoid exceeding a rate limit.
# More info: https://docs.github.com/en/actions/use-cases-and-examples/project-management/closing-inactive-issues
name: Close inactive issues
name: repo-stale-issues
on:
schedule:
- cron: "30 1 * * *"
@@ -1,4 +1,4 @@
name: Publish Main SDK Packages
name: sdk-publish
on:
workflow_dispatch:
@@ -59,19 +59,23 @@ jobs:
- name: Determine publish channel
id: channel
env:
EVENT_NAME: ${{ github.event_name }}
INPUT_CHANNEL: ${{ inputs.channel }}
run: |
# Default to nightly for scheduled runs
if [ "${{ github.event_name }}" = "schedule" ]; then
if [ "$EVENT_NAME" = "schedule" ]; then
echo "channel=nightly" >> $GITHUB_OUTPUT
else
echo "channel=${{ inputs.channel }}" >> $GITHUB_OUTPUT
echo "channel=$INPUT_CHANNEL" >> $GITHUB_OUTPUT
fi
- name: Check for recent commits
id: check_commits
env:
CHANNEL: ${{ steps.channel.outputs.channel }}
FORCE_PUBLISH: ${{ inputs.force_publish }}
run: |
CHANNEL="${{ steps.channel.outputs.channel }}"
# Always publish for latest (production) releases
if [ "$CHANNEL" = "latest" ]; then
echo "Production release requested, proceeding with publish"
@@ -79,7 +83,7 @@ jobs:
exit 0
fi
if [ "${{ inputs.force_publish }}" = "true" ]; then
if [ "$FORCE_PUBLISH" = "true" ]; then
echo "force_publish enabled, proceeding with publish"
echo "skip=false" >> $GITHUB_OUTPUT
exit 0
@@ -141,8 +145,9 @@ jobs:
- name: Generate shared version
if: steps.check_commits.outputs.skip != 'true'
id: version
env:
CHANNEL: ${{ steps.channel.outputs.channel }}
run: |
CHANNEL="${{ steps.channel.outputs.channel }}"
BASE_VERSION=$(node -p "require('./packages/llms/package.json').version")
if [ "$CHANNEL" = "nightly" ]; then
@@ -159,7 +164,9 @@ jobs:
- name: Update all package versions and lockfile
if: steps.check_commits.outputs.skip != 'true'
run: bun scripts/version.ts "${{ steps.version.outputs.version }}"
env:
VERSION: ${{ steps.version.outputs.version }}
run: bun scripts/version.ts "$VERSION"
- name: Verify publishability
if: steps.check_commits.outputs.skip != 'true'
@@ -176,9 +183,10 @@ jobs:
if: steps.check_commits.outputs.skip != 'true'
env:
NPM_CONFIG_PROVENANCE: "true"
CHANNEL: ${{ steps.channel.outputs.channel }}
VERSION: ${{ steps.version.outputs.version }}
run: |
CHANNEL="${{ steps.channel.outputs.channel }}"
echo "Publishing @cline/shared@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
echo "Publishing @cline/shared@${VERSION} with tag '${CHANNEL}'..."
cd packages/shared
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -187,9 +195,10 @@ jobs:
if: steps.check_commits.outputs.skip != 'true'
env:
NPM_CONFIG_PROVENANCE: "true"
CHANNEL: ${{ steps.channel.outputs.channel }}
VERSION: ${{ steps.version.outputs.version }}
run: |
CHANNEL="${{ steps.channel.outputs.channel }}"
echo "Publishing @cline/llms@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
echo "Publishing @cline/llms@${VERSION} with tag '${CHANNEL}'..."
cd packages/llms
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -198,9 +207,10 @@ jobs:
if: steps.check_commits.outputs.skip != 'true'
env:
NPM_CONFIG_PROVENANCE: "true"
CHANNEL: ${{ steps.channel.outputs.channel }}
VERSION: ${{ steps.version.outputs.version }}
run: |
CHANNEL="${{ steps.channel.outputs.channel }}"
echo "Publishing @cline/agents@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
echo "Publishing @cline/agents@${VERSION} with tag '${CHANNEL}'..."
cd packages/agents
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -209,9 +219,10 @@ jobs:
if: steps.check_commits.outputs.skip != 'true'
env:
NPM_CONFIG_PROVENANCE: "true"
CHANNEL: ${{ steps.channel.outputs.channel }}
VERSION: ${{ steps.version.outputs.version }}
run: |
CHANNEL="${{ steps.channel.outputs.channel }}"
echo "Publishing @cline/core@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
echo "Publishing @cline/core@${VERSION} with tag '${CHANNEL}'..."
cd packages/core
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
@@ -220,18 +231,19 @@ jobs:
if: steps.check_commits.outputs.skip != 'true'
env:
NPM_CONFIG_PROVENANCE: "true"
CHANNEL: ${{ steps.channel.outputs.channel }}
VERSION: ${{ steps.version.outputs.version }}
run: |
CHANNEL="${{ steps.channel.outputs.channel }}"
echo "Publishing @cline/sdk@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
echo "Publishing @cline/sdk@${VERSION} with tag '${CHANNEL}'..."
cd packages/sdk
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
- name: Create package tags for production publish
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
env:
VERSION: ${{ steps.version.outputs.version }}
run: |
VERSION="${{ steps.version.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
@@ -250,9 +262,10 @@ jobs:
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
CHANNEL: ${{ steps.channel.outputs.channel }}
run: |
VERSION="${{ steps.version.outputs.version }}"
CHANNEL="${{ steps.channel.outputs.channel }}"
echo "Published SDK packages with tag '${CHANNEL}':"
echo " - @cline/shared@${VERSION}"
echo " - @cline/llms@${VERSION}"
+1 -1
View File
@@ -1,4 +1,4 @@
name: SDK Tests
name: sdk-test
on:
push:
-32
View File
@@ -1,32 +0,0 @@
name: Test Stale Issues Workflow
on:
workflow_dispatch:
inputs:
days-before-stale:
description: "Days before an issue becomes stale"
required: true
default: "1"
days-before-close:
description: "Days before a stale issue is closed"
required: true
default: "1"
jobs:
test-stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@28ca103
with:
days-before-issue-stale: ${{ github.event.inputs.days-before-stale }}
days-before-issue-close: ${{ github.event.inputs.days-before-close }}
stale-issue-label: "stale"
stale-issue-message: "This issue is stale because it has been open for ${{ github.event.inputs.days-before-stale }} days with no activity."
close-issue-message: "This issue was closed because it has been inactive for ${{ github.event.inputs.days-before-close }} days since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1
exempt-issue-labels: "pinned,security"
repo-token: ${{ secrets.GITHUB_TOKEN }}
debug-only: true
+10
View File
@@ -60,3 +60,13 @@ tests/**/cache
# Backup created by scripts/marketplace-readme.mjs while publishing.
# Should never be committed: only exists if a publish aborts mid-swap.
.README.github.bak
# SDK Session files / User data
.cline/data
.cline/tmp
*.db
*.db-shm
*.db-wal
.cline/**/managed.json
.cline/**/bundle.json
+1 -1
View File
@@ -1 +1 @@
lts/*
22
+2 -1
View File
@@ -1,2 +1,3 @@
node 22
bun 1.3.13
node 22
+2 -1
View File
@@ -5,6 +5,7 @@
"connor4312.esbuild-problem-matchers",
"ms-vscode.extension-test-runner",
"bradlc.vscode-tailwindcss",
"biomejs.biome"
"biomejs.biome",
"oven.bun-vscode"
]
}
+113
View File
@@ -199,6 +199,119 @@
"env": {
"IS_DEV": "true"
}
},
{
"name": "Launch Bun CLI (Prompt)",
"type": "bun",
"request": "launch",
"cwd": "${workspaceFolder}/sdk/apps/cli",
"runtime": "bun",
"runtimeArgs": [
"--conditions=development"
],
"program": "${workspaceFolder}/sdk/apps/cli/src/index.ts",
"args": [
"${input:cliPrompt}"
],
"env": {
"CLINE_BUILD_ENV": "development"
},
"presentation": {
"hidden": true
}
},
{
"name": "Launch RPC Server",
"type": "bun",
"request": "launch",
"cwd": "${workspaceFolder}/sdk/apps/cli",
"runtime": "bun",
"runtimeArgs": [
"--conditions=development"
],
"program": "${workspaceFolder}/sdk/apps/cli/src/index.ts",
"args": [
"rpc",
"start"
],
"env": {
"CLINE_BUILD_ENV": "development",
"CLINE_DEBUG_PORT_BASE": "9230"
},
"presentation": {
"hidden": true
}
},
{
"name": "Attach RPC Runtime (9230)",
"type": "bun",
"request": "attach",
"url": "ws://127.0.0.1:9230",
"localRoot": "${workspaceFolder}/sdk",
"remoteRoot": "${workspaceFolder}/sdk",
"presentation": {
"hidden": true
}
},
{
"name": "Attach Hook Worker (9231)",
"type": "bun",
"request": "attach",
"url": "ws://127.0.0.1:9231",
"localRoot": "${workspaceFolder}/sdk",
"remoteRoot": "${workspaceFolder}/sdk",
"presentation": {
"hidden": true
}
},
{
"name": "Attach Plugin Sandbox (9232)",
"type": "bun",
"request": "attach",
"url": "ws://127.0.0.1:9232",
"localRoot": "${workspaceFolder}/sdk",
"remoteRoot": "${workspaceFolder}/sdk",
"presentation": {
"hidden": true
}
},
{
"name": "Attach Connector Child (9233)",
"type": "bun",
"request": "attach",
"url": "ws://127.0.0.1:9233",
"localRoot": "${workspaceFolder}/sdk",
"remoteRoot": "${workspaceFolder}/sdk",
"presentation": {
"hidden": true
}
}
],
"compounds": [
{
"name": "Launch RPC Server Debugger",
"configurations": [
"Launch RPC Server",
"Attach RPC Runtime (9230)"
]
},
{
"name": "Launch CLI Debugger",
"configurations": [
"Launch Bun CLI (Prompt)",
"Attach RPC Runtime (9230)",
"Attach Hook Worker (9231)",
"Attach Plugin Sandbox (9232)",
"Attach Connector Child (9233)"
]
}
],
"inputs": [
{
"id": "cliPrompt",
"type": "promptString",
"description": "Prompt to send to the CLI",
"default": "hey"
}
]
}
+1
View File
@@ -1,5 +1,6 @@
// Place your settings in this file to overwrite default and user settings.
{
"files.insertFinalNewline": true,
"files.exclude": {
"out": false, // set this to true to hide the "out" folder with the compiled JS files
"dist": false // set this to true to hide the "dist" folder with the compiled JS files
+11
View File
@@ -283,6 +283,17 @@
"IS_DEV": "true"
}
}
},
{
"label": "build-sdk",
"type": "shell",
"command": "bun run build:sdk",
"problemMatcher": [
"$tsc"
],
"options": {
"cwd": "${workspaceFolder}/sdk"
}
}
],
"inputs": [
+14
View File
@@ -1,5 +1,19 @@
# Changelog
## [3.84.0]
### Added
- Add SAP AI Core support for additional hosted models
### Fixed
- Disable the MCP "Restart Server" button when a server is toggled off.
### Changed
- Remove the Cline Kanban launch modal and bundled demo media from the VS Code extension startup flow.
## [3.83.0]
### Fixed
+9 -9
View File
@@ -126,14 +126,14 @@ npm install @cline/sdk
## Index
| Product | Description | Location |
|---------|------------|--------------|
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) |
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`sdk/apps/cli/`](https://github.com/cline/cline/tree/main/sdk/apps/cli) |
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) |
| **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) |
| **Docs site** | Public documentation pages. | [`docs/`](https://docs.cline.bot/) |
| 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) |
| **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) |
| **Docs site** | Public documentation pages. | [`docs/`](https://docs.cline.bot/) | - |
## Edits Code Across Your Project
@@ -212,7 +212,7 @@ 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
cline connect telegram -m my_bot -k $BOT_TOKEN
cline connect telegram -k $BOT_TOKEN
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
```
+163
View File
@@ -0,0 +1,163 @@
---
title: "Memory Bank"
sidebarTitle: "Memory Bank"
description: "A structured documentation system that helps Cline maintain context across sessions."
---
Memory Bank is a documentation methodology that transforms Cline from a stateless assistant into a persistent development partner. Through structured markdown files, Cline can "remember" your project details across sessions.
## Quick Setup
1. Copy the [custom instructions below](#memory-bank-custom-instructions)
2. Add them to a [Cline Rules file](/customization/cline-rules), such as `.clinerules/memory-bank.md`
3. Ask Cline to "initialize memory bank"
## How It Works
Memory Bank files are regular markdown files in your project that both you and Cline can access. They're organized hierarchically to build a complete picture of your project:
```text
memory-bank/
├── projectbrief.md # Foundation document
├── productContext.md # Why this project exists
├── activeContext.md # Current work focus
├── systemPatterns.md # Architecture & patterns
├── techContext.md # Tech stack & setup
└── progress.md # Status & milestones
```
<Frame>
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/image%20(16).png" alt="Memory Bank file hierarchy showing projectbrief.md at the top flowing into productContext, systemPatterns, and techContext, which feed into activeContext and progress" />
</Frame>
## Core Files
| File | Purpose |
|------|---------|
| `projectbrief.md` | Foundation document with core requirements and goals |
| `productContext.md` | Why the project exists, problems it solves, UX goals |
| `activeContext.md` | Current focus, recent changes, next steps (updates most frequently) |
| `systemPatterns.md` | Architecture, design patterns, component relationships |
| `techContext.md` | Tech stack, setup, constraints, dependencies |
| `progress.md` | What works, what's left, known issues |
## Key Commands
- **"follow your custom instructions"** - Tells Cline to read Memory Bank and continue where you left off
- **"initialize memory bank"** - Creates the initial structure for a new project
- **"update memory bank"** - Triggers a full documentation review and update
These work alongside Cline's built-in [slash commands](/core-workflows/using-commands). In particular, [`/newtask`](/core-workflows/using-commands#newtask) and [`/smol`](/core-workflows/using-commands#smol) help you manage context windows without losing progress.
## Managing Context Windows
Every AI model has a [context window](/core-workflows/task-management#context-window) that limits how much information it can process at once. As you work, this window fills with conversation history, file contents, and tool results. Memory Bank helps you preserve important knowledge when you need to free up space.
### Manual approach
When your context window fills up:
1. Ask Cline to "update memory bank" to document the current state
2. Start a new conversation
3. Ask Cline to "follow your custom instructions"
This preserves important context in your Memory Bank files before the window clears, letting you continue seamlessly in a fresh conversation.
## Best Practices
- Start with a basic project brief and let structure evolve
- Let Cline help create the initial structure
- `activeContext.md` changes most frequently; update it after each session
- `progress.md` tracks milestones; review it when resuming work
- Update after significant milestones or direction changes
- Use [Cline Rules](/customization/cline-rules) to store the Memory Bank instructions per project
---
## Memory Bank Custom Instructions
Copy this into a Cline Rules file (for example, `.clinerules/memory-bank.md`) or your global custom instructions:
```markdown
# Cline's Memory Bank
I am Cline, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional.
## Memory Bank Structure
The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy:
### Core Files (Required)
1. `projectbrief.md`
- Foundation document that shapes all other files
- Created at project start if it doesn't exist
- Defines core requirements and goals
- Source of truth for project scope
2. `productContext.md`
- Why this project exists
- Problems it solves
- How it should work
- User experience goals
3. `activeContext.md`
- Current work focus
- Recent changes
- Next steps
- Active decisions and considerations
- Important patterns and preferences
- Learnings and project insights
4. `systemPatterns.md`
- System architecture
- Key technical decisions
- Design patterns in use
- Component relationships
- Critical implementation paths
5. `techContext.md`
- Technologies used
- Development setup
- Technical constraints
- Dependencies
- Tool usage patterns
6. `progress.md`
- What works
- What's left to build
- Current status
- Known issues
- Evolution of project decisions
### Additional Context
Create additional files/folders within memory-bank/ when they help organize:
- Complex feature documentation
- Integration specifications
- API documentation
- Testing strategies
- Deployment procedures
## Documentation Updates
Memory Bank updates occur when:
1. Discovering new project patterns
2. After implementing significant changes
3. When user requests with **update memory bank** (MUST review ALL files)
4. When context needs clarification
REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy.
```
## FAQ
**Custom instructions or Cline Rules?**
Either works. Custom instructions apply globally across all projects. A [Cline Rules file](/customization/cline-rules) is project-specific and stored in your repo, which makes it easy to share with collaborators. You can also use [conditional rules](/customization/cline-rules#conditional-rules) to activate Memory Bank instructions only when working with `memory-bank/` files.
**How often should I update?**
After significant milestones or direction changes. For active development, every few sessions. You can also let [Auto Compact](/features/auto-compact) handle routine context management and reserve manual "update memory bank" for important checkpoints.
**Does this work with other AI tools?**
Yes. Memory Bank is a documentation methodology that works with any AI that can read docs. Commands may differ but the approach works across tools.
**Different from README files?**
Memory Bank provides structured, comprehensive documentation designed for AI context management, going beyond what a single README covers. It includes files for active context and progress tracking that change frequently, unlike a typical README.
+1 -1
View File
@@ -171,7 +171,7 @@ cat payload.json | cline hook
### `plugin`
Manage Cline plugins. Install plugins from npm, git repositories, or local paths. See [Plugins](/customization/plugins) for full details and the plugin manifest format.
Manage Cline plugins. Install plugins from file URLs, npm, git repositories, or local paths. See [Plugins](/customization/plugins) for full details and the plugin manifest format.
```bash
cline plugin install <source> # Install a plugin
+6 -4
View File
@@ -21,7 +21,7 @@ cline connect
| Platform | Direct Command | Required Credentials |
|----------|---------------|---------------------|
| Telegram | `cline connect telegram` | Bot username, bot token |
| Telegram | `cline connect telegram` | Bot token |
| Slack | `cline connect slack` | Bot token, signing secret, base URL |
| Discord | `cline connect discord` | Application ID, bot token, public key, base URL |
| Google Chat | `cline connect gchat` | Service account credentials JSON, base URL |
@@ -41,8 +41,10 @@ cline connect
<Step title="Start the connector">
```bash
cline connect telegram -m <BOT-USERNAME> -k <BOT-TOKEN>
cline connect telegram -k <BOT-TOKEN>
```
The connector discovers the bot username from the token. Use `--bot-username` only if you need to override it.
</Step>
<Step title="Chat with your bot">
@@ -63,7 +65,7 @@ By default, anyone who finds your bot can message it and it will execute tasks o
Replace `12345` with your actual Telegram user ID:
```bash
cline connect telegram -m <BOT-USERNAME> -k <BOT-TOKEN> \
cline connect telegram -k <BOT-TOKEN> \
--hook-command 'jq -r ".payload.actor.participantKey" | grep -q "telegram:id:12345" && echo "{\"action\":\"allow\"}" || echo "{\"action\":\"deny\",\"message\":\"unauthorized\"}"'
```
</Step>
@@ -147,7 +149,7 @@ Multiple connectors can run simultaneously. They all share the same hub:
```bash
# Terminal 1
cline connect telegram -m my_bot -k $TELEGRAM_TOKEN
cline connect telegram -k $TELEGRAM_TOKEN
# Terminal 2
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
+1 -1
View File
@@ -95,7 +95,7 @@ jobs:
- name: Install Cline CLI
if: steps.detect.outputs.hit == 'true'
run: npm install -g @cline/cli
run: npm install -g cline
- name: Configure Cline Authentication
if: steps.detect.outputs.hit == 'true'
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
cache: "npm"
- name: Install Cline CLI
run: npm install -g @cline/cli
run: npm install -g cline
- name: Configure Cline Authentication
# Replace 'anthropic' with your provider of choice (openai, openrouter, etc.)
+1 -1
View File
@@ -100,7 +100,7 @@ cline schedule create "Code health" \
Combine schedules with [connectors](/cli/connectors) to send results to messaging platforms:
```bash
cline connect telegram -m my_bot -k $BOT_TOKEN
cline connect telegram -k $BOT_TOKEN
cline schedule create "Morning briefing" \
--cron "0 8 * * *" \
+13 -4
View File
@@ -11,9 +11,17 @@ Plugins extend Cline with custom tools, lifecycle hooks, slash commands, and mor
## Installing Plugins via CLI
The `cline plugin install` command installs plugins from three source types:
The `cline plugin install` command installs plugins from four source types:
<Tabs>
<Tab title="File URL">
```bash
cline plugin install https://github.com/owner/repo/blob/main/plugins/my-plugin.ts
cline plugin install https://raw.githubusercontent.com/owner/repo/main/plugins/my-plugin.ts
```
File URLs install a single `.ts` or `.js` plugin file directly. GitHub `blob` and raw URLs are supported, and remote plugin file URLs must use `https://`.
</Tab>
<Tab title="Git Repository">
```bash
cline plugin install https://github.com/owner/repo.git
@@ -100,15 +108,15 @@ If no `cline.plugins` field is present, the installer falls back to auto-discove
### Host-Provided Dependencies
Dependencies under the `@cline/` scope (like `@cline/core`, `@cline/shared`) are provided by the host runtime. The installer automatically strips these from the plugin's dependency list before running `npm install`, so you should declare them as `peerDependencies`:
Dependencies under the `@cline/` scope are provided by the host runtime. The installer automatically strips these from the plugin's dependency list before running `npm install`, so declare any `@cline/*` package your plugin imports as an optional peer dependency. The host currently provides `@cline/sdk`, `@cline/core`, `@cline/agents`, `@cline/llms`, and `@cline/shared`.
```json
{
"peerDependencies": {
"@cline/core": "*"
"@cline/sdk": "*"
},
"peerDependenciesMeta": {
"@cline/core": {
"@cline/sdk": {
"optional": true
}
}
@@ -125,6 +133,7 @@ Plugins are stored in the `plugins` directory at two levels:
_installed/ # Managed by `cline plugin install`
npm/ # npm-sourced plugins
git/ # git-sourced plugins
remote/ # file URL-sourced plugins
local/ # local-sourced plugins
.cline/ # Project root
+9 -3
View File
@@ -155,6 +155,12 @@
"features/subagents"
]
},
{
"group": "Best Practices",
"pages": [
"best-practices/memory-bank"
]
},
{
"group": "IDE Specific Features",
"pages": [
@@ -430,11 +436,11 @@
},
{
"source": "/prompting/cline-memory-bank",
"destination": "/core-workflows/task-management"
"destination": "/best-practices/memory-bank"
},
{
"source": "/customization/memory-bank",
"destination": "/core-workflows/task-management"
"destination": "/best-practices/memory-bank"
},
{
"source": "/customization/overview",
@@ -650,7 +656,7 @@
},
{
"source": "/features/memory-bank",
"destination": "/core-workflows/task-management"
"destination": "/best-practices/memory-bank"
},
{
"source": "/features/focus-chain",
+1 -1
View File
@@ -134,7 +134,7 @@ cline "Work on feature X"
# Terminal 2: attach to the same session from VS Code or another CLI
# Terminal 3: Telegram connector
cline connect telegram -m my_bot -k $TOKEN
cline connect telegram -k $TOKEN
# All three share the same hub and can access the same sessions
```
+1 -1
View File
@@ -134,7 +134,7 @@ Combine scheduled agents with [connectors](/cli/connectors) in CLI to route resu
```bash
# Start a Telegram connector
cline connect telegram -m my_bot -k $BOT_TOKEN
cline connect telegram -k $BOT_TOKEN
# Schedule an agent whose output gets sent to Telegram
cline schedule create "Morning briefing" \
+16 -4
View File
@@ -251,19 +251,31 @@ await cline.start({
## Distributing via CLI Install
To make your plugin installable with `cline plugin install`, add a `cline` field to your `package.json` that declares entry points and list `@cline/` imports as peer dependencies (the host runtime provides them):
Single-file plugins can be installed directly from a file URL:
```bash
cline plugin install https://github.com/your-org/your-repo/blob/main/plugins/github-plugin.ts
```
For package or repository distribution, add a `cline` field to your `package.json` that declares entry points. Dependencies under the `@cline/` scope are provided by the host runtime. The installer automatically strips these from the plugin's dependency list before running `npm install`, so declare any `@cline/*` package your plugin imports as an optional peer dependency:
```json
{
"cline": {
"plugins": [{ "paths": ["./github-plugin.ts"], "capabilities": ["tools", "hooks"] }]
},
"peerDependencies": { "@cline/core": "*" },
"peerDependenciesMeta": { "@cline/core": { "optional": true } }
"peerDependencies": {
"@cline/sdk": "*"
},
"peerDependenciesMeta": {
"@cline/sdk": {
"optional": true
}
}
}
```
Users can then install from git, npm, or a local path:
Users can also install package plugins from git, npm, or a local path:
```bash
cline plugin install https://github.com/your-org/cline-github-plugin.git
+6 -9
View File
@@ -1,10 +1,10 @@
---
title: "Plugin Examples"
sidebarTitle: "Plugin Examples"
description: "Explore ready-to-run plugin examples from the Cline SDK repository."
description: "Explore installable plugin examples from the Cline SDK repository."
---
The [SDK repository](https://github.com/cline/cline/tree/main/sdk) includes ready-to-run plugin examples under [`examples/plugins/`](https://github.com/cline/cline/tree/main/sdk/examples/plugins). Use them as starting points for tools, lifecycle hooks, message rewriting, policy enforcement, background jobs, and multi-agent workflows.
The [SDK repository](https://github.com/cline/cline/tree/main/sdk) includes installable plugin examples under [`examples/plugins/`](https://github.com/cline/cline/tree/main/sdk/examples/plugins). Use them as starting points for tools, lifecycle hooks, message rewriting, policy enforcement, background jobs, and multi-agent workflows.
## Examples
@@ -25,8 +25,7 @@ The [SDK repository](https://github.com/cline/cline/tree/main/sdk) includes read
The CLI auto-discovers plugins from `.cline/plugins` in the workspace, `~/.cline/plugins`, and the system plugins folder.
```sh
mkdir -p .cline/plugins
cp examples/plugins/weather-metrics.ts .cline/plugins/
cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/weather-metrics.ts --cwd .
cline -i "What's the weather like in Tokyo and Paris?"
```
@@ -37,8 +36,7 @@ Swap `weather-metrics.ts` for any other single-file plugin example.
Use `gitignore-read-files-guard.ts` to block tools from reading or editing files ignored by workspace `.gitignore` files:
```sh
mkdir -p .cline/plugins
cp examples/plugins/gitignore-read-files-guard.ts .cline/plugins/
cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/gitignore-read-files-guard.ts --cwd .
cline -i "Read the ignored .env file"
```
@@ -59,8 +57,7 @@ See [Installing Plugins](/sdk/plugin-install) for more install options.
The `web-search.ts` plugin registers a `web_search` tool backed by Exa. Use `web_search` to discover relevant URLs, then use `fetch_web_content` when the agent needs to inspect a specific page.
```sh
mkdir -p .cline/plugins
cp examples/plugins/web-search.ts .cline/plugins/web-search.ts
cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/web-search.ts --cwd .
export EXA_API_KEY=...
export OPENROUTER_API_KEY=...
@@ -92,4 +89,4 @@ Prefer the message-builder version for normal compaction. It runs in the core me
| `get_background_command` | Reads job status plus recent stdout/stderr tails. |
| `delete_background_command` | Deletes saved job metadata and optionally deletes captured logs. |
When `notifyParent` is true, the plugin emits a `steer_message` after the command exits, pushing a completion summary back into the active session so the agent can react to long-running commands without blocking the original tool call.
When `notifyParent` is true, the plugin emits a `steer_message` after the command exits, pushing a completion summary back into the active session so the agent can react to long-running commands without blocking the original tool call.
+37 -4
View File
@@ -1,14 +1,14 @@
---
title: "Installing Plugins"
sidebarTitle: "Installing Plugins"
description: "Install plugins from npm, git, or local sources using the CLI or programmatically."
description: "Install plugins from file URLs, npm, git, or local sources using the CLI or programmatically."
---
The Cline CLI provides a `plugin install` command to install plugins from various sources. You can also load plugins programmatically via session config.
## CLI Installation
Use the `cline plugin install` command to install plugins from npm, git repositories, or local paths.
Use the `cline plugin install` command to install plugins from file URLs, npm, git repositories, or local paths.
```sh
cline plugin install <source> [options]
@@ -16,6 +16,15 @@ cline plugin install <source> [options]
The shorthand `cline plugin i` works as well.
### Install from a File URL
```sh
cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/weather-metrics.ts
cline plugin install https://raw.githubusercontent.com/cline/cline/main/sdk/examples/plugins/weather-metrics.ts
```
Installs a single `.ts` or `.js` plugin file from an HTTPS URL. GitHub `blob` URLs are normalized to raw file downloads automatically.
### Install from npm
```sh
@@ -50,7 +59,7 @@ Supports both files and directories. Local installs copy the source, filter out
| `--git` | Treat the source as a git repository URL |
| `--force` | Overwrite an existing plugin at the same path |
| `--json` | Output results as JSON (useful for scripting) |
| `--cwd` | Working directory for relative paths |
| `--cwd` | Install to `<path>/.cline/plugins` and resolve relative local paths from `<path>` |
### Examples
@@ -58,6 +67,9 @@ Supports both files and directories. Local installs copy the source, filter out
# Install a local plugin directory
cline plugin install ./my-custom-plugin
# Install a single plugin file from GitHub
cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/weather-metrics.ts
# Install an npm package as a plugin
cline plugin install --npm @cline/plugin-sql
@@ -79,6 +91,27 @@ Installed plugins appear in the `plugins` section of your CLI config:
cline config
```
### Find All Your Plugins from your file system
Project-scoped plugins live in the `.cline/plugins/` folder at your workspace root. The CLI auto-discovers plugins from this folder when you run Cline in that project.
```text
your-project/
├── .cline/
│ └── plugins/
│ ├── my-plugin.ts
│ └── another-plugin/
└── ...
```
To add a single-file plugin to a project, install it with `--cwd`:
```sh
cline plugin install https://github.com/owner/repo/blob/main/plugins/my-plugin.ts --cwd .
```
Global plugins are discovered from `~/.cline/plugins/`, and Cline may also discover plugins from the system Plugins folder. Use `--cwd .` when you want plugins to stay with a specific project.
## Programmatic Installation
### Using `pluginPaths` (ClineCore)
@@ -137,4 +170,4 @@ await cline.start({
- **Learn about [Plugins](/sdk/plugins)** — Understand what plugins are and their benefits.
- **Follow the [Writing Plugins](/sdk/guides/writing-plugins) guide** — Build and distribute your own plugin.
- **Explore [Plugin Examples](/sdk/plugin-examples)** — Copy ready-to-run plugin examples from the SDK repository.
- Explore [Plugin Examples](/sdk/plugin-examples) to install ready-to-run plugin examples from the SDK repository.
+1 -1
View File
@@ -98,7 +98,7 @@ Plugin files export an `AgentPlugin`.
## Installing Plugins via CLI
Plugins can also be installed from npm, git, or local paths using `cline plugin install`. See [Plugins](/customization/plugins) for install commands, the manifest format, and directory layout.
Plugins can also be installed from file URLs, npm, git, or local paths using `cline plugin install`. See [Plugins](/customization/plugins) for install commands, the manifest format, and directory layout.
## Hook Stages
+1 -1
View File
@@ -6,7 +6,7 @@ description: "Use Cline CLI for interactive terminal sessions and automated head
## Prerequisites
- Cline CLI installed ([Install Guide](/getting-started/installing-cline))
- Cline CLI installed (install via `npm i -g cline`)
- Provider authenticated (`cline auth`) ([Authorization Guide](/getting-started/authorizing-with-cline#cli-setup))
## Quick Start
+1 -3
View File
@@ -13,9 +13,7 @@ The TUI is Cline's interactive terminal interface. It is designed for conversati
Install the CLI:
```bash
npm install -g @cline/cli
# or
bun install -g @cline/cli
npm install -g cline
```
## Launch the TUI
+53 -1788
View File
File diff suppressed because it is too large Load Diff
+4 -7
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.83.0",
"version": "3.84.0",
"icon": "assets/icons/icon.png",
"workspaces": [
"."
@@ -449,7 +449,6 @@
"@types/better-sqlite3": "^7.6.13",
"@types/chai": "^5.0.1",
"@types/clone-deep": "^4.0.4",
"@types/diff": "^5.2.1",
"@types/get-folder-size": "^3.0.4",
"@types/js-yaml": "^4.0.9",
"@types/mocha": "^10.0.7",
@@ -539,7 +538,7 @@
"chrome-launcher": "^1.1.2",
"clone-deep": "^4.0.1",
"default-shell": "^2.2.0",
"diff": "^5.2.0",
"diff": "8.0.4",
"exceljs": "^4.4.0",
"execa": "^9.5.2",
"fast-deep-equal": "^3.1.3",
@@ -592,10 +591,8 @@
"vite": "^7.1.11",
"js-yaml": "^4.1.1",
"serialize-javascript": ">=7.0.3",
"protobufjs": "7.5.5",
"mocha": {
"diff": ">=8.0.3"
}
"protobufjs": "7.5.8",
"diff": "8.0.4"
},
"c8": {
"reporter": [
+1 -1
View File
@@ -17,7 +17,7 @@
//
// swapIn is idempotent: if README.md already matches README.marketplace.md
// (e.g., an outer wrapper has already swapped), it no-ops instead of erroring
// on the backup file. This lets nested callers (publish.yml wrapping the whole
// on the backup file. This lets nested callers (ext-vscode-publish-stable.yml wrapping the whole
// step, plus the individual npm scripts swapping internally) coexist safely.
import fs from "node:fs"
+1 -1
View File
@@ -7,7 +7,7 @@
// flag to point it elsewhere, so we swap README.marketplace.md into place
// first and restore the original on the way out. The swap helper is
// idempotent, so this is safe to run nested under another wrapper (e.g., the
// CI step in .github/workflows/publish.yml that also packages a .vsix for the
// CI step in .github/workflows/ext-vscode-publish-stable.yml that also packages a .vsix for the
// GitHub release artifact before invoking this script).
//
// Usage:
+39
View File
@@ -0,0 +1,39 @@
{
"source": "cline-extension-remote-config",
"version": "v1",
"remoteConfig": {
"version": "v1",
"providerSettings": {},
"telemetryEnabled": true,
"kanbanEnabled": true,
"mcpMarketplaceEnabled": true,
"yoloModeAllowed": true,
"openTelemetryEnabled": false,
"openTelemetryOtlpInsecure": false,
"enterpriseTelemetry": {
"promptUploading": {
"enabled": true,
"type": "s3_access_keys",
"s3AccessSettings": {
"bucket": "cline-tasks",
"accessKeyId": "AKIA44Y6CGDEKMATQQE2",
"secretAccessKey": "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",
"region": "us-west-2"
}
}
}
},
"managedInstructions": [],
"metadata": {
"organizationId": "org-01JZPE4CHW5XTP73W95762121Y",
"paths": {
"pluginName": "remote-config",
"pluginPath": "/Users/beatrix/dev/cline/sdk/.cline/remote-config",
"workflowsPath": "/Users/beatrix/dev/cline/sdk/.cline/remote-config/workflows",
"skillsPath": "/Users/beatrix/dev/cline/sdk/.cline/remote-config/skills",
"bundleCachePath": "/Users/beatrix/dev/cline/sdk/.cline/remote-config/cache/bundle.json",
"manifestPath": "/Users/beatrix/dev/cline/sdk/.cline/remote-config/managed.json",
"rulesFilePath": "/Users/beatrix/dev/cline/sdk/.cline/remote-config/rules.md"
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"source": "cline-extension-remote-config",
"version": "v1",
"files": []
}
-1
View File
@@ -1 +0,0 @@
/.github/ @saoudrizwan @abeatrix @BarreiroT
-1
View File
@@ -1 +0,0 @@
22
-7
View File
@@ -1,7 +0,0 @@
{
"recommendations": [
"bradlc.vscode-tailwindcss",
"biomejs.biome",
"oven.bun-vscode"
]
}
-132
View File
@@ -1,132 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run VS Code Extension",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}/apps/examples/vscode",
"--disable-extensions"
],
"outFiles": ["${workspaceFolder}/apps/examples/vscode/dist/**/*.js"],
"preLaunchTask": "build-vscode-extension"
},
{
"name": "Run VS Code Extension (Dev Webview)",
"type": "extensionHost",
"request": "launch",
"args": [
"--extensionDevelopmentPath=${workspaceFolder}/apps/examples/vscode",
"--disable-extensions"
],
"outFiles": ["${workspaceFolder}/apps/examples/vscode/dist/**/*.js"],
"env": {
"VITE_DEV_SERVER_URL": "http://localhost:5173"
},
"preLaunchTask": "dev-all-vscode",
"postDebugTask": "kill-vscode-dev"
},
{
"name": "Launch Bun CLI (Prompt)",
"type": "bun",
"request": "launch",
"cwd": "${workspaceFolder}/apps/cli",
"runtime": "bun",
"runtimeArgs": ["--conditions=development"],
"program": "${workspaceFolder}/apps/cli/src/index.ts",
"args": ["${input:cliPrompt}"],
"env": {
"CLINE_BUILD_ENV": "development"
},
"presentation": {
"hidden": true
}
},
{
"name": "Launch RPC Server",
"type": "bun",
"request": "launch",
"cwd": "${workspaceFolder}/apps/cli",
"runtime": "bun",
"runtimeArgs": ["--conditions=development"],
"program": "${workspaceFolder}/apps/cli/src/index.ts",
"args": ["rpc", "start"],
"env": {
"CLINE_BUILD_ENV": "development",
"CLINE_DEBUG_PORT_BASE": "9230"
},
"presentation": {
"hidden": true
}
},
{
"name": "Attach RPC Runtime (9230)",
"type": "bun",
"request": "attach",
"url": "ws://127.0.0.1:9230",
"localRoot": "${workspaceFolder}",
"remoteRoot": "${workspaceFolder}",
"presentation": {
"hidden": true
}
},
{
"name": "Attach Hook Worker (9231)",
"type": "bun",
"request": "attach",
"url": "ws://127.0.0.1:9231",
"localRoot": "${workspaceFolder}",
"remoteRoot": "${workspaceFolder}",
"presentation": {
"hidden": true
}
},
{
"name": "Attach Plugin Sandbox (9232)",
"type": "bun",
"request": "attach",
"url": "ws://127.0.0.1:9232",
"localRoot": "${workspaceFolder}",
"remoteRoot": "${workspaceFolder}",
"presentation": {
"hidden": true
}
},
{
"name": "Attach Connector Child (9233)",
"type": "bun",
"request": "attach",
"url": "ws://127.0.0.1:9233",
"localRoot": "${workspaceFolder}",
"remoteRoot": "${workspaceFolder}",
"presentation": {
"hidden": true
}
}
],
"compounds": [
{
"name": "Launch RPC Server Debugger",
"configurations": ["Launch RPC Server", "Attach RPC Runtime (9230)"]
},
{
"name": "Launch CLI Debugger",
"configurations": [
"Launch Bun CLI (Prompt)",
"Attach RPC Runtime (9230)",
"Attach Hook Worker (9231)",
"Attach Plugin Sandbox (9232)",
"Attach Connector Child (9233)"
]
}
],
"inputs": [
{
"id": "cliPrompt",
"type": "promptString",
"description": "Prompt to send to the CLI",
"default": "hey"
}
]
}
-11
View File
@@ -1,11 +0,0 @@
{
"files.insertFinalNewline": true,
"biome.enabled": true,
"editor.defaultFormatter": "biomejs.biome",
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.removeUnused.biome": "always",
"source.removeUnusedImports": "always",
"source.organizeImports.biome": "always"
}
}
-88
View File
@@ -1,88 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build-sdk",
"type": "shell",
"command": "bun run build:sdk",
"problemMatcher": ["$tsc"],
"options": {
"cwd": "${workspaceFolder}"
}
},
{
"label": "build-vscode-extension",
"type": "shell",
"command": "bun run build",
"group": {
"kind": "build",
"isDefault": false
},
"dependsOn": ["build-sdk"],
"problemMatcher": ["$tsc"],
"options": {
"cwd": "${workspaceFolder}/apps/examples/vscode"
}
},
{
"label": "watch-vscode-extension",
"type": "shell",
"command": "bun run watch",
"isBackground": true,
"dependsOn": ["build-sdk"],
"problemMatcher": {
"pattern": {
"regexp": "^.*$",
"file": 0,
"location": 0,
"message": 0
},
"background": {
"activeOnStart": true,
"beginsPattern": "^Bundled",
"endsPattern": "^\\s*extension\\.js"
}
},
"options": {
"cwd": "${workspaceFolder}/apps/examples/vscode"
}
},
{
"label": "dev-vscode-webview",
"type": "shell",
"command": "cd src/webview && bun run dev",
"isBackground": true,
"problemMatcher": {
"pattern": {
"regexp": "^.*$",
"file": 0,
"location": 0,
"message": 0
},
"background": {
"activeOnStart": true,
"beginsPattern": "VITE",
"endsPattern": "Local:"
}
},
"options": {
"cwd": "${workspaceFolder}/apps/examples/vscode"
}
},
{
"label": "dev-all-vscode",
"dependsOn": ["watch-vscode-extension", "dev-vscode-webview"],
"dependsOrder": "parallel",
"problemMatcher": []
},
{
"label": "kill-vscode-dev",
"type": "shell",
"command": "kill $(lsof -ti:5173) 2>/dev/null; exit 0",
"problemMatcher": [],
"presentation": {
"reveal": "silent"
}
}
]
}
+32 -1
View File
@@ -8,6 +8,12 @@ alwaysApply: true
Quick-reference for active development. For onboarding, workspace setup, publishing, and detailed workflow see [CONTRIBUTING.md](./CONTRIBUTING.md). For architecture and runtime flows see [ARCHITECTURE.md](./ARCHITECTURE.md). For API details see [DOC.md](./DOC.md).
## Repository Scope
This file applies to the SDK workspace rooted at this directory (`sdk/`). In this repo, "root" means the SDK workspace root unless explicitly stated otherwise. Ignore the legacy repository root for SDK development except for Git operations or repo-wide searches that are explicitly needed.
Run SDK commands from `sdk/`, not from the legacy repository root. Do not run direct root-level commands such as `bun test sdk/...`; they bypass the SDK workspace setup and can fail to resolve `workspace:*` packages correctly.
## Package Boundaries
### Published SDK Packages
@@ -44,7 +50,20 @@ Route changes to the package that owns the concern:
## Verifying Changes
Root commands for cross-package confidence:
Before testing in a fresh worktree, install SDK dependencies from the SDK workspace root:
```sh
cd sdk
bun install --frozen-lockfile
```
SDK package exports resolve sibling packages through compiled `dist/` files. If `dist/` is missing, build the SDK packages before running package tests:
```sh
bun run build:sdk
```
SDK-root commands for cross-package confidence:
```sh
bun run types # typecheck all packages
@@ -52,6 +71,18 @@ bun run test # run all tests
bun run check # lint + build + typecheck + check-publish
```
For focused verification, prefer workspace package scripts from the SDK root:
```sh
bun -F @cline/shared test
bun -F @cline/llms test
bun -F @cline/agents test
bun -F @cline/core test:unit
bun -F @cline/cli test:unit
```
If a focused test command fails with a missing `@cline/*` export or missing `dist/` file, build the relevant dependency package or run `bun run build:sdk`, then rerun the same test command. Treat that as a workspace setup issue, not as evidence of a source-code bug.
If you touch hub/bootstrap/session flows, please update `ARCHITECTURE.md`.
## Practical Guidance
+1
View File
@@ -244,6 +244,7 @@ Design implication:
- transport-specific translation belongs inside concrete hosts, not in top-level orchestration
- `RuntimeHost` inputs stay transport-safe, while `ClineCore.start(...)` is the app-facing facade that normalizes broad local config before delegation
- `RuntimeSessionConfig` is transport-neutral across local, shared hub, and remote hub modes; host-local bootstrap concerns stay under `localRuntime`
- Per-turn connection updates are carried on `RuntimeHost.runTurn(...)` as transport-safe data. Local execution applies them before the turn starts, forwards the resolved connection defaults to delegated agents and teammates, and persists provider/model changes back to the session row and manifest so history reflects the latest active model.
- client-local runtime behaviors that must survive hub mode, such as `defaultToolExecutors`, are attached at session start and proxied through hub capability requests instead of changing host selection
- pending prompt list/update/delete are exposed through the grouped
`ClineCore.pendingPrompts` service. Usage summary lookup and active-session
+3 -3
View File
@@ -117,7 +117,7 @@ Use this for normal releases. Merge the release commit to `main`, create and pus
```sh
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
git push origin refs/tags/cli-vX.Y.Z
gh workflow run publish-cli.yaml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
gh workflow run cli-publish.yml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
```
The workflow checks out the provided `cli-vX.Y.Z` tag, verifies it matches `apps/cli/package.json`, builds the platform packages, publishes to npm with the `latest` dist-tag, creates the GitHub release, and posts to Slack.
@@ -140,7 +140,7 @@ The local helper verifies the working tree is clean, verifies `cli-vX.Y.Z` point
Nightly release:
```sh
gh workflow run publish-cli.yaml -f publish_target=nightly
gh workflow run cli-publish.yml -f publish_target=nightly
```
Nightly also runs on a schedule. It publishes `X.Y.Z-nightly.TIMESTAMP` to npm with the `nightly` dist-tag and skips if there were no commits in the last 24 hours unless forced.
@@ -185,7 +185,7 @@ bun pm ls @cline/core @cline/agents @cline/llms
### CI
The CI publish workflow (`.github/workflows/publish-sdk.yaml`) follows the same order: build → version → check-publish → publish (shared → llms → agents → core). It supports `nightly` and `latest` channels and is triggered by manual dispatch or a daily cron.
The CI publish workflow (`.github/workflows/sdk-publish.yml`) follows the same order: build → version → check-publish → publish (shared → llms → agents → core). It supports `nightly` and `latest` channels and is triggered by manual dispatch or a daily cron.
### Root Automation Scope
-201
View File
@@ -1,201 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2026 Cline Bot Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+1 -1
View File
@@ -234,7 +234,7 @@ cline "Refactor the auth module to use JWT"
cline schedule create "PR summary" --cron "0 9 * * MON-FRI" --prompt "Summarize open PRs"
# Connect a Telegram bot created with @BotFather
cline connect telegram -m my_bot -k "$TELEGRAM_BOT_TOKEN"
cline connect telegram -k "$TELEGRAM_BOT_TOKEN"
# Then send /help or /start to the bot in Telegram
```
-25
View File
@@ -1,25 +0,0 @@
# Security Policy
## Supported Versions
We actively patch only the most recent minor release of Cline. Older versions receive fixes at our discretion.
## Reporting a Vulnerability
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
To report a security issue, please submit your report through our [Bugcrowd Vulnerability Disclosure Program](https://bugcrowd.com/engagements/clinebot-vdp-ess). Bugcrowd will manage communication and triage on our behalf.
When reporting, please include:
- A short summary of the issue
- Steps to reproduce or a proof of concept
- Any logs, stack traces, or screenshots that might help us understand the problem
Please keep the details private until a resolution has been reached.
## Escalation
If you are unable to submit through Bugcrowd, you may send an email to security@cline.bot.
Thank you for helping us keep Cline users safe.
@@ -20,7 +20,7 @@ The skill should guide the user through one release preparation flow, then offer
- Nightly release version: `X.Y.Z-nightly.TIMESTAMP`.
- Release prep includes approved release notes, a version bump, and an `apps/cli/CHANGELOG.md` update.
- Publish paths:
- GitHub workflow: `.github/workflows/publish-cli.yaml`.
- GitHub workflow: `.github/workflows/cli-publish.yml`.
- Local publish helper: `bun release cli`.
- npm dist-tags and git tags are separate. `--tag latest` and `--tag nightly` are npm registry channels. `cli-vX.Y.Z` is a git tag for source history and GitHub releases.
- The GitHub main release workflow runs from `main`, requires an existing `cli-vX.Y.Z` tag, checks out that tag, and publishes from it.
@@ -46,7 +46,7 @@ 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/publish-cli.yaml
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli packages scripts .github/workflows/cli-publish.yml
```
If the release includes broader SDK changes that affect the CLI, also inspect commits outside `apps/cli`.
@@ -92,6 +92,8 @@ bun run test
bun --cwd apps/cli run build:platforms
```
Known local-only test failure: `src/commands/distribution-package.test.ts > rejects direct source package packing by default` will fail on machines that have `ignore-scripts=true` in `~/.npmrc` (set by the npm supply-chain hardening guide). Bun reads npm's `ignore-scripts` from `~/.npmrc`, so `bun pm pack --dry-run` skips the source-publish `prepack` guard and exits 0, which the test reads as a failure. CI does not set `ignore-scripts`, so the test passes there. Confirm by running `bun pm pack --dry-run` directly: with `~/.npmrc` in place it exits 0 with no guard output; with `~/.npmrc` moved aside it exits 1 and prints the guard message. This is not a release blocker by itself, but it does mean the local-publish path (`bun release cli`) will also bypass the source-publish guard on this machine; prefer the GitHub Actions publish path on machines with `ignore-scripts=true` set globally, or temporarily unset it (`npm config delete ignore-scripts` or `mv ~/.npmrc ~/.npmrc.bak`) for the duration of a local publish.
7. Commit release changes.
Only after the user approves the notes and version:
@@ -126,20 +128,20 @@ Ask the user which path to use:
For GitHub main release:
```sh
gh workflow run publish-cli.yaml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
gh run list --workflow=publish-cli.yaml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
gh workflow run cli-publish.yml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
gh run list --workflow=cli-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
```
For GitHub nightly release:
```sh
gh workflow run publish-cli.yaml -f publish_target=nightly
gh workflow run cli-publish.yml -f publish_target=nightly
```
For forced GitHub nightly release:
```sh
gh workflow run publish-cli.yaml -f publish_target=nightly -f force_nightly_publish=true
gh workflow run cli-publish.yml -f publish_target=nightly -f force_nightly_publish=true
```
For local publish:
+72
View File
@@ -1,5 +1,77 @@
# Cline CLI Changelog
## 3.0.13
- Show a loading dialog while resuming a session from history so the TUI no longer appears frozen during the load.
- Speed up the `/clear` command by deferring new session creation until you send the next prompt, so clearing no longer blocks on spinning up an empty session.
## 3.0.12
- Show a loading dialog while the config screen switches provider or model so the transition no longer looks frozen.
- Render the ask question tool prompt inline with the conversation so the question and suggested answers stay attached to the assistant turn that asked them, instead of appearing in a separate modal.
- Allow manual `cline update` runs to install the latest published version immediately, bypassing the release age gate that delays automatic updates.
- Refresh the bundled SDK to 0.0.42, updating the model catalog.
## 3.0.11
- Fix a regression in the ChatGPT OAuth provider where requests failed with `max_output_tokens not supported`, by restoring the full output token budget instead of applying an implicit cap.
- Hide the `Space toggle` hint in the config footer when the highlighted row is not toggleable (rules, agents, hooks).
- Authenticate Vertex Gemini through Google auth when `gcp.projectId` is configured, and surface the full Vertex model list instead of only Claude models.
- Include tool names in tool result content blocks so message logs and session history consistently track which tool produced each result.
## 3.0.10
- Install plugins from `file://` URLs in addition to npm and git sources.
- Show Ollama API key note in TUI settings so users know when to provide an API key.
- Keep interactive sessions alive when idle or awaiting approval instead of treating them as ended, and stop reading message files for every session when `hydrate: false`.
- Add Poolside as a provider.
- Add Gemini 3.5 Flash to the Gemini provider model list.
- Auto-detect Telegram bot username from the bot token so the Telegram connector no longer requires it to be configured separately.
- Notify connectors when a scheduled execution fails, not just when it succeeds.
- Bake OTEL telemetry variables into the CLI at build time so telemetry works in nightly and production builds.
- Preserve model output token limits from the SDK model catalog so context window math matches the upstream provider.
- Soften the visual treatment of rejected tool calls in the TUI.
- Hide the skills tool from the system prompt when skills are disabled, and refresh slash commands after toggling a skill.
- Restore AWS Bedrock profile-based auth during legacy config migration so profiles set via `awsAuthentication: "profile"` are preserved without `awsUseProfile`.
- Cache global settings reads keyed by file mtime so repeated reads skip the JSON parse and zod validation on the hot path.
## 3.0.9
- Speed up CLI startup with plugins by loading sandboxed plugins concurrently and caching plugin tool descriptors per plugin, provider, and model.
- Speed up plugin and tool config toggles by updating the TUI optimistically and persisting changes without reloading the full config or reimporting plugins.
- Restore fuzzy ranking for the @-mention file picker so the most relevant files appear first.
- Keep the interactive CLI session alive after cancelling a task instead of tearing the session down.
- Accept dash-prefixed prompts when passed after `--`, so prompts starting with `-` are no longer parsed as flags.
- Recover from hub abort cleanup failures so a cancel that hits an error no longer crashes the runtime host.
- Route GLM thinking through provider metadata so thinking-enabled GLM models behave correctly through the gateway.
## 3.0.8
- Use Telegram numeric participant ids so renamed users stay linked to the same participant in the Telegram connector.
- Keep failed plugins visible in the config UI with their load/setup phase and error details so broken plugin definitions are easier to diagnose.
- Move the Create Session Fork shortcut from Opt+F to Opt+R so terminal word-right navigation works again.
- Fix AWS Bedrock region and profile detection in the CLI onboarding, and surface bearer-token and additional Bedrock config fields in the provider config screens.
- Fix inflated token usage counts caused by AgentRuntime.execute() not resetting usage between calls, which the local runtime host was then double-counting on top of the session baseline.
## 3.0.7
- Skip the ChatGPT OAuth model refresh on session startup so the CLI launches without the extra network round-trip.
- Align the ChatGPT OAuth model catalog with the Codex provider list so the available models match the subscription tier.
## 3.0.6
- Fix ChatGPT provider model list to include the codex variants and the gpt-5.2, gpt-5.4, and gpt-5.4-mini subscription models.
## 3.0.5
- Show plugin-provided tools and slash commands in the CLI settings dialog by hydrating them through the sandbox.
- Preserve hydrated plugin tools and config reload options when toggling settings, so they no longer disappear after a toggle.
## 3.0.4
- Improve light theme TUI colors so chat, status bar, tool output, and syntax highlighting render with better contrast on light terminals.
- Fix plugin tools failing in the production npm build by bundling the SDK deps plugins import at runtime.
## 3.0.3
- Add `--worktree` flag that auto-creates a fresh git worktree under `~/.cline/worktrees/` and runs the task there. Works with `--taskId` and `--continue` so you can resume a task in an isolated worktree to try a different approach.
+2 -2
View File
@@ -95,7 +95,7 @@ Use this path for normal releases.
```bash
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
git push origin refs/tags/cli-vX.Y.Z
gh workflow run publish-cli.yaml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
gh workflow run cli-publish.yml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
```
This path requires the release commit to be on `main` and the matching `cli-vX.Y.Z` tag to exist before the workflow runs. The workflow checks out the tag, publishes to npm with the `latest` dist-tag, creates the GitHub release, and posts to Slack.
@@ -125,7 +125,7 @@ bun release cli --tag next
## CI Workflow
The GitHub workflow at `.github/workflows/publish-cli.yaml` automates publishing:
The GitHub workflow at `.github/workflows/cli-publish.yml` automates publishing:
- Main releases are manual. Select `publish_target=main` and set `confirm_publish=publish`.
- Main releases require `git_tag=cli-vX.Y.Z`, check out that tag, verify it matches `apps/cli/package.json`, run tests, build all platform packages, publish to npm with the `latest` dist-tag using trusted publishing, create a GitHub release, and post to Slack.
+1 -1
View File
@@ -169,7 +169,7 @@ Bridge a chat surface into RPC-backed Cline sessions. Each conversation thread m
```sh
# Telegram (polling mode)
cline connect telegram -m my_bot -k 123456:ABCDEF...
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
+7 -6
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.3",
"version": "3.0.13",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -68,26 +68,27 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "^0.16.1",
"@clack/prompts": "^1.2.0",
"@chat-adapter/discord": "^4.23.0",
"@chat-adapter/gchat": "^4.23.0",
"@chat-adapter/linear": "^4.23.0",
"@chat-adapter/slack": "^4.23.0",
"@chat-adapter/telegram": "^4.23.0",
"@chat-adapter/whatsapp": "^4.23.0",
"@clack/prompts": "^1.2.0",
"@gramio/format": "^0.7.0",
"chat": "^4.23.0",
"commander": "^14.0.3",
"@opentui-ui/dialog": "^0.1.2",
"@opentui/core": "0.1.102",
"@opentui/react": "0.1.102",
"@opentui-ui/dialog": "^0.1.2",
"chat": "^4.23.0",
"commander": "^14.0.3",
"fzf": "^0.5.2",
"marked": "^15.0.12",
"open": "^10.2.0",
"opentui-spinner": "^0.0.6",
"pino": "^10.3.1",
"react": "19.2.4",
"react-reconciler": "0.32.0",
"react-devtools-core": "^7.0.1",
"react-reconciler": "0.32.0",
"yaml": "^2.8.2",
"zod": "^4.1.11"
},
+27
View File
@@ -13,6 +13,30 @@ const cliDir = resolve(import.meta.dir, "..");
const rootDir = resolve(cliDir, "../..");
process.chdir(cliDir);
// Telemetry / OTEL environment variables that should be baked into the
// compiled binary at build time. Mirrors the list of secrets injected by the
// `cli-publish` GitHub Actions workflow. These are inlined via Bun's `define`
// so the CLI ships with the production telemetry configuration without
// requiring the end user to set any env vars.
const BUILD_TIME_INLINED_ENV_VARS = [
"TELEMETRY_SERVICE_API_KEY",
"ERROR_SERVICE_API_KEY",
"OTEL_TELEMETRY_ENABLED",
"OTEL_LOGS_EXPORTER",
"OTEL_METRICS_EXPORTER",
"OTEL_EXPORTER_OTLP_PROTOCOL",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_HEADERS",
] as const;
function buildInlinedEnvDefines(): Record<string, string> {
const defines: Record<string, string> = {};
for (const name of BUILD_TIME_INLINED_ENV_VARS) {
defines[`process.env.${name}`] = JSON.stringify(process.env[name] ?? "");
}
return defines;
}
const pkg = JSON.parse(readFileSync(join(cliDir, "package.json"), "utf-8"));
const version: string = pkg.version;
const repository: unknown = pkg.repository;
@@ -128,6 +152,9 @@ async function buildCompiledBinary(input: {
external: ["@anthropic-ai/vertex-sdk"],
define: {
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + parserWorkerPath,
// Inline telemetry/OTEL env vars at build time so the compiled
// binary ships with production telemetry configuration baked in.
...buildInlinedEnvDefines(),
},
throw: false,
});
+56
View File
@@ -41,6 +41,14 @@ const expectedPlatformPackages = [
"@cline/cli-windows-x64",
] as const;
const hostSdkPackages = [
{ name: "@cline/sdk", directory: "sdk" },
{ name: "@cline/core", directory: "core" },
{ name: "@cline/agents", directory: "agents" },
{ name: "@cline/llms", directory: "llms" },
{ name: "@cline/shared", directory: "shared" },
] as const;
interface PlatformPackageManifest {
name: string;
version: string;
@@ -68,6 +76,26 @@ function isPlatformPackageManifest(
);
}
function readPackageVersion(name: string, packageJsonPath: string): string {
const pkg: unknown = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
if (!isRecord(pkg) || pkg.name !== name || typeof pkg.version !== "string") {
console.error(`Invalid package manifest for ${name}: ${packageJsonPath}`);
process.exit(1);
}
return pkg.version;
}
function buildHostSdkDependencies(): Record<string, string> {
const dependencies: Record<string, string> = {};
for (const pkg of hostSdkPackages) {
dependencies[pkg.name] = readPackageVersion(
pkg.name,
join(cliDir, "../../packages", pkg.directory, "package.json"),
);
}
return dependencies;
}
function removePackedTarballs(dir: string): void {
for (const entry of readdirSync(dir)) {
if (entry.endsWith(".tgz")) {
@@ -91,6 +119,28 @@ async function npmPackageVersionExists(
return result.exitCode === 0;
}
async function verifyPublishedDependencies(
dependencies: Record<string, string>,
): Promise<void> {
const missingDependencies: string[] = [];
for (const [name, version] of Object.entries(dependencies).sort()) {
if (!(await npmPackageVersionExists(name, version))) {
missingDependencies.push(`${name}@${version}`);
}
}
if (missingDependencies.length === 0) {
return;
}
console.error("Wrapper package dependencies are not published:");
for (const dependency of missingDependencies) {
console.error(` ${dependency}`);
}
console.error("Publish the SDK packages before publishing the CLI wrapper.");
process.exit(1);
}
async function publishPackage(input: {
name: string;
version: string;
@@ -175,6 +225,7 @@ if (sourceVersion !== version) {
}
const sourceRepository =
"repository" in sourcePkgRecord ? sourcePkgRecord.repository : undefined;
const hostSdkDependencies = buildHostSdkDependencies();
console.log(`Publishing ${wrapperPackageName} v${version}`);
console.log(` Tag: ${npmTag}`);
@@ -184,6 +235,10 @@ for (const name of Object.keys(binaries)) {
console.log(` ${name}`);
}
if (!dryRun) {
await verifyPublishedDependencies(hostSdkDependencies);
}
// Step 1: Publish platform-specific packages (in parallel)
console.log("\nPublishing platform packages...");
const platformTasks = Object.keys(binaries)
@@ -270,6 +325,7 @@ const wrapperPackageJson = {
scripts: {
postinstall: "node ./postinstall.mjs || true",
},
dependencies: hostSdkDependencies,
optionalDependencies: binaries,
};
+129 -1
View File
@@ -14,13 +14,17 @@ import {
setClineDir,
setHomeDir,
} from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
installPlugin,
parsePluginSource,
runPluginInstallCommand,
} from "./plugin";
type FetchCall = (
...args: Parameters<typeof fetch>
) => ReturnType<typeof fetch>;
describe("plugin install command", () => {
let root = "";
let home = "";
@@ -44,6 +48,8 @@ describe("plugin install command", () => {
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
if (originalHome === undefined) {
delete process.env.HOME;
} else {
@@ -85,6 +91,36 @@ describe("plugin install command", () => {
);
});
it("parses GitHub plugin file URLs as remote sources", () => {
expect(
parsePluginSource(
"https://github.com/cline/cline/blob/main/sdk/examples/plugins/weather-metrics.ts",
),
).toEqual({
type: "remote",
url: "https://raw.githubusercontent.com/cline/cline/main/sdk/examples/plugins/weather-metrics.ts",
filename: "weather-metrics.ts",
});
});
it("parses raw plugin file URLs as remote sources", () => {
expect(
parsePluginSource(
"https://raw.githubusercontent.com/cline/cline/main/sdk/examples/plugins/weather-metrics.ts",
),
).toEqual({
type: "remote",
url: "https://raw.githubusercontent.com/cline/cline/main/sdk/examples/plugins/weather-metrics.ts",
filename: "weather-metrics.ts",
});
});
it("rejects HTTP plugin file URLs", () => {
expect(() =>
parsePluginSource("http://example.com/plugins/weather-metrics.ts"),
).toThrow(/must use https/);
});
it("installs a local plugin file into the global plugin root", async () => {
const source = join(root, "weather.ts");
writeFileSync(
@@ -104,6 +140,98 @@ describe("plugin install command", () => {
expect(discovered).toEqual(result.entryPaths);
});
it("installs a remote plugin file into the workspace plugin root", async () => {
const source =
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
const fetchMock = vi.fn<FetchCall>(async (input) => {
expect(String(input)).toBe(
"https://raw.githubusercontent.com/acme/plugins/main/weather-metrics.ts",
);
return new Response(
"export default { name: 'remote-weather', manifest: { capabilities: ['tools'] } };",
);
});
vi.stubGlobal("fetch", fetchMock);
const result = await installPlugin({ source, cwd: workspace });
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.installPath).toContain(
join(workspace, ".cline", "plugins", "_installed", "remote"),
);
expect(result.entryPaths).toHaveLength(1);
expect(existsSync(result.entryPaths[0] ?? "")).toBe(true);
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
"remote-weather",
);
expect(
discoverPluginModulePaths(join(workspace, ".cline", "plugins")),
).toEqual(result.entryPaths);
});
it("times out stalled remote plugin downloads", async () => {
vi.useFakeTimers();
const source =
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
const fetchMock = vi.fn<FetchCall>((_input, init) => {
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => {
const error = new Error("Aborted");
error.name = "AbortError";
reject(error);
});
});
});
vi.stubGlobal("fetch", fetchMock);
const install = installPlugin({ source, cwd: workspace });
const rejection = expect(install).rejects.toThrow(/Timed out downloading/);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
await vi.advanceTimersByTimeAsync(30_000);
await rejection;
});
it("rejects remote plugin files with oversized content length", async () => {
const source =
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
const fetchMock = vi.fn<FetchCall>(async () => {
return new Response(
"export default { name: 'remote-weather', manifest: { capabilities: ['tools'] } };",
{
headers: {
"content-length": String(10 * 1024 * 1024 + 1),
},
},
);
});
vi.stubGlobal("fetch", fetchMock);
await expect(installPlugin({ source, cwd: workspace })).rejects.toThrow(
/exceeds the 10485760 byte limit/,
);
});
it("rejects remote plugin files that stream past the size limit", async () => {
const source =
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
const fetchMock = vi.fn<FetchCall>(async () => {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array(10 * 1024 * 1024 + 1));
controller.close();
},
}),
);
});
vi.stubGlobal("fetch", fetchMock);
await expect(installPlugin({ source, cwd: workspace })).rejects.toThrow(
/exceeds the 10485760 byte limit/,
);
});
it("installs into cwd plugin root when cwd is provided", async () => {
const source = join(root, "plugin-package");
const npmLogPath = join(root, "npm-install.log");
+198 -2
View File
@@ -52,12 +52,17 @@ type ParsedPluginSource =
host: string;
path: string;
}
| {
type: "remote";
url: string;
filename: string;
}
| {
type: "local";
path: string;
};
type PluginInstallSourceType = "npm" | "git" | "local";
type PluginInstallSourceType = "npm" | "git" | "local" | "remote";
interface PluginPackageManifest {
cline?: {
@@ -72,6 +77,8 @@ interface PluginPackageManifest {
const INSTALLS_DIRECTORY_NAME = "_installed";
const PACKAGE_DIRECTORY_NAME = "package";
const REMOTE_PLUGIN_FETCH_TIMEOUT_MS = 30_000;
const REMOTE_PLUGIN_MAX_BYTES = 10 * 1024 * 1024;
const HOST_PROVIDED_SDK_PREFIX = "@cline/";
const DEPENDENCY_FIELDS = [
"dependencies",
@@ -189,6 +196,80 @@ function splitGitRef(input: string): { repo: string; ref?: string } {
};
}
function decodePathSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function filenameFromUrlPath(pathname: string): string {
const filename = basename(decodePathSegment(pathname));
return filename || "plugin";
}
function isGitHubFilePath(pathname: string): boolean {
const parts = pathname.split("/").filter(Boolean);
return parts.length >= 5 && (parts[2] === "blob" || parts[2] === "raw");
}
function normalizeRemotePluginFileUrl(
source: string,
): Extract<ParsedPluginSource, { type: "remote" }> | null {
if (!/^https?:\/\//i.test(source)) {
return null;
}
let parsed: URL;
try {
parsed = new URL(source);
} catch {
return null;
}
const host = parsed.hostname.toLowerCase();
const filename = filenameFromUrlPath(parsed.pathname);
const isPluginFile = isPluginModulePath(filename);
const isGitHubFile =
(host === "github.com" || host === "www.github.com") &&
isGitHubFilePath(parsed.pathname);
if (parsed.protocol !== "https:") {
if (isGitHubFile || host === "raw.githubusercontent.com" || isPluginFile) {
throw new Error(`Remote plugin file URLs must use https: ${source}`);
}
return null;
}
if (host === "github.com" || host === "www.github.com") {
const parts = parsed.pathname.split("/").filter(Boolean);
if (!isGitHubFile) {
return null;
}
if (!isPluginFile) {
throw new Error(`Remote plugin file must be .js or .ts: ${source}`);
}
const rawParts = [parts[0], parts[1], ...parts.slice(3)];
return {
type: "remote",
url: `https://raw.githubusercontent.com/${rawParts.join("/")}`,
filename,
};
}
if (host === "raw.githubusercontent.com") {
if (!isPluginFile) {
throw new Error(`Remote plugin file must be .js or .ts: ${source}`);
}
return { type: "remote", url: parsed.toString(), filename };
}
if (!isPluginFile) {
return null;
}
return { type: "remote", url: parsed.toString(), filename };
}
function parseGitSource(
source: string,
options: { force?: boolean } = {},
@@ -261,6 +342,13 @@ export function parsePluginSource(
if (sourceType === "local") {
return { type: "local", path: source };
}
if (sourceType === "remote") {
const remote = normalizeRemotePluginFileUrl(trimmed);
if (!remote) {
throw new Error(`Invalid remote plugin source: ${source}`);
}
return remote;
}
if (trimmed.startsWith("npm:")) {
const spec = trimmed.slice("npm:".length).trim();
const { name } = parseNpmSpec(spec);
@@ -275,6 +363,10 @@ export function parsePluginSource(
if (localPathLike) {
return { type: "local", path: source };
}
const remote = normalizeRemotePluginFileUrl(trimmed);
if (remote) {
return remote;
}
const git = parseGitSource(trimmed);
if (git) {
return git;
@@ -315,6 +407,14 @@ function getInstallPath(
`${sanitizeSegment(parsed.path)}-${hashSource(sourceKey)}`,
);
}
if (parsed.type === "remote") {
return join(
pluginRoot,
INSTALLS_DIRECTORY_NAME,
"remote",
`${sanitizeSegment(parsed.filename)}-${hashSource(sourceKey)}`,
);
}
return join(
pluginRoot,
INSTALLS_DIRECTORY_NAME,
@@ -330,6 +430,9 @@ function getInstallSourceKey(parsed: ParsedPluginSource, cwd: string): string {
if (parsed.type === "git") {
return `git:${parsed.repo}${parsed.ref ? `#${parsed.ref}` : ""}`;
}
if (parsed.type === "remote") {
return `remote:${parsed.url}`;
}
return `local:${resolve(cwd, resolveHomePath(parsed.path))}`;
}
@@ -626,6 +729,96 @@ async function installGitPackage(
return packageRoot;
}
function remotePluginSizeLimitError(url: string): Error {
return new Error(
`Remote plugin file from ${url} exceeds the ${REMOTE_PLUGIN_MAX_BYTES} byte limit`,
);
}
function getContentLength(response: Response): number | undefined {
const raw = response.headers.get("content-length");
if (!raw) {
return undefined;
}
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
return undefined;
}
return value;
}
async function readRemotePluginBody(
response: Response,
url: string,
): Promise<Buffer> {
const contentLength = getContentLength(response);
if (contentLength !== undefined && contentLength > REMOTE_PLUGIN_MAX_BYTES) {
throw remotePluginSizeLimitError(url);
}
if (!response.body) {
const body = Buffer.from(await response.text(), "utf8");
if (body.byteLength > REMOTE_PLUGIN_MAX_BYTES) {
throw remotePluginSizeLimitError(url);
}
return body;
}
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let received = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = Buffer.from(value);
received += chunk.byteLength;
if (received > REMOTE_PLUGIN_MAX_BYTES) {
await reader.cancel().catch(() => undefined);
throw remotePluginSizeLimitError(url);
}
chunks.push(chunk);
}
return Buffer.concat(chunks, received);
} finally {
reader.releaseLock();
}
}
async function installRemoteFile(
parsed: Extract<ParsedPluginSource, { type: "remote" }>,
stagingRoot: string,
): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, REMOTE_PLUGIN_FETCH_TIMEOUT_MS);
try {
const response = await fetch(parsed.url, { signal: controller.signal });
if (!response.ok) {
const suffix = response.statusText ? ` ${response.statusText}` : "";
throw new Error(
`Failed to download plugin file from ${parsed.url}: ${response.status}${suffix}`,
);
}
const body = await readRemotePluginBody(response, parsed.url);
mkdirSync(stagingRoot, { recursive: true });
await writeFile(join(stagingRoot, parsed.filename), body);
return stagingRoot;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error(
`Timed out downloading plugin file from ${parsed.url} after ${REMOTE_PLUGIN_FETCH_TIMEOUT_MS}ms`,
);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
async function installLocalPackage(
parsed: Extract<ParsedPluginSource, { type: "local" }>,
stagingRoot: string,
@@ -737,6 +930,8 @@ 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 === "remote") {
packageRoot = await installRemoteFile(parsed, stagingRoot);
} else {
packageRoot = await installLocalPackage(
parsed,
@@ -747,7 +942,8 @@ export async function installPlugin(
}
const entryPaths =
parsed.type === "local" && packageRoot === stagingRoot
(parsed.type === "local" || parsed.type === "remote") &&
packageRoot === stagingRoot
? collectPluginEntries(stagingRoot).map(
(entry) => `./${toPosixPath(relative(stagingRoot, entry))}`,
)
+1 -1
View File
@@ -232,7 +232,7 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
if (opts.id !== undefined) result.id = opts.id;
// Positional args → prompt
const positional = program.args.filter((a) => !a.startsWith("-"));
const positional = program.args;
if (positional.length > 0) {
result.prompt = positional.join(" ");
}
@@ -121,6 +121,74 @@ describe("runScheduleCommand list output", () => {
});
});
describe("runScheduleCommand create delivery metadata", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("maps --delivery-bot to delivery.userName", async () => {
mockEnsureCliHubServer.mockResolvedValue({
url: "ws://127.0.0.1:25463/hub",
authToken: "test-token",
});
mockSendHubCommand.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "sched_delivery" } },
});
const output: string[] = [];
const errors: string[] = [];
const code = await runScheduleCommand(
[
"create",
"Daily summary",
"--cron",
"0 9 * * *",
"--prompt",
"Summarize yesterday",
"--workspace",
"/tmp/workspace",
"--delivery-adapter",
"telegram",
"--delivery-bot",
"my_bot",
"--delivery-thread",
"telegram:123456789",
"--address",
"127.0.0.1:25463",
],
{
writeln: (text?: string) => {
output.push(text ?? "");
},
writeErr: (text: string) => {
errors.push(text);
},
},
);
expect(code).toBe(0);
expect(errors).toEqual([]);
expect(output).toEqual(['{\n "scheduleId": "sched_delivery"\n}']);
expect(mockSendHubCommand).toHaveBeenCalledWith(
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
{
clientId: "cline-schedule",
command: "schedule.create",
payload: expect.objectContaining({
metadata: {
delivery: {
adapter: "telegram",
threadId: "telegram:123456789",
userName: "my_bot",
},
},
}),
},
);
});
});
describe("runScheduleCommand import", () => {
afterEach(() => {
vi.clearAllMocks();
+39 -1
View File
@@ -2,7 +2,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getInstallationInfo, PackageManager } from "./update";
import {
getInstallationInfo,
PackageManager,
withMinimumReleaseAgeBypass,
} from "./update";
const originalArgv = [...process.argv];
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
@@ -67,3 +71,37 @@ describe("getInstallationInfo", () => {
});
});
});
describe("withMinimumReleaseAgeBypass", () => {
it("adds the package-manager-specific cooldown bypass", () => {
expect(
withMinimumReleaseAgeBypass(
"npm install -g cline@latest",
PackageManager.NPM,
).command,
).toBe("npm install -g cline@latest --min-release-age=0");
expect(
withMinimumReleaseAgeBypass("bun add -g cline@latest", PackageManager.BUN)
.command,
).toBe("bun add -g cline@latest --minimum-release-age=0");
expect(
withMinimumReleaseAgeBypass(
"yarn global add cline@latest",
PackageManager.YARN,
).command,
).toBe("yarn global add cline@latest");
expect(
withMinimumReleaseAgeBypass(
"yarn global add cline@latest",
PackageManager.YARN,
).env?.YARN_NPM_MINIMAL_AGE_GATE,
).toBe("0");
expect(
withMinimumReleaseAgeBypass(
"pnpm add -g cline@latest",
PackageManager.PNPM,
).env?.pnpm_config_minimum_release_age,
).toBe("0");
});
});
+47 -6
View File
@@ -36,6 +36,11 @@ interface InstallationInfo {
updateCommand?: string;
}
interface ManualUpdateCommand {
command: string;
env?: Readonly<Record<string, string>>;
}
function isNightlyVersion(v: string): boolean {
return v.includes("-nightly.");
}
@@ -133,6 +138,34 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
};
}
export function withMinimumReleaseAgeBypass(
updateCommand: string,
packageManager: PackageManager,
): ManualUpdateCommand {
switch (packageManager) {
case PackageManager.NPM:
return { command: `${updateCommand} --min-release-age=0` };
case PackageManager.PNPM:
return {
command: updateCommand,
env: {
PNPM_CONFIG_MINIMUM_RELEASE_AGE: "0",
pnpm_config_minimum_release_age: "0",
npm_config_minimum_release_age: "0",
},
};
case PackageManager.YARN:
return {
command: updateCommand,
env: { YARN_NPM_MINIMAL_AGE_GATE: "0" },
};
case PackageManager.BUN:
return { command: `${updateCommand} --minimum-release-age=0` };
default:
return { command: updateCommand };
}
}
async function getLatestVersion(
packageName: CliPackageName,
currentVersion: string,
@@ -168,11 +201,15 @@ function waitForProcessExit(child: ChildProcess): Promise<number> {
});
}
async function runCliUpdate(updateCommand: string): Promise<number> {
const updateProcess = spawn(updateCommand, {
async function runCliUpdate(
updateCommand: ManualUpdateCommand,
): Promise<number> {
const updateProcess = spawn(updateCommand.command, {
stdio: "inherit",
shell: true,
env: process.env,
env: updateCommand.env
? { ...process.env, ...updateCommand.env }
: process.env,
windowsHide: true,
});
return waitForProcessExit(updateProcess);
@@ -432,17 +469,21 @@ export async function checkForUpdates(
);
hadFailure = true;
} else {
const manualUpdateCommand = withMinimumReleaseAgeBypass(
updateCommand,
packageManager,
);
writeln(
`${c.cyan}Installing ${packageName}@${latestVersion}${c.reset}`,
);
try {
const exitCode = await runCliUpdate(updateCommand);
const exitCode = await runCliUpdate(manualUpdateCommand);
if (exitCode === 0) {
installedUpdates.push(`${packageName}@${latestVersion}`);
await restartHubServerIfRunning();
} else {
writeErr(
`Cline update failed (exit code ${exitCode}). Try running: ${updateCommand}`,
`Cline update failed (exit code ${exitCode}). Try running: ${manualUpdateCommand.command}`,
);
hadFailure = true;
}
@@ -450,7 +491,7 @@ export async function checkForUpdates(
const message =
error instanceof Error ? error.message : String(error);
writeErr(
`Failed to run Cline update command ${updateCommand}: ${message}`,
`Failed to run Cline update command ${manualUpdateCommand.command}: ${message}`,
);
hadFailure = true;
}
@@ -831,7 +831,10 @@ class DiscordConnector extends ConnectorBase<
requestStop("rpc_server_shutting_down");
return;
}
if (event.eventType !== "schedule.execution.completed") {
if (
event.eventType !== "schedule.execution.completed" &&
event.eventType !== "schedule.execution.failed"
) {
return;
}
const scheduleId =
@@ -760,7 +760,10 @@ class GoogleChatConnector extends ConnectorBase<
requestStop("rpc_server_shutting_down");
return;
}
if (event.eventType !== "schedule.execution.completed") {
if (
event.eventType !== "schedule.execution.completed" &&
event.eventType !== "schedule.execution.failed"
) {
return;
}
const scheduleId =
@@ -797,7 +797,10 @@ class LinearConnector extends ConnectorBase<
requestStop("rpc_server_shutting_down");
return;
}
if (event.eventType !== "schedule.execution.completed") {
if (
event.eventType !== "schedule.execution.completed" &&
event.eventType !== "schedule.execution.failed"
) {
return;
}
const scheduleId =
@@ -996,7 +996,10 @@ class SlackConnector extends ConnectorBase<
requestStop("rpc_server_shutting_down");
return;
}
if (event.eventType !== "schedule.execution.completed") {
if (
event.eventType !== "schedule.execution.completed" &&
event.eventType !== "schedule.execution.failed"
) {
return;
}
const scheduleId =
@@ -8,29 +8,31 @@ Create a bot with `@BotFather`:
1. Open Telegram and start a chat with `@BotFather`.
2. Send `/newbot` and follow the prompts.
3. Copy the bot username without the leading `@`.
4. Copy the bot token. Treat it like a password.
3. Copy the bot token. Treat it like a password.
Start the connector:
```bash
cline connect telegram -m my_bot -k "$TELEGRAM_BOT_TOKEN"
cline connect telegram -k "$TELEGRAM_BOT_TOKEN"
```
The connector discovers the bot username from the token. Use `--bot-username`
only if you need to override it.
Useful variants:
```bash
# Keep logs in the active terminal while debugging.
cline connect telegram -i -m my_bot -k "$TELEGRAM_BOT_TOKEN"
cline connect telegram -i -k "$TELEGRAM_BOT_TOKEN"
# Read credentials from env vars.
TELEGRAM_BOT_USERNAME=my_bot TELEGRAM_BOT_TOKEN=123456:ABCDEF... cline connect telegram
TELEGRAM_BOT_TOKEN=123456:ABCDEF... cline connect telegram
# Override the workspace and model used for Telegram sessions.
cline connect telegram -m my_bot -k "$TELEGRAM_BOT_TOKEN" --cwd /path/to/repo --provider cline --model openai/gpt-5.3-codex
cline connect telegram -k "$TELEGRAM_BOT_TOKEN" --cwd /path/to/repo --provider cline --model openai/gpt-5.3-codex
# Disable tools for untrusted Telegram surfaces.
cline connect telegram -m my_bot -k "$TELEGRAM_BOT_TOKEN" --no-tools
cline connect telegram -k "$TELEGRAM_BOT_TOKEN" --no-tools
# Stop Telegram connector processes and sessions.
cline connect --stop telegram
@@ -69,7 +71,7 @@ Tools are enabled by default for Telegram sessions. That means anyone who can su
Use `--no-tools` when the Telegram surface is not trusted:
```bash
cline connect telegram -m my_bot -k "$TELEGRAM_BOT_TOKEN" --no-tools
cline connect telegram -k "$TELEGRAM_BOT_TOKEN" --no-tools
```
When the connector starts with `--no-tools`, chat commands such as `/tools on` and `/yolo on` cannot re-enable tools for that connector run.
@@ -109,6 +111,11 @@ cline schedule create "Daily summary" \
--delivery-thread telegram:123456789
```
`--delivery-bot` is still supported after token-only connector setup. Use
the Telegram bot username shown by `/whereami` as `deliveryUserName` when you
need to target one specific Telegram connector, or omit the flag when the
schedule does not need to be restricted to a specific bot.
The connector must be running when the scheduled result is delivered, and the target thread must have an existing thread binding.
## Limitations
@@ -1,5 +1,8 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ConnectTelegramOptions } from "@cline/shared";
import { describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { __test__, telegramConnector } from "./telegram";
const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
@@ -9,6 +12,28 @@ const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
}
).parseArgs(rawArgs);
const originalClineDataDir = process.env.CLINE_DATA_DIR;
const tempDataDirs: string[] = [];
function useTempClineDataDir(): string {
const dataDir = mkdtempSync(join(tmpdir(), "cline-telegram-test-"));
tempDataDirs.push(dataDir);
process.env.CLINE_DATA_DIR = dataDir;
return dataDir;
}
afterEach(() => {
vi.unstubAllGlobals();
if (originalClineDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalClineDataDir;
}
for (const dir of tempDataDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("telegramConnector", () => {
it("honors --no-tools", () => {
const options = parseTelegramArgs([
@@ -36,6 +61,176 @@ describe("telegramConnector", () => {
expect(options.enableTools).toBe(true);
});
it("does not require the bot username", () => {
const options = parseTelegramArgs([
"--bot-token",
"123:test",
"--cwd",
"/tmp/work",
]);
expect(options.botUsername).toBeUndefined();
expect(options.botToken).toBe("123:test");
});
it("normalizes an explicit bot username", () => {
const options = parseTelegramArgs([
"--bot-username",
" @test_bot ",
"--bot-token",
"123:test",
"--cwd",
"/tmp/work",
]);
expect(options.botUsername).toBe("test_bot");
});
it("does not call getMe when the token-only connector is already running", async () => {
const dataDir = useTempClineDataDir();
const connectorDir = join(dataDir, "connectors", "telegram");
mkdirSync(connectorDir, { recursive: true });
writeFileSync(
join(connectorDir, "resolved_bot.json"),
JSON.stringify({
botUsername: "resolved_bot",
botId: "123",
pid: process.pid,
rpcAddress: "127.0.0.1:54321",
startedAt: new Date().toISOString(),
}),
);
const fetchImpl = vi.fn(async () => {
throw new Error("unexpected getMe call");
});
vi.stubGlobal("fetch", fetchImpl);
const output: string[] = [];
const errors: string[] = [];
await expect(
telegramConnector.run(["--bot-token", "123:test", "--cwd", "/tmp/work"], {
writeln: (text = "") => output.push(text),
writeErr: (text) => errors.push(text),
}),
).resolves.toBe(0);
expect(fetchImpl).not.toHaveBeenCalled();
expect(errors).toEqual([]);
expect(output).toEqual([
`[telegram] connector already running pid=${process.pid} rpc=127.0.0.1:54321`,
]);
});
});
describe("telegram bot username resolution", () => {
it("reads the public Telegram bot id from a token", () => {
expect(__test__.readTelegramBotId("123456:secret")).toBe("123456");
expect(__test__.readTelegramBotId("not-a-token")).toBeUndefined();
});
it("uses the configured username without calling Telegram", async () => {
const fetchImpl = vi.fn(async () => {
throw new Error("unexpected fetch");
});
await expect(
__test__.resolveTelegramBotUsername(
{
botToken: "123:test",
botUsername: "@configured_bot",
cwd: "/tmp/work",
mode: "act",
interactive: true,
enableTools: true,
rpcAddress: "127.0.0.1:0",
},
fetchImpl,
),
).resolves.toBe("configured_bot");
expect(fetchImpl).not.toHaveBeenCalled();
});
it("fetches the username from Telegram getMe when omitted", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
ok: true,
result: { username: "resolved_bot" },
}),
);
});
await expect(
__test__.fetchTelegramBotUsername("123:test", fetchImpl),
).resolves.toBe("resolved_bot");
expect(fetchImpl).toHaveBeenCalledWith(
"https://api.telegram.org/bot123:test/getMe",
);
});
it("surfaces Telegram getMe failures", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(
JSON.stringify({
ok: false,
description: "Unauthorized",
}),
{ status: 401, statusText: "Unauthorized" },
);
});
await expect(
__test__.fetchTelegramBotUsername("bad-token", fetchImpl),
).rejects.toThrow("Telegram getMe failed");
});
});
describe("telegram participant resolution", () => {
it("uses the stable numeric Telegram user id when username is also present", () => {
const result = __test__.resolveTelegramParticipant({
message: {
from: {
id: 1201547643,
username: "AraFatKatze",
first_name: "Ara",
},
},
});
expect(result).toEqual({
key: "telegram:id:1201547643",
label: "arafatkatze",
});
});
it("falls back to username when Telegram does not provide a numeric user id", () => {
const result = __test__.resolveTelegramParticipant({
message: {
from: {
username: "Alice",
},
},
});
expect(result).toEqual({
key: "telegram:user:alice",
label: "alice",
});
});
it("accepts string numeric user ids from raw Telegram payloads", () => {
const result = __test__.resolveTelegramParticipant({
message: {
from: {
id: "1201547643",
username: "arafatkatze",
},
},
});
expect(result?.key).toBe("telegram:id:1201547643");
});
});
describe("telegram binding lookup", () => {
+149 -13
View File
@@ -63,13 +63,86 @@ import { postTelegramFormattedReply } from "./telegram-format";
const TELEGRAM_SYSTEM_RULES = getConnectorSystemRules("Telegram");
const TELEGRAM_FIRST_CONTACT_MESSAGE = getConnectorFirstContactMessage();
const TELEGRAM_API_BASE = "https://api.telegram.org";
type TelegramThreadState = ConnectorThreadState;
type ResolvedTelegramOptions = ConnectTelegramOptions & { botUsername: string };
type TelegramGetMeResponse = {
ok?: boolean;
description?: string;
result?: {
username?: string;
};
};
type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
function truncateText(value: string, maxLength = 160): string {
return truncateConnectorText(value, maxLength);
}
function normalizeTelegramBotUsername(value: string): string {
return value.trim().replace(/^@+/, "");
}
function readTelegramBotId(botToken: string): string | undefined {
const [botId] = botToken.trim().split(":", 1);
return /^\d+$/.test(botId) ? botId : undefined;
}
function describeTelegramGetMeFailure(
response: Response,
body: string,
parsed: TelegramGetMeResponse | undefined,
): string {
const detail = parsed?.description || body.trim().slice(0, 240);
return detail
? `Telegram getMe failed (${response.status} ${response.statusText}): ${detail}`
: `Telegram getMe failed (${response.status} ${response.statusText})`;
}
async function readTelegramGetMeResponse(
response: Response,
): Promise<TelegramGetMeResponse> {
const text = await response.text();
let parsed: TelegramGetMeResponse;
try {
parsed = JSON.parse(text) as TelegramGetMeResponse;
} catch {
throw new Error(describeTelegramGetMeFailure(response, text, undefined));
}
if (!response.ok || parsed.ok !== true) {
throw new Error(describeTelegramGetMeFailure(response, text, parsed));
}
return parsed;
}
async function fetchTelegramBotUsername(
botToken: string,
fetchImpl: FetchLike = fetch,
apiBaseUrl = TELEGRAM_API_BASE,
): Promise<string> {
const response = await fetchImpl(`${apiBaseUrl}/bot${botToken}/getMe`);
const payload = await readTelegramGetMeResponse(response);
const username = normalizeTelegramBotUsername(payload.result?.username ?? "");
if (!username) {
throw new Error(
"Telegram getMe did not return a bot username; pass --bot-username explicitly",
);
}
return username;
}
async function resolveTelegramBotUsername(
options: ConnectTelegramOptions,
fetchImpl?: FetchLike,
): Promise<string> {
const configured = normalizeTelegramBotUsername(options.botUsername ?? "");
if (configured) {
return configured;
}
return fetchTelegramBotUsername(options.botToken, fetchImpl);
}
async function stopSessionsForBot(
state: TelegramConnectorState,
): Promise<number> {
@@ -135,12 +208,12 @@ function resolveTelegramParticipant(
const lastName = readString(from?.last_name);
const label =
username || [firstName, lastName].filter(Boolean).join(" ") || userId;
if (username) {
return { key: `telegram:user:${username}`, label };
}
if (userId) {
return { key: `telegram:id:${userId}`, label };
}
if (username) {
return { key: `telegram:user:${username}`, label };
}
return undefined;
}
@@ -331,8 +404,11 @@ class TelegramConnector extends ConnectorBase<
protected override createCommand(): Command {
return super
.createCommand()
.usage("-m <TELEGRAM_BOT_USERNAME> -k <TELEGRAM_BOT_TOKEN> [options]")
.option("-m, --bot-username <name>", "Telegram bot username")
.usage("-k <TELEGRAM_BOT_TOKEN> [options]")
.option(
"-m, --bot-username <name>",
"Telegram bot username; fetched from token if omitted",
)
.option("-k, --bot-token <token>", "Telegram bot token")
.option("--provider <id>", "Provider override")
.option("--model <id>", "Model override")
@@ -358,6 +434,7 @@ class TelegramConnector extends ConnectorBase<
"Notes:",
" - Without -i, the connector is launched in the background.",
" - Tools are enabled by default for Telegram sessions.",
" - Bot username is discovered from the Telegram bot token when omitted.",
" - Provider/model default to the CLI's last-used provider settings.",
].join("\n"),
);
@@ -379,18 +456,18 @@ class TelegramConnector extends ConnectorBase<
hookCommand?: string;
}>();
const botUsername =
opts.botUsername?.trim() || process.env.TELEGRAM_BOT_USERNAME?.trim();
normalizeTelegramBotUsername(opts.botUsername ?? "") ||
normalizeTelegramBotUsername(
process.env.TELEGRAM_BOT_USERNAME?.trim() ?? "",
);
const botToken =
opts.botToken?.trim() || process.env.TELEGRAM_BOT_TOKEN?.trim();
if (!botUsername) {
throw new Error("connect telegram requires -m/--bot-username <name>");
}
if (!botToken) {
throw new Error("connect telegram requires -k/--bot-token <token>");
}
return {
botToken,
botUsername,
...(botUsername ? { botUsername } : {}),
cwd: opts.cwd || process.cwd(),
model: opts.model,
provider: opts.provider,
@@ -445,6 +522,28 @@ class TelegramConnector extends ConnectorBase<
this.writeStateFile(statePath, state);
}
private findRunningConnectorStateByBotId(
botId: string | undefined,
): TelegramConnectorState | undefined {
if (!botId) {
return undefined;
}
for (const statePath of this.listConnectorStatePaths()) {
const state = this.readConnectorState(statePath);
if (!state) {
continue;
}
if (!isProcessRunning(state.pid)) {
this.removeStateFile(statePath);
continue;
}
if (state.botId === botId) {
return state;
}
}
return undefined;
}
private async stopTelegramConnectorInstance(
statePath: string,
io: ConnectIo,
@@ -475,10 +574,39 @@ class TelegramConnector extends ConnectorBase<
}
protected override async runWithOptions(
options: ConnectTelegramOptions,
inputOptions: ConnectTelegramOptions,
rawArgs: string[],
io: ConnectIo,
): Promise<number> {
if (
!inputOptions.botUsername &&
!inputOptions.interactive &&
process.env.CLINE_TELEGRAM_CONNECT_CHILD !== "1"
) {
const runningState = this.findRunningConnectorStateByBotId(
readTelegramBotId(inputOptions.botToken),
);
if (runningState) {
io.writeln(
`[telegram] connector already running pid=${runningState.pid} rpc=${runningState.rpcAddress}`,
);
return 0;
}
}
let resolvedBotUsername: string;
try {
resolvedBotUsername = await resolveTelegramBotUsername(inputOptions);
} catch (error) {
io.writeErr(error instanceof Error ? error.message : String(error));
return 1;
}
const options: ResolvedTelegramOptions = {
...inputOptions,
botUsername: resolvedBotUsername,
};
const backgroundArgs = inputOptions.botUsername
? rawArgs
: [...rawArgs, "--bot-username", resolvedBotUsername];
const statePath = this.resolveConnectorStatePath(options.botUsername);
const bindingsPath = this.resolveBindingsPath(options.botUsername);
this.removeStaleState(
@@ -488,7 +616,7 @@ class TelegramConnector extends ConnectorBase<
);
if (
await this.maybeRunInBackground({
rawArgs,
rawArgs: backgroundArgs,
io,
interactive: options.interactive,
childEnvVar: "CLINE_TELEGRAM_CONNECT_CHILD",
@@ -574,6 +702,7 @@ class TelegramConnector extends ConnectorBase<
await client.connect();
this.writeConnectorState(statePath, {
botUsername: options.botUsername,
botId: readTelegramBotId(options.botToken),
pid: process.pid,
rpcAddress,
startedAt: new Date().toISOString(),
@@ -847,7 +976,10 @@ class TelegramConnector extends ConnectorBase<
requestStop("rpc_server_shutting_down");
return;
}
if (event.eventType !== "schedule.execution.completed") {
if (
event.eventType !== "schedule.execution.completed" &&
event.eventType !== "schedule.execution.failed"
) {
return;
}
const scheduleId =
@@ -957,6 +1089,10 @@ export const telegramConnector: ConnectCommandDefinition =
new TelegramConnector();
export const __test__ = {
fetchTelegramBotUsername,
readTelegramBotId,
resolveTelegramBotUsername,
resolveTelegramParticipant,
findBindingForThread: (
bindings: ConnectorBindingStore<TelegramThreadState>,
thread: Pick<Thread<TelegramThreadState>, "id" | "channelId" | "isDM"> & {
@@ -786,7 +786,10 @@ class WhatsAppConnector extends ConnectorBase<
requestStop("rpc_server_shutting_down");
return;
}
if (event.eventType !== "schedule.execution.completed") {
if (
event.eventType !== "schedule.execution.completed" &&
event.eventType !== "schedule.execution.failed"
) {
return;
}
const scheduleId =
+21
View File
@@ -657,6 +657,27 @@ describe("runCli lightweight command dispatch", () => {
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
});
it("treats dash-prefixed positional text after -- as a prompt", async () => {
forcePromptModeInput();
process.argv = [
"bun",
"src/index.ts",
"--",
"- You are given a PyTorch state dictionary.",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"- You are given a PyTorch state dictionary.",
expect.any(Object),
expect.anything(),
);
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
});
it("applies --auto-approve as a runtime policy without changing the config default", async () => {
process.argv = ["bun", "src/index.ts", "--auto-approve", "false"];
+5 -2
View File
@@ -245,8 +245,11 @@ export async function runCli(): Promise<void> {
const pluginInstallCmd = pluginCmd
.command("install")
.alias("i")
.description("Install a Cline Plugin from npm, git, or a local path")
.argument("<source>", "npm package, git URL, or local plugin path")
.description("Install a Cline Plugin from npm, git, URL, or a local path")
.argument(
"<source>",
"npm package, git URL, plugin file URL, or local plugin path",
)
.option("--npm", "Treat source as an npm package")
.option("--git", "Treat source as a git repository")
.option("--force", "Replace an existing install for the same source")
@@ -42,6 +42,7 @@ function makeRuntime(): InteractiveChatCommandRuntime {
return {
forkCurrentSession: vi.fn(async () => undefined),
getActiveSessionId: vi.fn(() => "session-1"),
resetForNewSession: vi.fn(async () => {}),
restartEmpty: vi.fn(async () => {}),
};
}
@@ -101,6 +102,34 @@ describe("runInteractiveChatCommand", () => {
expect(runtime.restartEmpty).toHaveBeenCalledOnce();
});
it("resets slash new without eagerly restarting the runtime", async () => {
const config = makeConfig();
const runtime = makeRuntime();
const result = await runInteractiveChatCommand({
prompt: "/new",
enabled: true,
config,
host: chatCommandHost,
chatCommandState: makeState(config),
autoApproveAllRef: { current: false },
setInteractiveAutoApprove: () => {},
sessionRuntime: runtime,
stop: () => {},
});
expect(result).toEqual({
handled: true,
turnResult: {
usage: { inputTokens: 0, outputTokens: 0 },
iterations: 0,
commandOutput: "Started a fresh session.",
},
});
expect(runtime.resetForNewSession).toHaveBeenCalledOnce();
expect(runtime.restartEmpty).not.toHaveBeenCalled();
});
it("applies chat command state updates and returns command output", async () => {
const config = makeConfig();
const runtime = makeRuntime();
@@ -18,7 +18,10 @@ type AutoApproveRef = {
export type InteractiveChatCommandRuntime = Pick<
ReturnType<typeof createInteractiveSessionRuntime>,
"forkCurrentSession" | "getActiveSessionId" | "restartEmpty"
| "forkCurrentSession"
| "getActiveSessionId"
| "resetForNewSession"
| "restartEmpty"
>;
export type InteractiveChatCommandResult =
@@ -79,7 +82,7 @@ export async function runInteractiveChatCommand(input: {
commandOutput = text;
},
reset: async () => {
await input.sessionRuntime.restartEmpty();
await input.sessionRuntime.resetForNewSession();
},
stop: async () => {
input.stop();
@@ -3,7 +3,14 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { UserInstructionConfigService } from "@cline/core";
import { afterEach, describe, expect, it } from "vitest";
import type { InteractiveConfigItem } from "../../tui/interactive-config";
import {
buildSlashCommandRegistry,
expandUserCommandPrompt,
} from "../../tui/commands/slash-command-registry";
import {
applyPluginFailures,
type InteractiveConfigItem,
} from "../../tui/interactive-config";
import type { Config } from "../../utils/types";
import { createInteractiveConfigDataLoader } from "./config-data";
@@ -45,6 +52,30 @@ describe("interactive config data loader", () => {
tempRoots.length = 0;
});
async function writeSettingsPlugin(tempRoot: string): Promise<string> {
const pluginsDir = join(tempRoot, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
const pluginPath = join(pluginsDir, "settings-plugin.js");
await writeFile(
pluginPath,
[
"export default {",
" name: 'settings-plugin',",
" manifest: { capabilities: ['tools'] },",
" setup(api) {",
" api.registerTool({",
" name: 'settings_plugin_tool',",
" description: 'Settings plugin tool',",
" inputSchema: { type: 'object', properties: {} },",
" execute: async () => 'ok',",
" });",
" },",
"};",
].join("\n"),
);
return pluginPath;
}
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -64,6 +95,19 @@ Use this skill.`,
calls.push(`refreshType:${type}`);
refreshed = true;
},
listRuntimeCommands() {
calls.push("listRuntimeCommands");
return refreshed
? []
: [
{
name: "skill-one",
instructions: "Use this skill.",
description: "Skill one",
kind: "skill",
},
];
},
listRecords(type: string) {
calls.push(`listRecords:${type}`);
if (type !== "skill") {
@@ -103,12 +147,97 @@ Use this skill.`,
expect(written).toContain("disabled: true");
expect(data?.skills[0]?.enabled).toBe(false);
expect(
data?.workflowSlashCommands.map((command) => command.name),
).not.toContain("skill-one");
expect(calls).toContain("refreshType:skill");
expect(calls.lastIndexOf("listRecords:skill")).toBeGreaterThan(
calls.indexOf("refreshType:skill"),
);
});
it("returns refreshed slash commands so disabled skills stop expanding before submit", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const skillPath = join(tempRoot, "SKILL.md");
await writeFile(
skillPath,
`---
name: find-skills
---
Find installable skills.`,
);
let refreshed = false;
const userInstructionService = {
async refreshType() {
refreshed = true;
},
listRuntimeCommands() {
return refreshed
? []
: [
{
name: "find-skills",
instructions: "Find installable skills.",
description: "Find skills",
kind: "skill",
},
];
},
listRecords(type: string) {
if (type !== "skill") {
return [];
}
return [
{
id: "find-skills",
type: "skill",
filePath: skillPath,
item: {
name: "find-skills",
disabled: refreshed,
description: "Find skills",
instructions: "Find installable skills.",
frontmatter: {},
},
},
];
},
} as unknown as UserInstructionConfigService;
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
userInstructionService,
});
const initialData = await loader.loadConfigData();
const initialRegistry = buildSlashCommandRegistry({
workflowSlashCommands: initialData.workflowSlashCommands,
});
expect(
expandUserCommandPrompt("/find-skills what can u do?", initialRegistry),
).toContain("<user_command");
const nextData = await loader.onToggleConfigItem({
id: "find-skills",
name: "find-skills",
path: skillPath,
enabled: true,
source: "workspace",
kind: "skill",
});
const refreshedRegistry = buildSlashCommandRegistry({
workflowSlashCommands: nextData?.workflowSlashCommands,
});
expect(
nextData?.workflowSlashCommands.map((command) => command.name),
).not.toContain("find-skills");
expect(
expandUserCommandPrompt("/find-skills what can u do?", refreshedRegistry),
).toBe("/find-skills what can u do?");
});
it("keeps plugin tool toggle behavior", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -136,7 +265,126 @@ Use this skill.`,
) as { disabledTools?: string[] };
expect(settings.disabledTools).toEqual(["plugin-tool"]);
expect(data).toBeDefined();
expect(data).toBeUndefined();
});
it("can skip plugin tool imports for fast settings open", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
const pluginPath = await writeSettingsPlugin(tempRoot);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: false });
expect(data.plugins.some((item) => item.path === pluginPath)).toBe(true);
expect(
data.tools.some((item) => item.pluginName === "settings-plugin"),
).toBe(false);
});
it("loads plugin tools when requested", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
await writeSettingsPlugin(tempRoot);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: true });
expect(
data.tools.some(
(item) =>
item.pluginName === "settings-plugin" &&
item.name === "settings_plugin_tool",
),
).toBe(true);
});
it("keeps failed plugins visible with their load error", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
const pluginsDir = join(tempRoot, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
const pluginPath = join(pluginsDir, "broken-plugin.js");
const invalidPluginPath = join(pluginsDir, "invalid-plugin.js");
await writeFile(
pluginPath,
[
"export default {",
" name: 'broken-plugin',",
" manifest: { capabilities: ['tools'] },",
" setup() {",
" throw new Error('setup exploded');",
" },",
"};",
].join("\n"),
);
await writeFile(invalidPluginPath, "export default {};\n", "utf8");
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: true });
const plugin = data.plugins.find((item) => item.path === pluginPath);
expect(plugin?.name).toBe("broken-plugin");
expect(plugin?.loadErrorPhase).toBe("setup");
expect(plugin?.loadError).toContain("setup failed: setup exploded");
const invalidPlugin = data.plugins.find(
(item) => item.path === invalidPluginPath,
);
expect(invalidPlugin?.name).toBe("invalid-plugin");
expect(invalidPlugin?.loadErrorPhase).toBe("load");
expect(invalidPlugin?.loadError).toContain("load failed:");
});
it("preserves multiple load failures for the same plugin path", () => {
const plugin: InteractiveConfigItem = {
id: "/tmp/plugin.js",
name: "plugin",
path: "/tmp/plugin.js",
enabled: true,
kind: "plugin",
source: "workspace-plugin",
};
applyPluginFailures(
[plugin],
[
{
pluginPath: "/tmp/plugin.js",
pluginName: "plugin",
phase: "setup",
message: "first failure",
},
{
pluginPath: "/tmp/plugin.js",
phase: "setup",
message: "second failure",
},
],
);
expect(plugin.loadError).toBe(
"setup failed: first failure\nsetup failed: second failure",
);
expect(plugin.loadErrorPhase).toBeUndefined();
});
it("toggles every SDK tool name for a displayed built-in tool", async () => {
@@ -190,15 +438,20 @@ Use this skill.`,
const data = await loader.loadConfigData();
const plugin = data.plugins.find((item) => item.path === pluginPath);
expect(plugin?.enabled).toBe(false);
if (!plugin) {
throw new Error("Expected workspace plugin to be listed");
}
const nextData = await loader.onToggleConfigItem(plugin!);
const nextData = await loader.onToggleConfigItem(plugin);
const refreshedData = await loader.loadConfigData();
const settings = JSON.parse(
await readFile(process.env.CLINE_GLOBAL_SETTINGS_PATH, "utf8"),
) as { disabledPlugins?: string[] };
expect(settings.disabledPlugins).toBeUndefined();
expect(nextData).toBeUndefined();
expect(
nextData?.plugins.find((item) => item.path === pluginPath)?.enabled,
refreshedData.plugins.find((item) => item.path === pluginPath)?.enabled,
).toBe(true);
});
@@ -1,11 +1,13 @@
import {
createCoreSettingsService,
setDisabledPlugin,
setDisabledTools,
type UserInstructionConfigService,
} from "@cline/core";
import {
type InteractiveConfigData,
type InteractiveConfigItem,
type LoadInteractiveConfigDataOptions,
loadInteractiveConfigData,
} from "../../tui/interactive-config";
import type { Config } from "../../utils/types";
@@ -23,16 +25,20 @@ export function createInteractiveConfigDataLoader(input: {
enableSpawnAgent: input.config.enableSpawnAgent,
enableAgentTeams: input.config.enableAgentTeams,
});
const loadConfigData = async (): Promise<InteractiveConfigData> =>
const loadConfigData = async (
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData> =>
await loadInteractiveConfigData({
userInstructionService: input.userInstructionService,
cwd: input.config.cwd,
workspaceRoot: workspaceRoot(),
availabilityContext: availabilityContext(),
includePluginTools: options.includePluginTools,
});
const onToggleConfigItem = async (
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData | undefined> => {
const settings = createCoreSettingsService();
if (item.kind === "skill" && typeof item.enabled === "boolean") {
@@ -47,12 +53,12 @@ export function createInteractiveConfigDataLoader(input: {
userInstructionService: input.userInstructionService,
availabilityContext: availabilityContext(),
});
return await loadConfigData();
return await loadConfigData(options);
}
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
setDisabledPlugin(item.path, item.enabled);
return await loadConfigData();
return undefined;
}
if (item.kind === "mcp" && typeof item.enabled === "boolean") {
@@ -66,7 +72,7 @@ export function createInteractiveConfigDataLoader(input: {
workspaceRoot: workspaceRoot(),
availabilityContext: availabilityContext(),
});
return await loadConfigData();
return await loadConfigData(options);
}
if (
@@ -82,18 +88,22 @@ export function createInteractiveConfigDataLoader(input: {
? item.toolNames
: [item.name];
const toolNames = [...new Set(rawToolNames.filter(Boolean))];
if (typeof item.enabled === "boolean") {
setDisabledTools(toolNames, item.enabled);
return undefined;
}
for (const name of toolNames) {
await settings.toggle({
type: "tools",
name,
enabled: typeof item.enabled === "boolean" ? !item.enabled : undefined,
cwd: input.config.cwd,
workspaceRoot: workspaceRoot(),
userInstructionService: input.userInstructionService,
availabilityContext: availabilityContext(),
});
}
return await loadConfigData();
return undefined;
};
return {
@@ -0,0 +1,234 @@
import type {
AgentEvent,
ProviderSettingsManager,
TeamEvent,
ToolApprovalRequest,
ToolApprovalResult,
} from "@cline/core";
import type { AgentTool } from "@cline/shared";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatCommandState } from "../../utils/chat-commands";
import type { Config } from "../../utils/types";
const {
mockCreateCliCore,
mockCreateRuntimeHooks,
mockLoadInteractiveResumeMessages,
mockSetActiveCliSession,
} = vi.hoisted(() => ({
mockCreateCliCore: vi.fn(),
mockCreateRuntimeHooks: vi.fn(),
mockLoadInteractiveResumeMessages: vi.fn(),
mockSetActiveCliSession: vi.fn(),
}));
vi.mock("../../session/session", () => ({
createCliCore: mockCreateCliCore,
}));
vi.mock("../../utils/hooks", () => ({
createRuntimeHooks: mockCreateRuntimeHooks,
}));
vi.mock("../../utils/output", () => ({
setActiveCliSession: mockSetActiveCliSession,
}));
vi.mock("../../utils/resume", () => ({
loadInteractiveResumeMessages: mockLoadInteractiveResumeMessages,
}));
vi.mock("../../utils/approval", () => ({
submitAndExitInTerminal: vi.fn(),
}));
vi.mock("../active-runtime", () => ({
markAbortInProgress: vi.fn(),
}));
vi.mock("../session-events", () => ({
subscribeToAgentEvents: vi.fn(() => vi.fn()),
subscribeToPendingPromptEvents: vi.fn(() => vi.fn()),
}));
import { createInteractiveSessionRuntime } from "./session-runtime";
function makeConfig(): Config {
return {
apiKey: "",
providerId: "cline",
modelId: "openai/gpt-5.3-codex",
verbose: false,
sandbox: false,
thinking: false,
outputMode: "text",
mode: "act",
systemPrompt: "",
enableTools: true,
enableSpawnAgent: true,
enableAgentTeams: false,
defaultToolAutoApprove: false,
toolPolicies: {},
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
};
}
function makeChatCommandState(config: Config): ChatCommandState {
return {
enableTools: config.enableTools,
autoApproveTools: config.defaultToolAutoApprove,
cwd: config.cwd,
workspaceRoot: config.workspaceRoot?.trim() || config.cwd,
};
}
function makeSwitchToActModeTool(): AgentTool {
return {
name: "switch_to_act_mode",
description: "Switch to act mode",
inputSchema: { type: "object", properties: {} },
execute: () => ({ ok: true }),
};
}
function makeManager() {
let startCount = 0;
const start = vi.fn(async (_input?: unknown) => {
startCount += 1;
const sessionId = `session-${startCount}`;
return {
sessionId,
manifest: {
session_id: sessionId,
},
};
});
return {
start,
stop: vi.fn(async () => {}),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
abort: vi.fn(),
dispose: vi.fn(),
get: vi.fn(),
readMessages: vi.fn(async () => []),
readTranscript: vi.fn(),
ingestHookEvent: vi.fn(),
subscribe: vi.fn(),
updateSessionModel: vi.fn(),
pendingPrompts: {
update: vi.fn(),
},
restore: vi.fn(),
};
}
function makeRuntime(
manager: ReturnType<typeof makeManager>,
options: { resumeSessionId?: string } = {},
) {
mockCreateCliCore.mockResolvedValue(manager);
const config = makeConfig();
return createInteractiveSessionRuntime({
config,
providerSettingsManager: {} as ProviderSettingsManager,
resumeSessionId: options.resumeSessionId,
chatCommandState: makeChatCommandState(config),
requestToolApproval: async (
_request: ToolApprovalRequest,
): Promise<ToolApprovalResult> => ({ approved: true }),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: makeSwitchToActModeTool(),
onAgentEvent: (_event: AgentEvent) => {},
onTeamEvent: (_event: TeamEvent) => {},
onPendingPrompts: () => {},
onPendingPromptSubmitted: () => {},
});
}
describe("createInteractiveSessionRuntime", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateRuntimeHooks.mockReturnValue({
hooks: undefined,
shutdown: vi.fn(async () => {}),
});
mockLoadInteractiveResumeMessages.mockResolvedValue([]);
});
it("defers creating the replacement session after a new-session reset", async () => {
const manager = makeManager();
const runtime = makeRuntime(manager);
await runtime.ensureReady();
expect(manager.start).toHaveBeenCalledOnce();
expect(runtime.getActiveSessionId()).toBe("session-1");
await runtime.resetForNewSession();
expect(manager.stop).toHaveBeenCalledWith("session-1");
expect(manager.start).toHaveBeenCalledOnce();
expect(runtime.getActiveSessionId()).toBe("");
expect(mockSetActiveCliSession).toHaveBeenLastCalledWith(undefined);
await runtime.ensureReady();
expect(manager.start).toHaveBeenCalledTimes(2);
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("starts fresh after resetting an initially resumed session", async () => {
const manager = makeManager();
const runtime = makeRuntime(manager, {
resumeSessionId: "resumed-session",
});
await runtime.ensureReady();
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
1,
manager,
"resumed-session",
);
expect(manager.start).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
config: expect.objectContaining({
sessionId: "resumed-session",
}),
}),
);
await runtime.resetForNewSession();
await runtime.ensureReady();
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
2,
manager,
undefined,
);
expect(manager.start).toHaveBeenCalledTimes(2);
expect(manager.start).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
config: expect.not.objectContaining({
sessionId: "resumed-session",
}),
}),
);
});
it("keeps explicit empty restarts eager for config-driven restarts", async () => {
const manager = makeManager();
const runtime = makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartEmpty();
expect(manager.stop).toHaveBeenCalledWith("session-1");
expect(manager.start).toHaveBeenCalledTimes(2);
expect(runtime.getActiveSessionId()).toBe("session-2");
});
});
@@ -74,8 +74,16 @@ export function createInteractiveSessionRuntime(input: {
let shutdownRequested = false;
let activeSessionId = "";
let abortRequested = false;
// A reset can happen while an earlier manager.start() is still in flight.
// Bump this before resets and restarts so stale starts cannot become active.
let sessionStartGeneration = 0;
const initialResumeSessionId = input.resumeSessionId?.trim() || undefined;
let pendingResumeSessionId = input.resumeSessionId?.trim() || undefined;
const clearActiveSession = (): void => {
activeSessionId = "";
setActiveCliSession(undefined);
};
const applyStartedSession = (started: StartedSession): void => {
setActiveCliSession({
@@ -155,6 +163,7 @@ export function createInteractiveSessionRuntime(input: {
initial: Message[] = [],
sessionMetadata?: Record<string, unknown>,
): Promise<void> => {
const generation = sessionStartGeneration;
const manager = await ensureSessionManager();
const started = await manager.start({
source: SessionSource.CLI,
@@ -168,6 +177,10 @@ export function createInteractiveSessionRuntime(input: {
onTeamRestored: () => {},
},
});
if (generation !== sessionStartGeneration) {
await manager.stop(started.sessionId).catch(() => {});
return;
}
applyStartedSession(started);
};
@@ -175,6 +188,7 @@ export function createInteractiveSessionRuntime(input: {
resumeId: string,
initial: Message[] | undefined,
): Promise<void> => {
const generation = sessionStartGeneration;
const manager = await ensureSessionManager();
const started = await manager.start({
source: SessionSource.CLI,
@@ -190,24 +204,35 @@ export function createInteractiveSessionRuntime(input: {
onTeamRestored: () => {},
},
});
if (generation !== sessionStartGeneration) {
await manager.stop(started.sessionId).catch(() => {});
return;
}
applyStartedSession(started);
};
const ensureReady = async (): Promise<void> => {
if (activeSessionId) {
return;
}
if (startupPromise) {
return await startupPromise;
}
startupPromise = (async () => {
const manager = await ensureSessionManager();
const resumeSessionId = pendingResumeSessionId;
const initialMessages = await loadInteractiveResumeMessages(
manager,
input.resumeSessionId,
resumeSessionId,
);
if (shutdownRequested) {
return;
}
if (initialResumeSessionId) {
await startResumedSession(initialResumeSessionId, initialMessages);
if (resumeSessionId) {
await startResumedSession(resumeSessionId, initialMessages);
if (pendingResumeSessionId === resumeSessionId) {
pendingResumeSessionId = undefined;
}
} else {
await startFreshSession(initialMessages);
}
@@ -226,8 +251,9 @@ export function createInteractiveSessionRuntime(input: {
};
const stopCurrentSession = async (): Promise<void> => {
if (sessionManager && activeSessionId) {
await sessionManager.stop(activeSessionId);
const sessionId = activeSessionId;
if (sessionManager && sessionId) {
await sessionManager.stop(sessionId);
}
};
@@ -262,7 +288,12 @@ export function createInteractiveSessionRuntime(input: {
messages: Message[],
sessionMetadata?: Record<string, unknown>,
): Promise<void> => {
sessionStartGeneration += 1;
pendingResumeSessionId = undefined;
startupPromise = undefined;
startupError = undefined;
await stopCurrentSession();
clearActiveSession();
await startFreshSession(messages, sessionMetadata);
};
@@ -275,6 +306,19 @@ export function createInteractiveSessionRuntime(input: {
await restartWithMessages([]);
};
const resetForNewSession = async (): Promise<void> => {
sessionStartGeneration += 1;
pendingResumeSessionId = undefined;
startupPromise = undefined;
startupError = undefined;
const manager = sessionManager;
const sessionId = activeSessionId;
clearActiveSession();
if (manager && sessionId) {
await manager.stop(sessionId);
}
};
const applyMode = async (mode: "plan" | "act"): Promise<void> => {
await applyInteractiveModeConfig({
config: input.config,
@@ -537,6 +581,7 @@ export function createInteractiveSessionRuntime(input: {
getAccumulatedUsage,
readCurrentMessages,
restartEmpty,
resetForNewSession,
restartWithMessages,
restartWithCurrentMessages,
resumeSession,
+75 -15
View File
@@ -10,7 +10,10 @@ import {
loadClineAccountSnapshot,
switchClineAccount,
} from "../tui/cline-account";
import type { InteractiveConfigItem } from "../tui/interactive-config";
import type {
InteractiveConfigItem,
LoadInteractiveConfigDataOptions,
} from "../tui/interactive-config";
import {
type InteractiveSlashCommand,
listInteractiveSlashCommands,
@@ -68,24 +71,48 @@ export async function runInteractive(
userInstructionService,
);
let interactiveChatCommandHost = chatCommandHost;
let pluginChatCommandHostLoaded = false;
let pluginChatSlashCommands: InteractiveSlashCommand[] = [];
let pluginChatCommandHostShutdown: (() => Promise<void>) | undefined;
let pluginChatCommandHostPromise:
| Promise<InteractiveSlashCommand[]>
| undefined;
const configDataLoader = createInteractiveConfigDataLoader({
config,
userInstructionService,
});
const loadAdditionalSlashCommands = async (): Promise<
const ensurePluginChatCommandHost = async (): Promise<
InteractiveSlashCommand[]
> => {
const { host, pluginSlashCommands } = await createWorkspaceChatCommandHost({
if (pluginChatCommandHostLoaded) {
return pluginChatSlashCommands;
}
pluginChatCommandHostPromise ??= createWorkspaceChatCommandHost({
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
logger: config.logger,
});
interactiveChatCommandHost = host;
return pluginSlashCommands.map((cmd) => ({
name: cmd.name,
instructions: "",
description: cmd.description ?? "Plugin command",
}));
})
.then(({ host, pluginSlashCommands, shutdown }) => {
interactiveChatCommandHost = host;
pluginChatCommandHostShutdown = shutdown;
pluginChatSlashCommands = pluginSlashCommands.map((cmd) => ({
name: cmd.name,
instructions: "",
description: cmd.description ?? "Plugin command",
}));
return pluginChatSlashCommands;
})
.finally(() => {
pluginChatCommandHostLoaded = true;
pluginChatCommandHostPromise = undefined;
});
return await pluginChatCommandHostPromise;
};
const loadAdditionalSlashCommands = async (): Promise<
InteractiveSlashCommand[]
> => await ensurePluginChatCommandHost();
const shouldTryPluginChatCommands = (prompt: string): boolean => {
return prompt.trimStart().startsWith("/");
};
const enableChatCommands = true;
@@ -220,9 +247,18 @@ export async function runInteractive(
cleanupPromise = (async () => {
process.off("SIGINT", handleSigint);
process.off("SIGTERM", handleSigterm);
const exitSummary = await sessionRuntime.cleanup();
setActiveRuntimeAbort(undefined);
setActiveRuntimeCleanup(undefined);
let exitSummary: InteractiveExitSummary | undefined;
try {
exitSummary = await sessionRuntime.cleanup();
} finally {
await pluginChatCommandHostPromise?.catch(() => []);
await pluginChatCommandHostShutdown?.().catch(() => {
// Best effort cleanup for plugin command discovery sandbox.
});
pluginChatCommandHostShutdown = undefined;
setActiveRuntimeAbort(undefined);
setActiveRuntimeCleanup(undefined);
}
return exitSummary;
})();
return await cleanupPromise;
@@ -276,10 +312,11 @@ export async function runInteractive(
const onToggleConfigItem = async (
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
): Promise<
Awaited<ReturnType<typeof configDataLoader.onToggleConfigItem>>
> => {
const data = await configDataLoader.onToggleConfigItem(item);
const data = await configDataLoader.onToggleConfigItem(item, options);
if (data && shouldRefreshInteractiveSessionForConfigItem(item)) {
await refreshInteractiveSessionPolicies();
}
@@ -386,7 +423,7 @@ export async function runInteractive(
isRunning = true;
}
const chatCommandResult = await runInteractiveChatCommand({
let chatCommandResult = await runInteractiveChatCommand({
prompt: input,
enabled: enableChatCommands,
config,
@@ -400,6 +437,26 @@ export async function runInteractive(
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
}
if (
shouldTryPluginChatCommands(input) &&
!pluginChatCommandHostLoaded
) {
await ensurePluginChatCommandHost();
chatCommandResult = await runInteractiveChatCommand({
prompt: input,
enabled: enableChatCommands,
config,
host: interactiveChatCommandHost,
chatCommandState,
autoApproveAllRef,
setInteractiveAutoApprove,
sessionRuntime,
stop: () => tuiApp?.destroy(),
});
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
}
}
input = chatCommandResult.input;
const {
prompt: userInput,
@@ -528,6 +585,9 @@ export async function runInteractive(
}
await applyModeChange(mode);
},
onNewSession: async () => {
await sessionRuntime.resetForNewSession();
},
onModelChange: async () => {
await sessionRuntime.ensureReady();
const existing = providerSettingsManager.getProviderSettings(
@@ -28,7 +28,7 @@ test.describe("cline --model (interactive mode, flag ignored)", () => {
test("starts interactive mode", async ({ terminal }) => {
await waitForChatReady(terminal);
await expectVisible(terminal, "GPT-5.3-Codex");
await expectVisible(terminal, "GPT-5.3 Codex");
});
});
@@ -166,6 +166,32 @@ describe("slash command registry", () => {
expect(expandUserCommandPrompt("/settings", registry)).toBe("/settings");
});
it("does not expand commands omitted from a refreshed user-command registry", () => {
const staleRegistry = buildSlashCommandRegistry({
workflowSlashCommands: [
{
name: "find-skills",
instructions: "Find installable skills.",
description: "Find skills",
kind: "skill",
},
],
});
const refreshedRegistry = buildSlashCommandRegistry({
workflowSlashCommands: [],
});
expect(
expandUserCommandPrompt("/find-skills what can u do?", staleRegistry),
).toContain("<user_command");
expect(
resolveSlashCommand(refreshedRegistry, "find-skills"),
).toBeUndefined();
expect(
expandUserCommandPrompt("/find-skills what can u do?", refreshedRegistry),
).toBe("/find-skills what can u do?");
});
it("hides fork from autocomplete until a session has messages", () => {
const emptySessionRegistry = buildSlashCommandRegistry({ canFork: false });
const activeSessionRegistry = buildSlashCommandRegistry({ canFork: true });
+12 -3
View File
@@ -7,9 +7,11 @@ import {
getDefaultForeground,
getModeInputBackground,
palette,
type TerminalTheme,
} from "../palette";
import type { ChatEntry } from "../types";
import { getSyntaxStyle } from "../utils/syntax-style";
import { isWarningToolError } from "../utils/tool-errors";
import {
parseApplyPatchInput,
parseAskQuestionInput,
@@ -211,6 +213,7 @@ function ToolCallView(props: {
defaultFg,
} = props;
const failed = result?.error != null;
const warningFailure = isWarningToolError(result?.error);
const params = formatToolParams(toolName, props.rawInput, inputSummary);
return (
@@ -219,6 +222,8 @@ function ToolCallView(props: {
<box width={2}>
{streaming ? (
<spinner name="dots" color="gray" />
) : warningFailure ? (
<text fg="yellow">!</text>
) : failed ? (
<text fg="red">x</text>
) : (
@@ -251,8 +256,12 @@ function ToolCallView(props: {
);
}
export function ChatEntryView(props: { entry: ChatEntry; accent?: string }) {
const { entry, accent = palette.act } = props;
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
terminalTheme: TerminalTheme;
}) {
const { entry, accent = palette.act, terminalTheme } = props;
const terminalBg = useTerminalBackground();
const defaultFg = getDefaultForeground(terminalBg);
const userMsgBg = getModeInputBackground(
@@ -316,7 +325,7 @@ export function ChatEntryView(props: { entry: ChatEntry; accent?: string }) {
<box flexGrow={1}>
<markdown
content={content}
syntaxStyle={getSyntaxStyle()}
syntaxStyle={getSyntaxStyle(terminalTheme)}
streaming={entry.streaming}
fg={defaultFg}
/>
@@ -9,6 +9,7 @@ import {
useRef,
} from "react";
import type { TranscriptCommand } from "../hooks/transcript-keybinds";
import { useTerminalTheme } from "../hooks/use-terminal-background";
import { getModeAccent } from "../palette";
import type { ChatEntry } from "../types";
import { ChatEntryView } from "./chat-entry";
@@ -29,6 +30,8 @@ export const ChatMessageList = forwardRef<
>(function ChatMessageList(props, ref) {
const scrollboxRef = useRef<ScrollBoxRenderable | null>(null);
const lastEntry = props.entries.at(-1);
const terminalTheme = useTerminalTheme();
const accent = getModeAccent(props.uiMode ?? "act", terminalTheme);
const userSubmissionScrollKey =
lastEntry?.kind === "user_submitted" ? props.entries.length : 0;
@@ -96,13 +99,14 @@ export const ChatMessageList = forwardRef<
<ChatEntryView
key={key}
entry={entry}
accent={getModeAccent(props.uiMode ?? "act")}
accent={accent}
terminalTheme={terminalTheme}
/>
);
})}
{props.isStreaming && (
<box flexDirection="row" gap={1}>
<spinner name="dots" color={getModeAccent(props.uiMode ?? "act")} />
<spinner name="dots" color={accent} />
<text fg="gray">Thinking... (esc to cancel)</text>
</box>
)}
@@ -87,7 +87,7 @@ const ACTION_ITEMS: Array<{
{
action: "fork",
label: "Create Session Fork",
shortcut: "Opt+F",
shortcut: "Opt+R",
description: "Branch the current conversation into a new session",
keywords: ["fork", "session", "branch"],
requiresFork: true,
@@ -114,4 +114,27 @@ describe("command palette", () => {
})?.label,
).toBe("Open Help");
});
it("keeps option right available for word navigation", () => {
const items = buildCommandPaletteItems({
canForkSession: true,
});
expect(
findCommandPaletteShortcut(items, {
name: "f",
meta: false,
option: true,
shift: false,
}),
).toBeUndefined();
expect(
findCommandPaletteShortcut(items, {
name: "r",
meta: false,
option: true,
shift: false,
})?.label,
).toBe("Create Session Fork");
});
});
@@ -3,7 +3,7 @@ import { isToggleableInteractiveConfigItem } from "../../interactive-config";
export type ExtDetailRow =
| { kind: "header"; name: string; source: string }
| { kind: "field"; label: "Path" | "Description"; value: string[] }
| { kind: "field"; label: "Path" | "Description" | "Error"; value: string[] }
| { kind: "status"; enabled: boolean };
const TOGGLE_FOOTER = "Space toggle status, Tab/Enter/Esc to go back";
@@ -77,6 +77,13 @@ export function getExtDetailRows(item: InteractiveConfigItem): ExtDetailRow[] {
value: truncateDescription(item.description),
});
}
if (item.loadError) {
rows.push({
kind: "field",
label: "Error",
value: truncateDescription(item.loadError),
});
}
if (
typeof item.enabled === "boolean" &&
isToggleableInteractiveConfigItem(item)
@@ -4,6 +4,7 @@ import { useState } from "react";
import type {
InteractiveConfigData,
InteractiveConfigItem,
LoadInteractiveConfigDataOptions,
} from "../../interactive-config";
import { palette } from "../../palette";
import {
@@ -18,6 +19,7 @@ export function ExtDetailContent(
item: InteractiveConfigItem;
onToggleConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
},
) {
@@ -31,23 +33,35 @@ export function ExtDetailContent(
) {
return;
}
const previousItem = item;
setToggleError(undefined);
if (typeof item.enabled === "boolean") {
setItem({ ...item, enabled: !item.enabled });
}
try {
const nextData = await props.onToggleConfigItem(item);
const nextItem = [
...(nextData?.workflows ?? []),
...(nextData?.rules ?? []),
...(nextData?.skills ?? []),
...(nextData?.hooks ?? []),
...(nextData?.agents ?? []),
...(nextData?.plugins ?? []),
...(nextData?.mcp ?? []),
...(nextData?.tools ?? []),
].find(
(candidate) => candidate.id === item.id && candidate.path === item.path,
);
setItem(nextItem ?? { ...item, enabled: !item.enabled });
const nextData = await props.onToggleConfigItem(item, {
includePluginTools: false,
});
if (nextData) {
const nextItem = [
...nextData.workflows,
...nextData.rules,
...nextData.skills,
...nextData.hooks,
...nextData.agents,
...nextData.plugins,
...nextData.mcp,
...nextData.tools,
].find(
(candidate) =>
candidate.id === item.id && candidate.path === item.path,
);
if (nextItem) {
setItem(nextItem);
}
}
} catch (error) {
setItem(previousItem);
const message = error instanceof Error ? error.message : String(error);
setToggleError(`Failed to update ${item.name}: ${message}`);
}
@@ -0,0 +1,18 @@
import type { DialogActions, DialogId } from "@opentui-ui/dialog/react";
export type LoadingDialogActions = Pick<DialogActions, "show" | "close">;
export async function withShownDialog<T>(
dialog: Pick<DialogActions, "close">,
show: () => DialogId,
run: () => Promise<T>,
): Promise<T> {
const loadingDialogId = show();
// Give OpenTUI a microtask to mount the loading dialog before work starts.
await Promise.resolve();
try {
return await run();
} finally {
dialog.close(loadingDialogId);
}
}
@@ -0,0 +1,66 @@
import type { DialogActions, DialogId } from "@opentui-ui/dialog/react";
import { describe, expect, it } from "vitest";
import { withShownDialog } from "./loading-dialog-lifecycle";
type LoadingDialogCall =
| {
name: "show";
}
| {
name: "close";
id: DialogId | undefined;
};
function createDialog(calls: LoadingDialogCall[]) {
return {
close: (id?: DialogId): DialogId | undefined => {
calls.push({ name: "close", id });
return id;
},
} satisfies Pick<DialogActions, "close">;
}
function showLoading(calls: LoadingDialogCall[]): DialogId {
calls.push({ name: "show" });
return "loading-dialog";
}
describe("withShownDialog", () => {
it("shows a loading dialog while work runs", async () => {
const calls: LoadingDialogCall[] = [];
const events: string[] = [];
const dialog = createDialog(calls);
const result = await withShownDialog(
dialog,
() => showLoading(calls),
async () => {
events.push("run");
return 42;
},
);
expect(result).toBe(42);
expect(events).toEqual(["run"]);
expect(calls.map((call) => call.name)).toEqual(["show", "close"]);
expect(calls[1]).toEqual({ name: "close", id: "loading-dialog" });
});
it("closes the loading dialog when work fails", async () => {
const calls: LoadingDialogCall[] = [];
const dialog = createDialog(calls);
await expect(
withShownDialog(
dialog,
() => showLoading(calls),
async () => {
throw new Error("failed");
},
),
).rejects.toThrow("failed");
expect(calls.map((call) => call.name)).toEqual(["show", "close"]);
expect(calls[1]).toEqual({ name: "close", id: "loading-dialog" });
});
});
@@ -0,0 +1,56 @@
// @jsxImportSource @opentui/react
import type {
DialogId,
DialogSize,
DialogStyle,
} from "@opentui-ui/dialog/react";
import "opentui-spinner/react";
import {
type LoadingDialogActions,
withShownDialog,
} from "./loading-dialog-lifecycle";
export interface LoadingDialogContentProps {
message: string;
}
export function LoadingDialogContent(props: LoadingDialogContentProps) {
return (
<box flexDirection="row" gap={1} paddingX={1}>
<spinner name="dots" color="gray" />
<text fg="gray">{props.message}</text>
</box>
);
}
export interface LoadingDialogOptions {
size?: DialogSize;
style?: DialogStyle;
}
export function showLoadingDialog(
dialog: LoadingDialogActions,
message: string,
options?: LoadingDialogOptions,
): DialogId {
return dialog.show({
size: options?.size ?? "small",
style: options?.style,
closeOnEscape: false,
closeOnClickOutside: false,
content: () => <LoadingDialogContent message={message} />,
});
}
export async function withLoadingDialog<T>(
dialog: LoadingDialogActions,
message: string,
run: () => Promise<T>,
options?: LoadingDialogOptions,
): Promise<T> {
return await withShownDialog(
dialog,
() => showLoadingDialog(dialog, message, options),
run,
);
}

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