Compare commits

..
Author SHA1 Message Date
Saoud Rizwan 98cfe2d0d8 fix: hide workflows from customize menu 2026-06-25 20:50:23 -07:00
166 changed files with 2965 additions and 8394 deletions
@@ -1,294 +0,0 @@
name: ext-vscode-publish-legacy
# Publishes the legacy (pre-SDK-migration) VS Code extension from the
# `legacy-extension` branch. This branch holds the npm-based 3.89.x codebase,
# rolled forward under a 4.0.x version so existing 4.0.0 users still receive
# the update. The main `ext-vscode-publish-stable.yml` workflow (bun-based)
# stays the path for releasing main once the SDK migration is solid.
#
# This workflow lives on and is dispatched from `main` (so it satisfies the
# default-branch dispatch requirement), but it checks out and builds the
# `legacy-extension` branch.
on:
workflow_dispatch:
inputs:
release-type:
description: "Choose release type (release or pre-release)"
required: true
default: "release"
type: choice
options:
- pre-release
- release
branch:
description: "Branch holding the legacy extension code"
required: true
default: "legacy-extension"
type: string
permissions:
contents: write
packages: write
checks: write
pull-requests: write
concurrency:
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
cancel-in-progress: false
jobs:
# Gate the publish on the legacy branch's own npm-based test suite. We can't
# reuse ./.github/workflows/ext-vscode-test.yml here — on main that's the
# bun-based suite and it would test main, not the legacy branch — so the
# essential quality + test steps are inlined against the checked-out legacy
# branch.
test:
name: Test Legacy Extension
runs-on: ubuntu-latest
defaults:
run:
working-directory: apps/vscode
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
cache-dependency-path: |
apps/vscode/package-lock.json
apps/vscode/webview-ui/package-lock.json
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode ci
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui ci
- name: Run Quality Checks (lint + typecheck)
run: npm run ci:check-all
- name: Build Tests and Extension
id: build_step
run: npm run ci:build
- name: Unit Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: npm run test:unit
- name: Extension Integration Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: xvfb-run -a npm run test:coverage
- name: Webview Tests
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
run: |
cd webview-ui
npm run test:coverage
publish:
needs: test
name: Publish Legacy Extension
runs-on: ubuntu-latest
environment: publish
defaults:
run:
working-directory: apps/vscode
steps:
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
# can create/push the release tag and compute the previous tag.
- uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch }}
fetch-depth: 0
fetch-tags: true
lfs: true
- name: Resolve Release Tag
id: resolve_tag
working-directory: ${{ github.workspace }}
env:
BRANCH: ${{ github.event.inputs.branch }}
run: |
# Tag is derived from the package version on the legacy branch.
VERSION=$(node -p "require('./apps/vscode/package.json').version")
TAG="v$VERSION"
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
echo "Error: derived tag '$TAG' does not match vX.Y.Z"
exit 1
fi
TAG_REF="refs/tags/$TAG"
HEAD_SHA=$(git rev-parse HEAD)
if git show-ref --verify --quiet "$TAG_REF"; then
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at branch head ($HEAD_SHA)"
exit 1
fi
echo "Tag '$TAG' already exists at branch head. Continuing."
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag "$TAG" "$HEAD_SHA"
git push origin "$TAG_REF"
echo "Created and pushed tag '$TAG' from $BRANCH head $HEAD_SHA."
fi
echo "tag=$TAG" >> $GITHUB_OUTPUT
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Install extension dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode install --include=optional
- name: Install webview-ui dependencies
working-directory: ${{ github.workspace }}
run: npm --prefix apps/vscode/webview-ui install --include=optional
- name: Install Publishing Tools
run: npm install -g @vscode/vsce ovsx
- name: Get Version
id: get_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Verify Tag Matches Package Version
run: |
TAG="${{ steps.resolve_tag.outputs.tag }}"
VERSION="v${{ steps.get_version.outputs.version }}"
if [[ "$TAG" != "$VERSION" ]]; then
echo "Error: tag '$TAG' does not match package version '$VERSION'"
exit 1
fi
echo "Tag and package version match: $TAG"
- name: Verify Changelog Entry
working-directory: ${{ github.workspace }}
run: |
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
exit 1
fi
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
- name: Verify Marketplace Tokens
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
exit 1
fi
if [[ -z "$OVSX_PAT" ]]; then
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
exit 1
fi
echo "Marketplace publish tokens are configured."
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
CLINE_ENVIRONMENT: production
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
# OpenTelemetry production defaults (can be overridden at runtime)
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
run: |
# Swap README.marketplace.md into README.md so both the GitHub
# release artifact (vsce package below) and the marketplace
# publish (npm run publish:marketplace below, which swaps
# internally as an idempotent no-op) ship the same README.
node scripts/marketplace-readme.mjs swap-in
trap 'node scripts/marketplace-readme.mjs restore' EXIT
# Required to generate the .vsix
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
if [ "$RELEASE_TYPE" = "pre-release" ]; then
npm run publish:marketplace:prerelease
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
else
npm run publish:marketplace
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
fi
- name: Get Previous Tag
id: prev_tag
working-directory: ${{ github.workspace }}
run: |
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
id: changelog
working-directory: ${{ github.workspace }}
run: |
# Get content between first ## [ and second ## [
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.resolve_tag.outputs.tag }}
files: "apps/vscode/*.vsix"
body: |
${{ steps.changelog.outputs.content }}
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Post release to Slack
uses: slackapi/slack-github-action@v3.0.1
with:
method: chat.postMessage
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
payload: |
channel: "C0APVKGGZFC"
text: "Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)"
blocks:
- type: "section"
text:
type: "mrkdwn"
text: "*Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)*"
- type: "section"
text:
type: "mrkdwn"
text: ${{ toJSON(steps.changelog.outputs.content) }}
- type: "context"
elements:
- type: "mrkdwn"
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
+19 -65
View File
@@ -27,10 +27,6 @@ permissions:
checks: write
pull-requests: write
concurrency:
group: ext-vscode-publish-stable-${{ github.event.inputs.tag }}
cancel-in-progress: false
jobs:
test:
uses: ./.github/workflows/ext-vscode-test.yml
@@ -106,13 +102,7 @@ jobs:
echo "Error: tag '$TAG' does not exist in the repository"
exit 1
fi
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
if [[ "$TAG_SHA" != "$TESTED_SHA" ]]; then
echo "Error: existing tag '$TAG' points to $TAG_SHA, but this workflow tested $TESTED_SHA"
echo "Dispatch from the tag ref, or from the exact main commit the tag points to."
exit 1
fi
echo "Using existing tag '$TAG' at tested SHA $TESTED_SHA."
echo "Using existing tag '$TAG'."
fi
git checkout --detach "$TAG_REF^{commit}"
@@ -180,60 +170,6 @@ jobs:
fi
echo "Tag and package version match: $TAG"
- name: Verify Changelog Entry
working-directory: ${{ github.workspace }}
run: |
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
exit 1
fi
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
- name: Verify Marketplace Tokens
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
if [[ -z "$VSCE_PAT" ]]; then
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
exit 1
fi
if [[ -z "$OVSX_PAT" ]]; then
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
exit 1
fi
echo "Marketplace publish tokens are configured."
- name: Get Previous Tag
id: prev_tag
working-directory: ${{ github.workspace }}
run: |
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
PREV_TAG=$(
git tag --merged "$CURRENT_TAG^" --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$' \
| head -n 1 || true
)
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
id: changelog
working-directory: ${{ github.workspace }}
run: |
# Get content between the matching version heading and the next release heading.
CONTENT=$(awk -v version="${{ steps.get_version.outputs.version }}" '
$0 == "## [" version "]" { found=1; next }
found && /^## \[/ { exit }
found { print }
END { if (!found) exit 1 }
' CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Package and Publish Extension
env:
VSCE_PAT: ${{ secrets.VSCE_PAT }}
@@ -274,6 +210,24 @@ jobs:
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
fi
- name: Get Previous Tag
id: prev_tag
working-directory: ${{ github.workspace }}
run: |
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
- name: Get Changelog Entry
id: changelog
working-directory: ${{ github.workspace }}
run: |
# Get content between first ## [ and second ## [
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$CONTENT" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
-39
View File
@@ -1,44 +1,5 @@
# Changelog
## [4.0.0]
### Added
- Add the SDK-backed VS Code extension runtime. Cline now runs tasks through the shared Cline SDK session layer for agent turns, tools, Plan/Act mode coordination, MCP, checkpoints, telemetry, provider changes, compaction, mistake limits, and task history.
- Add ClinePass to the VS Code extension, including onboarding, provider selection, signup and subscription handoff, live model lists, entitlement and organization error states, out-of-credit prompts, and clearer ClinePass auth/error handling.
- Add the Customize marketplace for discovering and managing Skills, MCP servers, and Plugins from the extension, including installed/marketplace tabs, search and filtering, install/uninstall flows, enable/disable controls, and support for plugin-bundled skills.
- Cline Plugins: Plugins let you extend Cline with custom tools, workflows, skills, and MCP-powered capabilities tailored to your team or project. Install them from the new Customize marketplace to add specialized behavior, connect external services, and package reusable automations—so Cline can do more than code: it can adapt to the way you work.
- Add queued prompts in chat. Messages submitted while Cline is already working are now queued, shown while the current turn streams, and can be cancelled before they run.
- Add edit-and-regenerate support for previous user messages, with clearer Reset Chat and Reset Code actions.
- Add generic SDK provider settings and model-catalog support so more providers can share the same model picker, reasoning controls, dynamic model IDs, provider config persistence, and custom model handling.
- Add additional SDK-backed provider exposure and model/provider updates, including ClinePass models, refreshed Cline catalog data, Fireworks GLM 5.2, Kimi K2.6 Fast, Kimi K2.7 Code, Qwen 3.7 Plus, MiniMax M3 updates, SAP AI Core wiring, LiteLLM model fetching, Codex OAuth credentials, and OpenAI-compatible model settings.
- Add MCP support for plugins and shared marketplace install/uninstall plumbing used by the VS Code extension.
### Changed
- Migrate the VS Code extension from the legacy task implementation to the shared Cline SDK and move the extension build/package workflow to Bun.
- Rework Plan/Act mode handling through SDK coordinators, including closer CLI parity and automatic continuation when switching from Plan to Act.
- Rework provider and model configuration around `providers.json`, the model catalog, and SDK session config so settings are preserved consistently across provider switches and active sessions can restart when the selected provider changes.
- Simplify provider settings UI by replacing many provider-specific views with shared generic settings components and consistent reasoning selectors.
- Simplify terminal execution through the SDK run-commands path, including clearer non-interactive command guidance and safer structured command formatting.
- Migrate legacy MCP files and formats into the shared settings file and protect MCP settings writes with safer locking/atomic updates.
- Refresh the MCP hub automatically after marketplace installs so newly installed servers are available without a manual restart.
- Reorganize MCP/Skills/Plugins entry points under Customize, hide workflows from the Customize menu, wrap Customize tabs on narrow screens, and allow the MCP Marketplace tab to be disabled remotely while installed MCP servers remain accessible.
- Simplify auto-approval settings. Command auto-approval is now disabled by default for safer new and reset configurations, and the auto-approval UI has been streamlined.
- Update task history handling for the SDK migration, including legacy task history visibility, metadata preservation on resume, and corrected deletion behavior.
- Route compacting and mistake-limit behavior through the SDK so the Compact button and mistake tracking affect the active SDK session.
- Remove the legacy Explain Changes feature as part of the SDK migration cleanup.
- Temporarily disable subagents in the VS Code extension while the SDK-backed experience is stabilized.
### Fixed
- Fix marketplace edge cases, including refreshing MCP servers after marketplace installs, disabling the MCP Marketplace tab from remote config, hiding workflows from Customize, surfacing plugin-bundled skills, and uninstalling shared marketplace entries.
- Fix chat submission during active turns by queuing user messages instead of dropping or racing them, showing pending/queued states promptly, rendering direct user messages immediately, and removing delayed send behavior.
- Fix editing previous user messages so Escape cancels editing locally and reset action labels are clearer.
- Fix terminal reliability, including standalone Windows output capture, hardened PowerShell command handling, running-state display for in-progress commands, raw structured command preservation, single-quote handling, cwd setup timeouts, failing-command stdout capture, heredoc coalescing, and removal of duplicated command echoes in tool results.
- Fix SDK tool-result and provider-message budgeting by truncating large tool outputs by default, capping assistant text, limiting bash/file-read/search output ingestion, bounding media budgets, batching outdated-read rewrites to preserve provider prefix caches, and normalizing JSON-like tool inputs by schema.
- Fix login and feature-flag resolution by using the correct user/account identity on startup and simplifying the login UX.
## [3.89.2]
### Fixed
-31
View File
@@ -1,36 +1,5 @@
# Cline CLI Changelog
## 3.0.34
- Fixed the ClinePass upgrade notice appearing immediately after completing onboarding.
- Improved the wording of the ClinePass onboarding step.
- Streamlined the Cline provider picker by merging the subscription and usage/billing options into one and removing the credits link.
## 3.0.33
- Show a ClinePass subscription URL as a fallback during onboarding so you can still subscribe if the subscription screen can't open automatically
- Hide the ClinePass promo for users who already have a ClinePass subscription
- Use an adaptive plan accent color for ClinePass prompts so they fit the active theme
## 3.0.32
- Improved the ClinePass onboarding experience
- Added an intermediate step before going to ClinePass model selection
- Made the ClinePass subscription screen selectable
- Promoted ClinePass in the startup notice
- Used "ClinePass" as one word consistently and refined the provider UI copy
- More accurate context compaction and clearer error messages (from SDK v0.0.54)
## 3.0.31
- Show when request cost is covered by your Cline subscription
- Prompt to switch to ClinePass when you run out of credits, and list ClinePass features in the not-subscribed message
- Added an option to open the subscription page from the ClinePass options
- Added marketplace uninstall support and surfaced plugin-bundled skills
- Require quoted prompts for one-shot mode
- Capped MCP tool names at 64 characters for OpenAI-compatible providers
- Updated coupon code
## 3.0.30
- Added a token count to the status bar, shown alongside cost
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.34",
"version": "3.0.30",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+10 -28
View File
@@ -1,27 +1,14 @@
import { installMcpServer } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import {
buildMcpInstallDefaults,
buildMcpInstallTransport,
runMcpInstallCommand,
} from "./mcp";
import { addServer } from "../wizards/mcp/settings";
vi.mock("@cline/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@cline/core")>();
return {
...actual,
installMcpServer: vi.fn((options) => {
const { name, transport, warnings } =
actual.buildMcpInstallTransport(options);
return {
name,
status: "installed",
transport,
warnings,
};
}),
};
});
vi.mock("../wizards/mcp/settings", () => ({
addServer: vi.fn(),
}));
describe("mcp install command", () => {
it("builds stdio wizard defaults from command args", () => {
@@ -229,17 +216,12 @@ describe("mcp install command", () => {
});
expect(code).toBe(0);
expect(installMcpServer).toHaveBeenCalledWith({
name: "docs",
transport: "http",
targetArgs: [
"https://example.com/mcp",
"--header",
"Authorization: Bearer token",
],
isTty: false,
yes: true,
io: { writeln, writeErr },
expect(addServer).toHaveBeenCalledWith("docs", {
type: "streamableHttp",
url: "https://example.com/mcp",
headers: {
Authorization: "Bearer token",
},
});
expect(writeln).toHaveBeenCalledWith("Installed MCP server docs.");
expect(writeErr).not.toHaveBeenCalled();
+131 -16
View File
@@ -1,19 +1,16 @@
import {
type McpInstallOptions as CoreMcpInstallOptions,
installMcpServer,
type McpInstallResult,
type McpServerTransportConfig,
} from "@cline/core";
import type { McpAddDefaults } from "../wizards/mcp";
export { buildMcpInstallTransport } from "@cline/core";
import { addServer, type McpTransport } from "../wizards/mcp/settings";
export interface McpCommandIo {
writeln?: (text: string) => void;
writeErr: (text: string) => void;
}
export interface McpInstallOptions extends CoreMcpInstallOptions {
export interface McpInstallOptions {
name: string;
headers?: string[];
targetArgs?: string[];
transport?: string;
io?: McpCommandIo;
isTty?: boolean;
json?: boolean;
@@ -24,13 +21,13 @@ export interface McpInstallOptions extends CoreMcpInstallOptions {
export interface McpInstallDirectResult {
name: string;
status: "installed";
transport: McpServerTransportConfig;
transport: McpTransport;
warnings: string[];
}
function normalizeTransportType(
value: string | undefined,
): McpServerTransportConfig["type"] {
): McpTransport["type"] {
const normalized = (value ?? "stdio").trim();
if (normalized === "http" || normalized === "streamable-http") {
return "streamableHttp";
@@ -61,6 +58,72 @@ function assertValidUrl(url: string): void {
}
}
function parseHeader(value: string): [string, string] {
const separatorIndex = value.indexOf(":");
if (separatorIndex <= 0) {
throw new Error(
`Invalid MCP header "${value}". Expected "Header-Name: header value".`,
);
}
const name = value.slice(0, separatorIndex).trim();
const headerValue = value.slice(separatorIndex + 1).trim();
if (!name || !headerValue) {
throw new Error(
`Invalid MCP header "${value}". Expected "Header-Name: header value".`,
);
}
if (!/^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test(name)) {
throw new Error(`Invalid MCP header name "${name}".`);
}
return [name, headerValue];
}
function splitTargetArgsAndHeaders(input: {
headers?: string[];
targetArgs?: string[];
}): { headers: string[]; targetArgs: string[] } {
const headers = [...(input.headers ?? [])];
const targetArgs: string[] = [];
const args = input.targetArgs ?? [];
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg === "--header") {
const value = args[index + 1];
if (!value) {
throw new Error("--header requires a value");
}
headers.push(value);
index++;
continue;
}
if (arg?.startsWith("--header=")) {
headers.push(arg.slice("--header=".length));
continue;
}
targetArgs.push(arg);
}
return { headers, targetArgs };
}
function buildHeaders(values: string[]): {
headers?: Record<string, string>;
warnings: string[];
} {
if (values.length === 0) return { warnings: [] };
const headers: Record<string, string> = {};
const warnings: string[] = [];
for (const value of values) {
const [name, headerValue] = parseHeader(value);
headers[name] = headerValue;
if (/<[^>]+>/.test(headerValue)) {
warnings.push(
`Header "${name}" looks like it contains a placeholder. Update it in MCP settings before using this server.`,
);
}
}
return { headers, warnings };
}
function quoteCommandArg(arg: string): string {
if (/^[^\s"'\\]+$/.test(arg)) {
return arg;
@@ -106,15 +169,67 @@ export function buildMcpInstallDefaults(options: {
};
}
export function buildMcpInstallTransport(options: {
headers?: string[];
name: string;
targetArgs?: string[];
transport?: string;
}): { name: string; transport: McpTransport; warnings: string[] } {
const name = options.name.trim();
if (!name) {
throw new Error("MCP server name is required");
}
const type = normalizeTransportType(options.transport);
const { headers: rawHeaders, targetArgs } = splitTargetArgsAndHeaders({
headers: options.headers,
targetArgs: options.targetArgs,
});
const { headers, warnings } = buildHeaders(rawHeaders);
if (type === "stdio") {
if (rawHeaders.length > 0) {
throw new Error("Stdio MCP installs do not support request headers.");
}
const [command, ...args] = targetArgs;
if (!command?.trim()) {
throw new Error(
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs --yes -- npx -y @modelcontextprotocol/server-filesystem /tmp",
);
}
return {
name,
transport: {
type,
command,
args: args.length > 0 ? args : undefined,
},
warnings,
};
}
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,
transport: headers ? { type, url, headers } : { type, url },
warnings,
};
}
export function installMcpServerDirect(
options: McpInstallOptions,
): McpInstallDirectResult {
const result: McpInstallResult = installMcpServer(options);
const { name, transport, warnings } = buildMcpInstallTransport(options);
addServer(name, transport);
return {
name: result.name,
status: result.status,
transport: result.transport,
warnings: result.warnings,
name,
status: "installed",
transport,
warnings,
};
}
File diff suppressed because it is too large Load Diff
@@ -101,7 +101,7 @@ describe("buildConnectorStartRequest", () => {
expect(request.apiKey).toBe("env-openrouter-key");
expect(request.model).toBe("anthropic/claude-sonnet-4.6");
expect(mockGetLastUsedProviderSettings).toHaveBeenCalledWith({
isClinePassEnabled: true,
isClinePassEnabled: false,
});
});
+3 -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,
@@ -63,7 +64,8 @@ export async function buildConnectorStartRequest(input: {
const providerSettingsManager = new ProviderSettingsManager();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings({
isClinePassEnabled: true,
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
const provider = normalizeProviderId(
input.options.provider?.trim() ||
+16 -37
View File
@@ -1,10 +1,7 @@
// @jsxImportSource @opentui/react
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import open from "open";
import { useCallback, useMemo, useState } from "react";
import { palette } from "../tui/palette";
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
import type { CliMigrationNotice } from "./notice";
export function MigrationNoticeContent(
@@ -13,29 +10,10 @@ export function MigrationNoticeContent(
},
) {
const { dialogId, notice, resolve } = props;
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
const [status, setStatus] = useState<string | undefined>();
const openSubscriptionPage = useCallback(() => {
setStatus("Opening ClinePass in your browser...");
void open(subscriptionUrl, { wait: false })
.then(() => {
setStatus("Opened ClinePass in your browser.");
})
.catch(() => {
setStatus(
"Could not open the browser automatically. Use the URL below.",
);
});
}, [subscriptionUrl]);
useDialogKeyboard((key) => {
if (key.name === "escape") {
resolve(true);
return;
}
if (key.name === "return" || key.name === "enter") {
openSubscriptionPage();
}
}, dialogId);
@@ -44,24 +22,25 @@ export function MigrationNoticeContent(
<text fg={palette.act}>{notice.title}</text>
<box flexDirection="column">
<text selectable>
ClinePass is a $9.99/month subscription plan to get access to the
latest open-weight coding models with enough quota for day-to-day
work, at a much lower cost than paying API costs directly.
We rebuilt the CLI from the ground up using the new Cline SDK. Learn
more:{" "}
<a href="https://github.com/cline/cline">
<span fg={palette.act}>https://github.com/cline/cline</span>
</a>
</text>
<text selectable>Try it now with a limited-time promo for $1.99.</text>
</box>
<box flexDirection="row">
<text fg={palette.act} selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
<text selectable>
Running{" "}
<span fg="#98c379" bg="#1f2937">
{" cline "}
</span>{" "}
now opens the terminal UI. To open Kanban, use /quit and run{" "}
<span fg="#98c379" bg="#1f2937">
{" cline kanban "}
</span>{" "}
in your terminal
</text>
</box>
<box flexDirection="row">
<box paddingX={1} backgroundColor={palette.act}>
<text fg={palette.textOnSelection}>Open ClinePass</text>
</box>
</box>
{status && <text fg={palette.muted}>{status}</text>}
<text fg={palette.muted}>Press Enter to open, Esc to close</text>
<text fg={palette.muted}>Press Esc to close</text>
</box>
);
}
+9 -71
View File
@@ -1,18 +1,11 @@
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
getClineCliMigrationNotice,
markClineCliMigrationNoticeShown,
resolveCliNoticeStatePath,
shouldSuppressClineCliMigrationNoticeForActiveProvider,
} from "./notice";
const tempDirs: string[] = [];
@@ -33,25 +26,8 @@ describe("migration notice", () => {
it("returns the notice for a fresh data dir", () => {
const dataDir = createTempDataDir();
expect(getClineCliMigrationNotice(dataDir)?.title).toBe("Try ClinePass");
});
it("shows when only the old Kanban notice was marked as shown", () => {
const dataDir = createTempDataDir();
const noticePath = resolveCliNoticeStatePath(dataDir);
mkdirSync(dirname(noticePath), { recursive: true, mode: 0o700 });
writeFileSync(
noticePath,
`${JSON.stringify(
{ shown: { "cline-cli-tui-default": true } },
null,
2,
)}\n`,
"utf8",
);
expect(getClineCliMigrationNotice(dataDir)?.id).toBe(
"cline-cli-cline-pass-intro",
expect(getClineCliMigrationNotice(dataDir)?.title).toBe(
"Welcome to the new Cline CLI",
);
});
@@ -70,7 +46,7 @@ describe("migration notice", () => {
expect(
getClineCliMigrationNotice(dataDir, {
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
CLINE_FORCE_MIGRATION_NOTICE: "1",
}),
).toBeDefined();
});
@@ -80,56 +56,18 @@ describe("migration notice", () => {
expect(
getClineCliMigrationNotice(dataDir, {
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
CLINE_DISABLE_MIGRATION_NOTICE: "1",
}),
).toBeUndefined();
});
it("does not show when ClinePass is already the active provider", () => {
const dataDir = createTempDataDir();
expect(
getClineCliMigrationNotice(
dataDir,
{},
{ activeProviderId: "cline-pass" },
),
).toBeUndefined();
});
it("suppresses the active ClinePass provider even when the provider id has surrounding whitespace", () => {
expect(
shouldSuppressClineCliMigrationNoticeForActiveProvider(" cline-pass "),
).toBe(true);
});
it("does not suppress the active ClinePass provider when forced", () => {
expect(
shouldSuppressClineCliMigrationNoticeForActiveProvider("cline-pass", {
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
}),
).toBe(false);
});
it("shows for the active ClinePass provider when forced", () => {
const dataDir = createTempDataDir();
expect(
getClineCliMigrationNotice(
dataDir,
{ CLINE_FORCE_CLINE_PASS_NOTICE: "1" },
{ activeProviderId: "cline-pass" },
),
).toBeDefined();
});
it("shows when forced even if disabled through the environment", () => {
const dataDir = createTempDataDir();
expect(
getClineCliMigrationNotice(dataDir, {
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
CLINE_DISABLE_MIGRATION_NOTICE: "1",
CLINE_FORCE_MIGRATION_NOTICE: "1",
}),
).toBeDefined();
});
@@ -140,7 +78,7 @@ describe("migration notice", () => {
markClineCliMigrationNoticeShown(dataDir);
const rawState = readFileSync(resolveCliNoticeStatePath(dataDir), "utf8");
expect(rawState).toContain("cline-cli-cline-pass-intro");
expect(rawState).toContain("cline-cli-tui-default");
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
});
});
+5 -31
View File
@@ -2,19 +2,15 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { resolveClineDataDir } from "@cline/shared/storage";
const NOTICE_ID = "cline-cli-cline-pass-intro";
const FORCE_NOTICE_ENV = "CLINE_FORCE_CLINE_PASS_NOTICE";
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_CLINE_PASS_NOTICE";
const NOTICE_ID = "cline-cli-tui-default";
const FORCE_NOTICE_ENV = "CLINE_FORCE_MIGRATION_NOTICE";
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_MIGRATION_NOTICE";
export interface CliMigrationNotice {
id: string;
title: string;
}
export interface CliMigrationNoticeOptions {
activeProviderId?: string;
}
interface CliNoticeState {
shown: Record<string, boolean>;
}
@@ -53,19 +49,6 @@ function readNoticeState(filePath: string): CliNoticeState {
return { shown };
}
function isForceNoticeEnabled(env: NodeJS.ProcessEnv): boolean {
return env[FORCE_NOTICE_ENV]?.trim() === "1";
}
export function shouldSuppressClineCliMigrationNoticeForActiveProvider(
activeProviderId: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): boolean {
return (
activeProviderId?.trim() === "cline-pass" && !isForceNoticeEnabled(env)
);
}
export function resolveCliNoticeStatePath(
dataDir = resolveClineDataDir(),
): string {
@@ -75,29 +58,20 @@ export function resolveCliNoticeStatePath(
export function getClineCliMigrationNotice(
dataDir = resolveClineDataDir(),
env: NodeJS.ProcessEnv = process.env,
options: CliMigrationNoticeOptions = {},
): CliMigrationNotice | undefined {
const noticePath = resolveCliNoticeStatePath(dataDir);
const noticeState = readNoticeState(noticePath);
const forceNotice = isForceNoticeEnabled(env);
const forceNotice = env[FORCE_NOTICE_ENV]?.trim() === "1";
const disableNotice = env[DISABLE_NOTICE_ENV]?.trim() === "1";
if (disableNotice && !forceNotice) {
return undefined;
}
if (
shouldSuppressClineCliMigrationNoticeForActiveProvider(
options.activeProviderId,
env,
)
) {
return undefined;
}
if (noticeState.shown[NOTICE_ID] && !forceNotice) {
return undefined;
}
return {
id: NOTICE_ID,
title: "Try ClinePass",
title: "Welcome to the new Cline CLI",
};
}
+47 -146
View File
@@ -1,9 +1,6 @@
import { fstatSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type {
CliMigrationNotice,
CliMigrationNoticeOptions,
} from "./kanban-migration/notice";
import type { CliMigrationNotice } from "./kanban-migration/notice";
/** Real `fstatSync`: used when tests stub only stdin (fd 0); throwing for every fd breaks imports and session I/O. */
const fsActual = vi.hoisted(() => ({
@@ -62,13 +59,9 @@ const dashboardMocks = vi.hoisted(() => ({
runDashboardCommand: vi.fn(),
}));
const migrationNoticeMocks = vi.hoisted(() => ({
getClineCliMigrationNotice: vi.fn<
(
dataDir?: string,
env?: NodeJS.ProcessEnv,
options?: CliMigrationNoticeOptions,
) => CliMigrationNotice | undefined
>(() => undefined),
getClineCliMigrationNotice: vi.fn<() => CliMigrationNotice | undefined>(
() => undefined,
),
markClineCliMigrationNoticeShown: vi.fn(),
}));
const updateMocks = vi.hoisted(() => ({
@@ -122,7 +115,6 @@ const telemetryMocks = vi.hoisted(() => ({
const featureFlagMocks = vi.hoisted(() => ({
getBooleanFlagEnabled: vi.fn(() => false),
setCliFeatureFlagsAccountContext: vi.fn(),
refreshCliFeatureFlagsInBackground: vi.fn(),
}));
function forcePromptModeInput() {
@@ -187,8 +179,7 @@ vi.mock("./utils/feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
}),
refreshCliFeatureFlagsInBackground:
featureFlagMocks.refreshCliFeatureFlagsInBackground,
refreshCliFeatureFlagsInBackground: vi.fn(),
setCliFeatureFlagsAccountContext:
featureFlagMocks.setCliFeatureFlagsAccountContext,
}));
@@ -267,7 +258,6 @@ describe("runCli lightweight command dispatch", () => {
featureFlagMocks.getBooleanFlagEnabled.mockReset();
featureFlagMocks.getBooleanFlagEnabled.mockReturnValue(false);
featureFlagMocks.setCliFeatureFlagsAccountContext.mockReset();
featureFlagMocks.refreshCliFeatureFlagsInBackground.mockReset();
kanbanMocks.launchKanban.mockReset();
kanbanMocks.launchKanban.mockResolvedValue(0);
dashboardMocks.runDashboardCommand.mockReset();
@@ -417,7 +407,7 @@ describe("runCli lightweight command dispatch", () => {
it("does not load interactive runtime for single-prompt mode", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
@@ -427,30 +417,6 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runInteractiveImports).toBe(0);
});
it("rejects a single bare positional prompt token", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "nonexistent-command"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining(
"Unknown command or unquoted prompt: nonexistent-command",
),
);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining('Use "cline --help"'),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(mockState.runAgentImports).toBe(0);
expect(mockState.runInteractiveImports).toBe(0);
});
it("rejects multiple bare positional prompt tokens", async () => {
const consoleError = vi
.spyOn(console, "error")
@@ -464,7 +430,7 @@ describe("runCli lightweight command dispatch", () => {
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining(
"Unknown command or unquoted prompt: hello world",
"Unknown command or extra arguments: hello world",
),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
@@ -508,7 +474,7 @@ describe("runCli lightweight command dispatch", () => {
it("creates a worktree and runs prompt sessions from it", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--worktree", "say hello"];
process.argv = ["bun", "src/index.ts", "--worktree", "hello"];
const { runCli } = await import("./main");
@@ -517,7 +483,7 @@ describe("runCli lightweight command dispatch", () => {
cwd: process.cwd(),
});
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
cwd: "/tmp/cline-worktree",
workspaceRoot: "/tmp/cline-worktree",
@@ -640,8 +606,8 @@ describe("runCli lightweight command dispatch", () => {
it("passes the migration notice marker into interactive mode", async () => {
const notice = {
id: "cline-cli-cline-pass-intro",
title: "Try ClinePass",
id: "cline-cli-tui-default",
title: "Welcome to the new Cline CLI",
};
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
Object.defineProperty(process.stdout, "isTTY", {
@@ -672,37 +638,6 @@ describe("runCli lightweight command dispatch", () => {
).toHaveBeenCalledTimes(1);
});
it("passes the active ClinePass provider into the migration notice gate", async () => {
providerSettingsMocks.getLastUsedProviderSettings.mockReturnValue({
provider: "cline-pass",
model: "cline-pass/test-model",
});
Object.defineProperty(process.stdout, "isTTY", {
value: true,
configurable: true,
});
process.argv = ["bun", "src/index.ts"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(
migrationNoticeMocks.getClineCliMigrationNotice,
).toHaveBeenCalledWith(undefined, process.env, {
activeProviderId: "cline-pass",
});
expect(runtimeMocks.runInteractive).toHaveBeenCalledWith(
expect.objectContaining({
providerId: "cline-pass",
}),
expect.anything(),
undefined,
expect.objectContaining({
initialNotice: undefined,
}),
);
});
it("does not start OAuth before onboarding in interactive mode", async () => {
authMocks.isOAuthProvider.mockReturnValue(true);
authMocks.normalizeProviderId.mockReturnValue("cline");
@@ -792,7 +727,7 @@ describe("runCli lightweight command dispatch", () => {
it("uses the bundled catalog path for single-prompt runs", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
@@ -983,7 +918,7 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("seeds feature flag identity from persisted Cline account id before refreshing flags", async () => {
it("seeds feature flag identity from persisted Cline account id before checking flags", async () => {
const clineSettings = {
provider: "cline",
model: "anthropic/claude-sonnet-4.6",
@@ -1002,14 +937,11 @@ describe("runCli lightweight command dispatch", () => {
expect(
featureFlagMocks.setCliFeatureFlagsAccountContext,
).toHaveBeenCalledWith({ id: "acct-startup" });
// The account identity must be seeded before flags are refreshed/used so
// the background refresh resolves flags for the correct account.
expect(
featureFlagMocks.setCliFeatureFlagsAccountContext.mock
.invocationCallOrder[0],
).toBeLessThan(
featureFlagMocks.refreshCliFeatureFlagsInBackground.mock
.invocationCallOrder[0],
featureFlagMocks.getBooleanFlagEnabled.mock.invocationCallOrder[0],
);
});
@@ -1081,30 +1013,12 @@ describe("runCli lightweight command dispatch", () => {
it("skips hub prewarm for yolo runs", async () => {
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--yolo", "say hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
});
it("rejects yolo runs with a single bare prompt token", async () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--yolo", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("Unknown command or unquoted prompt: hello"),
);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
});
@@ -1128,12 +1042,12 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("rejects /team without quoted task text", async () => {
it("shows /team usage in single-prompt mode when no task is provided", async () => {
mockState.runAgentCalls = 0;
runtimeMocks.runAgent.mockClear();
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
const stdoutWrite = vi
.spyOn(process.stdout, "write")
.mockImplementation(() => true);
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "/team"];
@@ -1141,10 +1055,9 @@ describe("runCli lightweight command dispatch", () => {
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(mockState.runAgentCalls).toBe(0);
expect(consoleError).toHaveBeenCalledWith(
expect.stringContaining("Unknown command or unquoted prompt: /team"),
expect(stdoutWrite).toHaveBeenCalledWith(
expect.stringContaining("Usage: /team <task description>"),
);
});
@@ -1153,14 +1066,14 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "high", "say hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "high", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "high",
@@ -1174,14 +1087,14 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
thinking: undefined,
reasoningEffort: undefined,
@@ -1195,14 +1108,14 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "none", "say hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "none", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
thinking: false,
reasoningEffort: undefined,
@@ -1216,14 +1129,14 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "--", "say hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "--", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1246,14 +1159,14 @@ describe("runCli lightweight command dispatch", () => {
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "high",
@@ -1272,14 +1185,14 @@ describe("runCli lightweight command dispatch", () => {
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
thinking: false,
reasoningEffort: undefined,
@@ -1298,14 +1211,14 @@ describe("runCli lightweight command dispatch", () => {
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--thinking", "low", "say hello"];
process.argv = ["bun", "src/index.ts", "--thinking", "low", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(mockState.runAgentCalls).toBe(1);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
thinking: true,
reasoningEffort: "low",
@@ -1319,13 +1232,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "say hello"];
process.argv = ["bun", "src/index.ts", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1341,19 +1254,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = [
"bun",
"src/index.ts",
"--compaction",
"basic",
"say hello",
];
process.argv = ["bun", "src/index.ts", "--compaction", "basic", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1369,19 +1276,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = [
"bun",
"src/index.ts",
"--compaction",
"agentic",
"say hello",
];
process.argv = ["bun", "src/index.ts", "--compaction", "agentic", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: true,
@@ -1431,13 +1332,13 @@ describe("runCli lightweight command dispatch", () => {
runtimeMocks.runAgent.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--compaction", "off", "say hello"];
process.argv = ["bun", "src/index.ts", "--compaction", "off", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
compaction: {
enabled: false,
@@ -1476,7 +1377,7 @@ describe("runCli lightweight command dispatch", () => {
authMocks.ensureOAuthProviderApiKey.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
process.argv = ["bun", "src/index.ts", "--json", "hello"];
const { runCli } = await import("./main");
@@ -1484,7 +1385,7 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runAgentCalls).toBe(1);
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
outputMode: "json",
apiKey: "",
@@ -1503,7 +1404,7 @@ describe("runCli lightweight command dispatch", () => {
authMocks.ensureOAuthProviderApiKey.mockClear();
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
process.argv = ["bun", "src/index.ts", "--json", "hello"];
const { runCli } = await import("./main");
@@ -1511,7 +1412,7 @@ describe("runCli lightweight command dispatch", () => {
expect(mockState.runAgentCalls).toBe(1);
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"say hello",
"hello",
expect.objectContaining({
outputMode: "json",
apiKey: "",
+11 -24
View File
@@ -20,6 +20,7 @@ import {
CLI_COMPACTION_MODE_EXPECTED_TEXT,
} from "./utils/compaction-mode";
import {
getCliFeatureFlagsService,
refreshCliFeatureFlagsInBackground,
setCliFeatureFlagsAccountContext,
} from "./utils/feature-flags";
@@ -116,19 +117,6 @@ function collectOption(value: string, previous: string[] = []): string[] {
return [...previous, value];
}
// Shells strip quote characters before argv reaches us, so a prompt that was
// typed in quotes is only observable when it remains one argv token with spaces.
function promptArgLooksQuoted(arg: string | undefined): boolean {
return !!arg && /\s/.test(arg);
}
function writePromptArgError(args: string[]): void {
const renderedArgs = args.join(" ");
writeErr(
`Unknown command or unquoted prompt: ${renderedArgs}\nPrompt text must be passed as a single quoted argument, for example: cline "fix the tests". Use "cline --help" to see available commands and flags.`,
);
}
export async function runCli(): Promise<void> {
installStreamErrorGuards();
autoUpdateOnStartup();
@@ -748,6 +736,13 @@ 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) {
@@ -834,13 +829,6 @@ export async function runCli(): Promise<void> {
if (args.hooksDir?.trim()) {
process.env.CLINE_HOOKS_DIR = args.hooksDir.trim();
}
if (args.prompt && !args.interactive) {
if (program.args.length > 1 || !promptArgLooksQuoted(program.args[0])) {
writePromptArgError(program.args);
process.exitCode = 1;
return;
}
}
setCurrentOutputMode(args.outputMode);
const defaultToolAutoApprove = true;
const effectiveToolAutoApprove =
@@ -955,7 +943,8 @@ export async function runCli(): Promise<void> {
refreshCliFeatureFlagsInBackground();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings({
isClinePassEnabled: true,
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
const provider = normalizeProviderId(
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
@@ -1180,9 +1169,7 @@ export async function runCli(): Promise<void> {
if (!launchConfigView && process.stdin.isTTY && process.stdout.isTTY) {
const { getClineCliMigrationNotice, markClineCliMigrationNoticeShown } =
await import("./kanban-migration/notice");
initialNotice = getClineCliMigrationNotice(undefined, process.env, {
activeProviderId: provider,
});
initialNotice = getClineCliMigrationNotice();
if (initialNotice) {
markInitialNoticeShown = () => {
markClineCliMigrationNoticeShown();
@@ -365,48 +365,6 @@ describe("createInteractiveSessionRuntime", () => {
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("recovers empty read-driven restarts when the active interactive session disappeared", async () => {
const manager = makeManager();
manager.readMessages.mockRejectedValueOnce(
new SessionNotFoundError("session-1"),
);
const runtime = makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartWithCurrentMessages();
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
expect(manager.start).toHaveBeenCalledTimes(2);
expect(manager.start).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
initialMessages: [],
}),
);
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("does not restart with stale messages when another operation changes the active session during a read", async () => {
const manager = makeManager();
let runtime!: ReturnType<typeof makeRuntime>;
manager.readMessages.mockImplementationOnce(async () => {
await runtime.restartEmpty();
return [
{
role: "user" as const,
content: [{ type: "text" as const, text: "stale" }],
},
];
});
runtime = makeRuntime(manager);
await runtime.ensureReady();
await runtime.restartWithCurrentMessages();
expect(manager.start).toHaveBeenCalledTimes(2);
expect(runtime.getActiveSessionId()).toBe("session-2");
});
it("waits for missing-session recovery before cleanup disposes the manager", async () => {
const manager = makeManager();
const recoveryRead = deferred<Message[]>();
@@ -49,13 +49,6 @@ type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
type AskQuestionRef = {
current: ((question: string, options: string[]) => Promise<string>) | null;
};
type CurrentMessagesRead =
| { messages: Message[]; status: "read" }
| { messages: Message[]; status: "recovered" }
| { messages: Message[]; status: "stale" };
type MissingSessionRecovery = {
messages: Message[];
};
type ToolPolicyResolver = (
toolName: string,
) => NonNullable<Config["toolPolicies"]>[string];
@@ -110,9 +103,7 @@ export function createInteractiveSessionRuntime(input: {
let shutdownRequested = false;
let activeSessionId = "";
let abortRequested = false;
let missingSessionRecoveryPromise:
| Promise<MissingSessionRecovery>
| undefined;
let missingSessionRecoveryPromise: Promise<void> | undefined;
// A reset can happen while an earlier manager.start() is still in flight.
// Bump this before resets and restarts so stale starts cannot become active.
let sessionStartGeneration = 0;
@@ -284,34 +275,14 @@ export function createInteractiveSessionRuntime(input: {
return await startupPromise;
};
const readCurrentMessages = async (): Promise<CurrentMessagesRead> => {
const manager = sessionManager;
const sessionId = activeSessionId;
if (!manager || !sessionId) {
return { messages: [], status: "read" };
}
try {
const messages = (await manager.readMessages(sessionId)) ?? [];
return {
messages,
status: activeSessionId === sessionId ? "read" : "stale",
};
} catch (error) {
if (
abortRequested ||
shutdownRequested ||
!isSessionNotFoundError(error)
) {
throw error;
}
const recovery = await recoverMissingActiveSession(error);
return { messages: recovery.messages, status: "recovered" };
const readCurrentMessages = async (): Promise<Message[]> => {
if (!sessionManager || !activeSessionId) {
return [];
}
return (await sessionManager.readMessages(activeSessionId)) ?? [];
};
const recoverMissingActiveSession = async (
error: unknown,
): Promise<MissingSessionRecovery> => {
const recoverMissingActiveSession = async (error: unknown): Promise<void> => {
if (missingSessionRecoveryPromise) {
return await missingSessionRecoveryPromise;
}
@@ -319,7 +290,7 @@ export function createInteractiveSessionRuntime(input: {
const manager = sessionManager;
const missingSessionId = activeSessionId;
if (!manager || !missingSessionId || shutdownRequested) {
return { messages: [] };
return;
}
const messages = await manager
.readMessages(missingSessionId)
@@ -336,7 +307,6 @@ export function createInteractiveSessionRuntime(input: {
startupError = undefined;
clearActiveSession();
await startFreshSession(messages);
return { messages };
})().finally(() => {
missingSessionRecoveryPromise = undefined;
});
@@ -391,13 +361,7 @@ export function createInteractiveSessionRuntime(input: {
};
const restartWithCurrentMessages = async (): Promise<void> => {
const { messages, status } = await readCurrentMessages();
if (status !== "read") {
// If reading recovered a missing hub session, the current messages are
// already in the replacement session. If the read is stale, another async
// operation changed the active session while this read was in flight.
return;
}
const messages = await readCurrentMessages();
await restartWithMessages(messages);
};
@@ -546,13 +510,7 @@ export function createInteractiveSessionRuntime(input: {
if (!sessionManager) {
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
}
const { messages, status } = await readCurrentMessages();
if (status === "stale" || (status === "recovered" && !activeSessionId)) {
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
}
// If reading messages recovered the session, `messages` are the same messages
// used to seed the replacement session, so it is safe to compact the current
// active session with them.
const messages = await readCurrentMessages();
const messagesBefore = messages.length;
if (messagesBefore === 0) {
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
@@ -593,10 +551,7 @@ export function createInteractiveSessionRuntime(input: {
return undefined;
}
const checkpointHistory = readSessionCheckpointHistory(sessionRecord);
const { messages, status } = await readCurrentMessages();
if (status !== "read") {
return undefined;
}
const messages = await readCurrentMessages();
return { messages, checkpointHistory };
};
+1 -1
View File
@@ -40,7 +40,7 @@ const sessionEventsMocks = vi.hoisted(() => ({
const CLINE_PASS_SUBSCRIPTION_URL =
"https://app.cline.bot/dashboard/subscription?personal=true";
const CLI_SUBSCRIPTION_URL =
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
"https://app.cline.bot/promo?code=CLI-100&personal=true";
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
+1 -8
View File
@@ -8,7 +8,6 @@ import type { CliMigrationNotice } from "../kanban-migration/notice";
import { logCliError } from "../logging/errors";
import {
loadClineAccountSnapshot,
loadIndividualSubscriptionPlans,
onProviderChange,
switchClineAccount,
} from "../tui/cline-account";
@@ -389,7 +388,7 @@ export async function runInteractive(
? async () => {
try {
await sessionRuntime.ensureReady();
const { messages } = await sessionRuntime.readCurrentMessages();
const messages = await sessionRuntime.readCurrentMessages();
const usage = await sessionRuntime.getAccumulatedUsage({
inputTokens: 0,
outputTokens: 0,
@@ -428,12 +427,6 @@ export async function runInteractive(
config,
clineApiBaseUrl: options?.clineApiBaseUrl,
}),
loadIndividualSubscriptionPlans: async () =>
await loadIndividualSubscriptionPlans({
config,
clineApiBaseUrl: options?.clineApiBaseUrl,
clineProviderSettings: options?.clineProviderSettings,
}),
switchClineAccount: async (organizationId) =>
await switchClineAccount({
config,
+1 -1
View File
@@ -124,7 +124,7 @@ export function clineEnv(
}),
CLINE_SESSION_DATA_DIR: path.join(dataDir, "sessions"),
CLINE_TEAM_DATA_DIR: path.join(dataDir, "teams"),
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
CLINE_DISABLE_MIGRATION_NOTICE: "1",
NO_UPDATE_NOTIFIER: "1",
CLINE_NO_AUTO_UPDATE: "1",
...extra,
-60
View File
@@ -12,8 +12,6 @@ const coreMocks = vi.hoisted(() => {
fetchMe: vi.fn(),
fetchBalance: vi.fn(),
fetchOrganizationBalance: vi.fn(),
fetchAvailableSubscriptionPlans: vi.fn(),
fetchCurrentUserPlan: vi.fn(),
serviceOptions,
};
});
@@ -41,14 +39,6 @@ vi.mock("@cline/core", async (importOriginal) => {
fetchOrganizationBalance(organizationId: string) {
return coreMocks.fetchOrganizationBalance(organizationId);
}
fetchAvailableSubscriptionPlans(input?: {
type?: "individual" | "teams";
}) {
return coreMocks.fetchAvailableSubscriptionPlans(input);
}
fetchCurrentUserPlan() {
return coreMocks.fetchCurrentUserPlan();
}
},
ProviderSettingsManager: class {
getProviderSettings(providerId: string) {
@@ -110,8 +100,6 @@ describe("createClineAccountService", () => {
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
coreMocks.fetchCurrentUserPlan.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
@@ -208,8 +196,6 @@ describe("loadClineAccountSnapshot", () => {
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
coreMocks.fetchCurrentUserPlan.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
@@ -263,49 +249,3 @@ describe("loadClineAccountSnapshot", () => {
);
});
});
describe("loadIndividualSubscriptionPlans", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
coreMocks.getProviderSettings.mockReset();
coreMocks.saveProviderSettings.mockReset();
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
coreMocks.fetchCurrentUserPlan.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("loads individual subscription plans through the authorized account service", async () => {
const plans = [
{
id: "plan-1",
interval: "Monthly",
features: { included: ["Major open-weights models"] },
},
];
coreMocks.getProviderSettings.mockReturnValue({
provider: "cline",
apiKey: "account-token",
});
coreMocks.fetchAvailableSubscriptionPlans.mockResolvedValue(plans);
const { loadIndividualSubscriptionPlans } = await import("./cline-account");
const result = await loadIndividualSubscriptionPlans({
config: makeConfig(),
});
expect(coreMocks.fetchAvailableSubscriptionPlans).toHaveBeenCalledWith({
type: "individual",
});
expect(result).toEqual(plans);
});
});
+1 -58
View File
@@ -2,8 +2,6 @@ import {
type ClineAccountBalance,
type ClineAccountOrganization,
type ClineAccountOrganizationBalance,
type ClineSubscriptionPlan,
type UserCurrentPlan,
ClineAccountService,
type ClineAccountUser,
formatProviderOAuthApiKey,
@@ -126,9 +124,8 @@ export async function createClineAccountService(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
clineProviderSettings?: ProviderSettings;
providerSettingsManager?: ProviderSettingsManager;
}): Promise<ClineAccountService | undefined> {
const manager = input.providerSettingsManager ?? new ProviderSettingsManager();
const manager = new ProviderSettingsManager();
const settings =
manager.getProviderSettings("cline") ?? input.clineProviderSettings;
const apiBaseUrl = resolveAccountApiBaseUrl({
@@ -206,60 +203,6 @@ export async function switchClineAccount(input: {
await service.switchAccount(input.organizationId);
}
export async function loadIndividualSubscriptionPlans(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
clineProviderSettings?: ProviderSettings;
}): Promise<ClineSubscriptionPlan[]> {
const service = await createClineAccountService(input);
if (!service) {
throw new Error("No Cline account auth token found");
}
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
}
export async function loadCurrentUserPlan(input: {
config: ClineAccountConfig;
clineApiBaseUrl?: string;
clineProviderSettings?: ProviderSettings;
}): Promise<UserCurrentPlan | undefined> {
const service = await createClineAccountService(input);
if (!service) {
throw new Error("No Cline account auth token found");
}
return service.fetchCurrentUserPlan();
}
export async function loadCurrentUserPlanFromProviderSettings(input: {
providerSettingsManager: ProviderSettingsManager;
clineApiBaseUrl?: string;
}): Promise<UserCurrentPlan | undefined> {
const service = await createClineAccountService({
config: { apiKey: "", logger: undefined, providerId: "cline" },
clineApiBaseUrl: input.clineApiBaseUrl,
providerSettingsManager: input.providerSettingsManager,
});
if (!service) {
throw new Error("No Cline account auth token found");
}
return service.fetchCurrentUserPlan();
}
export async function loadIndividualSubscriptionPlansFromProviderSettings(input: {
providerSettingsManager: ProviderSettingsManager;
clineApiBaseUrl?: string;
}): Promise<ClineSubscriptionPlan[]> {
const service = await createClineAccountService({
config: { apiKey: "", logger: undefined, providerId: "cline" },
clineApiBaseUrl: input.clineApiBaseUrl,
providerSettingsManager: input.providerSettingsManager,
});
if (!service) {
throw new Error("No Cline account auth token found");
}
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
}
async function onChangeToClinePass(config: ClineAccountConfig) {
try {
await switchClineAccount({
+13 -87
View File
@@ -1,12 +1,10 @@
import type { ClineSubscriptionPlan } from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import type React from "react";
import { useEffect, useState } from "react";
import { useState } from "react";
import "opentui-spinner/react";
import {
getClineOrgIndividualInferenceSubscriptionMessage,
getCliSubscriptionUrl,
getIndividualPlanFeatures,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
@@ -17,7 +15,6 @@ import {
import { useTerminalBackground } from "../hooks/use-terminal-background";
import {
getDefaultForeground,
getModeAccent,
getModeInputBackground,
palette,
type TerminalTheme,
@@ -269,8 +266,7 @@ function ToolCallView(props: {
);
}
function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
const subscriptionUrl = getCliSubscriptionUrl();
function ClineCreditsErrorView(props: { defaultFg?: string }) {
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -285,96 +281,39 @@ function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
<text
fg={props.defaultFg}
selectable
content={
"You have run out of Cline credits. Add credits in the dashboard or purchase and switch to ClinePass to continue."
}
content="You have run out of Cline credits. Add credits in the dashboard to continue."
/>
<box flexDirection="row">
<text fg="gray">Purchase Credits: </text>
<text fg="gray">Dashboard: </text>
<text fg="cyan" selectable>
<a href={CLINE_CREDITS_DASHBOARD_URL}>
{CLINE_CREDITS_DASHBOARD_URL}
</a>
</text>
</box>
<box flexDirection="row">
<text fg="gray">Purchase ClinePass: </text>
<text fg="cyan" selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
</text>
</box>
<box flexDirection="row">
<text fg="gray">Switch to ClinePass: </text>
<text fg="gray">
type /settings in CLI and switch provider to ClinePass
</text>
</box>
</box>
</box>
);
}
function ClineCreditsErrorView(props: { defaultFg?: string }) {
return <ClineCreditsClinePassErrorView defaultFg={props.defaultFg} />;
}
function ClinePassSubscriptionErrorView(props: {
defaultFg?: string;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
terminalTheme: TerminalTheme;
}) {
function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
const subscriptionUrl = getCliSubscriptionUrl();
const [planFeatures, setPlanFeatures] = useState<string[]>([]);
const planAccent = getModeAccent("plan", props.terminalTheme);
useEffect(() => {
if (!props.loadIndividualSubscriptionPlans) {
return;
}
let isMounted = true;
void props
.loadIndividualSubscriptionPlans()
.then((plans) => {
if (isMounted) {
setPlanFeatures(getIndividualPlanFeatures(plans));
}
})
.catch(() => {
// Keep the subscription error view usable if plan metadata is unavailable.
});
return () => {
isMounted = false;
};
}, [props.loadIndividualSubscriptionPlans]);
return (
<box flexDirection="row">
<text fg={planAccent} content="* " />
<text fg="yellow" content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={planAccent}
borderColor="yellow"
paddingX={1}
>
<text fg={planAccent}>ClinePass subscription required</text>
<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."
/>
{planFeatures.length > 0 && (
<box flexDirection="column" marginTop={1}>
<text fg={props.defaultFg}>ClinePass includes:</text>
{planFeatures.map((feature) => (
<text key={feature} fg={props.defaultFg} selectable>
<span fg="green"> </span>
<span>{feature}</span>
</text>
))}
</box>
)}
<box flexDirection="row">
<text fg="gray">Subscribe: </text>
<text fg="cyan" selectable>
@@ -394,21 +333,18 @@ function ClinePassSubscriptionErrorView(props: {
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
defaultFg?: string;
terminalTheme: TerminalTheme;
}) {
const planAccent = getModeAccent("plan", props.terminalTheme);
return (
<box flexDirection="row">
<text fg={planAccent} content="* " />
<text fg="yellow" content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={planAccent}
borderColor="yellow"
paddingX={1}
>
<text fg={planAccent}>Personal ClinePass required</text>
<text fg="yellow">Personal ClinePass required</text>
<text
fg={props.defaultFg}
selectable
@@ -422,7 +358,6 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
terminalTheme: TerminalTheme;
}) {
const { entry, accent = palette.act, terminalTheme } = props;
@@ -522,20 +457,11 @@ export function ChatEntryView(props: {
return (
<ClineOrgIndividualInferenceSubscriptionErrorView
defaultFg={defaultFg}
terminalTheme={terminalTheme}
/>
);
}
if (isClinePassSubscriptionError(entry.text)) {
return (
<ClinePassSubscriptionErrorView
defaultFg={defaultFg}
loadIndividualSubscriptionPlans={
props.loadIndividualSubscriptionPlans
}
terminalTheme={terminalTheme}
/>
);
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
}
return (
<box flexDirection="row">
@@ -1,5 +1,5 @@
import "opentui-spinner/react";
import type { AgentMode, ClineSubscriptionPlan } from "@cline/core";
import type { AgentMode } from "@cline/core";
import type { ScrollBoxRenderable } from "@opentui/core";
import {
forwardRef,
@@ -21,7 +21,6 @@ export interface TranscriptScrollHandle {
interface ChatMessageListProps {
entries: ChatEntry[];
isStreaming?: boolean;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
uiMode?: AgentMode;
}
@@ -101,9 +100,6 @@ export const ChatMessageList = forwardRef<
key={key}
entry={entry}
accent={accent}
loadIndividualSubscriptionPlans={
props.loadIndividualSubscriptionPlans
}
terminalTheme={terminalTheme}
/>
);
@@ -1,13 +0,0 @@
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
export function buildClinePassSubscriptionPageUrl(
appBaseUrl: string | undefined,
): string {
const url = new URL(
CLINE_PASS_SUBSCRIPTION_PATH,
appBaseUrl || DEFAULT_APP_BASE_URL,
);
url.searchParams.set("personal", "true");
return url.toString();
}
@@ -1,18 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
describe("buildClinePassSubscriptionPageUrl", () => {
it("opens the personal subscription page on production by default", () => {
expect(buildClinePassSubscriptionPageUrl(undefined)).toBe(
"https://app.cline.bot/dashboard/subscription?personal=true",
);
});
it("keeps the configured app base URL", () => {
expect(
buildClinePassSubscriptionPageUrl("https://staging-app.cline.bot"),
).toBe(
"https://staging-app.cline.bot/dashboard/subscription?personal=true",
);
});
});
@@ -37,7 +37,6 @@ import {
getSearchableListRowsWindow,
type SearchableItem,
} from "../searchable-list";
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
interface ProviderItem {
id: string;
@@ -249,33 +248,18 @@ export function ProviderPickerContent(
);
}
export type ExistingProviderAction =
| "use_existing"
| "reconfigure"
| "open_subscription_page"
| "open_usage_billing";
export interface ExistingProviderOption {
value: ExistingProviderAction;
label: string;
onSelect?: () => Promise<void> | void;
}
export type ExistingProviderAction = "use_existing" | "reconfigure";
export function UseExistingOrReconfigureContent(
props: ChoiceContext<ExistingProviderOption> & {
props: ChoiceContext<ExistingProviderAction> & {
providerName: string;
extraOptions?: ExistingProviderOption[];
},
) {
const { resolve, dismiss, dialogId, providerName, extraOptions } = props;
const options: ExistingProviderOption[] = useMemo(
() => [
{ value: "use_existing", label: "Use existing configuration" },
{ value: "reconfigure", label: "Configure again" },
...(extraOptions ?? []),
],
[extraOptions],
);
const { resolve, dismiss, dialogId, providerName } = props;
const options: { value: ExistingProviderAction; label: string }[] = [
{ value: "use_existing", label: "Use existing configuration" },
{ value: "reconfigure", label: "Configure again" },
];
const [selected, setSelected] = useState(0);
useDialogKeyboard((key) => {
@@ -285,7 +269,7 @@ export function UseExistingOrReconfigureContent(
}
if (key.name === "return" || key.name === "enter") {
const opt = options[selected];
if (opt) resolve(opt);
if (opt) resolve(opt.value);
return;
}
if (key.name === "up" || (key.ctrl && key.name === "p")) {
@@ -330,86 +314,6 @@ export function UseExistingOrReconfigureContent(
);
}
function ClinePassBrowserPageContent(
props: ChoiceContext<boolean> & {
providerName: string;
pageLabel: string;
url: string;
openedStatus: string;
},
) {
const {
resolve,
dismiss,
dialogId,
providerName,
pageLabel,
url,
openedStatus,
} = props;
const [status, setStatus] = useState("Opening browser...");
useEffect(() => {
void open(url, { wait: false })
.then(() => {
setStatus(openedStatus);
})
.catch(() => {
setStatus("Could not open browser automatically. Open the URL below.");
});
}, [url, openedStatus]);
useDialogKeyboard((key) => {
if (key.name === "escape") {
dismiss();
return;
}
if (key.name === "return" || key.name === "enter") {
resolve(true);
}
}, dialogId);
return (
<box flexDirection="column" paddingX={1} gap={1}>
<text fg="cyan">
<strong>{providerName}</strong>
</text>
<text>{status}</text>
<text fg="gray">{pageLabel}:</text>
<text fg="cyan" selectable>
<a href={url}>{url}</a>
</text>
<text fg="gray">
<em>Enter or Esc to go back</em>
</text>
</box>
);
}
export function ClinePassSubscriptionContent(
props: ChoiceContext<boolean> & {
providerName: string;
},
) {
const subscriptionUrl = useMemo(
() =>
buildClinePassSubscriptionPageUrl(getClineEnvironmentConfig().appBaseUrl),
[],
);
return (
<ClinePassBrowserPageContent
{...props}
pageLabel="Subscription page"
url={subscriptionUrl}
openedStatus="Opened subscription page in your browser."
/>
);
}
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
apiKey: "API key",
baseUrl: "Base URL",
+4 -31
View File
@@ -3,7 +3,6 @@ import {
createContextBar,
formatStatusBarUsageText,
resolveContextBarFilledForeground,
resolveModelDisplayName,
} from "./status-bar";
vi.mock("@opentui/react", () => ({
@@ -56,44 +55,18 @@ describe("formatStatusBarUsageText", () => {
formatStatusBarUsageText({
totalTokens: 12_345,
totalCost: 0.123,
providerId: "cline",
showCost: true,
}),
).toBe("(12,345 tokens) $0.12");
});
it("displays subscription message when the provider is a subscription provider", () => {
it("omits cost when usage cost is hidden", () => {
expect(
formatStatusBarUsageText({
totalTokens: 12_345,
totalCost: 0.123,
providerId: "cline-pass",
showCost: false,
}),
).toBe("(12,345 tokens) $0.00 (included with subscription)");
});
});
describe("resolveModelDisplayName", () => {
it("keeps ClinePass visible when model ids have provider prefixes", () => {
expect(
resolveModelDisplayName({
providerId: "cline-pass",
modelId: "zai/glm-5.2",
knownModels: {
"zai/glm-5.2": { name: "GLM 5.2" },
},
}),
).toBe("ClinePass/glm-5.2");
});
it("uses the friendly model name for non-ClinePass providers", () => {
expect(
resolveModelDisplayName({
providerId: "cline",
modelId: "zai/glm-5.2",
knownModels: {
"zai/glm-5.2": { name: "GLM 5.2" },
},
}),
).toBe("GLM 5.2");
).toBe("(12,345 tokens)");
});
});
+9 -33
View File
@@ -1,9 +1,6 @@
import type { AgentMode } from "@cline/core";
import { useTerminalDimensions } from "@opentui/react";
import {
shouldShowCliUsageCost,
shouldShowCliUsageCoveredBySubscription,
} from "../../utils/usage-cost-display";
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
import {
useTerminalBackground,
useTerminalTheme,
@@ -49,31 +46,14 @@ function formatCost(cost: number): string {
return `$${cost.toFixed(2)}`;
}
function formatCostText(providerId: string, totalCost: number): string {
if (shouldShowCliUsageCoveredBySubscription(providerId)) {
return "$0.00 (included with subscription)";
}
if (!shouldShowCliUsageCost(providerId)) {
return "";
}
return formatCost(totalCost);
}
export function formatStatusBarUsageText(input: {
totalTokens: number;
totalCost: number;
providerId: string;
showCost: boolean;
}): string {
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
const costText = formatCostText(input.providerId, input.totalCost);
if (!costText) {
return tokens;
}
return `${tokens} ${costText}`;
if (!input.showCost) return tokens;
return `${tokens} ${formatCost(input.totalCost)}`;
}
// knownModels keys are bare IDs ("claude-sonnet-4-6") but config.modelId
@@ -94,22 +74,17 @@ function lookupModelInfo(
}
export function resolveModelDisplayName(config: {
providerId?: string;
modelId: string;
knownModels?: Record<string, unknown>;
thinking?: boolean;
reasoningEffort?: string;
}): string {
const info = lookupModelInfo(config.modelId, config.knownModels);
const modelIdTail = config.modelId.split("/").pop() ?? config.modelId;
const displayName =
config.providerId === "cline-pass"
? `ClinePass/${modelIdTail}`
: (info?.name ?? modelIdTail);
const name = info?.name ?? config.modelId.split("/").pop() ?? config.modelId;
if (config.thinking && config.reasoningEffort) {
return `${displayName} (${config.reasoningEffort})`;
return `${name} (${config.reasoningEffort})`;
}
return displayName;
return name;
}
export function resolveModelMaxInputTokens(config: {
@@ -177,6 +152,7 @@ export function StatusBar(props: StatusBarProps) {
const bar = hasMaxInputTokens
? createContextBar(totalTokens, maxInputTokens)
: undefined;
const showUsageCost = shouldShowCliUsageCost(props.providerId);
// Available content width after accounting for padding.
// Home view: parent box is capped at 60 wide, status bar adds paddingX=1 (-2).
@@ -193,7 +169,7 @@ export function StatusBar(props: StatusBarProps) {
const usageText = formatStatusBarUsageText({
totalTokens,
totalCost,
providerId: props.providerId,
showCost: showUsageCost,
});
const contextText = bar
? ` ${bar.filled}${bar.empty} ${usageText}`
+8 -58
View File
@@ -18,9 +18,8 @@ import {
import type { Config } from "../../utils/types";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import {
ClinePassSubscriptionContent,
CodexCliStatusContent,
type ExistingProviderOption,
type ExistingProviderAction,
OAuthLoginContent,
ProviderConfigInputContent,
ProviderPickerContent,
@@ -79,36 +78,6 @@ function usesModelIdInput(providerId: string): boolean {
return providerId === "openai-compatible";
}
function providerToExistingProviderOptions(input: {
providerId: string;
providerName: string;
dialog: DialogActions;
termHeight: number;
}): ExistingProviderOption[] {
if (input.providerId !== "cline-pass") {
return [];
}
return [
{
value: "open_subscription_page",
label: "Manage subscription & see usage",
onSelect: async () => {
await input.dialog.choice<boolean>({
style: { maxHeight: input.termHeight - 2 },
closeOnEscape: false,
content: (ctx: ChoiceContext<boolean>) => (
<ClinePassSubscriptionContent
{...ctx}
providerName={input.providerName}
/>
),
});
},
},
];
}
async function runProviderChange(
dialog: DialogActions,
config: Config,
@@ -133,33 +102,14 @@ async function runProviderChange(
let needsAuth = true;
if (isProviderConfigured(newProviderId, existingSettings)) {
let option: ExistingProviderOption | undefined;
const extraOptions = providerToExistingProviderOptions({
providerId: newProviderId,
providerName: displayName,
dialog,
termHeight,
const action = await dialog.choice<ExistingProviderAction>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<ExistingProviderAction>) => (
<UseExistingOrReconfigureContent {...ctx} providerName={displayName} />
),
});
while (true) {
option = await dialog.choice<ExistingProviderOption>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<ExistingProviderOption>) => (
<UseExistingOrReconfigureContent
{...ctx}
providerName={displayName}
extraOptions={extraOptions}
/>
),
});
if (!option) return false;
if (option.onSelect) {
await option.onSelect();
option = undefined;
continue;
}
break;
}
needsAuth = option.value === "reconfigure";
if (!action) return false;
needsAuth = action === "reconfigure";
}
if (needsAuth) {
+1 -10
View File
@@ -10,7 +10,6 @@ import {
useDialogState,
} from "@opentui-ui/dialog/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { shouldSuppressClineCliMigrationNoticeForActiveProvider } from "../kanban-migration/notice";
import { MigrationNoticeContent } from "../kanban-migration/notice-dialog";
import type { RepoStatus } from "../utils/repo-status";
import { readRepoStatus } from "../utils/repo-status";
@@ -542,17 +541,10 @@ function App(props: TuiProps) {
const notice = props.initialNotice;
const onInitialNoticeShown = props.onInitialNoticeShown;
const currentProviderId = props.config.providerId;
useEffect(() => {
if (!notice) return;
if (initialNoticeShownRef.current) return;
if (appView !== "home") return;
if (
shouldSuppressClineCliMigrationNoticeForActiveProvider(currentProviderId)
) {
initialNoticeShownRef.current = true;
return;
}
initialNoticeShownRef.current = true;
const timeout = setTimeout(() => {
@@ -568,7 +560,7 @@ function App(props: TuiProps) {
});
}, 0);
return () => clearTimeout(timeout);
}, [appView, currentProviderId, dialog, notice, onInitialNoticeShown]);
}, [appView, dialog, notice, onInitialNoticeShown]);
const {
appendEntry: appendSessionEntry,
@@ -887,7 +879,6 @@ function App(props: TuiProps) {
repoStatus,
textareaRef: promptInput.textareaRef,
transcriptScrollRef,
loadIndividualSubscriptionPlans: props.loadIndividualSubscriptionPlans,
queuedPrompts,
selectedQueuedPromptId,
editingQueuedPrompt,
-2
View File
@@ -2,7 +2,6 @@ import type {
AgentEvent,
AgentMode,
CheckpointEntry,
ClineSubscriptionPlan,
TeamEvent,
} from "@cline/core";
import type {
@@ -130,7 +129,6 @@ export interface TuiProps {
loadAdditionalSlashCommands?: () => Promise<InteractiveSlashCommand[]>;
loadWelcomeLine?: () => Promise<string | undefined>;
loadClineAccount: () => Promise<ClineAccountSnapshot>;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
switchClineAccount: (organizationId?: string | null) => Promise<void>;
loadConfigData: (
options?: LoadInteractiveConfigDataOptions,
-2
View File
@@ -50,7 +50,6 @@ export function ChatView(props: {
};
textareaRef?: React.MutableRefObject<TextareaHandle | null>;
transcriptScrollRef?: React.Ref<TranscriptScrollHandle>;
loadIndividualSubscriptionPlans?: TuiProps["loadIndividualSubscriptionPlans"];
autocomplete?: AutocompleteDropdownProps;
queuedPrompts?: QueuedPromptItem[];
selectedQueuedPromptId?: string | null;
@@ -90,7 +89,6 @@ export function ChatView(props: {
ref={props.transcriptScrollRef}
entries={session.entries}
isStreaming={session.isStreaming}
loadIndividualSubscriptionPlans={props.loadIndividualSubscriptionPlans}
uiMode={session.uiMode}
/>
+7 -150
View File
@@ -10,24 +10,16 @@ import {
saveLocalProviderSettings,
} from "@cline/core";
import { isClineProvider } from "@cline/shared";
import open from "open";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
getCliSubscriptionUrl,
getIndividualPlanFeatures,
} from "../../../utils/cline-pass-errors";
import {
type CodexCliStatus,
checkCodexCliInstalled,
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 {
loadCurrentUserPlanFromProviderSettings,
loadIndividualSubscriptionPlansFromProviderSettings,
} from "../../cline-account";
import {
buildClineModelEntries,
type ClineModelPickerEntry,
@@ -56,8 +48,6 @@ import {
import { FIELD_ORDER } from "./fields";
import { useOnboardingKeyboard } from "./keyboard";
import {
CLINE_PASS_SUBSCRIPTION_OPTIONS,
type ClinePassSubscriptionStatus,
getMainMenuOptions,
type ModelEntry,
type OnboardingResult,
@@ -88,7 +78,8 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const menuOptions = useMemo(
() =>
getMainMenuOptions({
isClinePassEnabled: true,
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
}),
[],
);
@@ -159,19 +150,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const [modelsDefaultId, setModelsDefaultId] = useState("");
const [customModelId, setCustomModelId] = useState("");
const [customModelError, setCustomModelError] = useState("");
const [clinePassSubscriptionStatus, setClinePassSubscriptionStatus] =
useState<ClinePassSubscriptionStatus>("loading");
const [clinePassSubscriptionError, setClinePassSubscriptionError] =
useState("");
const [clinePassCurrentPlanName, setClinePassCurrentPlanName] = useState("");
const [clinePassPlanFeatures, setClinePassPlanFeatures] = useState<string[]>(
[],
);
const [clinePassSubscriptionSelected, setClinePassSubscriptionSelected] =
useState(0);
const [clinePassSubscriptionOpenStatus, setClinePassSubscriptionOpenStatus] =
useState("");
const clinePassSubscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
const modelItems: SearchableItem[] = useMemo(
() =>
@@ -287,62 +265,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
[providerSettingsManager],
);
const refreshClinePassSubscriptionStatus = useCallback(() => {
setClinePassSubscriptionStatus("loading");
setClinePassSubscriptionError("");
setClinePassCurrentPlanName("");
setClinePassSubscriptionOpenStatus("");
loadCurrentUserPlanFromProviderSettings({ providerSettingsManager })
.then(
(value) => ({ status: "fulfilled" as const, value }),
(reason) => ({ status: "rejected" as const, reason }),
)
.then((currentPlanResult) =>
loadIndividualSubscriptionPlansFromProviderSettings({
providerSettingsManager,
})
.then(
(value) => ({ status: "fulfilled" as const, value }),
(reason) => ({ status: "rejected" as const, reason }),
)
.then((availablePlansResult) => ({
availablePlansResult,
currentPlanResult,
})),
)
.then(({ currentPlanResult, availablePlansResult }) => {
if (availablePlansResult.status === "fulfilled") {
setClinePassPlanFeatures(
getIndividualPlanFeatures(availablePlansResult.value),
);
}
if (currentPlanResult.status === "rejected") {
const error = currentPlanResult.reason;
const message =
error instanceof Error ? error.message : String(error);
if (message.trim().toLowerCase() === "no plan found for user") {
setClinePassSubscriptionStatus("unsubscribed");
return;
}
setClinePassSubscriptionError(message);
setClinePassSubscriptionStatus("error");
return;
}
const plan = currentPlanResult.value?.plan;
if (plan) {
setClinePassCurrentPlanName(
plan.displayName || plan.name || plan.id || "ClinePass",
);
setClinePassSubscriptionStatus("subscribed");
} else {
setClinePassSubscriptionStatus("unsubscribed");
}
});
}, [providerSettingsManager]);
const transitionToModelPicker = useCallback(
(providerId: string) => {
setActiveProviderId(providerId);
@@ -366,27 +288,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
[providers, loadModelsForProvider, providerSettingsManager],
);
const transitionToClinePassSubscription = useCallback(() => {
setActiveProviderId("cline-pass");
const provider = providers.find((p) => p.id === "cline-pass");
setActiveProviderName(provider?.name ?? "ClinePass");
setModelsDefaultId(provider?.defaultModelId ?? "");
setClinePassSubscriptionSelected(0);
setStep("cline_pass_subscription");
refreshClinePassSubscriptionStatus();
}, [providers, refreshClinePassSubscriptionStatus]);
const handleAuthComplete = useCallback(
(providerId: OnboardingOAuthProviderId) => {
if (providerId === "cline-pass") {
transitionToClinePassSubscription();
return;
}
transitionToModelPicker(providerId);
},
[transitionToClinePassSubscription, transitionToModelPicker],
);
const resetAuth = useCallback(() => {
setAuthStatus("");
setAuthUrl("");
@@ -412,11 +313,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
setVerifyUrl: setDeviceVerifyUrl,
setStatus: setDeviceStatus,
setError: setDeviceError,
onComplete: handleAuthComplete,
onComplete: transitionToModelPicker,
telemetry: getCliTelemetryService(),
});
},
[providerSettingsManager, handleAuthComplete],
[providerSettingsManager, transitionToModelPicker],
);
const startOAuthFlow = useCallback(
@@ -438,46 +339,18 @@ export function useOnboardingController(props: OnboardingControllerProps) {
setStatus: setAuthStatus,
setAuthUrl,
setError: setAuthError,
onComplete: handleAuthComplete,
onComplete: transitionToModelPicker,
telemetry: getCliTelemetryService(),
});
},
[
providerSettingsManager,
resetAuth,
handleAuthComplete,
transitionToModelPicker,
startDeviceCodeFlow,
],
);
const continueFromClinePassSubscription = useCallback(() => {
transitionToModelPicker("cline-pass");
}, [transitionToModelPicker]);
const openClinePassSubscriptionPage = useCallback(() => {
setClinePassSubscriptionOpenStatus("Opening subscription page...");
void open(clinePassSubscriptionUrl, { wait: false })
.then(() => {
setClinePassSubscriptionOpenStatus(
"Opened subscription page in your browser.",
);
})
.catch(() => {
setClinePassSubscriptionOpenStatus(
`Could not open browser automatically. Open ${clinePassSubscriptionUrl}`,
);
});
}, [clinePassSubscriptionUrl]);
useEffect(() => {
if (
step === "cline_pass_subscription" &&
clinePassSubscriptionStatus === "subscribed"
) {
transitionToModelPicker("cline-pass");
}
}, [step, clinePassSubscriptionStatus, transitionToModelPicker]);
const refreshCodexCliStatus = useCallback(() => {
setCodexCliStatus(undefined);
setCodexCliChecking(true);
@@ -759,9 +632,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
modelList,
clineEntries,
clineModelSelected,
clinePassSubscriptionStatus,
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
clinePassSubscriptionSelected,
thinkingSelected,
setStep,
setMenuSelected,
@@ -778,11 +648,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
setDeviceError,
setDeviceStatus,
setClineModelSelected,
setClinePassSubscriptionSelected,
setThinkingSelected,
continueFromClinePassSubscription,
refreshClinePassSubscriptionStatus,
openClinePassSubscriptionPage,
abortOAuth: () => {
authAbortRef.current = true;
},
@@ -804,7 +670,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
return {
activeProviderName,
activeProviderId,
authError,
authStatus,
authUrl,
@@ -817,14 +682,6 @@ export function useOnboardingController(props: OnboardingControllerProps) {
clineEntries,
clineKnownModels,
clineModelSelected,
clinePassCurrentPlanName,
clinePassPlanFeatures,
clinePassSubscriptionError,
clinePassSubscriptionOpenStatus,
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
clinePassSubscriptionSelected,
clinePassSubscriptionStatus,
clinePassSubscriptionUrl,
deviceError,
deviceStatus,
deviceUserCode,
@@ -9,8 +9,6 @@ import {
} from "./auth";
import { FIELD_ORDER } from "./fields";
import {
type ClinePassSubscriptionOption,
type ClinePassSubscriptionStatus,
type MenuOption,
type OnboardingStep,
THINKING_LEVELS,
@@ -28,9 +26,6 @@ export function useOnboardingKeyboard(input: {
modelList: SearchableListState;
clineEntries: ClineModelPickerEntry[];
clineModelSelected: number;
clinePassSubscriptionStatus: ClinePassSubscriptionStatus;
clinePassSubscriptionOptions: ClinePassSubscriptionOption[];
clinePassSubscriptionSelected: number;
thinkingSelected: number;
setStep: (step: OnboardingStep) => void;
setMenuSelected: Dispatch<SetStateAction<number>>;
@@ -43,11 +38,7 @@ export function useOnboardingKeyboard(input: {
setDeviceError: (value: string) => void;
setDeviceStatus: (value: string) => void;
setClineModelSelected: Dispatch<SetStateAction<number>>;
setClinePassSubscriptionSelected: Dispatch<SetStateAction<number>>;
setThinkingSelected: Dispatch<SetStateAction<number>>;
continueFromClinePassSubscription: () => void;
refreshClinePassSubscriptionStatus: () => void;
openClinePassSubscriptionPage: () => void;
abortOAuth: () => void;
abortDeviceCode: () => void;
resetAuth: () => void;
@@ -102,11 +93,6 @@ export function useOnboardingKeyboard(input: {
input.setStep("byo_provider");
return;
}
if (input.step === "cline_pass_subscription") {
input.setStep("menu");
input.setMenuSelected(0);
return;
}
if (input.step === "cline_model") {
input.setStep("menu");
input.setMenuSelected(0);
@@ -149,43 +135,6 @@ export function useOnboardingKeyboard(input: {
if (input.step === "device_code") return;
if (input.step === "cline_pass_subscription") {
const total = input.clinePassSubscriptionOptions.length;
if (total === 0) return;
if (key.name === "up" || (key.ctrl && key.name === "p")) {
input.setClinePassSubscriptionSelected((s) =>
s <= 0 ? total - 1 : s - 1,
);
return;
}
if (key.name === "down" || (key.ctrl && key.name === "n")) {
input.setClinePassSubscriptionSelected((s) =>
s >= total - 1 ? 0 : s + 1,
);
return;
}
if (key.name === "return" || key.name === "enter") {
const option =
input.clinePassSubscriptionOptions[
Math.min(input.clinePassSubscriptionSelected, total - 1)
];
if (!option) return;
if (option.value === "subscribe") {
input.openClinePassSubscriptionPage();
} else if (option.value === "refresh") {
if (input.clinePassSubscriptionStatus !== "loading") {
input.refreshClinePassSubscriptionStatus();
}
} else if (option.value === "skip") {
input.continueFromClinePassSubscription();
} else if (option.value === "back") {
input.setStep("menu");
input.setMenuSelected(0);
}
}
return;
}
if (input.step === "menu") {
if (key.name === "up") {
input.setMenuSelected((s) =>
@@ -8,7 +8,6 @@ export type OnboardingStep =
| "byo_provider"
| "byo_apikey"
| "codex_cli_setup"
| "cline_pass_subscription"
| "cline_model"
| "model_picker"
| "custom_model_id"
@@ -37,17 +36,6 @@ export interface MenuOption {
icon: string;
}
export type ClinePassSubscriptionAction =
| "subscribe"
| "refresh"
| "skip"
| "back";
export interface ClinePassSubscriptionOption {
value: ClinePassSubscriptionAction;
label: string;
}
export const MAIN_MENU: MenuOption[] = [
{
label: "Sign in with Cline",
@@ -83,25 +71,6 @@ export function getMainMenuOptions(options?: {
);
}
export const CLINE_PASS_SUBSCRIPTION_OPTIONS: ClinePassSubscriptionOption[] = [
{
value: "subscribe",
label: "Subscribe to ClinePass",
},
{
value: "refresh",
label: "Re-check subscription status",
},
{
value: "skip",
label: "Skip for now",
},
{
value: "back",
label: "Go back",
},
];
export interface OnboardingResult {
providerId: string;
modelId: string;
@@ -127,12 +96,6 @@ export interface ModelEntry {
supportsReasoning: boolean;
}
export type ClinePassSubscriptionStatus =
| "loading"
| "subscribed"
| "unsubscribed"
| "error";
export interface ProviderCatalogItem {
id: string;
name: string;
+3 -209
View File
@@ -1,7 +1,5 @@
import "opentui-spinner/react";
import type { ScrollBoxRenderable } from "@opentui/core";
import type { ReactNode } from "react";
import { useEffect, useRef } from "react";
import {
CODEX_CLI_INSTALL_URL,
type CodexCliStatus,
@@ -19,18 +17,10 @@ import {
TrackedRobot,
type useMouseTracker,
} from "../../components/tracked-robot";
import {
useTerminalBackground,
useTerminalTheme,
} from "../../hooks/use-terminal-background";
import { getDefaultForeground, getModeAccent, palette } from "../../palette";
import { useTerminalBackground } from "../../hooks/use-terminal-background";
import { getDefaultForeground, palette } from "../../palette";
import { FIELD_ORDER } from "./fields";
import {
type ClinePassSubscriptionOption,
type ClinePassSubscriptionStatus,
type MenuOption,
THINKING_LEVELS,
} from "./model";
import { type MenuOption, THINKING_LEVELS } from "./model";
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
@@ -39,10 +29,6 @@ function useDefaultFg(): string | undefined {
return getDefaultForeground(terminalBg);
}
function getClinePassSubscriptionOptionId(index: number): string {
return `cline-pass-subscription-option-${index}`;
}
interface OnboardingFrameProps {
children: ReactNode;
compact: boolean;
@@ -482,198 +468,6 @@ export function OnboardingClineModelScreen(props: {
);
}
export function OnboardingClinePassSubscriptionScreen(props: {
compact: boolean;
contentWidth: number;
currentPlanName: string;
error: string;
mouse: MouseTrackerState;
openStatus: string;
options: ClinePassSubscriptionOption[];
planFeatures: string[];
selected: number;
status: ClinePassSubscriptionStatus;
subscriptionUrl: string;
}) {
const defaultFg = useDefaultFg();
const terminalTheme = useTerminalTheme();
const planAccent = getModeAccent("plan", terminalTheme);
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
const isLoading = props.status === "loading";
const isSubscribed = props.status === "subscribed";
const isError = props.status === "error";
const bodyHeight = props.compact ? 17 : 19;
useEffect(() => {
if (isSubscribed) {
return;
}
const scrollSelectedOptionIntoView = () => {
scrollRef.current?.scrollChildIntoView(
getClinePassSubscriptionOptionId(props.selected),
);
};
scrollSelectedOptionIntoView();
queueMicrotask(scrollSelectedOptionIntoView);
const timeout = setTimeout(scrollSelectedOptionIntoView, 0);
return () => clearTimeout(timeout);
}, [isSubscribed, props.selected]);
return (
<OnboardingFrame
compact={props.compact}
contentWidth={props.contentWidth}
mouse={props.mouse}
>
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor={isSubscribed ? palette.success : planAccent}
paddingX={1}
paddingY={1}
height={bodyHeight}
overflow="hidden"
>
<scrollbox
ref={scrollRef}
width="100%"
height="100%"
scrollY
scrollX={false}
viewportOptions={{ overflow: "hidden" }}
contentOptions={{ flexDirection: "column" }}
>
<box flexDirection="column" width="100%" flexShrink={0}>
<text
fg={isSubscribed ? palette.success : planAccent}
flexShrink={0}
>
{isSubscribed
? "ClinePass subscription active"
: "ClinePass subscription required"}
</text>
{isLoading ? (
<box flexDirection="row" gap={1} flexShrink={0}>
<spinner name="dots" color="gray" />
<text fg="gray">Checking your ClinePass subscription...</text>
</box>
) : isSubscribed ? (
<text fg={defaultFg} selectable flexShrink={0}>
Current plan: {props.currentPlanName || "ClinePass"}
</text>
) : isError ? (
<text
fg={defaultFg}
selectable
flexShrink={0}
content="Could not verify your ClinePass subscription. Re-check before choosing a ClinePass model."
/>
) : (
<text
fg={defaultFg}
selectable
flexShrink={0}
content="No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan."
/>
)}
{props.status === "error" &&
props.error &&
props.error !== "no plan found for user" && (
<text fg="red" selectable flexShrink={0}>
{props.error}
</text>
)}
{!isSubscribed && props.planFeatures.length > 0 && (
<box flexDirection="column" marginTop={1} flexShrink={0}>
{props.planFeatures.map((feature) => {
if (
feature === "Low cost subscription pricing" ||
feature === "Generous limits and reliable access" ||
feature === "Built for as many programmers as possible"
) {
return null;
}
return (
<text
key={feature}
fg={defaultFg}
selectable
flexShrink={0}
>
<span fg="green"> </span>
<span>{feature}</span>
</text>
);
})}
</box>
)}
{!isSubscribed && (
<box flexDirection="column" marginTop={1} flexShrink={0}>
{props.options.map((option, i) => {
const isSel = i === props.selected;
return (
<box
id={getClinePassSubscriptionOptionId(i)}
key={option.value}
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={isSel ? palette.selection : undefined}
height={1}
flexShrink={0}
overflow="hidden"
>
<text
fg={isSel ? palette.textOnSelection : "gray"}
flexShrink={0}
>
{isSel ? "\u276f" : " "}
</text>
<text
fg={isSel ? palette.textOnSelection : defaultFg}
flexShrink={0}
>
{option.label}
</text>
</box>
);
})}
</box>
)}
{props.openStatus && (
<text fg="gray" selectable flexShrink={0}>
{props.openStatus}
</text>
)}
{!isSubscribed && (
<box flexDirection="column" marginTop={1} flexShrink={0}>
<text fg="gray" flexShrink={0}>
If the browser button does not work:
</text>
<text fg={palette.act} selectable flexShrink={0}>
<a href={props.subscriptionUrl}>{props.subscriptionUrl}</a>
</text>
</box>
)}
</box>
</scrollbox>
</box>
<text fg="gray" paddingX={1}>
<em>/ navigate, Enter to select, Esc to go back, Ctrl+C to exit</em>
</text>
</OnboardingFrame>
);
}
export function OnboardingModelPickerScreen(props: {
activeProviderName: string;
compact: boolean;
@@ -6,7 +6,6 @@ import { useOnboardingController } from "./controller";
import { getOAuthProviderLabel, type OnboardingResult } from "./model";
import {
OnboardingClineModelScreen,
OnboardingClinePassSubscriptionScreen,
OnboardingCodexCliScreen,
OnboardingCustomModelIdScreen,
OnboardingDeviceCodeScreen,
@@ -122,24 +121,6 @@ export function OnboardingView(props: OnboardingViewProps) {
);
}
if (state.step === "cline_pass_subscription") {
return (
<OnboardingClinePassSubscriptionScreen
compact={compact}
contentWidth={contentWidth}
currentPlanName={state.clinePassCurrentPlanName}
error={state.clinePassSubscriptionError}
mouse={mouse}
openStatus={state.clinePassSubscriptionOpenStatus}
options={state.clinePassSubscriptionOptions}
planFeatures={state.clinePassPlanFeatures}
selected={state.clinePassSubscriptionSelected}
status={state.clinePassSubscriptionStatus}
subscriptionUrl={state.clinePassSubscriptionUrl}
/>
);
}
if (state.step === "model_picker") {
return (
<OnboardingModelPickerScreen
+2 -2
View File
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getCliNotSubscribedMessage,
getClineOrgIndividualInferenceSubscriptionMessage,
getCliSubscriptionUrl,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineOrgIndividualInferenceSubscriptionErrorMessage,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
@@ -27,7 +27,7 @@ describe("cline-pass-errors", () => {
it("formats the ClinePass subscription URL", () => {
expect(getCliSubscriptionUrl()).toBe(
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
"https://app.cline.bot/promo?code=CLI-100&personal=true",
);
});
+4 -11
View File
@@ -1,5 +1,4 @@
import {
type ClineSubscriptionPlan,
getClineOrgIndividualInferenceSubscriptionMessage,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
@@ -9,11 +8,13 @@ import {
import { getClineEnvironmentConfig } from "@cline/shared";
export { getClineOrgIndividualInferenceSubscriptionMessage };
export {
getClineOrgIndividualInferenceSubscriptionMessage,
};
export function getCliSubscriptionUrl(): string {
return `${new URL(
"/promo?code=CLI-8OFF&personal=true",
"/promo?code=CLI-100&personal=true",
getClineEnvironmentConfig().appBaseUrl,
).toString()}`;
}
@@ -22,14 +23,6 @@ export function getCliNotSubscribedMessage(): string {
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
}
export function getIndividualPlanFeatures(
plans: ClineSubscriptionPlan[],
): string[] {
const planWithFeatures = plans.find((plan) => plan.interval === "Monthly");
return planWithFeatures?.features?.included ?? [];
}
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
return (
+9 -1
View File
@@ -2,6 +2,7 @@ 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) => {
@@ -12,13 +13,20 @@ vi.mock("@cline/core", async (importOriginal) => {
};
});
vi.mock("./feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
}),
}));
describe("listLocalProviders", () => {
it("enables ClinePass when listing the SDK provider list", async () => {
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,
});
+3 -1
View File
@@ -2,11 +2,13 @@ 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: true,
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
}
-6
View File
@@ -3,9 +3,3 @@ import { Llms } from "@cline/core";
export function shouldShowCliUsageCost(providerId: string): boolean {
return Llms.shouldShowProviderUsageCost(providerId);
}
export function shouldShowCliUsageCoveredBySubscription(
providerId: string,
): boolean {
return Llms.resolveProviderUsageCostDisplay(providerId) === "subscription";
}
+29 -43
View File
@@ -51,36 +51,6 @@ describe("marketplace installer", () => {
vi.restoreAllMocks();
});
function createInstalledOfficialPlugin(
clineDir: string,
slug: string,
): string {
const sourceKey = `official:https://github.com/cline/plugins.git#plugins/${slug}`;
const hash = createHash("sha256")
.update(sourceKey)
.digest("hex")
.slice(0, 12);
const installPath = join(
clineDir,
"plugins",
"_installed",
"official",
`${slug}-${hash}`,
);
mkdirSync(join(installPath, "package"), { recursive: true });
writeFileSync(
join(installPath, "package.json"),
JSON.stringify({ name: slug }, null, 2),
"utf8",
);
writeFileSync(
join(installPath, "package", "index.ts"),
`export default { name: "${slug}", manifest: { capabilities: ["tools"] } };`,
"utf8",
);
return installPath;
}
it("maps remote MCP catalog args to MCP settings shape", () => {
expect(
buildMarketplaceMcpInput([
@@ -318,6 +288,8 @@ describe("marketplace installer", () => {
"remove",
"cline-sdk",
"-g",
"-a",
"cline",
"-y",
]);
});
@@ -563,13 +535,16 @@ describe("marketplace installer", () => {
]);
});
it("uninstalls official marketplace plugins through the shared core service", async () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-plugin-"));
process.env.CLINE_DIR = clineDir;
const installPath = createInstalledOfficialPlugin(clineDir, "goal");
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: "",
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stderr: "",
}));
@@ -590,8 +565,12 @@ describe("marketplace installer", () => {
message: "Uninstalled Goal.",
});
expect(spawnCommand).not.toHaveBeenCalled();
expect(existsSync(installPath)).toBe(false);
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 () => {
@@ -635,12 +614,15 @@ describe("marketplace installer", () => {
});
it("resolves desktop uninstalls from the server catalog instead of browser-sent args", async () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-plugin-"));
process.env.CLINE_DIR = clineDir;
const installPath = createInstalledOfficialPlugin(clineDir, "goal");
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stderr: "",
}));
@@ -668,8 +650,12 @@ describe("marketplace installer", () => {
},
);
expect(spawnCommand).not.toHaveBeenCalled();
expect(existsSync(installPath)).toBe(false);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
});
it("uninstalls MCP marketplace entries from Cline MCP settings", async () => {
+103 -18
View File
@@ -18,11 +18,8 @@ import {
resolve,
} from "node:path";
import {
type MarketplaceActionResult,
type MarketplaceEntryInput,
resolveSkillsConfigSearchPaths,
resolveWorkflowsConfigSearchPaths,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
uninstallPlugin as uninstallLocalPlugin,
} from "@cline/core";
import { resolveClineDir } from "@cline/shared/storage";
@@ -795,6 +792,50 @@ async function installSkill(
};
}
async function uninstallSkill(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installedName = findInstalledGlobalSkillName(entry);
if (!installedName) {
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `${entry.name ?? entry.id} is not installed.`,
};
}
const result = await spawnCommand("npx", [
"-y",
"skills@latest",
"remove",
installedName,
"-g",
"-a",
"cline",
"-y",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Skill uninstall failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
const output = commandOutput(result);
if (isGlobalSkillInstalled(entry)) {
throw new Error(
`Skill uninstall completed, but ${entry.name ?? entry.id} is still present in Cline's global skills directories.`,
);
}
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? entry.id}.`,
output,
};
}
async function installPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
@@ -845,6 +886,47 @@ async function installPlugin(
};
}
async function uninstallPlugin(
entry: MarketplaceInstallInput,
spawnCommand: SpawnCommand,
): Promise<MarketplaceInstallResult> {
const installArgs = entry.install.args ?? [];
const target = installArgs[0]?.trim() || entry.id;
if (!target) {
throw new Error("Plugin marketplace uninstalls require a plugin name.");
}
const { command, argsPrefix } = resolveClineInvocation();
const result = await spawnCommand(command, [
...argsPrefix,
"plugin",
"uninstall",
target,
"--json",
]);
if (result.exitCode !== 0) {
const output = commandOutput(result);
throw new Error(
`Plugin uninstall failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
);
}
let details: JsonRecord | undefined;
try {
details = result.stdout.trim()
? (JSON.parse(result.stdout.trim()) as JsonRecord)
: undefined;
} catch {
details = undefined;
}
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? entry.id}.`,
details,
output: commandOutput(result),
};
}
export async function installMarketplaceEntry(
args?: Record<string, unknown>,
options: { spawnCommand?: SpawnCommand } = {},
@@ -901,21 +983,24 @@ export async function uninstallMarketplaceEntry(
): Promise<MarketplaceInstallResult> {
const entry = readInstallInput(args);
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
let mcpDetails: JsonRecord | undefined;
const result = await uninstallCoreMarketplaceEntry(
entry satisfies MarketplaceEntryInput,
{
deleteMcpServer: (name) => {
mcpDetails = deleteMcpServer(name);
},
spawnCommand: (command, commandArgs) =>
spawnCommand(command, commandArgs),
},
);
return {
...(result satisfies MarketplaceActionResult),
details: mcpDetails ? { mcp: mcpDetails } : undefined,
};
if (entry.type === "mcp") {
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
const response = deleteMcpServer(String(input.name ?? ""));
return {
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${entry.name ?? input.name ?? entry.id}.`,
details: { mcp: response },
};
}
if (entry.type === "skill") {
return uninstallSkill(entry, spawnCommand);
}
if (entry.type === "plugin") {
return uninstallPlugin(entry, spawnCommand);
}
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
}
export async function installMarketplaceEntryFromCatalog(
@@ -13,9 +13,7 @@ service MarketplaceService {
rpc listMarketplaceLocalInstalledEntries(EmptyRequest) returns (MarketplaceLocalInstalledEntries);
rpc listMarketplaceInstalledEntries(MarketplaceEntriesRequest) returns (MarketplaceInstalledEntries);
rpc installMarketplaceEntry(MarketplaceEntryRequest) returns (MarketplaceInstallResult);
rpc uninstallMarketplaceEntry(MarketplaceEntryRequest) returns (MarketplaceInstallResult);
rpc toggleMarketplaceLocalInstalledEntry(ToggleMarketplaceLocalInstalledEntryRequest) returns (MarketplaceLocalInstalledEntries);
rpc uninstallMarketplaceLocalInstalledEntry(MarketplaceLocalInstalledEntryRequest) returns (MarketplaceInstallResult);
}
message MarketplaceTag {
@@ -89,10 +87,6 @@ message ToggleMarketplaceLocalInstalledEntryRequest {
bool enabled = 2;
}
message MarketplaceLocalInstalledEntryRequest {
MarketplaceLocalInstalledEntry entry = 1;
}
message MarketplaceEntryRequest {
MarketplaceEntry entry = 1;
}
+57 -4
View File
@@ -397,7 +397,7 @@ message ModelsApiOptions {
optional bool azure_identity = 44;
// Plan mode configurations
optional string plan_mode_api_provider = 100;
optional ApiProvider plan_mode_api_provider = 100;
optional string plan_mode_api_model_id = 101;
optional int64 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
@@ -438,7 +438,7 @@ message ModelsApiOptions {
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 138;
// Act mode configurations
optional string act_mode_api_provider = 200;
optional ApiProvider act_mode_api_provider = 200;
optional string act_mode_api_model_id = 201;
optional int64 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
@@ -568,6 +568,59 @@ message OcaCompatibleModelInfo {
optional string error = 2;
}
// API Provider enumeration
enum ApiProvider {
ANTHROPIC = 0;
OPENROUTER = 1;
BEDROCK = 2;
VERTEX = 3;
OPENAI = 4;
OLLAMA = 5;
LMSTUDIO = 6;
GEMINI = 7;
OPENAI_NATIVE = 8;
REQUESTY = 9;
TOGETHER = 10;
DEEPSEEK = 11;
QWEN = 12;
DOUBAO = 13;
MISTRAL = 14;
VSCODE_LM = 15;
CLINE = 16;
LITELLM = 17;
NEBIUS = 18;
FIREWORKS = 19;
ASKSAGE = 20;
XAI = 21;
SAMBANOVA = 22;
CEREBRAS = 23;
GROQ = 24;
SAPAICORE = 25;
CLAUDE_CODE = 26;
MOONSHOT = 27;
HUGGINGFACE = 28;
HUAWEI_CLOUD_MAAS = 29;
BASETEN = 30;
ZAI = 31;
VERCEL_AI_GATEWAY = 32;
QWEN_CODE = 33;
DIFY = 34;
OCA = 35;
MINIMAX = 36;
HICAP = 37;
AIHUBMIX = 38;
NOUSRESEARCH = 39;
OPENAI_CODEX = 40;
WANDB = 41;
CLINE_PASS = 42;
POOLSIDE = 45;
V0 = 46;
XIAOMI = 47;
ZAI_CODING_PLAN = 49;
reserved 43, 44, 48;
reserved "OPENAI_CODEX_CLI", "OPENCODE", "KILO";
}
enum ApiFormat {
ANTHROPIC_CHAT = 0;
GEMINI_CHAT = 1;
@@ -707,7 +760,7 @@ message ModelsApiConfiguration {
optional string wandb_api_key = 87;
// Plan mode configurations
optional string plan_mode_api_provider = 100;
optional ApiProvider plan_mode_api_provider = 100;
optional string plan_mode_api_model_id = 101;
optional int64 plan_mode_thinking_budget_tokens = 102;
optional string plan_mode_reasoning_effort = 103;
@@ -753,7 +806,7 @@ message ModelsApiConfiguration {
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 143;
// Act mode configurations
optional string act_mode_api_provider = 200;
optional ApiProvider act_mode_api_provider = 200;
optional string act_mode_api_model_id = 201;
optional int64 act_mode_thinking_budget_tokens = 202;
optional string act_mode_reasoning_effort = 203;
+2 -2
View File
@@ -237,8 +237,8 @@ message Settings {
optional string act_mode_nous_research_model_id = 121;
optional string act_mode_vercel_ai_gateway_model_id = 122;
optional OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 123;
optional string plan_mode_api_provider = 124;
optional string act_mode_api_provider = 125;
optional ApiProvider plan_mode_api_provider = 124;
optional ApiProvider act_mode_api_provider = 125;
optional string hicap_model_id = 126;
optional string lm_studio_model_id = 127;
optional AutoApprovalSettings auto_approval_settings = 128;
-2
View File
@@ -12,8 +12,6 @@ option java_package = "bot.cline.proto";
service TaskService {
// Cancels the currently running task
rpc cancelTask(EmptyRequest) returns (Empty);
// Cancels a queued prompt by ID
rpc cancelQueuedPrompt(StringRequest) returns (Empty);
// Cancels the currently running background command
rpc cancelBackgroundCommand(EmptyRequest) returns (Empty);
// Clears the current task
+11 -14
View File
@@ -61,36 +61,33 @@ async function main() {
fs.rmSync(path.join(__dirname, "..", "out", "src"), { recursive: true, force: true })
fs.rmSync(path.join(__dirname, "..", "out", "packages"), { recursive: true, force: true })
// Single source of truth for the test-runner split: any *.test.ts that imports
// from "bun:test" is owned by the bun runner (scripts/run-bun-unit-tests.ts),
// and any that imports from "vitest" is owned by the vitest runner (test:vitest,
// see vitest.config.ts). Neither may be compiled into the Node-based
// @vscode/test-cli `out/` tree: Node cannot load `bun:test`, and vitest suites
// use vitest-only APIs/matchers (e.g. `toHaveBeenCalledWith`) that the mocha
// runner does not provide. Generate a tsconfig that excludes them so the
// Single source of truth for the bun-vs-integration test split: any *.test.ts that
// imports from "bun:test" is owned by the bun runner (scripts/run-bun-unit-tests.ts)
// and must NOT be compiled into the Node-based @vscode/test-cli `out/` tree (Node
// cannot load the `bun:test` builtin, and these files use bun-only APIs like
// `mock.module` / 3-arg `it`). Generate a tsconfig that excludes them so the
// integration compile only ever sees mocha-owned tests.
const projectRoot = path.join(__dirname, "..")
const nonMochaTestImport =
/from\s+["'](?:bun:test|vitest(?:\/[^"']*)?|@vitest\/[^"']*)["']/
function collectNonMochaTestFiles(dir, acc) {
const bunTestImport = /from\s+["']bun:test["']/
function collectBunTestFiles(dir, acc) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules") continue
const full = path.join(dir, entry.name)
if (entry.isDirectory()) {
collectNonMochaTestFiles(full, acc)
collectBunTestFiles(full, acc)
} else if (entry.isFile() && entry.name.endsWith(".test.ts")) {
if (nonMochaTestImport.test(fs.readFileSync(full, "utf-8"))) {
if (bunTestImport.test(fs.readFileSync(full, "utf-8"))) {
acc.push(path.relative(projectRoot, full).split(path.sep).join("/"))
}
}
}
return acc
}
const nonMochaOwnedTests = collectNonMochaTestFiles(path.join(projectRoot, "src"), [])
const bunOwnedTests = collectBunTestFiles(path.join(projectRoot, "src"), [])
// tsconfig.test.json is JSONC (contains comments); parse with json5 (a project dep).
const JSON5 = require("json5")
const baseTestConfig = JSON5.parse(fs.readFileSync(path.join(projectRoot, "tsconfig.test.json"), "utf-8"))
baseTestConfig.exclude = [...(baseTestConfig.exclude ?? []), ...nonMochaOwnedTests]
baseTestConfig.exclude = [...(baseTestConfig.exclude ?? []), ...bunOwnedTests]
const generatedConfigPath = path.join(projectRoot, "tsconfig.test.generated.json")
fs.writeFileSync(generatedConfigPath, JSON.stringify(baseTestConfig, null, "\t"))
@@ -1,16 +1,65 @@
import { type CoreSettingsItem, createCoreSettingsService } from "@cline/core"
import { parseRemoteSkillEntries } from "@core/context/instructions/user-instructions/skills"
import { RefreshedSkills, SkillInfo } from "@shared/proto/cline/file"
import fs from "fs/promises"
import path from "path"
import { parseYamlFrontmatter } from "@/core/context/instructions/user-instructions/frontmatter"
import { getSkillsDirectoriesForScan } from "@/core/storage/disk"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/shared/services/Logger"
import { fileExistsAtPath, isDirectory } from "@/utils/fs"
import { Controller } from ".."
function coreSkillToSkillInfo(skill: CoreSettingsItem): SkillInfo {
return SkillInfo.create({
name: skill.name,
description: skill.description ?? "",
path: skill.path,
enabled: skill.enabled !== false,
})
/**
* Scan a directory for skill subdirectories containing SKILL.md files.
*/
async function scanSkillsDirectory(dirPath: string): Promise<SkillInfo[]> {
const skills: SkillInfo[] = []
if (!(await fileExistsAtPath(dirPath)) || !(await isDirectory(dirPath))) {
return skills
}
try {
const entries = await fs.readdir(dirPath)
for (const entryName of entries) {
const entryPath = path.join(dirPath, entryName)
const stats = await fs.stat(entryPath).catch(() => null)
if (!stats?.isDirectory()) continue
const skillMdPath = path.join(entryPath, "SKILL.md")
if (!(await fileExistsAtPath(skillMdPath))) continue
try {
const fileContent = await fs.readFile(skillMdPath, "utf-8")
const result = parseYamlFrontmatter(fileContent)
if (result.parseError) {
Logger.warn("Failed to parse YAML frontmatter:", result.parseError)
}
const frontmatter = result.data
// Validate required fields
if (!frontmatter.name || typeof frontmatter.name !== "string") continue
if (!frontmatter.description || typeof frontmatter.description !== "string") continue
if (frontmatter.name !== entryName) continue
skills.push(
SkillInfo.create({
name: entryName,
description: frontmatter.description,
path: skillMdPath,
enabled: true, // Will be updated with toggle state
}),
)
} catch {
// Skip invalid skills
}
}
} catch {
// Directory read error, skip
}
return skills
}
/**
@@ -21,15 +70,33 @@ export async function refreshSkills(controller: Controller): Promise<RefreshedSk
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const primaryWorkspace = workspacePaths.paths[0]
const settingsSnapshot = await createCoreSettingsService().list({
workspaceRoot: primaryWorkspace,
})
const globalSkills = settingsSnapshot.skills
.filter((skill) => skill.source === "global" || skill.source === "global-plugin")
.map(coreSkillToSkillInfo)
const localSkills = settingsSnapshot.skills
.filter((skill) => skill.source === "workspace" || skill.source === "workspace-plugin")
.map(coreSkillToSkillInfo)
const globalSkills: SkillInfo[] = []
const localSkills: SkillInfo[] = []
if (primaryWorkspace) {
const scanDirs = getSkillsDirectoriesForScan(primaryWorkspace)
for (const dir of scanDirs) {
const skills = await scanSkillsDirectory(dir.path)
if (dir.source === "global") {
globalSkills.push(...skills)
} else {
localSkills.push(...skills)
}
}
} else {
const scanDirs = getSkillsDirectoriesForScan("")
for (const dir of scanDirs) {
if (dir.source !== "global") continue
const skills = await scanSkillsDirectory(dir.path)
globalSkills.push(...skills)
}
}
// Get global toggles and apply them
const globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
for (const skill of globalSkills) {
skill.enabled = globalToggles[skill.path] !== false
}
// Add remote skills from remote config.
// Precedence: remote (enterprise) > disk-global (user) > project (workspace).
@@ -53,6 +120,12 @@ export async function refreshSkills(controller: Controller): Promise<RefreshedSk
)
}
// Get local toggles and apply them
const localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
for (const skill of localSkills) {
skill.enabled = localToggles[skill.path] !== false
}
return RefreshedSkills.create({
globalSkills,
localSkills,
@@ -1,51 +0,0 @@
import { afterEach, describe, it, mock } from "bun:test"
import * as assert from "assert"
import sinon from "sinon"
import type { Controller } from "../../index"
const installMarketplaceEntryFromCatalogStub: sinon.SinonStub = sinon.stub()
const marketplaceHelpersMock = () => ({
installMarketplaceEntryFromCatalog: installMarketplaceEntryFromCatalogStub,
})
mock.module("../marketplace-helpers", marketplaceHelpersMock)
mock.module("./marketplace-helpers", marketplaceHelpersMock)
describe("installMarketplaceEntry", () => {
afterEach(() => {
installMarketplaceEntryFromCatalogStub.reset()
})
it("reconciles the MCP hub after installing an MCP marketplace entry", async () => {
const { installMarketplaceEntry } = await import("../installMarketplaceEntry")
const reconcileMcpServersFromSettingsRPC = sinon.stub().resolves([])
const invalidateUserInstructionService = sinon.stub().resolves()
const controller = {
mcpHub: { reconcileMcpServersFromSettingsRPC },
invalidateUserInstructionService,
} as unknown as Controller
installMarketplaceEntryFromCatalogStub.resolves({
id: "chrome-devtools",
type: "mcp",
status: "installed",
})
await installMarketplaceEntry(controller, {
entry: {
id: "chrome-devtools",
type: "mcp",
name: "Chrome DevTools",
install: {
args: ["chrome-devtools", "--", "npx", "chrome-devtools-mcp@1.2.0"],
env: [],
},
tags: [],
tagObjects: [],
},
})
assert.equal(installMarketplaceEntryFromCatalogStub.callCount, 1)
assert.equal(reconcileMcpServersFromSettingsRPC.callCount, 1)
assert.equal(invalidateUserInstructionService.callCount, 0)
})
})
@@ -1,20 +1,13 @@
import { type MarketplaceEntryRequest, MarketplaceInstallResult } from "@shared/proto/cline/marketplace"
import type { Controller } from "../index"
import { installMarketplaceEntryFromCatalog } from "./marketplace-helpers"
import { installMarketplaceEntryWithCli } from "./marketplace-helpers"
export async function installMarketplaceEntry(
controller: Controller,
_controller: Controller,
request: MarketplaceEntryRequest,
): Promise<MarketplaceInstallResult> {
if (!request.entry) {
throw new Error("Marketplace entry is required.")
}
const result = await installMarketplaceEntryFromCatalog(request.entry)
if (request.entry.type === "mcp") {
await controller.mcpHub?.reconcileMcpServersFromSettingsRPC()
}
if (request.entry.type === "skill" || request.entry.type === "plugin") {
await controller.invalidateUserInstructionService()
}
return result
return installMarketplaceEntryWithCli(request.entry)
}
@@ -6,25 +6,15 @@ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from
import {
disablePluginMcpServersInSettings,
discoverPluginModulePaths,
installMcpServer,
installPlugin,
isMarketplaceSkillInstalled,
type MarketplaceActionResult,
type MarketplaceEntryInput,
type MarketplacePrimitiveType,
parseMcpInstallArgs,
readGlobalSettings,
resolvePluginConfigSearchPaths,
setDisabledPlugin,
syncPluginMcpServersToSettings,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
uninstallPlugin,
} from "@cline/core"
import { deleteSkillFile } from "@core/controller/file/deleteSkillFile"
import { refreshSkills } from "@core/controller/file/refreshSkills"
import { toggleSkill } from "@core/controller/file/toggleSkill"
import { resolveActiveModelIdFromApiConfiguration } from "@core/controller/models/taskApiModel"
import { DeleteSkillRequest, ToggleSkillRequest } from "@shared/proto/cline/file"
import { ToggleSkillRequest } from "@shared/proto/cline/file"
import {
MarketplaceCatalog,
MarketplaceEntry,
@@ -32,7 +22,6 @@ import {
MarketplaceInstallResult,
MarketplaceLocalInstalledEntries,
MarketplaceLocalInstalledEntry,
MarketplaceLocalInstalledEntryRequest,
ToggleMarketplaceLocalInstalledEntryRequest,
} from "@shared/proto/cline/marketplace"
import { HostProvider } from "@/hosts/host-provider"
@@ -50,6 +39,7 @@ const MARKETPLACE_CATALOG_URL = "https://cline.github.io/marketplace/catalog.jso
const OFFICIAL_PLUGINS_REPO = "https://github.com/cline/plugins.git"
const INSTALL_COMMAND_TIMEOUT_MS = 120_000
const MAX_OUTPUT_CHARS = 12_000
const LOCAL_CLI_ENTRYPOINT_ENV = "CLINE_MARKETPLACE_CLI_PATH"
const SECRET_PATTERN =
/(api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)/i
const SECRET_KEY_VALUE_PATTERN =
@@ -177,9 +167,31 @@ function isOfficialPluginInstalled(entry: MarketplaceEntry): boolean {
return existsSync(installPath)
}
function getSkillCandidates(entry: MarketplaceEntry): string[] {
const candidates = new Set([normalizeMatchValue(entry.id), normalizeMatchValue(entry.name)])
const args = getEntryArgs(entry)
for (let index = 0; index < args.length; index++) {
const arg = args[index]
if ((arg === "--skill" || arg === "-s") && args[index + 1]) {
candidates.add(normalizeMatchValue(args[index + 1]))
index++
continue
}
const skillFilter = arg.split("@").at(1)
if (skillFilter) candidates.add(normalizeMatchValue(skillFilter))
}
candidates.delete("")
return [...candidates]
}
function isSkillInstalled(entry: MarketplaceEntry): boolean {
if (entry.type !== "skill") return false
return isMarketplaceSkillInstalled(toCoreMarketplaceEntry(entry))
return getSkillCandidates(entry).some((candidate) =>
[
join(resolveClineHome(), "skills", candidate, "SKILL.md"),
join(homedir(), ".agents", "skills", candidate, "SKILL.md"),
].some((path) => existsSync(path)),
)
}
export function listInstalledMarketplaceEntries(
@@ -301,37 +313,60 @@ async function runCommand(command: string, args: string[]): Promise<SpawnResult>
})
}
function installMcpMarketplaceEntry(entry: MarketplaceEntry, args: string[]): MarketplaceInstallResult {
const parsed = parseMcpInstallArgs(args)
const result = installMcpServer(parsed)
return MarketplaceInstallResult.create({
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name || entry.id}.`,
output: result.warnings.join("\n") || undefined,
})
function localCliRunner(): { command: string; args: string[] } | undefined {
const overridePath = process.env[LOCAL_CLI_ENTRYPOINT_ENV]?.trim()
const devWorkspacePath = process.env.DEV_WORKSPACE_FOLDER?.trim()
const candidatePath =
overridePath ||
(devWorkspacePath ? join(devWorkspacePath, "apps", "cli", "src", "index.ts") : undefined) ||
findLocalCliEntrypointFromKnownDirectories()
if (!candidatePath || !existsSync(candidatePath)) return undefined
return {
command: "bun",
args: ["--conditions=development", candidatePath],
}
}
async function installPluginMarketplaceEntry(entry: MarketplaceEntry, args: string[]): Promise<MarketplaceInstallResult> {
const [source] = args
if (!source) throw new Error("Marketplace plugin install args must start with a plugin source.")
const result = await installPlugin({ source })
const warnings = result.mcpSyncFailures.map(
(failure) => `Failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
function findLocalCliEntrypointFromKnownDirectories(): string | undefined {
const startDirectories = [process.cwd(), typeof __dirname === "string" ? __dirname : undefined].filter(
(directory): directory is string => Boolean(directory),
)
return MarketplaceInstallResult.create({
id: entry.id,
type: entry.type,
status: "installed",
message: `Installed ${entry.name || entry.id}.`,
output: [`Path: ${result.installPath}`, ...warnings].join("\n"),
})
for (const startDirectory of startDirectories) {
const candidatePath = findLocalCliEntrypoint(startDirectory)
if (candidatePath) return candidatePath
}
return undefined
}
async function installSkillMarketplaceEntry(entry: MarketplaceEntry, args: string[]): Promise<MarketplaceInstallResult> {
const command = "npx"
const commandArgs = ["-y", "skills@latest", "add", ...args, "-g", "-a", "cline", "-y"]
function findLocalCliEntrypoint(startDirectory: string): string | undefined {
let current = resolve(startDirectory)
for (let depth = 0; depth < 8; depth++) {
const candidatePath = join(current, "apps", "cli", "src", "index.ts")
if (existsSync(candidatePath)) return candidatePath
const parent = dirname(current)
if (parent === current) break
current = parent
}
return undefined
}
export async function installMarketplaceEntryWithCli(entry: MarketplaceEntry): Promise<MarketplaceInstallResult> {
const args = getEntryArgs(entry)
if (args.length === 0) throw new Error("Marketplace install args are required.")
const localCli = entry.type === "mcp" || entry.type === "plugin" ? localCliRunner() : undefined
const command = localCli?.command ?? "npx"
const commandArgs = localCli
? [
...localCli.args,
...(entry.type === "mcp"
? ["mcp", "install", "--yes", "--json", ...args]
: ["plugin", "install", args[0] ?? "", "--json"]),
]
: entry.type === "skill"
? ["-y", "skills@latest", "add", ...args, "-g", "-a", "cline", "-y"]
: entry.type === "mcp"
? ["-y", "cline", "mcp", "install", "--yes", "--json", ...args]
: ["-y", "cline", "plugin", "install", args[0] ?? "", "--json"]
const displayCommand = formatCommand(command, commandArgs)
let result: SpawnResult
try {
@@ -360,52 +395,6 @@ async function installSkillMarketplaceEntry(entry: MarketplaceEntry, args: strin
})
}
export async function installMarketplaceEntryFromCatalog(entry: MarketplaceEntry): Promise<MarketplaceInstallResult> {
const args = getEntryArgs(entry)
if (args.length === 0) throw new Error("Marketplace install args are required.")
if (entry.type === "mcp") return installMcpMarketplaceEntry(entry, args)
if (entry.type === "plugin") return installPluginMarketplaceEntry(entry, args)
return installSkillMarketplaceEntry(entry, args)
}
function toCoreMarketplaceEntry(entry: MarketplaceEntry): MarketplaceEntryInput {
if (entry.type !== "mcp" && entry.type !== "skill" && entry.type !== "plugin") {
throw new Error(`Unsupported marketplace entry type: ${entry.type}`)
}
return {
id: entry.id,
type: entry.type as MarketplacePrimitiveType,
name: entry.name,
install: {
args: getEntryArgs(entry),
},
}
}
function toProtoMarketplaceInstallResult(result: MarketplaceActionResult): MarketplaceInstallResult {
return MarketplaceInstallResult.create({
id: result.id,
type: result.type,
status: result.status,
message: result.message,
output: result.output,
})
}
export async function uninstallMarketplaceEntryFromCatalog(
controller: Controller,
entry: MarketplaceEntry,
): Promise<MarketplaceInstallResult> {
const workspaceRoot = await getWorkspacePath()
const result = await uninstallCoreMarketplaceEntry(toCoreMarketplaceEntry(entry), {
deleteMcpServer: async (name) => {
await controller.mcpHub?.deleteServerRPC(name)
},
workspaceRoot,
})
return toProtoMarketplaceInstallResult(result)
}
function readPackageName(packageJsonPath: string): string | undefined {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown }
@@ -471,6 +460,7 @@ export async function listLocalMarketplaceInstalledEntries(controller: Controlle
type: "mcp",
name: server.name,
description: server.status,
path: server.config,
enabled: server.disabled !== true,
}),
)
@@ -553,12 +543,6 @@ export async function toggleLocalMarketplaceInstalledEntry(
): Promise<MarketplaceLocalInstalledEntries> {
const { entry, enabled } = request
if (!entry) throw new Error("Installed marketplace entry is required.")
if (entry.type === "mcp") {
const name = entry.name || entry.id
if (!name) throw new Error("MCP server name is required.")
await controller.mcpHub?.toggleServerDisabledRPC(name, !enabled)
return listLocalMarketplaceInstalledEntries(controller)
}
if (entry.type === "skill") {
await toggleSkill(
controller,
@@ -572,64 +556,7 @@ export async function toggleLocalMarketplaceInstalledEntry(
}
if (entry.type === "plugin") {
await togglePluginLocalEntry(controller, entry, enabled)
await controller.invalidateUserInstructionService()
return listLocalMarketplaceInstalledEntries(controller)
}
throw new Error(`Marketplace toggle is not supported for ${entry.type}.`)
}
export async function uninstallLocalMarketplaceInstalledEntry(
controller: Controller,
request: MarketplaceLocalInstalledEntryRequest,
): Promise<MarketplaceInstallResult> {
const { entry } = request
if (!entry) throw new Error("Installed marketplace entry is required.")
const name = entry.name || entry.id
if (entry.type === "mcp") {
if (!name) throw new Error("MCP server name is required.")
await controller.mcpHub?.deleteServerRPC(name)
return MarketplaceInstallResult.create({
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${name}.`,
})
}
if (entry.type === "skill") {
if (entry.path?.startsWith("remote:")) {
throw new Error("Remote-managed skills cannot be uninstalled from Customize.")
}
if (!entry.path) throw new Error("Skill path is required for uninstall.")
await deleteSkillFile(
controller,
DeleteSkillRequest.create({
skillPath: entry.path,
isGlobal: entry.source === "global",
}),
)
await controller.invalidateUserInstructionService()
return MarketplaceInstallResult.create({
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${name || entry.id}.`,
})
}
if (entry.type === "plugin") {
const workspaceRoot = await getWorkspacePath()
const result = await uninstallPlugin({
name: entry.path ? undefined : name,
path: entry.path,
workspaceRoot,
})
await controller.invalidateUserInstructionService()
return MarketplaceInstallResult.create({
id: entry.id,
type: entry.type,
status: "uninstalled",
message: `Uninstalled ${result.name}.`,
output: [`Path: ${result.installPath}`, ...result.removedPaths.map((path) => `Removed: ${path}`)].join("\n"),
})
}
throw new Error(`Marketplace uninstall is not supported for ${entry.type}.`)
}
@@ -1,17 +0,0 @@
import { type MarketplaceEntryRequest, MarketplaceInstallResult } from "@shared/proto/cline/marketplace"
import type { Controller } from "../index"
import { uninstallMarketplaceEntryFromCatalog } from "./marketplace-helpers"
export async function uninstallMarketplaceEntry(
controller: Controller,
request: MarketplaceEntryRequest,
): Promise<MarketplaceInstallResult> {
if (!request.entry) {
throw new Error("Marketplace entry is required.")
}
const result = await uninstallMarketplaceEntryFromCatalog(controller, request.entry)
if (request.entry.type === "skill" || request.entry.type === "plugin") {
await controller.invalidateUserInstructionService()
}
return result
}
@@ -1,10 +0,0 @@
import type { MarketplaceInstallResult, MarketplaceLocalInstalledEntryRequest } from "@shared/proto/cline/marketplace"
import type { Controller } from "../index"
import { uninstallLocalMarketplaceInstalledEntry } from "./marketplace-helpers"
export async function uninstallMarketplaceLocalInstalledEntry(
controller: Controller,
request: MarketplaceLocalInstalledEntryRequest,
): Promise<MarketplaceInstallResult> {
return uninstallLocalMarketplaceInstalledEntry(controller, request)
}
@@ -1,107 +0,0 @@
import { DEFAULT_AUTO_APPROVAL_SETTINGS } from "@shared/AutoApprovalSettings"
import { AutoApprovalSettingsRequest } from "@shared/proto/cline/state"
import { describe, expect, it, vi } from "vitest"
import type { Controller } from ".."
import { updateAutoApprovalSettings } from "./updateAutoApprovalSettings"
function makeController(currentSettings = DEFAULT_AUTO_APPROVAL_SETTINGS, taskId?: string) {
const controller = {
task: taskId ? { taskId } : undefined,
getStateToPostToWebview: vi.fn(async () => ({
autoApprovalSettings: currentSettings,
})),
postStateToWebview: vi.fn(async () => undefined),
stateManager: {
setGlobalState: vi.fn(),
setTaskSettings: vi.fn(),
},
}
return controller as unknown as Controller & {
getStateToPostToWebview: ReturnType<typeof vi.fn>
postStateToWebview: ReturnType<typeof vi.fn>
stateManager: {
setGlobalState: ReturnType<typeof vi.fn>
setTaskSettings: ReturnType<typeof vi.fn>
}
}
}
describe("updateAutoApprovalSettings", () => {
it("updates the active task override when auto-approval settings change", async () => {
const currentSettings = {
...DEFAULT_AUTO_APPROVAL_SETTINGS,
version: 1,
actions: {
...DEFAULT_AUTO_APPROVAL_SETTINGS.actions,
editFiles: false,
},
}
const controller = makeController(currentSettings, "task-1")
await updateAutoApprovalSettings(
controller,
AutoApprovalSettingsRequest.create({
version: 2,
actions: {
editFiles: true,
},
}),
)
const expectedSettings = {
...currentSettings,
version: 2,
actions: {
...currentSettings.actions,
editFiles: true,
},
}
expect(controller.stateManager.setGlobalState).toHaveBeenCalledWith("autoApprovalSettings", expectedSettings)
expect(controller.stateManager.setTaskSettings).toHaveBeenCalledWith("task-1", "autoApprovalSettings", expectedSettings)
expect(controller.postStateToWebview).toHaveBeenCalledOnce()
})
it("does not create a task override when no task is active", async () => {
const controller = makeController(DEFAULT_AUTO_APPROVAL_SETTINGS)
await updateAutoApprovalSettings(
controller,
AutoApprovalSettingsRequest.create({
version: DEFAULT_AUTO_APPROVAL_SETTINGS.version + 1,
actions: {
readFiles: false,
},
}),
)
expect(controller.stateManager.setGlobalState).toHaveBeenCalledOnce()
expect(controller.stateManager.setTaskSettings).not.toHaveBeenCalled()
expect(controller.postStateToWebview).toHaveBeenCalledOnce()
})
it("ignores stale auto-approval settings versions", async () => {
const controller = makeController(
{
...DEFAULT_AUTO_APPROVAL_SETTINGS,
version: 3,
},
"task-1",
)
await updateAutoApprovalSettings(
controller,
AutoApprovalSettingsRequest.create({
version: 3,
actions: {
readFiles: false,
},
}),
)
expect(controller.stateManager.setGlobalState).not.toHaveBeenCalled()
expect(controller.stateManager.setTaskSettings).not.toHaveBeenCalled()
expect(controller.postStateToWebview).not.toHaveBeenCalled()
})
})
@@ -29,9 +29,6 @@ export async function updateAutoApprovalSettings(controller: Controller, request
}
controller.stateManager.setGlobalState("autoApprovalSettings", settings)
if (controller.task?.taskId) {
controller.stateManager.setTaskSettings(controller.task.taskId, "autoApprovalSettings", settings)
}
await controller.postStateToWebview()
}
@@ -1,20 +0,0 @@
import { Empty, type StringRequest } from "@shared/proto/cline/common"
import { Logger } from "@/shared/services/Logger"
import { Controller } from ".."
/**
* Cancels a queued prompt for the active SDK session.
*
* @param controller The controller instance
* @param request The request containing the queued prompt ID
* @returns Empty response
*/
export async function cancelQueuedPrompt(controller: Controller, request: StringRequest): Promise<Empty> {
try {
await controller.cancelQueuedPrompt(request.value)
return Empty.create()
} catch (error) {
Logger.error("Error in cancelQueuedPrompt handler:", error)
throw error
}
}
@@ -1,68 +0,0 @@
import assert from "node:assert/strict"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as sinon from "sinon"
import * as vscode from "vscode"
import { VscodeTerminalManager } from "./VscodeTerminalManager"
import { TerminalInfo, TerminalRegistry } from "./VscodeTerminalRegistry"
function createNeverEndingStream(): AsyncIterable<string> {
return {
async *[Symbol.asyncIterator]() {
await new Promise(() => {})
},
}
}
describe("VscodeTerminalManager", () => {
let sandbox: sinon.SinonSandbox
let manager: VscodeTerminalManager
beforeEach(() => {
sandbox = sinon.createSandbox({ useFakeTimers: true })
manager = new VscodeTerminalManager()
})
afterEach(() => {
manager.disposeAll()
sandbox.restore()
})
it("returns after timing out a reused terminal cwd command", async () => {
const targetCwd = "/tmp/cline-target"
const executeCommandStub = sandbox.stub().returns({
read: () => createNeverEndingStream(),
})
const terminalInfo: TerminalInfo = {
id: 1,
busy: false,
lastCommand: "",
lastActive: Date.now(),
terminal: {
shellIntegration: {
cwd: vscode.Uri.file("/tmp/cline-original"),
executeCommand: executeCommandStub,
},
} as unknown as vscode.Terminal,
}
const getAllTerminalsStub = sandbox.stub(TerminalRegistry, "getAllTerminals").returns([terminalInfo])
let didResolve = false
const terminalPromise = manager.getOrCreateTerminal(targetCwd).then((terminal) => {
didResolve = true
return terminal
})
await sandbox.clock.tickAsync(4999)
assert.equal(didResolve, false)
await sandbox.clock.tickAsync(1)
const terminal = await terminalPromise
assert.equal(terminal, terminalInfo)
assert.equal(terminalInfo.busy, false)
assert.equal(terminalInfo.pendingCwdChange, undefined)
assert.equal(terminalInfo.cwdResolved, undefined)
assert.equal(getAllTerminalsStub.called, true)
assert.equal(executeCommandStub.calledOnceWith(`cd "${targetCwd}"`), true)
})
})
@@ -11,9 +11,6 @@ import { Logger } from "@/shared/services/Logger"
import { mergePromise, VscodeTerminalProcess } from "./VscodeTerminalProcess"
import { TerminalInfo, TerminalRegistry } from "./VscodeTerminalRegistry"
const CWD_COMMAND_TIMEOUT_MS = 5000
const CWD_STATE_TIMEOUT_MS = 1000
/*
TerminalManager:
- Creates/reuses terminals
@@ -175,57 +172,6 @@ export class VscodeTerminalManager implements ITerminalManager {
return arePathsEqual(currentCwd, targetCwd)
}
private async drainCommandOutput(output: AsyncIterable<string>): Promise<void> {
for await (const _chunk of output) {
// Drain the stream so shell integration can report command completion.
}
}
// VS Code shell integration sometimes finishes the internal `cd` command without
// reporting completion through the execution stream. Timeout this setup step so
// the user's actual command is still sent instead of leaving the chat stuck.
private async runCwdChangeCommand(terminalInfo: TerminalInfo, cwd: string): Promise<boolean> {
const command = `cd "${cwd}"`
const shellIntegration = terminalInfo.terminal.shellIntegration
if (!shellIntegration?.executeCommand) {
terminalInfo.terminal.sendText(command, true)
Logger.warn(
`[TerminalManager] Shell integration executeCommand is unavailable while changing terminal ${terminalInfo.id} cwd. Proceeding after ${CWD_COMMAND_TIMEOUT_MS}ms.`,
)
await new Promise((resolve) => setTimeout(resolve, CWD_COMMAND_TIMEOUT_MS))
return true
}
let timeout: NodeJS.Timeout | undefined
let didTimeOut = false
try {
const execution = shellIntegration.executeCommand(command)
await Promise.race([
this.drainCommandOutput(execution.read()),
new Promise<void>((resolve) => {
timeout = setTimeout(() => {
didTimeOut = true
Logger.warn(
`[TerminalManager] Timed out waiting ${CWD_COMMAND_TIMEOUT_MS}ms for terminal ${terminalInfo.id} to run cd "${cwd}". Proceeding with requested command.`,
)
resolve()
}, CWD_COMMAND_TIMEOUT_MS)
}),
])
} catch (error) {
Logger.warn(`[TerminalManager] Failed to observe terminal ${terminalInfo.id} cwd command completion`, error)
return true
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
return didTimeOut
}
runCommand(terminalInfo: ITerminalInfo, command: string): ITerminalProcessResultPromise {
// Cast to VSCode-specific TerminalInfo for internal use
// Using unknown as intermediate cast due to structural differences between ITerminal and vscode.Terminal
@@ -339,34 +285,43 @@ export class VscodeTerminalManager implements ITerminalManager {
(t) => !t.busy && VscodeTerminalManager.effectiveShellPath(t.shellPath) === effectiveExpected,
)
if (availableTerminal) {
availableTerminal.busy = true
// Set up promise and tracking for CWD change
const cwdPromise = new Promise<void>((resolve, reject) => {
availableTerminal.pendingCwdChange = cwd
availableTerminal.cwdResolved = { resolve, reject }
})
try {
const didCwdCommandTimeOut = await this.runCwdChangeCommand(availableTerminal, cwd)
// Navigate back to the desired directory
// Cast to ITerminalInfo for interface compatibility
const cdProcess = this.runCommand(availableTerminal as unknown as ITerminalInfo, `cd "${cwd}"`)
// Add a small delay to ensure terminal is ready after cd
if (!didCwdCommandTimeOut) {
await new Promise((resolve) => setTimeout(resolve, 100))
}
// Wait for the cd command to complete before proceeding
await cdProcess
// Either resolve immediately if CWD already updated or wait for event/timeout
if (this.isCwdMatchingExpected(availableTerminal)) {
if (availableTerminal.cwdResolved) {
availableTerminal.cwdResolved.resolve()
}
} else if (!didCwdCommandTimeOut) {
await Promise.race([cwdPromise, new Promise((resolve) => setTimeout(resolve, CWD_STATE_TIMEOUT_MS))])
// Add a small delay to ensure terminal is ready after cd
await new Promise((resolve) => setTimeout(resolve, 100))
// Either resolve immediately if CWD already updated or wait for event/timeout
if (this.isCwdMatchingExpected(availableTerminal)) {
if (availableTerminal.cwdResolved) {
availableTerminal.cwdResolved.resolve()
}
} finally {
availableTerminal.pendingCwdChange = undefined
availableTerminal.cwdResolved = undefined
availableTerminal.busy = false
} else {
try {
// Wait with a timeout for state change event to resolve
await Promise.race([
cwdPromise,
new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error(`CWD timeout: Failed to update to ${cwd}`)), 1000),
),
])
} catch (_err) {
// Clear pending state on timeout
availableTerminal.pendingCwdChange = undefined
availableTerminal.cwdResolved = undefined
}
}
this.terminalIds.add(availableTerminal.id)
// Cast to ITerminalInfo for interface compatibility
+7 -39
View File
@@ -9,8 +9,8 @@ import * as path from "node:path"
import {
createUserInstructionConfigService,
getProviderAuthStorageId,
type PreparedRemoteConfigCoreIntegration,
resolveDefaultMcpSettingsPath,
type PreparedRemoteConfigCoreIntegration,
type SessionHistoryRecord,
setTelemetryOptOutGlobally,
type UserInstructionConfigService,
@@ -610,15 +610,6 @@ export class Controller {
}
}
async invalidateUserInstructionService(): Promise<void> {
const userInstructionServicePromise = this.userInstructionService
this.userInstructionService = undefined
this.userInstructionServiceRoot = undefined
if (userInstructionServicePromise) {
await userInstructionServicePromise.then((service) => service.stop()).catch(() => {})
}
}
async dispose(): Promise<void> {
this.providerConfigStoreSubscription.dispose()
// Clear the remote config timer to prevent stale fetches
@@ -628,7 +619,11 @@ export class Controller {
}
await this.setRemoteConfigCoreIntegration(undefined)
this.isDisposed = true
await this.invalidateUserInstructionService()
const userInstructionServicePromise = this.userInstructionService
this.userInstructionService = undefined
if (userInstructionServicePromise) {
await userInstructionServicePromise.then((service) => service.stop()).catch(() => {})
}
this.messages.cancelPendingSave()
// Clear MCP tool list change callback before disposing McpHub
this.mcpHub?.clearToolListChangeCallback()
@@ -671,11 +666,7 @@ export class Controller {
this.userInstructionService = (async () => {
const service = createUserInstructionConfigService({
workflows: { workspacePath: workspaceRoot },
skills: {
workspacePath: workspaceRoot,
includePluginSkills: true,
cwd: workspaceRoot,
},
skills: { workspacePath: workspaceRoot },
rules: { workspacePath: workspaceRoot },
})
// start() runs the initial scan; await so the snapshot is populated
@@ -997,29 +988,6 @@ export class Controller {
stubWarn("cancelBackgroundCommand")
}
async cancelQueuedPrompt(promptId: string): Promise<void> {
const trimmedPromptId = promptId.trim()
if (!trimmedPromptId) {
Logger.warn("[SdkController] cancelQueuedPrompt: Missing prompt id")
return
}
const activeSession = this.sessions.getActiveSession()
if (!activeSession) {
Logger.warn("[SdkController] cancelQueuedPrompt: No active session")
return
}
const result = await activeSession.sdkHost.pendingPrompts("delete", {
sessionId: activeSession.sessionId,
promptId: trimmedPromptId,
})
if (!result.removed) {
Logger.warn(`[SdkController] cancelQueuedPrompt: Prompt not found: ${trimmedPromptId}`)
}
await this.postStateToWebview()
}
/**
* Manually compact (condense) the active task's conversation. Triggered by
* the compact button and the `/compact` (alias `/smol`) slash command.
@@ -333,21 +333,6 @@ describe("buildSessionConfig", () => {
expect(mocks.providerSettingsManager.getProviderSettings).not.toHaveBeenCalled()
})
it("resolves OpenAI Compatible API keys from migrated SDK provider settings", () => {
mocks.providerSettingsManager.getProviderSettings.mockImplementation((providerId?: string) => {
if (providerId !== "openai-compatible") {
return undefined
}
return {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
} as any
})
expect(resolveApiKey("openai", {} as any)).toBe("migrated-openai-compatible-key")
expect(mocks.providerSettingsManager.getProviderSettings).toHaveBeenCalledWith("openai-compatible")
})
it("resolves OpenAI Codex through the shared OAuth provider registry", async () => {
mocks.providerSettingsManager.getProviderSettings.mockReturnValue({
provider: "openai-codex",
+3 -7
View File
@@ -133,10 +133,6 @@ function hasStaleDisabledReasoningFields(reasoning: ProviderReasoningSettings |
return reasoning?.enabled === false && (reasoning.effort !== undefined || reasoning.budgetTokens !== undefined)
}
function providerSettingsProviderId(providerId: string): string {
return toSdkProviderId(providerId)
}
/**
* Convert SDK provider-level reasoning settings into the SDK session fields that
* are actually forwarded as model options. Keep `thinking` and
@@ -164,7 +160,7 @@ export function normalizeProviderReasoningSettings(reasoning: ProviderReasoningS
function resolveProviderReasoningConfig(providerId: string): SessionReasoningConfig {
try {
const manager = getProviderSettingsManager(resolveDataDir())
const settings = manager.getProviderSettings(providerSettingsProviderId(providerId))
const settings = manager.getProviderSettings(providerId)
if (!settings) {
return {}
}
@@ -337,7 +333,7 @@ export function resolveApiKey(providerId: string, config: ApiConfiguration): str
// hardcoding provider exceptions.
try {
const manager = getProviderSettingsManager()
const apiKey = resolveProviderApiKeyFromSettings(manager, providerSettingsProviderId(providerId))?.trim()
const apiKey = resolveProviderApiKeyFromSettings(manager, providerId)?.trim()
if (apiKey) {
return apiKey
}
@@ -363,7 +359,7 @@ export function resolveApiKey(providerId: string, config: ApiConfiguration): str
// startup.
try {
const manager = getProviderSettingsManager()
const apiKey = resolveProviderApiKeyFromSettings(manager, providerSettingsProviderId(providerId))?.trim()
const apiKey = resolveProviderApiKeyFromSettings(manager, providerId)?.trim()
if (apiKey) {
return apiKey
}
@@ -94,28 +94,9 @@ describe("buildEffectiveProviderConfig", () => {
})
})
it("reads migrated OpenAI Compatible settings from the SDK provider id", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({
"openai-compatible": {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
headers: { "X-Test": "legacy-header" },
},
})
expect(buildEffectiveProviderConfig(parseProviderId("openai"))).toEqual({
providerId: parseProviderId("openai"),
apiKey: "migrated-openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
headers: { "X-Test": "legacy-header" },
})
})
it("reads normalized nousResearch API key from StateManager", async () => {
const { buildEffectiveProviderConfig } = await import("./effective-config")
mocks.setProviderSettings({ nousResearch: { provider: "nousResearch", apiKey: "provider-nous-key" } })
mocks.setProviderSettings({ nousresearch: { provider: "nousresearch", apiKey: "provider-nous-key" } })
mocks.setApiConfiguration({ nousResearchApiKey: "state-nous-key" })
expect(buildEffectiveProviderConfig(parseProviderId("nousResearch"))).toEqual({
@@ -2,7 +2,6 @@ import type { ApiConfiguration } from "@shared/api"
import { StateManager } from "@/core/storage/StateManager"
import { getProviderSettingsManager } from "../provider-migration"
import type { AwsProviderConfig, EffectiveProviderConfig, GcpProviderConfig, ProviderId } from "./contracts"
import { toSdkProviderId } from "./sdk-provider-id"
type AuthConfig = NonNullable<EffectiveProviderConfig["auth"]>
type ExtrasConfig = NonNullable<EffectiveProviderConfig["extras"]>
@@ -193,7 +192,7 @@ function readAws(record: Record<string, unknown>): AwsProviderConfig | undefined
function readProviderSettings(providerId: ProviderId): ConfigParts {
try {
const settings: unknown = getProviderSettingsManager().getProviderSettings(toSdkProviderId(providerId))
const settings: unknown = getProviderSettingsManager().getProviderSettings(providerId)
if (!isPlainRecord(settings)) {
return {}
}
+2 -118
View File
@@ -181,129 +181,13 @@ describe("createProviderConfigStore", () => {
expect(written).toEqual({ providerId, apiKey: "nous-key" })
expect(store.readSelection(providerId, "act")).toEqual(selection)
expect(mocks.getSavedProviderSettings("nousResearch")).toMatchObject({
provider: "nousResearch",
expect(mocks.getSavedProviderSettings("nousresearch")).toMatchObject({
provider: "nousresearch",
apiKey: "nous-key",
model: "nousresearch/hermes-4-70b",
})
})
it("reads migrated OpenAI Compatible settings from the SDK provider id", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setProviderSettings({
"openai-compatible": {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
headers: { "X-Test": "legacy-header" },
},
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
expect(store.read(providerId)).toEqual({
providerId,
apiKey: "migrated-openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
headers: { "X-Test": "legacy-header" },
})
})
it("writes OpenAI Compatible settings under the SDK provider id", async () => {
const { createProviderConfigStore } = await import("./store")
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
store.write(providerId, {
apiKey: "openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
})
expect(mocks.getSavedProviderSettings("openai")).toBeUndefined()
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
apiKey: "openai-compatible-key",
baseUrl: "https://gateway.example.invalid/v1",
})
})
it("preserves migrated OpenAI Compatible settings when committing model selections", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setProviderSettings({
"openai-compatible": {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
},
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = { providerId, modelId: "gpt-oss-120b", modelInfo: modelInfoA }
store.commitSelection(providerId, "act", selection)
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
model: "gpt-oss-120b",
})
})
it("keeps OpenAI Compatible Plan and Act selections independent when separate models are enabled", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ planActSeparateModelsSetting: true })
mocks.setProviderSettings({
"openai-compatible": {
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
},
})
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const planSelection = { providerId, modelId: "plan-openai-model", modelInfo: modelInfoA }
const actSelection = { providerId, modelId: "act-openai-model", modelInfo: modelInfoB }
store.commitSelection(providerId, "plan", planSelection)
store.commitSelection(providerId, "act", actSelection)
expect(store.readSelection(providerId, "plan")).toEqual(planSelection)
expect(store.readSelection(providerId, "act")).toEqual(actSelection)
expect(mocks.getApiConfiguration()).toMatchObject({
planModeOpenAiModelId: "plan-openai-model",
planModeOpenAiModelInfo: modelInfoA,
actModeOpenAiModelId: "act-openai-model",
actModeOpenAiModelInfo: modelInfoB,
})
expect(mocks.getSavedProviderSettings("openai")).toBeUndefined()
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
apiKey: "migrated-openai-compatible-key",
model: "act-openai-model",
})
})
it("mirrors OpenAI Compatible selections to both modes when separate models are disabled", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ planActSeparateModelsSetting: false })
const store = createProviderConfigStore()
const providerId = parseProviderId("openai")
const selection = { providerId, modelId: "shared-openai-model", modelInfo: modelInfoA }
store.commitSelection(providerId, "act", selection)
expect(store.readSelection(providerId, "plan")).toEqual(selection)
expect(store.readSelection(providerId, "act")).toEqual(selection)
expect(mocks.getApiConfiguration()).toMatchObject({
planModeOpenAiModelId: "shared-openai-model",
planModeOpenAiModelInfo: modelInfoA,
actModeOpenAiModelId: "shared-openai-model",
actModeOpenAiModelInfo: modelInfoA,
})
expect(mocks.getSavedProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
model: "shared-openai-model",
})
})
it("writes Z.AI Coding Plan API keys only to provider-specific settings", async () => {
const { createProviderConfigStore } = await import("./store")
mocks.setApiConfiguration({ zaiApiKey: "shared-zai-key" })
+2 -7
View File
@@ -127,10 +127,6 @@ function providerForStorage(providerId: ProviderId): ApiProvider | undefined {
return key as ApiProvider
}
function providerSettingsProviderId(providerId: ProviderId): string {
return toSdkProviderId(providerId)
}
function memoryKey(providerId: ProviderId, mode: Mode): string {
return `${providerId}:${mode}`
}
@@ -284,13 +280,12 @@ function writeStateFields(providerId: ProviderId, patch: ProviderConfigPatch): v
}
function getProviderSettings(providerId: ProviderId): ProviderSettingsRecord {
const settings = getProviderSettingsManager().getProviderSettings(providerSettingsProviderId(providerId))
const settings = getProviderSettingsManager().getProviderSettings(providerId)
return isRecord(settings) ? settings : {}
}
function saveProviderSettings(providerId: ProviderId, next: ProviderSettingsRecord): void {
const provider = providerSettingsProviderId(providerId)
getProviderSettingsManager().saveProviderSettings({ ...next, provider }, { setLastUsed: false })
getProviderSettingsManager().saveProviderSettings({ provider: providerId, ...next }, { setLastUsed: false })
}
function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConfigPatch): void {
@@ -21,12 +21,7 @@ describe("SdkFollowupCoordinator", () => {
await coordinator.askResponse("yes", undefined, undefined, "yesButtonClicked")
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith(
"yes",
"yesButtonClicked",
undefined,
undefined,
)
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith("yes", "yesButtonClicked")
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
})
@@ -127,8 +122,6 @@ describe("SdkFollowupCoordinator", () => {
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith(
"do the next thing after this",
"messageResponse",
undefined,
undefined,
)
expect(options.waitForPendingModeRebuild).not.toHaveBeenCalled()
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
@@ -211,12 +204,7 @@ describe("SdkFollowupCoordinator", () => {
await coordinator.askResponse("just give me an answer", undefined, undefined, "messageResponse")
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith(
"just give me an answer",
"messageResponse",
undefined,
undefined,
)
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith("just give me an answer", "messageResponse")
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
activeSession.sdkHost,
@@ -58,7 +58,7 @@ export class SdkFollowupCoordinator {
return
}
if (this.options.interactions.resolvePendingToolApproval(prompt, askResponse, images, files)) {
if (this.options.interactions.resolvePendingToolApproval(prompt, askResponse)) {
return
}
@@ -103,9 +103,8 @@ describe("SdkInteractionCoordinator", () => {
const task = createTaskProxy("session-123", vi.fn(), vi.fn())
const recordApprovedToolMessage = vi.fn()
const recordDeniedToolApproval = vi.fn()
const messages = new SdkMessageCoordinator({ getTask: () => task })
const coordinator = new SdkInteractionCoordinator({
messages,
messages: new SdkMessageCoordinator({ getTask: () => task }),
getSessionId: () => "session-123",
postStateToWebview: vi.fn().mockResolvedValue(undefined),
recordApprovedToolMessage,
@@ -126,17 +125,9 @@ describe("SdkInteractionCoordinator", () => {
const clineMessages = task.messageStateHandler.getClineMessages()
expect(clineMessages[0]).toMatchObject({ type: "ask", ask: "command", text: "npm test" })
expect(coordinator.resolvePendingToolApproval("too risky", "noButtonClicked", ["image.png"], ["a.ts"])).toBe(true)
expect(coordinator.resolvePendingToolApproval("too risky", "noButtonClicked")).toBe(true)
expect(recordApprovedToolMessage).not.toHaveBeenCalled()
expect(recordDeniedToolApproval).toHaveBeenCalledWith("tool-call", "execute_command", "too risky")
expect(task.messageStateHandler.getClineMessages()[1]).toMatchObject({
type: "say",
say: "user_feedback",
text: "too risky",
images: ["image.png"],
files: ["a.ts"],
partial: false,
})
await expect(approvalPromise).resolves.toEqual({ approved: false, reason: "too risky" })
})
@@ -197,7 +188,6 @@ describe("SdkInteractionCoordinator", () => {
approved: false,
reason: DEFAULT_TOOL_APPROVAL_DENIAL_REASON,
})
expect(task.messageStateHandler.getClineMessages()).toHaveLength(1)
expect(recordDeniedToolApproval).toHaveBeenCalledWith(
"tool-call",
"fetch_web_content",
@@ -127,12 +127,7 @@ export class SdkInteractionCoordinator {
})
}
resolvePendingToolApproval(
prompt: string | undefined,
responseType: ClineAskResponse | undefined,
images?: string[],
files?: string[],
): boolean {
resolvePendingToolApproval(prompt: string | undefined, responseType: ClineAskResponse | undefined): boolean {
if (!this.pendingToolApprovalResolve) {
return false
}
@@ -160,21 +155,6 @@ export class SdkInteractionCoordinator {
// On rejection the agent receives the denial and continues; the SDK drives the next phase.
this.options.setTurnPhase?.("streaming")
const denialReason = prompt || DEFAULT_TOOL_APPROVAL_DENIAL_REASON
if (!approved && (prompt?.trim() || images?.length || files?.length)) {
const userMessage: ClineMessage = {
ts: this.nextMessageTs(),
type: "say",
say: "user_feedback",
text: prompt ?? "",
images,
files,
partial: false,
}
this.options.messages.appendAndEmit([userMessage], {
type: "status",
payload: { sessionId: this.options.getSessionId(), status: "running" },
})
}
if (!approved && pendingMessage) {
this.options.recordDeniedToolApproval?.(pendingMessage.toolCallId, pendingMessage.toolName, denialReason)
}
@@ -3,10 +3,6 @@ import { describe, expect, it } from "vitest"
import { isToolAutoApproved } from "./sdk-tool-policies"
describe("isToolAutoApproved", () => {
it("does not auto-approve command tools by default", () => {
expect(isToolAutoApproved("run_commands", DEFAULT_AUTO_APPROVAL_SETTINGS)).toBe(false)
})
it("uses executeSafeCommands as the single command approval flag", () => {
const settings = {
...DEFAULT_AUTO_APPROVAL_SETTINGS,
@@ -1,70 +0,0 @@
import { describe, expect, it } from "vitest"
import { formatCommandForTerminal } from "./vscode-run-commands-tool"
describe("formatCommandForTerminal", () => {
it.each([
{
name: "raw shell command",
input: "which git",
expected: "which git",
},
{
name: "raw shell command with pipes and quotes",
input: "git status --short | sed -n '1,20p'",
expected: "git status --short | sed -n '1,20p'",
},
{
name: "structured command with omitted args",
input: { command: "which git" },
expected: "which git",
},
{
name: "structured shell command with omitted args and metacharacters",
input: { command: "git status --short | head -20" },
expected: "git status --short | head -20",
},
{
name: "structured executable with explicit empty args",
input: { command: "/tmp/path with spaces/tool", args: [] },
expected: "'/tmp/path with spaces/tool'",
},
{
name: "structured executable with simple args",
input: { command: "which", args: ["git"] },
expected: "which git",
},
{
name: "structured executable with spaced args",
input: { command: "echo", args: ["hello world", "again"] },
expected: "echo 'hello world' again",
},
{
name: "structured executable with apostrophe args",
input: { command: "printf", args: ["it's ok"] },
expected: "printf 'it'\\''s ok'",
},
{
name: "structured executable with empty arg",
input: { command: "printf", args: [""] },
expected: "printf ''",
},
{
name: "structured executable with shell metacharacters in args",
input: { command: "echo", args: ["$HOME", "a&b", "semi;colon", "paren(value)"] },
expected: "echo '$HOME' 'a&b' 'semi;colon' 'paren(value)'",
},
{
name: "structured executable with quoted args",
input: { command: "node", args: ["-e", 'console.log("hi")'] },
expected: "node -e 'console.log(\"hi\")'",
},
])("$name", ({ input, expected }) => {
expect(formatCommandForTerminal(input)).toBe(expected)
})
it("quotes multiple structured args that need shell escaping", () => {
expect(formatCommandForTerminal({ command: "echo", args: ["hello world", "it's ok"] })).toBe(
"echo 'hello world' 'it'\\''s ok'",
)
})
})
@@ -53,13 +53,10 @@ function quoteShellArg(arg: string): string {
return `'${arg.replace(/'/g, `'\\''`)}'`
}
export function formatCommandForTerminal(command: ShellCommand): string {
function formatCommandForTerminal(command: ShellCommand): string {
if (typeof command === "string") {
return command
}
if (!("args" in command)) {
return command.command
}
return [command.command, ...(command.args ?? [])].map(quoteShellArg).join(" ")
}
@@ -11,7 +11,7 @@ describe("ClineError", () => {
it("should return Entitlement for the SDK ClinePass subscription message", () => {
const err = new ClineError(
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-100&personal=true",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
@@ -19,7 +19,7 @@ describe("ClineError", () => {
it("should return Entitlement for the SDK ClinePass subscription message with a different app URL", () => {
const err = new ClineError(
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://staging-app.cline.bot/promo?code=CLI-8OFF&personal=true",
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://staging-app.cline.bot/promo?code=CLI-100&personal=true",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
-9
View File
@@ -1267,15 +1267,6 @@ export class McpHub {
await this.notifyWebviewOfServerChanges()
}
async reconcileMcpServersFromSettingsRPC(): Promise<McpServer[]> {
const settings = await this.readPostWriteMcpSettings()
await this.updateServerConnectionsRPC(settings.mcpServers as Record<string, McpServerConfig>)
await this.notifyWebviewOfServerChanges()
const serverOrder = Object.keys(settings.mcpServers || {})
return this.getSortedMcpServers(serverOrder)
}
async getLatestMcpServersRPC(): Promise<McpServer[]> {
const settings = await this.readAndValidateMcpSettingsFile()
if (!settings) {
@@ -35,7 +35,7 @@ export const DEFAULT_AUTO_APPROVAL_SETTINGS: AutoApprovalSettings = {
readFilesExternally: true,
editFiles: true,
editFilesExternally: true,
executeSafeCommands: false,
executeSafeCommands: true,
executeAllCommands: true,
useBrowser: true,
useMcp: true,
@@ -3,6 +3,7 @@ import {
OpenAiCompatibleModelInfo,
OpenRouterModelInfo,
ModelsApiConfiguration as ProtoApiConfiguration,
ApiProvider as ProtoApiProvider,
OcaModelInfo as ProtoOcaModelInfo,
ThinkingConfig,
} from "@shared/proto/cline/models"
@@ -240,12 +241,208 @@ function convertProtoToOpenAiCompatibleModelInfo(
}
}
// Provider ids travel over the wire as plain strings (matching the `ApiProvider`
// union in `@shared/api`), so no enum mapping is needed in either direction.
// This thin helper just supplies the default and the single cast boundary for
// callers reading a provider id off a proto message.
export function convertProtoToApiProvider(provider: string | undefined): ApiProvider {
return (provider || "anthropic") as ApiProvider
// Convert application ApiProvider to proto ApiProvider
function convertApiProviderToProto(provider: string | undefined): ProtoApiProvider {
switch (provider) {
case "anthropic":
return ProtoApiProvider.ANTHROPIC
case "openrouter":
return ProtoApiProvider.OPENROUTER
case "bedrock":
return ProtoApiProvider.BEDROCK
case "vertex":
return ProtoApiProvider.VERTEX
case "openai":
return ProtoApiProvider.OPENAI
case "ollama":
return ProtoApiProvider.OLLAMA
case "lmstudio":
return ProtoApiProvider.LMSTUDIO
case "gemini":
return ProtoApiProvider.GEMINI
case "openai-native":
return ProtoApiProvider.OPENAI_NATIVE
case "requesty":
return ProtoApiProvider.REQUESTY
case "together":
return ProtoApiProvider.TOGETHER
case "deepseek":
return ProtoApiProvider.DEEPSEEK
case "qwen":
return ProtoApiProvider.QWEN
case "qwen-code":
return ProtoApiProvider.QWEN_CODE
case "doubao":
return ProtoApiProvider.DOUBAO
case "mistral":
return ProtoApiProvider.MISTRAL
case "vscode-lm":
return ProtoApiProvider.VSCODE_LM
case "cline":
return ProtoApiProvider.CLINE
case "cline-pass":
return ProtoApiProvider.CLINE_PASS
case "litellm":
return ProtoApiProvider.LITELLM
case "moonshot":
return ProtoApiProvider.MOONSHOT
case "huggingface":
return ProtoApiProvider.HUGGINGFACE
case "nebius":
return ProtoApiProvider.NEBIUS
case "wandb":
return ProtoApiProvider.WANDB
case "fireworks":
return ProtoApiProvider.FIREWORKS
case "asksage":
return ProtoApiProvider.ASKSAGE
case "xai":
return ProtoApiProvider.XAI
case "sambanova":
return ProtoApiProvider.SAMBANOVA
case "cerebras":
return ProtoApiProvider.CEREBRAS
case "groq":
return ProtoApiProvider.GROQ
case "baseten":
return ProtoApiProvider.BASETEN
case "sapaicore":
return ProtoApiProvider.SAPAICORE
case "claude-code":
return ProtoApiProvider.CLAUDE_CODE
case "huawei-cloud-maas":
return ProtoApiProvider.HUAWEI_CLOUD_MAAS
case "vercel-ai-gateway":
return ProtoApiProvider.VERCEL_AI_GATEWAY
case "zai":
return ProtoApiProvider.ZAI
case "dify":
return ProtoApiProvider.DIFY
case "oca":
return ProtoApiProvider.OCA
case "aihubmix":
return ProtoApiProvider.AIHUBMIX
case "minimax":
return ProtoApiProvider.MINIMAX
case "hicap":
return ProtoApiProvider.HICAP
case "nousResearch":
return ProtoApiProvider.NOUSRESEARCH
case "openai-codex":
return ProtoApiProvider.OPENAI_CODEX
case "poolside":
return ProtoApiProvider.POOLSIDE
case "v0":
return ProtoApiProvider.V0
case "xiaomi":
return ProtoApiProvider.XIAOMI
case "zai-coding-plan":
return ProtoApiProvider.ZAI_CODING_PLAN
default:
return ProtoApiProvider.ANTHROPIC
}
}
// Convert proto ApiProvider to application ApiProvider
export function convertProtoToApiProvider(provider: ProtoApiProvider): ApiProvider {
switch (provider) {
case ProtoApiProvider.ANTHROPIC:
return "anthropic"
case ProtoApiProvider.OPENROUTER:
return "openrouter"
case ProtoApiProvider.BEDROCK:
return "bedrock"
case ProtoApiProvider.VERTEX:
return "vertex"
case ProtoApiProvider.OPENAI:
return "openai"
case ProtoApiProvider.OLLAMA:
return "ollama"
case ProtoApiProvider.LMSTUDIO:
return "lmstudio"
case ProtoApiProvider.GEMINI:
return "gemini"
case ProtoApiProvider.OPENAI_NATIVE:
return "openai-native"
case ProtoApiProvider.REQUESTY:
return "requesty"
case ProtoApiProvider.TOGETHER:
return "together"
case ProtoApiProvider.DEEPSEEK:
return "deepseek"
case ProtoApiProvider.QWEN:
return "qwen"
case ProtoApiProvider.QWEN_CODE:
return "qwen-code"
case ProtoApiProvider.DOUBAO:
return "doubao"
case ProtoApiProvider.MISTRAL:
return "mistral"
case ProtoApiProvider.VSCODE_LM:
return "vscode-lm"
case ProtoApiProvider.CLINE:
return "cline"
case ProtoApiProvider.CLINE_PASS:
return "cline-pass"
case ProtoApiProvider.LITELLM:
return "litellm"
case ProtoApiProvider.MOONSHOT:
return "moonshot"
case ProtoApiProvider.HUGGINGFACE:
return "huggingface"
case ProtoApiProvider.NEBIUS:
return "nebius"
case ProtoApiProvider.WANDB:
return "wandb"
case ProtoApiProvider.FIREWORKS:
return "fireworks"
case ProtoApiProvider.ASKSAGE:
return "asksage"
case ProtoApiProvider.XAI:
return "xai"
case ProtoApiProvider.SAMBANOVA:
return "sambanova"
case ProtoApiProvider.CEREBRAS:
return "cerebras"
case ProtoApiProvider.GROQ:
return "groq"
case ProtoApiProvider.BASETEN:
return "baseten"
case ProtoApiProvider.SAPAICORE:
return "sapaicore"
case ProtoApiProvider.CLAUDE_CODE:
return "claude-code"
case ProtoApiProvider.HUAWEI_CLOUD_MAAS:
return "huawei-cloud-maas"
case ProtoApiProvider.VERCEL_AI_GATEWAY:
return "vercel-ai-gateway"
case ProtoApiProvider.ZAI:
return "zai"
case ProtoApiProvider.HICAP:
return "hicap"
case ProtoApiProvider.DIFY:
return "dify"
case ProtoApiProvider.OCA:
return "oca"
case ProtoApiProvider.AIHUBMIX:
return "aihubmix"
case ProtoApiProvider.MINIMAX:
return "minimax"
case ProtoApiProvider.NOUSRESEARCH:
return "nousResearch"
case ProtoApiProvider.OPENAI_CODEX:
return "openai-codex"
case ProtoApiProvider.POOLSIDE:
return "poolside"
case ProtoApiProvider.V0:
return "v0"
case ProtoApiProvider.XIAOMI:
return "xiaomi"
case ProtoApiProvider.ZAI_CODING_PLAN:
return "zai-coding-plan"
default:
return "anthropic"
}
}
// Converts application ApiConfiguration to proto ApiConfiguration
@@ -339,7 +536,7 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
hicapModelId: config.hicapModelId,
// Plan mode configurations
planModeApiProvider: config.planModeApiProvider,
planModeApiProvider: config.planModeApiProvider ? convertApiProviderToProto(config.planModeApiProvider) : undefined,
planModeApiModelId: config.planModeApiModelId,
planModeThinkingBudgetTokens: config.planModeThinkingBudgetTokens,
geminiPlanModeThinkingLevel: config.geminiPlanModeThinkingLevel,
@@ -385,7 +582,7 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
planModeVercelAiGatewayModelInfo: convertModelInfoToProtoOpenRouter(config.planModeVercelAiGatewayModelInfo),
// Act mode configurations
actModeApiProvider: config.actModeApiProvider,
actModeApiProvider: config.actModeApiProvider ? convertApiProviderToProto(config.actModeApiProvider) : undefined,
actModeApiModelId: config.actModeApiModelId,
actModeThinkingBudgetTokens: config.actModeThinkingBudgetTokens,
geminiActModeThinkingLevel: config.geminiActModeThinkingLevel,
@@ -0,0 +1,192 @@
{
"list": [
{
"value": "cline",
"label": "Cline"
},
{
"value": "cline-pass",
"label": "ClinePass"
},
{
"value": "openai-codex",
"label": "ChatGPT Subscription"
},
{
"value": "zai-coding-plan",
"label": "Z.AI Coding Plan"
},
{
"value": "gemini",
"label": "Google Gemini"
},
{
"value": "openai",
"label": "OpenAI Compatible"
},
{
"value": "anthropic",
"label": "Anthropic"
},
{
"value": "bedrock",
"label": "Amazon Bedrock"
},
{
"value": "vscode-lm",
"label": "GitHub Copilot"
},
{
"value": "deepseek",
"label": "DeepSeek"
},
{
"value": "openai-native",
"label": "OpenAI"
},
{
"value": "openrouter",
"label": "OpenRouter"
},
{
"value": "ollama",
"label": "Ollama"
},
{
"value": "vertex",
"label": "GCP Vertex AI"
},
{
"value": "litellm",
"label": "LiteLLM"
},
{
"value": "claude-code",
"label": "Claude Code"
},
{
"value": "sapaicore",
"label": "SAP AI Core"
},
{
"value": "mistral",
"label": "Mistral"
},
{
"value": "zai",
"label": "Z AI"
},
{
"value": "groq",
"label": "Groq"
},
{
"value": "poolside",
"label": "Poolside"
},
{
"value": "cerebras",
"label": "Cerebras"
},
{
"value": "vercel-ai-gateway",
"label": "Vercel AI Gateway"
},
{
"value": "v0",
"label": "Vercel v0"
},
{
"value": "baseten",
"label": "Baseten"
},
{
"value": "requesty",
"label": "Requesty"
},
{
"value": "fireworks",
"label": "Fireworks AI"
},
{
"value": "together",
"label": "Together"
},
{
"value": "qwen",
"label": "Alibaba Qwen"
},
{
"value": "qwen-code",
"label": "Qwen Code"
},
{
"value": "doubao",
"label": "Bytedance Doubao"
},
{
"value": "lmstudio",
"label": "LM Studio"
},
{
"value": "moonshot",
"label": "Moonshot"
},
{
"value": "huggingface",
"label": "Hugging Face"
},
{
"value": "nebius",
"label": "Nebius AI Studio"
},
{
"value": "asksage",
"label": "AskSage"
},
{
"value": "xai",
"label": "xAI"
},
{
"value": "sambanova",
"label": "SambaNova"
},
{
"value": "huawei-cloud-maas",
"label": "Huawei Cloud MaaS"
},
{
"value": "dify",
"label": "Dify.ai"
},
{
"value": "oca",
"label": "Oracle Code Assist"
},
{
"value": "minimax",
"label": "MiniMax"
},
{
"value": "hicap",
"label": "Hicap"
},
{
"value": "aihubmix",
"label": "AIhubmix"
},
{
"value": "nousResearch",
"label": "NousResearch"
},
{
"value": "wandb",
"label": "W&B Inference by CoreWeave"
},
{
"value": "xiaomi",
"label": "Xiaomi"
}
]
}
@@ -10,23 +10,6 @@ export interface StartSessionResult {
sessionId: string
}
export const MAX_COMMAND_OUTPUT_CHARS = 200_000
export function truncateCommandOutput(output: string): string {
return output
}
export function createShellExecutor() {
return async () => ""
}
export function createShellTool(execute: unknown) {
return {
name: "run_commands",
execute,
}
}
export interface SessionHistoryRecord {
id: string
metadata?: Record<string, unknown>
-1
View File
@@ -11,7 +11,6 @@ export default defineConfig({
"src/shared/vsCodeSelectorUtils.test.ts",
"src/shared/proto-conversions/models/**/*.test.ts",
"src/core/storage/remote-config/**/*.test.ts",
"src/core/controller/state/**/*.test.ts",
"src/core/controller/slash/**/*.test.ts",
"src/services/mcp/__tests__/settingsLock.test.ts",
"src/shared/model-catalog/provider-helpers.test.ts",
@@ -67,7 +67,7 @@ const HEADER_CLASSNAMES = "flex items-center gap-2.5 mb-3"
interface ChatRowProps {
message: ClineMessage
isExpanded: boolean
onToggleExpand: (ts: number, options?: { preserveAutoScroll?: boolean }) => void
onToggleExpand: (ts: number) => void
lastModifiedMessage?: ClineMessage
isLast: boolean
onHeightChange: (isTaller: boolean) => void
@@ -729,7 +729,7 @@ export const ChatRowContent = memo(
// Wait 500ms before auto-expanding to avoid animating fast commands
const timer = setTimeout(() => {
// Expand after 500ms
onToggleExpand(message.ts, { preserveAutoScroll: true })
onToggleExpand(message.ts)
}, 500)
return () => clearTimeout(timer)
@@ -747,7 +747,7 @@ export const ChatRowContent = memo(
isOutputFullyExpanded={isOutputFullyExpanded}
message={message}
onCancelCommand={onCancelCommand}
onOutputChange={onLastRowContentChange}
onOutputChange={isLast ? onLastRowContentChange : undefined}
setIsOutputFullyExpanded={setIsOutputFullyExpanded}
title={title}
/>
@@ -220,7 +220,7 @@ export const ClinePassEntitlementError: Story = {
message: createMockMessage(),
errorType: "error",
apiRequestFailedMessage:
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-100&personal=true",
},
parameters: {
docs: {
@@ -171,7 +171,7 @@ describe("ErrorRow", () => {
it("renders entitlement error when ClineError detects ClineNotSubscribedError", async () => {
const cliMessage =
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-8OFF&personal=true"
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/promo?code=CLI-100&personal=true"
const mockClineError = {
message: cliMessage,
isErrorType: vi.fn((type) => type === "entitlement"),
@@ -28,23 +28,6 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
setIsEditing(true)
}
const cancelEditing = () => {
if (savingMode) {
return
}
setIsEditing(false)
}
const handleEditingKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== "Escape") {
return
}
event.preventDefault()
event.stopPropagation()
cancelEditing()
}
const handleSave = async (restoreWorkspace: boolean) => {
if (!messageTs || savingMode) {
return
@@ -112,7 +95,7 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
</Tooltip>
)}
{isEditing ? (
<div className="flex flex-col gap-2" onKeyDown={handleEditingKeyDown}>
<div className="flex flex-col gap-2">
<textarea
className="w-full box-border rounded-xs border border-vscode-input-border bg-vscode-input-background text-vscode-input-foreground p-2 text-sm resize-vertical"
disabled={!!savingMode}
@@ -125,13 +108,15 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
<button
className="shrink-0 whitespace-nowrap px-1 py-1 rounded-xs border-0 bg-transparent text-badge-foreground/80 hover:text-badge-foreground cursor-pointer text-xs"
disabled={!!savingMode}
onClick={cancelEditing}
onClick={() => setIsEditing(false)}
type="button">
Cancel
</button>
<div className="flex items-center gap-1.5">
<Tooltip>
<TooltipContent side="top">Rewind conversation, keep current code edits</TooltipContent>
<TooltipContent side="top">
Regenerate from this edited message without changing files.
</TooltipContent>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<button
@@ -139,14 +124,16 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
disabled={!!savingMode}
onClick={() => handleSave(false)}
type="button">
{savingMode === "chat" ? "Running..." : "Reset Chat"}
{savingMode === "chat" ? "Running..." : "Regenerate"}
</button>
</span>
</TooltipTrigger>
</Tooltip>
{canRestoreWorkspace && (
<Tooltip>
<TooltipContent side="top">Rewind conversation, reset code edits</TooltipContent>
<TooltipContent side="top">
Restore workspace files to this checkpoint, then regenerate.
</TooltipContent>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<button
@@ -154,7 +141,7 @@ const UserMessage: React.FC<UserMessageProps> = ({ text, images, files, messageT
disabled={!!savingMode}
onClick={() => handleSave(true)}
type="button">
{savingMode === "workspace" ? "Restoring..." : "Reset Code"}
{savingMode === "workspace" ? "Restoring..." : "Restore + Run"}
</button>
</span>
</TooltipTrigger>
@@ -5,9 +5,8 @@
* even if you confirm the IME conversion (Enter) in message re-edit mode.
*/
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { fireEvent, render } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest"
vi.mock("@/context/ExtensionStateContext", () => ({
__esModule: true,
@@ -17,29 +16,9 @@ vi.mock("@/context/ExtensionStateContext", () => ({
}),
}))
vi.mock("@/services/grpc-client", () => ({
TaskServiceClient: {
editMessageAndRegenerate: vi.fn(),
},
}))
import { TaskServiceClient } from "@/services/grpc-client"
import UserMessage from "../UserMessage"
describe("UserMessage IME composition handling", () => {
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal(
"ResizeObserver",
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
)
vi.mocked(TaskServiceClient.editMessageAndRegenerate).mockResolvedValue({})
})
it("does NOT send when IME composition Enter is pressed while editing", () => {
const sendMessageFromChatRow = vi.fn()
@@ -61,61 +40,4 @@ describe("UserMessage IME composition handling", () => {
expect(sendMessageFromChatRow).not.toHaveBeenCalled()
})
it("cancels inline editing on Escape without bubbling to global task shortcuts", () => {
const onWindowKeyDown = vi.fn()
window.addEventListener("keydown", onWindowKeyDown)
try {
render(<UserMessage images={[]} messageTs={Date.now()} text="Original prompt" />)
fireEvent.click(screen.getByText("Original prompt"))
const textbox = screen.getByRole("textbox")
fireEvent.change(textbox, { target: { value: "Edited prompt" } })
fireEvent.keyDown(textbox, { key: "Escape" })
expect(screen.queryByRole("textbox")).not.toBeInTheDocument()
expect(screen.getByText("Original prompt")).toBeInTheDocument()
expect(onWindowKeyDown).not.toHaveBeenCalled()
} finally {
window.removeEventListener("keydown", onWindowKeyDown)
}
})
it("labels reset actions and preserves their restore behavior", async () => {
const user = userEvent.setup()
render(<UserMessage files={["src/app.ts"]} images={["image.png"]} messageTs={123} text="Update this" />)
await user.click(screen.getByText("Update this"))
expect(screen.getByRole("button", { name: "Reset Chat" })).toBeInTheDocument()
expect(screen.getByRole("button", { name: "Reset Code" })).toBeInTheDocument()
await user.click(screen.getByRole("button", { name: "Reset Chat" }))
await waitFor(() => expect(TaskServiceClient.editMessageAndRegenerate).toHaveBeenCalledTimes(1))
expect(TaskServiceClient.editMessageAndRegenerate).toHaveBeenLastCalledWith(
expect.objectContaining({
messageTs: 123,
text: "Update this",
images: ["image.png"],
files: ["src/app.ts"],
restoreWorkspace: false,
}),
)
await user.click(screen.getByText("Update this"))
await user.click(screen.getByRole("button", { name: "Reset Code" }))
await waitFor(() => expect(TaskServiceClient.editMessageAndRegenerate).toHaveBeenCalledTimes(2))
expect(TaskServiceClient.editMessageAndRegenerate).toHaveBeenLastCalledWith(
expect.objectContaining({
messageTs: 123,
text: "Update this",
images: ["image.png"],
files: ["src/app.ts"],
restoreWorkspace: true,
}),
)
})
})
@@ -75,28 +75,6 @@ describe("InputSection", () => {
expect(handleSendMessage).toHaveBeenCalledWith("queue this", [], [])
})
it("allows submit while approval is pending so typed feedback can reject the approval", () => {
mockTurnState.mockReturnValue({ phase: "awaiting_approval", seq: 1 })
const handleSendMessage = vi.fn().mockResolvedValue(undefined)
render(
<InputSection
chatState={makeChatState({ sendingDisabled: true })}
messageHandlers={{ handleSendMessage } as unknown as MessageHandlers}
placeholderText="Type a message"
scrollBehavior={makeScrollBehavior()}
selectFilesAndImages={vi.fn()}
shouldDisableFilesAndImages={false}
/>,
)
const composer = screen.getByLabelText("composer")
expect(composer).not.toBeDisabled()
fireEvent.keyDown(composer, { key: "Enter" })
expect(handleSendMessage).toHaveBeenCalledWith("queue this", [], [])
})
it("allows submit for legacy active-task state when turnState is unavailable", () => {
mockTurnState.mockReturnValue(undefined)
const handleSendMessage = vi.fn().mockResolvedValue(undefined)
@@ -1,59 +0,0 @@
import type { QueuedPrompt } from "@shared/ExtensionMessage"
import { fireEvent, render, screen, waitFor } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import { QueuedPrompts } from "./QueuedPrompts"
const cancelQueuedPromptMock = vi.hoisted(() => vi.fn())
vi.mock("@/services/grpc-client", () => ({
TaskServiceClient: {
cancelQueuedPrompt: (request: unknown) => cancelQueuedPromptMock(request),
},
}))
vi.mock("@shared/proto/cline/common", () => ({
StringRequest: {
create: (request: unknown) => request,
},
}))
const queuedPrompts: QueuedPrompt[] = [
{
id: "prompt-1",
prompt: "First queued message",
delivery: "queue",
attachmentCount: 0,
},
{
id: "prompt-2",
prompt: "Second queued message",
delivery: "steer",
attachmentCount: 1,
},
]
describe("QueuedPrompts", () => {
beforeEach(() => {
cancelQueuedPromptMock.mockReset()
cancelQueuedPromptMock.mockResolvedValue({})
})
it("cancels a queued prompt from the row action", async () => {
render(<QueuedPrompts items={queuedPrompts} />)
const cancelButtons = screen.getAllByRole("button", { name: "Cancel queued message" })
fireEvent.click(cancelButtons[0])
expect(cancelQueuedPromptMock).toHaveBeenCalledTimes(1)
expect(cancelQueuedPromptMock).toHaveBeenCalledWith({ value: "prompt-1" })
expect(cancelButtons[0]).toBeDisabled()
await waitFor(() => expect(cancelButtons[0]).not.toBeDisabled())
})
it("does not render an empty queue", () => {
const { container } = render(<QueuedPrompts items={[]} />)
expect(container).toBeEmptyDOMElement()
})
})
@@ -1,7 +1,4 @@
import type { QueuedPrompt } from "@shared/ExtensionMessage"
import { StringRequest } from "@shared/proto/cline/common"
import { useState } from "react"
import { TaskServiceClient } from "@/services/grpc-client"
function truncatePrompt(prompt: string): string {
const trimmed = prompt.trim()
@@ -32,27 +29,10 @@ interface QueuedPromptsProps {
}
export function QueuedPrompts({ items = [] }: QueuedPromptsProps) {
const [cancellingIds, setCancellingIds] = useState<Set<string>>(() => new Set())
if (items.length === 0) {
return null
}
const cancelQueuedPrompt = (promptId: string) => {
setCancellingIds((current) => new Set(current).add(promptId))
TaskServiceClient.cancelQueuedPrompt(StringRequest.create({ value: promptId }))
.catch((error) => {
console.error("Failed to cancel queued prompt:", error)
})
.finally(() => {
setCancellingIds((current) => {
const next = new Set(current)
next.delete(promptId)
return next
})
})
}
return (
<div className="mx-3 mt-2.5 mb-2.5 rounded-xs border border-editor-group-border bg-code/70 px-2.5 py-2 shadow-xs">
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-description">
@@ -63,7 +43,6 @@ export function QueuedPrompts({ items = [] }: QueuedPromptsProps) {
{items.map((item) => {
const attachments = attachmentLabel(item.attachmentCount)
const isSteer = item.delivery === "steer"
const isCancelling = cancellingIds.has(item.id)
return (
<div
className="flex items-start gap-2 rounded-[3px] bg-input-background/40 px-2 py-1.5 text-xs leading-snug"
@@ -80,15 +59,6 @@ export function QueuedPrompts({ items = [] }: QueuedPromptsProps) {
{attachments}
</span>
)}
<button
aria-label="Cancel queued message"
className="mt-[-2px] flex size-5 shrink-0 items-center justify-center rounded-[3px] text-description hover:bg-toolbar-hover-background hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
disabled={isCancelling}
onClick={() => cancelQueuedPrompt(item.id)}
title="Cancel queued message"
type="button">
<span aria-hidden="true" className="codicon codicon-close text-[12px]" />
</button>
</div>
)
})}
@@ -15,7 +15,7 @@ interface MessageRendererProps {
groupedMessages: (ClineMessage | ClineMessage[])[]
modifiedMessages: ClineMessage[]
expandedRows: Record<number, boolean>
onToggleExpand: (ts: number, options?: { preserveAutoScroll?: boolean }) => void
onToggleExpand: (ts: number) => void
onHeightChange: (isTaller: boolean) => void
onLastRowContentChange: () => void
onSetQuote: (quote: string | null) => void
@@ -136,7 +136,7 @@ export const createMessageRenderer = (
groupedMessages: (ClineMessage | ClineMessage[])[],
modifiedMessages: ClineMessage[],
expandedRows: Record<number, boolean>,
onToggleExpand: (ts: number, options?: { preserveAutoScroll?: boolean }) => void,
onToggleExpand: (ts: number) => void,
onHeightChange: (isTaller: boolean) => void,
onLastRowContentChange: () => void,
onSetQuote: (quote: string | null) => void,
@@ -300,36 +300,6 @@ describe("useMessageHandlers — send routing", () => {
expect(setPendingUserMessage).not.toHaveBeenCalled()
})
it("rejects a pending approval when the composer is submitted with typed feedback", async () => {
mockTurnState = { phase: "awaiting_approval", anchorTs: 2, seq: 9 }
const approvalConversation: ClineMessage[] = [
{ ts: 1, type: "say", say: "task", text: "task" },
{ ts: 2, type: "ask", ask: "tool", text: JSON.stringify({ tool: "newFileCreated", path: "notes.txt" }) },
]
const setPendingUserMessage = vi.fn()
const { result } = renderHook(() =>
useMessageHandlers(approvalConversation, makeChatState(approvalConversation, { setPendingUserMessage })),
)
await act(async () => {
await result.current.handleSendMessage("use a different filename", ["image.png"], ["notes.txt"])
})
expect(newTask).not.toHaveBeenCalled()
expect(condense).not.toHaveBeenCalled()
expect(askResponse).toHaveBeenCalledTimes(1)
expect(askResponse).toHaveBeenCalledWith(
expect.objectContaining({
responseType: "noButtonClicked",
text: "use a different filename",
images: ["image.png"],
files: ["notes.txt"],
}),
)
expect(askResponse).not.toHaveBeenCalledWith(expect.objectContaining({ responseType: "messageResponse" }))
expect(setPendingUserMessage).not.toHaveBeenCalled()
})
it("phase awaiting_followup also routes a follow-up to askResponse", async () => {
mockTurnState = { phase: "awaiting_followup", seq: 3 }
const { result } = renderHook(() => useMessageHandlers(completedConversation, makeChatState(completedConversation)))
@@ -125,16 +125,6 @@ export function useMessageHandlers(messages: ClineMessage[], chatState: ChatStat
throw error
}
messageSent = true
} else if (turnState?.phase === "awaiting_approval") {
await sendAskResponseWithPendingState(
AskResponseRequest.create({
responseType: "noButtonClicked",
text: messageToSend,
images,
files,
}),
)
messageSent = true
} else if (clineAsk) {
// For resume_task and resume_completed_task, use yesButtonClicked to match Resume button behavior
// This ensures Enter key and Resume button work identically
@@ -1,110 +0,0 @@
import { act, renderHook } from "@testing-library/react"
import type { MutableRefObject } from "react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { useScrollBehavior } from "./useScrollBehavior"
const commandMessage = {
ts: 1,
type: "ask",
ask: "command",
text: "echo hi",
}
describe("useScrollBehavior", () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it("scrolls to bottom after command output layout has been quiet for 500ms", () => {
const { result } = renderHook(() => useScrollBehavior([], [], [], {}, vi.fn()))
const scrollTo = vi.fn()
act(() => {
vi.runOnlyPendingTimers()
})
;(result.current.virtuosoRef as MutableRefObject<{ scrollTo: typeof scrollTo } | null>).current = { scrollTo }
act(() => {
result.current.handleLastRowContentChange()
})
expect(scrollTo).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(499)
})
expect(scrollTo).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(1)
})
expect(scrollTo).toHaveBeenCalledWith({
top: Number.MAX_SAFE_INTEGER,
behavior: "smooth",
})
})
it("resets the 500ms wait when another command output change arrives", () => {
const { result } = renderHook(() => useScrollBehavior([], [], [], {}, vi.fn()))
const scrollTo = vi.fn()
act(() => {
vi.runOnlyPendingTimers()
})
;(result.current.virtuosoRef as MutableRefObject<{ scrollTo: typeof scrollTo } | null>).current = { scrollTo }
act(() => {
result.current.handleLastRowContentChange()
scrollTo.mockClear()
vi.advanceTimersByTime(400)
result.current.handleLastRowContentChange()
scrollTo.mockClear()
vi.advanceTimersByTime(499)
})
expect(scrollTo).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(1)
})
expect(scrollTo).toHaveBeenCalledWith({
top: Number.MAX_SAFE_INTEGER,
behavior: "smooth",
})
})
it("does not re-pin command output changes after auto-scroll is disabled", () => {
const { result } = renderHook(() => useScrollBehavior([], [], [], {}, vi.fn()))
const scrollTo = vi.fn()
;(result.current.virtuosoRef as MutableRefObject<{ scrollTo: typeof scrollTo } | null>).current = { scrollTo }
act(() => {
result.current.disableAutoScrollRef.current = true
result.current.handleLastRowContentChange()
vi.runAllTimers()
})
expect(scrollTo).not.toHaveBeenCalled()
})
it("disables auto-scroll when a user expands a row", () => {
const { result } = renderHook(() => useScrollBehavior([], [], [commandMessage as any], {}, vi.fn()))
act(() => {
result.current.toggleRowExpansion(commandMessage.ts)
})
expect(result.current.disableAutoScrollRef.current).toBe(true)
})
it("keeps auto-scroll enabled when command output expands programmatically", () => {
const { result } = renderHook(() => useScrollBehavior([], [], [commandMessage as any], {}, vi.fn()))
act(() => {
result.current.toggleRowExpansion(commandMessage.ts, { preserveAutoScroll: true })
})
expect(result.current.disableAutoScrollRef.current).toBe(false)
})
})
@@ -30,7 +30,7 @@ export function useScrollBehavior(
const virtuosoRef = useRef<VirtuosoHandle>(null)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const disableAutoScrollRef = useRef(false)
const layoutSettleScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const lastRowContentScrollTimersRef = useRef<ReturnType<typeof setTimeout>[]>([])
// State
const [isAtBottom, setIsAtBottom] = useState(false)
@@ -214,7 +214,7 @@ export function useScrollBehavior(
// scroll when user toggles certain rows
const toggleRowExpansion = useCallback(
(ts: number, options?: { preserveAutoScroll?: boolean }) => {
(ts: number) => {
const isCollapsing = expandedRows[ts] ?? false
const lastGroup = groupedMessages.at(-1)
const isLast = Array.isArray(lastGroup) ? lastGroup[0].ts === ts : lastGroup?.ts === ts
@@ -234,9 +234,8 @@ export function useScrollBehavior(
[ts]: !prev[ts],
}))
// Disable auto-scroll when the user expands a row. Programmatic expansions
// for active command output should keep bottom pinning engaged.
if (!isCollapsing && !options?.preserveAutoScroll) {
// disable auto scroll when user expands row
if (!isCollapsing) {
disableAutoScrollRef.current = true
}
// Only scroll on collapse, never on expand - expanding should stay in place
@@ -260,41 +259,43 @@ export function useScrollBehavior(
[groupedMessages, expandedRows, scrollToBottomAuto, isAtBottom],
)
const clearLayoutSettleScrollTimers = useCallback(() => {
if (layoutSettleScrollTimerRef.current !== null) {
clearTimeout(layoutSettleScrollTimerRef.current)
layoutSettleScrollTimerRef.current = null
}
const handleRowHeightChange = useCallback(
(isTaller: boolean) => {
if (!disableAutoScrollRef.current) {
if (isTaller) {
scrollToBottomSmooth()
} else {
setTimeout(() => {
scrollToBottomAuto()
}, 0)
}
}
},
[scrollToBottomSmooth, scrollToBottomAuto],
)
const clearLastRowContentScrollTimers = useCallback(() => {
lastRowContentScrollTimersRef.current.forEach((timer) => clearTimeout(timer))
lastRowContentScrollTimersRef.current = []
}, [])
const keepPinnedToBottomAfterLayout = useCallback(() => {
const handleLastRowContentChange = useCallback(() => {
if (disableAutoScrollRef.current) {
return
}
if (layoutSettleScrollTimerRef.current !== null) {
clearTimeout(layoutSettleScrollTimerRef.current)
}
layoutSettleScrollTimerRef.current = setTimeout(() => {
if (!disableAutoScrollRef.current) {
scrollToBottomSmooth()
}
layoutSettleScrollTimerRef.current = null
}, 500)
}, [scrollToBottomSmooth])
clearLastRowContentScrollTimers()
scrollToBottomSmooth()
lastRowContentScrollTimersRef.current = [0, 50].map((delay) =>
setTimeout(() => {
if (!disableAutoScrollRef.current) {
scrollToBottomAuto()
}
}, delay),
)
}, [clearLastRowContentScrollTimers, scrollToBottomSmooth, scrollToBottomAuto])
const handleRowHeightChange = useCallback(
(_isTaller: boolean) => {
keepPinnedToBottomAfterLayout()
},
[keepPinnedToBottomAfterLayout],
)
const handleLastRowContentChange = useCallback(() => {
keepPinnedToBottomAfterLayout()
}, [keepPinnedToBottomAfterLayout])
useEffect(() => clearLayoutSettleScrollTimers, [clearLayoutSettleScrollTimers])
useEffect(() => clearLastRowContentScrollTimers, [clearLastRowContentScrollTimers])
useEffect(() => {
if (!disableAutoScrollRef.current) {
@@ -78,7 +78,7 @@ export interface ScrollBehavior {
scrollToBottomSmooth: () => void
scrollToBottomAuto: () => void
scrollToMessage: (messageIndex: number) => void
toggleRowExpansion: (ts: number, options?: { preserveAutoScroll?: boolean }) => void
toggleRowExpansion: (ts: number) => void
handleRowHeightChange: (isTaller: boolean) => void
handleLastRowContentChange: () => void
isAtBottom: boolean
@@ -4,11 +4,11 @@ import {
type MarketplaceEntry,
MarketplaceEntryRequest,
type MarketplaceLocalInstalledEntry,
MarketplaceLocalInstalledEntryRequest,
ToggleMarketplaceLocalInstalledEntryRequest,
} from "@shared/proto/cline/marketplace"
import { VSCodeButton, VSCodeLink, VSCodeProgressRing, VSCodeTextField } from "@vscode/webview-ui-toolkit/react"
import {
BlocksIcon,
CheckIcon,
DownloadIcon,
LoaderCircleIcon,
@@ -16,7 +16,6 @@ import {
PlugIcon,
PuzzleIcon,
SparklesIcon,
Trash2Icon,
} from "lucide-react"
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"
import { Switch } from "@/components/ui/switch"
@@ -25,11 +24,9 @@ import { MarketplaceServiceClient, McpServiceClient } from "@/services/grpc-clie
import { Tab, TabContent, TabList, TabTrigger } from "../common/Tab"
import ViewHeader from "../common/ViewHeader"
import AddRemoteServerForm from "../mcp/configuration/tabs/add-server/AddRemoteServerForm"
import ServersToggleList, { type MarketplaceMcpMetadata } from "../mcp/configuration/tabs/installed/ServersToggleList"
import { entryMatchesLocalEntry, localEntryKey } from "./marketplaceMatch"
import ServersToggleList from "../mcp/configuration/tabs/installed/ServersToggleList"
type PrimitiveType = "mcp" | "skill" | "plugin"
type MarketplaceSectionType = "installed" | "marketplace"
type MarketplaceViewProps = {
initialType?: PrimitiveType
@@ -92,11 +89,6 @@ const PRIMITIVES: PrimitiveConfig[] = [
},
]
const MARKETPLACE_SECTIONS: Array<{ type: MarketplaceSectionType; label: string }> = [
{ type: "installed", label: "Installed" },
{ type: "marketplace", label: "Marketplace" },
]
function isPrimitiveType(value: string): value is PrimitiveType {
return value === "mcp" || value === "skill" || value === "plugin"
}
@@ -180,40 +172,34 @@ const MarketplaceStyles = () => (
.marketplace-shell {
min-height: 0;
display: flex;
flex-direction: column;
flex: 1;
}
.marketplace-nav {
flex: 0 0 auto;
display: flex;
align-items: center;
min-width: 0;
overflow-x: auto;
overflow-y: hidden;
border-bottom: 1px solid var(--vscode-panel-border);
width: 148px;
flex: 0 0 148px;
overflow-y: auto;
border-right: 1px solid var(--vscode-panel-border);
background: var(--vscode-sideBar-background);
padding: 0 8px;
padding: 4px 0;
}
.marketplace-tab {
width: auto;
flex: 0 1 auto;
min-width: fit-content;
width: 100%;
height: 34px;
border: 0;
border-bottom: 2px solid transparent;
border-left: 2px solid transparent;
background: transparent;
color: var(--vscode-descriptionForeground);
font: inherit;
font-size: var(--vscode-font-size);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 0 12px;
text-align: center;
padding: 0 10px;
text-align: left;
cursor: pointer;
min-width: 0;
}
.marketplace-tab:hover {
@@ -224,7 +210,7 @@ const MarketplaceStyles = () => (
.marketplace-tab[aria-selected="true"] {
background: var(--vscode-list-activeSelectionBackground);
color: var(--vscode-list-activeSelectionForeground);
border-bottom-color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground));
border-left-color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground));
}
.marketplace-tab-label {
@@ -269,47 +255,6 @@ const MarketplaceStyles = () => (
word-break: break-word;
}
.marketplace-subnav {
display: flex;
align-items: center;
gap: 0;
min-width: 0;
margin: 0 0 12px;
border-bottom: 1px solid var(--vscode-panel-border);
}
.marketplace-subtab {
height: 30px;
border: 0;
border-bottom: 2px solid transparent;
background: transparent;
color: var(--vscode-descriptionForeground);
font: inherit;
font-size: calc(var(--vscode-font-size) * 0.92);
padding: 0 10px;
cursor: pointer;
}
.marketplace-subtab:hover {
background: var(--vscode-list-hoverBackground);
color: var(--vscode-foreground);
}
.marketplace-subtab:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.marketplace-subtab:disabled:hover {
background: transparent;
color: var(--vscode-descriptionForeground);
}
.marketplace-subtab[aria-selected="true"] {
color: var(--vscode-foreground);
border-bottom-color: var(--vscode-focusBorder);
}
.marketplace-section {
margin-bottom: 20px;
}
@@ -343,7 +288,7 @@ const MarketplaceStyles = () => (
.marketplace-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
grid-template-columns: minmax(0, 1fr) 26px;
gap: 10px;
align-items: start;
min-height: 42px;
@@ -436,9 +381,7 @@ const MarketplaceStyles = () => (
.marketplace-action {
display: flex;
gap: 6px;
justify-content: flex-end;
align-items: center;
}
.marketplace-local-toggle {
@@ -477,10 +420,6 @@ const MarketplaceStyles = () => (
opacity: 0.65;
}
.marketplace-icon-button-danger {
color: var(--vscode-errorForeground, var(--vscode-icon-foreground));
}
.marketplace-icon-button svg {
width: 14px;
height: 14px;
@@ -573,14 +512,16 @@ const MarketplaceStyles = () => (
}
.marketplace-mcp-panel {
display: grid;
gap: 10px;
border: 1px solid var(--vscode-panel-border);
background: var(--vscode-sideBar-background);
padding: 10px;
}
.marketplace-mcp-managed {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
padding: 8px 10px;
border-left: 3px solid var(--vscode-textLink-foreground);
background: var(--vscode-textBlockQuote-background);
@@ -635,15 +576,32 @@ const MarketplaceStyles = () => (
}
@media (max-width: 520px) {
.marketplace-shell {
flex-direction: column;
}
.marketplace-nav {
width: auto;
flex: 0 0 auto;
display: flex;
overflow-x: auto;
overflow-y: hidden;
border-right: 0;
border-bottom: 1px solid var(--vscode-panel-border);
padding: 0 4px;
}
.marketplace-tab {
flex: 1 1 0;
min-width: 0;
gap: 5px;
padding: 0 6px;
width: auto;
flex: 0 0 auto;
border-left: 0;
border-bottom: 2px solid transparent;
padding: 0 10px;
}
.marketplace-tab[aria-selected="true"] {
border-bottom-color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground));
border-left-color: transparent;
}
.marketplace-inner {
@@ -667,21 +625,17 @@ const Section = ({
children,
count,
empty,
showHeader = true,
title,
}: {
children: React.ReactNode
count: number
empty: string
showHeader?: boolean
title: string
}) => (
<section className="marketplace-section">
{showHeader && (
<div className="marketplace-section-header">
<h3 className="marketplace-section-title">{title}</h3>
</div>
)}
<div className="marketplace-section-header">
<h3 className="marketplace-section-title">{title}</h3>
</div>
{count > 0 ? <div className="marketplace-list">{children}</div> : <div className="marketplace-empty">{empty}</div>}
</section>
)
@@ -692,21 +646,17 @@ const MarketplaceCatalogSection = ({
empty,
filters,
search,
showHeader = true,
}: {
children: React.ReactNode
count: number
empty: string
filters: React.ReactNode
search: React.ReactNode
showHeader?: boolean
}) => (
<section className="marketplace-section">
{showHeader && (
<div className="marketplace-section-header">
<h3 className="marketplace-section-title">Marketplace</h3>
</div>
)}
<div className="marketplace-section-header">
<h3 className="marketplace-section-title">Marketplace</h3>
</div>
{search}
{filters}
{count > 0 ? <div className="marketplace-list">{children}</div> : <div className="marketplace-empty">{empty}</div>}
@@ -746,15 +696,7 @@ const TagFilters = ({
)
}
const McpManagementPanel = ({
marketplaceMetadataByServerName,
showHeader = true,
showServerList = true,
}: {
marketplaceMetadataByServerName?: Map<string, MarketplaceMcpMetadata>
showHeader?: boolean
showServerList?: boolean
}) => {
const McpManagementPanel = () => {
const { mcpServers, navigateToSettings, remoteConfigSettings } = useExtensionState()
const [showAddRemote, setShowAddRemote] = useState(false)
const showRemoteServers = remoteConfigSettings?.blockPersonalRemoteMCPServers !== true
@@ -762,30 +704,18 @@ const McpManagementPanel = ({
return (
<section className="marketplace-section">
{showHeader && (
<div className="marketplace-section-header">
<h3 className="marketplace-section-title">Installed MCP Servers</h3>
</div>
)}
{(showServerList || hasRemoteMCPServers) && (
<div className="marketplace-mcp-panel">
{hasRemoteMCPServers && (
<div className="marketplace-mcp-managed">
<span className="codicon codicon-lock" />
<span>Your organization manages some MCP servers</span>
</div>
)}
{showServerList && (
<ServersToggleList
hasTrashIcon={true}
isExpandable={true}
listGap="small"
marketplaceMetadataByServerName={marketplaceMetadataByServerName}
servers={mcpServers}
/>
)}
</div>
)}
<div className="marketplace-section-header">
<h3 className="marketplace-section-title">Installed MCP Servers</h3>
</div>
<div className="marketplace-mcp-panel">
{hasRemoteMCPServers && (
<div className="marketplace-mcp-managed">
<span className="codicon codicon-lock" />
<span>Your organization manages some MCP servers</span>
</div>
)}
<ServersToggleList hasTrashIcon={false} isExpandable={true} listGap="small" servers={mcpServers} />
</div>
<div className="marketplace-mcp-settings">
{showRemoteServers && !showAddRemote && (
<VSCodeButton appearance="primary" onClick={() => setShowAddRemote(true)}>
@@ -822,19 +752,14 @@ const McpManagementPanel = ({
const LocalInstalledRow = ({
entry,
onUninstall,
onToggle,
toggling,
uninstalling,
}: {
entry: MarketplaceLocalInstalledEntry
onUninstall: (entry: MarketplaceLocalInstalledEntry) => void
onToggle: (entry: MarketplaceLocalInstalledEntry, enabled: boolean) => void
toggling: boolean
uninstalling: boolean
}) => {
const origin = sourceLabel(entry)
const canUninstall = !(entry.type === "skill" && entry.path?.startsWith("remote:"))
return (
<div className="marketplace-row">
<div className="marketplace-row-main">
@@ -847,7 +772,7 @@ const LocalInstalledRow = ({
{entry.path && <span className="marketplace-path">{entry.path}</span>}
</div>
</div>
<div className="marketplace-action">
<div className="marketplace-local-toggle">
<Switch
aria-label={`${entry.enabled ? "Disable" : "Enable"} ${entry.name || entry.id}`}
checked={entry.enabled}
@@ -855,89 +780,6 @@ const LocalInstalledRow = ({
onClick={() => onToggle(entry, !entry.enabled)}
title={`${entry.enabled ? "Disable" : "Enable"} ${entry.name || entry.id}`}
/>
<button
aria-label={`Uninstall ${entry.name || entry.id}`}
className="marketplace-icon-button marketplace-icon-button-danger"
disabled={uninstalling || !canUninstall}
onClick={() => onUninstall(entry)}
title={
canUninstall ? `Uninstall ${entry.name || entry.id}` : "Remote-managed skills cannot be uninstalled here"
}
type="button">
{uninstalling ? (
<LoaderCircleIcon aria-hidden className="marketplace-icon-spin" />
) : (
<Trash2Icon aria-hidden />
)}
</button>
</div>
</div>
)
}
const InstalledMarketplaceRow = ({
entry,
matchedLocalEntries,
onToggle,
onUninstall,
togglingLocalId,
uninstalling,
}: {
entry: MarketplaceEntry
matchedLocalEntries: MarketplaceLocalInstalledEntry[]
onToggle: (entry: MarketplaceLocalInstalledEntry, enabled: boolean) => void
onUninstall: (entry: MarketplaceEntry) => void
togglingLocalId: string | null
uninstalling: boolean
}) => {
const primaryLocalEntry = matchedLocalEntries[0]
const label = `Uninstall ${entry.name || entry.id}`
return (
<div className="marketplace-row">
<div className="marketplace-row-main">
<div className="marketplace-row-title">
<CheckIcon aria-hidden className="h-3.5 w-3.5" />
<span className="marketplace-row-name">{entry.name || entry.id}</span>
</div>
{(entry.description || entry.tagline) && (
<div className="marketplace-row-description">{entry.description || entry.tagline}</div>
)}
<div className="marketplace-row-meta">
<span className="marketplace-pill">Marketplace</span>
{matchedLocalEntries.map((localEntry) => {
const origin = sourceLabel(localEntry)
return (
<span className="contents" key={localEntryKey(localEntry)}>
{origin && <span className="marketplace-pill">{origin}</span>}
{localEntry.path && <span className="marketplace-path">{localEntry.path}</span>}
</span>
)
})}
</div>
</div>
<div className="marketplace-action">
{primaryLocalEntry && (
<Switch
aria-label={`${primaryLocalEntry.enabled ? "Disable" : "Enable"} ${entry.name || entry.id}`}
checked={primaryLocalEntry.enabled}
disabled={togglingLocalId === localEntryKey(primaryLocalEntry)}
onClick={() => onToggle(primaryLocalEntry, !primaryLocalEntry.enabled)}
title={`${primaryLocalEntry.enabled ? "Disable" : "Enable"} ${entry.name || entry.id}`}
/>
)}
<button
aria-label={label}
className="marketplace-icon-button marketplace-icon-button-danger"
disabled={uninstalling}
onClick={() => onUninstall(entry)}
title={label}
type="button">
{uninstalling ? (
<LoaderCircleIcon aria-hidden className="marketplace-icon-spin" />
) : (
<Trash2Icon aria-hidden />
)}
</button>
</div>
</div>
)
@@ -945,20 +787,23 @@ const InstalledMarketplaceRow = ({
const CatalogEntryRow = ({
entry,
installed,
installing,
onInstall,
}: {
entry: MarketplaceEntry
installed: boolean
installing: boolean
onInstall: (entry: MarketplaceEntry) => void
}) => {
const summary = setupSummary(entry)
const canInstall = installArgs(entry).length > 0 && !installing
const label = `Install ${entry.name || entry.id}`
const canInstall = installArgs(entry).length > 0 && !installed && !installing
const label = installed ? `${entry.name || entry.id} is installed` : `Install ${entry.name || entry.id}`
return (
<div className="marketplace-row">
<div className="marketplace-row-main">
<div className="marketplace-row-title">
{installed && <CheckIcon aria-hidden className="h-3.5 w-3.5" />}
<span className="marketplace-row-name">{entry.name || entry.id}</span>
</div>
{(entry.description || entry.tagline) && (
@@ -979,6 +824,8 @@ const CatalogEntryRow = ({
type="button">
{installing ? (
<LoaderCircleIcon aria-hidden className="marketplace-icon-spin" />
) : installed ? (
<CheckIcon aria-hidden />
) : (
<DownloadIcon aria-hidden />
)}
@@ -989,15 +836,13 @@ const CatalogEntryRow = ({
}
const MarketplaceView = ({ initialType = "skill", onDone }: MarketplaceViewProps) => {
const { environment, remoteConfigSettings } = useExtensionState()
const { environment } = useExtensionState()
const [activeType, setActiveType] = useState<PrimitiveType>(initialType)
const [activeSection, setActiveSection] = useState<MarketplaceSectionType>("installed")
const [catalogEntries, setCatalogEntries] = useState<MarketplaceEntry[]>([])
const [localEntries, setLocalEntries] = useState<MarketplaceLocalInstalledEntry[]>([])
const [installedKeys, setInstalledKeys] = useState<Set<string>>(new Set())
const [installingId, setInstallingId] = useState<string | null>(null)
const [togglingLocalId, setTogglingLocalId] = useState<string | null>(null)
const [uninstallingId, setUninstallingId] = useState<string | null>(null)
const [query, setQuery] = useState("")
const [selectedTag, setSelectedTag] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
@@ -1033,18 +878,8 @@ const MarketplaceView = ({ initialType = "skill", onDone }: MarketplaceViewProps
setActiveType(initialType)
setQuery("")
setSelectedTag(null)
setActiveSection("installed")
}, [initialType])
const mcpMarketplaceDisabled = activeType === "mcp" && remoteConfigSettings?.mcpMarketplaceEnabled === false
const currentSection = mcpMarketplaceDisabled ? "installed" : activeSection
useEffect(() => {
if (mcpMarketplaceDisabled && activeSection === "marketplace") {
setActiveSection("installed")
}
}, [activeSection, mcpMarketplaceDisabled])
const primitive = getPrimitive(activeType)
const searchedCatalogEntries = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase()
@@ -1052,14 +887,10 @@ const MarketplaceView = ({ initialType = "skill", onDone }: MarketplaceViewProps
(entry) => entry.type === activeType && (!normalizedQuery || searchTextForEntry(entry).includes(normalizedQuery)),
)
}, [catalogEntries, activeType, query])
const marketplaceCatalogEntries = useMemo(
() => searchedCatalogEntries.filter((entry) => !installedKeys.has(entryKey(entry))),
[searchedCatalogEntries, installedKeys],
)
const tagFilters = useMemo(() => {
const labelsById = new Map<string, string>()
const counts = new Map<string, number>()
for (const entry of marketplaceCatalogEntries) {
for (const entry of searchedCatalogEntries) {
for (const label of entryTagLabels(entry)) {
const id = tagId(label)
if (!id) continue
@@ -1071,7 +902,7 @@ const MarketplaceView = ({ initialType = "skill", onDone }: MarketplaceViewProps
counts,
tags: [...labelsById.entries()].map(([id, label]) => ({ id, label })).sort((a, b) => a.label.localeCompare(b.label)),
}
}, [marketplaceCatalogEntries])
}, [searchedCatalogEntries])
useEffect(() => {
if (selectedTag && !tagFilters.counts.has(selectedTag)) {
setSelectedTag(null)
@@ -1080,59 +911,19 @@ const MarketplaceView = ({ initialType = "skill", onDone }: MarketplaceViewProps
const visibleCatalogEntries = useMemo(
() =>
selectedTag
? marketplaceCatalogEntries.filter((entry) => entryTagLabels(entry).some((label) => tagId(label) === selectedTag))
: marketplaceCatalogEntries,
[marketplaceCatalogEntries, selectedTag],
? searchedCatalogEntries.filter((entry) => entryTagLabels(entry).some((label) => tagId(label) === selectedTag))
: searchedCatalogEntries,
[searchedCatalogEntries, selectedTag],
)
const activeLocalEntries = useMemo(
const visibleLocalEntries = useMemo(
() => localEntries.filter((entry) => entry.type === activeType),
[localEntries, activeType],
)
const activeCatalogEntries = useMemo(
() => catalogEntries.filter((entry) => entry.type === activeType),
[catalogEntries, activeType],
const hasAnyCurrentPrimitiveEntries = useMemo(
() => catalogEntries.some((entry) => entry.type === activeType) || visibleLocalEntries.length > 0,
[catalogEntries, activeType, visibleLocalEntries.length],
)
const installedCatalogEntries = useMemo(
() => activeCatalogEntries.filter((entry) => installedKeys.has(entryKey(entry))),
[activeCatalogEntries, installedKeys],
)
const matchedLocalEntriesByCatalogKey = useMemo(() => {
const matched = new Map<string, MarketplaceLocalInstalledEntry[]>()
for (const entry of installedCatalogEntries) {
const matches = activeLocalEntries.filter((localEntry) => entryMatchesLocalEntry(entry, localEntry))
if (matches.length > 0) matched.set(entryKey(entry), matches)
}
return matched
}, [activeLocalEntries, installedCatalogEntries])
const matchedLocalEntryKeys = useMemo(() => {
const keys = new Set<string>()
for (const entries of matchedLocalEntriesByCatalogKey.values()) {
for (const entry of entries) {
keys.add(localEntryKey(entry))
}
}
return keys
}, [matchedLocalEntriesByCatalogKey])
const localOnlyInstalledEntries = useMemo(
() => activeLocalEntries.filter((entry) => !matchedLocalEntryKeys.has(localEntryKey(entry))),
[activeLocalEntries, matchedLocalEntryKeys],
)
const marketplaceMcpMetadataByServerName = useMemo(() => {
const metadata = new Map<string, MarketplaceMcpMetadata>()
for (const entry of installedCatalogEntries) {
if (entry.type !== "mcp") continue
const matchedLocalEntries = matchedLocalEntriesByCatalogKey.get(entryKey(entry)) ?? []
for (const localEntry of matchedLocalEntries) {
const serverName = localEntry.name || localEntry.id
if (!serverName) continue
metadata.set(serverName, {
name: entry.name || entry.id,
description: entry.description || entry.tagline || undefined,
})
}
}
return metadata
}, [installedCatalogEntries, matchedLocalEntriesByCatalogKey])
const handleInstall = useCallback(
async (entry: MarketplaceEntry) => {
setInstallingId(entryKey(entry))
@@ -1140,7 +931,6 @@ const MarketplaceView = ({ initialType = "skill", onDone }: MarketplaceViewProps
try {
await MarketplaceServiceClient.installMarketplaceEntry(MarketplaceEntryRequest.create({ entry }))
await refresh()
setActiveSection("installed")
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
@@ -1150,42 +940,8 @@ const MarketplaceView = ({ initialType = "skill", onDone }: MarketplaceViewProps
[refresh],
)
const handleUninstallMarketplace = useCallback(
async (entry: MarketplaceEntry) => {
setUninstallingId(entryKey(entry))
setError(null)
try {
await MarketplaceServiceClient.uninstallMarketplaceEntry(MarketplaceEntryRequest.create({ entry }))
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setUninstallingId(null)
}
},
[refresh],
)
const handleUninstallLocal = useCallback(
async (entry: MarketplaceLocalInstalledEntry) => {
setUninstallingId(localEntryKey(entry))
setError(null)
try {
await MarketplaceServiceClient.uninstallMarketplaceLocalInstalledEntry(
MarketplaceLocalInstalledEntryRequest.create({ entry }),
)
await refresh()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setUninstallingId(null)
}
},
[refresh],
)
const handleToggleLocal = useCallback(async (entry: MarketplaceLocalInstalledEntry, enabled: boolean) => {
const key = localEntryKey(entry)
const key = `${entry.type}:${entry.id}:${entry.path}`
setTogglingLocalId(key)
setError(null)
try {
@@ -1204,17 +960,8 @@ const MarketplaceView = ({ initialType = "skill", onDone }: MarketplaceViewProps
setActiveType(value as PrimitiveType)
setQuery("")
setSelectedTag(null)
setActiveSection("installed")
}, [])
const handleSectionTabChange = useCallback(
(value: string) => {
if (mcpMarketplaceDisabled && value === "marketplace") return
setActiveSection(value as MarketplaceSectionType)
},
[mcpMarketplaceDisabled],
)
return (
<Tab className="marketplace-view">
<MarketplaceStyles />
@@ -1232,22 +979,6 @@ const MarketplaceView = ({ initialType = "skill", onDone }: MarketplaceViewProps
<TabContent className="marketplace-content">
<div className="marketplace-inner">
<TabList
aria-label={`${primitive.title} sections`}
className="marketplace-subnav"
onValueChange={handleSectionTabChange}
value={currentSection}>
{MARKETPLACE_SECTIONS.map((section) => (
<TabTrigger
className="marketplace-subtab"
disabled={mcpMarketplaceDisabled && section.type === "marketplace"}
key={section.type}
value={section.type}>
{section.label}
</TabTrigger>
))}
</TabList>
<div className="marketplace-primitive-description">{primitive.description}</div>
{error && <div className="marketplace-error">{error}</div>}
@@ -1258,91 +989,75 @@ const MarketplaceView = ({ initialType = "skill", onDone }: MarketplaceViewProps
</div>
) : (
<>
{currentSection === "installed" &&
(activeType === "mcp" ? (
<McpManagementPanel
marketplaceMetadataByServerName={marketplaceMcpMetadataByServerName}
showHeader={false}
showServerList={true}
/>
) : (
<Section
count={installedCatalogEntries.length + localOnlyInstalledEntries.length}
empty={`No installed ${primitive.plural}.`}
showHeader={false}
title={`Installed ${primitive.title}`}>
{installedCatalogEntries.map((entry) => (
<InstalledMarketplaceRow
entry={entry}
key={entryKey(entry)}
matchedLocalEntries={
matchedLocalEntriesByCatalogKey.get(entryKey(entry)) ?? []
}
onToggle={handleToggleLocal}
onUninstall={handleUninstallMarketplace}
togglingLocalId={togglingLocalId}
uninstalling={uninstallingId === entryKey(entry)}
/>
))}
{localOnlyInstalledEntries.map((entry) => (
<LocalInstalledRow
entry={entry}
key={localEntryKey(entry)}
onToggle={handleToggleLocal}
onUninstall={handleUninstallLocal}
toggling={togglingLocalId === localEntryKey(entry)}
uninstalling={uninstallingId === localEntryKey(entry)}
/>
))}
</Section>
))}
{currentSection === "marketplace" && (
<MarketplaceCatalogSection
count={visibleCatalogEntries.length}
empty={
query || selectedTag
? `No ${primitive.plural} match your search.`
: `No marketplace ${primitive.plural}.`
}
filters={
<TagFilters
counts={tagFilters.counts}
onSelect={setSelectedTag}
selectedTag={selectedTag}
tags={tagFilters.tags}
/>
}
search={
<div className="marketplace-search">
<VSCodeTextField
aria-label={`Search ${primitive.title}`}
onInput={(event) => setQuery((event.target as HTMLInputElement).value)}
placeholder={`Search ${primitive.plural}`}
value={query}>
<span className="codicon codicon-search" slot="start" />
{query && (
<button
aria-label="Clear search"
className="codicon codicon-close marketplace-clear-search"
onClick={() => setQuery("")}
slot="end"
type="button"
/>
)}
</VSCodeTextField>
</div>
}
showHeader={false}>
{visibleCatalogEntries.map((entry) => (
<CatalogEntryRow
{activeType === "mcp" ? (
<McpManagementPanel />
) : (
<Section
count={visibleLocalEntries.length}
empty={`No installed ${primitive.plural}.`}
title={`Installed ${primitive.title}`}>
{visibleLocalEntries.map((entry) => (
<LocalInstalledRow
entry={entry}
installing={installingId === entryKey(entry)}
key={entryKey(entry)}
onInstall={handleInstall}
key={`${entry.type}:${entry.id}:${entry.path}`}
onToggle={handleToggleLocal}
toggling={togglingLocalId === `${entry.type}:${entry.id}:${entry.path}`}
/>
))}
</MarketplaceCatalogSection>
</Section>
)}
<MarketplaceCatalogSection
count={visibleCatalogEntries.length}
empty={
query || selectedTag
? `No ${primitive.plural} match your search.`
: `No marketplace ${primitive.plural}.`
}
filters={
<TagFilters
counts={tagFilters.counts}
onSelect={setSelectedTag}
selectedTag={selectedTag}
tags={tagFilters.tags}
/>
}
search={
<div className="marketplace-search">
<VSCodeTextField
aria-label={`Search ${primitive.title}`}
onInput={(event) => setQuery((event.target as HTMLInputElement).value)}
placeholder={`Search ${primitive.plural}`}
value={query}>
<span className="codicon codicon-search" slot="start" />
{query && (
<button
aria-label="Clear search"
className="codicon codicon-close marketplace-clear-search"
onClick={() => setQuery("")}
slot="end"
type="button"
/>
)}
</VSCodeTextField>
</div>
}>
{visibleCatalogEntries.map((entry) => (
<CatalogEntryRow
entry={entry}
installed={installedKeys.has(entryKey(entry))}
installing={installingId === entryKey(entry)}
key={entryKey(entry)}
onInstall={handleInstall}
/>
))}
</MarketplaceCatalogSection>
{!hasAnyCurrentPrimitiveEntries && (
<div className="marketplace-empty">
<BlocksIcon aria-hidden className="h-4 w-4" />
<span>No {primitive.plural} found.</span>
</div>
)}
</>
)}
@@ -1,53 +0,0 @@
import type { MarketplaceEntry, MarketplaceLocalInstalledEntry } from "@shared/proto/cline/marketplace"
import { describe, expect, it } from "vitest"
import { entryMatchesLocalEntry } from "./marketplaceMatch"
function skillEntry(input: Partial<MarketplaceEntry>): MarketplaceEntry {
return {
id: input.id ?? "",
type: "skill",
name: input.name ?? "",
install: input.install ?? { args: [] },
} as MarketplaceEntry
}
function localSkill(input: Partial<MarketplaceLocalInstalledEntry>): MarketplaceLocalInstalledEntry {
return {
id: input.id ?? "",
type: "skill",
name: input.name ?? "",
path: input.path ?? "",
enabled: true,
} as MarketplaceLocalInstalledEntry
}
describe("marketplace installed row matching", () => {
it("does not match unrelated installed skills through shared path segments", () => {
const reviewTeam = skillEntry({
id: "review-team",
name: "Review Team",
install: { args: ["owner/repo", "--skill", "review-team"] },
})
const installed = [
localSkill({
id: "review-team",
name: "review-team",
path: "/home/tester/.agents/skills/review-team/SKILL.md",
}),
localSkill({
id: "sentry-cli",
name: "sentry-cli",
path: "/home/tester/.agents/skills/sentry-cli/SKILL.md",
}),
localSkill({
id: "cline-sdk",
name: "cline-sdk",
path: "/home/tester/.agents/skills/cline-sdk/SKILL.md",
}),
]
expect(installed.filter((localEntry) => entryMatchesLocalEntry(reviewTeam, localEntry)).map((entry) => entry.id)).toEqual(
["review-team"],
)
})
})
@@ -1,105 +0,0 @@
import type { MarketplaceEntry, MarketplaceLocalInstalledEntry } from "@shared/proto/cline/marketplace"
function installArgs(entry: MarketplaceEntry): string[] {
return entry.install?.args ?? []
}
export function localEntryKey(entry: MarketplaceLocalInstalledEntry): string {
return `${entry.type}:${entry.id}:${entry.path}`
}
function normalizeMatchValue(value: string | undefined): string {
return (value ?? "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
function pathBaseName(value: string | undefined): string | undefined {
const segments = (value ?? "").split(/[\\/]/).filter(Boolean)
const last = segments.at(-1)
if (!last) return undefined
if (last.toLowerCase() === "skill.md" && segments.length > 1) {
return segments.at(-2)
}
return last.replace(/\.[^.]+$/, "")
}
function sourceBaseName(value: string | undefined): string | undefined {
const withoutFragment = value?.split("#")[0]?.split("?")[0]
return pathBaseName(withoutFragment)
}
function stripPluginInstallSuffix(value: string | undefined): string | undefined {
return value?.replace(/-[0-9a-f]{12}$/i, "")
}
function addMatchValue(values: Set<string>, value: string | undefined): void {
const normalized = normalizeMatchValue(value)
if (normalized && normalized !== "skill") {
values.add(normalized)
}
}
function entrySkillMatchValues(entry: MarketplaceEntry): Set<string> {
const values = new Set<string>()
addMatchValue(values, entry.id)
addMatchValue(values, entry.name)
const args = installArgs(entry)
for (let index = 0; index < args.length; index++) {
const arg = args[index]
if ((arg === "--skill" || arg === "-s") && args[index + 1]) {
addMatchValue(values, args[index + 1])
index++
continue
}
const skillFilter = arg.split("@").at(1)
if (skillFilter) {
addMatchValue(values, skillFilter)
continue
}
if (arg.includes("/") || arg.includes("\\")) {
addMatchValue(values, sourceBaseName(arg))
}
}
return values
}
function entryMatchValues(entry: MarketplaceEntry): Set<string> {
if (entry.type === "skill") return entrySkillMatchValues(entry)
const values = new Set<string>()
addMatchValue(values, entry.id)
addMatchValue(values, entry.name)
const [source] = installArgs(entry)
if (entry.type === "plugin") {
addMatchValue(values, source)
addMatchValue(values, stripPluginInstallSuffix(sourceBaseName(source)))
} else if (entry.type === "mcp") {
addMatchValue(values, source)
}
return values
}
function localEntryMatchValues(entry: MarketplaceLocalInstalledEntry): Set<string> {
const values = new Set<string>()
addMatchValue(values, entry.id)
addMatchValue(values, entry.name)
if (entry.type === "skill") {
addMatchValue(values, pathBaseName(entry.path))
} else if (entry.type === "plugin") {
addMatchValue(values, stripPluginInstallSuffix(pathBaseName(entry.path)))
}
return values
}
export function entryMatchesLocalEntry(entry: MarketplaceEntry, localEntry: MarketplaceLocalInstalledEntry): boolean {
if (entry.type !== localEntry.type) return false
const marketplaceValues = entryMatchValues(entry)
for (const localValue of localEntryMatchValues(localEntry)) {
if (marketplaceValues.has(localValue)) return true
}
return false
}
@@ -1,23 +1,16 @@
import { McpServer } from "@shared/mcp"
import ServerRow from "./server-row/ServerRow"
export type MarketplaceMcpMetadata = {
name: string
description?: string
}
const ServersToggleList = ({
servers,
isExpandable,
hasTrashIcon,
listGap = "medium",
marketplaceMetadataByServerName,
}: {
servers: McpServer[]
isExpandable: boolean
hasTrashIcon: boolean
listGap?: "small" | "medium" | "large"
marketplaceMetadataByServerName?: Map<string, MarketplaceMcpMetadata>
}) => {
const gapClasses = {
small: "gap-0",
@@ -30,13 +23,7 @@ const ServersToggleList = ({
return servers.length > 0 ? (
<div className={`flex flex-col ${gapClass}`}>
{servers.map((server) => (
<ServerRow
hasTrashIcon={hasTrashIcon}
isExpandable={isExpandable}
key={server.name}
marketplaceMetadata={marketplaceMetadataByServerName?.get(server.name)}
server={server}
/>
<ServerRow hasTrashIcon={hasTrashIcon} isExpandable={isExpandable} key={server.name} server={server} />
))}
</div>
) : (

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