Compare commits

..
Author SHA1 Message Date
Saoud Rizwan 9aac8340dc chore(sdk): regenerate bun.lock for v0.0.54
Align resolved workspace versions in bun.lock with the v0.0.54 package
bumps. bun pm pack substitutes workspace:* deps using the version
recorded in bun.lock, so a stale lock made packed inter-package deps
resolve to 0.0.53, failing check-publish and the node smoke test (which
then pulled the old published @cline/shared from npm).
2026-06-28 21:24:41 -07:00
Saoud Rizwan 242b5ebff6 chore(sdk): release v0.0.54 2026-06-28 20:54:47 -07:00
Tomás Barreiro 7ca41fdb7d Improve ClinePass onboarding UX (#11959)
* Prevent ClinePass onboarding flicker

* Make the clinepass step scrollable and remove details
2026-06-28 20:49:27 -07:00
Saoud Rizwan 000918989f fix(cli): make ClinePass subscription screen selectable (#11957) 2026-06-28 20:12:04 -07:00
Saoud Rizwan 9560b6d625 fix(cli): use ClinePass as one word consistently (#11956) 2026-06-28 18:41:26 -07:00
Saoud Rizwan c7304097a7 fix(cli): update ClinePass provider UI copy (#11953)
* fix(cli): update ClinePass provider UI copy

* fix(llms): rename Cline provider display name

* fix(cli): separate ClinePass billing links
2026-06-28 17:57:11 -07:00
Tomás BarreiroandSaoud Rizwan 674a6022ee Add an intermediate step before going to ClinePass model selection (#11947)
* Add an intermediate step before going to model selection

* fix type issues

* use allSettled

* fix(cli): serialize ClinePass subscription checks

* fix(cli): handle missing ClinePass plan as unsubscribed

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-28 16:13:25 -07:00
Saoud Rizwan dbf0775384 fix(llms): keep error detail extraction for Error instances (#11949)
PR #11928 added an `instanceof Error` branch to extractErrorMessage to
preserve transport-error wrappers (e.g. "fetch failed: SocketError: ...
(UND_ERR_SOCKET)"), but that branch regressed two cases:

- Generic SDK wrappers like "No output generated. Check the stream for
  errors." were prepended to the real cause instead of being dropped.
- Errors carrying structured detail on responseBody/detail/error fields
  surfaced the bland top-level .message ("Bad Request") instead of the
  detail ("Instructions are required").

The Error branch now drops known generic wrappers in favor of the cause
and extracts structured detail from the error's own fields, while keeping
the transport-wrapper concat behavior #11928 intended.
2026-06-28 15:27:46 -07:00
Saoud Rizwan e130b45eb0 feat(cli): promote Cline Pass in startup notice (#11948) 2026-06-28 13:14:27 -07:00
6f4dbae86f Improve the ClinePass onboarding experience on the CLI [ENG-2236] (#11946)
* Improve the ClinePass onboarding experience on the CLI

* Update apps/cli/src/tui/views/onboarding/screens.tsx

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

* address comments

* style(cli): format ClinePass onboarding warning

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-28 10:53:27 -07:00
Saoud Rizwan c7de31ae24 ci(vscode): gate legacy publish on test job (#11936) 2026-06-27 19:33:37 -07:00
Saoud Rizwan 45dddb9a4e ci(vscode): add legacy extension publish workflow (#11935) 2026-06-27 19:17:46 -07:00
BeeandSaoud Rizwan e32caee96b fix: basic compaction token budgeting (#11894)
* Improve compaction token budgeting

Use MessageWithMetadata.metrics input/output token counts when estimating message size for compaction, falling back to the existing chars/3 heuristic only when metrics are unavailable. This makes trigger decisions and post-compaction accounting use provider-reported token usage instead of relying only on serialized character estimates.

When an explicit compaction maxInputTokens budget is configured and the model exposes maxTokens, reserve half of the model output budget before triggering compaction. This gives the next provider request room for completion tokens and reduces edge cases where the local context estimate passes but the provider rejects the prompt as exceeding its limit.

Keep explicit reserveTokens and thresholdRatio overrides intact, and add regression coverage for metric-based token estimation, fallback estimation, and output-token-aware trigger budgeting.

* new target tokens and trigger tokens value

* fixes

* use imports and add unit test

* resolveMaxInputTokens

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-27 16:04:56 -07:00
Saoud Rizwan 8f6dae0ac0 fix(vscode): sync auto-approve task settings (#11929) 2026-06-27 15:13:14 -07:00
Saoud Rizwan 263b58f8c3 fix(llms): preserve fetch error cause details (#11928) 2026-06-27 14:51:56 -07:00
Saoud Rizwan b193a81ac9 fix: prevent API key field clearing on settings load (#11925)
* fix: prevent API key field clearing on settings load

* fix: cancel API key save on mask hydration
2026-06-27 14:32:14 -07:00
John Choi 92806c60ca fix(agents): derive messageModelInfo in the provider/model runtime path (#11903)
The standalone AgentRuntime({ providerId, modelId }) constructor built the gateway model in resolveRuntimeConfig and returned { ...rest, model } without deriving messageModelInfo. The prebuilt-model path preserves it, and core sessions populate it via buildMessageModelInfo, but standalone SDK callers lost it -- so assistant-message modelInfo and model-tagged telemetry (reasoning tokens, action-follow-through) emitted without provider/model dimensions.

Derive messageModelInfo as { id: modelId, provider: providerId } in that path (family omitted; it is optional and unavailable here). An explicit caller-provided messageModelInfo still wins. Adds provider-form tests covering both the derived and explicit-override cases.
2026-06-26 18:23:54 -07:00
Saoud Rizwan 3a05171e30 fix(sdk): preserve failed run error messages (#11904) 2026-06-26 18:04:53 -07:00
Saoud Rizwan a6e315a4a6 chore(cli): release v3.0.31 2026-06-26 17:37:36 -07:00
Saoud Rizwan 2714f93b45 chore(sdk): drop volatile catalog refresh from v0.0.53
The bun run version catalog regen dropped the xiaomi mimo-v2-omni,
mimo-v2-pro, and mimo-v2-flash models from the live data. mimo-v2-omni
is the xiaomi provider's defaultModelId in builtins.ts, so shipping the
refreshed catalog would point the default at a missing model and broke
the provider-ids test. Revert the catalog to the pre-release state and
ship v0.0.53 as a pure SDK code release; the catalog will refresh in a
later release once upstream data is stable.
2026-06-26 17:17:00 -07:00
Saoud Rizwan 3abeb8a90b chore(sdk): release v0.0.53 2026-06-26 17:03:04 -07:00
Tomás Barreiro 7830472017 Add open subscription page option to the ClinePass options (#11896)
* Add open subscription page option to the ClinePass options

* Address comments
2026-06-27 01:07:11 +02:00
Bee 83339c3c5a refactor: extension to use sdk provider list (#11888)
* refactor(vscode): use string type for api provider in proto

Replace the ApiProvider proto enum with plain string fields across\nmodels.proto and state.proto, and drop the enum<->string conversion\nmappings in api-configuration-conversion.ts. Updates ApiOptions and\nOpenAICompatible settings components accordingly.

* fix custom provider render

* format

* id

* remove extension providers file

* uses includes
2026-06-26 16:05:55 -07:00
Tomás Barreiroandgreptile-apps[bot] 664daf6ded Show cost has been covered by the users subscription (#11889)
* Show cost has been covered by the users subscription

* fix tests

* Update apps/cli/src/tui/components/status-bar.tsx

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

* Update wording

* Update tests

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-26 23:34:06 +02:00
Tomás Barreiro 5b1f8850af Update coupon code (#11890) 2026-06-26 23:33:11 +02:00
Tomás Barreiro c49d4121a3 Fix SDK tests (#11895) 2026-06-26 14:20:58 -07:00
Tomás Barreiro 7f495a5e99 Open ClinePass subscribe page (#11891) 2026-06-26 22:54:00 +02:00
1e88a708bd upate changelog (#11886)
* upate changelog

* Update CHANGELOG.md

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

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-26 12:50:52 -07:00
Saoud Rizwan 408be18be9 fix(ci): harden ext-vscode stable release workflow (#11887)
* fix(ci): harden ext-vscode stable release workflow

- Resolve previous tag to the latest vX.Y.Z ancestor instead of the most
  recent reachable tag. The nightly workflow now pushes a nightly-main-*
  annotated tag on every main commit, so git describe was resolving the
  release notes' Full Changelog compare link to a nightly tag rather than
  the prior release tag.
- Extract the changelog section by exact version heading (and fail if it
  is missing) instead of always taking the first ## [ block, so a stale
  top entry can no longer ship as the release notes for a different
  version.
- Add a pre-publish Verify Changelog Entry gate so a release cannot be
  published unless CHANGELOG.md leads with the version being released.
- Add a Verify Marketplace Tokens gate so a missing VSCE_PAT/OVSX_PAT
  fails fast before packaging rather than mid-publish.
- In existing-tag mode, require the tag to point at the tested SHA so the
  published artifact always matches what CI verified.
- Add a concurrency group keyed on the tag to prevent duplicate
  concurrent publishes of the same release.

* fix(ci): validate stable release metadata before publish
2026-06-26 12:50:28 -07:00
Saoud Rizwan 5a9c637e32 fix(core): cap MCP tool names at 64 chars for OpenAI-compatible providers (#11885) 2026-06-26 12:42:57 -07:00
Saoud Rizwan 8152572641 fix(vscode): disable MCP marketplace tab from remote config (#11883) 2026-06-26 11:48:07 -07:00
Saoud Rizwan 0736e12e32 fix(vscode): refresh MCP hub after marketplace install (#11882) 2026-06-26 11:41:34 -07:00
Saoud Rizwan 690f80523f fix(vscode): preserve migrated OpenAI-compatible settings (#11880)
* fix(vscode): preserve migrated OpenAI-compatible settings

* test(vscode): cover OpenAI-compatible plan act selections
2026-06-26 10:56:50 -07:00
Saoud Rizwan 4fa1b8a291 fix(vscode): reject approvals from composer feedback (#11874) 2026-06-26 04:01:10 -07:00
Saoud Rizwan ed685b28e0 feat(vscode): allow cancelling queued prompts (#11875) 2026-06-26 03:58:46 -07:00
Saoud Rizwan 38338310d7 fix(vscode): timeout terminal cwd setup (#11871)
* fix(vscode): timeout terminal cwd setup

* refactor(vscode): simplify terminal cwd timeout
2026-06-26 03:49:36 -07:00
Saoud Rizwan 1c4a7885e6 fix(vscode): keep command output pinned to bottom (#11873) 2026-06-26 03:49:05 -07:00
Saoud Rizwan a0517db2fa feat: add shared marketplace uninstall support (#11870)
* feat: add shared marketplace uninstall support

* fix: avoid regex backtracking in marketplace skill sanitization

* fix: address marketplace uninstall review feedback

* fix: clean up remaining marketplace skill installs

* fix: remove marketplace skills from all agents

* fix: keep customize primitive tabs horizontal
2026-06-26 03:47:53 -07:00
Saoud Rizwan 38134ef967 fix(vscode): surface plugin bundled skills (#11868)
* fix(vscode): surface plugin bundled skills

* fix(core): align plugin skill settings lookup
2026-06-25 21:31:45 -07:00
Saoud Rizwan 8715cafce7 fix(vscode): rename user message reset actions (#11869) 2026-06-25 21:30:46 -07:00
Saoud Rizwan 46ee8ea329 feat(vscode): add customize section tabs (#11867)
* feat(vscode): add customize section tabs

* fix(vscode): reset customize section tab

* fix(vscode): reset customize section on initial type
2026-06-25 21:29:30 -07:00
Tomás BarreiroandSaoud Rizwan b7d9ea4500 Add a prompt to change to ClinePass when out of credits (#11866)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-25 21:11:23 -07:00
Saoud Rizwan 5147abc75e fix: hide workflows from customize menu (#11864) 2026-06-25 20:57:10 -07:00
Saoud Rizwan 9abd7ae8c3 fix(vscode): disable command auto-approval by default (#11865) 2026-06-25 20:56:30 -07:00
Saoud Rizwan bf83303bb7 fix(cli): require quoted prompts for one-shot mode (#11861) 2026-06-25 20:54:13 -07:00
Saoud Rizwan bfe5bf841a refactor: share marketplace install logic through core (#11862)
* refactor: share marketplace install logic through core

* fix: harden shared install helpers

* refactor: share mcp marketplace arg parsing
2026-06-25 20:34:42 -07:00
Tomás Barreiro eb362df3ba List ClinePass features in the CLI not-subscribed message (#11846)
* List ClinePass features in the CLI not-subscribed message

* fix tests
2026-06-26 05:22:15 +02:00
Saoud Rizwan f735ddcb7a fix(vscode): handle escape while editing user messages (#11860) 2026-06-25 20:20:33 -07:00
Saoud Rizwan 5b63d3e9c8 fix(vscode): preserve raw structured terminal commands (#11857) 2026-06-25 20:19:40 -07:00
Saoud Rizwan 1ff6a54825 fix: wrap customize tabs on narrow screens (#11855) 2026-06-25 19:17:49 -07:00
Saoud Rizwan b1a3cb6cfc chore(cli): release v3.0.30 2026-06-25 18:03:44 -07:00
Saoud Rizwan 78f1736723 test(cli): widen help terminal so long flag descriptions don't wrap
The --thinking description added in #11656 is long enough that at 120
columns commander wraps it, splitting "omitted leaves provider default"
across two lines. The TUI e2e assertion uses a contiguous getByText, so it
failed on the ubuntu-only TUI test leg, blocking the SDK publish gate.
Widen the help terminal to 200 columns so long descriptions render on a
single line.
2026-06-25 17:51:56 -07:00
Tran Binh MinhandMinhkunn 28a014c1c6 docs: fix outdated skills enable path (#11838)
The Skills note pointed users to "Settings → Features → Enable Skills,"
but that toggle no longer exists — the Features settings section has no
Skills entry and skills are loaded by default. Point users to the actual
Skills menu (scale icon → Skills tab), consistent with the access path
already documented later in the same page.

Fixes #11740

Co-authored-by: Minhkunn <minh.12072k6@gmail.com>
2026-06-25 17:32:09 -07:00
Saoud Rizwan fed291e37a chore(sdk): release v0.0.52 2026-06-25 17:27:03 -07:00
Saoud Rizwan bb68351123 fix(vscode): avoid duplicate followup answer bubbles 2026-06-25 17:18:09 -07:00
Saoud Rizwan 84cb15813a fix(vscode): polish queued prompt panel 2026-06-25 17:17:48 -07:00
Saoud Rizwan c3671de7de fix(vscode): disable subagents (#11847) 2026-06-25 17:02:25 -07:00
Saoud Rizwan 26f737913f fix(vscode): show direct user messages immediately (#11845)
* fix(vscode): show direct user messages immediately

* fix(vscode): address pending chat bubble review
2026-06-25 16:53:15 -07:00
Dominic Cooney 50797dd82d vscode(ENG-2203): Migrate legacy MCP files, formats to shared settings file. (#11818)
* Migrate legacy MCP files, formats to shared MCP settings.
2026-06-26 08:23:02 +09:00
Saoud Rizwan 923ee3e137 fix(vscode): enable auto-approval defaults (#11840)
* fix(vscode): enable auto-approval defaults

* test(vscode): update file edit e2e for auto-approval defaults
2026-06-25 15:03:09 -07:00
Saoud Rizwan bbf5bb2302 fix(vscode): hide auto-approve notifications toggle (#11843) 2026-06-25 15:01:46 -07:00
Saoud Rizwan 0220b9a506 fix(vscode): queue chat submits during active turns (#11839)
* fix(core): avoid requeueing terminal failed prompts

* fix(vscode): avoid duplicate queued follow-up messages

* fix(vscode): keep approvals pending for queued messages

* fix(vscode): queue chat submits during active turns

* fix(core): restore pending prompt requeue on send failure

* test(core): remove pending prompt status churn
2026-06-25 15:01:10 -07:00
Ara 1b573275f8 fix(vscode): remove test server (#11842) 2026-06-25 14:39:59 -07:00
Robin Newhouse d7d74e0b89 fix(llms): preserve OpenRouter reasoning disable semantics (#11656)
* fix(llms): preserve OpenRouter reasoning disable semantics

* fix(llms): clarify reasoning token usage

* fix(llms): use OpenRouter reasoning effort none

* refactor(cli): extract reasoning resolution helper

* fix(cli): clarify thinking defaults

* feat(agents): capture unexpected reasoning token telemetry

* fix(cli): preserve reasoning on model change

* refactor(llms): table-drive reasoning token extraction

* fix(sdk): catalog unexpected reasoning telemetry
2026-06-25 14:35:05 -07:00
Saoud Rizwan bebc581652 fix(core): add non-interactive command guidance (#11815)
* fix(core): add non-interactive command guidance

* fix(core): refine non-interactive command guidance
2026-06-25 12:34:32 -07:00
Saoud Rizwan 21ebee3638 fix(sdk): keep SAP model filtering in clients (#11837) 2026-06-25 12:31:59 -07:00
Saoud Rizwan 7d78c5f074 refactor(vscode): attach checkpoints to user edits (#11832)
* refactor(vscode): attach checkpoints to user edits

* fix(vscode): improve checkpoint edit controls

* fix(vscode): align checkpoint edit actions

* fix(vscode): gate checkpoint restore edits
2026-06-25 12:21:17 -07:00
Saoud RizwanandDavid Knaack 352a23a6da fix: stabilize SAP AI Core provider setup (#11833)
* fix: Filter SAP AI Core models based on mode-availibility

* chore: fix model picker test

* fix: harden SAP AI Core model filtering

* fix: pin SAP Cloud SDK to 4.6.0

* fix(vscode): simplify SAP AI Core model filtering

---------

Co-authored-by: David Knaack <david.knaack@sap.com>
2026-06-25 12:13:53 -07:00
Saoud Rizwan 176662ee20 fix(vscode): show pending state before queued prompt appears (#11836)
* fix(vscode): show pending state for chat sends

* fix(vscode): keep chat input editable during pending sends
2026-06-25 12:08:05 -07:00
Saoud Rizwan 309ad9da72 fix(vscode): show queued prompts while streaming (#11835)
* fix(vscode): show queued prompts while streaming

* fix(vscode): refine queued prompt updates
2026-06-25 11:54:44 -07:00
Saoud Rizwan 16a6926985 fix: improve ask option selection UI (#11824)
* fix: disable hover for selected ask options

* fix: mark ask options disabled after selection

* fix: hide duplicate ask option echoes
2026-06-25 11:51:43 -07:00
Saoud Rizwan d6db723c9d fix(vscode): keep command output pinned to bottom (#11825)
* fix(vscode): keep command output pinned to bottom

* fix(vscode): address command output scroll review
2026-06-25 11:31:54 -07:00
Tomás Barreiro ce1fc20a9f Limit the ClinePass CLI url to the CLI (#11811)
* Limit the ClinePass CLI url to the CLI

* fix tests

* Remove unused import

* Fix run-agent

* fix run-aent error message
2026-06-25 20:31:31 +02:00
Tomás Barreiro b780a5ec00 Fix ClinePass error mapping on VSCode (#11807)
* Fix ClinePass error mapping on VSCode

* refactor

* fix types

* remove unused constants

* refactor
2026-06-25 19:29:13 +02:00
Tomás Barreiro 285cd6d54c Fix vscode tests (#11831) 2026-06-25 10:16:39 -07:00
Tomás Barreiro ff845539e4 Fix createRequire (#11829) 2026-06-25 18:17:16 +02:00
Saoud Rizwan 05b0aa7dcb fix(vscode): flush state after model selection (#11822)
* fix(vscode): flush state after model selection

* fix(vscode): drain state after teardown cleanup
2026-06-25 04:13:40 -07:00
Saoud Rizwan 6cdd882acb fix(vscode): remove dead settings (#11819) 2026-06-25 03:38:47 -07:00
Saoud Rizwan d055b0941f feat(vscode): add marketplace (#11816)
* feat(vscode): add customize marketplace

* style(vscode): format marketplace imports

* fix(vscode): open marketplace mcp tab from configure

* fix(marketplace): redact authorization headers
2026-06-25 02:57:15 -07:00
Saoud Rizwan 21a0e4c4f1 fix(vscode): remove delay when sending message (#11817)
* fix(vscode): show new chat immediately on send

* fix(vscode): restore chat input after new task failure
2026-06-25 02:56:55 -07:00
Saoud Rizwan 54d022b536 fix(vscode): simplify auto-approve menu (#11814)
* fix(vscode): remove all-commands auto-approve option

* fix(vscode): clear legacy all-commands approval

* fix(vscode): remove external path auto-approve options

* Revert "fix(vscode): clear legacy all-commands approval"

This reverts commit 39af65f894.

* fix(vscode): ignore legacy all-commands approval

* fix(vscode): use all-commands auto-approve flag

* Revert "fix(vscode): use all-commands auto-approve flag"

This reverts commit 8b093ce654.
2026-06-25 02:46:07 -07:00
Dominic CooneyandSaoud Rizwan a1709d37e5 fix(vscode): make compact button run real SDK compaction (#11764)
* fix(vscode): make compact button run real SDK compaction

The compact button (and the typed /compact and /smol commands) sent the
literal text "/compact" to the model as a normal chat message. In the SDK
adapter only /workflow and /skill are expanded as runtime commands, so the
model received "/compact" as a prompt and improvised a fake "Conversation
Summary" without actually reducing the context window (CLINE-2503).

Wire the same SDK effect the CLI's /compact (alias /smol) uses:

- sdk-compaction.ts: compactSessionMessages(), the VSCode analog of the CLI's
  compactInteractiveMessages -- a manual-mode createContextCompactionPrepareTurn
  over the current transcript, force-enabling compaction and forwarding
  telemetry/sessionId.
- sdk-compaction-coordinator.ts: reads the active session transcript, runs the
  manual compaction, and restarts the session with the compacted messages via
  replaceActiveSession (same sequencing as a mode rebuild), preserving the
  session id and emitting a CLI-style status line. Guards no-session, mid-turn,
  and empty-transcript cases.
- SdkController.compactTask() exposes it; the condense slash handler now calls
  it instead of the no-op ask response.
- Webview: the compact-confirm button and typed /compact + /smol (with an active
  task) route to the condense RPC instead of sending literal text.

Adds unit tests for the helper, the coordinator, and the webview send routing.

* chore(vscode): drop trailing newline in condense handler (biome)

* test(vscode): cover manual compact flow

* test(vscode): use portable compact matcher

* test(vscode): assert compact calls without vitest matchers

* test(vscode): keep compact assertion type safe

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-25 02:28:07 -07:00
Saoud Rizwan b4157df509 fix(vscode): refresh Cline model info from catalog (#11806) 2026-06-25 02:20:22 -07:00
Saoud Rizwan 60dee73f0f fix(vscode): use SDK for mistake limit (#11808)
* fix(vscode): use SDK mistake-limit recovery

* test(vscode): cover mistake-limit stop response
2026-06-25 02:19:58 -07:00
Saoud Rizwan 2d66bd475e feat: add checkpoints (#11813)
* feat: add SDK-backed VS Code checkpoints

* fix: remove stale checkpoint view changes reset
2026-06-25 02:19:14 -07:00
Renee HuangandSaoud Rizwan a1374ae4a5 docs: add ClinePass subscription page and reorganize sidebar nav (#11672)
* docs: add ClinePass subscription page and reorganize sidebar nav

* docs: polish ClinePass copy and add cross-links

* more wording updates

* polishing

* docs: update 5x to 2-5x API rate limits

* add beta label to clinepass

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-24 22:03:39 -07:00
John Choi b8c62ecbae fix(onboarding): restore ClinePass models in onboarding (SDK parser dropped clinePass) (#11805)
* fix(onboarding): restore ClinePass models in onboarding

Root cause: the SDK's fetchClineRecommendedModels (@cline/core) silently
dropped the clinePass list. Its ClineRecommendedModelsData type and
normalizeResponse only handled recommended/free, so the recommended-models
endpoint's clinePass entries were stripped before reaching the extension.
Result: the onboarding ClinePass option appeared but the model list was always
empty ('No ClinePass models are available right now'), regardless of the
ext-cline-pass flag. This also affected any SDK consumer (CLI/JetBrains).

Also reverts the pre-login regression from #11798: that PR gated the first
onboarding screen on the extension-side clinePassEnabled flag, which is only
populated after login (featureFlagsService.poll runs on auth), so the ClinePass
option disappeared on the pre-login 'How will you use Cline?' screen.

Changes:
- @cline/core cline-recommended-models: parse/clone clinePass; include it in
  the type and offline fallback; treat clinePass-only responses as non-empty.
- OnboardingView: gate the ClinePass option on the webview useHasFeatureFlag
  (works pre-login) instead of the extension-side clinePassEnabled.
- Revert the extension-side clinePassEnabled plumbing added in #11798
  (FeatureFlagsService.getClinePassEnabled, state payload, ExtensionMessage,
  ExtensionStateContext default).

* test: add clinePass to recommended-models SDK mocks

ClineRecommendedModelsData now requires clinePass; update the mocked SDK
results in refreshClineRecommendedModels.test.ts so check-types passes.

* fix(onboarding): only offer ClinePass when models are available

Gate the ClinePass option on isClinePassEnabled AND models.clinePass.length > 0.
Previously, when the flag was on but the recommended-models request fell back
(or returned no clinePass entries), the option still appeared and routed users
into the ClinePass model step's empty state, where signup is disabled -- a dead
end instead of staying on Free/Frontier/BYOK.

* chore: trim ClinePass gate comment to one line
2026-06-24 17:41:14 -07:00
Bee 96b29d787e fix: normalize JSON-like tool inputs by schema (#11803) 2026-06-24 17:04:57 -07:00
Tomás Barreiro 5b96beb583 Link the CLI to the promo (#11794) 2026-06-25 01:48:09 +02:00
Dominic Cooney f2af0d700c refactor: simplify sdk terminal execution (#11789) 2026-06-25 08:43:41 +09:00
Tomás Barreiro a9c47f88fd Remove unused retry function (#11804) 2026-06-25 08:38:41 +09:00
John Choi 49884a1194 fix(sdk): batch outdated-read rewrites in MessageBuilder to preserve provider prefix caches (#11471)
* fix(sdk): batch outdated-read rewrites in MessageBuilder to preserve provider prefix caches

MessageBuilder previously rewrote stale read_files results to
'[outdated - see the latest file content]' eagerly on every re-read.
Each rewrite mutates bytes in the middle of the provider-facing
transcript, invalidating provider prefix caches (DeepSeek/Anthropic/
MiniMax-style) from that message to the end of the conversation. Agents
re-read files constantly (read -> edit -> verify), so long sessions paid
full uncached input price on most requests.

Now pending outdated rewrites accumulate and only commit once the total
reclaimable bytes cross a 64KB threshold, then apply as a single batch
(one cache break amortized over a large context saving). Committed
rewrites are sticky so subsequent requests stay byte-stable.

* fix(sdk): count only reclaimable locator bytes when batching outdated-read rewrites

Addresses review feedback (greptile P1, codex P2): pendingBytes was
incremented with the whole tool-result block size once per outdated
locator, so multi-file read_files results were overcounted (N stale
locators = N x block bytes), crossing the batch threshold far earlier
than intended and partially defeating the cache-stability guarantee.

Now estimateOutdatedReclaimBytes attributes bytes per stale entry in
the parsed read result (falling back to full text size only when the
whole block is outdated, matching replaceOutdatedReadContent), counted
once per block. Adds a multi-locator regression test where a 3-file
read result is invalidated file-by-file and must only commit when the
actual reclaimable bytes cross the threshold.

Real-session replay improved from 12.6% to 19.7% net-token reduction
with the accurate counting (commits defer longer, breaks amortize
better).

* fix(sdk): drop committed outdated rewrites when history is rolled back

Addresses review P1: committedOutdatedRewrites survived checkpoint
restore/clearHistory (the orchestrator reuses one MessageBuilder), so a
read that became the latest again after rollback stayed rewritten to
'[outdated...]' forever, hiding live file content from the provider.

Two guards: re-validate committed locators against the current index at
apply time, and clear the committed set in resetIndexes — that path only
fires on non-append-only history changes, where the provider prefix is
already broken, so stickiness loses nothing.

Adds a rollback regression test (commit rewrite, restore to before the
re-read, assert full content returns).

* test(sdk): trim redundant comments in rollback regression test

* fix(sdk): keep outdated-rewrite batching state across fresh message rebuilds

Addresses review feedback: the runtime provider path rebuilds Message
objects every request (agentMessagesToMessages constructs new literals),
so the identity-based reindex check fails each build and resetIndexes
fires. Clearing committedOutdatedRewrites there (added for the rollback
P1) recounted already-committed bytes as pending on every request — once
the first 64KB committed, every newly-stale small read rewrote
immediately, degenerating to eager behavior in steady state.

committedOutdatedRewrites now survives resetIndexes. Rollback
correctness is preserved without it: the apply-time re-validation is
identity-free, and commitOutdatedRewrites now prunes committed locators
that are no longer outdated in the current index plus entries whose
tool_use_id left the transcript. Both prunes are no-ops in append-only
growth since outdatedness is monotonic.

Adds two regression tests that route messages through the real
agent-message codec round-trip (fresh objects per build, as production):
steady-state deferral of a small newly-stale read after a committed
large one, and rollback restoring full content.

* fix(sdk): batch orphaned read results and count stale image bytes

Addresses robinnewhouse review (two pre-approval follow-ups):

1. Tool-name lookups went through toolNameByIdCache only, so a
   tool_result orphaned by compaction/rollback (paired tool_use gone)
   was invisible to the batching scan and pruned from committed state —
   reverting its rewrite mid-transcript in exactly the history-shrinking
   case the batching needs to survive. resolveToolName now falls back to
   tool_result.name at all three lookup sites (transform, reindex,
   commit scan).

2. estimateOutdatedReclaimBytes attributed only text/file entries, but
   replaceOutdatedReadContent also replaces stale image siblings
   (flagged by codex too). Image-heavy sessions accrued ~0 pending bytes
   and never crossed the threshold. The estimator now counts stale image
   payload bytes using the same positional marker counting as the
   rewriter (countOutdatedImageEntries).

Both regression tests fail before this change: orphaned result keeps
its committed rewrite through a codec round-trip, and a 4KB stale image
crosses a 2KB threshold that its ~70-byte text marker alone would not.

* perf(sdk): retune outdated-rewrite threshold to 128KB for executor caps

The 64KB default was calibrated before executor-layer output caps landed
(#11480/#11504: read_files/run_commands/search now cap at 48K chars).
With reads bounded at ~48K, 64KB sat awkwardly — one stale read can't
cross it, two overshoot — making it the worst non-extreme threshold in a
post-cap cost sweep.

Re-measured eager vs batched on 48K-capped transcripts (DeepSeek 10x
cache pricing): batching still beats eager 44-61%, confirming the
mechanism remains valuable after the caps (never-rewrite is now +35%
worse in long sessions). 128KB (~2-3 capped reads) is cheapest in both
short and long shapes, ~5-12 points better than the old 64KB.

Bumps LARGE_CONTENT test fixture to ~140KB so single-large-read commit
tests still exceed the raised threshold.

* fix(sdk): batch structured read tool results

* fix(sdk): resolve orphaned tool names for aggregate truncation

* test(sdk): trim redundant message builder cache tests

* style(sdk): trim message builder comments

* test(sdk): allow schedule history test more time on windows

* fix(sdk): preserve infinity outdated rewrite threshold

* perf(sdk): retune outdated rewrite threshold to 64KB

* Revert "test(sdk): allow schedule history test more time on windows"

This reverts commit ac21ef1702.

* test(sdk): fold message builder cache stability coverage

* fix(sdk): address stale read batching review
2026-06-24 16:17:34 -07:00
Dominic Cooney 49da86f60c chore: update biome vscode settings (#11788) 2026-06-25 08:16:50 +09:00
John Choi 84477dd84f fix(onboarding): gate ClinePass on reliable extension-side flag (#11798)
* fix(onboarding): show ClinePass models reliably + label the group ClinePass

Two issues:

1. Nightly feature-flag race. ClinePass was gated twice by two different flag
   clients: the recommended-models endpoint is server-gated by ext-cline-pass
   (PostHog-node), while the webview independently re-checked ext-cline-pass via
   PostHog-js to decide whether to show the option and keep the models. These
   reads race and disagree (mid auth/identify handshake, or when PostHog
   remote-config scripts are blocked by the webview CSP), so the ClinePass
   option could appear with an empty model list.

   Fix: make the server-gated payload the single source of truth. Onboarding
   shows the ClinePass option iff the payload contains ClinePass models
   (getUserTypeSelections now takes hasClinePassModels), and
   getRecommendedModelsData no longer re-filters response.clinePass on the
   webview flag. Removes the second racy webview PostHog read entirely.

2. Group label. The ClinePass group rendered as the raw provider id (CLINE-PASS).
   Render it with the product's proper casing (ClinePass). Model ids/names are
   intentionally left as-is (e.g. cline-pass/minimax-m3), since that's what the
   model is called.

* fix(onboarding): gate ClinePass on reliable extension-side flag

The ext-cline-pass flag is rolled out to internal cohorts only (QA/Cline
team/ClinePass Beta), not GA. Onboarding read it via the webview posthog-js
client, which is unreliable during onboarding (CSP blocks PostHog remote
config in Nightly, and it evaluates before auth/identify resolves) -- so
eligible team members saw ClinePass with an empty list / not at all.

Read the flag from the extension-side featureFlagsService instead (the same
server-evaluated source Settings/catalog already use), plumbed into webview
state like worktreesEnabled. Onboarding now shows ClinePass iff the flag is
enabled AND the payload contains ClinePass models, so the option and the
list are always in sync.

- FeatureFlagsService.getClinePassEnabled()
- getStateToPostToWebview: clinePassEnabled
- ExtensionState type + webview default
- OnboardingView gates on state.clinePassEnabled
2026-06-24 16:15:53 -07:00
Saoud Rizwan bd662d81f6 fix: bundle SAP AI Core provider auth (#11796)
* fix: bundle SAP AI Core provider auth

* fix: serialize SAP service-key auth calls
2026-06-24 13:45:00 -07:00
Tran Binh MinhandMinhkunn d8a3086eaa docs(mcp): set type=streamableHttp in remote server example (#11670) (#11690)
The remote-server JSON example omitted the `type` field. Because the
config schema's z.union lists the SSE branch before streamableHttp
(intentionally, for backward compat), an untyped remote entry silently
resolves to the deprecated legacy SSE transport — the opposite of the
docs' own "Streamable HTTP (recommended)" guidance.

Add `"type": "streamableHttp"` to the example, rename the heading to
match, and add a sentence explaining that omitting `type` defaults to
legacy SSE.

Fixes #11670

Co-authored-by: Minhkunn <minh.12072k6@gmail.com>
2026-06-24 20:59:31 +02:00
Tomás Barreiro 8ecd136e52 Update the clinePass model list live (#11792)
* Generate the model list dynamically

* Do not return known models

* Make both calls in parallel

* Remove modelsDev catch on model generation

* readd error catching
2026-06-24 20:44:36 +02:00
Robin Newhouse 14a28b0559 fix(core): avoid nullable editor old_text schema (#11784) 2026-06-24 04:31:01 -07:00
Dominic Cooney c5378d1847 Merge pull request #11777 from cline/dpc/sdk-migration-simpler-login
SDK migration: move apps/vscode to bun + Cline SDK

### Description

This is the integration branch that moves the VSCode extension onto the Cline SDK and the bun toolchain. Major facts:

- **`apps/vscode` now runs on the Cline SDK.** The extension consumes `@cline/core`, `@cline/llms`, and `@cline/shared` through an adapter layer in `apps/vscode/src/sdk/` (single codepath — no `CLINE_SDK` flag). The webview still talks gRPC; the adapter translates between the gRPC handlers and SDK calls.
- **`apps/vscode` is folded into the root bun workspace.** Package management and task running move from npm/node to **bun**; the extension links the local `@cline/*` packages via `workspace:*` instead of pinned published versions. **Node remains the runtime** (extension host, standalone `cline-core`, esbuild `platform: node`, prebuild ABI targets).
- **npm lockfiles deleted; root `bun.lock` is authoritative** (`apps/vscode`, `webview-ui`, and `testing-platform` per-package lockfiles removed).
- **CI updated** for the new layout: the `ext-vscode-*` workflows install once at the root with bun and build the SDK before the extension build.
- **VSCode extension version bumped to `4.0.0`.**

### Test Procedure

Validated locally before opening:

- `bun run lint` — clean.
- Typechecks across SDK packages, `@cline/cli`, `@cline/cline-hub`, plus `apps/vscode` extension + webview `tsc` — all clean.
- Extension esbuild bundle and both webviews (`apps/vscode/webview-ui`, `apps/cline-hub`) build.
- Unit suites: `apps/vscode` bun-unit (932 pass), webview-ui vitest (247 pass), and SDK package suites (llms 323, agents 41, shared 202) pass.

Watching CI here for the authoritative cross-platform signal.

### Type of Change

-   [x]  New feature (non-breaking change which adds functionality)
-   [x] ♻️ Refactor Changes
-   [x] 🏃 Workflow Changes

### Pre-flight Checklist

-   [ ] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
-   [x] Tests are passing (`bun test`) and code is formatted and linted (`bun run format && bun run lint`)
-   [x] I have reviewed contributor guidelines
2026-06-24 15:46:39 +09:00
Dominic Cooney b5388e5c8d chore(vscode): bump version to 4.0.0 2026-06-24 14:48:03 +09:00
Saoud Rizwan 65b3977bc5 fix(vscode): forward OCA reasoning effort to SDK sessions (#11739) 2026-06-24 14:43:39 +09:00
Dominic Cooney f4a46cf8e7 fix(deps): drop global vite override so cline-hub webview keeps vite 8
The bun migration relocated apps/vscode/webview-ui overrides to the root
package.json, including vite ^7.1.11. As a workspace-wide override this
forced vite 7 onto apps/cline-hub/src/webview, which targets vite 8 and
uses rolldownOptions in its vite.config.ts. That broke `bun run -F
@cline/cli build` (cline-hub build:webview) with TS2769 on rolldownOptions.

Removing the global override lets each workspace resolve its declared
vite: webview-ui stays on vite 7.3.5, cline-hub resolves vite 8.0.16.
Both webviews build and the webview-ui vitest suite (247 tests) passes.
2026-06-24 14:36:34 +09:00
Dominic Cooney 0b47033a0f fix(core): suppress cross-package import lint in SAP handler-factory test 2026-06-24 14:23:23 +09:00
Saoud Rizwan c94ef4b750 fix(vscode): revert OpenAI-compatible metadata limit plumbing (#11775)
* fix(vscode): stop deriving output limits from model metadata

* fix(vscode): send OpenAI-compatible output token limit (#11776)
2026-06-24 14:12:00 +09:00
Tomás Barreiro 3e855f7d3f Fix other instances of issues with the litellm model list (#11773)
* Fix other instances of issues with the litellm model list

* address comment
2026-06-24 14:12:00 +09:00
Saoud Rizwan 2cd062ce68 fix(vscode): keep model metadata out of provider settings (#11772)
* fix(vscode): keep model metadata out of provider settings

* fix(vscode): prune stale provider model metadata

* docs(vscode): explain provider metadata pruning
2026-06-24 14:12:00 +09:00
Tomás Barreiro be6999209e Prevent injecting other models into the LiteLLM model list (#11771) 2026-06-24 14:12:00 +09:00
Saoud Rizwan 9a1e6121c7 fix(llms): align SAP AI Core provider config (#11759)
* fix(core): align SAP AI Core mode config

* fix(llms): map SAP AI Core credentials to service binding
2026-06-24 14:12:00 +09:00
Tomás Barreiro 4ae0ff4d5a Build the SDK sourcemaps (#11757)
* Build the SDK sourcemaps

* Do not minify

* do not minify packages when building sourcemaps
2026-06-24 14:12:00 +09:00
BarreiroT 18737f1448 Map ClinePass model information 2026-06-24 14:12:00 +09:00
Saoud Rizwan b56ce72fc7 fix(core): forward SAP provider options to gateway (#11756) 2026-06-24 14:11:59 +09:00
MaxandMax Paulus 🥪 425182c7c0 Remove chat scroll action button (#11734)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-24 14:11:59 +09:00
MaxandMax Paulus 🥪 30fe302a71 fix retry after cline login issue (#11646)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-24 14:11:59 +09:00
BarreiroT eadbc09ffd Update generated models 2026-06-24 14:11:59 +09:00
BarreiroT 3178487bdd fix cline-pass options 2026-06-24 14:11:59 +09:00
Saoud Rizwan f58941c500 fix(core): add OCA legacy reasoning effort (#11746) 2026-06-24 14:11:59 +09:00
Saoud Rizwan 8da5ffa874 fix: wire up SAP provider (#11745)
* fix(vscode): wire SAP AI Core session config

* fix(vscode): remove redundant SAP base URL mapping
2026-06-24 14:11:59 +09:00
Dominic Cooney 9ed95d1dc2 fix(llms): restore provider-request capture wiring lost in SDK migration 2026-06-24 14:11:59 +09:00
Dominic Cooney 5fc7341312 chore: regenerate bun.lock after rebase onto main 2026-06-24 14:11:37 +09:00
Dominic Cooney d5b38f38cb fix(vscode): preserve OrgClinePass error UI through SDK rebase 2026-06-24 14:11:37 +09:00
Tomás Barreiro 386401b41f Identfy accounts for feature flag resolution (#11741)
* Identify accounts for Feature Flag resolution

* simply code
2026-06-24 14:11:37 +09:00
MaxandMax Paulus 🥪 6471d2475a if search result is undefined then don't crash the extension (#11733)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-24 14:11:37 +09:00
Max Paulus 🥪 fada079558 remove gap between approve bar and input box 2026-06-24 14:11:37 +09:00
BarreiroT 31d50ff54a Add log 2026-06-24 14:11:37 +09:00
BarreiroT efebc6380b Fix imports 2026-06-24 14:11:36 +09:00
Saoud Rizwan 2acc25cf6e test(vscode): raise vitest testTimeout to 20s to fix import-cost flakes (#11729)
Several vitest suites lazily await import() their subject inside the first
test (so vi.mock factories apply first). That import pulls in heavy workspace
packages (@cline/core, @cline/llms, @cline/shared), and on loaded CI runners
the first test in a file intermittently exceeds the 5s default timeout and
fails the nightly (observed in catalog.test.ts, now resolveModelInfo.test.ts).
Set a global 20s testTimeout so import cost attributed to the first test does
not cause flakes.
2026-06-24 14:11:36 +09:00
Saoud Rizwan 1ebe99c676 test(vscode): add invalidateProviderListings to auth-service mock controllers (#11727)
#11720 (feature flag resolution on startup) added a
controller.invalidateProviderListings() call to AuthService.sendAuthStatusUpdate
but did not update the test's mock controllers, which only stubbed
postStateToWebview. The new call threw on the mocks, so the throw happened
before postStateToWebview ran (failing the 'polls feature flags' test) and
caused subscribeToAuthStatusUpdate to delete the handler in its catch block
(failing the 'removes subscription on cleanup' test). Add the now-required
invalidateProviderListings stub to the mock controllers.
2026-06-24 14:11:36 +09:00
BarreiroT f5bbbd7f08 Log feature flags 2026-06-24 14:11:36 +09:00
Saoud Rizwan 8e4a2b8a19 fix(sdk): repair exposed provider auth routing (#11721)
* fix(sdk): repair exposed provider auth routing

* fix(sdk): use accessible ZAI coding plan default

* fix(sdk): use live Poolside model default

* docs(vscode): explain SDK provider key fallback
2026-06-24 14:11:36 +09:00
Tomás Barreiro dfdeffb558 Fix ModelAutocomplete selection (#11718) 2026-06-24 14:11:36 +09:00
Tomás Barreiro 1d22a51e30 Fix Feature Flag resolution on startup (#11720)
* Fix Feature Flag resolution on startup

* remove irrelevant test
2026-06-24 14:11:36 +09:00
Saoud Rizwan c827537386 test(vscode): use toBe instead of toMatchObject in proto conversion test (#11712)
api-configuration-conversion.test.ts is picked up by both the vitest
runner and the mocha-based vscode-test integration runner (.vscode-test.mjs
globs src/shared/**/*.test.js). vitest's jest-compat matcher toMatchObject
does not exist in the mocha runtime, so the test passed under vitest but
threw "toMatchObject is not a function" in the integration suite, failing
the nightly publish. Assert the two provider fields with toBe, which works
under both runners.
2026-06-24 14:11:36 +09:00
Saoud Rizwan f73121e369 test(vscode): warm catalog import to fix flaky 5s timeout (#11711)
The first test in catalog.test.ts paid the cost of dynamically importing
./catalog (which pulls in @cline/core, @cline/llms and @cline/shared)
inside its own 5s test timeout, intermittently failing CI/nightly runs.
Warm the import once in beforeAll so the cost falls outside any per-test
clock.
2026-06-24 14:11:36 +09:00
Saoud Rizwan 52bbda183d feat(vscode): expose additional SDK providers (#11703)
* feat(vscode): expose additional SDK providers

* fix(vscode): reserve skipped provider enum slots

* fix(vscode): keep Z.AI Coding Plan provider-specific
2026-06-24 14:11:36 +09:00
Saoud Rizwan 80036cef44 fix(sdk): route LiteLLM model fetches through SDK (#11705)
* fix(vscode): improve LiteLLM model fetch errors

* fix(vscode): align LiteLLM fetch return contract

* fix(sdk): improve LiteLLM private model fetch

* chore(vscode): drop duplicate LiteLLM fetch changes

* fix(vscode): route LiteLLM refresh through SDK
2026-06-24 14:11:36 +09:00
Saoud Rizwan b37f872a1f fix(vscode): honor OpenAI-compatible model settings (#11710)
* fix(vscode): honor OpenAI-compatible model settings

* fix(vscode): simplify OpenAI-compatible model bridge

* fix(vscode): respect OpenAI-compatible image support
2026-06-24 14:11:35 +09:00
Max ebe2b6498b fix(vscode): use Codex OAuth credentials (#11691) 2026-06-24 14:11:35 +09:00
BarreiroT 365250c785 Fix tests 2026-06-24 14:11:35 +09:00
BarreiroT 1e208472da fix tests 2026-06-24 14:11:35 +09:00
Tomás Barreiro ee974ea863 Fix ClinePass auth (#11680)
* Return local providers with ClinePass in the new extension

* Fix import

* Fix ClinePass auth
2026-06-24 14:11:35 +09:00
Tomás Barreiro a6416d02c8 Return local providers with ClinePass in the new extension (#11678)
* Return local providers with ClinePass in the new extension

* Fix import
2026-06-24 14:11:35 +09:00
Max Paulus 🥪 55cfeaec65 fix broken CI tests 2026-06-24 14:11:35 +09:00
Max Paulus 🥪 acfe779b20 fix local build not picking up .env file 2026-06-24 14:11:35 +09:00
Max Paulus 🥪 cff8c22f06 update vscode ignore
vsix bundling failed because of some unignored files
2026-06-24 14:11:35 +09:00
Max Paulus 🥪 8f2be20858 fix broken integ tests 2026-06-24 14:11:35 +09:00
Max Paulus 🥪 f1fd189631 remove storage tests from vitest
- these run under node resolution so they can see "bun:test" imports.
- these tests will get run by scripts/run-bun-unit-tests.ts instead
2026-06-24 14:11:34 +09:00
Cline Agent 9b03cb878d fix: repair SDK ClinePass webview rebase
Restore the webview feature-flag hook needed by the ClinePass onboarding/settings UI, but implement it against the existing posthog singleton instead of posthog-js/react so tests do not pull in a second React copy.

Make ClinePass settings follow the SDK provider-catalog pattern: render the Cline account card, resolve models with useProviderModels("cline-pass"), and persist selections with useProviderConfig/useProviderModelSelection for providerId="cline-pass". Remove the stale origin/main props that tried to drive the SDK-era ClineModelPicker, which is intentionally Cline-provider specific.

ClinePass remains hidden by the ext-cline-pass flag in settings/onboarding, and its model info hides token usage costs because billing is subscription-based.
2026-06-24 14:11:34 +09:00
Cline Agent 3dc020185f fix: post-rebase ClinePass plumbing for SDK migration
Resolve type-check and test breakages from rebasing the ClinePass
feature (origin/main) onto the SDK migration branch:

- provider-keys: re-add cline-pass to ProviderKeyMap and
  NON_SDK_PROVIDER_DEFAULTS (removed by the 'remove unused code'
  commit which predated ClinePass), so getProviderModelIdKey and
  getProviderDefaultModelId handle the cline-pass provider.
- provider-id: register 'cline-pass' in KNOWN_API_PROVIDERS so the
  Record<ApiProvider, true> constraint is satisfied.
- refreshClineRecommendedModels: add optional 'clinePass' field to
  ClineRecommendedModelsData so the RPC handler can map it into the
  proto response without a type error.
- refreshClineRecommendedModelsRpc: guard models.clinePass with ?? []
  for the same reason.
- handleClinePassProviderSelection: pass undefined (not null) to
  accountService.switchAccount to match the SDK signature.
- provider-keys.test: remove a duplicate closing brace left by the
  conflict resolution.
- Biome formatting (asNeeded semicolons) applied by check-types.
2026-06-24 14:11:34 +09:00
4829f08b3f fix(vscode): reliable MCP OAuth on the SDK extension (ENG-2108, CLINE-2304) (#11529)
* fix(vscode): store MCP OAuth in shared settings file like the CLI (ENG-2108)

VSCode stored MCP OAuth tokens in a single mcpOAuthSecrets secrets blob
keyed by sha256(name:url), while the CLI/SDK store per-server oauth state in
cline_mcp_settings.json. The two never interoperated (CLI auth was invisible to
VSCode), and VSCode's read-whole-blob/write-whole-blob through StateManager's
non-refreshing cache meant concurrent windows clobbered each other's tokens.

- Store MCP OAuth state in the shared settings file in @cline/core's format.
- Reads are fresh from disk; writes are scoped read-modify-write of one
  server's oauth key via updateMcpServerOAuthState (now atomic temp+rename).
- Replace the vscode:// callback flow with HTTP-based token collection via
  authorizeMcpServerOAuth (same local loopback flow the CLI uses).
- Reconnect an unauthenticated server when its tokens appear (e.g. CLI auth).
- One-time migration of legacy mcpOAuthSecrets tokens into the shared file.
- Remove McpOAuthRedirectResolver, mcpOAuthFlow, completeOAuth, and the
  mcp-auth URI callback route.

* feat(vscode): add --instances/--random-port to MCP OAuth test server

Lets you start several independent test servers, each on its own OS-assigned
random port, so you can add multiple streamableHttp MCP servers to Cline at
once and exercise concurrent OAuth flows. baseUrl now reflects the actually
bound port so discovery metadata and redirect URIs stay correct under random
ports.

* fix(vscode): stop MCP OAuth handshake writes from livelocking the settings watcher (ENG-2108)

Now that codeVerifier/clientInformation live in the shared settings file, the
MCP SDK's per-connect-attempt saveCodeVerifier() writes were tripping the
settings watcher, which re-entered updateServerConnections -> connectToServer
-> another write, looping forever. It was especially bad with two+
unauthenticated servers, where each server's verifier churn re-triggered the
other (visible as a flickering, ever-changing codeVerifier nonce).

The watcher now compares a connection-relevant fingerprint (full per-server
config minus the oauth block, plus a boolean for whether an access token
exists) and skips writes that only churn OAuth-handshake fields. A token
appearing/disappearing still changes the fingerprint, so CLI/other-window
authorization continues to trigger a reconnect via serverGainedOAuthTokens.

* feat(vscode): print paste-ready MCP settings fragment from OAuth test server

On startup the test server now emits an mcpServers JSON fragment (nested
transport shape, matching cline_mcp_settings.json) alongside the banner, so you
can paste it straight into the settings file instead of hand-writing it. With
--instances the entries get distinct names (oauth-test-1, ...), each carrying
its actual bound port.

* fix(vscode): atomic MCP settings writes + fingerprint gate; drop timer guards (CLINE-2097)

Deleting one MCP server could empty the whole list. Root cause: settings
writes were non-atomic (fs.writeFile), so chokidar (and any other process)
could read a transient empty/torn file mid-write and reconcile to zero servers.
The previous fix only masked this with a per-process isUpdatingClineSettings
boolean cleared on a 300ms timer — it did nothing for the CLI or other windows
and was racy.

Replace both timer guards (isUpdatingClineSettings, isUpdatingFromRemoteConfig)
with two deterministic, process-agnostic mechanisms:

- writeSettingsFile(): atomic temp-file + rename for every settings write, so
  any reader always sees a complete file. Holds for any number of concurrent
  writers (CLI, multiple windows, SDK OAuth handshake).
- content fingerprint: the watcher reconciles only when the connection-relevant
  view changed. writeSettingsFile pre-seeds the fingerprint so our own write is
  a no-op, while a genuine change from any other process is still processed.
  Because reconcile is idempotent and reads are never torn, a missed
  suppression is at worst a redundant reconnect, never data loss.

All RPC writers (toggle disabled, autoApprove x2, timeout, add, delete) and the
remote-config sync now go through writeSettingsFile. Removes all setTimeout(.,
300) flag juggling.

* feat(vscode): add a non-guessable 'frozzle' tool to the MCP OAuth test server

The MCP OAuth test server now serves tools/list + tools/call exposing a
'frozzle' tool whose output cannot be derived without calling it (reverse the
string and swap each letter's case, wrapped in guillemets). This gives an eval
a reliable end-to-end signal that the OAuth-authenticated MCP round-trip really
happened: a correct 'frozzle <text>' answer can't be hallucinated. The
transform is easy to verify at a glance and invertible. Adds frozzle.test.ts.

* fix(sdk): drop lingering OAuth callback sockets on close so deny->approve re-auth works (ENG-2108)

The local OAuth callback server's close() called Server.close(), which only
stops accepting new connections and lets existing keep-alive sockets linger.
The browser / global-fetch connection pool keeps such a socket to the fixed
callback port (1456) alive. So after the user denied an MCP OAuth request and
retried, the retry's approve callback could be delivered over the pooled socket
to the FIRST (already-settled) server. That server's settle() was a no-op, so
waitForCallback() never resolved, finishAuth()/token exchange never ran, and no
token was saved — the server stayed unauthenticated (the deny->approve repro).

Call server.closeAllConnections() in close() so no pooled socket outlives the
server. Adds a regression test driving a keep-alive agent across close().

* fix(vscode): actually reconnect MCP server when toggled back on (ENG-2108)

toggleServerDisabledRPC only flipped the in-memory disabled flag and set status
to 'connecting', but never rebuilt the connection. A disabled server's
connection has no live transport/client, so re-enabling left it stuck on the
yellow 'connecting' indicator forever and never re-advertised its tools to the
agent.

Tear down and rebuild the connection through deleteConnection + connectToServer
(which opens a real transport when enabled, or a disconnected stub when
disabled), then notifyWebviewOfServerChanges so the SDK session's tool list is
refreshed. OAuth state is preserved (deleteConnection doesn't clear it). Adds
McpHub.toggleServerDisabledRPC.test.ts.

* fix(vscode): reload MCP tools silently without chat spam (ENG-2108)

Restarting the SDK session to pick up MCP tool changes appended visible chat
messages ('MCP tools changed - reloading...' and 'MCP tools reloaded
successfully...') plus a completion_result banner. Toggling several servers
piled up many of these. Tool reloading should be transparent.

Emit only the session status transitions (running -> idle) via
emitSessionEvents([], ...) instead of appendAndEmit, so no chat messages or
completion banner are shown. Genuine reload failures still surface an error
message. Updates sdk-mcp-coordinator.test.ts accordingly.

* docs(mcp): clean up comments to describe current behavior

Revise comments across the MCP OAuth and settings code to document the code as
it stands, dropping references to prior implementations, task IDs, and
before/after narration. Also reflow the auth-server regression test to the
repository's formatter. No behavior change.

* fix(vscode): atomic fallback write in remote MCP sync; document sync OAuth I/O

Make the no-McpHub branch of syncRemoteMcpServersToSettings write via an
atomic temp-file + rename so a concurrent reader never observes a torn or
empty settings file, matching every other settings write.

Document why the OAuth state read-modify-write in McpOAuthManager is
synchronous: it serializes this process's shared-file updates without a
Promise queue, which we prefer over async I/O for reliability of the
cross-process settings file.

* fix(mcp): serialize settings read-modify-writes

* docs(vscode): clarify MCP settings create race

* fix(vscode): create MCP settings atomically

* fix(cli): keep clearing missing MCP OAuth state a no-op

* fix(vscode): avoid yielding while holding MCP settings lock (#11596)

* fix(mcp): async lock acquisition for VSCode MCP settings/OAuth writes

Add updateMcpSettingsFile/updateMcpServerOAuthStateAsync to @cline/core that
yield the event loop while acquiring the cross-process settings lock instead of
blocking it with Atomics.wait. The critical section stays synchronous and the
mutator stays pure, so the lock is never held across an await and serialization
is preserved without an in-process queue.

Route the VSCode extension host's OAuth state writes (McpOAuthManager) through
the async variant so a connection-time OAuth callback can no longer freeze the
extension host event loop or deadlock against an in-flight updateMcpSettingsFile
whose lock-releasing continuation needs the loop.

Unify the sync and async acquisition paths on a shared reentrancy guard
(activeLocks) so a nested settings update on the same file fails fast instead of
self-deadlocking.

Tests: contended async serialization asserting zero Atomics.wait calls, async
stale-lock reclaim, reentrancy fail-fast, and uncontended run+release.

* fix(mcp): bootstrap missing settings file inside the lock; tidy docs

Creating the MCP settings file now happens in one place: the locked
read-modify-write helpers. A missing file reads as an empty settings object, so
the first write to a fresh path (e.g. a fresh-install `cline mcp add`) creates
it inside the lock instead of throwing ENOENT. The SDK (updateMcpSettingsFile /
updateMcpSettingsFileSync) and the VSCode lock helper share this contract, so
callers no longer need to pre-create the file. Add regression tests for the
SDK, the CLI wizard addServer(), and the VSCode helper on a missing path.

Also flag the synchronous SDK entry points (updateMcpSettingsFileSync,
updateMcpServerOAuthState) as preferring their async siblings, with a TODO to
delete them once all callers migrate, and tighten the lock-helper doc comments
to describe current behavior.

* fix(vscode): finish npm->bun migration in dev tooling, tasks, and docs

The npm->bun migration (#11632) updated package scripts, .vscodeignore and .vscode-test.mjs but left a trail of npm/npx/node invocations in editor configs, dev scripts, and docs. Following the breadcrumbs from 'npm run protos':

- .vscode/launch.json: standalone-core debug uses 'bun <file>.ts' (was npx tsx); Open Storybook uses 'bun run' (was npm run).
- .vscode/tasks.json: all task commands use 'bun run' (was npm run).
- scripts/run-extension-host.sh and .claude/hooks/claude-code-for-web-setup.sh: 'bun run' (was npm run).
- debug-harness/server.ts: shebang 'bun'; build steps use 'bun run protos', 'bun esbuild.mjs', 'bunx vite build' (were npm/node/npx).
- dev script shebangs (test-hostbridge-server, test-standalone-core-api-server, testing-platform-orchestrator, interactive-playwright): '#!/usr/bin/env bun' (was npx tsx).
- WebviewProvider HMR hint, e2e README, copilot-instructions, PR template, mcp-oauth-test-server docs, generate-state-proto message, tsconfig.test comment, state-keys test comment: bun.

Left untouched (correct per .clinerules/bun-and-node): Node-runtime invocations (node build.mjs), prebuild-install --target=<node>, vsce, 'npm install -g cline' (user CLI install), and App.stories.tsx mock chat fixtures.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-06-24 14:11:34 +09:00
Dominic CooneyandCline Agent 82d1846a45 Migrate apps/vscode from npm/node to bun (#11632)
* chore(vscode): migrate package management & build from npm/node to bun

Fold apps/vscode (+ webview-ui, testing-platform) into the root bun
workspace so the extension consumes the local @cline/* SDK packages via
workspace symlinks instead of pinned published versions, eliminating the
SDK vendoring cycle. Node remains the runtime (extension host, standalone
cline-core, esbuild platform:node, prebuild-install ABI target).

- root: drop "!apps/vscode", add nested members, relocate overrides to
  root, add trustedDependencies [better-sqlite3, grpc-tools]
- apps/vscode: @cline/* -> workspace:*, scripts -> bun/bunx,
  npm-run-all -> bun --parallel, drop cross-env; keep esbuild + vite;
  declare previously-hoisted phantom deps (nice-grpc-common, playwright)
- package-standalone.mjs: npm install -> bun install (isolated dist dir)
- CI: setup-bun + single root bun install --frozen-lockfile, build:sdk
  before extension build, better-sqlite3 binary + zero-test guards;
  publish workflows intentionally keep setup-node for vsce/ovsx
- docs/comments: curated pass (keep-list vs rewrite-list), add
  apps/vscode/docs/bun-migration-notes.md guard doc
- delete npm lockfiles (root bun.lock authoritative)

Deferred to follow-up PRs: test-runner migration to bun test (Phase 4)
and devDep cleanup (Phase 6).

* test(vscode): add bun test foundation for the vitest-native unit suites

Phase 4a of the test-runner migration. Adds a bun test runner that
reaches full parity (582 pass / 0 fail / 50 files) with the existing
vitest SDK-adapter + model-catalog suite, without touching the
@vscode/test-cli integration tests or the webview vitest suite.

- bunfig.toml: [test] preload
- src/test/bun-test-preload.ts: mock.module() shadows `vscode` and
  `@cline/core` with their unit-test stubs (bun's onResolve plugin hook
  does not intercept host/symlinked specifiers); seeds real @cline/core
  export names as undefined to satisfy bun's strict ESM named-import
  linking; full vitest->bun:test shim (vi.fn/mocked/spyOn, describe/it/
  expect/before*/after*)
- scripts/run-bun-tests.ts: mirrors vitest.config.ts include[] exactly and
  runs with --parallel for per-file mock isolation (bun test's single-process
  default lets mock.module clobber across files)
- test:bun script

* test(vscode): migrate node-side unit suite from mocha to bun test

Phase 4b of the test-runner migration. The standalone mocha unit runner
(.mocharc spec: __tests__/* + test/services/**) was already broken under
bun (mocha was a phantom dependency — only @types/mocha/ts-node were
declared, npm hoisted mocha transitively). Migrate it to `bun test`.

- codemod 77 files: import { ... } from "mocha" -> "bun:test", renaming
  before->beforeAll / after->afterAll at imports and call-sites; chai,
  should and sinon kept as libraries (they work under bun test)
- convert sinon.stub() on ESM namespace exports to mock.module()/spyOn
  (bun loads real ESM: "ES Modules cannot be stubbed")
- scripts/run-bun-unit-tests.ts: runs the .mocharc spec set with one
  isolated `bun test` process per file (Bun.spawn + concurrency pool),
  restoring vitest-forks module-registry isolation (bun's single-process
  default lets mock.module leak across files)
- scripts/codemod-mocha-{to-bun,this}.ts: one-shot migration tooling
- test:unit now runs the bun unit runner; CI calls bun + a non-zero
  pass-count guard instead of `bunx nyc ... mocha`
- tsconfig: add root node_modules/@types to typeRoots so `bun:test`
  types resolve under tsc; cast loose os.userInfo mocks in shell.test

Result: unit suite 58 files / 880 pass / 0 fail; vitest set still
582/0. @vscode/test-cli integration tests and webview vitest unchanged.

* chore(vscode): remove dead mocha-runner deps and artifacts

Phase 6 cleanup after the bun test migration. The standalone mocha unit
runner is gone (replaced by scripts/run-bun-unit-tests.ts), so its
config and now-unused devDependencies are removed.

- remove dead files: .mocharc.json, tsconfig.unit-test.json,
  src/test/requires.ts, .nycrc.unit.json
- remove unused devDeps: @types/mocha, @types/proxyquire, ts-node,
  tsconfig-paths, cross-env, npm-run-all, nyc, proxyquire, husky
  (root owns the husky hook; chai/should/sinon stay — used as libs)
- install:all -> single root `bun install` (workspace covers webview-ui)
- drop .mocharc.json / .nycrc*.json from CI paths-filters and
  .vscodeignore; add bunfig.toml to the filters

Verified: check-types clean, unit 880/0, vitest 582/0.

* fix(vscode): import bun:test globals in tests that relied on ambient @types/mocha

CI Quality Checks (clean `bun install` without @types/mocha) surfaced
TS2582/TS2304 "Cannot find name 'describe'/'it'/'beforeEach'" in test
files that used the global mocha/jest test functions without importing
them. The Phase 4b codemod only rewrote files that imported from
"mocha"; these used ambient globals, so they were missed (and passed
locally because a stale @types/mocha lingered in node_modules).

Add explicit `bun:test` imports (before->beforeAll, after->afterAll in
TelemetryService.test.ts). chai/sinon stay as libraries.

Verified against a clean tree (no @types/mocha): check-types 0 errors,
unit suite 58 files / 880 pass / 0 fail.

* style(vscode): biome-format migrated test files + codemod scripts

The mocha->bun:test codemod and manual import edits left formatting that
didn't match biome (the CI `format` check, which validates files changed
since main, flagged them). Also narrow setup.ts's bun:test import to the
actually-used beforeEach/afterEach (describe/it only appear in a JSDoc
example), fixing a noUnusedImports lint error.

ci:check-all (check-types + lint + format) now passes locally.

* fix(webview-ui): declare phantom deps + pin React 18 types under bun workspace

Folding webview-ui into the bun workspace changed its install topology
from an isolated npm flat tree to the shared hoisted store, surfacing
two classes of pre-existing latent issues that npm hoisting had masked:

1. Phantom dependencies: src imports `marked`, `unist`, `unist-util-visit`
   and `@heroui/theme` directly but never declared them. Declared them
   (marked ^15, unist-util-visit ^5, @types/unist ^3, @heroui/theme 2.4.26).
2. React types: @testing-library/react's optional peer pulls @types/react@19
   into a resolvable location; tsc mixed it with the toolkit's React 18
   types (React 19 dropped Component.refs), breaking 452 JSX usages. Pin
   react/react-dom type resolution to webview-ui's React 18 copy via
   tsconfig paths.

build:webview (tsc -b && vite build) and ci:check-all now pass.

* fix(vscode): restore @types/mocha for integration build + add bun:test types

The @vscode/test-cli integration runner still uses mocha, and
tsconfig.test.json compiles all src/**/*.test.ts (including bun-migrated
files) to out/. So:
- restore @types/mocha (integration compile needs the mocha ambient types)
- add `bun` to tsconfig.test.json types + root @types to both tsconfig
  typeRoots so `bun:test` resolves under tsc for the migrated tests

* fix(vscode): declare glob — phantom dep used by package-standalone.mjs

scripts/package-standalone.mjs imports `glob` but it was never declared
(resolved transitively under npm's flat hoist). Under the bun workspace
store it's unresolvable, failing postcompile-standalone with
ERR_MODULE_NOT_FOUND. Declare glob ^11 (modern named-export API).

compile-standalone now produces dist-standalone/standalone.zip.

* fix(ci): strip ANSI before vitest zero-test guard grep

The vitest summary line colorizes the count ("Tests  <ansi>582 passed"),
so the count isn't adjacent to the "Tests" label in raw bytes and the
guard regex failed even though 582 tests passed. Strip ANSI escapes
before matching.

* fix(vscode): declare minimist — phantom dep in testing-platform-orchestrator

scripts/testing-platform-orchestrator.ts imports `minimist` (undeclared,
resolved transitively under npm hoist). Declare it so the testing-platform
integration job runs under the bun workspace store.

* fix(vscode): restore tsconfig-paths for integration runner; tp-orchestrator uses bun

Phase 6 over-removed tsconfig-paths: test-setup.js (loaded by the
@vscode/test-cli mocha integration runner) requires it to resolve @/
aliases in the compiled out/ tree — the extension host test runner failed
with "Cannot find module 'tsconfig-paths'". Restore it. Also switch the
testing-platform spawn from `npx ts-node index.ts` to `bun index.ts`
(bun runs TS natively; avoids the removed ts-node).

* fix(vscode): route tests by bun:test import marker; integration runner stays mocha

The mocha->bun codemod swept up tests that the Node-based @vscode/test-cli
integration runner compiles/runs, which cannot load the `bun:test` builtin
(and some need the real VSCode host). Establish a single source of truth:
a *.test.ts is bun-runner-owned IFF it imports "bun:test".

- run-bun-unit-tests.ts: discover files by the bun:test import marker
  (not fixed globs), so every migrated file runs under bun.
- build-tests.js: generate a tsconfig that excludes all bun:test files
  from the integration compile (json5-parsed), so out/ never contains
  bun:test; gitignore the generated config.
- .vscode-test.mjs: exclude the bun unit dirs from the runner globs.
- revert host-dependent tests (hostbridge/*, extension, terminal,
  FileContextTracker host bits) and 3 files with sinon-on-ESM/behavioral
  issues (ClineIgnoreController, mentions, TelemetryService) back to
  mocha; they run on @vscode/test-cli as before.

Verified: check-types 0 errors; compile-tests 0 bun:test in out/;
bun unit 65 files/962 pass/0 fail; vitest 582/0.

* fix(vscode): declare mocha — phantom dep for @vscode/test-cli integration runner

The @vscode/test-cli extension host loads `mocha` at runtime to run the
integration suite, but only @types/mocha was declared (npm hoisted the
mocha package transitively; bun's store does not expose it). The host
failed with "Cannot find module 'mocha'". Declare mocha ^11.7.4 (matches
@vscode/test-cli's own range).

* fix(vscode): robust Windows protoc-gen-ts_proto plugin resolution under bun

build-proto.mjs hardcoded node_modules/.bin/protoc-gen-ts_proto.cmd for
Windows, but bun's workspace store places/extensions the bin shim
differently (hoist + .cmd/.bunx), so Windows protos failed with
"protoc-gen-ts_proto: The system cannot find the file specified". Probe
the local + root .bin with known shim extensions instead. Also update
the testing-platform usage string (ts-node -> bun).

* fix(vscode): generate node .cmd wrapper for ts-proto plugin on Windows

The previous probe found bun's `.bunx` shim, but protoc cannot exec it
("%1 is not a valid Win32 application"). Instead, on Windows generate a
small .cmd wrapper that runs the resolved protoc-gen-ts_proto JS via
`node`, which protoc can execute regardless of package manager. POSIX
path (direct JS bin) is unchanged.

* fix(vscode): package VSIX with --no-dependencies (bundled) to stop monorepo traversal

Under the bun workspace, @cline/* are workspace:* symlinks pointing to
../../../../sdk/packages/*. vsce, walking the dependency tree, followed
them out of apps/vscode and packaged the whole monorepo (../, ~84MB incl.
root node_modules and .env), which crashed vsce's secret scanner and
failed all e2e jobs.

The extension is fully esbuild-bundled into dist/extension.js, so vsce
should not walk node_modules at all. Add --no-dependencies to every
vsce/ovsx package/publish path (e2e build, marketplace, nightly), and
tighten .vscodeignore to drop nested node_modules and dev-only inputs
(scripts, proto, testing-platform, bunfig, esbuild.mjs, etc.).

Result: VSIX is 39 files / ~7 MB and the secret scan passes.

* docs(vscode): tighten bun/node comments and consolidate into a clinerule

- add .clinerules/bun-and-node.md (eternal-now: bun=tooling, node=runtime,
  keep-list, and the bun:test-vs-mocha test routing rule); remove the
  apps/vscode/docs/bun-migration-notes.md migration doc and point
  .clinerules/general.md at the rule (single-line bullet matching the file).
- fix the hotfix-release note: there is no infra step that regenerates the
  lockfile; a CHANGELOG+version bump leaves bun.lock consistent (workspace
  versions aren't pinned) and publish runs --frozen-lockfile.
- reframe runner/preload comments to describe the code as-is (drop
  "migrated off mocha"/codemod history); add a TODO on the bun-test preload
  to migrate suites off the vitest `vi` shim to native bun:test and delete it.
- remove the one-shot mocha->bun codemod scripts.

* fix(debug-harness): pin debugee VSCode version so bundled Playwright can drive it

The harness downloaded "stable" VSCode (currently 1.125 / Electron 42),
which the bundled Playwright cannot drive — `_electron.launch()` hangs
until its 60s timeout (Electron started and a window appeared, but the
launch handshake never completed). Default to a known-good version
(1.103.0, matching the e2e CI matrix) and allow override via
VSCODE_TEST_VERSION.

* fix(webview): render under bun workspace — dedupe React, drop stale codicons link

The webview mounted but crashed before rendering (blank sidebar; e2e
"Login to Cline" never visible) with "Cannot read properties of null
(reading 'useRef')" — the classic two-React-copies / null hook dispatcher.
Under the bun workspace, sibling packages pull react@19 into the shared
store and a transitive webview dep resolved a second React instance into
the vite bundle. Add resolve.dedupe + pin react/react-dom to webview-ui's
own React 18 copy.

Also drop the separate `<link>` to node_modules/@vscode/codicons in the
webview HTML: the webview's index.css already @imports codicons, so the
font is bundled into the build assets. Under bun that node_modules path
is a symlink to the root store (outside the webview localResourceRoots)
and isn't packaged with --no-dependencies, so the link 404'd; the bundle
covers it. Re-scope the .vscodeignore nested-node_modules exclude so it
no longer shadows the codicons re-include.

* fix(debug-harness): disable GPU so the debugee renders in headless/VM envs

On headless/VM GPU stacks the debugee Electron's GPU process crash-loops
("Exiting GPU process during initialization" / CreateCommandBuffer
kTransientFailure), killing the window before Playwright finishes
attaching and tripping the 60s launch timeout. Force software rendering
(--disable-gpu and friends) for a stable harness launch.

* fix(debug-harness): survive launch failures; configurable, longer launch timeout

The harness crashed (whole bun process exited) whenever VSCode launch
failed/timed out: Playwright emits a late unhandled rejection on the dead
CDP transport after we've already handled the launch error, and the
default behavior takes the HTTP server down with it — forcing a full
restart just to retry.

- Add process-level unhandledRejection/uncaughtException guards so stray
  async errors are logged and the server keeps serving (retry via `launch`).
- On launch failure, close the orphaned Electron so a retry isn't blocked.
- Make the _electron.launch timeout configurable (--launch-timeout) and
  raise the default to 120s for cold launches; document VSCODE_TEST_VERSION.

* fix(ci): address review feedback — vsix --no-dependencies, drop stale coverage path, Windows shell

- ext-vscode-publish-stable.yml: add --no-dependencies to the release-artifact
  `vsce package` (Max's catch). Without it, vsce follows the @cline/* workspace
  symlinks out of the package and bloats the .vsix with the whole monorepo.
- ext-vscode-test.yml: drop the stale apps/vscode/coverage-unit/lcov.info upload
  path (Max's catch). That file was produced by the removed nyc unit-coverage
  step (.nycrc.unit.json); nothing generates it now.
- ext-vscode-test-e2e.yml: the better-sqlite3 assert step ran under the Windows
  runner's default pwsh and failed to parse the POSIX test. Pin it to `shell: bash`
  (Git Bash ships on windows-latest); the non-e2e job already defaults to bash.

---------

Co-authored-by: Cline Agent <cline-agent@users.noreply.github.com>
2026-06-24 14:11:34 +09:00
MaxandMax Paulus 🥪 8636272eb5 Improve onboarding funnel metrics (#11650)
* improve onboarding metrics

* fix onboarding page view dedupe

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-24 14:11:34 +09:00
Max Paulus 🥪 815b0346f6 fix package lock issues post rebase 2026-06-24 14:11:34 +09:00
MaxandMax Paulus 🥪 84fe95de4d fix(vscode): persist Vertex provider settings (#11565)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-24 14:11:34 +09:00
MaxandMax Paulus 🥪 36be3333d4 fix(vscode): preserve legacy task metadata on resume (#11570)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-24 14:11:34 +09:00
Dominic Cooney 96c0bd70a4 chore(vscode): remove stale HuggingFace provider test 2026-06-24 14:11:33 +09:00
Max Paulus 🥪 6f621e4bf2 fix standalone e2e test 2026-06-24 14:11:33 +09:00
Max Paulus 🥪 d7ea201c25 bump sdk version 2026-06-24 14:11:33 +09:00
Dominic Cooney e3b3e6b0ad fix(vscode): simpler login UX (ENG-2174) — remove inline provider gate, restore debug harness (#11534)
* remove inline no-usable-provider sign-in banner; rely on inference-time errors

The "Sign in to Cline or set up a provider" banner gated chat input on a
parallel provider-usability heuristic that mis-detected BYOK setups
(Bedrock profile/IAM, Vertex ADC) and its sign-in button discarded the
device code. Remove the component and the hasUsableProvider plumbing.

Auth/config problems now surface at inference time, where handling
already exists:
- cline provider without a token -> emitClineAuthError -> ErrorRow
  renders the Sign in button with the device-code display
- any other misconfigured provider -> say:"error" row

Also deletes the now-dead sdk/provider-usability module and adds a test
that failed session start emits a plain chat error.

* restore debug-harness server deleted in 0bfbfb944

Commit 0bfbfb944 ("delete unused files") removed src/dev/debug-harness/server.ts
as dead code, but it is a dev tool launched directly via
`npx tsx src/dev/debug-harness/server.ts` (see its README and
.clinerules/debug-harness.md) — no static import graph reaches it, which
is why the unused-file analysis flagged it. The README, the .clinerules
docs, and the CLINE_CAPTURE_BROWSER / __clineHandleUri hooks in
extension.ts and utils/env.ts that exist solely for this harness all
survived the deletion, leaving them dangling.

Restored verbatim from 0bfbfb944~1; verified it boots and listens on
:19229.
2026-06-24 14:11:33 +09:00
Saoud Rizwan 53d0f50494 fix(vscode): restart session when user switches provider (#11507)
* fix: format Cline OAuth tokens in provider config

* fix(vscode): restart SDK session on provider switch

* fix(vscode): serialize SDK provider restarts

* chore(vscode): keep provider switch PR scoped

* fix(vscode): simplify deferred provider restarts
2026-06-24 14:11:33 +09:00
Mikołaj Kondratek 141a61fd45 fix: thread proxy/CA-aware fetch into the SDK inference path (#11462)
* fix: thread proxy/CA-aware fetch into the SDK inference path

The main agent loop did not receive the host's proxy/CA-aware fetch, so
on JetBrains and the CLI inference over a corporate proxy or to a
self-signed/private-CA endpoint failed with "unable to get local issuer
certificate". This regressed at the SDK cutover: the pre-SDK CLI
(2.18.0) constructed provider clients with a proxy-aware fetch directly,
while the SDK agent loop fell back to bare global fetch (CLINE-2353).

Two layers:
- App (cline-session-factory.ts): always build CoreSessionConfig.
  providerConfig and carry the proxy-aware fetch from @/shared/net, not
  just for Bedrock. In VSCode this fetch is global fetch, so behavior is
  unchanged there; in the standalone (JetBrains) build it is undici with
  EnvHttpProxyAgent.
- SDK (handler-factory.ts): forward providerConfig.fetch into
  createGateway both as the top-level fallback fetch and per provider, so
  the gateway's provider clients use it. Passing undefined is a no-op
  (registry resolves config?.fetch ?? defaults?.fetch ?? fallbackFetch),
  so other SDK consumers are unaffected.

The SDK change covers every host that supplies a fetch; the app change
covers VSCode and JetBrains. The CLI builds its session config through a
separate path (apps/cli) that does not yet wire a proxy-aware fetch, so
CLINE-2353 on the CLI surface is addressed in a follow-up.

Adds a handler-factory unit test asserting the host fetch is forwarded
to createGateway at both the top level and per provider.

* fix: deterministically install proxy dispatcher in standalone core

The proxy/CA-aware undici dispatcher is installed as a side effect of
loading @/shared/net (it calls setGlobalDispatcher with EnvHttpProxyAgent
in the standalone build). The standalone entry cline-core.ts did not
import that module, so the dispatcher was only installed incidentally
when some other transitively-imported module happened to pull it in. A
future change to the import graph could silently drop proxy/CA support on
JetBrains.

Import @/shared/net for its side effect, first, so the install is
deterministic and runs before any network use (CLINE-2353).

Standalone-only hardening; VSCode uses global fetch and is unaffected.
2026-06-24 14:11:33 +09:00
Saoud Rizwan 7578a1de36 fix(vscode): fix duplicate tool row when changing plan/act mode during pending tool approval (#11437)
* fix(vscode): suppress duplicate tool row when a mode change clears a pending approval

Switching plan/act while a tool approval was pending duplicated the
approval row in chat. clearPending resolved the pending approval as
denied, which unblocks the core; the core then emits the denied tool
call's content_start/content_end events before the mode coordinator's
abort lands. The interactive deny paths record the denial in the
message translator state so those events are suppressed, but
clearPending skipped that step, so the translator rendered the events
as a fresh say:tool row next to the still-visible approval ask.

clearPending now records the denial through recordDeniedToolApproval
before resolving, mirroring resolvePendingToolApproval. This covers all
clearPending callers: mode changes, task cancel, and task clear.

* refactor(vscode): trim the clearPending denial fix to its minimal shape

Keep clearPending's original structure, only inserting the denial
recording before the resolve. Drop the end-to-end suppression test:
translator suppression for recorded denials is already covered by
message-translator-approval-denial.test.ts, and the clearPending
recording is covered by the extended unit assertion.
2026-06-24 14:11:33 +09:00
Saoud Rizwan 5aa60cf116 fix(vscode): restore aggressive pin-to-bottom auto scroll in chat view (#11436)
* fix(webview): restore aggressive pin-to-bottom auto scroll in chat view

The auto-scroll effect only fired on groupedMessages.length changes, but in
the SDK-migrated extension new content can appear in the chat without the
message list length changing:

- The Thinking placeholder row is driven by turnState alone (e.g. the plan
  to act switch auto-continues the task with no new message), and it was
  appended to the rendered list inside MessagesArea where the scroll hook
  never saw it.
- New tool messages merge into the trailing tool group, and the thinking
  placeholder gets swapped for a real reasoning row at constant length.

Fixes:
- Lift the thinking placeholder computation out of MessagesArea into a new
  useDisplayedGroupedMessages hook so ChatView feeds the same list to both
  Virtuoso and useScrollBehavior; the placeholder appearing now pins to
  bottom like a real message.
- Key the pin effect on the tail message ts (skipping the placeholder) in
  addition to list length, covering in-place tail changes.
- Re-engage auto scroll when turnState.phase transitions into streaming. In
  the old extension every turn start was accompanied by a user send/button
  click that reset disableAutoScrollRef; turnState-driven turn starts like
  plan to act auto-continue have no webview-side action, so handle it in
  the scroll hook.

* refactor(webview): replace scroll fix with minimal single-file version

Same three behaviors as the previous commit (pin when the thinking
placeholder appears, pin on in-place tail changes, re-engage auto scroll
when a turn starts streaming) but implemented as two small effects in
MessagesArea, which already has both the rendered list and scrollBehavior
in scope. Reverts the useDisplayedGroupedMessages hook extraction and the
ChatView/useScrollBehavior changes; net diff vs the base branch is now
one file.
2026-06-24 14:11:33 +09:00
Dominic Cooney b62e572ea9 test(vscode): exercise full SDK structured edit flow in file-edit e2e (#11442)
* test(vscode): exercise full SDK structured edit flow in diff.test.ts e2e (ENG-2042)

The SDK runtime executes structured (OpenAI-format) tool calls instead of parsing XML-style tool syntax out of assistant text. Teach the e2e mock server to stream an editor tool call for edit_request (arguments split across deltas to exercise fragment reassembly), answer the SDK's follow-up tool-result request (role:'tool' message) with turn-ending completion text, and remove the classic XML-era EDIT_REQUEST/REPLACE_REQUEST responses.

diff.test.ts now covers the full approval flow: approval ask row -> Save -> editor tool writes the file -> completion text, verifying the edit on disk and restoring the git-tracked fixture afterwards. The old 'test.ts: Original <-> Cline's Changes' diff-tab assertions are unreachable under the SDK executor architecture (the editor executor writes via Node fs and does not route through DiffViewProvider); this behavioral difference is documented in the test file.

* test(vscode): address review feedback on diff.test.ts e2e

- Scope the mock server's tool-result follow-up detection to edit_request conversations so tool results from other (future) scenarios don't mis-route to EDIT_REQUEST_COMPLETE.

- Move the fixture readFileSync inside the try block and guard the finally restore, so a failed read doesn't bypass cleanup attribution or write undefined back to the fixture.

* docs(vscode): rephrase diff e2e comments to describe current behavior

Comments described historical behavior (XML-style tool-call parsing that predates the SDK runtime), which is confusing to readers of the current code. Rephrase them to describe the code as it exists now.

* test(vscode): rename diff.test.ts to file-edit.test.ts and drop duplicated preamble

The test no longer touches a diff editor (the SDK editor executor writes files directly after approval), so the 'Diff Editor' name was misleading. Rename the file and describe block to match what it asserts: the file-edit approval flow.

Drop the first half of the test (send hello, wait, New Task, check history), which duplicated chat.test.ts, and the mock server's 500ms delay that existed only to support an 'API Request...' visibility assertion that no longer exists.
2026-06-24 14:11:33 +09:00
Robin Newhouse 268462ddcf fix(vscode): stabilize SDK e2e login flow (#11441) 2026-06-24 14:11:33 +09:00
Dominic Cooney 072e237d01 fix(vscode): persist skill disable to SKILL.md frontmatter so the model honors it (ENG-1995) (#11294)
* fix(vscode): persist skill disable to SKILL.md frontmatter so the model honors it (ENG-1995)

The VS Code skill toggle only updated extension state (globalSkillsToggles /
localSkillsToggles), but the SDK builds the model's skill list and the `skills`
tool from each SKILL.md's frontmatter `disabled` flag. As a result, disabling a
skill in the sidebar left it fully available to the model, including in new
tasks.

toggleSkill now also writes the `disabled` flag to the skill's SKILL.md
frontmatter (no-op for remote skills, which have no backing file), via new
helpers updateSkillMarkdownDisabledState / setSkillDisabledInFrontmatter in
skills.ts. Adds unit tests for both helpers.

* fix(vscode): don't rewrite skills with malformed frontmatter (ENG-1995)

parseYamlFrontmatter fails open on invalid YAML, returning the full original
document as the body. updateSkillMarkdownDisabledState would then prepend a
second `---` block on a disable, corrupting the file. Bail out and leave the
file untouched when frontmatter fails to parse. Adds tests for the malformed
disable/enable cases.

Addresses Greptile review feedback on #11294.

* test(vscode): assert malformed-skill fixture is actually invalid YAML (ENG-1995)

Add a guard test that parseYamlFrontmatter reports hadFrontmatter and a
parseError for the shared malformed fixture, so the two "leave file untouched"
tests can't silently pass via a different code path if the fixture ever became
valid YAML.

Addresses Greptile review feedback on #11294.

* fix(vscode): resolve @cline/shared/storage subpath in mocha unit-test compile

The CommonJS mocha unit-test runner uses classic "node" moduleResolution,
which does not read the `exports` subpath maps in @cline/* package
manifests, so `@cline/shared/storage` (imported by
src/sdk/telemetry-settings-sync.ts) failed with TS2307 when test files
transitively reach the SDK adapter. Mirror the explicit paths mapping
already added to tsconfig.test.json for the integration-test compile.

* fix(vscode): restore E2E mock auth in SDK auth service so e2e tests can sign in

The SDK migration replaced classic AuthService (which swapped in
AuthServiceMock under E2E_TEST) with sdk/auth-service.ts, losing the
mock path. "Login to Cline" then invoked the real SDK OAuth flow and
opened a native browser dialog the Playwright tests cannot interact
with, so helper.signin() never authenticated and chat.test.ts +
diff.test.ts failed on every platform (the failures also reproduce on
the base branch).

- auth-service.ts: under E2E_TEST=true (and CLINE_ENVIRONMENT=local),
  exchange the well-known test code with the local mock API server and
  persist credentials to providers.json — no browser. Replaces classic
  AuthServiceMock (see origin/main src/services/auth/AuthServiceMock.ts).
- chat.test.ts/diff.test.ts: wait for the mock turn to complete before
  clicking New Task; SDK history is persisted at turn end, so navigating
  mid-turn races the write and Recent never shows.
- diff.test.ts: the footer Start New Task button only appears for
  attempt_completion turns under SDK TurnState; use the header New Task
  button like chat.test.ts.
2026-06-24 14:11:32 +09:00
Saoud Rizwan 31cff1f75e fix(vscode): auto-continue the task when switching from plan to act (#11401)
* fix(vscode): enforce stop-before-start ordering for same-id session restarts

The app reuses the taskId as the sessionId whenever it replaces or
resumes a session (mode/MCP rebuilds, follow-up resume, history
restore), but the old session's stop ran fire-and-forget, and core
cleanup is keyed by sessionId across multiple awaits. A stop still in
flight when the same-id replacement started could tear down the live
successor: late sessions-map deletes, a late 'ended' emission, or a
stalled status write landing on the replacement.

Adopt the sequencing invariant the CLI has always used: never start a
same-id session while its stop is in flight. SdkSessionLifecycle tracks
in-flight stops in a pendingStops map keyed by sessionId, and
startNewSession awaits the pending stop for a reused id before starting
(with a log line so a wedged stop is diagnosable). Fresh-id starts
never wait. fireAndForgetSend additionally captures the ActiveSession
by object identity at send time so a send settling after a same-id
replacement cannot flip the successor's run state.

* fix(vscode): auto-continue the task when switching from plan to act

In plan mode, the model's switch_to_act_mode tool call flipped the toggle
but ended the run as aborted: the beforeModel stop hook fired after
turn-started, leaving a dangling api_req_started spinner rendered as
'API Request Cancelled', and nothing continued the task after the
act-mode rebuild. Manually toggling after a presented plan had the same
dead end.

The tool now declares lifecycle.completesRun so the run ends cleanly
after the tool result, and the queued mode change rebuilds the session
and auto-continues with a hidden continuation prompt. A manual plan to
act toggle auto-continues only when the agent is idle after presenting
its plan (not running and awaiting_followup; a pending ask_question
blocks mid-run so it cannot false-positive). Composer content rides
along: typed text becomes the continuation, attachments are forwarded
and echoed, attachment-only toggles count as consumed. The RPC reports
consumption only after the send was actually handed to the session, and
the webview then clears only the exact submitted content, so failures
and racing input never lose composer state. Failures before the send
undo the optimistic running flip, report an error phase, and roll the
mode back when the session was never replaced.

Hidden prompts (the act continuation and the pre-existing task
resumption prompt) shifted editMessageAndRegenerate's visible-to-SDK
user message ordinal mapping; the new sdk-user-message-mapping module
skips them in their persisted user_input-wrapped shape, counts
attachment-only messages (which have visible bubbles), ignores
tool-result rows, and attachment-only resumes now echo a bubble to keep
both transcripts aligned. Follow-ups sent during a rebuild wait on
waitForPendingRebuild instead of resuming a parallel session that the
rebuild would kill.

The plan-mode system prompt and tool description require explicit user
approval in a message sent after the plan was presented, preventing the
model from self-escalating to act mode.

* fix(vscode): move the turn phase to error when a task resume fails

askResponse optimistically sets the turn phase to streaming before
delegating to the followup coordinator, but the coordinator's resume
catch only posted an error row, leaving the footer stuck on
Thinking/Cancel. Resume failures (auth errors, session start errors)
now report back via onResumeFailed so the controller can set the phase
to error.
2026-06-24 14:11:32 +09:00
Saoud Rizwan 9a981f81d7 fix(webview): use consistent reasoning selector component in extension provider settings (#11399)
* fix(webview): use themed components and reasoning selector in generic provider settings

The catalog-backed GenericProviderSettings path (deepseek, gemini, mistral,
and other migrated providers) rendered its model picker with raw unstyled
HTML select/input/button elements, unlike every other provider which uses
the VS Code webview-ui-toolkit components. Swap ModelPickerWithManualEntry
to VSCodeDropdown/VSCodeOption/VSCodeTextField/VSCodeButton, reusing the
DropdownContainer and re-init key workaround from common/ModelSelector.

Also render ReasoningEffortSelector in GenericProviderSettings when the
selected model's catalog info has supportsReasoning, persisting the effort
through the provider config reasoning patch, matching ClineModelPicker.
This is driven by the catalog capability flag rather than provider id.

* fix(webview): re-sync custom model id field after async config hydration

The controlled customModelId state was initialized once at mount, but the
provider config and model catalog both hydrate asynchronously, so the lazy
initializer could capture a placeholder value and leave the custom model
text field stale once the committed selection loaded. Sync the field via an
effect keyed on the committed model id and its in-list status, depending on
derived values rather than the models object whose identity can change
every render while the catalog loads.
2026-06-24 14:11:32 +09:00
Robin NewhouseandCursor d8b1ce54d4 fix(vscode): expand remote workflow/skill slash commands before send ENG-2036 (#11388)
* fix(vscode): expand remote workflow/skill slash commands before send

The SDK-backed extension sent `/workflow` text to the model verbatim, so
remote-config workflows never ran. Expansion is host-driven (the agent loop
never auto-expands), and the controller's pre-send path did none — matching
the CLI's `buildUserInputMessage`, resolve slash commands via a
controller-owned UserInstructionConfigService that watches the workspace
(including `.cline/remote-config/`), refreshed after each remote-config sync.

Fixes ENG-2036.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(vscode): guard instruction watcher against post-dispose race

Reject in ensureUserInstructionService when the controller is already
disposed so a slash-command resolution that yielded across dispose() can't
resurrect a file watcher that nothing will stop. Also log the post-expansion
length handed to parseMentions. Addresses Greptile review.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-24 14:11:32 +09:00
Max Paulus 🥪 6bc771ba9d include optional deps so that CI passes 2026-06-24 14:11:32 +09:00
Max Paulus 🥪 87338cacd1 fix broken tests 2026-06-24 14:11:32 +09:00
Max Paulus 🥪 827a4a2616 bump sdk version 2026-06-24 14:11:32 +09:00
Max Paulus 🥪 13d6fe3876 add vertex support to extension 2026-06-24 14:11:32 +09:00
Mikołaj Kondratek 82c3d8d771 fix(sdk): make model-not-found API errors actionable in the webview (#11378)
When a provider returns a model-not-found error (e.g. Anthropic's HTTP 404
for a retired model such as claude-3-haiku-20240307), the SDK strips the
status and delivers only the terse body, which collapses to the bare label
"model: <id>". reshapeErrorForWebview fell through to returning that raw
string, so ErrorRow rendered a label-like fragment in red with no hint that
the model is gone or how to recover.

Detect these in the plain-text branch of reshapeErrorForWebview and rewrite
them into a sentence that names the model and tells the user to switch models
in API Configuration settings, then retry. The model switch is framed as a
precondition rather than a parallel option so users don't loop on Retry.

Detection is text-based because the HTTP status is unavailable at this point.
The keyword match is anchored to the word "model" with a not-found signal in
the same sentence, so unrelated errors that merely mention a model (plan
gating, deprecated features) are left untouched. Adds tests for the bare
label form, a generic "does not exist" form, and two negative cases (plan
gating and an auth error mentioning a model) that must pass through unchanged.
2026-06-24 14:11:32 +09:00
Max Paulus 🥪 ee0c63a216 fix telemtry opt flag migration 2026-06-24 14:11:31 +09:00
Dominic Cooney 11e668ca23 fix(vscode): resolve @cline/shared/storage subpath in test compile + vitest
The CommonJS integration-test tsconfig (moduleResolution: node) and the
vitest config did not resolve the @cline/shared/storage exports subpath
imported by src/sdk/SdkController.ts, breaking 'compile-tests' (TS2307)
and 3 vitest SDK suites. Add explicit path/alias mappings to the built
dist so both resolve without changing module emit. Compile-time/test-only;
emitted JS still uses the real package specifier.
2026-06-24 14:11:31 +09:00
Max Paulus 🥪 50a4bfb435 migrate telemetry value in extension 2026-06-24 14:11:31 +09:00
Max Paulus 🥪 2c53d36e6d bump sdk version 2026-06-24 14:11:31 +09:00
Max Paulus 🥪 e4ab360c3c fix claude-code setting loading/persistence 2026-06-24 14:11:31 +09:00
Max Paulus 🥪 5363b2a6d2 fix task history delete 2026-06-24 14:11:31 +09:00
Max Paulus 🥪 51d3865ec5 fix model selector not showing most up to date model in providers.json 2026-06-24 14:11:31 +09:00
Max Paulus 🥪 2a070cddf5 fix ui test 2026-06-24 14:11:31 +09:00
Max Paulus 🥪 401154a42e fix ci checks 2026-06-24 14:11:31 +09:00
Robin Newhouse 3b02d5162e refactor(vscode): remove MCP marketplace ENG-1591 (#11217)
* refactor(vscode): remove MCP marketplace

* test(vscode): clarify MCP marketplace removal test

* docs: update MCP server controls docs
2026-06-24 14:11:31 +09:00
Max Paulus 🥪 710cceab90 fix anthropic provider settings persistence 2026-06-24 14:11:30 +09:00
Max Paulus 🥪 a469377d57 remove baseUrl from providers.json when unchecking box in ui 2026-06-24 14:11:30 +09:00
Max Paulus 🥪 1689abf479 fix ollama and lmtudio settings persistence 2026-06-24 14:11:30 +09:00
Max Paulus 🥪 abd84b80cb fix openrouter apikey persist to providers.json 2026-06-24 14:11:30 +09:00
Max Paulus 🥪 9d4aa03d4d fix vscodelm provider settings persist 2026-06-24 14:11:30 +09:00
Max Paulus 🥪 91077fd49e persist bedrock settings to providers.json 2026-06-24 14:11:30 +09:00
Max Paulus 🥪 6aa4c33918 don't block user input when hasNoUsableProvider == true 2026-06-24 14:11:30 +09:00
Mikołaj Kondratek aa8e11ad47 fix(bedrock): treat profile/IAM/credential-chain auth as a usable provider (#11313)
The inline "Sign in to Cline or add an API key" gate appeared and disabled
chat for Amazon Bedrock users who configured AWS Credentials (access key +
secret), an AWS profile, or relied on the default AWS credential chain, even
though the provider was fully usable (issue #11270).

hasUsableProvider() decided Bedrock usability solely via resolveApiKey(),
which maps bedrock -> awsBedrockApiKey. Bedrock's three non-API-key auth
modes leave that field empty, so buildBedrockProviderConfig() would build a
working session while the gate reported the provider unusable. The Cline
login state is irrelevant here: the gate is computed for the active-mode
provider, and the "Sign in to Cline" button is just one of two generic
remedies, which is what made the symptom look like a logged-out state.

Add a Bedrock branch that classifies usability per auth mode, reusing
resolveBedrockAuthentication() so the gate and the session builder agree on
what each mode means:
- api-key: usable only when awsBedrockApiKey is non-blank (unchanged, now
  also rejects whitespace-only keys)
- profile / iam / default credential chain: usable, deferring credential
  resolution to request time (mirrors buildBedrockProviderConfig and the
  existing keyless-provider philosophy)

Manually verified on a real setup across all four auth modes: pre-fix the
gate blocked chat for access-key and profile auth; post-fix the gate clears
and chat works. API-key mode was never gated incorrectly.

Tests: add Bedrock coverage for every auth mode, including api-key with a
blank and with an unset key (both not usable), the SigV4 repro, profile
(explicit/inferred/awsUseProfile), the bare credential-chain config, and
plan-mode resolution plus plan/act isolation.
2026-06-24 14:11:30 +09:00
Ara ca128cfd9f Fix SDK task size in delete tooltip (#11277)
* fix: show SDK task size in delete tooltip

* fix: address SDK task size review feedback

* fix: simplify SDK task size caching
2026-06-24 14:11:30 +09:00
Max Paulus 🥪 78b1f30133 remove unused code/files
fix broken webview build

remove unused code/files

fix broken webview build
2026-06-24 14:11:30 +09:00
Max Paulus 🥪 1f31738b32 delete unused files 2026-06-24 14:11:29 +09:00
Max Paulus 🥪 6eeee7c4e6 Add edit and regenerate for VS Code chat messages
Allow user feedback messages in the VS Code extension to be edited inline and regenerated from that point. Adds a TaskService RPC, truncates persisted SDK history before the selected visible user prompt, and starts a new session with the edited prompt. Also ensures the regenerated active task appears in extension history while SDK history catches up.
2026-06-24 14:11:29 +09:00
Max Paulus 🥪 e88e0c4994 fix webview-ui tests 2026-06-24 14:11:29 +09:00
Max Paulus 🥪 4258b338df show model list if possible for openai compatible 2026-06-24 14:11:29 +09:00
Max Paulus 🥪 7b24026291 fix onboarding model selection not persisting 2026-06-24 14:11:29 +09:00
Max Paulus 🥪 9a6c972b02 remove provider-specific views and just use genericprovidersettings.tsx 2026-06-24 14:11:29 +09:00
Max Paulus 🥪 fae824bff0 dry up duplicate code and create useProviderModelSelection 2026-06-24 14:11:29 +09:00
Max Paulus 🥪 9011051f76 dry up provider api key logic 2026-06-24 14:11:29 +09:00
Max Paulus 🥪 c4068823a4 dry up some duplicate code 2026-06-24 14:11:29 +09:00
Max Paulus 🥪 323492108d fix onboarding models 2026-06-24 14:11:28 +09:00
Max Paulus 🥪 58b41fbaf5 fix failing biome/lint 2026-06-24 14:11:28 +09:00
Mikołaj Kondratek 3df2d6c780 Remove unused import 2026-06-24 14:11:28 +09:00
Mikołaj Kondratek 60b1ab4d57 fix(sdk): drop dead autoContinue branch in mode rebuild
cf25cd66a ("make extension plan mode more similar to CLI") removed the
file-level ACT_MODE_CONTINUATION_PROMPT constant and stopped passing the
autoContinue / continuationPrompt options when rebuilding a session for
a mode change, but left the corresponding block inside
rebuildSessionForMode in place. The block still references the deleted
constant, so tsc fails on the SDK migration branch with TS2304: Cannot
find name "ACT_MODE_CONTINUATION_PROMPT".

No caller passes options to rebuildSessionForMode anymore, so the block
is dead. Drop the block and narrow the signature to take only newMode.
Existing tests already invoke rebuildSessionForMode(<mode>) with no
second argument and assert that fireAndForgetSend is not called on a
mode rebuild, so they keep passing.
2026-06-24 14:11:28 +09:00
Mikołaj Kondratek 0755b58da1 fix(terminal): capture standalone terminal output on Windows and harden PowerShell command handling (#11133)
* fix(terminal): surface standalone terminal spawn diagnostics

Add Logger calls at every chokepoint of the standalone terminal pipeline
so the (currently silent) failure modes around JetBrains-hosted
cline-core become debuggable from cline-core-service.log.

Lines added, all using the existing Logger facility (no new
dependencies, no behavioral changes):

* StandaloneTerminalProcess.run() now logs:
  - `[StandaloneTerminalProcess] run() entered: shell=… cwd=… args=…`
    on entry, before the try block;
  - `[StandaloneTerminalProcess] spawned pid=… for shell=…` right
    after child_process.spawn returns;
  - `[StandaloneTerminalProcess] close: code=… signal=… fullOutputLen=…`
    inside the `close` handler (the `fullOutputLen` reveals when the
    child exits 0 with empty pipes — the symptom in issue #10948);
  - `[StandaloneTerminalProcess] child error: …` in the `error`
    handler;
  - `[StandaloneTerminalProcess] spawn threw synchronously: …` in
    the outer catch.

* StandaloneTerminalManager.runCommand() now logs entry
  (`[StandaloneTerminalManager] runCommand terminalId=…: <cmd>`) and
  attaches a `.catch` to the previously fire-and-forget
  `process.run(…)` Promise so an unhandled rejection surfaces as
  `[StandaloneTerminalManager] process.run rejected for terminal …`
  instead of disappearing.

* CommandExecutor.execute() extends the existing "Executing command
  in … terminal" line with `mode=<terminalExecutionMode>` and
  `managerCtor=<manager.constructor.name>`, so it's possible to
  confirm whether the `vscodeTerminal` path is in fact backed by a
  `StandaloneTerminalManager` on JetBrains (it is — see
  notes/issue-10948-…md).

* CommandOrchestrator.orchestrateCommandExecution() logs the
  `process.once("completed")` event with `exitCode`/`signal`/
  `terminalType`, the "resolved completed" return branch with the
  line/byte totals, and emits a `WARN` on the silent "still running"
  fall-through. The last one matters because the original repro
  reported "Command executed successfully (exit code 0)" with empty
  output — the WARN makes that branch loud the next time it fires.

These logs are what made the two distinct bugs in #10948 visible
(see the 2026-05-28 update in
notes/issue-10948-terminal-output-investigation-2026-05-27.md). They
stay in to keep the next regression debuggable.

Refs: cline/cline#10948

* fix(terminal): keep Windows child stdio attached to parent pipes

The non-cmd Windows branch in StandaloneTerminalProcess.run() spawned
the shell (powershell.exe in practice) with `detached: true` and no
`windowsHide`. When cline-core is launched by the JetBrains plugin it
has no console of its own, so Windows CreateProcess allocates a NEW
console for the detached child and the child's stdio routes to that
new console instead of the pipe handles the parent created. From the
parent's point of view the pipes immediately EOF, `close` fires with
`code=0`, and `fullOutput` is 0 bytes — exactly the symptom reported
in cline/cline#10948 ("Command executed successfully (exit code 0)"
with no output and no filesystem effect).

This bug applies to every command the agent runs through the
standalone terminal path on Windows, not just the
double-wrapped-PowerShell cases (verified by re-running a clean
`dir <file>` after the diagnostics from the previous commit landed:
`run() entered` and `spawned pid=<num>` both fired, then `close: code=0
fullOutputLen=0`).

Fix:

* `detached: process.platform !== "win32"` — keep the existing
  POSIX behavior (a separate process group helps `tree-kill`), but
  drop it on Windows where `tree-kill` walks the PID tree with
  `taskkill /T` and doesn't need a process group.
* `windowsHide: true` — matches every other `child_process.spawn`
  call site in cline-core (git, MCP, hooks, browser) and flips on
  `CREATE_NO_WINDOW`, keeping the child attached to our pipes
  without popping a console window.

Verified on Windows 11 + IntelliJ IDEA 2026.1 + Cline plugin
1.1.59-Internal: `dir <path>`-style probes now produce a non-zero
`fullOutputLen` in the close log, and the captured output bytes
match what would have been visible interactively. PowerShell
double-wrapping (the other half of #10948) is handled in a
follow-up commit.

Refs: cline/cline#10948

* fix(terminal): harden PowerShell command wrapping for standalone shell

`StandaloneTerminalProcess.getShellArgs()` blindly wrapped every
PowerShell command as `["-Command", command]`. That has two
end-user-visible failure modes on Windows, both observed in
cline/cline#10948:

1. The agent's `run_commands` tool call sometimes arrives already
   prefixed with `powershell -Command "…"`. We then spawned
   `powershell.exe -Command 'powershell -Command "…"'`, and the
   outer shell shredded the inner single/double-quote pairs while
   re-parsing its `-Command` argument. The inner pwsh saw
   quote-empty `Test-Path` calls, fell through to the `else` branch
   and reported "File not found" — to ITS stdout, which the outer
   inherited but the file deletion the LLM intended never ran.
2. The user's `$PROFILE` script ran on every spawn, leaking
   non-deterministic noise (e.g.
   `%windir%\System32\REG.exe : The module '%windir%' could not be
   loaded`) into the captured output and confusing the agent.
3. Bonus: the POSIX branch used `["-l", "-c", command]`. The `-l`
   re-sources login files on every command, which is slow and lets
   greeter scripts leak into output.
4. Bonus: the cmd branch used `["/c", command]`. `/d` skips
   AutoRun, `/s` makes the embedded-quote handling deterministic.

Fix:

* PowerShell branch returns
  `["-NoProfile", "-NonInteractive", "-Command", unwrap(command)]`.
  `-NoProfile` suppresses (1) the spurious profile noise that
  contaminated the captured output, and `-NonInteractive` ensures
  the child doesn't deadlock waiting on a prompt no one will answer.
* `unwrapPowerShell(command)` strips a leading
  `powershell|pwsh [.exe] -Command|-c "…"` (or single-quoted)
  wrapper that the LLM sometimes emits, fixing the double-pass
  argument-quoting destruction. If the command does not match the
  exact wrapper shape it is returned verbatim — worst case is "no
  change", preserving pre-fix behavior.
* cmd branch returns `["/d", "/s", "/c", command]`, matching the
  canonical helper in cline/sdk/packages/shared/src/parse/shell.ts.
* POSIX branch returns `["-c", command]`, dropping the unhelpful
  `-l`. Also matches the SDK helper.

Verified on Windows 11 + IntelliJ IDEA 2026.1 + Cline plugin
1.1.59-Internal in combination with the previous "keep Windows
child stdio attached" commit: `Remove-Item CHANGELOG.md` now
deletes the file, the agent's verification `Get-ChildItem CHANGELOG*`
returns nothing, and the profile-load REG.exe error no longer leaks
into captured output.

Refs: cline/cline#10948

* refactor(terminal): tone down standalone terminal diagnostics

The diagnostics added while chasing #10948 were intentionally loud so the
two bugs were visible. Now that the fixes are in, reduce them to a normal
operating posture:

* Demote fine-grained traces to `debug`: the per-spawn `spawning …` and
  `spawned pid=…` lines, `StandaloneTerminalManager.runCommand`, and the
  orchestrator's `resolved completed` summary.
* Drop the orchestrator's `completed event` line entirely — the
  `resolved completed` debug line already carries exit code, signal, and
  line/byte totals.
* Stop echoing the full command in the manager line and stop echoing the
  args vector in the spawn line. The command is still logged once at
  `info` by CommandExecutor (unchanged, pre-existing), so we go back from
  three command echoes to one. Commands routinely embed secrets
  (Authorization headers, tokens), so fewer copies on disk is better.

Kept loud on purpose:

* `info` on `close: code=… fullOutputLen=…` — the single line that proves
  the Windows stdio-capture fix and the most useful per-command signal.
* `warn` on `resolved without completion event` — the silent-success
  canary for the #10948 failure mode.
* `error` on child error / synchronous spawn failure / unhandled
  process.run rejection.

Refs: cline/cline#10948

* fix(terminal): tighten PowerShell unwrap regex and extract to a pure module

Two review follow-ups for the #10948 shell-arg handling:

1. The wrapper-strip regex used a greedy `([\s\S]*)` body, so a command
   like `powershell -Command "foo" "bar"` would match with the body
   captured as `foo" "bar`, silently rewriting a command into something
   different. Replace the body with a tempered match `((?:(?!\1).)*)`
   that cannot contain the captured delimiter, so anything other than
   exactly one quoted token is returned verbatim. Worst case is now
   "no change" rather than an incorrect rewrite. The legitimate
   double-wrapped case from #10948 (outer ", inner ') still unwraps.

2. `getShellArgs` and `unwrapPowerShell` were private methods on
   StandaloneTerminalProcess, untestable without spawning a process.
   Move them to a pure `shellArgs.ts` module. `getShellArgs` now takes
   an injectable `platform` (defaulting to `process.platform`) purely so
   the win32-vs-posix branch is testable; behavior is unchanged. This
   also gives us a single local seam to later consolidate onto the
   canonical `@cline/shared` helper (tracked as a follow-up).

No behavioral change beyond the regex correctness fix.

Refs: cline/cline#10948

* test(terminal): cover shell-arg construction and PowerShell unwrap

Add mocha unit tests (matching the repo's node:assert/strict + __tests__/
convention so the existing mocharc spec globs pick them up) for the newly
extracted shellArgs module:

* unwrapPowerShell: double-quote and single-quote wrappers, powershell.exe
  -c form, the #10948 nested-quote repro (inner quotes preserved),
  non-wrapped passthrough, and the two regressions the tightened regex
  must reject (`… "foo" "bar"` and a command that merely mentions
  powershell mid-string).
* getShellArgs: PowerShell -> -NoProfile -NonInteractive -Command (with
  unwrap), cmd -> /d /s /c, POSIX -> -c. The injectable platform arg lets
  these run on any CI host.

This closes the M1 review finding (the regex was the riskiest line in the
change and had zero coverage) and exercises the cmd/POSIX flag changes
called out in M2.

Refs: cline/cline#10948

* docs(terminal): drop issue references and clarify windowsHide comment

Remove inline issue-number references from source comments and a test
name; that context belongs in the commit history, not the code. Also add
a one-line note that windowsHide is a no-op on non-Windows platforms,
since it is set unconditionally while the surrounding comment is
Windows-specific.

No behavior change.

* refactor(terminal): drop warn on the non-completion return path

The orchestrator's final fall-through return is a normal, expected path:
the process resolved via `continue` without a `completed` event (e.g. a
terminal mode without shell integration, or proceed-while-running flows).
Logging it at `warn` cries wolf on healthy runs, so remove it. The
genuine failure mode this was meant to catch surfaces through the
`close`/error logs and the result string itself.

* fix(terminal): address review feedback on standalone spawn paths

Three follow-ups from code review:

* StandaloneTerminalManager.runCommand: the unawaited process.run()
  .catch only logged. run() emits "error" for failures it catches, but a
  rejection escaping without an "error" event would leave the outer
  promise (resolved via the "continue"/"error" events) pending forever,
  stalling the caller. Re-emit "error" from the catch so both paths stay
  consistent. Cannot trigger today (no await outside run()'s try/catch)
  but the guard exists precisely for future rejections.

* shellArgs POSIX branch: document that dropping the login flag (`-l`)
  is intentional and relies on the child inheriting the parent's PATH via
  process.env, with a note that a GUI-launched IDE without a login PATH
  is the edge case to watch.

* StandaloneTerminalProcess cmd.exe branch: add windowsHide:true. The
  console-allocation/window-pop problem is not exclusive to the non-cmd
  branch; a console-less parent could pop a window for cmd.exe too.
  No-op on non-Windows.
2026-06-24 14:11:28 +09:00
Ara 9c030a93b6 Remove Explain Changes feature (#11278)
* chore(vscode): remove explain changes entry points

* chore(vscode): remove explain changes feature
2026-06-24 14:11:28 +09:00
Max Paulus 🥪 f9fcf7f5ce make extension plan mode more similar to CLI
- basically, don't auto continue when agent switches to act mode
2026-06-24 14:11:28 +09:00
Max Paulus 🥪 c4cf2743d6 fix zai insufficient credits issue 2026-06-24 14:11:28 +09:00
Max Paulus 🥪 1dbfb50038 fix tool use name sanitization 2026-06-24 14:11:28 +09:00
Max Paulus 🥪 9b548347ef fix broken tsc 2026-06-24 14:11:28 +09:00
Dominic Cooney 2dc5791d46 fix(vscode): exclude vitest src/sdk suites from CommonJS test compile
compile-tests runs 'tsc -p tsconfig.test.json' (module: commonjs) over all
src/**/*.test.ts for the VS Code integration runner. The new src/sdk vitest
suites use top-level 'await import(...)' (after vi.mock), which is invalid
under CommonJS and fails with TS1378. The integration runner never runs
src/sdk anyway (.vscode-test.mjs only globs core/test/utils/shared/
integrations/hosts/services); these run via 'npm run test:vitest'. Exclude
src/sdk/**/*.test.ts from the integration compile.
2026-06-24 14:11:27 +09:00
Dominic Cooney 35e3bb4787 fix(vscode): restore biome --config-path so lint resolves apps/vscode/biome.jsonc
The rebase dropped '--config-path ./biome.jsonc' from the lint/format/
postprotos scripts and removed the '!!**/.vscode-test' ignore from
biome.jsonc. Without the explicit config path, biome auto-discovered the
root biome.json instead of apps/vscode/biome.jsonc, applying the wrong
rule severities (449 errors at error level for rules that are off/info in
the nested config). Restore both to match origin/main and apply the
pending buf format fix to models.proto.
2026-06-24 14:11:27 +09:00
Dominic Cooney 205a539491 fix(vscode): show running state for in-progress commands
The command row reflects an executing state while a command runs. The
message translator includes the command-output marker on the running
command row so the webview renders it as executing; the row is finalized
with output and a completed flag when the command ends.

Also remove the unused onChange parameter from the foreground run_commands
path: the SDK runtime does not pass it, so it had no effect. Foreground
command output is surfaced to the chat at completion, not incrementally.

Fixes CLINE-2298 and CLINE-2162
2026-06-24 14:11:27 +09:00
Dominic Cooney 0175548dd8 fix(vscode): re-enable approval buttons for consecutive asks
The footer Approve/Reject buttons stayed disabled when a second consecutive
approval ask arrived. The button configs are shared singletons (e.g.
BUTTON_CONFIGS.tool_approve), so two identical asks return the same object
reference and the effect that reset the processing latch never re-ran.

Key the processing latch on the ask identity (anchored turn timestamp plus the
button labels) rather than the config object reference, using a ref-based latch
so each new ask re-enables the buttons. Adds a regression test.

Test plan:

1. Ask the agent to generate two requests to ls /tmp at once

2. Approve (or reject) the first request

3. Check that the buttons for the second request are enabled
2026-06-24 14:11:27 +09:00
Dominic Cooney 6acc231da5 feat(vscode): add the VS Code Language Model (vscode-lm) provider
Run Cline inference through the VS Code Language Model API (vscode.lm), enabling
models contributed by any extension that registers a language model chat
provider with VS Code. GitHub Copilot is the most common such vendor, but the
implementation is vendor-agnostic — it selects models via
vscode.lm.selectChatModels and has no Copilot-specific logic.

- VsCodeLmHandler implements the Cline SDK ApiHandler and is registered with the
  SDK handler registry; the model selector travels as a vendor/family[/version/id]
  string in modelId and is parsed back here. Selector segments are
  percent-encoded so values containing slashes round-trip intact.
- Native tool calling: tool definitions are passed to sendRequest and tool calls
  are surfaced as tool-call chunks; tool results round-trip as
  LanguageModelToolResultPart, with structured tool output serialized to text and
  a trailing user message appended when a turn ends on tool results so models can
  read the output.
- Gated to VS Code: registration is conditioned on the vscode.lm API being
  present, and the provider is hidden in the UI on hosts without it (JetBrains).

Depends on @cline/{shared,llms,agents,core} 0.0.42-nightly.1780514867, the first
published SDK build with the custom-registered-handler routing this provider
needs.
2026-06-24 14:11:27 +09:00
Ara d2ac39455b Fix approval chat replies rendering as tool errors (#11246)
* fix(vscode): route approval chat replies as user feedback

* fix(vscode): suppress approval reply denial errors

* fix(vscode): hide rejected approval tool failures

* chore(vscode): clarify denied approval suppression helper
2026-06-24 14:11:27 +09:00
Max Paulus 🥪 87543e6f47 Persist OpenRouter provider config via catalog hook 2026-06-24 14:11:27 +09:00
Max Paulus 🥪 3757a8fd0d persist openai-codex provider model settings
- also don't show a sign in button if openai-codex is the only provider
2026-06-24 14:11:27 +09:00
Max Paulus 🥪 0e9a5c0fc1 Persist Cline model selections to provider config 2026-06-24 14:11:27 +09:00
Dominic Cooney 8aba515d6b fix(vscode): declare missing direct deps @grpc/proto-loader and @opentelemetry/api-logs
Both packages are imported directly from source but were never declared in
apps/vscode/package.json, so they only resolved transitively. On a clean
install this broke:

- @grpc/proto-loader — imported by scripts/proto-utils.mjs,
  src/standalone/utils.ts and src/standalone/hostbridge-client.ts; its absence
  made `npm run protos` (and therefore the whole build) fail on a fresh checkout.
- @opentelemetry/api-logs — imported by the OpenTelemetry telemetry providers;
  its absence produced TS2307 "Cannot find module" errors under tsc.

Versions are pinned to align with the existing dependency families already
declared in this package (@grpc/grpc-js ^1.9.x → proto-loader ^0.7.13;
the @opentelemetry/* 0.56.x line → api-logs ^0.56.0). The npm and bun
lockfiles are updated accordingly (the api-logs change also dedupes several
previously-nested copies to a single hoisted entry).
2026-06-24 14:11:27 +09:00
Max Paulus 🥪 a2194f4908 show legacy task history that is not saved in the ~/.cline folder 2026-06-24 14:11:26 +09:00
Max Paulus 🥪 f5d0b4fd49 add migration telemetry 2026-06-24 14:11:26 +09:00
Ara 61ea8688c6 fix(vscode): reuse approved tool rows (#11213)
* fix(vscode): reuse approved tool rows

* fix(vscode): clear stale approved tool rows
2026-06-24 14:11:26 +09:00
Dominic Cooney 94f5a47a59 sdk migration: squashed pre-2026-06-02 work
Omnibus squash of the 10 oldest SDK-migration commits (authored 2026-05-27
through 2026-06-02), collapsed during the 2026-06-09 rebase onto origin/main.

Squashed commits:
- sdk migration: squashed pre-2026-05-27 work
- sdk migration: squashed 06-05-2026 -- instead of listHistory, use host.get(sessionId) instead
- updat gitignore
- fix xai provider
- fix(vscode): forward Bedrock region + AWS auth to the SDK gateway
- fix(vscode): keep in-progress MCP OAuth flow across reconnects
- fix(vscode): wire auto compact into SDK sessions (#11197)
- fix(vscode): compact Codex OAuth before input cap (#11194)
- fix unauthed user flow
- fix(llms): strip Cerebras reasoning history (#11214)
2026-06-24 14:11:26 +09:00
John Choi 864419d8b0 Fix ClinePass onboarding model grouping (#11768) 2026-06-23 17:38:30 -07:00
Tomás Barreiro 218db38544 Add to the status bar [ENG-2216] (#11766)
* Add  to the status bar

* Update tests
2026-06-24 01:24:46 +02:00
Bee a3b3295461 fix(llms): includes session id for OpenRouter caching (#11744)
* Improve OpenRouter sticky session routing

OpenRouter prompt caching was getting weaker cache hit rates because requests did not include a stable session_id. Without that identifier, OpenRouter can route consecutive turns away from the same upstream cache context even when cache_control is present, causing more fresh input billing.

Propagate explicit runtime sessionId metadata from agents into model requests without synthesizing fallback session or conversation ids. Add provider metadata for sticky sessions so OpenRouter maps metadata.sessionId to the JSON body session_id field, while keeping the mechanism extensible for header-based providers.

Apply sticky-session metadata in the AI SDK provider fetch wrapper, preserving explicit wire values when callers already set them. Move trimNonEmpty and omitUndefinedValues into @cline/shared for reuse, and cover the JSON-body, header, and no-fallback paths with tests.

* Tighten sticky session fetch body handling

Treat init.body null as an explicit no-body override so the sticky-session wrapper does not inspect or parse a Request body that the caller has intentionally overridden.

Model fetch body text by source, which removes the unreachable JSON-body injection branch and makes the Request rewrite path explicit. Also type the provider fetch mocks so sticky-session assertions compile cleanly in editors.

* merges model-handle default metadata with per-request metadata
2026-06-23 12:07:17 -07:00
MaxandMax Paulus 🥪 d09270940f fix(hub): validate dashboard websocket host and origin (#11724)
* fix(hub): validate browser websocket origins

* test(hub): cover websocket host and origin gates

* fix(hub): allow explicit bind host for dashboard socket

* fix(hub): default protect browser routes

* fix(hub): normalize default port origins

---------

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-23 10:14:25 -07:00
Saoud Rizwan 23c9bf1c05 fix: prevent 'ERROR: EMPTY CONTENT' message when error occurs (#11726)
* fix: prevent empty assistant history replay

* refactor: move tool call filtering after empty guard
2026-06-23 09:33:10 -07:00
Bee 52531d935a refactor: add connector configure path and share catalog to shared pkg (#11730)
* feat(hub): add connector configure path and share catalog via @cline/shared

Add connector.channels/configure/delete_config Hub commands that persist
connector settings to disk without starting connector processes or calling
provider auth APIs. This means settings (including tokens) are saved as-is
without verification; callers are responsible for supplying valid values.

Shared catalog and platform definitions:
- Move ConnectorCatalogEntry, CONNECTOR_CATALOG, listConnectorCatalog, and
  all ConnectorPlatformDef/FieldDef/SecurityDef types + CONNECTOR_PLATFORMS
  into sdk/packages/shared/src/connectors/platforms.ts
- Export everything from @cline/shared index so CLI and Hub use the same
  definitions without duplication
- Reduce apps/cli/src/connectors/catalog.ts and
  apps/cli/src/wizards/connect/platforms.ts to thin re-export shims that
  preserve existing CLI import paths

New Hub connector handlers (sdk/packages/core):
- connector-handlers.ts: handles connector.channels (list available/active/
  configured), connector.configure (validate fields and write settings.json
  under ~/.cline/data/connectors/), connector.delete_config (remove entry
  and clean up empty file)
- Settings are stored as ConnectorSettingsFile (version 1) at
  ~/.cline/data/connectors/settings.json; reads are lenient/defensive
- Wire handlers into hub-server-transport.ts dispatch switch
- connector-handlers.test.ts: unit tests for configure, channels, and
  delete_config covering field validation, conditional fields, security
  constraints, and settings round-trips

Hub WebSocket auth helpers (hub-websocket-server.ts):
- Extract isLocalHubHostName / isLocalHubOrigin as named, tested exports
- Allow unauthenticated WebSocket upgrades from local origins (localhost,
  127.0.0.1, ::1) so the Hub UI can connect without an auth token
- hub-websocket-server.test.ts: extend tests to cover new local-origin logic

Add connector.channels, connector.configure, connector.delete_config to
HubCommandName union in sdk/packages/shared/src/hub.ts

* apply feedback

* export connector settings json path

* apply feedback from robin
2026-06-22 22:25:49 -07:00
Tomás BarreiroandSaoud Rizwan 19d4248381 Add Organization error messages to the Cline CLI (#11737)
* Add Organization error messages to the Cline CLI

* Fix tests

* Fix tests

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-22 17:18:29 -07:00
Tomás Barreiro b2b29678a2 Add a button to unselect an org on ClinePass error (#11738)
* Add a button to unselect an org on ClinePass error

* Fix comments
2026-06-23 01:47:15 +02:00
Bee ad30714608 fix(hub-ui): Use development mode conditionally for connector (#11728)
* fix(hub-ui): Use development mode conditionally for connector

Refactor connector CLI launch logic to conditionally apply Bun-specific
flags (`--conditions=development`) only when running under Bun or Node
runtimes with access to the source entrypoint. When running from a
compiled binary (e.g., packaged Cline app), use the execPath directly
without Bun flags.

- Extract `buildCliConnectCommand` function to encapsulate launcher
  and args resolution logic based on runtime detection
- Use `withResolvedClineBuildEnv` for environment variable setup
- Export `__test__` object to enable unit testing of internal logic
- Add tests covering Bun source entrypoint and compiled binary cases

* address feedback
2026-06-22 16:41:47 -07:00
Bee 9eebc44f24 chore: Optimize Cline Hub webview bundle (#11731)
* Optimize Cline Hub webview bundle

Issue:

The Cline Hub webview build emitted Vite's large chunk warning. The main entry bundle was over 2 MB minified, and the build output included many Shiki language/theme chunks plus eagerly bundled Streamdown math/Mermaid support that the hub did not need on startup.

Changes:

- Split heavyweight Cline Hub views behind React.lazy/Suspense so settings, customization, and chat code do not all land in the initial app shell.

- Replace root Shiki highlighter usage with shiki/core and lazily loaded, explicitly supported languages/themes.

- Route Streamdown through a local HubStreamdown wrapper that uses the local CodeBlock renderer and keeps Mermaid as a lazy diagram plugin.

- Remove unused Streamdown math/code/Mermaid packages, add direct lazy Mermaid support, and add targeted Rolldown chunk groups for Mermaid parser/layout/markup assets.

Before/after bundle measurements:

| Metric | Before | After |

| --- | ---: | ---: |

| Total generated JS | 24,356 KiB | 5,800 KiB |

| Total generated JS gzip | 4,805.5 KiB | 1,434.6 KiB |

| Main entry chunk | 2,044.02 kB | 373.88 kB |

| Main entry gzip | 617.25 kB | 117.83 kB |

Verification:

- bun -F @cline/cline-hub build:webview

- bun biome check apps/cline-hub/src/webview/vite.config.ts apps/cline-hub/src/webview/src/App.tsx apps/cline-hub/src/webview/src/components/ai-elements/code-block.tsx apps/cline-hub/src/webview/src/components/ai-elements/message.tsx apps/cline-hub/src/webview/src/components/ai-elements/reasoning.tsx apps/cline-hub/src/webview/src/components/ai-elements/streamdown.tsx apps/cline-hub/src/webview/package.json

* address feedback
2026-06-22 16:40:01 -07:00
Tomás Barreiro 6ddf48d227 Forcefully send users to their personal dashboard when going to subscribe (#11725)
* Forcefully send users to their personal dashboard when going to subscribe

* fix trailing slash
2026-06-22 22:27:31 +02:00
Saoud Rizwan ee59f81706 chore(cli): release v3.0.29 2026-06-20 00:30:51 -07:00
Saoud Rizwan 92f6e28f13 fix(cli): hide costs for Cline free models (#11686)
* fix(cli): hide Cline free model costs

* fix(cli): harden free model cost lookup
2026-06-20 00:20:07 -07:00
Saoud Rizwan 27a78f0248 chore(sdk): release v0.0.51 2026-06-19 19:15:00 -07:00
Saoud Rizwan 2c4980f42a fix(sdk): resolve Cline Z.ai model metadata aliases (#11685)
* fix(sdk): resolve Cline Z.ai model metadata aliases

* fix(sdk): preserve Cline model alias overrides

* test(sdk): update Cline provider model list expectation
2026-06-19 19:05:47 -07:00
Saoud Rizwan 64c5e48edb Revert "fix(cli): resolve Cline model display names by just model name (#11668)" (#11684)
This reverts commit c497698beb.
2026-06-19 18:27:29 -07:00
Saoud Rizwan 3c23f80a94 fix(sdk): deflake run_commands timeout telemetry tests 2026-06-19 14:59:46 -07:00
Saoud Rizwan 4be362892f chore(cli): release v3.0.28 2026-06-19 14:52:50 -07:00
Saoud Rizwan cdff084652 chore(sdk): release v0.0.50 2026-06-19 14:11:25 -07:00
Robin NewhouseandSaoud Rizwan 299a4a9520 fix(sdk): harden parallel tool guidance (#11598)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-19 12:25:20 -07:00
Saoud Rizwan c497698beb fix(cli): resolve Cline model display names by just model name (#11668)
* fix(cli): resolve Cline model display names by slug

* fix(cli): type model display metadata

* test(cli): cover model display fallback paths
2026-06-19 12:21:57 -07:00
Ara 292133989b fix(sdk): cap assistant text in provider messages (#11478) 2026-06-19 11:41:04 -07:00
AraandSaoud Rizwan f5aa035a6a fix(sdk): default-on tool result truncation, tightened limits + budget accounting in MessageBuilder (#11475)
* fix(sdk): default-on tool result truncation, name fallback, tool_use budget accounting

- Truncate every tool result (MCP/custom tools included), not just an allowlist
- Resolve tool names from tool_result.name when the paired tool_use is gone
- Count tool_use.input strings toward the aggregate provider request budget
- Protect any binary carrier block ({type, data}) from truncation, not just images
- Don't let failSession cleanup errors mask the original turn error

* fix(sdk): tighten MessageBuilder limits with named options and env overrides

Folds #11474 into the default-on truncation branch and addresses review
feedback from both PRs:

- per-result tool cap drops to 8k chars; MessageBuilderOptions constructor
  with CLINE_MESSAGE_BUILDER_* env overrides for A/B testing
- env parsing is positive-only so '=0' cannot silently disable a limit
  (greptile on #11474)
- aggregate budget stays at 6MB: budget truncation rewrites mid-transcript
  bytes and breaks provider prefix caching, so it must stay a rare overflow
  valve rather than the steady state (johnwschoi on #11474)
- user file attachments get a dedicated 50k cap instead of inheriting the
  aggressive tool-result cap (codex on #11474)
- isBinaryContentLike restricted to known binary block types so textual
  {type, data} payloads can no longer dodge every cap (codex/greptile)
- tool_use.input strings become last-resort budget truncation candidates,
  making the aggregate budget reclaimable when oversized model-generated
  arguments carry the overflow (robinnewhouse/greptile)

* fix(sdk): constrain binary tool result truncation

* style(sdk): remove disallowed comment formatting

* Revert "style(sdk): remove disallowed comment formatting"

This reverts commit 9d8b66726d.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-19 11:15:42 -07:00
Saoud Rizwan bbcae25542 fix(cli): refine marketplace primitive rows (#11659) 2026-06-19 10:57:37 -07:00
Tomás Barreiro 9b914912ac Rename Cline Pass to ClinePass (#11657) 2026-06-19 18:36:07 +02:00
Saoud Rizwan 8c369eeed9 docs(vscode): remove marketplace readme language links (#11658) 2026-06-18 18:09:02 -07:00
922 changed files with 117653 additions and 10195 deletions
@@ -0,0 +1,294 @@
name: ext-vscode-publish-legacy
# Publishes the legacy (pre-SDK-migration) VS Code extension from the
# `legacy-extension` branch. This branch holds the npm-based 3.89.x codebase,
# rolled forward under a 4.0.x version so existing 4.0.0 users still receive
# the update. The main `ext-vscode-publish-stable.yml` workflow (bun-based)
# stays the path for releasing main once the SDK migration is solid.
#
# This workflow lives on and is dispatched from `main` (so it satisfies the
# default-branch dispatch requirement), but it checks out and builds the
# `legacy-extension` branch.
on:
workflow_dispatch:
inputs:
release-type:
description: "Choose release type (release or pre-release)"
required: true
default: "release"
type: choice
options:
- pre-release
- release
branch:
description: "Branch holding the legacy extension code"
required: true
default: "legacy-extension"
type: string
permissions:
contents: write
packages: write
checks: write
pull-requests: write
concurrency:
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
cancel-in-progress: false
jobs:
# Gate the publish on the legacy branch's own npm-based test suite. We can't
# reuse ./.github/workflows/ext-vscode-test.yml here — on main that's the
# bun-based suite and it would test main, not the legacy branch — so the
# essential quality + test steps are inlined against the checked-out legacy
# branch.
test:
name: Test Legacy Extension
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/vscode
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
- name: Run Quality Checks (lint + typecheck)
run: npm run ci:check-all
- name: Build Tests and Extension
id: build_step
run: npm run ci:build
- name: Unit Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: npm run test:unit
- name: Extension Integration Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: xvfb-run -a npm run test:coverage
- name: Webview Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: |
cd webview-ui
npm run test:coverage
publish:
needs: test
name: Publish Legacy Extension
runs-on: ubuntu-latest
environment: publish
defaults:
run:
working-directory: apps/vscode
steps:
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
# can create/push the release tag and compute the previous tag.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
fetch-tags: true
lfs: true
- name: Resolve Release Tag
id: resolve_tag
working-directory: ${{ github.workspace }}
env:
BRANCH: ${{ github.event.inputs.branch }}
run: |
# Tag is derived from the package version on the legacy branch.
VERSION=$(node -p "require('./apps/vscode/package.json').version")
TAG="v$VERSION"
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
echo "Error: derived tag '$TAG' does not match vX.Y.Z"
exit 1
fi
TAG_REF="refs/tags/$TAG"
HEAD_SHA=$(git rev-parse HEAD)
if git show-ref --verify --quiet "$TAG_REF"; then
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at branch head ($HEAD_SHA)"
exit 1
fi
echo "Tag '$TAG' already exists at branch head. Continuing."
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$TAG" "$HEAD_SHA"
git push origin "$TAG_REF"
echo "Created and pushed tag '$TAG' from $BRANCH head $HEAD_SHA."
fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode install --include=optional
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui install --include=optional
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Get Version
id: get_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Verify Tag Matches Package Version
run: |
TAG="${{ steps.resolve_tag.outputs.tag }}"
VERSION="v${{ steps.get_version.outputs.version }}"
if [[ "$TAG" != "$VERSION" ]]; then
echo "Error: tag '$TAG' does not match package version '$VERSION'"
exit 1
fi
echo "Tag and package version match: $TAG"
- name: Verify Changelog Entry
working-directory: ${{ github.workspace }}
run: |
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
exit 1
fi
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
- name: Verify Marketplace Tokens
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
exit 1
fi
if [[ -z "$OVSX_PAT" ]]; then
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
exit 1
fi
echo "Marketplace publish tokens are configured."
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
run: |
# Swap README.marketplace.md into README.md so both the GitHub
# release artifact (vsce package below) and the marketplace
# publish (npm run publish:marketplace below, which swaps
# internally as an idempotent no-op) ship the same README.
node scripts/marketplace-readme.mjs swap-in
trap 'node scripts/marketplace-readme.mjs restore' EXIT
# Required to generate the .vsix
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
if [ "$RELEASE_TYPE" = "pre-release" ]; then
npm run publish:marketplace:prerelease
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
else
npm run publish:marketplace
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
fi
- name: Get Previous Tag
id: prev_tag
working-directory: ${{ github.workspace }}
run: |
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
id: changelog
working-directory: ${{ github.workspace }}
run: |
# Get content between first ## [ and second ## [
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.resolve_tag.outputs.tag }}
files: "apps/vscode/*.vsix"
body: |
${{ steps.changelog.outputs.content }}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post release to Slack
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "*Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)*"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
+65 -19
View File
@@ -27,6 +27,10 @@ permissions:
checks: write
pull-requests: write
concurrency:
group: ext-vscode-publish-stable-${{ github.event.inputs.tag }}
cancel-in-progress: false
jobs:
test:
uses: ./.github/workflows/ext-vscode-test.yml
@@ -102,7 +106,13 @@ jobs:
echo "Error: tag '$TAG' does not exist in the repository"
exit 1
fi
echo "Using existing tag '$TAG'."
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
if [[ "$TAG_SHA" != "$TESTED_SHA" ]]; then
echo "Error: existing tag '$TAG' points to $TAG_SHA, but this workflow tested $TESTED_SHA"
echo "Dispatch from the tag ref, or from the exact main commit the tag points to."
exit 1
fi
echo "Using existing tag '$TAG' at tested SHA $TESTED_SHA."
fi
git checkout --detach "$TAG_REF^{commit}"
@@ -170,6 +180,60 @@ jobs:
fi
echo "Tag and package version match: $TAG"
- name: Verify Changelog Entry
working-directory: ${{ github.workspace }}
run: |
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
exit 1
fi
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
- name: Verify Marketplace Tokens
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
exit 1
fi
if [[ -z "$OVSX_PAT" ]]; then
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
exit 1
fi
echo "Marketplace publish tokens are configured."
- name: Get Previous Tag
id: prev_tag
working-directory: ${{ github.workspace }}
run: |
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
PREV_TAG=$(
git tag --merged "$CURRENT_TAG^" --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$' \
| head -n 1 || true
)
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
id: changelog
working-directory: ${{ github.workspace }}
run: |
# Get content between the matching version heading and the next release heading.
CONTENT=$(awk -v version="${{ steps.get_version.outputs.version }}" '
$0 == "## [" version "]" { found=1; next }
found && /^## \[/ { exit }
found { print }
END { if (!found) exit 1 }
' CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
@@ -210,24 +274,6 @@ jobs:
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
fi
- name: Get Previous Tag
id: prev_tag
working-directory: ${{ github.workspace }}
run: |
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
id: changelog
working-directory: ${{ github.workspace }}
run: |
# Get content between first ## [ and second ## [
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
+14 -1
View File
@@ -22,11 +22,24 @@
},
// Enable Lint and format using Biome
"biome.enabled": true,
"biome.requireConfiguration": true,
"prettier.enable": false,
"editor.defaultFormatter": "biomejs.biome",
"[javascript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[javascriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescript]": {
"editor.defaultFormatter": "biomejs.biome"
},
"[typescriptreact]": {
"editor.defaultFormatter": "biomejs.biome"
},
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.removeUnused.biome": "always",
"source.removeUnusedImports": "always",
"source.organizeImports.biome": "always"
},
// Disable auto-forwarding ports to prevent Simple Browser from opening the Vite dev server
+22 -2
View File
@@ -169,7 +169,8 @@
"isBackground": true,
"label": "npm: watch:esbuild",
"dependsOn": [
"npm: protos"
"npm: protos",
"build-sdk:debug"
],
"presentation": {
"group": "watch",
@@ -208,7 +209,8 @@
"isBackground": true,
"label": "npm: watch:esbuild:test",
"dependsOn": [
"npm: protos"
"npm: protos",
"build-sdk:debug"
],
"presentation": {
"group": "watch",
@@ -311,6 +313,24 @@
"options": {
"cwd": "${workspaceFolder}"
}
},
{
"label": "build-sdk:debug",
"type": "shell",
"command": "bun run build:sdk",
"problemMatcher": [
"$tsc"
],
"presentation": {
"group": "watch",
"reveal": "always"
},
"options": {
"cwd": "${workspaceFolder}",
"env": {
"CLINE_SOURCEMAPS": "1"
}
}
}
],
"inputs": [
+39
View File
@@ -1,5 +1,44 @@
# Changelog
## [4.0.0]
### Added
- Add the SDK-backed VS Code extension runtime. Cline now runs tasks through the shared Cline SDK session layer for agent turns, tools, Plan/Act mode coordination, MCP, checkpoints, telemetry, provider changes, compaction, mistake limits, and task history.
- Add ClinePass to the VS Code extension, including onboarding, provider selection, signup and subscription handoff, live model lists, entitlement and organization error states, out-of-credit prompts, and clearer ClinePass auth/error handling.
- Add the Customize marketplace for discovering and managing Skills, MCP servers, and Plugins from the extension, including installed/marketplace tabs, search and filtering, install/uninstall flows, enable/disable controls, and support for plugin-bundled skills.
- Cline Plugins: Plugins let you extend Cline with custom tools, workflows, skills, and MCP-powered capabilities tailored to your team or project. Install them from the new Customize marketplace to add specialized behavior, connect external services, and package reusable automations—so Cline can do more than code: it can adapt to the way you work.
- Add queued prompts in chat. Messages submitted while Cline is already working are now queued, shown while the current turn streams, and can be cancelled before they run.
- Add edit-and-regenerate support for previous user messages, with clearer Reset Chat and Reset Code actions.
- Add generic SDK provider settings and model-catalog support so more providers can share the same model picker, reasoning controls, dynamic model IDs, provider config persistence, and custom model handling.
- Add additional SDK-backed provider exposure and model/provider updates, including ClinePass models, refreshed Cline catalog data, Fireworks GLM 5.2, Kimi K2.6 Fast, Kimi K2.7 Code, Qwen 3.7 Plus, MiniMax M3 updates, SAP AI Core wiring, LiteLLM model fetching, Codex OAuth credentials, and OpenAI-compatible model settings.
- Add MCP support for plugins and shared marketplace install/uninstall plumbing used by the VS Code extension.
### Changed
- Migrate the VS Code extension from the legacy task implementation to the shared Cline SDK and move the extension build/package workflow to Bun.
- Rework Plan/Act mode handling through SDK coordinators, including closer CLI parity and automatic continuation when switching from Plan to Act.
- Rework provider and model configuration around `providers.json`, the model catalog, and SDK session config so settings are preserved consistently across provider switches and active sessions can restart when the selected provider changes.
- Simplify provider settings UI by replacing many provider-specific views with shared generic settings components and consistent reasoning selectors.
- Simplify terminal execution through the SDK run-commands path, including clearer non-interactive command guidance and safer structured command formatting.
- Migrate legacy MCP files and formats into the shared settings file and protect MCP settings writes with safer locking/atomic updates.
- Refresh the MCP hub automatically after marketplace installs so newly installed servers are available without a manual restart.
- Reorganize MCP/Skills/Plugins entry points under Customize, hide workflows from the Customize menu, wrap Customize tabs on narrow screens, and allow the MCP Marketplace tab to be disabled remotely while installed MCP servers remain accessible.
- Simplify auto-approval settings. Command auto-approval is now disabled by default for safer new and reset configurations, and the auto-approval UI has been streamlined.
- Update task history handling for the SDK migration, including legacy task history visibility, metadata preservation on resume, and corrected deletion behavior.
- Route compacting and mistake-limit behavior through the SDK so the Compact button and mistake tracking affect the active SDK session.
- Remove the legacy Explain Changes feature as part of the SDK migration cleanup.
- Temporarily disable subagents in the VS Code extension while the SDK-backed experience is stabilized.
### Fixed
- Fix marketplace edge cases, including refreshing MCP servers after marketplace installs, disabling the MCP Marketplace tab from remote config, hiding workflows from Customize, surfacing plugin-bundled skills, and uninstalling shared marketplace entries.
- Fix chat submission during active turns by queuing user messages instead of dropping or racing them, showing pending/queued states promptly, rendering direct user messages immediately, and removing delayed send behavior.
- Fix editing previous user messages so Escape cancels editing locally and reset action labels are clearer.
- Fix terminal reliability, including standalone Windows output capture, hardened PowerShell command handling, running-state display for in-progress commands, raw structured command preservation, single-quote handling, cwd setup timeouts, failing-command stdout capture, heredoc coalescing, and removal of duplicated command echoes in tool results.
- Fix SDK tool-result and provider-message budgeting by truncating large tool outputs by default, capping assistant text, limiting bash/file-read/search output ingestion, bounding media budgets, batching outdated-read rewrites to preserve provider prefix caches, and normalizing JSON-like tool inputs by schema.
- Fix login and feature-flag resolution by using the correct user/account identity on startup and simplifying the login UX.
## [3.89.2]
### Fixed
+41
View File
@@ -1,5 +1,46 @@
# Cline CLI Changelog
## 3.0.31
- Show when request cost is covered by your Cline subscription
- Prompt to switch to ClinePass when you run out of credits, and list ClinePass features in the not-subscribed message
- Added an option to open the subscription page from the ClinePass options
- Added marketplace uninstall support and surfaced plugin-bundled skills
- Require quoted prompts for one-shot mode
- Capped MCP tool names at 64 characters for OpenAI-compatible providers
- Updated coupon code
## 3.0.30
- Added a token count to the status bar, shown alongside cost
- Added organization-specific error messages
- Added SAP AI Core provider support
- Refreshed the model catalog with the latest provider models
- Preserved OpenRouter reasoning-disable behavior and improved OpenRouter prompt caching
- Routed LiteLLM model fetches through the SDK and stopped unrelated models from appearing in the LiteLLM model list
- Updated ClinePass models live, restored ClinePass models in onboarding, and improved ClinePass error messages
- Threaded proxy/CA-aware networking into the inference path
- Persisted Bedrock settings to providers.json
- Normalized JSON-like tool inputs by schema for more reliable tool calls
- Fixed an "ERROR: EMPTY CONTENT" message that could appear when an error occurred
- Fixed a packaging issue (createRequire) that could break the CLI at runtime
## 3.0.29
- Costs are now hidden for Cline free models
- Fixed Z.ai model metadata resolution for Z.ai models accessed through the Cline provider
- Reverted the model-name-only display change from v3.0.28; the model picker, selector, and status bar return to their previous display behavior
## 3.0.28
- Added a ClinePass onboarding flow with selectable ClinePass models, plus improved ClinePass error handling
- Added hub primitive catalogs and refreshed the hub dashboard design with a dedicated customizations breakout
- Auto-approve toggles now apply immediately when changed
- Feature flags now resolve using your user ID on startup
- Fixed Cline model display names so they resolve by model name
- Truncate large tool results by default (including MCP and custom tool output) to keep requests within context budget
- Hardened parallel tool-call guidance for faster, more reliable multi-tool execution
## 3.0.27
- Added a `cline skill` command to install and manage skills, matching `cline plugin install` and `cline mcp` (installs default to the Cline agent directory)
+1 -1
View File
@@ -121,7 +121,7 @@ const result = await Bun.build({
},
env: "OTEL_*",
banner:
'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
'import { createRequire as __clineCreateRequire } from "node:module"; const require = __clineCreateRequire(import.meta.url);',
});
if (result.logs.length > 0) {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.27",
"version": "3.0.31",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+130 -4
View File
@@ -1,5 +1,27 @@
import { installMcpServer } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import { buildMcpInstallDefaults, runMcpInstallCommand } from "./mcp";
import {
buildMcpInstallDefaults,
buildMcpInstallTransport,
runMcpInstallCommand,
} from "./mcp";
vi.mock("@cline/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@cline/core")>();
return {
...actual,
installMcpServer: vi.fn((options) => {
const { name, transport, warnings } =
actual.buildMcpInstallTransport(options);
return {
name,
status: "installed",
transport,
warnings,
};
}),
};
});
describe("mcp install command", () => {
it("builds stdio wizard defaults from command args", () => {
@@ -88,6 +110,52 @@ describe("mcp install command", () => {
).toThrow(/only http and https are supported/);
});
it("builds direct stdio installs without shell-joining args", () => {
expect(
buildMcpInstallTransport({
name: "fs",
targetArgs: [
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp/my dir",
],
}),
).toEqual({
name: "fs",
transport: {
type: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/my dir"],
},
warnings: [],
});
});
it("builds direct remote installs with headers and placeholder warnings", () => {
expect(
buildMcpInstallTransport({
name: "docs",
transport: "http",
headers: ["Authorization: Bearer <token>"],
targetArgs: ["https://example.com/mcp", "--header=X-Extra: yes"],
}),
).toEqual({
name: "docs",
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer <token>",
"X-Extra": "yes",
},
},
warnings: [
'Header "Authorization" looks like it contains a placeholder. Update it in MCP settings before using this server.',
],
});
});
it("opens the add wizard with prefilled defaults", async () => {
const runWizard = vi.fn(async () => 0);
@@ -124,11 +192,11 @@ describe("mcp install command", () => {
expect(code).toBe(1);
expect(runWizard).not.toHaveBeenCalled();
expect(writeErr).toHaveBeenCalledWith(
"cline mcp install opens the MCP wizard and requires a TTY.",
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
);
});
it("checks for TTY before validating install arguments", async () => {
it("checks for TTY before validating wizard install arguments", async () => {
const writeErr = vi.fn();
const code = await runMcpInstallCommand({
@@ -139,7 +207,65 @@ describe("mcp install command", () => {
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith(
"cline mcp install opens the MCP wizard and requires a TTY.",
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
);
});
it("installs directly with --yes without requiring a TTY", async () => {
const writeln = vi.fn();
const writeErr = vi.fn();
const code = await runMcpInstallCommand({
name: "docs",
transport: "http",
targetArgs: [
"https://example.com/mcp",
"--header",
"Authorization: Bearer token",
],
isTty: false,
yes: true,
io: { writeln, writeErr },
});
expect(code).toBe(0);
expect(installMcpServer).toHaveBeenCalledWith({
name: "docs",
transport: "http",
targetArgs: [
"https://example.com/mcp",
"--header",
"Authorization: Bearer token",
],
isTty: false,
yes: true,
io: { writeln, writeErr },
});
expect(writeln).toHaveBeenCalledWith("Installed MCP server docs.");
expect(writeErr).not.toHaveBeenCalled();
});
it("prints direct install JSON with --yes --json", async () => {
const writeln = vi.fn();
const code = await runMcpInstallCommand({
name: "fs",
targetArgs: ["node", "server.js"],
isTty: false,
yes: true,
json: true,
io: { writeln, writeErr: vi.fn() },
});
expect(code).toBe(0);
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toMatchObject({
name: "fs",
status: "installed",
transport: {
type: "stdio",
command: "node",
args: ["server.js"],
},
});
});
});
+45 -6
View File
@@ -1,21 +1,36 @@
import {
type McpInstallOptions as CoreMcpInstallOptions,
installMcpServer,
type McpInstallResult,
type McpServerTransportConfig,
} from "@cline/core";
import type { McpAddDefaults } from "../wizards/mcp";
export { buildMcpInstallTransport } from "@cline/core";
export interface McpCommandIo {
writeln?: (text: string) => void;
writeErr: (text: string) => void;
}
export interface McpInstallOptions {
name: string;
targetArgs?: string[];
transport?: string;
export interface McpInstallOptions extends CoreMcpInstallOptions {
io?: McpCommandIo;
isTty?: boolean;
json?: boolean;
runWizard?: (defaults: McpAddDefaults) => Promise<number>;
yes?: boolean;
}
export interface McpInstallDirectResult {
name: string;
status: "installed";
transport: McpServerTransportConfig;
warnings: string[];
}
function normalizeTransportType(
value: string | undefined,
): McpAddDefaults["type"] {
): McpServerTransportConfig["type"] {
const normalized = (value ?? "stdio").trim();
if (normalized === "http" || normalized === "streamable-http") {
return "streamableHttp";
@@ -91,6 +106,18 @@ export function buildMcpInstallDefaults(options: {
};
}
export function installMcpServerDirect(
options: McpInstallOptions,
): McpInstallDirectResult {
const result: McpInstallResult = installMcpServer(options);
return {
name: result.name,
status: result.status,
transport: result.transport,
warnings: result.warnings,
};
}
async function runPrefilledWizard(defaults: McpAddDefaults): Promise<number> {
const { runMcpWizard } = await import("../wizards/mcp");
return runMcpWizard({
@@ -104,11 +131,23 @@ export async function runMcpInstallCommand(
options: McpInstallOptions,
): Promise<number> {
try {
if (options.yes) {
const result = installMcpServerDirect(options);
if (options.json) {
options.io?.writeln?.(JSON.stringify(result));
} else {
options.io?.writeln?.(`Installed MCP server ${result.name}.`);
for (const warning of result.warnings) {
options.io?.writeErr(warning);
}
}
return 0;
}
const isTty =
options.isTty ?? (process.stdin.isTTY && process.stdout.isTTY);
if (!isTty) {
throw new Error(
"cline mcp install opens the MCP wizard and requires a TTY.",
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
);
}
const defaults = buildMcpInstallDefaults(options);
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -33,7 +33,7 @@ export function addRootOptions(cmd: Command): Command {
.option("-c, --cwd <path>", "Working directory")
.option(
"--thinking <level>",
"Set reasoning effort level between none|low|medium|high|xhigh (default: medium)",
"Set reasoning effort: none|low|medium|high|xhigh. Bare --thinking uses medium; omitted leaves provider default.",
)
.option("--compaction <mode>", CLI_COMPACTION_MODE_OPTION_DESCRIPTION)
.option(
+2 -36
View File
@@ -1,36 +1,2 @@
export type ConnectorCatalogEntry = {
name: string;
description: string;
};
export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
{
name: "discord",
description:
"Discord interactions and gateway bridge backed by RPC runtime sessions",
},
{
name: "gchat",
description: "Google Chat webhook bridge backed by RPC runtime sessions",
},
{
name: "linear",
description: "Linear webhook bridge backed by RPC runtime sessions",
},
{
name: "slack",
description: "Slack webhook/socket bridge backed by RPC runtime sessions",
},
{
name: "telegram",
description: "Bridge Telegram bot messages into RPC chat sessions",
},
{
name: "whatsapp",
description: "Bridge WhatsApp webhook messages into RPC chat sessions",
},
];
export function listConnectorCatalog(): ConnectorCatalogEntry[] {
return CONNECTOR_CATALOG.map((entry) => ({ ...entry }));
}
export type { ConnectorCatalogEntry } from "@cline/shared";
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
+37 -16
View File
@@ -1,7 +1,10 @@
// @jsxImportSource @opentui/react
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import open from "open";
import { useCallback, useMemo, useState } from "react";
import { palette } from "../tui/palette";
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
import type { CliMigrationNotice } from "./notice";
export function MigrationNoticeContent(
@@ -10,10 +13,29 @@ export function MigrationNoticeContent(
},
) {
const { dialogId, notice, resolve } = props;
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
const [status, setStatus] = useState<string | undefined>();
const openSubscriptionPage = useCallback(() => {
setStatus("Opening ClinePass in your browser...");
void open(subscriptionUrl, { wait: false })
.then(() => {
setStatus("Opened ClinePass in your browser.");
})
.catch(() => {
setStatus(
"Could not open the browser automatically. Use the URL below.",
);
});
}, [subscriptionUrl]);
useDialogKeyboard((key) => {
if (key.name === "escape") {
resolve(true);
return;
}
if (key.name === "return" || key.name === "enter") {
openSubscriptionPage();
}
}, dialogId);
@@ -22,25 +44,24 @@ export function MigrationNoticeContent(
<text fg={palette.act}>{notice.title}</text>
<box flexDirection="column">
<text selectable>
We rebuilt the CLI from the ground up using the new Cline SDK. Learn
more:{" "}
<a href="https://github.com/cline/cline">
<span fg={palette.act}>https://github.com/cline/cline</span>
</a>
ClinePass is a $9.99/month subscription plan to get access to the
latest open-weight coding models with enough quota for day-to-day
work, at a much lower cost than paying API costs directly.
</text>
<text selectable>
Running{" "}
<span fg="#98c379" bg="#1f2937">
{" cline "}
</span>{" "}
now opens the terminal UI. To open Kanban, use /quit and run{" "}
<span fg="#98c379" bg="#1f2937">
{" cline kanban "}
</span>{" "}
in your terminal
<text selectable>Try it now with a limited-time promo for $1.99.</text>
</box>
<box flexDirection="row">
<text fg={palette.act} selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
</text>
</box>
<text fg={palette.muted}>Press Esc to close</text>
<box flexDirection="row">
<box paddingX={1} backgroundColor={palette.act}>
<text fg={palette.textOnSelection}>Open ClinePass</text>
</box>
</box>
{status && <text fg={palette.muted}>{status}</text>}
<text fg={palette.muted}>Press Enter to open, Esc to close</text>
</box>
);
}
+32 -9
View File
@@ -1,6 +1,12 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
getClineCliMigrationNotice,
@@ -26,8 +32,25 @@ describe("migration notice", () => {
it("returns the notice for a fresh data dir", () => {
const dataDir = createTempDataDir();
expect(getClineCliMigrationNotice(dataDir)?.title).toBe(
"Welcome to the new Cline CLI",
expect(getClineCliMigrationNotice(dataDir)?.title).toBe("Try ClinePass");
});
it("shows when only the old Kanban notice was marked as shown", () => {
const dataDir = createTempDataDir();
const noticePath = resolveCliNoticeStatePath(dataDir);
mkdirSync(dirname(noticePath), { recursive: true, mode: 0o700 });
writeFileSync(
noticePath,
`${JSON.stringify(
{ shown: { "cline-cli-tui-default": true } },
null,
2,
)}\n`,
"utf8",
);
expect(getClineCliMigrationNotice(dataDir)?.id).toBe(
"cline-cli-cline-pass-intro",
);
});
@@ -46,7 +69,7 @@ describe("migration notice", () => {
expect(
getClineCliMigrationNotice(dataDir, {
CLINE_FORCE_MIGRATION_NOTICE: "1",
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
}),
).toBeDefined();
});
@@ -56,7 +79,7 @@ describe("migration notice", () => {
expect(
getClineCliMigrationNotice(dataDir, {
CLINE_DISABLE_MIGRATION_NOTICE: "1",
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
}),
).toBeUndefined();
});
@@ -66,8 +89,8 @@ describe("migration notice", () => {
expect(
getClineCliMigrationNotice(dataDir, {
CLINE_DISABLE_MIGRATION_NOTICE: "1",
CLINE_FORCE_MIGRATION_NOTICE: "1",
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
}),
).toBeDefined();
});
@@ -78,7 +101,7 @@ describe("migration notice", () => {
markClineCliMigrationNoticeShown(dataDir);
const rawState = readFileSync(resolveCliNoticeStatePath(dataDir), "utf8");
expect(rawState).toContain("cline-cli-tui-default");
expect(rawState).toContain("cline-cli-cline-pass-intro");
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
});
});
+4 -4
View File
@@ -2,9 +2,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { resolveClineDataDir } from "@cline/shared/storage";
const NOTICE_ID = "cline-cli-tui-default";
const FORCE_NOTICE_ENV = "CLINE_FORCE_MIGRATION_NOTICE";
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_MIGRATION_NOTICE";
const NOTICE_ID = "cline-cli-cline-pass-intro";
const FORCE_NOTICE_ENV = "CLINE_FORCE_CLINE_PASS_NOTICE";
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_CLINE_PASS_NOTICE";
export interface CliMigrationNotice {
id: string;
@@ -71,7 +71,7 @@ export function getClineCliMigrationNotice(
}
return {
id: NOTICE_ID,
title: "Welcome to the new Cline CLI",
title: "Try ClinePass",
};
}
+133 -37
View File
@@ -407,7 +407,7 @@ describe("runCli lightweight command dispatch", () => {
it("does not load interactive runtime for single-prompt mode", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello"];
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
@@ -417,6 +417,30 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runInteractiveImports).toBe(0);
});
it("rejects a single bare positional prompt token", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "nonexistent-command"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining(
"Unknown command or unquoted prompt: nonexistent-command",
),
);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('Use "cline --help"'),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
});
it("rejects multiple bare positional prompt tokens", async () => {
const consoleError = vi
.spyOn(console, "error")
@@ -430,7 +454,7 @@ describe("runCli lightweight command dispatch", () => {
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining(
"Unknown command or extra arguments: hello world",
"Unknown command or unquoted prompt: hello world",
),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
@@ -474,7 +498,7 @@ describe("runCli lightweight command dispatch", () => {
it("creates a worktree and runs prompt sessions from it", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--worktree", "hello"];
process.argv = ["bun", "src/index.ts", "--worktree", "say hello"];
const { runCli } = await import("./main");
@@ -483,7 +507,7 @@ describe("runCli lightweight command dispatch", () => {
cwd: process.cwd(),
});
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
cwd: "/tmp/cline-worktree",
workspaceRoot: "/tmp/cline-worktree",
@@ -606,8 +630,8 @@ describe("runCli lightweight command dispatch", () => {
it("passes the migration notice marker into interactive mode", async () => {
const notice = {
id: "cline-cli-tui-default",
title: "Welcome to the new Cline CLI",
id: "cline-cli-cline-pass-intro",
title: "Try ClinePass",
};
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
Object.defineProperty(process.stdout, "isTTY", {
@@ -727,7 +751,7 @@ describe("runCli lightweight command dispatch", () => {
it("uses the bundled catalog path for single-prompt runs", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello"];
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
@@ -1013,7 +1037,7 @@ describe("runCli lightweight command dispatch", () => {
it("skips hub prewarm for yolo runs", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--yolo", "hello"];
process.argv = ["bun", "src/index.ts", "--yolo", "say hello"];
const { runCli } = await import("./main");
@@ -1022,6 +1046,24 @@ describe("runCli lightweight command dispatch", () => {
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
});
it("rejects yolo runs with a single bare prompt token", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--yolo", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("Unknown command or unquoted prompt: hello"),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
});
it("rewrites /team prompts and enables teams in single-prompt mode", async () => {
runtimeMocks.runAgent.mockClear();
@@ -1042,12 +1084,12 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("shows /team usage in single-prompt mode when no task is provided", async () => {
it("rejects /team without quoted task text", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
const stdoutWrite = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true);
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "/team"];
@@ -1055,9 +1097,10 @@ describe("runCli lightweight command dispatch", () => {
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(mockState.runAgentCalls).toBe(0);
expect(stdoutWrite).toHaveBeenCalledWith(
expect.stringContaining("Usage: /team <task description>"),
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("Unknown command or unquoted prompt: /team"),
);
});
@@ -1066,14 +1109,14 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "high", "hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "high", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "high",
@@ -1082,19 +1125,40 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("leaves thinking disabled when --thinking is not provided", async () => {
it("leaves thinking unset when --thinking is not provided", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello"];
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
thinking: undefined,
reasoningEffort: undefined,
}),
expect.anything(),
);
});
it("disables thinking when --thinking none is explicitly provided", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "none", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
expect.objectContaining({
thinking: false,
reasoningEffort: undefined,
@@ -1108,14 +1172,14 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "--", "hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "--", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1138,14 +1202,14 @@ describe("runCli lightweight command dispatch", () => {
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello"];
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "high",
@@ -1154,6 +1218,32 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("uses persisted disabled reasoning when --thinking is not provided", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
providerSettingsMocks.getProviderSettings.mockReturnValue({
provider: "cline",
model: "openai/gpt-5",
reasoning: { enabled: false },
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
expect.objectContaining({
thinking: false,
reasoningEffort: undefined,
}),
expect.anything(),
);
});
it("prefers explicit --thinking over persisted reasoning effort", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
@@ -1164,14 +1254,14 @@ describe("runCli lightweight command dispatch", () => {
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "low", "hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "low", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "low",
@@ -1185,13 +1275,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello"];
process.argv = ["bun", "src/index.ts", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1207,13 +1297,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--compaction", "basic", "hello"];
process.argv = ["bun", "src/index.ts", "--compaction", "basic", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1229,13 +1319,19 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--compaction", "agentic", "hello"];
process.argv = [
"bun",
"src/index.ts",
"--compaction",
"agentic",
"say hello",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1285,13 +1381,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--compaction", "off", "hello"];
process.argv = ["bun", "src/index.ts", "--compaction", "off", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
compaction: {
enabled: false,
@@ -1330,7 +1426,7 @@ describe("runCli lightweight command dispatch", () => {
authMocks.ensureOAuthProviderApiKey.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--json", "hello"];
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
const { runCli } = await import("./main");
@@ -1338,7 +1434,7 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runAgentCalls).toBe(1);
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
outputMode: "json",
apiKey: "",
@@ -1357,7 +1453,7 @@ describe("runCli lightweight command dispatch", () => {
authMocks.ensureOAuthProviderApiKey.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--json", "hello"];
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
const { runCli } = await import("./main");
@@ -1365,7 +1461,7 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runAgentCalls).toBe(1);
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
"say hello",
expect.objectContaining({
outputMode: "json",
apiKey: "",
+42 -25
View File
@@ -42,6 +42,7 @@ import {
isOAuthProvider,
normalizeProviderId,
} from "./utils/provider-auth";
import { resolveCliReasoning } from "./utils/reasoning";
import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
import {
captureCliExtensionActivated,
@@ -112,6 +113,23 @@ export function resolveConfigDirArg(argv: string[]): string | undefined {
return undefined;
}
function collectOption(value: string, previous: string[] = []): string[] {
return [...previous, value];
}
// Shells strip quote characters before argv reaches us, so a prompt that was
// typed in quotes is only observable when it remains one argv token with spaces.
function promptArgLooksQuoted(arg: string | undefined): boolean {
return !!arg && /\s/.test(arg);
}
function writePromptArgError(args: string[]): void {
const renderedArgs = args.join(" ");
writeErr(
`Unknown command or unquoted prompt: ${renderedArgs}\nPrompt text must be passed as a single quoted argument, for example: cline "fix the tests". Use "cline --help" to see available commands and flags.`,
);
}
export async function runCli(): Promise<void> {
installStreamErrorGuards();
autoUpdateOnStartup();
@@ -404,15 +422,24 @@ export async function runCli(): Promise<void> {
"--transport <transport>",
"stdio, sse, http, streamable-http, or streamableHttp (default: stdio)",
)
.option("--header <header>", "Remote MCP request header", collectOption, [])
.option("--yes", "Install noninteractively without opening the wizard")
.option("--json", "Output as JSON")
.action(async (name: string, targetArgs: string[]) => {
const opts = mcpInstallCmd.opts<{
header?: string[];
json?: boolean;
transport?: string;
yes?: boolean;
}>();
const { runMcpInstallCommand } = await import("./commands/mcp");
ctx.exitCode = await runMcpInstallCommand({
name,
headers: opts.header,
targetArgs,
transport: opts.transport,
json: opts.json === true || program.opts().json === true,
yes: opts.yes === true,
io,
});
});
@@ -722,13 +749,6 @@ export async function runCli(): Promise<void> {
// Default flow: no subcommand matched, or fall-through from config/history.
let args = commanderToParsedArgs(program);
if (program.args.length > 1) {
writeErr(
`Unknown command or extra arguments: ${program.args.join(" ")}\nPrompt text with spaces must be quoted as a single argument, for example: cline "fix the tests". Use "cline --help" to see available commands and flags.`,
);
process.exitCode = 1;
return;
}
let resumeSessionId: string | undefined = ctx.resumeSessionId;
if (resumeSessionId) {
@@ -815,6 +835,13 @@ export async function runCli(): Promise<void> {
if (args.hooksDir?.trim()) {
process.env.CLINE_HOOKS_DIR = args.hooksDir.trim();
}
if (args.prompt && !args.interactive) {
if (program.args.length > 1 || !promptArgLooksQuoted(program.args[0])) {
writePromptArgError(program.args);
process.exitCode = 1;
return;
}
}
setCurrentOutputMode(args.outputMode);
const defaultToolAutoApprove = true;
const effectiveToolAutoApprove =
@@ -998,19 +1025,12 @@ export async function runCli(): Promise<void> {
);
}
const knownModelIds = knownModels ? Object.keys(knownModels) : [];
const persistedReasoning = selectedProviderSettings?.reasoning;
const persistedReasoningEffort = persistedReasoning?.effort;
const reasoningEffortFromSettings =
persistedReasoning?.enabled === false
? "none"
: persistedReasoningEffort && persistedReasoningEffort !== "none"
? persistedReasoningEffort
: persistedReasoning?.enabled === true
? "medium"
: "none";
const effectiveReasoningEffort = args.thinkingExplicitlySet
? (args.reasoningEffort ?? "none")
: (args.reasoningEffort ?? reasoningEffortFromSettings);
const resolvedReasoning = resolveCliReasoning({
thinking: args.thinking,
thinkingExplicitlySet: args.thinkingExplicitlySet,
reasoningEffort: args.reasoningEffort,
persistedReasoning: selectedProviderSettings?.reasoning,
});
const { createCliLoggerAdapter } = await import("./logging/adapter");
const loggerAdapter = createCliLoggerAdapter({
runtime: "cli",
@@ -1046,11 +1066,8 @@ export async function runCli(): Promise<void> {
sandbox: sandboxEnabled,
sandboxDataDir,
verbose: args.verbose,
thinking: effectiveReasoningEffort !== "none",
reasoningEffort:
effectiveReasoningEffort === "none"
? undefined
: effectiveReasoningEffort,
thinking: resolvedReasoning.thinking,
reasoningEffort: resolvedReasoning.reasoningEffort,
outputMode: args.outputMode,
mode: args.mode,
logger: loggerAdapter.core,
+216 -7
View File
@@ -27,11 +27,28 @@ const outputMocks = vi.hoisted(() => ({
c: { dim: "", reset: "" },
}));
const sessionEventsMocks = vi.hoisted(() => ({
listener: undefined as ((event: unknown) => void) | undefined,
subscribeToAgentEvents: vi.fn(
(_: unknown, listener: (event: unknown) => void) => {
sessionEventsMocks.listener = listener;
return () => {};
},
),
}));
const CLINE_PASS_SUBSCRIPTION_URL =
"https://app.cline.bot/dashboard/subscription/";
const CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
"https://app.cline.bot/dashboard/subscription?personal=true";
const CLI_SUBSCRIPTION_URL =
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
vi.mock("@cline/core", () => ({
getClineOrgIndividualInferenceSubscriptionMessage: () =>
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
isClineNotSubscribedError: (error: unknown) =>
error instanceof Error && error.name === "ClineNotSubscribedError",
@@ -39,6 +56,15 @@ vi.mock("@cline/core", () => ({
text
.toLowerCase()
.includes("the user is not subscribed to required model plan"),
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
error instanceof Error &&
error.name === "ClineOrgIndividualInferenceSubscriptionError",
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
text
.toLowerCase()
.includes(
"organization accounts cannot use individual model inference subscriptions",
),
prewarmFileIndex: vi.fn(async () => undefined),
SessionSource: {
CLI: "cli",
@@ -88,7 +114,7 @@ vi.mock("./prompt", () => ({
}));
vi.mock("./session-events", () => ({
subscribeToAgentEvents: vi.fn(() => () => {}),
subscribeToAgentEvents: sessionEventsMocks.subscribeToAgentEvents,
}));
describe("runAgent", () => {
@@ -112,6 +138,9 @@ describe("runAgent", () => {
outputMocks.writeln.mockReset();
outputMocks.emitJsonLine.mockReset();
outputMocks.setActiveCliSession.mockReset();
sessionEventsMocks.listener = undefined;
sessionEventsMocks.subscribeToAgentEvents.mockClear();
vi.unstubAllGlobals();
});
afterEach(() => {
@@ -523,7 +552,7 @@ describe("runAgent", () => {
});
it("renders ClinePass subscription errors with friendly copy when startup throws", async () => {
const error = new Error(CLINE_PASS_SUBSCRIPTION_MESSAGE);
const error = new Error(SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE);
error.name = "ClineNotSubscribedError";
sessionManagerMocks.start.mockRejectedValue(error);
@@ -551,7 +580,7 @@ describe("runAgent", () => {
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).toHaveBeenCalledWith(
CLINE_PASS_SUBSCRIPTION_MESSAGE,
CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE,
);
});
@@ -629,7 +658,7 @@ describe("runAgent", () => {
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: CLINE_PASS_SUBSCRIPTION_MESSAGE,
text: SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE,
usage: {
inputTokens: 0,
outputTokens: 0,
@@ -673,10 +702,73 @@ describe("runAgent", () => {
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).toHaveBeenCalledWith(
CLINE_PASS_SUBSCRIPTION_MESSAGE,
CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE,
);
});
it("does not duplicate ClinePass subscription errors already displayed by agent events", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
sessionManagerMocks.start.mockImplementation(async () => {
sessionEventsMocks.listener?.({
type: "error",
error: new Error(SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE),
recoverable: false,
});
return {
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE,
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "error",
model: { id: "premium-model", provider: "cline-pass", info: {} },
startedAt,
endedAt,
durationMs: 1000,
},
};
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
const { runAgent } = await import("./run-agent");
await expect(
runAgent("test prompt", {
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: { maxConsecutiveMistakes: 3 },
logger: undefined,
mode: "yolo",
modelId: "premium-model",
outputMode: "text",
providerId: "cline-pass",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).not.toHaveBeenCalled();
});
it("surfaces post-run bookkeeping failures after a completed result", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
@@ -938,4 +1030,121 @@ describe("runAgent", () => {
expect.stringContaining("est. cost"),
);
});
it("zeros Cline free model costs in JSON results and agent events", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}),
);
sessionManagerMocks.start.mockResolvedValue({
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: {
session_id: "session-1",
},
result: {
text: "completed text",
usage: {
inputTokens: 1,
outputTokens: 1,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0.25,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "completed",
model: {
id: "deepseek/deepseek-v4-flash",
provider: "cline",
info: {},
},
startedAt,
endedAt,
durationMs: 1000,
},
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue({
usage: {
inputTokens: 1,
outputTokens: 1,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0.25,
},
aggregateUsage: {
inputTokens: 1,
outputTokens: 1,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0.25,
},
});
const { runAgent } = await import("./run-agent");
const { handleEvent } = await import("../utils/events");
await expect(
runAgent("test prompt", {
baseUrl: "https://cline.test/api/v1",
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: {
maxConsecutiveMistakes: 3,
},
logger: undefined,
mode: "yolo",
modelId: "deepseek/deepseek-v4-flash",
outputMode: "json",
providerId: "cline",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
const runResult = outputMocks.emitJsonLine.mock.calls.find(
([, payload]) =>
(payload as { type?: string } | undefined)?.type === "run_result",
)?.[1] as
| {
usage?: { totalCost?: number };
aggregateUsage?: { totalCost?: number };
}
| undefined;
expect(runResult?.usage?.totalCost).toBe(0);
expect(runResult?.aggregateUsage?.totalCost).toBe(0);
sessionEventsMocks.listener?.({
type: "usage",
inputTokens: 1,
outputTokens: 1,
cost: 0.25,
totalCost: 0.25,
});
expect(handleEvent).toHaveBeenLastCalledWith(
expect.objectContaining({
type: "usage",
cost: 0,
totalCost: 0,
}),
expect.any(Object),
);
});
});
+19 -4
View File
@@ -18,6 +18,11 @@ import {
} from "../utils/approval";
import { formatCliErrorMessage } from "../utils/cline-pass-errors";
import { handleEvent, handleTeamEvent } from "../utils/events";
import {
shouldZeroClineFreeModelCost,
zeroCliAgentEventCost,
zeroCliUsageCost,
} from "../utils/free-model-cost";
import { createRuntimeHooks } from "../utils/hooks";
import {
c,
@@ -184,8 +189,10 @@ export async function runAgent(
let reasoningChunkCount = 0;
let redactedReasoningChunkCount = 0;
const displayedErrorMessages = new Set<string>();
const shouldZeroCost = await shouldZeroClineFreeModelCost(config);
const onAgentEvent = (event: AgentEvent): void => {
const onAgentEvent = (rawEvent: AgentEvent): void => {
const event = zeroCliAgentEventCost(rawEvent, shouldZeroCost);
if (event.type === "content_start" && event.contentType === "reasoning") {
reasoningChunkCount += 1;
if (event.redacted) {
@@ -197,7 +204,9 @@ export async function runAgent(
(!event.recoverable || config.verbose) &&
event.error.message.trim()
) {
displayedErrorMessages.add(event.error.message.trim());
displayedErrorMessages.add(
formatCliErrorMessage(event.error.message).trim(),
);
}
handleEvent(event, config);
};
@@ -339,8 +348,14 @@ export async function runAgent(
const usageSummary = await sessionManager.getAccumulatedUsage(
started.sessionId,
);
const aggregateUsage = usageSummary?.aggregateUsage;
const usage = aggregateUsage ?? usageSummary?.usage ?? result.usage;
const aggregateUsage = zeroCliUsageCost(
usageSummary?.aggregateUsage,
shouldZeroCost,
);
const usage = zeroCliUsageCost(
aggregateUsage ?? usageSummary?.usage ?? result.usage,
shouldZeroCost,
);
if (config.outputMode === "json") {
emitJsonLine("stdout", {
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { resolveReasoningForModelChange } from "./run-interactive";
describe("resolveReasoningForModelChange", () => {
it("persists disabled reasoning only when thinking is explicitly false", () => {
expect(
resolveReasoningForModelChange(
{ thinking: false, reasoningEffort: undefined },
{ reasoning: { enabled: true, effort: "high" } },
),
).toEqual({ enabled: false });
});
it("persists enabled reasoning with the selected effort", () => {
expect(
resolveReasoningForModelChange(
{ thinking: true, reasoningEffort: "low" },
{ reasoning: { enabled: false } },
),
).toEqual({ enabled: true, effort: "low" });
});
it("persists enabled reasoning when thinking is explicitly true without effort", () => {
expect(
resolveReasoningForModelChange(
{ thinking: true, reasoningEffort: undefined },
{ reasoning: { enabled: false } },
),
).toEqual({ enabled: true });
});
it("preserves existing reasoning when thinking is unset", () => {
expect(
resolveReasoningForModelChange(
{ thinking: undefined, reasoningEffort: undefined },
{ reasoning: { enabled: true, effort: "medium" } },
),
).toEqual({ enabled: true, effort: "medium" });
});
});
+44 -7
View File
@@ -8,6 +8,7 @@ import type { CliMigrationNotice } from "../kanban-migration/notice";
import { logCliError } from "../logging/errors";
import {
loadClineAccountSnapshot,
loadIndividualSubscriptionPlans,
onProviderChange,
switchClineAccount,
} from "../tui/cline-account";
@@ -24,6 +25,11 @@ import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
import type { QueuedPromptItem } from "../tui/types";
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
import { applyCliCompactionMode } from "../utils/compaction-mode";
import {
shouldZeroClineFreeModelCost,
zeroCliAgentEventCost,
zeroCliUsageCost,
} from "../utils/free-model-cost";
import {
prepareTerminalForPostTuiOutput,
writeErr,
@@ -52,6 +58,23 @@ import { createInteractiveSessionRuntime } from "./interactive/session-runtime";
import { buildUserInputMessage } from "./prompt";
import { getUIEventEmitter } from "./session-events";
type ModelChangeReasoningConfig = {
thinking?: boolean;
reasoningEffort?: Config["reasoningEffort"];
};
export function resolveReasoningForModelChange(
config: ModelChangeReasoningConfig,
existing: Pick<ProviderSettings, "reasoning">,
): ProviderSettings["reasoning"] {
if (config.thinking === false) return { enabled: false };
if (config.reasoningEffort) {
return { enabled: true, effort: config.reasoningEffort };
}
if (config.thinking === true) return { enabled: true };
return existing.reasoning;
}
export async function runInteractive(
config: Config,
userInstructionService?: UserInstructionConfigService,
@@ -153,6 +176,7 @@ export async function runInteractive(
askQuestionRef: tuiAskQuestion,
});
const providerSettingsManager = new ProviderSettingsManager();
let zeroCurrentTurnCost = false;
const sessionRuntime = createInteractiveSessionRuntime({
config,
@@ -166,7 +190,7 @@ export async function runInteractive(
resolveMistakeLimitDecision,
switchToActModeTool,
onAgentEvent: (event) => {
uiEvents.emit("agent", event);
uiEvents.emit("agent", zeroCliAgentEventCost(event, zeroCurrentTurnCost));
},
onTeamEvent: (event) => {
uiEvents.emit("team", event);
@@ -404,6 +428,12 @@ export async function runInteractive(
config,
clineApiBaseUrl: options?.clineApiBaseUrl,
}),
loadIndividualSubscriptionPlans: async () =>
await loadIndividualSubscriptionPlans({
config,
clineApiBaseUrl: options?.clineApiBaseUrl,
clineProviderSettings: options?.clineProviderSettings,
}),
switchClineAccount: async (organizationId) =>
await switchClineAccount({
config,
@@ -432,6 +462,7 @@ export async function runInteractive(
},
onSubmit: async (input, mode, delivery, attachments, onCommandOutput) => {
let commandOutput: string | undefined;
let zeroTurnCost = false;
try {
await sessionRuntime.ensureReady();
await waitForSubmittedMode(mode);
@@ -478,6 +509,8 @@ export async function runInteractive(
}
input = chatCommandResult.input;
commandOutput = chatCommandResult.commandOutput;
zeroTurnCost = await shouldZeroClineFreeModelCost(config);
zeroCurrentTurnCost = zeroTurnCost;
const {
prompt: userInput,
userImages,
@@ -519,8 +552,9 @@ export async function runInteractive(
}
if (result.finishReason !== "completed") {
if (result.finishReason === "aborted" || isAbortInProgress()) {
const usage = await sessionRuntime.getAccumulatedUsage(
result.usage,
const usage = zeroCliUsageCost(
await sessionRuntime.getAccumulatedUsage(result.usage),
zeroTurnCost,
);
return {
usage,
@@ -535,7 +569,10 @@ export async function runInteractive(
errorText || `Turn finished with ${result.finishReason}`,
);
}
const usage = await sessionRuntime.getAccumulatedUsage(result.usage);
const usage = zeroCliUsageCost(
await sessionRuntime.getAccumulatedUsage(result.usage),
zeroTurnCost,
);
return {
usage,
currentContextSize: getCurrentContextSize(result.messages),
@@ -559,6 +596,7 @@ export async function runInteractive(
});
throw error;
} finally {
zeroCurrentTurnCost = false;
if (!delivery) {
isRunning = false;
clearAbortInProgress();
@@ -623,12 +661,11 @@ export async function runInteractive(
) ?? {
provider: config.providerId,
};
const reasoning = resolveReasoningForModelChange(config, existing);
providerSettingsManager.saveProviderSettings({
...existing,
model: config.modelId,
reasoning: config.reasoningEffort
? { enabled: true, effort: config.reasoningEffort }
: { enabled: false },
...(reasoning === undefined ? {} : { reasoning }),
});
await sessionRuntime.restartWithCurrentMessages();
},
+8 -2
View File
@@ -3,7 +3,11 @@ import { CLINE_BIN } from "./helpers/constants.js";
import { clineEnv } from "./helpers/env.js";
import { expectVisible } from "./helpers/terminal.js";
const HELP_TERMINAL = { columns: 120, rows: 50 };
// Wide enough that long option descriptions (e.g. --thinking) render on a
// single line. At narrower widths commander wraps them, splitting phrases
// like "omitted leaves provider default" across lines so the contiguous
// getByText assertions below fail.
const HELP_TERMINAL = { columns: 200, rows: 50 };
// ===========================================================================
// Root-level flag descriptions
@@ -23,7 +27,9 @@ test.describe("root flag descriptions", () => {
"verbose output",
"Working directory",
"Configuration directory",
"Set reasoning effort level",
"Set reasoning effort:",
"Bare --thinking uses medium",
"omitted leaves provider default",
"consecutive mistakes",
"Output messages as JSON",
"Check for updates and install if available",
+1 -1
View File
@@ -124,7 +124,7 @@ export function clineEnv(
}),
CLINE_SESSION_DATA_DIR: path.join(dataDir, "sessions"),
CLINE_TEAM_DATA_DIR: path.join(dataDir, "teams"),
CLINE_DISABLE_MIGRATION_NOTICE: "1",
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
NO_UPDATE_NOTIFIER: "1",
CLINE_NO_AUTO_UPDATE: "1",
...extra,
+60
View File
@@ -12,6 +12,8 @@ const coreMocks = vi.hoisted(() => {
fetchMe: vi.fn(),
fetchBalance: vi.fn(),
fetchOrganizationBalance: vi.fn(),
fetchAvailableSubscriptionPlans: vi.fn(),
fetchCurrentUserPlan: vi.fn(),
serviceOptions,
};
});
@@ -39,6 +41,14 @@ vi.mock("@cline/core", async (importOriginal) => {
fetchOrganizationBalance(organizationId: string) {
return coreMocks.fetchOrganizationBalance(organizationId);
}
fetchAvailableSubscriptionPlans(input?: {
type?: "individual" | "teams";
}) {
return coreMocks.fetchAvailableSubscriptionPlans(input);
}
fetchCurrentUserPlan() {
return coreMocks.fetchCurrentUserPlan();
}
},
ProviderSettingsManager: class {
getProviderSettings(providerId: string) {
@@ -100,6 +110,8 @@ describe("createClineAccountService", () => {
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
coreMocks.fetchCurrentUserPlan.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
@@ -196,6 +208,8 @@ describe("loadClineAccountSnapshot", () => {
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
coreMocks.fetchCurrentUserPlan.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
@@ -249,3 +263,49 @@ describe("loadClineAccountSnapshot", () => {
);
});
});
describe("loadIndividualSubscriptionPlans", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
coreMocks.getProviderSettings.mockReset();
coreMocks.saveProviderSettings.mockReset();
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
coreMocks.fetchCurrentUserPlan.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("loads individual subscription plans through the authorized account service", async () => {
const plans = [
{
id: "plan-1",
interval: "Monthly",
features: { included: ["Major open-weights models"] },
},
];
coreMocks.getProviderSettings.mockReturnValue({
provider: "cline",
apiKey: "account-token",
});
coreMocks.fetchAvailableSubscriptionPlans.mockResolvedValue(plans);
const { loadIndividualSubscriptionPlans } = await import("./cline-account");
const result = await loadIndividualSubscriptionPlans({
config: makeConfig(),
});
expect(coreMocks.fetchAvailableSubscriptionPlans).toHaveBeenCalledWith({
type: "individual",
});
expect(result).toEqual(plans);
});
});
+58 -1
View File
@@ -2,6 +2,8 @@ import {
type ClineAccountBalance,
type ClineAccountOrganization,
type ClineAccountOrganizationBalance,
type ClineSubscriptionPlan,
type UserCurrentPlan,
ClineAccountService,
type ClineAccountUser,
formatProviderOAuthApiKey,
@@ -124,8 +126,9 @@ export async function createClineAccountService(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
clineProviderSettings?: ProviderSettings;
providerSettingsManager?: ProviderSettingsManager;
}): Promise<ClineAccountService | undefined> {
const manager = new ProviderSettingsManager();
const manager = input.providerSettingsManager ?? new ProviderSettingsManager();
const settings =
manager.getProviderSettings("cline") ?? input.clineProviderSettings;
const apiBaseUrl = resolveAccountApiBaseUrl({
@@ -203,6 +206,60 @@ export async function switchClineAccount(input: {
await service.switchAccount(input.organizationId);
}
export async function loadIndividualSubscriptionPlans(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
clineProviderSettings?: ProviderSettings;
}): Promise<ClineSubscriptionPlan[]> {
const service = await createClineAccountService(input);
if (!service) {
throw new Error("No Cline account auth token found");
}
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
}
export async function loadCurrentUserPlan(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
clineProviderSettings?: ProviderSettings;
}): Promise<UserCurrentPlan | undefined> {
const service = await createClineAccountService(input);
if (!service) {
throw new Error("No Cline account auth token found");
}
return service.fetchCurrentUserPlan();
}
export async function loadCurrentUserPlanFromProviderSettings(input: {
providerSettingsManager: ProviderSettingsManager;
clineApiBaseUrl?: string;
}): Promise<UserCurrentPlan | undefined> {
const service = await createClineAccountService({
config: { apiKey: "", logger: undefined, providerId: "cline" },
clineApiBaseUrl: input.clineApiBaseUrl,
providerSettingsManager: input.providerSettingsManager,
});
if (!service) {
throw new Error("No Cline account auth token found");
}
return service.fetchCurrentUserPlan();
}
export async function loadIndividualSubscriptionPlansFromProviderSettings(input: {
providerSettingsManager: ProviderSettingsManager;
clineApiBaseUrl?: string;
}): Promise<ClineSubscriptionPlan[]> {
const service = await createClineAccountService({
config: { apiKey: "", logger: undefined, providerId: "cline" },
clineApiBaseUrl: input.clineApiBaseUrl,
providerSettingsManager: input.providerSettingsManager,
});
if (!service) {
throw new Error("No Cline account auth token found");
}
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
}
async function onChangeToClinePass(config: ClineAccountConfig) {
try {
await switchClineAccount({
+142 -7
View File
@@ -1,11 +1,16 @@
import type { ClineSubscriptionPlan } from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import type React from "react";
import { useState } from "react";
import { useEffect, useState } from "react";
import "opentui-spinner/react";
import {
getClinePassSubscriptionUrl,
getClineOrgIndividualInferenceSubscriptionMessage,
getCliSubscriptionUrl,
getIndividualPlanFeatures,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
import { getCliFeatureFlagsService } from "../../utils/feature-flags";
import {
CLINE_CREDITS_DASHBOARD_URL,
isClineAccountCreditsErrorMessage,
@@ -264,7 +269,8 @@ function ToolCallView(props: {
);
}
function ClineCreditsErrorView(props: { defaultFg?: string }) {
function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
const subscriptionUrl = getCliSubscriptionUrl();
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -279,7 +285,60 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
<text
fg={props.defaultFg}
selectable
content="You have run out of Cline credits. Add credits in the dashboard to continue."
content={
"You have run out of Cline credits. Add credits in the dashboard or purchase and switch to ClinePass to continue."
}
/>
<box flexDirection="row">
<text fg="gray">Purchase Credits: </text>
<text fg="cyan" selectable>
<a href={CLINE_CREDITS_DASHBOARD_URL}>
{CLINE_CREDITS_DASHBOARD_URL}
</a>
</text>
</box>
<box flexDirection="row">
<text fg="gray">Purchase ClinePass: </text>
<text fg="cyan" selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
</text>
</box>
<box flexDirection="row">
<text fg="gray">Switch to ClinePass: </text>
<text fg="gray">
type /settings in CLI and switch provider to ClinePass
</text>
</box>
</box>
</box>
);
}
function ClineCreditsErrorView(props: { defaultFg?: string }) {
const isClinePassEnabled =
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass");
if (isClinePassEnabled) {
return <ClineCreditsClinePassErrorView defaultFg={props.defaultFg} />;
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor="red"
paddingX={1}
>
<text fg="red">Cline Credits depleted</text>
<text
fg={props.defaultFg}
selectable
content={
"You have run out of Cline credits. Add credits in the dashboard to continue."
}
/>
<box flexDirection="row">
<text fg="gray">Dashboard: </text>
@@ -294,8 +353,34 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
);
}
function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
const subscriptionUrl = getClinePassSubscriptionUrl();
function ClinePassSubscriptionErrorView(props: {
defaultFg?: string;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
}) {
const subscriptionUrl = getCliSubscriptionUrl();
const [planFeatures, setPlanFeatures] = useState<string[]>([]);
useEffect(() => {
if (!props.loadIndividualSubscriptionPlans) {
return;
}
let isMounted = true;
void props
.loadIndividualSubscriptionPlans()
.then((plans) => {
if (isMounted) {
setPlanFeatures(getIndividualPlanFeatures(plans));
}
})
.catch(() => {
// Keep the subscription error view usable if plan metadata is unavailable.
});
return () => {
isMounted = false;
};
}, [props.loadIndividualSubscriptionPlans]);
return (
<box flexDirection="row">
<text fg="yellow" content="* " />
@@ -312,6 +397,17 @@ function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
selectable
content="No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan."
/>
{planFeatures.length > 0 && (
<box flexDirection="column" marginTop={1}>
<text fg={props.defaultFg}>ClinePass includes:</text>
{planFeatures.map((feature) => (
<text key={feature} fg={props.defaultFg} selectable>
<span fg="green"> </span>
<span>{feature}</span>
</text>
))}
</box>
)}
<box flexDirection="row">
<text fg="gray">Subscribe: </text>
<text fg="cyan" selectable>
@@ -329,9 +425,34 @@ function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
);
}
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
defaultFg?: string;
}) {
return (
<box flexDirection="row">
<text fg="yellow" content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor="yellow"
paddingX={1}
>
<text fg="yellow">Personal ClinePass required</text>
<text
fg={props.defaultFg}
selectable
content={getClineOrgIndividualInferenceSubscriptionMessage()}
/>
</box>
</box>
);
}
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
terminalTheme: TerminalTheme;
}) {
const { entry, accent = palette.act, terminalTheme } = props;
@@ -427,8 +548,22 @@ export function ChatEntryView(props: {
if (isClineAccountCreditsErrorMessage(entry.text)) {
return <ClineCreditsErrorView defaultFg={defaultFg} />;
}
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
return (
<ClineOrgIndividualInferenceSubscriptionErrorView
defaultFg={defaultFg}
/>
);
}
if (isClinePassSubscriptionError(entry.text)) {
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
return (
<ClinePassSubscriptionErrorView
defaultFg={defaultFg}
loadIndividualSubscriptionPlans={
props.loadIndividualSubscriptionPlans
}
/>
);
}
return (
<box flexDirection="row">
@@ -1,5 +1,5 @@
import "opentui-spinner/react";
import type { AgentMode } from "@cline/core";
import type { AgentMode, ClineSubscriptionPlan } from "@cline/core";
import type { ScrollBoxRenderable } from "@opentui/core";
import {
forwardRef,
@@ -21,6 +21,7 @@ export interface TranscriptScrollHandle {
interface ChatMessageListProps {
entries: ChatEntry[];
isStreaming?: boolean;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
uiMode?: AgentMode;
}
@@ -100,6 +101,9 @@ export const ChatMessageList = forwardRef<
key={key}
entry={entry}
accent={accent}
loadIndividualSubscriptionPlans={
props.loadIndividualSubscriptionPlans
}
terminalTheme={terminalTheme}
/>
);
@@ -0,0 +1,25 @@
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
const CLINE_USAGE_BILLING_PATH = "/dashboard/account";
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
export function buildClinePassSubscriptionPageUrl(
appBaseUrl: string | undefined,
): string {
const url = new URL(
CLINE_PASS_SUBSCRIPTION_PATH,
appBaseUrl || DEFAULT_APP_BASE_URL,
);
url.searchParams.set("personal", "true");
return url.toString();
}
export function buildClineUsageBillingPageUrl(
appBaseUrl: string | undefined,
): string {
const url = new URL(
CLINE_USAGE_BILLING_PATH,
appBaseUrl || DEFAULT_APP_BASE_URL,
);
url.searchParams.set("tab", "credits");
return url.toString();
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
buildClinePassSubscriptionPageUrl,
buildClineUsageBillingPageUrl,
} from "./provider-picker-helpers";
describe("buildClinePassSubscriptionPageUrl", () => {
it("opens the personal subscription page on production by default", () => {
expect(buildClinePassSubscriptionPageUrl(undefined)).toBe(
"https://app.cline.bot/dashboard/subscription?personal=true",
);
});
it("keeps the configured app base URL", () => {
expect(
buildClinePassSubscriptionPageUrl("https://staging-app.cline.bot"),
).toBe(
"https://staging-app.cline.bot/dashboard/subscription?personal=true",
);
});
});
describe("buildClineUsageBillingPageUrl", () => {
it("opens the credits tab on production by default", () => {
expect(buildClineUsageBillingPageUrl(undefined)).toBe(
"https://app.cline.bot/dashboard/account?tab=credits",
);
});
it("keeps the configured app base URL", () => {
expect(buildClineUsageBillingPageUrl("https://staging-app.cline.bot")).toBe(
"https://staging-app.cline.bot/dashboard/account?tab=credits",
);
});
});
@@ -37,6 +37,10 @@ import {
getSearchableListRowsWindow,
type SearchableItem,
} from "../searchable-list";
import {
buildClinePassSubscriptionPageUrl,
buildClineUsageBillingPageUrl,
} from "./provider-picker-helpers";
interface ProviderItem {
id: string;
@@ -248,18 +252,33 @@ export function ProviderPickerContent(
);
}
export type ExistingProviderAction = "use_existing" | "reconfigure";
export type ExistingProviderAction =
| "use_existing"
| "reconfigure"
| "open_subscription_page"
| "open_usage_billing";
export interface ExistingProviderOption {
value: ExistingProviderAction;
label: string;
onSelect?: () => Promise<void> | void;
}
export function UseExistingOrReconfigureContent(
props: ChoiceContext<ExistingProviderAction> & {
props: ChoiceContext<ExistingProviderOption> & {
providerName: string;
extraOptions?: ExistingProviderOption[];
},
) {
const { resolve, dismiss, dialogId, providerName } = props;
const options: { value: ExistingProviderAction; label: string }[] = [
{ value: "use_existing", label: "Use existing configuration" },
{ value: "reconfigure", label: "Configure again" },
];
const { resolve, dismiss, dialogId, providerName, extraOptions } = props;
const options: ExistingProviderOption[] = useMemo(
() => [
{ value: "use_existing", label: "Use existing configuration" },
{ value: "reconfigure", label: "Configure again" },
...(extraOptions ?? []),
],
[extraOptions],
);
const [selected, setSelected] = useState(0);
useDialogKeyboard((key) => {
@@ -269,7 +288,7 @@ export function UseExistingOrReconfigureContent(
}
if (key.name === "return" || key.name === "enter") {
const opt = options[selected];
if (opt) resolve(opt.value);
if (opt) resolve(opt);
return;
}
if (key.name === "up" || (key.ctrl && key.name === "p")) {
@@ -314,6 +333,106 @@ export function UseExistingOrReconfigureContent(
);
}
function ClinePassBrowserPageContent(
props: ChoiceContext<boolean> & {
providerName: string;
pageLabel: string;
url: string;
openedStatus: string;
},
) {
const {
resolve,
dismiss,
dialogId,
providerName,
pageLabel,
url,
openedStatus,
} = props;
const [status, setStatus] = useState("Opening browser...");
useEffect(() => {
void open(url, { wait: false })
.then(() => {
setStatus(openedStatus);
})
.catch(() => {
setStatus("Could not open browser automatically. Open the URL below.");
});
}, [url, openedStatus]);
useDialogKeyboard((key) => {
if (key.name === "escape") {
dismiss();
return;
}
if (key.name === "return" || key.name === "enter") {
resolve(true);
}
}, dialogId);
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">
<strong>{providerName}</strong>
</text>
<text>{status}</text>
<text fg="gray">{pageLabel}:</text>
<text fg="cyan" selectable>
<a href={url}>{url}</a>
</text>
<text fg="gray">
<em>Enter or Esc to go back</em>
</text>
</box>
);
}
export function ClinePassSubscriptionContent(
props: ChoiceContext<boolean> & {
providerName: string;
},
) {
const subscriptionUrl = useMemo(
() =>
buildClinePassSubscriptionPageUrl(getClineEnvironmentConfig().appBaseUrl),
[],
);
return (
<ClinePassBrowserPageContent
{...props}
pageLabel="Subscription page"
url={subscriptionUrl}
openedStatus="Opened subscription page in your browser."
/>
);
}
export function ClineUsageBillingContent(
props: ChoiceContext<boolean> & {
providerName: string;
},
) {
const usageBillingUrl = useMemo(
() => buildClineUsageBillingPageUrl(getClineEnvironmentConfig().appBaseUrl),
[],
);
return (
<ClinePassBrowserPageContent
{...props}
pageLabel="Usage and billing"
url={usageBillingUrl}
openedStatus="Opened usage and billing in your browser."
/>
);
}
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
apiKey: "API key",
baseUrl: "Base URL",
+32 -5
View File
@@ -3,6 +3,7 @@ import {
createContextBar,
formatStatusBarUsageText,
resolveContextBarFilledForeground,
resolveModelDisplayName,
} from "./status-bar";
vi.mock("@opentui/react", () => ({
@@ -55,18 +56,44 @@ describe("formatStatusBarUsageText", () => {
formatStatusBarUsageText({
totalTokens: 12_345,
totalCost: 0.123,
showCost: true,
providerId: "cline",
}),
).toBe("(12,345) $0.12");
).toBe("(12,345 tokens) $0.12");
});
it("omits cost when usage cost is hidden", () => {
it("displays subscription message when the provider is a subscription provider", () => {
expect(
formatStatusBarUsageText({
totalTokens: 12_345,
totalCost: 0.123,
showCost: false,
providerId: "cline-pass",
}),
).toBe("(12,345)");
).toBe("(12,345 tokens) $0.00 (included with subscription)");
});
});
describe("resolveModelDisplayName", () => {
it("keeps ClinePass visible when model ids have provider prefixes", () => {
expect(
resolveModelDisplayName({
providerId: "cline-pass",
modelId: "zai/glm-5.2",
knownModels: {
"zai/glm-5.2": { name: "GLM 5.2" },
},
}),
).toBe("ClinePass/glm-5.2");
});
it("uses the friendly model name for non-ClinePass providers", () => {
expect(
resolveModelDisplayName({
providerId: "cline",
modelId: "zai/glm-5.2",
knownModels: {
"zai/glm-5.2": { name: "GLM 5.2" },
},
}),
).toBe("GLM 5.2");
});
});
+34 -10
View File
@@ -1,6 +1,9 @@
import type { AgentMode } from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
import {
shouldShowCliUsageCost,
shouldShowCliUsageCoveredBySubscription,
} from "../../utils/usage-cost-display";
import {
useTerminalBackground,
useTerminalTheme,
@@ -46,14 +49,31 @@ function formatCost(cost: number): string {
return `$${cost.toFixed(2)}`;
}
function formatCostText(providerId: string, totalCost: number): string {
if (shouldShowCliUsageCoveredBySubscription(providerId)) {
return "$0.00 (included with subscription)";
}
if (!shouldShowCliUsageCost(providerId)) {
return "";
}
return formatCost(totalCost);
}
export function formatStatusBarUsageText(input: {
totalTokens: number;
totalCost: number;
showCost: boolean;
providerId: string;
}): string {
const tokens = `(${input.totalTokens.toLocaleString()})`;
if (!input.showCost) return tokens;
return `${tokens} ${formatCost(input.totalCost)}`;
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
const costText = formatCostText(input.providerId, input.totalCost);
if (!costText) {
return tokens;
}
return `${tokens} ${costText}`;
}
// knownModels keys are bare IDs ("claude-sonnet-4-6") but config.modelId
@@ -74,17 +94,22 @@ function lookupModelInfo(
}
export function resolveModelDisplayName(config: {
providerId?: string;
modelId: string;
knownModels?: Record<string, unknown>;
thinking?: boolean;
reasoningEffort?: string;
}): string {
const info = lookupModelInfo(config.modelId, config.knownModels);
const name = info?.name ?? config.modelId.split("/").pop() ?? config.modelId;
const modelIdTail = config.modelId.split("/").pop() ?? config.modelId;
const displayName =
config.providerId === "cline-pass"
? `ClinePass/${modelIdTail}`
: (info?.name ?? modelIdTail);
if (config.thinking && config.reasoningEffort) {
return `${name} (${config.reasoningEffort})`;
return `${displayName} (${config.reasoningEffort})`;
}
return name;
return displayName;
}
export function resolveModelMaxInputTokens(config: {
@@ -152,7 +177,6 @@ export function StatusBar(props: StatusBarProps) {
const bar = hasMaxInputTokens
? createContextBar(totalTokens, maxInputTokens)
: undefined;
const showUsageCost = shouldShowCliUsageCost(props.providerId);
// Available content width after accounting for padding.
// Home view: parent box is capped at 60 wide, status bar adds paddingX=1 (-2).
@@ -169,7 +193,7 @@ export function StatusBar(props: StatusBarProps) {
const usageText = formatStatusBarUsageText({
totalTokens,
totalCost,
showCost: showUsageCost,
providerId: props.providerId,
});
const contextText = bar
? ` ${bar.filled}${bar.empty} ${usageText}`
+75 -8
View File
@@ -18,8 +18,10 @@ import {
import type { Config } from "../../utils/types";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import {
ClinePassSubscriptionContent,
ClineUsageBillingContent,
CodexCliStatusContent,
type ExistingProviderAction,
type ExistingProviderOption,
OAuthLoginContent,
ProviderConfigInputContent,
ProviderPickerContent,
@@ -78,6 +80,52 @@ function usesModelIdInput(providerId: string): boolean {
return providerId === "openai-compatible";
}
function providerToExistingProviderOptions(input: {
providerId: string;
providerName: string;
dialog: DialogActions;
termHeight: number;
}): ExistingProviderOption[] {
if (input.providerId !== "cline-pass") {
return [];
}
return [
{
value: "open_subscription_page",
label: "Open subscription page",
onSelect: async () => {
await input.dialog.choice<boolean>({
style: { maxHeight: input.termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<boolean>) => (
<ClinePassSubscriptionContent
{...ctx}
providerName={input.providerName}
/>
),
});
},
},
{
value: "open_usage_billing",
label: "See usage and billing",
onSelect: async () => {
await input.dialog.choice<boolean>({
style: { maxHeight: input.termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<boolean>) => (
<ClineUsageBillingContent
{...ctx}
providerName={input.providerName}
/>
),
});
},
},
];
}
async function runProviderChange(
dialog: DialogActions,
config: Config,
@@ -102,14 +150,33 @@ async function runProviderChange(
let needsAuth = true;
if (isProviderConfigured(newProviderId, existingSettings)) {
const action = await dialog.choice<ExistingProviderAction>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<ExistingProviderAction>) => (
<UseExistingOrReconfigureContent {...ctx} providerName={displayName} />
),
let option: ExistingProviderOption | undefined;
const extraOptions = providerToExistingProviderOptions({
providerId: newProviderId,
providerName: displayName,
dialog,
termHeight,
});
if (!action) return false;
needsAuth = action === "reconfigure";
while (true) {
option = await dialog.choice<ExistingProviderOption>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<ExistingProviderOption>) => (
<UseExistingOrReconfigureContent
{...ctx}
providerName={displayName}
extraOptions={extraOptions}
/>
),
});
if (!option) return false;
if (option.onSelect) {
await option.onSelect();
option = undefined;
continue;
}
break;
}
needsAuth = option.value === "reconfigure";
}
if (needsAuth) {
+1
View File
@@ -879,6 +879,7 @@ function App(props: TuiProps) {
repoStatus,
textareaRef: promptInput.textareaRef,
transcriptScrollRef,
loadIndividualSubscriptionPlans: props.loadIndividualSubscriptionPlans,
queuedPrompts,
selectedQueuedPromptId,
editingQueuedPrompt,
+2
View File
@@ -2,6 +2,7 @@ import type {
AgentEvent,
AgentMode,
CheckpointEntry,
ClineSubscriptionPlan,
TeamEvent,
} from "@cline/core";
import type {
@@ -129,6 +130,7 @@ export interface TuiProps {
loadAdditionalSlashCommands?: () => Promise<InteractiveSlashCommand[]>;
loadWelcomeLine?: () => Promise<string | undefined>;
loadClineAccount: () => Promise<ClineAccountSnapshot>;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
switchClineAccount: (organizationId?: string | null) => Promise<void>;
loadConfigData: (
options?: LoadInteractiveConfigDataOptions,
+2
View File
@@ -50,6 +50,7 @@ export function ChatView(props: {
};
textareaRef?: React.MutableRefObject<TextareaHandle | null>;
transcriptScrollRef?: React.Ref<TranscriptScrollHandle>;
loadIndividualSubscriptionPlans?: TuiProps["loadIndividualSubscriptionPlans"];
autocomplete?: AutocompleteDropdownProps;
queuedPrompts?: QueuedPromptItem[];
selectedQueuedPromptId?: string | null;
@@ -89,6 +90,7 @@ export function ChatView(props: {
ref={props.transcriptScrollRef}
entries={session.entries}
isStreaming={session.isStreaming}
loadIndividualSubscriptionPlans={props.loadIndividualSubscriptionPlans}
uiMode={session.uiMode}
/>
+148 -4
View File
@@ -10,7 +10,12 @@ import {
saveLocalProviderSettings,
} from "@cline/core";
import { isClineProvider } from "@cline/shared";
import open from "open";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
getCliSubscriptionUrl,
getIndividualPlanFeatures,
} from "../../../utils/cline-pass-errors";
import {
type CodexCliStatus,
checkCodexCliInstalled,
@@ -20,6 +25,10 @@ import { getCliFeatureFlagsService } from "../../../utils/feature-flags";
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { getCliTelemetryService } from "../../../utils/telemetry";
import {
loadCurrentUserPlanFromProviderSettings,
loadIndividualSubscriptionPlansFromProviderSettings,
} from "../../cline-account";
import {
buildClineModelEntries,
type ClineModelPickerEntry,
@@ -48,6 +57,8 @@ import {
import { FIELD_ORDER } from "./fields";
import { useOnboardingKeyboard } from "./keyboard";
import {
CLINE_PASS_SUBSCRIPTION_OPTIONS,
type ClinePassSubscriptionStatus,
getMainMenuOptions,
type ModelEntry,
type OnboardingResult,
@@ -150,6 +161,18 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const [modelsDefaultId, setModelsDefaultId] = useState("");
const [customModelId, setCustomModelId] = useState("");
const [customModelError, setCustomModelError] = useState("");
const [clinePassSubscriptionStatus, setClinePassSubscriptionStatus] =
useState<ClinePassSubscriptionStatus>("loading");
const [clinePassSubscriptionError, setClinePassSubscriptionError] =
useState("");
const [clinePassCurrentPlanName, setClinePassCurrentPlanName] = useState("");
const [clinePassPlanFeatures, setClinePassPlanFeatures] = useState<string[]>(
[],
);
const [clinePassSubscriptionSelected, setClinePassSubscriptionSelected] =
useState(0);
const [clinePassSubscriptionOpenStatus, setClinePassSubscriptionOpenStatus] =
useState("");
const modelItems: SearchableItem[] = useMemo(
() =>
@@ -265,6 +288,62 @@ export function useOnboardingController(props: OnboardingControllerProps) {
[providerSettingsManager],
);
const refreshClinePassSubscriptionStatus = useCallback(() => {
setClinePassSubscriptionStatus("loading");
setClinePassSubscriptionError("");
setClinePassCurrentPlanName("");
setClinePassSubscriptionOpenStatus("");
loadCurrentUserPlanFromProviderSettings({ providerSettingsManager })
.then(
(value) => ({ status: "fulfilled" as const, value }),
(reason) => ({ status: "rejected" as const, reason }),
)
.then((currentPlanResult) =>
loadIndividualSubscriptionPlansFromProviderSettings({
providerSettingsManager,
})
.then(
(value) => ({ status: "fulfilled" as const, value }),
(reason) => ({ status: "rejected" as const, reason }),
)
.then((availablePlansResult) => ({
availablePlansResult,
currentPlanResult,
})),
)
.then(({ currentPlanResult, availablePlansResult }) => {
if (availablePlansResult.status === "fulfilled") {
setClinePassPlanFeatures(
getIndividualPlanFeatures(availablePlansResult.value),
);
}
if (currentPlanResult.status === "rejected") {
const error = currentPlanResult.reason;
const message =
error instanceof Error ? error.message : String(error);
if (message.trim().toLowerCase() === "no plan found for user") {
setClinePassSubscriptionStatus("unsubscribed");
return;
}
setClinePassSubscriptionError(message);
setClinePassSubscriptionStatus("error");
return;
}
const plan = currentPlanResult.value?.plan;
if (plan) {
setClinePassCurrentPlanName(
plan.displayName || plan.name || plan.id || "ClinePass",
);
setClinePassSubscriptionStatus("subscribed");
} else {
setClinePassSubscriptionStatus("unsubscribed");
}
});
}, [providerSettingsManager]);
const transitionToModelPicker = useCallback(
(providerId: string) => {
setActiveProviderId(providerId);
@@ -288,6 +367,27 @@ export function useOnboardingController(props: OnboardingControllerProps) {
[providers, loadModelsForProvider, providerSettingsManager],
);
const transitionToClinePassSubscription = useCallback(() => {
setActiveProviderId("cline-pass");
const provider = providers.find((p) => p.id === "cline-pass");
setActiveProviderName(provider?.name ?? "ClinePass");
setModelsDefaultId(provider?.defaultModelId ?? "");
setClinePassSubscriptionSelected(0);
setStep("cline_pass_subscription");
refreshClinePassSubscriptionStatus();
}, [providers, refreshClinePassSubscriptionStatus]);
const handleAuthComplete = useCallback(
(providerId: OnboardingOAuthProviderId) => {
if (providerId === "cline-pass") {
transitionToClinePassSubscription();
return;
}
transitionToModelPicker(providerId);
},
[transitionToClinePassSubscription, transitionToModelPicker],
);
const resetAuth = useCallback(() => {
setAuthStatus("");
setAuthUrl("");
@@ -313,11 +413,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
setVerifyUrl: setDeviceVerifyUrl,
setStatus: setDeviceStatus,
setError: setDeviceError,
onComplete: transitionToModelPicker,
onComplete: handleAuthComplete,
telemetry: getCliTelemetryService(),
});
},
[providerSettingsManager, transitionToModelPicker],
[providerSettingsManager, handleAuthComplete],
);
const startOAuthFlow = useCallback(
@@ -339,18 +439,47 @@ export function useOnboardingController(props: OnboardingControllerProps) {
setStatus: setAuthStatus,
setAuthUrl,
setError: setAuthError,
onComplete: transitionToModelPicker,
onComplete: handleAuthComplete,
telemetry: getCliTelemetryService(),
});
},
[
providerSettingsManager,
resetAuth,
transitionToModelPicker,
handleAuthComplete,
startDeviceCodeFlow,
],
);
const continueFromClinePassSubscription = useCallback(() => {
transitionToModelPicker("cline-pass");
}, [transitionToModelPicker]);
const openClinePassSubscriptionPage = useCallback(() => {
const subscriptionUrl = getCliSubscriptionUrl();
setClinePassSubscriptionOpenStatus("Opening subscription page...");
void open(subscriptionUrl, { wait: false })
.then(() => {
setClinePassSubscriptionOpenStatus(
"Opened subscription page in your browser.",
);
})
.catch(() => {
setClinePassSubscriptionOpenStatus(
`Could not open browser automatically. Open ${subscriptionUrl}`,
);
});
}, []);
useEffect(() => {
if (
step === "cline_pass_subscription" &&
clinePassSubscriptionStatus === "subscribed"
) {
transitionToModelPicker("cline-pass");
}
}, [step, clinePassSubscriptionStatus, transitionToModelPicker]);
const refreshCodexCliStatus = useCallback(() => {
setCodexCliStatus(undefined);
setCodexCliChecking(true);
@@ -632,6 +761,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
modelList,
clineEntries,
clineModelSelected,
clinePassSubscriptionStatus,
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
clinePassSubscriptionSelected,
thinkingSelected,
setStep,
setMenuSelected,
@@ -648,7 +780,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
setDeviceError,
setDeviceStatus,
setClineModelSelected,
setClinePassSubscriptionSelected,
setThinkingSelected,
continueFromClinePassSubscription,
refreshClinePassSubscriptionStatus,
openClinePassSubscriptionPage,
abortOAuth: () => {
authAbortRef.current = true;
},
@@ -670,6 +806,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
return {
activeProviderName,
activeProviderId,
authError,
authStatus,
authUrl,
@@ -682,6 +819,13 @@ export function useOnboardingController(props: OnboardingControllerProps) {
clineEntries,
clineKnownModels,
clineModelSelected,
clinePassCurrentPlanName,
clinePassPlanFeatures,
clinePassSubscriptionError,
clinePassSubscriptionOpenStatus,
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
clinePassSubscriptionSelected,
clinePassSubscriptionStatus,
deviceError,
deviceStatus,
deviceUserCode,
@@ -9,6 +9,8 @@ import {
} from "./auth";
import { FIELD_ORDER } from "./fields";
import {
type ClinePassSubscriptionOption,
type ClinePassSubscriptionStatus,
type MenuOption,
type OnboardingStep,
THINKING_LEVELS,
@@ -26,6 +28,9 @@ export function useOnboardingKeyboard(input: {
modelList: SearchableListState;
clineEntries: ClineModelPickerEntry[];
clineModelSelected: number;
clinePassSubscriptionStatus: ClinePassSubscriptionStatus;
clinePassSubscriptionOptions: ClinePassSubscriptionOption[];
clinePassSubscriptionSelected: number;
thinkingSelected: number;
setStep: (step: OnboardingStep) => void;
setMenuSelected: Dispatch<SetStateAction<number>>;
@@ -38,7 +43,11 @@ export function useOnboardingKeyboard(input: {
setDeviceError: (value: string) => void;
setDeviceStatus: (value: string) => void;
setClineModelSelected: Dispatch<SetStateAction<number>>;
setClinePassSubscriptionSelected: Dispatch<SetStateAction<number>>;
setThinkingSelected: Dispatch<SetStateAction<number>>;
continueFromClinePassSubscription: () => void;
refreshClinePassSubscriptionStatus: () => void;
openClinePassSubscriptionPage: () => void;
abortOAuth: () => void;
abortDeviceCode: () => void;
resetAuth: () => void;
@@ -93,6 +102,11 @@ export function useOnboardingKeyboard(input: {
input.setStep("byo_provider");
return;
}
if (input.step === "cline_pass_subscription") {
input.setStep("menu");
input.setMenuSelected(0);
return;
}
if (input.step === "cline_model") {
input.setStep("menu");
input.setMenuSelected(0);
@@ -135,6 +149,43 @@ export function useOnboardingKeyboard(input: {
if (input.step === "device_code") return;
if (input.step === "cline_pass_subscription") {
const total = input.clinePassSubscriptionOptions.length;
if (total === 0) return;
if (key.name === "up" || (key.ctrl && key.name === "p")) {
input.setClinePassSubscriptionSelected((s) =>
s <= 0 ? total - 1 : s - 1,
);
return;
}
if (key.name === "down" || (key.ctrl && key.name === "n")) {
input.setClinePassSubscriptionSelected((s) =>
s >= total - 1 ? 0 : s + 1,
);
return;
}
if (key.name === "return" || key.name === "enter") {
const option =
input.clinePassSubscriptionOptions[
Math.min(input.clinePassSubscriptionSelected, total - 1)
];
if (!option) return;
if (option.value === "subscribe") {
input.openClinePassSubscriptionPage();
} else if (option.value === "refresh") {
if (input.clinePassSubscriptionStatus !== "loading") {
input.refreshClinePassSubscriptionStatus();
}
} else if (option.value === "skip") {
input.continueFromClinePassSubscription();
} else if (option.value === "back") {
input.setStep("menu");
input.setMenuSelected(0);
}
}
return;
}
if (input.step === "menu") {
if (key.name === "up") {
input.setMenuSelected((s) =>
@@ -8,6 +8,7 @@ export type OnboardingStep =
| "byo_provider"
| "byo_apikey"
| "codex_cli_setup"
| "cline_pass_subscription"
| "cline_model"
| "model_picker"
| "custom_model_id"
@@ -36,6 +37,17 @@ export interface MenuOption {
icon: string;
}
export type ClinePassSubscriptionAction =
| "subscribe"
| "refresh"
| "skip"
| "back";
export interface ClinePassSubscriptionOption {
value: ClinePassSubscriptionAction;
label: string;
}
export const MAIN_MENU: MenuOption[] = [
{
label: "Sign in with Cline",
@@ -71,6 +83,25 @@ export function getMainMenuOptions(options?: {
);
}
export const CLINE_PASS_SUBSCRIPTION_OPTIONS: ClinePassSubscriptionOption[] = [
{
value: "subscribe",
label: "Subscribe to ClinePass",
},
{
value: "refresh",
label: "Re-check subscription status",
},
{
value: "skip",
label: "Skip for now",
},
{
value: "back",
label: "Go back",
},
];
export interface OnboardingResult {
providerId: string;
modelId: string;
@@ -96,6 +127,12 @@ export interface ModelEntry {
supportsReasoning: boolean;
}
export type ClinePassSubscriptionStatus =
| "loading"
| "subscribed"
| "unsubscribed"
| "error";
export interface ProviderCatalogItem {
id: string;
name: string;
+187 -1
View File
@@ -1,5 +1,7 @@
import "opentui-spinner/react";
import type { ScrollBoxRenderable } from "@opentui/core";
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import {
CODEX_CLI_INSTALL_URL,
type CodexCliStatus,
@@ -20,7 +22,12 @@ import {
import { useTerminalBackground } from "../../hooks/use-terminal-background";
import { getDefaultForeground, palette } from "../../palette";
import { FIELD_ORDER } from "./fields";
import { type MenuOption, THINKING_LEVELS } from "./model";
import {
type ClinePassSubscriptionOption,
type ClinePassSubscriptionStatus,
type MenuOption,
THINKING_LEVELS,
} from "./model";
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
@@ -29,6 +36,10 @@ function useDefaultFg(): string | undefined {
return getDefaultForeground(terminalBg);
}
function getClinePassSubscriptionOptionId(index: number): string {
return `cline-pass-subscription-option-${index}`;
}
interface OnboardingFrameProps {
children: ReactNode;
compact: boolean;
@@ -468,6 +479,181 @@ export function OnboardingClineModelScreen(props: {
);
}
export function OnboardingClinePassSubscriptionScreen(props: {
compact: boolean;
contentWidth: number;
currentPlanName: string;
error: string;
mouse: MouseTrackerState;
openStatus: string;
options: ClinePassSubscriptionOption[];
planFeatures: string[];
selected: number;
status: ClinePassSubscriptionStatus;
}) {
const defaultFg = useDefaultFg();
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
const isLoading = props.status === "loading";
const isSubscribed = props.status === "subscribed";
const isError = props.status === "error";
const bodyHeight = props.compact ? 14 : 18;
useEffect(() => {
if (isSubscribed) {
return;
}
const scrollSelectedOptionIntoView = () => {
scrollRef.current?.scrollChildIntoView(
getClinePassSubscriptionOptionId(props.selected),
);
};
scrollSelectedOptionIntoView();
queueMicrotask(scrollSelectedOptionIntoView);
const timeout = setTimeout(scrollSelectedOptionIntoView, 0);
return () => clearTimeout(timeout);
}, [isSubscribed, props.selected]);
return (
<OnboardingFrame
compact={props.compact}
contentWidth={props.contentWidth}
mouse={props.mouse}
>
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={isSubscribed ? palette.success : "yellow"}
paddingX={1}
paddingY={1}
height={bodyHeight}
overflow="hidden"
>
<scrollbox
ref={scrollRef}
width="100%"
height="100%"
scrollY
scrollX={false}
viewportOptions={{ overflow: "hidden" }}
contentOptions={{ flexDirection: "column" }}
>
<box flexDirection="column" width="100%" flexShrink={0}>
<text fg={isSubscribed ? palette.success : "yellow"} flexShrink={0}>
{isSubscribed
? "ClinePass subscription active"
: "ClinePass subscription required"}
</text>
{isLoading ? (
<box flexDirection="row" gap={1} flexShrink={0}>
<spinner name="dots" color="gray" />
<text fg="gray">Checking your ClinePass subscription...</text>
</box>
) : isSubscribed ? (
<text fg={defaultFg} selectable flexShrink={0}>
Current plan: {props.currentPlanName || "ClinePass"}
</text>
) : isError ? (
<text
fg={defaultFg}
selectable
flexShrink={0}
content="Could not verify your ClinePass subscription. Re-check before choosing a ClinePass model."
/>
) : (
<text
fg={defaultFg}
selectable
flexShrink={0}
content="No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan."
/>
)}
{props.status === "error" &&
props.error &&
props.error !== "no plan found for user" && (
<text fg="red" selectable flexShrink={0}>
{props.error}
</text>
)}
{!isSubscribed && props.planFeatures.length > 0 && (
<box flexDirection="column" marginTop={1} flexShrink={0}>
<text fg={defaultFg}>ClinePass includes:</text>
{props.planFeatures.map((feature) => {
if (
feature === "Generous limits and reliable access" ||
feature === "Built for as many programmers as possible"
) {
return null;
}
return (
<text
key={feature}
fg={defaultFg}
selectable
flexShrink={0}
>
<span fg="green"> </span>
<span>{feature}</span>
</text>
);
})}
</box>
)}
{!isSubscribed && (
<box flexDirection="column" marginTop={1} flexShrink={0}>
{props.options.map((option, i) => {
const isSel = i === props.selected;
return (
<box
id={getClinePassSubscriptionOptionId(i)}
key={option.value}
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={isSel ? palette.selection : undefined}
height={1}
flexShrink={0}
overflow="hidden"
>
<text
fg={isSel ? palette.textOnSelection : "gray"}
flexShrink={0}
>
{isSel ? "\u276f" : " "}
</text>
<text
fg={isSel ? palette.textOnSelection : defaultFg}
flexShrink={0}
>
{option.label}
</text>
</box>
);
})}
</box>
)}
{props.openStatus && (
<text fg="gray" selectable flexShrink={0}>
{props.openStatus}
</text>
)}
</box>
</scrollbox>
</box>
<text fg="gray" paddingX={1}>
<em>/ navigate, Enter to select, Esc to go back, Ctrl+C to exit</em>
</text>
</OnboardingFrame>
);
}
export function OnboardingModelPickerScreen(props: {
activeProviderName: string;
compact: boolean;
@@ -6,6 +6,7 @@ import { useOnboardingController } from "./controller";
import { getOAuthProviderLabel, type OnboardingResult } from "./model";
import {
OnboardingClineModelScreen,
OnboardingClinePassSubscriptionScreen,
OnboardingCodexCliScreen,
OnboardingCustomModelIdScreen,
OnboardingDeviceCodeScreen,
@@ -121,6 +122,23 @@ export function OnboardingView(props: OnboardingViewProps) {
);
}
if (state.step === "cline_pass_subscription") {
return (
<OnboardingClinePassSubscriptionScreen
compact={compact}
contentWidth={contentWidth}
currentPlanName={state.clinePassCurrentPlanName}
error={state.clinePassSubscriptionError}
mouse={mouse}
openStatus={state.clinePassSubscriptionOpenStatus}
options={state.clinePassSubscriptionOptions}
planFeatures={state.clinePassPlanFeatures}
selected={state.clinePassSubscriptionSelected}
status={state.clinePassSubscriptionStatus}
/>
);
}
if (state.step === "model_picker") {
return (
<OnboardingModelPickerScreen
+27 -4
View File
@@ -1,7 +1,10 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getClinePassSubscriptionUrl,
getCliNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getCliSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
@@ -13,14 +16,34 @@ describe("cline-pass-errors", () => {
),
).toBe(true);
const formatted = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getClinePassSubscriptionUrl()}`;
const sdkFormatted =
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/dashboard/subscription?personal=true";
const formatted = getCliNotSubscribedMessage();
expect(isClinePassSubscriptionError(sdkFormatted)).toBe(true);
expect(isClinePassSubscriptionError(formatted)).toBe(true);
expect(formatCliErrorMessage(new Error(sdkFormatted))).toBe(formatted);
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
});
it("formats the ClinePass subscription URL", () => {
expect(getClinePassSubscriptionUrl()).toBe(
"https://app.cline.bot/dashboard/subscription/",
expect(getCliSubscriptionUrl()).toBe(
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
);
});
it("recognizes and formats organization account individual subscription errors", () => {
const raw =
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
const formatted = getClineOrgIndividualInferenceSubscriptionMessage();
expect(isClineOrgIndividualInferenceSubscriptionErrorMessage(raw)).toBe(
true,
);
expect(
isClineOrgIndividualInferenceSubscriptionErrorMessage(
new Error(formatted),
),
).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
});
});
+52 -2
View File
@@ -1,10 +1,34 @@
import {
getClinePassSubscriptionUrl,
type ClineSubscriptionPlan,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
} from "@cline/core";
export { getClinePassSubscriptionUrl };
import { getClineEnvironmentConfig } from "@cline/shared";
export { getClineOrgIndividualInferenceSubscriptionMessage };
export function getCliSubscriptionUrl(): string {
return `${new URL(
"/promo?code=CLI-8OFF&personal=true",
getClineEnvironmentConfig().appBaseUrl,
).toString()}`;
}
export function getCliNotSubscribedMessage(): string {
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
}
export function getIndividualPlanFeatures(
plans: ClineSubscriptionPlan[],
): string[] {
const planWithFeatures = plans.find((plan) => plan.interval === "Monthly");
return planWithFeatures?.features?.included ?? [];
}
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
@@ -32,7 +56,33 @@ export function isClinePassSubscriptionError(error: unknown): boolean {
);
}
export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
error: unknown,
): boolean {
if (isClineOrgIndividualInferenceSubscriptionError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClineOrgIndividualInferenceSubscriptionError" ||
isClineOrgIndividualInferenceSubscriptionMessage(error.message) ||
error.message === getClineOrgIndividualInferenceSubscriptionMessage()
);
}
return (
typeof error === "string" &&
(isClineOrgIndividualInferenceSubscriptionMessage(error) ||
error === getClineOrgIndividualInferenceSubscriptionMessage())
);
}
export function formatCliErrorMessage(error: unknown): string {
if (isClinePassSubscriptionError(error)) {
return getCliNotSubscribedMessage();
}
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
return getClineOrgIndividualInferenceSubscriptionMessage();
}
if (error instanceof Error) {
return error.message;
}
+162
View File
@@ -0,0 +1,162 @@
import type { AgentEvent } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearClineFreeModelCostCache,
shouldZeroClineFreeModelCost,
zeroCliAgentEventCost,
zeroCliUsageCost,
} from "./free-model-cost";
afterEach(() => {
clearClineFreeModelCostCache();
vi.unstubAllGlobals();
});
describe("shouldZeroClineFreeModelCost", () => {
it("uses the Cline free model list", async () => {
const fetchMock = vi.fn(
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
},
);
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://cline.test/api/v1/ai/cline/recommended-models",
);
});
it("does not zero non-Cline providers", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "openrouter",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not match a paid model by only the final path segment", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}),
);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "acme/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
});
it("retries after a failed free model list fetch", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe("zeroCliUsageCost", () => {
it("zeros total cost while preserving token usage", () => {
expect(
zeroCliUsageCost(
{
inputTokens: 10,
outputTokens: 5,
totalCost: 0.001,
},
true,
),
).toEqual({
inputTokens: 10,
outputTokens: 5,
totalCost: 0,
});
});
});
describe("zeroCliAgentEventCost", () => {
it("zeros usage event cost fields", () => {
const event = {
type: "usage",
inputTokens: 10,
outputTokens: 5,
cost: 0.001,
totalCost: 0.001,
} as AgentEvent;
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
cost: 0,
totalCost: 0,
});
});
it("zeros done event usage cost", () => {
const event = {
type: "done",
reason: "completed",
text: "ok",
iterations: 1,
usage: {
inputTokens: 10,
outputTokens: 5,
totalCost: 0.001,
},
} as AgentEvent;
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
usage: { totalCost: 0 },
});
});
});
+123
View File
@@ -0,0 +1,123 @@
import type { AgentEvent } from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import type { Config } from "./types";
const CLINE_RECOMMENDED_MODELS_TIMEOUT_MS = 5_000;
const freeModelIdsByBaseUrl = new Map<
string,
Promise<readonly string[] | undefined>
>();
function normalizeModelId(modelId: string | undefined): string {
return modelId?.trim().toLowerCase() ?? "";
}
function modelIdsMatch(selectedModelId: string, freeModelId: string): boolean {
const selected = normalizeModelId(selectedModelId);
const free = normalizeModelId(freeModelId);
if (!selected || !free) return false;
return selected === free;
}
function resolveClineRecommendedModelsUrl(baseUrl: string): string {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const apiBaseUrl = normalizedBaseUrl.endsWith("/api/v1")
? normalizedBaseUrl.slice(0, -"/api/v1".length)
: normalizedBaseUrl;
return `${apiBaseUrl}/api/v1/ai/cline/recommended-models`;
}
async function fetchClineFreeModelIds(
baseUrl: string,
): Promise<readonly string[] | undefined> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
CLINE_RECOMMENDED_MODELS_TIMEOUT_MS,
);
try {
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
signal: controller.signal,
});
if (!response.ok) return undefined;
const json = (await response.json()) as { free?: unknown };
return Array.isArray(json.free)
? json.free
.map((model) =>
model && typeof model === "object"
? (model as Record<string, unknown>).id
: undefined,
)
.filter((id): id is string => typeof id === "string" && id.length > 0)
: [];
} catch {
return undefined;
} finally {
clearTimeout(timeout);
}
}
function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
const cacheKey = baseUrl.trim();
let cached = freeModelIdsByBaseUrl.get(cacheKey);
if (!cached) {
cached = fetchClineFreeModelIds(cacheKey).then((ids) => {
if (!ids) freeModelIdsByBaseUrl.delete(cacheKey);
return ids;
});
freeModelIdsByBaseUrl.set(cacheKey, cached);
}
return cached.then((ids) => ids ?? []);
}
export async function shouldZeroClineFreeModelCost(
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
): Promise<boolean> {
if (config.providerId !== "cline") return false;
const modelId = normalizeModelId(config.modelId);
if (!modelId) return false;
const baseUrl =
config.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
const freeModelIds = await getClineFreeModelIds(baseUrl);
return freeModelIds.some((freeModelId) =>
modelIdsMatch(modelId, freeModelId),
);
}
export function zeroCliUsageCost<T extends { totalCost?: number } | undefined>(
usage: T,
shouldZeroCost: boolean,
): T {
if (
!shouldZeroCost ||
!usage ||
typeof usage.totalCost !== "number" ||
usage.totalCost === 0
) {
return usage;
}
return { ...usage, totalCost: 0 } as T;
}
export function zeroCliAgentEventCost(
event: AgentEvent,
shouldZeroCost: boolean,
): AgentEvent {
if (!shouldZeroCost) return event;
if (event.type === "done" && event.usage) {
return {
...event,
usage: zeroCliUsageCost(event.usage, true),
};
}
if (event.type !== "usage") return event;
const next = { ...event } as Record<string, unknown>;
if (typeof next.cost === "number") next.cost = 0;
if (typeof next.totalCost === "number") next.totalCost = 0;
return next as unknown as AgentEvent;
}
export function clearClineFreeModelCostCache(): void {
freeModelIdsByBaseUrl.clear();
}
+89
View File
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { resolveCliReasoning } from "./reasoning";
describe("resolveCliReasoning", () => {
it("leaves reasoning unset when neither CLI nor persisted settings specify it", () => {
expect(
resolveCliReasoning({
thinking: false,
}),
).toEqual({
thinking: undefined,
reasoningEffort: undefined,
});
});
it("preserves explicit --thinking none as disabled reasoning", () => {
expect(
resolveCliReasoning({
thinking: false,
thinkingExplicitlySet: true,
}),
).toEqual({
thinking: false,
reasoningEffort: undefined,
});
});
it("prefers explicit --thinking over persisted reasoning settings", () => {
expect(
resolveCliReasoning({
thinking: true,
thinkingExplicitlySet: true,
reasoningEffort: "low",
persistedReasoning: { enabled: false },
}),
).toEqual({
thinking: true,
reasoningEffort: "low",
});
});
it("uses persisted disabled reasoning when --thinking is unset", () => {
expect(
resolveCliReasoning({
thinking: false,
persistedReasoning: { enabled: false },
}),
).toEqual({
thinking: false,
reasoningEffort: undefined,
});
});
it("uses persisted effort none as disabled reasoning when --thinking is unset", () => {
expect(
resolveCliReasoning({
thinking: false,
persistedReasoning: { effort: "none" },
}),
).toEqual({
thinking: false,
reasoningEffort: undefined,
});
});
it("uses persisted active effort when --thinking is unset", () => {
expect(
resolveCliReasoning({
thinking: false,
persistedReasoning: { enabled: true, effort: "high" },
}),
).toEqual({
thinking: true,
reasoningEffort: "high",
});
});
it("uses medium effort when persisted reasoning is enabled without an effort", () => {
expect(
resolveCliReasoning({
thinking: false,
persistedReasoning: { enabled: true },
}),
).toEqual({
thinking: true,
reasoningEffort: "medium",
});
});
});
+65
View File
@@ -0,0 +1,65 @@
import type { ProviderSettings } from "@cline/core";
import type { CliReasoningEffort } from "./types";
type ActiveCliReasoningEffort = Exclude<CliReasoningEffort, "none">;
const ACTIVE_REASONING_EFFORTS = new Set<ActiveCliReasoningEffort>([
"low",
"medium",
"high",
"xhigh",
]);
export interface ResolveCliReasoningInput {
thinking: boolean;
thinkingExplicitlySet?: boolean;
reasoningEffort?: CliReasoningEffort;
persistedReasoning?: ProviderSettings["reasoning"];
}
export interface ResolvedCliReasoning {
thinking?: boolean;
reasoningEffort?: ActiveCliReasoningEffort;
}
function isActiveReasoningEffort(
effort: unknown,
): effort is ActiveCliReasoningEffort {
return (
typeof effort === "string" &&
ACTIVE_REASONING_EFFORTS.has(effort as ActiveCliReasoningEffort)
);
}
export function resolveCliReasoning({
thinking,
thinkingExplicitlySet,
reasoningEffort,
persistedReasoning,
}: ResolveCliReasoningInput): ResolvedCliReasoning {
if (thinkingExplicitlySet) {
return {
thinking,
reasoningEffort: isActiveReasoningEffort(reasoningEffort)
? reasoningEffort
: undefined,
};
}
if (
persistedReasoning?.enabled === false ||
persistedReasoning?.effort === "none"
) {
return { thinking: false, reasoningEffort: undefined };
}
if (isActiveReasoningEffort(persistedReasoning?.effort)) {
return { thinking: true, reasoningEffort: persistedReasoning.effort };
}
if (persistedReasoning?.enabled === true) {
return { thinking: true, reasoningEffort: "medium" };
}
return { thinking: undefined, reasoningEffort: undefined };
}
+1 -1
View File
@@ -25,7 +25,7 @@ export interface Config extends Omit<CoreSessionConfig, "apiKey" | "mode"> {
timeoutSeconds?: number;
sandbox: boolean;
sandboxDataDir?: string;
thinking: boolean;
thinking?: boolean;
outputMode: CliOutputMode;
mode: CliAgentMode;
defaultToolAutoApprove: boolean;
+6
View File
@@ -3,3 +3,9 @@ import { Llms } from "@cline/core";
export function shouldShowCliUsageCost(providerId: string): boolean {
return Llms.shouldShowProviderUsageCost(providerId);
}
export function shouldShowCliUsageCoveredBySubscription(
providerId: string,
): boolean {
return Llms.resolveProviderUsageCostDisplay(providerId) === "subscription";
}
+14 -323
View File
@@ -1,325 +1,16 @@
export interface PlatformDef {
id: string;
name: string;
type: "polling" | "webhook" | "hybrid";
hint: string;
fields: FieldDef[];
security?: SecurityDef;
}
import {
CONNECTOR_PLATFORMS,
shouldIncludeConnectorField,
} from "@cline/shared";
export interface FieldDef {
flag: string;
label: string;
placeholder?: string;
required?: boolean;
help?: string[];
initialValue?: string;
options?: Array<{ value: string; label: string; hint?: string }>;
includeWhen?: FieldCondition;
}
export type {
ConnectorFieldCondition as FieldCondition,
ConnectorFieldDef as FieldDef,
ConnectorPlatformDef as PlatformDef,
ConnectorSecurityDef as SecurityDef,
ConnectorSecurityFieldDef as SecurityFieldDef,
} from "@cline/shared";
export { CONNECTOR_PLATFORMS, shouldIncludeConnectorField };
export type FieldCondition = {
flag: string;
equals?: string;
notEquals?: string;
};
export interface SecurityFieldDef {
key: string;
label: string;
placeholder?: string;
help?: string[];
requiredMessage: string;
validate?: (value: string) => string | undefined;
}
export interface SecurityDef {
prompt: string;
fields: SecurityFieldDef[];
buildArgs: (values: Record<string, string>) => string[];
}
export function shouldIncludeField(
field: FieldDef,
values: Record<string, string>,
): boolean {
const condition = field.includeWhen;
if (!condition) {
return true;
}
const value = values[condition.flag] ?? "";
if (condition.equals !== undefined && value !== condition.equals) {
return false;
}
if (condition.notEquals !== undefined && value === condition.notEquals) {
return false;
}
return true;
}
function validateTelegramUserId(value: string): string | undefined {
return /^\d+$/.test(value)
? undefined
: "Telegram user ID must contain digits only";
}
function validateSlackTeamId(value: string): string | undefined {
return /^T[A-Z0-9]+$/.test(value)
? undefined
: "Slack workspace ID must start with T and contain uppercase letters or digits only";
}
function validateSlackUserId(value: string): string | undefined {
return /^[UW][A-Z0-9]+$/.test(value)
? undefined
: "Slack member ID must start with U or W and contain uppercase letters or digits only";
}
export const PLATFORMS: PlatformDef[] = [
{
id: "telegram",
name: "Telegram",
type: "polling",
hint: "Easiest to set up. No public URL needed.",
fields: [
{
flag: "-k",
label: "Bot token",
placeholder: "7123456789:AAH...",
required: true,
help: [
"Open Telegram and start a chat with @BotFather",
"Send /newbot and follow the prompts",
"BotFather gives you this after creating the bot",
"It looks like 7123456789:AAHxxx...",
],
},
],
security: {
prompt:
"By default, anyone who finds your bot can message it and run tasks on your machine. Restrict access to your Telegram user ID?",
fields: [
{
key: "userId",
label: "Your Telegram user ID",
placeholder: "123456789",
help: [
"Message @userinfobot on Telegram",
"It will reply with your numeric user ID",
],
requiredMessage: "User ID is required to restrict access",
validate: validateTelegramUserId,
},
],
buildArgs: ({ userId }) => ["--allowed-user-id", userId ?? ""],
},
},
{
id: "slack",
name: "Slack",
type: "hybrid",
hint: "Public URL for webhook mode; leave blank for socket mode.",
fields: [
{
flag: "--bot-token",
label: "Bot token",
placeholder: "xoxb-...",
required: true,
help: [
"Go to api.slack.com/apps and create a new app",
"Add Bot Token Scopes: chat:write, app_mentions:read, channels:history, channels:read, im:history, im:read, im:write, users:read",
"Install to workspace and copy the Bot Token",
],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "leave blank for socket mode",
help: [
"Enter a publicly accessible URL for webhook mode",
"Leave blank to use Slack socket mode instead",
],
},
{
flag: "--signing-secret",
label: "Signing secret",
required: true,
help: ["Found in your app's Basic Information page"],
includeWhen: { flag: "--base-url", notEquals: "" },
},
{
flag: "--app-token",
label: "App-level token",
placeholder: "xapp-...",
required: true,
help: [
"Enable Socket Mode in the Slack app",
"Generate an app-level token with the connections:write scope",
],
includeWhen: { flag: "--base-url", equals: "" },
},
],
security: {
prompt: "Restrict which Slack users can interact with the bot?",
fields: [
{
key: "teamId",
label: "Allowed Slack workspace ID",
placeholder: "T01ABC123",
help: [
"Open your Slack workspace URL in a browser",
"The workspace ID is the segment after /client/, for example T01ABC123",
],
requiredMessage: "Workspace ID is required to restrict access",
validate: validateSlackTeamId,
},
{
key: "userId",
label: "Allowed Slack member ID",
placeholder: "U01ABC123",
help: [
"Click a user's name in Slack, then View full profile",
"Click ... and Copy member ID",
],
requiredMessage: "Member ID is required to restrict access",
validate: validateSlackUserId,
},
],
buildArgs: ({ teamId, userId }) => [
"--hook-command",
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:${teamId}:user:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
],
},
},
{
id: "discord",
name: "Discord",
type: "webhook",
hint: "Requires a Discord app and public URL.",
fields: [
{
flag: "--application-id",
label: "Application ID",
required: true,
help: [
"Go to discord.com/developers/applications",
"Create a new app, copy the Application ID",
],
},
{
flag: "--bot-token",
label: "Bot token",
required: true,
help: ["Go to Bot section, create a bot, copy the token"],
},
{
flag: "--public-key",
label: "Public key",
required: true,
help: ["Found in General Information of your app"],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
help: [
"Base URL for the connector",
"For Discord, set the Interactions Endpoint URL to <base-url>/api/webhooks/discord",
],
},
],
},
{
id: "whatsapp",
name: "WhatsApp",
type: "webhook",
hint: "Requires Meta developer account and public URL.",
fields: [
{
flag: "--phone-number-id",
label: "Phone number ID",
required: true,
help: ["From your WhatsApp Business account in Meta Developer portal"],
},
{
flag: "--access-token",
label: "Access token",
required: true,
help: ["Generate a permanent token in Meta Developer portal"],
},
{
flag: "--app-secret",
label: "App secret",
required: true,
help: ["Found in App Settings > Basic"],
},
{
flag: "--verify-token",
label: "Webhook verify token",
placeholder: "my-verify-token",
required: true,
help: ["Any string you choose, used to verify webhook setup"],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
},
],
},
{
id: "gchat",
name: "Google Chat",
type: "webhook",
hint: "Requires Google Cloud project and public URL.",
fields: [
{
flag: "--credentials-json",
label: "Service account credentials JSON",
required: true,
help: [
"Create a service account in Google Cloud Console",
"Download the credentials JSON file",
"Paste the JSON content here",
],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
},
],
},
{
id: "linear",
name: "Linear",
type: "webhook",
hint: "React to Linear issues and comments.",
fields: [
{
flag: "--api-key",
label: "API key",
required: true,
help: ["Go to Linear Settings > API > Personal API keys"],
},
{
flag: "--webhook-secret",
label: "Webhook signing secret",
required: true,
help: [
"Go to Settings > API > Webhooks, create one",
"Copy the signing secret",
],
},
{
flag: "--base-url",
label: "Public base URL",
placeholder: "https://example.com",
required: true,
},
],
},
];
export const PLATFORMS = CONNECTOR_PLATFORMS;
export const shouldIncludeField = shouldIncludeConnectorField;
+21 -2
View File
@@ -1,3 +1,5 @@
import { isIP } from "node:net";
export interface ClineHubServerOptions {
host: string;
port: number;
@@ -47,6 +49,9 @@ function normalizePublicUrl(
`PUBLIC_URL must use http: or https:, got ${parsed.protocol}`,
);
}
if (shouldAddDashboardPortToPublicUrl(parsed, port)) {
parsed.port = String(port);
}
parsed.hash = "";
return parsed.toString().replace(/\/$/, "");
}
@@ -85,12 +90,26 @@ export function resolveClineHubServerOptions(
};
}
function isDefaultProtocolPort(url: URL, port: number): boolean {
return (
(url.protocol === "http:" && port === 80) ||
(url.protocol === "https:" && port === 443)
);
}
function shouldAddDashboardPortToPublicUrl(url: URL, port: number): boolean {
if (url.port || isDefaultProtocolPort(url, port)) return false;
const hostname = url.hostname.replace(/^\[|\]$/g, "");
return hostname === "localhost" || isIP(hostname) !== 0;
}
export function buildInviteUrl(
publicUrl: string,
roomSecret: string | undefined,
): string {
if (!roomSecret) return publicUrl;
const url = new URL(publicUrl);
url.searchParams.set("roomSecret", roomSecret);
if (roomSecret) {
url.searchParams.set("roomSecret", roomSecret);
}
return url.toString();
}
+42 -9
View File
@@ -4,6 +4,7 @@ import {
handleToolApprovalResponse,
rejectOrphanedApprovals,
} from "./server/approvals";
import { isAuthorizedBrowserToDesktopRequest } from "./server/browser-auth";
import {
browserConfig,
host,
@@ -14,7 +15,11 @@ import {
webviewDistDir,
} from "./server/deps";
import { handleDesktopCommand } from "./server/desktop-commands";
import { createJsonResponse, WebviewAssets } from "./server/http";
import {
createJsonResponse,
isWebviewRoute,
WebviewAssets,
} from "./server/http";
import {
attachHub,
detachHub,
@@ -53,17 +58,33 @@ export interface ClineHubDashboardServer {
stop: () => Promise<void>;
}
const PUBLIC_BROWSER_PATHS = new Set([
"/version",
"/health",
"/config.json",
"/api/marketplace/catalog",
"/icon.png",
"/icon.svg",
"/icon.ico",
"/32x32.png",
"/cline-logo-filled.svg",
"/favicon.svg",
]);
function isPublicStaticAssetPath(pathname: string): boolean {
return pathname.startsWith("/assets/") || PUBLIC_BROWSER_PATHS.has(pathname);
}
function isPublicBrowserRoute(_req: Request, url: URL): boolean {
return isWebviewRoute(url.pathname) || isPublicStaticAssetPath(url.pathname);
}
export async function startClineHubDashboardServer(): Promise<ClineHubDashboardServer> {
const ctx = new HubContext();
const assets = new WebviewAssets(webviewDistDir);
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
let stopped = false;
function isAuthorizedBrowserRequest(url: URL): boolean {
if (!roomSecret) return true;
return url.searchParams.get("roomSecret") === roomSecret;
}
await attachHub(ctx);
const healthInterval = setInterval(() => {
void (async () => {
@@ -77,6 +98,21 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
hostname: host,
async fetch(req, server) {
const url = new URL(req.url);
if (
!isAuthorizedBrowserToDesktopRequest(
req,
url,
{
bindHost: host,
port,
publicUrl,
roomSecret,
},
isPublicBrowserRoute,
)
) {
return createJsonResponse({ error: "unauthorized_browser" }, 403);
}
if (url.pathname === "/version") {
return createJsonResponse({ coreVersion: CORE_BUILD_VERSION });
}
@@ -85,9 +121,6 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
return createJsonResponse(hubStatusPayload(ctx));
}
if (url.pathname === "/browser") {
if (!isAuthorizedBrowserRequest(url)) {
return createJsonResponse({ error: "invalid_room_secret" }, 401);
}
const displayName = `Browser ${Math.random().toString(36).slice(2, 6)}`;
const data = {
socket: undefined as never,
@@ -0,0 +1,359 @@
import { describe, expect, it } from "vitest";
import {
allowedBrowserHosts,
allowedBrowserOrigins,
isAuthorizedBrowserRequest,
isAuthorizedBrowserToDesktopRequest,
requiresBrowserRequestAuth,
} from "./browser-auth";
const defaultOptions = {
bindHost: "127.0.0.1",
port: 8787,
publicUrl: "http://127.0.0.1:8787",
};
const publicRoute = (_req: Request, url: URL) => url.pathname === "/public";
function browserRequest(
origin?: string,
init?: Omit<RequestInit, "headers"> & {
headers?: Record<string, string>;
},
): Request {
return new Request("http://127.0.0.1:8787/browser", {
...init,
headers: {
host: "127.0.0.1:8787",
...(origin === undefined ? {} : { origin }),
...(init?.headers ?? {}),
},
});
}
describe("allowedBrowserOrigins", () => {
it("allows the configured public URL origin and local aliases for local binds", () => {
expect([...allowedBrowserOrigins(defaultOptions)].sort()).toEqual([
"http://127.0.0.1:8787",
"http://[::1]:8787",
"http://localhost:8787",
]);
});
it("uses the configured public URL scheme for local aliases", () => {
expect(
[
...allowedBrowserOrigins({
...defaultOptions,
publicUrl: "https://127.0.0.1:8787",
}),
].sort(),
).toEqual([
"https://127.0.0.1:8787",
"https://[::1]:8787",
"https://localhost:8787",
]);
});
it("omits default protocol ports for local alias origins", () => {
expect(
[
...allowedBrowserOrigins({
bindHost: "127.0.0.1",
port: 80,
publicUrl: "http://localhost",
}),
].sort(),
).toEqual(["http://127.0.0.1", "http://[::1]", "http://localhost"]);
expect(
[
...allowedBrowserOrigins({
bindHost: "127.0.0.1",
port: 443,
publicUrl: "https://localhost",
}),
].sort(),
).toEqual(["https://127.0.0.1", "https://[::1]", "https://localhost"]);
});
it("allows the configured public URL origin and explicit bind origin for non-local binds", () => {
expect(
[
...allowedBrowserOrigins({
bindHost: "0.0.0.0",
port: 8787,
publicUrl: "https://example.ngrok-free.app",
roomSecret: "secret",
}),
].sort(),
).toEqual(["https://0.0.0.0:8787", "https://example.ngrok-free.app"]);
});
});
describe("allowedBrowserHosts", () => {
it("allows the configured public URL host and local aliases for local binds", () => {
expect([...allowedBrowserHosts(defaultOptions)].sort()).toEqual([
"127.0.0.1:8787",
"[::1]:8787",
"localhost:8787",
]);
});
it("omits default protocol ports for local alias hosts", () => {
expect(
[
...allowedBrowserHosts({
bindHost: "127.0.0.1",
port: 80,
publicUrl: "http://localhost",
}),
].sort(),
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
expect(
[
...allowedBrowserHosts({
bindHost: "127.0.0.1",
port: 443,
publicUrl: "https://localhost",
}),
].sort(),
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
});
it("allows the configured public URL host and explicit bind host for non-local binds", () => {
expect(
[
...allowedBrowserHosts({
bindHost: "0.0.0.0",
port: 8787,
publicUrl: "https://example.ngrok-free.app",
roomSecret: "secret",
}),
].sort(),
).toEqual(["0.0.0.0:8787", "example.ngrok-free.app"]);
});
});
describe("requiresBrowserRequestAuth", () => {
it("does not require browser auth for public GET routes", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/public"),
new URL("http://127.0.0.1:8787/public"),
publicRoute,
),
).toBe(false);
});
it("requires browser auth for unknown paths even when they use GET", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/future-api"),
new URL("http://127.0.0.1:8787/future-api"),
publicRoute,
),
).toBe(true);
});
it("requires browser auth for privileged paths even when they use GET", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/browser"),
new URL("http://127.0.0.1:8787/browser"),
publicRoute,
),
).toBe(true);
});
it("requires browser auth for every WebSocket upgrade path", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/future-socket", {
headers: { upgrade: "websocket" },
}),
new URL("http://127.0.0.1:8787/future-socket"),
publicRoute,
),
).toBe(true);
});
it("requires browser auth for every unsafe HTTP method", () => {
expect(
requiresBrowserRequestAuth(
new Request("http://127.0.0.1:8787/future-api", { method: "POST" }),
new URL("http://127.0.0.1:8787/future-api"),
publicRoute,
),
).toBe(true);
});
});
describe("isAuthorizedBrowserRequest", () => {
it.each([
"http://127.0.0.1:8787",
"http://localhost:8787",
"http://[::1]:8787",
])("accepts local dashboard origin %s without a room secret", (origin) => {
expect(
isAuthorizedBrowserRequest(
browserRequest(origin),
new URL("http://127.0.0.1:8787/browser"),
defaultOptions,
),
).toBe(true);
});
it.each([
undefined,
"",
"null",
"not a url",
"http://evil.attacker.example.com",
"http://127.0.0.1:9999",
"https://127.0.0.1:8787",
])("rejects untrusted origin %s", (origin) => {
expect(
isAuthorizedBrowserRequest(
browserRequest(origin),
new URL("http://127.0.0.1:8787/browser"),
defaultOptions,
),
).toBe(false);
});
it.each([
undefined,
"",
"evil.attacker.example.com",
"127.0.0.1:9999",
"localhost:9999",
])("rejects untrusted host %s", (host) => {
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787", {
headers: host === undefined ? { host: "" } : { host },
}),
new URL("http://127.0.0.1:8787/browser"),
defaultOptions,
),
).toBe(false);
});
it("allows explicit wildcard bind host and origin when a room secret is configured", () => {
expect(
isAuthorizedBrowserRequest(
browserRequest("http://0.0.0.0:8787", {
headers: { host: "0.0.0.0:8787" },
}),
new URL("http://0.0.0.0:8787/browser?roomSecret=invite-123"),
{
bindHost: "0.0.0.0",
port: 8787,
publicUrl: "http://127.0.0.1:8787",
roomSecret: "invite-123",
},
),
).toBe(true);
});
it("requires trusted origin, trusted host, and room secret when a room secret is configured", () => {
const options = { ...defaultOptions, roomSecret: "invite-123" };
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787"),
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
options,
),
).toBe(true);
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787"),
new URL("http://127.0.0.1:8787/browser"),
options,
),
).toBe(false);
expect(
isAuthorizedBrowserRequest(
browserRequest("http://evil.attacker.example.com"),
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
options,
),
).toBe(false);
expect(
isAuthorizedBrowserRequest(
browserRequest("http://127.0.0.1:8787", {
headers: { host: "evil.attacker.example.com" },
}),
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
options,
),
).toBe(false);
});
});
describe("isAuthorizedBrowserToDesktopRequest", () => {
it("allows safe public GET routes without an origin", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/public"),
new URL("http://127.0.0.1:8787/public"),
defaultOptions,
publicRoute,
),
).toBe(true);
});
it("rejects future WebSocket paths from untrusted origins by default", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/future-socket", {
headers: {
host: "127.0.0.1:8787",
origin: "http://evil.attacker.example.com",
upgrade: "websocket",
},
}),
new URL("http://127.0.0.1:8787/future-socket"),
defaultOptions,
publicRoute,
),
).toBe(false);
});
it("rejects future unsafe HTTP routes from untrusted origins by default", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/future-api", {
method: "POST",
headers: {
host: "127.0.0.1:8787",
origin: "http://evil.attacker.example.com",
},
}),
new URL("http://127.0.0.1:8787/future-api"),
defaultOptions,
publicRoute,
),
).toBe(false);
});
it("allows future unsafe HTTP routes from trusted origins", () => {
expect(
isAuthorizedBrowserToDesktopRequest(
new Request("http://127.0.0.1:8787/future-api", {
method: "POST",
headers: {
host: "127.0.0.1:8787",
origin: "http://127.0.0.1:8787",
},
}),
new URL("http://127.0.0.1:8787/future-api"),
defaultOptions,
publicRoute,
),
).toBe(true);
});
});
+134
View File
@@ -0,0 +1,134 @@
import { isNonLocalBindHost } from "../options";
export interface BrowserRequestAuthOptions {
bindHost: string;
port: number;
publicUrl: string;
roomSecret?: string;
}
const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
export type PublicBrowserRoutePredicate = (req: Request, url: URL) => boolean;
function isWebSocketUpgrade(req: Request): boolean {
return req.headers.get("upgrade")?.toLowerCase() === "websocket";
}
function parseOrigin(value: string | null): string | undefined {
const origin = parseHeader(value);
try {
return new URL(origin ?? "").origin;
} catch {
return undefined;
}
}
function parseHeader(value: string | null): string | undefined {
const host = value?.trim().toLowerCase();
return host || undefined;
}
function formatHostForOrigin(host: string): string {
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
}
function isDefaultProtocolPort(protocol: string, port: number): boolean {
return (
(protocol === "http:" && port === 80) ||
(protocol === "https:" && port === 443)
);
}
function originForHost(protocol: string, host: string, port: number): string {
return new URL(`${protocol}//${formatHostForOrigin(host)}:${port}`).origin;
}
function hostHeaderForHost(
protocol: string,
host: string,
port: number,
): string {
const formattedHost = formatHostForOrigin(host).toLowerCase();
return isDefaultProtocolPort(protocol, port)
? formattedHost
: `${formattedHost}:${port}`;
}
export function allowedBrowserOrigins({
bindHost,
port,
publicUrl,
}: BrowserRequestAuthOptions): Set<string> {
const publicUrlParts = new URL(publicUrl);
const origins = new Set<string>();
origins.add(publicUrlParts.origin);
origins.add(originForHost(publicUrlParts.protocol, bindHost, port));
if (!isNonLocalBindHost(bindHost)) {
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
origins.add(originForHost(publicUrlParts.protocol, hostname, port));
}
}
return origins;
}
export function allowedBrowserHosts({
bindHost,
port,
publicUrl,
}: BrowserRequestAuthOptions): Set<string> {
const publicUrlParts = new URL(publicUrl);
const hosts = new Set<string>();
const publicHost = publicUrlParts.host.toLowerCase();
hosts.add(publicHost);
hosts.add(hostHeaderForHost(publicUrlParts.protocol, bindHost, port));
if (!isNonLocalBindHost(bindHost)) {
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
hosts.add(hostHeaderForHost(publicUrlParts.protocol, hostname, port));
}
}
return hosts;
}
export function requiresBrowserRequestAuth(
req: Request,
url: URL,
isPublicBrowserRoute: PublicBrowserRoutePredicate,
): boolean {
if (isWebSocketUpgrade(req)) return true;
if (!SAFE_HTTP_METHODS.has(req.method.toUpperCase())) return true;
return !isPublicBrowserRoute(req, url);
}
export function isAuthorizedBrowserRequest(
req: Request,
url: URL,
options: BrowserRequestAuthOptions,
): boolean {
const host = parseHeader(req.headers.get("host"));
if (!host || !allowedBrowserHosts(options).has(host)) return false;
const origin = parseOrigin(req.headers.get("origin"));
if (!origin || !allowedBrowserOrigins(options).has(origin)) return false;
if (!options.roomSecret) return true;
return url.searchParams.get("roomSecret") === options.roomSecret;
}
export function isAuthorizedBrowserToDesktopRequest(
req: Request,
url: URL,
options: BrowserRequestAuthOptions,
isPublicBrowserRoute: PublicBrowserRoutePredicate,
): boolean {
return (
!requiresBrowserRequestAuth(req, url, isPublicBrowserRoute) ||
isAuthorizedBrowserRequest(req, url, options)
);
}
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import { __test__ } from "./connectors";
describe("connector launch command", () => {
it("uses Bun conditions when launching the source CLI from Bun", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "/Users/test/.bun/bin/bun",
cliPath: "/repo/apps/cli/src/index.ts",
exists: () => true,
}),
).toEqual({
launcher: "/Users/test/.bun/bin/bun",
childArgs: [
"--conditions=development",
"/repo/apps/cli/src/index.ts",
"connect",
"telegram",
"--bot-token",
"token",
],
});
});
it("uses compiled CLI subcommands without Bun flags", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "/Applications/Cline/bin/cline",
cliPath: "/repo/apps/cli/src/index.ts",
exists: () => true,
}),
).toEqual({
launcher: "/Applications/Cline/bin/cline",
childArgs: ["connect", "telegram", "--bot-token", "token"],
});
});
it("uses Bun conditions when launching the source CLI from Node", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "/usr/local/bin/node",
cliPath: "/repo/apps/cli/src/index.ts",
exists: () => true,
}),
).toEqual({
launcher: "bun",
childArgs: [
"--conditions=development",
"/repo/apps/cli/src/index.ts",
"connect",
"telegram",
"--bot-token",
"token",
],
});
});
it("detects Windows Node when launching the source CLI", () => {
expect(
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
execPath: "node.exe",
cliPath: "C:\\repo\\apps\\cli\\src\\index.ts",
exists: () => true,
}),
).toEqual({
launcher: "bun",
childArgs: [
"--conditions=development",
"C:\\repo\\apps\\cli\\src\\index.ts",
"connect",
"telegram",
"--bot-token",
"token",
],
});
});
it("strips terminal color codes from connector command failures", () => {
expect(
__test__.normalizeConnectorError(
"\u001B[31merror:\u001B[0m error: unknown option '--conditions=development'",
"connector start failed",
),
).toBe("unknown option '--conditions=development'");
});
it("turns Telegram unauthorized responses into a token validation message", () => {
expect(
__test__.normalizeConnectorError(
"\u001B[31merror:\u001B[0m Telegram getMe failed (401 Unauthorized): Unauthorized",
"connector start failed",
),
).toBe(
"Telegram rejected this bot token. Copy the token from @BotFather and try again.",
);
});
});
+86 -23
View File
@@ -1,5 +1,8 @@
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { basename } from "node:path";
import process from "node:process";
import { withResolvedClineBuildEnv } from "@cline/shared";
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
import { listActiveConnectors } from "../../../cli/src/connectors/status";
import {
@@ -13,6 +16,68 @@ import type {
import { cliIndexPath, workspaceRoot } from "./deps";
import { asRecord, asString } from "./utils";
type CliConnectCommand = {
launcher: string;
childArgs: string[];
};
const ANSI_ESCAPE_PATTERN = new RegExp(
[
"[\\u001B\\u009B][[\\]()#;?]*",
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
].join(""),
"g",
);
function stripAnsi(value: string): string {
return value.replace(ANSI_ESCAPE_PATTERN, "");
}
function normalizeConnectorError(rawMessage: string, fallback: string): string {
const message =
stripAnsi(rawMessage)
.replace(/\r\n/g, "\n")
.trim()
.replace(/^(?:error:\s*)+/i, "")
.trim() || fallback;
if (
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
) {
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
}
return message.slice(0, 2_000);
}
function buildCliConnectCommand(
args: string[],
options: {
execPath?: string;
cliPath?: string;
exists?: (path: string) => boolean;
} = {},
): CliConnectCommand {
const execPath = options.execPath ?? process.execPath;
const cliPath = options.cliPath ?? cliIndexPath;
const exists = options.exists ?? existsSync;
const runtimeName = basename(execPath).toLowerCase();
const isBunRuntime = runtimeName.includes("bun");
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
const useBunSourceEntrypoint =
(isBunRuntime || isNodeRuntime) && exists(cliPath);
const launcher = isBunRuntime
? execPath
: useBunSourceEntrypoint
? "bun"
: execPath;
const childArgs = useBunSourceEntrypoint
? ["--conditions=development", cliPath, "connect", ...args]
: ["connect", ...args];
return { launcher, childArgs };
}
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
const supported = new Set(
listConnectorCatalog().map((connector) => connector.name),
@@ -55,23 +120,14 @@ async function runCliConnectCommand(args: string[]): Promise<{
stdout: string;
stderr: string;
}> {
const launcher = (process.versions as Record<string, string | undefined>).bun
? process.execPath
: "bun";
const child = spawn(
launcher,
["--conditions=development", cliIndexPath, "connect", ...args],
{
cwd: workspaceRoot,
env: {
...process.env,
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV ?? "development",
},
stdio: ["ignore", "pipe", "pipe"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
},
);
const { launcher, childArgs } = buildCliConnectCommand(args);
const child = spawn(launcher, childArgs, {
cwd: workspaceRoot,
env: withResolvedClineBuildEnv(process.env),
stdio: ["ignore", "pipe", "pipe"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
let stdout = "";
let stderr = "";
child.stdout?.setEncoding("utf8");
@@ -157,9 +213,10 @@ export async function startConnectorChannel(
const result = await runCliConnectCommand(cliArgs);
if (result.code !== 0) {
throw new Error(
(result.stderr.trim() || result.stdout.trim() || "connector start failed")
.trim()
.slice(0, 2_000),
normalizeConnectorError(
result.stderr || result.stdout,
"connector start failed",
),
);
}
await waitForConnectorState(() =>
@@ -168,6 +225,11 @@ export async function startConnectorChannel(
return connectorChannelsPayload();
}
export const __test__ = {
buildCliConnectCommand,
normalizeConnectorError,
};
export async function stopConnectorChannel(
args?: Record<string, unknown>,
): Promise<WebviewConnectorChannelsResponse> {
@@ -182,9 +244,10 @@ export async function stopConnectorChannel(
const result = await runCliConnectCommand([channel, "--stop"]);
if (result.code !== 0) {
throw new Error(
(result.stderr.trim() || result.stdout.trim() || "connector stop failed")
.trim()
.slice(0, 2_000),
normalizeConnectorError(
result.stderr || result.stdout,
"connector stop failed",
),
);
}
await waitForConnectorState(
+112 -28
View File
@@ -51,18 +51,53 @@ describe("marketplace installer", () => {
vi.restoreAllMocks();
});
it("maps remote MCP catalog args to the hub MCP upsert shape", () => {
function createInstalledOfficialPlugin(
clineDir: string,
slug: string,
): string {
const sourceKey = `official:https://github.com/cline/plugins.git#plugins/${slug}`;
const hash = createHash("sha256")
.update(sourceKey)
.digest("hex")
.slice(0, 12);
const installPath = join(
clineDir,
"plugins",
"_installed",
"official",
`${slug}-${hash}`,
);
mkdirSync(join(installPath, "package"), { recursive: true });
writeFileSync(
join(installPath, "package.json"),
JSON.stringify({ name: slug }, null, 2),
"utf8",
);
writeFileSync(
join(installPath, "package", "index.ts"),
`export default { name: "${slug}", manifest: { capabilities: ["tools"] } };`,
"utf8",
);
return installPath;
}
it("maps remote MCP catalog args to MCP settings shape", () => {
expect(
buildMarketplaceMcpInput([
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
"--header",
"Authorization: Bearer <token>",
]),
).toEqual({
name: "context7",
transportType: "streamableHttp",
url: "https://mcp.context7.com/mcp",
headers: {
Authorization: "Bearer <token>",
},
disabled: false,
});
});
@@ -283,8 +318,6 @@ describe("marketplace installer", () => {
"remove",
"cline-sdk",
"-g",
"-a",
"cline",
"-y",
]);
});
@@ -348,7 +381,7 @@ describe("marketplace installer", () => {
const spawnCommand = vi.fn(async () => ({
exitCode: 1,
stdout:
"Authorization: Bearer stdout-token\napi key stdout-key\nOPENAI_API_KEY=compound-key",
"Authorization: Bearer stdout-token\nAuthorization: Basic basic-token\napi key stdout-key\nOPENAI_API_KEY=compound-key",
stderr:
"TOKEN=stderr-token\npassword is stderr-password\nANTHROPIC_SECRET_KEY=anthropic-secret",
}));
@@ -370,13 +403,16 @@ describe("marketplace installer", () => {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toContain("Authorization: Bearer [redacted]");
expect(message).toContain("Authorization: [redacted]");
expect(message).not.toContain("Authorization: Bearer [redacted]]");
expect(message).toContain("api key [redacted]");
expect(message).toContain("OPENAI_API_KEY=[redacted]");
expect(message).toContain("TOKEN=[redacted]");
expect(message).toContain("password is [redacted]");
expect(message).toContain("ANTHROPIC_SECRET_KEY=[redacted]");
expect(message).not.toContain("stdout-token");
expect(message).not.toContain("basic-token");
expect(message).not.toContain("stdout-key");
expect(message).not.toContain("compound-key");
expect(message).not.toContain("stderr-token");
@@ -465,19 +501,78 @@ describe("marketplace installer", () => {
]);
});
it("runs official plugin uninstalls through the current Cline CLI", async () => {
it("runs MCP installs through the current Cline CLI without prompts", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
name: "context7",
status: "installed",
transport: {
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
headers: {
Authorization: "Bearer token",
},
},
}),
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "context7",
type: "mcp",
name: "Context7",
install: {
args: [
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
"--header",
"Authorization: Bearer token",
],
},
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "installed",
message: "Installed Context7.",
details: {
name: "context7",
status: "installed",
},
});
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"mcp",
"install",
"--yes",
"--json",
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
"--header",
"Authorization: Bearer token",
]);
});
it("uninstalls official marketplace plugins through the shared core service", async () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-plugin-"));
process.env.CLINE_DIR = clineDir;
const installPath = createInstalledOfficialPlugin(clineDir, "goal");
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
await expect(
uninstallMarketplaceEntry(
{
@@ -495,12 +590,8 @@ describe("marketplace installer", () => {
message: "Uninstalled Goal.",
});
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
expect(spawnCommand).not.toHaveBeenCalled();
expect(existsSync(installPath)).toBe(false);
});
it("resolves desktop installs from the server catalog instead of browser-sent args", async () => {
@@ -544,15 +635,12 @@ describe("marketplace installer", () => {
});
it("resolves desktop uninstalls from the server catalog instead of browser-sent args", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-plugin-"));
process.env.CLINE_DIR = clineDir;
const installPath = createInstalledOfficialPlugin(clineDir, "goal");
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stdout: "",
stderr: "",
}));
@@ -580,12 +668,8 @@ describe("marketplace installer", () => {
},
);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
expect(spawnCommand).not.toHaveBeenCalled();
expect(existsSync(installPath)).toBe(false);
});
it("uninstalls MCP marketplace entries from Cline MCP settings", async () => {
+97 -120
View File
@@ -8,7 +8,7 @@ import {
unlinkSync,
writeFileSync,
} from "node:fs";
import { homedir, platform } from "node:os";
import { homedir as osHomedir, platform } from "node:os";
import {
basename,
dirname,
@@ -18,16 +18,15 @@ import {
resolve,
} from "node:path";
import {
type MarketplaceActionResult,
type MarketplaceEntryInput,
resolveSkillsConfigSearchPaths,
resolveWorkflowsConfigSearchPaths,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
uninstallPlugin as uninstallLocalPlugin,
} from "@cline/core";
import { resolveClineDir } from "@cline/shared/storage";
import {
deleteMcpServer,
readMcpServersResponse,
upsertMcpServer,
} from "./mcp";
import { deleteMcpServer, readMcpServersResponse } from "./mcp";
import type { JsonRecord } from "./types";
type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
@@ -90,6 +89,12 @@ const MARKETPLACE_CATALOG_URL =
"https://cline.github.io/marketplace/catalog.json";
const SECRET_PATTERN =
/(api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)/i;
const SECRET_KEY_VALUE_PATTERN =
/((?:^|[^\w])(?:[a-z0-9_]*?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|credential)[a-z0-9_]*)\s*[:=]\s*)(.+)$/gi;
const SECRET_BEARER_VALUE_PATTERN =
/((?:^|[^\w])authorization\s*[:=]\s*)bearer\s+([^\s,"'}\]]+)/gi;
const SECRET_AUTHORIZATION_VALUE_PATTERN =
/((?:^|[^\w])authorization\s*[:=])(?!\s*bearer\b)\s*(.+)$/gi;
export async function fetchMarketplaceCatalog(
fetchImpl: CatalogFetch = fetch,
@@ -270,11 +275,10 @@ function redactOutput(value: string): string {
const lines = value.split(/\r?\n/).map((line) => {
if (!SECRET_PATTERN.test(line)) return line;
return line
.replace(
/((?:^|[^\w])(?:[a-z0-9_]*?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)[a-z0-9_]*)\s*[:=]\s*)(.+)$/gi,
"$1[redacted]",
)
.replace(/\b(Bearer)\s+\S+/gi, "$1 [redacted]")
.replace(SECRET_KEY_VALUE_PATTERN, "$1[redacted]")
.replace(SECRET_BEARER_VALUE_PATTERN, "$1Bearer [redacted]")
.replace(/\b(Bearer)\s+(?!\[redacted\])([^\s,"'}\]]+)/gi, "$1 [redacted]")
.replace(SECRET_AUTHORIZATION_VALUE_PATTERN, "$1 [redacted]")
.replace(
/((?:^|[^\w])(?:api\s+key|access\s+token|refresh\s+token|auth(?:orization)?\s+token|secret|password|credential)\s+(?:is\s+)?)(\S+)/gi,
"$1[redacted]",
@@ -374,6 +378,7 @@ export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
throw new Error("MCP marketplace install requires a server name");
}
let transportType = "stdio";
const headers: Record<string, string> = {};
const targetArgs: string[] = [];
let parsingMarketplaceOptions = true;
for (let index = 0; index < rest.length; index++) {
@@ -389,11 +394,40 @@ export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
index++;
continue;
}
const shouldParseHeader =
parsingMarketplaceOptions ||
normalizeTransport(transportType) !== "stdio";
if (
shouldParseHeader &&
(arg === "--header" || arg?.startsWith("--header="))
) {
const rawHeader =
arg === "--header" ? rest[++index] : arg.slice("--header=".length);
if (!rawHeader) throw new Error("--header requires a value");
const separatorIndex = rawHeader.indexOf(":");
if (separatorIndex <= 0) {
throw new Error(
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
);
}
const headerName = rawHeader.slice(0, separatorIndex).trim();
const headerValue = rawHeader.slice(separatorIndex + 1).trim();
if (!headerName || !headerValue) {
throw new Error(
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
);
}
headers[headerName] = headerValue;
continue;
}
parsingMarketplaceOptions = false;
targetArgs.push(arg);
}
transportType = normalizeTransport(transportType);
if (transportType === "stdio") {
if (Object.keys(headers).length > 0) {
throw new Error("Stdio MCP installs do not support request headers.");
}
const [command, ...commandArgs] = targetArgs;
if (!command?.trim()) {
throw new Error("Stdio MCP install requires a command");
@@ -415,6 +449,7 @@ export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
name,
transportType,
url,
headers: Object.keys(headers).length > 0 ? headers : undefined,
disabled: false,
};
}
@@ -570,6 +605,12 @@ function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
return Boolean(installPath && existsSync(installPath));
}
function resolveHomeDir(): string {
return (
process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || osHomedir()
);
}
function normalizeMatchValue(value: string | undefined): string {
return (value ?? "")
.toLowerCase()
@@ -606,12 +647,12 @@ function getSkillInstallCandidates(entry: MarketplaceInstallInput): string[] {
function getGlobalSkillPaths(skillName: string): string[] {
return [
join(resolveClineDir(), "skills", skillName, "SKILL.md"),
join(homedir(), ".agents", "skills", skillName, "SKILL.md"),
join(resolveHomeDir(), ".agents", "skills", skillName, "SKILL.md"),
].filter((path, index, paths) => paths.indexOf(path) === index);
}
function ensureGlobalSkillsDirWritable(): void {
const skillsDir = join(homedir(), ".agents", "skills");
const skillsDir = join(resolveHomeDir(), ".agents", "skills");
try {
mkdirSync(skillsDir, { recursive: true });
const probePath = join(
@@ -754,50 +795,6 @@ async function installSkill(
};
}
async function uninstallSkill(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installedName = findInstalledGlobalSkillName(entry);
if (!installedName) {
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `${entry.name ?? entry.id} is not installed.`,
};
}
const result = await spawnCommand("npx", [
"-y",
"skills@latest",
"remove",
installedName,
"-g",
"-a",
"cline",
"-y",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Skill uninstall failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
const output = commandOutput(result);
if (isGlobalSkillInstalled(entry)) {
throw new Error(
`Skill uninstall completed, but ${entry.name ?? entry.id} is still present in Cline's global skills directories.`,
);
}
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? entry.id}.`,
output,
};
}
async function installPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
@@ -848,47 +845,6 @@ async function installPlugin(
};
}
async function uninstallPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installArgs = entry.install.args ?? [];
const target = installArgs[0]?.trim() || entry.id;
if (!target) {
throw new Error("Plugin marketplace uninstalls require a plugin name.");
}
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"plugin",
"uninstall",
target,
"--json",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Plugin uninstall failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
};
}
export async function installMarketplaceEntry(
args?: Record<string, unknown>,
options: { spawnCommand?: SpawnCommand } = {},
@@ -896,14 +852,38 @@ export async function installMarketplaceEntry(
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
if (entry.type === "mcp") {
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
const response = upsertMcpServer(input);
// Validate marketplace args before handing them to the CLI-backed installer.
buildMarketplaceMcpInput(entry.install.args ?? []);
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"mcp",
"install",
"--yes",
"--json",
...(entry.install.args ?? []),
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`MCP install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
return {
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name ?? input.name ?? entry.id}.`,
details: { mcp: response },
message: `Installed ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
};
}
if (entry.type === "skill") {
@@ -921,24 +901,21 @@ export async function uninstallMarketplaceEntry(
): Promise<MarketplaceInstallResult> {
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
if (entry.type === "mcp") {
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
const response = deleteMcpServer(String(input.name ?? ""));
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? input.name ?? entry.id}.`,
details: { mcp: response },
};
}
if (entry.type === "skill") {
return uninstallSkill(entry, spawnCommand);
}
if (entry.type === "plugin") {
return uninstallPlugin(entry, spawnCommand);
}
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
let mcpDetails: JsonRecord | undefined;
const result = await uninstallCoreMarketplaceEntry(
entry satisfies MarketplaceEntryInput,
{
deleteMcpServer: (name) => {
mcpDetails = deleteMcpServer(name);
},
spawnCommand: (command, commandArgs) =>
spawnCommand(command, commandArgs),
},
);
return {
...(result satisfies MarketplaceActionResult),
details: mcpDetails ? { mcp: mcpDetails } : undefined,
};
}
export async function installMarketplaceEntryFromCatalog(
+17
View File
@@ -41,6 +41,23 @@ expectEqual(
"invite URL",
);
const tailscale = resolveClineHubServerOptions({
HOST: "0.0.0.0",
CLINE_HUB_DASHBOARD_PORT: "8787",
PUBLIC_URL: "http://100.82.5.118",
ROOM_SECRET: "invite-123",
});
expectEqual(
tailscale.publicUrl,
"http://100.82.5.118:8787",
"direct IP public URL gets dashboard port",
);
expectEqual(
buildInviteUrl(tailscale.publicUrl, tailscale.roomSecret),
"http://100.82.5.118:8787/?roomSecret=invite-123",
"invite URL for direct IP public URL",
);
expectThrows(
() => resolveClineHubServerOptions({ HOST: "0.0.0.0" }),
"non-local bind without ROOM_SECRET",
+3 -3
View File
@@ -13,10 +13,9 @@
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
"@shikijs/langs": "^4.2.0",
"@shikijs/themes": "^4.2.0",
"@streamdown/cjk": "^1.0.3",
"@streamdown/code": "^1.1.1",
"@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2",
"@tailwindcss/vite": "^4.2.1",
"@xyflow/react": "^12.10.1",
"ai": "^6.0.116",
@@ -27,6 +26,7 @@
"embla-carousel-react": "^8.6.0",
"lucide-react": "^0.577.0",
"media-chrome": "^4.18.1",
"mermaid": "^11.15.0",
"motion": "^12.38.0",
"nanoid": "^5.1.7",
"next-themes": "^0.4.6",
+31 -11
View File
@@ -22,7 +22,14 @@ import {
WrenchIcon,
} from "lucide-react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import {
AlertDialog,
AlertDialogAction,
@@ -53,19 +60,24 @@ import type {
WebviewOutboundMessage,
WebviewSessionSummary,
} from "../../webview-protocol";
import Chat from "./Chat";
import { PageFrame, PageHeader } from "./components/views/page-layout";
import {
type CustomizationSection,
CustomizationSectionView,
} from "./components/views/settings/extensions-view";
import {
type SettingsSection,
SettingsView,
} from "./components/views/settings/settings-view";
import type { CustomizationSection } from "./components/views/settings/extensions-view";
import type { SettingsSection } from "./components/views/settings/settings-view";
import { syncHubTheme } from "./lib/theme";
import { postToHost } from "./vscode";
const Chat = lazy(() => import("./Chat"));
const SettingsView = lazy(() =>
import("./components/views/settings/settings-view").then((module) => ({
default: module.SettingsView,
})),
);
const CustomizationSectionView = lazy(() =>
import("./components/views/settings/extensions-view").then((module) => ({
default: module.CustomizationSectionView,
})),
);
type View =
| "home"
| "sessions"
@@ -238,6 +250,14 @@ function currentPathWithSearch(): string {
return `${window.location.pathname}${window.location.search}`;
}
function ViewLoading() {
return (
<PageFrame>
<p className="text-sm text-muted-foreground">Loading...</p>
</PageFrame>
);
}
function formatRelativeTime(timestamp?: number): string {
if (!timestamp) return "unknown";
const elapsed = Math.max(0, Date.now() - timestamp);
@@ -1333,7 +1353,7 @@ function App() {
return (
<Shell onNavigate={navigate} version={hubState.coreVersion} view={view}>
{content}
<Suspense fallback={<ViewLoading />}>{content}</Suspense>
</Shell>
);
}
@@ -11,12 +11,13 @@ import {
useState,
} from "react";
import type {
BundledLanguage,
BundledTheme,
HighlighterGeneric,
HighlighterCore,
LanguageRegistration,
ThemedToken,
} from "shiki";
import { createHighlighter } from "shiki";
ThemeRegistration,
} from "shiki/core";
import { createHighlighterCore } from "shiki/core";
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
import { Button } from "@/components/ui/button";
import {
Select,
@@ -36,6 +37,78 @@ const isUnderline = (fontStyle: number | undefined) =>
// oxlint-disable-next-line eslint(no-bitwise)
fontStyle && fontStyle & 4;
const SUPPORTED_LANGUAGES = [
"bash",
"css",
"diff",
"html",
"javascript",
"json",
"jsonc",
"jsx",
"markdown",
"python",
"shellscript",
"tsx",
"typescript",
"yaml",
] as const;
export type SupportedCodeLanguage = (typeof SUPPORTED_LANGUAGES)[number];
const SUPPORTED_LANGUAGE_SET = new Set<string>(SUPPORTED_LANGUAGES);
const LANGUAGE_LOADERS: Record<
SupportedCodeLanguage,
() => Promise<LanguageRegistration[]>
> = {
bash: () => import("@shikijs/langs/bash").then((module) => module.default),
css: () => import("@shikijs/langs/css").then((module) => module.default),
diff: () => import("@shikijs/langs/diff").then((module) => module.default),
html: () => import("@shikijs/langs/html").then((module) => module.default),
javascript: () =>
import("@shikijs/langs/javascript").then((module) => module.default),
json: () => import("@shikijs/langs/json").then((module) => module.default),
jsonc: () => import("@shikijs/langs/jsonc").then((module) => module.default),
jsx: () => import("@shikijs/langs/jsx").then((module) => module.default),
markdown: () =>
import("@shikijs/langs/markdown").then((module) => module.default),
python: () =>
import("@shikijs/langs/python").then((module) => module.default),
shellscript: () =>
import("@shikijs/langs/shellscript").then((module) => module.default),
tsx: () => import("@shikijs/langs/tsx").then((module) => module.default),
typescript: () =>
import("@shikijs/langs/typescript").then((module) => module.default),
yaml: () => import("@shikijs/langs/yaml").then((module) => module.default),
};
const LANGUAGE_ALIASES: Record<string, SupportedCodeLanguage> = {
console: "shellscript",
cjs: "javascript",
htm: "html",
js: "javascript",
json5: "jsonc",
md: "markdown",
mjs: "javascript",
py: "python",
sh: "shellscript",
shell: "shellscript",
ts: "typescript",
yml: "yaml",
};
const normalizeLanguage = (
language: string,
): SupportedCodeLanguage | "text" => {
const normalized = language.trim().toLowerCase();
if (!normalized) {
return "text";
}
const aliased = LANGUAGE_ALIASES[normalized] ?? normalized;
return SUPPORTED_LANGUAGE_SET.has(aliased) ? aliased : "text";
};
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
interface KeyedToken {
token: ThemedToken;
@@ -108,7 +181,7 @@ const LineSpan = ({
// Types
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
code: string;
language: BundledLanguage;
language: string;
showLineNumbers?: boolean;
};
@@ -128,10 +201,9 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
});
// Highlighter cache (singleton per language)
const highlighterCache = new Map<
string,
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
>();
let highlighterPromise: Promise<HighlighterCore> | undefined;
let themesPromise: Promise<void> | undefined;
const languagePromises = new Map<SupportedCodeLanguage, Promise<void>>();
// Token cache
const tokensCache = new Map<string, TokenizedCode>();
@@ -139,27 +211,44 @@ const tokensCache = new Map<string, TokenizedCode>();
// Subscribers for async token updates
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
const getTokensCacheKey = (code: string, language: string) => {
const start = code.slice(0, 100);
const end = code.length > 100 ? code.slice(-100) : "";
return `${language}:${code.length}:${start}:${end}`;
};
const getHighlighter = (
language: BundledLanguage,
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
const cached = highlighterCache.get(language);
const getHighlighter = (): Promise<HighlighterCore> => {
if (!highlighterPromise) {
highlighterPromise = createHighlighterCore({
engine: createJavaScriptRegexEngine({ forgiving: true }),
});
}
return highlighterPromise;
};
const ensureThemes = (highlighter: HighlighterCore): Promise<void> => {
if (!themesPromise) {
themesPromise = Promise.all([
import("@shikijs/themes/github-light").then((module) => module.default),
import("@shikijs/themes/github-dark").then((module) => module.default),
]).then((themes: ThemeRegistration[]) => highlighter.loadTheme(...themes));
}
return themesPromise;
};
const ensureLanguage = (
highlighter: HighlighterCore,
language: SupportedCodeLanguage,
): Promise<void> => {
const cached = languagePromises.get(language);
if (cached) {
return cached;
}
const highlighterPromise = createHighlighter({
langs: [language],
themes: ["github-light", "github-dark"],
});
highlighterCache.set(language, highlighterPromise);
return highlighterPromise;
const languagePromise = LANGUAGE_LOADERS[language]().then((registrations) =>
highlighter.loadLanguage(...registrations),
);
languagePromises.set(language, languagePromise);
return languagePromise;
};
// Create raw tokens for immediate display while highlighting loads
@@ -181,11 +270,16 @@ const createRawTokens = (code: string): TokenizedCode => ({
// Synchronous highlight with callback for async results
export const highlightCode = (
code: string,
language: BundledLanguage,
language: string,
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
callback?: (result: TokenizedCode) => void,
): TokenizedCode | null => {
const tokensCacheKey = getTokensCacheKey(code, language);
const langToUse = normalizeLanguage(language);
if (langToUse === "text") {
return createRawTokens(code);
}
const tokensCacheKey = getTokensCacheKey(code, langToUse);
// Return cached result if available
const cached = tokensCache.get(tokensCacheKey);
@@ -202,11 +296,11 @@ export const highlightCode = (
}
// Start highlighting in background - fire-and-forget async pattern
getHighlighter(language)
getHighlighter()
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
.then((highlighter) => {
const availableLangs = highlighter.getLoadedLanguages();
const langToUse = availableLangs.includes(language) ? language : "text";
.then(async (highlighter) => {
await ensureThemes(highlighter);
await ensureLanguage(highlighter, langToUse);
const result = highlighter.codeToTokens(code, {
lang: langToUse,
@@ -376,7 +470,7 @@ export const CodeBlockContent = ({
showLineNumbers = false,
}: {
code: string;
language: BundledLanguage;
language: string;
showLineNumbers?: boolean;
}) => {
// Memoized raw tokens for immediate display
@@ -1,9 +1,5 @@
"use client";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
@@ -16,7 +12,6 @@ import {
useMemo,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import {
@@ -26,6 +21,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { HubStreamdown } from "./streamdown";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
@@ -316,24 +312,18 @@ export const MessageBranchPage = ({
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
const streamdownPlugins = { cjk, code, math, mermaid };
export type MessageResponseProps = ComponentProps<typeof HubStreamdown>;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
<HubStreamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className,
)}
plugins={streamdownPlugins}
{...props}
/>
),
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
nextProps.isAnimating === prevProps.isAnimating,
);
MessageResponse.displayName = "MessageResponse";
@@ -1,10 +1,6 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
@@ -17,7 +13,6 @@ import {
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
import {
Collapsible,
CollapsibleContent,
@@ -26,6 +21,7 @@ import {
import { cn } from "@/lib/utils";
import { Shimmer } from "./shimmer";
import { HubStreamdown } from "./streamdown";
interface ReasoningContextValue {
isStreaming: boolean;
@@ -204,8 +200,6 @@ export type ReasoningContentProps = ComponentProps<
children: string;
};
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
@@ -216,7 +210,7 @@ export const ReasoningContent = memo(
)}
{...props}
>
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
<HubStreamdown>{children}</HubStreamdown>
</CollapsibleContent>
),
);
@@ -0,0 +1,170 @@
import { cjk } from "@streamdown/cjk";
import type { MermaidConfig } from "mermaid";
import type { ComponentProps, ReactNode } from "react";
import { isValidElement, memo } from "react";
import {
type Components,
type DiagramPlugin,
Streamdown,
type StreamdownProps,
} from "streamdown";
import {
CodeBlock,
CodeBlockActions,
CodeBlockCopyButton,
CodeBlockFilename,
CodeBlockHeader,
CodeBlockTitle,
} from "@/components/ai-elements/code-block";
import { cn } from "@/lib/utils";
type MarkdownCodeProps = ComponentProps<"code"> & {
"data-block"?: boolean | string;
node?: {
properties?: {
metastring?: string;
};
};
};
const LANGUAGE_CLASS_PATTERN = /(?:^|\s)language-([^\s]+)/;
const START_LINE_PATTERN = /startLine=(\d+)/;
const NO_LINE_NUMBERS_PATTERN = /\bnoLineNumbers\b/;
function codeText(children: ReactNode): string {
if (typeof children === "string" || typeof children === "number") {
return String(children);
}
if (Array.isArray(children)) {
return children.map(codeText).join("");
}
if (isValidElement<{ children?: ReactNode }>(children)) {
return codeText(children.props.children);
}
return "";
}
const MarkdownCode = ({
children,
className,
node,
"data-block": dataBlock,
...props
}: MarkdownCodeProps) => {
const language = className?.match(LANGUAGE_CLASS_PATTERN)?.[1] ?? "text";
if (!dataBlock) {
return (
<code
className={cn(
"rounded bg-muted px-1.5 py-0.5 font-mono text-sm",
className,
)}
{...props}
>
{children}
</code>
);
}
const meta = node?.properties?.metastring;
const startLineMatch = meta?.match(START_LINE_PATTERN);
const startLine = startLineMatch ? Number.parseInt(startLineMatch[1], 10) : 1;
const showLineNumbers = meta ? !NO_LINE_NUMBERS_PATTERN.test(meta) : true;
return (
<CodeBlock
code={codeText(children)}
data-start-line={startLine > 1 ? startLine : undefined}
language={language}
showLineNumbers={showLineNumbers}
>
<CodeBlockHeader>
<CodeBlockTitle>
<CodeBlockFilename>{language}</CodeBlockFilename>
</CodeBlockTitle>
<CodeBlockActions>
<CodeBlockCopyButton />
</CodeBlockActions>
</CodeBlockHeader>
</CodeBlock>
);
};
const markdownComponents = {
code: MarkdownCode,
} satisfies Components;
const DEFAULT_MERMAID_CONFIG = {
fontFamily: "monospace",
securityLevel: "strict",
startOnLoad: false,
suppressErrorRendering: true,
theme: "default",
} satisfies MermaidConfig;
interface LazyMermaidInstance {
initialize: (config: MermaidConfig) => void;
render: (
id: string,
source: string,
) => Promise<{
svg: string;
}>;
}
function createLazyMermaidPlugin(): DiagramPlugin {
let config: MermaidConfig = DEFAULT_MERMAID_CONFIG;
let initialized = false;
const instance: LazyMermaidInstance = {
initialize(nextConfig: MermaidConfig) {
config = { ...DEFAULT_MERMAID_CONFIG, ...config, ...nextConfig };
initialized = false;
},
async render(id: string, source: string) {
const mermaidModule = await import("mermaid");
const mermaid = mermaidModule.default;
if (!initialized) {
mermaid.initialize(config);
initialized = true;
}
return mermaid.render(id, source);
},
};
return {
getMermaid(nextConfig?: MermaidConfig) {
if (nextConfig) {
instance.initialize(nextConfig);
}
return instance;
},
language: "mermaid",
name: "mermaid",
type: "diagram",
};
}
const streamdownPlugins = { cjk, mermaid: createLazyMermaidPlugin() };
export type HubStreamdownProps = StreamdownProps;
export const HubStreamdown = memo(
({ className, components, ...props }: HubStreamdownProps) => {
const mergedComponents = components
? { ...markdownComponents, ...components }
: markdownComponents;
return (
<Streamdown
className={className}
components={mergedComponents}
plugins={streamdownPlugins}
{...props}
/>
);
},
);
HubStreamdown.displayName = "HubStreamdown";
@@ -3,6 +3,7 @@ import {
Puzzle,
Search,
Server,
Star,
Trash2,
Zap,
} from "lucide-react";
@@ -26,6 +27,7 @@ import {
type MarketplacePrimitiveType,
type MarketplaceTag,
} from "@/lib/marketplace";
import { cn } from "@/lib/utils";
import { CommandBadge, PageFrame, PageHeader } from "./page-layout";
type EntryActionState =
@@ -114,6 +116,13 @@ function entryKey(entry: Pick<MarketplaceEntry, "id" | "type">): string {
return `${entry.type}:${entry.id}`;
}
function compareFeaturedEntries(
left: MarketplaceEntry,
right: MarketplaceEntry,
): number {
return Number(Boolean(right.featured)) - Number(Boolean(left.featured));
}
function normalizeMatchValue(value: string): string {
return value.trim().toLowerCase();
}
@@ -257,6 +266,8 @@ function MarketplaceEntryCard({
onToggleExpanded,
onUninstall,
matchedLocalItems = [],
showFeatured = true,
showTags = true,
sourceLabel,
tagLabels,
}: {
@@ -269,6 +280,8 @@ function MarketplaceEntryCard({
onToggleExpanded: (entry: MarketplaceEntry) => void;
onUninstall: (entry: MarketplaceEntry) => void;
matchedLocalItems?: MarketplaceLocalInstalledItem[];
showFeatured?: boolean;
showTags?: boolean;
sourceLabel?: string;
tagLabels: Map<string, string>;
}) {
@@ -299,15 +312,63 @@ function MarketplaceEntryCard({
: installed
? "Uninstall"
: "Install";
const statusMessage = inlineMessage ? (
<output
className={cn(
"text-xs",
actionState?.status === "failed"
? "text-destructive"
: "text-muted-foreground",
)}
>
{inlineMessage}
</output>
) : setupNeeded ? (
<span className="text-xs text-amber-700 dark:text-amber-300">
Requires setup after install
</span>
) : null;
const actionButton = (
<Button
disabled={!installedStatusReady || busy}
onClick={handleActionClick}
size="sm"
type="button"
variant={installed ? "destructive" : "default"}
>
{busy || !installedStatusReady ? <Spinner /> : null}
{installed && !busy ? <Trash2 className="size-4" /> : null}
{actionLabel}
</Button>
);
const content = (
<>
{!installed ? (
<div
className="absolute top-4 right-4"
data-marketplace-entry-interactive
>
{actionButton}
</div>
) : null}
<div className="min-w-0">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-3">
<div
className={cn(
"flex min-w-0 flex-1 items-center gap-2",
!installed && "pr-24",
)}
>
<EntryIcon className="h-4 w-4 shrink-0 text-primary" />
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
<h2 className="min-w-0 truncate text-sm font-semibold text-foreground">
{entry.name}
</h2>
{showFeatured && entry.featured ? (
<Badge className="border border-violet-500/20 bg-violet-500/10 text-violet-700 dark:text-violet-300">
<Star className="fill-current" />
Featured
</Badge>
) : null}
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{sourceLabel ? (
@@ -349,23 +410,34 @@ function MarketplaceEntryCard({
) : null}
</div>
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{entry.description}
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-stretch sm:justify-between">
<div className="grid min-w-0 flex-1 gap-2">
{showTags && entry.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{entry.tags.slice(0, 5).map((tag) => (
<Badge
key={tag}
variant="outline"
className="max-w-full text-muted-foreground"
>
<span className="truncate">{tagLabels.get(tag) ?? tag}</span>
</Badge>
))}
</div>
) : null}
{entry.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{entry.tags.slice(0, 5).map((tag) => (
<Badge
key={tag}
variant="outline"
className="max-w-full text-muted-foreground"
>
<span className="truncate">{tagLabels.get(tag) ?? tag}</span>
</Badge>
))}
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{entry.description}
</p>
{statusMessage}
</div>
) : null}
{installed ? (
<div className="flex shrink-0 flex-col items-start gap-1 sm:items-end sm:justify-end">
{actionButton}
</div>
) : null}
</div>
{matchedLocalItems.some((item) => item.renderMatchedDetails) ? (
<div className="grid gap-2" data-marketplace-entry-details>
@@ -377,36 +449,6 @@ function MarketplaceEntryCard({
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-h-5 text-xs text-muted-foreground">
{inlineMessage ? (
<output
className={
actionState?.status === "failed"
? "text-destructive"
: "text-muted-foreground"
}
>
{inlineMessage}
</output>
) : setupNeeded ? (
<span className="text-amber-700 dark:text-amber-300">
Requires setup after install
</span>
) : null}
</div>
<Button
disabled={!installedStatusReady || busy}
onClick={handleActionClick}
type="button"
variant={installed ? "destructive" : "default"}
>
{busy || !installedStatusReady ? <Spinner /> : null}
{installed && !busy ? <Trash2 className="size-4" /> : null}
{actionLabel}
</Button>
</div>
{expanded && hasExpandableDetails ? (
<EntryDetails actionState={actionState} entry={entry} />
) : null}
@@ -415,7 +457,7 @@ function MarketplaceEntryCard({
if (!hasExpandableDetails) {
return (
<div className="grid gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
<div className="relative grid gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
{content}
</div>
);
@@ -426,7 +468,7 @@ function MarketplaceEntryCard({
<div
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
className="grid cursor-pointer gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
className="relative grid cursor-pointer gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
onClick={(event) => {
if (
event.target instanceof HTMLElement &&
@@ -487,6 +529,7 @@ function MarketplaceSection({
emptyMessage,
entries,
expandedEntryKey,
headerContent,
installedEntryKeys,
installedStatusReady,
localOnlyInstalledItems = [],
@@ -494,6 +537,8 @@ function MarketplaceSection({
onInstall,
onToggleExpanded,
onUninstall,
showFeaturedBadges = true,
showEntryTags = true,
sourceLabel,
tagLabels,
title,
@@ -502,6 +547,7 @@ function MarketplaceSection({
emptyMessage: string;
entries: MarketplaceEntry[];
expandedEntryKey: string | null;
headerContent?: ReactNode;
installedEntryKeys: Set<string>;
installedStatusReady: boolean;
localOnlyInstalledItems?: MarketplaceLocalInstalledItem[];
@@ -509,6 +555,8 @@ function MarketplaceSection({
onInstall: (entry: MarketplaceEntry) => void;
onToggleExpanded: (entry: MarketplaceEntry) => void;
onUninstall: (entry: MarketplaceEntry) => void;
showFeaturedBadges?: boolean;
showEntryTags?: boolean;
sourceLabel?: string;
tagLabels: Map<string, string>;
title: string;
@@ -520,6 +568,7 @@ function MarketplaceSection({
<h2 className="text-base font-semibold text-foreground">{title}</h2>
<span className="text-sm text-muted-foreground">{totalCount}</span>
</div>
{headerContent}
{totalCount > 0 ? (
<div className="grid gap-3">
{localOnlyInstalledItems.map((item) => item.render())}
@@ -537,6 +586,8 @@ function MarketplaceSection({
onToggleExpanded={onToggleExpanded}
onUninstall={onUninstall}
matchedLocalItems={matchedLocalItemsByEntryKey?.get(key) ?? []}
showFeatured={showFeaturedBadges}
showTags={showEntryTags}
sourceLabel={sourceLabel}
tagLabels={tagLabels}
/>
@@ -626,19 +677,48 @@ export function MarketplaceView({
);
const primitiveEntries = useMemo(
() => catalog?.entries.filter((entry) => entry.type === primitive) ?? [],
() =>
(catalog?.entries.filter((entry) => entry.type === primitive) ?? []).sort(
compareFeaturedEntries,
),
[catalog?.entries, primitive],
);
const queryFilteredEntries = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return primitiveEntries.filter((entry) => {
return (
normalizedQuery.length === 0 ||
entrySearchText(entry, tagLabels).includes(normalizedQuery)
);
});
}, [primitiveEntries, query, tagLabels]);
const installedEntries = useMemo(
() =>
queryFilteredEntries.filter((entry) =>
installedEntryKeys.has(entryKey(entry)),
),
[queryFilteredEntries, installedEntryKeys],
);
const marketplaceEntriesBeforeTag = useMemo(
() =>
queryFilteredEntries.filter(
(entry) => !installedEntryKeys.has(entryKey(entry)),
),
[queryFilteredEntries, installedEntryKeys],
);
const tagCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const entry of primitiveEntries) {
for (const entry of marketplaceEntriesBeforeTag) {
for (const tag of entry.tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
return counts;
}, [primitiveEntries]);
}, [marketplaceEntriesBeforeTag]);
const primitiveTags = useMemo(
() =>
@@ -646,26 +726,20 @@ export function MarketplaceView({
[catalog?.tags, tagCounts],
);
const filteredEntries = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return primitiveEntries.filter((entry) => {
const matchesTag = !selectedTag || entry.tags.includes(selectedTag);
const matchesQuery =
normalizedQuery.length === 0 ||
entrySearchText(entry, tagLabels).includes(normalizedQuery);
return matchesTag && matchesQuery;
});
}, [primitiveEntries, query, selectedTag, tagLabels]);
const catalogEntries = useMemo(
() =>
marketplaceEntriesBeforeTag.filter(
(entry) => !selectedTag || entry.tags.includes(selectedTag),
),
[marketplaceEntriesBeforeTag, selectedTag],
);
const matchedLocalItemsByEntryKey = useMemo(() => {
const matched = new Map<string, MarketplaceLocalInstalledItem[]>();
for (const item of installedItems ?? []) {
for (const entry of filteredEntries) {
for (const entry of installedEntries) {
const key = entryKey(entry);
if (
!installedEntryKeys.has(key) ||
!entryMatchesLocalItem(entry, item)
) {
if (!entryMatchesLocalItem(entry, item)) {
continue;
}
const items = matched.get(key) ?? [];
@@ -674,49 +748,78 @@ export function MarketplaceView({
}
}
return matched;
}, [filteredEntries, installedEntryKeys, installedItems]);
}, [installedEntries, installedItems]);
const matchedLocalItemKeys = useMemo(
() =>
new Set(
[...matchedLocalItemsByEntryKey.values()].flatMap((items) =>
items.map((item) => item.key),
),
),
[matchedLocalItemsByEntryKey],
);
const matchedLocalItemKeys = useMemo(() => {
const matched = new Set<string>();
const installedMarketplaceEntries = primitiveEntries.filter((entry) =>
installedEntryKeys.has(entryKey(entry)),
);
for (const item of installedItems ?? []) {
if (
installedMarketplaceEntries.some((entry) =>
entryMatchesLocalItem(entry, item),
)
) {
matched.add(item.key);
}
}
return matched;
}, [installedEntryKeys, installedItems, primitiveEntries]);
const localOnlyInstalledItems = useMemo(
() =>
(installedItems ?? []).filter(
(item) => !matchedLocalItemKeys.has(item.key),
),
[installedItems, matchedLocalItemKeys],
);
const localOnlyInstalledItems = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return (installedItems ?? []).filter((item) => {
if (matchedLocalItemKeys.has(item.key)) {
return false;
}
return (
normalizedQuery.length === 0 ||
item.matchValues
.map(normalizeMatchValue)
.some((value) => value.includes(normalizedQuery))
);
});
}, [installedItems, matchedLocalItemKeys, query]);
const installedEntries = useMemo(
() =>
filteredEntries.filter((entry) =>
installedEntryKeys.has(entryKey(entry)),
),
[filteredEntries, installedEntryKeys],
);
const catalogEntries = useMemo(
() =>
filteredEntries.filter(
(entry) => !installedEntryKeys.has(entryKey(entry)),
),
[filteredEntries, installedEntryKeys],
);
const activeFilters = query.trim().length > 0 || selectedTag !== null;
const installedStatusReady = installedStatusState === "ready";
const clearFilters = () => {
setQuery("");
setSelectedTag(null);
};
const marketplaceTagFilters =
primitiveTags.length > 0 ? (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div className="flex gap-2 overflow-x-auto pb-1">
{primitiveTags.map((tag) => (
<TagButton
active={selectedTag === tag.id}
count={tagCounts.get(tag.id) ?? 0}
key={tag.id}
onClick={() =>
setSelectedTag((current) =>
current === tag.id ? null : tag.id,
)
}
tag={tag}
/>
))}
</div>
<div className="flex min-h-8 shrink-0 items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{catalogEntries.length}
</span>
<span>{catalogEntries.length === 1 ? "result" : "results"}</span>
{selectedTag ? (
<Button
onClick={() => setSelectedTag(null)}
size="sm"
type="button"
variant="ghost"
>
Clear filters
</Button>
) : null}
</div>
</div>
) : null;
const setEntryState = (entry: MarketplaceEntry, state: EntryActionState) => {
const key = entryKey(entry);
@@ -863,43 +966,7 @@ export function MarketplaceView({
value={query}
/>
</div>
<div className="flex min-h-8 items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{filteredEntries.length}
</span>
<span>
{filteredEntries.length === 1 ? "result" : "results"}
</span>
{activeFilters ? (
<Button
onClick={clearFilters}
size="sm"
type="button"
variant="ghost"
>
Clear filters
</Button>
) : null}
</div>
</div>
{primitiveTags.length > 0 ? (
<div className="flex gap-2 overflow-x-auto pb-1">
{primitiveTags.map((tag) => (
<TagButton
active={selectedTag === tag.id}
count={tagCounts.get(tag.id) ?? 0}
key={tag.id}
onClick={() =>
setSelectedTag((current) =>
current === tag.id ? null : tag.id,
)
}
tag={tag}
/>
))}
</div>
) : null}
</div>
<MarketplaceSection
@@ -914,6 +981,8 @@ export function MarketplaceView({
onInstall={installEntry}
onToggleExpanded={toggleExpanded}
onUninstall={uninstallEntry}
showFeaturedBadges={false}
showEntryTags={false}
sourceLabel="Marketplace"
tagLabels={tagLabels}
title="Installed"
@@ -924,6 +993,7 @@ export function MarketplaceView({
emptyMessage={pageDetails.emptyCatalog}
entries={catalogEntries}
expandedEntryKey={expandedEntryKey}
headerContent={marketplaceTagFilters}
installedEntryKeys={installedEntryKeys}
installedStatusReady={installedStatusReady}
onInstall={installEntry}
@@ -39,10 +39,10 @@ export type CustomizationSection =
const sectionDescriptions: Record<CustomizationSection, string> = {
Rules: "Review project and global rule files that shape Cline behavior.",
Hooks: "Inspect hook configuration and recent execution status.",
MCP: "Manage installed MCP servers and add new servers from the catalog.",
Skills: "Manage installed skills and add new skills from the catalog.",
MCP: "Manage installed MCP servers and add new servers from the marketplace.",
Skills: "Manage installed skills and add new skills from the marketplace.",
Agents: "Review configured agents discovered from local settings.",
Plugins: "Manage installed plugins and add new plugins from the catalog.",
Plugins: "Manage installed plugins and add new plugins from the marketplace.",
Tools: "Inspect built-in tools and tools contributed by plugins.",
};
@@ -17,6 +17,7 @@ export type MarketplaceEntry = {
id: string;
type: MarketplacePrimitiveType;
name: string;
featured?: boolean;
tagline: string;
description: string;
tags: string[];
@@ -100,7 +101,7 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Failed to fetch marketplace catalog: ${response.status}`);
throw new Error(`Failed to fetch marketplace: ${response.status}`);
}
const data = await response.json();
const baseUrl = typeof data?.baseUrl === "string" ? data.baseUrl : undefined;
@@ -152,6 +153,10 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
id: candidate.id,
type: candidate.type,
name: candidate.name,
featured:
typeof candidate.featured === "boolean"
? candidate.featured
: undefined,
tagline: candidate.tagline,
description: candidate.description,
tags: toStringArray(candidate.tags),
+29
View File
@@ -3,6 +3,27 @@ import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react-swc";
import { defineConfig } from "vite";
const mermaidChunkGroups = [
{
name: "mermaid-parser",
maxSize: 450_000,
test: /node_modules[\\/](?:\.bun[\\/])?@mermaid-js[+]parser/,
},
{
name: "mermaid-langium",
test: /node_modules[\\/](?:\.bun[\\/])?langium/,
},
{
name: "mermaid-layout",
maxSize: 450_000,
test: /node_modules[\\/](?:\.bun[\\/])?(?:cytoscape|cytoscape-cose-bilkent|dagre|elkjs)/,
},
{
name: "mermaid-markup",
test: /node_modules[\\/](?:\.bun[\\/])?(?:katex|dompurify)/,
},
];
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
@@ -25,5 +46,13 @@ export default defineConfig({
outDir: "../../dist/webview",
emptyOutDir: true,
cssMinify: "esbuild",
chunkSizeWarningLimit: 600,
rolldownOptions: {
output: {
codeSplitting: {
groups: mermaidChunkGroups,
},
},
},
},
});
-12
View File
@@ -105,15 +105,3 @@ webview-ui/.vite-port
*storybook.log
storybook-static
**/StorybookDecorator.tsx
# Worktree symlinks to directories. In a git worktree these paths are symlinks
# pointing at the shared checkout. vsce treats a symlinked directory as a single
# file entry, and the trailing-slash ignores above only exclude directory
# *contents* — not the symlink entry itself — so secretlint then tries to read
# the symlink as a file and crashes with EISDIR. Exclude the entries themselves.
out
test-results
.vscode-test
.cline
dist-standalone
webview-ui/node_modules
-4
View File
@@ -1,7 +1,3 @@
<div align="center"><sub>
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
</sub></div>
# Cline
<div align="center">
<table>
-392
View File
@@ -1,392 +0,0 @@
# VSCode Extension → Cline SDK Migration Audit
> Status: **living document**. Updated 2026-06-21.
>
> **The extension host has now been demolished to a bare-bones inert shell** so it
> can be rebuilt from near-zero on the Cline SDK, the way `apps/cli` is. See
> [Bare-bones demolition](#bare-bones-demolition-2026-06-21) for exactly what was
> removed and what survived. The earlier sections below describe the architecture
> and the original incremental plan; they remain useful as the blueprint for the
> rebuild.
## TL;DR
The VSCode extension still carries a large amount of **provider-, model-, and
session-specific code that the Cline SDK is designed to own**. The CLI
(`apps/cli`) already proves the target architecture: it is a thin shell that
delegates *everything* — provider selection, auth, model catalog, session
lifecycle, the agent loop, persistence, streaming — to the SDK
(`@cline/core`, `@cline/llms`, `@cline/agents`). The extension should converge on
that same pattern.
This document inventories what should move to the SDK, what was removed in the
first pass, and a prioritized roadmap for the rest.
---
## Rebuilt on the SDK (2026-06-21)
After the demolition (below), the host was **rebuilt on the Cline SDK**, mirroring
how `apps/cli` consumes it. The extension now **builds on the SDK** — verified:
`bun run protos && tsc --noEmit` (host) ✅, webview `tsc --noEmit` ✅,
`bun esbuild.mjs``dist/extension.js` ✅, `bun run build` (vite webview) ✅.
### New SDK integration layer (`src/core/sdk/`)
| Module | Role |
|---|---|
| `session-manager.ts` | Wraps `ClineCore.create()` + `start`/`send`/`subscribe`/`abort` (mirrors the CLI's core lifecycle) |
| `session-config.ts` | Maps the legacy `ApiConfiguration` → SDK `CoreSessionConfig` (provider/model/key/reasoning) |
| `message-translator.ts` | Translates SDK `CoreSessionEvent`/`AgentEvent` → webview `ClineMessage[]` (text/reasoning/tool/usage/done/error), with SDK→classic tool-name mapping |
| `message-id-minter.ts` | `ts`/`seq`/`epoch` authority for the webview's convergent-replica merge protocol |
| `webview-bridge.ts` | Holds the active `subscribeToPartialMessage` + `subscribeToState` response streams and pushes to them |
| `state-store.ts` | `ApiConfiguration`/mode/history persisted via `context.globalState`/secrets; builds a valid `ExtensionState` |
The `Controller` (`src/core/controller/index.ts`) was rewritten to own these and
implement `initTask`/`askResponse`/`cancelTask`/`clearTask`/`getStateToPostToWebview`,
delegating all inference to the SDK. The critical-path gRPC handlers were re-wired
to it: `state/{subscribeToState,getLatestState}`, `task/{newTask,askResponse,cancelTask,clearTask}`,
`ui/{subscribeToPartialMessage,initializeWebview}`, `models/{listProviders,resolveProviderModels,updateApiConfiguration*}`.
### Verified vs not
- **Verified (builds):** host tsc, webview tsc, esbuild, vite, and `vsce package`
all green.
- **Verified (runtime, real VS Code):** using the Playwright/Electron e2e harness
(`closed-loop-extension` skill), two e2e tests pass in a real VS Code instance
(`src/test/e2e/sdk-smoke.test.ts`):
1. *renders the Cline webview* — extension activates, the webview mounts, the
gRPC state subscription reaches the host, and the host returns a valid
`ExtensionState` (the chat UI renders).
2. *submits a chat message and starts a task through the SDK* — sending a
message clears the input (proves `newTask` dispatched), and the task starts
with a resolved provider/model (no empty-model error), exercising
webview → gRPC → `Controller.initTask``ClineCore``CoreSessionEvent`
→ translator → render.
- **NOT yet verified:** a full assistant *response* rendering end-to-end. The
default `cline` provider requires account auth (signin) and the cline endpoint
routed to the mock; the MVP left account/auth and cline-endpoint routing inert,
so the LLM call doesn't complete in the harness. Wiring those is the next step.
- **MVP scope:** secondary surfaces (MCP, checkpoints, browser, task-history
persistence, plan/act switching, spawn-agent aggregation, account/auth) are
minimal/inert but compile. They are the follow-on work to reach feature parity.
### Bugs found & fixed via the closed loop
- **Double `acquireVsCodeApi()`** crashed the webview's VS Code API/gRPC client
(zero requests reached the host → blank UI). Made acquisition idempotent in
`webview-ui/src/config/platform.config.ts` (cache on `window`, tolerate a throw).
- **Empty `modelId`**`session-config` didn't default a model when the user
hadn't selected one, so `ClineCore` rejected the session (Zod
`model: expected string to have >=1 characters`). Now falls back to the SDK
catalog default via `getProviderCollectionSync(...).provider.defaultModelId`
(imported from `@cline/llms`, not `@cline/core`).
- **Packaging in a git worktree**`vsce` can't follow the worktree's symlinked
`dist` / `webview-ui/build` directories, and `.vscodeignore`'s trailing-slash
entries didn't exclude symlinked-dir entries (`out`, `test-results`, …), so
`secretlint` crashed with `EISDIR`. Dereferenced the two required dirs and added
bare-name `.vscodeignore` entries for the worktree symlinks.
- A leftover empty interface (`ProviderCatalogController`) failed lint (blocked
packaging) and then a naive fix broke types — aliased it to `Controller`.
---
## Bare-bones demolition (2026-06-21)
We decided to stop doing this incrementally and instead **gut the extension host
down to a minimal inert shell**, then rebuild it on the SDK like `apps/cli`. The
demolition was executed by a multi-agent workflow and verified to compile.
### Result
- **571 files changed · 752 insertions · 93,256 deletions** (363 files deleted,
208 modified).
- **`tsc --noEmit` is green for both the host and the webview.**
- Host check: `cd apps/vscode && bun run protos && bunx tsc --noEmit`
(the `protos` step regenerates `src/generated/**`, which is a build artifact
and not committed — you must run it before typechecking).
- Webview check: `cd apps/vscode/webview-ui && bunx tsc --noEmit`.
- The webview UI is **fully intact** — it renders and you can click around, but
every backend action is inert.
### The demolition seam
The webview talks to the host **only** through generated proto service clients
over a postMessage bridge, and never imports handler implementations. So the
strategy was:
1. **Decouple the handler layer.** Every gRPC handler under
`src/core/controller/<group>/` was gutted to an inert stub that returns an
empty/default proto response and imports nothing downward. This severed the
handlers from all implementation.
2. **Delete the implementation** now that nothing references it.
3. **Rewrite the shell** (`extension.ts`, `common.ts`, `Controller`, `hosts/`)
to the minimum needed to render the webview and route gRPC.
4. **Reconverge** with an iterative `tsc`-fix loop until host + webview were clean.
### What was deleted (entire areas)
- `src/sdk/**` (the entire SDK bridge — ~14.6k lines)
- `src/services/**` (mcp, auth, telemetry, feature-flags, … — ~12.6k lines)
- `src/integrations/**` (~4.5k lines)
- `src/core/task/**`, `src/core/context/**`, `src/core/hooks/**`,
`src/core/storage/**`, `src/core/prompts/**`, `src/core/mentions/**`,
`src/core/ignore/**`, `src/core/locks/**`
- Most of `src/hosts/vscode/**` (terminal, diff view, comment review,
commit-message generation, file migrations, …)
- **All host-side test files** (`src/**/*.test.ts`, `src/__tests__`). Webview
tests were kept.
### What survived (the shell)
- `src/extension.ts`**760 → 106 lines**: HostProvider setup, storage context,
register the sidebar webview provider, route gRPC. Throwing stubs for removed
host creators (diff/terminal/review).
- `src/core/controller/``grpc-handler.ts`, `grpc-request-registry.ts`,
`grpc-recorder/**`, `index.ts` (a **minimal inert `Controller`** with
`any`-typed/no-op members), and all the **gutted handler files** (kept because
the generated `protobus-services.ts` imports them by path; bodies return empty
responses).
- `src/core/webview/**` — the `WebviewProvider` (slimmed to construct the minimal
Controller and load the webview HTML).
- `src/hosts/``host-provider.ts`, `VscodeWebviewProvider.ts`, and the
hostbridge client/handler needed for the webview + gRPC path.
- `src/shared/**` (incl. `src/shared/proto/**`), `src/generated/**`,
`src/utils/**`, `src/registry.ts`, `src/config.ts` — kept (types + generated
glue the webview and shell compile against).
- `webview-ui/**` — kept entirely (the inert UI).
### Caveats / not-yet-verified
- **Only `tsc` was verified.** The actual bundle builds
(`bun esbuild.mjs` for the host, `bun run build:webview` for vite) and a real
Extension-Development-Host launch have **not** been run yet. Compiling ≠ runs.
- `package.json` still declares many `commands`/menus whose handlers were removed.
They'll be dead/no-op in the UI until cleaned up.
- The minimal `Controller` uses `any` in several places by design — this is
throwaway scaffolding to be replaced by the SDK-backed implementation.
### Rebuilding on the SDK (next)
The shell is the foundation. Rebuild the host the way `apps/cli` does it
(`apps/cli/src/session/session.ts`, `apps/cli/src/runtime/run-agent.ts`): create a
`ClineCore`, `core.start()` sessions, `core.subscribe()` to events, and translate
those SDK events into the webview's gRPC/`ClineMessage` shape inside the surviving
handler/`Controller` layer. The (now empty) gRPC handlers are the exact insertion
points: re-implement each by delegating to `@cline/core` / `@cline/llms` instead of
the deleted in-extension code. The [target architecture](#the-target-architecture-what-the-cli-demonstrates)
table below is the contract for what the SDK should own.
---
## The target architecture (what the CLI demonstrates)
The CLI hands these responsibilities entirely to the SDK and keeps zero
provider-specific code:
| Responsibility | SDK owner |
|---|---|
| Provider selection / settings persistence | `ProviderSettingsManager` (`@cline/core`) |
| Provider auth / OAuth flows | `@cline/core/auth/*`, `getProviderAuthHandler()` |
| API handler creation (per-protocol HTTP) | `createHandler()` (`@cline/llms`) |
| Model catalog (ids, pricing, context windows) | `getProviderCollection()`, `resolveProviderConfig()` (`@cline/llms`) |
| Session start / resume / events | `ClineCore.create()/start()/send()/subscribe()` (`@cline/core`) |
| Agent loop (model → tools → repeat) | `Agent` / `AgentRuntime` (`@cline/agents`) |
| Conversation history & persistence | `CoreSessionService`, `persistence-service.ts` (`@cline/core`) |
| Context window mgmt / truncation / tokens | `MessageBuilder` (`@cline/core`) |
| Checkpoints / restore | `SessionVersioningService` (`@cline/core`) |
| MCP servers | `createMcpTools()`, `InMemoryMcpManager` (`@cline/core`) |
The CLI's consumer pattern (see `apps/cli/src/session/session.ts`,
`apps/cli/src/runtime/run-agent.ts`, `apps/cli/src/main.ts`):
```
ClineCore.create({ clientName, backendMode, ... })
→ core.start({ config, prompt, localRuntime })
→ core.subscribe(sessionId, event => render(event))
```
**Already done well in the extension:** the legacy per-provider *API handlers*
are gone. `src/core/api/index.ts` is types-only and all inference routes through
`@cline/llms` via `apps/vscode/src/sdk/sdk-api-handler.ts`. The remaining work is
mostly in the **webview settings UI**, the **model-refresh/catalog layer**, and
**proto/config conversion**.
---
## The core anti-pattern: per-provider files
The headline divergence from the CLI is that the extension has **one file (often
several) per provider**:
- **`webview-ui/src/components/settings/providers/*Provider.tsx`** — ~35 bespoke
settings components (`AnthropicProvider.tsx`, `BedrockProvider.tsx`,
`GroqProvider.tsx`, …), ~6,000 lines.
- **`webview-ui/src/components/settings/*ModelPicker.tsx`** — ~10 bespoke model
pickers (`OpenRouterModelPicker.tsx`, `GroqModelPicker.tsx`, …), ~3,200 lines.
- **`src/core/controller/models/refresh*.ts` / `get*Models.ts`** — ~23
per-provider model-refresh RPC handlers, ~2,350 lines.
The replacement already exists and works: **`GenericProviderSettings.tsx`** is a
fully-featured, catalog-driven settings shell (API key, optional base URL,
model picker, reasoning selector, model info) that takes only a `providerId` plus
presentation metadata. It pulls its model list from the unified
`resolveProviderModels` RPC (`useProviderModels(providerId)`), i.e. the SDK
catalog — not from per-provider state.
`providers/providerSettingsRegistry.ts` is the cutover mechanism:
- `CUSTOM_PROVIDER_SETTINGS_IDS` — providers that *still* have bespoke UIs.
- `getGenericProviderSettings(id, listing)` — builds generic settings from an SDK
`ProviderListing` (requires `protocol ∈ {anthropic, gemini, openai-chat,
openai-responses}`).
- `getFallbackGenericProviderSettings(id)` — static fallback so a generic
provider renders **before** SDK listings load.
In `ApiOptions.tsx`, providers mid-migration are dispatched with a
`&& !genericProviderSettings` guard, meaning the bespoke component is now just a
**pre-listing fallback**. Completing a provider's migration =
make the generic path always resolve, then delete the fallback component, its
model picker, its dispatch block, and its import.
---
## What was removed this pass
Three providers were fully cut over to the generic SDK-driven path and their
bespoke code deleted. These were chosen because they were already dual-pathed
(`&& !genericProviderSettings`) and have simple API-key-only config.
**Deleted (6 files, ~1,066 lines):**
- `webview-ui/src/components/settings/providers/GroqProvider.tsx`
- `webview-ui/src/components/settings/providers/BasetenProvider.tsx`
- `webview-ui/src/components/settings/providers/VercelAIGatewayProvider.tsx`
- `webview-ui/src/components/settings/GroqModelPicker.tsx`
- `webview-ui/src/components/settings/BasetenModelPicker.tsx`
- `webview-ui/src/components/settings/VercelModelPicker.tsx`
**Modified:**
- `providers/providerSettingsRegistry.ts` — added `groq`, `baseten`,
`vercel-ai-gateway` to `FALLBACK_GENERIC_PROVIDER_NAMES` so the generic path
*always* renders for them (no dependency on the catalog listing being loaded).
- `ApiOptions.tsx` — removed the 3 imports and the 3 `selectedProvider === …`
dispatch blocks. These providers now fall through to the single
`genericProviderSettings && <GenericProviderSettings … />` block.
- `context/ExtensionStateContext.tsx` — updated a now-stale comment about
`BasetenModelPicker`; flagged the legacy `refreshBasetenModelsRpc` /
`basetenModels` state as orphaned (see follow-ups).
- `providers/providerSettingsRegistry.test.ts` — added fallback assertions for
the 3 migrated providers.
**Verification:** `tsc -b` clean; `vitest run src/components/settings/`
10 files / 87 tests passing.
> ⚠️ **Runtime check still recommended.** Typecheck + unit tests confirm the
> wiring, but the settings UI for these 3 providers should be smoke-tested in a
> running extension host (open Settings → select Groq / Baseten / Vercel AI
> Gateway → confirm API-key field, model picker, and model selection persist).
**Orphaned-but-left (intentional, to keep this pass small):** the legacy
per-provider model-refresh plumbing for baseten/vercel (`refreshBasetenModelsRpc`,
`setBasetenModels`, and the analogous vercel state in `ExtensionStateContext.tsx`
+ `src/core/controller/models/refresh*.ts`) is no longer consumed by the UI but
was not removed here — it touches RPC services and shared state. Queued under
[Roadmap §3](#3-per-provider-model-refresh-layer).
---
## Roadmap (prioritized)
Ordered by value/risk. Each item notes whether it can be done **extension-only**
or **needs SDK changes** (deferred per this pass's scope).
### 1. Finish migrating "simple" providers to `GenericProviderSettings`
**Extension-only. Low risk. High value.** Same recipe as this pass. Candidates
that are already dual-pathed or API-key-only and likely covered by the generic
shell + SDK catalog:
- `huggingface` (already `&& !genericProviderSettings` guarded — next easiest)
- `xai`, `zai`, `qwen`, `moonshot`, `aihubmix`, `asksage`, `dify`, `requesty`
Recipe per provider:
1. Confirm SDK `ProviderListing.protocol` is one of the generic protocols (or add
to `getFallbackGenericProviderSettings`).
2. Add to `FALLBACK_GENERIC_PROVIDER_NAMES` (+ overrides for base URL / signup).
3. Delete `*Provider.tsx`, its `*ModelPicker.tsx`, the dispatch block, the import.
4. `tsc -b` + settings tests + runtime smoke test.
### 2. Collapse the `ApiOptions.tsx` dispatch chain
**Extension-only. Medium risk.** Today it's a ~30-branch
`selectedProvider === "x" && <XProvider/>` ladder. As §1 empties it, the file
should converge to: `genericProviderSettings ? <GenericProviderSettings/> :
<remaining custom providers>`. The genuinely-custom set that needs bespoke UI
(multi-field auth, special flows) is small: `bedrock`, `vertex`, `sapaicore`,
`oca`, `openai` (compatible), `lmstudio`, `ollama`, `vscode-lm`, `cline`,
`cline-pass`, `claude-code`, `openai-codex`.
### 3. Per-provider model-refresh layer
**Needs SDK alignment. Medium/high value.** `src/core/controller/models/`
contains ~23 `refresh*Models.ts` / `get*Models.ts` handlers that fetch model
lists from provider APIs. The SDK's unified `resolveProviderModels` RPC +
`@cline/llms` catalog is meant to own live model discovery. Migrate consumers to
the unified RPC, then delete the per-provider handlers and their
`ExtensionStateContext` state (`basetenModels`, `vercelAiGatewayModels`,
`groqModels`, `openRouterModels`, …). The orphaned baseten/vercel state from this
pass is the first cleanup here.
### 4. ApiConfiguration ↔ proto conversion & provider type unions
**Needs SDK changes (deferred).** `src/shared/proto-conversions/models/`
(~1,000 lines, esp. `api-configuration-conversion.ts` at ~800) and the provider
type unions / model metadata in `src/shared/api.ts` + `src/shared/providers/*.json`
duplicate what the SDK's `ProviderConfig` / catalog already model. The extension
should consume SDK provider/config types directly rather than maintaining a
parallel `ApiConfiguration` shape. **SDK gap:** needs the SDK to expose its
provider config + listing metadata richly enough that the webview can drive all
settings UIs from it. Document the exact fields needed, then implement on the SDK
side in a follow-up.
### 5. In-extension model catalog (`src/sdk/model-catalog/`)
**Needs SDK changes (deferred).** ~3,000 lines (`catalog.ts`, `store.ts`,
`effective-config.ts`, `contracts.ts`, `shape-adapter.ts`, …) that re-derive an
effective model/provider config inside the extension. Much of this is a shim that
exists because the SDK catalog isn't yet consumed end-to-end. As §3/§4 land, this
layer should shrink to a thin host adapter or disappear.
### 6. Session / task / state overlap
**Mostly SDK-owned already; keep VSCode glue.** The agent loop, context-window
management, truncation, and token counting are already owned by the SDK
(`@cline/agents` `agent-runtime.ts`, `@cline/core` `message-builder.ts`,
`session-runtime-orchestrator.ts`). The extension's `src/sdk/*` is largely a
legitimate bridge (webview gRPC, VSCode UI concerns). Candidates to consolidate
*into* the SDK over time (deferred — needs SDK changes):
- `src/sdk/sdk-task-history.ts` (~747) overlaps `persistence-service.ts` /
`session-data.ts`.
- `src/sdk/tool-approval-denial.ts` + `sdk-tool-policies.ts` — tool policy logic
the SDK could own generically.
- `src/core/hooks/*` + `src/sdk/hooks-adapter.ts` overlap the SDK hook system.
Keep in the extension (genuinely VSCode-specific): `message-translator.ts`
(webview message format), `StateManager.ts` (VSCode storage), the
`sdk-*-coordinator.ts` bridge layer, `vscode-run-commands-tool.ts` (terminal),
`McpHub.ts` (connection lifecycle), `vscode-lm/*` (GitHub Copilot models).
---
## Appendix: key files
- Generic settings shell: `webview-ui/src/components/settings/providers/GenericProviderSettings.tsx`
- Cutover registry: `webview-ui/src/components/settings/providers/providerSettingsRegistry.ts`
- Provider dispatch: `webview-ui/src/components/settings/ApiOptions.tsx`
- Unified model RPC hook: `webview-ui/src/hooks/useProviderModels.ts`
- SDK API handler bridge: `apps/vscode/src/sdk/sdk-api-handler.ts`
- Session factory: `apps/vscode/src/sdk/cline-session-factory.ts`
- CLI reference (target pattern): `apps/cli/src/session/session.ts`, `apps/cli/src/runtime/run-agent.ts`
+9 -5
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.89.0",
"version": "4.0.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -42,8 +42,7 @@
"activationEvents": [
"onLanguage",
"onUri",
"onStartupFinished",
"workspaceContains:evals.env"
"onStartupFinished"
],
"main": "./dist/extension.js",
"contributes": {
@@ -138,6 +137,11 @@
"title": "MCP Servers",
"icon": "$(server)"
},
{
"command": "cline.marketplaceButtonClicked",
"title": "Customize",
"icon": "$(wrench)"
},
{
"command": "cline.historyButtonClicked",
"title": "History",
@@ -261,7 +265,7 @@
"when": "view == claude-dev.SidebarProvider"
},
{
"command": "cline.mcpButtonClicked",
"command": "cline.marketplaceButtonClicked",
"group": "navigation@2",
"when": "view == claude-dev.SidebarProvider"
},
@@ -391,7 +395,7 @@
"test:e2e:ui": "bun scripts/interactive-playwright.ts",
"install:all": "bun install",
"dev:webview": "cd webview-ui && bun run dev",
"build:webview": "cd webview-ui && bun run build",
"build:webview": "bun run protos && cd webview-ui && bun run build",
"test:webview": "cd webview-ui && bun run test",
"publish:marketplace": "node scripts/publish-marketplace.mjs",
"publish:marketplace:prerelease": "node scripts/publish-marketplace.mjs --pre-release",
-27
View File
@@ -3,17 +3,13 @@ syntax = "proto3";
package cline;
import "cline/common.proto";
import "google/protobuf/timestamp.proto";
option go_package = "github.com/cline/grpc-go/cline";
option java_multiple_files = true;
option java_package = "bot.cline.proto";
service CheckpointsService {
rpc checkpointDiff(Int64Request) returns (Empty);
rpc checkpointRestore(CheckpointRestoreRequest) returns (Empty);
rpc subscribeToCheckpoints(CheckpointSubscriptionRequest) returns (stream CheckpointEvent);
rpc getCwdHash(StringArrayRequest) returns (PathHashMap);
}
message CheckpointRestoreRequest {
@@ -22,26 +18,3 @@ message CheckpointRestoreRequest {
string restore_type = 3;
optional int64 offset = 4;
}
message CheckpointSubscriptionRequest {
string cwd_hash = 1;
}
message CheckpointEvent {
enum OperationType {
CHECKPOINT_INIT = 0;
CHECKPOINT_COMMIT = 1;
CHECKPOINT_RESTORE = 2;
}
OperationType operation = 1;
string cwd_hash = 2;
bool is_active = 3;
google.protobuf.Timestamp timestamp = 4;
optional string task_id = 5;
optional string commit_hash = 6;
}
message PathHashMap {
map<string, string> path_hash = 1;
}
+106
View File
@@ -0,0 +1,106 @@
syntax = "proto3";
package cline;
import "cline/common.proto";
option go_package = "github.com/cline/grpc-go/cline";
option java_multiple_files = true;
option java_package = "bot.cline.proto";
service MarketplaceService {
rpc getMarketplaceCatalog(EmptyRequest) returns (MarketplaceCatalog);
rpc listMarketplaceLocalInstalledEntries(EmptyRequest) returns (MarketplaceLocalInstalledEntries);
rpc listMarketplaceInstalledEntries(MarketplaceEntriesRequest) returns (MarketplaceInstalledEntries);
rpc installMarketplaceEntry(MarketplaceEntryRequest) returns (MarketplaceInstallResult);
rpc uninstallMarketplaceEntry(MarketplaceEntryRequest) returns (MarketplaceInstallResult);
rpc toggleMarketplaceLocalInstalledEntry(ToggleMarketplaceLocalInstalledEntryRequest) returns (MarketplaceLocalInstalledEntries);
rpc uninstallMarketplaceLocalInstalledEntry(MarketplaceLocalInstalledEntryRequest) returns (MarketplaceInstallResult);
}
message MarketplaceTag {
string label = 1;
optional string color = 2;
}
message MarketplaceCounts {
optional int32 tools = 1;
optional int32 prompts = 2;
optional int32 resources = 3;
}
message MarketplaceEnvVar {
string name = 1;
bool required = 2;
optional string description = 3;
optional string url = 4;
}
message MarketplaceInstall {
repeated string args = 1;
repeated MarketplaceEnvVar env = 2;
optional string command = 3;
optional string notes = 4;
}
message MarketplaceEntry {
string id = 1;
string type = 2;
string name = 3;
optional string tagline = 4;
optional string description = 5;
repeated string tags = 6;
repeated MarketplaceTag tag_objects = 7;
optional string author = 8;
optional string source_url = 9;
optional string homepage_url = 10;
optional MarketplaceCounts counts = 11;
optional MarketplaceInstall install = 12;
}
message MarketplaceCatalog {
repeated MarketplaceEntry entries = 1;
}
message MarketplaceEntriesRequest {
repeated MarketplaceEntry entries = 1;
}
message MarketplaceInstalledEntries {
repeated string installed_keys = 1;
}
message MarketplaceLocalInstalledEntry {
string id = 1;
string type = 2;
string name = 3;
optional string description = 4;
optional string path = 5;
optional string source = 6;
bool enabled = 7;
}
message MarketplaceLocalInstalledEntries {
repeated MarketplaceLocalInstalledEntry entries = 1;
}
message ToggleMarketplaceLocalInstalledEntryRequest {
MarketplaceLocalInstalledEntry entry = 1;
bool enabled = 2;
}
message MarketplaceLocalInstalledEntryRequest {
MarketplaceLocalInstalledEntry entry = 1;
}
message MarketplaceEntryRequest {
MarketplaceEntry entry = 1;
}
message MarketplaceInstallResult {
string id = 1;
string type = 2;
string status = 3;
string message = 4;
optional string output = 5;
}
+4 -51
View File
@@ -397,7 +397,7 @@ message ModelsApiOptions {
optional bool azure_identity = 44;
// Plan mode configurations
optional ApiProvider plan_mode_api_provider = 100;
optional string plan_mode_api_provider = 100;
optional string plan_mode_api_model_id = 101;
optional int64 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
@@ -438,7 +438,7 @@ message ModelsApiOptions {
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 138;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
optional string act_mode_api_provider = 200;
optional string act_mode_api_model_id = 201;
optional int64 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
@@ -568,53 +568,6 @@ message OcaCompatibleModelInfo {
optional string error = 2;
}
// API Provider enumeration
enum ApiProvider {
ANTHROPIC = 0;
OPENROUTER = 1;
BEDROCK = 2;
VERTEX = 3;
OPENAI = 4;
OLLAMA = 5;
LMSTUDIO = 6;
GEMINI = 7;
OPENAI_NATIVE = 8;
REQUESTY = 9;
TOGETHER = 10;
DEEPSEEK = 11;
QWEN = 12;
DOUBAO = 13;
MISTRAL = 14;
VSCODE_LM = 15;
CLINE = 16;
LITELLM = 17;
NEBIUS = 18;
FIREWORKS = 19;
ASKSAGE = 20;
XAI = 21;
SAMBANOVA = 22;
CEREBRAS = 23;
GROQ = 24;
SAPAICORE = 25;
CLAUDE_CODE = 26;
MOONSHOT = 27;
HUGGINGFACE = 28;
HUAWEI_CLOUD_MAAS = 29;
BASETEN = 30;
ZAI = 31;
VERCEL_AI_GATEWAY = 32;
QWEN_CODE = 33;
DIFY = 34;
OCA = 35;
MINIMAX = 36;
HICAP = 37;
AIHUBMIX = 38;
NOUSRESEARCH = 39;
OPENAI_CODEX = 40;
WANDB = 41;
CLINE_PASS = 42;
}
enum ApiFormat {
ANTHROPIC_CHAT = 0;
GEMINI_CHAT = 1;
@@ -754,7 +707,7 @@ message ModelsApiConfiguration {
optional string wandb_api_key = 87;
// Plan mode configurations
optional ApiProvider plan_mode_api_provider = 100;
optional string plan_mode_api_provider = 100;
optional string plan_mode_api_model_id = 101;
optional int64 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
@@ -800,7 +753,7 @@ message ModelsApiConfiguration {
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 143;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
optional string act_mode_api_provider = 200;
optional string act_mode_api_model_id = 201;
optional int64 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
+8 -11
View File
@@ -112,6 +112,9 @@ message Secrets {
// in src/shared/storage/state-keys.ts and use the scripts/generate-state-proto.mjs
// script to regenerate this list.
message Settings {
reserved 144, 154, 176;
reserved "cline_web_tools_enabled", "enable_parallel_tool_calling", "double_check_completion_enabled";
optional string lite_llm_base_url = 1;
optional bool lite_llm_use_prompt_cache = 2;
optional string anthropic_base_url = 4;
@@ -234,8 +237,8 @@ message Settings {
optional string act_mode_nous_research_model_id = 121;
optional string act_mode_vercel_ai_gateway_model_id = 122;
optional OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 123;
optional ApiProvider plan_mode_api_provider = 124;
optional ApiProvider act_mode_api_provider = 125;
optional string plan_mode_api_provider = 124;
optional string act_mode_api_provider = 125;
optional string hicap_model_id = 126;
optional string lm_studio_model_id = 127;
optional AutoApprovalSettings auto_approval_settings = 128;
@@ -248,18 +251,15 @@ message Settings {
optional bool enable_checkpoints_setting = 135;
optional int32 shell_integration_timeout = 136;
optional string default_terminal_profile = 137;
optional int32 terminal_output_line_limit = 138;
optional int32 max_consecutive_mistakes = 139;
optional bool yolo_mode_toggled = 142;
optional bool use_auto_condense = 143;
optional bool cline_web_tools_enabled = 144;
optional string preferred_language = 145;
optional PlanActMode mode = 147;
optional FocusChainSettings focus_chain_settings = 149;
optional string custom_prompt = 150;
optional bool hooks_enabled = 152;
optional bool subagents_enabled = 153;
optional bool enable_parallel_tool_calling = 154;
optional bool background_edit_enabled = 155;
optional bool opt_out_of_remote_config = 157;
optional bool open_telemetry_enabled = 158;
@@ -278,7 +278,6 @@ message Settings {
optional int32 open_telemetry_log_max_queue_size = 171;
optional bool worktrees_enabled = 172;
optional bool auto_approve_all_toggled = 174;
optional bool double_check_completion_enabled = 176;
map<string, string> open_ai_headers = 177;
optional string plan_mode_cline_model_id = 178;
optional OpenRouterModelInfo plan_mode_cline_model_info = 179;
@@ -388,8 +387,11 @@ message UpdateTaskSettingsRequest {
message UpdateSettingsRequest {
reserved 15; // was openai_reasoning_effort (moved to mode-scoped reasoning effort)
reserved 23; // was dictation_settings (dictation removed)
reserved 32, 34, 35, 41;
reserved 38; // was skills_enabled (removed - now always enabled)
reserved 43; // was lazy_teammate_mode_enabled (removed)
reserved 12; // was terminal_output_line_limit (removed; SDK command output limits are character-based)
reserved "native_tool_call_enabled", "cline_web_tools_enabled", "enable_parallel_tool_calling", "double_check_completion_enabled";
Metadata metadata = 1;
optional ModelsApiConfiguration api_configuration = 2;
@@ -401,7 +403,6 @@ message UpdateSettingsRequest {
optional bool terminal_reuse_enabled = 9;
optional bool mcp_responses_collapsed = 10;
optional McpDisplayMode mcp_display_mode = 11;
optional int32 terminal_output_line_limit = 12;
optional PlanActMode mode = 13;
optional string preferred_language = 14;
reserved 16; // was strict_plan_mode_enabled (removed)
@@ -418,15 +419,11 @@ message UpdateSettingsRequest {
optional bool subagents_enabled = 29;
optional int32 subagent_terminal_output_line_limit = 30;
optional string cline_env = 31;
optional bool native_tool_call_enabled = 32;
optional OnboardingModelGroup onboarding_models = 33;
optional bool cline_web_tools_enabled = 34;
optional bool enable_parallel_tool_calling = 35;
optional bool background_edit_enabled = 36;
optional string oca_reasoning_effort = 37;
optional bool opt_out_of_remote_config = 39;
optional bool worktrees_enabled = 40;
optional bool double_check_completion_enabled = 41;
optional bool show_feature_tips = 42;
}
+2 -2
View File
@@ -12,6 +12,8 @@ option java_package = "bot.cline.proto";
service TaskService {
// Cancels the currently running task
rpc cancelTask(EmptyRequest) returns (Empty);
// Cancels a queued prompt by ID
rpc cancelQueuedPrompt(StringRequest) returns (Empty);
// Cancels the currently running background command
rpc cancelBackgroundCommand(EmptyRequest) returns (Empty);
// Clears the current task
@@ -36,8 +38,6 @@ service TaskService {
rpc editMessageAndRegenerate(EditMessageAndRegenerateRequest) returns (Empty);
// Records task feedback (thumbs up/down)
rpc taskFeedback(StringRequest) returns (Empty);
// Shows task completion changes diff in a view
rpc taskCompletionViewChanges(Int64Request) returns (Empty);
// Executes a quick win task with command and title
rpc executeQuickWin(ExecuteQuickWinRequest) returns (Empty);
// Deletes all task history
+3
View File
@@ -254,6 +254,9 @@ service UiService {
// Subscribe to MCP button clicked events
rpc subscribeToMcpButtonClicked(EmptyRequest) returns (stream Empty);
// Subscribe to customize button clicked events
rpc subscribeToMarketplaceButtonClicked(EmptyRequest) returns (stream Empty);
// Subscribe to history button click events
rpc subscribeToHistoryButtonClicked(EmptyRequest) returns (stream Empty);
+705
View File
@@ -0,0 +1,705 @@
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import "should"
import fs from "fs/promises"
import * as actualOs from "os"
import path from "path"
import sinon from "sinon"
// The SUT does `import * as os from "os"; os.homedir()`. Under bun, sinon's
// `stub(os, "homedir")` on the test's own `os` binding does NOT propagate to the
// SUT's namespace import, so inject a module-level homedir stub via mock.module
// (the rest of `os` — tmpdir() etc. — keeps its real behavior).
const homedirStub = sinon.stub()
const osMockNamespace = { ...actualOs, homedir: homedirStub }
const osMock = () => ({ ...osMockNamespace, default: osMockNamespace })
mock.module("os", osMock)
mock.module("node:os", osMock)
import os from "os"
import { ClineConfigurationError, ClineEndpoint, ClineEnv, Environment } from "../config"
describe("ClineEndpoint configuration", () => {
let sandbox: sinon.SinonSandbox
let tempDir: string
let originalHomedir: typeof os.homedir
beforeEach(async () => {
sandbox = sinon.createSandbox()
tempDir = path.join(os.tmpdir(), `config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(tempDir, { recursive: true })
// Create .cline directory
await fs.mkdir(path.join(tempDir, ".cline"), { recursive: true })
// Stub os.homedir to return our temp directory (via mock.module homedirStub)
originalHomedir = os.homedir
homedirStub.reset()
homedirStub.returns(tempDir)
// Reset the singleton state using internal method
;(ClineEndpoint as any)._instance = null
;(ClineEndpoint as any)._initialized = false
;(ClineEndpoint as any)._extensionFsPath = undefined
})
afterEach(async () => {
sandbox.restore()
// Reset singleton state
;(ClineEndpoint as any)._instance = null
;(ClineEndpoint as any)._initialized = false
try {
await fs.rm(tempDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})
describe("valid config parsing", () => {
it("should parse valid endpoints.json with all required fields", async () => {
const validConfig = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
await ClineEndpoint.initialize(tempDir)
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://app.enterprise.com")
config.apiBaseUrl.should.equal("https://api.enterprise.com")
config.mcpBaseUrl.should.equal("https://mcp.enterprise.com")
config.environment.should.equal(Environment.selfHosted)
})
it("should work without endpoints.json (standard mode)", async () => {
// No endpoints.json file exists
await ClineEndpoint.initialize(tempDir)
const config = ClineEndpoint.config
config.environment.should.not.equal(Environment.selfHosted)
// Should use production defaults
config.appBaseUrl.should.equal("https://app.cline.bot")
config.apiBaseUrl.should.equal("https://api.cline.bot")
})
it("should accept URLs with ports", async () => {
const validConfig = {
appBaseUrl: "http://localhost:3000",
apiBaseUrl: "http://localhost:7777",
mcpBaseUrl: "http://localhost:8080/mcp",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
await ClineEndpoint.initialize(tempDir)
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("http://localhost:3000")
config.apiBaseUrl.should.equal("http://localhost:7777")
config.mcpBaseUrl.should.equal("http://localhost:8080/mcp")
})
it("should accept URLs with paths", async () => {
const validConfig = {
appBaseUrl: "https://proxy.enterprise.com/cline/app",
apiBaseUrl: "https://proxy.enterprise.com/cline/api",
mcpBaseUrl: "https://proxy.enterprise.com/cline/mcp",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
await ClineEndpoint.initialize(tempDir)
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://proxy.enterprise.com/cline/app")
})
})
describe("invalid JSON handling", () => {
it("should throw ClineConfigurationError for invalid JSON syntax", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "{ invalid json }", "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("Invalid JSON")
}
})
it("should throw ClineConfigurationError for truncated JSON", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), '{"appBaseUrl": "https://test.com"', "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("Invalid JSON")
}
})
it("should throw ClineConfigurationError for empty file", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "", "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
}
})
it("should throw ClineConfigurationError for non-object JSON", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), '"just a string"', "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must contain a JSON object")
}
})
it("should throw ClineConfigurationError for array JSON", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "[]", "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
// Arrays pass the object check but fail on required fields
error.message.should.containEql("Missing required field")
}
})
it("should throw ClineConfigurationError for null JSON", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "null", "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must contain a JSON object")
}
})
})
describe("missing required fields", () => {
it("should throw ClineConfigurationError when appBaseUrl is missing", async () => {
const config = {
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql('Missing required field "appBaseUrl"')
}
})
it("should throw ClineConfigurationError when apiBaseUrl is missing", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql('Missing required field "apiBaseUrl"')
}
})
it("should throw ClineConfigurationError when mcpBaseUrl is missing", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql('Missing required field "mcpBaseUrl"')
}
})
it("should throw ClineConfigurationError when all fields are missing", async () => {
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "{}", "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("Missing required field")
}
})
it("should throw ClineConfigurationError when field is null", async () => {
const config = {
appBaseUrl: null,
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql('Missing required field "appBaseUrl"')
}
})
it("should throw ClineConfigurationError when field is empty string", async () => {
const config = {
appBaseUrl: "",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("cannot be empty")
}
})
it("should throw ClineConfigurationError when field is whitespace only", async () => {
const config = {
appBaseUrl: " ",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("cannot be empty")
}
})
it("should throw ClineConfigurationError when field is non-string", async () => {
const config = {
appBaseUrl: 12345,
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must be a string")
}
})
})
describe("invalid URL detection", () => {
it("should throw ClineConfigurationError for invalid URL format", async () => {
const config = {
appBaseUrl: "not-a-valid-url",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must be a valid URL")
}
})
it("should throw ClineConfigurationError for URL without protocol", async () => {
const config = {
appBaseUrl: "app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must be a valid URL")
}
})
it("should throw ClineConfigurationError for malformed URL", async () => {
const config = {
appBaseUrl: "https://",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must be a valid URL")
}
})
it("should include the invalid URL value in error message", async () => {
const invalidUrl = "definitely-not-a-url"
const config = {
appBaseUrl: invalidUrl,
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
try {
await ClineEndpoint.initialize(tempDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql(invalidUrl)
}
})
})
describe("environment switching blocked in self-hosted mode", () => {
it("should throw error when trying to change environment in self-hosted mode", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
await ClineEndpoint.initialize(tempDir)
// Verify we're in self-hosted mode
ClineEndpoint.config.environment.should.equal(Environment.selfHosted)
// Try to change environment - should throw
try {
ClineEnv.setEnvironment("staging")
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.containEql("Cannot change environment in on-premise mode")
}
})
it("should throw error for all environment values in self-hosted mode", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
await ClineEndpoint.initialize(tempDir)
const environments = ["staging", "local", "production", "anything"]
for (const env of environments) {
try {
ClineEnv.setEnvironment(env)
throw new Error(`Should have thrown for environment: ${env}`)
} catch (error: any) {
error.message.should.containEql("Cannot change environment in on-premise mode")
}
}
})
it("should allow environment switching in standard mode", async () => {
// No endpoints.json file - standard mode
await ClineEndpoint.initialize(tempDir)
// Verify we're NOT in self-hosted mode
ClineEndpoint.config.environment.should.not.equal(Environment.selfHosted)
// Should be able to change environment
ClineEnv.setEnvironment("staging")
ClineEnv.getEnvironment().environment.should.equal("staging")
ClineEnv.setEnvironment("local")
ClineEnv.getEnvironment().environment.should.equal("local")
ClineEnv.setEnvironment("production")
ClineEnv.getEnvironment().environment.should.equal("production")
})
})
describe("self-hosted mode behavior", () => {
it("should report selfHosted environment in self-hosted mode", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
await ClineEndpoint.initialize(tempDir)
const envConfig = ClineEndpoint.config
envConfig.environment.should.equal(Environment.selfHosted)
})
it("should use custom endpoints from file", async () => {
const customConfig = {
appBaseUrl: "https://custom-app.internal",
apiBaseUrl: "https://custom-api.internal",
mcpBaseUrl: "https://custom-mcp.internal/v1",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(customConfig), "utf8")
await ClineEndpoint.initialize(tempDir)
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://custom-app.internal")
config.apiBaseUrl.should.equal("https://custom-api.internal")
config.mcpBaseUrl.should.equal("https://custom-mcp.internal/v1")
})
})
describe("initialization behavior", () => {
it("should only initialize once", async () => {
await ClineEndpoint.initialize(tempDir)
ClineEndpoint.isInitialized().should.be.true()
// Second initialize should be a no-op
await ClineEndpoint.initialize(tempDir)
ClineEndpoint.isInitialized().should.be.true()
})
it("should throw error when accessing config before initialization", async () => {
// Already reset in beforeEach, so accessing should throw
try {
const _ = ClineEndpoint.config
throw new Error("Should have thrown")
} catch (error: any) {
error.message.should.containEql("not initialized")
}
})
})
describe("isSelfHosted() method", () => {
it("should return true when not initialized (safety fallback)", async () => {
// Reset singleton state - already done in beforeEach, not initialized
ClineEndpoint.isInitialized().should.be.false()
ClineEndpoint.isSelfHosted().should.be.true()
})
it("should return true when in self-hosted mode", async () => {
const config = {
appBaseUrl: "https://app.enterprise.com",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
await ClineEndpoint.initialize(tempDir)
ClineEndpoint.isSelfHosted().should.be.true()
})
it("should return false when in normal mode (no endpoints.json)", async () => {
// No endpoints.json file exists
await ClineEndpoint.initialize(tempDir)
ClineEndpoint.isSelfHosted().should.be.false()
})
})
describe("bundled endpoints.json behavior", () => {
let bundledDir: string
let setVscodeHostProviderMock: (mock: { extensionFsPath: string; globalStorageFsPath: string }) => void
beforeEach(async () => {
// Create a separate directory for bundled config
bundledDir = path.join(os.tmpdir(), `config-bundled-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
await fs.mkdir(bundledDir, { recursive: true })
// Import HostProvider utilities
const hostProviderModule = await import("../test/host-provider-test-utils")
setVscodeHostProviderMock = hostProviderModule.setVscodeHostProviderMock
})
afterEach(async () => {
try {
await fs.rm(bundledDir, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})
it("should use bundled endpoints.json when available", async () => {
const bundledConfig = {
appBaseUrl: "https://bundled.enterprise.com",
apiBaseUrl: "https://bundled-api.enterprise.com",
mcpBaseUrl: "https://bundled-mcp.enterprise.com",
}
// Set up bundled config
await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(bundledConfig), "utf8")
await ClineEndpoint.initialize(bundledDir)
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://bundled.enterprise.com")
config.apiBaseUrl.should.equal("https://bundled-api.enterprise.com")
config.mcpBaseUrl.should.equal("https://bundled-mcp.enterprise.com")
config.environment.should.equal(Environment.selfHosted)
})
it("should prefer bundled endpoints.json over user file", async () => {
const bundledConfig = {
appBaseUrl: "https://bundled.enterprise.com",
apiBaseUrl: "https://bundled-api.enterprise.com",
mcpBaseUrl: "https://bundled-mcp.enterprise.com",
}
const userConfig = {
appBaseUrl: "https://user.enterprise.com",
apiBaseUrl: "https://user-api.enterprise.com",
mcpBaseUrl: "https://user-mcp.enterprise.com",
}
// Set up both configs
await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(bundledConfig), "utf8")
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(userConfig), "utf8")
await ClineEndpoint.initialize(bundledDir)
// Should use bundled config, not user config
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://bundled.enterprise.com")
config.apiBaseUrl.should.equal("https://bundled-api.enterprise.com")
config.mcpBaseUrl.should.equal("https://bundled-mcp.enterprise.com")
})
it("should fall back to user endpoints.json when bundled is not present", async () => {
const userConfig = {
appBaseUrl: "https://user.enterprise.com",
apiBaseUrl: "https://user-api.enterprise.com",
mcpBaseUrl: "https://user-mcp.enterprise.com",
}
// Only create user config, no bundled config
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(userConfig), "utf8")
await ClineEndpoint.initialize(bundledDir)
// Should use user config
const config = ClineEndpoint.config
config.appBaseUrl.should.equal("https://user.enterprise.com")
config.apiBaseUrl.should.equal("https://user-api.enterprise.com")
config.mcpBaseUrl.should.equal("https://user-mcp.enterprise.com")
})
it("should use standard mode when neither bundled nor user file exists", async () => {
// No config files at all
await ClineEndpoint.initialize(bundledDir)
// Should use production defaults
const config = ClineEndpoint.config
config.environment.should.not.equal(Environment.selfHosted)
config.appBaseUrl.should.equal("https://app.cline.bot")
config.apiBaseUrl.should.equal("https://api.cline.bot")
})
it("should throw ClineConfigurationError for invalid bundled file", async () => {
const invalidConfig = {
appBaseUrl: "not-a-url",
apiBaseUrl: "https://api.enterprise.com",
mcpBaseUrl: "https://mcp.enterprise.com",
}
// Set up invalid bundled config
await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(invalidConfig), "utf8")
try {
await ClineEndpoint.initialize(bundledDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("must be a valid URL")
error.message.should.containEql("bundled")
}
})
it("should throw ClineConfigurationError for invalid JSON in bundled file", async () => {
// Set up invalid JSON in bundled file
await fs.writeFile(path.join(bundledDir, "endpoints.json"), "{ invalid json }", "utf8")
try {
await ClineEndpoint.initialize(bundledDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("Invalid JSON")
error.message.should.containEql("bundled")
}
})
it("should indicate bundled source in error messages", async () => {
const incompleteConfig = {
appBaseUrl: "https://bundled.enterprise.com",
// Missing apiBaseUrl and mcpBaseUrl
}
await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(incompleteConfig), "utf8")
try {
await ClineEndpoint.initialize(bundledDir)
throw new Error("Should have thrown")
} catch (error: any) {
error.should.be.instanceof(ClineConfigurationError)
error.message.should.containEql("Missing required field")
error.message.should.containEql(path.join(bundledDir, "endpoints.json"))
}
})
})
})
+149 -10
View File
@@ -4,38 +4,177 @@ import "./utils/path" // necessary to have access to String.prototype.toPosix
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import type { StorageContext } from "@/shared/storage/storage-context"
import { clearOnboardingModelsCache } from "./core/controller/models/getClineOnboardingModels"
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
import { StateManager } from "./core/storage/StateManager"
import { AgentConfigLoader } from "./core/task/tools/subagent/AgentConfigLoader"
import { ExtensionRegistryInfo } from "./registry"
import { registerVsCodeLmHandler } from "./sdk/vscode-lm/register-vscode-lm"
import { ErrorService } from "./services/error"
import { featureFlagsService } from "./services/feature-flags"
import { getDistinctId } from "./services/logging/distinctId"
import { telemetryService } from "./services/telemetry"
import { PostHogClientProvider } from "./services/telemetry/providers/posthog/PostHogClientProvider"
import { ClineTempManager } from "./services/temp"
import { ShowMessageType } from "./shared/proto/host/window"
import { syncWorker } from "./shared/services/worker/sync"
import { getBlobStoreSettingsFromEnv } from "./shared/services/worker/worker"
import { getLatestAnnouncementId } from "./utils/announcements"
import { arePathsEqual } from "./utils/path"
/**
* Performs initialization for Cline that is common to all platforms.
* Performs intialization for Cline that is common to all platforms.
*
* This is the MINIMAL inert-shell version: it only wires up logging,
* initializes the bundled endpoint configuration, and creates the webview
* provider so the UI can render. All heavy services (state manager,
* telemetry, error service, sync worker, hooks, etc.) have been removed.
*
* @param _storageContext kept for signature compatibility with callers.
* @param context
* @returns The webview provider
* @throws ClineConfigurationError if endpoints.json exists but is invalid
*/
export async function initialize(_storageContext: StorageContext): Promise<WebviewProvider> {
export async function initialize(storageContext: StorageContext): Promise<WebviewProvider> {
// Configure the shared Logging class to use HostProvider's output channels and debug logger
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg)) // File system logging
Logger.subscribe((msg: string) => HostProvider.env.debugLog({ value: msg })) // Host debug logging
// Initialize ClineEndpoint configuration (reads bundled and ~/.cline/endpoints.json if present)
// This must be done before any other code that calls ClineEnv.config()
// Throws ClineConfigurationError if config file exists but is invalid
const { ClineEndpoint } = await import("./config")
await ClineEndpoint.initialize(HostProvider.get().extensionFsPath)
try {
await StateManager.initialize(storageContext)
} catch (error) {
Logger.error("[Cline] CRITICAL: Failed to initialize StateManager:", error)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Failed to initialize storage. Please check logs for details or try restarting the client.",
})
}
// Register host-only SDK provider handlers (e.g. VS Code Language Model API),
// which depend on the `vscode` module and cannot live in the SDK package.
// Must run before any handler is built (standalone utilities or task loop).
registerVsCodeLmHandler()
// =============== External services ===============
await ErrorService.initialize()
// Initialize PostHog client provider (skip in self-hosted mode)
if (!ClineEndpoint.isSelfHosted()) {
PostHogClientProvider.getInstance()
}
// =============== Webview services ===============
const webview = HostProvider.get().createWebviewProvider()
const stateManager = StateManager.get()
// Non-blocking announcement check and display
showVersionUpdateAnnouncement(stateManager)
// Check if this workspace was opened from worktree quick launch
await checkWorktreeAutoOpen(stateManager)
// =============== Background sync and cleanup tasks ===============
// Use remote config blobStoreConfig if available, otherwise fall back to env vars
const blobStoreSettings = stateManager.getRemoteConfigSettings()?.blobStoreConfig ?? getBlobStoreSettingsFromEnv()
syncWorker().init({ ...blobStoreSettings, userDistinctId: getDistinctId() })
// Clean up old temp files in background (non-blocking) and start periodic cleanup every 24 hours
ClineTempManager.startPeriodicCleanup()
telemetryService.captureExtensionActivated()
return webview
}
async function showVersionUpdateAnnouncement(stateManager: StateManager) {
// Version checking for autoupdate notification
const currentVersion = ExtensionRegistryInfo.version
const previousVersion = stateManager.getGlobalStateKey("clineVersion")
// Perform post-update actions if necessary
try {
if (!previousVersion || currentVersion !== previousVersion) {
Logger.log(`Cline version changed: ${previousVersion} -> ${currentVersion}. First run or update detected.`)
// Check if there's a new announcement to show
const lastShownAnnouncementId = stateManager.getGlobalStateKey("lastShownAnnouncementId")
const latestAnnouncementId = getLatestAnnouncementId()
if (lastShownAnnouncementId !== latestAnnouncementId) {
// Show notification when there's a new announcement (major/minor updates or fresh installs)
const message = previousVersion
? `Cline has been updated to v${currentVersion}`
: `Welcome to Cline v${currentVersion}`
HostProvider.window.showMessage({
type: ShowMessageType.INFORMATION,
message,
})
}
// Always update the main version tracker for the next launch.
stateManager.setGlobalState("clineVersion", currentVersion)
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logger.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
}
}
/**
* Checks if this workspace was opened from the worktree quick launch button.
* If so, opens the Cline sidebar and clears the state.
*/
async function checkWorktreeAutoOpen(stateManager: StateManager): Promise<void> {
try {
// Read directly from globalState (not StateManager cache) since this may have been
// set by another window right before this one opened
const worktreeAutoOpenPath = stateManager.getGlobalStateKey("worktreeAutoOpenPath")
if (!worktreeAutoOpenPath) {
return
}
// Get current workspace path
const workspacePaths = (await HostProvider.workspace.getWorkspacePaths({})).paths
if (workspacePaths.length === 0) {
return
}
const currentPath = workspacePaths[0]
// Check if current workspace matches the worktree path
if (arePathsEqual(currentPath, worktreeAutoOpenPath)) {
// Clear the state first to prevent re-triggering
stateManager.setGlobalState("worktreeAutoOpenPath", undefined)
// Open the Cline sidebar
await HostProvider.workspace.openClineSidebarPanel({})
}
} catch (error) {
Logger.error("Error checking worktree auto-open", error)
}
}
/**
* Performs cleanup when Cline is deactivated that is common to all platforms.
*/
export async function tearDown(): Promise<void> {
// Dispose all webview instances
await WebviewProvider.disposeAllInstances()
try {
AgentConfigLoader.getInstance()?.dispose()
PostHogClientProvider.getInstance().dispose()
telemetryService.dispose()
ErrorService.get().dispose()
featureFlagsService.dispose()
// Dispose all webview instances
await WebviewProvider.disposeAllInstances()
syncWorker().dispose()
clearOnboardingModelsCache()
// Kill any running hook processes to prevent zombies
await HookProcessRegistry.terminateAll()
// Clean up hook discovery cache
HookDiscoveryCache.getInstance().dispose()
// Stop periodic temp file cleanup
ClineTempManager.stopPeriodicCleanup()
} finally {
try {
await StateManager.get().flushPendingState()
} catch (error) {
Logger.error("[Cline] Failed to flush pending state during teardown:", error)
}
}
}
@@ -0,0 +1,32 @@
// Type definitions for FileContextTracker
export interface FileMetadataEntry {
path: string
record_state: "active" | "stale"
record_source: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned"
cline_read_date: number | null
cline_edit_date: number | null
user_edit_date?: number | null
}
interface ModelMetadataEntry {
ts: number
model_id: string
model_provider_id: string
mode: string
}
interface EnvironmentMetadataEntry {
ts: number
os_name: string
os_version: string
os_arch: string
host_name: string
host_version: string
cline_version: string
}
export interface TaskMetadata {
files_in_context: FileMetadataEntry[]
model_usage: ModelMetadataEntry[]
environment_history: EnvironmentMetadataEntry[]
}
@@ -0,0 +1,263 @@
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import * as actualDiskModule from "@core/storage/disk"
import { expect } from "chai"
import * as actualChokidar from "chokidar"
import * as path from "path"
import * as sinon from "sinon"
import * as vscode from "vscode"
import { Controller } from "@/core/controller"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import type { FileMetadataEntry, TaskMetadata } from "./ContextTrackerTypes"
// bun loads real ESM, so sinon cannot stub the `@core/storage/disk` and
// `chokidar` namespace exports ("ES Modules cannot be stubbed"). Inject
// module-level sinon stubs via mock.module so the full sinon stub API keeps
// working. (`vscode` is the writable unit-test stub, still sinon-stubbed below.)
const getTaskMetadataStub: sinon.SinonStub = sinon.stub()
const saveTaskMetadataStub: sinon.SinonStub = sinon.stub()
const chokidarWatchStub: sinon.SinonStub = sinon.stub()
const diskMock = () => ({
...actualDiskModule,
getTaskMetadata: getTaskMetadataStub,
saveTaskMetadata: saveTaskMetadataStub,
})
const chokidarNamespace = { ...actualChokidar, watch: chokidarWatchStub }
const chokidarMock = () => ({ ...chokidarNamespace, default: chokidarNamespace })
mock.module("@core/storage/disk", diskMock)
mock.module("@/core/storage/disk", diskMock)
mock.module("chokidar", chokidarMock)
import { FileContextTracker } from "./FileContextTracker"
describe("FileContextTracker", () => {
const filePath = "src/test-file.ts"
const taskId = "test-task-id"
let sandbox: sinon.SinonSandbox
let _mockWorkspace: sinon.SinonStub
let mockFileSystemWatcher: any
let tracker: FileContextTracker
let mockTaskMetadata: TaskMetadata
beforeEach(() => {
sandbox = sinon.createSandbox()
// Mock vscode workspace
_mockWorkspace = sandbox.stub(vscode.workspace, "workspaceFolders").value([
{
uri: {
fsPath: "/mock/workspace",
},
} as vscode.WorkspaceFolder,
])
// Mock chokidar file watcher
mockFileSystemWatcher = {
close: sandbox.stub().resolves(),
on: sandbox.stub(),
}
// Return the watcher itself for chaining
mockFileSystemWatcher.on.returns(mockFileSystemWatcher)
// Reset the module-level chokidar.watch stub to return our mock watcher
chokidarWatchStub.reset()
chokidarWatchStub.returns(mockFileSystemWatcher as any)
// Reset the module-level disk stubs
mockTaskMetadata = { files_in_context: [], model_usage: [], environment_history: [] }
getTaskMetadataStub.reset()
getTaskMetadataStub.resolves(mockTaskMetadata)
saveTaskMetadataStub.reset()
saveTaskMetadataStub.resolves()
setVscodeHostProviderMock()
// Create tracker instance
tracker = new FileContextTracker({} as Controller, taskId)
})
afterEach(() => {
sandbox.restore()
})
it("should add a record when a file is read by a tool", async () => {
await tracker.trackFileContext(filePath, "read_tool")
// Verify getTaskMetadata was called
expect(getTaskMetadataStub.calledOnce).to.be.true
expect(getTaskMetadataStub.firstCall.args[0]).to.equal(taskId)
// Verify saveTaskMetadata was called with the correct data
expect(saveTaskMetadataStub.calledOnce).to.be.true
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
expect(savedMetadata.files_in_context.length).to.equal(1)
const fileEntry = savedMetadata.files_in_context[0]
expect(fileEntry.path).to.equal(filePath)
expect(fileEntry.record_state).to.equal("active")
expect(fileEntry.record_source).to.equal("read_tool")
expect(fileEntry.cline_read_date).to.be.a("number")
expect(fileEntry.cline_edit_date).to.be.null
})
it("should add a record when a file is edited by Cline", async () => {
await tracker.trackFileContext(filePath, "cline_edited")
// Verify saveTaskMetadata was called with the correct data
expect(saveTaskMetadataStub.calledOnce).to.be.true
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
// Check that we have at least one entry in files_in_context
expect(savedMetadata.files_in_context).to.be.an("array").that.is.not.empty
// Find the active entry for this file
const activeEntry = savedMetadata.files_in_context.find(
(entry: FileMetadataEntry) => entry.path === filePath && entry.record_state === "active",
)
// Assert that we found an active entry
expect(activeEntry).to.exist
// Now check the properties of the active entry
expect(activeEntry.path).to.equal(filePath)
expect(activeEntry.record_state).to.equal("active")
expect(activeEntry.record_source).to.equal("cline_edited")
expect(activeEntry.cline_read_date).to.be.a("number")
expect(activeEntry.cline_edit_date).to.be.a("number")
})
it("should add a record when a file is mentioned", async () => {
await tracker.trackFileContext(filePath, "file_mentioned")
// Verify saveTaskMetadata was called with the correct data
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
const fileEntry = savedMetadata.files_in_context[0]
expect(fileEntry.path).to.equal(filePath)
expect(fileEntry.record_state).to.equal("active")
expect(fileEntry.record_source).to.equal("file_mentioned")
expect(fileEntry.cline_read_date).to.be.a("number")
expect(fileEntry.cline_edit_date).to.be.null
})
it("should add a record when a file is edited by the user", async () => {
await tracker.trackFileContext(filePath, "user_edited")
// Verify saveTaskMetadata was called with the correct data
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
const fileEntry = savedMetadata.files_in_context[0]
expect(fileEntry.path).to.equal(filePath)
expect(fileEntry.record_state).to.equal("active")
expect(fileEntry.record_source).to.equal("user_edited")
expect(fileEntry.user_edit_date).to.be.a("number")
// Verify the file was added to recentlyModifiedFiles
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
expect(modifiedFiles).to.include(filePath)
})
it("should mark existing entries as stale when adding a new entry for the same file", async () => {
// Add an initial entry
mockTaskMetadata.files_in_context = [
{
path: filePath,
record_state: "active",
record_source: "read_tool",
cline_read_date: Date.now() - 1000, // 1 second ago
cline_edit_date: null,
user_edit_date: null,
},
]
// Track a new operation on the same file
await tracker.trackFileContext(filePath, "cline_edited")
// Verify the metadata now has two entries - one stale and one active
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
expect(savedMetadata.files_in_context.length).to.equal(2)
// First entry should be marked as stale
expect(savedMetadata.files_in_context[0].record_state).to.equal("stale")
// New entry should be active
const newEntry = savedMetadata.files_in_context[1]
expect(newEntry.record_state).to.equal("active")
expect(newEntry.record_source).to.equal("cline_edited")
})
it("should setup a file watcher for tracked files", async () => {
await tracker.trackFileContext(filePath, "read_tool")
// Verify chokidar.watch was called
expect(chokidarWatchStub.called).to.be.true
// Verify change listener was set up
expect(mockFileSystemWatcher.on.called).to.be.true
})
it("should track user edits when file watcher detects changes", async () => {
// First track the file to set up the watcher
await tracker.trackFileContext(filePath, "read_tool")
// Reset the stubs to check the next calls
getTaskMetadataStub.resetHistory()
saveTaskMetadataStub.resetHistory()
// Create a spy on trackFileContext to verify it's called with the right parameters
const trackFileContextSpy = sandbox.spy(tracker, "trackFileContext")
// Get the callback that was registered with chokidar "change" event
const callback = mockFileSystemWatcher.on.firstCall.args[1]
// Directly call the callback to simulate a file change event
callback(vscode.Uri.file(path.resolve("/mock/workspace", filePath)))
// Verify trackFileContext was called with the right parameters
expect(trackFileContextSpy.calledWith(filePath, "user_edited")).to.be.true
// Verify the file was added to recentlyModifiedFiles
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
expect(modifiedFiles).to.include(filePath)
})
it("should not track Cline edits as user edits", async () => {
// First track the file to set up the watcher
await tracker.trackFileContext(filePath, "read_tool")
// Mark the file as edited by Cline
tracker.markFileAsEditedByCline(filePath)
// Reset the stubs to check the next calls
getTaskMetadataStub.resetHistory()
saveTaskMetadataStub.resetHistory()
// Create a spy on trackFileContext to verify it's not called
const trackFileContextSpy = sandbox.spy(tracker, "trackFileContext")
// Get the callback that was registered with chokidar "change" event
const callback = mockFileSystemWatcher.on.firstCall.args[1]
// Directly call the callback to simulate a file change event
callback(vscode.Uri.file(path.resolve("/mock/workspace", filePath)))
// Verify trackFileContext was not called with user_edited
expect(trackFileContextSpy.calledWith(filePath, "user_edited")).to.be.false
// Verify the file was not added to recentlyModifiedFiles
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
expect(modifiedFiles).to.not.include(filePath)
})
it("should dispose file watchers when dispose is called", async () => {
// Track a file to set up the watcher
await tracker.trackFileContext(filePath, "read_tool")
// Call dispose
await tracker.dispose()
// Verify the watcher was closed
expect(mockFileSystemWatcher.close.called).to.be.true
})
})
@@ -0,0 +1,279 @@
import { getTaskMetadata, saveTaskMetadata } from "@core/storage/disk"
import type { ClineMessage } from "@shared/ExtensionMessage"
import chokidar, { FSWatcher } from "chokidar"
import * as path from "path"
import { Controller } from "@/core/controller"
import { Logger } from "@/shared/services/Logger"
import { getCwd } from "@/utils/path"
import type { FileMetadataEntry } from "./ContextTrackerTypes"
// This class is responsible for tracking file operations that may result in stale context.
// If a user modifies a file outside of Cline, the context may become stale and need to be updated.
// We do not want Cline to reload the context every time a file is modified, so we use this class merely
// to inform Cline that the change has occurred, and tell Cline to reload the file before making
// any changes to it. This fixes an issue with diff editing, where Cline was unable to complete a diff edit.
// a diff edit because the file was modified since Cline last read it.
// FileContextTracker
/**
This class is responsible for tracking file operations.
If the full contents of a file are passed to Cline via a tool, mention, or edit, the file is marked as active.
If a file is modified outside of Cline, we detect and track this change to prevent stale context.
This is used when restoring a task (non-git "checkpoint" restore), and mid-task.
*/
export class FileContextTracker {
private controller: Controller
readonly taskId: string
// File tracking and watching
private fileWatchers = new Map<string, FSWatcher>()
private recentlyModifiedFiles = new Set<string>()
private recentlyEditedByCline = new Set<string>()
constructor(controller: Controller, taskId: string) {
this.controller = controller
this.taskId = taskId
}
/**
* File watchers are set up for each file that is tracked in the task metadata.
*/
async setupFileWatcher(filePath: string) {
// Only setup watcher if it doesn't already exist for this file
if (this.fileWatchers.has(filePath)) {
return
}
const cwd = await getCwd()
if (!cwd) {
Logger.info("No workspace folder available - cannot determine current working directory")
return
}
// Create a chokidar file watcher for this specific file
const resolvedFilePath = path.resolve(cwd, filePath)
const watcher = chokidar.watch(resolvedFilePath, {
persistent: true, // Keep process alive while watching
ignoreInitial: true, // Don't emit events for existing files on startup
atomic: true, // Handle atomic writes (editors that use temp files)
awaitWriteFinish: {
// Wait for writes to finish before emitting events
stabilityThreshold: 100, // Wait 100ms for file size to stabilize
pollInterval: 100, // Check every 100ms while waiting
},
})
// Track file changes
watcher.on("change", () => {
if (this.recentlyEditedByCline.has(filePath)) {
this.recentlyEditedByCline.delete(filePath) // This was an edit by Cline, no need to inform Cline
} else {
this.recentlyModifiedFiles.add(filePath) // This was a user edit, we will inform Cline
this.trackFileContext(filePath, "user_edited") // Update the task metadata with file tracking
}
})
// Store the watcher so we can dispose it later
this.fileWatchers.set(filePath, watcher)
}
/**
* Tracks a file operation in metadata and sets up a watcher for the file
* This is the main entry point for FileContextTracker and is called when a file is passed to Cline via a tool, mention, or edit.
*/
async trackFileContext(filePath: string, operation: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned") {
try {
const cwd = await getCwd()
if (!cwd) {
Logger.info("No workspace folder available - cannot determine current working directory")
return
}
// Add file to metadata
await this.addFileToFileContextTracker(this.taskId, filePath, operation)
// Set up file watcher for this file
await this.setupFileWatcher(filePath)
} catch (error) {
Logger.error("Failed to track file operation:", error)
}
}
/**
* Adds a file to the metadata tracker
* This handles the business logic of determining if the file is new, stale, or active.
* It also updates the metadata with the latest read/edit dates.
*/
async addFileToFileContextTracker(taskId: string, filePath: string, source: FileMetadataEntry["record_source"]) {
try {
const metadata = await getTaskMetadata(taskId)
const now = Date.now()
// Mark existing entries for this file as stale
metadata.files_in_context.forEach((entry) => {
if (entry.path === filePath && entry.record_state === "active") {
entry.record_state = "stale"
}
})
// Helper to get the latest date for a specific field and file
const getLatestDateForField = (path: string, field: keyof FileMetadataEntry): number | null => {
const relevantEntries = metadata.files_in_context
.filter((entry) => entry.path === path && entry[field])
.sort((a, b) => (b[field] as number) - (a[field] as number))
return relevantEntries.length > 0 ? (relevantEntries[0][field] as number) : null
}
const newEntry: FileMetadataEntry = {
path: filePath,
record_state: "active",
record_source: source,
cline_read_date: getLatestDateForField(filePath, "cline_read_date"),
cline_edit_date: getLatestDateForField(filePath, "cline_edit_date"),
user_edit_date: getLatestDateForField(filePath, "user_edit_date"),
}
switch (source) {
// user_edited: The user has edited the file
case "user_edited":
newEntry.user_edit_date = now
this.recentlyModifiedFiles.add(filePath)
break
// cline_edited: Cline has edited the file
case "cline_edited":
newEntry.cline_read_date = now
newEntry.cline_edit_date = now
break
// read_tool/file_mentioned: Cline has read the file via a tool or file mention
case "read_tool":
case "file_mentioned":
newEntry.cline_read_date = now
break
}
metadata.files_in_context.push(newEntry)
await saveTaskMetadata(taskId, metadata)
} catch (error) {
Logger.error("Failed to add file to metadata:", error)
}
}
/**
* Returns (and then clears) the set of recently modified files
*/
getAndClearRecentlyModifiedFiles(): string[] {
const files = Array.from(this.recentlyModifiedFiles)
this.recentlyModifiedFiles.clear()
return files
}
/**
* Marks a file as edited by Cline to prevent false positives in file watchers
*/
markFileAsEditedByCline(filePath: string): void {
this.recentlyEditedByCline.add(filePath)
}
/**
* Disposes all file watchers
*/
async dispose(): Promise<void> {
const closePromises = Array.from(this.fileWatchers.values()).map((watcher) => watcher.close())
await Promise.all(closePromises)
this.fileWatchers.clear()
}
/**
* Detects files that were edited by Cline or users after a specific message timestamp
* This is used when restoring checkpoints to warn about potential file content mismatches
*/
async detectFilesEditedAfterMessage(messageTs: number, deletedMessages: ClineMessage[]): Promise<string[]> {
const editedFiles: string[] = []
try {
// Check task metadata for files that were edited by Cline or users after the message timestamp
const taskMetadata = await getTaskMetadata(this.taskId)
if (taskMetadata?.files_in_context) {
for (const fileEntry of taskMetadata.files_in_context) {
const clineEditedAfter = fileEntry.cline_edit_date && fileEntry.cline_edit_date > messageTs
const userEditedAfter = fileEntry.user_edit_date && fileEntry.user_edit_date > messageTs
if (clineEditedAfter || userEditedAfter) {
editedFiles.push(fileEntry.path)
}
}
}
} catch (error) {
Logger.error("Error checking file context metadata:", error)
}
// Also check deleted task messages for file operations
for (const message of deletedMessages) {
if (message.say === "tool" && message.text) {
try {
const toolData = JSON.parse(message.text)
if ((toolData.tool === "editedExistingFile" || toolData.tool === "newFileCreated") && toolData.path) {
if (!editedFiles.includes(toolData.path)) {
editedFiles.push(toolData.path)
}
}
} catch (error) {
Logger.error("Error checking task messages:", error)
}
}
}
return [...new Set(editedFiles)]
}
/**
* Stores pending file context warning in workspace state so it persists across task reinitialization
*/
async storePendingFileContextWarning(files: string[]): Promise<void> {
try {
const key = `pendingFileContextWarning_${this.taskId}`
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
this.controller.stateManager.setWorkspaceState(key as any, files)
} catch (error) {
Logger.error("Error storing pending file context warning:", error)
}
}
/**
* Retrieves pending file context warning from workspace state (without clearing it)
*/
async retrievePendingFileContextWarning(): Promise<string[] | undefined> {
try {
const key = `pendingFileContextWarning_${this.taskId}`
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
const files = this.controller.stateManager.getWorkspaceStateKey(key as any) as string[]
return files
} catch (error) {
Logger.error("Error retrieving pending file context warning:", error)
}
return undefined
}
/**
* Retrieves and clears pending file context warning from workspace state
*/
async retrieveAndClearPendingFileContextWarning(): Promise<string[] | undefined> {
try {
const files = await this.retrievePendingFileContextWarning()
if (files) {
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
this.controller.stateManager.setWorkspaceState(`pendingFileContextWarning_${this.taskId}` as any, undefined)
return files
}
} catch (error) {
Logger.error("Error retrieving pending file context warning:", error)
}
return undefined
}
}
@@ -0,0 +1,59 @@
import { describe, it } from "bun:test"
import { expect } from "chai"
import { parseYamlFrontmatter } from "../frontmatter"
describe("parseYamlFrontmatter", () => {
it("returns original content when no frontmatter", () => {
const input = "Just text"
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(false)
expect(result.data).to.deep.equal({})
expect(result.body).to.equal(input)
})
it("parses valid YAML frontmatter", () => {
const input = `---\npaths:\n - "src/**"\n---\n\nHello`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.parseError).to.equal(undefined)
expect(result.data).to.deep.equal({ paths: ["src/**"] })
expect(result.body.trim()).to.equal("Hello")
})
it("fails open on malformed YAML", () => {
const input = `---\npaths: [invalid\n---\nBody`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.data).to.deep.equal({})
expect(result.body).to.equal(input)
expect(result.parseError).to.be.a("string")
})
it("rejects YAML custom tags (security: prevents unsafe deserialization)", () => {
// !!js/function is the classic RCE vector in js-yaml v3.
// With JSON_SCHEMA, any custom tag should be rejected.
const input = `---\nfoo: !!js/function 'function(){ return 1 }'\n---\nBody`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.data).to.deep.equal({})
expect(result.body).to.equal(input)
expect(result.parseError).to.be.a("string")
})
it("rejects !!python/object YAML tag", () => {
const input = `---\nfoo: !!python/object:os.system 'echo pwned'\n---\nBody`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.data).to.deep.equal({})
expect(result.parseError).to.be.a("string")
})
it("parses JSON-compatible YAML values correctly", () => {
const input = `---\ncount: 42\nenabled: true\ntags:\n - "a"\n - "b"\n---\nContent`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.parseError).to.equal(undefined)
expect(result.data).to.deep.equal({ count: 42, enabled: true, tags: ["a", "b"] })
expect(result.body.trim()).to.equal("Content")
})
})
@@ -0,0 +1,74 @@
import { describe, it } from "bun:test"
import { expect } from "chai"
import { evaluateRuleConditionals, extractPathLikeStrings } from "../rule-conditionals"
describe("rule-conditionals", () => {
describe("evaluateRuleConditionals(paths)", () => {
it("treats missing paths as universal", () => {
const res = evaluateRuleConditionals({}, { paths: [] })
expect(res.passed).to.equal(true)
})
it("treats empty paths list in frontmatter as match-nothing (fail-closed)", () => {
const res = evaluateRuleConditionals({ paths: [] }, { paths: ["src/index.ts"] })
expect(res.passed).to.equal(false)
})
it("does not activate path-scoped rules with empty context", () => {
const res = evaluateRuleConditionals({ paths: ["src/**"] }, { paths: [] })
expect(res.passed).to.equal(false)
})
it("matches when any candidate path matches any glob", () => {
const res = evaluateRuleConditionals({ paths: ["src/**", "apps/**"] }, { paths: ["src/index.ts"] })
expect(res.passed).to.equal(true)
expect(res.matchedConditions.paths).to.deep.equal(["src/**"])
})
it("ignores invalid paths type (fail-open)", () => {
const res = evaluateRuleConditionals({ paths: "src/**" as any }, { paths: [] })
expect(res.passed).to.equal(true)
})
})
describe("extractPathLikeStrings", () => {
it("extracts basic relative paths", () => {
const res = extractPathLikeStrings("edit apps/web/src/App.tsx and packages/foo/src")
expect(res).to.deep.equal(["apps/web/src/App.tsx", "packages/foo/src"])
})
it("extracts simple filenames with extensions (no slashes)", () => {
const res = extractPathLikeStrings("Does foo.md exist? If not, create foo.md")
expect(res).to.deep.equal(["foo.md"])
})
it("does not extract bare words without an extension", () => {
const res = extractPathLikeStrings("Please create foo and then update bar")
expect(res).to.deep.equal([])
})
it("ignores URLs", () => {
const res = extractPathLikeStrings("see https://example.com/a/b and edit src/index.ts")
expect(res).to.deep.equal(["src/index.ts"])
})
it("ignores fenced code blocks", () => {
const text =
"Please update src/index.ts\n\n```ts\n// example code\nconst p = 'apps/web/src/App.tsx'\n// also: packages/foo/src\n```\n\nThanks!"
const res = extractPathLikeStrings(text)
expect(res).to.deep.equal(["src/index.ts"])
})
it("does not extract URLs inside code fences", () => {
const text = "```\nSee https://example.com/a/b and src/index.ts\n```\nBut edit docs/readme.md"
const res = extractPathLikeStrings(text)
expect(res).to.deep.equal(["docs/readme.md"])
})
it("extracts paths from stack traces (outside code fences)", () => {
const text = "Error: boom\n at foo (src/index.ts:12:3)\n at bar (apps/web/src/App.tsx:5:1)"
const res = extractPathLikeStrings(text)
expect(res).to.deep.equal(["src/index.ts", "apps/web/src/App.tsx"])
})
})
})
@@ -0,0 +1,118 @@
import { describe, it } from "bun:test"
import { expect } from "chai"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { getRuleFilesTotalContentWithMetadata } from "../rule-helpers"
describe("rule loading with paths frontmatter", () => {
it("filters rules by evaluationContext.paths", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
try {
const rulesDir = path.join(tmp, ".clinerules")
await fs.mkdir(rulesDir, { recursive: true })
await fs.writeFile(path.join(rulesDir, "universal.md"), "Always on")
await fs.writeFile(path.join(rulesDir, "scoped.md"), `---\npaths:\n - "src/**"\n---\n\nOnly for src`)
const files = ["universal.md", "scoped.md"]
const toggles: Record<string, boolean> = {
[path.join(rulesDir, "universal.md")]: true,
[path.join(rulesDir, "scoped.md")]: true,
}
const res1 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["src/index.ts"] },
})
expect(res1.content).to.contain("universal.md")
expect(res1.content).to.contain("scoped.md")
expect(res1.content).to.not.contain("paths:")
expect(res1.activatedConditionalRules.map((r) => r.name)).to.include("global:scoped.md")
const res2 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["docs/readme.md"] },
})
expect(res2.content).to.contain("universal.md")
expect(res2.content).to.not.contain("scoped.md")
} finally {
await fs.rm(tmp, { recursive: true, force: true })
}
})
it("treats invalid YAML frontmatter as fail-open and preserves the raw frontmatter for the LLM", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
try {
const rulesDir = path.join(tmp, ".clinerules")
await fs.mkdir(rulesDir, { recursive: true })
// Intentionally invalid YAML (unquoted '*' is a YAML alias indicator)
await fs.writeFile(
path.join(rulesDir, "invalid.md"),
`---\npaths: *\n---\n\nInvalid YAML, but should still be included`,
)
const files = ["invalid.md"]
const toggles: Record<string, boolean> = {
[path.join(rulesDir, "invalid.md")]: true,
}
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["src/index.ts"] },
})
// Fail-open: included even though frontmatter cannot be parsed.
expect(res.content).to.contain("invalid.md")
// Preserve raw frontmatter fence/content for the LLM.
expect(res.content).to.contain("---")
expect(res.content).to.contain("paths:")
} finally {
await fs.rm(tmp, { recursive: true, force: true })
}
})
it("treats paths: [] as match-nothing (fail-closed)", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
try {
const rulesDir = path.join(tmp, ".clinerules")
await fs.mkdir(rulesDir, { recursive: true })
await fs.writeFile(path.join(rulesDir, "scoped-empty.md"), `---\npaths: []\n---\n\nShould never activate`)
const files = ["scoped-empty.md"]
const toggles: Record<string, boolean> = {
[path.join(rulesDir, "scoped-empty.md")]: true,
}
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["src/index.ts"] },
})
expect(res.content).to.not.contain("scoped-empty.md")
} finally {
await fs.rm(tmp, { recursive: true, force: true })
}
})
it("keeps activatedConditionalRules order stable (matches input file order)", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
try {
const rulesDir = path.join(tmp, ".clinerules")
await fs.mkdir(rulesDir, { recursive: true })
await fs.writeFile(path.join(rulesDir, "a.md"), `---\npaths:\n - "src/**"\n---\n\nA`)
await fs.writeFile(path.join(rulesDir, "b.md"), `---\npaths:\n - "src/**"\n---\n\nB`)
await fs.writeFile(path.join(rulesDir, "c.md"), `---\npaths:\n - "src/**"\n---\n\nC`)
const files = ["a.md", "b.md", "c.md"]
const toggles: Record<string, boolean> = {
[path.join(rulesDir, "a.md")]: true,
[path.join(rulesDir, "b.md")]: true,
[path.join(rulesDir, "c.md")]: true,
}
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["src/index.ts"] },
})
expect(res.activatedConditionalRules.map((r) => r.name)).to.deep.equal(files.map((f) => `global:${f}`))
} finally {
await fs.rm(tmp, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,837 @@
/**
* Unit tests for skills utility functions
* Tests skill discovery, override resolution, toggle filtering, and content loading
*/
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
import { expect } from "chai"
import * as actualFsPromises from "fs/promises"
import * as path from "path"
import * as sinon from "sinon"
import * as actualSkillDirectories from "@/core/storage/skill-directories"
import { Logger } from "@/shared/services/Logger"
import * as actualFsUtils from "@/utils/fs"
// bun loads real ESM, so sinon cannot stub the `@utils/fs`,
// `@core/storage/skill-directories`, or the `fs/promises` namespace exports
// ("ES Modules cannot be stubbed"). Crucially, under bun `fs.promises` and the
// `fs/promises` module are NOT the same object, so stubbing `fs.promises.X`
// (which worked under mocha/ts-node) does not affect the SUT's
// `import * as fs from "fs/promises"` bindings. Inject module-level sinon stubs
// via bun's mock.module so the full sinon stub API (.withArgs/.resolves/etc.)
// keeps working through the exact specifiers the SUT imports.
const fileExistsAtPathStub = sinon.stub()
const isDirectoryStub_ = sinon.stub()
const getSkillsDirectoriesForScanStub = sinon.stub()
const readdirStub_ = sinon.stub()
const statStub_ = sinon.stub()
const readFileStub_ = sinon.stub()
const writeFileStub_ = sinon.stub()
const fsUtilsMock = () => ({
...actualFsUtils,
fileExistsAtPath: fileExistsAtPathStub,
isDirectory: isDirectoryStub_,
})
const skillDirsMock = () => ({
...actualSkillDirectories,
getSkillsDirectoriesForScan: getSkillsDirectoriesForScanStub,
})
const fsPromisesMockNamespace = {
...actualFsPromises,
readdir: readdirStub_,
stat: statStub_,
readFile: readFileStub_,
writeFile: writeFileStub_,
}
const fsPromisesMock = () => ({ ...fsPromisesMockNamespace, default: fsPromisesMockNamespace })
// The SUT imports via the `@utils/*` and `@core/*` tsconfig path aliases;
// register both the `@/`-prefixed and bare-alias forms to be safe.
mock.module("@utils/fs", fsUtilsMock)
mock.module("@/utils/fs", fsUtilsMock)
mock.module("@core/storage/skill-directories", skillDirsMock)
mock.module("@/core/storage/skill-directories", skillDirsMock)
mock.module("fs/promises", fsPromisesMock)
mock.module("node:fs/promises", fsPromisesMock)
import { parseYamlFrontmatter } from "../frontmatter"
import {
discoverSkills,
getAvailableSkills,
getSkillContent,
parseRemoteSkillEntries,
setSkillDisabledInFrontmatter,
updateSkillMarkdownDisabledState,
} from "../skills"
describe("Skills Utility Functions", () => {
let sandbox: sinon.SinonSandbox
let fileExistsStub: sinon.SinonStub
let isDirectoryStub: sinon.SinonStub
let readdirStub: sinon.SinonStub
let statStub: sinon.SinonStub
let readFileStub: sinon.SinonStub
// Use path.join for OS-independent paths
const TEST_CWD = path.join("/test", "project")
const GLOBAL_SKILLS_DIR = path.join("/home", "user", ".cline", "skills")
beforeEach(() => {
sandbox = sinon.createSandbox()
// Stub Logger.warn to avoid noise in test output
sandbox.stub(Logger, "warn")
// Reset the module-level sinon stubs (injected via mock.module above) and
// re-point the per-test handles at them.
fileExistsAtPathStub.reset()
isDirectoryStub_.reset()
getSkillsDirectoriesForScanStub.reset()
readdirStub_.reset()
statStub_.reset()
readFileStub_.reset()
writeFileStub_.reset()
fileExistsStub = fileExistsAtPathStub
isDirectoryStub = isDirectoryStub_
readdirStub = readdirStub_
statStub = statStub_
readFileStub = readFileStub_
getSkillsDirectoriesForScanStub.returns([
{ path: path.join(TEST_CWD, ".clinerules", "skills"), source: "project" },
{ path: path.join(TEST_CWD, ".cline", "skills"), source: "project" },
{ path: path.join(TEST_CWD, ".claude", "skills"), source: "project" },
{ path: path.join(TEST_CWD, ".agents", "skills"), source: "project" },
{ path: GLOBAL_SKILLS_DIR, source: "global" },
{ path: path.join("/home", "user", ".agents", "skills"), source: "global" },
])
// Default: no directories exist
fileExistsStub.resolves(false)
isDirectoryStub.resolves(false)
})
afterEach(() => {
sandbox.restore()
})
describe("discoverSkills", () => {
it("should discover skills from global directory", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: my-skill
description: A test skill
---
Instructions here`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("my-skill")
expect(skills[0].description).to.equal("A test skill")
expect(skills[0].source).to.equal("global")
})
it("should discover skills from project .clinerules/skills directory", async () => {
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
const skillDir = path.join(projectSkillsDir, "explaining-code")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(projectSkillsDir).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(projectSkillsDir).resolves(true)
readdirStub.withArgs(projectSkillsDir).resolves(["explaining-code"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: explaining-code
description: Explains code with diagrams and analogies
---
Use analogies and ASCII diagrams when explaining code.`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("explaining-code")
expect(skills[0].source).to.equal("project")
})
it("should discover skills from project .cline/skills directory", async () => {
const clineSkillsDir = path.join(TEST_CWD, ".cline", "skills")
const skillDir = path.join(clineSkillsDir, "debugging")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(clineSkillsDir).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(clineSkillsDir).resolves(true)
readdirStub.withArgs(clineSkillsDir).resolves(["debugging"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: debugging
description: Debug code systematically
---
Use systematic debugging approaches.`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("debugging")
expect(skills[0].source).to.equal("project")
})
it("should discover skills from project .claude/skills directory", async () => {
const claudeSkillsDir = path.join(TEST_CWD, ".claude", "skills")
const skillDir = path.join(claudeSkillsDir, "coding")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(claudeSkillsDir).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(claudeSkillsDir).resolves(true)
readdirStub.withArgs(claudeSkillsDir).resolves(["coding"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: coding
description: Write clean code
---
Follow best practices.`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("coding")
expect(skills[0].source).to.equal("project")
})
it("should discover skills from project .agents/skills directory", async () => {
const agentsSkillsDir = path.join(TEST_CWD, ".agents", "skills")
const skillDir = path.join(agentsSkillsDir, "testing")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(agentsSkillsDir).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(agentsSkillsDir).resolves(true)
readdirStub.withArgs(agentsSkillsDir).resolves(["testing"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: testing
description: Write comprehensive tests
---
Always write tests.`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("testing")
expect(skills[0].source).to.equal("project")
})
it("should handle empty skills directories gracefully", async () => {
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves([])
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
})
it("should skip non-directory entries in skills folder", async () => {
const readmePath = path.join(GLOBAL_SKILLS_DIR, "README.md")
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["README.md", "my-skill"])
statStub.withArgs(readmePath).resolves({ isDirectory: () => false })
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
fileExistsStub.withArgs(skillMdPath).resolves(true)
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: my-skill
description: A skill
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("my-skill")
})
it("should skip skill directories without SKILL.md", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "incomplete-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["incomplete-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
fileExistsStub.withArgs(skillMdPath).resolves(false)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
})
})
describe("getAvailableSkills - Override Resolution", () => {
it("should override project skill with global skill of same name", async () => {
const globalSkillDir = path.join(GLOBAL_SKILLS_DIR, "coding")
const globalSkillMdPath = path.join(globalSkillDir, "SKILL.md")
// Setup global skill (higher priority)
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(globalSkillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["coding"])
statStub.withArgs(globalSkillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(globalSkillMdPath, "utf-8").resolves(`---
name: coding
description: Global coding skill
---
Global instructions`)
// Setup project skill with same name (lower priority)
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
const projectSkillDir = path.join(projectSkillsDir, "coding")
const projectSkillMdPath = path.join(projectSkillDir, "SKILL.md")
fileExistsStub.withArgs(projectSkillsDir).resolves(true)
fileExistsStub.withArgs(projectSkillMdPath).resolves(true)
isDirectoryStub.withArgs(projectSkillsDir).resolves(true)
readdirStub.withArgs(projectSkillsDir).resolves(["coding"])
statStub.withArgs(projectSkillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(projectSkillMdPath, "utf-8").resolves(`---
name: coding
description: Project coding skill
---
Project instructions`)
const allSkills = await discoverSkills(TEST_CWD)
const skills = getAvailableSkills(allSkills)
expect(skills).to.have.lengthOf(1)
expect(skills[0].description).to.equal("Global coding skill")
expect(skills[0].source).to.equal("global")
})
it("should keep both skills when names are different", async () => {
const globalSkillDir = path.join(GLOBAL_SKILLS_DIR, "global-skill")
const globalSkillMdPath = path.join(globalSkillDir, "SKILL.md")
// Setup global skill
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(globalSkillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["global-skill"])
statStub.withArgs(globalSkillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(globalSkillMdPath, "utf-8").resolves(`---
name: global-skill
description: A global skill
---
Content`)
// Setup project skill with different name
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
const projectSkillDir = path.join(projectSkillsDir, "project-skill")
const projectSkillMdPath = path.join(projectSkillDir, "SKILL.md")
fileExistsStub.withArgs(projectSkillsDir).resolves(true)
fileExistsStub.withArgs(projectSkillMdPath).resolves(true)
isDirectoryStub.withArgs(projectSkillsDir).resolves(true)
readdirStub.withArgs(projectSkillsDir).resolves(["project-skill"])
statStub.withArgs(projectSkillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(projectSkillMdPath, "utf-8").resolves(`---
name: project-skill
description: A project skill
---
Content`)
const allSkills = await discoverSkills(TEST_CWD)
const skills = getAvailableSkills(allSkills)
expect(skills).to.have.lengthOf(2)
const names = skills.map((s) => s.name)
expect(names).to.include("global-skill")
expect(names).to.include("project-skill")
})
})
describe("Metadata Validation", () => {
it("should reject skill with missing name field", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "bad-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["bad-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
description: Missing name
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
sinon.assert.calledWithMatch(Logger.warn as sinon.SinonStub, /missing required 'name' field/)
})
it("should reject skill with missing description field", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "bad-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["bad-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: bad-skill
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
sinon.assert.calledWithMatch(Logger.warn as sinon.SinonStub, /missing required 'description' field/)
})
it("should reject skill when name doesn't match directory name", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-dir")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-dir"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: different-name
description: Mismatched name
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
sinon.assert.calledWithMatch(Logger.warn as sinon.SinonStub, /doesn't match directory/)
})
it("should handle malformed YAML frontmatter gracefully", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "bad-yaml")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["bad-yaml"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: [invalid yaml
description: broken
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
})
it("should handle file without frontmatter", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "no-front")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["no-front"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`Just plain markdown content without frontmatter`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
})
})
describe("getSkillContent", () => {
it("should load full skill content with instructions", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: my-skill
description: Test skill
---
These are the detailed instructions.
## Step 1
Do this first.
## Step 2
Then do this.`)
const allSkills = await discoverSkills(TEST_CWD)
const availableSkills = getAvailableSkills(allSkills)
const content = await getSkillContent("my-skill", availableSkills)
expect(content).to.not.be.null
expect(content!.name).to.equal("my-skill")
expect(content!.instructions).to.include("These are the detailed instructions")
expect(content!.instructions).to.include("Step 1")
expect(content!.instructions).to.include("Step 2")
})
it("should return null for non-existent skill", async () => {
const content = await getSkillContent("non-existent", [])
expect(content).to.be.null
})
it("should trim whitespace from instructions", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: my-skill
description: Test
---
Instructions with whitespace
`)
const allSkills = await discoverSkills(TEST_CWD)
const availableSkills = getAvailableSkills(allSkills)
const content = await getSkillContent("my-skill", availableSkills)
expect(content!.instructions).to.equal("Instructions with whitespace")
})
})
describe("Remote Skills", () => {
// entry.name must match frontmatter name (enforced by parseRemoteSkillEntries)
const makeEntry = (name: string, desc: string, body = "Instructions", alwaysEnabled = false) => ({
name,
alwaysEnabled,
contents: `---\nname: ${name}\ndescription: ${desc}\n---\n${body}`,
})
describe("parseRemoteSkillEntries", () => {
it("should return validated entries when entry.name matches frontmatter.name", () => {
const entries = [
{ name: "Deploy", alwaysEnabled: true, contents: `---\nname: Deploy\ndescription: CI/CD\n---\nBody` },
]
const result = parseRemoteSkillEntries(entries)
expect(result).to.have.lengthOf(1)
expect(result[0].name).to.equal("Deploy")
expect(result[0].description).to.equal("CI/CD")
expect(result[0].alwaysEnabled).to.equal(true)
})
it("should warn but still include entries where entry.name drifts from frontmatter.name", () => {
const entries = [
{
name: "entry-key",
alwaysEnabled: false,
contents: `---\nname: Different Name\ndescription: Desc\n---\nBody`,
},
]
const result = parseRemoteSkillEntries(entries)
expect(result).to.have.lengthOf(1)
expect(result[0].name).to.equal("Different Name")
sinon.assert.calledWithMatch(Logger.warn as sinon.SinonStub, /does not match frontmatter\.name/)
})
it("should skip entries with missing frontmatter name", () => {
const entries = [{ name: "bad", alwaysEnabled: false, contents: `---\ndescription: No name\n---\nContent` }]
const result = parseRemoteSkillEntries(entries)
expect(result).to.have.lengthOf(0)
})
it("should skip entries with missing frontmatter description", () => {
const entries = [{ name: "No Desc", alwaysEnabled: false, contents: `---\nname: No Desc\n---\nContent` }]
const result = parseRemoteSkillEntries(entries)
expect(result).to.have.lengthOf(0)
})
it("should handle empty array", () => {
expect(parseRemoteSkillEntries([])).to.have.lengthOf(0)
})
it("should include all entries with valid frontmatter even with drift", () => {
const entries = [
{ name: "Good", alwaysEnabled: false, contents: `---\nname: Good\ndescription: Valid\n---\nBody` },
{ name: "drift", alwaysEnabled: false, contents: `---\nname: Different\ndescription: Drifted\n---\nBody` },
{
name: "Also Good",
alwaysEnabled: true,
contents: `---\nname: Also Good\ndescription: Valid too\n---\nBody`,
},
]
const result = parseRemoteSkillEntries(entries)
expect(result).to.have.lengthOf(3)
expect(result[0].name).to.equal("Good")
expect(result[1].name).to.equal("Different")
expect(result[2].name).to.equal("Also Good")
})
})
describe("discoverSkills - remote skill discovery", () => {
it("should include remote skills from remote config", async () => {
const entries = [makeEntry("Deploy Pipeline", "Handles CI/CD deployment", "Deploy instructions")]
const skills = await discoverSkills(TEST_CWD, entries)
const remoteSkill = skills.find((s) => s.name === "Deploy Pipeline")
expect(remoteSkill).to.not.be.undefined
expect(remoteSkill!.path).to.equal("remote:Deploy Pipeline")
expect(remoteSkill!.source).to.equal("global")
expect(remoteSkill!.description).to.equal("Handles CI/CD deployment")
})
it("should use frontmatter.name as identity even when entry.name drifts", async () => {
const entries = [
{ name: "entry-key", alwaysEnabled: false, contents: `---\nname: Actual Name\ndescription: Desc\n---\nBody` },
]
const skills = await discoverSkills(TEST_CWD, entries)
const remoteSkill = skills.find((s) => s.path?.startsWith("remote:"))
expect(remoteSkill).to.not.be.undefined
expect(remoteSkill!.name).to.equal("Actual Name")
expect(remoteSkill!.path).to.equal("remote:Actual Name")
})
it("should skip remote skills with missing frontmatter name", async () => {
const entries = [{ name: "bad", alwaysEnabled: false, contents: `---\ndescription: No name\n---\nContent` }]
const skills = await discoverSkills(TEST_CWD, entries)
expect(skills.find((s) => s.path?.startsWith("remote:"))).to.be.undefined
})
it("should skip remote skills with missing frontmatter description", async () => {
const entries = [{ name: "No Desc", alwaysEnabled: false, contents: `---\nname: No Desc\n---\nContent` }]
const skills = await discoverSkills(TEST_CWD, entries)
expect(skills.find((s) => s.path?.startsWith("remote:"))).to.be.undefined
})
it("should handle empty and undefined entries gracefully", async () => {
for (const val of [[], undefined]) {
const skills = await discoverSkills(TEST_CWD, val)
expect(skills.filter((s) => s.path?.startsWith("remote:"))).to.have.lengthOf(0)
}
})
})
describe("Override resolution (remote > disk-global > project)", () => {
it("remote overrides disk-global skill of same name", async () => {
const entries = [makeEntry("coding", "Remote coding")]
const diskGlobalDir = path.join(GLOBAL_SKILLS_DIR, "coding")
const diskGlobalMd = path.join(diskGlobalDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(diskGlobalMd).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["coding"])
statStub.withArgs(diskGlobalDir).resolves({ isDirectory: () => true })
readFileStub
.withArgs(diskGlobalMd, "utf-8")
.resolves(`---\nname: coding\ndescription: Disk global coding\n---\nDisk`)
const available = getAvailableSkills(await discoverSkills(TEST_CWD, entries))
expect(available).to.have.lengthOf(1)
expect(available[0].description).to.equal("Remote coding")
expect(available[0].path).to.equal("remote:coding")
})
it("remote overrides project skill of same name", async () => {
const entries = [makeEntry("coding", "Remote coding")]
const projDir = path.join(TEST_CWD, ".clinerules", "skills")
const projSkillDir = path.join(projDir, "coding")
const projMd = path.join(projSkillDir, "SKILL.md")
fileExistsStub.withArgs(projDir).resolves(true)
fileExistsStub.withArgs(projMd).resolves(true)
isDirectoryStub.withArgs(projDir).resolves(true)
readdirStub.withArgs(projDir).resolves(["coding"])
statStub.withArgs(projSkillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(projMd, "utf-8").resolves(`---\nname: coding\ndescription: Project coding\n---\nProject`)
const available = getAvailableSkills(await discoverSkills(TEST_CWD, entries))
expect(available).to.have.lengthOf(1)
expect(available[0].description).to.equal("Remote coding")
expect(available[0].path).to.equal("remote:coding")
})
})
describe("getSkillContent - remote skill content loading", () => {
it("should load content from provided entries for remote skills", async () => {
const entries = [makeEntry("Deploy Pipeline", "Deployment skill", "These are the deployment instructions.")]
const skill = {
name: "Deploy Pipeline",
description: "Deployment skill",
path: "remote:Deploy Pipeline",
source: "global" as const,
}
const content = await getSkillContent("Deploy Pipeline", [skill], entries)
expect(content).to.not.be.null
expect(content!.name).to.equal("Deploy Pipeline")
expect(content!.instructions).to.equal("These are the deployment instructions.")
})
it("should trim whitespace from remote skill instructions", async () => {
const entries = [makeEntry("Trim Skill", "Test", "\n Instructions with whitespace \n\n")]
const skill = { name: "Trim Skill", description: "Test", path: "remote:Trim Skill", source: "global" as const }
const content = await getSkillContent("Trim Skill", [skill], entries)
expect(content!.instructions).to.equal("Instructions with whitespace")
})
it("should return null if remote skill entry not found in entries", async () => {
const skill = { name: "Gone", description: "Removed", path: "remote:Gone", source: "global" as const }
const content = await getSkillContent("Gone", [skill], [])
expect(content).to.be.null
})
it("should not attempt disk read for remote skills", async () => {
const entries = [makeEntry("Remote Only", "Test", "Remote content")]
const skill = { name: "Remote Only", description: "Test", path: "remote:Remote Only", source: "global" as const }
await getSkillContent("Remote Only", [skill], entries)
sinon.assert.notCalled(readFileStub)
})
})
})
})
describe("updateSkillMarkdownDisabledState", () => {
it("adds disabled: true when disabling a skill with existing frontmatter", () => {
const input = ["---", "name: my-skill", "description: A skill", "---", "Body here"].join("\n")
const output = updateSkillMarkdownDisabledState(input, false)
expect(output).to.contain("disabled: true")
expect(output).to.contain("name: my-skill")
expect(output).to.contain("Body here")
})
it("removes disabled flag when enabling a previously-disabled skill", () => {
const input = ["---", "name: my-skill", "description: A skill", "disabled: true", "---", "Body"].join("\n")
const output = updateSkillMarkdownDisabledState(input, true)
expect(output).to.not.contain("disabled")
expect(output).to.contain("name: my-skill")
expect(output).to.contain("Body")
})
it("also clears a stale enabled: false when enabling", () => {
const input = ["---", "name: my-skill", "enabled: false", "---", "Body"].join("\n")
const output = updateSkillMarkdownDisabledState(input, true)
expect(output).to.not.contain("enabled: false")
})
it("drops the frontmatter block entirely when enabling leaves it empty", () => {
const input = ["---", "disabled: true", "---", "Body only"].join("\n")
const output = updateSkillMarkdownDisabledState(input, true)
expect(output).to.equal("Body only")
})
it("returns content unchanged when enabling a doc with no frontmatter", () => {
const input = "Just body, no frontmatter"
expect(updateSkillMarkdownDisabledState(input, true)).to.equal(input)
})
it("is idempotent: disabling an already-disabled skill keeps disabled: true once", () => {
const input = ["---", "name: s", "disabled: true", "---", "B"].join("\n")
const output = updateSkillMarkdownDisabledState(input, false)
expect(output.match(/disabled: true/g)).to.have.lengthOf(1)
})
// Frontmatter block whose YAML is genuinely invalid (asserted below). The
// `---` markers are well-formed so parseYamlFrontmatter detects frontmatter
// and then fails to parse it, exercising the parseError branch.
const MALFORMED_FRONTMATTER = ["---", "name: s", "description: : : bad", " - nope", "---", "Body"].join("\n")
it("uses a fixture whose frontmatter YAML is actually invalid", () => {
// Guards the two tests below from rotting into false positives: if this
// fixture ever became valid YAML, updateSkillMarkdownDisabledState would
// take a different (rewriting) path and the "untouched" assertions could
// pass for the wrong reason.
const parsed = parseYamlFrontmatter(MALFORMED_FRONTMATTER)
expect(parsed.hadFrontmatter).to.be.true
expect(parsed.parseError, "fixture frontmatter should be invalid YAML").to.be.a("string")
})
it("leaves malformed-frontmatter files untouched when disabling (no double header)", () => {
// parseYamlFrontmatter fails open and returns the full original document
// as the body. Disabling must not prepend a second `---` block and corrupt
// the file.
const output = updateSkillMarkdownDisabledState(MALFORMED_FRONTMATTER, false)
expect(output).to.equal(MALFORMED_FRONTMATTER)
// Exactly one frontmatter opener/closer pair, not two.
expect(output.match(/^---$/gm)).to.have.lengthOf(2)
})
it("leaves malformed-frontmatter files untouched when enabling", () => {
expect(updateSkillMarkdownDisabledState(MALFORMED_FRONTMATTER, true)).to.equal(MALFORMED_FRONTMATTER)
})
})
describe("setSkillDisabledInFrontmatter", () => {
let sandbox: sinon.SinonSandbox
let readFileStub: sinon.SinonStub
let writeFileStub: sinon.SinonStub
beforeEach(() => {
sandbox = sinon.createSandbox()
sandbox.stub(Logger, "warn")
// Use the module-level fs/promises stubs (mock.module above) since under
// bun fs.promises !== the `fs/promises` module the SUT imports.
readFileStub_.reset()
writeFileStub_.reset()
readFileStub = readFileStub_
writeFileStub = writeFileStub_
writeFileStub.resolves()
})
afterEach(() => sandbox.restore())
it("writes disabled: true to the SKILL.md when disabling a disk skill", async () => {
const skillPath = path.join("/home", "user", ".cline", "skills", "s", "SKILL.md")
readFileStub.withArgs(skillPath, "utf-8").resolves(["---", "name: s", "description: d", "---", "Body"].join("\n"))
const ok = await setSkillDisabledInFrontmatter(skillPath, false)
expect(ok).to.be.true
sinon.assert.calledOnce(writeFileStub)
const written = writeFileStub.getCall(0).args[1] as string
expect(written).to.contain("disabled: true")
})
it("does not write for remote skills (no backing file)", async () => {
const ok = await setSkillDisabledInFrontmatter("remote:Some Skill", false)
expect(ok).to.be.false
sinon.assert.notCalled(readFileStub)
sinon.assert.notCalled(writeFileStub)
})
it("skips the write when content is unchanged", async () => {
const skillPath = path.join("/home", "user", ".cline", "skills", "s", "SKILL.md")
// Already disabled; disabling again yields identical content.
readFileStub.withArgs(skillPath, "utf-8").resolves(["---", "name: s", "disabled: true", "---", "B"].join("\n"))
const ok = await setSkillDisabledInFrontmatter(skillPath, false)
expect(ok).to.be.true
sinon.assert.notCalled(writeFileStub)
})
})
@@ -0,0 +1,34 @@
import { synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
import { ensureRulesDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { ClineRulesToggles } from "@shared/cline-rules"
import path from "path"
import { Controller } from "@/core/controller"
export async function refreshClineRulesToggles(
controller: Controller,
workingDirectory: string,
): Promise<{
globalToggles: ClineRulesToggles
localToggles: ClineRulesToggles
}> {
// Global toggles
const globalClineRulesToggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
const updatedGlobalToggles = await synchronizeRuleToggles(globalClineRulesFilePath, globalClineRulesToggles)
controller.stateManager.setGlobalState("globalClineRulesToggles", updatedGlobalToggles)
// Local toggles
const localClineRulesToggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
const localClineRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.clineRules)
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles, "", [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
[".clinerules", "skills"],
])
controller.stateManager.setWorkspaceState("localClineRulesToggles", updatedLocalToggles)
return {
globalToggles: updatedGlobalToggles,
localToggles: updatedLocalToggles,
}
}
@@ -0,0 +1,49 @@
import { combineRuleToggles, synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
import { GlobalFileNames } from "@core/storage/disk"
import { ClineRulesToggles } from "@shared/cline-rules"
import path from "path"
import { Controller } from "@/core/controller"
/**
* Refreshes the toggles for windsurf, cursor, and agents rules
*/
export async function refreshExternalRulesToggles(
controller: Controller,
workingDirectory: string,
): Promise<{
windsurfLocalToggles: ClineRulesToggles
cursorLocalToggles: ClineRulesToggles
agentsLocalToggles: ClineRulesToggles
}> {
// local windsurf toggles
const localWindsurfRulesToggles = controller.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
const localWindsurfRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.windsurfRules)
const updatedLocalWindsurfToggles = await synchronizeRuleToggles(localWindsurfRulesFilePath, localWindsurfRulesToggles)
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", updatedLocalWindsurfToggles)
// local cursor toggles
const localCursorRulesToggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
// cursor has two valid locations for rules files, so we need to check both and combine
// synchronizeRuleToggles will drop whichever rules files are not in each given path, but combining the results will result in no data loss
let localCursorRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.cursorRulesDir)
const updatedLocalCursorToggles1 = await synchronizeRuleToggles(localCursorRulesFilePath, localCursorRulesToggles, ".mdc")
localCursorRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.cursorRulesFile)
const updatedLocalCursorToggles2 = await synchronizeRuleToggles(localCursorRulesFilePath, localCursorRulesToggles)
const updatedLocalCursorToggles = combineRuleToggles(updatedLocalCursorToggles1, updatedLocalCursorToggles2)
controller.stateManager.setWorkspaceState("localCursorRulesToggles", updatedLocalCursorToggles)
// local agents toggles
const localAgentsRulesToggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
const localAgentsRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.agentsRulesFile)
const updatedLocalAgentsToggles = await synchronizeRuleToggles(localAgentsRulesFilePath, localAgentsRulesToggles)
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", updatedLocalAgentsToggles)
return {
windsurfLocalToggles: updatedLocalWindsurfToggles,
cursorLocalToggles: updatedLocalCursorToggles,
agentsLocalToggles: updatedLocalAgentsToggles,
}
}
@@ -0,0 +1,53 @@
import * as yaml from "js-yaml"
export type FrontmatterParseResult = {
data: Record<string, unknown>
/**
* The markdown content after stripping the `--- frontmatter ---` block.
*
* Named `body` (rather than `content`) to make it clear this is the remaining
* document body and to keep this helper generic for multiple consumers.
*/
body: string
/**
* True when the input contained a frontmatter block, even if parsing failed.
*
* This allows callers to distinguish:
* - "no frontmatter provided" (baseline behavior), vs
* - "frontmatter was provided" (may have semantic meaning in future consumers).
*/
hadFrontmatter: boolean
/**
* Present only when YAML frontmatter was detected but failed to parse.
*
* This helper is intentionally fail-open and does not log. Returning `parseError`
* lets each caller decide whether to log, surface diagnostics, etc.
*/
parseError?: string
}
/**
* Parse YAML frontmatter from markdown content.
*
* Behavior is intentionally fail-open:
* - If YAML fails to parse, returns data={} and body=original markdown.
* - If no frontmatter exists, returns data={} and body=original markdown.
*/
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = markdown.match(frontmatterRegex)
if (!match) {
return { data: {}, body: markdown, hadFrontmatter: false }
}
const [, yamlContent, body] = match
try {
const data = (yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>) || {}
return { data, body, hadFrontmatter: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
}
}
@@ -0,0 +1,153 @@
/**
* Rule frontmatter conditional evaluation.
*
* This module implements a small conditional "DSL" for Cline Rules YAML frontmatter.
* It is used to decide whether a rule should be activated for a given request context.
*
* Notes:
* - Unknown conditional keys are ignored for forward compatibility.
* - The `paths` conditional matches if any candidate path matches any glob pattern.
* - Candidate paths are expected to be workspace-root-relative POSIX paths.
*/
import picomatch from "picomatch"
export type RuleEvaluationContext = {
/**
* Candidate workspace-relative paths that represent the current request context.
* These should be POSIX-style paths, relative to their workspace root.
*/
paths?: string[]
}
type MatchedConditions = Record<string, string[]>
type ConditionalEvaluatorResult = {
passed: boolean
matched?: string[]
}
type ConditionalEvaluatorWithMatch = (frontmatterValue: unknown, context: RuleEvaluationContext) => ConditionalEvaluatorResult
function toPosix(p: string): string {
return p.replace(/\\/g, "/")
}
function isNonEmptyStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string" && v.length > 0)
}
const evaluatePathsConditional: ConditionalEvaluatorWithMatch = (frontmatterValue: unknown, context: RuleEvaluationContext) => {
// Invalid type -> ignore conditional (fail-open)
if (!isNonEmptyStringArray(frontmatterValue)) {
return { passed: true }
}
const patterns = frontmatterValue.map((p) => p.trim()).filter(Boolean)
// Policy:
// - `paths` omitted => universal (because this evaluator is never invoked)
// - `paths: []` (or `paths` that trims to no usable patterns) => match nothing (fail-closed)
// This gives users an explicit way to disable a rule via frontmatter, while omission
// remains the mechanism for "always on" rules.
if (patterns.length === 0) {
return { passed: false }
}
const candidatePaths = (context.paths || []).map((p) => toPosix(p)).filter(Boolean)
// Conservative: no evidence => do not activate path-scoped rules
if (candidatePaths.length === 0) {
return { passed: false }
}
const matchedPatterns: string[] = []
for (const pattern of patterns) {
const matcher = picomatch(pattern, { dot: true })
if (candidatePaths.some((candidate) => matcher(candidate))) {
matchedPatterns.push(pattern)
}
}
return { passed: matchedPatterns.length > 0, matched: matchedPatterns.length > 0 ? matchedPatterns : undefined }
}
const conditionalEvaluators: Record<string, ConditionalEvaluatorWithMatch> = {
paths: evaluatePathsConditional,
}
export function evaluateRuleConditionals(
frontmatter: Record<string, unknown>,
context: RuleEvaluationContext,
): {
passed: boolean
matchedConditions: MatchedConditions
} {
const matchedConditions: MatchedConditions = {}
for (const [key, value] of Object.entries(frontmatter)) {
const evaluator = conditionalEvaluators[key]
if (!evaluator) {
continue // unknown conditional: ignore
}
const result = evaluator(value, context)
if (!result.passed) {
return { passed: false, matchedConditions: {} }
}
if (result.matched && result.matched.length > 0) {
matchedConditions[key] = result.matched
}
}
return { passed: true, matchedConditions }
}
/**
* Extracts path-like strings from user text to help enable first-turn activation.
* This is intentionally heuristic and conservative.
*/
export function extractPathLikeStrings(text: string): string[] {
if (!text) return []
// 0) Strip fenced code blocks to avoid extracting paths from pasted code.
// This dramatically reduces false positives from snippets containing `foo/bar` or `a.b.c`.
// Note: We intentionally keep this simple and fail-open (if fences are unbalanced, we do nothing special).
const withoutCodeFences = text.replace(/```[\s\S]*?```/g, " ")
// 1) Remove URLs to avoid false positives.
const withoutUrls = withoutCodeFences.replace(/\b\w+:\/\/[^\s]+/g, " ")
// 2) Match tokens that look like paths.
// - Either contain at least one slash (e.g. src/index.ts)
// - Or look like a simple filename with an extension (e.g. foo.md)
// (no slashes; conservative to reduce false positives).
const tokenRegex =
/(?:^|[\s([{"'`])((?:[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\/?|[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,10}))(?=$|[\s)\]}"'`,.;:!?])/g
const matches: string[] = []
let match: RegExpExecArray | null
while ((match = tokenRegex.exec(withoutUrls))) {
const candidate = match[1]
if (!candidate) continue
// Normalize away leading ./
const normalized = candidate.startsWith("./") ? candidate.slice(2) : candidate
// Avoid absurdly long tokens
if (normalized.length > 300) continue
matches.push(normalized)
}
// De-dupe while preserving order
const seen = new Set<string>()
const result: string[] = []
for (const m of matches) {
const posix = m.replace(/\\/g, "/")
if (posix === "/" || posix.startsWith("/") || posix.includes("..")) {
// We only want repo/workspace-relative hints here.
continue
}
if (!seen.has(posix)) {
seen.add(posix)
result.push(posix)
}
}
return result
}
@@ -0,0 +1,467 @@
import { ClineRulesToggles } from "@shared/cline-rules"
import { GlobalInstructionsFile } from "@shared/remote-config/schema"
import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
import fs from "fs/promises"
import * as path from "path"
import { Logger } from "@/shared/services/Logger"
import { parseYamlFrontmatter } from "./frontmatter"
import { evaluateRuleConditionals, RuleEvaluationContext } from "./rule-conditionals"
/**
* Recursively traverses directory and finds all files, including checking for optional whitelisted file extension
*/
async function readDirectoryRecursive(
directoryPath: string,
allowedFileExtension: string,
excludedPaths: string[][] = [],
): Promise<string[]> {
try {
const entries = await readDirectory(directoryPath, excludedPaths)
const results: string[] = []
for (const entry of entries) {
if (allowedFileExtension !== "") {
const fileExtension = path.extname(entry)
if (fileExtension !== allowedFileExtension) {
continue
}
}
results.push(entry)
}
return results
} catch (error) {
Logger.error(`Error reading directory ${directoryPath}: ${error}`)
return []
}
}
/**
* Gets the up to date toggles
*/
export async function synchronizeRuleToggles(
rulesDirectoryPath: string,
currentToggles: ClineRulesToggles,
allowedFileExtension = "",
excludedPaths: string[][] = [],
): Promise<ClineRulesToggles> {
// Create a copy of toggles to modify
const updatedToggles = { ...currentToggles }
try {
const pathExists = await fileExistsAtPath(rulesDirectoryPath)
if (pathExists) {
const isDir = await isDirectory(rulesDirectoryPath)
if (isDir) {
// DIRECTORY CASE
const filePaths = await readDirectoryRecursive(rulesDirectoryPath, allowedFileExtension, excludedPaths)
const existingRulePaths = new Set<string>()
for (const filePath of filePaths) {
const ruleFilePath = path.resolve(rulesDirectoryPath, filePath)
existingRulePaths.add(ruleFilePath)
const pathHasToggle = ruleFilePath in updatedToggles
if (!pathHasToggle) {
updatedToggles[ruleFilePath] = true
}
}
// Clean up toggles for non-existent files
for (const togglePath in updatedToggles) {
const pathExists = existingRulePaths.has(togglePath)
if (!pathExists) {
delete updatedToggles[togglePath]
}
}
} else {
// FILE CASE
// Add toggle for this file
const pathHasToggle = rulesDirectoryPath in updatedToggles
if (!pathHasToggle) {
updatedToggles[rulesDirectoryPath] = true
}
// Remove toggles for any other paths
for (const togglePath in updatedToggles) {
if (togglePath !== rulesDirectoryPath) {
delete updatedToggles[togglePath]
}
}
}
} else {
// PATH DOESN'T EXIST CASE
// Clear all toggles since the path doesn't exist
for (const togglePath in updatedToggles) {
delete updatedToggles[togglePath]
}
}
} catch (error) {
Logger.error(`Failed to synchronize rule toggles for path: ${rulesDirectoryPath}`, error)
}
return updatedToggles
}
/**
* Synchronizes remote rule toggles with current remote config
* Removes toggles for rules that no longer exist, adds defaults for new rules
*/
export function synchronizeRemoteRuleToggles(
remoteRules: GlobalInstructionsFile[],
currentToggles: ClineRulesToggles,
): ClineRulesToggles {
const updatedToggles: ClineRulesToggles = {}
// Create set of current remote rule names
const existingRuleNames = new Set(remoteRules.map((rule) => rule.name))
// Keep toggles only for rules that still exist
for (const [ruleName, enabled] of Object.entries(currentToggles)) {
if (existingRuleNames.has(ruleName)) {
updatedToggles[ruleName] = enabled
}
}
// Add default toggles for new rules (default to enabled)
for (const rule of remoteRules) {
if (!(rule.name in updatedToggles)) {
updatedToggles[rule.name] = true
}
}
return updatedToggles
}
/**
* Certain project rules have more than a single location where rules are allowed to be stored
*/
export function combineRuleToggles(toggles1: ClineRulesToggles, toggles2: ClineRulesToggles): ClineRulesToggles {
return { ...toggles1, ...toggles2 }
}
/**
* Read the content of rules files
*/
const getRuleFilesTotalContent = async (rulesFilePaths: string[], basePath: string, toggles: ClineRulesToggles) => {
return (await getRuleFilesTotalContentWithMetadata(rulesFilePaths, basePath, toggles)).content
}
const LOCAL_RULE_PATHS = {
clineRules: ".clinerules",
workflows: ".clinerules/workflows",
} as const
type ActivatedConditionalRule = {
name: string
matchedConditions: Record<string, string[]>
}
type RuleFileController = {
stateManager: {
getGlobalSettingsKey(key: "globalWorkflowToggles" | "globalClineRulesToggles"): ClineRulesToggles
setGlobalState(key: "globalWorkflowToggles" | "globalClineRulesToggles", value: ClineRulesToggles): void
getWorkspaceStateKey(
key:
| "workflowToggles"
| "localCursorRulesToggles"
| "localWindsurfRulesToggles"
| "localAgentsRulesToggles"
| "localClineRulesToggles",
): ClineRulesToggles
setWorkspaceState(
key:
| "workflowToggles"
| "localCursorRulesToggles"
| "localWindsurfRulesToggles"
| "localAgentsRulesToggles"
| "localClineRulesToggles",
value: ClineRulesToggles,
): void
}
}
// Prefixes used to make activated conditional rule identifiers self-explanatory in the UI.
// NOTE: These are display identifiers (not toggle keys).
export const RULE_SOURCE_PREFIX = {
workspace: "workspace",
global: "global",
remote: "remote",
} as const
export type RuleLoadResult = {
content: string
activatedConditionalRules: ActivatedConditionalRule[]
}
/**
* Result type for rule loading functions that return formatted instructions.
* Used by getGlobalClineRules and getLocalClineRules.
*/
type RuleLoadResultWithInstructions = {
instructions?: string
activatedConditionalRules: ActivatedConditionalRule[]
}
export const getRuleFilesTotalContentWithMetadata = async (
rulesFilePaths: string[],
basePath: string,
toggles: ClineRulesToggles,
opts?: { evaluationContext?: RuleEvaluationContext; ruleNamePrefix?: keyof typeof RULE_SOURCE_PREFIX },
): Promise<RuleLoadResult> => {
const evaluationContext = opts?.evaluationContext ?? {}
const prefix = RULE_SOURCE_PREFIX[opts?.ruleNamePrefix ?? "global"]
const parts = await Promise.all(
rulesFilePaths.map(async (filePath) => {
const ruleFilePath = path.resolve(basePath, filePath)
const ruleFilePathRelative = path.relative(basePath, ruleFilePath)
if (ruleFilePath in toggles && toggles[ruleFilePath] === false) {
return { contentPart: null, activatedRule: null }
}
const raw = (await fs.readFile(ruleFilePath, "utf8")).trim()
if (!raw) {
return { contentPart: null, activatedRule: null }
}
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(raw)
// YAML parse errors are treated as fail-open.
// NOTE: We intentionally preserve the raw frontmatter fence/content here so the LLM can still
// see the author's intended scoping (e.g., `paths:`) and reason about it, even if it cannot be
// evaluated reliably due to invalid YAML.
if (hadFrontmatter && parseError) {
return { contentPart: `${ruleFilePathRelative}\n${raw}`, activatedRule: null }
}
const { passed, matchedConditions } = evaluateRuleConditionals(data, evaluationContext)
if (!passed) {
return { contentPart: null, activatedRule: null }
}
const activatedRule =
hadFrontmatter && Object.keys(matchedConditions).length > 0
? { name: `${prefix}:${ruleFilePathRelative}`, matchedConditions }
: null
return { contentPart: `${ruleFilePathRelative}\n${body.trim()}`, activatedRule }
}),
)
return {
content: parts
.map((p) => p.contentPart)
.filter(Boolean)
.join("\n\n"),
activatedConditionalRules: parts
.map((p) => p.activatedRule)
.filter((rule): rule is ActivatedConditionalRule => rule !== null),
}
}
function getRemoteRulesTotalContentWithMetadata(
remoteRules: GlobalInstructionsFile[],
remoteToggles: ClineRulesToggles,
opts?: { evaluationContext?: RuleEvaluationContext },
): RuleLoadResult {
const activatedConditionalRules: ActivatedConditionalRule[] = []
const evaluationContext = opts?.evaluationContext ?? {}
let combinedContent = ""
for (const rule of remoteRules) {
const isEnabled = rule.alwaysEnabled || remoteToggles[rule.name] !== false
if (!isEnabled) continue
const raw = (rule.contents || "").trim()
if (!raw) continue
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(raw)
if (hadFrontmatter && parseError) {
// Fail open: include entire raw contents
if (combinedContent) combinedContent += "\n\n"
combinedContent += `${rule.name}\n${raw}`
continue
}
const { passed, matchedConditions } = evaluateRuleConditionals(data, evaluationContext)
if (!passed) continue
if (hadFrontmatter && Object.keys(matchedConditions).length > 0) {
activatedConditionalRules.push({ name: `${RULE_SOURCE_PREFIX.remote}:${rule.name}`, matchedConditions })
}
if (combinedContent) combinedContent += "\n\n"
combinedContent += `${rule.name}\n${body.trim()}`
}
return { content: combinedContent, activatedConditionalRules }
}
/**
* Handles converting any directory into a file (specifically used for .clinerules and .clinerules/workflows)
* The old .clinerules file or .clinerules/workflows file will be renamed to a default filename
* Doesn't do anything if the dir already exists or doesn't exist
* Returns whether there are any uncaught errors
*/
async function ensureLocalClineDirExists(clinerulePath: string, defaultRuleFilename: string): Promise<boolean> {
try {
const exists = await fileExistsAtPath(clinerulePath)
if (exists && !(await isDirectory(clinerulePath))) {
// logic to convert .clinerules file into directory, and rename the rules file to {defaultRuleFilename}
const content = await fs.readFile(clinerulePath, "utf8")
const tempPath = clinerulePath + ".bak"
await fs.rename(clinerulePath, tempPath) // create backup
try {
await fs.mkdir(clinerulePath, { recursive: true })
await fs.writeFile(path.join(clinerulePath, defaultRuleFilename), content, "utf8")
await fs.unlink(tempPath).catch(() => {}) // delete backup
return false // conversion successful with no errors
} catch (_conversionError) {
// attempt to restore backup on conversion failure
try {
await fs.rm(clinerulePath, { recursive: true, force: true }).catch(() => {})
await fs.rename(tempPath, clinerulePath) // restore backup
} catch (_restoreError) {}
return true // in either case here we consider this an error
}
}
// exists and is a dir or doesn't exist, either of these cases we dont need to handle here
return false
} catch (_error) {
return true
}
}
/**
* Create a rule file or workflow file
*/
export const createRuleFile = async (isGlobal: boolean, filename: string, cwd: string, type: string) => {
try {
let filePath: string
if (isGlobal) {
const disk = require("@core/storage/disk") as {
ensureWorkflowsDirectoryExists: () => Promise<string>
ensureRulesDirectoryExists: () => Promise<string>
}
if (type === "workflow") {
const globalClineWorkflowFilePath = await disk.ensureWorkflowsDirectoryExists()
filePath = path.join(globalClineWorkflowFilePath, filename)
} else {
const globalClineRulesFilePath = await disk.ensureRulesDirectoryExists()
filePath = path.join(globalClineRulesFilePath, filename)
}
} else {
const localClineRulesFilePath = path.resolve(cwd, LOCAL_RULE_PATHS.clineRules)
const hasError = await ensureLocalClineDirExists(localClineRulesFilePath, "default-rules.md")
if (hasError === true) {
return { filePath: null, fileExists: false }
}
await fs.mkdir(localClineRulesFilePath, { recursive: true })
if (type === "workflow") {
const localWorkflowsFilePath = path.resolve(cwd, LOCAL_RULE_PATHS.workflows)
const hasError = await ensureLocalClineDirExists(localWorkflowsFilePath, "default-workflows.md")
if (hasError === true) {
return { filePath: null, fileExists: false }
}
await fs.mkdir(localWorkflowsFilePath, { recursive: true })
filePath = path.join(localWorkflowsFilePath, filename)
} else {
// clinerules file creation
filePath = path.join(localClineRulesFilePath, filename)
}
}
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
return { filePath, fileExists }
}
await fs.writeFile(filePath, "", "utf8")
return { filePath, fileExists: false }
} catch (_error) {
return { filePath: null, fileExists: false }
}
}
/**
* Delete a rule file or workflow file
*/
export async function deleteRuleFile(
controller: RuleFileController,
rulePath: string,
isGlobal: boolean,
type: string,
): Promise<{ success: boolean; message: string }> {
try {
// Check if file exists
const fileExists = await fileExistsAtPath(rulePath)
if (!fileExists) {
return {
success: false,
message: `File does not exist: ${rulePath}`,
}
}
// Delete the file from disk
await fs.rm(rulePath, { force: true })
// Get the filename for messages
const fileName = path.basename(rulePath)
// Update the appropriate toggles
if (isGlobal) {
if (type === "workflow") {
const toggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
delete toggles[rulePath]
controller.stateManager.setGlobalState("globalWorkflowToggles", toggles)
} else {
const toggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
delete toggles[rulePath]
controller.stateManager.setGlobalState("globalClineRulesToggles", toggles)
}
} else {
if (type === "workflow") {
const toggles = controller.stateManager.getWorkspaceStateKey("workflowToggles")
delete toggles[rulePath]
controller.stateManager.setWorkspaceState("workflowToggles", toggles)
} else if (type === "cursor") {
const toggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
delete toggles[rulePath]
controller.stateManager.setWorkspaceState("localCursorRulesToggles", toggles)
} else if (type === "windsurf") {
const toggles = controller.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
delete toggles[rulePath]
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", toggles)
} else if (type === "agents") {
const toggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
delete toggles[rulePath]
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", toggles)
} else {
const toggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
delete toggles[rulePath]
controller.stateManager.setWorkspaceState("localClineRulesToggles", toggles)
}
}
return {
success: true,
message: `File "${fileName}" deleted successfully`,
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
Logger.error(`Error deleting file: ${errorMessage}`, error)
return {
success: false,
message: `Failed to delete file.`,
}
}
}
@@ -0,0 +1,313 @@
import { getSkillsDirectoriesForScan } from "@core/storage/skill-directories"
import type { GlobalInstructionsFile } from "@shared/remote-config/schema"
import type { SkillContent, SkillMetadata } from "@shared/skills"
import { fileExistsAtPath, isDirectory } from "@utils/fs"
import * as fs from "fs/promises"
import * as yaml from "js-yaml"
import * as path from "path"
import { Logger } from "@/shared/services/Logger"
import { parseYamlFrontmatter } from "./frontmatter"
/**
* Update the `disabled` frontmatter flag of a SKILL.md document.
*
* The SDK (which builds the model's skill list and the `skills` tool) reads a
* skill's enabled state from the SKILL.md frontmatter `disabled` field, not from
* the extension's UI toggle state. Toggling a skill in the VS Code sidebar must
* therefore also write this flag so the change is reflected for the model
* (ENG-1995). This mirrors the SDK's updateSkillMarkdownEnabledState but lives in
* the extension and uses js-yaml (the extension's frontmatter parser).
*
* - enabled=false sets `disabled: true`.
* - enabled=true removes `disabled` (and a stale `enabled: false`), dropping
* the frontmatter block entirely if it becomes empty.
*
* Returns the original content unchanged when enabling a document that has no
* frontmatter (nothing to clear).
*/
export function updateSkillMarkdownDisabledState(content: string, enabled: boolean): string {
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(content)
if (!hadFrontmatter && enabled) {
return content
}
// parseYamlFrontmatter fails open on malformed YAML: it returns data={} and
// body=<full original content> (frontmatter block included). Serializing here
// would prepend a second `---` block and corrupt the file, so leave it
// untouched and let the user fix the frontmatter.
if (parseError) {
return content
}
if (enabled) {
delete data.disabled
if (data.enabled === false) {
delete data.enabled
}
if (Object.keys(data).length === 0) {
return body
}
return serializeSkillFrontmatter(data, body)
}
data.disabled = true
return serializeSkillFrontmatter(data, body)
}
function serializeSkillFrontmatter(data: Record<string, unknown>, body: string): string {
const yamlText = yaml.dump(data, { schema: yaml.JSON_SCHEMA }).trimEnd()
return `---\n${yamlText}\n---\n${body}`
}
/**
* Persist a skill's enabled/disabled state to its SKILL.md frontmatter on disk.
* No-op (returns false) for remote skills, which have no backing file.
*/
export async function setSkillDisabledInFrontmatter(skillMdPath: string, enabled: boolean): Promise<boolean> {
if (!skillMdPath || skillMdPath.startsWith("remote:")) {
return false
}
try {
const content = await fs.readFile(skillMdPath, "utf-8")
const updated = updateSkillMarkdownDisabledState(content, enabled)
if (updated !== content) {
await fs.writeFile(skillMdPath, updated)
}
return true
} catch (error) {
Logger.warn(`Failed to update skill frontmatter at ${skillMdPath}:`, error)
return false
}
}
/**
* A remote skill entry after frontmatter validation.
* name is always frontmatter.name (canonical). A warning is logged if entry.name drifts.
*/
export interface ValidatedRemoteSkill {
name: string
description: string
alwaysEnabled: boolean
contents: string
}
/**
* Parse and validate remote skill entries from GlobalInstructionsFile[].
*
* Validates:
* - frontmatter.name and frontmatter.description are present strings
* - Warns if entry.name does not match frontmatter.name (drift)
*
* Returns only valid entries. Callers share this single validation point
* instead of duplicating frontmatter parsing.
*/
export function parseRemoteSkillEntries(entries: GlobalInstructionsFile[]): ValidatedRemoteSkill[] {
return entries
.map((entry) => {
const { data: frontmatter } = parseYamlFrontmatter(entry.contents)
if (!frontmatter.name || typeof frontmatter.name !== "string") return null
if (!frontmatter.description || typeof frontmatter.description !== "string") return null
// Warn on drift but use frontmatter.name as the canonical identity.
// The dashboard should keep entry.name in sync, but we don't reject on mismatch
// since that would silently hide org-configured skills from users.
if (entry.name !== frontmatter.name) {
Logger.warn(`Remote skill entry.name "${entry.name}" does not match frontmatter.name "${frontmatter.name}"`)
}
return {
name: frontmatter.name,
description: frontmatter.description as string,
alwaysEnabled: entry.alwaysEnabled,
contents: entry.contents,
}
})
.filter((e): e is NonNullable<typeof e> => e !== null)
}
/** Parse YAML frontmatter from markdown content (shared helper). */
function parseFrontmatter(fileContent: string): { data: Record<string, unknown>; content: string } {
const result = parseYamlFrontmatter(fileContent)
if (result.parseError) {
Logger.warn("Failed to parse YAML frontmatter:", result.parseError)
}
return { data: result.data, content: result.body }
}
/**
* Scan a directory for skill subdirectories containing SKILL.md files.
*/
async function scanSkillsDirectory(dirPath: string, source: "global" | "project"): Promise<SkillMetadata[]> {
const skills: SkillMetadata[] = []
if (!(await fileExistsAtPath(dirPath)) || !(await isDirectory(dirPath))) {
return skills
}
try {
const entries = await fs.readdir(dirPath)
for (const entryName of entries) {
const entryPath = path.join(dirPath, entryName)
const stats = await fs.stat(entryPath).catch(() => null)
if (!stats?.isDirectory()) continue
const skill = await loadSkillMetadata(entryPath, source, entryName)
if (skill) {
skills.push(skill)
}
}
} catch (error: unknown) {
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "EACCES") {
Logger.warn(`Permission denied reading skills directory: ${dirPath}`)
}
}
return skills
}
/**
* Load skill metadata from a skill directory.
*/
async function loadSkillMetadata(
skillDir: string,
source: "global" | "project",
skillName: string,
): Promise<SkillMetadata | null> {
const skillMdPath = path.join(skillDir, "SKILL.md")
if (!(await fileExistsAtPath(skillMdPath))) return null
try {
const fileContent = await fs.readFile(skillMdPath, "utf-8")
const { data: frontmatter } = parseFrontmatter(fileContent)
// Validate required fields
if (!frontmatter.name || typeof frontmatter.name !== "string") {
Logger.warn(`Skill at ${skillDir} missing required 'name' field`)
return null
}
if (!frontmatter.description || typeof frontmatter.description !== "string") {
Logger.warn(`Skill at ${skillDir} missing required 'description' field`)
return null
}
// Name must match directory name per spec
if (frontmatter.name !== skillName) {
Logger.warn(`Skill name "${frontmatter.name}" doesn't match directory "${skillName}"`)
return null
}
return {
name: skillName,
description: frontmatter.description,
path: skillMdPath,
source,
}
} catch (error) {
Logger.warn(`Failed to load skill at ${skillDir}:`, error)
return null
}
}
/**
* Discover all skills from global (~/.cline/skills), remote config, and project directories.
*
* Precedence (highest wins on name collision via getAvailableSkills):
* remote (enterprise) > disk-global (user personal) > project (workspace)
*
* This is achieved by the array order + getAvailableSkills iterating in reverse (last wins):
* [project..., disk-global..., remote...]
*/
export async function discoverSkills(cwd: string, remoteSkillEntries?: GlobalInstructionsFile[]): Promise<SkillMetadata[]> {
const skills: SkillMetadata[] = []
const scanDirs = getSkillsDirectoriesForScan(cwd)
// Collect project and disk-global skills separately so we can insert remote between them
const projectSkills: SkillMetadata[] = []
const diskGlobalSkills: SkillMetadata[] = []
for (const dir of scanDirs) {
const dirSkills = await scanSkillsDirectory(dir.path, dir.source)
if (dir.source === "project") {
projectSkills.push(...dirSkills)
} else {
diskGlobalSkills.push(...dirSkills)
}
}
// Remote skills: validated via parseRemoteSkillEntries and keyed by frontmatter.name.
const remoteSkills: SkillMetadata[] = parseRemoteSkillEntries(remoteSkillEntries || []).map((entry) => ({
name: entry.name,
description: entry.description,
path: `remote:${entry.name}`,
source: "global" as const,
}))
// Insert in order: project → disk-global → remote
// getAvailableSkills iterates backwards so remote (last) wins, then disk-global, then project
skills.push(...projectSkills, ...diskGlobalSkills, ...remoteSkills)
return skills
}
/**
* Get available skills with override resolution (global > project).
*/
export function getAvailableSkills(skills: SkillMetadata[]): SkillMetadata[] {
const seen = new Set<string>()
const result: SkillMetadata[] = []
// Iterate backwards: global skills (added last) are seen first and take precedence
for (let i = skills.length - 1; i >= 0; i--) {
const skill = skills[i]
if (!seen.has(skill.name)) {
seen.add(skill.name)
result.unshift(skill)
}
}
return result
}
/**
* Get full skill content including instructions.
* For remote skills, pass remoteSkillEntries so content can be loaded without disk I/O.
*/
export async function getSkillContent(
skillName: string,
availableSkills: SkillMetadata[],
remoteSkillEntries?: GlobalInstructionsFile[],
): Promise<SkillContent | null> {
const skill = availableSkills.find((s) => s.name === skillName)
if (!skill) return null
// Remote skills have no file on disk — retrieve content from the provided entries.
// Try entry.name first (fast path when dashboard is in sync), fall back to frontmatter match.
if (skill.path.startsWith("remote:")) {
let entry = (remoteSkillEntries || []).find((e) => e.name === skillName)
if (!entry) {
entry = (remoteSkillEntries || []).find((e) => {
const { data } = parseYamlFrontmatter(e.contents)
return typeof data.name === "string" && data.name === skillName
})
}
if (!entry) return null
const { body } = parseYamlFrontmatter(entry.contents)
return {
...skill,
instructions: body.trim(),
}
}
try {
const fileContent = await fs.readFile(skill.path, "utf-8")
const { content: body } = parseFrontmatter(fileContent)
return {
...skill,
instructions: body.trim(),
}
} catch {
return null
}
}
@@ -0,0 +1,32 @@
import { synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
import { ensureWorkflowsDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { ClineRulesToggles } from "@shared/cline-rules"
import path from "path"
import { Controller } from "@/core/controller"
/**
* Refresh the workflow toggles
*/
export async function refreshWorkflowToggles(
controller: Controller,
workingDirectory: string,
): Promise<{
globalWorkflowToggles: ClineRulesToggles
localWorkflowToggles: ClineRulesToggles
}> {
// Global workflows
const globalWorkflowToggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
const globalClineWorkflowsFilePath = await ensureWorkflowsDirectoryExists()
const updatedGlobalWorkflowToggles = await synchronizeRuleToggles(globalClineWorkflowsFilePath, globalWorkflowToggles)
controller.stateManager.setGlobalState("globalWorkflowToggles", updatedGlobalWorkflowToggles)
const workflowRulesToggles = controller.stateManager.getWorkspaceStateKey("workflowToggles")
const workflowsDirPath = path.resolve(workingDirectory, GlobalFileNames.workflows)
const updatedWorkflowToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
controller.stateManager.setWorkspaceState("workflowToggles", updatedWorkflowToggles)
return {
globalWorkflowToggles: updatedGlobalWorkflowToggles,
localWorkflowToggles: updatedWorkflowToggles,
}
}
@@ -1,10 +1,15 @@
import { EmptyRequest, String } from "@shared/proto/cline/common"
import { AuthService } from "@/sdk/auth-service"
import { Controller } from "../index"
/**
* Starts Cline account sign-in. In e2e/local this performs a browserless mock token exchange; in
* prod it runs the SDK OAuth flow. Returns a URL/status string for the webview.
* Handles the user clicking the login link in the UI.
* Uses the SDK-backed AuthService to initiate the Cline OAuth flow.
* The SDK spawns a local callback server and opens the browser.
*
* @param controller The controller instance.
* @returns The login URL as a string.
*/
export async function accountLoginClicked(controller: Controller, _: EmptyRequest): Promise<String> {
return controller.authService.createAuthRequest()
export async function accountLoginClicked(_controller: Controller, _: EmptyRequest): Promise<String> {
return await AuthService.getInstance().createAuthRequest()
}
@@ -2,7 +2,14 @@ import type { EmptyRequest } from "@shared/proto/cline/common"
import { Empty } from "@shared/proto/cline/common"
import type { Controller } from "../index"
/**
* Handles the account logout action.
* Delegates to the SdkController which uses the SDK-backed AuthService.
* @param controller The controller instance
* @param _request The empty request object
* @returns Empty response
*/
export async function accountLogoutClicked(controller: Controller, _request: EmptyRequest): Promise<Empty> {
await controller.authService.signOut()
await controller.handleSignOut()
return Empty.create({})
}
@@ -1,6 +1,23 @@
import { AuthState, AuthStateChangedRequest } from "@shared/proto/cline/account"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
export async function authStateChanged(controller: Controller, _request: AuthStateChangedRequest): Promise<AuthState> {
return controller.authService.getInfo()
/**
* Handles authentication state changes from the Firebase context.
* Updates the user info in global state and returns the updated value.
* @param controller The controller instance
* @param request The auth state change request
* @returns The updated user info
*/
export async function authStateChanged(controller: Controller, request: AuthStateChangedRequest): Promise<AuthState> {
try {
// Store the user info directly in global state
controller.stateManager.setGlobalState("userInfo", request.user)
// Return the same user info
return AuthState.create({ user: request.user })
} catch (error) {
Logger.error(`Failed to update auth state: ${error}`)
throw error
}
}
@@ -1,9 +1,57 @@
import { GetOrganizationCreditsRequest, OrganizationCreditsData } from "@shared/proto/cline/account"
import { GetOrganizationCreditsRequest, OrganizationCreditsData, OrganizationUsageTransaction } from "@shared/proto/cline/account"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
/**
* Handles fetching all organization credits data (balance, usage, payments)
* @param controller The controller instance
* @param request Organization credits request
* @returns Organization credits data response
*/
export async function getOrganizationCredits(
_controller: Controller,
_request: GetOrganizationCreditsRequest,
controller: Controller,
request: GetOrganizationCreditsRequest,
): Promise<OrganizationCreditsData> {
return OrganizationCreditsData.create({})
try {
if (!controller.accountService) {
throw new Error("Account service not available")
}
// Call the individual RPC variants in parallel
const [balanceData, usageTransactions] = await Promise.all([
controller.accountService.fetchOrganizationCreditsRPC(request.organizationId),
controller.accountService.fetchOrganizationUsageTransactionsRPC(request.organizationId),
])
// If balance call fails (returns undefined), throw an error
if (!balanceData) {
throw new Error("Failed to fetch organization credits data")
}
return OrganizationCreditsData.create({
balance: balanceData ? { currentBalance: balanceData.balance / 100 } : { currentBalance: 0 },
organizationId: balanceData?.organizationId || "",
usageTransactions:
usageTransactions?.map((tx: any) =>
OrganizationUsageTransaction.create({
aiInferenceProviderName: tx.aiInferenceProviderName,
aiModelName: tx.aiModelName,
aiModelTypeName: tx.aiModelTypeName,
completionTokens: tx.completionTokens,
costUsd: tx.costUsd,
createdAt: tx.createdAt,
creditsUsed: tx.creditsUsed,
generationId: tx.generationId,
organizationId: tx.organizationId,
promptTokens: tx.promptTokens,
totalTokens: tx.totalTokens,
userId: tx.userId,
operation: tx.operation,
}),
) || [],
})
} catch (error) {
Logger.error(`Failed to fetch organization credits data: ${error}`)
throw error
}
}

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