Compare commits

..
Author SHA1 Message Date
Saoud Rizwan cdff084652 chore(sdk): release v0.0.50 2026-06-19 14:11:25 -07:00
Robin NewhouseandSaoud Rizwan 299a4a9520 fix(sdk): harden parallel tool guidance (#11598)
Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-19 12:25:20 -07:00
Saoud Rizwan c497698beb fix(cli): resolve Cline model display names by just model name (#11668)
* fix(cli): resolve Cline model display names by slug

* fix(cli): type model display metadata

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

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

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

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

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

* fix(sdk): constrain binary tool result truncation

* style(sdk): remove disallowed comment formatting

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

This reverts commit 9d8b66726d.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-19 11:15:42 -07:00
Saoud Rizwan bbcae25542 fix(cli): refine marketplace primitive rows (#11659) 2026-06-19 10:57:37 -07:00
Tomás Barreiro 9b914912ac Rename Cline Pass to ClinePass (#11657) 2026-06-19 18:36:07 +02:00
Saoud Rizwan 8c369eeed9 docs(vscode): remove marketplace readme language links (#11658) 2026-06-18 18:09:02 -07:00
Saoud Rizwan 85223a07cf fix(cli): apply auto-approve toggles immediately (#11653) 2026-06-18 15:58:31 -07:00
John ChoiandBarreiroT 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
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
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 Barreiroandgreptile-apps[bot] 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
MaxandMax Paulus 🥪 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
MaxandMax Paulus 🥪 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
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 NewhouseandSaoud Rizwan 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 NewhouseandSaoud Rizwan 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 BarreiroandSaoud Rizwan 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
297 changed files with 27870 additions and 7655 deletions
+37
View File
@@ -1,5 +1,42 @@
# Cline CLI Changelog
## 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.27",
"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: "" }],
+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 {
+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": {
+238 -3
View File
@@ -21,7 +21,15 @@ import {
resolve,
sep,
} from "node:path";
import { type PluginUninstallOptions, uninstallPlugin } from "@cline/core";
import {
type McpServerRegistration,
type PluginMcpSettingsSyncResult,
type PluginUninstallOptions,
resolveDefaultMcpSettingsPath,
resolveMcpServerRegistrations,
syncPluginMcpServersToSettings,
uninstallPlugin,
} from "@cline/core";
import {
isPluginModulePath,
resolveClineDir,
@@ -36,12 +44,31 @@ export interface PluginInstallOptions {
npmCommand?: string;
officialPluginsRepo?: string;
io?: PluginInstallIo;
mcpOAuth?: PluginInstallMcpOAuthOptions;
}
export interface PluginInstallResult {
source: string;
installPath: string;
entryPaths: string[];
mcpSyncFailures: PluginMcpSettingsSyncResult["failures"];
mcpOAuthCandidates: PluginMcpOAuthCandidate[];
}
export interface PluginMcpOAuthCandidate {
name: string;
pluginName: string;
pluginPath: string;
transportType: "sse" | "streamableHttp";
lastError?: string;
}
export interface PluginInstallMcpOAuthOptions {
interactive?: boolean;
selectCandidates?: (
candidates: PluginMcpOAuthCandidate[],
) => Promise<PluginMcpOAuthCandidate[]>;
authorize?: (candidate: PluginMcpOAuthCandidate) => Promise<void>;
}
export interface PluginInstallIo {
@@ -1005,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> {
@@ -1071,28 +1173,161 @@ export async function installPlugin(
}
replaceInstallPath(stagingRoot, installPath, force);
return {
const result = {
source,
installPath,
entryPaths: entryPaths.map((entry) => resolve(installPath, entry)),
mcpSyncFailures: [] as PluginMcpSettingsSyncResult["failures"],
mcpOAuthCandidates: [] as PluginMcpOAuthCandidate[],
};
const syncResult = await syncPluginMcpServersToSettings({
pluginPaths: result.entryPaths,
cwd,
workspacePath: cwd,
});
result.mcpSyncFailures = syncResult.failures;
result.mcpOAuthCandidates = collectPluginMcpOAuthCandidates({
pluginPaths: result.entryPaths,
});
return result;
} catch (error) {
rmSync(stagingRoot, { recursive: true, force: true });
throw error;
}
}
function serializePluginInstallResult(
result: PluginInstallResult,
): Omit<PluginInstallResult, "mcpOAuthCandidates"> {
return {
source: result.source,
installPath: result.installPath,
entryPaths: result.entryPaths,
mcpSyncFailures: result.mcpSyncFailures,
};
}
function isInteractivePluginInstall(options: PluginInstallOptions): boolean {
return (
options.mcpOAuth?.interactive ??
(process.stdin.isTTY && process.stdout.isTTY)
);
}
async function selectMcpOAuthCandidatesWithClack(
candidates: PluginMcpOAuthCandidate[],
): Promise<PluginMcpOAuthCandidate[]> {
const p = await import("@clack/prompts");
const action = await p.select({
message: "Authorize plugin MCP servers now?",
options: [
{
value: "all",
label: "Authorize all",
hint: "open browser authorization for each server",
},
{
value: "choose",
label: "Choose servers",
hint: "select which servers to authorize",
},
{
value: "skip",
label: "Skip",
},
],
});
if (p.isCancel(action) || action === "skip") {
return [];
}
if (action === "all") {
return candidates;
}
const selectedNames = await p.multiselect({
message: "Select MCP servers to authorize",
options: candidates.map((candidate) => ({
value: candidate.name,
label: candidate.name,
hint: `${candidate.transportType} [${candidate.pluginName}]`,
})),
required: false,
});
if (p.isCancel(selectedNames) || !Array.isArray(selectedNames)) {
return [];
}
const selected = new Set(selectedNames);
return candidates.filter((candidate) => selected.has(candidate.name));
}
async function authorizeMcpOAuthCandidate(
candidate: PluginMcpOAuthCandidate,
): Promise<void> {
const { authorizeMcpServerOAuthWithBrowser } = await import(
"../wizards/mcp/oauth"
);
await authorizeMcpServerOAuthWithBrowser(candidate.name, {
throwOnError: true,
});
}
async function runPluginMcpOAuthFollowup(
candidates: PluginMcpOAuthCandidate[],
options: PluginInstallOptions,
): Promise<void> {
if (candidates.length === 0) {
return;
}
if (!isInteractivePluginInstall(options)) {
options.io?.writeln("Plugin MCP servers may require OAuth authorization:");
for (const candidate of candidates) {
options.io?.writeln(
` ${candidate.name} (${candidate.transportType}, plugin: ${candidate.pluginName})`,
);
}
options.io?.writeln(
'Run "cline mcp" and choose "Authorize OAuth" to authorize them.',
);
return;
}
const selected =
options.mcpOAuth?.selectCandidates !== undefined
? await options.mcpOAuth.selectCandidates(candidates)
: await selectMcpOAuthCandidatesWithClack(candidates);
const authorize = options.mcpOAuth?.authorize ?? authorizeMcpOAuthCandidate;
for (const candidate of selected) {
try {
await authorize(candidate);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(
`Warning: failed to authorize MCP server ${candidate.name}: ${message}. Run "cline mcp" and choose "Authorize OAuth" to retry.`,
);
}
}
}
export async function runPluginInstallCommand(
options: PluginInstallOptions & { json?: boolean },
): Promise<number> {
try {
const result = await installPlugin(options);
if (options.json) {
process.stdout.write(JSON.stringify(result));
process.stdout.write(
JSON.stringify(serializePluginInstallResult(result)),
);
return 0;
}
options.io?.writeln(`Installed plugin from ${result.source}`);
options.io?.writeln(` Path: ${result.installPath}`);
for (const failure of result.mcpSyncFailures) {
options.io?.writeErr(
`Warning: failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
);
}
await runPluginMcpOAuthFollowup(result.mcpOAuthCandidates, options);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
-1
View File
@@ -116,7 +116,6 @@ export function createProgram(): Command {
writeOut: () => {}, // suppress by default; main.ts re-enables for routing
writeErr: () => {},
})
.allowUnknownOption()
.allowExcessArguments()
.enablePositionalOptions()
.argument(
+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));
});
});
}
@@ -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 -7
View File
@@ -29,7 +29,9 @@ const authMocks = vi.hoisted(() => ({
runAuthCommand: vi.fn(),
}));
const providerSettingsMocks = vi.hoisted(() => ({
getLastUsedProviderSettings: vi.fn<() => unknown>(() => undefined),
getLastUsedProviderSettings: vi.fn<(options?: unknown) => unknown>(
() => undefined,
),
getProviderConfig: vi.fn<(providerId: string, options?: unknown) => unknown>(
() => undefined,
),
@@ -82,6 +84,11 @@ const historyMocks = vi.hoisted(() => ({
runHistoryExport: vi.fn(async () => 0),
runHistoryUpdate: vi.fn(async () => 0),
}));
const historyResumeMocks = vi.hoisted(() => ({
spawnHistoryResume: vi.fn<() => Promise<number | undefined>>(
async () => undefined,
),
}));
const loggingMocks = vi.hoisted(() => ({
createCliLoggerAdapter: vi.fn(() => ({
core: {
@@ -101,10 +108,14 @@ const hubRuntimeMocks = vi.hoisted(() => ({
}));
const telemetryMocks = vi.hoisted(() => ({
captureCliExtensionActivated: vi.fn(),
identifyCliTelemetryAccount: vi.fn(),
identifyTelemetryAccount: vi.fn(),
getCliTelemetryService: vi.fn(),
disposeCliTelemetryService: vi.fn(async () => {}),
}));
const featureFlagMocks = vi.hoisted(() => ({
getBooleanFlagEnabled: vi.fn(() => false),
setCliFeatureFlagsAccountContext: vi.fn(),
}));
function forcePromptModeInput() {
Object.defineProperty(process.stdin, "isTTY", {
@@ -148,8 +159,8 @@ vi.mock("@cline/core", () => {
stop: vi.fn(),
})),
ProviderSettingsManager: class {
getLastUsedProviderSettings() {
return providerSettingsMocks.getLastUsedProviderSettings();
getLastUsedProviderSettings(options?: unknown) {
return providerSettingsMocks.getLastUsedProviderSettings(options);
}
getProviderSettings(providerId: string) {
return providerSettingsMocks.getProviderSettings(providerId);
@@ -164,6 +175,14 @@ vi.mock("@cline/core", () => {
};
});
vi.mock("./utils/provider-auth", () => authMocks);
vi.mock("./utils/feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
}),
refreshCliFeatureFlagsInBackground: vi.fn(),
setCliFeatureFlagsAccountContext:
featureFlagMocks.setCliFeatureFlagsAccountContext,
}));
vi.mock("./runtime/prompt", () => ({
resolveSystemPrompt: promptMocks.resolveSystemPrompt,
}));
@@ -172,6 +191,7 @@ vi.mock("./commands/dashboard", () => dashboardMocks);
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
vi.mock("./commands/update", () => updateMocks);
vi.mock("./commands/history", () => historyMocks);
vi.mock("./utils/history-resume", () => historyResumeMocks);
vi.mock("./logging/adapter", () => loggingMocks);
vi.mock("./utils/hub-runtime", () => hubRuntimeMocks);
vi.mock("./utils/telemetry", () => telemetryMocks);
@@ -191,6 +211,8 @@ describe("runCli lightweight command dispatch", () => {
historyMocks.runHistoryExport.mockResolvedValue(0);
historyMocks.runHistoryUpdate.mockReset();
historyMocks.runHistoryUpdate.mockResolvedValue(0);
historyResumeMocks.spawnHistoryResume.mockReset();
historyResumeMocks.spawnHistoryResume.mockResolvedValue(undefined);
sessionMocks.getSessionRow.mockReset();
sessionMocks.getSessionRow.mockResolvedValue({
sessionId: "sess_123",
@@ -233,6 +255,9 @@ describe("runCli lightweight command dispatch", () => {
providerSettingsMocks.getProviderSettings.mockReset();
providerSettingsMocks.getProviderSettings.mockReturnValue(undefined);
providerSettingsMocks.saveProviderSettings.mockReset();
featureFlagMocks.getBooleanFlagEnabled.mockReset();
featureFlagMocks.getBooleanFlagEnabled.mockReturnValue(false);
featureFlagMocks.setCliFeatureFlagsAccountContext.mockReset();
kanbanMocks.launchKanban.mockReset();
kanbanMocks.launchKanban.mockResolvedValue(0);
dashboardMocks.runDashboardCommand.mockReset();
@@ -246,7 +271,7 @@ describe("runCli lightweight command dispatch", () => {
updateMocks.getPreferredKanbanInstaller.mockReset();
updateMocks.getPreferredKanbanInstaller.mockReturnValue(undefined);
telemetryMocks.captureCliExtensionActivated.mockReset();
telemetryMocks.identifyCliTelemetryAccount.mockReset();
telemetryMocks.identifyTelemetryAccount.mockReset();
telemetryMocks.getCliTelemetryService.mockReset();
telemetryMocks.disposeCliTelemetryService.mockReset();
telemetryMocks.disposeCliTelemetryService.mockResolvedValue(undefined);
@@ -392,6 +417,61 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runInteractiveImports).toBe(0);
});
it("rejects multiple bare positional prompt tokens", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello", "world"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining(
"Unknown command or extra arguments: hello world",
),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
});
it("runs quoted positional prompt text", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "hello world"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello world",
expect.any(Object),
expect.anything(),
);
});
it("rejects unknown root flags before loading runtime modules", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--made-up-flag"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("unknown option '--made-up-flag'"),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
});
it("creates a worktree and runs prompt sessions from it", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--worktree", "hello"];
@@ -719,10 +799,47 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("forces chat view when resuming from history picker", async () => {
it("resumes a history-picked session in a child process", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(0);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledTimes(1);
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "sess_from_history",
normalizedArgs: ["history"],
remainingArgs: ["history"],
}),
);
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
expect(process.exitCode).toBe(0);
});
it("propagates the child exit code when resuming from history picker", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(3);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(3);
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
});
it("forces chat view when the history-picker child cannot launch", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(undefined);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
@@ -801,6 +918,33 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("seeds feature flag identity from persisted Cline account id before checking flags", async () => {
const clineSettings = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
auth: {
accountId: "acct-startup",
accessToken: "workos:token",
refreshToken: "refresh-token",
},
};
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(
featureFlagMocks.setCliFeatureFlagsAccountContext,
).toHaveBeenCalledWith({ id: "acct-startup" });
expect(
featureFlagMocks.setCliFeatureFlagsAccountContext.mock
.invocationCallOrder[0],
).toBeLessThan(
featureFlagMocks.getBooleanFlagEnabled.mock.invocationCallOrder[0],
);
});
it("runs kanban before loading runtime modules", async () => {
process.argv = ["bun", "src/index.ts", "kanban"];
@@ -818,6 +962,10 @@ describe("runCli lightweight command dispatch", () => {
"bun",
"src/index.ts",
"dashboard",
"--config",
"/tmp/cline-config",
"--data-dir",
".cline-dashboard-data",
"--port",
"9090",
"--no-open",
@@ -828,6 +976,8 @@ describe("runCli lightweight command dispatch", () => {
await expect(runCli()).resolves.toBeUndefined();
expect(dashboardMocks.runDashboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
configDir: "/tmp/cline-config",
dataDir: ".cline-dashboard-data",
port: "9090",
openBrowser: false,
io: expect.any(Object),
@@ -876,7 +1026,7 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "/team", "find", "the", "bug"];
process.argv = ["bun", "src/index.ts", "/team find the bug"];
const { runCli } = await import("./main");
+97 -3
View File
@@ -19,6 +19,11 @@ import {
buildCliCompactionConfig,
CLI_COMPACTION_MODE_EXPECTED_TEXT,
} from "./utils/compaction-mode";
import {
getCliFeatureFlagsService,
refreshCliFeatureFlagsInBackground,
setCliFeatureFlagsAccountContext,
} from "./utils/feature-flags";
import {
configureSandboxEnvironment,
normalizeAutoApproveArgs,
@@ -135,7 +140,7 @@ export async function runCli(): Promise<void> {
// Re-enable built-in help/version output for the routing program
program.configureOutput({
writeOut: (str: string) => process.stdout.write(str),
writeErr: (str: string) => process.stderr.write(str),
writeErr: () => {},
});
// Default action handles non-subcommand args (e.g. prompt text)
program.action(() => {});
@@ -311,6 +316,28 @@ export async function runCli(): Promise<void> {
io,
});
});
const skillCmd = program
.command("skill")
.description("Manage Cline Skills via the open skills CLI (npx skills)")
.allowUnknownOption()
.passThroughOptions()
.argument("[args...]", "arguments forwarded to the skills CLI")
.addHelpText(
"after",
"\nForwards to the open skills CLI via npx. Examples:\n" +
" cline skill add <owner/repo> Add a skill into Cline\n" +
" cline skill install <owner/repo> Alias for add\n" +
" cline skill list List installed skills\n" +
" cline skill remove Remove installed skills\n" +
" cline skill uninstall Alias for remove\n" +
"\nadd/install and remove/uninstall default to '--agent cline' unless you pass your own --agent.\n" +
"Run 'npx skills --help' for the full command reference.",
)
.action(async () => {
const { runSkillCommand } = await import("./commands/skill");
ctx.exitCode = await runSkillCommand(skillCmd.args, io);
});
const connectCmd = program
.command("connect")
.description("Connect to an external channel")
@@ -352,7 +379,7 @@ export async function runCli(): Promise<void> {
}
});
program
const mcpCmd = program
.command("mcp")
.description("Manage MCP servers")
.action(async () => {
@@ -364,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");
@@ -544,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")
@@ -552,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;
@@ -561,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,
@@ -610,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;
@@ -660,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,
@@ -836,8 +920,18 @@ export async function runCli(): Promise<void> {
};
registerDisposable(stopUserInstructionService);
try {
const persistedClineAccountId = providerSettingsManager
.getProviderSettings("cline")
?.auth?.accountId?.trim();
if (persistedClineAccountId) {
setCliFeatureFlagsAccountContext({ id: persistedClineAccountId });
}
refreshCliFeatureFlagsInBackground();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings();
providerSettingsManager.getLastUsedProviderSettings({
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
const provider = normalizeProviderId(
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
);
@@ -61,6 +61,20 @@ describe("createInteractiveApprovalController", () => {
).resolves.toEqual({ approved: false, reason: "no" });
});
it("approves stale required-approval requests after auto-approve is enabled", async () => {
const controller = createInteractiveApprovalController(makeConfig(false));
controller.tuiToolApprover.current = async () => ({
approved: false,
reason: "stale prompt",
});
controller.setInteractiveAutoApprove(true);
await expect(
controller.requestToolApproval(makeRequest({ autoApprove: false })),
).resolves.toEqual({ approved: true });
});
it("denies approval-required requests when no TUI approver is available", async () => {
const controller = createInteractiveApprovalController(makeConfig(false));
@@ -77,6 +91,7 @@ describe("createInteractiveApprovalController", () => {
expect(controller.autoApproveAllRef.current).toBe(true);
expect(config.defaultToolAutoApprove).toBe(false);
expect(config.toolPolicies["*"]?.autoApprove).toBe(false);
expect(config.toolPolicies["*"]?.autoApprove).toBe(true);
expect(controller.resolveToolPolicy("run_commands").autoApprove).toBe(true);
});
});
@@ -3,6 +3,7 @@ import type { Config } from "../../utils/types";
import {
applyInteractiveAutoApproveOverride,
cloneToolPolicies,
resolveInteractiveAutoApprovePolicy,
} from "../tool-policies";
export interface InteractiveRuntimeRefs {
@@ -38,10 +39,10 @@ export function createInteractiveApprovalController(config: Config) {
const requestToolApproval = async (
request: ToolApprovalRequest,
): Promise<ToolApprovalResult> => {
if (request.policy?.autoApprove === true) {
if (autoApproveAllRef.current) {
return { approved: true };
}
if (autoApproveAllRef.current && request.policy?.autoApprove !== false) {
if (request.policy?.autoApprove === true) {
return { approved: true };
}
if (refs.tuiToolApprover.current) {
@@ -54,6 +55,12 @@ export function createInteractiveApprovalController(config: Config) {
autoApproveAllRef,
setInteractiveAutoApprove,
requestToolApproval,
resolveToolPolicy: (toolName: string) =>
resolveInteractiveAutoApprovePolicy({
toolName,
baselinePolicies: baselineToolPolicies,
enabled: autoApproveAllRef.current,
}),
...refs,
};
}
@@ -1,8 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import {
type ChatCommandState,
createChatCommandHost,
chatCommandHost,
createChatCommandHost,
} from "../../utils/chat-commands";
import type { Config } from "../../utils/types";
import {
@@ -1,4 +1,11 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { UserInstructionConfigService } from "@cline/core";
@@ -43,9 +50,17 @@ describe("interactive config data loader", () => {
};
afterEach(async () => {
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
if (envSnapshot.CLINE_GLOBAL_SETTINGS_PATH === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
}
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
}
await Promise.all(
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
);
@@ -76,6 +91,28 @@ describe("interactive config data loader", () => {
return pluginPath;
}
async function writeMcpSettingsPlugin(tempRoot: string): Promise<string> {
const pluginsDir = join(tempRoot, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
const pluginPath = join(pluginsDir, "settings-mcp-plugin.js");
await writeFile(
pluginPath,
[
"export default {",
" name: 'settings-mcp-plugin',",
" manifest: { capabilities: ['mcp'] },",
" setup(api) {",
" api.registerMcpServer({",
" name: 'smoke',",
" transport: { type: 'stdio', command: process.execPath, args: ['-e', 'process.exit(0)'] },",
" });",
" },",
"};",
].join("\n"),
);
return pluginPath;
}
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -311,6 +348,70 @@ Find installable skills.`,
).toBe(true);
});
it("loads plugin-owned MCP servers from settings", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
smoke: {
transport: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
metadata: {
source: "plugin",
pluginName: "settings-mcp-plugin",
pluginPath,
},
},
},
},
null,
2,
)}\n`,
);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: true });
expect(
data.mcp.some(
(item) =>
item.name === "smoke" &&
item.pluginName === "settings-mcp-plugin" &&
item.pluginPath === pluginPath &&
item.kind === "mcp",
),
).toBe(true);
});
it("does not load plugin MCP rows directly from plugin diagnostics", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: true });
expect(data.plugins.some((item) => item.path === pluginPath)).toBe(true);
expect(data.mcp.some((item) => item.pluginPath === pluginPath)).toBe(false);
});
it("keeps failed plugins visible with their load error", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -731,6 +832,142 @@ Review with the bundled skill.`,
).toBe(false);
});
it("disables and re-syncs plugin-owned MCP servers when toggling plugins", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
smoke: {
transport: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
oauth: {
tokens: {
access_token: "token",
},
},
metadata: {
source: "plugin",
pluginName: "settings-mcp-plugin",
pluginPath,
},
},
},
},
null,
2,
)}\n`,
);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const item: InteractiveConfigItem = {
id: pluginPath,
name: "settings-mcp-plugin",
path: pluginPath,
enabled: true,
source: "workspace-plugin",
kind: "plugin",
};
await loader.onToggleConfigItem(item);
let settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<
string,
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
>;
};
expect(settings.mcpServers?.smoke?.disabled).toBe(true);
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
"token",
);
await loader.onToggleConfigItem({ ...item, enabled: false });
settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<
string,
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
>;
};
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
"token",
);
});
it.skipIf(process.platform === "win32")(
"does not mark plugin disabled when MCP disable write fails",
async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
const globalSettingsPath = join(tempRoot, "global-settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = globalSettingsPath;
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
smoke: {
transport: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
metadata: {
source: "plugin",
pluginName: "settings-mcp-plugin",
pluginPath,
},
},
},
},
null,
2,
)}\n`,
);
await chmod(settingsPath, 0o444);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
try {
await expect(
loader.onToggleConfigItem({
id: pluginPath,
name: "settings-mcp-plugin",
path: pluginPath,
enabled: true,
source: "workspace-plugin",
kind: "plugin",
}),
).rejects.toThrow();
} finally {
await chmod(settingsPath, 0o644);
}
await expect(readFile(globalSettingsPath, "utf8")).rejects.toThrow();
const settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<string, { disabled?: boolean }>;
};
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
},
);
it("surfaces MCP OAuth status and errors", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -1,7 +1,9 @@
import {
createCoreSettingsService,
disablePluginMcpServersInSettings,
setDisabledPlugin,
setDisabledTools,
syncPluginMcpServersToSettings,
type UserInstructionConfigService,
uninstallPlugin,
} from "@cline/core";
@@ -70,7 +72,32 @@ export function createInteractiveConfigDataLoader(input: {
}
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
setDisabledPlugin(item.path, item.enabled);
if (item.enabled) {
disablePluginMcpServersInSettings({ pluginPaths: [item.path] });
setDisabledPlugin(item.path, true);
} else {
const ownedMcpMutations = disablePluginMcpServersInSettings({
pluginPaths: [item.path],
});
const result = await syncPluginMcpServersToSettings({
pluginPaths: [item.path],
cwd: input.config.cwd,
workspacePath: workspaceRoot(),
providerId: input.config.providerId,
modelId: input.config.modelId,
});
if (ownedMcpMutations.length > 0 && result.failures.length > 0) {
throw new Error(
`Failed to sync plugin MCP servers: ${result.failures
.map((failure) => {
const plugin = failure.pluginName ?? failure.pluginPath;
return `${plugin}: ${failure.message}`;
})
.join("; ")}`,
);
}
setDisabledPlugin(item.path, false);
}
return undefined;
}
@@ -152,7 +152,10 @@ function deferred<T>() {
function makeRuntime(
manager: ReturnType<typeof makeManager>,
options: { resumeSessionId?: string } = {},
options: {
resumeSessionId?: string;
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
} = {},
) {
mockCreateCliCore.mockResolvedValue(manager);
const config = makeConfig();
@@ -164,6 +167,8 @@ function makeRuntime(
requestToolApproval: async (
_request: ToolApprovalRequest,
): Promise<ToolApprovalResult> => ({ approved: true }),
resolveToolPolicy:
options.resolveToolPolicy ?? (() => ({ autoApprove: true })),
askQuestionRef: { current: null },
resolveMistakeLimitDecision: undefined,
switchToActModeTool: makeSwitchToActModeTool(),
@@ -205,6 +210,68 @@ describe("createInteractiveSessionRuntime", () => {
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("adds a live interactive approval policy hook to started sessions", async () => {
const manager = makeManager();
const upstreamBeforeTool = vi.fn(async () => ({
input: { text: "updated" },
}));
mockCreateRuntimeHooks.mockReturnValueOnce({
hooks: {
beforeTool: upstreamBeforeTool,
},
shutdown: vi.fn(async () => {}),
});
const runtime = makeRuntime(manager, {
resolveToolPolicy: (toolName) => ({
autoApprove: toolName === "echo",
}),
});
await runtime.ensureReady();
const startInput = manager.start.mock.calls[0]?.[0] as
| { config?: Config }
| undefined;
const beforeTool = startInput?.config?.hooks?.beforeTool;
expect(beforeTool).toBeTypeOf("function");
const result = await beforeTool?.({
snapshot: {
agentId: "agent-1",
conversationId: "conversation-1",
status: "running",
iteration: 1,
messages: [],
pendingToolCalls: [],
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
},
},
tool: {
name: "echo",
description: "",
inputSchema: {},
execute: async () => "ok",
},
toolCall: {
type: "tool-call",
toolCallId: "call-1",
toolName: "echo",
input: { text: "original" },
},
input: { text: "original" },
});
expect(upstreamBeforeTool).toHaveBeenCalledOnce();
expect(result).toEqual({
input: { text: "updated" },
policy: { autoApprove: true },
});
});
it("starts fresh after resetting an initially resumed session", async () => {
const manager = makeManager();
const runtime = makeRuntime(manager, {
@@ -1,5 +1,6 @@
import {
type AgentEvent,
type AgentHooks,
type CheckpointEntry,
isSessionNotFoundError,
type PendingPromptMutationResult,
@@ -48,6 +49,32 @@ type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
type AskQuestionRef = {
current: ((question: string, options: string[]) => Promise<string>) | null;
};
type ToolPolicyResolver = (
toolName: string,
) => NonNullable<Config["toolPolicies"]>[string];
function withInteractiveApprovalPolicyHook(
hooks: AgentHooks | undefined,
resolveToolPolicy: ToolPolicyResolver,
): AgentHooks {
return {
...hooks,
beforeTool: async (ctx) => {
const result = await hooks?.beforeTool?.(ctx);
if (result?.stop || result?.skip) {
return result;
}
const policy = resolveToolPolicy(ctx.toolCall.toolName);
return {
...result,
policy: {
...result?.policy,
autoApprove: policy.autoApprove,
},
};
},
};
}
export function createInteractiveSessionRuntime(input: {
config: Config;
@@ -58,6 +85,7 @@ export function createInteractiveSessionRuntime(input: {
requestToolApproval: (
request: ToolApprovalRequest,
) => Promise<ToolApprovalResult>;
resolveToolPolicy: ToolPolicyResolver;
askQuestionRef: AskQuestionRef;
resolveMistakeLimitDecision: Config["onConsecutiveMistakeLimitReached"];
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
@@ -152,10 +180,14 @@ export function createInteractiveSessionRuntime(input: {
if (!runtimeHooks) {
throw new Error("interactive runtime hooks are unavailable");
}
const hooks = withInteractiveApprovalPolicyHook(
runtimeHooks.hooks,
input.resolveToolPolicy,
);
return buildInteractiveSessionConfig({
config: input.config,
chatCommandState: input.chatCommandState,
runtimeHooks,
runtimeHooks: { hooks },
onTeamEvent: input.onTeamEvent,
resolveMistakeLimitDecision: input.resolveMistakeLimitDecision,
});
+101
View File
@@ -27,7 +27,18 @@ const outputMocks = vi.hoisted(() => ({
c: { dim: "", reset: "" },
}));
const CLINE_PASS_SUBSCRIPTION_URL =
"https://app.cline.bot/dashboard/subscription/";
const CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
vi.mock("@cline/core", () => ({
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"),
prewarmFileIndex: vi.fn(async () => undefined),
SessionSource: {
CLI: "cli",
@@ -511,6 +522,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 +620,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");
+3 -2
View File
@@ -16,6 +16,7 @@ import {
requestToolApproval,
submitAndExitInTerminal,
} from "../utils/approval";
import { formatCliErrorMessage } from "../utils/cline-pass-errors";
import { handleEvent, handleTeamEvent } from "../utils/events";
import { createRuntimeHooks } from "../utils/hooks";
import {
@@ -374,7 +375,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 +396,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;
+17
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 {
@@ -120,6 +121,7 @@ export async function runInteractive(
autoApproveAllRef,
setInteractiveAutoApprove,
requestToolApproval,
resolveToolPolicy,
tuiToolApprover,
tuiAskQuestion,
} = createInteractiveApprovalController(config);
@@ -159,6 +161,7 @@ export async function runInteractive(
resumeSessionId,
chatCommandState,
requestToolApproval,
resolveToolPolicy,
askQuestionRef: tuiAskQuestion,
resolveMistakeLimitDecision,
switchToActModeTool,
@@ -611,6 +614,10 @@ export async function runInteractive(
},
onModelChange: async () => {
await sessionRuntime.ensureReady();
await onProviderChange({
config,
providerId: config.providerId,
});
const existing = providerSettingsManager.getProviderSettings(
config.providerId,
) ?? {
@@ -631,6 +638,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;
}
@@ -2,6 +2,10 @@ import { useTerminalDimensions } from "@opentui/react";
import type React from "react";
import { useState } from "react";
import "opentui-spinner/react";
import {
getClinePassSubscriptionUrl,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
import {
CLINE_CREDITS_DASHBOARD_URL,
isClineAccountCreditsErrorMessage,
@@ -290,6 +294,41 @@ 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>
);
}
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
@@ -388,6 +427,9 @@ export function ChatEntryView(props: {
if (isClineAccountCreditsErrorMessage(entry.text)) {
return <ClineCreditsErrorView defaultFg={defaultFg} />;
}
if (isClinePassSubscriptionError(entry.text)) {
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -58,7 +58,11 @@ describe("mcp manager dialog helpers", () => {
};
afterEach(async () => {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
}
await Promise.all(
tempRoots.map((directory) =>
rm(directory, { recursive: true, force: true }),
@@ -107,6 +111,44 @@ describe("mcp manager dialog helpers", () => {
).toBeUndefined();
});
it("does not toggle plugin-owned servers", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-mcp-manager-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
docs: {
transport: {
type: "stdio",
command: "node",
},
},
},
},
null,
2,
)}\n`,
);
const result = toggleMcpServer({
name: "docs",
path: settingsPath,
enabled: true,
pluginName: "repo-docs",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.message).toContain('managed by plugin "repo-docs"');
}
expect((await readSettings(settingsPath)).mcpServers?.docs?.disabled).toBe(
undefined,
);
});
it("returns a visible error message when toggling fails", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-mcp-manager-"));
tempRoots.push(tempRoot);
@@ -13,6 +13,7 @@ export interface McpEntry {
enabled?: boolean;
description?: string;
lastError?: string;
pluginName?: string;
}
export type McpServerToggleResult =
@@ -36,6 +37,12 @@ export function getMcpManagerEntryStatus(
}
export function toggleMcpServer(server: McpEntry): McpServerToggleResult {
if (server.pluginName) {
return {
ok: false,
message: `MCP server "${server.name}" is managed by plugin "${server.pluginName}". Disable the plugin to disable this server.`,
};
}
try {
const currentlyEnabled = server.enabled !== false;
setMcpServerDisabled({
@@ -71,6 +78,7 @@ export function McpManagerContent(
const settingsPath = servers[0]?.path ?? resolveDefaultMcpSettingsPath();
const itemCount = servers.length;
const selectedServer = servers[selected];
const hasPluginOwnedServers = servers.some((server) => server.pluginName);
useDialogKeyboard((key) => {
if (key.name === "escape") {
@@ -150,6 +158,7 @@ export function McpManagerContent(
{isSel ? "\u25b8 " : " "}
{enabledIcon}
{srv.name}
{srv.pluginName ? " *" : ""}
</text>
{status && (
<text fg={srv.lastError ? palette.error : "gray"}>
@@ -184,6 +193,12 @@ export function McpManagerContent(
</box>
)}
{hasPluginOwnedServers && (
<text fg="gray" marginTop={1}>
* managed by plugin; disable the plugin to disable the server.
</text>
)}
<text fg="gray" marginTop={1}>
<em>{getMcpManagerFooterText(servers.length > 0)}</em>
</text>
@@ -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,
@@ -9,6 +9,10 @@ import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import "opentui-spinner/react";
import { palette } from "../../palette";
import {
type KnownModels,
resolveModelDisplayName,
} from "./model-display-name";
export interface ClineModelPickerItem {
kind: "model";
@@ -30,23 +34,6 @@ function tagColor(tag: string): string {
return "cyan";
}
function resolveDisplayName(
modelId: string,
knownModels?: Record<string, unknown>,
): string {
if (knownModels) {
const candidates = [modelId, modelId.split("/").pop()];
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit?.name) return hit.name;
}
}
return modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
}
export function useClineRecommendedModels() {
const [data, setData] = useState<ClineRecommendedModelsData | null>(null);
const [loading, setLoading] = useState(true);
@@ -86,7 +73,7 @@ export function ClineModelPicker(props: {
entries: ClineModelPickerEntry[];
selected: number;
loading?: boolean;
knownModels?: Record<string, unknown>;
knownModels?: KnownModels;
currentModelId?: string;
}) {
const { entries, selected, loading, knownModels, currentModelId } = props;
@@ -126,7 +113,11 @@ export function ClineModelPicker(props: {
}
const tags = entry.model.tags;
const name = resolveDisplayName(entry.model.id, knownModels);
const name = resolveModelDisplayName(
entry.model.id,
knownModels,
entry.model.name,
);
const isCurrent = currentModelId === entry.model.id;
rows.push(
<box
@@ -4,6 +4,10 @@ import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { palette } from "../../palette";
import type { ClineModelPickerEntry } from "./cline-model-picker";
import {
type KnownModels,
resolveModelDisplayName,
} from "./model-display-name";
import { CHANGE_PROVIDER_ACTION } from "./model-selector";
import { ProviderRow } from "./provider-row";
@@ -20,28 +24,11 @@ function tagColor(tag: string): string {
return "cyan";
}
function resolveDisplayName(
modelId: string,
knownModels?: Record<string, unknown>,
): string {
if (knownModels) {
const candidates = [modelId, modelId.split("/").pop()];
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit?.name) return hit.name;
}
}
return modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
}
export function ClineModelSelectorContent(
props: ChoiceContext<string> & {
currentModel: string;
currentProviderName: string;
knownModels?: Record<string, unknown>;
knownModels?: KnownModels;
entries: ClineModelPickerEntry[];
},
) {
@@ -85,7 +72,11 @@ export function ClineModelSelectorContent(
rows.push({
key: entry.model.id,
kind: "model",
label: resolveDisplayName(entry.model.id, knownModels),
label: resolveModelDisplayName(
entry.model.id,
knownModels,
entry.model.name,
),
tags: entry.model.tags,
isCurrent: currentModel === entry.model.id,
entryIndex: i,
@@ -218,7 +209,7 @@ export function ClineModelSelectorDialogContent(
props: ChoiceContext<string> & {
currentModel: string;
currentProviderName: string;
knownModels?: Record<string, unknown>;
knownModels?: KnownModels;
loadEntries: () => Promise<ClineModelPickerEntry[]>;
},
) {
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { resolveModelDisplayName } from "./model-display-name";
describe("resolveModelDisplayName", () => {
it("resolves display names by exact model id", () => {
expect(
resolveModelDisplayName("claude-sonnet", {
"claude-sonnet": { name: "Claude Sonnet" },
}),
).toBe("Claude Sonnet");
});
it("resolves display names by model slug when provider prefixes differ", () => {
expect(
resolveModelDisplayName("zai/glm-5.2", {
"z-ai/glm-5.2": { id: "z-ai/glm-5.2", name: "GLM-5.2" },
}),
).toBe("GLM-5.2");
});
it("prefers exact model id matches over slug matches", () => {
expect(
resolveModelDisplayName("zai/glm-5.2", {
"z-ai/glm-5.2": { id: "z-ai/glm-5.2", name: "OpenRouter GLM" },
"zai/glm-5.2": { id: "zai/glm-5.2", name: "Vercel GLM" },
}),
).toBe("Vercel GLM");
});
it("uses the fallback name when no catalog entry matches", () => {
expect(resolveModelDisplayName("zai/glm-5.2", {}, "GLM 5.2")).toBe(
"GLM 5.2",
);
});
it("falls back to the model slug without known models", () => {
expect(resolveModelDisplayName("zai/glm-5.2")).toBe("glm-5.2");
});
});
@@ -0,0 +1,41 @@
export interface KnownModelInfo {
id?: string;
name?: string;
capabilities?: string[];
maxInputTokens?: number;
contextWindow?: number;
}
export type KnownModels = Record<string, KnownModelInfo>;
export function resolveKnownModelInfo(
modelId: string,
knownModels?: KnownModels,
): KnownModelInfo | undefined {
if (!knownModels) return undefined;
const exactMatch = knownModels[modelId];
if (exactMatch !== undefined) return exactMatch;
const modelSlug = modelId.split("/").pop();
return Object.entries(knownModels).find(([key, model]) => {
return (
key.split("/").pop() === modelSlug ||
model.id?.split("/").pop() === modelSlug
);
})?.[1];
}
export function resolveModelDisplayName(
modelId: string,
knownModels?: KnownModels,
fallbackName?: string,
): string {
const modelInfo = resolveKnownModelInfo(modelId, knownModels);
if (modelInfo?.name) return modelInfo.name;
if (fallbackName) return fallbackName;
return modelId.includes("/")
? (modelId.split("/").pop() ?? modelId)
: modelId;
}
@@ -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) {
@@ -3,6 +3,8 @@ import {
createContextBar,
formatStatusBarUsageText,
resolveContextBarFilledForeground,
resolveModelDisplayName,
resolveModelMaxInputTokens,
} from "./status-bar";
vi.mock("@opentui/react", () => ({
@@ -70,3 +72,31 @@ describe("formatStatusBarUsageText", () => {
).toBe("(12,345)");
});
});
describe("model display helpers", () => {
it("resolves status-bar model names by model slug when provider prefixes differ", () => {
expect(
resolveModelDisplayName({
modelId: "zai/glm-5.2",
knownModels: {
"z-ai/glm-5.2": { id: "z-ai/glm-5.2", name: "GLM-5.2" },
},
}),
).toBe("GLM-5.2");
});
it("resolves max input tokens by model slug when provider prefixes differ", () => {
expect(
resolveModelMaxInputTokens({
modelId: "zai/glm-5.2",
knownModels: {
"z-ai/glm-5.2": {
id: "z-ai/glm-5.2",
name: "GLM-5.2",
maxInputTokens: 262_144,
},
},
}),
).toBe(262_144);
});
});
+9 -25
View File
@@ -11,6 +11,11 @@ import {
getSuccessColor,
} from "../palette";
import { HOME_VIEW_MAX_WIDTH } from "../types";
import {
type KnownModels,
resolveModelDisplayName as resolveKnownModelDisplayName,
resolveKnownModelInfo,
} from "./model-selector/model-display-name";
export function createContextBar(
used: number,
@@ -56,31 +61,13 @@ export function formatStatusBarUsageText(input: {
return `${tokens} ${formatCost(input.totalCost)}`;
}
// knownModels keys are bare IDs ("claude-sonnet-4-6") but config.modelId
// may include a provider prefix ("anthropic/claude-sonnet-4-6"), so we
// try the full ID first, then strip the prefix and retry.
function lookupModelInfo(
modelId: string,
knownModels?: Record<string, unknown>,
): { name?: string } | undefined {
if (!knownModels) return undefined;
const candidates = [modelId, modelId.split("/").pop()];
for (const key of candidates) {
if (!key) continue;
const hit = knownModels[key] as { name?: string } | undefined;
if (hit) return hit;
}
return undefined;
}
export function resolveModelDisplayName(config: {
modelId: string;
knownModels?: Record<string, unknown>;
knownModels?: KnownModels;
thinking?: boolean;
reasoningEffort?: string;
}): string {
const info = lookupModelInfo(config.modelId, config.knownModels);
const name = info?.name ?? config.modelId.split("/").pop() ?? config.modelId;
const name = resolveKnownModelDisplayName(config.modelId, config.knownModels);
if (config.thinking && config.reasoningEffort) {
return `${name} (${config.reasoningEffort})`;
}
@@ -89,12 +76,9 @@ export function resolveModelDisplayName(config: {
export function resolveModelMaxInputTokens(config: {
modelId: string;
knownModels?: Record<string, unknown>;
knownModels?: KnownModels;
}): number | undefined {
const info = (lookupModelInfo(config.modelId, config.knownModels) ?? {}) as {
maxInputTokens?: number;
contextWindow?: number;
};
const info = resolveKnownModelInfo(config.modelId, config.knownModels) ?? {};
if (typeof info.maxInputTokens === "number" && info.maxInputTokens > 0) {
return info.maxInputTokens;
}
@@ -106,9 +106,9 @@ export function SessionProvider(props: {
const [uiMode, setUiMode] = useState<AgentMode>(
config.mode === "plan" ? "plan" : "act",
);
const [autoApproveAll, _setAutoApproveAll] = useState(
config.toolPolicies["*"]?.autoApprove !== false,
);
const initialAutoApproveAll = config.toolPolicies["*"]?.autoApprove !== false;
const autoApproveAllRef = useRef(initialAutoApproveAll);
const [autoApproveAll, _setAutoApproveAll] = useState(initialAutoApproveAll);
const [compactionMode, _setCompactionMode] = useState<CliCompactionMode>(() =>
getCliCompactionMode(config),
);
@@ -192,11 +192,10 @@ export function SessionProvider(props: {
}, []);
const toggleAutoApprove = useCallback(() => {
_setAutoApproveAll((prev) => {
const next = !prev;
onAutoApproveChange(next);
return next;
});
const next = !autoApproveAllRef.current;
autoApproveAllRef.current = next;
onAutoApproveChange(next);
_setAutoApproveAll(next);
}, [onAutoApproveChange]);
const setCompactionMode = useCallback(
+21 -14
View File
@@ -17,7 +17,9 @@ export async function renderHistoryStandalone(input: {
});
return new Promise((resolve) => {
let settled = false;
let result: number | string = 0;
let resolved = false;
let destroyStarted = false;
let unmounted = false;
const root = createRoot(renderer);
@@ -29,24 +31,29 @@ export async function renderHistoryStandalone(input: {
root.unmount();
};
const settle = (value: number | string) => {
if (settled) {
return;
}
settled = true;
unmountRoot();
renderer.destroy();
resolve(value);
};
// Resolve only once teardown has finished, so callers never run while
// the renderer is still restoring the terminal.
renderer.on("destroy", () => {
unmountRoot();
if (!settled) {
settled = true;
resolve(0);
if (!resolved) {
resolved = true;
resolve(result);
}
});
const settle = (value: number | string) => {
if (destroyStarted) {
return;
}
destroyStarted = true;
result = value;
unmountRoot();
// Let OpenTUI finish parsing the current stdin batch before teardown.
queueMicrotask(() => {
renderer.destroy();
});
};
root.render(
React.createElement(HistoryStandaloneContent, {
rows: input.rows,
+5 -1
View File
@@ -4,6 +4,7 @@ import type {
PendingPromptSnapshot,
PendingPromptSubmittedEvent,
} from "../../runtime/session-events";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { resolveStatusNoticeLabel } from "../../utils/events";
import {
formatToolInput,
@@ -171,7 +172,10 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
turnErrorReportedRef.current = true;
onTurnErrorReported(true);
if (!event.recoverable || verbose) {
appendEntry({ kind: "error", text: event.error.message });
appendEntry({
kind: "error",
text: formatCliErrorMessage(event.error),
});
}
break;
case "notice":
@@ -17,6 +17,7 @@ function toMcpEntries(items: InteractiveConfigItem[]): McpEntry[] {
enabled: item.enabled,
description: item.description,
lastError: item.loadError,
pluginName: item.pluginName,
}));
}
@@ -179,7 +179,6 @@ async function runProviderChange(
config.providerId = newProviderId;
config.apiKey = newApiKey;
const resolved = await resolveProviderConfig(
newProviderId,
{
@@ -300,7 +299,7 @@ export function useModelSelector(opts: {
{...ctx}
currentModel={config.modelId}
currentProviderName={providerDisplayName}
knownModels={config.knownModels as Record<string, unknown>}
knownModels={config.knownModels}
loadEntries={async () =>
buildClineModelEntries(await fetchClineRecommendedModels())
}
@@ -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 {
@@ -376,7 +377,7 @@ export function usePromptInputController(input: {
if (!turnErrorReportedRef.current) {
session.appendEntry({
kind: "error",
text: error instanceof Error ? error.message : String(error),
text: formatCliErrorMessage(error),
});
}
} finally {
+23 -5
View File
@@ -86,6 +86,7 @@ export interface InteractiveConfigData {
mcp: InteractiveConfigItem[];
tools: InteractiveConfigItem[];
workflowSlashCommands: InteractiveSlashCommand[];
pluginDiagnosticsLoaded?: boolean;
}
export interface LoadInteractiveConfigDataOptions {
@@ -93,12 +94,14 @@ export interface LoadInteractiveConfigDataOptions {
}
export function isToggleableInteractiveConfigItem(
item: Pick<InteractiveConfigItem, "kind" | "source">,
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
): boolean {
if (item.kind === "mcp") {
return !item.pluginName;
}
return (
item.kind === "skill" ||
item.kind === "plugin" ||
item.kind === "mcp" ||
item.source === "builtin" ||
item.source === "workspace-plugin" ||
item.source === "global-plugin"
@@ -242,9 +245,10 @@ function readPackageName(packageJsonPath: string): string | undefined {
}
}
function getPluginDisplayName(filePath: string): string {
function getPluginDisplayName(filePath: string, searchRoot: string): string {
let current = dirname(filePath);
for (let depth = 0; depth < 4; depth++) {
const root = resolve(searchRoot);
while (isPathWithin(root, current)) {
const packageJsonPath = join(current, "package.json");
if (existsSync(packageJsonPath)) {
const packageName = readPackageName(packageJsonPath);
@@ -384,7 +388,7 @@ export async function loadInteractiveConfigData(input: {
for (const filePath of discoverPluginModulePaths(directory)) {
plugins.push({
id: filePath,
name: getPluginDisplayName(filePath),
name: getPluginDisplayName(filePath, directory),
path: filePath,
enabled: !disabledPlugins.has(filePath),
kind: "plugin",
@@ -458,6 +462,16 @@ export async function loadInteractiveConfigData(input: {
for (const registration of resolveMcpServerRegistrations({
filePath: mcpSettingsPath,
})) {
const pluginName =
registration.metadata?.source === "plugin" &&
typeof registration.metadata.pluginName === "string"
? registration.metadata.pluginName
: undefined;
const pluginPath =
registration.metadata?.source === "plugin" &&
typeof registration.metadata.pluginPath === "string"
? registration.metadata.pluginPath
: undefined;
mcp.push({
id: registration.name,
name: registration.name,
@@ -467,6 +481,8 @@ export async function loadInteractiveConfigData(input: {
source: detectSource(mcpSettingsPath, input.workspaceRoot),
description: getMcpDescription(registration),
loadError: registration.oauth?.lastError,
pluginName,
pluginPath,
});
}
} catch {
@@ -514,6 +530,7 @@ export async function loadInteractiveConfigData(input: {
toolNames: [pluginTool.name],
configKind: "tool",
pluginName: pluginTool.pluginName,
pluginPath: pluginTool.path,
source: pluginTool.source,
description: pluginTool.description,
});
@@ -533,5 +550,6 @@ export async function loadInteractiveConfigData(input: {
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
tools: toSorted(tools),
workflowSlashCommands,
pluginDiagnosticsLoaded: input.includePluginTools !== false,
};
}
+1
View File
@@ -921,6 +921,7 @@ function App(props: TuiProps) {
if (result.reasoningEffort !== undefined) {
props.config.reasoningEffort = result.reasoningEffort;
}
handleModelChange().then(() => setAppView("home"));
}}
onExit={() => {
@@ -229,3 +229,15 @@ export function getConfigFooterText({
export function getConfigItemDisplayName(name: string): string {
return name;
}
export function getPluginDiagnosticsLoadingText(
tab: InteractiveConfigTab,
): string | undefined {
if (tab === "tools") {
return "Loading plugin tools...";
}
if (tab === "plugins") {
return "Loading plugin diagnostics...";
}
return undefined;
}
@@ -59,6 +59,18 @@ describe("config view helpers", () => {
expect(isToggleableConfigItem(createItem({ kind: "mcp" }))).toBe(true);
});
it("does not treat plugin MCP rows as toggleable", () => {
expect(
isToggleableConfigItem(
createItem({
kind: "mcp",
pluginName: "plugin",
source: "workspace-plugin",
}),
),
).toBe(false);
});
it("resolves Enter/Tab on a skill row to details", () => {
const skill = createItem({
kind: "skill",
+31 -15
View File
@@ -25,6 +25,7 @@ import {
getConfigFooterText,
getConfigItemDisplayName,
getConfigTabs,
getPluginDiagnosticsLoadingText,
isInlineConfigAction,
isToggleableConfigItem,
resolveActiveConfigItems,
@@ -198,6 +199,7 @@ function appendToolGroupRows(
rightLabel: `${enabledCount}/${groupItems.length} tools enabled`,
indent: 2,
});
for (const item of sortBySourceThenName(groupItems)) {
rows.push({
kind: "ext",
@@ -245,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[],
@@ -305,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)) {
@@ -381,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<
@@ -465,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) {
@@ -499,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) {
@@ -549,15 +567,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
if (nextData) {
setConfigData(nextData);
setPluginToolsLoaded(nextData.tools.some((tool) => tool.pluginName));
setPluginToolsLoaded(hasPluginDiagnostics(nextData));
} else if (item.kind === "plugin" && loadConfigData) {
const refreshedData = await loadConfigData({
includePluginTools: true,
});
setConfigData(refreshedData);
setPluginToolsLoaded(
refreshedData.tools.some((tool) => tool.pluginName),
);
setPluginToolsLoaded(hasPluginDiagnostics(refreshedData));
setPluginToolsError(undefined);
}
} catch (error) {
@@ -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,6 +156,10 @@ 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 },
+17
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) => {
@@ -118,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,19 +9,23 @@ 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,
type ClineModelPickerEntry,
useClineRecommendedModels,
} from "../../components/model-selector/cline-model-picker";
import type { KnownModels } from "../../components/model-selector/model-display-name";
import {
type SearchableItem,
useSearchableList,
@@ -46,11 +49,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 +76,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 +166,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 +179,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
searchText: "create custom model id manual entry",
} satisfies SearchableItem;
},
[],
[activeProviderId],
);
const modelList = useSearchableList(modelItems, createCustomModelItem);
@@ -179,7 +195,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
Set<string>
>(new Set());
const [clineKnownModels, setClineKnownModels] = useState<
Record<string, unknown> | undefined
KnownModels | undefined
>(undefined);
useEffect(() => {
@@ -256,7 +272,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 +323,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const startOAuthFlow = useCallback(
(providerId: OnboardingOAuthProviderId) => {
if (providerId === "cline") {
if (isClineProvider(providerId)) {
startDeviceCodeFlow(providerId);
return;
}
@@ -611,6 +627,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
onExit: props.onExit,
oauthProvider,
activeProviderId,
menuOptions,
menuSelected,
providerList,
modelList,
@@ -685,6 +702,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";
}
@@ -8,6 +8,7 @@ import {
ClineModelPicker,
type ClineModelPickerEntry,
} from "../../components/model-selector/cline-model-picker";
import type { KnownModels } from "../../components/model-selector/model-display-name";
import {
type SearchableItem,
SearchableList,
@@ -20,7 +21,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>;
@@ -433,7 +434,7 @@ export function OnboardingProviderPickerScreen(props: {
export function OnboardingClineModelScreen(props: {
clineEntries: ClineModelPickerEntry[];
clineKnownModels: Record<string, unknown> | undefined;
clineKnownModels: KnownModels | undefined;
clineModelSelected: number;
compact: boolean;
contentWidth: number;
@@ -633,6 +634,7 @@ export function OnboardingThinkingLevelScreen(props: {
export function OnboardingMainMenuScreen(props: {
contentWidth: number;
menuOptions: MenuOption[];
menuSelected: number;
mouse: MouseTrackerState;
}) {
@@ -671,7 +673,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}
/>
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getClinePassSubscriptionUrl,
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/",
);
});
});
+40
View File
@@ -0,0 +1,40 @@
import {
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
} from "@cline/core";
export { 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 formatCliErrorMessage(error: unknown): string {
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 });
}
}
+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)));
});
}
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listLocalProviders: vi.fn(async () => ({ providers: [], settingsPath: "" })),
getBooleanFlagEnabled: vi.fn(() => true),
}));
vi.mock("@cline/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@cline/core")>();
return {
...actual,
listLocalProviders: mocks.listLocalProviders,
};
});
vi.mock("./feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
}),
}));
describe("listLocalProviders", () => {
it("passes the ClinePass feature flag into the SDK provider list", async () => {
const { listLocalProviders } = await import("./provider-catalog");
const manager = {} as never;
await listLocalProviders(manager);
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith("ext-cline-pass");
expect(mocks.listLocalProviders).toHaveBeenCalledWith(manager, {
isClinePassEnabled: true,
});
});
});
+14
View File
@@ -0,0 +1,14 @@
import {
listLocalProviders as internalListLocalProviders,
type ProviderSettingsManager,
} from "@cline/core";
import { getCliFeatureFlagsService } from "./feature-flags";
export async function listLocalProviders(
manager: ProviderSettingsManager,
): ReturnType<typeof internalListLocalProviders> {
return await internalListLocalProviders(manager, {
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
}
@@ -34,7 +34,7 @@ vi.mock("./telemetry", async (importOriginal) => {
import {
captureCliExtensionActivated,
identifyCliTelemetryAccount,
identifyTelemetryAccount,
} from "./telemetry";
import { resetCliExtensionActivationForTests } from "./telemetry.test-helpers";
@@ -93,7 +93,7 @@ describe("captureCliExtensionActivated", () => {
});
});
describe("identifyCliTelemetryAccount", () => {
describe("identifyTelemetryAccount", () => {
beforeEach(() => {
hoisted.identifyAccount.mockClear();
hoisted.getCliTelemetryService.mockClear();
@@ -107,7 +107,7 @@ describe("identifyCliTelemetryAccount", () => {
memberId: "member-7",
provider: "cline",
};
identifyCliTelemetryAccount(account);
identifyTelemetryAccount(account);
expect(hoisted.identifyAccount).toHaveBeenCalledWith(undefined, account);
});
});
+4 -1
View File
@@ -9,6 +9,7 @@ import {
TelemetryLoggerSink,
} from "@cline/core";
import { getCliBuildInfo } from "./common";
import { identifyFeatureFlagsAccount } from "./feature-flags";
import {
markActivationCaptured,
wasActivationCaptured,
@@ -102,11 +103,12 @@ export interface CliTelemetryAccountContext {
* Safe to call multiple times; the latest values win, mirroring the legacy
* singleton-based behavior.
*/
export function identifyCliTelemetryAccount(
export function identifyTelemetryAccount(
account: CliTelemetryAccountContext,
logger?: BasicLogger,
): void {
identifyAccount(getCliTelemetryService(logger), account);
void identifyFeatureFlagsAccount(account, logger);
}
/**
@@ -134,6 +136,7 @@ export function captureCliExtensionActivated(
const telemetry = getCliTelemetryService(logger);
if (account) {
identifyAccount(telemetry, account);
void identifyFeatureFlagsAccount(account, logger);
}
captureExtensionActivated(telemetry);
}
+45 -40
View File
@@ -1,6 +1,5 @@
import * as p from "@clack/prompts";
import { authorizeMcpServerOAuth } from "@cline/core";
import open from "open";
import { authorizeMcpServerOAuthWithBrowser as authorizeOAuth } from "./oauth";
import {
addServer,
clearServerOAuth,
@@ -17,16 +16,6 @@ function isCancel(value: unknown): value is symbol {
return p.isCancel(value);
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
const message = error.message.trim();
if (message.length > 0) {
return message;
}
}
return String(error);
}
function transportLabel(t: McpTransport): string {
if (t.type === "stdio") return `stdio: ${t.command}`;
return `${t.type}: ${t.url}`;
@@ -62,6 +51,19 @@ interface UrlServerConfig {
authMode: RemoteAuthMode;
}
export interface McpAddDefaults {
name?: string;
type?: McpTransport["type"];
command?: string;
url?: string;
}
export interface RunMcpWizardOptions {
initialAction?: "add";
addDefaults?: McpAddDefaults;
exitAfterInitialAction?: boolean;
}
export function parseStdioCommand(input: string): string[] {
const tokens: string[] = [];
let current = "";
@@ -107,12 +109,15 @@ export function parseStdioCommand(input: string): string[] {
return tokens;
}
async function collectStdioTransport(): Promise<McpTransport | null> {
async function collectStdioTransport(
defaultCommand?: string,
): Promise<McpTransport | null> {
p.log.info("Quoted arguments and escaped spaces are supported");
const command = await p.text({
message: "Command to run",
placeholder: "npx -y @modelcontextprotocol/server-filesystem",
initialValue: defaultCommand,
validate: (v) => {
if (!v?.trim()) return "Command is required";
return undefined;
@@ -152,10 +157,12 @@ async function collectStdioTransport(): Promise<McpTransport | null> {
async function collectUrlTransport(
type: "sse" | "streamableHttp",
defaultUrl?: string,
): Promise<UrlServerConfig | null> {
const url = await p.text({
message: "Server URL",
placeholder: "https://example.com/mcp",
initialValue: defaultUrl,
validate: (v) => {
if (!v?.trim()) return "URL is required";
try {
@@ -222,33 +229,11 @@ async function collectUrlTransport(
};
}
async function authorizeOAuth(name: string): Promise<void> {
p.log.info("Opening browser for MCP OAuth authorization");
try {
const result = await authorizeMcpServerOAuth({
serverName: name,
filePath: getSettingsPath(),
openUrl: async (url) => {
p.log.message(`Authorization URL: ${url}`);
await open(url, { wait: false });
},
onServerListening: (info) => {
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
},
});
p.log.success(result.message);
} catch (error) {
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
p.log.warn(
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
);
}
}
async function actionAdd(): Promise<void> {
async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
const name = await p.text({
message: "Server name",
placeholder: "my-mcp-server",
initialValue: defaults?.name,
validate: (v) => {
if (!v?.trim()) return "Name is required";
const existing = loadServers();
@@ -262,6 +247,7 @@ async function actionAdd(): Promise<void> {
const type = await p.select({
message: "Server type",
initialValue: defaults?.type,
options: [
{
value: "stdio",
@@ -285,9 +271,12 @@ async function actionAdd(): Promise<void> {
let transport: McpTransport | null;
let authMode: RemoteAuthMode = "none";
if (type === "stdio") {
transport = await collectStdioTransport();
transport = await collectStdioTransport(defaults?.command);
} else {
const config = await collectUrlTransport(type as "sse" | "streamableHttp");
const config = await collectUrlTransport(
type as "sse" | "streamableHttp",
defaults?.url,
);
transport = config?.transport ?? null;
authMode = config?.authMode ?? "none";
}
@@ -441,9 +430,25 @@ async function actionAuthorizeOAuth(): Promise<void> {
await authorizeOAuth(name);
}
export async function runMcpWizard(): Promise<number> {
export async function runMcpWizard(
options: RunMcpWizardOptions = {},
): Promise<number> {
p.intro("MCP Servers");
if (options.initialAction === "add") {
let initialActionExitCode = 0;
try {
await actionAdd(options.addDefaults);
} catch (err) {
initialActionExitCode = 1;
p.log.error(err instanceof Error ? err.message : String(err));
}
if (options.exitAfterInitialAction === true) {
p.outro("Done");
return initialActionExitCode;
}
}
let keepGoing = true;
while (keepGoing) {
const action = await p.select({
+45
View File
@@ -0,0 +1,45 @@
import * as p from "@clack/prompts";
import {
authorizeMcpServerOAuth,
resolveDefaultMcpSettingsPath,
} from "@cline/core";
import open from "open";
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
const message = error.message.trim();
if (message.length > 0) {
return message;
}
}
return String(error);
}
export async function authorizeMcpServerOAuthWithBrowser(
name: string,
options: { throwOnError?: boolean } = {},
): Promise<void> {
p.log.info("Opening browser for MCP OAuth authorization");
try {
const result = await authorizeMcpServerOAuth({
serverName: name,
filePath: resolveDefaultMcpSettingsPath(),
openUrl: async (url) => {
p.log.message(`Authorization URL: ${url}`);
await open(url, { wait: false });
},
onServerListening: (info) => {
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
},
});
p.log.success(result.message);
} catch (error) {
if (options.throwOnError === true) {
throw error instanceof Error ? error : new Error(toErrorMessage(error));
}
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
p.log.warn(
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
);
}
}
+1
View File
@@ -12,6 +12,7 @@
"dev": "bun run src/dev.ts",
"start": "bun run src/server.ts",
"smoke:options": "bun run src/validate-options.ts",
"test": "bunx vitest run --config vitest.config.ts",
"typecheck": "bun tsc -p tsconfig.json --noEmit"
},
"dependencies": {
+16
View File
@@ -22,6 +22,7 @@ import {
syncHubClientsAndSessions,
syncHubHealth,
} from "./server/hub";
import { fetchMarketplaceCatalog } from "./server/marketplace";
import {
loadModels,
runProviderOAuthLogin,
@@ -99,6 +100,21 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
if (url.pathname === "/config.json") {
return createJsonResponse(browserConfig);
}
if (url.pathname === "/api/marketplace/catalog") {
try {
return createJsonResponse(await fetchMarketplaceCatalog());
} catch (error) {
return createJsonResponse(
{
error:
error instanceof Error
? error.message
: "Failed to fetch marketplace catalog",
},
502,
);
}
}
return assets.serve(url.pathname);
},
websocket: {
+77 -4
View File
@@ -4,15 +4,20 @@ import {
ClineAccountService,
ensureCustomProvidersLoaded,
executeClineAccountAction,
formatProviderOAuthApiKey,
getLocalProviderModels,
getPersistedProviderApiKey,
getProviderOAuthCredentialsFromSettings,
getValidClineCredentials,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
normalizeOAuthProvider,
type ProviderCapability,
type ProviderClient,
type ProviderProtocol,
type ProviderSettings,
readGlobalSettings,
resolveLocalClineAuthToken,
saveLocalProviderOAuthCredentials,
saveLocalProviderSettings,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
@@ -27,6 +32,12 @@ import {
stopConnectorChannel,
} from "./connectors";
import { providerSettingsManager, workspaceRoot } from "./deps";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
uninstallLocalPrimitive,
uninstallMarketplaceEntryForDesktopCommand,
} from "./marketplace";
import {
deleteMcpServer,
ensureMcpSettingsFile,
@@ -52,6 +63,39 @@ const ROUTINE_SCHEDULE_COMMANDS = new Set([
"delete_routine_schedule",
]);
async function resolveHubClineAccountAuthToken(input: {
settings?: ProviderSettings;
apiBaseUrl: string;
}): Promise<string | undefined> {
const credentials = input.settings
? getProviderOAuthCredentialsFromSettings("cline", input.settings)
: null;
if (!credentials || !input.settings) {
return getPersistedProviderApiKey("cline", input.settings);
}
const nextCredentials = await getValidClineCredentials(credentials, {
apiBaseUrl: input.apiBaseUrl,
});
if (!nextCredentials) {
throw new Error(
"Cline account requires re-authentication. Run cline auth cline.",
);
}
if (nextCredentials !== credentials) {
saveLocalProviderOAuthCredentials(
providerSettingsManager,
"cline",
input.settings,
nextCredentials,
{ setLastUsed: false },
);
}
return formatProviderOAuthApiKey("cline", nextCredentials);
}
export async function handleDesktopCommand(
ctx: HubContext,
command: string,
@@ -128,10 +172,18 @@ export async function handleDesktopCommand(
}
if (command === "cline_account") {
const settings = providerSettingsManager.getProviderSettings("cline");
const apiBaseUrl =
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
const authToken = await resolveHubClineAccountAuthToken({
settings,
apiBaseUrl,
});
if (!authToken) {
throw new Error("No Cline account auth token found");
}
const accountService = new ClineAccountService({
apiBaseUrl:
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
getAuthToken: async () => resolveLocalClineAuthToken(settings),
apiBaseUrl,
getAuthToken: async () => authToken,
});
return await executeClineAccountAction(
args as ClineAccountActionRequest,
@@ -213,6 +265,27 @@ export async function handleDesktopCommand(
if (command === "list_user_instruction_configs") {
return await listUserInstructionConfigs(workspaceRoot);
}
if (command === "list_marketplace_installed_entries") {
return listMarketplaceInstalledEntries(
args,
await listUserInstructionConfigs(workspaceRoot),
);
}
if (command === "install_marketplace_entry") {
const result = await installMarketplaceEntryForDesktopCommand(args);
broadcastHubState(ctx);
return result;
}
if (command === "uninstall_marketplace_entry") {
const result = await uninstallMarketplaceEntryForDesktopCommand(args);
broadcastHubState(ctx);
return result;
}
if (command === "uninstall_local_primitive") {
const result = await uninstallLocalPrimitive(args, { workspaceRoot });
broadcastHubState(ctx);
return result;
}
if (command === "toggle_disabled_plugin_tool") {
const toolName = String(args?.name ?? "").trim();
if (!toolName) throw new Error("tool name is required");
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { isWebviewRoute, normalizeWebviewIndexHtml } from "./http";
describe("isWebviewRoute", () => {
it.each([
"/",
"/chat",
"/sessions",
"/models",
"/customizations",
"/rules",
"/hooks",
"/mcp",
"/plugins",
"/skills",
"/agents",
"/tools",
"/marketplace",
"/marketplace/mcp",
"/marketplace/skills",
"/marketplace/plugins",
"/channels",
"/schedules",
"/settings",
"/settings/providers",
])("matches dashboard SPA route %s", (pathname) => {
expect(isWebviewRoute(pathname)).toBe(true);
});
it("does not treat nested marketplace asset requests as SPA routes", () => {
expect(isWebviewRoute("/marketplace/assets/index.js")).toBe(false);
});
});
describe("normalizeWebviewIndexHtml", () => {
it("rewrites relative built asset URLs so deep links can refresh", () => {
expect(
normalizeWebviewIndexHtml(
'<script type="module" src="./assets/index.js"></script><link href="./assets/index.css">',
),
).toBe(
'<script type="module" src="/assets/index.js"></script><link href="/assets/index.css">',
);
});
it("injects the persisted theme bootstrap once", () => {
const normalized = normalizeWebviewIndexHtml(
"<html><head></head><body></body></html>",
);
expect(normalized).toContain('id="cline-hub-theme-bootstrap"');
expect(normalizeWebviewIndexHtml(normalized)).toBe(normalized);
});
});
+92 -7
View File
@@ -15,6 +15,25 @@ export function createTextResponse(text: string, status = 200): Response {
});
}
const NO_STORE_HEADERS = {
"cache-control": "no-store, no-cache, must-revalidate, proxy-revalidate",
pragma: "no-cache",
expires: "0",
};
const IMMUTABLE_ASSET_CACHE = "public, max-age=31536000, immutable";
const THEME_BOOTSTRAP_SCRIPT = `<script id="cline-hub-theme-bootstrap">
(() => {
try {
const theme = window.localStorage.getItem("cline-hub-theme");
if (theme === "dark" || theme === "light") {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.dataset.clineHubTheme = theme;
}
} catch {}
})();
</script>`;
function contentTypeFor(path: string): string {
switch (extname(path)) {
case ".html":
@@ -36,20 +55,47 @@ function contentTypeFor(path: string): string {
}
}
function isWebviewRoute(pathname: string): boolean {
export function isWebviewRoute(pathname: string): boolean {
return (
pathname === "/" ||
pathname === "/index.html" ||
pathname === "/chat" ||
pathname === "/sessions" ||
pathname === "/models" ||
pathname === "/customizations" ||
pathname === "/rules" ||
pathname === "/hooks" ||
pathname === "/mcp" ||
pathname === "/plugins" ||
pathname === "/skills" ||
pathname === "/agents" ||
pathname === "/tools" ||
pathname === "/marketplace" ||
pathname === "/marketplace/mcp" ||
pathname === "/marketplace/skills" ||
pathname === "/marketplace/plugins" ||
pathname === "/channels" ||
pathname === "/schedules" ||
pathname === "/settings" ||
pathname.startsWith("/settings/")
);
}
export function normalizeWebviewIndexHtml(html: string): string {
const normalized = html
.replaceAll('src="./', 'src="/')
.replaceAll('href="./', 'href="/');
if (normalized.includes('id="cline-hub-theme-bootstrap"')) {
return normalized;
}
return normalized.replace("<head>", `<head>\n${THEME_BOOTSTRAP_SCRIPT}`);
}
function renderDevIndexHtml(devServerUrl: string): string {
return `<!doctype html>
<html lang="en">
<head>
${THEME_BOOTSTRAP_SCRIPT}
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="module">
@@ -60,7 +106,7 @@ function renderDevIndexHtml(devServerUrl: string): string {
window.__vite_plugin_react_preamble_installed__ = true;
</script>
<script type="module" src="${devServerUrl}/@vite/client"></script>
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/cline-logo-filled.svg" />
<title>Cline Hub</title>
</head>
<body>
@@ -74,6 +120,14 @@ function renderDevIndexHtml(devServerUrl: string): string {
export class WebviewAssets {
constructor(private readonly webviewDistDir: string) {}
private async resolveCurrentMainAssetPath(): Promise<string | undefined> {
const indexFile = Bun.file(join(this.webviewDistDir, "index.html"));
if (!(await indexFile.exists())) return undefined;
const html = await indexFile.text();
const match = html.match(/src="\.\/(assets\/index-[^"]+\.js)"/);
return match?.[1] ? join(this.webviewDistDir, match[1]) : undefined;
}
private resolveStaticPath(pathname: string): string | undefined {
const decoded = decodeURIComponent(pathname);
const requested = decoded === "/" ? "/index.html" : decoded;
@@ -89,8 +143,11 @@ export class WebviewAssets {
private async serveIndex(): Promise<Response> {
const indexFile = Bun.file(join(this.webviewDistDir, "index.html"));
if (await indexFile.exists()) {
return new Response(indexFile, {
headers: { "content-type": "text/html; charset=utf-8" },
return new Response(normalizeWebviewIndexHtml(await indexFile.text()), {
headers: {
"content-type": "text/html; charset=utf-8",
...NO_STORE_HEADERS,
},
});
}
return createTextResponse(
@@ -103,7 +160,10 @@ export class WebviewAssets {
const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
if (devServerUrl && isWebviewRoute(pathname)) {
return new Response(renderDevIndexHtml(devServerUrl), {
headers: { "content-type": "text/html; charset=utf-8" },
headers: {
"content-type": "text/html; charset=utf-8",
...NO_STORE_HEADERS,
},
});
}
if (isWebviewRoute(pathname)) {
@@ -112,12 +172,37 @@ export class WebviewAssets {
const filePath = this.resolveStaticPath(pathname);
if (!filePath) return createTextResponse("not found", 404);
const file = Bun.file(filePath);
let responsePath = filePath;
let file = Bun.file(responsePath);
if (
!(await file.exists()) &&
/^\/assets\/index-[A-Za-z0-9_-]+\.js$/.test(pathname)
) {
const currentMainAssetPath = await this.resolveCurrentMainAssetPath();
if (currentMainAssetPath) {
responsePath = currentMainAssetPath;
file = Bun.file(responsePath);
}
}
if (!(await file.exists())) {
return createTextResponse("not found", 404);
}
const isHashedAsset = /^\/assets\/.+-[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/.test(
pathname,
);
return new Response(file, {
headers: { "content-type": contentTypeFor(filePath) },
headers: {
"content-type": contentTypeFor(responsePath),
"cache-control": isHashedAsset
? IMMUTABLE_ASSET_CACHE
: NO_STORE_HEADERS["cache-control"],
...(isHashedAsset
? {}
: {
pragma: NO_STORE_HEADERS.pragma,
expires: NO_STORE_HEADERS.expires,
}),
},
});
}
}
@@ -0,0 +1,870 @@
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildMarketplaceMcpInput,
fetchMarketplaceCatalog,
installMarketplaceEntry,
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
uninstallLocalPrimitive,
uninstallMarketplaceEntry,
uninstallMarketplaceEntryForDesktopCommand,
} from "./marketplace";
describe("marketplace installer", () => {
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
const originalClineDir = process.env.CLINE_DIR;
const originalHome = process.env.HOME;
const originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
afterEach(() => {
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
}
if (originalClineDir === undefined) {
delete process.env.CLINE_DIR;
} else {
process.env.CLINE_DIR = originalClineDir;
}
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalMcpSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
}
vi.restoreAllMocks();
});
it("maps remote MCP catalog args to the hub MCP upsert shape", () => {
expect(
buildMarketplaceMcpInput([
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
]),
).toEqual({
name: "context7",
transportType: "streamableHttp",
url: "https://mcp.context7.com/mcp",
disabled: false,
});
});
it("maps stdio MCP catalog args to command and args", () => {
expect(
buildMarketplaceMcpInput(["filesystem", "npx", "-y", "server", "/tmp"]),
).toEqual({
name: "filesystem",
transportType: "stdio",
command: "npx",
args: ["-y", "server", "/tmp"],
disabled: false,
});
});
it("preserves server flags after stdio MCP command args begin", () => {
expect(
buildMarketplaceMcpInput([
"search",
"npx",
"-y",
"server",
"--transport",
"stdio",
]),
).toEqual({
name: "search",
transportType: "stdio",
command: "npx",
args: ["-y", "server", "--transport", "stdio"],
disabled: false,
});
});
it("runs skills globally for Cline without prompts", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => {
mkdirSync(join(homeDir, ".agents", "skills", "web-design-guidelines"), {
recursive: true,
});
writeFileSync(
join(homeDir, ".agents", "skills", "web-design-guidelines", "SKILL.md"),
"---\nname: web-design-guidelines\n---\n",
);
return {
exitCode: 0,
stdout: "installed",
stderr: "",
};
});
await installMarketplaceEntry(
{
entry: {
id: "web-design-guidelines",
type: "skill",
name: "Web Design Guidelines",
install: {
args: [
"vercel-labs/agent-skills",
"--skill",
"web-design-guidelines",
],
},
},
},
{ spawnCommand },
);
expect(spawnCommand).toHaveBeenCalledWith("npx", [
"-y",
"skills@latest",
"add",
"vercel-labs/agent-skills",
"--skill",
"web-design-guidelines",
"-g",
"-a",
"cline",
"-y",
]);
});
it("skips skill install commands when the global skill already exists", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
mkdirSync(join(homeDir, ".agents", "skills", "cline-sdk"), {
recursive: true,
});
writeFileSync(
join(homeDir, ".agents", "skills", "cline-sdk", "SKILL.md"),
"---\nname: cline-sdk\n---\n",
);
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "installed",
message: "Cline SDK is already installed.",
});
expect(spawnCommand).not.toHaveBeenCalled();
});
it("reports Cline global skills as marketplace-installed", () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-cline-"));
process.env.CLINE_DIR = clineDir;
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
recursive: true,
});
writeFileSync(
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
"---\nname: cline-sdk\n---\n",
);
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
],
}),
).toEqual({ installedKeys: ["skill:cline-sdk"] });
});
it("accepts skill installs that create Cline global skills", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
const clineDir = join(homeDir, ".cline");
process.env.HOME = homeDir;
process.env.CLINE_DIR = clineDir;
const spawnCommand = vi.fn(async () => {
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
recursive: true,
});
writeFileSync(
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
"---\nname: cline-sdk\n---\n",
);
return {
exitCode: 0,
stdout: "installed",
stderr: "",
};
});
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "installed",
message: "Installed Cline SDK globally for Cline.",
});
});
it("removes Cline global marketplace skills without prompts", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const skillDir = join(homeDir, ".agents", "skills", "cline-sdk");
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: cline-sdk\n---\n");
const spawnCommand = vi.fn(async () => {
rmSync(skillDir, { recursive: true, force: true });
return {
exitCode: 0,
stdout: "removed",
stderr: "",
};
});
await expect(
uninstallMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Cline SDK.",
});
expect(spawnCommand).toHaveBeenCalledWith("npx", [
"-y",
"skills@latest",
"remove",
"cline-sdk",
"-g",
"-a",
"cline",
"-y",
]);
});
it("does not report project-local skills as marketplace-installed globals", () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
expect(
listMarketplaceInstalledEntries(
{
entries: [
{
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
],
},
{
skills: [
{
id: "cline-sdk",
name: "cline-sdk",
path: "/workspace/project/.agents/skills/cline-sdk/SKILL.md",
},
],
},
),
).toEqual({ installedKeys: [] });
});
it("rejects skill installs that exit zero but report failure", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "Failed to install 1",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).rejects.toThrow("Skill install failed");
});
it("redacts common secret formats from failed install output", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => ({
exitCode: 1,
stdout:
"Authorization: Bearer stdout-token\napi key stdout-key\nOPENAI_API_KEY=compound-key",
stderr:
"TOKEN=stderr-token\npassword is stderr-password\nANTHROPIC_SECRET_KEY=anthropic-secret",
}));
let message = "";
try {
await installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
);
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toContain("Authorization: [redacted]");
expect(message).toContain("api key [redacted]");
expect(message).toContain("OPENAI_API_KEY=[redacted]");
expect(message).toContain("TOKEN=[redacted]");
expect(message).toContain("password is [redacted]");
expect(message).toContain("ANTHROPIC_SECRET_KEY=[redacted]");
expect(message).not.toContain("stdout-token");
expect(message).not.toContain("stdout-key");
expect(message).not.toContain("compound-key");
expect(message).not.toContain("stderr-token");
expect(message).not.toContain("stderr-password");
expect(message).not.toContain("anthropic-secret");
});
it("rejects skill installs before spawning when the global skill directory is not writable", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
mkdirSync(join(homeDir, ".agents"), { recursive: true });
writeFileSync(join(homeDir, ".agents", "skills"), "");
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).rejects.toThrow(
"Cannot install skill globally because ~/.agents/skills is not writable",
);
expect(spawnCommand).not.toHaveBeenCalled();
});
it("rejects skill installs that do not create a global skill", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "Installation complete",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).rejects.toThrow("was not found in Cline's global skills directories");
});
it("runs official plugin installs through the current Cline CLI", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
stderr: "",
}));
await installMarketplaceEntry(
{
entry: {
id: "marketplace-test-plugin",
type: "plugin",
name: "Marketplace Test Plugin",
install: { args: ["marketplace-test-plugin"] },
},
},
{ spawnCommand },
);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"install",
"marketplace-test-plugin",
"--json",
]);
});
it("runs official plugin uninstalls through the current Cline CLI", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stderr: "",
}));
await expect(
uninstallMarketplaceEntry(
{
entry: {
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Goal.",
});
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
});
it("resolves desktop installs from the server catalog instead of browser-sent args", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
stderr: "",
}));
await installMarketplaceEntryForDesktopCommand(
{
entry: {
id: "marketplace-test-plugin",
type: "plugin",
name: "Tampered",
install: { args: ["malicious-source"] },
},
},
{
spawnCommand,
loadCatalog: async () => ({
entries: [
{
id: "marketplace-test-plugin",
type: "plugin",
name: "Marketplace Test Plugin",
install: { args: ["marketplace-test-plugin"] },
},
],
}),
},
);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"install",
"marketplace-test-plugin",
"--json",
]);
});
it("resolves desktop uninstalls from the server catalog instead of browser-sent args", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stderr: "",
}));
await uninstallMarketplaceEntryForDesktopCommand(
{
entry: {
id: "goal",
type: "plugin",
name: "Tampered",
install: { args: ["malicious-source"] },
},
},
{
spawnCommand,
loadCatalog: async () => ({
entries: [
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
}),
},
);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
});
it("uninstalls MCP marketplace entries from Cline MCP settings", async () => {
const settingsPath = join(
mkdtempSync(join(tmpdir(), "cline-marketplace-mcp-")),
"cline_mcp_settings.json",
);
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
context7: {
transport: {
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
},
},
},
},
null,
2,
),
);
await expect(
uninstallMarketplaceEntry({
entry: {
id: "context7",
type: "mcp",
name: "Context7",
install: {
args: [
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
],
},
},
}),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Context7.",
});
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "context7",
type: "mcp",
name: "Context7",
install: {
args: [
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
],
},
},
],
}),
).toEqual({ installedKeys: [] });
});
it("uninstalls local MCP servers by name", async () => {
const settingsPath = join(
mkdtempSync(join(tmpdir(), "cline-local-mcp-")),
"cline_mcp_settings.json",
);
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
context7: {
transport: {
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
},
},
},
},
null,
2,
),
);
await expect(
uninstallLocalPrimitive({
type: "mcp",
id: "context7",
name: "context7",
}),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled context7.",
});
expect(readFileSync(settingsPath, "utf8")).not.toContain("context7");
});
it("uninstalls local skills by removing their configured skill directory", async () => {
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-local-skill-"));
const skillDir = join(workspaceRoot, ".cline", "skills", "review");
mkdirSync(skillDir, { recursive: true });
const skillPath = join(skillDir, "SKILL.md");
writeFileSync(skillPath, "---\nname: review\n---\nReview changes.");
await expect(
uninstallLocalPrimitive(
{
type: "skill",
id: "review",
name: "Review",
path: skillPath,
},
{ workspaceRoot },
),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Review.",
});
expect(existsSync(skillDir)).toBe(false);
});
it("reports official plugin marketplace entries installed from Cline home", () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
process.env.CLINE_DIR = clineDir;
const sourceKey =
"official:https://github.com/cline/plugins.git#plugins/goal";
const hash = createHash("sha256")
.update(sourceKey)
.digest("hex")
.slice(0, 12);
mkdirSync(
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
{
recursive: true,
},
);
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
}),
).toEqual({ installedKeys: ["plugin:goal"] });
});
it("does not report plugin inventory substring matches as installed", () => {
process.env.CLINE_DIR = mkdtempSync(
join(tmpdir(), "cline-marketplace-test-"),
);
expect(
listMarketplaceInstalledEntries(
{
entries: [
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
},
{
plugins: [
{
name: "goal-helper",
path: "/workspace/.cline/plugins/goal-helper/index.ts",
},
],
},
),
).toEqual({ installedKeys: [] });
});
it("skips invalid marketplace entries during installed-status checks", () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
process.env.CLINE_DIR = clineDir;
const sourceKey =
"official:https://github.com/cline/plugins.git#plugins/goal";
const hash = createHash("sha256")
.update(sourceKey)
.digest("hex")
.slice(0, 12);
mkdirSync(
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
{
recursive: true,
},
);
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "broken-mcp",
type: "mcp",
name: "Broken MCP",
install: {
args: [
"broken-mcp",
"--transport",
"ws",
"https://example.com/mcp",
],
},
},
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
}),
).toEqual({ installedKeys: ["plugin:goal"] });
});
it("rejects invalid marketplace entries before spawning commands", async () => {
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "bad",
type: "skill",
install: { args: [] },
},
},
{ spawnCommand },
),
).rejects.toThrow("marketplace install args are required");
expect(spawnCommand).not.toHaveBeenCalled();
});
it("fetches the marketplace catalog through the server helper", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(JSON.stringify({ version: 1, entries: [] }), {
headers: { "content-type": "application/json" },
});
});
await expect(fetchMarketplaceCatalog(fetchImpl)).resolves.toEqual({
version: 1,
entries: [],
});
expect(fetchImpl).toHaveBeenCalledWith(
"https://cline.github.io/marketplace/catalog.json",
{ headers: { Accept: "application/json" } },
);
});
it("surfaces marketplace catalog upstream failures", async () => {
const fetchImpl = vi.fn(async () => {
return new Response("nope", {
status: 503,
statusText: "Service Unavailable",
});
});
await expect(fetchMarketplaceCatalog(fetchImpl)).rejects.toThrow(
"Failed to fetch marketplace catalog: 503 Service Unavailable",
);
});
});
File diff suppressed because it is too large Load Diff
@@ -235,6 +235,7 @@ export function toWebviewSessionSummary(
providerId: session.provider,
model: session.model,
workspaceRoot: session.workspaceRoot,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
@@ -0,0 +1,70 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { listUserInstructionConfigs } from "./user-instructions";
describe("listUserInstructionConfigs", () => {
const tempRoots: string[] = [];
const envSnapshot = {
CLINE_GLOBAL_SETTINGS_PATH: process.env.CLINE_GLOBAL_SETTINGS_PATH,
CLINE_MCP_SETTINGS_PATH: process.env.CLINE_MCP_SETTINGS_PATH,
};
afterEach(async () => {
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 })),
);
tempRoots.length = 0;
});
it("uses the package name for package-backed plugin entries", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cline-hub-config-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(tempRoot, "settings.json");
process.env.CLINE_MCP_SETTINGS_PATH = join(tempRoot, "mcp.json");
const packageDir = join(
tempRoot,
".cline",
"plugins",
"_installed",
"git",
"github.com",
"demo",
"package",
);
await mkdir(packageDir, { recursive: true });
const pluginPath = join(packageDir, "index.ts");
await writeFile(
join(packageDir, "package.json"),
JSON.stringify(
{
name: "cline-sdk-portable-agents",
cline: {
plugins: [{ paths: ["./index.ts"] }],
},
},
null,
2,
),
);
await writeFile(pluginPath, "export default {};\n");
const data = await listUserInstructionConfigs(tempRoot);
const plugins = data.plugins as Array<{ name: string; path: string }>;
const plugin = plugins.find((item) => item.path === pluginPath);
expect(plugin?.name).toBe("cline-sdk-portable-agents");
});
});
+52 -2
View File
@@ -1,5 +1,13 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { extname, join, basename as pathBasename } from "node:path";
import {
dirname,
extname,
isAbsolute,
join,
basename as pathBasename,
relative,
resolve,
} from "node:path";
import {
createUserInstructionConfigService,
discoverPluginModulePaths,
@@ -17,6 +25,48 @@ function resolveAgentConfigSearchPaths(workspaceRoot?: string): string[] {
return resolveSharedAgentConfigSearchPaths(workspaceRoot);
}
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 isPathWithin(parentPath: string, childPath: string): boolean {
const relativePath = relative(resolve(parentPath), resolve(childPath));
return (
relativePath === "" ||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
);
}
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 pathBasename(filePath, extname(filePath));
}
export async function listUserInstructionConfigs(
targetWorkspaceRoot: string,
): Promise<JsonRecord> {
@@ -115,7 +165,7 @@ export async function listUserInstructionConfigs(
for (const filePath of discoverPluginModulePaths(directory)) {
if (pluginsByPath.has(filePath)) continue;
pluginsByPath.set(filePath, {
name: pathBasename(filePath, extname(filePath)),
name: getPluginDisplayName(filePath, directory),
path: filePath,
enabled: !disabledPlugins.has(filePath),
});
+1
View File
@@ -109,6 +109,7 @@ export type WebviewSessionSummary = {
providerId?: string;
model?: string;
workspaceRoot?: string;
createdAt?: number;
updatedAt?: number;
inputTokens?: number;
outputTokens?: number;
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/cline-logo-filled.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webview</title>
<title>Cline Hub</title>
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -10,7 +10,7 @@
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
"@streamdown/cjk": "^1.0.3",
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 487.04">
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
<path d="M463.6,275.08l-29.26-58.75v-33.83c0-56.08-45.01-101.5-100.53-101.5h-50.01c3.62-7.43,5.61-15.79,5.61-24.61,0-31.17-25.08-56.39-56.07-56.39s-56.07,25.22-56.07,56.39c0,8.82,1.99,17.17,5.61,24.61h-50.01c-55.51,0-100.52,45.42-100.52,101.5v33.83l-29.87,58.59c-3.01,5.9-3.01,12.92,0,18.81l29.87,57.93v33.83c0,56.08,45.01,101.5,100.52,101.5h200.95c55.51,0,100.53-45.42,100.53-101.5v-33.83l29.21-58.13c2.9-5.79,2.9-12.61.05-18.46ZM202.75,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02ZM350.58,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02Z"/>
</svg>

After

Width:  |  Height:  |  Size: 957 B

File diff suppressed because it is too large Load Diff
@@ -32,7 +32,7 @@ function AccordionTrigger({
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
"group/accordion-trigger relative flex flex-1 cursor-pointer items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className,
)}
{...props}
@@ -9,7 +9,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-lg border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
"peer relative flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className,
)}
{...props}
@@ -24,7 +24,10 @@ function ComboboxTrigger({
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
className={cn(
"cursor-pointer data-disabled:cursor-not-allowed [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
@@ -140,7 +143,7 @@ function ComboboxItem({
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex w-full cursor-pointer items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -250,7 +253,7 @@ function ComboboxChip({
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
className="-ml-1 cursor-pointer opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
@@ -152,7 +152,7 @@ function CommandItem({
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
"group/command-item relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className,
)}
{...props}
@@ -90,7 +90,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
"group/dropdown-menu-item relative flex cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
@@ -115,7 +115,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"flex cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground data-disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -164,7 +164,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
@@ -205,7 +205,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -56,7 +56,7 @@ function MenubarTrigger({
<DropdownMenuTrigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-0.5 text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
"flex cursor-pointer items-center rounded-sm px-1.5 py-0.5 text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
className,
)}
{...props}
@@ -120,7 +120,7 @@ function MenubarCheckboxItem({
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
@@ -155,7 +155,7 @@ function MenubarRadioItem({
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -23,7 +23,7 @@ function NativeSelect({
<select
data-slot="native-select"
data-size={size}
className="h-full w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
className="h-full w-full min-w-0 cursor-pointer appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
{...props}
/>
<ChevronDownIcon

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