Compare commits

...

130 Commits

Author SHA1 Message Date
abeatrix d270941bcd keeps union validation 2026-06-24 13:45:16 -07:00
abeatrix 86e8b61206 fix(tools): parse JSON-encoded command arrays in shell tools
Handle cases where the `commands` field is passed as a JSON-encoded
string array instead of a native array. Refactor input handling in
`createBashTool` and `createWindowsShellTool` to support both string
and structured command inputs, and update `coalesceSplitHeredocCommands`
to skip non-string entries. Removes the `RunCommandsInputUnionSchema`
in favor of direct validation.
2026-06-24 12:53:30 -07: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
Max 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 Barreiro 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 Newhouse 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
Ara 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
Saoud Rizwan 85223a07cf fix(cli): apply auto-approve toggles immediately (#11653) 2026-06-18 15:58:31 -07:00
John Choi eb2687677c feat(onboarding): add Cline Pass to the signup flow (#11609)
* Add Cline Pass provider to VS Code extension

* Fix the model picker

* ClinePass specific options

* Add feature flag to the api options

* Extend flag usage

* fix type error

* Use the right model info

* Fix tests

* Hide price info for clinePass models

* feat(onboarding): add Cline Pass as optional user-type in signup flow

Surface Cline Pass as a recommended-but-optional onboarding choice gated
behind the ext-cline-pass feature flag, alongside Free / Frontier / BYOK.
When selected, signup provisions the cline-pass provider and ClinePass
model fields; price info is hidden since cost is covered by the
subscription. Falls back cleanly to the existing flow when the flag is
off. Adds unit tests for the new helpers.

* feat(onboarding): nudge + Cline Pass subscribe link in signup flow

Strengthen the Cline Pass onboarding option with a 'Recommended' copy
nudge (text + ordering, no badge/paid-default), and add an additive
subscribe affordance on the post-signup 'Almost there!' step that links
to {appBaseUrl}/dashboard/plan. Client-only: reuses existing appBaseUrl
from useClineAuth and the existing dashboard subscribe page; no backend
change. Shown only when the user selects Cline Pass; existing flow and
other user-types are unchanged.

* refactor(onboarding): redirect Cline Pass signup to subscription page

Replace the manual 'Get Cline Pass' callout with an automatic redirect:
after a Cline Pass user completes account creation, open the dashboard
subscription signup page (/onboarding/individual-plan) in the browser.
Keep the free option first and default-selected, with Cline Pass shown
second and labeled '(Recommended)'.

* chore(onboarding): tighten inline comments

* fix(onboarding): constrain Cline Pass selection and clear stale subscribe redirect

Addresses review findings:
- Never save a non-Cline-Pass model id under the cline-pass provider:
  Cline Pass selection no longer falls back to a free model when the
  Cline Pass list is empty, the generic model search is hidden for Cline
  Pass, finishOnboarding guards on a cline-pass/ id, and the CTA is
  disabled (with an empty state) when no Cline Pass model is available.
- Clear the pending subscription redirect when the user backs out, signs
  in, or navigates away from Cline Pass, and re-check userType in the
  redirect effect so a late auth update can't force the paid flow.

* fix(onboarding): add userType to handleFooterAction deps

The signup handler reads userType (to set the pending Cline Pass
subscribe flag); make that dependency explicit rather than relying on
the transitive finishOnboarding dependency.

* fix(onboarding): gate Cline Pass model data by feature flag

* fix(onboarding): address Greptile review on Cline Pass signup

- Log when the Cline Pass provider write is skipped due to an unexpected
  (non cline-pass/) model id, so the otherwise-silent no-op is observable.
- Open the subscription page for already-authenticated users by invoking
  the redirect helper directly after accountLoginClicked resolves, not
  only via the auth effect (which never re-runs when clineUser is
  unchanged).

* fix(onboarding): preserve ClinePass selection through login + one-word branding

- handleAuthCallback no longer forces the provider back to 'cline' on
  login when the user picked Cline Pass during onboarding; it preserves a
  'cline-pass' selection per mode. Only 'cline-pass' configs are affected
  (which require the ext-cline-pass flag), so all other logins are
  unchanged.
- Open the subscription page solely from the clineUser auth effect; the
  signup action no longer also calls it directly (prevents the login
  browser and subscribe page opening at once / dead-ends for already-
  authenticated users). Signup flow is otherwise untouched.
- Rename user-facing 'Cline Pass' to one-word 'ClinePass' (card titles,
  step title, empty state) and the model group label to 'clinepass'.

* chore(onboarding): normalize ClinePass branding in comments, trim verbose comments

* fix(merge): resolve ApiOptions redeclare + apply biome semicolon formatting

- Remove duplicate CLINE_PASS_FEATURE_FLAG const in ApiOptions.tsx (the
  shared import from constants/featureFlags supersedes main's local const).
- Apply biome format (semicolons) to onboarding/controller files so they
  match main's current style and pass Quality Checks.

* fix: restore clean no-semicolon formatting, keep only real ClinePass changes

The previous merge-recovery commit ran a local biome that incorrectly added
semicolons across entire files, bloating the diff by ~2000 LOC. Restore the
files to their clean pre-format state (matching main's asNeeded style) so the
diff reflects only the actual ClinePass onboarding changes (~420 LOC). Keeps
the handleAuthCallback provider-preservation fix and the ApiOptions duplicate
const removal.

* chore(onboarding): trim redundant inline comments

* chore: match main in refreshClineRecommendedModels (drop snake_case cline_pass handling)

* chore: trim featureFlags.ts comment

* fix(onboarding): open ClinePass subscribe page from App, not OnboardingView

handleAuthCallback marks the welcome view completed (unmounting OnboardingView)
before it pushes the auth-status update that sets clineUser. The redirect effect
lived in OnboardingView, so the pending-subscribe intent was lost on unmount and
the subscription page never opened for new ClinePass users.

Move the pending intent to a module-level store (clinePassSubscribe.ts) and run
the redirect from an effect in App, which outlives the onboarding unmount.

---------

Co-authored-by: BarreiroT <tomasmbarreiroi@gmail.com>
2026-06-18 14:45:34 -07:00
Tomás Barreiro 6fe5acb2a4 Resolve feature flags using the user id on startup (#11652) 2026-06-18 23:24:42 +02:00
Tomás Barreiro 99fb5101e5 Improve ClinePass error handling (#11637)
* Make sections expandable and add ClinePass

* fix label

* Map clinePass models to clinePass and the rest to cline

* gate the models behind the feature flag

* Do not allow custom model on cline pass

* Do not show the count on the mode list

* fix clinepass model list

* fix focus when expanding a section

* update the model data when the provider doesn't match

* Add ClinePass to the onboarding screen

* Fix the auth flow not starting

* Hide option behind a feature flag

* Update icon

* Remove unrelated changes

* Hide the custom model id

* fix model names

* remove custom model id option

* fix tests

* Fix names

* Display the clinepass models in the onboarding

* Throw a specific error when the user isn't subscribed

* properly render the error message

* fix error detection

* refactor

* Update the ResponseErrorHandler type

* re-add trailing slash
2026-06-18 22:18:12 +02:00
Tomás Barreiro 58db9887ee Clear organization on ClinePass selection on the VSCode extension (#11648)
* Clear organization on ClinePass selection on the VSCode extension

* Disable remote config if ClinePass is selected

* Disable remote config if ClinePass is selected

* Revert formatting changes

* fix issue
2026-06-18 22:04:49 +02:00
Ahmad Shahzad b16a28cd6f feat(vscode): add Fireworks GLM 5.2 and Kimi K2.6 Fast, fix DeepSeek V4 Flash cache-read price (#11642)
Update the Fireworks model registry in apps/vscode/src/shared/api.ts:

- Add accounts/fireworks/models/glm-5p2: new general-purpose model with
  a 1,048,576-token context window (131,072 output).
- Add accounts/fireworks/routers/kimi-k2p6-fast: standardizes the
  Kimi K2.6 router on the `-fast` naming convention used for /routers.
  The existing kimi-k2p6-turbo entry is retained for now to avoid
  breaking user workflows; the turbo variant is expected to be removed
  in a future release once the fast variant is the only one served.
- Correct the cache-read price for accounts/fireworks/models/deepseek-v4-flash
  from 0.03 to 0.028 to match the published rate.

Pricing sourced from https://docs.fireworks.ai/serverless/pricing.
2026-06-18 21:18:01 +02:00
John Choi 114aa3f161 Add friendly Cline Pass entitlement error UI (#11606)
* Add Cline Pass provider to VS Code extension

* Fix the model picker

* ClinePass specific options

* Add feature flag to the api options

* Extend flag usage

* fix type error

* Use the right model info

* Fix tests

* Hide price info for clinePass models

* Add friendly Cline Pass entitlement error UI

When a Cline Pass model returns a 403 ENTITLEMENT_ERROR (user not subscribed to the required model plan), the chat dumped the raw serialized error JSON and pointlessly auto-retried it.

- Add ClineErrorType.Entitlement, classified before the generic 403/auth path.
- Skip auto-retry (and the retries-exhausted message) for entitlement errors.
- Render a dedicated EntitlementError card: friendly headline, env-aware 'Get Cline Pass' subscribe link (clineUser.appBaseUrl, falling back to production), and a Retry button; backend detail is shown as muted support text.
- Add unit/component tests and a Storybook story.

* Scope entitlement to individual case; fix path-prefixed subscribe URL

- ClineError: only classify the individual 'not subscribed' ENTITLEMENT_ERROR as Entitlement (checks message and details.message). Org-account variant falls through to generic handling rather than showing a misleading 'Get Cline Pass' card.

- EntitlementError: build the subscribe URL with a relative path against a trailing-slash-normalized base so path-prefixed self-hosted/proxy app URLs (e.g. https://proxy.example.com/cline/app) are preserved instead of resetting to origin.

- Tests: add org-exclusion case, path-prefixed URL case, and assert the retry askResponse payload (yesButtonClicked).

* Trim redundant inline comments

* Add provider test: pre-stream 403 entitlement error classifies correctly

Documents and locks in that a 403 ENTITLEMENT_ERROR (which rejects completions.create before streaming, as an OpenAI SDK APIError) propagates through ClineHandler with the code intact so ClineError classifies it as Entitlement. Confirms the error does not rely on the mid-stream chunk.error path.

* Guard subscribe URL build against malformed appBaseUrl

Wrap new URL() in try/catch so an invalid appBaseUrl from the auth context omits the subscribe link instead of throwing TypeError and crashing the EntitlementError card mid-render. Addresses Greptile review feedback; adds a malformed-URL test.

* test: trim entitlement error UI coverage

* Use one-word 'ClinePass' in user-facing copy

Renames the display text in the entitlement card (headline, helper, button) and related tests/story/comments from 'Cline Pass' to 'ClinePass'. Also normalizes EntitlementError.test.tsx formatting to the webview Biome style.

* fix: skip subagent retries for ClinePass entitlement errors

---------

Co-authored-by: BarreiroT <tomasmbarreiroi@gmail.com>
Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
2026-06-18 12:06:40 -07:00
Tomás Barreiro 64743d2911 Improve the ClinePass UX (#11644) 2026-06-18 20:39:52 +02:00
Tomás Barreiro d7292993e9 Add Cline Pass to the extension (#11556)
* Add Cline Pass provider to VS Code extension

* Fix the model picker

* ClinePass specific options

* Add feature flag to the api options

* Extend flag usage

* fix type error

* Use the right model info

* Fix tests

* Hide price info for clinePass models

* refactor

* remove redundancy

* fix syncModeConfigurations
2026-06-18 20:19:46 +02:00
Tomás Barreiro 8110cc46d8 Add the ClinePass onboarding flow (#11590)
* Make sections expandable and add ClinePass

* fix label

* Map clinePass models to clinePass and the rest to cline

* gate the models behind the feature flag

* Do not allow custom model on cline pass

* Do not show the count on the mode list

* fix clinepass model list

* fix focus when expanding a section

* update the model data when the provider doesn't match

* Add ClinePass to the onboarding screen

* Fix the auth flow not starting

* Hide option behind a feature flag

* Update icon

* Remove unrelated changes

* Hide the custom model id

* fix model names

* remove custom model id option

* fix tests

* Fix names

* Display the clinepass models in the onboarding
2026-06-18 19:00:18 +02:00
Saoud Rizwan a3cd39da14 feat(cli): update hub dashboard design with customizations break out (#11631)
* feat(cli): refresh hub dashboard navigation

* fix(cli): address hub dashboard review feedback

* feat(cli): polish hub dashboard mockup styling

* fix(cli): polish hub dashboard pages

* fix(cli): brand hub browser metadata

* fix(cli): simplify marketplace entry details

* fix(cli): consolidate customization catalog lists

* fix(cli): dedupe marketplace installed rows

* fix(cli): preserve marketplace controls in consolidated rows

* fix(cli): align marketplace plugin controls

* fix(cli): flatten marketplace plugin tool layout

* fix(cli): place marketplace path below tags

* fix(cli): show primitive icons in marketplace rows

* fix(cli): sort sessions by displayed created time

* fix(cli): restore hub account navigation

* fix(cli): refine hub account navigation

* fix(cli): reuse hub account credentials

* fix(cli): support uninstalling local primitives
2026-06-17 21:55:31 -07:00
Saoud Rizwan a51b156383 feat(cli): add hub primitive catalogs (#11624)
* feat(cli): add hub marketplace

* chore(cli): clean up marketplace install code

* fix(cli): harden marketplace review issues

* feat(cli): add marketplace uninstall actions

* fix(cli): keep marketplace dialog actions in sync

* fix(cli): uninstall marketplace cards directly

* fix(cli): hide stale marketplace installed notice

* feat(cli): split hub marketplace pages

* fix(cli): show full marketplace descriptions

* fix(cli): avoid duplicate marketplace descriptions

* fix(cli): support marketplace deep refreshes

* fix(cli): hide marketplace placeholder icons

* fix(cli): remove marketplace card icons

* fix(cli): ignore featured marketplace entries

* chore(cli): clean marketplace follow-up code

* fix(cli): address marketplace review findings
2026-06-17 17:17:38 -07:00
Saoud Rizwan 7966bb9f21 chore(cli): release v3.0.27 2026-06-16 23:56:02 -07:00
Saoud Rizwan d92e4797ed fix(cli): reject accidental prompt fallback for bad args (#11615) 2026-06-16 23:52:43 -07:00
Saoud Rizwan 0de0c94555 fix(cli): clarify plugin MCP OAuth failure handling (#11614) 2026-06-16 23:38:28 -07:00
Saoud Rizwan 94b82fca3c feat(cli): add prefilled MCP install wizard command (#11610)
* feat(cli): add prefilled MCP install wizard command

* fix(cli): address MCP install review feedback
2026-06-16 23:07:16 -07:00
Saoud Rizwan ba2f2284e8 feat(cli): add cline skill command aliasing the open skills CLI (#11611)
* feat(cli): add cline skill command aliasing the open skills CLI

Adds a 'cline skill' command that forwards to Vercel's open skills CLI
via 'npx -y skills@latest <args>', giving parity with 'cline plugin
install' and 'cline mcp' without reimplementing skill installation.

install/add/i default to '--agent cline' (unless the user passes their
own -a/--agent) so installs land in a directory Cline already scans;
use/list/remove pass through verbatim.

* fix(cli): scope skill update to cline
2026-06-16 22:59:35 -07:00
Saoud Rizwan b7f955c78b chore(cli): release v3.0.26 2026-06-16 21:07:22 -07:00
Saoud Rizwan 8433549327 chore(sdk): release v0.0.49 2026-06-16 20:43:23 -07:00
Saoud Rizwan 860f544ab8 Revert "Make sections expandable and add ClinePass models (#11582)" (#11608)
This reverts commit 2fd944cb84.
2026-06-16 20:33:44 -07:00
Saoud Rizwan 0761de984b chore(cli): release v3.0.25 2026-06-16 19:35:02 -07:00
Saoud Rizwan 940b43f72f chore(sdk): release v0.0.48 2026-06-16 19:20:04 -07:00
Saoud Rizwan a466060f79 fix(llms): avoid disabled reasoning for StepFun flash (#11597)
* fix(llms): avoid disabled reasoning for StepFun flash

* fix(llms): cover StepFun flash reasoning variants
2026-06-16 19:07:50 -07:00
Tomás Barreiro 2fd944cb84 Make sections expandable and add ClinePass models (#11582)
* Make sections expandable and add ClinePass

* fix label

* Map clinePass models to clinePass and the rest to cline

* gate the models behind the feature flag

* Do not allow custom model on cline pass

* Do not show the count on the mode list

* fix clinepass model list

* fix focus when expanding a section

* update the model data when the provider doesn't match

* Update sdk/packages/core/src/services/llms/cline-recommended-models.ts

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

* style: format Cline recommended models fallback

* Build models

---------

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-17 04:02:27 +02:00
Tomás Barreiro 7d1abb25a7 Update fixed model list (#11591) 2026-06-17 03:28:35 +02:00
Saoud Rizwan 628aaa0675 feat(cli): authorize plugin MCP OAuth during install (#11575) 2026-06-16 17:46:19 -07:00
Robin Newhouse af7fda87c0 ENG-2184 Add generic provider-request capture for SDK CLI (#11481)
* Add generic provider request capture

* Use per-request provider capture files
2026-06-16 16:49:46 -07:00
Tomás Barreiro 9af6ced896 Rename Cline Pass to ClinePass everywhere (#11584)
* Rename Cline Pass to ClinePass everywhere

* Update apps/vscode/src/utils/path.ts

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

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-16 21:50:40 +02:00
Ahmad Shahzad a3258dd79a feat: update Fireworks AI model registry with latest platform offerings (#11554)
The VS Code extension's Fireworks model list is out of date compared to the current active models available on the Fireworks platform. This commit updates the registry to match the current model lineup, ensuring users can select from the latest available models.

Changes:
- Add Kimi K2.7 Code (accounts/fireworks/models/kimi-k2p7-code)
- Add Kimi K2.7 Code Fast (accounts/fireworks/routers/kimi-k2p7-code-fast)
- Add Qwen 3.7 Plus (accounts/fireworks/models/qwen3p7-plus)
- Add MiniMax M3 (accounts/fireworks/models/minimax-m3)
- Remove deprecated Kimi K2.5 (accounts/fireworks/models/kimi-k2p5)
- Remove deprecated MiniMax M2.5 (accounts/fireworks/models/minimax-m2p5)
- Remove deprecated Qwen 3.6 Plus (accounts/fireworks/models/qwen3p6-plus)

The default model remains accounts/fireworks/models/kimi-k2p6.

Files:
- apps/vscode/src/shared/api.ts
2026-06-16 14:23:55 +02:00
Saoud Rizwan ebb05ed963 feat: add MCP support to plugins (#11516)
* feat: add MCP support to plugins

* fix: address plugin MCP review comments

* feat: sync plugin MCP servers to settings

* fix: tighten plugin MCP settings behavior

* fix: isolate plugin MCP sync failures

* fix: prune plugin MCP cleanup paths

* fix: surface plugin MCP re-enable failures

* fix: surface plugin MCP install sync failures

* fix: order plugin MCP state transitions
2026-06-15 19:44:46 -07:00
Tomás Barreiro 8d03d176f2 Fix WebView env replacing (#11574)
* Fix WebView env replacing

* fix node env resolution

* fix node env resolution

* fix node env resolution

* Fix platform reference

* Fix platform reference
2026-06-16 04:09:32 +02:00
Max 32b3cfc081 fix hugging face url (#11567)
hugging face inference url was incorrect

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-15 14:26:47 -07:00
Tomás Barreiro a6500a07b4 Enable feature flags on vscode (#11566)
* Enable feature flags on vscode

* Remove comment

* refactor

* fix
2026-06-15 22:52:21 +02:00
Max 81384089c4 allow dynamic models ids in huggingface provider (#11563)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-15 10:22:31 -07:00
Robin Newhouse 6364792c47 fix(cli): isolate history resume renderer ENG-2190 (#11502)
* fix(cli): isolate history resume renderer

* fix(cli): avoid duplicate history resume signals
2026-06-15 10:04:23 -07:00
Tomás Barreiro 40fc8879f1 Fix Postprotos modifying unrelated files (#11557) 2026-06-15 08:59:29 -07:00
Ara e91cba4045 fix(sdk): search output cap + bash executor fixes (follow-up to #11480) (#11504)
* fix(sdk): search output cap + bash executor fixes (follow-up to #11480)

Slimmed from the original revision: the aggregate per-call output budget
is deferred to its own follow-up PR. What remains:

- cap search_codebase output at 48k chars per query with a middle-cut
  notice teaching the model to narrow the pattern (robinnewhouse's
  finding on #11480 — search was the last uncapped tool)
- rename bash executor maxOutputBytes -> maxOutputChars; the limit was
  always enforced in characters. Deprecated alias retained; stale
  @default annotation fixed
- flush the rolling collector's StringDecoder at end-of-stream so
  trailing incomplete multibyte sequences are not silently dropped
  (greptile's finding on #11480)
- decouple output-limits comments from MessageBuilder's specific
  backstop value; the durable invariant is that truncation notices live
  in the preserved head/tail of an entry

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/ygz2qho62ub6o8v1zhjvdktt

* test(sdk): cover search output cap

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-14 19:22:39 -07:00
Robin Newhouse e5e1aa3455 Add bounded provider request media budget ENG-2191 (#11520)
* fix(sdk): bound provider request media payloads

* fix(sdk): address media review feedback

* fix(sdk): scrub media from error tool results

* chore(sdk): remove media budget docs noise

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-14 18:21:12 -07:00
Robin Newhouse d2893d2e93 fix(sdk): coalesce split heredoc run_commands (#11518)
* fix(sdk): coalesce split heredoc run_commands

* fix(sdk): address heredoc coalescing review

* test(sdk): cover split heredoc edge cases

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-14 16:47:16 -07:00
Saoud Rizwan 4fc366df5f fix(sdk): allow ranged reads on large files (#11511)
* fix(sdk): allow ranged reads on large files

* fix(sdk): bound ranged file reads

* fix(sdk): bound streamed file reads

* fix(sdk): simplify file read streaming bounds
2026-06-12 17:23:25 -07:00
Saoud Rizwan d8eb06318b fix(sdk): fail apply_patch when a hunk is skipped (#11509) 2026-06-12 16:46:48 -07:00
Tomás Barreiro fe4eb44c6b Unselect the org when selecting Cline Pass (#11501)
* Centralize OAuth management to the SDK

* Update mock

* Cleanup TUI cline-account logic

* clean save credentails

* Remove unused code

* Reduce mocks

* use normalizeStoredAccessToken

* Add Cline Pass

* Properly read storageProviderId

* Use the name for the model generation

* Use the model for the capabilities lookup

* Fix capability discovery

* Fix getLastUsedProviderSettings

* remove the provider id from the resolveWithSingleFlight return

* Fix tests

* Remove the entry.name check

* Execute model API calls separetely

* Hide Cline Pass pricing

* update model list

* Unselect the org when selecting Cline Pass

* deduplicate onProviderChange calls

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-13 01:44:54 +02:00
Saoud Rizwan 6c52bdc177 fix(sdk): return captured stdout on failing run_commands (#11508)
* fix(sdk): return captured stdout on failing run_commands

* fix(sdk): respect combineOutput on command failure
2026-06-12 16:42:08 -07:00
Saoud Rizwan 5260595472 fix(sdk): treat zero search results as success (#11510) 2026-06-12 16:35:00 -07:00
Tomás Barreiro a279388451 Add feature flag for cline pass (#11500)
* Centralize OAuth management to the SDK

* Update mock

* Cleanup TUI cline-account logic

* clean save credentails

* Remove unused code

* Reduce mocks

* use normalizeStoredAccessToken

* Add Cline Pass

* Properly read storageProviderId

* Use the name for the model generation

* Use the model for the capabilities lookup

* Fix capability discovery

* Fix getLastUsedProviderSettings

* remove the provider id from the resolveWithSingleFlight return

* Fix tests

* Remove the entry.name check

* Execute model API calls separetely

* Hide Cline Pass pricing

* Display cline-pass only if the feature flag is enabled

* unselect cline pass when the feature flag is off

* Store and read feature flag cache

* Add comment

* Do not return userId

* fix tests

* Revert unrelated changes
2026-06-13 01:29:29 +02:00
Saoud Rizwan 7810a81efe feat(sdk): encourage parallel tool calls (#11514)
* feat(sdk): encourage parallel tool calls

* fix(sdk): tighten tool execution return type

* test(sdk): remove prompt assertion test

* chore(sdk): restore existing prompt formatting

* chore(sdk): soften command batching wording

* chore(sdk): preserve tool execution default

* test(sdk): remove tool description assertions
2026-06-12 16:29:07 -07:00
Tomás Barreiro 2a54e2a76e Add cline pass (#11355)
* Centralize OAuth management to the SDK

* Update mock

* Cleanup TUI cline-account logic

* clean save credentails

* Remove unused code

* Reduce mocks

* use normalizeStoredAccessToken

* Add Cline Pass

* Properly read storageProviderId

* Use the name for the model generation

* Use the model for the capabilities lookup

* Fix capability discovery

* Fix getLastUsedProviderSettings

* remove the provider id from the resolveWithSingleFlight return

* Fix tests

* Remove the entry.name check

* Execute model API calls separetely

* Hide Cline Pass pricing

* update model list

* Address PR feedback

* revert unrelated changes

* Update comment

* Update check
2026-06-13 00:49:52 +02:00
Tomás Barreiro b7c38f76c9 Add buildtime variables for posthog (#11503)
* Add buildtime variables for posthog

* Apply changes
2026-06-13 00:25:22 +02:00
Saoud Rizwan d20e517831 feat(sdk): cap tool output ingestion for bash and file reads (#11480)
Tool outputs previously entered conversation history nearly unbounded
(1MB command output, whole-file reads up to 10MB) and were re-sent on
every subsequent request. Evals showed single observations of 350KB-3.2MB
dominating token spend versus opencode's 50KB-bounded observations.

- run_commands: combined stdout/stderr capped at 48,000 chars with
  head+tail sampling (middle elided with a notice reporting total size),
  since failures usually live at the end of build/test output. Failing
  commands carry the notice in stderr errors too. Streams decode through
  StringDecoder so multibyte chars split across chunks stay intact.
- read_files: whole-file and oversized-range reads windowed to 2,000
  lines / 48,000 chars with a notice reporting total line count and how
  to paginate via start_line/end_line. Per-line cap of 2,000 chars
  defangs minified files. In-window ranged reads are byte-for-byte
  unchanged; the 10MB stat guard stays.
- Shared constants live in executors/output-limits.ts, sized below
  MessageBuilder's 50,000 per-string backstop so source notices survive
  provider-request truncation intact. Tool descriptions document the
  windowing so the model pages or filters instead of retrying.

Companion to #11463/#11465: those bound provider requests at build time;
this bounds what enters history at the source and gives the model a
recovery path.
2026-06-12 11:51:48 -07:00
Tomás Barreiro fa3630da47 Add posthog for feature flags on the cli (#11491)
* Introduce PostHog as a Feature Flag provider

* Set-up auth after login

* Update the context when something changes in the CLI

* Make the distinctId not be optional

* Dispose of the feature flag service

* Remove the distinctId from the options

* get rid of isSharedClient

* Remove timeoutMs from the posthog options

* Rename functions to not refer cli

* Change the PostHogFeatureFlagsProvider API
2026-06-12 18:02:50 +02:00
Saoud Rizwan 8229d0c9be fix: format Cline OAuth tokens in provider config (#11489) 2026-06-11 20:03:46 -07:00
Saoud Rizwan efa14b6cab chore(cli): release v3.0.24 2026-06-11 14:27:27 -07:00
Saoud Rizwan c10b417b78 chore(sdk): release v0.0.47 2026-06-11 14:02:45 -07:00
Saoud Rizwan 9958e3f354 feat(cli): allow plugin commands to submit prompts (#11479)
* feat(cli): allow plugin commands to submit prompts

* fix(cli): preserve plugin command output on abort

* revert(cli): drop ineffective clear view tweak
2026-06-11 13:49:50 -07:00
Tomás Barreiro ec75291d5b Allow overriding the API base url (#11440)
* Allow overriding the base url

* Override the mcpbaseurl

* update the api base url

* Fix tests
2026-06-11 22:29:17 +02:00
Tomás Barreiro a3a31da37d Add the FeatureFlagService to the SDK [NOOP] (#11444)
* Add the FeatureFlagService to the SDK

* Fix comments

* Dispose of the telemetry service

* Dispose of the feature flag service

* Address PR feedback

* Stop the polling early if a new one is triggered with another user id

* Address PR feedback

* Dispose of the feature flag service

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-11 22:11:35 +02:00
Tomás Barreiro a69d650838 Open URLs when starting device auth (#11393)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-11 21:27:44 +02:00
Ara 7934d367a9 fix(sdk): truncate structured ToolOperationResult strings in MessageBuilder (#11465)
* test(sdk): add regression tests for structured ToolOperationResult truncation

MessageBuilder tests only covered string and {type:

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/mrbt2b39jfn370scr4g0ivzo"text"} tool-result
content, not the structured ToolOperationResult[] shape the default tools
(run_commands, read_files, search_codebase) actually emit. Those entries
are plain {query, result, success} objects with no type discriminator, so
the token-bloat path they create was unprotected by tests.

Adds regression tests using the real structured shape: huge result, huge
query, huge read_files payload, aggregate budget across multiple results,
mutation safety, and provider-formatted AI SDK payload size. Assertions
are on actual serialized payload sizes, not transcript shape.

The new tests fail at this commit by design; the following commit makes
them pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(sdk): truncate structured ToolOperationResult strings in MessageBuilder

The runtime stores structured tool outputs (ToolOperationResult[] from
run_commands/read_files/search_codebase) directly as the tool_result
content array (agentPartToContentBlock casts the array straight through).
Those entries have no type discriminator, so MessageBuilder's per-result
truncation, aggregate byte counting, and budget truncation all skipped
them — multi-megabyte command outputs and file reads were JSON-serialized
in full into every subsequent provider request.

MessageBuilder now deep-truncates nested strings inside structured
entries (middle truncation, preserving head and tail), counts them
against the aggregate text budget, collects them as budget-truncation
candidates, and deep-clones them before mutation so the original
conversation history stays untouched. Image blocks are skipped so base64
payloads survive intact.

Real-inference A/B on openrouter:minimax/minimax-m2.7 with realistic
structured payloads: 58.7% overall input-token reduction (82.6% on a
single huge command output) with identical answer correctness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/mrbt2b39jfn370scr4g0ivzo

* fix(sdk): include fetch_web_content in MessageBuilder truncation targets

Review feedback: fetch_web_content also returns ToolOperationResult[] and
its executor allows responses up to 5MB, but the tool was missing from
TARGET_TOOL_NAMES, so a single web fetch could still bloat every
subsequent provider request. Adds the tool to the truncation target set
with a regression test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/mrbt2b39jfn370scr4g0ivzo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:34:43 -07:00
Ara 7f9d5461f1 fix(sdk): stop echoing full command text in run_commands tool results (#11463)
The run_commands tool result's query field repeated the entire executed
command, which already exists verbatim in the assistant tool-call input.
For large generated-file commands (e.g. cat <<EOF heredocs) this
duplicated thousands of chars of source text into every subsequent
provider request.

Bound the provider-facing echo to a 200-char preview plus a truncation
note pointing at the tool call input. Short commands pass through
unchanged. Applies to both createBashTool and createWindowsShellTool,
on success and error paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

🔮 View transcript: https://agentlogs.beatrixwoo.workers.dev/s/og840mbjqaog4zita8m0262m
2026-06-11 11:22:20 -07:00
Saoud Rizwan e1bdeeff68 docs(changelog): note Vertex SDK companion bump in 3.89.2 (#11459) 2026-06-11 04:10:30 -07:00
Saoud Rizwan 49897830bb fix(vscode): align Anthropic Vertex SDK with runtime SDK (#11458) 2026-06-11 04:07:51 -07:00
Saoud Rizwan 1f316a2734 fix(vscode): remove unused ClineStorageMessage import in openai-format (#11457)
The SDK 0.50.1 upgrade widened convertToOpenAiMessages to take
Anthropic.Messages.MessageParam[], which left the ClineStorageMessage
import referenced only in comments. tsc does not flag unused imports in
this config, but biome lint does, and it blocked the 3.89.2 publish.
2026-06-11 03:52:55 -07:00
Saoud Rizwan 9c1f9133c7 v3.89.2 Release Notes (#11455) 2026-06-11 03:47:24 -07:00
Saoud Rizwan 2faef2b40d fix(vscode): upgrade @anthropic-ai/sdk to 0.50.1 for Node 24 compatibility (#11454)
VS Code 1.123.0 bumped its bundled runtime from Node 22 to Node 24
(Electron 39 to 42). The Anthropic provider broke on the updated editor
because the old SDK (<=0.41.x) shipped a legacy runtime built on
node-fetch and an internal _shims layer that does not work under Node 24.

0.50.1 is the first SDK release rewritten on top of the platform's native
fetch: it has zero runtime dependencies (no node-fetch, no _shims), which
removes the incompatibility. This is the actual fix; the earlier 0.40.1
bump did not change the runtime architecture.

The 0.50.1 type changes are minimal:
- Usage gained a required server_tool_use field, so fabricated Usage
  objects in the gemini/o1/openai/vscode-lm transforms set it to null.
- ContentBlockParam widened, so Anthropic.MessageParam is no longer
  structurally assignable to ClineStorageMessage. Handled by narrowing
  the two transform helpers that only ever receive Cline history
  (sanitizeAnthropicMessages, convertAnthropicMessageToGemini), typing
  getSavedApiConversationHistory as the Cline history it reads, and
  narrowing ContextManager's loosely-typed truncated output back to
  ClineStorageMessage at the two provider/hook boundaries.
2026-06-11 03:43:33 -07:00
Saoud Rizwan 64829bca8c chore(vscode): release v3.89.1 (#11451) 2026-06-11 02:49:39 -07:00
Saoud Rizwan 4c9ba6b091 fix(vscode): restore Anthropic provider on Node 24 by bumping SDK (#11449)
VS Code 1.123.0 bumped its bundled runtime from Node 22 to Node 24
(Electron 39 to 42). The extension passes VS Code's globalThis.fetch to
every provider SDK, but @anthropic-ai/sdk was pinned at 0.37.0, which
predates the SDK's native-fetch rewrite and relies on legacy _shims
runtime detection that breaks under Node 24. The modern OpenAI and Gemini
SDKs are unaffected, which is why only the Anthropic provider broke after
users updated VS Code.

Bump to ^0.40.1, the first release with the native-fetch rewrite that
restores Node 24 compatibility, while staying short of the latest line's
larger breaking surface.

The only code change the bump requires is narrowing the image source type:
ImageBlockParam.source widened from a base64-only type to
Base64ImageSource | URLImageSource. Add a getBase64ImageSource/
getImageDataUrl helper in shared/messages/content.ts and route the
provider transforms through it. Cline only ever produces base64 image
sources, so behavior is unchanged; the helper emits the same data URL the
inline code did.
2026-06-11 02:44:25 -07:00
Bee 6138bdfe40 feat: Enforce a production singleton Cline Hub (#11372)
* feat: Enforce a production singleton Cline Hub

This PR changes local Hub startup/discovery so production uses one stable daemon per user machine instead of silently creating additional hubs on random ports.

Replace resolveSharedHubOwnerContext with resolveProductionHubOwnerContext
across doctor and hub server lifecycle management to scope hub discovery
to the production owner.

Additionally:
- Preserve and propagate auth tokens when retiring incompatible hubs
- Throw a clear error when a compatible hub is already running but its
  discovery record is missing, guiding users to run 'cline doctor fix'
- Gate port fallback behind an explicit allowPortFallback override
- Update tests to mock the new production hub owner context

* patches

* fix

* hasExplicitPort

* Restored daemon cron startup, made discovery auth tokens required again, and fixed graceful hub stop/restart paths to use the selected production/shared owner context.

* clean up

* patches

* fix Polynomial regular expression

* test

* fix: require explicit hub port fallback in production

* fix(cli): stop pgrep from parsing the hub daemon marker as an option

pgrep treats the "--cline-hub-daemon" pattern as an unknown long option
and exits 2, so doctor never found stale daemons from compiled-binary
installs, which are exactly the processes 'cline doctor fix' is told to
clean up. Pass "--" before the pattern to end option parsing.

* fix(hub): retire legacy shared-owner hubs on production startup

Pre-singleton production builds tracked the local hub under the shared
owner discovery path and spawned daemons on random fallback ports. The
production owner context never reads that path, so upgrades would leave
those daemons running indefinitely with no way to reuse or stop them.
Retire the recorded legacy hub (its record carries the auth token and
pid needed for a graceful stop) and clear the legacy record before
resolving the production hub.

* refactor(hub): simplify stale discovery clearing, share capability list

shouldClearStaleHubDiscovery was only ever called with
discoveredVerified=false (the true assignment sits on a return path),
so the expected-hub probe and compatibility check had no effect and the
condition reduced to "a discovery record exists and was not reused".
Replace it with a plain conditional and drop the tests that exercised
unreachable states.

Also move the hub capability list into a typed HUB_CAPABILITIES
constant in @cline/shared next to HubCapabilityName so the server
cannot drift from the type.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-10 16:59:13 -07:00
Dominic Cooney 7d119351b1 fix(cli): suppress flickering console windows on Windows (#11408)
* fix(cli): suppress flickering console windows on Windows by setting windowsHide on child processes

On Windows, child_process.spawn/execFile default to windowsHide: false,
so console-subsystem children (powershell, rg, git, node, npm) can
allocate a new visible console window - guaranteed when detached: true
is used. In the CLI this caused constant short-lived window flashes
from run_commands, the git status bar polling, ripgrep searches and
indexing, clipboard helpers, and hook/plugin node subprocesses.

Set windowsHide: true (CREATE_NO_WINDOW; a no-op on non-Windows) on all
remaining spawn/spawnSync/execFile call sites in the SDK core, CLI,
Cline Hub, and example plugins, matching the pattern already used by
the MCP client, checkpoint-hooks, and StandaloneTerminalProcess.

* Update apps/cli/src/commands/kanban.ts

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

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-10 22:51:59 +09:00
Saoud Rizwan de987a5246 chore(cli): release v3.0.23 2026-06-09 17:43:24 -07:00
Saoud Rizwan 90050426df chore(sdk): release v0.0.46 2026-06-09 17:30:20 -07:00
Saoud Rizwan 205c5676ff fix(llms): fix disabled reasoning for Fable 5 error (#11397)
* fix(llms): avoid disabled reasoning for fable 5

* fix(llms): route fable reasoning by family

* Revert "fix(llms): route fable reasoning by family"

This reverts commit 6dd4e5dcf5.

* fix(llms): match claude fable reasoning workaround broadly
2026-06-09 17:24:34 -07:00
Bee 1c13edd395 fix(core): configured agent support as subagent tools (#11368)
* fix(core):  configured agent support as subagent tools

Introduce configured agent config parsing and tool creation for
subagents. Agent configs are defined via YAML frontmatter files
specifying name, description, tools, skills, model, and system prompt.

- Add `configured-agent-config` for loading and parsing agent
  definitions from search paths
- Add configured agent tool factory that wraps delegated agents as
  named subagent tools with policy and approval support

* patch

* patches

* fixes

* Infinite loop when YAML block is a non-object fix

* apply feedback

Forwarded host requestToolApproval into configured subagents.
Used the resolved workspace config root for configured-agent skills discovery.
Split configured-agent skill loading from root-session skills enablement.
Added host lifecycle/event plumbing for configured subagents via shared subagent callbacks.
Made UserInstructionConfigService.createSkillsExecutor optional and guarded its use.

* threaded

* test(core): cover configured subagent skill isolation (#11396)

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-09 17:23:00 -07:00
Ara a2a1936709 Fix Azure Foundry API version for CLI (#11359)
* Fix Azure Foundry API version for CLI

* Fix Azure API version setup
2026-06-09 16:28:37 -07:00
Max 35ce6a3f26 fix(cli): configure Vertex GCP settings (#11390)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-09 16:05:09 -07:00
Saoud Rizwan 2c4aeae4f3 fix(vscode): handle DeepSeek V4 reasoning format (#11392) 2026-06-09 15:10:38 -07:00
Tomás Barreiro 0c027d2731 Centralize OAuth management to the SDK (#11260)
* Centralize OAuth management to the SDK

* Update mock

* Cleanup TUI cline-account logic

* clean save credentails

* Remove unused code

* Reduce mocks

* use normalizeStoredAccessToken
2026-06-09 23:56:26 +02:00
Tomás Barreiro 6cc93c124e Format vscode using biome higher order rules (#11389) 2026-06-09 22:14:19 +02:00
Saoud Rizwan 7e5b8be28c chore(cli): release v3.0.22 2026-06-09 12:08:17 -07:00
Saoud Rizwan 764e901693 test(core): update legacy migration default to claude-fable-5
The Fable 5 PR (#11385) made claude-fable-5 the newest anthropic model,
which sorts first in the generated catalog. Legacy provider migration
defaults to the first catalog model, so the migrated default changed from
claude-opus-4-8 to claude-fable-5. Update the test expectation to match.
2026-06-09 11:55:08 -07:00
Saoud Rizwan 2cabb2ddf6 chore(sdk): release v0.0.45 2026-06-09 11:43:56 -07:00
Saoud Rizwan c32789f697 chore: bump version and update changelog (v3.89.0) (#11386) 2026-06-09 11:36:08 -07:00
Saoud Rizwan 349a8da750 feat(sdk): add Claude Fable 5 model support (#11385) 2026-06-09 11:31:50 -07:00
Saoud Rizwan f09dab7a0b feat(vscode): add Claude Fable 5 support to VS Code extension (#11384) 2026-06-09 11:19:29 -07:00
Robin Newhouse 3a3ea6ee96 Fix MiniMax M3 thinking controls across gateways [ENG-2163] (#11371)
* fix(llms): route MiniMax M3 thinking controls

* test(llms): tighten MiniMax M3 routing scope

* fix(llms): preserve fetch preconnect in MiniMax shim
2026-06-09 10:53:59 -07:00
dependabot[bot] 1c1ea0bd53 chore(deps): bump shell-quote from 1.8.3 to 1.8.4 in /apps/vscode (#11383)
Bumps [shell-quote](https://github.com/ljharb/shell-quote) from 1.8.3 to 1.8.4.
- [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.3...v1.8.4)

---
updated-dependencies:
- dependency-name: shell-quote
  dependency-version: 1.8.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-09 19:11:24 +02:00
Mikołaj Kondratek 70303d8541 Improve bug report form: rename surface dropdown, add IDE/CLI diagnostics (#11381)
* Improve bug report form: rename surface dropdown, add IDE/CLI diagnostics

Rename the 'Plugin Type' dropdown to 'Cline Surface' since CLI is not a plugin; the option values keep each choice unambiguous.

Add an 'IDE / CLI Diagnostics' field with per-surface copy-paste steps for About info (VSCode Help/About, JetBrains Help/About Copy button) and a CLI exception using 'cline --version'. System Information is left as-is; minor overlap is acceptable.

* Update repo-label-issues workflow for renamed Cline Surface field

The auto-labeler matches the rendered '### Plugin Type' heading. Since the form label was renamed to 'Cline Surface', update the three regexes so JetBrains/VS Code/CLI labels keep applying.
2026-06-09 18:38:53 +02:00
Saoud Rizwan 8ba15dfca6 chore(cli): release v3.0.21 2026-06-08 21:44:14 -07:00
Saoud Rizwan 6ab6a1eabc chore(sdk): release v0.0.44 2026-06-08 21:27:22 -07:00
Bee 2ad4146de1 doc(sdk): add host logger support in plugin examples (#11363)
* doc(sdk): add host logger support in plugin examples

Add examples to use the exposed `ctx.logger` to plugins via the `setup` second argument for
diagnostics. Wire logging into the agents-squad example to record setup,
subagent starts, follow-ups, and async failures, with a `logPluginError`
helper that falls back to severity-tagged logs. Update README with
logger usage guidance and examples.

* patches
2026-06-08 16:29:38 -07:00
Ara cfc2250717 fix(sdk): support Vertex ADC tool-use inference (#10773)
* fix(sdk): replay Vertex thought signatures

* fix(sdk): route Gemini 2.5 thinking config

* fix(sdk): tighten Vertex thinking replay routing

* chore(sdk): keep Vertex PR scoped to signatures

* chore(sdk): remove defensive thought signature fallback

* test(sdk): cover legacy Google thought signatures

* refactor(sdk): move Gemini model facts
2026-06-08 15:38:54 -07:00
Bee 730bac7f59 fix: empty SDK message content replay for Bedrock CLINE-2373 (#11320)
* fix: empty SDK message content replay for Bedrock CLINE-2373

This fixes SDK message formatting when persisted conversation history contains an empty user or assistant message, such as after an interrupted task is resumed.

Instead of dropping the message turn, the SDK now preserves it and inserts a text content block:

ERROR: EMPTY CONTENT

This prevents providers like Amazon Bedrock from rejecting replayed history with empty content arrays while avoiding message removal that could affect provider turn ordering.

* Exported EMPTY_CONTENT_TEXT from @cline/shared so core/shared use one constant
2026-06-08 14:47:53 -07:00
Bee 797ea1f607 feat: global auto-update setting for CLI startup updates (#11326)
* feat: global auto-update setting for CLI startup updates

* patches

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-08 14:22:41 -07:00
Saoud Rizwan 9d59de4a4c test(llms): align ChatGPT subscription model expectations (#11348) 2026-06-07 23:44:51 -07:00
Saoud Rizwan ae67ca7a13 fix(cli): show Cline credits refill link (#11345)
* fix(cli): show Cline credits refill link

* fix(cli): simplify Cline credits error matcher

* fix(cli): keep credits handling in TUI

* fix(cli): rename credits error matcher

* fix(cli): render credits dashboard as link

* fix(cli): remove credits redirect param

* fix(cli): document temporary credits matcher
2026-06-07 19:58:34 -07:00
Tomás Barreiro 7e2583f40c Fix broken tests (#11344) 2026-06-07 18:22:43 -07:00
Tomás Barreiro ecca88bb98 Clean-up the Codex model list (#11342) 2026-06-07 17:16:12 -07:00
Saoud Rizwan 4bb93ee5b9 chore: bump version and update changelog (v3.88.1) (#11334) 2026-06-06 18:26:28 -07:00
Saoud Rizwan 4f2d7398ed fix(vscode): include walkthrough files in extension package (#11333) 2026-06-06 17:56:55 -07:00
Saoud Rizwan bc184f346d fix(cli): scroll inline ask question responses (#11293)
* fix(cli): scroll inline ask question responses

* fix(cli): address ask question review feedback
2026-06-06 17:31:16 -07:00
Bee 96aea0d34b fix(cli): connector thread session routing & stale hub session (#11325)
* fix(cli): connector thread session routing & stale hub session

Fix connector thread session routing and stale hub session recovery

**PR Description**

This fixes connector messages from separate chat threads being routed into the wrong active runtime session.

**Issue**

In Slack, if a user sent a message in a different thread while another thread was still processing, the new message could be treated as a steer message for the active task. Users could also see errors like:

```text
Slack bridge error: session not found: 1780596180501_ms45m
```

when a connector thread had a persisted session id that no longer existed in the hub, such as after a hub restart.

**Cause**

Connector conversation bindings and active turn queues were using participant identity as the primary key in several paths. That allowed messages from the same user in different chat threads to resolve to the same connector session/active turn.

Separately, persisted connector `sessionId` values were trusted without checking whether the hub still had that runtime session. After a hub restart, the connector could try to send input to a stale session id.

**Fix**

- Store connector conversation bindings by thread id instead of participant key.
- Key connector active turn queues by thread id across Slack, Discord, Telegram, Google Chat, Linear, and WhatsApp adapters.
- Only treat a follow-up as a steer message when the active turn belongs to the same thread.
- Keep participant key/label as metadata instead of using it as the conversation binding key.
- Validate a persisted session id with the hub before reusing it.
- If the persisted session is missing, clear it from thread state and start a fresh runtime session.
- Update schedule delivery metadata to target thread ids while preserving participant metadata.
- Add regression coverage for cross-thread active sessions and stale persisted session ids.

**Verification**

```bash
bun -F @cline/cli typecheck
bunx vitest run apps/cli/src/connectors/connector-host.test.ts apps/cli/src/connectors/thread-bindings.test.ts apps/cli/src/connectors/adapters/slack.test.ts apps/cli/src/connectors/adapters/telegram.test.ts apps/cli/src/connectors/adapters/discord.test.ts apps/cli/src/connectors/adapters/gchat.test.ts apps/cli/src/connectors/adapters/linear.test.ts apps/cli/src/connectors/adapters/whatsapp.test.ts
```

* patches
2026-06-06 10:17:01 -07:00
Tomás Barreiro 676b446d47 Add debug section for Cline testers (#11318) 2026-06-05 12:09:01 -07:00
Ara a8835425bf chore: bump version and update changelog (v3.88.0) (#11316) 2026-06-05 09:58:50 -07:00
474 changed files with 43922 additions and 12269 deletions
+15 -3
View File
@@ -7,10 +7,10 @@ body:
value: |
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
- type: dropdown
id: plugin-type
id: cline-surface
attributes:
label: Plugin Type
description: Which plugin are you reporting a bug for?
label: Cline Surface
description: Which Cline surface are you reporting a bug for?
options:
- VSCode Extension
- JetBrains Plugin
@@ -59,6 +59,18 @@ body:
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
validations:
required: false
- type: textarea
id: ide-diagnostics
attributes:
label: IDE / CLI Diagnostics
description: |
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
- CLI: there is no About dialog. Run `cline --version` and paste the output.
placeholder: Paste the copied About info or `cline --version` output here.
validations:
required: false
- type: textarea
id: system-info
attributes:
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
const labels = context.payload.issue.labels.map(l => l.name);
// Check if JetBrains Plugin is selected
if (body.match(/###\s*Plugin Type\s*\n+JetBrains Plugin/i)) {
if (body.match(/###\s*Cline Surface\s*\n+JetBrains Plugin/i)) {
if (!labels.includes('JetBrains')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
@@ -29,7 +29,7 @@ jobs:
}
// Check if VSCode Extension is selected
if (body.match(/###\s*Plugin Type\s*\n+VSCode Extension/i)) {
if (body.match(/###\s*Cline Surface\s*\n+VSCode Extension/i)) {
if (!labels.includes('VS Code')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
@@ -41,7 +41,7 @@ jobs:
}
// Check if CLI is selected
if (body.match(/###\s*Plugin Type\s*\n+CLI/i)) {
if (body.match(/###\s*Cline Surface\s*\n+CLI/i)) {
if (!labels.includes('CLI')) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
+53
View File
@@ -1,5 +1,58 @@
# Changelog
## [3.89.2]
### Fixed
- Complete the fix for the Anthropic provider on VS Code 1.123 and later by upgrading the bundled Anthropic SDK to a release compatible with the Node 24 runtime.
- Update the Vertex AI provider to a compatible Anthropic Vertex SDK release so it works with the upgraded Anthropic SDK.
## [3.89.1]
### Fixed
- Restore the Anthropic provider on VS Code 1.123 and later, where the updated Node 24 runtime broke the bundled Anthropic SDK.
- Handle the DeepSeek V4 reasoning format.
## [3.89.0]
### Added
- Add Claude Fable 5 model support.
### Fixed
- Fix MiniMax M3 thinking controls across gateways.
### Changed
- Clean up the Codex model list.
## [3.88.1]
### Added
- Add a debug section in settings for Cline testers.
### Fixed
- Include the walkthrough markdown files in the VS Code extension package so the first-run walkthrough steps load correctly.
## [3.88.0]
### Added
- Add the latest Fireworks AI serverless models and update the default Fireworks model to Kimi K2.6.
### Fixed
- Fix MCP server delete/add flows so settings writes do not cause the MCP server list to be emptied by the file watcher.
- Remove stale Fireworks AI models and correct Fireworks model metadata and cache pricing.
### Changed
- Always use the upstream Cline recommended models endpoint instead of gating it behind a feature flag.
## [3.87.0]
### Added
+3 -1
View File
@@ -1,7 +1,9 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
"root": false,
"extends": ["../sdk/biome.json"],
"extends": [
"../sdk/biome.json"
],
"linter": {
"rules": {
"a11y": {
+76
View File
@@ -1,5 +1,81 @@
# Cline CLI Changelog
## 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)
- Added a prefilled MCP install wizard command for quicker MCP server setup
- Improved error handling and messaging when plugin MCP OAuth authorization fails
- The CLI now rejects unknown commands and unquoted multi-word input with a clear error instead of silently treating bad arguments as a prompt
## 3.0.26
- Reverted the expandable model picker sections and ClinePass models, restoring the previous model-selection UI
## 3.0.25
- Added ClinePass support, with selectable ClinePass models in the model picker
- Made model picker sections expandable
- Added MCP server support to plugins, including authorizing plugin MCP OAuth during install
- Encouraged parallel tool calls for faster task execution
- Capped tool output for bash commands and file reads to keep large output within context limits
- Allowed ranged reads on large files
- Fixed apply_patch to fail when a hunk is skipped
- Fixed run_commands to return captured stdout on failure and handle split heredocs
- Fixed search tools to treat zero results as success
- Fixed disabled-reasoning handling for StepFun flash
- Fixed history resume rendering isolation
- Fixed the Hugging Face URL
- Fixed Cline OAuth token formatting in provider config
## 3.0.24
- Plugin commands can now submit prompts to the agent
- Added support for overriding the API base URL
- Open the verification URL automatically when starting device authentication
- Enforced a single shared Cline Hub, so a stale hub is respawned after an upgrade
- Suppressed flickering console windows on Windows
- Fixed truncation of structured tool operation result strings so oversized tool output stays within limits
- Stopped echoing the full command text in run_commands tool results
## 3.0.23
- Fixed Vertex AI GCP settings configuration
- Fixed the Azure Foundry API version
- Added support for configured agents as subagent tools
- Centralized OAuth management into the SDK
- Fixed an error caused by disabled reasoning on Fable 5
## 3.0.22
- Added support for the Claude Fable 5 model
- Fixed MiniMax M3 thinking controls so they route correctly across gateways
## 3.0.21
- Added a global auto-update setting that controls automatic updates on CLI startup
- Added a Cline credits refill link
- Fixed scrolling for inline ask-question responses
- Fixed connector thread session routing and stale hub session handling
- Added support for Vertex AI Application Default Credentials (ADC) with tool use
- Fixed empty message content replay for Bedrock
- Cleaned up the OpenAI Codex model list
## 3.0.20
- Installed plugin wrappers are now named from their source (npm package name, git repo, remote filename, official slug, or local directory) instead of an opaque hash, making installed plugins easier to identify.
+24
View File
@@ -163,6 +163,30 @@ cline auth --provider anthropic --apikey sk-... --modelid claude-sonnet-4-6
cline auth --provider openai-native --apikey sk-... --modelid gpt-5 --baseurl https://api.example.com/v1
```
### MCP servers
Manage MCP servers with the interactive wizard:
```sh
cline mcp
cline config mcp
```
Open the add-server wizard with the name, transport, and command or URL already filled in with `cline mcp install` (`cline mcp add` also works). Stdio servers use everything after `--` as the command and arguments:
```sh
cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp
```
Remote HTTP and SSE servers take a name, transport, and URL. The wizard still asks for auth details before saving:
```sh
cline mcp install ctx7 --transport http https://mcp.context7.com/mcp
cline mcp install events --transport sse https://example.com/sse
```
Because this command opens the wizard, it requires a TTY.
### Connectors
Bridge a chat surface into RPC-backed Cline sessions. Each conversation thread maps to a session with full context. Supported platforms: Telegram, Slack, Google Chat, WhatsApp, and Linear.
+14
View File
@@ -85,6 +85,20 @@ const result = await Bun.build({
],
define: {
"process.env.NODE_ENV": '"production"',
...(process.env.TELEMETRY_SERVICE_API_KEY
? {
"process.env.TELEMETRY_SERVICE_API_KEY": defineProcessEnv(
"TELEMETRY_SERVICE_API_KEY",
),
}
: {}),
...(process.env.ERROR_SERVICE_API_KEY
? {
"process.env.ERROR_SERVICE_API_KEY": defineProcessEnv(
"ERROR_SERVICE_API_KEY",
),
}
: {}),
"process.env.OTEL_TELEMETRY_ENABLED": defineProcessEnv(
"OTEL_TELEMETRY_ENABLED",
),
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.20",
"version": "3.0.29",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -87,6 +87,7 @@
"open": "^10.2.0",
"opentui-spinner": "^0.0.6",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"react": "19.2.4",
"react-devtools-core": "^7.0.1",
"react-reconciler": "0.32.0",
+24 -59
View File
@@ -1,11 +1,6 @@
import type { ProviderSettings, ProviderSettingsManager } from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import type { OAuthCredentials } from "../commands/auth";
import {
getPersistedProviderApiKey,
saveOAuthProviderSettings,
toProviderApiKey,
} from "../commands/auth";
import type { ProviderSettingsManager } from "@cline/core";
import { loginAndSaveProviderOAuthCredentials } from "@cline/core";
import { getPersistedProviderApiKey } from "../commands/auth";
import { writeDiagnostic } from "../utils/output";
/**
@@ -30,37 +25,13 @@ export function isAcpAuthMethodId(id: string): id is AcpAuthMethodId {
* If the OAuth flow requires interactive prompts (rare), defaults are used
* when available; otherwise an error is thrown.
*/
async function performOAuthLogin(
providerId: AcpAuthMethodId,
existingSettings: ProviderSettings | undefined,
): Promise<OAuthCredentials> {
const [{ createOAuthClientCallbacks }, { default: open }, coreOAuth] =
await Promise.all([
import("@cline/core"),
import("open"),
import("@cline/core").then((m) => ({
loginClineOAuth: m.loginClineOAuth as (input: {
useWorkOSDeviceAuth?: boolean;
apiBaseUrl: string;
callbacks: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
};
}) => Promise<OAuthCredentials>,
loginOpenAICodex: m.loginOpenAICodex as (input: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
}) => Promise<OAuthCredentials>,
})),
]);
async function performOAuthLogin(input: {
providerId: AcpAuthMethodId;
providerSettingsManager: ProviderSettingsManager;
}): Promise<string> {
const [{ createOAuthClientCallbacks }, { default: open }] = await Promise.all(
[import("@cline/core"), import("open")],
);
const callbacks = createOAuthClientCallbacks({
onPrompt: ({ defaultValue }) => {
@@ -82,18 +53,18 @@ async function performOAuthLogin(
},
});
if (providerId === "cline") {
return coreOAuth.loginClineOAuth({
apiBaseUrl:
existingSettings?.baseUrl?.trim() ||
getClineEnvironmentConfig().apiBaseUrl,
callbacks,
useWorkOSDeviceAuth: true,
});
const settings = await loginAndSaveProviderOAuthCredentials(
input.providerSettingsManager,
input.providerId,
{ callbacks },
);
const apiKey = getPersistedProviderApiKey(input.providerId, settings);
if (!apiKey) {
throw new Error(
`OAuth login did not persist credentials for ${input.providerId}`,
);
}
// openai-codex
return coreOAuth.loginOpenAICodex(callbacks);
return apiKey;
}
export interface AcpAuthResult {
@@ -122,16 +93,10 @@ export async function authenticateAcpProvider(
// Perform a fresh OAuth login.
writeDiagnostic(`[acp/auth] Starting OAuth login for ${methodId}`);
const credentials = await performOAuthLogin(methodId, existing);
saveOAuthProviderSettings(
const apiKey = await performOAuthLogin({
providerId: methodId,
providerSettingsManager,
methodId,
existing,
credentials,
);
const apiKey = toProviderApiKey(methodId, credentials);
});
writeDiagnostic(`[acp/auth] Successfully authenticated with ${methodId}`);
return { providerId: methodId, apiKey };
}
+21
View File
@@ -746,6 +746,27 @@ Break work into clear steps.`,
).toBe(true);
});
it("routes mcp install and requires a TTY for the prefilled wizard", () => {
const result = runCli(
[
"mcp",
"install",
"fs",
"--",
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp",
],
{ env: createIsolatedEnv() },
);
expect(result.status).toBe(1);
expect(asText(result.stderr)).toContain(
"cline mcp install opens the MCP wizard and requires a TTY.",
);
});
it("lists available tools", () => {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
+94 -22
View File
@@ -18,6 +18,8 @@ interface KeyStep {
const INITIAL_RENDER_DELAY_SECONDS = 2.5;
const POST_ACTION_SETTLE_SECONDS = 1.0;
const INTERACTIVE_TEST_TIMEOUT_MS = 40_000;
const HISTORY_PICKER_READY_DELAY_SECONDS = 8.0;
const HISTORY_RESUME_READY_DELAY_SECONDS = 15.0;
function normalizeTerminalOutput(output: string): string {
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally strips ANSI escape sequences
@@ -51,16 +53,40 @@ function buildScriptCommand(scriptedInput: string, launchArgs: string): string {
return `(${scriptedInput}) | script ${quietFlag} /dev/null ${toShellSingleQuotedLiteral(bunExec)} ${launchArgs}`;
}
function runInteractiveCli(
steps: KeyStep[],
options?: { launchConfigView?: boolean },
): CliResult {
function createCliEnv(): NodeJS.ProcessEnv {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-data-"));
const sessionDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-sessions-"));
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-teams-"));
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
return {
...process.env,
HOME: homeDir,
CLINE_DATA_DIR: dataDir,
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
CLINE_SESSION_DATA_DIR: sessionDir,
CLINE_TEAM_DATA_DIR: teamDir,
CLINE_SESSION_BACKEND_MODE: "local",
CLINE_PROVIDER_SETTINGS_PATH: path.join(
dataDir,
"settings",
"providers.json",
),
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
};
}
function runInteractiveCli(
steps: KeyStep[],
options?: {
launchConfigView?: boolean;
launchArgs?: string[];
env?: NodeJS.ProcessEnv;
},
): CliResult {
const env = options?.env ?? createCliEnv();
const scriptedInput = [
...steps,
// Exit each interactive run explicitly so tests do not idle until timeout.
@@ -80,9 +106,13 @@ function runInteractiveCli(
"-k",
"test-key",
];
const launchArgs = [
...(options?.launchConfigView ? [...baseArgs, "config"] : baseArgs),
]
const launchArgs = (
options?.launchArgs
? [cliEntry, ...options.launchArgs]
: options?.launchConfigView
? [...baseArgs, "config"]
: baseArgs
)
.map((arg) => toShellSingleQuotedLiteral(arg))
.join(" ");
const command = buildScriptCommand(scriptedInput, launchArgs);
@@ -90,21 +120,7 @@ function runInteractiveCli(
return spawnSync("bash", ["-lc", command], {
cwd: cliRoot,
encoding: "utf8",
env: {
...process.env,
HOME: homeDir,
CLINE_DATA_DIR: dataDir,
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
CLINE_SESSION_DATA_DIR: sessionDir,
CLINE_TEAM_DATA_DIR: teamDir,
CLINE_SESSION_BACKEND_MODE: "local",
CLINE_PROVIDER_SETTINGS_PATH: path.join(
dataDir,
"settings",
"providers.json",
),
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
},
env,
timeout: INTERACTIVE_TEST_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
});
@@ -188,6 +204,62 @@ describe("cli interactive e2e", () => {
expect(output).toContain("/ for commands · @ for files");
});
it("resumes a history-picked session and survives Ctrl+C without a native crash", {
timeout: 120_000,
}, () => {
const env = createCliEnv();
// Seed one session; the invalid key makes the run fail fast while
// still persisting a resumable session record.
const seed = spawnSync(
bunExec,
[
cliEntry,
"--provider",
"anthropic",
"-m",
"claude-sonnet-4-6",
"-k",
"test-key",
"hello",
],
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
);
expect(seed.error).toBeUndefined();
const history = spawnSync(bunExec, [cliEntry, "history", "--json"], {
cwd: cliRoot,
encoding: "utf8",
env,
timeout: 60_000,
});
expect(history.error).toBeUndefined();
expect(history.status).toBe(0);
const historyRows = JSON.parse(history.stdout) as unknown[];
expect(historyRows.length).toBeGreaterThan(0);
// history picker -> Enter resumes the seeded session in the
// interactive TUI -> double Ctrl+C exits it. Regression guard for
// the Bun "panic(main thread): Segmentation fault" that occurred
// when the resumed TUI shared the picker's process (a second
// OpenTUI renderer in one process crashes natively on teardown).
const result = runInteractiveCli(
[
// Select the seeded session in the picker.
{ delaySeconds: HISTORY_PICKER_READY_DELAY_SECONDS, input: "\r" },
// Give the resumed TUI time to start, then double-press
// Ctrl+C; the harness appends the final press 0.2s later.
{ delaySeconds: HISTORY_RESUME_READY_DELAY_SECONDS, input: "\u0003" },
],
{ launchArgs: ["history"], env },
);
const output = outputOf(result);
// The exit summary only prints after the resumed interactive TUI ran
// and shut down cleanly; the history picker alone never prints it.
expect(output).toContain("Session Summary");
expect(output).not.toContain("panic(");
expect(output).not.toContain("Segmentation fault");
expect(result.status).toBe(0);
});
it("launches config view directly with `cline config`", () => {
const result = runInteractiveCli(
[{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "" }],
+37 -1
View File
@@ -2,7 +2,37 @@ import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import type { ProviderSettingsManager } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import { getPersistedProviderApiKey, saveOAuthProviderSettings } from "./auth";
import {
getPersistedProviderApiKey,
normalizeAuthProviderId,
parseAuthCommandArgs,
saveOAuthProviderSettings,
} from "./auth";
describe("parseAuthCommandArgs", () => {
it("parses Azure API version quick setup option", () => {
expect(
parseAuthCommandArgs([
"--provider",
"openai-compatible",
"--apikey",
"key",
"--modelid",
"gpt-4.1",
"--baseurl",
"https://example.openai.azure.com/openai/deployments/gpt-4.1",
"--azure-api-version",
"2025-01-01-preview",
]),
).toMatchObject({
explicitProvider: "openai-compatible",
apikey: "key",
modelid: "gpt-4.1",
baseurl: "https://example.openai.azure.com/openai/deployments/gpt-4.1",
azureApiVersion: "2025-01-01-preview",
});
});
});
describe("saveOAuthProviderSettings", () => {
it("preserves existing manual apiKey while updating OAuth tokens", () => {
@@ -67,6 +97,12 @@ describe("getPersistedProviderApiKey", () => {
});
});
describe("normalizeAuthProviderId", () => {
it("keeps CLI-only codex shorthand in CLI parsing", () => {
expect(normalizeAuthProviderId("codex")).toBe("openai-codex");
});
});
describe("loadAuthTuiRuntime", () => {
it("loads OpenTUI React after provider catalog initialization", async () => {
const cliRoot = fileURLToPath(new URL("../..", import.meta.url));
+46 -125
View File
@@ -3,11 +3,12 @@ import {
BUILT_IN_PROVIDER,
createOAuthClientCallbacks,
ensureCustomProvidersLoaded,
listLocalProviders,
getProviderAuthHandler,
loginAndSaveProviderOAuthCredentials,
type ProviderSettings,
type ProviderSettingsManager,
saveProviderOAuthCredentials,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import React from "react";
@@ -20,6 +21,8 @@ import {
type OAuthCredentials,
toProviderApiKey,
} from "../utils/provider-auth";
import { listLocalProviders } from "../utils/provider-catalog";
import { identifyTelemetryAccount } from "../utils/telemetry";
export {
getPersistedProviderApiKey,
@@ -37,40 +40,6 @@ const c = {
green: "\x1b[32m",
};
type CoreOAuthApi = {
loginClineOAuth: (input: {
apiBaseUrl: string;
useWorkOSDeviceAuth?: boolean;
callbacks: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
};
}) => Promise<OAuthCredentials>;
loginOcaOAuth: (input: {
mode?: "internal" | "external";
callbacks: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
};
}) => Promise<OAuthCredentials>;
loginOpenAICodex: (input: {
onAuth: (info: { url: string; instructions?: string }) => void;
onPrompt: (prompt: {
message: string;
defaultValue?: string;
}) => Promise<string>;
onManualCodeInput?: () => Promise<string>;
}) => Promise<OAuthCredentials>;
};
type AuthIo = {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
@@ -81,6 +50,7 @@ type AuthQuickSetupInput = {
apikey: string;
modelid: string;
baseurl?: string;
azureApiVersion?: string;
};
type AuthCommandInput = {
@@ -90,6 +60,7 @@ type AuthCommandInput = {
apikey?: string;
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
};
type ParsedAuthCommandArgs = {
@@ -97,30 +68,10 @@ type ParsedAuthCommandArgs = {
apikey?: string;
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
parseError?: string;
};
let cachedCoreOAuthApi: Promise<CoreOAuthApi> | undefined;
async function getCoreOAuthApi(): Promise<CoreOAuthApi> {
if (!cachedCoreOAuthApi) {
cachedCoreOAuthApi = import("@cline/core").then((module) => {
const runtimeApi = module as Partial<CoreOAuthApi>;
if (
typeof runtimeApi.loginClineOAuth !== "function" ||
typeof runtimeApi.loginOcaOAuth !== "function" ||
typeof runtimeApi.loginOpenAICodex !== "function"
) {
throw new Error(
"Installed @cline/core does not expose OAuth login helpers required by the CLI",
);
}
return runtimeApi as CoreOAuthApi;
});
}
return cachedCoreOAuthApi;
}
/**
* Create the `auth` subcommand for Commander.
*
@@ -137,7 +88,8 @@ export function createAuthCommand(): Command {
.option("-p, --provider <id>", "provider id")
.option("-k, --apikey <key>", "API key")
.option("-m, --modelid <id>", "model id")
.option("-b, --baseurl <url>", "base URL");
.option("-b, --baseurl <url>", "base URL")
.option("--azure-api-version <version>", "Azure API version");
return cmd;
}
@@ -154,6 +106,7 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
apikey?: string;
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
}>();
const positionalProvider = cmd.args[0];
return {
@@ -161,6 +114,7 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
apikey: opts.apikey,
modelid: opts.modelid,
baseurl: opts.baseurl,
azureApiVersion: opts.azureApiVersion,
};
}
@@ -200,6 +154,12 @@ async function ensureQuickSetupInputValid(
) {
return "base URL is only supported for OpenAI and OpenAI-compatible providers";
}
if (
input.azureApiVersion?.trim() &&
normalizedProvider !== BUILT_IN_PROVIDER.OPENAI_COMPATIBLE
) {
return "Azure API version is only supported for OpenAI-compatible providers";
}
return undefined;
}
@@ -209,6 +169,7 @@ function saveQuickAuthProviderSettings(input: {
apikey: string;
modelid: string;
baseurl?: string;
azureApiVersion?: string;
}): void {
const existing = input.providerSettingsManager.getProviderSettings(
input.providerId,
@@ -224,6 +185,12 @@ function saveQuickAuthProviderSettings(input: {
if (input.baseurl?.trim()) {
nextSettings.baseUrl = input.baseurl.trim();
}
if (input.azureApiVersion?.trim()) {
nextSettings.azure = {
...(nextSettings.azure ?? {}),
apiVersion: input.azureApiVersion.trim(),
};
}
input.providerSettingsManager.saveProviderSettings(nextSettings);
}
@@ -272,64 +239,18 @@ function createOAuthCallbacks(io: AuthIo): {
});
}
async function loginWithOAuthProvider(
providerId: string,
existing: ProviderSettings | undefined,
io: AuthIo,
): Promise<OAuthCredentials> {
const oauthApi = await getCoreOAuthApi();
const callbacks = createOAuthCallbacks(io);
if (providerId === "cline") {
return oauthApi.loginClineOAuth({
apiBaseUrl:
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
useWorkOSDeviceAuth: true,
callbacks,
});
}
if (providerId === "oca") {
const mode = existing?.oca?.mode;
return oauthApi.loginOcaOAuth({
mode,
callbacks,
});
}
if (providerId === "openai-codex") {
return oauthApi.loginOpenAICodex(callbacks);
}
throw new Error(
`Provider "${providerId}" does not support CLI OAuth flow (supported: cline, openai-codex, oca)`,
);
}
export function saveOAuthProviderSettings(
providerSettingsManager: ProviderSettingsManager,
providerId: string,
existing: ProviderSettings | undefined,
credentials: OAuthCredentials,
): ProviderSettings {
const auth = {
...(existing?.auth ?? {}),
accessToken: toProviderApiKey(providerId, credentials),
refreshToken: credentials.refresh,
accountId: credentials.accountId,
} as ProviderSettings["auth"] & { expiresAt?: number };
auth.expiresAt = credentials.expires;
const merged: ProviderSettings = {
...(existing ?? {
provider: providerId as ProviderSettings["provider"],
}),
provider: providerId as ProviderSettings["provider"],
auth,
};
providerSettingsManager.saveProviderSettings(merged, {
tokenSource: "oauth",
return saveProviderOAuthCredentials({
manager: providerSettingsManager,
providerId,
settings: existing,
credentials,
});
return merged;
}
export async function ensureOAuthProviderApiKey(input: {
@@ -348,19 +269,14 @@ export async function ensureOAuthProviderApiKey(input: {
selectedProviderSettings: input.existingSettings,
};
}
const credentials = await loginWithOAuthProvider(
input.providerId,
input.existingSettings,
input.io,
);
const selectedProviderSettings = saveOAuthProviderSettings(
const selectedProviderSettings = await loginAndSaveProviderOAuthCredentials(
input.providerSettingsManager,
input.providerId,
input.existingSettings,
credentials,
{ callbacks: createOAuthCallbacks(input.io) },
);
const handler = getProviderAuthHandler(input.providerId);
return {
apiKey: toProviderApiKey(input.providerId, credentials),
apiKey: handler?.getApiKey(selectedProviderSettings),
selectedProviderSettings,
};
}
@@ -370,12 +286,14 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
const apikey = input.apikey?.trim() ?? "";
const modelid = input.modelid?.trim() ?? "";
const baseurl = input.baseurl?.trim();
const azureApiVersion = input.azureApiVersion?.trim();
const validationError = await ensureQuickSetupInputValid(
{
provider: providerId,
apikey,
modelid,
baseurl,
azureApiVersion,
},
input.providerSettingsManager,
);
@@ -389,6 +307,7 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
apikey,
modelid,
baseurl,
azureApiVersion,
});
input.io.writeln(
`${c.green}Provider configured:${c.reset} ${c.cyan}${providerId}${c.reset} (${modelid})`,
@@ -473,12 +392,13 @@ export async function runAuthCommand(input: AuthCommandInput): Promise<number> {
const hasQuickSetupFlags =
typeof input.apikey === "string" ||
typeof input.modelid === "string" ||
typeof input.baseurl === "string";
typeof input.baseurl === "string" ||
typeof input.azureApiVersion === "string";
if (hasQuickSetupFlags) {
if (!input.explicitProvider?.trim()) {
input.io.writeErr(
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl",
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl/--azure-api-version",
);
return 1;
}
@@ -515,14 +435,15 @@ export async function runAuthProviderCommand(
return 1;
}
try {
const existing = providerSettingsManager.getProviderSettings(providerId);
const credentials = await loginWithOAuthProvider(providerId, existing, io);
saveOAuthProviderSettings(
const settings = await loginAndSaveProviderOAuthCredentials(
providerSettingsManager,
providerId,
existing,
credentials,
{ callbacks: createOAuthCallbacks(io) },
);
identifyTelemetryAccount({
id: settings.auth?.accountId,
provider: providerId,
});
io.writeln(
`${c.green}You are now logged in to ${c.cyan}${providerId}${c.reset}`,
);
+24
View File
@@ -6,6 +6,15 @@ import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
const ENV_KEYS = [
"WORKSPACE_ROOT",
"CLINE_DIR",
"CLINE_SANDBOX",
"CLINE_SANDBOX_DATA_DIR",
"CLINE_DATA_DIR",
"CLINE_DB_DATA_DIR",
"CLINE_SESSION_DATA_DIR",
"CLINE_TEAM_DATA_DIR",
"CLINE_PROVIDER_SETTINGS_PATH",
"CLINE_HOOKS_LOG_PATH",
"HOST",
"CLINE_HUB_DASHBOARD_PORT",
"PUBLIC_URL",
@@ -38,6 +47,9 @@ describe("runDashboardCommand", () => {
let observedEnv:
| {
workspaceRoot: string | undefined;
clineDir: string | undefined;
clineDataDir: string | undefined;
providerSettingsPath: string | undefined;
host: string | undefined;
port: string | undefined;
publicUrl: string | undefined;
@@ -50,7 +62,9 @@ describe("runDashboardCommand", () => {
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
const exitCode = await runDashboardCommand({
configDir: "/tmp/cline-config",
cwd: "sdk",
dataDir: ".cline-dashboard-data",
host: "127.0.0.1",
port: "9090",
publicUrl: "http://127.0.0.1:9090",
@@ -62,6 +76,9 @@ describe("runDashboardCommand", () => {
startServer: async () => {
observedEnv = {
workspaceRoot: process.env.WORKSPACE_ROOT,
clineDir: process.env.CLINE_DIR,
clineDataDir: process.env.CLINE_DATA_DIR,
providerSettingsPath: process.env.CLINE_PROVIDER_SETTINGS_PATH,
host: process.env.HOST,
port: process.env.CLINE_HUB_DASHBOARD_PORT,
publicUrl: process.env.PUBLIC_URL,
@@ -87,6 +104,13 @@ describe("runDashboardCommand", () => {
expect(exitCode).toBe(0);
expect(observedEnv).toEqual({
workspaceRoot: resolve("sdk"),
clineDir: "/tmp/cline-config",
clineDataDir: resolve("sdk", ".cline-dashboard-data"),
providerSettingsPath: join(
resolve("sdk", ".cline-dashboard-data"),
"settings",
"providers.json",
),
host: "127.0.0.1",
port: "9090",
publicUrl: "http://127.0.0.1:9090",
+27 -7
View File
@@ -3,6 +3,7 @@ import { arch, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import open from "open";
import { configureSandboxEnvironment } from "../utils/helpers";
import { c } from "../utils/output";
export interface DashboardServerHandle {
@@ -19,7 +20,9 @@ interface DashboardCommandIo {
}
export interface RunDashboardCommandOptions {
configDir?: string;
cwd?: string;
dataDir?: string;
host?: string;
port?: string;
publicUrl?: string;
@@ -36,10 +39,9 @@ const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
function setEnvValue(name: string, value: string | undefined): () => void {
const previous = process.env[name];
if (value === undefined) {
return () => {};
if (value !== undefined) {
process.env[name] = value;
}
process.env[name] = value;
return () => {
if (previous === undefined) {
delete process.env[name];
@@ -49,21 +51,39 @@ function setEnvValue(name: string, value: string | undefined): () => void {
};
}
const SANDBOX_ENV_KEYS = [
"CLINE_SANDBOX",
"CLINE_SANDBOX_DATA_DIR",
"CLINE_DATA_DIR",
"CLINE_DB_DATA_DIR",
"CLINE_SESSION_DATA_DIR",
"CLINE_TEAM_DATA_DIR",
"CLINE_PROVIDER_SETTINGS_PATH",
"CLINE_HOOKS_LOG_PATH",
] as const;
async function withDashboardEnvironment<T>(
options: RunDashboardCommandOptions,
fn: () => Promise<T>,
): Promise<T> {
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
const restore = [
setEnvValue(
"WORKSPACE_ROOT",
options.cwd ? resolve(options.cwd) : undefined,
),
setEnvValue("WORKSPACE_ROOT", options.cwd ? cwd : undefined),
setEnvValue("CLINE_DIR", options.configDir?.trim() || undefined),
setEnvValue("HOST", options.host),
setEnvValue(DASHBOARD_PORT_ENV, options.port),
setEnvValue("PUBLIC_URL", options.publicUrl),
setEnvValue("ROOM_SECRET", options.roomSecret),
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
...SANDBOX_ENV_KEYS.map((key) => setEnvValue(key, undefined)),
];
if (options.dataDir || process.env.CLINE_SANDBOX?.trim() === "1") {
configureSandboxEnvironment({
enabled: true,
cwd,
explicitDir: options.dataDir,
});
}
try {
return await fn();
} finally {
+24 -2
View File
@@ -14,6 +14,7 @@ import { getCliBuildInfo } from "../utils/common";
const {
mockSpawnSync,
mockResolveClineDataDir,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockReadHubDiscovery,
mockProbeHubServer,
@@ -24,6 +25,15 @@ const {
} = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"production.json",
),
})),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: path.join(
@@ -52,6 +62,7 @@ vi.mock("node:child_process", () => ({
vi.mock("@cline/core", () => ({
resolveClineDataDir: mockResolveClineDataDir,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
clearHubDiscovery: mockClearHubDiscovery,
probeHubServer: mockProbeHubServer,
@@ -76,6 +87,15 @@ describe("runDoctorCommand", () => {
afterEach(() => {
vi.clearAllMocks();
mockResolveClineDataDir.mockReturnValue("/tmp/cline-data");
mockResolveProductionHubOwnerContext.mockReturnValue({
ownerId: "hub-production",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"production.json",
),
});
mockStopLocalHubServerGracefully.mockResolvedValue(false);
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 0,
@@ -110,7 +130,8 @@ describe("runDoctorCommand", () => {
command === "pgrep" &&
Array.isArray(args) &&
args[0] === "-fal" &&
args[1] === "/apps/cli/src/index.ts"
args[1] === "--" &&
args[2] === "/apps/cli/src/index.ts"
) {
return {
status: 0,
@@ -261,7 +282,8 @@ describe("runDoctorCommand", () => {
command === "pgrep" &&
Array.isArray(args) &&
args[0] === "-fal" &&
args[1] === "/src-tauri/bin/code-sidecar"
args[1] === "--" &&
args[2] === "/src-tauri/bin/code-sidecar"
) {
return {
status: 0,
+60 -9
View File
@@ -7,10 +7,11 @@ import {
probeHubServer,
readHubDiscovery,
resolveClineDataDir,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime } from "@cline/shared";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { isProcessRunning } from "../connectors/common";
@@ -54,6 +55,7 @@ type DoctorStatus = {
hubStartedAt?: string;
hubUptime?: string;
listeningPids: number[];
staleHubPids: number[];
hubStartupLocks: StartupArtifact[];
staleCliPids: number[];
staleSidecarPids: number[];
@@ -77,7 +79,11 @@ function listMatchingProcesses(pattern: string): ProcessRecord[] {
if (process.platform === "win32") {
return [];
}
const result = spawnSync("pgrep", ["-fal", pattern], { encoding: "utf8" });
// "--" stops pgrep's option parsing so patterns that start with dashes
// (e.g. the "--cline-hub-daemon" marker) are treated as patterns.
const result = spawnSync("pgrep", ["-fal", "--", pattern], {
encoding: "utf8",
});
if (result.status !== 0 && result.status !== 1) {
return [];
}
@@ -148,6 +154,25 @@ function listStaleCliPids(): number[] {
.map((record) => record.pid);
}
function listStaleHubPids(currentHubPids: number[]): number[] {
const current = new Set(currentHubPids.filter((pid) => pid > 0));
const patterns = [
"/sdk/packages/core/src/hub/daemon/entry.ts",
"/sdk/packages/core/dist/hub/daemon/entry.js",
"--cline-hub-daemon",
];
const records = new Map<number, ProcessRecord>();
for (const pattern of patterns) {
for (const record of listMatchingProcesses(pattern)) {
if (current.has(record.pid) || /\bpgrep\s+-fal\b/.test(record.command)) {
continue;
}
records.set(record.pid, record);
}
}
return [...records.values()].map((record) => record.pid);
}
function listStaleSidecarPids(): number[] {
const patterns = [
"/apps/examples/desktop-app/sidecar/index.ts",
@@ -235,7 +260,7 @@ function readStartupArtifact(path: string): StartupArtifact | undefined {
}
function listHubStartupLocks(_cwd: string): StartupArtifact[] {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const ownerPath = join(`${owner.discoveryPath}.lock`, "owner.json");
if (!existsSync(ownerPath)) {
return [];
@@ -259,7 +284,7 @@ async function clearHubStartupArtifacts(
_cwd: string,
options?: { clearDiscovery?: boolean },
): Promise<{ startupLocks: number; discovery: number }> {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const startupLocks = listHubStartupLocks(_cwd);
let clearedStartupLocks = 0;
for (const artifact of startupLocks) {
@@ -291,14 +316,25 @@ function formatHubUptimeFromStartedAt(
return formatUptime(Date.now() - timestamp);
}
function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
const health = discovery?.url
? await probeHubServer(discovery.url)
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
: undefined;
const current = health ?? discovery;
const hubUptime = formatHubUptimeFromStartedAt(health?.startedAt);
const listeningPids = listListeningPids(current?.port);
const currentHubPids = [
...(current?.pid ? [current.pid] : []),
...listeningPids,
];
return {
cwd,
hubUrl: current?.url,
@@ -306,7 +342,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
hubPid: current?.pid,
hubStartedAt: health?.startedAt,
hubUptime,
listeningPids: listListeningPids(current?.port),
listeningPids,
staleHubPids: listStaleHubPids(currentHubPids),
hubStartupLocks: listHubStartupLocks(cwd),
staleCliPids: listStaleCliPids(),
staleSidecarPids: listStaleSidecarPids(),
@@ -388,6 +425,7 @@ export async function runDoctorCommand(
);
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
writeln(formatPidList("hub listeners", before.listeningPids));
writeln(formatPidList("stale hub daemons", before.staleHubPids));
writeln(
formatPidList(
"hub startup locks",
@@ -412,6 +450,7 @@ export async function runDoctorCommand(
}
if (
before.listeningPids.length > 0 ||
before.staleHubPids.length > 0 ||
before.staleCliPids.length > 0 ||
before.staleSidecarPids.length > 0
) {
@@ -423,7 +462,9 @@ export async function runDoctorCommand(
}
const gracefullyStoppedHub = before.hubHealthy
? await stopLocalHubServerGracefully().catch(() => false)
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
() => false,
)
: false;
const refreshedAfterGracefulStop = gracefullyStoppedHub
? await collectDoctorStatus(opts.cwd)
@@ -431,13 +472,20 @@ export async function runDoctorCommand(
const killedHub = gracefullyStoppedHub
? 0
: killPids(refreshedAfterGracefulStop.listeningPids);
const staleCliTargets = before.staleCliPids.filter(
const staleHubTargets = before.staleHubPids.filter(
(pid) => !refreshedAfterGracefulStop.listeningPids.includes(pid),
);
const killedStaleHubs = killPids(staleHubTargets);
const staleCliTargets = before.staleCliPids.filter(
(pid) =>
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
!staleHubTargets.includes(pid),
);
const killedCli = killPids(staleCliTargets);
const staleSidecarTargets = before.staleSidecarPids.filter(
(pid) =>
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
!staleHubTargets.includes(pid) &&
!staleCliTargets.includes(pid),
);
const killedSidecars = killPids(staleSidecarTargets);
@@ -459,6 +507,7 @@ export async function runDoctorCommand(
after,
killed: {
hubListeners: killedHub,
staleHubDaemons: killedStaleHubs,
cliProcesses: killedCli,
sidecarProcesses: killedSidecars,
connectorProcesses: stoppedConnectors.stoppedProcesses,
@@ -471,6 +520,7 @@ export async function runDoctorCommand(
return 0;
}
writeln(`killed hub listeners ${c.dim}${killedHub}${c.reset}`);
writeln(`killed stale hub daemons ${c.dim}${killedStaleHubs}${c.reset}`);
writeln(`killed cli processes ${c.dim}${killedCli}${c.reset}`);
writeln(`killed sidecar processes ${c.dim}${killedSidecars}${c.reset}`);
writeln(
@@ -487,6 +537,7 @@ export async function runDoctorCommand(
);
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
writeln(formatPidList("remaining hub listeners", after.listeningPids));
writeln(formatPidList("remaining stale hub daemons", after.staleHubPids));
writeln(
formatPidList(
"remaining hub startup locks",
+51 -1
View File
@@ -1,10 +1,11 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
const {
mockClearHubDiscovery,
mockEnsureDetachedHubServer,
mockProbeHubServer,
mockReadHubDiscovery,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockStopLocalHubServerGracefully,
} = vi.hoisted(() => ({
@@ -12,6 +13,10 @@ const {
mockEnsureDetachedHubServer: vi.fn(),
mockProbeHubServer: vi.fn(),
mockReadHubDiscovery: vi.fn(),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
})),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
@@ -24,13 +29,25 @@ vi.mock("@cline/core", () => ({
ensureDetachedHubServer: mockEnsureDetachedHubServer,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
}));
import { createHubCommand } from "./hub";
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
describe("createHubCommand", () => {
afterEach(() => {
vi.clearAllMocks();
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
});
it("includes uptime in hub status output", async () => {
vi.spyOn(Date, "now").mockReturnValue(
new Date("2026-01-01T00:01:05.000Z").getTime(),
@@ -73,4 +90,37 @@ describe("createHubCommand", () => {
uptime: "1m 5s",
});
});
it("passes the selected owner to graceful stop", async () => {
process.env.CLINE_BUILD_ENV = "development";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25466/hub",
port: 25466,
pid: 50174,
});
mockStopLocalHubServerGracefully.mockResolvedValue(true);
const output: string[] = [];
let exitCode = 0;
const cmd = createHubCommand(
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
(code) => {
exitCode = code;
},
);
await cmd.parseAsync(["stop"], { from: "user" });
expect(exitCode).toBe(0);
expect(mockStopLocalHubServerGracefully).toHaveBeenCalledWith({
ownerId: "hub-owner",
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
});
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
});
});
+14 -5
View File
@@ -3,10 +3,11 @@ import {
ensureDetachedHubServer,
probeHubServer,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime } from "@cline/shared";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { Command } from "commander";
interface HubCommandIo {
@@ -15,9 +16,9 @@ interface HubCommandIo {
}
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (await stopLocalHubServerGracefully()) {
if (await stopLocalHubServerGracefully(owner)) {
await clearHubDiscovery(owner.discoveryPath);
return true;
}
@@ -46,6 +47,12 @@ function formatHubUptimeFromStartedAt(
return formatUptime(Date.now() - timestamp);
}
function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
export function createHubCommand(
io: HubCommandIo,
setExitCode: (code: number) => void,
@@ -112,10 +119,12 @@ export function createHubCommand(
hub.command("status").action(
action(async () => {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
const health = discovery?.url
? await probeHubServer(discovery.url)
? await probeHubServer(discovery.url, {
authToken: discovery.authToken,
})
: undefined;
const uptime = formatHubUptimeFromStartedAt(health?.startedAt);
io.writeln(
+6
View File
@@ -168,6 +168,8 @@ export function buildKanbanSpawnOptions(
detached: shouldDetachKanbanProcess(platform),
...(platform === "win32" ? { shell: true } : {}),
...options,
// Prevent a console window from flashing on Windows.
windowsHide: true,
};
}
@@ -178,6 +180,8 @@ function buildKanbanInstallSpawnOptions(
return {
detached: false,
stdio: "inherit",
// Prevent a console window from flashing on Windows.
windowsHide: true,
...(platform === "win32" ? { shell: true } : {}),
...options,
};
@@ -203,6 +207,8 @@ export function getInstalledKanbanVersion(): string | null {
const result = spawnSync(getKanbanCommand(), ["--version"], {
encoding: "utf8",
shell: process.platform === "win32",
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
if (result.status !== 0) {
return null;
+145
View File
@@ -0,0 +1,145 @@
import { describe, expect, it, vi } from "vitest";
import { buildMcpInstallDefaults, runMcpInstallCommand } from "./mcp";
describe("mcp install command", () => {
it("builds stdio wizard defaults from command args", () => {
expect(
buildMcpInstallDefaults({
name: "fs",
targetArgs: [
"npx",
"-y",
"@modelcontextprotocol/server-filesystem",
"/tmp/my dir",
],
}),
).toEqual({
name: "fs",
type: "stdio",
command: 'npx -y @modelcontextprotocol/server-filesystem "/tmp/my dir"',
});
});
it("builds remote wizard defaults and normalizes http transport", () => {
expect(
buildMcpInstallDefaults({
name: "ctx7",
transport: "http",
targetArgs: ["https://mcp.context7.com/mcp"],
}),
).toEqual({
name: "ctx7",
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
});
});
it("normalizes streamable-http transport", () => {
expect(
buildMcpInstallDefaults({
name: "docs",
transport: "streamable-http",
targetArgs: ["https://example.com/mcp"],
}),
).toEqual({
name: "docs",
type: "streamableHttp",
url: "https://example.com/mcp",
});
});
it("builds SSE wizard defaults", () => {
expect(
buildMcpInstallDefaults({
name: "events",
transport: "sse",
targetArgs: ["https://example.com/sse"],
}),
).toEqual({
name: "events",
type: "sse",
url: "https://example.com/sse",
});
});
it("rejects missing stdio command and invalid remote URL", () => {
expect(() =>
buildMcpInstallDefaults({
name: "fs",
}),
).toThrow(/requires a command/);
expect(() =>
buildMcpInstallDefaults({
name: "bad",
transport: "http",
targetArgs: ["not-a-url"],
}),
).toThrow(/Invalid MCP server URL/);
});
it("rejects remote URL schemes other than http and https", () => {
expect(() =>
buildMcpInstallDefaults({
name: "bad",
transport: "http",
targetArgs: ["file:///etc/passwd"],
}),
).toThrow(/only http and https are supported/);
});
it("opens the add wizard with prefilled defaults", async () => {
const runWizard = vi.fn(async () => 0);
const code = await runMcpInstallCommand({
name: "ctx7",
transport: "http",
targetArgs: ["https://mcp.context7.com/mcp"],
isTty: true,
runWizard,
io: { writeErr: vi.fn() },
});
expect(code).toBe(0);
expect(runWizard).toHaveBeenCalledWith({
name: "ctx7",
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
});
});
it("requires a TTY because it opens the wizard", async () => {
const writeErr = vi.fn();
const runWizard = vi.fn(async () => 0);
const code = await runMcpInstallCommand({
name: "ctx7",
transport: "http",
targetArgs: ["https://mcp.context7.com/mcp"],
isTty: false,
runWizard,
io: { writeErr },
});
expect(code).toBe(1);
expect(runWizard).not.toHaveBeenCalled();
expect(writeErr).toHaveBeenCalledWith(
"cline mcp install opens the MCP wizard and requires a TTY.",
);
});
it("checks for TTY before validating install arguments", async () => {
const writeErr = vi.fn();
const code = await runMcpInstallCommand({
name: "fs",
isTty: false,
io: { writeErr },
});
expect(code).toBe(1);
expect(writeErr).toHaveBeenCalledWith(
"cline mcp install opens the MCP wizard and requires a TTY.",
);
});
});
+121
View File
@@ -0,0 +1,121 @@
import type { McpAddDefaults } from "../wizards/mcp";
export interface McpCommandIo {
writeErr: (text: string) => void;
}
export interface McpInstallOptions {
name: string;
targetArgs?: string[];
transport?: string;
io?: McpCommandIo;
isTty?: boolean;
runWizard?: (defaults: McpAddDefaults) => Promise<number>;
}
function normalizeTransportType(
value: string | undefined,
): McpAddDefaults["type"] {
const normalized = (value ?? "stdio").trim();
if (normalized === "http" || normalized === "streamable-http") {
return "streamableHttp";
}
if (
normalized === "stdio" ||
normalized === "sse" ||
normalized === "streamableHttp"
) {
return normalized;
}
throw new Error(
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
);
}
function assertValidUrl(url: string): void {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid MCP server URL: ${url}`);
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(
`Invalid MCP server URL: ${url} (only http and https are supported)`,
);
}
}
function quoteCommandArg(arg: string): string {
if (/^[^\s"'\\]+$/.test(arg)) {
return arg;
}
return `"${arg.replace(/(["\\])/g, "\\$1")}"`;
}
export function buildMcpInstallDefaults(options: {
name: string;
targetArgs?: string[];
transport?: string;
}): McpAddDefaults {
const name = options.name.trim();
if (!name) {
throw new Error("MCP server name is required");
}
const type = normalizeTransportType(options.transport);
const targetArgs = options.targetArgs ?? [];
if (type === "stdio") {
if (targetArgs.length === 0) {
throw new Error(
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp",
);
}
return {
name,
type,
command: targetArgs.map(quoteCommandArg).join(" "),
};
}
if (targetArgs.length !== 1) {
throw new Error(
"Remote MCP install requires exactly one URL argument after the server name.",
);
}
const url = targetArgs[0]?.trim() ?? "";
assertValidUrl(url);
return {
name,
type,
url,
};
}
async function runPrefilledWizard(defaults: McpAddDefaults): Promise<number> {
const { runMcpWizard } = await import("../wizards/mcp");
return runMcpWizard({
initialAction: "add",
addDefaults: defaults,
exitAfterInitialAction: true,
});
}
export async function runMcpInstallCommand(
options: McpInstallOptions,
): Promise<number> {
try {
const isTty =
options.isTty ?? (process.stdin.isTTY && process.stdout.isTTY);
if (!isTty) {
throw new Error(
"cline mcp install opens the MCP wizard and requires a TTY.",
);
}
const defaults = buildMcpInstallDefaults(options);
return await (options.runWizard ?? runPrefilledWizard)(defaults);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(message);
return 1;
}
}
+338
View File
@@ -17,6 +17,7 @@ import {
} from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
collectPluginMcpOAuthCandidates,
installPlugin,
isOfficialPluginSlug,
parsePluginSource,
@@ -35,6 +36,7 @@ describe("plugin install command", () => {
let originalHome: string | undefined;
let originalClineDir: string | undefined;
let originalClineDataDir: string | undefined;
let originalMcpSettingsPath: string | undefined;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "cli-plugin-install-"));
@@ -43,6 +45,7 @@ describe("plugin install command", () => {
originalHome = process.env.HOME;
originalClineDir = process.env.CLINE_DIR;
originalClineDataDir = process.env.CLINE_DATA_DIR;
originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
process.env.HOME = home;
process.env.CLINE_DIR = join(home, ".cline");
process.env.CLINE_DATA_DIR = join(home, ".cline", "data");
@@ -91,6 +94,11 @@ describe("plugin install command", () => {
} else {
process.env.CLINE_DATA_DIR = originalClineDataDir;
}
if (originalMcpSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
}
rmSync(root, { recursive: true, force: true });
});
@@ -676,11 +684,341 @@ describe("plugin install command", () => {
expect(code).toBe(0);
const parsed = JSON.parse(stdout.join("")) as { installPath: string };
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
expect("mcpOAuthCandidates" in parsed).toBe(false);
} finally {
process.stdout.write = originalWrite;
}
});
it("does not run MCP OAuth follow-up for JSON plugin installs", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "json-oauth-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "json-oauth-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "json-oauth-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const stdout: string[] = [];
const originalWrite = process.stdout.write;
const authorize = vi.fn();
process.stdout.write = ((chunk: string | Uint8Array) => {
stdout.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
const code = await runPluginInstallCommand({
source,
json: true,
io: {
writeln: () => {},
writeErr: () => {},
},
mcpOAuth: {
interactive: true,
selectCandidates: async (candidates) => candidates,
authorize,
},
});
expect(code).toBe(0);
expect(authorize).not.toHaveBeenCalled();
const parsed = JSON.parse(stdout.join("")) as {
installPath: string;
mcpOAuthCandidates?: unknown;
};
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
expect(parsed.mcpOAuthCandidates).toBeUndefined();
} finally {
process.stdout.write = originalWrite;
}
});
it("warns when plugin MCP settings sync fails after install", async () => {
const source = join(root, "mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "mcp-plugin",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const blockedDirectory = join(root, "not-a-directory");
writeFileSync(blockedDirectory, "file", "utf8");
const originalSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
process.env.CLINE_MCP_SETTINGS_PATH = join(
blockedDirectory,
"cline_mcp_settings.json",
);
const output: string[] = [];
try {
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
});
expect(code).toBe(0);
expect(output.join("\n")).toContain("Installed plugin from");
expect(output.join("\n")).toContain(
"Warning: failed to sync plugin MCP servers",
);
expect(output.join("\n")).toContain("mcp-plugin");
} finally {
if (originalSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = originalSettingsPath;
}
}
});
it("detects plugin-owned remote MCP servers as OAuth candidates", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "oauth-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "oauth-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "oauth-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const result = await installPlugin({ source });
expect(result.mcpOAuthCandidates).toEqual([
expect.objectContaining({
name: "oauth-docs",
pluginName: "oauth-mcp-plugin",
transportType: "streamableHttp",
}),
]);
});
it("does not treat remote MCP servers with static headers as OAuth candidates", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "headers-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "headers-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "headers-docs",
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
headers: { Authorization: "Bearer token" },
},
})
},
}
`,
"utf8",
);
const result = await installPlugin({ source });
expect(result.mcpOAuthCandidates).toEqual([]);
});
it("skips plugin MCP OAuth candidates that already have tokens", async () => {
const settingsPath = join(root, "mcp-settings.json");
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const source = join(root, "authorized-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "authorized-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "authorized-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const result = await installPlugin({ source });
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as {
mcpServers?: Record<string, { oauth?: unknown }>;
};
const server = settings.mcpServers?.["authorized-docs"];
if (!server) {
throw new Error("Expected authorized-docs MCP server to be written");
}
server.oauth = { tokens: { access_token: "oauth-token" } };
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8");
expect(
collectPluginMcpOAuthCandidates({
pluginPaths: result.entryPaths,
settingsPath,
}),
).toEqual([]);
});
it("authorizes selected plugin MCP OAuth candidates during interactive installs", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "interactive-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "interactive-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "interactive-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const authorized: string[] = [];
const output: string[] = [];
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
mcpOAuth: {
interactive: true,
selectCandidates: async (candidates) => candidates,
authorize: async (candidate) => {
authorized.push(candidate.name);
},
},
});
expect(code).toBe(0);
expect(authorized).toEqual(["interactive-docs"]);
expect(output.join("\n")).toContain("Installed plugin from");
});
it("keeps plugin install successful when MCP OAuth authorization fails", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "failing-oauth-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "failing-oauth-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "failing-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const output: string[] = [];
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
mcpOAuth: {
interactive: true,
selectCandidates: async (candidates) => candidates,
authorize: async () => {
throw new Error("oauth unavailable");
},
},
});
expect(code).toBe(0);
expect(output.join("\n")).toContain(
"Warning: failed to authorize MCP server failing-docs: oauth unavailable",
);
});
it("prints guidance for plugin MCP OAuth candidates in non-interactive installs", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "non-interactive-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "non-interactive-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "non-interactive-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const output: string[] = [];
const authorize = vi.fn();
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
mcpOAuth: {
interactive: false,
authorize,
},
});
expect(code).toBe(0);
expect(authorize).not.toHaveBeenCalled();
expect(output.join("\n")).toContain(
"Plugin MCP servers may require OAuth authorization",
);
expect(output.join("\n")).toContain("non-interactive-docs");
expect(output.join("\n")).toContain('Run "cline mcp"');
});
it("prints JSON output for official plugin installs", async () => {
const officialPluginsRepo = await createOfficialPluginsRepo({
"json-plugin": {
+240 -3
View File
@@ -21,7 +21,15 @@ import {
resolve,
sep,
} from "node:path";
import { type PluginUninstallOptions, uninstallPlugin } from "@cline/core";
import {
type McpServerRegistration,
type PluginMcpSettingsSyncResult,
type PluginUninstallOptions,
resolveDefaultMcpSettingsPath,
resolveMcpServerRegistrations,
syncPluginMcpServersToSettings,
uninstallPlugin,
} from "@cline/core";
import {
isPluginModulePath,
resolveClineDir,
@@ -36,12 +44,31 @@ export interface PluginInstallOptions {
npmCommand?: string;
officialPluginsRepo?: string;
io?: PluginInstallIo;
mcpOAuth?: PluginInstallMcpOAuthOptions;
}
export interface PluginInstallResult {
source: string;
installPath: string;
entryPaths: string[];
mcpSyncFailures: PluginMcpSettingsSyncResult["failures"];
mcpOAuthCandidates: PluginMcpOAuthCandidate[];
}
export interface PluginMcpOAuthCandidate {
name: string;
pluginName: string;
pluginPath: string;
transportType: "sse" | "streamableHttp";
lastError?: string;
}
export interface PluginInstallMcpOAuthOptions {
interactive?: boolean;
selectCandidates?: (
candidates: PluginMcpOAuthCandidate[],
) => Promise<PluginMcpOAuthCandidate[]>;
authorize?: (candidate: PluginMcpOAuthCandidate) => Promise<void>;
}
export interface PluginInstallIo {
@@ -506,6 +533,8 @@ async function runCommand(
cwd: options.cwd,
stdio: ["ignore", "ignore", "pipe"],
env: process.env,
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
let stderr = "";
child.stderr.on("data", (chunk) => {
@@ -1003,6 +1032,81 @@ function replaceInstallPath(
}
}
function hasStaticHeaders(registration: McpServerRegistration): boolean {
const transport = registration.transport;
if (transport.type === "stdio") {
return false;
}
return (
transport.headers !== undefined && Object.keys(transport.headers).length > 0
);
}
function hasOAuthAccessToken(registration: McpServerRegistration): boolean {
const accessToken = registration.oauth?.tokens?.access_token;
return typeof accessToken === "string" && accessToken.trim().length > 0;
}
function getPluginOwner(
registration: McpServerRegistration,
): { pluginName: string; pluginPath: string } | undefined {
const metadata = registration.metadata;
if (
!metadata ||
metadata.source !== "plugin" ||
typeof metadata.pluginName !== "string" ||
typeof metadata.pluginPath !== "string"
) {
return undefined;
}
return {
pluginName: metadata.pluginName,
pluginPath: metadata.pluginPath,
};
}
export function collectPluginMcpOAuthCandidates(input: {
pluginPaths: readonly string[];
settingsPath?: string;
}): PluginMcpOAuthCandidate[] {
const pluginPaths = new Set(input.pluginPaths.map((path) => resolve(path)));
if (pluginPaths.size === 0) {
return [];
}
let registrations: McpServerRegistration[];
try {
registrations = resolveMcpServerRegistrations({
filePath: input.settingsPath ?? resolveDefaultMcpSettingsPath(),
});
} catch {
return [];
}
const candidates: PluginMcpOAuthCandidate[] = [];
for (const registration of registrations) {
const owner = getPluginOwner(registration);
if (!owner || !pluginPaths.has(resolve(owner.pluginPath))) {
continue;
}
const transportType = registration.transport.type;
if (transportType === "stdio") {
continue;
}
if (hasStaticHeaders(registration) || hasOAuthAccessToken(registration)) {
continue;
}
candidates.push({
name: registration.name,
pluginName: owner.pluginName,
pluginPath: owner.pluginPath,
transportType,
lastError: registration.oauth?.lastError,
});
}
return candidates.sort((left, right) => left.name.localeCompare(right.name));
}
export async function installPlugin(
options: PluginInstallOptions,
): Promise<PluginInstallResult> {
@@ -1069,28 +1173,161 @@ export async function installPlugin(
}
replaceInstallPath(stagingRoot, installPath, force);
return {
const result = {
source,
installPath,
entryPaths: entryPaths.map((entry) => resolve(installPath, entry)),
mcpSyncFailures: [] as PluginMcpSettingsSyncResult["failures"],
mcpOAuthCandidates: [] as PluginMcpOAuthCandidate[],
};
const syncResult = await syncPluginMcpServersToSettings({
pluginPaths: result.entryPaths,
cwd,
workspacePath: cwd,
});
result.mcpSyncFailures = syncResult.failures;
result.mcpOAuthCandidates = collectPluginMcpOAuthCandidates({
pluginPaths: result.entryPaths,
});
return result;
} catch (error) {
rmSync(stagingRoot, { recursive: true, force: true });
throw error;
}
}
function serializePluginInstallResult(
result: PluginInstallResult,
): Omit<PluginInstallResult, "mcpOAuthCandidates"> {
return {
source: result.source,
installPath: result.installPath,
entryPaths: result.entryPaths,
mcpSyncFailures: result.mcpSyncFailures,
};
}
function isInteractivePluginInstall(options: PluginInstallOptions): boolean {
return (
options.mcpOAuth?.interactive ??
(process.stdin.isTTY && process.stdout.isTTY)
);
}
async function selectMcpOAuthCandidatesWithClack(
candidates: PluginMcpOAuthCandidate[],
): Promise<PluginMcpOAuthCandidate[]> {
const p = await import("@clack/prompts");
const action = await p.select({
message: "Authorize plugin MCP servers now?",
options: [
{
value: "all",
label: "Authorize all",
hint: "open browser authorization for each server",
},
{
value: "choose",
label: "Choose servers",
hint: "select which servers to authorize",
},
{
value: "skip",
label: "Skip",
},
],
});
if (p.isCancel(action) || action === "skip") {
return [];
}
if (action === "all") {
return candidates;
}
const selectedNames = await p.multiselect({
message: "Select MCP servers to authorize",
options: candidates.map((candidate) => ({
value: candidate.name,
label: candidate.name,
hint: `${candidate.transportType} [${candidate.pluginName}]`,
})),
required: false,
});
if (p.isCancel(selectedNames) || !Array.isArray(selectedNames)) {
return [];
}
const selected = new Set(selectedNames);
return candidates.filter((candidate) => selected.has(candidate.name));
}
async function authorizeMcpOAuthCandidate(
candidate: PluginMcpOAuthCandidate,
): Promise<void> {
const { authorizeMcpServerOAuthWithBrowser } = await import(
"../wizards/mcp/oauth"
);
await authorizeMcpServerOAuthWithBrowser(candidate.name, {
throwOnError: true,
});
}
async function runPluginMcpOAuthFollowup(
candidates: PluginMcpOAuthCandidate[],
options: PluginInstallOptions,
): Promise<void> {
if (candidates.length === 0) {
return;
}
if (!isInteractivePluginInstall(options)) {
options.io?.writeln("Plugin MCP servers may require OAuth authorization:");
for (const candidate of candidates) {
options.io?.writeln(
` ${candidate.name} (${candidate.transportType}, plugin: ${candidate.pluginName})`,
);
}
options.io?.writeln(
'Run "cline mcp" and choose "Authorize OAuth" to authorize them.',
);
return;
}
const selected =
options.mcpOAuth?.selectCandidates !== undefined
? await options.mcpOAuth.selectCandidates(candidates)
: await selectMcpOAuthCandidatesWithClack(candidates);
const authorize = options.mcpOAuth?.authorize ?? authorizeMcpOAuthCandidate;
for (const candidate of selected) {
try {
await authorize(candidate);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(
`Warning: failed to authorize MCP server ${candidate.name}: ${message}. Run "cline mcp" and choose "Authorize OAuth" to retry.`,
);
}
}
}
export async function runPluginInstallCommand(
options: PluginInstallOptions & { json?: boolean },
): Promise<number> {
try {
const result = await installPlugin(options);
if (options.json) {
process.stdout.write(JSON.stringify(result));
process.stdout.write(
JSON.stringify(serializePluginInstallResult(result)),
);
return 0;
}
options.io?.writeln(`Installed plugin from ${result.source}`);
options.io?.writeln(` Path: ${result.installPath}`);
for (const failure of result.mcpSyncFailures) {
options.io?.writeErr(
`Warning: failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
);
}
await runPluginMcpOAuthFollowup(result.mcpOAuthCandidates, options);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
-1
View File
@@ -116,7 +116,6 @@ export function createProgram(): Command {
writeOut: () => {}, // suppress by default; main.ts re-enables for routing
writeErr: () => {},
})
.allowUnknownOption()
.allowExcessArguments()
.enablePositionalOptions()
.argument(
@@ -78,6 +78,74 @@ describe("saveLocalProviderSettings", () => {
);
});
it("merges and clears Azure provider settings", () => {
const save = vi.fn();
const manager = {
read: vi.fn().mockReturnValue({
providers: {},
}),
write: vi.fn(),
getFilePath: vi.fn().mockReturnValue("/tmp/providers.json"),
getProviderSettings: vi.fn().mockReturnValue({
provider: "openai-compatible",
azure: {
apiVersion: "2024-10-21",
useIdentity: true,
},
}),
saveProviderSettings: save,
};
saveLocalProviderSettings(
manager as unknown as ProviderSettingsManager,
{
action: "saveProviderSettings",
providerId: "openai-compatible",
azure: {
apiVersion: "2025-01-01-preview",
},
} as SaveProviderSettingsActionRequest,
);
expect(save).toHaveBeenCalledTimes(1);
expect(save).toHaveBeenCalledWith(
{
provider: "openai-compatible",
azure: {
apiVersion: "2025-01-01-preview",
useIdentity: true,
},
},
{ setLastUsed: false },
);
save.mockClear();
manager.getProviderSettings.mockReturnValue({
provider: "openai-compatible",
azure: {
apiVersion: "2025-01-01-preview",
},
});
saveLocalProviderSettings(
manager as unknown as ProviderSettingsManager,
{
action: "saveProviderSettings",
providerId: "openai-compatible",
azure: {
apiVersion: "",
},
} as SaveProviderSettingsActionRequest,
);
expect(save).toHaveBeenCalledWith(
{
provider: "openai-compatible",
},
{ setLastUsed: false },
);
});
it("keeps OAuth auth fields when updating manual apiKey", () => {
const save = vi.fn();
const manager = {
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { buildSkillsArgs } from "./skill";
describe("buildSkillsArgs", () => {
it("runs the skills package through npx with -y", () => {
expect(buildSkillsArgs(["list"])).toEqual(["-y", "skills@latest", "list"]);
});
it("injects --agent cline for install-style subcommands", () => {
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
"-y",
"skills@latest",
"add",
"owner/repo",
"--agent",
"cline",
]);
expect(buildSkillsArgs(["add", "owner/repo"])).toContain("cline");
expect(buildSkillsArgs(["i", "owner/repo"])).toContain("cline");
expect(buildSkillsArgs(["update", "owner/repo"])).toContain("cline");
});
it("aliases uninstall to the skills remove subcommand", () => {
expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([
"-y",
"skills@latest",
"remove",
"my-skill",
"--agent",
"cline",
]);
});
it("does not inject when the user already targeted an agent", () => {
expect(
buildSkillsArgs(["install", "owner/repo", "--agent", "cursor"]),
).not.toContain("cline");
expect(
buildSkillsArgs(["install", "owner/repo", "-a", "cursor"]),
).not.toContain("cline");
expect(
buildSkillsArgs(["install", "owner/repo", "--agent=cursor"]),
).not.toContain("cline");
});
it("aliases install and uninstall when agent options come before the subcommand", () => {
expect(
buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]),
).toEqual([
"-y",
"skills@latest",
"--agent",
"cursor",
"add",
"owner/repo",
]);
expect(
buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]),
).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]);
});
it("does not scope non-install subcommands to cline", () => {
expect(buildSkillsArgs(["use", "owner/repo"])).not.toContain("--agent");
expect(buildSkillsArgs(["list"])).not.toContain("--agent");
});
it("scopes remove-style subcommands to cline", () => {
expect(buildSkillsArgs(["remove"])).toEqual([
"-y",
"skills@latest",
"remove",
"--agent",
"cline",
]);
expect(buildSkillsArgs(["rm", "my-skill"])).toContain("cline");
expect(buildSkillsArgs(["r", "my-skill"])).toContain("cline");
});
it("ignores leading flags when detecting the subcommand", () => {
expect(buildSkillsArgs(["--global", "install", "owner/repo"])).toContain(
"cline",
);
});
it("forwards an empty arg list unchanged", () => {
expect(buildSkillsArgs([])).toEqual(["-y", "skills@latest"]);
});
});
+160
View File
@@ -0,0 +1,160 @@
import { type SpawnOptions, spawn } from "node:child_process";
export interface SkillCommandIo {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
}
// `cline skill` is a thin wrapper around the open skills CLI
// (https://www.npmjs.com/package/skills). We run it through `npx` so users
// don't need a separate global install. Pin the version here if we ever need to
// lock behavior to a known-good release.
const SKILLS_PACKAGE = "skills@latest";
// Subcommands that write skill files into an agent's skills directory. For a
// `cline skill` command we default these to Cline unless the user picked their
// own agent. `use` is intentionally excluded: without --agent it prints the
// generated prompt to stdout, whereas adding --agent would launch that agent
// interactively instead — not what someone scoping to Cline would expect.
const CLINE_SCOPED_SUBCOMMANDS = new Set([
"add",
"install",
"i",
"update",
"remove",
"rm",
"r",
"uninstall",
]);
const SKILLS_SUBCOMMAND_ALIASES = new Map([
["install", "add"],
["uninstall", "remove"],
]);
function hasAgentFlag(args: readonly string[]): boolean {
return args.some(
(arg) => arg === "-a" || arg === "--agent" || arg.startsWith("--agent="),
);
}
function optionConsumesNextValue(arg: string): boolean {
return arg === "-a" || arg === "--agent";
}
function findSubcommandIndex(args: readonly string[]): number {
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg.startsWith("-")) {
if (optionConsumesNextValue(arg)) {
index++;
}
continue;
}
return index;
}
return -1;
}
function findSubcommand(args: readonly string[]): string | undefined {
const index = findSubcommandIndex(args);
return index >= 0 ? args[index] : undefined;
}
function normalizeSkillsSubcommandAliases(args: string[]): void {
const index = findSubcommandIndex(args);
if (index < 0) return;
const alias = SKILLS_SUBCOMMAND_ALIASES.get(args[index]);
if (alias) {
args[index] = alias;
}
}
/**
* Build the argument list passed to `npx`, injecting `--agent cline` for
* install-style subcommands unless the user already targeted an agent.
*/
export function buildSkillsArgs(userArgs: readonly string[]): string[] {
const args = [...userArgs];
const subcommand = findSubcommand(args);
normalizeSkillsSubcommandAliases(args);
if (
subcommand &&
CLINE_SCOPED_SUBCOMMANDS.has(subcommand) &&
!hasAgentFlag(args)
) {
args.push("--agent", "cline");
}
return ["-y", SKILLS_PACKAGE, ...args];
}
function resolveExitCode(
code: number | null,
signal: NodeJS.Signals | null,
): number {
if (code !== null) {
return code;
}
switch (signal) {
case "SIGINT":
return 130;
case "SIGTERM":
return 143;
default:
return 1;
}
}
/**
* Forward all arguments to the open skills CLI via `npx skills`.
*
* Returns the child process exit code, or 1 if npx is unavailable or fails to
* spawn. stdio is inherited so the skills CLI's interactive prompts and output
* pass straight through to the user's terminal.
*/
export async function runSkillCommand(
userArgs: readonly string[],
io: SkillCommandIo,
): Promise<number> {
const args = buildSkillsArgs(userArgs);
const isWindows = process.platform === "win32";
const options: SpawnOptions = {
stdio: "inherit",
env: process.env,
// Prevent a console window from flashing on Windows.
windowsHide: true,
...(isWindows ? { shell: true } : {}),
};
return new Promise<number>((resolve) => {
const child = spawn("npx", args, options);
const forward = (signal: NodeJS.Signals) => {
child.kill(signal);
};
const handleSigint = () => forward("SIGINT");
const handleSigterm = () => forward("SIGTERM");
process.on("SIGINT", handleSigint);
process.on("SIGTERM", handleSigterm);
const cleanup = () => {
process.off("SIGINT", handleSigint);
process.off("SIGTERM", handleSigterm);
};
child.once("error", (error: NodeJS.ErrnoException) => {
cleanup();
if (error.code === "ENOENT") {
io.writeErr(
'npx was not found. Install Node.js (which includes npx) to use "cline skill".',
);
} else {
io.writeErr(`Failed to run npx ${SKILLS_PACKAGE}: ${error.message}`);
}
resolve(1);
});
child.once("close", (code, signal) => {
cleanup();
resolve(resolveExitCode(code, signal));
});
});
}
+149 -1
View File
@@ -1,15 +1,24 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
autoUpdateOnStartup,
checkForUpdates,
getInstallationInfo,
PackageManager,
resolveCliHubOwnerContext,
withMinimumReleaseAgeBypass,
} from "./update";
const originalArgv = [...process.argv];
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
const originalDataDir = process.env.CLINE_DATA_DIR;
const originalHubDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
const originalIsDev = process.env.IS_DEV;
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
const tempDirs: string[] = [];
function createFile(path: string): string {
@@ -27,11 +36,42 @@ function createTempFile(pathSuffix: string): string {
describe("getInstallationInfo", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
}
if (originalGlobalSettingsPath === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
}
if (originalIsDev === undefined) {
delete process.env.IS_DEV;
} else {
process.env.IS_DEV = originalIsDev;
}
if (originalNoAutoUpdate === undefined) {
delete process.env.CLINE_NO_AUTO_UPDATE;
} else {
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
}
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
@@ -72,6 +112,114 @@ describe("getInstallationInfo", () => {
});
});
describe("auto update settings", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
}
if (originalGlobalSettingsPath === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
}
if (originalIsDev === undefined) {
delete process.env.IS_DEV;
} else {
process.env.IS_DEV = originalIsDev;
}
if (originalNoAutoUpdate === undefined) {
delete process.env.CLINE_NO_AUTO_UPDATE;
} else {
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
}
vi.restoreAllMocks();
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { recursive: true, force: true });
}
});
it("skips startup auto update when disabled globally", () => {
const settingsPath = createTempFile("data/global-settings.json");
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
delete process.env.IS_DEV;
delete process.env.CLINE_NO_AUTO_UPDATE;
const fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockRejectedValue(new Error("should not fetch"));
autoUpdateOnStartup();
expect(fetchSpy).not.toHaveBeenCalled();
});
it("still lets manual update checks run when startup auto update is disabled", async () => {
const settingsPath = createTempFile("data/global-settings.json");
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
delete process.env.CLINE_NO_AUTO_UPDATE;
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
ok: true,
json: async () => ({ version: "0.0.0" }),
} as Response);
await checkForUpdates({ includeKanban: false });
expect(fetchSpy).toHaveBeenCalled();
});
});
describe("hub restart owner selection", () => {
afterEach(() => {
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
});
it("uses the shared hub owner outside production builds", () => {
process.env.CLINE_BUILD_ENV = "development";
process.env.CLINE_DATA_DIR = "/tmp/cline-update-test-data";
delete process.env.CLINE_HUB_DISCOVERY_PATH;
const owner = resolveCliHubOwnerContext();
expect(owner.discoveryPath).toContain("/locks/hub/owners/");
expect(owner.discoveryPath).not.toBe(
"/tmp/cline-update-test-data/locks/hub/production.json",
);
});
});
describe("withMinimumReleaseAgeBypass", () => {
it("adds the package-manager-specific cooldown bypass", () => {
expect(
+25 -7
View File
@@ -2,11 +2,14 @@ import { type ChildProcess, spawn } from "node:child_process";
import { realpathSync } from "node:fs";
import {
clearHubDiscovery,
isAutoUpdateEnabledGlobally,
probeHubServer,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { resolveClineBuildEnv } from "@cline/shared";
import { version } from "../../package.json";
import { ensureCliHubServer } from "../utils/hub-runtime";
import { c, writeErr, writeln } from "../utils/output";
@@ -268,13 +271,22 @@ export function getPreferredKanbanInstaller(
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
async function waitForHubToStop(
url: string,
authToken: string | undefined,
timeoutMs: number,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const check = await probeHubServer(url).catch(() => undefined);
const check = await probeHubServer(url, { authToken }).catch(
() => undefined,
);
if (!check?.url) return true;
await sleep(100);
}
@@ -287,20 +299,22 @@ async function waitForHubToStop(
* clears stale discovery, then re-ensures a fresh instance is spawned.
*/
async function restartHubServerIfRunning(): Promise<void> {
const owner = resolveSharedHubOwnerContext();
const owner = resolveCliHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
() => undefined,
);
const health = discovery?.url
? await probeHubServer(discovery.url).catch(() => undefined)
? await probeHubServer(discovery.url, {
authToken: discovery.authToken,
}).catch(() => undefined)
: undefined;
if (!health?.url) return;
if (!discovery || !health?.url) return;
const pid = discovery?.pid;
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
let stopped = await stopLocalHubServerGracefully().catch(() => false);
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
if (!stopped && pid) {
try {
process.kill(pid, "SIGTERM");
@@ -309,14 +323,14 @@ async function restartHubServerIfRunning(): Promise<void> {
}
}
stopped = await waitForHubToStop(health.url, 3_000);
stopped = await waitForHubToStop(health.url, discovery.authToken, 3_000);
if (!stopped && pid) {
try {
process.kill(pid, "SIGKILL");
} catch {
// best-effort
}
stopped = await waitForHubToStop(health.url, 2_000);
stopped = await waitForHubToStop(health.url, discovery.authToken, 2_000);
}
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
@@ -340,6 +354,7 @@ async function restartHubServerIfRunning(): Promise<void> {
export function autoUpdateOnStartup(): void {
if (process.env.IS_DEV === "true") return;
if (process.env.CLINE_NO_AUTO_UPDATE === "1") return;
if (!isAutoUpdateEnabledGlobally()) return;
const { packageName, packageManager, updateCommand } =
getInstallationInfo(version);
@@ -360,6 +375,9 @@ export function autoUpdateOnStartup(): void {
env: autoUpdateCommand.env
? { ...process.env, ...autoUpdateCommand.env }
: process.env,
// Prevent a console window from flashing on Windows; detached
// processes otherwise allocate a new visible console.
windowsHide: true,
});
const exitCode = await waitForProcessExit(child);
if (exitCode === 0) {
@@ -228,7 +228,7 @@ describe("discordConnector", () => {
});
});
it("switches Discord thread state to the incoming participant without reusing the previous participant session", async () => {
it("updates Discord participant metadata without changing the thread session", async () => {
const dir = await mkdtemp(join(tmpdir(), "discord-participants-"));
const bindingsPath = join(dir, "threads.json");
const thread = createThread({
@@ -278,11 +278,13 @@ describe("discordConnector", () => {
errorLabel: "Discord",
});
const bob =
readBindings<TestDiscordState>(bindingsPath)["discord:user:bob"];
expect(bob?.state?.participantKey).toBe("discord:user:bob");
expect(bob?.state?.participantLabel).toBe("Bob");
expect(bob?.state?.sessionId).toBeUndefined();
const binding =
readBindings<TestDiscordState>(bindingsPath)[
"discord:guild:channel:thread"
];
expect(binding?.state?.participantKey).toBe("discord:user:bob");
expect(binding?.state?.participantLabel).toBe("Bob");
expect(binding?.state?.sessionId).toBe("session-alice");
expect(
readBindings<TestDiscordState>(bindingsPath)["discord:user:alice"]?.state
?.sessionId,
+16 -49
View File
@@ -50,10 +50,9 @@ import {
type ConnectorMuteTarget,
type ConnectorThreadState,
clearBindingSessionIds,
findBindingForParticipantKey,
findBindingForDeliveryTarget,
findBindingForThread,
loadThreadState,
mergeThreadState,
persistMergedThreadState,
readBindings,
} from "../thread-bindings";
@@ -564,45 +563,17 @@ async function postDiscordResolvedText(input: {
});
}
function resolveParticipantState(input: {
bindingsPath: string;
baseStartRequest: ChatStartSessionRequest;
function resolveCurrentStateWithParticipant(input: {
currentState: DiscordThreadState;
participant: DiscordParticipant;
}): DiscordThreadState {
const existing = findBindingForParticipantKey(
readBindings<DiscordThreadState>(input.bindingsPath),
input.participant.key,
)?.binding.state;
return {
...mergeThreadState<DiscordThreadState>(
undefined,
existing,
input.baseStartRequest,
),
...input.currentState,
participantKey: input.participant.key,
participantLabel: input.participant.label,
};
}
function resolveCurrentStateWithParticipant(input: {
currentState: DiscordThreadState;
bindingsPath: string;
baseStartRequest: ChatStartSessionRequest;
participant: DiscordParticipant;
}): DiscordThreadState {
if (input.currentState.participantKey === input.participant.key) {
return {
...input.currentState,
participantLabel: input.participant.label,
};
}
return resolveParticipantState({
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
participant: input.participant,
});
}
async function persistDiscordThreadContext(input: {
thread: Thread<DiscordThreadState>;
bindingsPath: string;
@@ -624,8 +595,6 @@ async function persistDiscordThreadContext(input: {
);
const nextState = resolveCurrentStateWithParticipant({
currentState,
bindingsPath: input.bindingsPath,
baseStartRequest: input.baseStartRequest,
participant,
});
if (
@@ -669,20 +638,20 @@ async function deliverScheduledResult(input: {
const threadId =
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
const bindingKey =
typeof delivery.bindingKey === "string"
? delivery.bindingKey.trim()
: typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey) {
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
const participantKey =
typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey && !participantKey) {
return;
}
const bindings = readBindings<DiscordThreadState>(input.bindingsPath);
const match = bindingKey
? findBindingForParticipantKey(bindings, bindingKey)
: threadId
? { key: threadId, binding: bindings[threadId] }
: undefined;
const match = findBindingForDeliveryTarget(bindings, {
bindingKey,
threadId,
participantKey,
});
const binding = match?.binding;
if (!binding?.serializedThread) {
return;
@@ -1133,9 +1102,7 @@ class DiscordConnector extends ConnectorBase<
isSubscribedThreadMessage?: boolean;
},
) => {
const queueKey =
(await loadThreadState(thread, bindingsPath, startRequest))
.participantKey || thread.id;
const queueKey = thread.id;
const runTurn = async () => {
try {
await handleConnectorUserTurn({
+4 -15
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { __test__ } from "./gchat";
describe("gchat binding lookup", () => {
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
it("does not fall back to channel identity for a different space thread id", () => {
const result = __test__.findBindingForThread(
{
legacy_thread_id: {
@@ -21,17 +21,7 @@ describe("gchat binding lookup", () => {
},
);
expect(result).toEqual({
key: "legacy_thread_id",
binding: {
channelId: "space-123",
isDM: false,
serializedThread: "{}",
sessionId: "sess-1",
state: { sessionId: "sess-1", cwd: "/tmp/work" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
expect(result).toBeUndefined();
});
it("prefers an exact thread id match over a channel fallback", () => {
@@ -65,7 +55,7 @@ describe("gchat binding lookup", () => {
expect(result?.binding.sessionId).toBe("sess-2");
});
it("reuses a binding by participant key across different spaces", () => {
it("does not reuse a binding by participant key across different spaces", () => {
const result = __test__.findBindingForThread(
{
"gchat:email:alice@example.com": {
@@ -91,7 +81,6 @@ describe("gchat binding lookup", () => {
},
);
expect(result?.key).toBe("gchat:email:alice@example.com");
expect(result?.binding.sessionId).toBe("sess-1");
expect(result).toBeUndefined();
});
});
+13 -15
View File
@@ -46,7 +46,7 @@ import {
type ConnectorBindingStore,
type ConnectorThreadState,
clearBindingSessionIds,
findBindingForParticipantKey,
findBindingForDeliveryTarget,
findBindingForThread,
loadThreadState,
persistMergedThreadState,
@@ -191,20 +191,20 @@ async function deliverScheduledResult(input: {
const threadId =
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
const bindingKey =
typeof delivery.bindingKey === "string"
? delivery.bindingKey.trim()
: typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey) {
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
const participantKey =
typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey && !participantKey) {
return;
}
const bindings = readBindings<GoogleChatThreadState>(input.bindingsPath);
const match = bindingKey
? findBindingForParticipantKey(bindings, bindingKey)
: threadId
? { key: threadId, binding: bindings[threadId] }
: undefined;
const match = findBindingForDeliveryTarget(bindings, {
bindingKey,
threadId,
participantKey,
});
const binding = match?.binding;
if (!binding?.serializedThread) {
return;
@@ -590,9 +590,7 @@ class GoogleChatConnector extends ConnectorBase<
thread: Thread<GoogleChatThreadState>,
text: string,
) => {
const queueKey =
(await loadThreadState(thread, bindingsPath, startRequest))
.participantKey || thread.id;
const queueKey = thread.id;
const runTurn = async () => {
try {
await handleConnectorUserTurn({
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
import { __test__ } from "./linear";
describe("linear binding lookup", () => {
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
it("does not fall back to channel identity for a different issue thread id", () => {
const result = __test__.findBindingForThread(
{
legacy_thread_id: {
@@ -21,17 +21,7 @@ describe("linear binding lookup", () => {
},
);
expect(result).toEqual({
key: "legacy_thread_id",
binding: {
channelId: "linear:issue:ISS-123",
isDM: false,
serializedThread: "{}",
sessionId: "sess-1",
state: { sessionId: "sess-1", cwd: "/tmp/work" },
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
expect(result).toBeUndefined();
});
it("prefers an exact thread id match over a channel fallback", () => {
@@ -65,7 +55,7 @@ describe("linear binding lookup", () => {
expect(result?.binding.sessionId).toBe("sess-2");
});
it("reuses a binding by participant key across different issue threads", () => {
it("does not reuse a binding by participant key across different issue threads", () => {
const result = __test__.findBindingForThread(
{
"linear:user:user_123": {
@@ -91,7 +81,6 @@ describe("linear binding lookup", () => {
},
);
expect(result?.key).toBe("linear:user:user_123");
expect(result?.binding.sessionId).toBe("sess-1");
expect(result).toBeUndefined();
});
});
+13 -15
View File
@@ -42,7 +42,7 @@ import {
type ConnectorBindingStore,
type ConnectorThreadState,
clearBindingSessionIds,
findBindingForParticipantKey,
findBindingForDeliveryTarget,
findBindingForThread,
loadThreadState,
persistMergedThreadState,
@@ -229,20 +229,20 @@ async function deliverScheduledResult(input: {
const threadId =
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
const bindingKey =
typeof delivery.bindingKey === "string"
? delivery.bindingKey.trim()
: typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey) {
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
const participantKey =
typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey && !participantKey) {
return;
}
const bindings = readBindings<LinearThreadState>(input.bindingsPath);
const match = bindingKey
? findBindingForParticipantKey(bindings, bindingKey)
: threadId
? { key: threadId, binding: bindings[threadId] }
: undefined;
const match = findBindingForDeliveryTarget(bindings, {
bindingKey,
threadId,
participantKey,
});
const binding = match?.binding;
if (!binding?.serializedThread) {
return;
@@ -625,9 +625,7 @@ class LinearConnector extends ConnectorBase<
thread: Thread<LinearThreadState>,
text: string,
) => {
const queueKey =
(await loadThreadState(thread, bindingsPath, startRequest))
.participantKey || thread.id;
const queueKey = thread.id;
const runTurn = async () => {
try {
await handleConnectorUserTurn({
+1 -1
View File
@@ -29,7 +29,7 @@ export function getConnectorSystemRules(
}
const CONNECTOR_FIRST_CONTACT_MESSAGE = [
"Connected.",
"Connected to Cline.",
"Your chat history is kept separately for your account.",
"Send /new to start a fresh session or /whereami for thread details.",
].join("\n");
@@ -63,12 +63,12 @@ describe("slack binding lookup", () => {
expect(options.appToken).toBe("xapp-token");
});
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
it("falls back to DM channel identity when a restarted connector gets a new thread id", () => {
const result = __test__.findBindingForThread(
{
legacy_thread_id: {
channelId: "slack:C123",
isDM: false,
isDM: true,
serializedThread: "{}",
sessionId: "sess-1",
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
@@ -78,7 +78,7 @@ describe("slack binding lookup", () => {
{
id: "new_thread_id",
channelId: "slack:C123",
isDM: false,
isDM: true,
},
);
@@ -86,7 +86,7 @@ describe("slack binding lookup", () => {
key: "legacy_thread_id",
binding: {
channelId: "slack:C123",
isDM: false,
isDM: true,
serializedThread: "{}",
sessionId: "sess-1",
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
@@ -126,7 +126,7 @@ describe("slack binding lookup", () => {
expect(result?.binding.sessionId).toBe("sess-2");
});
it("reuses a binding by participant key across different threads", () => {
it("does not reuse a binding by participant key across different threads", () => {
const result = __test__.findBindingForThread(
{
[participantKey]: {
@@ -153,8 +153,7 @@ describe("slack binding lookup", () => {
},
);
expect(result?.key).toBe(participantKey);
expect(result?.binding.sessionId).toBe("sess-1");
expect(result).toBeUndefined();
});
it("builds Slack participant keys with a team scope", () => {
+13 -13
View File
@@ -51,7 +51,7 @@ import {
type ConnectorThreadBinding,
type ConnectorThreadState,
clearBindingSessionIds,
findBindingForParticipantKey,
findBindingForDeliveryTarget,
findBindingForThread,
loadThreadState,
persistMergedThreadState,
@@ -376,20 +376,20 @@ async function deliverScheduledResult(input: {
const threadId =
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
const bindingKey =
typeof delivery.bindingKey === "string"
? delivery.bindingKey.trim()
: typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey) {
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
const participantKey =
typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey && !participantKey) {
return;
}
const bindings = readBindings<SlackThreadState>(input.bindingsPath);
const match = bindingKey
? findBindingForParticipantKey(bindings, bindingKey)
: threadId
? { key: threadId, binding: bindings[threadId] }
: undefined;
const match = findBindingForDeliveryTarget(bindings, {
bindingKey,
threadId,
participantKey,
});
const binding = match?.binding;
const deliveryThreadId = match?.key || threadId || bindingKey;
if (!binding?.serializedThread) {
@@ -826,7 +826,7 @@ class SlackConnector extends ConnectorBase<
bindingsPath,
startRequest,
);
const queueKey = currentState.participantKey || thread.id;
const queueKey = thread.id;
const runTurn = async () => {
try {
await withSlackTeamBotToken({
@@ -363,7 +363,7 @@ describe("telegram binding lookup", () => {
expect(result?.binding.sessionId).toBe("sess-2");
});
it("reuses a binding by participant key across different chats", () => {
it("does not reuse a binding by participant key across different chats", () => {
const result = __test__.findBindingForThread(
{
"telegram:user:alice": {
@@ -389,7 +389,6 @@ describe("telegram binding lookup", () => {
},
);
expect(result?.key).toBe("telegram:user:alice");
expect(result?.binding.sessionId).toBe("sess-1");
expect(result).toBeUndefined();
});
});
+13 -15
View File
@@ -42,7 +42,7 @@ import {
type ConnectorBindingStore,
type ConnectorThreadState,
clearBindingSessionIds,
findBindingForParticipantKey,
findBindingForDeliveryTarget,
findBindingForThread,
loadThreadState,
persistMergedThreadState,
@@ -293,20 +293,20 @@ async function deliverScheduledResult(input: {
const threadId =
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
const bindingKey =
typeof delivery.bindingKey === "string"
? delivery.bindingKey.trim()
: typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey) {
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
const participantKey =
typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey && !participantKey) {
return;
}
const bindings = readBindings<TelegramThreadState>(input.bindingsPath);
const match = bindingKey
? findBindingForParticipantKey(bindings, bindingKey)
: threadId
? { key: threadId, binding: bindings[threadId] }
: undefined;
const match = findBindingForDeliveryTarget(bindings, {
bindingKey,
threadId,
participantKey,
});
const binding = match?.binding;
const deliveryThreadId = match?.key || threadId;
if (!binding?.serializedThread) {
@@ -788,9 +788,7 @@ class TelegramConnector extends ConnectorBase<
thread: Thread<TelegramThreadState>,
text: string,
) => {
const queueKey =
(await loadThreadState(thread, bindingsPath, startRequest))
.participantKey || thread.id;
const queueKey = thread.id;
const runTurn = async () => {
try {
await handleConnectorUserTurn({
@@ -65,7 +65,7 @@ describe("whatsapp binding lookup", () => {
expect(result?.binding.sessionId).toBe("sess-2");
});
it("reuses a binding by participant key across different threads", () => {
it("does not reuse a binding by participant key across different threads", () => {
const result = __test__.findBindingForThread(
{
"whatsapp:user:15551234567": {
@@ -91,7 +91,6 @@ describe("whatsapp binding lookup", () => {
},
);
expect(result?.key).toBe("whatsapp:user:15551234567");
expect(result?.binding.sessionId).toBe("sess-1");
expect(result).toBeUndefined();
});
});
+13 -15
View File
@@ -46,7 +46,7 @@ import {
type ConnectorBindingStore,
type ConnectorThreadState,
clearBindingSessionIds,
findBindingForParticipantKey,
findBindingForDeliveryTarget,
findBindingForThread,
loadThreadState,
persistMergedThreadState,
@@ -226,20 +226,20 @@ async function deliverScheduledResult(input: {
const threadId =
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
const bindingKey =
typeof delivery.bindingKey === "string"
? delivery.bindingKey.trim()
: typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey) {
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
const participantKey =
typeof delivery.participantKey === "string"
? delivery.participantKey.trim()
: "";
if (!threadId && !bindingKey && !participantKey) {
return;
}
const bindings = readBindings<WhatsAppThreadState>(input.bindingsPath);
const match = bindingKey
? findBindingForParticipantKey(bindings, bindingKey)
: threadId
? { key: threadId, binding: bindings[threadId] }
: undefined;
const match = findBindingForDeliveryTarget(bindings, {
bindingKey,
threadId,
participantKey,
});
const binding = match?.binding;
if (!binding?.serializedThread) {
return;
@@ -597,9 +597,7 @@ class WhatsAppConnector extends ConnectorBase<
thread: Thread<WhatsAppThreadState>,
text: string,
) => {
const queueKey =
(await loadThreadState(thread, bindingsPath, startRequest))
.participantKey || thread.id;
const queueKey = thread.id;
const runTurn = async () => {
try {
await handleConnectorUserTurn({
+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";
+3
View File
@@ -194,6 +194,9 @@ export function spawnDetachedConnector(
...withResolvedClineBuildEnv(process.env),
[childEnvKey]: "1",
},
// Prevent a console window from appearing on Windows; detached
// processes otherwise allocate a new visible console.
windowsHide: true,
});
logSpawnedProcess({
component: options?.component ?? "connectors",
+117 -5
View File
@@ -92,6 +92,11 @@ function createRuntimeClient(
) {
const startRuntimeSession = vi.fn(async () => ({ sessionId: "session-1" }));
const updateSession = vi.fn(async () => undefined);
const getSession = vi.fn(
async (sessionId: string): Promise<{ sessionId: string } | undefined> => ({
sessionId,
}),
);
const abortRuntimeSession = vi.fn(async () => undefined);
const deleteSession = vi.fn(async () => undefined);
const sendRuntimeSession = vi.fn(async () => ({
@@ -106,6 +111,7 @@ function createRuntimeClient(
client: {
startRuntimeSession,
updateSession,
getSession,
abortRuntimeSession,
stopRuntimeSession: abortRuntimeSession,
deleteSession,
@@ -115,6 +121,7 @@ function createRuntimeClient(
},
startRuntimeSession,
updateSession,
getSession,
sendRuntimeSession,
readMessages,
};
@@ -593,7 +600,8 @@ describe("handleConnectorUserTurn", () => {
metadata: expect.objectContaining({
delivery: expect.objectContaining({
adapter: "telegram",
bindingKey: "telegram:user:alice",
bindingKey: "thread-1",
participantKey: "telegram:user:alice",
}),
}),
}),
@@ -627,7 +635,8 @@ describe("handleConnectorUserTurn", () => {
metadata: {
delivery: {
adapter: "telegram",
bindingKey: "telegram:user:alice",
bindingKey: "thread-1",
participantKey: "telegram:user:alice",
threadId: "thread-1",
},
},
@@ -640,7 +649,8 @@ describe("handleConnectorUserTurn", () => {
metadata: {
delivery: {
adapter: "telegram",
bindingKey: "telegram:user:bob",
bindingKey: "thread-2",
participantKey: "telegram:user:bob",
threadId: "thread-2",
},
},
@@ -699,7 +709,8 @@ describe("handleConnectorUserTurn", () => {
delivery: expect.objectContaining({
adapter: "telegram",
threadId: "thread-1",
bindingKey: "telegram:user:alice",
bindingKey: "thread-1",
participantKey: "telegram:user:alice",
userName: "ClineAdapterBot",
}),
}),
@@ -1442,7 +1453,7 @@ describe("handleConnectorUserTurn", () => {
});
const runtime = createRuntimeClient("unused");
const activeTurns = new Map([
["other-turn-key", { sessionId: "session-1" }],
["other-turn-key", { sessionId: "session-1", threadId: "thread-1" }],
]);
await handleConnectorUserTurn({
@@ -1478,4 +1489,105 @@ describe("handleConnectorUserTurn", () => {
);
expect(posts.at(-1)).toEqual({ raw: "Steering current task." });
});
it("starts a normal turn when the active session is in a different thread", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts } = createThread({
enableTools: true,
autoApproveTools: true,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
welcomeSentAt: new Date().toISOString(),
});
const runtime = createRuntimeClient("normal reply");
const activeTurns = new Map([
["other-thread", { sessionId: "session-1", threadId: "other-thread" }],
]);
await handleConnectorUserTurn({
thread: thread as never,
text: "start work in this thread",
client: runtime.client as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "telegram",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Telegram",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
activeTurns,
turnKey: "thread-1",
});
expect(runtime.startRuntimeSession).toHaveBeenCalled();
expect(runtime.sendRuntimeSession).toHaveBeenCalledWith(
"session-1",
expect.not.objectContaining({
delivery: "steer",
}),
{ timeoutMs: null },
);
expect(posts.at(-1)).toEqual({ raw: "normal reply" });
});
it("starts a fresh session when persisted thread session is missing from the hub", async () => {
const dir = mkdtempSync(join(tmpdir(), "connector-host-test-"));
tempDirs.push(dir);
const bindingsPath = join(dir, "threads.json");
const { thread, posts, getState } = createThread({
sessionId: "stale-session",
enableTools: true,
autoApproveTools: true,
cwd: "/tmp/work",
workspaceRoot: "/tmp/work",
welcomeSentAt: new Date().toISOString(),
});
const runtime = createRuntimeClient("fresh reply");
runtime.getSession.mockResolvedValueOnce(undefined);
await handleConnectorUserTurn({
thread: thread as never,
text: "continue after hub restart",
client: runtime.client as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
explicitSystemPrompt: undefined,
clientId: "client-1",
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport: "telegram",
botUserName: "ClineAdapterBot",
requestStop: vi.fn(),
bindingsPath,
systemRules: "rules",
errorLabel: "Telegram",
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
startedLogMessage: "started",
turnKey: "thread-1",
});
expect(runtime.getSession).toHaveBeenCalledWith("stale-session");
expect(runtime.startRuntimeSession).toHaveBeenCalled();
expect(runtime.sendRuntimeSession).toHaveBeenCalledWith(
"session-1",
expect.not.objectContaining({
delivery: "steer",
}),
{ timeoutMs: null },
);
expect(getState().sessionId).toBe("session-1");
expect(posts.at(-1)).toEqual({ raw: "fresh reply" });
});
});
+7 -22
View File
@@ -749,9 +749,7 @@ export async function handleConnectorUserTurn<
`channelId=${input.thread.channelId}`,
`deliveryAdapter=${input.transport}`,
`deliveryThread=${input.thread.id}`,
...(effectiveCurrent.participantKey
? [`deliveryBindingKey=${effectiveCurrent.participantKey}`]
: []),
`deliveryBindingKey=${input.thread.id}`,
`deliveryChannel=${input.thread.channelId}`,
...(input.botUserName
? [`deliveryUserName=${input.botUserName}`]
@@ -789,11 +787,9 @@ export async function handleConnectorUserTurn<
delivery: {
adapter: input.transport,
threadId: input.thread.id,
bindingKey: input.thread.id,
...(current.participantKey
? {
bindingKey: current.participantKey,
participantKey: current.participantKey,
}
? { participantKey: current.participantKey }
: {}),
...(current.participantLabel
? { participantLabel: current.participantLabel }
@@ -832,11 +828,6 @@ export async function handleConnectorUserTurn<
].join("\n");
},
list: async () => {
const current = await loadThreadState(
input.thread,
input.bindingsPath,
input.baseStartRequest,
);
const schedules = await input.client.listSchedules({ limit: 200 });
const matching = schedules.filter((schedule) => {
const delivery = schedule.metadata?.delivery;
@@ -846,17 +837,9 @@ export async function handleConnectorUserTurn<
!Array.isArray(delivery)
? (delivery as Record<string, unknown>)
: undefined;
const deliveryBindingKey =
typeof deliveryRecord?.bindingKey === "string"
? deliveryRecord.bindingKey
: typeof deliveryRecord?.participantKey === "string"
? deliveryRecord.participantKey
: undefined;
return (
deliveryRecord?.adapter === input.transport &&
(current.participantKey
? deliveryBindingKey === current.participantKey
: deliveryRecord.threadId === input.thread.id)
deliveryRecord.threadId === input.thread.id
);
});
if (matching.length === 0) {
@@ -913,7 +896,9 @@ export async function handleConnectorUserTurn<
input.activeTurns?.get(turnKey) ??
(input.activeTurns && currentState.sessionId?.trim()
? Array.from(input.activeTurns.values()).find(
(turn) => turn.sessionId === currentState.sessionId?.trim(),
(turn) =>
turn.sessionId === currentState.sessionId?.trim() &&
turn.threadId === input.thread.id,
)
: undefined);
if (activeTurn?.sessionId?.trim()) {
@@ -1,15 +1,17 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const {
mockGetLastUsedProviderSettings,
mockGetProviderSettings,
mockResolveSystemPrompt,
mockGetProviderCollection,
mockGetBooleanFlagEnabled,
} = vi.hoisted(() => ({
mockGetLastUsedProviderSettings: vi.fn(),
mockGetProviderSettings: vi.fn(),
mockResolveSystemPrompt: vi.fn(),
mockGetProviderCollection: vi.fn(),
mockGetBooleanFlagEnabled: vi.fn(),
}));
vi.mock("@cline/core", async () => {
@@ -18,8 +20,8 @@ vi.mock("@cline/core", async () => {
return {
...actual,
ProviderSettingsManager: class {
getLastUsedProviderSettings() {
return mockGetLastUsedProviderSettings();
getLastUsedProviderSettings(options?: unknown) {
return mockGetLastUsedProviderSettings(options);
}
getProviderSettings(providerId: string) {
@@ -43,6 +45,12 @@ vi.mock("../utils/helpers", () => ({
resolveWorkspaceRoot: vi.fn((cwd: string) => cwd),
}));
vi.mock("../utils/feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: mockGetBooleanFlagEnabled,
}),
}));
vi.mock("../commands/auth", async () => {
const actual =
await vi.importActual<typeof import("../commands/auth")>(
@@ -57,6 +65,10 @@ vi.mock("../commands/auth", async () => {
import { buildConnectorStartRequest } from "./session-runtime";
describe("buildConnectorStartRequest", () => {
beforeEach(() => {
mockGetBooleanFlagEnabled.mockReturnValue(false);
});
afterEach(() => {
vi.clearAllMocks();
delete process.env.OPENROUTER_API_KEY;
@@ -88,5 +100,64 @@ describe("buildConnectorStartRequest", () => {
expect(request.provider).toBe("openrouter");
expect(request.apiKey).toBe("env-openrouter-key");
expect(request.model).toBe("anthropic/claude-sonnet-4.6");
expect(mockGetLastUsedProviderSettings).toHaveBeenCalledWith({
isClinePassEnabled: false,
});
});
it("uses auth material resolved by provider settings manager", async () => {
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
mockGetProviderSettings.mockReturnValue({
provider: "cline-pass",
auth: { accessToken: "workos:resolved-token" },
});
mockGetProviderCollection.mockReturnValue({
provider: { env: ["CLINE_API_KEY"] },
});
mockResolveSystemPrompt.mockResolvedValue("system");
const request = await buildConnectorStartRequest({
options: {
cwd: "/tmp/work",
mode: "act",
enableTools: false,
},
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.1",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.1");
});
it("uses auth material resolved by provider settings manager", async () => {
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
mockGetProviderSettings.mockReturnValue({
provider: "cline-pass",
auth: { accessToken: "workos:resolved-token" },
});
mockGetProviderCollection.mockReturnValue({
provider: { env: ["CLINE_API_KEY"] },
});
mockResolveSystemPrompt.mockResolvedValue("system");
const request = await buildConnectorStartRequest({
options: {
cwd: "/tmp/work",
mode: "act",
enableTools: false,
},
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.1",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.1");
});
});
+45 -20
View File
@@ -16,6 +16,7 @@ import {
import type { CliLoggerAdapter } from "../logging/adapter";
import { resolveSystemPrompt } from "../runtime/prompt";
import { resolveCliSessionMetadata } from "../utils/enterprise";
import { getCliFeatureFlagsService } from "../utils/feature-flags";
import { resolveWorkspaceRoot } from "../utils/helpers";
import {
parseLocalRowMetadata,
@@ -62,7 +63,10 @@ export async function buildConnectorStartRequest(input: {
}): Promise<ChatStartSessionRequest> {
const providerSettingsManager = new ProviderSettingsManager();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings();
providerSettingsManager.getLastUsedProviderSettings({
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
const provider = normalizeProviderId(
input.options.provider?.trim() ||
lastUsedProviderSettings?.provider ||
@@ -159,36 +163,57 @@ export async function getOrCreateSessionId<
);
const existing = threadState.sessionId?.trim();
if (existing) {
const existingSession = await input.client.getSession(existing);
if (existingSession) {
await persistMergedThreadState(
input.thread,
input.bindingsPath,
{
...threadState,
sessionId: existing,
},
input.errorLabel,
);
input.logger.core.log(input.reusedLogMessage, {
transport: input.transport,
threadId: input.thread.id,
sessionId: existing,
});
await dispatchConnectorHook(
input.hookCommand,
{
adapter: input.transport,
botUserName: input.hookBotUserName,
event: "session.reused",
payload: {
threadId: input.thread.id,
channelId: input.thread.channelId,
sessionId: existing,
},
ts: new Date().toISOString(),
},
input.logger,
);
return existing;
}
await persistMergedThreadState(
input.thread,
input.bindingsPath,
{
...threadState,
sessionId: existing,
sessionId: undefined,
},
input.errorLabel,
);
input.logger.core.log(input.reusedLogMessage, {
transport: input.transport,
threadId: input.thread.id,
sessionId: existing,
});
await dispatchConnectorHook(
input.hookCommand,
input.logger.core.log(
"Connector thread session missing; starting a new session",
{
adapter: input.transport,
botUserName: input.hookBotUserName,
event: "session.reused",
payload: {
threadId: input.thread.id,
channelId: input.thread.channelId,
sessionId: existing,
},
ts: new Date().toISOString(),
severity: "warn",
transport: input.transport,
threadId: input.thread.id,
sessionId: existing,
},
input.logger,
);
return existing;
}
const started = await input.client.startRuntimeSession(input.startRequest);
@@ -6,6 +6,7 @@ import { afterEach, describe, expect, it } from "vitest";
import {
type ConnectorThreadState,
clearBindingSessionIds,
findBindingForDeliveryTarget,
isParticipantMuted,
isThreadMuted,
readBindingForThread,
@@ -52,16 +53,16 @@ afterEach(() => {
});
describe("thread binding refresh", () => {
it("refreshes the serialized thread immediately when channel fallback rebinds a thread id", () => {
it("refreshes the serialized thread immediately when DM channel fallback rebinds a thread id", () => {
const path = createBindingsPath();
writeBindings<TestState>(path, {
legacy_thread_id: {
channelId: "slack:C123",
isDM: false,
isDM: true,
serializedThread: JSON.stringify({
id: "legacy_thread_id",
channelId: "slack:C123",
isDM: false,
isDM: true,
}),
sessionId: "sess-1",
state: { sessionId: "sess-1", teamId: "T123" },
@@ -74,7 +75,7 @@ describe("thread binding refresh", () => {
createThread({
id: "new_thread_id",
channelId: "slack:C123",
isDM: false,
isDM: true,
}),
"Slack",
);
@@ -85,7 +86,7 @@ describe("thread binding refresh", () => {
expect(bindings.new_thread_id?.serializedThread).toContain("new_thread_id");
});
it("refreshes the serialized thread when a participant-key binding matches a new thread id", () => {
it("does not rebind a different thread by participant key", () => {
const path = createBindingsPath();
const participantKey = "slack:team:T123:user:U123";
writeBindings<TestState>(path, {
@@ -119,10 +120,69 @@ describe("thread binding refresh", () => {
participantKey,
);
expect(binding?.serializedThread).toContain("new_thread_id");
expect(binding).toBeUndefined();
expect(
readBindings<TestState>(path)[participantKey]?.serializedThread,
).toContain("new_thread_id");
).toContain("legacy_thread_id");
});
it("resolves schedule delivery targets by exact binding key before participant metadata", () => {
const path = createBindingsPath();
writeBindings<TestState>(path, {
"slack:C123:111.222": {
kind: "conversation",
channelId: "slack:C123",
isDM: false,
participantKey: "slack:team:T123:user:U123",
serializedThread: "{}",
sessionId: "sess-thread",
state: {
sessionId: "sess-thread",
participantKey: "slack:team:T123:user:U123",
},
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
const match = findBindingForDeliveryTarget<TestState>(
readBindings<TestState>(path),
{
bindingKey: "slack:C123:111.222",
threadId: "slack:C123:111.222",
participantKey: "slack:team:T123:user:U123",
},
);
expect(match?.key).toBe("slack:C123:111.222");
expect(match?.binding.sessionId).toBe("sess-thread");
});
it("resolves schedule delivery targets by participant key when no exact thread binding exists", () => {
const path = createBindingsPath();
writeBindings<TestState>(path, {
"slack:team:T123:user:U123": {
channelId: "slack:C123",
isDM: true,
participantKey: "slack:team:T123:user:U123",
serializedThread: "{}",
sessionId: "sess-participant",
state: {
sessionId: "sess-participant",
participantKey: "slack:team:T123:user:U123",
},
updatedAt: "2026-03-17T00:00:00.000Z",
},
});
const match = findBindingForDeliveryTarget<TestState>(
readBindings<TestState>(path),
{
participantKey: "slack:team:T123:user:U123",
},
);
expect(match?.key).toBe("slack:team:T123:user:U123");
expect(match?.binding.sessionId).toBe("sess-participant");
});
it("stores mute state at thread scope instead of participant scope", () => {
+38 -58
View File
@@ -14,7 +14,7 @@ export type ConnectorThreadState = {
};
export type ConnectorThreadBinding<TState extends ConnectorThreadState> = {
kind?: "participant" | "thread" | "thread-participant-mute";
kind?: "conversation" | "participant" | "thread" | "thread-participant-mute";
channelId: string;
isDM: boolean;
participantKey?: string;
@@ -134,12 +134,9 @@ function clearSerializedThreadSessionId(serializedThread: string | undefined): {
export function resolveThreadBindingKey(
thread: ConnectorBindingThreadIdentity,
state?: ConnectorThreadState | null,
_state?: ConnectorThreadState | null,
): string {
return (
normalizeParticipantKey(state?.participantKey ?? thread.participantKey) ??
thread.id
);
return thread.id;
}
export function readBindings<TState extends ConnectorThreadState>(
@@ -160,40 +157,13 @@ export function findBindingForThread<TState extends ConnectorThreadState>(
bindings: ConnectorBindingStore<TState>,
thread: ConnectorBindingThreadIdentity,
): { binding: ConnectorThreadBinding<TState>; key: string } | undefined {
const participantKey = normalizeParticipantKey(thread.participantKey);
if (participantKey) {
const exactThread = bindings[thread.id];
const exactThreadParticipantKey = normalizeParticipantKey(
exactThread?.participantKey ?? exactThread?.state?.participantKey,
);
if (
exactThread &&
!isControlBinding(exactThread) &&
exactThreadParticipantKey === participantKey
) {
return { key: thread.id, binding: exactThread };
}
const exactParticipant = bindings[participantKey];
if (exactParticipant && !isControlBinding(exactParticipant)) {
return { key: participantKey, binding: exactParticipant };
}
for (const [key, binding] of Object.entries(bindings)) {
if (isControlBinding(binding)) {
continue;
}
const bindingParticipantKey = normalizeParticipantKey(
binding.participantKey ?? binding.state?.participantKey,
);
if (bindingParticipantKey === participantKey) {
return { key, binding };
}
}
return undefined;
}
const exact = bindings[thread.id];
if (exact && !isControlBinding(exact)) {
return { key: thread.id, binding: exact };
}
if (!thread.isDM) {
return undefined;
}
for (const [key, binding] of Object.entries(bindings)) {
if (isControlBinding(binding)) {
continue;
@@ -282,29 +252,8 @@ export function persistThreadBinding<TState extends ConnectorThreadState>(
thread as ConnectorBindingThreadIdentity,
state,
);
for (const [key, binding] of Object.entries(bindings)) {
if (isControlBinding(binding)) {
continue;
}
const bindingParticipantKey = normalizeParticipantKey(
binding.participantKey ?? binding.state?.participantKey,
);
const matchesParticipant =
participantKey && bindingParticipantKey === participantKey;
const matchesLegacyKey = participantKey && key === thread.id;
const matchesLegacyThread =
!participantKey &&
binding.channelId === thread.channelId &&
binding.isDM === thread.isDM;
if (
key !== bindingKey &&
(matchesParticipant || matchesLegacyKey || matchesLegacyThread)
) {
delete bindings[key];
}
}
bindings[bindingKey] = {
kind: "participant",
kind: "conversation",
channelId: thread.channelId,
isDM: thread.isDM,
participantKey,
@@ -531,6 +480,37 @@ export function findBindingForParticipantKey<
return undefined;
}
export function findBindingForDeliveryTarget<
TState extends ConnectorThreadState,
>(
bindings: ConnectorBindingStore<TState>,
input: {
bindingKey?: string;
threadId?: string;
participantKey?: string;
},
): { binding: ConnectorThreadBinding<TState>; key: string } | undefined {
const bindingKey = normalizeParticipantKey(input.bindingKey);
if (bindingKey) {
const exact = bindings[bindingKey];
if (exact && !isControlBinding(exact)) {
return { key: bindingKey, binding: exact };
}
const participantMatch = findBindingForParticipantKey(bindings, bindingKey);
if (participantMatch) {
return participantMatch;
}
}
const threadId = input.threadId?.trim();
if (threadId) {
const exact = bindings[threadId];
if (exact && !isControlBinding(exact)) {
return { key: threadId, binding: exact };
}
}
return findBindingForParticipantKey(bindings, input.participantKey);
}
export async function persistMergedThreadState<
TState extends ConnectorThreadState,
>(
+157 -7
View File
@@ -29,7 +29,9 @@ const authMocks = vi.hoisted(() => ({
runAuthCommand: vi.fn(),
}));
const providerSettingsMocks = vi.hoisted(() => ({
getLastUsedProviderSettings: vi.fn<() => unknown>(() => undefined),
getLastUsedProviderSettings: vi.fn<(options?: unknown) => unknown>(
() => undefined,
),
getProviderConfig: vi.fn<(providerId: string, options?: unknown) => unknown>(
() => undefined,
),
@@ -82,6 +84,11 @@ const historyMocks = vi.hoisted(() => ({
runHistoryExport: vi.fn(async () => 0),
runHistoryUpdate: vi.fn(async () => 0),
}));
const historyResumeMocks = vi.hoisted(() => ({
spawnHistoryResume: vi.fn<() => Promise<number | undefined>>(
async () => undefined,
),
}));
const loggingMocks = vi.hoisted(() => ({
createCliLoggerAdapter: vi.fn(() => ({
core: {
@@ -101,10 +108,14 @@ const hubRuntimeMocks = vi.hoisted(() => ({
}));
const telemetryMocks = vi.hoisted(() => ({
captureCliExtensionActivated: vi.fn(),
identifyCliTelemetryAccount: vi.fn(),
identifyTelemetryAccount: vi.fn(),
getCliTelemetryService: vi.fn(),
disposeCliTelemetryService: vi.fn(async () => {}),
}));
const featureFlagMocks = vi.hoisted(() => ({
getBooleanFlagEnabled: vi.fn(() => false),
setCliFeatureFlagsAccountContext: vi.fn(),
}));
function forcePromptModeInput() {
Object.defineProperty(process.stdin, "isTTY", {
@@ -148,8 +159,8 @@ vi.mock("@cline/core", () => {
stop: vi.fn(),
})),
ProviderSettingsManager: class {
getLastUsedProviderSettings() {
return providerSettingsMocks.getLastUsedProviderSettings();
getLastUsedProviderSettings(options?: unknown) {
return providerSettingsMocks.getLastUsedProviderSettings(options);
}
getProviderSettings(providerId: string) {
return providerSettingsMocks.getProviderSettings(providerId);
@@ -164,6 +175,14 @@ vi.mock("@cline/core", () => {
};
});
vi.mock("./utils/provider-auth", () => authMocks);
vi.mock("./utils/feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
}),
refreshCliFeatureFlagsInBackground: vi.fn(),
setCliFeatureFlagsAccountContext:
featureFlagMocks.setCliFeatureFlagsAccountContext,
}));
vi.mock("./runtime/prompt", () => ({
resolveSystemPrompt: promptMocks.resolveSystemPrompt,
}));
@@ -172,6 +191,7 @@ vi.mock("./commands/dashboard", () => dashboardMocks);
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
vi.mock("./commands/update", () => updateMocks);
vi.mock("./commands/history", () => historyMocks);
vi.mock("./utils/history-resume", () => historyResumeMocks);
vi.mock("./logging/adapter", () => loggingMocks);
vi.mock("./utils/hub-runtime", () => hubRuntimeMocks);
vi.mock("./utils/telemetry", () => telemetryMocks);
@@ -191,6 +211,8 @@ describe("runCli lightweight command dispatch", () => {
historyMocks.runHistoryExport.mockResolvedValue(0);
historyMocks.runHistoryUpdate.mockReset();
historyMocks.runHistoryUpdate.mockResolvedValue(0);
historyResumeMocks.spawnHistoryResume.mockReset();
historyResumeMocks.spawnHistoryResume.mockResolvedValue(undefined);
sessionMocks.getSessionRow.mockReset();
sessionMocks.getSessionRow.mockResolvedValue({
sessionId: "sess_123",
@@ -233,6 +255,9 @@ describe("runCli lightweight command dispatch", () => {
providerSettingsMocks.getProviderSettings.mockReset();
providerSettingsMocks.getProviderSettings.mockReturnValue(undefined);
providerSettingsMocks.saveProviderSettings.mockReset();
featureFlagMocks.getBooleanFlagEnabled.mockReset();
featureFlagMocks.getBooleanFlagEnabled.mockReturnValue(false);
featureFlagMocks.setCliFeatureFlagsAccountContext.mockReset();
kanbanMocks.launchKanban.mockReset();
kanbanMocks.launchKanban.mockResolvedValue(0);
dashboardMocks.runDashboardCommand.mockReset();
@@ -246,7 +271,7 @@ describe("runCli lightweight command dispatch", () => {
updateMocks.getPreferredKanbanInstaller.mockReset();
updateMocks.getPreferredKanbanInstaller.mockReturnValue(undefined);
telemetryMocks.captureCliExtensionActivated.mockReset();
telemetryMocks.identifyCliTelemetryAccount.mockReset();
telemetryMocks.identifyTelemetryAccount.mockReset();
telemetryMocks.getCliTelemetryService.mockReset();
telemetryMocks.disposeCliTelemetryService.mockReset();
telemetryMocks.disposeCliTelemetryService.mockResolvedValue(undefined);
@@ -392,6 +417,61 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runInteractiveImports).toBe(0);
});
it("rejects multiple bare positional prompt tokens", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello", "world"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining(
"Unknown command or extra arguments: hello world",
),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
});
it("runs quoted positional prompt text", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello world"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello world",
expect.any(Object),
expect.anything(),
);
});
it("rejects unknown root flags before loading runtime modules", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--made-up-flag"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("unknown option '--made-up-flag'"),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
});
it("creates a worktree and runs prompt sessions from it", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--worktree", "hello"];
@@ -719,10 +799,47 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("forces chat view when resuming from history picker", async () => {
it("resumes a history-picked session in a child process", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(0);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledTimes(1);
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "sess_from_history",
normalizedArgs: ["history"],
remainingArgs: ["history"],
}),
);
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
expect(process.exitCode).toBe(0);
});
it("propagates the child exit code when resuming from history picker", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(3);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(3);
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
});
it("forces chat view when the history-picker child cannot launch", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(undefined);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
@@ -801,6 +918,33 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("seeds feature flag identity from persisted Cline account id before checking flags", async () => {
const clineSettings = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
auth: {
accountId: "acct-startup",
accessToken: "workos:token",
refreshToken: "refresh-token",
},
};
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(
featureFlagMocks.setCliFeatureFlagsAccountContext,
).toHaveBeenCalledWith({ id: "acct-startup" });
expect(
featureFlagMocks.setCliFeatureFlagsAccountContext.mock
.invocationCallOrder[0],
).toBeLessThan(
featureFlagMocks.getBooleanFlagEnabled.mock.invocationCallOrder[0],
);
});
it("runs kanban before loading runtime modules", async () => {
process.argv = ["bun", "src/index.ts", "kanban"];
@@ -818,6 +962,10 @@ describe("runCli lightweight command dispatch", () => {
"bun",
"src/index.ts",
"dashboard",
"--config",
"/tmp/cline-config",
"--data-dir",
".cline-dashboard-data",
"--port",
"9090",
"--no-open",
@@ -828,6 +976,8 @@ describe("runCli lightweight command dispatch", () => {
await expect(runCli()).resolves.toBeUndefined();
expect(dashboardMocks.runDashboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
configDir: "/tmp/cline-config",
dataDir: ".cline-dashboard-data",
port: "9090",
openBrowser: false,
io: expect.any(Object),
@@ -876,7 +1026,7 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "/team", "find", "the", "bug"];
process.argv = ["bun", "src/index.ts", "/team find the bug"];
const { runCli } = await import("./main");
+100 -3
View File
@@ -19,6 +19,11 @@ import {
buildCliCompactionConfig,
CLI_COMPACTION_MODE_EXPECTED_TEXT,
} from "./utils/compaction-mode";
import {
getCliFeatureFlagsService,
refreshCliFeatureFlagsInBackground,
setCliFeatureFlagsAccountContext,
} from "./utils/feature-flags";
import {
configureSandboxEnvironment,
normalizeAutoApproveArgs,
@@ -135,7 +140,7 @@ export async function runCli(): Promise<void> {
// Re-enable built-in help/version output for the routing program
program.configureOutput({
writeOut: (str: string) => process.stdout.write(str),
writeErr: (str: string) => process.stderr.write(str),
writeErr: () => {},
});
// Default action handles non-subcommand args (e.g. prompt text)
program.action(() => {});
@@ -152,6 +157,7 @@ export async function runCli(): Promise<void> {
.option("-k, --apikey <key>", "API key")
.option("-m, --modelid <id>", "Model ID")
.option("-b, --baseurl <url>", "Base URL")
.option("--azure-api-version <version>", "Azure API version")
.option("--config <dir>", "configuration directory")
.option("-c, --cwd <path>", "Working directory")
.option(
@@ -165,6 +171,7 @@ export async function runCli(): Promise<void> {
apikey?: string;
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
config?: string;
cwd?: string;
dataDir?: string;
@@ -195,6 +202,7 @@ export async function runCli(): Promise<void> {
apikey: opts.apikey,
modelid: opts.modelid,
baseurl: opts.baseurl,
azureApiVersion: opts.azureApiVersion,
io,
});
});
@@ -308,6 +316,28 @@ export async function runCli(): Promise<void> {
io,
});
});
const skillCmd = program
.command("skill")
.description("Manage Cline Skills via the open skills CLI (npx skills)")
.allowUnknownOption()
.passThroughOptions()
.argument("[args...]", "arguments forwarded to the skills CLI")
.addHelpText(
"after",
"\nForwards to the open skills CLI via npx. Examples:\n" +
" cline skill add <owner/repo> Add a skill into Cline\n" +
" cline skill install <owner/repo> Alias for add\n" +
" cline skill list List installed skills\n" +
" cline skill remove Remove installed skills\n" +
" cline skill uninstall Alias for remove\n" +
"\nadd/install and remove/uninstall default to '--agent cline' unless you pass your own --agent.\n" +
"Run 'npx skills --help' for the full command reference.",
)
.action(async () => {
const { runSkillCommand } = await import("./commands/skill");
ctx.exitCode = await runSkillCommand(skillCmd.args, io);
});
const connectCmd = program
.command("connect")
.description("Connect to an external channel")
@@ -349,7 +379,7 @@ export async function runCli(): Promise<void> {
}
});
program
const mcpCmd = program
.command("mcp")
.description("Manage MCP servers")
.action(async () => {
@@ -361,6 +391,31 @@ export async function runCli(): Promise<void> {
);
}
});
const mcpInstallCmd = mcpCmd
.command("install")
.alias("add")
.description("Open the MCP add wizard with server fields prefilled")
.argument("<name>", "MCP server name")
.argument(
"[targetArgs...]",
"URL for remote transports, or command and args after -- for stdio",
)
.option(
"--transport <transport>",
"stdio, sse, http, streamable-http, or streamableHttp (default: stdio)",
)
.action(async (name: string, targetArgs: string[]) => {
const opts = mcpInstallCmd.opts<{
transport?: string;
}>();
const { runMcpInstallCommand } = await import("./commands/mcp");
ctx.exitCode = await runMcpInstallCommand({
name,
targetArgs,
transport: opts.transport,
io,
});
});
const createDoctorRuntimeCommand = async () => {
const { createDoctorCommand } = await import("./commands/doctor");
@@ -541,7 +596,12 @@ export async function runCli(): Promise<void> {
const dashboardCmd = program
.command("dashboard")
.description("Start the Cline Hub dashboard and open it in a browser")
.option("--config <dir>", "configuration directory")
.option("-c, --cwd <path>", "Workspace root", process.cwd())
.option(
"--data-dir <dir>",
"Use isolated local state at <dir> instead of ~/.cline (enables sandbox mode)",
)
.option("--host <host>", "Dashboard bind host")
.option("--port <port>", "Dashboard HTTP/WebSocket port")
.option("--public-url <url>", "Public dashboard URL")
@@ -549,7 +609,9 @@ export async function runCli(): Promise<void> {
.option("--no-open", "Start the dashboard without opening a browser")
.action(async () => {
const opts = dashboardCmd.opts<{
config?: string;
cwd?: string;
dataDir?: string;
host?: string;
port?: string;
publicUrl?: string;
@@ -558,7 +620,9 @@ export async function runCli(): Promise<void> {
}>();
const { runDashboardCommand } = await import("./commands/dashboard");
ctx.exitCode = await runDashboardCommand({
configDir: opts.config,
cwd: opts.cwd,
dataDir: opts.dataDir,
host: opts.host,
port: opts.port,
publicUrl: opts.publicUrl,
@@ -607,6 +671,7 @@ export async function runCli(): Promise<void> {
if (err instanceof CommanderError) {
if (err.exitCode !== 0) {
writeErr(err.message);
process.exitCode = err.exitCode;
return;
}
return;
@@ -657,9 +722,31 @@ 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) {
// The history picker already created (and tore down) an OpenTUI renderer
// in this process; starting the interactive TUI here would create a
// second one, which can crash natively during teardown. Resume in a
// fresh `cline --id <session-id>` child process instead.
const { spawnHistoryResume } = await import("./utils/history-resume");
const childExitCode = await spawnHistoryResume({
sessionId: resumeSessionId,
normalizedArgs,
remainingArgs: program.args,
configDir,
});
if (childExitCode !== undefined) {
process.exitCode = childExitCode;
return;
}
args = {
...args,
interactive: true,
@@ -833,8 +920,18 @@ export async function runCli(): Promise<void> {
};
registerDisposable(stopUserInstructionService);
try {
const persistedClineAccountId = providerSettingsManager
.getProviderSettings("cline")
?.auth?.accountId?.trim();
if (persistedClineAccountId) {
setCliFeatureFlagsAccountContext({ id: persistedClineAccountId });
}
refreshCliFeatureFlagsInBackground();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings();
providerSettingsManager.getLastUsedProviderSettings({
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
const provider = normalizeProviderId(
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
);
@@ -61,6 +61,20 @@ describe("createInteractiveApprovalController", () => {
).resolves.toEqual({ approved: false, reason: "no" });
});
it("approves stale required-approval requests after auto-approve is enabled", async () => {
const controller = createInteractiveApprovalController(makeConfig(false));
controller.tuiToolApprover.current = async () => ({
approved: false,
reason: "stale prompt",
});
controller.setInteractiveAutoApprove(true);
await expect(
controller.requestToolApproval(makeRequest({ autoApprove: false })),
).resolves.toEqual({ approved: true });
});
it("denies approval-required requests when no TUI approver is available", async () => {
const controller = createInteractiveApprovalController(makeConfig(false));
@@ -77,6 +91,7 @@ describe("createInteractiveApprovalController", () => {
expect(controller.autoApproveAllRef.current).toBe(true);
expect(config.defaultToolAutoApprove).toBe(false);
expect(config.toolPolicies["*"]?.autoApprove).toBe(false);
expect(config.toolPolicies["*"]?.autoApprove).toBe(true);
expect(controller.resolveToolPolicy("run_commands").autoApprove).toBe(true);
});
});
@@ -3,6 +3,7 @@ import type { Config } from "../../utils/types";
import {
applyInteractiveAutoApproveOverride,
cloneToolPolicies,
resolveInteractiveAutoApprovePolicy,
} from "../tool-policies";
export interface InteractiveRuntimeRefs {
@@ -38,10 +39,10 @@ export function createInteractiveApprovalController(config: Config) {
const requestToolApproval = async (
request: ToolApprovalRequest,
): Promise<ToolApprovalResult> => {
if (request.policy?.autoApprove === true) {
if (autoApproveAllRef.current) {
return { approved: true };
}
if (autoApproveAllRef.current && request.policy?.autoApprove !== false) {
if (request.policy?.autoApprove === true) {
return { approved: true };
}
if (refs.tuiToolApprover.current) {
@@ -54,6 +55,12 @@ export function createInteractiveApprovalController(config: Config) {
autoApproveAllRef,
setInteractiveAutoApprove,
requestToolApproval,
resolveToolPolicy: (toolName: string) =>
resolveInteractiveAutoApprovePolicy({
toolName,
baselinePolicies: baselineToolPolicies,
enabled: autoApproveAllRef.current,
}),
...refs,
};
}
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import {
type ChatCommandState,
chatCommandHost,
createChatCommandHost,
} from "../../utils/chat-commands";
import type { Config } from "../../utils/types";
import {
@@ -162,4 +163,37 @@ describe("runInteractiveChatCommand", () => {
expect(state.autoApproveTools).toBe(true);
expect(setInteractiveAutoApprove).toHaveBeenCalledWith(true);
});
it("returns plugin command submit prompts as model input", async () => {
const config = makeConfig();
const runtime = makeRuntime();
const onCommandOutput = vi.fn();
const host = createChatCommandHost().register("command", {
names: ["/goal"],
run: async ({ args }, context) => {
await context.reply(`Goal guard set: ${args.join(" ")}`);
await context.submitPrompt?.(args.join(" "));
},
});
const result = await runInteractiveChatCommand({
prompt: "/goal fix tests",
enabled: true,
config,
host,
chatCommandState: makeState(config),
autoApproveAllRef: { current: false },
setInteractiveAutoApprove: () => {},
sessionRuntime: runtime,
stop: () => {},
onCommandOutput,
});
expect(result).toEqual({
handled: false,
input: "fix tests",
commandOutput: "Goal guard set: fix tests",
});
expect(onCommandOutput).toHaveBeenCalledWith("Goal guard set: fix tests");
});
});
@@ -26,7 +26,7 @@ export type InteractiveChatCommandRuntime = Pick<
export type InteractiveChatCommandResult =
| { handled: true; turnResult: InteractiveTurnResult }
| { handled: false; input: string };
| { handled: false; input: string; commandOutput?: string };
function commandTurnResult(commandOutput?: string): InteractiveTurnResult {
return {
@@ -46,6 +46,7 @@ export async function runInteractiveChatCommand(input: {
setInteractiveAutoApprove: (enabled: boolean) => void;
sessionRuntime: InteractiveChatCommandRuntime;
stop: () => void;
onCommandOutput?: (text: string) => void;
}): Promise<InteractiveChatCommandResult> {
let prompt = input.prompt;
const rewrittenTeamPrompt = rewriteTeamPrompt(prompt);
@@ -64,6 +65,7 @@ export async function runInteractiveChatCommand(input: {
}
let commandOutput: string | undefined;
let submitPrompt: string | undefined;
const handled = await maybeHandleChatCommand(prompt, {
enabled: input.enabled,
host: input.host,
@@ -80,6 +82,13 @@ export async function runInteractiveChatCommand(input: {
},
reply: async (text) => {
commandOutput = text;
input.onCommandOutput?.(text);
},
submitPrompt: async (text) => {
const trimmed = text.trim();
if (trimmed) {
submitPrompt = trimmed;
}
},
reset: async () => {
await input.sessionRuntime.resetForNewSession();
@@ -98,6 +107,13 @@ export async function runInteractiveChatCommand(input: {
fork: input.sessionRuntime.forkCurrentSession,
});
if (handled) {
if (submitPrompt) {
return {
handled: false,
input: submitPrompt,
...(commandOutput ? { commandOutput } : {}),
};
}
return {
handled: true,
turnResult: commandTurnResult(commandOutput),
@@ -1,4 +1,11 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { UserInstructionConfigService } from "@cline/core";
@@ -43,9 +50,17 @@ describe("interactive config data loader", () => {
};
afterEach(async () => {
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
if (envSnapshot.CLINE_GLOBAL_SETTINGS_PATH === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
}
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
}
await Promise.all(
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
);
@@ -76,6 +91,28 @@ describe("interactive config data loader", () => {
return pluginPath;
}
async function writeMcpSettingsPlugin(tempRoot: string): Promise<string> {
const pluginsDir = join(tempRoot, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
const pluginPath = join(pluginsDir, "settings-mcp-plugin.js");
await writeFile(
pluginPath,
[
"export default {",
" name: 'settings-mcp-plugin',",
" manifest: { capabilities: ['mcp'] },",
" setup(api) {",
" api.registerMcpServer({",
" name: 'smoke',",
" transport: { type: 'stdio', command: process.execPath, args: ['-e', 'process.exit(0)'] },",
" });",
" },",
"};",
].join("\n"),
);
return pluginPath;
}
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -311,6 +348,70 @@ Find installable skills.`,
).toBe(true);
});
it("loads plugin-owned MCP servers from settings", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
smoke: {
transport: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
metadata: {
source: "plugin",
pluginName: "settings-mcp-plugin",
pluginPath,
},
},
},
},
null,
2,
)}\n`,
);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: true });
expect(
data.mcp.some(
(item) =>
item.name === "smoke" &&
item.pluginName === "settings-mcp-plugin" &&
item.pluginPath === pluginPath &&
item.kind === "mcp",
),
).toBe(true);
});
it("does not load plugin MCP rows directly from plugin diagnostics", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: true });
expect(data.plugins.some((item) => item.path === pluginPath)).toBe(true);
expect(data.mcp.some((item) => item.pluginPath === pluginPath)).toBe(false);
});
it("keeps failed plugins visible with their load error", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -731,6 +832,142 @@ Review with the bundled skill.`,
).toBe(false);
});
it("disables and re-syncs plugin-owned MCP servers when toggling plugins", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
smoke: {
transport: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
oauth: {
tokens: {
access_token: "token",
},
},
metadata: {
source: "plugin",
pluginName: "settings-mcp-plugin",
pluginPath,
},
},
},
},
null,
2,
)}\n`,
);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const item: InteractiveConfigItem = {
id: pluginPath,
name: "settings-mcp-plugin",
path: pluginPath,
enabled: true,
source: "workspace-plugin",
kind: "plugin",
};
await loader.onToggleConfigItem(item);
let settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<
string,
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
>;
};
expect(settings.mcpServers?.smoke?.disabled).toBe(true);
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
"token",
);
await loader.onToggleConfigItem({ ...item, enabled: false });
settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<
string,
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
>;
};
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
"token",
);
});
it.skipIf(process.platform === "win32")(
"does not mark plugin disabled when MCP disable write fails",
async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
const globalSettingsPath = join(tempRoot, "global-settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = globalSettingsPath;
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
smoke: {
transport: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
metadata: {
source: "plugin",
pluginName: "settings-mcp-plugin",
pluginPath,
},
},
},
},
null,
2,
)}\n`,
);
await chmod(settingsPath, 0o444);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
try {
await expect(
loader.onToggleConfigItem({
id: pluginPath,
name: "settings-mcp-plugin",
path: pluginPath,
enabled: true,
source: "workspace-plugin",
kind: "plugin",
}),
).rejects.toThrow();
} finally {
await chmod(settingsPath, 0o644);
}
await expect(readFile(globalSettingsPath, "utf8")).rejects.toThrow();
const settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<string, { disabled?: boolean }>;
};
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
},
);
it("surfaces MCP OAuth status and errors", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -1,7 +1,9 @@
import {
createCoreSettingsService,
disablePluginMcpServersInSettings,
setDisabledPlugin,
setDisabledTools,
syncPluginMcpServersToSettings,
type UserInstructionConfigService,
uninstallPlugin,
} from "@cline/core";
@@ -70,7 +72,32 @@ export function createInteractiveConfigDataLoader(input: {
}
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
setDisabledPlugin(item.path, item.enabled);
if (item.enabled) {
disablePluginMcpServersInSettings({ pluginPaths: [item.path] });
setDisabledPlugin(item.path, true);
} else {
const ownedMcpMutations = disablePluginMcpServersInSettings({
pluginPaths: [item.path],
});
const result = await syncPluginMcpServersToSettings({
pluginPaths: [item.path],
cwd: input.config.cwd,
workspacePath: workspaceRoot(),
providerId: input.config.providerId,
modelId: input.config.modelId,
});
if (ownedMcpMutations.length > 0 && result.failures.length > 0) {
throw new Error(
`Failed to sync plugin MCP servers: ${result.failures
.map((failure) => {
const plugin = failure.pluginName ?? failure.pluginPath;
return `${plugin}: ${failure.message}`;
})
.join("; ")}`,
);
}
setDisabledPlugin(item.path, false);
}
return undefined;
}
@@ -152,7 +152,10 @@ function deferred<T>() {
function makeRuntime(
manager: ReturnType<typeof makeManager>,
options: { resumeSessionId?: string } = {},
options: {
resumeSessionId?: string;
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
} = {},
) {
mockCreateCliCore.mockResolvedValue(manager);
const config = makeConfig();
@@ -164,6 +167,8 @@ function makeRuntime(
requestToolApproval: async (
_request: ToolApprovalRequest,
): Promise<ToolApprovalResult> => ({ approved: true }),
resolveToolPolicy:
options.resolveToolPolicy ?? (() => ({ autoApprove: true })),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: makeSwitchToActModeTool(),
@@ -205,6 +210,68 @@ describe("createInteractiveSessionRuntime", () => {
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("adds a live interactive approval policy hook to started sessions", async () => {
const manager = makeManager();
const upstreamBeforeTool = vi.fn(async () => ({
input: { text: "updated" },
}));
mockCreateRuntimeHooks.mockReturnValueOnce({
hooks: {
beforeTool: upstreamBeforeTool,
},
shutdown: vi.fn(async () => {}),
});
const runtime = makeRuntime(manager, {
resolveToolPolicy: (toolName) => ({
autoApprove: toolName === "echo",
}),
});
await runtime.ensureReady();
const startInput = manager.start.mock.calls[0]?.[0] as
| { config?: Config }
| undefined;
const beforeTool = startInput?.config?.hooks?.beforeTool;
expect(beforeTool).toBeTypeOf("function");
const result = await beforeTool?.({
snapshot: {
agentId: "agent-1",
conversationId: "conversation-1",
status: "running",
iteration: 1,
messages: [],
pendingToolCalls: [],
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
},
tool: {
name: "echo",
description: "",
inputSchema: {},
execute: async () => "ok",
},
toolCall: {
type: "tool-call",
toolCallId: "call-1",
toolName: "echo",
input: { text: "original" },
},
input: { text: "original" },
});
expect(upstreamBeforeTool).toHaveBeenCalledOnce();
expect(result).toEqual({
input: { text: "updated" },
policy: { autoApprove: true },
});
});
it("starts fresh after resetting an initially resumed session", async () => {
const manager = makeManager();
const runtime = makeRuntime(manager, {
@@ -1,5 +1,6 @@
import {
type AgentEvent,
type AgentHooks,
type CheckpointEntry,
isSessionNotFoundError,
type PendingPromptMutationResult,
@@ -48,6 +49,32 @@ type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
type AskQuestionRef = {
current: ((question: string, options: string[]) => Promise<string>) | null;
};
type ToolPolicyResolver = (
toolName: string,
) => NonNullable<Config["toolPolicies"]>[string];
function withInteractiveApprovalPolicyHook(
hooks: AgentHooks | undefined,
resolveToolPolicy: ToolPolicyResolver,
): AgentHooks {
return {
...hooks,
beforeTool: async (ctx) => {
const result = await hooks?.beforeTool?.(ctx);
if (result?.stop || result?.skip) {
return result;
}
const policy = resolveToolPolicy(ctx.toolCall.toolName);
return {
...result,
policy: {
...result?.policy,
autoApprove: policy.autoApprove,
},
};
},
};
}
export function createInteractiveSessionRuntime(input: {
config: Config;
@@ -58,6 +85,7 @@ export function createInteractiveSessionRuntime(input: {
requestToolApproval: (
request: ToolApprovalRequest,
) => Promise<ToolApprovalResult>;
resolveToolPolicy: ToolPolicyResolver;
askQuestionRef: AskQuestionRef;
resolveMistakeLimitDecision: Config["onConsecutiveMistakeLimitReached"];
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
@@ -152,10 +180,14 @@ export function createInteractiveSessionRuntime(input: {
if (!runtimeHooks) {
throw new Error("interactive runtime hooks are unavailable");
}
const hooks = withInteractiveApprovalPolicyHook(
runtimeHooks.hooks,
input.resolveToolPolicy,
);
return buildInteractiveSessionConfig({
config: input.config,
chatCommandState: input.chatCommandState,
runtimeHooks,
runtimeHooks: { hooks },
onTeamEvent: input.onTeamEvent,
resolveMistakeLimitDecision: input.resolveMistakeLimitDecision,
});
+245 -1
View File
@@ -27,7 +27,41 @@ 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?personal=true";
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}`;
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",
isClineNotSubscribedMessage: (text: string) =>
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",
@@ -77,7 +111,7 @@ vi.mock("./prompt", () => ({
}));
vi.mock("./session-events", () => ({
subscribeToAgentEvents: vi.fn(() => () => {}),
subscribeToAgentEvents: sessionEventsMocks.subscribeToAgentEvents,
}));
describe("runAgent", () => {
@@ -101,6 +135,9 @@ describe("runAgent", () => {
outputMocks.writeln.mockReset();
outputMocks.emitJsonLine.mockReset();
outputMocks.setActiveCliSession.mockReset();
sessionEventsMocks.listener = undefined;
sessionEventsMocks.subscribeToAgentEvents.mockClear();
vi.unstubAllGlobals();
});
afterEach(() => {
@@ -511,6 +548,39 @@ describe("runAgent", () => {
expect(outputMocks.writeErr).toHaveBeenCalledWith("Missing API key");
});
it("renders ClinePass subscription errors with friendly copy when startup throws", async () => {
const error = new Error(CLINE_PASS_SUBSCRIPTION_MESSAGE);
error.name = "ClineNotSubscribedError";
sessionManagerMocks.start.mockRejectedValue(error);
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).toHaveBeenCalledWith(
CLINE_PASS_SUBSCRIPTION_MESSAGE,
);
});
it("emits JSON error lines for non-completed results", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
@@ -576,6 +646,63 @@ describe("runAgent", () => {
);
});
it("renders ClinePass subscription errors with friendly copy for failed results", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
sessionManagerMocks.start.mockResolvedValue({
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: 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).toHaveBeenCalledWith(
CLINE_PASS_SUBSCRIPTION_MESSAGE,
);
});
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");
@@ -837,4 +964,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 -5
View File
@@ -16,7 +16,13 @@ import {
requestToolApproval,
submitAndExitInTerminal,
} 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,
@@ -183,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) {
@@ -338,8 +346,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", {
@@ -374,7 +388,7 @@ export async function runAgent(
}
if (result.finishReason !== "completed") {
const errorText = result.text.trim();
const errorText = formatCliErrorMessage(result.text).trim();
if (
errorText &&
(config.outputMode === "json" || !displayedErrorMessages.has(errorText))
@@ -395,7 +409,7 @@ export async function runAgent(
);
process.exitCode = 0;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = formatCliErrorMessage(err);
logCliError(config.logger, "CLI task run failed", { error: err });
writeErr(message);
process.exitCode = 1;
+44 -5
View File
@@ -8,6 +8,7 @@ import type { CliMigrationNotice } from "../kanban-migration/notice";
import { logCliError } from "../logging/errors";
import {
loadClineAccountSnapshot,
onProviderChange,
switchClineAccount,
} from "../tui/cline-account";
import type {
@@ -23,6 +24,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,
@@ -120,6 +126,7 @@ export async function runInteractive(
autoApproveAllRef,
setInteractiveAutoApprove,
requestToolApproval,
resolveToolPolicy,
tuiToolApprover,
tuiAskQuestion,
} = createInteractiveApprovalController(config);
@@ -151,6 +158,7 @@ export async function runInteractive(
askQuestionRef: tuiAskQuestion,
});
const providerSettingsManager = new ProviderSettingsManager();
let zeroCurrentTurnCost = false;
const sessionRuntime = createInteractiveSessionRuntime({
config,
@@ -159,11 +167,12 @@ export async function runInteractive(
resumeSessionId,
chatCommandState,
requestToolApproval,
resolveToolPolicy,
askQuestionRef: tuiAskQuestion,
resolveMistakeLimitDecision,
switchToActModeTool,
onAgentEvent: (event) => {
uiEvents.emit("agent", event);
uiEvents.emit("agent", zeroCliAgentEventCost(event, zeroCurrentTurnCost));
},
onTeamEvent: (event) => {
uiEvents.emit("team", event);
@@ -427,7 +436,9 @@ export async function runInteractive(
uiEvents.off("pending-prompt-submitted", onPendingPromptSubmitted);
};
},
onSubmit: async (input, mode, delivery, attachments) => {
onSubmit: async (input, mode, delivery, attachments, onCommandOutput) => {
let commandOutput: string | undefined;
let zeroTurnCost = false;
try {
await sessionRuntime.ensureReady();
await waitForSubmittedMode(mode);
@@ -446,6 +457,7 @@ export async function runInteractive(
setInteractiveAutoApprove,
sessionRuntime,
stop: () => tuiApp?.destroy(),
onCommandOutput,
});
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
@@ -465,12 +477,16 @@ export async function runInteractive(
setInteractiveAutoApprove,
sessionRuntime,
stop: () => tuiApp?.destroy(),
onCommandOutput,
});
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
}
}
input = chatCommandResult.input;
commandOutput = chatCommandResult.commandOutput;
zeroTurnCost = await shouldZeroClineFreeModelCost(config);
zeroCurrentTurnCost = zeroTurnCost;
const {
prompt: userInput,
userImages,
@@ -507,18 +523,21 @@ export async function runInteractive(
iterations: 0,
finishReason: "queued",
queued: delivery === "queue" || delivery === "steer",
commandOutput,
};
}
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,
currentContextSize: getCurrentContextSize(result.messages),
iterations: result.iterations,
finishReason: "aborted",
commandOutput,
};
}
const errorText = result.text.trim();
@@ -526,12 +545,16 @@ 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),
iterations: result.iterations,
finishReason: result.finishReason,
commandOutput,
};
} catch (error) {
if (isAbortInProgress()) {
@@ -539,6 +562,7 @@ export async function runInteractive(
usage: { inputTokens: 0, outputTokens: 0 },
iterations: 0,
finishReason: "aborted",
commandOutput,
};
}
logCliError(config.logger, "Interactive turn failed", {
@@ -548,6 +572,7 @@ export async function runInteractive(
});
throw error;
} finally {
zeroCurrentTurnCost = false;
if (!delivery) {
isRunning = false;
clearAbortInProgress();
@@ -603,6 +628,10 @@ export async function runInteractive(
},
onModelChange: async () => {
await sessionRuntime.ensureReady();
await onProviderChange({
config,
providerId: config.providerId,
});
const existing = providerSettingsManager.getProviderSettings(
config.providerId,
) ?? {
@@ -623,6 +652,16 @@ export async function runInteractive(
},
onAccountChange: async () => {
await sessionRuntime.ensureReady();
await loadClineAccountSnapshot({
config,
clineApiBaseUrl: options?.clineApiBaseUrl,
}).catch((error) => {
logCliError(
config.logger,
"Cline account refresh after account change failed",
{ error },
);
});
await sessionRuntime.restartWithCurrentMessages();
},
onResumeSession: async (sessionId: string) => {
+38 -3
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
applyInteractiveAutoApproveOverride,
cloneToolPolicies,
resolveInteractiveAutoApprovePolicy,
} from "./tool-policies";
describe("tool policy helpers", () => {
@@ -53,9 +54,9 @@ describe("tool policy helpers", () => {
});
});
it("restores the baseline policies when toggled back on", () => {
it("forces all baseline policies to auto-approve when toggled back on", () => {
const baseline = {
"*": { autoApprove: true },
"*": { autoApprove: false },
run_commands: { autoApprove: true, enabled: true },
editor: { autoApprove: false, enabled: true },
};
@@ -72,6 +73,40 @@ describe("tool policy helpers", () => {
enabled: true,
});
expect(target).toEqual(baseline);
expect(target).toEqual({
"*": { autoApprove: true },
run_commands: { autoApprove: true, enabled: true },
editor: { autoApprove: true, enabled: true },
});
});
it("resolves live per-tool policies from the interactive auto-approve state", () => {
const baseline = {
"*": { autoApprove: false },
read_files: { enabled: true },
editor: { autoApprove: false, enabled: true },
};
expect(
resolveInteractiveAutoApprovePolicy({
toolName: "editor",
baselinePolicies: baseline,
enabled: true,
}),
).toEqual({ autoApprove: true, enabled: true });
expect(
resolveInteractiveAutoApprovePolicy({
toolName: "run_commands",
baselinePolicies: baseline,
enabled: false,
}),
).toEqual({ autoApprove: false });
expect(
resolveInteractiveAutoApprovePolicy({
toolName: "read_files",
baselinePolicies: baseline,
enabled: false,
}),
).toEqual({ autoApprove: true, enabled: true });
});
});
+35 -7
View File
@@ -27,21 +27,51 @@ export function cloneToolPolicies(
);
}
export function resolveInteractiveAutoApprovePolicy(input: {
toolName: string;
baselinePolicies: Record<string, ToolPolicy>;
enabled: boolean;
}): ToolPolicy {
const toolPolicy = input.baselinePolicies[input.toolName] ?? {};
const baselinePolicy = {
...(input.baselinePolicies["*"] ?? {}),
...toolPolicy,
};
return {
...baselinePolicy,
autoApprove: input.enabled
? true
: SAFE_AUTO_APPROVE_TOOLS.has(input.toolName)
? (toolPolicy.autoApprove ?? true)
: false,
};
}
export function applyInteractiveAutoApproveOverride(input: {
targetPolicies: Record<string, ToolPolicy>;
baselinePolicies: Record<string, ToolPolicy>;
enabled: boolean;
}): void {
const nextPolicies: Record<string, ToolPolicy> = input.enabled
? cloneToolPolicies(input.baselinePolicies)
? Object.fromEntries(
Object.entries(input.baselinePolicies).map(([name, policy]) => [
name,
{
...policy,
autoApprove: true,
},
]),
)
: Object.fromEntries(
Object.entries(input.baselinePolicies).map(([name, policy]) => [
name,
{
...policy,
autoApprove: SAFE_AUTO_APPROVE_TOOLS.has(name)
? (policy.autoApprove ?? true)
: false,
autoApprove: resolveInteractiveAutoApprovePolicy({
toolName: name,
baselinePolicies: input.baselinePolicies,
enabled: false,
}).autoApprove,
},
]),
);
@@ -53,9 +83,7 @@ export function applyInteractiveAutoApproveOverride(input: {
}
const globalPolicy = clonePolicy(nextPolicies["*"]);
globalPolicy.autoApprove = input.enabled
? (input.baselinePolicies["*"]?.autoApprove ?? true)
: false;
globalPolicy.autoApprove = input.enabled;
nextPolicies["*"] = globalPolicy;
for (const key of Object.keys(input.targetPolicies)) {
+9
View File
@@ -12,6 +12,8 @@ const createCore = vi.fn();
const getCliTelemetryService = vi.fn(() => undefined);
const resolveSessionBackend = vi.fn();
const listSessionHistoryFromBackend = vi.fn();
const featureFlagsPoll = vi.fn(async () => {});
const featureFlagsDispose = vi.fn(async () => {});
vi.mock("@cline/core", async () => {
const actual =
@@ -49,6 +51,10 @@ describe("createCliCore", () => {
listSessionHistoryFromBackend.mockReset();
createCore.mockResolvedValue({
runtimeAddress: "127.0.0.1:25463",
featureFlags: {
poll: featureFlagsPoll,
dispose: featureFlagsDispose,
},
start: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
@@ -68,6 +74,8 @@ describe("createCliCore", () => {
delete process.env.CLINE_RPC_ADDRESS;
delete process.env.CLINE_SESSION_BACKEND_MODE;
delete process.env.CLINE_VCR;
featureFlagsPoll.mockClear();
featureFlagsDispose.mockClear();
});
afterEach(() => {
@@ -108,6 +116,7 @@ describe("createCliCore", () => {
backendMode: expect.anything(),
}),
);
expect(featureFlagsPoll).toHaveBeenCalledTimes(1);
});
it("forces the local backend when requested by the caller", async () => {
+13 -1
View File
@@ -15,6 +15,7 @@ import {
createCliMessagesArtifactUploader,
prepareCliEnterpriseIntegration,
} from "../utils/enterprise";
import { getCliFeatureFlagsService } from "../utils/feature-flags";
import { resolveWorkspaceRoot } from "../utils/helpers";
import { getCliTelemetryService } from "../utils/telemetry";
import type { ConversationHistory } from "./export";
@@ -40,6 +41,11 @@ export async function createCliCore(options?: {
const cwd = options?.cwd?.trim() || process.cwd();
const workspaceRoot =
options?.workspaceRoot?.trim() || resolveWorkspaceRoot(cwd);
const telemetry = getCliTelemetryService(options?.logger);
const featureFlags = getCliFeatureFlagsService({
logger: options?.logger,
telemetry,
});
const core = await ClineCore.create({
...(explicitBackendMode ? { backendMode: explicitBackendMode } : {}),
...(options?.forceLocalBackend !== true
@@ -53,12 +59,18 @@ export async function createCliCore(options?: {
}
: {}),
capabilities: options?.capabilities,
telemetry: getCliTelemetryService(options?.logger),
telemetry,
featureFlags,
logger: options?.logger,
toolPolicies: options?.toolPolicies,
messagesArtifactUploader: createCliMessagesArtifactUploader(),
prepare: prepareCliEnterpriseIntegration,
});
try {
await core.featureFlags.poll();
} catch (error) {
options?.logger?.error?.("Error polling CLI feature flags", { error });
}
options?.logger?.log("CLI core runtime routing selected", {
backendMode: explicitBackendMode ?? "env-managed",
rpcAddress: core.runtimeAddress,
+136 -21
View File
@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Config } from "../utils/types";
const coreMocks = vi.hoisted(() => {
@@ -9,13 +9,20 @@ const coreMocks = vi.hoisted(() => {
return {
getProviderSettings: vi.fn(),
saveProviderSettings: vi.fn(),
getValidClineCredentials: vi.fn(),
fetchMe: vi.fn(),
fetchBalance: vi.fn(),
fetchOrganizationBalance: vi.fn(),
serviceOptions,
};
});
const telemetryMocks = vi.hoisted(() => ({
identifyTelemetryAccount: vi.fn(),
}));
vi.mock("@cline/core", () => {
vi.mock("@cline/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@cline/core")>();
return {
...actual,
ClineAccountService: class {
constructor(options: {
apiBaseUrl: string;
@@ -23,6 +30,15 @@ vi.mock("@cline/core", () => {
}) {
coreMocks.serviceOptions.push(options);
}
fetchMe() {
return coreMocks.fetchMe();
}
fetchBalance(userId?: string) {
return coreMocks.fetchBalance(userId);
}
fetchOrganizationBalance(organizationId: string) {
return coreMocks.fetchOrganizationBalance(organizationId);
}
},
ProviderSettingsManager: class {
getProviderSettings(providerId: string) {
@@ -32,10 +48,13 @@ vi.mock("@cline/core", () => {
coreMocks.saveProviderSettings(settings, options);
}
},
getValidClineCredentials: coreMocks.getValidClineCredentials,
};
});
vi.mock("../utils/telemetry", () => ({
identifyTelemetryAccount: telemetryMocks.identifyTelemetryAccount,
}));
function makeConfig(overrides: Partial<Config> = {}): Config {
return {
providerId: "cline",
@@ -59,15 +78,55 @@ function makeConfig(overrides: Partial<Config> = {}): Config {
} as unknown as Config;
}
function mockFetchJson(body: unknown, status = 200): void {
vi.stubGlobal(
"fetch",
vi.fn(
async () =>
new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json" },
}),
) as unknown as typeof fetch,
);
}
describe("createClineAccountService", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
coreMocks.getProviderSettings.mockReset();
coreMocks.saveProviderSettings.mockReset();
coreMocks.getValidClineCredentials.mockReset();
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("refreshes persisted Cline OAuth credentials before creating the account service", async () => {
vi.spyOn(Date, "now").mockReturnValue(100_000);
mockFetchJson({
success: true,
data: {
accessToken: "new-access",
refreshToken: "new-refresh",
tokenType: "Bearer",
expiresAt: "2096-10-02T07:06:40.000Z",
userInfo: {
subject: "sub-new",
email: "new@example.com",
name: "New User",
clineUserId: "acct-new",
accounts: [],
},
},
});
coreMocks.getProviderSettings.mockReturnValue({
provider: "cline",
auth: {
@@ -77,26 +136,12 @@ describe("createClineAccountService", () => {
expiresAt: 1,
},
});
coreMocks.getValidClineCredentials.mockResolvedValue({
access: "new-access",
refresh: "new-refresh",
expires: 4_000_000_000_000,
accountId: "acct-new",
});
const { createClineAccountService } = await import("./cline-account");
const service = await createClineAccountService({ config: makeConfig() });
expect(service).toBeDefined();
expect(coreMocks.getValidClineCredentials).toHaveBeenCalledWith(
{
access: "old-access",
refresh: "refresh-token",
expires: 1,
accountId: "acct-old",
},
{ apiBaseUrl: "https://api.cline.bot" },
);
expect(globalThis.fetch).toHaveBeenCalled();
expect(coreMocks.saveProviderSettings).toHaveBeenCalledWith(
expect.objectContaining({
provider: "cline",
@@ -115,6 +160,14 @@ describe("createClineAccountService", () => {
});
it("asks the user to re-authenticate when Cline OAuth credentials cannot refresh", async () => {
vi.spyOn(Date, "now").mockReturnValue(100_000);
mockFetchJson(
{
error: "invalid_grant",
error_description: "refresh expired",
},
401,
);
coreMocks.getProviderSettings.mockReturnValue({
provider: "cline",
auth: {
@@ -123,7 +176,6 @@ describe("createClineAccountService", () => {
expiresAt: 1,
},
});
coreMocks.getValidClineCredentials.mockResolvedValue(null);
const { createClineAccountService } = await import("./cline-account");
@@ -134,3 +186,66 @@ describe("createClineAccountService", () => {
);
});
});
describe("loadClineAccountSnapshot", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
coreMocks.getProviderSettings.mockReset();
coreMocks.saveProviderSettings.mockReset();
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("identifies the loaded Cline account for telemetry and feature flags", async () => {
coreMocks.getProviderSettings.mockReturnValue({
provider: "cline",
apiKey: "account-token",
});
const { loadClineAccountSnapshot } = await import("./cline-account");
coreMocks.fetchMe.mockResolvedValue({
id: "user-1",
email: "user@example.com",
displayName: "User One",
photoUrl: "",
createdAt: "",
updatedAt: "",
organizations: [
{
active: true,
memberId: "member-1",
name: "Acme",
organizationId: "org-1",
roles: ["member"],
},
],
});
coreMocks.fetchBalance.mockResolvedValue({ balance: 10, userId: "user-1" });
coreMocks.fetchOrganizationBalance.mockResolvedValue({
balance: 20,
organizationId: "org-1",
});
await loadClineAccountSnapshot({ config: makeConfig() });
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
{
id: "user-1",
email: "user@example.com",
provider: "cline",
organizationId: "org-1",
organizationName: "Acme",
memberId: "member-1",
},
expect.any(Object),
);
});
});
+74 -54
View File
@@ -4,18 +4,25 @@ import {
type ClineAccountOrganizationBalance,
ClineAccountService,
type ClineAccountUser,
formatProviderOAuthApiKey,
getPersistedProviderApiKey,
getProviderOAuthCredentialsFromSettings,
getValidClineCredentials,
type ProviderSettings,
ProviderSettingsManager,
saveLocalProviderOAuthCredentials,
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
import { toProviderApiKey } from "../utils/provider-auth";
import { identifyTelemetryAccount } from "../utils/telemetry";
import type { Config } from "../utils/types";
const WORKOS_TOKEN_PREFIX = "workos:";
export const CLINE_CREDITS_DASHBOARD_URL =
"https://app.cline.bot/dashboard/account?tab=credits";
type ClineAccountConfig = Pick<Config, "apiKey" | "providerId">;
type ClineAccountConfig = Pick<Config, "apiKey" | "logger" | "providerId">;
const CLINE_PASS_PROVIDER_ID = "cline-pass";
export interface ClineAccountSnapshot {
user: ClineAccountUser;
@@ -30,6 +37,8 @@ export function formatClineCredits(value: number): string {
return formatCreditBalance(normalizeCreditBalance(value));
}
// FIXME: These message checks are temporary until structured error types are
// passed through to the CLI instead of plain error strings.
export function isClineAccountAuthErrorMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
return (
@@ -38,6 +47,14 @@ export function isClineAccountAuthErrorMessage(message: string): boolean {
);
}
export function isClineAccountCreditsErrorMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
return (
normalized.includes("insufficient balance") &&
normalized.includes("cline credits balance")
);
}
function resolveAccountApiBaseUrl(input: {
clineApiBaseUrl?: string;
clineProviderSettings?: ProviderSettings;
@@ -57,26 +74,13 @@ function resolveClineAccountAuthToken(input: {
config: ClineAccountConfig;
clineProviderSettings?: ProviderSettings;
}): string | undefined {
const persistedAccessToken =
input.clineProviderSettings?.auth?.accessToken?.trim() || "";
const configApiKey =
input.config.providerId === "cline" ? input.config.apiKey.trim() : "";
const settingsApiKey =
input.clineProviderSettings?.apiKey?.trim() ||
input.clineProviderSettings?.auth?.apiKey?.trim() ||
"";
let authToken = persistedAccessToken || configApiKey || settingsApiKey;
if (authToken.toLowerCase().startsWith("workos:workos:")) {
authToken = authToken.slice("workos:".length);
}
return authToken || undefined;
}
function stripWorkosTokenPrefix(accessToken: string): string {
return accessToken.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
? accessToken.slice(WORKOS_TOKEN_PREFIX.length)
: accessToken;
return (
getPersistedProviderApiKey("cline", input.clineProviderSettings) ||
configApiKey ||
undefined
);
}
async function resolveValidClineAccountAuthToken(input: {
@@ -86,43 +90,26 @@ async function resolveValidClineAccountAuthToken(input: {
apiBaseUrl: string;
}): Promise<string | undefined> {
const settings = input.clineProviderSettings;
const auth = settings?.auth;
const accessToken = auth?.accessToken?.trim();
const refreshToken = auth?.refreshToken?.trim();
if (settings && auth && accessToken && refreshToken) {
const credentials = await getValidClineCredentials(
{
access: stripWorkosTokenPrefix(accessToken),
refresh: refreshToken,
expires: auth.expiresAt ?? Date.now() - 1,
accountId: auth.accountId,
},
{ apiBaseUrl: input.apiBaseUrl },
);
if (!credentials) {
const credentials = settings
? getProviderOAuthCredentialsFromSettings("cline", settings)
: null;
if (settings && credentials) {
const nextCredentials = await getValidClineCredentials(credentials, {
apiBaseUrl: input.apiBaseUrl,
});
if (!nextCredentials) {
throw new Error(
"Cline account requires re-authentication. Run cline auth cline.",
);
}
const nextAccessToken = toProviderApiKey("cline", credentials);
if (
nextAccessToken !== accessToken ||
credentials.refresh !== refreshToken ||
credentials.accountId !== auth.accountId ||
credentials.expires !== auth.expiresAt
) {
input.manager.saveProviderSettings(
{
...settings,
auth: {
...(settings.auth ?? {}),
accessToken: nextAccessToken,
refreshToken: credentials.refresh,
accountId: credentials.accountId,
expiresAt: credentials.expires,
},
},
{ setLastUsed: false, tokenSource: "oauth" },
const nextAccessToken = formatProviderOAuthApiKey("cline", nextCredentials);
if (nextCredentials !== credentials) {
saveLocalProviderOAuthCredentials(
input.manager,
"cline",
settings,
nextCredentials,
{ setLastUsed: false },
);
}
return nextAccessToken;
@@ -183,6 +170,15 @@ export async function loadClineAccountSnapshot(input: {
const displayedBalance = activeOrganization
? (organizationBalance?.balance ?? balance.balance)
: balance.balance;
const accountContext = {
id: user.id,
email: user.email,
provider: "cline",
organizationId: activeOrganization?.organizationId,
organizationName: activeOrganization?.name,
memberId: activeOrganization?.memberId,
};
identifyTelemetryAccount(accountContext, input.config.logger);
return {
user,
@@ -206,3 +202,27 @@ export async function switchClineAccount(input: {
}
await service.switchAccount(input.organizationId);
}
async function onChangeToClinePass(config: ClineAccountConfig) {
try {
await switchClineAccount({
config: config,
organizationId: null,
});
} catch (error) {
config.logger?.debug("Failed to switch ClinePass to personal account", {
error,
});
}
}
export async function onProviderChange(input: {
config: ClineAccountConfig;
providerId: string;
}): Promise<void> {
if (input.providerId === CLINE_PASS_PROVIDER_ID) {
return onChangeToClinePass(input.config);
}
return;
}
+110
View File
@@ -2,6 +2,16 @@ import { useTerminalDimensions } from "@opentui/react";
import type React from "react";
import { useState } from "react";
import "opentui-spinner/react";
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
import {
CLINE_CREDITS_DASHBOARD_URL,
isClineAccountCreditsErrorMessage,
} from "../cline-account";
import { useTerminalBackground } from "../hooks/use-terminal-background";
import {
getDefaultForeground,
@@ -256,6 +266,95 @@ function ToolCallView(props: {
);
}
function ClineCreditsErrorView(props: { defaultFg?: string }) {
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>
<text fg="cyan" selectable>
<a href={CLINE_CREDITS_DASHBOARD_URL}>
{CLINE_CREDITS_DASHBOARD_URL}
</a>
</text>
</box>
</box>
</box>
);
}
function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
const subscriptionUrl = getClinePassSubscriptionUrl();
return (
<box flexDirection="row">
<text fg="yellow" content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor="yellow"
paddingX={1}
>
<text fg="yellow">ClinePass subscription required</text>
<text
fg={props.defaultFg}
selectable
content="No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan."
/>
<box flexDirection="row">
<text fg="gray">Subscribe: </text>
<text fg="cyan" selectable>
<a href={subscriptionUrl}>Open subscription page</a>
</text>
</box>
<box flexDirection="row">
<text fg="gray">URL: </text>
<text fg="cyan" selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
</text>
</box>
</box>
</box>
);
}
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;
@@ -351,6 +450,17 @@ export function ChatEntryView(props: {
);
case "error":
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 (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -58,7 +58,11 @@ describe("mcp manager dialog helpers", () => {
};
afterEach(async () => {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
}
await Promise.all(
tempRoots.map((directory) =>
rm(directory, { recursive: true, force: true }),
@@ -107,6 +111,44 @@ describe("mcp manager dialog helpers", () => {
).toBeUndefined();
});
it("does not toggle plugin-owned servers", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-mcp-manager-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
docs: {
transport: {
type: "stdio",
command: "node",
},
},
},
},
null,
2,
)}\n`,
);
const result = toggleMcpServer({
name: "docs",
path: settingsPath,
enabled: true,
pluginName: "repo-docs",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.message).toContain('managed by plugin "repo-docs"');
}
expect((await readSettings(settingsPath)).mcpServers?.docs?.disabled).toBe(
undefined,
);
});
it("returns a visible error message when toggling fails", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-mcp-manager-"));
tempRoots.push(tempRoot);
@@ -13,6 +13,7 @@ export interface McpEntry {
enabled?: boolean;
description?: string;
lastError?: string;
pluginName?: string;
}
export type McpServerToggleResult =
@@ -36,6 +37,12 @@ export function getMcpManagerEntryStatus(
}
export function toggleMcpServer(server: McpEntry): McpServerToggleResult {
if (server.pluginName) {
return {
ok: false,
message: `MCP server "${server.name}" is managed by plugin "${server.pluginName}". Disable the plugin to disable this server.`,
};
}
try {
const currentlyEnabled = server.enabled !== false;
setMcpServerDisabled({
@@ -71,6 +78,7 @@ export function McpManagerContent(
const settingsPath = servers[0]?.path ?? resolveDefaultMcpSettingsPath();
const itemCount = servers.length;
const selectedServer = servers[selected];
const hasPluginOwnedServers = servers.some((server) => server.pluginName);
useDialogKeyboard((key) => {
if (key.name === "escape") {
@@ -150,6 +158,7 @@ export function McpManagerContent(
{isSel ? "\u25b8 " : " "}
{enabledIcon}
{srv.name}
{srv.pluginName ? " *" : ""}
</text>
{status && (
<text fg={srv.lastError ? palette.error : "gray"}>
@@ -184,6 +193,12 @@ export function McpManagerContent(
</box>
)}
{hasPluginOwnedServers && (
<text fg="gray" marginTop={1}>
* managed by plugin; disable the plugin to disable the server.
</text>
)}
<text fg="gray" marginTop={1}>
<em>{getMcpManagerFooterText(servers.length > 0)}</em>
</text>
@@ -1,7 +1,7 @@
import {
completeClineDeviceAuth,
getProviderConfigFields,
listLocalProviders,
isOAuthProvider,
loginLocalProvider,
type ProviderConfigFieldKey,
type ProviderConfigFieldRequirement,
@@ -21,12 +21,14 @@ import {
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
import { isOAuthProvider } from "../../../utils/provider-auth";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { palette } from "../../palette";
import {
getDefaultAwsRegion,
type ProviderConfigValues,
resolveProviderConfigAwsRegion,
resolveProviderConfigAzure,
resolveProviderConfigGcp,
resolveProviderConfigSap,
updateProviderConfigValue,
} from "../../utils/provider-config-values";
@@ -315,8 +317,11 @@ export function UseExistingOrReconfigureContent(
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
apiKey: "API key",
baseUrl: "Base URL",
azureApiVersion: "Azure API Version",
awsRegion: "AWS Region",
awsProfile: "AWS Profile Name",
gcpProjectId: "Google Cloud Project ID",
gcpRegion: "Google Cloud Region",
sapClientId: "Client ID",
sapClientSecret: "Client Secret",
sapTokenUrl: "Token URL",
@@ -329,8 +334,11 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
> = {
apiKey: "sk-...",
baseUrl: "",
azureApiVersion: "2025-01-01-preview",
awsRegion: "us-east-1",
awsProfile: "default",
gcpProjectId: "my-gcp-project",
gcpRegion: "us-central1",
sapClientId: "sb-...|xsuaa_std!b...",
sapClientSecret: "SAP AI Core client secret",
sapTokenUrl: "https://<subdomain>.authentication.sap.hana.ondemand.com",
@@ -341,7 +349,10 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
/** Render order for cycling focus with Tab. */
const FIELD_ORDER: ProviderConfigFieldKey[] = [
"awsRegion",
"gcpProjectId",
"gcpRegion",
"baseUrl",
"azureApiVersion",
"apiKey",
"awsProfile",
"sapClientId",
@@ -398,11 +409,22 @@ export function ProviderConfigInputContent(
config.fields.baseUrl?.defaultValue ??
"";
}
if (config.fields.azureApiVersion) {
initial.azureApiVersion =
existingSettings?.azure?.apiVersion?.trim() ?? "";
}
if (config.fields.awsRegion) {
const ep = existingSettings?.aws?.profile?.trim() ?? "";
initial.awsRegion =
existingSettings?.aws?.region?.trim() || getDefaultAwsRegion(ep);
}
if (config.fields.gcpProjectId)
initial.gcpProjectId = existingSettings?.gcp?.projectId?.trim() ?? "";
if (config.fields.gcpRegion)
initial.gcpRegion =
existingSettings?.gcp?.region?.trim() ??
config.fields.gcpRegion.defaultValue ??
"us-central1";
if (config.fields.apiKey)
initial.apiKey = existingSettings?.apiKey?.trim() ?? "";
if (config.fields.awsProfile)
@@ -430,7 +452,9 @@ export function ProviderConfigInputContent(
const submit = () => {
const apiKey = values.apiKey?.trim();
const awsProfile = values.awsProfile?.trim();
const hasAzureFields = config.fields.azureApiVersion;
const hasAwsFields = config.fields.awsRegion || config.fields.awsProfile;
const hasGcpFields = config.fields.gcpProjectId || config.fields.gcpRegion;
const hasSapFields =
config.fields.sapClientId ||
config.fields.sapClientSecret ||
@@ -441,6 +465,7 @@ export function ProviderConfigInputContent(
providerId,
apiKey: config.fields.apiKey ? apiKey : undefined,
baseUrl: config.fields.baseUrl ? values.baseUrl?.trim() : undefined,
azure: hasAzureFields ? resolveProviderConfigAzure(values) : undefined,
aws: hasAwsFields
? {
region: resolveProviderConfigAwsRegion(values),
@@ -448,6 +473,7 @@ export function ProviderConfigInputContent(
profile: apiKey ? undefined : awsProfile || undefined,
}
: undefined,
gcp: hasGcpFields ? resolveProviderConfigGcp(values) : undefined,
sap: hasSapFields ? resolveProviderConfigSap(values) : undefined,
});
resolve(true);
@@ -671,7 +697,7 @@ export function OAuthLoginContent(
if (!isActiveAuthAttempt(attempt)) return;
saveLocalProviderOAuthCredentials(
manager,
providerId as "cline" | "oca" | "openai-codex",
providerId,
existing,
credentials,
);
@@ -705,30 +731,24 @@ export function OAuthLoginContent(
const manager = new ProviderSettingsManager();
const existing = manager.getProviderSettings(providerId);
loginLocalProvider(
providerId as "cline" | "oca" | "openai-codex",
existing,
(url: string) => {
setAuthUrl(url);
setStatus("Waiting for authentication in browser...");
try {
void open(url, { wait: false }).catch(() => {
setStatus(
"Could not open browser automatically. Open the URL below.",
);
});
} catch {
loginLocalProvider(providerId, existing, (url: string) => {
setAuthUrl(url);
setStatus("Waiting for authentication in browser...");
try {
void open(url, { wait: false }).catch(() => {
setStatus(
"Could not open browser automatically. Open the URL below.",
);
}
},
)
});
} catch {
setStatus("Could not open browser automatically. Open the URL below.");
}
})
.then((credentials) => {
if (!isActiveAuthAttempt(attempt)) return;
saveLocalProviderOAuthCredentials(
manager,
providerId as "cline" | "oca" | "openai-codex",
providerId,
existing,
credentials,
);
@@ -1,5 +1,6 @@
import type { ScrollBoxRenderable } from "@opentui/core";
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
import { useCallback, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { palette } from "../palette";
import type { RuntimeToolInteraction } from "../types";
import { formatApprovalParams } from "./dialogs/tool-approval";
@@ -22,23 +23,129 @@ function keyToText(name: string): string {
return name === "space" ? " " : name;
}
function getToolShellMaxHeight(terminalHeight: number): number {
return Math.max(7, Math.min(14, Math.floor(terminalHeight * 0.38)));
}
function getAskQuestionShellMaxHeight(terminalHeight: number): number {
const preferredHeight = Math.max(11, Math.floor(terminalHeight * 0.58));
const availableHeight = Math.max(7, terminalHeight - 3);
return Math.min(18, preferredHeight, availableHeight);
}
function getAskQuestionBodyHeight(shellMaxHeight: number): number {
return Math.max(1, shellMaxHeight - 4);
}
function addWrappedWidth(input: {
rows: number;
lineWidth: number;
width: number;
maxWidth: number;
}): { rows: number; lineWidth: number } {
if (input.width <= 0) {
return { rows: input.rows, lineWidth: input.lineWidth };
}
let rows = input.rows;
let remainingWidth = input.width;
let lineWidth = input.lineWidth;
if (lineWidth > 0) {
const availableWidth = input.maxWidth - lineWidth;
if (remainingWidth <= availableWidth) {
return { rows, lineWidth: lineWidth + remainingWidth };
}
remainingWidth -= Math.max(0, availableWidth);
rows += 1;
lineWidth = 0;
}
rows += Math.max(0, Math.ceil(remainingWidth / input.maxWidth) - 1);
lineWidth = remainingWidth % input.maxWidth || input.maxWidth;
return { rows, lineWidth };
}
function countWrappedRows(text: string, width: number): number {
const safeWidth = Math.max(1, width);
const paragraphs = text.split("\n");
let rows = 0;
for (const paragraph of paragraphs) {
rows += 1;
let lineWidth = 0;
const tokens = paragraph.match(/\s+|\S+/g) ?? [];
for (const token of tokens) {
const tokenWidth = Bun.stringWidth(token);
const isWhitespace = /^\s+$/.test(token);
if (
!isWhitespace &&
lineWidth > 0 &&
lineWidth + tokenWidth > safeWidth
) {
rows += 1;
lineWidth = 0;
}
const next = addWrappedWidth({
rows,
lineWidth,
width: tokenWidth,
maxWidth: safeWidth,
});
rows = next.rows;
lineWidth = next.lineWidth;
}
}
return rows;
}
function getAskQuestionContentHeight(input: {
terminalWidth: number;
question: string;
options: string[];
customText: string;
}): number {
const questionWidth = Math.max(1, input.terminalWidth - 3);
const optionTextWidth = Math.max(1, input.terminalWidth - 7);
const questionRows = countWrappedRows(input.question, questionWidth);
const optionRows = input.options.reduce(
(rows, option) => rows + countWrappedRows(option, optionTextWidth),
0,
);
const customRows = countWrappedRows(input.customText, optionTextWidth);
return questionRows + 1 + optionRows + customRows;
}
function getAskQuestionChoiceId(interactionId: number, index: number): string {
return `ask-question-${interactionId.toString()}-choice-${index.toString()}`;
}
function Shell(
props: Pick<
InlineToolResponseProps,
"accent" | "inputBackground" | "inputForeground"
> & {
title: string;
maxHeight?: number;
overflow?: "hidden";
children: React.ReactNode;
},
) {
const { height } = useTerminalDimensions();
const maxHeight = Math.max(7, Math.min(14, Math.floor(height * 0.38)));
const maxHeight = props.maxHeight ?? getToolShellMaxHeight(height);
return (
<box
flexDirection="column"
width="100%"
maxHeight={maxHeight}
overflow={props.overflow}
backgroundColor={props.inputBackground}
paddingX={1}
paddingY={1}
@@ -59,6 +166,7 @@ function ChoiceButton(props: {
onPress: () => void;
}) {
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input.
<box
paddingX={1}
backgroundColor={props.selected ? palette.selection : undefined}
@@ -155,9 +263,11 @@ function AskQuestionResponse(
},
) {
const { interaction } = props;
const { height, width } = useTerminalDimensions();
const [selected, setSelected] = useState(0);
const [customValue, setCustomValue] = useState("");
const [customEmptyAttempted, setCustomEmptyAttempted] = useState(false);
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
const selectedRef = useRef(0);
const customValueRef = useRef("");
const interactionId = interaction.id;
@@ -165,6 +275,24 @@ function AskQuestionResponse(
const customIndex = interaction.options.length;
const isTyping = selected === customIndex;
const totalChoices = interaction.options.length + 1;
const shellMaxHeight = getAskQuestionShellMaxHeight(height);
const maxBodyHeight = getAskQuestionBodyHeight(shellMaxHeight);
const customText = isTyping
? customValue
? `${customValue}|`
: customEmptyAttempted
? "Type a response first..."
: "Type a response..."
: "Type a response...";
const bodyHeight = Math.min(
maxBodyHeight,
getAskQuestionContentHeight({
terminalWidth: width,
question: interaction.question,
options: interaction.options,
customText,
}),
);
const selectIndex = useCallback(
(index: number) => {
@@ -192,6 +320,26 @@ function AskQuestionResponse(
[interactionId, onResolveAskQuestion],
);
useEffect(() => {
const choiceId = getAskQuestionChoiceId(interactionId, selected);
let canceled = false;
const scrollSelectedChoiceIntoView = () => {
if (canceled) {
return;
}
scrollRef.current?.scrollChildIntoView(choiceId);
};
scrollSelectedChoiceIntoView();
queueMicrotask(scrollSelectedChoiceIntoView);
const timeout = setTimeout(scrollSelectedChoiceIntoView, 0);
return () => {
canceled = true;
clearTimeout(timeout);
};
}, [interactionId, selected]);
useKeyboard((key) => {
const typing = selectedRef.current === customIndex;
if (key.name === "escape") {
@@ -261,64 +409,91 @@ function AskQuestionResponse(
accent={props.accent}
inputBackground={props.inputBackground}
inputForeground={props.inputForeground}
maxHeight={shellMaxHeight}
overflow="hidden"
>
<text fg={props.inputForeground} selectable>
{interaction.question}
</text>
<scrollbox
ref={scrollRef}
height={bodyHeight}
width="100%"
scrollY
scrollX={false}
viewportOptions={{ overflow: "hidden" }}
contentOptions={{ flexDirection: "column" }}
>
<box flexDirection="column" gap={1} flexShrink={0} width="100%">
<text fg={props.inputForeground} selectable flexShrink={0}>
{interaction.question}
</text>
<box flexDirection="column">
{interaction.options.map((option, index) => {
const optionSelected = !isTyping && selected === index;
return (
<box flexDirection="column" flexShrink={0} width="100%">
{interaction.options.map((option, index) => {
const optionSelected = !isTyping && selected === index;
return (
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input.
<box
id={getAskQuestionChoiceId(interactionId, index)}
key={`${index.toString()}:${option}`}
paddingX={1}
flexDirection="row"
gap={1}
flexShrink={0}
width="100%"
backgroundColor={
optionSelected ? palette.selection : undefined
}
onMouseDown={() => resolveAnswer(option)}
>
<text
fg={optionSelected ? palette.textOnSelection : "gray"}
flexShrink={0}
>
{optionSelected ? ">" : " "}
</text>
<text
fg={
optionSelected
? palette.textOnSelection
: props.inputForeground
}
flexGrow={1}
flexShrink={1}
>
{option}
</text>
</box>
);
})}
{/* biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input. */}
<box
key={`${index.toString()}:${option}`}
id={getAskQuestionChoiceId(interactionId, customIndex)}
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={optionSelected ? palette.selection : undefined}
onMouseDown={() => resolveAnswer(option)}
flexShrink={0}
width="100%"
backgroundColor={isTyping ? palette.selection : undefined}
onMouseDown={() => selectIndex(customIndex)}
>
<text
fg={optionSelected ? palette.textOnSelection : "gray"}
fg={isTyping ? palette.textOnSelection : "gray"}
flexShrink={0}
>
{optionSelected ? ">" : " "}
</text>
<text
fg={
optionSelected
? palette.textOnSelection
: props.inputForeground
}
>
{option}
{isTyping ? ">" : " "}
</text>
{isTyping ? (
<text fg={palette.textOnSelection} flexGrow={1} flexShrink={1}>
{customText}
</text>
) : (
<text fg={props.inputPlaceholder} flexGrow={1} flexShrink={1}>
Type a response...
</text>
)}
</box>
);
})}
<box
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={isTyping ? palette.selection : undefined}
onMouseDown={() => selectIndex(customIndex)}
>
<text fg={isTyping ? palette.textOnSelection : "gray"} flexShrink={0}>
{isTyping ? ">" : " "}
</text>
{isTyping ? (
<text fg={palette.textOnSelection} flexGrow={1}>
{customValue
? `${customValue}|`
: customEmptyAttempted
? "Type a response first..."
: "Type a response..."}
</text>
) : (
<text fg={props.inputPlaceholder}>Type a response...</text>
)}
</box>
</box>
</box>
</scrollbox>
</Shell>
);
}
@@ -134,6 +134,7 @@ export function ModelSelectorContent(
currentModel: string;
currentProviderName: string;
models: ModelOption[];
showCustomModelId?: boolean;
},
) {
const {
@@ -143,6 +144,7 @@ export function ModelSelectorContent(
currentModel,
currentProviderName,
models,
showCustomModelId = true,
} = props;
const [search, setSearch] = useState("");
const [selected, setSelected] = useState(() => {
@@ -164,7 +166,7 @@ export function ModelSelectorContent(
return scored.map((r) => r.model);
}, [models, search]);
const optionCount = filtered.length + 1;
const optionCount = filtered.length + (showCustomModelId ? 1 : 0);
const safeSelected = Math.min(selected, Math.max(0, optionCount - 1));
useDialogKeyboard((key) => {
@@ -188,7 +190,7 @@ export function ModelSelectorContent(
resolve(model.key);
return;
}
if (safeSelected === filtered.length) {
if (showCustomModelId && safeSelected === filtered.length) {
setIsCreatingCustomModel(true);
setCustomModelId("");
setCustomModelError("");
@@ -290,6 +292,7 @@ export function ModelSelectorContent(
dimmed={onProvider}
currentModel={currentModel}
onSelect={resolve}
showCustomModelId={showCustomModelId}
onCreateCustomModel={() => {
setIsCreatingCustomModel(true);
setCustomModelId("");
@@ -408,6 +411,7 @@ function ModelList(props: {
dimmed?: boolean;
currentModel: string;
onSelect: (key: string) => void;
showCustomModelId: boolean;
onCreateCustomModel: () => void;
}) {
const {
@@ -416,11 +420,12 @@ function ModelList(props: {
dimmed,
currentModel,
onSelect,
showCustomModelId,
onCreateCustomModel,
} = props;
const rows: ({ type: "model"; model: ModelOption } | { type: "custom" })[] = [
...items.map((model) => ({ type: "model" as const, model })),
{ type: "custom" as const },
...(showCustomModelId ? ([{ type: "custom" as const }] as const) : []),
];
if (rows.length <= MAX_VISIBLE) {
@@ -57,7 +57,7 @@ describe("formatStatusBarUsageText", () => {
totalCost: 0.123,
showCost: true,
}),
).toBe("(12,345) $0.12");
).toBe("(12,345 tokens) $0.12");
});
it("omits cost when usage cost is hidden", () => {
@@ -67,6 +67,6 @@ describe("formatStatusBarUsageText", () => {
totalCost: 0.123,
showCost: false,
}),
).toBe("(12,345)");
).toBe("(12,345 tokens)");
});
});
+1 -1
View File
@@ -51,7 +51,7 @@ export function formatStatusBarUsageText(input: {
totalCost: number;
showCost: boolean;
}): string {
const tokens = `(${input.totalTokens.toLocaleString()})`;
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
if (!input.showCost) return tokens;
return `${tokens} ${formatCost(input.totalCost)}`;
}
@@ -106,9 +106,9 @@ export function SessionProvider(props: {
const [uiMode, setUiMode] = useState<AgentMode>(
config.mode === "plan" ? "plan" : "act",
);
const [autoApproveAll, _setAutoApproveAll] = useState(
config.toolPolicies["*"]?.autoApprove !== false,
);
const initialAutoApproveAll = config.toolPolicies["*"]?.autoApprove !== false;
const autoApproveAllRef = useRef(initialAutoApproveAll);
const [autoApproveAll, _setAutoApproveAll] = useState(initialAutoApproveAll);
const [compactionMode, _setCompactionMode] = useState<CliCompactionMode>(() =>
getCliCompactionMode(config),
);
@@ -192,11 +192,10 @@ export function SessionProvider(props: {
}, []);
const toggleAutoApprove = useCallback(() => {
_setAutoApproveAll((prev) => {
const next = !prev;
onAutoApproveChange(next);
return next;
});
const next = !autoApproveAllRef.current;
autoApproveAllRef.current = next;
onAutoApproveChange(next);
_setAutoApproveAll(next);
}, [onAutoApproveChange]);
const setCompactionMode = useCallback(
+21 -14
View File
@@ -17,7 +17,9 @@ export async function renderHistoryStandalone(input: {
});
return new Promise((resolve) => {
let settled = false;
let result: number | string = 0;
let resolved = false;
let destroyStarted = false;
let unmounted = false;
const root = createRoot(renderer);
@@ -29,24 +31,29 @@ export async function renderHistoryStandalone(input: {
root.unmount();
};
const settle = (value: number | string) => {
if (settled) {
return;
}
settled = true;
unmountRoot();
renderer.destroy();
resolve(value);
};
// Resolve only once teardown has finished, so callers never run while
// the renderer is still restoring the terminal.
renderer.on("destroy", () => {
unmountRoot();
if (!settled) {
settled = true;
resolve(0);
if (!resolved) {
resolved = true;
resolve(result);
}
});
const settle = (value: number | string) => {
if (destroyStarted) {
return;
}
destroyStarted = true;
result = value;
unmountRoot();
// Let OpenTUI finish parsing the current stdin batch before teardown.
queueMicrotask(() => {
renderer.destroy();
});
};
root.render(
React.createElement(HistoryStandaloneContent, {
rows: input.rows,
+5 -1
View File
@@ -4,6 +4,7 @@ import type {
PendingPromptSnapshot,
PendingPromptSubmittedEvent,
} from "../../runtime/session-events";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { resolveStatusNoticeLabel } from "../../utils/events";
import {
formatToolInput,
@@ -171,7 +172,10 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
turnErrorReportedRef.current = true;
onTurnErrorReported(true);
if (!event.recoverable || verbose) {
appendEntry({ kind: "error", text: event.error.message });
appendEntry({
kind: "error",
text: formatCliErrorMessage(event.error),
});
}
break;
case "notice":
@@ -17,6 +17,7 @@ function toMcpEntries(items: InteractiveConfigItem[]): McpEntry[] {
enabled: item.enabled,
description: item.description,
lastError: item.loadError,
pluginName: item.pluginName,
}));
}
@@ -179,7 +179,6 @@ async function runProviderChange(
config.providerId = newProviderId;
config.apiKey = newApiKey;
const resolved = await resolveProviderConfig(
newProviderId,
{
@@ -324,6 +323,7 @@ export function useModelSelector(opts: {
currentModel={config.modelId}
currentProviderName={providerDisplayName}
models={modelOptions}
showCustomModelId={config.providerId !== "cline-pass"}
/>
),
});
@@ -414,6 +414,7 @@ export function useModelSelector(opts: {
currentModel={config.modelId}
currentProviderName={providerDisplayName}
models={modelOptions}
showCustomModelId={config.providerId !== "cline-pass"}
/>
),
});
@@ -1,4 +1,5 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
import type { SlashCommandRegistry } from "../commands/slash-command-registry";
import {
@@ -327,6 +328,14 @@ export function usePromptInputController(input: {
}
const startedAt = performance.now();
let commandOutputAppended = false;
const appendCommandOutput = (text: string) => {
commandOutputAppended = true;
session.appendEntry({
kind: "status",
text,
});
};
try {
const result = await onSubmit(
promptForSubmit,
@@ -335,8 +344,9 @@ export function usePromptInputController(input: {
activeUserImages.length > 0
? { userImages: activeUserImages }
: undefined,
appendCommandOutput,
);
if (result.commandOutput) {
if (result.commandOutput && !commandOutputAppended) {
session.appendEntry({
kind: "status",
text: result.commandOutput,
@@ -367,7 +377,7 @@ export function usePromptInputController(input: {
if (!turnErrorReportedRef.current) {
session.appendEntry({
kind: "error",
text: error instanceof Error ? error.message : String(error),
text: formatCliErrorMessage(error),
});
}
} finally {
+23 -5
View File
@@ -86,6 +86,7 @@ export interface InteractiveConfigData {
mcp: InteractiveConfigItem[];
tools: InteractiveConfigItem[];
workflowSlashCommands: InteractiveSlashCommand[];
pluginDiagnosticsLoaded?: boolean;
}
export interface LoadInteractiveConfigDataOptions {
@@ -93,12 +94,14 @@ export interface LoadInteractiveConfigDataOptions {
}
export function isToggleableInteractiveConfigItem(
item: Pick<InteractiveConfigItem, "kind" | "source">,
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
): boolean {
if (item.kind === "mcp") {
return !item.pluginName;
}
return (
item.kind === "skill" ||
item.kind === "plugin" ||
item.kind === "mcp" ||
item.source === "builtin" ||
item.source === "workspace-plugin" ||
item.source === "global-plugin"
@@ -242,9 +245,10 @@ function readPackageName(packageJsonPath: string): string | undefined {
}
}
function getPluginDisplayName(filePath: string): string {
function getPluginDisplayName(filePath: string, searchRoot: string): string {
let current = dirname(filePath);
for (let depth = 0; depth < 4; depth++) {
const root = resolve(searchRoot);
while (isPathWithin(root, current)) {
const packageJsonPath = join(current, "package.json");
if (existsSync(packageJsonPath)) {
const packageName = readPackageName(packageJsonPath);
@@ -384,7 +388,7 @@ export async function loadInteractiveConfigData(input: {
for (const filePath of discoverPluginModulePaths(directory)) {
plugins.push({
id: filePath,
name: getPluginDisplayName(filePath),
name: getPluginDisplayName(filePath, directory),
path: filePath,
enabled: !disabledPlugins.has(filePath),
kind: "plugin",
@@ -458,6 +462,16 @@ export async function loadInteractiveConfigData(input: {
for (const registration of resolveMcpServerRegistrations({
filePath: mcpSettingsPath,
})) {
const pluginName =
registration.metadata?.source === "plugin" &&
typeof registration.metadata.pluginName === "string"
? registration.metadata.pluginName
: undefined;
const pluginPath =
registration.metadata?.source === "plugin" &&
typeof registration.metadata.pluginPath === "string"
? registration.metadata.pluginPath
: undefined;
mcp.push({
id: registration.name,
name: registration.name,
@@ -467,6 +481,8 @@ export async function loadInteractiveConfigData(input: {
source: detectSource(mcpSettingsPath, input.workspaceRoot),
description: getMcpDescription(registration),
loadError: registration.oauth?.lastError,
pluginName,
pluginPath,
});
}
} catch {
@@ -514,6 +530,7 @@ export async function loadInteractiveConfigData(input: {
toolNames: [pluginTool.name],
configKind: "tool",
pluginName: pluginTool.pluginName,
pluginPath: pluginTool.path,
source: pluginTool.source,
description: pluginTool.description,
});
@@ -533,5 +550,6 @@ export async function loadInteractiveConfigData(input: {
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
tools: toSorted(tools),
workflowSlashCommands,
pluginDiagnosticsLoaded: input.includePluginTools !== false,
};
}
+1
View File
@@ -921,6 +921,7 @@ function App(props: TuiProps) {
if (result.reasoningEffort !== undefined) {
props.config.reasoningEffort = result.reasoningEffort;
}
handleModelChange().then(() => setAppView("home"));
}}
onExit={() => {
+1
View File
@@ -152,6 +152,7 @@ export interface TuiProps {
mode: AgentMode,
delivery?: "queue" | "steer",
attachments?: UserInputAttachments,
onCommandOutput?: (text: string) => void,
) => Promise<InteractiveTurnResult>;
onUpdatePendingPrompt: (input: {
promptId: string;
+3 -1
View File
@@ -107,12 +107,13 @@ describe("copyTextToSystemClipboard", () => {
expect(spawnMock).toHaveBeenNthCalledWith(1, "wl-copy", [], {
stdio: ["pipe", "ignore", "ignore"],
windowsHide: true,
});
expect(spawnMock).toHaveBeenNthCalledWith(
2,
"xclip",
["-selection", "clipboard"],
{ stdio: ["pipe", "ignore", "ignore"] },
{ stdio: ["pipe", "ignore", "ignore"], windowsHide: true },
);
expect(failed.getInput()).toBe("selected text");
expect(succeeded.getInput()).toBe("selected text");
@@ -134,6 +135,7 @@ describe("copyTextToSystemClipboard", () => {
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith("wl-copy", [], {
stdio: ["pipe", "ignore", "ignore"],
windowsHide: true,
});
expect(wlcopy.getInput()).toBe("plain linux");
});
+2
View File
@@ -142,6 +142,8 @@ function runClipboardCommand(
const child = spawn(command.command, command.args, {
stdio: ["pipe", "ignore", "ignore"],
...(command.env ? { env: command.env } : {}),
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
let settled = false;
+2
View File
@@ -115,6 +115,8 @@ async function runCommand(
return await new Promise((resolve) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "ignore"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
const chunks: Buffer[] = [];
let total = 0;
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it } from "vitest";
import {
getDefaultAwsRegion,
resolveProviderConfigAwsRegion,
resolveProviderConfigAzure,
resolveProviderConfigGcp,
resolveProviderConfigSap,
updateProviderConfigValue,
} from "./provider-config-values";
@@ -66,6 +68,18 @@ describe("provider config values", () => {
).toBe("us-west-2");
});
it("resolves Vertex GCP field values into GCP settings", () => {
expect(
resolveProviderConfigGcp({ gcpRegion: "us-central1" }),
).toBeUndefined();
expect(
resolveProviderConfigGcp({
gcpProjectId: " project ",
gcpRegion: " europe-west4 ",
}),
).toEqual({ projectId: "project", region: "europe-west4" });
});
it("resolves SAP AI Core field values into SAP settings", () => {
expect(
resolveProviderConfigSap({
@@ -83,4 +97,24 @@ describe("provider config values", () => {
deploymentId: "deployment",
});
});
it("resolves Azure API version into Azure settings", () => {
expect(
resolveProviderConfigAzure({
azureApiVersion: " 2025-01-01-preview ",
}),
).toEqual({
apiVersion: "2025-01-01-preview",
});
});
it("keeps blank Azure API version so persisted settings can be cleared", () => {
expect(
resolveProviderConfigAzure({
azureApiVersion: " ",
}),
).toEqual({
apiVersion: "",
});
});
});
@@ -6,6 +6,7 @@ export type ProviderConfigValues = Partial<
>;
const DEFAULT_AWS_REGION = "us-east-1";
const DEFAULT_GCP_REGION = "us-central1";
export function getDefaultAwsRegion(profile?: string): string {
return (
@@ -20,6 +21,20 @@ export function resolveProviderConfigAwsRegion(
return values.awsRegion?.trim() || getDefaultAwsRegion(values.awsProfile);
}
export function resolveProviderConfigGcp(values: ProviderConfigValues):
| {
projectId?: string;
region?: string;
}
| undefined {
const projectId = values.gcpProjectId?.trim() || undefined;
if (!projectId) return undefined;
return {
projectId,
region: values.gcpRegion?.trim() || DEFAULT_GCP_REGION,
};
}
export function resolveProviderConfigSap(values: ProviderConfigValues):
| {
clientId?: string;
@@ -41,6 +56,12 @@ export function resolveProviderConfigSap(values: ProviderConfigValues):
: undefined;
}
export function resolveProviderConfigAzure(values: ProviderConfigValues): {
apiVersion?: string;
} {
return { apiVersion: values.azureApiVersion?.trim() ?? "" };
}
export function updateProviderConfigValue(
previous: ProviderConfigValues,
field: ProviderConfigFieldKey,
@@ -229,3 +229,15 @@ export function getConfigFooterText({
export function getConfigItemDisplayName(name: string): string {
return name;
}
export function getPluginDiagnosticsLoadingText(
tab: InteractiveConfigTab,
): string | undefined {
if (tab === "tools") {
return "Loading plugin tools...";
}
if (tab === "plugins") {
return "Loading plugin diagnostics...";
}
return undefined;
}
@@ -59,6 +59,18 @@ describe("config view helpers", () => {
expect(isToggleableConfigItem(createItem({ kind: "mcp" }))).toBe(true);
});
it("does not treat plugin MCP rows as toggleable", () => {
expect(
isToggleableConfigItem(
createItem({
kind: "mcp",
pluginName: "plugin",
source: "workspace-plugin",
}),
),
).toBe(false);
});
it("resolves Enter/Tab on a skill row to details", () => {
const skill = createItem({
kind: "skill",
+46 -15
View File
@@ -1,3 +1,4 @@
import { readGlobalSettings, setAutoUpdateEnabledGlobally } from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
@@ -24,6 +25,7 @@ import {
getConfigFooterText,
getConfigItemDisplayName,
getConfigTabs,
getPluginDiagnosticsLoadingText,
isInlineConfigAction,
isToggleableConfigItem,
resolveActiveConfigItems,
@@ -197,6 +199,7 @@ function appendToolGroupRows(
rightLabel: `${enabledCount}/${groupItems.length} tools enabled`,
indent: 2,
});
for (const item of sortBySourceThenName(groupItems)) {
rows.push({
kind: "ext",
@@ -244,17 +247,24 @@ function appendToolRows(
appendExtRows(rows, builtinTools);
}
const pluginGroups = groupToolItems(items.filter((item) => item.pluginName));
const pluginToolItems = items.filter((item) => item.pluginName);
const pluginGroups = groupToolItems(pluginToolItems);
if (pluginGroups.length > 0) {
rows.push({ kind: "head", label: "Plugins" });
appendToolGroupRows(
rows,
pluginGroups,
getSharedToolNames(items.filter((item) => item.pluginName)),
getSharedToolNames(pluginToolItems),
);
}
}
function hasPluginDiagnostics(data: InteractiveConfigData): boolean {
return (
data.pluginDiagnosticsLoaded || data.tools.some((item) => item.pluginName)
);
}
function appendSkillRows(
rows: ConfigRow[],
items: InteractiveConfigItem[],
@@ -304,11 +314,18 @@ function withOptimisticToggle(
).filter(Boolean),
);
const updateItems = (items: InteractiveConfigItem[]) =>
items.map((candidate) =>
matchesItem(candidate)
? { ...candidate, enabled: nextEnabled }
: candidate,
);
items.map((candidate) => {
if (matchesItem(candidate)) {
return { ...candidate, enabled: nextEnabled };
}
if (
item.kind === "plugin" &&
(candidate.path === item.path || candidate.pluginPath === item.path)
) {
return { ...candidate, enabled: nextEnabled };
}
return candidate;
});
const updateTools = (items: InteractiveConfigItem[]) =>
items.map((candidate) => {
if (matchesItem(candidate)) {
@@ -368,6 +385,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
const [autoApprove, setAutoApprove] = useState(
config.toolPolicies["*"]?.autoApprove !== false,
);
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(
() => readGlobalSettings().autoUpdateEnabled,
);
const [verbose, setVerbose] = useState(config.verbose);
const [compactionMode, setCompactionMode] = useState(
props.currentCompactionMode,
@@ -377,7 +397,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
);
const [configData, setConfigData] = useState(props.configData);
const [pluginToolsLoaded, setPluginToolsLoaded] = useState(
props.configData.tools.some((item) => item.pluginName),
hasPluginDiagnostics(props.configData),
);
const [pluginToolsLoading, setPluginToolsLoading] = useState(false);
const [pluginToolsError, setPluginToolsError] = useState<
@@ -445,6 +465,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
id: "auto-approve",
label: "Auto-approve all",
});
r.push({ kind: "toggle", id: "auto-update", label: "Auto update" });
r.push({ kind: "toggle", id: "verbose", label: "Verbose" });
} else {
const activeItems = resolveActiveConfigItems(configData, activeTab);
@@ -460,10 +481,11 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
} else if (activeTab === "tools") {
appendToolRows(r, activeItems);
if (pluginToolsLoading) {
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
if (pluginToolsLoading && loadingText) {
r.push({
kind: "detail",
text: "Loading plugin tools...",
text: loadingText,
});
}
if (pluginToolsError) {
@@ -494,9 +516,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
}
if (activeTab === "plugins" && pluginToolsLoading) {
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
r.push({
kind: "detail",
text: "Loading plugin diagnostics...",
text: loadingText ?? "Loading plugin diagnostics...",
});
}
if (activeTab === "plugins" && pluginToolsError) {
@@ -544,15 +567,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
if (nextData) {
setConfigData(nextData);
setPluginToolsLoaded(nextData.tools.some((tool) => tool.pluginName));
setPluginToolsLoaded(hasPluginDiagnostics(nextData));
} else if (item.kind === "plugin" && loadConfigData) {
const refreshedData = await loadConfigData({
includePluginTools: true,
});
setConfigData(refreshedData);
setPluginToolsLoaded(
refreshedData.tools.some((tool) => tool.pluginName),
);
setPluginToolsLoaded(hasPluginDiagnostics(refreshedData));
setPluginToolsError(undefined);
}
} catch (error) {
@@ -584,6 +605,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
setAutoApprove(!autoApprove);
props.onToggleAutoApprove();
break;
case "auto-update":
setAutoUpdateEnabled((previous) => {
const next = !previous;
setAutoUpdateEnabledGlobally(next);
return next;
});
break;
case "compaction": {
const nextMode = getNextCliCompactionMode(compactionMode);
setCompactionMode(nextMode);
@@ -789,6 +817,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
} else if (row.id === "auto-approve") {
value = autoApprove ? "● on" : "○ off";
valueColor = autoApprove ? palette.success : "gray";
} else if (row.id === "auto-update") {
value = autoUpdateEnabled ? "● on" : "○ off";
valueColor = autoUpdateEnabled ? palette.success : "gray";
} else if (row.id === "compaction") {
value = formatCliCompactionMode(compactionMode);
valueColor = COMPACTION_MODE_COLORS[compactionMode];
@@ -5,6 +5,7 @@ const hoisted = vi.hoisted(() => ({
startClineDeviceAuth: vi.fn(),
completeClineDeviceAuth: vi.fn(),
saveLocalProviderOAuthCredentials: vi.fn(),
identifyFeatureFlagsAccount: vi.fn(async () => {}),
openMock: vi.fn(() => Promise.resolve()),
}));
@@ -22,6 +23,10 @@ vi.mock("@cline/shared", () => ({
vi.mock("open", () => ({ default: hoisted.openMock }));
vi.mock("../../../utils/feature-flags", () => ({
identifyFeatureFlagsAccount: hoisted.identifyFeatureFlagsAccount,
}));
import { runDeviceCodeAuthFlow, runOAuthAuthFlow } from "./auth";
// Minimal stand-in for a telemetry service. The auth helpers must forward this
@@ -45,6 +50,8 @@ describe("onboarding auth telemetry forwarding", () => {
hoisted.startClineDeviceAuth.mockReset();
hoisted.completeClineDeviceAuth.mockReset();
hoisted.saveLocalProviderOAuthCredentials.mockReset();
hoisted.identifyFeatureFlagsAccount.mockReset();
hoisted.identifyFeatureFlagsAccount.mockResolvedValue(undefined);
hoisted.openMock.mockReset();
hoisted.openMock.mockResolvedValue(undefined);
});
@@ -85,6 +92,7 @@ describe("onboarding auth telemetry forwarding", () => {
// Identity, not deep-equal — we are validating the exact reference flows
// through so opt-out / common metadata stays consistent.
expect(telemetryArg).toBe(fakeTelemetry);
expect(hoisted.identifyFeatureFlagsAccount).not.toHaveBeenCalled();
});
it("does not pass telemetry when none is provided (back-compat)", () => {
@@ -121,6 +129,8 @@ describe("onboarding auth telemetry forwarding", () => {
access: "a",
refresh: "r",
expires: 0,
accountId: "acct-1",
email: "user@example.com",
});
runDeviceCodeAuthFlow({
@@ -146,5 +156,54 @@ describe("onboarding auth telemetry forwarding", () => {
// emitted by completeClineDeviceAuth, so passing telemetry to the start
// helper would double-emit the event.
expect(hoisted.startClineDeviceAuth).toHaveBeenCalledWith();
expect(hoisted.identifyFeatureFlagsAccount).toHaveBeenCalledWith({
id: "acct-1",
email: "user@example.com",
});
expect(hoisted.openMock).toHaveBeenCalledWith(
"https://verify?user_code=uc",
{ wait: false },
);
});
it("falls back to displaying the device auth URL when browser open fails", async () => {
hoisted.openMock.mockRejectedValueOnce(new Error("no browser"));
hoisted.startClineDeviceAuth.mockResolvedValueOnce({
deviceCode: "dc",
userCode: "uc",
verificationUri: "https://verify",
verificationUriComplete: "https://verify?user_code=uc",
expiresInSeconds: 600,
pollIntervalSeconds: 5,
});
hoisted.completeClineDeviceAuth.mockResolvedValueOnce({
access: "a",
refresh: "r",
expires: 0,
});
const setStatus = vi.fn();
runDeviceCodeAuthFlow({
providerId: "cline",
providerSettingsManager: makeManager(),
isAborted: () => false,
setUserCode: vi.fn(),
setVerifyUrl: vi.fn(),
setStatus,
setError: vi.fn(),
onComplete: vi.fn(),
});
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(hoisted.openMock).toHaveBeenCalledWith(
"https://verify?user_code=uc",
{ wait: false },
);
expect(setStatus).toHaveBeenCalledWith(
"Could not open browser. Visit the URL below.",
);
});
});
+30 -9
View File
@@ -1,6 +1,7 @@
import {
completeClineDeviceAuth,
type ITelemetryService,
isOAuthProvider,
loginLocalProvider,
type ProviderSettingsManager,
saveLocalProviderOAuthCredentials,
@@ -8,17 +9,18 @@ import {
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import open from "open";
import { identifyFeatureFlagsAccount } from "../../../utils/feature-flags";
export type OnboardingOAuthProviderId = "cline" | "oca" | "openai-codex";
export type OnboardingOAuthProviderId = string;
export function isOnboardingOAuthProviderId(
providerId: string,
): providerId is OnboardingOAuthProviderId {
return (
providerId === "cline" ||
providerId === "oca" ||
providerId === "openai-codex"
);
return isOAuthProvider(providerId);
}
function isClineAccountOAuthProvider(providerId: string): boolean {
return providerId === "cline" || providerId === "cline-pass";
}
export function runOAuthAuthFlow(input: {
@@ -59,6 +61,12 @@ export function runOAuthAuthFlow(input: {
existing,
credentials,
);
if (isClineAccountOAuthProvider(input.providerId)) {
void identifyFeatureFlagsAccount({
id: credentials.accountId,
email: credentials.email,
}).catch(() => {});
}
input.onComplete(input.providerId);
})
.catch((err: unknown) => {
@@ -92,11 +100,18 @@ export function runDeviceCodeAuthFlow(input: {
startClineDeviceAuth()
.then((result) => {
if (input.isAborted()) return;
const verifyUrl =
result.verificationUriComplete || result.verificationUri;
input.setUserCode(result.userCode);
input.setVerifyUrl(
result.verificationUriComplete || result.verificationUri,
);
input.setVerifyUrl(verifyUrl);
input.setStatus("Enter the code at the URL below");
try {
void open(verifyUrl, { wait: false }).catch(() => {
input.setStatus("Could not open browser. Visit the URL below.");
});
} catch {
input.setStatus("Could not open browser. Visit the URL below.");
}
completeClineDeviceAuth({
deviceCode: result.deviceCode,
@@ -114,6 +129,12 @@ export function runDeviceCodeAuthFlow(input: {
existing,
credentials,
);
if (isClineAccountOAuthProvider(input.providerId)) {
void identifyFeatureFlagsAccount({
id: credentials.accountId,
email: credentials.email,
}).catch(() => {});
}
input.onComplete(input.providerId);
})
.catch((err: unknown) => {
@@ -2,7 +2,6 @@ import {
captureProviderConfigured,
getLocalProviderModels,
getProviderConfigFields,
listLocalProviders,
type ProviderConfigFieldKey,
type ProviderConfigFields,
ProviderSettingsManager,
@@ -10,13 +9,16 @@ import {
resolveProviderConfig,
saveLocalProviderSettings,
} from "@cline/core";
import { isClineProvider } from "@cline/shared";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
type CodexCliStatus,
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
import { getCliFeatureFlagsService } from "../../../utils/feature-flags";
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { getCliTelemetryService } from "../../../utils/telemetry";
import {
buildClineModelEntries,
@@ -32,6 +34,7 @@ import {
getDefaultAwsRegion,
type ProviderConfigValues,
resolveProviderConfigAwsRegion,
resolveProviderConfigAzure,
resolveProviderConfigSap,
updateProviderConfigValue,
} from "../../utils/provider-config-values";
@@ -45,11 +48,13 @@ import {
import { FIELD_ORDER } from "./fields";
import { useOnboardingKeyboard } from "./keyboard";
import {
getMainMenuOptions,
type ModelEntry,
type OnboardingResult,
type OnboardingStep,
type ProviderEntry,
type ReasoningEffort,
shouldUseFeaturedClineModelPicker,
type ThinkingLevel,
toModelEntriesFromKnownModels,
toModelEntry,
@@ -70,6 +75,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
() => props.providerSettingsManager ?? new ProviderSettingsManager(),
[props.providerSettingsManager],
);
const menuOptions = useMemo(
() =>
getMainMenuOptions({
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
}),
[],
);
const [step, setStep] = useState<OnboardingStep>("menu");
const [menuSelected, setMenuSelected] = useState(0);
const [oauthProvider, setOauthProvider] = useState("");
@@ -152,6 +165,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const createCustomModelItem = useCallback(
(_search: string, filteredItems: SearchableItem[]) => {
if (activeProviderId === "cline-pass") {
return undefined;
}
if (filteredItems.some((item) => item.key === CUSTOM_MODEL_ID_ACTION)) {
return undefined;
}
@@ -162,7 +178,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
searchText: "create custom model id manual entry",
} satisfies SearchableItem;
},
[],
[activeProviderId],
);
const modelList = useSearchableList(modelItems, createCustomModelItem);
@@ -255,7 +271,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const provider = providers.find((p) => p.id === providerId);
setActiveProviderName(provider?.name ?? providerId);
setModelsDefaultId(provider?.defaultModelId ?? "");
if (providerId === "cline") {
if (shouldUseFeaturedClineModelPicker(providerId)) {
setClineModelSelected(0);
setStep("cline_model");
} else if (providerId === "openai-compatible") {
@@ -306,7 +322,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const startOAuthFlow = useCallback(
(providerId: OnboardingOAuthProviderId) => {
if (providerId === "cline") {
if (isClineProvider(providerId)) {
startDeviceCodeFlow(providerId);
return;
}
@@ -382,6 +398,10 @@ export function useOnboardingController(props: OnboardingControllerProps) {
config.fields.baseUrl?.defaultValue ??
"";
}
if (config.fields.azureApiVersion) {
initialValues.azureApiVersion =
existing?.azure?.apiVersion?.trim() ?? "";
}
if (config.fields.awsRegion) {
const existingProfile = existing?.aws?.profile?.trim() ?? "";
initialValues.awsRegion =
@@ -444,6 +464,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
// surfaced when the model picker / first turn runs.
const apiKey = byoValues.apiKey?.trim();
const awsProfile = byoValues.awsProfile?.trim();
const hasAzureFields = byoFields.azureApiVersion;
const hasAwsFields = byoFields.awsRegion || byoFields.awsProfile;
const hasSapFields =
byoFields.sapClientId ||
@@ -456,6 +477,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
providerId: activeProviderId,
apiKey: byoFields.apiKey ? apiKey : undefined,
baseUrl: byoFields.baseUrl ? byoValues.baseUrl?.trim() : undefined,
azure: hasAzureFields ? resolveProviderConfigAzure(byoValues) : undefined,
aws: hasAwsFields
? {
region: resolveProviderConfigAwsRegion(byoValues),
@@ -604,6 +626,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
onExit: props.onExit,
oauthProvider,
activeProviderId,
menuOptions,
menuSelected,
providerList,
modelList,
@@ -678,6 +701,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
},
handleModelItemSelect: selectModelItem,
menuSelected,
menuOptions,
modelItems,
modelList,
modelsLoading,
@@ -4,6 +4,7 @@ import type { ProviderConfigFieldKey } from "@cline/core";
export const FIELD_ORDER: ProviderConfigFieldKey[] = [
"awsRegion",
"baseUrl",
"azureApiVersion",
"apiKey",
"awsProfile",
"sapClientId",
+14 -6
View File
@@ -3,10 +3,13 @@ import { useKeyboard } from "@opentui/react";
import type { Dispatch, SetStateAction } from "react";
import type { ClineModelPickerEntry } from "../../components/model-selector/cline-model-picker";
import type { SearchableListState } from "../../components/searchable-list";
import type { OnboardingOAuthProviderId } from "./auth";
import {
isOnboardingOAuthProviderId,
type OnboardingOAuthProviderId,
} from "./auth";
import { FIELD_ORDER } from "./fields";
import {
MAIN_MENU,
type MenuOption,
type OnboardingStep,
THINKING_LEVELS,
type ThinkingLevel,
@@ -17,6 +20,7 @@ export function useOnboardingKeyboard(input: {
onExit: () => void;
oauthProvider: string;
activeProviderId: string;
menuOptions: MenuOption[];
menuSelected: number;
providerList: SearchableListState;
modelList: SearchableListState;
@@ -133,17 +137,21 @@ export function useOnboardingKeyboard(input: {
if (input.step === "menu") {
if (key.name === "up") {
input.setMenuSelected((s) => (s <= 0 ? MAIN_MENU.length - 1 : s - 1));
input.setMenuSelected((s) =>
s <= 0 ? input.menuOptions.length - 1 : s - 1,
);
return;
}
if (key.name === "down") {
input.setMenuSelected((s) => (s >= MAIN_MENU.length - 1 ? 0 : s + 1));
input.setMenuSelected((s) =>
s >= input.menuOptions.length - 1 ? 0 : s + 1,
);
return;
}
if (key.name === "return") {
const option = MAIN_MENU[input.menuSelected];
const option = input.menuOptions[input.menuSelected];
if (!option) return;
if (option.value === "cline" || option.value === "openai-codex") {
if (isOnboardingOAuthProviderId(option.value)) {
input.startOAuthFlow(option.value);
} else {
input.setStep("byo_provider");

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