Compare commits

..

95 Commits

Author SHA1 Message Date
abeatrix e40a9d6072 feat: add support for JSON-encoded string arrays for tools
- Added normalizeJsonLikeSearchCodebaseInput and normalizeJsonLikeReadFilesInput helpers
- Updated createSearchTool and createReadFilesTool to accept queries/files as JSON-encoded string arrays
- Refactored normalizeJsonLikeRunCommandsInput into a generic normalizeJsonLikeToolInput utility
- Added test coverage for new input format acceptance
2026-06-24 14:29:14 -07:00
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
384 changed files with 35362 additions and 12353 deletions
+14
View File
@@ -1,5 +1,19 @@
# 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
+53
View File
@@ -1,5 +1,58 @@
# 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
+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.23",
"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",
+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: "" }],
-210
View File
@@ -1,210 +0,0 @@
import { existsSync, readFileSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setHomeDir } from "@cline/shared/storage";
import { afterEach, describe, expect, it } from "vitest";
import {
installAgentProfile,
parseAgentSource,
planAgentPluginInstalls,
uninstallAgentProfile,
} from "./agent";
const PROFILE = `---
name: reviewer
description: Reviews code
plugins:
- branch-protector
- name: my-tool
install: https://example.com/my-tool.ts
---
You are a meticulous reviewer.`;
describe("agent command", () => {
const envSnapshot = { HOME: process.env.HOME };
afterEach(() => {
process.env.HOME = envSnapshot.HOME;
setHomeDir(envSnapshot.HOME ?? "~");
});
async function setUpHome(): Promise<{ root: string; home: string }> {
// Home is nested under a fixture root so the plugin display-name
// package.json walk never escapes into the shared temp directory.
const root = await mkdtemp(join(tmpdir(), "cli-agent-cmd-"));
const home = join(root, "home");
await mkdir(home, { recursive: true });
process.env.HOME = home;
setHomeDir(home);
return { root, home };
}
describe("parseAgentSource", () => {
it("parses local paths, official slugs, and remote URLs", () => {
expect(parseAgentSource("./reviewer.yml")).toEqual({
type: "local",
path: "./reviewer.yml",
});
expect(parseAgentSource("~/agents/reviewer.yaml")).toEqual({
type: "local",
path: "~/agents/reviewer.yaml",
});
expect(parseAgentSource("reviewer")).toEqual({
type: "official",
slug: "reviewer",
});
expect(parseAgentSource("code-reviewer")).toEqual({
type: "official",
slug: "code-reviewer",
});
expect(
parseAgentSource("https://example.com/profiles/reviewer.yml"),
).toEqual({
type: "remote",
url: "https://example.com/profiles/reviewer.yml",
filename: "reviewer.yml",
});
});
it("rewrites GitHub blob URLs to raw URLs", () => {
expect(
parseAgentSource(
"https://github.com/cline/agents/blob/main/agents/reviewer.yml",
),
).toEqual({
type: "remote",
url: "https://raw.githubusercontent.com/cline/agents/main/agents/reviewer.yml",
filename: "reviewer.yml",
});
});
it("rejects non-yaml GitHub file URLs and http URLs", () => {
expect(() =>
parseAgentSource(
"https://github.com/cline/agents/blob/main/agents/reviewer.md",
),
).toThrow(/must be \.yml or \.yaml/);
expect(() => parseAgentSource("http://example.com/reviewer.yml")).toThrow(
/must use https/,
);
});
});
describe("installAgentProfile", () => {
it("validates and writes the profile under the global agents dir", async () => {
const { root, home } = await setUpHome();
try {
const { config, installPath } = installAgentProfile({
content: PROFILE,
source: "./reviewer.yml",
});
expect(config.name).toBe("reviewer");
expect(installPath).toBe(
join(home, ".cline", "agents", "reviewer.yml"),
);
expect(readFileSync(installPath, "utf8")).toBe(PROFILE);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects invalid profiles before writing anything", async () => {
const { root, home } = await setUpHome();
try {
expect(() =>
installAgentProfile({
content: "not a profile",
source: "./broken.yml",
}),
).toThrow(/Invalid agent profile from \.\/broken\.yml/);
expect(existsSync(join(home, ".cline", "agents"))).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("refuses to replace an existing profile without force", async () => {
const { root } = await setUpHome();
try {
installAgentProfile({ content: PROFILE, source: "a" });
expect(() =>
installAgentProfile({ content: PROFILE, source: "a" }),
).toThrow(/already installed/);
expect(() =>
installAgentProfile({ content: PROFILE, source: "a", force: true }),
).not.toThrow();
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
describe("planAgentPluginInstalls", () => {
it("classifies listed plugins as installed, installable, or manual", async () => {
const { root, home } = await setUpHome();
try {
const userPlugins = join(home, ".cline", "plugins");
await mkdir(userPlugins, { recursive: true });
await writeFile(
join(userPlugins, "branch-protector.ts"),
"export default {}",
"utf8",
);
const plan = planAgentPluginInstalls([
{ name: "Branch-Protector" },
{ name: "my-tool", install: "https://example.com/my-tool.ts" },
{ name: "mystery-plugin" },
]);
expect(plan.alreadyInstalled).toEqual([{ name: "Branch-Protector" }]);
expect(plan.installable).toEqual([
{ name: "my-tool", install: "https://example.com/my-tool.ts" },
]);
expect(plan.manual).toEqual([{ name: "mystery-plugin" }]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("returns an empty plan when the profile lists no plugins", async () => {
const { root } = await setUpHome();
try {
expect(planAgentPluginInstalls(undefined)).toEqual({
alreadyInstalled: [],
installable: [],
manual: [],
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
describe("uninstallAgentProfile", () => {
it("removes a profile by frontmatter name or file name", async () => {
const { root } = await setUpHome();
try {
installAgentProfile({ content: PROFILE, source: "a" });
const result = uninstallAgentProfile("Reviewer");
expect(result.name).toBe("reviewer");
expect(existsSync(result.installPath)).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("lists available profiles when the name does not match", async () => {
const { root } = await setUpHome();
try {
installAgentProfile({ content: PROFILE, source: "a" });
expect(() => uninstallAgentProfile("nope")).toThrow(
/available: reviewer/,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
});
-520
View File
@@ -1,520 +0,0 @@
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, extname, join, resolve } from "node:path";
import * as p from "@clack/prompts";
import {
type ConfiguredAgentConfig,
type ConfiguredAgentPluginRef,
discoverPluginModulePaths,
loadConfiguredAgentConfigs,
parseConfiguredAgentConfig,
resolvePluginConfigSearchPaths,
} from "@cline/core";
import {
getPluginDisplayName,
resolveAgentsConfigDirPath,
} from "@cline/shared/storage";
import { resolveWorkspaceRoot } from "../utils/helpers";
import {
downloadRemoteFile,
isLocalPathLike,
isOfficialRegistrySlug,
normalizeRemoteSingleFileUrl,
resolveHomePath,
runCommand,
sanitizeSegment,
} from "./install-utils";
import { installPlugin } from "./plugin";
export interface AgentCommandIo {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
}
export interface AgentInstallOptions {
source: string;
force?: boolean;
/** Install profile-declared plugins without asking. */
yes?: boolean;
json?: boolean;
cwd?: string;
officialAgentsRepo?: string;
io?: AgentCommandIo;
}
export interface AgentInstallResult {
source: string;
name: string;
installPath: string;
/** Plugin names by outcome, one consistent shape across categories. */
plugins: {
alreadyInstalled: string[];
installed: string[];
failed: string[];
skipped: string[];
manual: string[];
};
}
export type ParsedAgentSource =
| { type: "official"; slug: string }
| { type: "remote"; url: string; filename: string }
| { type: "local"; path: string };
export const OFFICIAL_AGENTS_REPO = "https://github.com/cline/agents.git";
const AGENTS_REPO_DIRECTORY_NAME = "agents";
const REMOTE_AGENT_FETCH_TIMEOUT_MS = 30_000;
const REMOTE_AGENT_MAX_BYTES = 1024 * 1024;
const AGENT_SOURCE_KIND = "agent profile";
function isAgentConfigFilename(filename: string): boolean {
const extension = extname(filename).toLowerCase();
return extension === ".yml" || extension === ".yaml";
}
export function parseAgentSource(source: string): ParsedAgentSource {
const trimmed = source.trim();
if (!trimmed) {
throw new Error("agent install requires a source");
}
if (isLocalPathLike(trimmed)) {
return { type: "local", path: source };
}
const remote = normalizeRemoteSingleFileUrl(trimmed, {
isExpectedFile: isAgentConfigFilename,
kind: AGENT_SOURCE_KIND,
extensionsLabel: ".yml or .yaml",
fallbackFilename: "agent.yml",
});
if (remote) {
return { type: "remote", ...remote };
}
if (isOfficialRegistrySlug(trimmed)) {
return { type: "official", slug: trimmed };
}
return { type: "local", path: source };
}
async function fetchOfficialAgentProfile(
slug: string,
officialAgentsRepo: string,
): Promise<string> {
const stagingRoot = await mkdtemp(join(tmpdir(), "cline-agent-install-"));
try {
await runCommand("git", [
"clone",
"--filter=blob:none",
"--depth",
"1",
"--",
officialAgentsRepo,
stagingRoot,
]);
for (const extension of [".yml", ".yaml"]) {
const candidate = join(
stagingRoot,
AGENTS_REPO_DIRECTORY_NAME,
`${slug}${extension}`,
);
if (existsSync(candidate)) {
return readFileSync(candidate, "utf8");
}
}
throw new Error(
`Official Cline agent "${slug}" was not found at ${AGENTS_REPO_DIRECTORY_NAME}/${slug}.yml in ${officialAgentsRepo}`,
);
} finally {
await rm(stagingRoot, { recursive: true, force: true });
}
}
async function fetchAgentProfileContent(
parsed: ParsedAgentSource,
cwd: string,
officialAgentsRepo: string,
): Promise<string> {
if (parsed.type === "official") {
return fetchOfficialAgentProfile(parsed.slug, officialAgentsRepo);
}
if (parsed.type === "remote") {
const body = await downloadRemoteFile(parsed.url, {
timeoutMs: REMOTE_AGENT_FETCH_TIMEOUT_MS,
maxBytes: REMOTE_AGENT_MAX_BYTES,
kind: AGENT_SOURCE_KIND,
});
return body.toString("utf8");
}
const absolutePath = resolve(cwd, resolveHomePath(parsed.path));
if (!existsSync(absolutePath)) {
throw new Error(`Agent profile path does not exist: ${absolutePath}`);
}
if (!isAgentConfigFilename(absolutePath)) {
throw new Error(`Agent profile must be .yml or .yaml: ${absolutePath}`);
}
return readFileSync(absolutePath, "utf8");
}
export interface AgentPluginInstallPlan {
/** Listed plugins already installed (matched by display name). */
alreadyInstalled: ConfiguredAgentPluginRef[];
/** Listed plugins with an install source, not installed yet. */
installable: ConfiguredAgentPluginRef[];
/** Listed plugins with no install source and no local match. */
manual: ConfiguredAgentPluginRef[];
}
export function planAgentPluginInstalls(
plugins: ConfiguredAgentPluginRef[] | undefined,
): AgentPluginInstallPlan {
const plan: AgentPluginInstallPlan = {
alreadyInstalled: [],
installable: [],
manual: [],
};
if (!plugins?.length) {
return plan;
}
const installedNames = new Set<string>();
// Global plugin directories only: the profile installs globally, so a
// workspace-local plugin cannot satisfy its dependencies.
for (const directory of resolvePluginConfigSearchPaths(undefined)) {
let pluginPaths: string[] = [];
try {
pluginPaths = discoverPluginModulePaths(directory);
} catch {
// Best effort: skip unreadable plugin roots.
}
for (const pluginPath of pluginPaths) {
try {
installedNames.add(getPluginDisplayName(pluginPath).toLowerCase());
} catch {
// Best effort: one unreadable plugin should not hide the rest.
}
}
}
for (const ref of plugins) {
if (installedNames.has(ref.name.toLowerCase())) {
plan.alreadyInstalled.push(ref);
} else if (ref.install) {
plan.installable.push(ref);
} else {
plan.manual.push(ref);
}
}
return plan;
}
export function installAgentProfile(options: {
content: string;
source: string;
force?: boolean;
}): { config: ConfiguredAgentConfig; installPath: string } {
let config: ConfiguredAgentConfig;
try {
config = parseConfiguredAgentConfig(options.content);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid agent profile from ${options.source}: ${message}`);
}
const agentsDir = resolveAgentsConfigDirPath();
const installPath = join(
agentsDir,
`${sanitizeSegment(config.name.toLowerCase(), "agent")}.yml`,
);
if (existsSync(installPath) && options.force !== true) {
throw new Error(
`Agent profile is already installed at ${installPath}. Use --force to replace it.`,
);
}
mkdirSync(agentsDir, { recursive: true });
writeFileSync(installPath, options.content, "utf8");
return { config, installPath };
}
function formatPluginRef(ref: ConfiguredAgentPluginRef): string {
return ref.install && ref.install !== ref.name
? `${ref.name} (${ref.install})`
: ref.name;
}
async function installPluginDependencies(input: {
refs: ConfiguredAgentPluginRef[];
wizard: boolean;
io?: AgentCommandIo;
}): Promise<{ installed: string[]; failed: string[] }> {
const installed: string[] = [];
const failed: string[] = [];
for (const ref of input.refs) {
const source = ref.install ?? ref.name;
const spinner = input.wizard ? p.spinner() : undefined;
spinner?.start(`Installing plugin ${ref.name}`);
try {
const result = await installPlugin({ source });
spinner?.stop(`Installed plugin ${ref.name}`);
if (!input.wizard) {
input.io?.writeln(
`Installed plugin ${ref.name} at ${result.installPath}`,
);
}
installed.push(ref.name);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
spinner?.stop(`Failed to install plugin ${ref.name}: ${message}`);
if (!input.wizard) {
input.io?.writeErr(`Failed to install plugin ${ref.name}: ${message}`);
}
failed.push(ref.name);
}
}
return { installed, failed };
}
export async function runAgentInstallCommand(
options: AgentInstallOptions,
): Promise<number> {
const json = options.json === true;
const wizard = !json && process.stdout.isTTY === true;
const cwd = options.cwd?.trim() ? resolve(options.cwd) : process.cwd();
const officialAgentsRepo =
options.officialAgentsRepo?.trim() || OFFICIAL_AGENTS_REPO;
try {
if (wizard) {
p.intro("cline agent install");
}
const parsed = parseAgentSource(options.source);
const content = await fetchAgentProfileContent(
parsed,
cwd,
officialAgentsRepo,
);
const { config, installPath } = installAgentProfile({
content,
source: options.source.trim(),
force: options.force,
});
if (wizard) {
p.log.success(`Installed agent profile "${config.name}"`);
p.log.info(`Path: ${installPath}`);
} else if (!json) {
options.io?.writeln(`Installed agent profile "${config.name}"`);
options.io?.writeln(` Path: ${installPath}`);
}
const plan = planAgentPluginInstalls(config.plugins);
const reportLine = (text: string) => {
if (wizard) {
p.log.info(text);
} else if (!json) {
options.io?.writeln(text);
}
};
for (const ref of plan.alreadyInstalled) {
reportLine(`Plugin ${ref.name} is already installed`);
}
for (const ref of plan.manual) {
reportLine(
`Profile references plugin ${ref.name} with no install source; install it manually with: cline plugin install <source>`,
);
}
let installed: string[] = [];
let failed: string[] = [];
let skipped: string[] = [];
if (plan.installable.length > 0) {
// Profile-declared plugin installs run arbitrary code; never install
// them without an explicit confirmation or --yes.
let confirmed = options.yes === true;
if (!confirmed && wizard) {
const lines = plan.installable.map(formatPluginRef).join("\n");
p.note(lines, "This agent profile wants to install plugins");
const answer = await p.confirm({
message: `Install ${plan.installable.length} plugin${plan.installable.length === 1 ? "" : "s"}?`,
});
if (p.isCancel(answer)) {
p.cancel(
`Cancelled. The agent profile is installed at ${installPath}; install its plugins later with cline plugin install.`,
);
return 1;
}
confirmed = answer === true;
}
if (confirmed) {
const result = await installPluginDependencies({
refs: plan.installable,
wizard,
io: options.io,
});
installed = result.installed;
failed = result.failed;
} else {
skipped = plan.installable.map((ref) => ref.name);
const sources = plan.installable
.map((ref) => `cline plugin install ${ref.install ?? ref.name}`)
.join("; ");
reportLine(`Skipped plugin installs. Run manually: ${sources}`);
}
}
if (wizard) {
p.outro(
failed.length > 0
? "Done with errors"
: `Agent "${config.name}" is ready. Switch to it with /agents or --agent ${config.name}.`,
);
}
if (json) {
const result: AgentInstallResult = {
source: options.source.trim(),
name: config.name,
installPath,
plugins: {
alreadyInstalled: plan.alreadyInstalled.map((ref) => ref.name),
installed,
failed,
skipped,
manual: plan.manual.map((ref) => ref.name),
},
};
process.stdout.write(JSON.stringify(result));
}
return failed.length > 0 ? 1 : 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (wizard) {
p.cancel(message);
} else {
options.io?.writeErr(message);
}
return 1;
}
}
export interface AgentUninstallResult {
name: string;
installPath: string;
}
export function uninstallAgentProfile(name: string): AgentUninstallResult {
const trimmed = name.trim();
if (!trimmed) {
throw new Error("agent uninstall requires a profile name");
}
const agentsDir = resolveAgentsConfigDirPath();
const normalized = trimmed.toLowerCase();
const available: string[] = [];
let entries: string[];
try {
entries = readdirSync(agentsDir);
} catch {
entries = [];
}
for (const entry of entries) {
if (!isAgentConfigFilename(entry)) {
continue;
}
const filePath = join(agentsDir, entry);
let profileName = basename(entry, extname(entry));
try {
profileName = parseConfiguredAgentConfig(
readFileSync(filePath, "utf8"),
).name;
} catch {
// Unparseable file: fall back to matching the filename.
}
available.push(profileName);
if (
profileName.trim().toLowerCase() === normalized ||
basename(entry, extname(entry)).toLowerCase() === normalized
) {
rmSync(filePath);
return { name: profileName, installPath: filePath };
}
}
throw new Error(
available.length > 0
? `Agent profile "${trimmed}" was not found in ${agentsDir} (available: ${available.join(", ")})`
: `Agent profile "${trimmed}" was not found (no agent profiles in ${agentsDir})`,
);
}
export async function runAgentUninstallCommand(options: {
name: string;
json?: boolean;
io?: AgentCommandIo;
}): Promise<number> {
try {
const result = uninstallAgentProfile(options.name);
if (options.json) {
process.stdout.write(JSON.stringify(result));
return 0;
}
options.io?.writeln(`Uninstalled agent profile "${result.name}"`);
options.io?.writeln(` Removed: ${result.installPath}`);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(message);
return 1;
}
}
export async function runAgentListCommand(options: {
cwd?: string;
json?: boolean;
io?: AgentCommandIo;
}): Promise<number> {
const workspaceRoot = resolveWorkspaceRoot(
options.cwd?.trim() ? resolve(options.cwd) : process.cwd(),
);
const { configs, errors } = loadConfiguredAgentConfigs({ workspaceRoot });
if (options.json) {
process.stdout.write(
JSON.stringify({
agents: configs.map((config) => ({
name: config.name,
description: config.description,
path: config.path,
plugins: config.plugins,
})),
errors: errors.map((error) => ({
path: error.path,
message: error.error.message,
})),
}),
);
return 0;
}
if (configs.length === 0 && errors.length === 0) {
options.io?.writeln(
"No agent profiles found. Install one with: cline agent install <source>",
);
return 0;
}
for (const config of configs) {
options.io?.writeln(`${config.name} ${config.description}`);
if (config.path) {
options.io?.writeln(` path: ${config.path}`);
}
if (config.plugins?.length) {
options.io?.writeln(
` plugins: ${config.plugins.map((plugin) => plugin.name).join(", ")}`,
);
}
}
for (const error of errors) {
options.io?.writeErr(
`failed to load ${error.path}: ${error.error.message}`,
);
}
return errors.length > 0 ? 1 : 0;
}
+7 -2
View File
@@ -4,7 +4,6 @@ import {
createOAuthClientCallbacks,
ensureCustomProvidersLoaded,
getProviderAuthHandler,
listLocalProviders,
loginAndSaveProviderOAuthCredentials,
type ProviderSettings,
type ProviderSettingsManager,
@@ -22,6 +21,8 @@ import {
type OAuthCredentials,
toProviderApiKey,
} from "../utils/provider-auth";
import { listLocalProviders } from "../utils/provider-catalog";
import { identifyTelemetryAccount } from "../utils/telemetry";
export {
getPersistedProviderApiKey,
@@ -434,11 +435,15 @@ export async function runAuthProviderCommand(
return 1;
}
try {
await loginAndSaveProviderOAuthCredentials(
const settings = await loginAndSaveProviderOAuthCredentials(
providerSettingsManager,
providerId,
{ 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 {
-280
View File
@@ -1,280 +0,0 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { basename, join } from "node:path";
/**
* Generic helpers shared by the single-source install commands
* (`cline plugin install`, `cline agent install`).
*/
export function resolveHomePath(value: string): string {
if (value === "~") {
return homedir();
}
if (value.startsWith("~/")) {
return join(homedir(), value.slice(2));
}
return value;
}
export function hashSource(source: string): string {
return createHash("sha256").update(source).digest("hex").slice(0, 12);
}
export function sanitizeSegment(value: string, fallback = "plugin"): string {
const sanitized = value
.replace(/^@/, "")
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return sanitized || fallback;
}
export function isOfficialRegistrySlug(source: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
}
export function isLocalPathLike(source: string): boolean {
return (
source.startsWith(".") ||
source.startsWith("/") ||
source === "~" ||
source.startsWith("~/") ||
/^[A-Za-z]:[\\/]|^\\\\/.test(source)
);
}
export async function runCommand(
command: string,
args: string[],
options: { cwd?: string } = {},
): Promise<void> {
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(command, args, {
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) => {
stderr += String(chunk);
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolvePromise();
return;
}
const details = stderr.trim();
reject(
new Error(
`${command} ${args.join(" ")} failed with exit code ${code}${details ? `: ${details}` : ""}`,
),
);
});
});
}
function decodePathSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function filenameFromUrlPath(pathname: string, fallback: string): string {
const filename = basename(decodePathSegment(pathname));
return filename || fallback;
}
function isGitHubFilePath(pathname: string): boolean {
const parts = pathname.split("/").filter(Boolean);
return parts.length >= 5 && (parts[2] === "blob" || parts[2] === "raw");
}
export interface NormalizeRemoteSingleFileUrlOptions {
/** Whether the URL's filename has an expected extension for this kind. */
isExpectedFile: (filename: string) => boolean;
/** Short noun for error messages, e.g. "plugin" or "agent profile". */
kind: string;
/** Human label of accepted extensions, e.g. ".js or .ts". */
extensionsLabel: string;
/** Fallback filename when the URL path has none. */
fallbackFilename: string;
}
/**
* Validates an https single-file URL, rewriting GitHub blob/raw page URLs to
* raw.githubusercontent.com. Returns null when the source is not a candidate
* file URL for this kind; throws when it is but violates a constraint.
*/
export function normalizeRemoteSingleFileUrl(
source: string,
options: NormalizeRemoteSingleFileUrlOptions,
): { url: string; filename: string } | null {
if (!/^https?:\/\//i.test(source)) {
return null;
}
let parsed: URL;
try {
parsed = new URL(source);
} catch {
return null;
}
const host = parsed.hostname.toLowerCase();
const filename = filenameFromUrlPath(
parsed.pathname,
options.fallbackFilename,
);
const isExpectedFile = options.isExpectedFile(filename);
const isGitHubFile =
(host === "github.com" || host === "www.github.com") &&
isGitHubFilePath(parsed.pathname);
if (parsed.protocol !== "https:") {
if (
isGitHubFile ||
host === "raw.githubusercontent.com" ||
isExpectedFile
) {
throw new Error(
`Remote ${options.kind} file URLs must use https: ${source}`,
);
}
return null;
}
if (host === "github.com" || host === "www.github.com") {
const parts = parsed.pathname.split("/").filter(Boolean);
if (!isGitHubFile) {
return null;
}
if (!isExpectedFile) {
throw new Error(
`Remote ${options.kind} file must be ${options.extensionsLabel}: ${source}`,
);
}
const rawParts = [parts[0], parts[1], ...parts.slice(3)];
return {
url: `https://raw.githubusercontent.com/${rawParts.join("/")}`,
filename,
};
}
if (host === "raw.githubusercontent.com") {
if (!isExpectedFile) {
throw new Error(
`Remote ${options.kind} file must be ${options.extensionsLabel}: ${source}`,
);
}
return { url: parsed.toString(), filename };
}
if (!isExpectedFile) {
return null;
}
return { url: parsed.toString(), filename };
}
export interface DownloadRemoteFileOptions {
timeoutMs: number;
maxBytes: number;
/** Short noun for error messages, e.g. "plugin" or "agent profile". */
kind: string;
}
function sizeLimitError(
url: string,
options: DownloadRemoteFileOptions,
): Error {
return new Error(
`Remote ${options.kind} file from ${url} exceeds the ${options.maxBytes} byte limit`,
);
}
function getContentLength(response: Response): number | undefined {
const raw = response.headers.get("content-length");
if (!raw) {
return undefined;
}
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
return undefined;
}
return value;
}
async function readRemoteBody(
response: Response,
url: string,
options: DownloadRemoteFileOptions,
): Promise<Buffer> {
const contentLength = getContentLength(response);
if (contentLength !== undefined && contentLength > options.maxBytes) {
throw sizeLimitError(url, options);
}
if (!response.body) {
const body = Buffer.from(await response.text(), "utf8");
if (body.byteLength > options.maxBytes) {
throw sizeLimitError(url, options);
}
return body;
}
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let received = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = Buffer.from(value);
received += chunk.byteLength;
if (received > options.maxBytes) {
await reader.cancel().catch(() => undefined);
throw sizeLimitError(url, options);
}
chunks.push(chunk);
}
return Buffer.concat(chunks, received);
} finally {
reader.releaseLock();
}
}
export async function downloadRemoteFile(
url: string,
options: DownloadRemoteFileOptions,
): Promise<Buffer> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, options.timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
const suffix = response.statusText ? ` ${response.statusText}` : "";
throw new Error(
`Failed to download ${options.kind} file from ${url}: ${response.status}${suffix}`,
);
}
return await readRemoteBody(response, url, options);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error(
`Timed out downloading ${options.kind} file from ${url} after ${options.timeoutMs}ms`,
);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
+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": {
+458 -30
View File
@@ -1,3 +1,5 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
type Dirent,
existsSync,
@@ -9,6 +11,7 @@ import {
statSync,
} from "node:fs";
import { cp, mkdir, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import {
basename,
dirname,
@@ -18,22 +21,20 @@ 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,
resolvePluginModuleEntries,
} from "@cline/shared/storage";
import {
downloadRemoteFile,
hashSource,
isLocalPathLike,
isOfficialRegistrySlug,
normalizeRemoteSingleFileUrl,
resolveHomePath,
runCommand,
sanitizeSegment,
} from "./install-utils";
export interface PluginInstallOptions {
source: string;
@@ -43,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 {
@@ -116,12 +136,35 @@ const WRAPPER_PACKAGE_JSON = {
},
};
function resolveHomePath(value: string): string {
if (value === "~") {
return homedir();
}
if (value.startsWith("~/")) {
return join(homedir(), value.slice(2));
}
return value;
}
function toPosixPath(path: string): string {
return path.split(sep).join("/");
}
function hashSource(source: string): string {
return createHash("sha256").update(source).digest("hex").slice(0, 12);
}
function sanitizeSegment(value: string): string {
const sanitized = value
.replace(/^@/, "")
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return sanitized || "plugin";
}
export function isOfficialPluginSlug(source: string): boolean {
return isOfficialRegistrySlug(source);
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
}
function resolveOfficialPluginsRepo(override: string | undefined): string {
@@ -203,16 +246,78 @@ function splitGitRef(input: string): { repo: string; ref?: string } {
};
}
function decodePathSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function filenameFromUrlPath(pathname: string): string {
const filename = basename(decodePathSegment(pathname));
return filename || "plugin";
}
function isGitHubFilePath(pathname: string): boolean {
const parts = pathname.split("/").filter(Boolean);
return parts.length >= 5 && (parts[2] === "blob" || parts[2] === "raw");
}
function normalizeRemotePluginFileUrl(
source: string,
): Extract<ParsedPluginSource, { type: "remote" }> | null {
const remote = normalizeRemoteSingleFileUrl(source, {
isExpectedFile: isPluginModulePath,
kind: "plugin",
extensionsLabel: ".js or .ts",
fallbackFilename: "plugin",
});
return remote ? { type: "remote", ...remote } : null;
if (!/^https?:\/\//i.test(source)) {
return null;
}
let parsed: URL;
try {
parsed = new URL(source);
} catch {
return null;
}
const host = parsed.hostname.toLowerCase();
const filename = filenameFromUrlPath(parsed.pathname);
const isPluginFile = isPluginModulePath(filename);
const isGitHubFile =
(host === "github.com" || host === "www.github.com") &&
isGitHubFilePath(parsed.pathname);
if (parsed.protocol !== "https:") {
if (isGitHubFile || host === "raw.githubusercontent.com" || isPluginFile) {
throw new Error(`Remote plugin file URLs must use https: ${source}`);
}
return null;
}
if (host === "github.com" || host === "www.github.com") {
const parts = parsed.pathname.split("/").filter(Boolean);
if (!isGitHubFile) {
return null;
}
if (!isPluginFile) {
throw new Error(`Remote plugin file must be .js or .ts: ${source}`);
}
const rawParts = [parts[0], parts[1], ...parts.slice(3)];
return {
type: "remote",
url: `https://raw.githubusercontent.com/${rawParts.join("/")}`,
filename,
};
}
if (host === "raw.githubusercontent.com") {
if (!isPluginFile) {
throw new Error(`Remote plugin file must be .js or .ts: ${source}`);
}
return { type: "remote", url: parsed.toString(), filename };
}
if (!isPluginFile) {
return null;
}
return { type: "remote", url: parsed.toString(), filename };
}
function parseGitSource(
@@ -299,7 +404,13 @@ export function parsePluginSource(
const { name } = parseNpmSpec(spec);
return { type: "npm", spec, name };
}
if (isLocalPathLike(trimmed)) {
const localPathLike =
trimmed.startsWith(".") ||
trimmed.startsWith("/") ||
trimmed === "~" ||
trimmed.startsWith("~/") ||
/^[A-Za-z]:[\\/]|^\\\\/.test(trimmed);
if (localPathLike) {
return { type: "local", path: source };
}
const remote = normalizeRemotePluginFileUrl(trimmed);
@@ -412,6 +523,39 @@ function getWrapperPackageName(
return sanitizeSegment(basename(resolve(cwd, resolveHomePath(parsed.path))));
}
async function runCommand(
command: string,
args: string[],
options: { cwd?: string } = {},
): Promise<void> {
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(command, args, {
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) => {
stderr += String(chunk);
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolvePromise();
return;
}
const details = stderr.trim();
reject(
new Error(
`${command} ${args.join(" ")} failed with exit code ${code}${details ? `: ${details}` : ""}`,
),
);
});
});
}
function readPackageManifest(
packageRoot: string,
): PluginPackageManifest | null {
@@ -715,18 +859,94 @@ async function installOfficialPlugin(
return packageRoot;
}
function remotePluginSizeLimitError(url: string): Error {
return new Error(
`Remote plugin file from ${url} exceeds the ${REMOTE_PLUGIN_MAX_BYTES} byte limit`,
);
}
function getContentLength(response: Response): number | undefined {
const raw = response.headers.get("content-length");
if (!raw) {
return undefined;
}
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
return undefined;
}
return value;
}
async function readRemotePluginBody(
response: Response,
url: string,
): Promise<Buffer> {
const contentLength = getContentLength(response);
if (contentLength !== undefined && contentLength > REMOTE_PLUGIN_MAX_BYTES) {
throw remotePluginSizeLimitError(url);
}
if (!response.body) {
const body = Buffer.from(await response.text(), "utf8");
if (body.byteLength > REMOTE_PLUGIN_MAX_BYTES) {
throw remotePluginSizeLimitError(url);
}
return body;
}
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let received = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = Buffer.from(value);
received += chunk.byteLength;
if (received > REMOTE_PLUGIN_MAX_BYTES) {
await reader.cancel().catch(() => undefined);
throw remotePluginSizeLimitError(url);
}
chunks.push(chunk);
}
return Buffer.concat(chunks, received);
} finally {
reader.releaseLock();
}
}
async function installRemoteFile(
parsed: Extract<ParsedPluginSource, { type: "remote" }>,
stagingRoot: string,
): Promise<string> {
const body = await downloadRemoteFile(parsed.url, {
timeoutMs: REMOTE_PLUGIN_FETCH_TIMEOUT_MS,
maxBytes: REMOTE_PLUGIN_MAX_BYTES,
kind: "plugin",
});
mkdirSync(stagingRoot, { recursive: true });
await writeFile(join(stagingRoot, parsed.filename), body);
return stagingRoot;
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, REMOTE_PLUGIN_FETCH_TIMEOUT_MS);
try {
const response = await fetch(parsed.url, { signal: controller.signal });
if (!response.ok) {
const suffix = response.statusText ? ` ${response.statusText}` : "";
throw new Error(
`Failed to download plugin file from ${parsed.url}: ${response.status}${suffix}`,
);
}
const body = await readRemotePluginBody(response, parsed.url);
mkdirSync(stagingRoot, { recursive: true });
await writeFile(join(stagingRoot, parsed.filename), body);
return stagingRoot;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error(
`Timed out downloading plugin file from ${parsed.url} after ${REMOTE_PLUGIN_FETCH_TIMEOUT_MS}ms`,
);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
async function installLocalPackage(
@@ -812,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> {
@@ -878,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);
-6
View File
@@ -51,10 +51,6 @@ export function addRootOptions(cmd: Command): Command {
"-s, --system <system-prompt>",
"Override the default system prompt",
)
.option(
"--agent <name>",
"Use an agent profile from .cline/agents for this session",
)
.option("-z, --zen", "Start a session that runs in the background hub")
.option(
"--retries [value]",
@@ -120,7 +116,6 @@ export function createProgram(): Command {
writeOut: () => {}, // suppress by default; main.ts re-enables for routing
writeErr: () => {},
})
.allowUnknownOption()
.allowExcessArguments()
.enablePositionalOptions()
.argument(
@@ -229,7 +224,6 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
if (opts.cwd !== undefined) result.cwd = opts.cwd;
if (opts.teamName !== undefined) result.teamName = opts.teamName;
if (opts.system !== undefined) result.systemPrompt = opts.system;
if (opts.agent !== undefined) result.agent = opts.agent;
if (opts.model !== undefined) result.model = opts.model;
if (opts.provider !== undefined) result.provider = opts.provider;
if (opts.key !== undefined) result.key = opts.key;
+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));
});
});
}
+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";
@@ -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");
});
});
+5 -1
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 ||
+157 -165
View File
@@ -2,23 +2,6 @@ import { fstatSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CliMigrationNotice } from "./kanban-migration/notice";
interface MockConfiguredAgentConfig {
name: string;
description: string;
systemPrompt: string;
path?: string;
}
interface MockConfiguredAgentReadError {
path: string;
error: Error;
}
interface MockConfiguredAgentLoadResult {
configs: MockConfiguredAgentConfig[];
errors: MockConfiguredAgentReadError[];
}
/** Real `fstatSync`: used when tests stub only stdin (fd 0); throwing for every fd breaks imports and session I/O. */
const fsActual = vi.hoisted(() => ({
realFstatSync: null as null | typeof import("node:fs").fstatSync,
@@ -46,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,
),
@@ -64,14 +49,6 @@ const sessionMocks = vi.hoisted(() => ({
const llmMocks = vi.hoisted(() => ({
resolveProviderConfig: vi.fn(async (): Promise<unknown> => undefined),
}));
const agentConfigMocks = vi.hoisted(() => ({
loadConfiguredAgentConfigs: vi.fn<
(input: { workspaceRoot?: string }) => MockConfiguredAgentLoadResult
>(() => ({
configs: [],
errors: [],
})),
}));
const promptMocks = vi.hoisted(() => ({
resolveSystemPrompt: vi.fn(async () => "system prompt"),
}));
@@ -107,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: {
@@ -126,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", {
@@ -167,15 +153,14 @@ vi.mock("./session/session", () => sessionMocks);
vi.mock("@cline/core", () => {
return {
resolveProviderConfig: llmMocks.resolveProviderConfig,
loadConfiguredAgentConfigs: agentConfigMocks.loadConfiguredAgentConfigs,
createTeamName: vi.fn(() => "team-test"),
createUserInstructionConfigService: vi.fn(() => ({
start: vi.fn(async () => {}),
stop: vi.fn(),
})),
ProviderSettingsManager: class {
getLastUsedProviderSettings() {
return providerSettingsMocks.getLastUsedProviderSettings();
getLastUsedProviderSettings(options?: unknown) {
return providerSettingsMocks.getLastUsedProviderSettings(options);
}
getProviderSettings(providerId: string) {
return providerSettingsMocks.getProviderSettings(providerId);
@@ -190,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,
}));
@@ -198,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);
@@ -217,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",
@@ -241,11 +237,6 @@ describe("runCli lightweight command dispatch", () => {
});
llmMocks.resolveProviderConfig.mockReset();
llmMocks.resolveProviderConfig.mockResolvedValue(undefined);
agentConfigMocks.loadConfiguredAgentConfigs.mockReset();
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
configs: [],
errors: [],
});
authMocks.ensureOAuthProviderApiKey.mockReset();
authMocks.getPersistedProviderApiKey.mockReset();
authMocks.getPersistedProviderApiKey.mockReturnValue(undefined);
@@ -264,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();
@@ -277,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);
@@ -423,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"];
@@ -750,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");
@@ -832,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"];
@@ -849,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",
@@ -859,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),
@@ -903,138 +1022,11 @@ describe("runCli lightweight command dispatch", () => {
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
});
it("applies an agent profile to prompt runs", async () => {
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
configs: [
{
name: "Reviewer",
description: "Reviews code",
systemPrompt: "You are a reviewer.",
path: "/repo/.cline/agents/reviewer.yaml",
},
],
errors: [],
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--agent", "reviewer", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(agentConfigMocks.loadConfiguredAgentConfigs).toHaveBeenCalledWith({
workspaceRoot: "/workspace/cline",
});
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
expect.objectContaining({
agentPersona: "You are a reviewer.",
}),
);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
expect.objectContaining({
agentProfile: {
name: "Reviewer",
systemPrompt: "You are a reviewer.",
},
systemPrompt: "system prompt",
}),
expect.anything(),
);
});
it("lets --system override --agent without storing the profile", async () => {
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
configs: [
{
name: "Reviewer",
description: "Reviews code",
systemPrompt: "You are a reviewer.",
path: "/repo/.cline/agents/reviewer.yaml",
},
],
errors: [],
});
forcePromptModeInput();
process.argv = [
"bun",
"src/index.ts",
"--agent",
"reviewer",
"--system",
"Custom system.",
"hello",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(agentConfigMocks.loadConfiguredAgentConfigs).not.toHaveBeenCalled();
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
expect.objectContaining({
explicitSystemPrompt: "Custom system.",
}),
);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
expect.objectContaining({
agentProfile: undefined,
}),
expect.anything(),
);
});
it("rejects missing agent profiles before starting the runtime", async () => {
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
configs: [
{
name: "Planner",
description: "Plans work",
systemPrompt: "You are a planner.",
path: "/repo/.cline/agents/planner.yaml",
},
],
errors: [
{
path: "/repo/.cline/agents/broken.yaml",
error: new Error("Missing system prompt body"),
},
],
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--agent", "reviewer", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
});
it("rejects --agent in yolo mode before loading profiles", async () => {
forcePromptModeInput();
process.argv = [
"bun",
"src/index.ts",
"--yolo",
"--agent",
"reviewer",
"hello",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(agentConfigMocks.loadConfiguredAgentConfigs).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
});
it("rewrites /team prompts and enables teams in single-prompt mode", async () => {
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");
+95 -117
View File
@@ -14,12 +14,16 @@ import {
autoUpdateOnStartup,
getPreferredKanbanInstaller,
} from "./commands/update";
import { resolveAgentProfileDisabledPluginPaths } from "./runtime/agent-profile-plugins";
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
import {
buildCliCompactionConfig,
CLI_COMPACTION_MODE_EXPECTED_TEXT,
} from "./utils/compaction-mode";
import {
getCliFeatureFlagsService,
refreshCliFeatureFlagsInBackground,
setCliFeatureFlagsAccountContext,
} from "./utils/feature-flags";
import {
configureSandboxEnvironment,
normalizeAutoApproveArgs,
@@ -43,7 +47,7 @@ import {
captureCliExtensionActivated,
getCliTelemetryService,
} from "./utils/telemetry";
import type { ActiveAgentProfile, Config } from "./utils/types";
import type { Config } from "./utils/types";
import { runConnectWizard } from "./wizards/connect";
import { runMcpWizard } from "./wizards/mcp";
import { runScheduleWizard } from "./wizards/schedule";
@@ -136,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(() => {});
@@ -312,71 +316,28 @@ export async function runCli(): Promise<void> {
io,
});
});
const agentCmd = program
.command("agent")
.description("Manage Cline Agent profiles")
.action(() => {
agentCmd.help();
});
const agentInstallCmd = agentCmd
.command("install")
.alias("i")
.description(
"Install an agent profile from an official keyword, profile file URL, or a local path",
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.",
)
.argument(
"<source>",
"official keyword, profile .yml URL, or local profile path",
)
.option("--force", "Replace an existing profile with the same name")
.option("--yes", "Install profile-declared plugins without asking")
.option("--json", "Output as JSON")
.action(async (source: string) => {
const opts = agentInstallCmd.opts<{
force?: boolean;
yes?: boolean;
json?: boolean;
}>();
const { runAgentInstallCommand } = await import("./commands/agent");
ctx.exitCode = await runAgentInstallCommand({
source,
force: opts.force === true,
yes: opts.yes === true,
json: opts.json === true || program.opts().json === true,
cwd: program.opts().cwd,
io,
});
});
const agentUninstallCmd = agentCmd
.command("uninstall")
.alias("remove")
.alias("rm")
.description("Uninstall a globally installed agent profile by name")
.argument("<name>", "agent profile name or file name")
.option("--json", "Output as JSON")
.action(async (name: string) => {
const opts = agentUninstallCmd.opts<{ json?: boolean }>();
const { runAgentUninstallCommand } = await import("./commands/agent");
ctx.exitCode = await runAgentUninstallCommand({
name,
json: opts.json === true || program.opts().json === true,
io,
});
});
const agentListCmd = agentCmd
.command("list")
.alias("ls")
.description("List available agent profiles")
.option("--json", "Output as JSON")
.action(async () => {
const opts = agentListCmd.opts<{ json?: boolean }>();
const { runAgentListCommand } = await import("./commands/agent");
ctx.exitCode = await runAgentListCommand({
cwd: program.opts().cwd,
json: opts.json === true || program.opts().json === true,
io,
});
const { runSkillCommand } = await import("./commands/skill");
ctx.exitCode = await runSkillCommand(skillCmd.args, io);
});
const connectCmd = program
.command("connect")
.description("Connect to an external channel")
@@ -418,7 +379,7 @@ export async function runCli(): Promise<void> {
}
});
program
const mcpCmd = program
.command("mcp")
.description("Manage MCP servers")
.action(async () => {
@@ -430,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");
@@ -610,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")
@@ -618,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;
@@ -627,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,
@@ -676,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;
@@ -726,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,
@@ -902,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",
);
@@ -994,50 +1022,6 @@ export async function runCli(): Promise<void> {
cwd,
});
let activeAgentProfile: ActiveAgentProfile | undefined;
const requestedAgentName = args.agent?.trim();
if (requestedAgentName) {
if (isYoloMode) {
writeErr("--agent is not supported in yolo mode");
process.exitCode = 1;
return;
}
if (args.systemPrompt) {
// Don't store an unused profile: it would resurface on plan/act toggles.
writeln(
`${c.dim}[warn] --system overrides --agent; ignoring agent profile "${requestedAgentName}"${c.reset}`,
);
} else {
const { loadConfiguredAgentConfigs } = await import("@cline/core");
const { configs, errors } = loadConfiguredAgentConfigs({
workspaceRoot,
});
const profile = configs.find(
(candidate) =>
candidate.name.trim().toLowerCase() ===
requestedAgentName.toLowerCase(),
);
if (!profile) {
const availableNames = configs.map((candidate) => candidate.name);
writeErr(
availableNames.length > 0
? `agent profile "${requestedAgentName}" not found (available: ${availableNames.join(", ")})`
: `agent profile "${requestedAgentName}" not found (no agent profiles in .cline/agents)`,
);
for (const error of errors) {
writeErr(`failed to load ${error.path}: ${error.error.message}`);
}
process.exitCode = 1;
return;
}
activeAgentProfile = {
name: profile.name,
systemPrompt: profile.systemPrompt,
plugins: profile.plugins?.map((plugin) => plugin.name),
};
}
}
const config: Config = {
providerId: provider,
modelId:
@@ -1052,7 +1036,6 @@ export async function runCli(): Promise<void> {
explicitSystemPrompt: args.systemPrompt,
providerId: provider,
mode: args.mode ?? "act",
agentPersona: activeAgentProfile?.systemPrompt,
}),
execution: {
maxConsecutiveMistakes: args.retries ?? 3,
@@ -1075,11 +1058,6 @@ export async function runCli(): Promise<void> {
telemetry: getCliTelemetryService(loggerAdapter.core),
defaultToolAutoApprove,
toolPolicies,
agentProfile: activeAgentProfile,
disabledPluginPaths: resolveAgentProfileDisabledPluginPaths(
activeAgentProfile,
workspaceRoot,
),
enableSpawnAgent: !isYoloMode,
enableAgentTeams: !isYoloMode,
enableTools: true,
@@ -1,171 +0,0 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setHomeDir } from "@cline/shared/storage";
import { afterEach, describe, expect, it } from "vitest";
import { resolveAgentProfileDisabledPluginPaths } from "./agent-profile-plugins";
describe("resolveAgentProfileDisabledPluginPaths", () => {
const envSnapshot = {
HOME: process.env.HOME,
CLINE_GLOBAL_SETTINGS_PATH: process.env.CLINE_GLOBAL_SETTINGS_PATH,
};
afterEach(() => {
process.env.HOME = envSnapshot.HOME;
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
setHomeDir(envSnapshot.HOME ?? "~");
});
async function setUpFixture(): Promise<{
root: string;
home: string;
workspace: string;
listedPlugin: string;
unlistedPlugin: string;
alwaysEnabledPlugin: string;
}> {
// Nested under a fixture root so the display-name package.json walk
// never escapes into the shared temp directory.
const root = await mkdtemp(join(tmpdir(), "cli-profile-plugins-"));
const home = join(root, "home");
const workspace = join(root, "workspace");
await mkdir(home, { recursive: true });
await mkdir(workspace, { recursive: true });
process.env.HOME = home;
setHomeDir(home);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(home, "global-settings.json");
const workspacePlugins = join(workspace, ".cline", "plugins");
const userPlugins = join(home, ".cline", "plugins");
await mkdir(workspacePlugins, { recursive: true });
await mkdir(userPlugins, { recursive: true });
const listedPlugin = join(workspacePlugins, "listed-plugin.js");
const unlistedPlugin = join(workspacePlugins, "unlisted-plugin.js");
const alwaysEnabledPlugin = join(userPlugins, "always-on.js");
await writeFile(listedPlugin, "export default {}", "utf8");
await writeFile(unlistedPlugin, "export default {}", "utf8");
await writeFile(alwaysEnabledPlugin, "export default {}", "utf8");
return {
root,
home,
workspace,
listedPlugin,
unlistedPlugin,
alwaysEnabledPlugin,
};
}
it("returns undefined when the profile has no plugins field", async () => {
const fixture = await setUpFixture();
try {
expect(
resolveAgentProfileDisabledPluginPaths(undefined, fixture.workspace),
).toBeUndefined();
expect(
resolveAgentProfileDisabledPluginPaths({}, fixture.workspace),
).toBeUndefined();
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("disables installed plugins not listed in the profile", async () => {
const fixture = await setUpFixture();
try {
const disabled = resolveAgentProfileDisabledPluginPaths(
{ plugins: ["Listed-Plugin"] },
fixture.workspace,
);
expect(disabled).toContain(fixture.unlistedPlugin);
expect(disabled).toContain(fixture.alwaysEnabledPlugin);
expect(disabled).not.toContain(fixture.listedPlugin);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("exempts always-enabled plugins from profile disabling", async () => {
const fixture = await setUpFixture();
try {
await writeFile(
process.env.CLINE_GLOBAL_SETTINGS_PATH ?? "",
JSON.stringify({
alwaysEnabledPlugins: [fixture.alwaysEnabledPlugin],
}),
"utf8",
);
const disabled = resolveAgentProfileDisabledPluginPaths(
{ plugins: ["listed-plugin"] },
fixture.workspace,
);
expect(disabled).toContain(fixture.unlistedPlugin);
expect(disabled).not.toContain(fixture.alwaysEnabledPlugin);
expect(disabled).not.toContain(fixture.listedPlugin);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("disables everything but always-enabled plugins for an empty list", async () => {
const fixture = await setUpFixture();
try {
await writeFile(
process.env.CLINE_GLOBAL_SETTINGS_PATH ?? "",
JSON.stringify({
alwaysEnabledPlugins: [fixture.alwaysEnabledPlugin],
}),
"utf8",
);
const disabled = resolveAgentProfileDisabledPluginPaths(
{ plugins: [] },
fixture.workspace,
);
expect(disabled).toContain(fixture.listedPlugin);
expect(disabled).toContain(fixture.unlistedPlugin);
expect(disabled).not.toContain(fixture.alwaysEnabledPlugin);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("matches names resolved from an install wrapper package.json", async () => {
const fixture = await setUpFixture();
try {
const installRoot = join(
fixture.home,
".cline",
"plugins",
"_installed",
"registry",
"branch-protector-abc123",
);
const packageRoot = join(installRoot, "package");
await mkdir(packageRoot, { recursive: true });
await writeFile(
join(installRoot, "package.json"),
JSON.stringify({
name: "branch-protector",
private: true,
cline: { plugins: [{ paths: ["./package/index.ts"] }] },
}),
"utf8",
);
const wrappedEntry = join(packageRoot, "index.ts");
await writeFile(wrappedEntry, "export default {}", "utf8");
const disabled = resolveAgentProfileDisabledPluginPaths(
{ plugins: ["branch-protector"] },
fixture.workspace,
);
expect(disabled).not.toContain(wrappedEntry);
expect(disabled).toContain(fixture.listedPlugin);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
});
@@ -1,57 +0,0 @@
import {
discoverPluginModulePaths,
resolveAlwaysEnabledPluginPaths,
resolvePluginConfigSearchPaths,
} from "@cline/core";
import { getPluginDisplayName } from "@cline/shared/storage";
import type { ActiveAgentProfile } from "../utils/types";
/**
* Computes the session-scoped plugin disable list for an agent profile's
* plugins restriction: every installed plugin whose display name is not in
* the profile's list and is not marked always-enabled in global settings.
* Returns undefined when the profile has no plugins field (no restriction).
* Names listed in the profile that match no installed plugin are silently
* ignored. Recomputed on every session (re)start so plugin installs and
* always-enabled toggles apply on the next restart.
*/
export function resolveAgentProfileDisabledPluginPaths(
profile: Pick<ActiveAgentProfile, "plugins"> | undefined,
workspaceRoot: string | undefined,
): string[] | undefined {
const pluginNames = profile?.plugins;
if (!pluginNames) {
return undefined;
}
const allowedNames = new Set(
pluginNames.map((name) => name.trim().toLowerCase()).filter(Boolean),
);
const alwaysEnabled = resolveAlwaysEnabledPluginPaths();
const disabled = new Set<string>();
for (const directory of resolvePluginConfigSearchPaths(workspaceRoot)) {
let pluginPaths: string[];
try {
pluginPaths = discoverPluginModulePaths(directory);
} catch {
continue;
}
for (const pluginPath of pluginPaths) {
if (alwaysEnabled.has(pluginPath)) {
continue;
}
let displayName: string;
try {
displayName = getPluginDisplayName(pluginPath);
} catch {
// Unresolvable name cannot match the allowlist; disable it.
disabled.add(pluginPath);
continue;
}
if (allowedNames.has(displayName.toLowerCase())) {
continue;
}
disabled.add(pluginPath);
}
}
return [...disabled];
}
@@ -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);
@@ -429,15 +530,7 @@ Find installable skills.`,
await writeFile(pluginPath, "export default {};\n");
await writeFile(
process.env.CLINE_GLOBAL_SETTINGS_PATH,
JSON.stringify(
{
disabledPlugins: [pluginPath],
// Stale state: disabled and always-on at the same time.
alwaysEnabledPlugins: [pluginPath],
},
null,
2,
),
JSON.stringify({ disabledPlugins: [pluginPath] }, null, 2),
);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
@@ -451,58 +544,18 @@ Find installable skills.`,
}
const nextData = await loader.onToggleConfigItem(plugin);
const refreshedData = await loader.loadConfigData();
const settings = JSON.parse(
await readFile(process.env.CLINE_GLOBAL_SETTINGS_PATH, "utf8"),
) as { disabledPlugins?: string[]; alwaysEnabledPlugins?: string[] };
) as { disabledPlugins?: string[] };
expect(settings.disabledPlugins).toBeUndefined();
// Toggling sweeps up the stale always-on flag too.
expect(settings.alwaysEnabledPlugins).toBeUndefined();
// Plugin toggles return fresh data so the runtime restarts the session.
expect(nextData).toBeUndefined();
expect(
nextData?.plugins.find((item) => item.path === pluginPath)?.enabled,
refreshedData.plugins.find((item) => item.path === pluginPath)?.enabled,
).toBe(true);
});
it("clears the always-on flag when a plugin is disabled", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
const pluginsDir = join(tempRoot, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
const pluginPath = join(pluginsDir, "workspace-plugin.js");
await writeFile(pluginPath, "export default {};\n");
await writeFile(
process.env.CLINE_GLOBAL_SETTINGS_PATH,
JSON.stringify({ alwaysEnabledPlugins: [pluginPath] }, null, 2),
);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData();
const plugin = data.plugins.find((item) => item.path === pluginPath);
expect(plugin?.enabled).toBe(true);
expect(plugin?.alwaysEnabled).toBe(true);
if (!plugin) {
throw new Error("Expected workspace plugin to be listed");
}
const nextData = await loader.onToggleConfigItem(plugin);
const settings = JSON.parse(
await readFile(process.env.CLINE_GLOBAL_SETTINGS_PATH, "utf8"),
) as { disabledPlugins?: string[]; alwaysEnabledPlugins?: string[] };
expect(settings.disabledPlugins).toEqual([pluginPath]);
expect(settings.alwaysEnabledPlugins).toBeUndefined();
const toggled = nextData?.plugins.find((item) => item.path === pluginPath);
expect(toggled?.enabled).toBe(false);
expect(toggled?.alwaysEnabled).toBe(false);
});
it("deletes a package-backed plugin and refreshes bundled slash commands", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -779,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);
+29 -23
View File
@@ -1,8 +1,9 @@
import {
createCoreSettingsService,
setAlwaysEnabledPlugin,
disablePluginMcpServersInSettings,
setDisabledPlugin,
setDisabledTools,
syncPluginMcpServersToSettings,
type UserInstructionConfigService,
uninstallPlugin,
} from "@cline/core";
@@ -71,16 +72,33 @@ export function createInteractiveConfigDataLoader(input: {
}
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
setDisabledPlugin(item.path, item.enabled);
// Any enable/disable toggle clears always-on: the flag never
// overrides a global disable, so it would be a dead marker on a
// disabled plugin, and clearing on enable too sweeps up stale
// disabled-plus-always-on states. It is only set deliberately via
// the A action on an enabled plugin.
setAlwaysEnabledPlugin(item.path, false);
// Returning fresh data signals the runtime to restart the live
// session so the toggle applies immediately, matching skills/mcp.
return await loadConfigData({ ...options, includePluginTools: true });
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;
}
if (item.kind === "mcp" && typeof item.enabled === "boolean") {
@@ -128,17 +146,6 @@ export function createInteractiveConfigDataLoader(input: {
return undefined;
};
const onToggleAlwaysEnabledConfigItem = async (
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData | undefined> => {
if (item.kind !== "plugin") {
return undefined;
}
setAlwaysEnabledPlugin(item.path, item.alwaysEnabled !== true);
return await loadConfigData(options);
};
const onDeleteConfigItem = async (
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
@@ -159,7 +166,6 @@ export function createInteractiveConfigDataLoader(input: {
return {
loadConfigData,
onToggleConfigItem,
onToggleAlwaysEnabledConfigItem,
onDeleteConfigItem,
};
}
@@ -78,36 +78,4 @@ describe("applyInteractiveModeConfig", () => {
expect(config.extraTools).toEqual([]);
expect(config.systemPrompt).toBe("system prompt for act");
});
it("threads the active agent profile persona across mode switches", async () => {
const config = makeConfig();
config.agentProfile = {
name: "reviewer",
systemPrompt: "You are a reviewer.",
};
await applyInteractiveModeConfig({
config,
mode: "plan",
switchToActModeTool,
});
expect(resolveSystemPrompt).toHaveBeenLastCalledWith({
cwd: config.cwd,
providerId: config.providerId,
mode: "plan",
agentPersona: "You are a reviewer.",
});
await applyInteractiveModeConfig({
config,
mode: "act",
switchToActModeTool,
});
expect(resolveSystemPrompt).toHaveBeenLastCalledWith({
cwd: config.cwd,
providerId: config.providerId,
mode: "act",
agentPersona: "You are a reviewer.",
});
});
});
-1
View File
@@ -43,6 +43,5 @@ export async function applyInteractiveModeConfig(input: {
cwd: input.config.cwd,
providerId: input.config.providerId,
mode: input.mode,
agentPersona: input.config.agentProfile?.systemPrompt,
});
}
@@ -1,7 +1,6 @@
import type { TeamEvent } from "@cline/core";
import type { ChatCommandState } from "../../utils/chat-commands";
import type { Config } from "../../utils/types";
import { resolveAgentProfileDisabledPluginPaths } from "../agent-profile-plugins";
import {
CLI_DEFAULT_CHECKPOINT_CONFIG,
CLI_DEFAULT_LOOP_DETECTION,
@@ -28,11 +27,5 @@ export function buildInteractiveSessionConfig(input: {
hooks: input.runtimeHooks.hooks,
onTeamEvent: input.onTeamEvent,
onConsecutiveMistakeLimitReached: input.resolveMistakeLimitDecision,
// Recomputed on every session (re)start so switching profiles swaps the
// plugin set and reverting to the default agent clears the restriction.
disabledPluginPaths: resolveAgentProfileDisabledPluginPaths(
input.config.agentProfile,
input.chatCommandState.workspaceRoot,
),
};
}
@@ -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,
@@ -21,7 +22,7 @@ import type {
import { createRuntimeHooks } from "../../utils/hooks";
import { setActiveCliSession } from "../../utils/output";
import { loadInteractiveResumeMessages } from "../../utils/resume";
import type { ActiveAgentProfile, Config } from "../../utils/types";
import type { Config } from "../../utils/types";
import { markAbortInProgress } from "../active-runtime";
import type {
PendingPromptSnapshot,
@@ -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,
});
@@ -359,19 +391,6 @@ export function createInteractiveSessionRuntime(input: {
await restartWithCurrentMessages();
};
const applyAgentProfile = async (
profile: ActiveAgentProfile | undefined,
): Promise<void> => {
input.config.agentProfile = profile;
// Re-apply the current mode so the system prompt picks up the persona.
await applyInteractiveModeConfig({
config: input.config,
mode: input.config.mode === "plan" ? "plan" : "act",
switchToActModeTool: input.switchToActModeTool,
});
await restartWithCurrentMessages();
};
const sendCurrentTurn = async (
turnInput: CurrentTurnInput,
): Promise<CurrentTurnResult> => {
@@ -652,7 +671,6 @@ export function createInteractiveSessionRuntime(input: {
getCheckpointData,
restoreCheckpoint,
applyMode,
applyAgentProfile,
resetAbortRequest,
abortAll,
cleanup,
-3
View File
@@ -28,8 +28,6 @@ export async function resolveSystemPrompt(input: {
providerId?: string;
rules?: string;
mode?: AgentMode;
/** Agent profile body that replaces the persona slot of the base prompt */
agentPersona?: string;
}): Promise<string> {
const metadata = await buildWorkspaceMetadata(input.cwd);
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
@@ -47,7 +45,6 @@ export async function resolveSystemPrompt(input: {
mode: input.mode,
providerId: input.providerId,
overridePrompt: input.explicitSystemPrompt,
personaPrompt: input.agentPersona,
platform:
(typeof process !== "undefined" && process?.platform) || "unknown",
});
+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 -54
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,
@@ -37,7 +43,6 @@ import {
setActiveRuntimeAbort,
setActiveRuntimeCleanup,
} from "./active-runtime";
import { resolveAgentProfileDisabledPluginPaths } from "./agent-profile-plugins";
import { createInteractiveApprovalController } from "./interactive/approvals";
import { runInteractiveChatCommand } from "./interactive/chat-command-runner";
import { createInteractiveConfigDataLoader } from "./interactive/config-data";
@@ -91,11 +96,6 @@ export async function runInteractive(
pluginChatCommandHostPromise ??= createWorkspaceChatCommandHost({
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
// Honor the active profile's plugin restriction for slash commands too.
disabledPluginPaths: resolveAgentProfileDisabledPluginPaths(
config.agentProfile,
config.workspaceRoot?.trim() || config.cwd,
),
logger: config.logger,
})
.then(({ host, pluginSlashCommands, shutdown }) => {
@@ -114,19 +114,6 @@ export async function runInteractive(
});
return await pluginChatCommandHostPromise;
};
// Drops the cached plugin command host so the next use reloads it against
// the current plugin set (profile switches and plugin toggles change it).
const resetPluginChatCommandHost = async (): Promise<void> => {
await pluginChatCommandHostPromise?.catch(() => []);
const shutdown = pluginChatCommandHostShutdown;
pluginChatCommandHostShutdown = undefined;
pluginChatCommandHostLoaded = false;
pluginChatSlashCommands = [];
interactiveChatCommandHost = chatCommandHost;
await shutdown?.().catch(() => {
// Best effort cleanup for plugin command discovery sandbox.
});
};
const loadAdditionalSlashCommands = async (): Promise<
InteractiveSlashCommand[]
> => await ensurePluginChatCommandHost();
@@ -139,6 +126,7 @@ export async function runInteractive(
autoApproveAllRef,
setInteractiveAutoApprove,
requestToolApproval,
resolveToolPolicy,
tuiToolApprover,
tuiAskQuestion,
} = createInteractiveApprovalController(config);
@@ -170,6 +158,7 @@ export async function runInteractive(
askQuestionRef: tuiAskQuestion,
});
const providerSettingsManager = new ProviderSettingsManager();
let zeroCurrentTurnCost = false;
const sessionRuntime = createInteractiveSessionRuntime({
config,
@@ -178,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);
@@ -337,27 +327,6 @@ export async function runInteractive(
> => {
const data = await configDataLoader.onToggleConfigItem(item, options);
if (data && shouldRefreshInteractiveSessionForConfigItem(item)) {
if (item.kind === "plugin") {
await resetPluginChatCommandHost();
}
await refreshInteractiveSessionPolicies();
}
return data;
};
const onToggleAlwaysEnabledConfigItem = async (
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
): Promise<
Awaited<ReturnType<typeof configDataLoader.onToggleAlwaysEnabledConfigItem>>
> => {
const data = await configDataLoader.onToggleAlwaysEnabledConfigItem(
item,
options,
);
// The flag only affects the live session while a profile restriction is
// active; without one there is nothing to restart.
if (data && config.agentProfile?.plugins) {
await resetPluginChatCommandHost();
await refreshInteractiveSessionPolicies();
}
return data;
@@ -370,9 +339,6 @@ export async function runInteractive(
> => {
const data = await configDataLoader.onDeleteConfigItem(item, options);
if (data && shouldRefreshInteractiveSessionForConfigItem(item)) {
if (item.kind === "plugin") {
await resetPluginChatCommandHost();
}
await refreshInteractiveSessionPolicies();
}
return data;
@@ -452,7 +418,6 @@ export async function runInteractive(
}),
loadConfigData: configDataLoader.loadConfigData,
onToggleConfigItem,
onToggleAlwaysEnabledConfigItem,
onDeleteConfigItem,
subscribeToEvents: ({
onAgentEvent: onAgent,
@@ -471,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);
@@ -490,6 +457,7 @@ export async function runInteractive(
setInteractiveAutoApprove,
sessionRuntime,
stop: () => tuiApp?.destroy(),
onCommandOutput,
});
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
@@ -509,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,
@@ -551,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();
@@ -570,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()) {
@@ -583,6 +562,7 @@ export async function runInteractive(
usage: { inputTokens: 0, outputTokens: 0 },
iterations: 0,
finishReason: "aborted",
commandOutput,
};
}
logCliError(config.logger, "Interactive turn failed", {
@@ -592,6 +572,7 @@ export async function runInteractive(
});
throw error;
} finally {
zeroCurrentTurnCost = false;
if (!delivery) {
isRunning = false;
clearAbortInProgress();
@@ -642,16 +623,15 @@ export async function runInteractive(
}
await applyModeChange(mode);
},
onAgentProfileChange: async (profile) => {
await sessionRuntime.ensureReady();
await sessionRuntime.applyAgentProfile(profile ?? undefined);
await resetPluginChatCommandHost();
},
onNewSession: async () => {
await sessionRuntime.resetForNewSession();
},
onModelChange: async () => {
await sessionRuntime.ensureReady();
await onProviderChange({
config,
providerId: config.providerId,
});
const existing = providerSettingsManager.getProviderSettings(
config.providerId,
) ?? {
@@ -672,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,
+86
View File
@@ -9,9 +9,15 @@ const coreMocks = vi.hoisted(() => {
return {
getProviderSettings: vi.fn(),
saveProviderSettings: vi.fn(),
fetchMe: vi.fn(),
fetchBalance: vi.fn(),
fetchOrganizationBalance: vi.fn(),
serviceOptions,
};
});
const telemetryMocks = vi.hoisted(() => ({
identifyTelemetryAccount: vi.fn(),
}));
vi.mock("@cline/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@cline/core")>();
@@ -24,6 +30,15 @@ vi.mock("@cline/core", async (importOriginal) => {
}) {
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) {
@@ -36,6 +51,10 @@ vi.mock("@cline/core", async (importOriginal) => {
};
});
vi.mock("../utils/telemetry", () => ({
identifyTelemetryAccount: telemetryMocks.identifyTelemetryAccount,
}));
function makeConfig(overrides: Partial<Config> = {}): Config {
return {
providerId: "cline",
@@ -78,7 +97,11 @@ describe("createClineAccountService", () => {
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(() => {
@@ -163,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),
);
});
});
+37 -1
View File
@@ -14,12 +14,15 @@ import {
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
import { identifyTelemetryAccount } from "../utils/telemetry";
import type { Config } from "../utils/types";
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;
@@ -167,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,
@@ -190,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;
}
@@ -271,35 +271,6 @@ describe("slash command registry", () => {
).toContain("settings");
});
it("exposes agents as a local command with a hidden agent alias", () => {
const registry = buildSlashCommandRegistry({});
const commandNames = getVisibleSystemSlashCommands(registry).map(
(command) => command.name,
);
expect(resolveSlashCommand(registry, "agents")).toMatchObject({
source: "tui",
execution: "local",
description: "Switch agent profile",
visible: true,
selectable: true,
});
expect(resolveSlashCommand(registry, "agent")).toMatchObject({
source: "tui",
execution: "local",
visible: false,
selectable: false,
});
expect(commandNames).toContain("agents");
expect(commandNames).not.toContain("agent");
expect(commandNames.indexOf("agents")).toBeGreaterThan(
commandNames.indexOf("model"),
);
expect(commandNames.indexOf("agents")).toBeLessThan(
commandNames.indexOf("account"),
);
});
it("always exposes the account command", () => {
const registry = buildSlashCommandRegistry({});
@@ -17,8 +17,6 @@ export type LocalSlashCommandName =
| "plugins"
| "account"
| "model"
| "agents"
| "agent"
| "compact"
| "skills"
| "fork"
@@ -64,15 +62,6 @@ const TUI_LOCAL_COMMANDS: Array<{
name: "model",
description: "Switch model or provider",
},
{
name: "agents",
description: "Switch agent profile",
},
{
name: "agent",
description: "Switch agent profile",
visible: false,
},
{
name: "account",
description: "View Cline account",
@@ -123,7 +112,6 @@ const TUI_LOCAL_COMMANDS: Array<{
const SYSTEM_COMMAND_ORDER = [
"settings",
"model",
"agents",
"account",
"mcp",
"plugins",
@@ -2,6 +2,12 @@ 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,
@@ -290,6 +296,65 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
);
}
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;
@@ -388,6 +453,14 @@ export function ChatEntryView(props: {
if (isClineAccountCreditsErrorMessage(entry.text)) {
return <ClineCreditsErrorView defaultFg={defaultFg} />;
}
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
return (
<ClineOrgIndividualInferenceSubscriptionErrorView defaultFg={defaultFg} />
);
}
if (isClinePassSubscriptionError(entry.text)) {
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -1,110 +0,0 @@
import { basename } from "node:path";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useMemo } from "react";
import {
type SearchableItem,
SearchableList,
useSearchableList,
} from "../searchable-list";
/** Sentinel resolved when the user picks the default Cline agent. */
export const DEFAULT_AGENT_ACTION = "__default_agent__";
export interface AgentProfileOption {
name: string;
description?: string;
systemPrompt: string;
plugins?: string[];
source: "workspace" | "global";
}
export interface AgentProfileLoadError {
path: string;
message: string;
}
export function AgentSelectorContent(
props: ChoiceContext<string> & {
currentAgentName: string | null;
agents: AgentProfileOption[];
loadErrors: AgentProfileLoadError[];
},
) {
const { resolve, dismiss, dialogId, currentAgentName, agents, loadErrors } =
props;
const items: SearchableItem[] = useMemo(() => {
const normalizedCurrent = currentAgentName?.trim().toLowerCase() ?? null;
const defaultItem: SearchableItem = {
key: DEFAULT_AGENT_ACTION,
label: "Cline (default)",
detail: "Standard Cline agent",
section: "Agents",
rightLabel: normalizedCurrent === null ? "(current)" : undefined,
};
const profileItems = agents.map((agent) => ({
key: agent.name.toLowerCase(),
label: agent.name,
detail: agent.description,
section:
agent.source === "workspace" ? "Workspace agents" : "Global agents",
rightLabel:
normalizedCurrent === agent.name.trim().toLowerCase()
? "(current)"
: undefined,
}));
return [defaultItem, ...profileItems];
}, [agents, currentAgentName]);
const list = useSearchableList(items);
useDialogKeyboard((key) => {
if (key.name === "escape") {
dismiss();
return;
}
if (key.name === "return") {
const item = list.selectedItem;
if (item) resolve(item.key);
return;
}
if (key.name === "up") {
list.moveUp();
return;
}
if (key.name === "down") {
list.moveDown();
}
}, dialogId);
return (
<box flexDirection="column" gap={1}>
<text>Select Agent</text>
<SearchableList
items={list.filtered}
selected={list.safeSelected}
placeholder="Search agents..."
onSearchChange={list.setSearch}
onItemSelect={(item) => resolve(item.key)}
emptyText="No agents match"
detailPosition="below"
/>
{loadErrors.length > 0 && (
<box flexDirection="column">
{loadErrors.map((error) => (
<text key={error.path} fg="red">
{basename(error.path)}: {error.message}
</text>
))}
</box>
)}
<text fg="gray">
Type to search, / navigate, Enter to select, Esc to go back
</text>
</box>
);
}
@@ -2,7 +2,6 @@ export type CommandPaletteAction =
| "settings"
| "change-model"
| "change-provider"
| "agents"
| "account"
| "mcp"
| "plugins"
@@ -58,13 +57,6 @@ const ACTION_ITEMS: Array<{
description: "Switch provider and configure credentials",
keywords: ["provider", "api key", "account", "auth"],
},
{
action: "agents",
label: "Switch Agent",
shortcut: "Opt+T",
description: "Use an agent profile from .cline/agents",
keywords: ["agent", "agents", "profile", "persona", "subagent"],
},
{
action: "mcp",
label: "Manage MCP Servers",
@@ -120,12 +120,6 @@ const HELP_ROWS: HelpRow[] = [
key: "/model",
desc: "Switch model or provider",
},
{
kind: "entry",
id: "c-agents",
key: "/agents",
desc: "Switch agent profile",
},
{
kind: "entry",
id: "c-settings",
@@ -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>
@@ -2,7 +2,6 @@ import {
completeClineDeviceAuth,
getProviderConfigFields,
isOAuthProvider,
listLocalProviders,
loginLocalProvider,
type ProviderConfigFieldKey,
type ProviderConfigFieldRequirement,
@@ -22,6 +21,7 @@ import {
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { palette } from "../../palette";
import {
getDefaultAwsRegion,
@@ -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) {
+14 -63
View File
@@ -219,8 +219,6 @@ export function useSearchableList(
}
const MAX_VISIBLE = 10;
// Below-mode items take roughly two lines each, so show fewer at once.
const MAX_VISIBLE_DETAIL_BELOW = 6;
export function SearchableList(props: {
items: SearchableItem[];
@@ -230,11 +228,6 @@ export function SearchableList(props: {
onItemSelect?: (item: SearchableItem) => void;
emptyText?: string;
borderColor?: string;
/**
* Where to render item details: truncated inline next to the label
* (default), or word-wrapped in full on their own line below it.
*/
detailPosition?: "inline" | "below";
}) {
const terminalBg = useTerminalBackground();
const defaultFg = getDefaultForeground(terminalBg);
@@ -246,16 +239,11 @@ export function SearchableList(props: {
onItemSelect,
emptyText = "No results",
borderColor = "gray",
detailPosition = "inline",
} = props;
const safeSelected = Math.min(selected, Math.max(0, items.length - 1));
const { visibleRows, aboveCount, belowCount, showAbove, showBelow } =
getSearchableListRowsWindow(
items,
safeSelected,
detailPosition === "below" ? MAX_VISIBLE_DETAIL_BELOW : MAX_VISIBLE,
);
getSearchableListRowsWindow(items, safeSelected, MAX_VISIBLE);
return (
<box flexDirection="column" gap={1}>
@@ -294,21 +282,27 @@ export function SearchableList(props: {
}
const item = row.item;
const isSel = row.itemIndex === safeSelected;
const labelLine = (
<>
return (
<box
key={item.key}
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={isSel ? palette.selection : undefined}
onMouseDown={() => onItemSelect?.(item)}
overflow="hidden"
height={1}
>
<text
fg={isSel ? palette.textOnSelection : "gray"}
flexShrink={0}
>
{isSel ? "\u276f" : " "}
</text>
<text
fg={isSel ? palette.textOnSelection : defaultFg}
flexShrink={0}
>
<text fg={isSel ? palette.textOnSelection : defaultFg}>
{item.label}
</text>
{detailPosition === "inline" && item.detail && (
{item.detail && (
<text
fg={isSel ? palette.textOnSelection : "gray"}
flexShrink={1}
@@ -340,49 +334,6 @@ export function SearchableList(props: {
{item.rightLabel}
</text>
)}
</>
);
if (detailPosition === "below") {
// One container for both lines so the selection highlight
// and mouse target cover the name and the description.
return (
<box
key={item.key}
paddingX={1}
flexDirection="column"
backgroundColor={isSel ? palette.selection : undefined}
onMouseDown={() => onItemSelect?.(item)}
>
<box flexDirection="row" gap={1} overflow="hidden" height={1}>
{labelLine}
</box>
{item.detail && (
// maxHeight bounds pathological descriptions so wrapped
// items cannot grow the list past the dialog height.
<box paddingLeft={2} maxHeight={2} overflow="hidden">
<text
fg={isSel ? palette.textOnSelection : "gray"}
wrapMode="word"
>
{item.detail}
</text>
</box>
)}
</box>
);
}
return (
<box
key={item.key}
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={isSel ? palette.selection : undefined}
onMouseDown={() => onItemSelect?.(item)}
overflow="hidden"
height={1}
>
{labelLine}
</box>
);
})}
+2 -46
View File
@@ -1,8 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import {
createContextBar,
formatStatusBarAgentLabel,
formatStatusBarAgentName,
formatStatusBarUsageText,
resolveContextBarFilledForeground,
} from "./status-bar";
@@ -51,48 +49,6 @@ describe("createContextBar", () => {
});
});
describe("formatStatusBarAgentName", () => {
it("keeps short names intact", () => {
expect(formatStatusBarAgentName("reviewer")).toBe("reviewer");
expect(formatStatusBarAgentName(" reviewer ")).toBe("reviewer");
});
it("truncates long names with an ellipsis", () => {
expect(formatStatusBarAgentName("documentation-specialist")).toBe(
"documentation...",
);
expect(formatStatusBarAgentName("documentation-specialist").length).toBe(
16,
);
});
it("handles very narrow limits without negative slicing", () => {
expect(formatStatusBarAgentName("reviewer", 3)).toBe("...");
expect(formatStatusBarAgentName("reviewer", 0)).toBe("");
});
});
describe("formatStatusBarAgentLabel", () => {
it("returns the trimmed active agent name", () => {
expect(formatStatusBarAgentLabel("reviewer")).toBe("reviewer");
expect(formatStatusBarAgentLabel(" reviewer ")).toBe("reviewer");
});
it("truncates to fit the label width", () => {
expect(formatStatusBarAgentLabel("documentation-specialist", 18)).toBe(
"documentation-s...",
);
expect(
formatStatusBarAgentLabel("documentation-specialist", 18)?.length,
).toBe(18);
});
it("hides blank or too-narrow labels", () => {
expect(formatStatusBarAgentLabel(" ")).toBeUndefined();
expect(formatStatusBarAgentLabel("reviewer", 0)).toBeUndefined();
});
});
describe("formatStatusBarUsageText", () => {
it("includes cost when usage cost is visible", () => {
expect(
@@ -101,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", () => {
@@ -111,6 +67,6 @@ describe("formatStatusBarUsageText", () => {
totalCost: 0.123,
showCost: false,
}),
).toBe("(12,345)");
).toBe("(12,345 tokens)");
});
});
+7 -55
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)}`;
}
@@ -104,23 +104,6 @@ export function resolveModelMaxInputTokens(config: {
return undefined;
}
export function formatStatusBarAgentName(name: string, maxLen = 16): string {
const normalized = name.trim();
if (maxLen <= 0) return "";
if (normalized.length <= maxLen) return normalized;
if (maxLen <= 3) return ".".repeat(maxLen);
return `${normalized.slice(0, maxLen - 3)}...`;
}
export function formatStatusBarAgentLabel(
name: string,
maxLen = 18,
): string | undefined {
const normalized = name.trim();
if (!normalized || maxLen <= 0) return undefined;
return formatStatusBarAgentName(normalized, maxLen);
}
export interface StatusBarProps {
providerId: string;
modelId: string;
@@ -137,9 +120,6 @@ export interface StatusBarProps {
deletions: number;
} | null;
onToggleMode?: () => void;
/** Active agent profile name; the indicator is hidden when unset */
agentName?: string | null;
onOpenAgent?: () => void;
variant?: "home" | "chat";
}
@@ -155,8 +135,6 @@ export function StatusBar(props: StatusBarProps) {
gitBranch,
gitDiffStats,
onToggleMode,
agentName,
onOpenAgent,
} = props;
const { width } = useTerminalDimensions();
@@ -184,26 +162,20 @@ export function StatusBar(props: StatusBarProps) {
? Math.min(width, HOME_VIEW_MAX_WIDTH) - 2
: width - 2;
// Row 1 layout: [model + context info] .... [agent label] [Plan/Act toggle]
// Row 1 layout: [model + context info] .... [Plan/Act toggle]
// When the full row doesn't fit, context info drops to its own row 2.
// Model ID truncates with "..." before wrapping; toggle stays right-aligned.
const toggleWidth = 20;
const fullAgentLabel = agentName
? formatStatusBarAgentLabel(agentName)
: undefined;
const agentLabelWidth = fullAgentLabel ? fullAgentLabel.length + 3 : 0;
const usageText = formatStatusBarUsageText({
totalTokens,
totalCost,
showCost: showUsageCost,
});
const contextInlineText = bar
? `${bar.filled}${bar.empty} ${usageText}`
: usageText;
const contextText = ` ${contextInlineText}`;
const contextText = bar
? ` ${bar.filled}${bar.empty} ${usageText}`
: ` ${usageText}`;
const firstRowFits =
modelId.length + contextText.length + toggleWidth + agentLabelWidth + 1 <=
avail;
modelId.length + contextText.length + toggleWidth + 1 <= avail;
const renderContextText = (withLeadingSpace: boolean) => (
<>
{withLeadingSpace && " "}
@@ -219,11 +191,7 @@ export function StatusBar(props: StatusBarProps) {
const modelMaxLen = Math.max(
10,
avail -
toggleWidth -
agentLabelWidth -
(firstRowFits ? contextText.length : 0) -
1,
avail - toggleWidth - (firstRowFits ? contextText.length : 0) - 1,
);
const truncatedModel =
modelId.length > modelMaxLen
@@ -242,14 +210,6 @@ export function StatusBar(props: StatusBarProps) {
pathPart.length > pathMax
? `${pathPart.slice(0, pathMax - 3)}...`
: pathPart;
const firstRowAgentMaxLen = Math.min(
18,
Math.max(0, avail - toggleWidth - 3),
);
const agentLabel = agentName
? formatStatusBarAgentLabel(agentName, firstRowAgentMaxLen)
: undefined;
return (
<box flexDirection="column" paddingX={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -263,14 +223,6 @@ export function StatusBar(props: StatusBarProps) {
flexShrink={0}
onMouseDown={onToggleMode}
>
{agentLabel && (
<>
<box flexShrink={0} onMouseDown={onOpenAgent}>
<text fg={defaultFg}>{agentLabel}</text>
</box>
<text fg="gray">|</text>
</>
)}
<text fg={uiMode === "plan" ? planAccent : "gray"}>
{uiMode === "plan" ? "●" : "○"} Plan
</text>
+7 -15
View File
@@ -21,7 +21,6 @@ interface SessionContextValue {
uiMode: AgentMode;
autoApproveAll: boolean;
compactionMode: CliCompactionMode;
activeAgentName: string | null;
lastTotalTokens: number;
lastTotalCost: number;
isExitRequested: boolean;
@@ -42,7 +41,6 @@ interface SessionContextValue {
setUiMode: (mode: AgentMode) => void;
toggleMode: () => void;
toggleAutoApprove: () => void;
setActiveAgentName: (name: string | null) => void;
setCompactionMode: (mode: CliCompactionMode) => void;
requestExit: () => void;
clearEntries: () => void;
@@ -108,15 +106,12 @@ 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),
);
const [activeAgentName, setActiveAgentName] = useState<string | null>(
config.agentProfile?.name ?? null,
);
const [lastTotalTokens, setLastTotalTokens] = useState(
() => initialUsage?.totalTokens ?? 0,
);
@@ -197,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(
@@ -255,7 +249,6 @@ export function SessionProvider(props: {
uiMode,
autoApproveAll,
compactionMode,
activeAgentName,
lastTotalTokens,
lastTotalCost,
isExitRequested,
@@ -274,7 +267,6 @@ export function SessionProvider(props: {
setUiMode,
toggleMode,
toggleAutoApprove,
setActiveAgentName,
setCompactionMode,
requestExit,
clearEntries,
+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,
@@ -7,7 +7,6 @@ export interface LocalSlashCommandActionInput {
openConfig: (options?: OpenConfigOptions) => void;
openMcpManager: () => Promise<boolean>;
openModelSelector: () => void;
openAgentSelector: () => void;
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
invocation?: LocalSlashCommandInvocation;
runCompact: () => void;
@@ -46,10 +45,6 @@ export function runLocalSlashCommandAction(
input.openModelSelector();
return true;
}
if (normalized === "agents" || normalized === "agent") {
input.openAgentSelector();
return true;
}
if (normalized === "compact") {
input.runCompact();
return true;
+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":
@@ -1,121 +0,0 @@
import { sep } from "node:path";
import { loadConfiguredAgentConfigs } from "@cline/core";
import type { ChoiceContext } from "@opentui-ui/dialog";
import type { DialogActions } from "@opentui-ui/dialog/react";
import { useCallback } from "react";
import type { Config } from "../../utils/types";
import {
type AgentProfileLoadError,
type AgentProfileOption,
AgentSelectorContent,
DEFAULT_AGENT_ACTION,
} from "../components/dialogs/agent-selector";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import { useSession } from "../contexts/session-context";
import type { TuiProps } from "../types";
function loadAgentProfileEntries(config: Config): {
agents: AgentProfileOption[];
loadErrors: AgentProfileLoadError[];
} {
const workspaceRoot = config.workspaceRoot?.trim() || config.cwd;
const { configs, errors } = loadConfiguredAgentConfigs({ workspaceRoot });
return {
agents: configs.map((profile) => ({
name: profile.name,
description: profile.description,
systemPrompt: profile.systemPrompt,
plugins: profile.plugins?.map((plugin) => plugin.name),
source:
workspaceRoot && profile.path?.startsWith(`${workspaceRoot}${sep}`)
? ("workspace" as const)
: ("global" as const),
})),
loadErrors: errors.map((error) => ({
path: error.path,
message: error.error.message,
})),
};
}
export function useAgentSelector(opts: {
dialog: DialogActions;
config: Config;
termHeight: number;
onAgentProfileChange: TuiProps["onAgentProfileChange"];
refocusTextarea: () => void;
}): () => Promise<void> {
const { dialog, config, termHeight, onAgentProfileChange, refocusTextarea } =
opts;
const session = useSession();
const openAgentSelector = useCallback(async () => {
// Applying a profile restarts the session in place, so refuse while a
// turn is running instead of yanking the live stream out from under it.
if (session.isRunning) {
session.appendEntry({
kind: "status",
text: "Finish or abort the current task before switching agents.",
});
refocusTextarea();
return;
}
const { agents, loadErrors } = loadAgentProfileEntries(config);
const currentAgentName = config.agentProfile?.name ?? null;
const selectedKey = await dialog.choice<string>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<string>) => (
<AgentSelectorContent
{...ctx}
currentAgentName={currentAgentName}
agents={agents}
loadErrors={loadErrors}
/>
),
});
if (!selectedKey) {
refocusTextarea();
return;
}
if (selectedKey === DEFAULT_AGENT_ACTION) {
if (config.agentProfile) {
await withLoadingDialog(dialog, "Applying agent...", async () => {
await onAgentProfileChange(null);
});
session.setActiveAgentName(null);
}
refocusTextarea();
return;
}
const profile = agents.find(
(agent) => agent.name.toLowerCase() === selectedKey,
);
if (!profile) {
refocusTextarea();
return;
}
if (profile.name !== currentAgentName) {
await withLoadingDialog(dialog, "Applying agent...", async () => {
await onAgentProfileChange({
name: profile.name,
systemPrompt: profile.systemPrompt,
plugins: profile.plugins,
});
});
session.setActiveAgentName(profile.name);
}
refocusTextarea();
}, [
dialog,
config,
termHeight,
onAgentProfileChange,
refocusTextarea,
session,
]);
return openAgentSelector;
}
@@ -39,10 +39,6 @@ export function useConfigPanel(opts: {
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
onToggleAlwaysEnabledConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
onDeleteConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
@@ -102,9 +98,6 @@ export function useConfigPanel(opts: {
activeTab = tab;
}}
onToggleConfigItem={opts.onToggleConfigItem}
onToggleAlwaysEnabledConfigItem={
opts.onToggleAlwaysEnabledConfigItem
}
onDeleteConfigItem={opts.onDeleteConfigItem}
onToggleMode={opts.toggleMode}
onToggleAutoApprove={opts.toggleAutoApprove}
@@ -13,7 +13,6 @@ function makeActions(
openConfig: vi.fn(),
openMcpManager: vi.fn(async () => false),
openModelSelector: vi.fn(),
openAgentSelector: vi.fn(),
openSkills: vi.fn(),
runCompact: vi.fn(),
runFork: vi.fn(),
@@ -46,21 +45,6 @@ describe("runLocalSlashCommandAction", () => {
expect(openSkills).toHaveBeenCalledWith(invocation);
});
it("opens the agent selector with agents and the agent alias", () => {
for (const name of ["agents", "agent"]) {
const openAgentSelector = vi.fn();
const actions = makeActions({ openAgentSelector });
const handled = runLocalSlashCommandAction({
name,
...actions,
});
expect(handled).toBe(true);
expect(openAgentSelector).toHaveBeenCalledOnce();
}
});
it("opens settings to the plugins tab with plugins", () => {
const openConfig = vi.fn();
const actions = makeActions({ openConfig });
@@ -23,7 +23,6 @@ export function useLocalCommandActions(input: {
openConfig: (options?: OpenConfigOptions) => void;
openMcpManager: () => Promise<boolean>;
openModelSelector: () => void;
openAgentSelector: () => void;
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
refocusTextarea: () => void;
setAppView: (view: AppView) => void;
@@ -44,7 +43,6 @@ export function useLocalCommandActions(input: {
openConfig,
openMcpManager,
openModelSelector,
openAgentSelector,
openSkills,
refocusTextarea,
setAppView,
@@ -187,7 +185,6 @@ export function useLocalCommandActions(input: {
openConfig,
openMcpManager,
openModelSelector,
openAgentSelector,
openSkills,
runCompact,
runFork,
@@ -208,7 +205,6 @@ export function useLocalCommandActions(input: {
openHelp,
openHistory,
openModelSelector,
openAgentSelector,
openSkills,
runCompact,
runFork,
@@ -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 {
+1 -5
View File
@@ -11,17 +11,13 @@ export function useSlashCommands(input: {
workflowSlashCommands: TuiProps["workflowSlashCommands"];
loadAdditionalSlashCommands: TuiProps["loadAdditionalSlashCommands"];
canFork: boolean;
/** Bump to re-run the loader, e.g. after the plugin set changes. */
refreshKey?: number;
}) {
const { workflowSlashCommands, loadAdditionalSlashCommands, canFork } = input;
const refreshKey = input.refreshKey ?? 0;
const [additionalSlashCommands, setAdditionalSlashCommands] = useState<
TuiProps["workflowSlashCommands"] | undefined
>(loadAdditionalSlashCommands ? [] : undefined);
useEffect(() => {
void refreshKey;
if (!loadAdditionalSlashCommands) {
setAdditionalSlashCommands(undefined);
return;
@@ -41,7 +37,7 @@ export function useSlashCommands(input: {
return () => {
cancelled = true;
};
}, [loadAdditionalSlashCommands, refreshKey]);
}, [loadAdditionalSlashCommands]);
const registry = useMemo(() => {
return buildSlashCommandRegistry({
+117 -38
View File
@@ -1,16 +1,24 @@
import { existsSync } from "node:fs";
import { basename, extname, isAbsolute, relative, resolve } from "node:path";
import { existsSync, readdirSync, readFileSync } from "node:fs";
import {
basename,
dirname,
extname,
isAbsolute,
join,
relative,
resolve,
} from "node:path";
import {
type BuiltinToolAvailabilityContext,
discoverPluginModulePaths,
hasMcpSettingsFile,
listHookConfigFiles,
listPluginToolsWithDiagnostics,
loadConfiguredAgentConfigs,
type McpServerRegistration,
type PluginInitializationFailure,
type RuleConfig,
readGlobalSettings,
resolveAgentConfigSearchPaths,
resolveDefaultMcpSettingsPath,
resolveMcpServerRegistrations,
resolvePluginConfigSearchPaths,
@@ -19,7 +27,6 @@ import {
type UserInstructionConfigService,
type WorkflowConfig,
} from "@cline/core";
import { getPluginDisplayName } from "@cline/shared/storage";
import { getToolCatalog } from "../runtime/tools";
import {
type InteractiveSlashCommand,
@@ -54,8 +61,6 @@ export interface InteractiveConfigItem {
enabled?: boolean;
kind: InteractiveConfigItemKind;
enabledState?: "enabled" | "disabled" | "partial";
/** Plugins only: exempt from agent-profile plugin restrictions. */
alwaysEnabled?: boolean;
toolNames?: string[];
configKind?: "tool" | "plugin";
pluginName?: string;
@@ -81,6 +86,7 @@ export interface InteractiveConfigData {
mcp: InteractiveConfigItem[];
tools: InteractiveConfigItem[];
workflowSlashCommands: InteractiveSlashCommand[];
pluginDiagnosticsLoaded?: boolean;
}
export interface LoadInteractiveConfigDataOptions {
@@ -88,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"
@@ -170,30 +178,92 @@ function getMcpDescription(registration: McpServerRegistration): string {
}
function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
const { configs, errors } = loadConfiguredAgentConfigs({ workspaceRoot });
const items: InteractiveConfigItem[] = configs.map((config) => ({
id: config.name.toLowerCase(),
name: config.name,
path: config.path ?? "",
enabled: true,
kind: "agent" as const,
source: detectSource(config.path ?? "", workspaceRoot),
description: config.description,
}));
// Keep broken profile files visible so users can spot and fix them.
for (const error of errors) {
items.push({
id: error.path,
name: basename(error.path, extname(error.path)),
path: error.path,
enabled: false,
kind: "agent" as const,
source: detectSource(error.path, workspaceRoot),
description: error.error.message,
loadError: error.error.message,
});
const agentsById = new Map<string, InteractiveConfigItem>();
const directories = resolveAgentConfigSearchPaths(workspaceRoot).filter(
(directory) => existsSync(directory),
);
for (const directory of directories) {
try {
const entries = readdirSync(directory, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const extension = extname(entry.name).toLowerCase();
if (extension !== ".yml" && extension !== ".yaml") {
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
const descriptionMatch = frontmatter.match(
/^\s*description:\s*(.+?)\s*$/m,
);
const parsedName = nameMatch?.[1]?.replace(/^["']|["']$/g, "").trim();
const parsedDescription = descriptionMatch?.[1]
?.replace(/^["']|["']$/g, "")
.trim();
const name =
parsedName && parsedName.length > 0
? parsedName
: basename(entry.name, extension);
const id = name.toLowerCase();
if (agentsById.has(id)) {
continue;
}
agentsById.set(id, {
id,
name,
path: filePath,
enabled: true,
kind: "agent",
source: detectSource(filePath, workspaceRoot),
description: parsedDescription,
});
}
} catch {
// Best effort: keep listing other agent config roots.
}
}
return items;
return [...agentsById.values()];
}
function readPackageName(packageJsonPath: string): string | undefined {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
name?: unknown;
};
return typeof packageJson.name === "string" && packageJson.name.trim()
? packageJson.name.trim()
: undefined;
} catch {
return undefined;
}
}
function getPluginDisplayName(filePath: string, searchRoot: string): string {
let current = dirname(filePath);
const root = resolve(searchRoot);
while (isPathWithin(root, current)) {
const packageJsonPath = join(current, "package.json");
if (existsSync(packageJsonPath)) {
const packageName = readPackageName(packageJsonPath);
if (packageName) {
return packageName;
}
break;
}
const parent = resolve(current, "..");
if (parent === current) {
break;
}
current = parent;
}
return basename(filePath, extname(filePath));
}
function isPathWithin(parentPath: string, childPath: string): boolean {
@@ -309,11 +379,7 @@ export async function loadInteractiveConfigData(input: {
agents.push(...loadAgentConfigItems(input.workspaceRoot));
const globalSettings = readGlobalSettings();
const disabledPlugins = new Set(globalSettings.disabledPlugins ?? []);
const alwaysEnabledPlugins = new Set(
globalSettings.alwaysEnabledPlugins ?? [],
);
const disabledPlugins = new Set(readGlobalSettings().disabledPlugins ?? []);
const pluginDirectories = resolvePluginConfigSearchPaths(
input.workspaceRoot,
).filter((directory) => existsSync(directory));
@@ -322,10 +388,9 @@ 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),
alwaysEnabled: alwaysEnabledPlugins.has(filePath),
kind: "plugin",
configKind: "plugin",
source: detectPluginSource(filePath, input.workspaceRoot),
@@ -397,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,
@@ -406,6 +481,8 @@ export async function loadInteractiveConfigData(input: {
source: detectSource(mcpSettingsPath, input.workspaceRoot),
description: getMcpDescription(registration),
loadError: registration.oauth?.lastError,
pluginName,
pluginPath,
});
}
} catch {
@@ -453,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,
});
@@ -472,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 -42
View File
@@ -41,7 +41,6 @@ import { EventBridgeProvider } from "./contexts/event-bridge-context";
import { SessionProvider, useSession } from "./contexts/session-context";
import { useAccountDialog } from "./hooks/use-account-dialog";
import { useAgentEventHandlers } from "./hooks/use-agent-events";
import { useAgentSelector } from "./hooks/use-agent-selector";
import { useAutocomplete } from "./hooks/use-autocomplete";
import { useConfigPanel } from "./hooks/use-config-panel";
import { useLocalCommandActions } from "./hooks/use-local-command-actions";
@@ -91,9 +90,6 @@ function App(props: TuiProps) {
const [workflowSlashCommands, setWorkflowSlashCommands] = useState(
props.workflowSlashCommands,
);
// Bumped after actions that can change the loaded plugin set so plugin
// slash command autocomplete reloads (a no-op when the host kept its cache).
const [pluginCommandsRefreshKey, setPluginCommandsRefreshKey] = useState(0);
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const checkpointRestoreInFlightRef = useRef(false);
@@ -122,7 +118,6 @@ function App(props: TuiProps) {
workflowSlashCommands,
loadAdditionalSlashCommands: props.loadAdditionalSlashCommands,
canFork: canForkSession,
refreshKey: pluginCommandsRefreshKey,
});
const autocomplete = useAutocomplete({
@@ -192,17 +187,6 @@ function App(props: TuiProps) {
refocusTextarea: () => refocusTextareaRef.current(),
});
const openAgentSelector = useAgentSelector({
dialog,
config: props.config,
termHeight,
onAgentProfileChange: async (profile) => {
await props.onAgentProfileChange(profile);
setPluginCommandsRefreshKey((key) => key + 1);
},
refocusTextarea: () => refocusTextareaRef.current(),
});
const openMcpManager = useMcpManager({
dialog,
termHeight,
@@ -219,28 +203,10 @@ function App(props: TuiProps) {
const data = await propsOnToggleConfigItem(item, options);
if (data) {
setWorkflowSlashCommands(data.workflowSlashCommands);
if (item.kind === "plugin") {
setPluginCommandsRefreshKey((key) => key + 1);
}
}
return data;
};
}, [propsOnToggleConfigItem]);
const propsOnToggleAlwaysEnabled = props.onToggleAlwaysEnabledConfigItem;
const onToggleAlwaysEnabledConfigItem = useMemo<
TuiProps["onToggleAlwaysEnabledConfigItem"]
>(() => {
if (!propsOnToggleAlwaysEnabled) {
return undefined;
}
return async (item, options) => {
const data = await propsOnToggleAlwaysEnabled(item, options);
if (data) {
setPluginCommandsRefreshKey((key) => key + 1);
}
return data;
};
}, [propsOnToggleAlwaysEnabled]);
const propsOnDeleteConfigItem = props.onDeleteConfigItem;
const onDeleteConfigItem = useMemo<TuiProps["onDeleteConfigItem"]>(() => {
if (!propsOnDeleteConfigItem) {
@@ -250,9 +216,6 @@ function App(props: TuiProps) {
const data = await propsOnDeleteConfigItem(item, options);
if (data) {
setWorkflowSlashCommands(data.workflowSlashCommands);
if (item.kind === "plugin") {
setPluginCommandsRefreshKey((key) => key + 1);
}
}
return data;
};
@@ -269,7 +232,6 @@ function App(props: TuiProps) {
termHeight,
loadConfigData: props.loadConfigData,
onToggleConfigItem,
onToggleAlwaysEnabledConfigItem: onToggleAlwaysEnabledConfigItem,
onDeleteConfigItem,
openModelSelector,
openMcpManager,
@@ -677,7 +639,6 @@ function App(props: TuiProps) {
openConfig,
openMcpManager,
openModelSelector,
openAgentSelector,
openSkills,
refocusTextarea: () => refocusTextareaRef.current(),
setAppView,
@@ -925,9 +886,6 @@ function App(props: TuiProps) {
void saveQueuedPromptEdit(id, prompt);
},
onToggleMode: toggleMode,
onOpenAgentSelector: () => {
void openAgentSelector();
},
runtimeInteraction,
onResolveToolApproval: runtimeBridge.resolveToolApproval,
onResolveAskQuestion: runtimeBridge.resolveAskQuestion,
@@ -963,6 +921,7 @@ function App(props: TuiProps) {
if (result.reasoningEffort !== undefined) {
props.config.reasoningEffort = result.reasoningEffort;
}
handleModelChange().then(() => setAppView("home"));
}}
onExit={() => {
+2 -10
View File
@@ -15,11 +15,7 @@ import type {
PendingPromptSubmittedEvent,
} from "../runtime/session-events";
import type { RepoStatus } from "../utils/repo-status";
import type {
ActiveAgentProfile,
CliCompactionMode,
Config,
} from "../utils/types";
import type { CliCompactionMode, Config } from "../utils/types";
import type { ClineAccountSnapshot } from "./cline-account";
import type {
InteractiveConfigData,
@@ -141,10 +137,6 @@ export interface TuiProps {
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
onToggleAlwaysEnabledConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
onDeleteConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
@@ -160,6 +152,7 @@ export interface TuiProps {
mode: AgentMode,
delivery?: "queue" | "steer",
attachments?: UserInputAttachments,
onCommandOutput?: (text: string) => void,
) => Promise<InteractiveTurnResult>;
onUpdatePendingPrompt: (input: {
promptId: string;
@@ -174,7 +167,6 @@ export interface TuiProps {
onCompactionModeChange: (mode: CliCompactionMode) => Promise<void>;
onModelChange: () => Promise<void>;
onModeChange: (mode: AgentMode) => Promise<void>;
onAgentProfileChange: (profile: ActiveAgentProfile | null) => Promise<void>;
onNewSession: () => Promise<void>;
onSessionRestart: () => Promise<void>;
onAccountChange: () => Promise<void>;
-3
View File
@@ -56,7 +56,6 @@ export function ChatView(props: {
editingQueuedPrompt?: QueuedPromptItem;
onQueuedPromptEditConfirm: (id: string, prompt: string) => void;
onToggleMode: () => void;
onOpenAgentSelector: () => void;
runtimeInteraction?: RuntimeToolInteraction | null;
onResolveToolApproval: (id: number, approved: boolean) => void;
onResolveAskQuestion: (id: number, answer: string | null) => void;
@@ -158,8 +157,6 @@ export function ChatView(props: {
gitBranch={repoStatus.branch}
gitDiffStats={repoStatus.diffStats}
onToggleMode={props.onToggleMode}
agentName={session.activeAgentName}
onOpenAgent={props.onOpenAgentSelector}
variant="chat"
/>
</box>
+12 -22
View File
@@ -208,39 +208,17 @@ export function canDeleteConfigFooterRow(
);
}
export function canAlwaysEnableConfigFooterRow(
row:
| { kind: "ext"; item: InteractiveConfigItem }
| { kind: string }
| undefined,
): boolean {
// Always-on never overrides a global disable, so the action is only
// offered on enabled plugin rows.
return (
row?.kind === "ext" &&
"item" in row &&
row.item.kind === "plugin" &&
row.item.enabled !== false &&
!row.item.loadError
);
}
export function getConfigFooterText({
canToggle = false,
canDelete = false,
canAlwaysEnable = false,
}: {
canToggle?: boolean;
canDelete?: boolean;
canAlwaysEnable?: boolean;
} = {}): string {
const actions = ["←/→ switch tabs", "↑/↓ navigate", "Tab/Enter select"];
if (canToggle) {
actions.push("Space toggle");
}
if (canAlwaysEnable) {
actions.push("A always-on (*)");
}
if (canDelete) {
actions.push("D delete");
}
@@ -251,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;
}
+12 -38
View File
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import type { InteractiveConfigItem } from "../../tui/interactive-config";
import {
canAlwaysEnableConfigFooterRow,
canToggleConfigFooterRow,
getAdjacentConfigTab,
getConfigFooterText,
@@ -60,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",
@@ -117,43 +128,6 @@ describe("config view helpers", () => {
expect(canToggleConfigFooterRow({ kind: "mcp-manager" })).toBe(false);
});
it("offers the always-on action only for healthy enabled plugin rows", () => {
const plugin = createItem({ kind: "plugin" });
const brokenPlugin = createItem({ kind: "plugin", loadError: "boom" });
const disabledPlugin = createItem({ kind: "plugin", enabled: false });
const skill = createItem({ kind: "skill" });
expect(
canAlwaysEnableConfigFooterRow({
kind: "ext",
item: plugin,
}),
).toBe(true);
expect(
canAlwaysEnableConfigFooterRow({
kind: "ext",
item: brokenPlugin,
}),
).toBe(false);
expect(
canAlwaysEnableConfigFooterRow({
kind: "ext",
item: disabledPlugin,
}),
).toBe(false);
expect(
canAlwaysEnableConfigFooterRow({
kind: "ext",
item: skill,
}),
).toBe(false);
expect(canAlwaysEnableConfigFooterRow({ kind: "toggle" })).toBe(false);
expect(getConfigFooterText({ canAlwaysEnable: true })).toContain(
"A always-on",
);
expect(getConfigFooterText()).not.toContain("A always-on");
});
it("supports restoring and advancing the active settings tab", () => {
expect(resolveInitialConfigTab("skills")).toBe("skills");
expect(resolveInitialConfigTab(undefined)).toBe("general");
+37 -69
View File
@@ -19,13 +19,13 @@ import { resolveModelDisplayName } from "../components/status-bar";
import { getModeAccent, palette } from "../palette";
import {
type ConfigAction,
canAlwaysEnableConfigFooterRow,
canDeleteConfigFooterRow,
canToggleConfigFooterRow,
getAdjacentConfigTab,
getConfigFooterText,
getConfigItemDisplayName,
getConfigTabs,
getPluginDiagnosticsLoadingText,
isInlineConfigAction,
isToggleableConfigItem,
resolveActiveConfigItems,
@@ -137,10 +137,6 @@ export interface ConfigPanelProps extends ChoiceContext<ConfigAction> {
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
onToggleAlwaysEnabledConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
onDeleteConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
@@ -203,6 +199,7 @@ function appendToolGroupRows(
rightLabel: `${enabledCount}/${groupItems.length} tools enabled`,
indent: 2,
});
for (const item of sortBySourceThenName(groupItems)) {
rows.push({
kind: "ext",
@@ -250,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[],
@@ -310,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)) {
@@ -386,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<
@@ -470,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) {
@@ -504,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) {
@@ -537,10 +550,6 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
const canDeleteSelectedRow = Boolean(
props.onDeleteConfigItem && canDeleteConfigFooterRow(selectedRow),
);
const canAlwaysEnableSelectedRow = Boolean(
props.onToggleAlwaysEnabledConfigItem &&
canAlwaysEnableConfigFooterRow(selectedRow),
);
const setNavPosition = (nextNavPos: number) => {
setNavPos(nextNavPos);
@@ -558,10 +567,14 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
if (nextData) {
setConfigData(nextData);
setPluginToolsLoaded(nextData.tools.some((tool) => tool.pluginName));
if (item.kind === "plugin") {
setPluginToolsError(undefined);
}
setPluginToolsLoaded(hasPluginDiagnostics(nextData));
} else if (item.kind === "plugin" && loadConfigData) {
const refreshedData = await loadConfigData({
includePluginTools: true,
});
setConfigData(refreshedData);
setPluginToolsLoaded(hasPluginDiagnostics(refreshedData));
setPluginToolsError(undefined);
}
} catch (error) {
setConfigData(previousData);
@@ -642,37 +655,6 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
}
};
const handleAlwaysEnableSelected = () => {
const row = rows[selectedRowIdx];
if (
!row ||
row.kind !== "ext" ||
!canAlwaysEnableConfigFooterRow(row) ||
!props.onToggleAlwaysEnabledConfigItem ||
togglingItemId
) {
return;
}
const item = row.item;
void (async () => {
setTogglingItemId(item.id);
setToggleError(undefined);
try {
const nextData = await props.onToggleAlwaysEnabledConfigItem?.(item, {
includePluginTools: pluginToolsLoaded,
});
if (nextData) {
setConfigData(nextData);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setToggleError(`Failed to update ${item.name}: ${message}`);
} finally {
setTogglingItemId(null);
}
})();
};
const handleDeleteSelected = () => {
if (!props.onDeleteConfigItem) {
return;
@@ -727,16 +709,6 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
handleDeleteSelected();
return;
}
if (
key.name === "a" &&
!key.ctrl &&
!key.meta &&
!key.option &&
!key.shift
) {
handleAlwaysEnableSelected();
return;
}
if (key.name === "return" || key.name === "tab") {
handleSelect();
}
@@ -907,9 +879,6 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
{prefix}
{enabledIcon}
{getConfigItemDisplayName(row.name)}
{row.item.alwaysEnabled && row.item.enabled !== false
? " *"
: ""}
</text>
<text fg="gray">{rightLabel}</text>
</box>
@@ -942,7 +911,6 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
: getConfigFooterText({
canToggle: canToggleSelectedRow,
canDelete: canDeleteSelectedRow,
canAlwaysEnable: canAlwaysEnableSelectedRow,
})}
</em>
</text>
-3
View File
@@ -46,7 +46,6 @@ export function HomeView(props: {
textareaRef?: React.MutableRefObject<TextareaHandle | null>;
autocomplete?: AutocompleteDropdownProps;
onToggleMode: () => void;
onOpenAgentSelector: () => void;
}) {
const {
config,
@@ -157,8 +156,6 @@ export function HomeView(props: {
gitBranch={repoStatus.branch}
gitDiffStats={repoStatus.diffStats}
onToggleMode={props.onToggleMode}
agentName={session.activeAgentName}
onOpenAgent={props.onOpenAgentSelector}
variant="home"
/>
</box>
@@ -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.",
);
});
});
+27 -3
View File
@@ -9,6 +9,7 @@ import {
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import open from "open";
import { identifyFeatureFlagsAccount } from "../../../utils/feature-flags";
export type OnboardingOAuthProviderId = string;
@@ -18,6 +19,10 @@ export function isOnboardingOAuthProviderId(
return isOAuthProvider(providerId);
}
function isClineAccountOAuthProvider(providerId: string): boolean {
return providerId === "cline" || providerId === "cline-pass";
}
export function runOAuthAuthFlow(input: {
providerId: OnboardingOAuthProviderId;
providerSettingsManager: ProviderSettingsManager;
@@ -56,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) => {
@@ -89,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,
@@ -111,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,
@@ -46,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,
@@ -71,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("");
@@ -153,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;
}
@@ -163,7 +178,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
searchText: "create custom model id manual entry",
} satisfies SearchableItem;
},
[],
[activeProviderId],
);
const modelList = useSearchableList(modelItems, createCustomModelItem);
@@ -256,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") {
@@ -307,7 +322,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const startOAuthFlow = useCallback(
(providerId: OnboardingOAuthProviderId) => {
if (providerId === "cline") {
if (isClineProvider(providerId)) {
startDeviceCodeFlow(providerId);
return;
}
@@ -611,6 +626,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
onExit: props.onExit,
oauthProvider,
activeProviderId,
menuOptions,
menuSelected,
providerList,
modelList,
@@ -685,6 +701,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
},
handleModelItemSelect: selectModelItem,
menuSelected,
menuOptions,
modelItems,
modelList,
modelsLoading,
+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");
@@ -1,12 +1,30 @@
import { describe, expect, it } from "vitest";
import {
getMainMenuOptions,
getOAuthProviderLabel,
shouldUseFeaturedClineModelPicker,
toModelEntriesFromKnownModels,
toModelEntry,
toProviderEntry,
} from "./model";
describe("onboarding model helpers", () => {
it("hides ClinePass from the main menu unless its feature flag is enabled", () => {
expect(
getMainMenuOptions().some((option) => option.value === "cline-pass"),
).toBe(false);
expect(
getMainMenuOptions({ isClinePassEnabled: false }).some(
(option) => option.value === "cline-pass",
),
).toBe(false);
expect(
getMainMenuOptions({ isClinePassEnabled: true }).some(
(option) => option.value === "cline-pass",
),
).toBe(true);
});
it("maps provider catalog entries into onboarding provider entries", () => {
expect(
toProviderEntry({
@@ -112,7 +130,14 @@ describe("onboarding model helpers", () => {
it("formats OAuth provider labels for onboarding status views", () => {
expect(getOAuthProviderLabel("cline")).toBe("Cline");
expect(getOAuthProviderLabel("cline-pass")).toBe("ClinePass");
expect(getOAuthProviderLabel("openai-codex")).toBe("ChatGPT");
expect(getOAuthProviderLabel("oca")).toBe("oca");
});
it("uses the featured Cline model picker only for the Cline provider", () => {
expect(shouldUseFeaturedClineModelPicker("cline")).toBe(true);
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(false);
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
});
});
@@ -43,6 +43,12 @@ export const MAIN_MENU: MenuOption[] = [
detail: "Latest models with regular free promos",
icon: "\u263a",
},
{
label: "Sign in with ClinePass",
value: "cline-pass",
detail: "Low cost subscription for everyone",
icon: "\u2726",
},
{
label: "Sign in with ChatGPT",
value: "openai-codex",
@@ -57,6 +63,14 @@ export const MAIN_MENU: MenuOption[] = [
},
];
export function getMainMenuOptions(options?: {
isClinePassEnabled?: boolean;
}): MenuOption[] {
return MAIN_MENU.filter(
(option) => option.value !== "cline-pass" || options?.isClinePassEnabled,
);
}
export interface OnboardingResult {
providerId: string;
modelId: string;
@@ -139,6 +153,9 @@ export function toModelEntriesFromKnownModels(
}
export function getOAuthProviderLabel(providerId: string): string {
if (providerId === "cline-pass") {
return "ClinePass";
}
if (providerId === "cline") {
return "Cline";
}
@@ -147,3 +164,7 @@ export function getOAuthProviderLabel(providerId: string): string {
}
return providerId;
}
export function shouldUseFeaturedClineModelPicker(providerId: string): boolean {
return providerId === "cline";
}
@@ -20,7 +20,7 @@ import {
import { useTerminalBackground } from "../../hooks/use-terminal-background";
import { getDefaultForeground, palette } from "../../palette";
import { FIELD_ORDER } from "./fields";
import { MAIN_MENU, THINKING_LEVELS } from "./model";
import { type MenuOption, THINKING_LEVELS } from "./model";
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
@@ -633,6 +633,7 @@ export function OnboardingThinkingLevelScreen(props: {
export function OnboardingMainMenuScreen(props: {
contentWidth: number;
menuOptions: MenuOption[];
menuSelected: number;
mouse: MouseTrackerState;
}) {
@@ -671,7 +672,7 @@ export function OnboardingMainMenuScreen(props: {
marginTop={1}
gap={0}
>
{MAIN_MENU.map((option, i) => {
{props.menuOptions.map((option, i) => {
const isSel = i === props.menuSelected;
return (
<box
@@ -166,6 +166,7 @@ export function OnboardingView(props: OnboardingViewProps) {
return (
<OnboardingMainMenuScreen
contentWidth={contentWidth}
menuOptions={state.menuOptions}
menuSelected={state.menuSelected}
mouse={mouse}
/>
+1
View File
@@ -28,6 +28,7 @@ export type ChatCommandContext = {
getState: () => Promise<ChatCommandState> | ChatCommandState;
setState: (next: ChatCommandState) => Promise<void> | void;
reply: (text: string) => Promise<void> | void;
submitPrompt?: (prompt: string) => Promise<void> | void;
reset?: () => Promise<void> | void;
abort?: () => Promise<void> | void;
stop?: () => Promise<void> | void;
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
describe("cline-pass-errors", () => {
it("recognizes both raw and formatted ClinePass subscription messages", () => {
expect(
isClinePassSubscriptionError(
"the user is not subscribed to required model plan",
),
).toBe(true);
const formatted = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getClinePassSubscriptionUrl()}`;
expect(isClinePassSubscriptionError(formatted)).toBe(true);
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
});
it("formats the ClinePass subscription URL", () => {
expect(getClinePassSubscriptionUrl()).toBe(
"https://app.cline.bot/dashboard/subscription?personal=true",
);
});
it("recognizes and formats organization account individual subscription errors", () => {
const raw =
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
const formatted = getClineOrgIndividualInferenceSubscriptionMessage();
expect(isClineOrgIndividualInferenceSubscriptionErrorMessage(raw)).toBe(
true,
);
expect(
isClineOrgIndividualInferenceSubscriptionErrorMessage(
new Error(formatted),
),
).toBe(true);
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
});
});
+69
View File
@@ -0,0 +1,69 @@
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
isClineOrgIndividualInferenceSubscriptionError,
isClineOrgIndividualInferenceSubscriptionMessage,
} from "@cline/core";
export {
getClineOrgIndividualInferenceSubscriptionMessage,
getClinePassSubscriptionUrl,
};
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
return (
normalized.includes("no access to clinepass subscription models yet") &&
normalized.includes("subscribe to clinepass")
);
}
export function isClinePassSubscriptionError(error: unknown): boolean {
if (isClineNotSubscribedError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClineNotSubscribedError" ||
isClineNotSubscribedMessage(error.message) ||
isFormattedClinePassSubscriptionMessage(error.message)
);
}
return (
typeof error === "string" &&
(isClineNotSubscribedMessage(error) ||
isFormattedClinePassSubscriptionMessage(error))
);
}
export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
error: unknown,
): boolean {
if (isClineOrgIndividualInferenceSubscriptionError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClineOrgIndividualInferenceSubscriptionError" ||
isClineOrgIndividualInferenceSubscriptionMessage(error.message) ||
error.message === getClineOrgIndividualInferenceSubscriptionMessage()
);
}
return (
typeof error === "string" &&
(isClineOrgIndividualInferenceSubscriptionMessage(error) ||
error === getClineOrgIndividualInferenceSubscriptionMessage())
);
}
export function formatCliErrorMessage(error: unknown): string {
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
return getClineOrgIndividualInferenceSubscriptionMessage();
}
if (error instanceof Error) {
return error.message;
}
return String(error);
}
+19
View File
@@ -0,0 +1,19 @@
import { afterEach, describe, expect, it } from "vitest";
import {
disposeCliFeatureFlagsService,
getCliFeatureFlagsService,
} from "./feature-flags";
describe("CLI feature flags singleton", () => {
afterEach(async () => {
await disposeCliFeatureFlagsService();
});
it("recreates the singleton after disposal", async () => {
const service = getCliFeatureFlagsService();
await disposeCliFeatureFlagsService();
expect(getCliFeatureFlagsService()).not.toBe(service);
});
});
+118
View File
@@ -0,0 +1,118 @@
import { join } from "node:path";
import {
type BasicLogger,
type FeatureFlagsContext,
FeatureFlagsService,
type ITelemetryService,
NoOpFeatureFlagsProvider,
registerDisposable,
resolveCoreDistinctId,
} from "@cline/core";
import {
buildClinePostHogClient,
PostHogFeatureFlagsProvider,
} from "@cline/core/services/feature-flags/posthog";
import { resolveClineDataDir } from "@cline/shared/storage";
let cliFeatureFlagsContext: FeatureFlagsContext = { clientName: "cline-cli" };
let cliFeatureFlagsService: FeatureFlagsService | undefined;
const CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
function resolveCliFeatureFlagsCachePath(): string {
return join(resolveClineDataDir(), "cache", "feature-flags.json");
}
function ensureCliDistinctId(): string {
const distinctId = cliFeatureFlagsContext.distinctId?.trim();
if (distinctId) {
return distinctId;
}
const resolved = resolveCoreDistinctId();
cliFeatureFlagsContext.distinctId = resolved;
return resolved;
}
export function getCliFeatureFlagsContext(): FeatureFlagsContext {
ensureCliDistinctId();
return { ...cliFeatureFlagsContext };
}
export function getCliFeatureFlagsService(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): FeatureFlagsService {
if (!cliFeatureFlagsService) {
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
const provider =
apiKey &&
process.env.IS_TEST !== "true" &&
process.env.E2E_TEST !== "true"
? new PostHogFeatureFlagsProvider({
client: buildClinePostHogClient(apiKey),
config: {
logger: options?.logger,
},
})
: new NoOpFeatureFlagsProvider();
cliFeatureFlagsService = new FeatureFlagsService({
provider,
telemetry: options?.telemetry,
logger: options?.logger,
context: getCliFeatureFlagsContext(),
cacheFilePath: resolveCliFeatureFlagsCachePath(),
persistentCacheMaxAgeMs: CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
});
registerDisposable(disposeCliFeatureFlagsService);
}
return cliFeatureFlagsService;
}
export function refreshCliFeatureFlagsInBackground(logger?: BasicLogger): void {
const service = getCliFeatureFlagsService({ logger });
void service.poll().catch((error) => {
logger?.error?.("Error refreshing CLI feature flags", { error });
});
}
export async function disposeCliFeatureFlagsService(): Promise<void> {
if (!cliFeatureFlagsService) {
return;
}
const current = cliFeatureFlagsService;
cliFeatureFlagsService = undefined;
await current.dispose();
}
export function setCliFeatureFlagsAccountContext(account: {
id?: string;
email?: string;
}): void {
const accountId = account.id?.trim();
cliFeatureFlagsContext = {
...cliFeatureFlagsContext,
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
...(account.email?.trim() ? { email: account.email.trim() } : {}),
};
cliFeatureFlagsService?.setContext(getCliFeatureFlagsContext());
}
export async function identifyFeatureFlagsAccount(
account: { id?: string; email?: string },
logger?: BasicLogger,
): Promise<void> {
setCliFeatureFlagsAccountContext(account);
if (!cliFeatureFlagsService) {
return;
}
try {
await cliFeatureFlagsService.poll();
} catch (error) {
logger?.error?.("Error polling CLI feature flags", { error });
}
}
+162
View File
@@ -0,0 +1,162 @@
import type { AgentEvent } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
clearClineFreeModelCostCache,
shouldZeroClineFreeModelCost,
zeroCliAgentEventCost,
zeroCliUsageCost,
} from "./free-model-cost";
afterEach(() => {
clearClineFreeModelCostCache();
vi.unstubAllGlobals();
});
describe("shouldZeroClineFreeModelCost", () => {
it("uses the Cline free model list", async () => {
const fetchMock = vi.fn(
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
},
);
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
expect(fetchMock.mock.calls[0]?.[0]).toBe(
"https://cline.test/api/v1/ai/cline/recommended-models",
);
});
it("does not zero non-Cline providers", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "openrouter",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
it("does not match a paid model by only the final path segment", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => {
return new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}),
);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "acme/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
});
it("retries after a failed free model list fetch", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
free: [{ id: "deepseek/deepseek-v4-flash" }],
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
vi.stubGlobal("fetch", fetchMock);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(false);
await expect(
shouldZeroClineFreeModelCost({
providerId: "cline",
modelId: "deepseek/deepseek-v4-flash",
baseUrl: "https://cline.test/api/v1",
}),
).resolves.toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
describe("zeroCliUsageCost", () => {
it("zeros total cost while preserving token usage", () => {
expect(
zeroCliUsageCost(
{
inputTokens: 10,
outputTokens: 5,
totalCost: 0.001,
},
true,
),
).toEqual({
inputTokens: 10,
outputTokens: 5,
totalCost: 0,
});
});
});
describe("zeroCliAgentEventCost", () => {
it("zeros usage event cost fields", () => {
const event = {
type: "usage",
inputTokens: 10,
outputTokens: 5,
cost: 0.001,
totalCost: 0.001,
} as AgentEvent;
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
cost: 0,
totalCost: 0,
});
});
it("zeros done event usage cost", () => {
const event = {
type: "done",
reason: "completed",
text: "ok",
iterations: 1,
usage: {
inputTokens: 10,
outputTokens: 5,
totalCost: 0.001,
},
} as AgentEvent;
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
usage: { totalCost: 0 },
});
});
});
+123
View File
@@ -0,0 +1,123 @@
import type { AgentEvent } from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import type { Config } from "./types";
const CLINE_RECOMMENDED_MODELS_TIMEOUT_MS = 5_000;
const freeModelIdsByBaseUrl = new Map<
string,
Promise<readonly string[] | undefined>
>();
function normalizeModelId(modelId: string | undefined): string {
return modelId?.trim().toLowerCase() ?? "";
}
function modelIdsMatch(selectedModelId: string, freeModelId: string): boolean {
const selected = normalizeModelId(selectedModelId);
const free = normalizeModelId(freeModelId);
if (!selected || !free) return false;
return selected === free;
}
function resolveClineRecommendedModelsUrl(baseUrl: string): string {
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
const apiBaseUrl = normalizedBaseUrl.endsWith("/api/v1")
? normalizedBaseUrl.slice(0, -"/api/v1".length)
: normalizedBaseUrl;
return `${apiBaseUrl}/api/v1/ai/cline/recommended-models`;
}
async function fetchClineFreeModelIds(
baseUrl: string,
): Promise<readonly string[] | undefined> {
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
CLINE_RECOMMENDED_MODELS_TIMEOUT_MS,
);
try {
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
signal: controller.signal,
});
if (!response.ok) return undefined;
const json = (await response.json()) as { free?: unknown };
return Array.isArray(json.free)
? json.free
.map((model) =>
model && typeof model === "object"
? (model as Record<string, unknown>).id
: undefined,
)
.filter((id): id is string => typeof id === "string" && id.length > 0)
: [];
} catch {
return undefined;
} finally {
clearTimeout(timeout);
}
}
function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
const cacheKey = baseUrl.trim();
let cached = freeModelIdsByBaseUrl.get(cacheKey);
if (!cached) {
cached = fetchClineFreeModelIds(cacheKey).then((ids) => {
if (!ids) freeModelIdsByBaseUrl.delete(cacheKey);
return ids;
});
freeModelIdsByBaseUrl.set(cacheKey, cached);
}
return cached.then((ids) => ids ?? []);
}
export async function shouldZeroClineFreeModelCost(
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
): Promise<boolean> {
if (config.providerId !== "cline") return false;
const modelId = normalizeModelId(config.modelId);
if (!modelId) return false;
const baseUrl =
config.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
const freeModelIds = await getClineFreeModelIds(baseUrl);
return freeModelIds.some((freeModelId) =>
modelIdsMatch(modelId, freeModelId),
);
}
export function zeroCliUsageCost<T extends { totalCost?: number } | undefined>(
usage: T,
shouldZeroCost: boolean,
): T {
if (
!shouldZeroCost ||
!usage ||
typeof usage.totalCost !== "number" ||
usage.totalCost === 0
) {
return usage;
}
return { ...usage, totalCost: 0 } as T;
}
export function zeroCliAgentEventCost(
event: AgentEvent,
shouldZeroCost: boolean,
): AgentEvent {
if (!shouldZeroCost) return event;
if (event.type === "done" && event.usage) {
return {
...event,
usage: zeroCliUsageCost(event.usage, true),
};
}
if (event.type !== "usage") return event;
const next = { ...event } as Record<string, unknown>;
if (typeof next.cost === "number") next.cost = 0;
if (typeof next.totalCost === "number") next.totalCost = 0;
return next as unknown as AgentEvent;
}
export function clearClineFreeModelCostCache(): void {
freeModelIdsByBaseUrl.clear();
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import { buildHistoryResumeArgs } from "./history-resume";
describe("buildHistoryResumeArgs", () => {
it("replaces the history subcommand with --id", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history"],
remainingArgs: ["history"],
}),
).toEqual(["--id", "sess_1"]);
});
it("preserves global flags that precede the subcommand", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: [
"--data-dir",
"/tmp/data",
"-m",
"claude-sonnet-4-6",
"history",
"--limit",
"5",
],
remainingArgs: ["history", "--limit", "5"],
}),
).toEqual([
"--data-dir",
"/tmp/data",
"-m",
"claude-sonnet-4-6",
"--id",
"sess_1",
]);
});
it("keeps a global flag value that matches the subcommand alias", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["-m", "h", "h"],
remainingArgs: ["h"],
}),
).toEqual(["-m", "h", "--id", "sess_1"]);
});
it("forwards a config dir passed as a subcommand option", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history", "--config", "/tmp/conf"],
remainingArgs: ["history", "--config", "/tmp/conf"],
configDir: "/tmp/conf",
}),
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
});
it("does not duplicate a config dir already in the global flags", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["--config", "/tmp/conf", "history"],
remainingArgs: ["history"],
configDir: "/tmp/conf",
}),
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
});
it("recognizes the --config=<dir> spelling in global flags", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["--config=/tmp/conf", "history"],
remainingArgs: ["history"],
configDir: "/tmp/conf",
}),
).toEqual(["--config=/tmp/conf", "--id", "sess_1"]);
});
it("returns undefined when remaining args are not a suffix of argv", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history", "--limit", "5"],
remainingArgs: ["history", "--limit", "9"],
}),
).toBeUndefined();
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history"],
remainingArgs: ["extra", "history"],
}),
).toBeUndefined();
});
});
+115
View File
@@ -0,0 +1,115 @@
import { resolveCliLaunchSpec } from "./internal-launch";
export interface HistoryResumeCommand {
launcher: string;
childArgs: string[];
}
export interface BuildHistoryResumeArgsInput {
sessionId: string;
/** Full normalized CLI args (process.argv.slice(2) after normalization). */
normalizedArgs: string[];
/**
* Commander's `program.args` after parsing: the `history` subcommand token
* and everything following it. Must be a suffix of `normalizedArgs`.
*/
remainingArgs: string[];
/**
* Config dir resolved from the full argv. Forwarded explicitly because
* `--config` may have been passed as a `history` subcommand option, which
* would otherwise be dropped with the rest of the subcommand args.
*/
configDir?: string;
}
/**
* Builds argv for relaunching the CLI as `cline <globalFlags> --id <sessionId>`
* after a session is picked in `cline history`. Returns undefined when the
* global-flag prefix cannot be derived safely (caller falls back to resuming
* in-process).
*/
export function buildHistoryResumeArgs(
input: BuildHistoryResumeArgsInput,
): string[] | undefined {
const { sessionId, normalizedArgs, remainingArgs, configDir } = input;
const splitIndex = normalizedArgs.length - remainingArgs.length;
if (splitIndex < 0) {
return undefined;
}
for (let i = 0; i < remainingArgs.length; i++) {
if (normalizedArgs[splitIndex + i] !== remainingArgs[i]) {
return undefined;
}
}
const globalArgs = normalizedArgs.slice(0, splitIndex);
const args = [...globalArgs];
const hasConfigFlag = globalArgs.some(
(arg) => arg === "--config" || arg.startsWith("--config="),
);
if (configDir && !hasConfigFlag) {
args.push("--config", configDir);
}
args.push("--id", sessionId);
return args;
}
export function buildHistoryResumeCommand(
input: BuildHistoryResumeArgsInput,
): HistoryResumeCommand | undefined {
const childArgs = buildHistoryResumeArgs(input);
if (!childArgs) {
return undefined;
}
const spec = resolveCliLaunchSpec();
if (!spec) {
return undefined;
}
return {
launcher: spec.launcher,
childArgs: [...spec.childArgsPrefix, ...childArgs],
};
}
/**
* Resumes a history-picked session in a fresh `cline --id <sessionId>` child
* process with inherited stdio, and returns its exit code. Creating a second
* OpenTUI renderer in the picker's process can crash natively during teardown
* (Bun "panic(main thread): Segmentation fault" on Ctrl+C), so the resumed
* interactive TUI must get a process of its own.
*
* Returns undefined when the child cannot be launched; the caller should fall
* back to resuming in-process.
*/
export async function spawnHistoryResume(
input: BuildHistoryResumeArgsInput,
): Promise<number | undefined> {
const command = buildHistoryResumeCommand(input);
if (!command) {
return undefined;
}
const { spawn } = await import("node:child_process");
return await new Promise<number | undefined>((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(command.launcher, command.childArgs, {
stdio: "inherit",
});
} catch {
resolve(undefined);
return;
}
// The child shares this foreground process group, so terminal-generated
// Ctrl+C already reaches it. Keep the parent alive to reap the child
// without re-forwarding a second signal into the TUI teardown path.
const suppressParentSignal = () => {};
process.on("SIGINT", suppressParentSignal);
process.on("SIGTERM", suppressParentSignal);
const finish = (value: number | undefined) => {
process.off("SIGINT", suppressParentSignal);
process.off("SIGTERM", suppressParentSignal);
resolve(value);
};
child.once("error", () => finish(undefined));
child.once("exit", (code, signal) => finish(signal ? 1 : (code ?? 0)));
});
}
@@ -65,4 +65,55 @@ describe("plugin chat commands", () => {
expect(reply).toHaveBeenCalledWith("echo:hello plugin");
await shutdown?.();
});
it("bridges plugin command submit prompts onto the chat command context", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-plugin-commands-"));
tempRoots.push(tempRoot);
const pluginsDir = join(tempRoot, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
await writeFile(
join(pluginsDir, "submit.js"),
[
"export default {",
" name: 'submit-plugin',",
" manifest: { capabilities: ['commands'] },",
" setup(api) {",
" api.registerCommand({",
" name: 'goal',",
" description: 'Set a goal and submit it',",
" handler: async (input) => ({",
" reply: 'goal:' + input,",
" submitPrompt: input",
" })",
" });",
" },",
"};",
].join("\n"),
);
const { host, shutdown } = await createWorkspaceChatCommandHost({
cwd: tempRoot,
workspaceRoot: tempRoot,
});
const reply = vi.fn(async () => undefined);
const submitPrompt = vi.fn(async () => undefined);
const handled = await host.handle("/goal fix tests", {
enabled: true,
getState: async () => ({
enableTools: false,
autoApproveTools: false,
cwd: tempRoot,
workspaceRoot: tempRoot,
}),
setState: async () => undefined,
reply,
submitPrompt,
});
expect(handled).toBe(true);
expect(reply).toHaveBeenCalledWith("goal:fix tests");
expect(submitPrompt).toHaveBeenCalledWith("fix tests");
await shutdown?.();
});
});
+31 -4
View File
@@ -1,5 +1,6 @@
import {
type AgentExtensionCommand,
type AgentExtensionCommandResult,
type BasicLogger,
createContributionRegistry,
resolveAndLoadAgentPlugins,
@@ -45,17 +46,44 @@ function createPluginCommandDefinition(
names: [normalizedName.toLowerCase()],
run: async ({ args }, context) => {
const result = await command.handler?.(args.join(" "));
if (typeof result === "string" && result.trim()) {
await context.reply(result);
const { reply, submitPrompt } = normalizeCommandResult(result);
if (reply) {
await context.reply(reply);
}
if (submitPrompt) {
await context.submitPrompt?.(submitPrompt);
}
},
};
}
function normalizeCommandResult(
result: AgentExtensionCommandResult | undefined,
): { reply?: string; submitPrompt?: string } {
if (typeof result === "string") {
const reply = result.trim();
return reply ? { reply } : {};
}
if (!result || typeof result !== "object") {
return {};
}
const reply =
typeof result.reply === "string" && result.reply.trim()
? result.reply.trim()
: undefined;
const submitPrompt =
typeof result.submitPrompt === "string" && result.submitPrompt.trim()
? result.submitPrompt.trim()
: undefined;
return {
...(reply ? { reply } : {}),
...(submitPrompt ? { submitPrompt } : {}),
};
}
export async function createWorkspaceChatCommandHost(input: {
cwd: string;
workspaceRoot?: string;
disabledPluginPaths?: ReadonlyArray<string>;
logger?: BasicLogger;
}): Promise<WorkspaceChatCommandHostResult> {
const workspaceRoot = input.workspaceRoot?.trim() || input.cwd;
@@ -64,7 +92,6 @@ export async function createWorkspaceChatCommandHost(input: {
loaded = await resolveAndLoadAgentPlugins({
cwd: input.cwd,
workspacePath: workspaceRoot,
disabledPluginPaths: input.disabledPluginPaths,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);

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