mirror of
https://github.com/cline/cline.git
synced 2026-09-13 01:39:57 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abbeea0bfa | ||
|
|
60f4a482ca | ||
|
|
dbe15202e1 | ||
|
|
8d102db392 | ||
|
|
3dfd5dc31c | ||
|
|
43ce9f3694 | ||
|
|
f1c73fb48b | ||
|
|
abaa8383c4 | ||
|
|
3a5e372d73 | ||
|
|
cf3a59f0e2 | ||
|
|
b3aee68ca5 | ||
|
|
cd8fd29063 | ||
|
|
7777d61311 | ||
|
|
64fc3f372e | ||
|
|
4175677e71 | ||
|
|
4934450947 | ||
|
|
b0a2d8a223 | ||
|
|
d9f1d862a5 | ||
|
|
f8d73f3811 | ||
|
|
9aac8340dc | ||
|
|
242b5ebff6 | ||
|
|
7ca41fdb7d | ||
|
|
000918989f | ||
|
|
9560b6d625 | ||
|
|
c7304097a7 | ||
|
|
674a6022ee | ||
|
|
dbf0775384 | ||
|
|
e130b45eb0 | ||
|
|
6f4dbae86f | ||
|
|
c7de31ae24 | ||
|
|
45dddb9a4e | ||
|
|
e32caee96b | ||
|
|
8f6dae0ac0 | ||
|
|
263b58f8c3 | ||
|
|
b193a81ac9 | ||
|
|
92806c60ca | ||
|
|
3a05171e30 | ||
|
|
a6e315a4a6 | ||
|
|
2714f93b45 | ||
|
|
3abeb8a90b | ||
|
|
7830472017 | ||
|
|
83339c3c5a | ||
|
|
664daf6ded | ||
|
|
5b1f8850af | ||
|
|
c49d4121a3 | ||
|
|
7f495a5e99 | ||
|
|
1e88a708bd | ||
|
|
408be18be9 | ||
|
|
5a9c637e32 | ||
|
|
8152572641 | ||
|
|
0736e12e32 | ||
|
|
690f80523f | ||
|
|
4fa1b8a291 | ||
|
|
ed685b28e0 | ||
|
|
38338310d7 | ||
|
|
1c4a7885e6 | ||
|
|
a0517db2fa | ||
|
|
38134ef967 | ||
|
|
8715cafce7 | ||
|
|
46ee8ea329 | ||
|
|
b7d9ea4500 | ||
|
|
5147abc75e | ||
|
|
9abd7ae8c3 | ||
|
|
bf83303bb7 | ||
|
|
bfe5bf841a | ||
|
|
eb362df3ba | ||
|
|
f735ddcb7a | ||
|
|
5b63d3e9c8 | ||
|
|
1ff6a54825 | ||
|
|
b1a3cb6cfc | ||
|
|
78f1736723 | ||
|
|
28a014c1c6 | ||
|
|
fed291e37a | ||
|
|
bb68351123 | ||
|
|
84cb15813a | ||
|
|
c3671de7de | ||
|
|
26f737913f | ||
|
|
50797dd82d | ||
|
|
923ee3e137 | ||
|
|
bbf5bb2302 | ||
|
|
0220b9a506 | ||
|
|
1b573275f8 | ||
|
|
d7d74e0b89 |
@@ -0,0 +1,294 @@
|
||||
name: ext-vscode-publish-legacy
|
||||
|
||||
# Publishes the legacy (pre-SDK-migration) VS Code extension from the
|
||||
# `legacy-extension` branch. This branch holds the npm-based 3.89.x codebase,
|
||||
# rolled forward under a 4.0.x version so existing 4.0.0 users still receive
|
||||
# the update. The main `ext-vscode-publish-stable.yml` workflow (bun-based)
|
||||
# stays the path for releasing main once the SDK migration is solid.
|
||||
#
|
||||
# This workflow lives on and is dispatched from `main` (so it satisfies the
|
||||
# default-branch dispatch requirement), but it checks out and builds the
|
||||
# `legacy-extension` branch.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release-type:
|
||||
description: "Choose release type (release or pre-release)"
|
||||
required: true
|
||||
default: "release"
|
||||
type: choice
|
||||
options:
|
||||
- pre-release
|
||||
- release
|
||||
branch:
|
||||
description: "Branch holding the legacy extension code"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Gate the publish on the legacy branch's own npm-based test suite. We can't
|
||||
# reuse ./.github/workflows/ext-vscode-test.yml here — on main that's the
|
||||
# bun-based suite and it would test main, not the legacy branch — so the
|
||||
# essential quality + test steps are inlined against the checked-out legacy
|
||||
# branch.
|
||||
test:
|
||||
name: Test Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Run Quality Checks (lint + typecheck)
|
||||
run: npm run ci:check-all
|
||||
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Webview Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
name: Publish Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
|
||||
# can create/push the release tag and compute the previous tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
# Tag is derived from the package version on the legacy branch.
|
||||
VERSION=$(node -p "require('./apps/vscode/package.json').version")
|
||||
TAG="v$VERSION"
|
||||
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "Error: derived tag '$TAG' does not match vX.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
TAG_REF="refs/tags/$TAG"
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
if git show-ref --verify --quiet "$TAG_REF"; then
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at branch head ($HEAD_SHA)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag '$TAG' already exists at branch head. Continuing."
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$TAG" "$HEAD_SHA"
|
||||
git push origin "$TAG_REF"
|
||||
echo "Created and pushed tag '$TAG' from $BRANCH head $HEAD_SHA."
|
||||
fi
|
||||
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode install --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Get Version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify Tag Matches Package Version
|
||||
run: |
|
||||
TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
VERSION="v${{ steps.get_version.outputs.version }}"
|
||||
if [[ "$TAG" != "$VERSION" ]]; then
|
||||
echo "Error: tag '$TAG' does not match package version '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify Changelog Entry
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
|
||||
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
- name: Verify Marketplace Tokens
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
|
||||
exit 1
|
||||
fi
|
||||
echo "Marketplace publish tokens are configured."
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
|
||||
run: |
|
||||
# Swap README.marketplace.md into README.md so both the GitHub
|
||||
# release artifact (vsce package below) and the marketplace
|
||||
# publish (npm run publish:marketplace below, which swaps
|
||||
# internally as an idempotent no-op) ship the same README.
|
||||
node scripts/marketplace-readme.mjs swap-in
|
||||
trap 'node scripts/marketplace-readme.mjs restore' EXIT
|
||||
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
npm run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.resolve_tag.outputs.tag }}
|
||||
files: "apps/vscode/*.vsix"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
|
||||
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "*Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)*"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
|
||||
@@ -27,6 +27,10 @@ permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-stable-${{ github.event.inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
@@ -102,7 +106,13 @@ jobs:
|
||||
echo "Error: tag '$TAG' does not exist in the repository"
|
||||
exit 1
|
||||
fi
|
||||
echo "Using existing tag '$TAG'."
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$TESTED_SHA" ]]; then
|
||||
echo "Error: existing tag '$TAG' points to $TAG_SHA, but this workflow tested $TESTED_SHA"
|
||||
echo "Dispatch from the tag ref, or from the exact main commit the tag points to."
|
||||
exit 1
|
||||
fi
|
||||
echo "Using existing tag '$TAG' at tested SHA $TESTED_SHA."
|
||||
fi
|
||||
|
||||
git checkout --detach "$TAG_REF^{commit}"
|
||||
@@ -170,6 +180,60 @@ jobs:
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify Changelog Entry
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
|
||||
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
- name: Verify Marketplace Tokens
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
|
||||
exit 1
|
||||
fi
|
||||
echo "Marketplace publish tokens are configured."
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(
|
||||
git tag --merged "$CURRENT_TAG^" --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$' \
|
||||
| head -n 1 || true
|
||||
)
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between the matching version heading and the next release heading.
|
||||
CONTENT=$(awk -v version="${{ steps.get_version.outputs.version }}" '
|
||||
$0 == "## [" version "]" { found=1; next }
|
||||
found && /^## \[/ { exit }
|
||||
found { print }
|
||||
END { if (!found) exit 1 }
|
||||
' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
@@ -210,24 +274,6 @@ jobs:
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
# Changelog
|
||||
|
||||
## [4.0.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add the SDK-backed VS Code extension runtime. Cline now runs tasks through the shared Cline SDK session layer for agent turns, tools, Plan/Act mode coordination, MCP, checkpoints, telemetry, provider changes, compaction, mistake limits, and task history.
|
||||
- Add ClinePass to the VS Code extension, including onboarding, provider selection, signup and subscription handoff, live model lists, entitlement and organization error states, out-of-credit prompts, and clearer ClinePass auth/error handling.
|
||||
- Add the Customize marketplace for discovering and managing Skills, MCP servers, and Plugins from the extension, including installed/marketplace tabs, search and filtering, install/uninstall flows, enable/disable controls, and support for plugin-bundled skills.
|
||||
- Cline Plugins: Plugins let you extend Cline with custom tools, workflows, skills, and MCP-powered capabilities tailored to your team or project. Install them from the new Customize marketplace to add specialized behavior, connect external services, and package reusable automations—so Cline can do more than code: it can adapt to the way you work.
|
||||
- Add queued prompts in chat. Messages submitted while Cline is already working are now queued, shown while the current turn streams, and can be cancelled before they run.
|
||||
- Add edit-and-regenerate support for previous user messages, with clearer Reset Chat and Reset Code actions.
|
||||
- Add generic SDK provider settings and model-catalog support so more providers can share the same model picker, reasoning controls, dynamic model IDs, provider config persistence, and custom model handling.
|
||||
- Add additional SDK-backed provider exposure and model/provider updates, including ClinePass models, refreshed Cline catalog data, Fireworks GLM 5.2, Kimi K2.6 Fast, Kimi K2.7 Code, Qwen 3.7 Plus, MiniMax M3 updates, SAP AI Core wiring, LiteLLM model fetching, Codex OAuth credentials, and OpenAI-compatible model settings.
|
||||
- Add MCP support for plugins and shared marketplace install/uninstall plumbing used by the VS Code extension.
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrate the VS Code extension from the legacy task implementation to the shared Cline SDK and move the extension build/package workflow to Bun.
|
||||
- Rework Plan/Act mode handling through SDK coordinators, including closer CLI parity and automatic continuation when switching from Plan to Act.
|
||||
- Rework provider and model configuration around `providers.json`, the model catalog, and SDK session config so settings are preserved consistently across provider switches and active sessions can restart when the selected provider changes.
|
||||
- Simplify provider settings UI by replacing many provider-specific views with shared generic settings components and consistent reasoning selectors.
|
||||
- Simplify terminal execution through the SDK run-commands path, including clearer non-interactive command guidance and safer structured command formatting.
|
||||
- Migrate legacy MCP files and formats into the shared settings file and protect MCP settings writes with safer locking/atomic updates.
|
||||
- Refresh the MCP hub automatically after marketplace installs so newly installed servers are available without a manual restart.
|
||||
- Reorganize MCP/Skills/Plugins entry points under Customize, hide workflows from the Customize menu, wrap Customize tabs on narrow screens, and allow the MCP Marketplace tab to be disabled remotely while installed MCP servers remain accessible.
|
||||
- Simplify auto-approval settings. Command auto-approval is now disabled by default for safer new and reset configurations, and the auto-approval UI has been streamlined.
|
||||
- Update task history handling for the SDK migration, including legacy task history visibility, metadata preservation on resume, and corrected deletion behavior.
|
||||
- Route compacting and mistake-limit behavior through the SDK so the Compact button and mistake tracking affect the active SDK session.
|
||||
- Remove the legacy Explain Changes feature as part of the SDK migration cleanup.
|
||||
- Temporarily disable subagents in the VS Code extension while the SDK-backed experience is stabilized.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix marketplace edge cases, including refreshing MCP servers after marketplace installs, disabling the MCP Marketplace tab from remote config, hiding workflows from Customize, surfacing plugin-bundled skills, and uninstalling shared marketplace entries.
|
||||
- Fix chat submission during active turns by queuing user messages instead of dropping or racing them, showing pending/queued states promptly, rendering direct user messages immediately, and removing delayed send behavior.
|
||||
- Fix editing previous user messages so Escape cancels editing locally and reset action labels are clearer.
|
||||
- Fix terminal reliability, including standalone Windows output capture, hardened PowerShell command handling, running-state display for in-progress commands, raw structured command preservation, single-quote handling, cwd setup timeouts, failing-command stdout capture, heredoc coalescing, and removal of duplicated command echoes in tool results.
|
||||
- Fix SDK tool-result and provider-message budgeting by truncating large tool outputs by default, capping assistant text, limiting bash/file-read/search output ingestion, bounding media budgets, batching outdated-read rewrites to preserve provider prefix caches, and normalizing JSON-like tool inputs by schema.
|
||||
- Fix login and feature-flag resolution by using the correct user/account identity on startup and simplifying the login UX.
|
||||
|
||||
## [3.89.2]
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,5 +1,51 @@
|
||||
# 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
|
||||
- Added organization-specific error messages
|
||||
- Added SAP AI Core provider support
|
||||
- Refreshed the model catalog with the latest provider models
|
||||
- Preserved OpenRouter reasoning-disable behavior and improved OpenRouter prompt caching
|
||||
- Routed LiteLLM model fetches through the SDK and stopped unrelated models from appearing in the LiteLLM model list
|
||||
- Updated ClinePass models live, restored ClinePass models in onboarding, and improved ClinePass error messages
|
||||
- Threaded proxy/CA-aware networking into the inference path
|
||||
- Persisted Bedrock settings to providers.json
|
||||
- Normalized JSON-like tool inputs by schema for more reliable tool calls
|
||||
- Fixed an "ERROR: EMPTY CONTENT" message that could appear when an error occurred
|
||||
- Fixed a packaging issue (createRequire) that could break the CLI at runtime
|
||||
|
||||
## 3.0.29
|
||||
|
||||
- Costs are now hidden for Cline free models
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.29",
|
||||
"version": "3.0.34",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,14 +1,27 @@
|
||||
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("../wizards/mcp/settings", () => ({
|
||||
addServer: vi.fn(),
|
||||
}));
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
installMcpServer: vi.fn((options) => {
|
||||
const { name, transport, warnings } =
|
||||
actual.buildMcpInstallTransport(options);
|
||||
return {
|
||||
name,
|
||||
status: "installed",
|
||||
transport,
|
||||
warnings,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("mcp install command", () => {
|
||||
it("builds stdio wizard defaults from command args", () => {
|
||||
@@ -216,12 +229,17 @@ describe("mcp install command", () => {
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(addServer).toHaveBeenCalledWith("docs", {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer token",
|
||||
},
|
||||
expect(installMcpServer).toHaveBeenCalledWith({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
targetArgs: [
|
||||
"https://example.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
expect(writeln).toHaveBeenCalledWith("Installed MCP server docs.");
|
||||
expect(writeErr).not.toHaveBeenCalled();
|
||||
|
||||
+16
-131
@@ -1,16 +1,19 @@
|
||||
import {
|
||||
type McpInstallOptions as CoreMcpInstallOptions,
|
||||
installMcpServer,
|
||||
type McpInstallResult,
|
||||
type McpServerTransportConfig,
|
||||
} from "@cline/core";
|
||||
import type { McpAddDefaults } from "../wizards/mcp";
|
||||
import { addServer, type McpTransport } from "../wizards/mcp/settings";
|
||||
|
||||
export { buildMcpInstallTransport } from "@cline/core";
|
||||
|
||||
export interface McpCommandIo {
|
||||
writeln?: (text: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface McpInstallOptions {
|
||||
name: string;
|
||||
headers?: string[];
|
||||
targetArgs?: string[];
|
||||
transport?: string;
|
||||
export interface McpInstallOptions extends CoreMcpInstallOptions {
|
||||
io?: McpCommandIo;
|
||||
isTty?: boolean;
|
||||
json?: boolean;
|
||||
@@ -21,13 +24,13 @@ export interface McpInstallOptions {
|
||||
export interface McpInstallDirectResult {
|
||||
name: string;
|
||||
status: "installed";
|
||||
transport: McpTransport;
|
||||
transport: McpServerTransportConfig;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function normalizeTransportType(
|
||||
value: string | undefined,
|
||||
): McpTransport["type"] {
|
||||
): McpServerTransportConfig["type"] {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
@@ -58,72 +61,6 @@ 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;
|
||||
@@ -169,67 +106,15 @@ 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 { name, transport, warnings } = buildMcpInstallTransport(options);
|
||||
addServer(name, transport);
|
||||
const result: McpInstallResult = installMcpServer(options);
|
||||
return {
|
||||
name,
|
||||
status: "installed",
|
||||
transport,
|
||||
warnings,
|
||||
name: result.name,
|
||||
status: result.status,
|
||||
transport: result.transport,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+24
-1180
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ export function addRootOptions(cmd: Command): Command {
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option(
|
||||
"--thinking <level>",
|
||||
"Set reasoning effort level between none|low|medium|high|xhigh (default: medium)",
|
||||
"Set reasoning effort: none|low|medium|high|xhigh. Bare --thinking uses medium; omitted leaves provider default.",
|
||||
)
|
||||
.option("--compaction <mode>", CLI_COMPACTION_MODE_OPTION_DESCRIPTION)
|
||||
.option(
|
||||
|
||||
@@ -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: false,
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ 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,
|
||||
@@ -64,8 +63,7 @@ export async function buildConnectorStartRequest(input: {
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
input.options.provider?.trim() ||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import open from "open";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { palette } from "../tui/palette";
|
||||
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
|
||||
import type { CliMigrationNotice } from "./notice";
|
||||
|
||||
export function MigrationNoticeContent(
|
||||
@@ -10,10 +13,29 @@ export function MigrationNoticeContent(
|
||||
},
|
||||
) {
|
||||
const { dialogId, notice, resolve } = props;
|
||||
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
|
||||
const openSubscriptionPage = useCallback(() => {
|
||||
setStatus("Opening ClinePass in your browser...");
|
||||
void open(subscriptionUrl, { wait: false })
|
||||
.then(() => {
|
||||
setStatus("Opened ClinePass in your browser.");
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus(
|
||||
"Could not open the browser automatically. Use the URL below.",
|
||||
);
|
||||
});
|
||||
}, [subscriptionUrl]);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
openSubscriptionPage();
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
@@ -22,25 +44,24 @@ export function MigrationNoticeContent(
|
||||
<text fg={palette.act}>{notice.title}</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
We rebuilt the CLI from the ground up using the new Cline SDK. Learn
|
||||
more:{" "}
|
||||
<a href="https://github.com/cline/cline">
|
||||
<span fg={palette.act}>https://github.com/cline/cline</span>
|
||||
</a>
|
||||
ClinePass is a $9.99/month subscription plan to get access to the
|
||||
latest open-weight coding models with enough quota for day-to-day
|
||||
work, at a much lower cost than paying API costs directly.
|
||||
</text>
|
||||
<text selectable>
|
||||
Running{" "}
|
||||
<span fg="#98c379" bg="#1f2937">
|
||||
{" cline "}
|
||||
</span>{" "}
|
||||
now opens the terminal UI. To open Kanban, use /quit and run{" "}
|
||||
<span fg="#98c379" bg="#1f2937">
|
||||
{" cline kanban "}
|
||||
</span>{" "}
|
||||
in your terminal
|
||||
<text selectable>Try it now with a limited-time promo for $1.99.</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
<text fg={palette.muted}>Press Esc to close</text>
|
||||
<box flexDirection="row">
|
||||
<box paddingX={1} backgroundColor={palette.act}>
|
||||
<text fg={palette.textOnSelection}>Open ClinePass</text>
|
||||
</box>
|
||||
</box>
|
||||
{status && <text fg={palette.muted}>{status}</text>}
|
||||
<text fg={palette.muted}>Press Enter to open, Esc to close</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getClineCliMigrationNotice,
|
||||
markClineCliMigrationNoticeShown,
|
||||
resolveCliNoticeStatePath,
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider,
|
||||
} from "./notice";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -26,8 +33,25 @@ describe("migration notice", () => {
|
||||
it("returns the notice for a fresh data dir", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(getClineCliMigrationNotice(dataDir)?.title).toBe(
|
||||
"Welcome to the new Cline CLI",
|
||||
expect(getClineCliMigrationNotice(dataDir)?.title).toBe("Try ClinePass");
|
||||
});
|
||||
|
||||
it("shows when only the old Kanban notice was marked as shown", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
const noticePath = resolveCliNoticeStatePath(dataDir);
|
||||
mkdirSync(dirname(noticePath), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(
|
||||
noticePath,
|
||||
`${JSON.stringify(
|
||||
{ shown: { "cline-cli-tui-default": true } },
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(getClineCliMigrationNotice(dataDir)?.id).toBe(
|
||||
"cline-cli-cline-pass-intro",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -46,7 +70,7 @@ describe("migration notice", () => {
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_FORCE_MIGRATION_NOTICE: "1",
|
||||
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
|
||||
}),
|
||||
).toBeDefined();
|
||||
});
|
||||
@@ -56,18 +80,56 @@ describe("migration notice", () => {
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_DISABLE_MIGRATION_NOTICE: "1",
|
||||
CLINE_DISABLE_CLINE_PASS_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_MIGRATION_NOTICE: "1",
|
||||
CLINE_FORCE_MIGRATION_NOTICE: "1",
|
||||
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
|
||||
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
|
||||
}),
|
||||
).toBeDefined();
|
||||
});
|
||||
@@ -78,7 +140,7 @@ describe("migration notice", () => {
|
||||
markClineCliMigrationNoticeShown(dataDir);
|
||||
|
||||
const rawState = readFileSync(resolveCliNoticeStatePath(dataDir), "utf8");
|
||||
expect(rawState).toContain("cline-cli-tui-default");
|
||||
expect(rawState).toContain("cline-cli-cline-pass-intro");
|
||||
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,15 +2,19 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
const NOTICE_ID = "cline-cli-tui-default";
|
||||
const FORCE_NOTICE_ENV = "CLINE_FORCE_MIGRATION_NOTICE";
|
||||
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_MIGRATION_NOTICE";
|
||||
const NOTICE_ID = "cline-cli-cline-pass-intro";
|
||||
const FORCE_NOTICE_ENV = "CLINE_FORCE_CLINE_PASS_NOTICE";
|
||||
const DISABLE_NOTICE_ENV = "CLINE_DISABLE_CLINE_PASS_NOTICE";
|
||||
|
||||
export interface CliMigrationNotice {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface CliMigrationNoticeOptions {
|
||||
activeProviderId?: string;
|
||||
}
|
||||
|
||||
interface CliNoticeState {
|
||||
shown: Record<string, boolean>;
|
||||
}
|
||||
@@ -49,6 +53,19 @@ 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 {
|
||||
@@ -58,20 +75,29 @@ 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 = env[FORCE_NOTICE_ENV]?.trim() === "1";
|
||||
const forceNotice = isForceNoticeEnabled(env);
|
||||
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: "Welcome to the new Cline CLI",
|
||||
title: "Try ClinePass",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+190
-44
@@ -1,6 +1,9 @@
|
||||
import { fstatSync } from "node:fs";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CliMigrationNotice } from "./kanban-migration/notice";
|
||||
import type {
|
||||
CliMigrationNotice,
|
||||
CliMigrationNoticeOptions,
|
||||
} 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(() => ({
|
||||
@@ -59,9 +62,13 @@ const dashboardMocks = vi.hoisted(() => ({
|
||||
runDashboardCommand: vi.fn(),
|
||||
}));
|
||||
const migrationNoticeMocks = vi.hoisted(() => ({
|
||||
getClineCliMigrationNotice: vi.fn<() => CliMigrationNotice | undefined>(
|
||||
() => undefined,
|
||||
),
|
||||
getClineCliMigrationNotice: vi.fn<
|
||||
(
|
||||
dataDir?: string,
|
||||
env?: NodeJS.ProcessEnv,
|
||||
options?: CliMigrationNoticeOptions,
|
||||
) => CliMigrationNotice | undefined
|
||||
>(() => undefined),
|
||||
markClineCliMigrationNoticeShown: vi.fn(),
|
||||
}));
|
||||
const updateMocks = vi.hoisted(() => ({
|
||||
@@ -115,6 +122,7 @@ const telemetryMocks = vi.hoisted(() => ({
|
||||
const featureFlagMocks = vi.hoisted(() => ({
|
||||
getBooleanFlagEnabled: vi.fn(() => false),
|
||||
setCliFeatureFlagsAccountContext: vi.fn(),
|
||||
refreshCliFeatureFlagsInBackground: vi.fn(),
|
||||
}));
|
||||
|
||||
function forcePromptModeInput() {
|
||||
@@ -179,7 +187,8 @@ vi.mock("./utils/feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
refreshCliFeatureFlagsInBackground: vi.fn(),
|
||||
refreshCliFeatureFlagsInBackground:
|
||||
featureFlagMocks.refreshCliFeatureFlagsInBackground,
|
||||
setCliFeatureFlagsAccountContext:
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext,
|
||||
}));
|
||||
@@ -258,6 +267,7 @@ 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();
|
||||
@@ -407,7 +417,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("does not load interactive runtime for single-prompt mode", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -417,6 +427,30 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a single bare positional prompt token", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "nonexistent-command"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Unknown command or unquoted prompt: nonexistent-command",
|
||||
),
|
||||
);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Use "cline --help"'),
|
||||
);
|
||||
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
||||
expect(mockState.runAgentImports).toBe(0);
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects multiple bare positional prompt tokens", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
@@ -430,7 +464,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Unknown command or extra arguments: hello world",
|
||||
"Unknown command or unquoted prompt: hello world",
|
||||
),
|
||||
);
|
||||
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
||||
@@ -474,7 +508,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("creates a worktree and runs prompt sessions from it", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--worktree", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--worktree", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -483,7 +517,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
cwd: "/tmp/cline-worktree",
|
||||
workspaceRoot: "/tmp/cline-worktree",
|
||||
@@ -606,8 +640,8 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("passes the migration notice marker into interactive mode", async () => {
|
||||
const notice = {
|
||||
id: "cline-cli-tui-default",
|
||||
title: "Welcome to the new Cline CLI",
|
||||
id: "cline-cli-cline-pass-intro",
|
||||
title: "Try ClinePass",
|
||||
};
|
||||
migrationNoticeMocks.getClineCliMigrationNotice.mockReturnValue(notice);
|
||||
Object.defineProperty(process.stdout, "isTTY", {
|
||||
@@ -638,6 +672,37 @@ 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");
|
||||
@@ -727,7 +792,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("uses the bundled catalog path for single-prompt runs", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -918,7 +983,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds feature flag identity from persisted Cline account id before checking flags", async () => {
|
||||
it("seeds feature flag identity from persisted Cline account id before refreshing flags", async () => {
|
||||
const clineSettings = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
@@ -937,11 +1002,14 @@ 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.getBooleanFlagEnabled.mock.invocationCallOrder[0],
|
||||
featureFlagMocks.refreshCliFeatureFlagsInBackground.mock
|
||||
.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1013,7 +1081,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("skips hub prewarm for yolo runs", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--yolo", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--yolo", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -1022,6 +1090,24 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects yolo runs with a single bare prompt token", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--yolo", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Unknown command or unquoted prompt: hello"),
|
||||
);
|
||||
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
||||
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rewrites /team prompts and enables teams in single-prompt mode", async () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
@@ -1042,12 +1128,12 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("shows /team usage in single-prompt mode when no task is provided", async () => {
|
||||
it("rejects /team without quoted task text", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
const stdoutWrite = vi
|
||||
.spyOn(process.stdout, "write")
|
||||
.mockImplementation(() => true);
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "/team"];
|
||||
@@ -1055,9 +1141,10 @@ describe("runCli lightweight command dispatch", () => {
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(mockState.runAgentCalls).toBe(0);
|
||||
expect(stdoutWrite).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Usage: /team <task description>"),
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Unknown command or unquoted prompt: /team"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1066,14 +1153,14 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "high", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "high", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
@@ -1082,19 +1169,40 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves thinking disabled when --thinking is not provided", async () => {
|
||||
it("leaves thinking unset when --thinking is not provided", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("disables thinking when --thinking none is explicitly provided", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "none", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
@@ -1108,14 +1216,14 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "--", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "--", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
@@ -1138,14 +1246,14 @@ describe("runCli lightweight command dispatch", () => {
|
||||
});
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
@@ -1154,6 +1262,32 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses persisted disabled reasoning when --thinking is not provided", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
model: "openai/gpt-5",
|
||||
reasoning: { enabled: false },
|
||||
});
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers explicit --thinking over persisted reasoning effort", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
@@ -1164,14 +1298,14 @@ describe("runCli lightweight command dispatch", () => {
|
||||
});
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "low", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--thinking", "low", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
thinking: true,
|
||||
reasoningEffort: "low",
|
||||
@@ -1185,13 +1319,13 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
@@ -1207,13 +1341,19 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "basic", "hello"];
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"--compaction",
|
||||
"basic",
|
||||
"say hello",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
@@ -1229,13 +1369,19 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "agentic", "hello"];
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"--compaction",
|
||||
"agentic",
|
||||
"say hello",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: true,
|
||||
@@ -1285,13 +1431,13 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "off", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--compaction", "off", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
compaction: {
|
||||
enabled: false,
|
||||
@@ -1330,7 +1476,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
authMocks.ensureOAuthProviderApiKey.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--json", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -1338,7 +1484,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
outputMode: "json",
|
||||
apiKey: "",
|
||||
@@ -1357,7 +1503,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
authMocks.ensureOAuthProviderApiKey.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--json", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "--json", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -1365,7 +1511,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(authMocks.ensureOAuthProviderApiKey).not.toHaveBeenCalled();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
"say hello",
|
||||
expect.objectContaining({
|
||||
outputMode: "json",
|
||||
apiKey: "",
|
||||
|
||||
+33
-29
@@ -20,7 +20,6 @@ import {
|
||||
CLI_COMPACTION_MODE_EXPECTED_TEXT,
|
||||
} from "./utils/compaction-mode";
|
||||
import {
|
||||
getCliFeatureFlagsService,
|
||||
refreshCliFeatureFlagsInBackground,
|
||||
setCliFeatureFlagsAccountContext,
|
||||
} from "./utils/feature-flags";
|
||||
@@ -42,6 +41,7 @@ import {
|
||||
isOAuthProvider,
|
||||
normalizeProviderId,
|
||||
} from "./utils/provider-auth";
|
||||
import { resolveCliReasoning } from "./utils/reasoning";
|
||||
import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
|
||||
import {
|
||||
captureCliExtensionActivated,
|
||||
@@ -116,6 +116,19 @@ 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();
|
||||
@@ -735,13 +748,6 @@ export async function runCli(): Promise<void> {
|
||||
|
||||
// Default flow: no subcommand matched, or fall-through from config/history.
|
||||
let args = commanderToParsedArgs(program);
|
||||
if (program.args.length > 1) {
|
||||
writeErr(
|
||||
`Unknown command or extra arguments: ${program.args.join(" ")}\nPrompt text with spaces must be quoted as a single argument, for example: cline "fix the tests". Use "cline --help" to see available commands and flags.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let resumeSessionId: string | undefined = ctx.resumeSessionId;
|
||||
if (resumeSessionId) {
|
||||
@@ -828,6 +834,13 @@ export async function runCli(): Promise<void> {
|
||||
if (args.hooksDir?.trim()) {
|
||||
process.env.CLINE_HOOKS_DIR = args.hooksDir.trim();
|
||||
}
|
||||
if (args.prompt && !args.interactive) {
|
||||
if (program.args.length > 1 || !promptArgLooksQuoted(program.args[0])) {
|
||||
writePromptArgError(program.args);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCurrentOutputMode(args.outputMode);
|
||||
const defaultToolAutoApprove = true;
|
||||
const effectiveToolAutoApprove =
|
||||
@@ -942,8 +955,7 @@ export async function runCli(): Promise<void> {
|
||||
refreshCliFeatureFlagsInBackground();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
|
||||
@@ -1011,19 +1023,12 @@ export async function runCli(): Promise<void> {
|
||||
);
|
||||
}
|
||||
const knownModelIds = knownModels ? Object.keys(knownModels) : [];
|
||||
const persistedReasoning = selectedProviderSettings?.reasoning;
|
||||
const persistedReasoningEffort = persistedReasoning?.effort;
|
||||
const reasoningEffortFromSettings =
|
||||
persistedReasoning?.enabled === false
|
||||
? "none"
|
||||
: persistedReasoningEffort && persistedReasoningEffort !== "none"
|
||||
? persistedReasoningEffort
|
||||
: persistedReasoning?.enabled === true
|
||||
? "medium"
|
||||
: "none";
|
||||
const effectiveReasoningEffort = args.thinkingExplicitlySet
|
||||
? (args.reasoningEffort ?? "none")
|
||||
: (args.reasoningEffort ?? reasoningEffortFromSettings);
|
||||
const resolvedReasoning = resolveCliReasoning({
|
||||
thinking: args.thinking,
|
||||
thinkingExplicitlySet: args.thinkingExplicitlySet,
|
||||
reasoningEffort: args.reasoningEffort,
|
||||
persistedReasoning: selectedProviderSettings?.reasoning,
|
||||
});
|
||||
const { createCliLoggerAdapter } = await import("./logging/adapter");
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
@@ -1059,11 +1064,8 @@ export async function runCli(): Promise<void> {
|
||||
sandbox: sandboxEnabled,
|
||||
sandboxDataDir,
|
||||
verbose: args.verbose,
|
||||
thinking: effectiveReasoningEffort !== "none",
|
||||
reasoningEffort:
|
||||
effectiveReasoningEffort === "none"
|
||||
? undefined
|
||||
: effectiveReasoningEffort,
|
||||
thinking: resolvedReasoning.thinking,
|
||||
reasoningEffort: resolvedReasoning.reasoningEffort,
|
||||
outputMode: args.outputMode,
|
||||
mode: args.mode,
|
||||
logger: loggerAdapter.core,
|
||||
@@ -1178,7 +1180,9 @@ export async function runCli(): Promise<void> {
|
||||
if (!launchConfigView && process.stdin.isTTY && process.stdout.isTTY) {
|
||||
const { getClineCliMigrationNotice, markClineCliMigrationNoticeShown } =
|
||||
await import("./kanban-migration/notice");
|
||||
initialNotice = getClineCliMigrationNotice();
|
||||
initialNotice = getClineCliMigrationNotice(undefined, process.env, {
|
||||
activeProviderId: provider,
|
||||
});
|
||||
if (initialNotice) {
|
||||
markInitialNoticeShown = () => {
|
||||
markClineCliMigrationNoticeShown();
|
||||
|
||||
@@ -365,6 +365,48 @@ 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,6 +49,13 @@ 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];
|
||||
@@ -103,7 +110,9 @@ export function createInteractiveSessionRuntime(input: {
|
||||
let shutdownRequested = false;
|
||||
let activeSessionId = "";
|
||||
let abortRequested = false;
|
||||
let missingSessionRecoveryPromise: Promise<void> | undefined;
|
||||
let missingSessionRecoveryPromise:
|
||||
| Promise<MissingSessionRecovery>
|
||||
| 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;
|
||||
@@ -275,14 +284,34 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return await startupPromise;
|
||||
};
|
||||
|
||||
const readCurrentMessages = async (): Promise<Message[]> => {
|
||||
if (!sessionManager || !activeSessionId) {
|
||||
return [];
|
||||
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" };
|
||||
}
|
||||
return (await sessionManager.readMessages(activeSessionId)) ?? [];
|
||||
};
|
||||
|
||||
const recoverMissingActiveSession = async (error: unknown): Promise<void> => {
|
||||
const recoverMissingActiveSession = async (
|
||||
error: unknown,
|
||||
): Promise<MissingSessionRecovery> => {
|
||||
if (missingSessionRecoveryPromise) {
|
||||
return await missingSessionRecoveryPromise;
|
||||
}
|
||||
@@ -290,7 +319,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
const manager = sessionManager;
|
||||
const missingSessionId = activeSessionId;
|
||||
if (!manager || !missingSessionId || shutdownRequested) {
|
||||
return;
|
||||
return { messages: [] };
|
||||
}
|
||||
const messages = await manager
|
||||
.readMessages(missingSessionId)
|
||||
@@ -307,6 +336,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
startupError = undefined;
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages);
|
||||
return { messages };
|
||||
})().finally(() => {
|
||||
missingSessionRecoveryPromise = undefined;
|
||||
});
|
||||
@@ -361,7 +391,13 @@ export function createInteractiveSessionRuntime(input: {
|
||||
};
|
||||
|
||||
const restartWithCurrentMessages = async (): Promise<void> => {
|
||||
const messages = await readCurrentMessages();
|
||||
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;
|
||||
}
|
||||
await restartWithMessages(messages);
|
||||
};
|
||||
|
||||
@@ -510,7 +546,13 @@ export function createInteractiveSessionRuntime(input: {
|
||||
if (!sessionManager) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
}
|
||||
const messages = await readCurrentMessages();
|
||||
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 messagesBefore = messages.length;
|
||||
if (messagesBefore === 0) {
|
||||
return { messagesBefore: 0, messagesAfter: 0, compacted: false };
|
||||
@@ -551,7 +593,10 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return undefined;
|
||||
}
|
||||
const checkpointHistory = readSessionCheckpointHistory(sessionRecord);
|
||||
const messages = await readCurrentMessages();
|
||||
const { messages, status } = await readCurrentMessages();
|
||||
if (status !== "read") {
|
||||
return undefined;
|
||||
}
|
||||
return { messages, checkpointHistory };
|
||||
};
|
||||
|
||||
|
||||
@@ -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-100&personal=true";
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true";
|
||||
const SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
|
||||
const CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLI_SUBSCRIPTION_URL}`;
|
||||
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveReasoningForModelChange } from "./run-interactive";
|
||||
|
||||
describe("resolveReasoningForModelChange", () => {
|
||||
it("persists disabled reasoning only when thinking is explicitly false", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: false, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: true, effort: "high" } },
|
||||
),
|
||||
).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it("persists enabled reasoning with the selected effort", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: true, reasoningEffort: "low" },
|
||||
{ reasoning: { enabled: false } },
|
||||
),
|
||||
).toEqual({ enabled: true, effort: "low" });
|
||||
});
|
||||
|
||||
it("persists enabled reasoning when thinking is explicitly true without effort", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: true, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: false } },
|
||||
),
|
||||
).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("preserves existing reasoning when thinking is unset", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: undefined, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: true, effort: "medium" } },
|
||||
),
|
||||
).toEqual({ enabled: true, effort: "medium" });
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import type { CliMigrationNotice } from "../kanban-migration/notice";
|
||||
import { logCliError } from "../logging/errors";
|
||||
import {
|
||||
loadClineAccountSnapshot,
|
||||
loadIndividualSubscriptionPlans,
|
||||
onProviderChange,
|
||||
switchClineAccount,
|
||||
} from "../tui/cline-account";
|
||||
@@ -57,6 +58,23 @@ import { createInteractiveSessionRuntime } from "./interactive/session-runtime";
|
||||
import { buildUserInputMessage } from "./prompt";
|
||||
import { getUIEventEmitter } from "./session-events";
|
||||
|
||||
type ModelChangeReasoningConfig = {
|
||||
thinking?: boolean;
|
||||
reasoningEffort?: Config["reasoningEffort"];
|
||||
};
|
||||
|
||||
export function resolveReasoningForModelChange(
|
||||
config: ModelChangeReasoningConfig,
|
||||
existing: Pick<ProviderSettings, "reasoning">,
|
||||
): ProviderSettings["reasoning"] {
|
||||
if (config.thinking === false) return { enabled: false };
|
||||
if (config.reasoningEffort) {
|
||||
return { enabled: true, effort: config.reasoningEffort };
|
||||
}
|
||||
if (config.thinking === true) return { enabled: true };
|
||||
return existing.reasoning;
|
||||
}
|
||||
|
||||
export async function runInteractive(
|
||||
config: Config,
|
||||
userInstructionService?: UserInstructionConfigService,
|
||||
@@ -371,7 +389,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,
|
||||
@@ -410,6 +428,12 @@ export async function runInteractive(
|
||||
config,
|
||||
clineApiBaseUrl: options?.clineApiBaseUrl,
|
||||
}),
|
||||
loadIndividualSubscriptionPlans: async () =>
|
||||
await loadIndividualSubscriptionPlans({
|
||||
config,
|
||||
clineApiBaseUrl: options?.clineApiBaseUrl,
|
||||
clineProviderSettings: options?.clineProviderSettings,
|
||||
}),
|
||||
switchClineAccount: async (organizationId) =>
|
||||
await switchClineAccount({
|
||||
config,
|
||||
@@ -637,12 +661,11 @@ export async function runInteractive(
|
||||
) ?? {
|
||||
provider: config.providerId,
|
||||
};
|
||||
const reasoning = resolveReasoningForModelChange(config, existing);
|
||||
providerSettingsManager.saveProviderSettings({
|
||||
...existing,
|
||||
model: config.modelId,
|
||||
reasoning: config.reasoningEffort
|
||||
? { enabled: true, effort: config.reasoningEffort }
|
||||
: { enabled: false },
|
||||
...(reasoning === undefined ? {} : { reasoning }),
|
||||
});
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
|
||||
@@ -3,7 +3,11 @@ import { CLINE_BIN } from "./helpers/constants.js";
|
||||
import { clineEnv } from "./helpers/env.js";
|
||||
import { expectVisible } from "./helpers/terminal.js";
|
||||
|
||||
const HELP_TERMINAL = { columns: 120, rows: 50 };
|
||||
// Wide enough that long option descriptions (e.g. --thinking) render on a
|
||||
// single line. At narrower widths commander wraps them, splitting phrases
|
||||
// like "omitted leaves provider default" across lines so the contiguous
|
||||
// getByText assertions below fail.
|
||||
const HELP_TERMINAL = { columns: 200, rows: 50 };
|
||||
|
||||
// ===========================================================================
|
||||
// Root-level flag descriptions
|
||||
@@ -23,7 +27,9 @@ test.describe("root flag descriptions", () => {
|
||||
"verbose output",
|
||||
"Working directory",
|
||||
"Configuration directory",
|
||||
"Set reasoning effort level",
|
||||
"Set reasoning effort:",
|
||||
"Bare --thinking uses medium",
|
||||
"omitted leaves provider default",
|
||||
"consecutive mistakes",
|
||||
"Output messages as JSON",
|
||||
"Check for updates and install if available",
|
||||
|
||||
@@ -124,7 +124,7 @@ export function clineEnv(
|
||||
}),
|
||||
CLINE_SESSION_DATA_DIR: path.join(dataDir, "sessions"),
|
||||
CLINE_TEAM_DATA_DIR: path.join(dataDir, "teams"),
|
||||
CLINE_DISABLE_MIGRATION_NOTICE: "1",
|
||||
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
|
||||
NO_UPDATE_NOTIFIER: "1",
|
||||
CLINE_NO_AUTO_UPDATE: "1",
|
||||
...extra,
|
||||
|
||||
@@ -12,6 +12,8 @@ const coreMocks = vi.hoisted(() => {
|
||||
fetchMe: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchOrganizationBalance: vi.fn(),
|
||||
fetchAvailableSubscriptionPlans: vi.fn(),
|
||||
fetchCurrentUserPlan: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
@@ -39,6 +41,14 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
fetchOrganizationBalance(organizationId: string) {
|
||||
return coreMocks.fetchOrganizationBalance(organizationId);
|
||||
}
|
||||
fetchAvailableSubscriptionPlans(input?: {
|
||||
type?: "individual" | "teams";
|
||||
}) {
|
||||
return coreMocks.fetchAvailableSubscriptionPlans(input);
|
||||
}
|
||||
fetchCurrentUserPlan() {
|
||||
return coreMocks.fetchCurrentUserPlan();
|
||||
}
|
||||
},
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings(providerId: string) {
|
||||
@@ -100,6 +110,8 @@ describe("createClineAccountService", () => {
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
@@ -196,6 +208,8 @@ describe("loadClineAccountSnapshot", () => {
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
@@ -249,3 +263,49 @@ describe("loadClineAccountSnapshot", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadIndividualSubscriptionPlans", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads individual subscription plans through the authorized account service", async () => {
|
||||
const plans = [
|
||||
{
|
||||
id: "plan-1",
|
||||
interval: "Monthly",
|
||||
features: { included: ["Major open-weights models"] },
|
||||
},
|
||||
];
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "account-token",
|
||||
});
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockResolvedValue(plans);
|
||||
|
||||
const { loadIndividualSubscriptionPlans } = await import("./cline-account");
|
||||
const result = await loadIndividualSubscriptionPlans({
|
||||
config: makeConfig(),
|
||||
});
|
||||
|
||||
expect(coreMocks.fetchAvailableSubscriptionPlans).toHaveBeenCalledWith({
|
||||
type: "individual",
|
||||
});
|
||||
expect(result).toEqual(plans);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
type ClineAccountBalance,
|
||||
type ClineAccountOrganization,
|
||||
type ClineAccountOrganizationBalance,
|
||||
type ClineSubscriptionPlan,
|
||||
type UserCurrentPlan,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
formatProviderOAuthApiKey,
|
||||
@@ -124,8 +126,9 @@ export async function createClineAccountService(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
providerSettingsManager?: ProviderSettingsManager;
|
||||
}): Promise<ClineAccountService | undefined> {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const manager = input.providerSettingsManager ?? new ProviderSettingsManager();
|
||||
const settings =
|
||||
manager.getProviderSettings("cline") ?? input.clineProviderSettings;
|
||||
const apiBaseUrl = resolveAccountApiBaseUrl({
|
||||
@@ -203,6 +206,60 @@ export async function switchClineAccount(input: {
|
||||
await service.switchAccount(input.organizationId);
|
||||
}
|
||||
|
||||
export async function loadIndividualSubscriptionPlans(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): Promise<ClineSubscriptionPlan[]> {
|
||||
const service = await createClineAccountService(input);
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
|
||||
}
|
||||
|
||||
export async function loadCurrentUserPlan(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): Promise<UserCurrentPlan | undefined> {
|
||||
const service = await createClineAccountService(input);
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchCurrentUserPlan();
|
||||
}
|
||||
|
||||
export async function loadCurrentUserPlanFromProviderSettings(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
clineApiBaseUrl?: string;
|
||||
}): Promise<UserCurrentPlan | undefined> {
|
||||
const service = await createClineAccountService({
|
||||
config: { apiKey: "", logger: undefined, providerId: "cline" },
|
||||
clineApiBaseUrl: input.clineApiBaseUrl,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
});
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchCurrentUserPlan();
|
||||
}
|
||||
|
||||
export async function loadIndividualSubscriptionPlansFromProviderSettings(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
clineApiBaseUrl?: string;
|
||||
}): Promise<ClineSubscriptionPlan[]> {
|
||||
const service = await createClineAccountService({
|
||||
config: { apiKey: "", logger: undefined, providerId: "cline" },
|
||||
clineApiBaseUrl: input.clineApiBaseUrl,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
});
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
|
||||
}
|
||||
|
||||
async function onChangeToClinePass(config: ClineAccountConfig) {
|
||||
try {
|
||||
await switchClineAccount({
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { ClineSubscriptionPlan } from "@cline/core";
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
getCliSubscriptionUrl,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
import { useTerminalBackground } from "../hooks/use-terminal-background";
|
||||
import {
|
||||
getDefaultForeground,
|
||||
getModeAccent,
|
||||
getModeInputBackground,
|
||||
palette,
|
||||
type TerminalTheme,
|
||||
@@ -266,7 +269,8 @@ function ToolCallView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
function ClineCreditsClinePassErrorView(props: { defaultFg?: string }) {
|
||||
const subscriptionUrl = getCliSubscriptionUrl();
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
@@ -281,39 +285,96 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="You have run out of Cline credits. Add credits in the dashboard to continue."
|
||||
content={
|
||||
"You have run out of Cline credits. Add credits in the dashboard or purchase and switch to ClinePass to continue."
|
||||
}
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Dashboard: </text>
|
||||
<text fg="gray">Purchase Credits: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={CLINE_CREDITS_DASHBOARD_URL}>
|
||||
{CLINE_CREDITS_DASHBOARD_URL}
|
||||
</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Purchase ClinePass: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Switch to ClinePass: </text>
|
||||
<text fg="gray">
|
||||
type /settings in CLI and switch provider to ClinePass
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
|
||||
function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
return <ClineCreditsClinePassErrorView defaultFg={props.defaultFg} />;
|
||||
}
|
||||
|
||||
function ClinePassSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
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="yellow" content="* " />
|
||||
<text fg={planAccent} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor="yellow"
|
||||
borderColor={planAccent}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="yellow">ClinePass subscription required</text>
|
||||
<text fg={planAccent}>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>
|
||||
@@ -333,18 +394,21 @@ function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
|
||||
|
||||
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const planAccent = getModeAccent("plan", props.terminalTheme);
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="yellow" content="* " />
|
||||
<text fg={planAccent} content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor="yellow"
|
||||
borderColor={planAccent}
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="yellow">Personal ClinePass required</text>
|
||||
<text fg={planAccent}>Personal ClinePass required</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
@@ -358,6 +422,7 @@ function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const { entry, accent = palette.act, terminalTheme } = props;
|
||||
@@ -457,11 +522,20 @@ export function ChatEntryView(props: {
|
||||
return (
|
||||
<ClineOrgIndividualInferenceSubscriptionErrorView
|
||||
defaultFg={defaultFg}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClinePassSubscriptionError(entry.text)) {
|
||||
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
|
||||
return (
|
||||
<ClinePassSubscriptionErrorView
|
||||
defaultFg={defaultFg}
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import "opentui-spinner/react";
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import type { AgentMode, ClineSubscriptionPlan } from "@cline/core";
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import {
|
||||
forwardRef,
|
||||
@@ -21,6 +21,7 @@ export interface TranscriptScrollHandle {
|
||||
interface ChatMessageListProps {
|
||||
entries: ChatEntry[];
|
||||
isStreaming?: boolean;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
uiMode?: AgentMode;
|
||||
}
|
||||
|
||||
@@ -100,6 +101,9 @@ export const ChatMessageList = forwardRef<
|
||||
key={key}
|
||||
entry={entry}
|
||||
accent={accent}
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
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,6 +37,7 @@ import {
|
||||
getSearchableListRowsWindow,
|
||||
type SearchableItem,
|
||||
} from "../searchable-list";
|
||||
import { buildClinePassSubscriptionPageUrl } from "./provider-picker-helpers";
|
||||
|
||||
interface ProviderItem {
|
||||
id: string;
|
||||
@@ -248,18 +249,33 @@ export function ProviderPickerContent(
|
||||
);
|
||||
}
|
||||
|
||||
export type ExistingProviderAction = "use_existing" | "reconfigure";
|
||||
export type ExistingProviderAction =
|
||||
| "use_existing"
|
||||
| "reconfigure"
|
||||
| "open_subscription_page"
|
||||
| "open_usage_billing";
|
||||
|
||||
export interface ExistingProviderOption {
|
||||
value: ExistingProviderAction;
|
||||
label: string;
|
||||
onSelect?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
export function UseExistingOrReconfigureContent(
|
||||
props: ChoiceContext<ExistingProviderAction> & {
|
||||
props: ChoiceContext<ExistingProviderOption> & {
|
||||
providerName: string;
|
||||
extraOptions?: ExistingProviderOption[];
|
||||
},
|
||||
) {
|
||||
const { resolve, dismiss, dialogId, providerName } = props;
|
||||
const options: { value: ExistingProviderAction; label: string }[] = [
|
||||
{ value: "use_existing", label: "Use existing configuration" },
|
||||
{ value: "reconfigure", label: "Configure again" },
|
||||
];
|
||||
const { resolve, dismiss, dialogId, providerName, extraOptions } = props;
|
||||
const options: ExistingProviderOption[] = useMemo(
|
||||
() => [
|
||||
{ value: "use_existing", label: "Use existing configuration" },
|
||||
{ value: "reconfigure", label: "Configure again" },
|
||||
...(extraOptions ?? []),
|
||||
],
|
||||
[extraOptions],
|
||||
);
|
||||
const [selected, setSelected] = useState(0);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
@@ -269,7 +285,7 @@ export function UseExistingOrReconfigureContent(
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
const opt = options[selected];
|
||||
if (opt) resolve(opt.value);
|
||||
if (opt) resolve(opt);
|
||||
return;
|
||||
}
|
||||
if (key.name === "up" || (key.ctrl && key.name === "p")) {
|
||||
@@ -314,6 +330,86 @@ 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",
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createContextBar,
|
||||
formatStatusBarUsageText,
|
||||
resolveContextBarFilledForeground,
|
||||
resolveModelDisplayName,
|
||||
} from "./status-bar";
|
||||
|
||||
vi.mock("@opentui/react", () => ({
|
||||
@@ -55,18 +56,44 @@ describe("formatStatusBarUsageText", () => {
|
||||
formatStatusBarUsageText({
|
||||
totalTokens: 12_345,
|
||||
totalCost: 0.123,
|
||||
showCost: true,
|
||||
providerId: "cline",
|
||||
}),
|
||||
).toBe("(12,345 tokens) $0.12");
|
||||
});
|
||||
|
||||
it("omits cost when usage cost is hidden", () => {
|
||||
it("displays subscription message when the provider is a subscription provider", () => {
|
||||
expect(
|
||||
formatStatusBarUsageText({
|
||||
totalTokens: 12_345,
|
||||
totalCost: 0.123,
|
||||
showCost: false,
|
||||
providerId: "cline-pass",
|
||||
}),
|
||||
).toBe("(12,345 tokens)");
|
||||
).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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import { useTerminalDimensions } from "@opentui/react";
|
||||
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
|
||||
import {
|
||||
shouldShowCliUsageCost,
|
||||
shouldShowCliUsageCoveredBySubscription,
|
||||
} from "../../utils/usage-cost-display";
|
||||
import {
|
||||
useTerminalBackground,
|
||||
useTerminalTheme,
|
||||
@@ -46,14 +49,31 @@ function formatCost(cost: number): string {
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatCostText(providerId: string, totalCost: number): string {
|
||||
if (shouldShowCliUsageCoveredBySubscription(providerId)) {
|
||||
return "$0.00 (included with subscription)";
|
||||
}
|
||||
|
||||
if (!shouldShowCliUsageCost(providerId)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return formatCost(totalCost);
|
||||
}
|
||||
|
||||
export function formatStatusBarUsageText(input: {
|
||||
totalTokens: number;
|
||||
totalCost: number;
|
||||
showCost: boolean;
|
||||
providerId: string;
|
||||
}): string {
|
||||
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
|
||||
if (!input.showCost) return tokens;
|
||||
return `${tokens} ${formatCost(input.totalCost)}`;
|
||||
const costText = formatCostText(input.providerId, input.totalCost);
|
||||
|
||||
if (!costText) {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
return `${tokens} ${costText}`;
|
||||
}
|
||||
|
||||
// knownModels keys are bare IDs ("claude-sonnet-4-6") but config.modelId
|
||||
@@ -74,17 +94,22 @@ function lookupModelInfo(
|
||||
}
|
||||
|
||||
export function resolveModelDisplayName(config: {
|
||||
providerId?: string;
|
||||
modelId: string;
|
||||
knownModels?: Record<string, unknown>;
|
||||
thinking?: boolean;
|
||||
reasoningEffort?: string;
|
||||
}): string {
|
||||
const info = lookupModelInfo(config.modelId, config.knownModels);
|
||||
const name = info?.name ?? config.modelId.split("/").pop() ?? config.modelId;
|
||||
const modelIdTail = config.modelId.split("/").pop() ?? config.modelId;
|
||||
const displayName =
|
||||
config.providerId === "cline-pass"
|
||||
? `ClinePass/${modelIdTail}`
|
||||
: (info?.name ?? modelIdTail);
|
||||
if (config.thinking && config.reasoningEffort) {
|
||||
return `${name} (${config.reasoningEffort})`;
|
||||
return `${displayName} (${config.reasoningEffort})`;
|
||||
}
|
||||
return name;
|
||||
return displayName;
|
||||
}
|
||||
|
||||
export function resolveModelMaxInputTokens(config: {
|
||||
@@ -152,7 +177,6 @@ export function StatusBar(props: StatusBarProps) {
|
||||
const bar = hasMaxInputTokens
|
||||
? createContextBar(totalTokens, maxInputTokens)
|
||||
: undefined;
|
||||
const showUsageCost = shouldShowCliUsageCost(props.providerId);
|
||||
|
||||
// Available content width after accounting for padding.
|
||||
// Home view: parent box is capped at 60 wide, status bar adds paddingX=1 (-2).
|
||||
@@ -169,7 +193,7 @@ export function StatusBar(props: StatusBarProps) {
|
||||
const usageText = formatStatusBarUsageText({
|
||||
totalTokens,
|
||||
totalCost,
|
||||
showCost: showUsageCost,
|
||||
providerId: props.providerId,
|
||||
});
|
||||
const contextText = bar
|
||||
? ` ${bar.filled}${bar.empty} ${usageText}`
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
import type { Config } from "../../utils/types";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import {
|
||||
ClinePassSubscriptionContent,
|
||||
CodexCliStatusContent,
|
||||
type ExistingProviderAction,
|
||||
type ExistingProviderOption,
|
||||
OAuthLoginContent,
|
||||
ProviderConfigInputContent,
|
||||
ProviderPickerContent,
|
||||
@@ -78,6 +79,36 @@ 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,
|
||||
@@ -102,14 +133,33 @@ async function runProviderChange(
|
||||
|
||||
let needsAuth = true;
|
||||
if (isProviderConfigured(newProviderId, existingSettings)) {
|
||||
const action = await dialog.choice<ExistingProviderAction>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<ExistingProviderAction>) => (
|
||||
<UseExistingOrReconfigureContent {...ctx} providerName={displayName} />
|
||||
),
|
||||
let option: ExistingProviderOption | undefined;
|
||||
const extraOptions = providerToExistingProviderOptions({
|
||||
providerId: newProviderId,
|
||||
providerName: displayName,
|
||||
dialog,
|
||||
termHeight,
|
||||
});
|
||||
if (!action) return false;
|
||||
needsAuth = action === "reconfigure";
|
||||
while (true) {
|
||||
option = await dialog.choice<ExistingProviderOption>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<ExistingProviderOption>) => (
|
||||
<UseExistingOrReconfigureContent
|
||||
{...ctx}
|
||||
providerName={displayName}
|
||||
extraOptions={extraOptions}
|
||||
/>
|
||||
),
|
||||
});
|
||||
if (!option) return false;
|
||||
if (option.onSelect) {
|
||||
await option.onSelect();
|
||||
option = undefined;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
needsAuth = option.value === "reconfigure";
|
||||
}
|
||||
|
||||
if (needsAuth) {
|
||||
|
||||
@@ -10,6 +10,7 @@ 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";
|
||||
@@ -541,10 +542,17 @@ 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(() => {
|
||||
@@ -560,7 +568,7 @@ function App(props: TuiProps) {
|
||||
});
|
||||
}, 0);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [appView, dialog, notice, onInitialNoticeShown]);
|
||||
}, [appView, currentProviderId, dialog, notice, onInitialNoticeShown]);
|
||||
|
||||
const {
|
||||
appendEntry: appendSessionEntry,
|
||||
@@ -879,6 +887,7 @@ function App(props: TuiProps) {
|
||||
repoStatus,
|
||||
textareaRef: promptInput.textareaRef,
|
||||
transcriptScrollRef,
|
||||
loadIndividualSubscriptionPlans: props.loadIndividualSubscriptionPlans,
|
||||
queuedPrompts,
|
||||
selectedQueuedPromptId,
|
||||
editingQueuedPrompt,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
AgentEvent,
|
||||
AgentMode,
|
||||
CheckpointEntry,
|
||||
ClineSubscriptionPlan,
|
||||
TeamEvent,
|
||||
} from "@cline/core";
|
||||
import type {
|
||||
@@ -129,6 +130,7 @@ export interface TuiProps {
|
||||
loadAdditionalSlashCommands?: () => Promise<InteractiveSlashCommand[]>;
|
||||
loadWelcomeLine?: () => Promise<string | undefined>;
|
||||
loadClineAccount: () => Promise<ClineAccountSnapshot>;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
switchClineAccount: (organizationId?: string | null) => Promise<void>;
|
||||
loadConfigData: (
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
|
||||
@@ -50,6 +50,7 @@ export function ChatView(props: {
|
||||
};
|
||||
textareaRef?: React.MutableRefObject<TextareaHandle | null>;
|
||||
transcriptScrollRef?: React.Ref<TranscriptScrollHandle>;
|
||||
loadIndividualSubscriptionPlans?: TuiProps["loadIndividualSubscriptionPlans"];
|
||||
autocomplete?: AutocompleteDropdownProps;
|
||||
queuedPrompts?: QueuedPromptItem[];
|
||||
selectedQueuedPromptId?: string | null;
|
||||
@@ -89,6 +90,7 @@ export function ChatView(props: {
|
||||
ref={props.transcriptScrollRef}
|
||||
entries={session.entries}
|
||||
isStreaming={session.isStreaming}
|
||||
loadIndividualSubscriptionPlans={props.loadIndividualSubscriptionPlans}
|
||||
uiMode={session.uiMode}
|
||||
/>
|
||||
|
||||
|
||||
@@ -10,16 +10,24 @@ 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,
|
||||
@@ -48,6 +56,8 @@ import {
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import { useOnboardingKeyboard } from "./keyboard";
|
||||
import {
|
||||
CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
type ClinePassSubscriptionStatus,
|
||||
getMainMenuOptions,
|
||||
type ModelEntry,
|
||||
type OnboardingResult,
|
||||
@@ -78,8 +88,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const menuOptions = useMemo(
|
||||
() =>
|
||||
getMainMenuOptions({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
isClinePassEnabled: true,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
@@ -150,6 +159,19 @@ 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(
|
||||
() =>
|
||||
@@ -265,6 +287,62 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
[providerSettingsManager],
|
||||
);
|
||||
|
||||
const refreshClinePassSubscriptionStatus = useCallback(() => {
|
||||
setClinePassSubscriptionStatus("loading");
|
||||
setClinePassSubscriptionError("");
|
||||
setClinePassCurrentPlanName("");
|
||||
setClinePassSubscriptionOpenStatus("");
|
||||
|
||||
loadCurrentUserPlanFromProviderSettings({ providerSettingsManager })
|
||||
.then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(reason) => ({ status: "rejected" as const, reason }),
|
||||
)
|
||||
.then((currentPlanResult) =>
|
||||
loadIndividualSubscriptionPlansFromProviderSettings({
|
||||
providerSettingsManager,
|
||||
})
|
||||
.then(
|
||||
(value) => ({ status: "fulfilled" as const, value }),
|
||||
(reason) => ({ status: "rejected" as const, reason }),
|
||||
)
|
||||
.then((availablePlansResult) => ({
|
||||
availablePlansResult,
|
||||
currentPlanResult,
|
||||
})),
|
||||
)
|
||||
.then(({ currentPlanResult, availablePlansResult }) => {
|
||||
if (availablePlansResult.status === "fulfilled") {
|
||||
setClinePassPlanFeatures(
|
||||
getIndividualPlanFeatures(availablePlansResult.value),
|
||||
);
|
||||
}
|
||||
|
||||
if (currentPlanResult.status === "rejected") {
|
||||
const error = currentPlanResult.reason;
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
if (message.trim().toLowerCase() === "no plan found for user") {
|
||||
setClinePassSubscriptionStatus("unsubscribed");
|
||||
return;
|
||||
}
|
||||
setClinePassSubscriptionError(message);
|
||||
setClinePassSubscriptionStatus("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const plan = currentPlanResult.value?.plan;
|
||||
if (plan) {
|
||||
setClinePassCurrentPlanName(
|
||||
plan.displayName || plan.name || plan.id || "ClinePass",
|
||||
);
|
||||
setClinePassSubscriptionStatus("subscribed");
|
||||
} else {
|
||||
setClinePassSubscriptionStatus("unsubscribed");
|
||||
}
|
||||
});
|
||||
}, [providerSettingsManager]);
|
||||
|
||||
const transitionToModelPicker = useCallback(
|
||||
(providerId: string) => {
|
||||
setActiveProviderId(providerId);
|
||||
@@ -288,6 +366,27 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
[providers, loadModelsForProvider, providerSettingsManager],
|
||||
);
|
||||
|
||||
const transitionToClinePassSubscription = useCallback(() => {
|
||||
setActiveProviderId("cline-pass");
|
||||
const provider = providers.find((p) => p.id === "cline-pass");
|
||||
setActiveProviderName(provider?.name ?? "ClinePass");
|
||||
setModelsDefaultId(provider?.defaultModelId ?? "");
|
||||
setClinePassSubscriptionSelected(0);
|
||||
setStep("cline_pass_subscription");
|
||||
refreshClinePassSubscriptionStatus();
|
||||
}, [providers, refreshClinePassSubscriptionStatus]);
|
||||
|
||||
const handleAuthComplete = useCallback(
|
||||
(providerId: OnboardingOAuthProviderId) => {
|
||||
if (providerId === "cline-pass") {
|
||||
transitionToClinePassSubscription();
|
||||
return;
|
||||
}
|
||||
transitionToModelPicker(providerId);
|
||||
},
|
||||
[transitionToClinePassSubscription, transitionToModelPicker],
|
||||
);
|
||||
|
||||
const resetAuth = useCallback(() => {
|
||||
setAuthStatus("");
|
||||
setAuthUrl("");
|
||||
@@ -313,11 +412,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setVerifyUrl: setDeviceVerifyUrl,
|
||||
setStatus: setDeviceStatus,
|
||||
setError: setDeviceError,
|
||||
onComplete: transitionToModelPicker,
|
||||
onComplete: handleAuthComplete,
|
||||
telemetry: getCliTelemetryService(),
|
||||
});
|
||||
},
|
||||
[providerSettingsManager, transitionToModelPicker],
|
||||
[providerSettingsManager, handleAuthComplete],
|
||||
);
|
||||
|
||||
const startOAuthFlow = useCallback(
|
||||
@@ -339,18 +438,46 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setStatus: setAuthStatus,
|
||||
setAuthUrl,
|
||||
setError: setAuthError,
|
||||
onComplete: transitionToModelPicker,
|
||||
onComplete: handleAuthComplete,
|
||||
telemetry: getCliTelemetryService(),
|
||||
});
|
||||
},
|
||||
[
|
||||
providerSettingsManager,
|
||||
resetAuth,
|
||||
transitionToModelPicker,
|
||||
handleAuthComplete,
|
||||
startDeviceCodeFlow,
|
||||
],
|
||||
);
|
||||
|
||||
const continueFromClinePassSubscription = useCallback(() => {
|
||||
transitionToModelPicker("cline-pass");
|
||||
}, [transitionToModelPicker]);
|
||||
|
||||
const openClinePassSubscriptionPage = useCallback(() => {
|
||||
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);
|
||||
@@ -632,6 +759,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
modelList,
|
||||
clineEntries,
|
||||
clineModelSelected,
|
||||
clinePassSubscriptionStatus,
|
||||
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
clinePassSubscriptionSelected,
|
||||
thinkingSelected,
|
||||
setStep,
|
||||
setMenuSelected,
|
||||
@@ -648,7 +778,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setDeviceError,
|
||||
setDeviceStatus,
|
||||
setClineModelSelected,
|
||||
setClinePassSubscriptionSelected,
|
||||
setThinkingSelected,
|
||||
continueFromClinePassSubscription,
|
||||
refreshClinePassSubscriptionStatus,
|
||||
openClinePassSubscriptionPage,
|
||||
abortOAuth: () => {
|
||||
authAbortRef.current = true;
|
||||
},
|
||||
@@ -670,6 +804,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
return {
|
||||
activeProviderName,
|
||||
activeProviderId,
|
||||
authError,
|
||||
authStatus,
|
||||
authUrl,
|
||||
@@ -682,6 +817,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
clineEntries,
|
||||
clineKnownModels,
|
||||
clineModelSelected,
|
||||
clinePassCurrentPlanName,
|
||||
clinePassPlanFeatures,
|
||||
clinePassSubscriptionError,
|
||||
clinePassSubscriptionOpenStatus,
|
||||
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
clinePassSubscriptionSelected,
|
||||
clinePassSubscriptionStatus,
|
||||
clinePassSubscriptionUrl,
|
||||
deviceError,
|
||||
deviceStatus,
|
||||
deviceUserCode,
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from "./auth";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import {
|
||||
type ClinePassSubscriptionOption,
|
||||
type ClinePassSubscriptionStatus,
|
||||
type MenuOption,
|
||||
type OnboardingStep,
|
||||
THINKING_LEVELS,
|
||||
@@ -26,6 +28,9 @@ export function useOnboardingKeyboard(input: {
|
||||
modelList: SearchableListState;
|
||||
clineEntries: ClineModelPickerEntry[];
|
||||
clineModelSelected: number;
|
||||
clinePassSubscriptionStatus: ClinePassSubscriptionStatus;
|
||||
clinePassSubscriptionOptions: ClinePassSubscriptionOption[];
|
||||
clinePassSubscriptionSelected: number;
|
||||
thinkingSelected: number;
|
||||
setStep: (step: OnboardingStep) => void;
|
||||
setMenuSelected: Dispatch<SetStateAction<number>>;
|
||||
@@ -38,7 +43,11 @@ export function useOnboardingKeyboard(input: {
|
||||
setDeviceError: (value: string) => void;
|
||||
setDeviceStatus: (value: string) => void;
|
||||
setClineModelSelected: Dispatch<SetStateAction<number>>;
|
||||
setClinePassSubscriptionSelected: Dispatch<SetStateAction<number>>;
|
||||
setThinkingSelected: Dispatch<SetStateAction<number>>;
|
||||
continueFromClinePassSubscription: () => void;
|
||||
refreshClinePassSubscriptionStatus: () => void;
|
||||
openClinePassSubscriptionPage: () => void;
|
||||
abortOAuth: () => void;
|
||||
abortDeviceCode: () => void;
|
||||
resetAuth: () => void;
|
||||
@@ -93,6 +102,11 @@ export function useOnboardingKeyboard(input: {
|
||||
input.setStep("byo_provider");
|
||||
return;
|
||||
}
|
||||
if (input.step === "cline_pass_subscription") {
|
||||
input.setStep("menu");
|
||||
input.setMenuSelected(0);
|
||||
return;
|
||||
}
|
||||
if (input.step === "cline_model") {
|
||||
input.setStep("menu");
|
||||
input.setMenuSelected(0);
|
||||
@@ -135,6 +149,43 @@ export function useOnboardingKeyboard(input: {
|
||||
|
||||
if (input.step === "device_code") return;
|
||||
|
||||
if (input.step === "cline_pass_subscription") {
|
||||
const total = input.clinePassSubscriptionOptions.length;
|
||||
if (total === 0) return;
|
||||
if (key.name === "up" || (key.ctrl && key.name === "p")) {
|
||||
input.setClinePassSubscriptionSelected((s) =>
|
||||
s <= 0 ? total - 1 : s - 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (key.name === "down" || (key.ctrl && key.name === "n")) {
|
||||
input.setClinePassSubscriptionSelected((s) =>
|
||||
s >= total - 1 ? 0 : s + 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
const option =
|
||||
input.clinePassSubscriptionOptions[
|
||||
Math.min(input.clinePassSubscriptionSelected, total - 1)
|
||||
];
|
||||
if (!option) return;
|
||||
if (option.value === "subscribe") {
|
||||
input.openClinePassSubscriptionPage();
|
||||
} else if (option.value === "refresh") {
|
||||
if (input.clinePassSubscriptionStatus !== "loading") {
|
||||
input.refreshClinePassSubscriptionStatus();
|
||||
}
|
||||
} else if (option.value === "skip") {
|
||||
input.continueFromClinePassSubscription();
|
||||
} else if (option.value === "back") {
|
||||
input.setStep("menu");
|
||||
input.setMenuSelected(0);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.step === "menu") {
|
||||
if (key.name === "up") {
|
||||
input.setMenuSelected((s) =>
|
||||
|
||||
@@ -8,6 +8,7 @@ export type OnboardingStep =
|
||||
| "byo_provider"
|
||||
| "byo_apikey"
|
||||
| "codex_cli_setup"
|
||||
| "cline_pass_subscription"
|
||||
| "cline_model"
|
||||
| "model_picker"
|
||||
| "custom_model_id"
|
||||
@@ -36,6 +37,17 @@ export interface MenuOption {
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export type ClinePassSubscriptionAction =
|
||||
| "subscribe"
|
||||
| "refresh"
|
||||
| "skip"
|
||||
| "back";
|
||||
|
||||
export interface ClinePassSubscriptionOption {
|
||||
value: ClinePassSubscriptionAction;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const MAIN_MENU: MenuOption[] = [
|
||||
{
|
||||
label: "Sign in with Cline",
|
||||
@@ -71,6 +83,25 @@ export function getMainMenuOptions(options?: {
|
||||
);
|
||||
}
|
||||
|
||||
export const CLINE_PASS_SUBSCRIPTION_OPTIONS: ClinePassSubscriptionOption[] = [
|
||||
{
|
||||
value: "subscribe",
|
||||
label: "Subscribe to ClinePass",
|
||||
},
|
||||
{
|
||||
value: "refresh",
|
||||
label: "Re-check subscription status",
|
||||
},
|
||||
{
|
||||
value: "skip",
|
||||
label: "Skip for now",
|
||||
},
|
||||
{
|
||||
value: "back",
|
||||
label: "Go back",
|
||||
},
|
||||
];
|
||||
|
||||
export interface OnboardingResult {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
@@ -96,6 +127,12 @@ export interface ModelEntry {
|
||||
supportsReasoning: boolean;
|
||||
}
|
||||
|
||||
export type ClinePassSubscriptionStatus =
|
||||
| "loading"
|
||||
| "subscribed"
|
||||
| "unsubscribed"
|
||||
| "error";
|
||||
|
||||
export interface ProviderCatalogItem {
|
||||
id: string;
|
||||
name: string;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import "opentui-spinner/react";
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
CODEX_CLI_INSTALL_URL,
|
||||
type CodexCliStatus,
|
||||
@@ -17,10 +19,18 @@ import {
|
||||
TrackedRobot,
|
||||
type useMouseTracker,
|
||||
} from "../../components/tracked-robot";
|
||||
import { useTerminalBackground } from "../../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, palette } from "../../palette";
|
||||
import {
|
||||
useTerminalBackground,
|
||||
useTerminalTheme,
|
||||
} from "../../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, getModeAccent, palette } from "../../palette";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import { type MenuOption, THINKING_LEVELS } from "./model";
|
||||
import {
|
||||
type ClinePassSubscriptionOption,
|
||||
type ClinePassSubscriptionStatus,
|
||||
type MenuOption,
|
||||
THINKING_LEVELS,
|
||||
} from "./model";
|
||||
|
||||
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
|
||||
|
||||
@@ -29,6 +39,10 @@ function useDefaultFg(): string | undefined {
|
||||
return getDefaultForeground(terminalBg);
|
||||
}
|
||||
|
||||
function getClinePassSubscriptionOptionId(index: number): string {
|
||||
return `cline-pass-subscription-option-${index}`;
|
||||
}
|
||||
|
||||
interface OnboardingFrameProps {
|
||||
children: ReactNode;
|
||||
compact: boolean;
|
||||
@@ -468,6 +482,198 @@ 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,6 +6,7 @@ import { useOnboardingController } from "./controller";
|
||||
import { getOAuthProviderLabel, type OnboardingResult } from "./model";
|
||||
import {
|
||||
OnboardingClineModelScreen,
|
||||
OnboardingClinePassSubscriptionScreen,
|
||||
OnboardingCodexCliScreen,
|
||||
OnboardingCustomModelIdScreen,
|
||||
OnboardingDeviceCodeScreen,
|
||||
@@ -121,6 +122,24 @@ 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,8 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getCliNotSubscribedMessage,
|
||||
getCliSubscriptionUrl,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getCliSubscriptionUrl,
|
||||
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-100&personal=true",
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
@@ -8,13 +9,11 @@ import {
|
||||
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
};
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
return `${new URL(
|
||||
"/promo?code=CLI-100&personal=true",
|
||||
"/promo?code=CLI-8OFF&personal=true",
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()}`;
|
||||
}
|
||||
@@ -23,6 +22,14 @@ 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 (
|
||||
|
||||
@@ -2,7 +2,6 @@ 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) => {
|
||||
@@ -13,20 +12,13 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("listLocalProviders", () => {
|
||||
it("passes the ClinePass feature flag into the SDK provider list", async () => {
|
||||
it("enables ClinePass when listing 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,
|
||||
});
|
||||
|
||||
@@ -2,13 +2,11 @@ import {
|
||||
listLocalProviders as internalListLocalProviders,
|
||||
type ProviderSettingsManager,
|
||||
} from "@cline/core";
|
||||
import { getCliFeatureFlagsService } from "./feature-flags";
|
||||
|
||||
export async function listLocalProviders(
|
||||
manager: ProviderSettingsManager,
|
||||
): ReturnType<typeof internalListLocalProviders> {
|
||||
return await internalListLocalProviders(manager, {
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCliReasoning } from "./reasoning";
|
||||
|
||||
describe("resolveCliReasoning", () => {
|
||||
it("leaves reasoning unset when neither CLI nor persisted settings specify it", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit --thinking none as disabled reasoning", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
thinkingExplicitlySet: true,
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers explicit --thinking over persisted reasoning settings", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: true,
|
||||
thinkingExplicitlySet: true,
|
||||
reasoningEffort: "low",
|
||||
persistedReasoning: { enabled: false },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: true,
|
||||
reasoningEffort: "low",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses persisted disabled reasoning when --thinking is unset", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { enabled: false },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses persisted effort none as disabled reasoning when --thinking is unset", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { effort: "none" },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses persisted active effort when --thinking is unset", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { enabled: true, effort: "high" },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses medium effort when persisted reasoning is enabled without an effort", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { enabled: true },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ProviderSettings } from "@cline/core";
|
||||
import type { CliReasoningEffort } from "./types";
|
||||
|
||||
type ActiveCliReasoningEffort = Exclude<CliReasoningEffort, "none">;
|
||||
|
||||
const ACTIVE_REASONING_EFFORTS = new Set<ActiveCliReasoningEffort>([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
]);
|
||||
|
||||
export interface ResolveCliReasoningInput {
|
||||
thinking: boolean;
|
||||
thinkingExplicitlySet?: boolean;
|
||||
reasoningEffort?: CliReasoningEffort;
|
||||
persistedReasoning?: ProviderSettings["reasoning"];
|
||||
}
|
||||
|
||||
export interface ResolvedCliReasoning {
|
||||
thinking?: boolean;
|
||||
reasoningEffort?: ActiveCliReasoningEffort;
|
||||
}
|
||||
|
||||
function isActiveReasoningEffort(
|
||||
effort: unknown,
|
||||
): effort is ActiveCliReasoningEffort {
|
||||
return (
|
||||
typeof effort === "string" &&
|
||||
ACTIVE_REASONING_EFFORTS.has(effort as ActiveCliReasoningEffort)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCliReasoning({
|
||||
thinking,
|
||||
thinkingExplicitlySet,
|
||||
reasoningEffort,
|
||||
persistedReasoning,
|
||||
}: ResolveCliReasoningInput): ResolvedCliReasoning {
|
||||
if (thinkingExplicitlySet) {
|
||||
return {
|
||||
thinking,
|
||||
reasoningEffort: isActiveReasoningEffort(reasoningEffort)
|
||||
? reasoningEffort
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
persistedReasoning?.enabled === false ||
|
||||
persistedReasoning?.effort === "none"
|
||||
) {
|
||||
return { thinking: false, reasoningEffort: undefined };
|
||||
}
|
||||
|
||||
if (isActiveReasoningEffort(persistedReasoning?.effort)) {
|
||||
return { thinking: true, reasoningEffort: persistedReasoning.effort };
|
||||
}
|
||||
|
||||
if (persistedReasoning?.enabled === true) {
|
||||
return { thinking: true, reasoningEffort: "medium" };
|
||||
}
|
||||
|
||||
return { thinking: undefined, reasoningEffort: undefined };
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export interface Config extends Omit<CoreSessionConfig, "apiKey" | "mode"> {
|
||||
timeoutSeconds?: number;
|
||||
sandbox: boolean;
|
||||
sandboxDataDir?: string;
|
||||
thinking: boolean;
|
||||
thinking?: boolean;
|
||||
outputMode: CliOutputMode;
|
||||
mode: CliAgentMode;
|
||||
defaultToolAutoApprove: boolean;
|
||||
|
||||
@@ -3,3 +3,9 @@ import { Llms } from "@cline/core";
|
||||
export function shouldShowCliUsageCost(providerId: string): boolean {
|
||||
return Llms.shouldShowProviderUsageCost(providerId);
|
||||
}
|
||||
|
||||
export function shouldShowCliUsageCoveredBySubscription(
|
||||
providerId: string,
|
||||
): boolean {
|
||||
return Llms.resolveProviderUsageCostDisplay(providerId) === "subscription";
|
||||
}
|
||||
|
||||
@@ -51,6 +51,36 @@ 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([
|
||||
@@ -288,8 +318,6 @@ describe("marketplace installer", () => {
|
||||
"remove",
|
||||
"cline-sdk",
|
||||
"-g",
|
||||
"-a",
|
||||
"cline",
|
||||
"-y",
|
||||
]);
|
||||
});
|
||||
@@ -535,16 +563,13 @@ describe("marketplace installer", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs official plugin uninstalls through the current Cline CLI", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
it("uninstalls official marketplace plugins through the shared core service", async () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-plugin-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const installPath = createInstalledOfficialPlugin(clineDir, "goal");
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({
|
||||
name: "goal",
|
||||
installPath: "/tmp/plugin",
|
||||
removedPaths: ["/tmp/plugin"],
|
||||
entryPaths: [],
|
||||
}),
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
@@ -565,12 +590,8 @@ describe("marketplace installer", () => {
|
||||
message: "Uninstalled Goal.",
|
||||
});
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"plugin",
|
||||
"uninstall",
|
||||
"goal",
|
||||
"--json",
|
||||
]);
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
expect(existsSync(installPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves desktop installs from the server catalog instead of browser-sent args", async () => {
|
||||
@@ -614,15 +635,12 @@ describe("marketplace installer", () => {
|
||||
});
|
||||
|
||||
it("resolves desktop uninstalls from the server catalog instead of browser-sent args", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-plugin-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const installPath = createInstalledOfficialPlugin(clineDir, "goal");
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({
|
||||
name: "goal",
|
||||
installPath: "/tmp/plugin",
|
||||
removedPaths: ["/tmp/plugin"],
|
||||
entryPaths: [],
|
||||
}),
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
@@ -650,12 +668,8 @@ describe("marketplace installer", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"plugin",
|
||||
"uninstall",
|
||||
"goal",
|
||||
"--json",
|
||||
]);
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
expect(existsSync(installPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("uninstalls MCP marketplace entries from Cline MCP settings", async () => {
|
||||
|
||||
@@ -18,8 +18,11 @@ 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";
|
||||
@@ -792,50 +795,6 @@ async function installSkill(
|
||||
};
|
||||
}
|
||||
|
||||
async function uninstallSkill(
|
||||
entry: MarketplaceInstallInput,
|
||||
spawnCommand: SpawnCommand,
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const installedName = findInstalledGlobalSkillName(entry);
|
||||
if (!installedName) {
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "uninstalled",
|
||||
message: `${entry.name ?? entry.id} is not installed.`,
|
||||
};
|
||||
}
|
||||
const result = await spawnCommand("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
installedName,
|
||||
"-g",
|
||||
"-a",
|
||||
"cline",
|
||||
"-y",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`Skill uninstall failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
const output = commandOutput(result);
|
||||
if (isGlobalSkillInstalled(entry)) {
|
||||
throw new Error(
|
||||
`Skill uninstall completed, but ${entry.name ?? entry.id} is still present in Cline's global skills directories.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${entry.name ?? entry.id}.`,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
async function installPlugin(
|
||||
entry: MarketplaceInstallInput,
|
||||
spawnCommand: SpawnCommand,
|
||||
@@ -886,47 +845,6 @@ async function installPlugin(
|
||||
};
|
||||
}
|
||||
|
||||
async function uninstallPlugin(
|
||||
entry: MarketplaceInstallInput,
|
||||
spawnCommand: SpawnCommand,
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const installArgs = entry.install.args ?? [];
|
||||
const target = installArgs[0]?.trim() || entry.id;
|
||||
if (!target) {
|
||||
throw new Error("Plugin marketplace uninstalls require a plugin name.");
|
||||
}
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"plugin",
|
||||
"uninstall",
|
||||
target,
|
||||
"--json",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`Plugin uninstall failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${entry.name ?? entry.id}.`,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installMarketplaceEntry(
|
||||
args?: Record<string, unknown>,
|
||||
options: { spawnCommand?: SpawnCommand } = {},
|
||||
@@ -983,24 +901,21 @@ export async function uninstallMarketplaceEntry(
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const entry = readInstallInput(args);
|
||||
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
|
||||
if (entry.type === "mcp") {
|
||||
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
|
||||
const response = deleteMcpServer(String(input.name ?? ""));
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${entry.name ?? input.name ?? entry.id}.`,
|
||||
details: { mcp: response },
|
||||
};
|
||||
}
|
||||
if (entry.type === "skill") {
|
||||
return uninstallSkill(entry, spawnCommand);
|
||||
}
|
||||
if (entry.type === "plugin") {
|
||||
return uninstallPlugin(entry, spawnCommand);
|
||||
}
|
||||
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
|
||||
let mcpDetails: JsonRecord | undefined;
|
||||
const result = await uninstallCoreMarketplaceEntry(
|
||||
entry satisfies MarketplaceEntryInput,
|
||||
{
|
||||
deleteMcpServer: (name) => {
|
||||
mcpDetails = deleteMcpServer(name);
|
||||
},
|
||||
spawnCommand: (command, commandArgs) =>
|
||||
spawnCommand(command, commandArgs),
|
||||
},
|
||||
);
|
||||
return {
|
||||
...(result satisfies MarketplaceActionResult),
|
||||
details: mcpDetails ? { mcp: mcpDetails } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function installMarketplaceEntryFromCatalog(
|
||||
|
||||
@@ -42,8 +42,7 @@
|
||||
"activationEvents": [
|
||||
"onLanguage",
|
||||
"onUri",
|
||||
"onStartupFinished",
|
||||
"workspaceContains:evals.env"
|
||||
"onStartupFinished"
|
||||
],
|
||||
"main": "./dist/extension.js",
|
||||
"contributes": {
|
||||
|
||||
@@ -13,7 +13,9 @@ 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 {
|
||||
@@ -87,6 +89,10 @@ message ToggleMarketplaceLocalInstalledEntryRequest {
|
||||
bool enabled = 2;
|
||||
}
|
||||
|
||||
message MarketplaceLocalInstalledEntryRequest {
|
||||
MarketplaceLocalInstalledEntry entry = 1;
|
||||
}
|
||||
|
||||
message MarketplaceEntryRequest {
|
||||
MarketplaceEntry entry = 1;
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ message ModelsApiOptions {
|
||||
optional bool azure_identity = 44;
|
||||
|
||||
// Plan mode configurations
|
||||
optional ApiProvider plan_mode_api_provider = 100;
|
||||
optional string plan_mode_api_provider = 100;
|
||||
optional string plan_mode_api_model_id = 101;
|
||||
optional int64 plan_mode_thinking_budget_tokens = 102;
|
||||
optional string plan_mode_reasoning_effort = 103;
|
||||
@@ -438,7 +438,7 @@ message ModelsApiOptions {
|
||||
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 138;
|
||||
|
||||
// Act mode configurations
|
||||
optional ApiProvider act_mode_api_provider = 200;
|
||||
optional string act_mode_api_provider = 200;
|
||||
optional string act_mode_api_model_id = 201;
|
||||
optional int64 act_mode_thinking_budget_tokens = 202;
|
||||
optional string act_mode_reasoning_effort = 203;
|
||||
@@ -568,59 +568,6 @@ message OcaCompatibleModelInfo {
|
||||
optional string error = 2;
|
||||
}
|
||||
|
||||
// API Provider enumeration
|
||||
enum ApiProvider {
|
||||
ANTHROPIC = 0;
|
||||
OPENROUTER = 1;
|
||||
BEDROCK = 2;
|
||||
VERTEX = 3;
|
||||
OPENAI = 4;
|
||||
OLLAMA = 5;
|
||||
LMSTUDIO = 6;
|
||||
GEMINI = 7;
|
||||
OPENAI_NATIVE = 8;
|
||||
REQUESTY = 9;
|
||||
TOGETHER = 10;
|
||||
DEEPSEEK = 11;
|
||||
QWEN = 12;
|
||||
DOUBAO = 13;
|
||||
MISTRAL = 14;
|
||||
VSCODE_LM = 15;
|
||||
CLINE = 16;
|
||||
LITELLM = 17;
|
||||
NEBIUS = 18;
|
||||
FIREWORKS = 19;
|
||||
ASKSAGE = 20;
|
||||
XAI = 21;
|
||||
SAMBANOVA = 22;
|
||||
CEREBRAS = 23;
|
||||
GROQ = 24;
|
||||
SAPAICORE = 25;
|
||||
CLAUDE_CODE = 26;
|
||||
MOONSHOT = 27;
|
||||
HUGGINGFACE = 28;
|
||||
HUAWEI_CLOUD_MAAS = 29;
|
||||
BASETEN = 30;
|
||||
ZAI = 31;
|
||||
VERCEL_AI_GATEWAY = 32;
|
||||
QWEN_CODE = 33;
|
||||
DIFY = 34;
|
||||
OCA = 35;
|
||||
MINIMAX = 36;
|
||||
HICAP = 37;
|
||||
AIHUBMIX = 38;
|
||||
NOUSRESEARCH = 39;
|
||||
OPENAI_CODEX = 40;
|
||||
WANDB = 41;
|
||||
CLINE_PASS = 42;
|
||||
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;
|
||||
@@ -760,7 +707,7 @@ message ModelsApiConfiguration {
|
||||
optional string wandb_api_key = 87;
|
||||
|
||||
// Plan mode configurations
|
||||
optional ApiProvider plan_mode_api_provider = 100;
|
||||
optional string plan_mode_api_provider = 100;
|
||||
optional string plan_mode_api_model_id = 101;
|
||||
optional int64 plan_mode_thinking_budget_tokens = 102;
|
||||
optional string plan_mode_reasoning_effort = 103;
|
||||
@@ -806,7 +753,7 @@ message ModelsApiConfiguration {
|
||||
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 143;
|
||||
|
||||
// Act mode configurations
|
||||
optional ApiProvider act_mode_api_provider = 200;
|
||||
optional string act_mode_api_provider = 200;
|
||||
optional string act_mode_api_model_id = 201;
|
||||
optional int64 act_mode_thinking_budget_tokens = 202;
|
||||
optional string act_mode_reasoning_effort = 203;
|
||||
|
||||
@@ -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 ApiProvider plan_mode_api_provider = 124;
|
||||
optional ApiProvider act_mode_api_provider = 125;
|
||||
optional string plan_mode_api_provider = 124;
|
||||
optional string act_mode_api_provider = 125;
|
||||
optional string hicap_model_id = 126;
|
||||
optional string lm_studio_model_id = 127;
|
||||
optional AutoApprovalSettings auto_approval_settings = 128;
|
||||
|
||||
@@ -12,6 +12,8 @@ option java_package = "bot.cline.proto";
|
||||
service TaskService {
|
||||
// Cancels the currently running task
|
||||
rpc cancelTask(EmptyRequest) returns (Empty);
|
||||
// Cancels a queued prompt by ID
|
||||
rpc cancelQueuedPrompt(StringRequest) returns (Empty);
|
||||
// Cancels the currently running background command
|
||||
rpc cancelBackgroundCommand(EmptyRequest) returns (Empty);
|
||||
// Clears the current task
|
||||
|
||||
@@ -237,6 +237,16 @@ message ShowWebviewEvent {
|
||||
bool preserve_editor_focus = 1; // When true, webview should not steal focus from editor
|
||||
}
|
||||
|
||||
message IntentEvent {
|
||||
string action = 1;
|
||||
string source = 2;
|
||||
bool has_text = 3;
|
||||
bool has_images = 4;
|
||||
bool has_files = 5;
|
||||
bool has_active_task = 6;
|
||||
int32 text_length = 7;
|
||||
}
|
||||
|
||||
// UiService provides methods for managing UI interactions
|
||||
service UiService {
|
||||
// Scrolls to a specific settings section in the settings view
|
||||
@@ -292,4 +302,7 @@ service UiService {
|
||||
|
||||
// Opens the Cline walkthrough
|
||||
rpc openWalkthrough(EmptyRequest) returns (Empty);
|
||||
|
||||
// Tracks intent signals before task creation or first model activity
|
||||
rpc trackIntent(IntentEvent) returns (Empty);
|
||||
}
|
||||
|
||||
@@ -61,33 +61,36 @@ 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 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
|
||||
// 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
|
||||
// integration compile only ever sees mocha-owned tests.
|
||||
const projectRoot = path.join(__dirname, "..")
|
||||
const bunTestImport = /from\s+["']bun:test["']/
|
||||
function collectBunTestFiles(dir, acc) {
|
||||
const nonMochaTestImport =
|
||||
/from\s+["'](?:bun:test|vitest(?:\/[^"']*)?|@vitest\/[^"']*)["']/
|
||||
function collectNonMochaTestFiles(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()) {
|
||||
collectBunTestFiles(full, acc)
|
||||
collectNonMochaTestFiles(full, acc)
|
||||
} else if (entry.isFile() && entry.name.endsWith(".test.ts")) {
|
||||
if (bunTestImport.test(fs.readFileSync(full, "utf-8"))) {
|
||||
if (nonMochaTestImport.test(fs.readFileSync(full, "utf-8"))) {
|
||||
acc.push(path.relative(projectRoot, full).split(path.sep).join("/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}
|
||||
const bunOwnedTests = collectBunTestFiles(path.join(projectRoot, "src"), [])
|
||||
const nonMochaOwnedTests = collectNonMochaTestFiles(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 ?? []), ...bunOwnedTests]
|
||||
baseTestConfig.exclude = [...(baseTestConfig.exclude ?? []), ...nonMochaOwnedTests]
|
||||
const generatedConfigPath = path.join(projectRoot, "tsconfig.test.generated.json")
|
||||
fs.writeFileSync(generatedConfigPath, JSON.stringify(baseTestConfig, null, "\t"))
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ import { getDistinctId } from "./services/logging/distinctId"
|
||||
import { telemetryService } from "./services/telemetry"
|
||||
import { PostHogClientProvider } from "./services/telemetry/providers/posthog/PostHogClientProvider"
|
||||
import { ClineTempManager } from "./services/temp"
|
||||
import { cleanupTestMode } from "./services/test/TestMode"
|
||||
import { ShowMessageType } from "./shared/proto/host/window"
|
||||
import { syncWorker } from "./shared/services/worker/sync"
|
||||
import { getBlobStoreSettingsFromEnv } from "./shared/services/worker/worker"
|
||||
@@ -171,9 +170,6 @@ export async function tearDown(): Promise<void> {
|
||||
HookDiscoveryCache.getInstance().dispose()
|
||||
// Stop periodic temp file cleanup
|
||||
ClineTempManager.stopPeriodicCleanup()
|
||||
|
||||
// Clean up test mode
|
||||
cleanupTestMode()
|
||||
} finally {
|
||||
try {
|
||||
await StateManager.get().flushPendingState()
|
||||
|
||||
@@ -1,65 +1,16 @@
|
||||
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 ".."
|
||||
|
||||
/**
|
||||
* 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
|
||||
function coreSkillToSkillInfo(skill: CoreSettingsItem): SkillInfo {
|
||||
return SkillInfo.create({
|
||||
name: skill.name,
|
||||
description: skill.description ?? "",
|
||||
path: skill.path,
|
||||
enabled: skill.enabled !== false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,33 +21,15 @@ export async function refreshSkills(controller: Controller): Promise<RefreshedSk
|
||||
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
|
||||
const primaryWorkspace = workspacePaths.paths[0]
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
|
||||
// Add remote skills from remote config.
|
||||
// Precedence: remote (enterprise) > disk-global (user) > project (workspace).
|
||||
@@ -120,12 +53,6 @@ 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,
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
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,13 +1,20 @@
|
||||
import { type MarketplaceEntryRequest, MarketplaceInstallResult } from "@shared/proto/cline/marketplace"
|
||||
import type { Controller } from "../index"
|
||||
import { installMarketplaceEntryWithCli } from "./marketplace-helpers"
|
||||
import { installMarketplaceEntryFromCatalog } 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.")
|
||||
}
|
||||
return installMarketplaceEntryWithCli(request.entry)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -6,15 +6,25 @@ 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 { ToggleSkillRequest } from "@shared/proto/cline/file"
|
||||
import { DeleteSkillRequest, ToggleSkillRequest } from "@shared/proto/cline/file"
|
||||
import {
|
||||
MarketplaceCatalog,
|
||||
MarketplaceEntry,
|
||||
@@ -22,6 +32,7 @@ import {
|
||||
MarketplaceInstallResult,
|
||||
MarketplaceLocalInstalledEntries,
|
||||
MarketplaceLocalInstalledEntry,
|
||||
MarketplaceLocalInstalledEntryRequest,
|
||||
ToggleMarketplaceLocalInstalledEntryRequest,
|
||||
} from "@shared/proto/cline/marketplace"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
@@ -39,7 +50,6 @@ 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 =
|
||||
@@ -167,31 +177,9 @@ 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 getSkillCandidates(entry).some((candidate) =>
|
||||
[
|
||||
join(resolveClineHome(), "skills", candidate, "SKILL.md"),
|
||||
join(homedir(), ".agents", "skills", candidate, "SKILL.md"),
|
||||
].some((path) => existsSync(path)),
|
||||
)
|
||||
return isMarketplaceSkillInstalled(toCoreMarketplaceEntry(entry))
|
||||
}
|
||||
|
||||
export function listInstalledMarketplaceEntries(
|
||||
@@ -313,60 +301,37 @@ async function runCommand(command: string, args: string[]): Promise<SpawnResult>
|
||||
})
|
||||
}
|
||||
|
||||
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],
|
||||
}
|
||||
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 findLocalCliEntrypointFromKnownDirectories(): string | undefined {
|
||||
const startDirectories = [process.cwd(), typeof __dirname === "string" ? __dirname : undefined].filter(
|
||||
(directory): directory is string => Boolean(directory),
|
||||
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}`,
|
||||
)
|
||||
for (const startDirectory of startDirectories) {
|
||||
const candidatePath = findLocalCliEntrypoint(startDirectory)
|
||||
if (candidatePath) return candidatePath
|
||||
}
|
||||
return undefined
|
||||
return MarketplaceInstallResult.create({
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name || entry.id}.`,
|
||||
output: [`Path: ${result.installPath}`, ...warnings].join("\n"),
|
||||
})
|
||||
}
|
||||
|
||||
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"]
|
||||
async function installSkillMarketplaceEntry(entry: MarketplaceEntry, args: string[]): Promise<MarketplaceInstallResult> {
|
||||
const command = "npx"
|
||||
const commandArgs = ["-y", "skills@latest", "add", ...args, "-g", "-a", "cline", "-y"]
|
||||
const displayCommand = formatCommand(command, commandArgs)
|
||||
let result: SpawnResult
|
||||
try {
|
||||
@@ -395,6 +360,52 @@ export async function installMarketplaceEntryWithCli(entry: MarketplaceEntry): P
|
||||
})
|
||||
}
|
||||
|
||||
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 }
|
||||
@@ -460,7 +471,6 @@ export async function listLocalMarketplaceInstalledEntries(controller: Controlle
|
||||
type: "mcp",
|
||||
name: server.name,
|
||||
description: server.status,
|
||||
path: server.config,
|
||||
enabled: server.disabled !== true,
|
||||
}),
|
||||
)
|
||||
@@ -543,6 +553,12 @@ 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,
|
||||
@@ -556,7 +572,64 @@ 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}.`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
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,6 +29,9 @@ 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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import type { IntentEvent } from "@shared/proto/cline/ui"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import type { Controller } from "../index"
|
||||
|
||||
export async function trackIntent(_controller: Controller, request: IntentEvent): Promise<Empty> {
|
||||
switch (request.action) {
|
||||
case "new_task_clicked":
|
||||
telemetryService.captureNewTaskClicked(request.source, request.hasActiveTask)
|
||||
break
|
||||
case "prompt_submitted":
|
||||
telemetryService.capturePromptSubmitted({
|
||||
source: request.source,
|
||||
hasText: request.hasText,
|
||||
hasImages: request.hasImages,
|
||||
hasFiles: request.hasFiles,
|
||||
hasActiveTask: request.hasActiveTask,
|
||||
textLength: request.textLength,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
return Empty.create({})
|
||||
}
|
||||
@@ -1,21 +1,10 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, it } from "bun:test"
|
||||
import "should"
|
||||
import * as actualDiskModule from "@core/storage/disk"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
|
||||
// bun loads real ESM, so sinon cannot stub the `@core/storage/disk` namespace
|
||||
// export ("ES Modules cannot be stubbed"). Inject a module-level sinon stub for
|
||||
// `getMcpSettingsFilePath` via mock.module so the full sinon stub API keeps
|
||||
// working. Register both the alias form and the relative form the SUT uses.
|
||||
const getMcpSettingsFilePathStub: sinon.SinonStub = sinon.stub()
|
||||
const diskMock = () => ({ ...actualDiskModule, getMcpSettingsFilePath: getMcpSettingsFilePathStub })
|
||||
mock.module("@core/storage/disk", diskMock)
|
||||
mock.module("@/core/storage/disk", diskMock)
|
||||
mock.module("../../disk", diskMock)
|
||||
|
||||
import { syncRemoteMcpServersToSettings } from "../remote-config/syncRemoteMcpServers"
|
||||
|
||||
describe("syncRemoteMcpServersToSettings", () => {
|
||||
@@ -29,20 +18,11 @@ describe("syncRemoteMcpServersToSettings", () => {
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
settingsPath = path.join(tempDir, "cline_mcp_settings.json")
|
||||
|
||||
getMcpSettingsFilePathStub.reset()
|
||||
getMcpSettingsFilePathStub.callsFake(async () => {
|
||||
try {
|
||||
await fs.access(settingsPath)
|
||||
} catch {
|
||||
await fs.writeFile(settingsPath, JSON.stringify({ mcpServers: {} }, null, 2))
|
||||
}
|
||||
return settingsPath
|
||||
})
|
||||
await fs.writeFile(settingsPath, JSON.stringify({ mcpServers: {} }, null, 2))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
getMcpSettingsFilePathStub.reset()
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
@@ -61,7 +41,7 @@ describe("syncRemoteMcpServersToSettings", () => {
|
||||
|
||||
describe("adding remote servers", () => {
|
||||
it("should add a new remote server with remoteConfigured marker", async () => {
|
||||
await syncRemoteMcpServersToSettings([{ name: "test-server", url: "https://example.com/mcp" }], tempDir)
|
||||
await syncRemoteMcpServersToSettings([{ name: "test-server", url: "https://example.com/mcp" }], settingsPath)
|
||||
|
||||
const result = await readSettings()
|
||||
result.mcpServers["test-server"].should.have.property("url", "https://example.com/mcp")
|
||||
@@ -80,7 +60,7 @@ describe("syncRemoteMcpServersToSettings", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await syncRemoteMcpServersToSettings([{ name: "test-server", url: "https://example.com/mcp" }], tempDir)
|
||||
await syncRemoteMcpServersToSettings([{ name: "test-server", url: "https://example.com/mcp" }], settingsPath)
|
||||
|
||||
const result = await readSettings()
|
||||
result.mcpServers["test-server"].disabled.should.equal(true)
|
||||
@@ -88,6 +68,29 @@ describe("syncRemoteMcpServersToSettings", () => {
|
||||
result.mcpServers["test-server"].remoteConfigured.should.equal(true)
|
||||
})
|
||||
|
||||
it("should preserve nested transport remote server settings when matching remote config", async () => {
|
||||
await writeSettings({
|
||||
"test-server": {
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
disabled: true,
|
||||
autoApprove: ["some-tool"],
|
||||
remoteConfigured: true,
|
||||
},
|
||||
})
|
||||
|
||||
await syncRemoteMcpServersToSettings([{ name: "test-server", url: "https://example.com/mcp" }], settingsPath)
|
||||
|
||||
const result = await readSettings()
|
||||
result.mcpServers["test-server"].transport.should.deepEqual({
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
})
|
||||
result.mcpServers["test-server"].url.should.equal("https://example.com/mcp")
|
||||
result.mcpServers["test-server"].disabled.should.equal(true)
|
||||
result.mcpServers["test-server"].autoApprove.should.deepEqual(["some-tool"])
|
||||
result.mcpServers["test-server"].remoteConfigured.should.equal(true)
|
||||
})
|
||||
|
||||
it("should add multiple remote servers", async () => {
|
||||
await writeSettings({})
|
||||
|
||||
@@ -96,7 +99,7 @@ describe("syncRemoteMcpServersToSettings", () => {
|
||||
{ name: "server-a", url: "https://a.example.com" },
|
||||
{ name: "server-b", url: "https://b.example.com" },
|
||||
],
|
||||
tempDir,
|
||||
settingsPath,
|
||||
)
|
||||
|
||||
const result = await readSettings()
|
||||
@@ -114,7 +117,7 @@ describe("syncRemoteMcpServersToSettings", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await syncRemoteMcpServersToSettings([{ name: "remote-server", url: "https://example.com" }], tempDir)
|
||||
await syncRemoteMcpServersToSettings([{ name: "remote-server", url: "https://example.com" }], settingsPath)
|
||||
|
||||
const result = await readSettings()
|
||||
result.mcpServers.should.have.property("local-server")
|
||||
@@ -133,12 +136,31 @@ describe("syncRemoteMcpServersToSettings", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await syncRemoteMcpServersToSettings([], tempDir)
|
||||
await syncRemoteMcpServersToSettings([], settingsPath)
|
||||
|
||||
const result = await readSettings()
|
||||
result.mcpServers.should.not.have.property("old-server")
|
||||
})
|
||||
|
||||
it("should not remove nested transport remote server when URL still matches remote config", async () => {
|
||||
await writeSettings({
|
||||
"keep-server": {
|
||||
transport: { type: "streamableHttp", url: "https://keep.example.com" },
|
||||
remoteConfigured: true,
|
||||
disabled: true,
|
||||
autoApprove: ["tool-a"],
|
||||
},
|
||||
})
|
||||
|
||||
await syncRemoteMcpServersToSettings([{ name: "keep-server", url: "https://keep.example.com" }], settingsPath)
|
||||
|
||||
const result = await readSettings()
|
||||
result.mcpServers.should.have.property("keep-server")
|
||||
result.mcpServers["keep-server"].transport.url.should.equal("https://keep.example.com")
|
||||
result.mcpServers["keep-server"].disabled.should.equal(true)
|
||||
result.mcpServers["keep-server"].autoApprove.should.deepEqual(["tool-a"])
|
||||
})
|
||||
|
||||
it("should NOT remove a server without remoteConfigured marker", async () => {
|
||||
await writeSettings({
|
||||
"user-server": {
|
||||
@@ -147,7 +169,7 @@ describe("syncRemoteMcpServersToSettings", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await syncRemoteMcpServersToSettings([], tempDir)
|
||||
await syncRemoteMcpServersToSettings([], settingsPath)
|
||||
|
||||
const result = await readSettings()
|
||||
result.mcpServers.should.have.property("user-server")
|
||||
@@ -167,7 +189,7 @@ describe("syncRemoteMcpServersToSettings", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await syncRemoteMcpServersToSettings([{ name: "keep-server", url: "https://keep.example.com" }], tempDir)
|
||||
await syncRemoteMcpServersToSettings([{ name: "keep-server", url: "https://keep.example.com" }], settingsPath)
|
||||
|
||||
const result = await readSettings()
|
||||
result.mcpServers.should.have.property("keep-server")
|
||||
@@ -181,7 +203,7 @@ describe("syncRemoteMcpServersToSettings", () => {
|
||||
"local-server": { command: "node", type: "stdio" },
|
||||
})
|
||||
|
||||
await syncRemoteMcpServersToSettings([], tempDir)
|
||||
await syncRemoteMcpServersToSettings([], settingsPath)
|
||||
|
||||
const result = await readSettings()
|
||||
result.mcpServers.should.not.have.property("server-a")
|
||||
@@ -200,7 +222,7 @@ describe("syncRemoteMcpServersToSettings", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await syncRemoteMcpServersToSettings([{ name: "legacy-server", url: "https://legacy.example.com" }], tempDir)
|
||||
await syncRemoteMcpServersToSettings([{ name: "legacy-server", url: "https://legacy.example.com" }], settingsPath)
|
||||
|
||||
const result = await readSettings()
|
||||
result.mcpServers["legacy-server"].remoteConfigured.should.equal(true)
|
||||
@@ -211,7 +233,7 @@ describe("syncRemoteMcpServersToSettings", () => {
|
||||
it("should handle empty settings file with no mcpServers key", async () => {
|
||||
await fs.writeFile(settingsPath, JSON.stringify({}, null, 2))
|
||||
|
||||
await syncRemoteMcpServersToSettings([{ name: "new-server", url: "https://new.example.com" }], tempDir)
|
||||
await syncRemoteMcpServersToSettings([{ name: "new-server", url: "https://new.example.com" }], settingsPath)
|
||||
|
||||
const result = await readSettings()
|
||||
result.mcpServers["new-server"].url.should.equal("https://new.example.com")
|
||||
@@ -227,7 +249,7 @@ describe("syncRemoteMcpServersToSettings", () => {
|
||||
},
|
||||
})
|
||||
|
||||
await syncRemoteMcpServersToSettings([{ name: "my-server", url: "https://new-url.example.com" }], tempDir)
|
||||
await syncRemoteMcpServersToSettings([{ name: "my-server", url: "https://new-url.example.com" }], settingsPath)
|
||||
|
||||
const result = await readSettings()
|
||||
result.mcpServers["my-server"].url.should.equal("https://new-url.example.com")
|
||||
@@ -241,7 +263,7 @@ describe("syncRemoteMcpServersToSettings", () => {
|
||||
recordSettingsFingerprint: sandbox.stub(),
|
||||
}
|
||||
|
||||
await syncRemoteMcpServersToSettings([{ name: "test", url: "https://test.com" }], tempDir, mockMcpHub as any)
|
||||
await syncRemoteMcpServersToSettings([{ name: "test", url: "https://test.com" }], settingsPath, mockMcpHub as any)
|
||||
|
||||
mockMcpHub.recordSettingsFingerprint.calledOnce.should.be.true()
|
||||
const result = await readSettings()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { TaskMetadata } from "@core/context/context-tracking/ContextTrackerTypes"
|
||||
import { execa } from "@packages/execa"
|
||||
import { RemoteConfig } from "@shared/remote-config/schema"
|
||||
import { GlobalState, Settings } from "@shared/storage/state-keys"
|
||||
import { fileExistsAtPath, isDirectory } from "@utils/fs"
|
||||
@@ -9,8 +8,11 @@ import os from "os"
|
||||
import * as path from "path"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getDocumentsPath } from "./documents-path"
|
||||
import { StateManager } from "./StateManager"
|
||||
|
||||
export { getDocumentsPath } from "./documents-path"
|
||||
|
||||
export { getSkillsDirectoriesForScan, type SkillsScanDirectory } from "./skill-directories"
|
||||
|
||||
export const GlobalFileNames = {
|
||||
@@ -39,42 +41,6 @@ export const GlobalFileNames = {
|
||||
remoteConfig: (orgId: string) => `remote_config_${orgId}.json`,
|
||||
}
|
||||
|
||||
export async function getDocumentsPath(): Promise<string> {
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
const { stdout: docsPath } = await execa("powershell", [
|
||||
"-NoProfile", // Ignore user's PowerShell profile(s)
|
||||
"-Command",
|
||||
"[System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::MyDocuments)",
|
||||
])
|
||||
const trimmedPath = docsPath.trim()
|
||||
if (trimmedPath) {
|
||||
return trimmedPath
|
||||
}
|
||||
} catch (_err) {
|
||||
Logger.error("Failed to retrieve Windows Documents path. Falling back to homedir/Documents.")
|
||||
}
|
||||
} else if (process.platform === "linux") {
|
||||
try {
|
||||
// First check if xdg-user-dir exists
|
||||
await execa("which", ["xdg-user-dir"])
|
||||
|
||||
// If it exists, try to get XDG documents path
|
||||
const { stdout } = await execa("xdg-user-dir", ["DOCUMENTS"])
|
||||
const trimmedPath = stdout.trim()
|
||||
if (trimmedPath) {
|
||||
return trimmedPath
|
||||
}
|
||||
} catch {
|
||||
// Log error but continue to fallback
|
||||
Logger.error("Failed to retrieve XDG Documents path. Falling back to homedir/Documents.")
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback for all platforms
|
||||
return path.join(os.homedir(), "Documents")
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cross-platform path to the Cline home directory (~/.cline).
|
||||
* This works on macOS, Linux, and Windows:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { execa } from "@packages/execa"
|
||||
import os from "os"
|
||||
import * as path from "path"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
export async function getDocumentsPath(): Promise<string> {
|
||||
if (process.platform === "win32") {
|
||||
try {
|
||||
const { stdout: docsPath } = await execa("powershell", [
|
||||
"-NoProfile", // Ignore user's PowerShell profile(s)
|
||||
"-Command",
|
||||
"[System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::MyDocuments)",
|
||||
])
|
||||
const trimmedPath = docsPath.trim()
|
||||
if (trimmedPath) {
|
||||
return trimmedPath
|
||||
}
|
||||
} catch (_err) {
|
||||
Logger.error("Failed to retrieve Windows Documents path. Falling back to homedir/Documents.")
|
||||
}
|
||||
} else if (process.platform === "linux") {
|
||||
try {
|
||||
// First check if xdg-user-dir exists
|
||||
await execa("which", ["xdg-user-dir"])
|
||||
|
||||
// If it exists, try to get XDG documents path
|
||||
const { stdout } = await execa("xdg-user-dir", ["DOCUMENTS"])
|
||||
const trimmedPath = stdout.trim()
|
||||
if (trimmedPath) {
|
||||
return trimmedPath
|
||||
}
|
||||
} catch {
|
||||
// Log error but continue to fallback
|
||||
Logger.error("Failed to retrieve XDG Documents path. Falling back to homedir/Documents.")
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback for all platforms
|
||||
return path.join(os.homedir(), "Documents")
|
||||
}
|
||||
@@ -1,9 +1,23 @@
|
||||
import { getMcpSettingsFilePath } from "@core/storage/disk"
|
||||
import { RemoteMCPServer } from "@shared/remote-config/schema"
|
||||
import type { McpHub } from "@/services/mcp/McpHub"
|
||||
import { updateMcpSettingsFile } from "@/services/mcp/settingsLock"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
|
||||
type McpSettingsFingerprintRecorder = {
|
||||
recordSettingsFingerprint(servers: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
function getConfiguredServerUrl(server: Record<string, unknown>): string | undefined {
|
||||
if (typeof server.url === "string") {
|
||||
return server.url
|
||||
}
|
||||
const transport = server.transport
|
||||
if (transport && typeof transport === "object" && !Array.isArray(transport)) {
|
||||
const url = (transport as Record<string, unknown>).url
|
||||
return typeof url === "string" ? url : undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes remote MCP servers from remote config to the local MCP settings file
|
||||
* This allows admins to centrally configure MCP servers that are automatically deployed to users
|
||||
@@ -18,18 +32,15 @@ import { Logger } from "@/shared/services/Logger"
|
||||
* - Preventing duplicates when re-adding servers
|
||||
*
|
||||
* @param remoteMCPServers Array of remote MCP servers from remote config
|
||||
* @param settingsDirectoryPath Path to the settings directory
|
||||
* @param settingsPath Path to the MCP settings file
|
||||
* @param mcpHub Optional McpHub instance to set flag preventing watcher triggers
|
||||
*/
|
||||
export async function syncRemoteMcpServersToSettings(
|
||||
remoteMCPServers: RemoteMCPServer[],
|
||||
settingsDirectoryPath: string,
|
||||
mcpHub?: McpHub,
|
||||
settingsPath: string,
|
||||
mcpHub?: McpSettingsFingerprintRecorder,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Get or create the MCP settings file
|
||||
const settingsPath = await getMcpSettingsFilePath(settingsDirectoryPath)
|
||||
|
||||
// Hold the cross-process lock across the whole read-modify-write so a
|
||||
// concurrent writer (CLI, another window, an OAuth handshake) cannot drop
|
||||
// this sync's changes from a stale snapshot. Only writers need the lock;
|
||||
@@ -44,8 +55,9 @@ export async function syncRemoteMcpServersToSettings(
|
||||
for (const [serverName, serverConfig] of Object.entries(servers)) {
|
||||
const server = serverConfig as Record<string, unknown>
|
||||
if (server.remoteConfigured === true) {
|
||||
const configuredUrl = getConfiguredServerUrl(server)
|
||||
const stillInRemoteConfig = remoteMCPServers.some(
|
||||
(remoteServer) => remoteServer.name === serverName && remoteServer.url === server.url,
|
||||
(remoteServer) => remoteServer.name === serverName && remoteServer.url === configuredUrl,
|
||||
)
|
||||
if (!stillInRemoteConfig) {
|
||||
delete servers[serverName]
|
||||
@@ -57,10 +69,16 @@ export async function syncRemoteMcpServersToSettings(
|
||||
for (const server of remoteMCPServers) {
|
||||
// Check if server with same name and URL already exists to skip duplicates
|
||||
const existingServer = servers[server.name]
|
||||
if (existingServer && existingServer.url === server.url) {
|
||||
if (existingServer && getConfiguredServerUrl(existingServer) === server.url) {
|
||||
if (!existingServer.remoteConfigured) {
|
||||
existingServer.remoteConfigured = true
|
||||
}
|
||||
// Keep the historical top-level URL field for remote-configured
|
||||
// servers so older sync/UI code can identify the managed server
|
||||
// without needing to understand nested SDK transport shape.
|
||||
if (!existingServer.url) {
|
||||
existingServer.url = server.url
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import { isOpenTelemetryConfigValid, remoteConfigToOtelConfig } from "@/shared/s
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { syncWorker } from "@/shared/services/worker/sync"
|
||||
import { BlobStoreSettings } from "@/shared/storage"
|
||||
import { ensureSettingsDirectoryExists } from "../disk"
|
||||
import { StateManager } from "../StateManager"
|
||||
import { syncRemoteMcpServersToSettings } from "./syncRemoteMcpServers"
|
||||
|
||||
@@ -373,7 +372,7 @@ export async function applyRemoteConfig(
|
||||
// - No dependency on in-memory state that would be lost across restarts
|
||||
try {
|
||||
const serversToSync = remoteConfig.remoteMCPServers ?? []
|
||||
const settingsPath = await ensureSettingsDirectoryExists()
|
||||
const settingsPath = await mcpHub.getMcpSettingsFilePath()
|
||||
await syncRemoteMcpServersToSettings(serversToSync, settingsPath, mcpHub)
|
||||
stateManager.setRemoteConfigField("previousRemoteMCPServers", serversToSync)
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,7 +14,6 @@ import { sendSettingsButtonClickedEvent } from "./core/controller/ui/subscribeTo
|
||||
import { sendWorktreesButtonClickedEvent } from "./core/controller/ui/subscribeToWorktreesButtonClicked"
|
||||
import { WebviewProvider } from "./core/webview"
|
||||
import { createClineAPI } from "./exports"
|
||||
import { initializeTestMode } from "./services/test/TestMode"
|
||||
import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
import path from "node:path"
|
||||
import type { ExtensionContext } from "vscode"
|
||||
@@ -83,10 +82,6 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
const webview = (await initialize(storageContext)) as VscodeWebviewProvider
|
||||
|
||||
// 5. Register services and commands specific to VS Code
|
||||
// Initialize test mode and add disposables to context
|
||||
const testModeWatchers = await initializeTestMode(webview)
|
||||
context.subscriptions.push(...testModeWatchers)
|
||||
|
||||
// Initialize hook discovery cache for performance optimization
|
||||
HookDiscoveryCache.getInstance().initialize(
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Adapt VSCode ExtensionContext to generic interface
|
||||
@@ -128,6 +123,7 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(commands.PlusButton, async () => {
|
||||
const sidebarInstance = WebviewProvider.getInstance()
|
||||
telemetryService.captureNewTaskClicked("activity_bar_plus", !!sidebarInstance.controller.task)
|
||||
await sidebarInstance.controller.clearTask()
|
||||
await sidebarInstance.controller.postStateToWebview()
|
||||
await sendChatButtonClickedEvent()
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as vscode from "vscode"
|
||||
import { handleGrpcRequest, handleGrpcRequestCancel } from "@/core/controller/grpc-handler"
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import type { ExtensionMessage } from "@/shared/ExtensionMessage"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { WebviewMessage } from "@/shared/WebviewMessage"
|
||||
@@ -67,6 +68,7 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
|
||||
// Sets up an event listener to listen for messages passed from the webview view context
|
||||
// and executes code based on the message that is received
|
||||
this.setWebviewMessageListener(webviewView.webview)
|
||||
telemetryService.capturePanelOpened("sidebar_resolved")
|
||||
|
||||
// Logs show up in bottom panel > Debug Console
|
||||
//Logger.log("registering listener")
|
||||
@@ -80,6 +82,7 @@ export class VscodeWebviewProvider extends WebviewProvider implements vscode.Web
|
||||
webviewView.onDidChangeVisibility(
|
||||
async () => {
|
||||
if (this.webview?.visible) {
|
||||
telemetryService.capturePanelOpened("sidebar_visible")
|
||||
// View becoming visible should not steal editor focus.
|
||||
await sendShowWebviewEvent(true)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import fs from "fs"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { getServerAuthHash } from "@/utils/mcpAuth"
|
||||
import { exportVSCodeStorageToSharedFiles } from "../vscode-to-file-migration"
|
||||
|
||||
/**
|
||||
@@ -62,6 +63,9 @@ function createMockVSCodeContext() {
|
||||
},
|
||||
setKeysForSync() {},
|
||||
},
|
||||
globalStorageUri: {
|
||||
fsPath: "",
|
||||
},
|
||||
// Expose internal stores for test setup
|
||||
_globalStateStore: globalStateStore,
|
||||
_secretsStore: secretsStore,
|
||||
@@ -73,11 +77,14 @@ describe("vscode-to-file-migration", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let tempDir: string
|
||||
let storageContext: StorageContext
|
||||
let originalMcpSettingsPath: string | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH
|
||||
tempDir = path.join(os.tmpdir(), `migration-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
fs.mkdirSync(tempDir, { recursive: true })
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = path.join(tempDir, "runtime-mcp-settings", "cline_mcp_settings.json")
|
||||
|
||||
storageContext = createStorageContext({
|
||||
clineDir: tempDir,
|
||||
@@ -87,6 +94,11 @@ describe("vscode-to-file-migration", () => {
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
if (originalMcpSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath
|
||||
}
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
@@ -105,14 +117,41 @@ describe("vscode-to-file-migration", () => {
|
||||
result.globalStateCount.should.be.greaterThan(0)
|
||||
storageContext.globalState.get("mode")!.should.equal("act")
|
||||
// Both sentinels should be written
|
||||
storageContext.globalState.get("__vscodeMigrationVersion")!.should.equal(1)
|
||||
storageContext.workspaceState.get("__vscodeMigrationVersion")!.should.equal(1)
|
||||
storageContext.globalState.get("__vscodeMigrationVersion")!.should.equal(2)
|
||||
storageContext.workspaceState.get("__vscodeMigrationVersion")!.should.equal(2)
|
||||
})
|
||||
|
||||
it("should run only MCP settings migration when v1 storage export sentinels are already present", async () => {
|
||||
storageContext.globalState.update("__vscodeMigrationVersion", 1)
|
||||
storageContext.workspaceState.set("__vscodeMigrationVersion", 1)
|
||||
const mockCtx = createMockVSCodeContext()
|
||||
mockCtx._globalStateStore.set("mode", "plan")
|
||||
mockCtx._workspaceStateStore.set("localClineRulesToggles", { "rule-1": true })
|
||||
const extensionStorage = path.join(tempDir, "vscode-global-storage")
|
||||
mockCtx.globalStorageUri.fsPath = extensionStorage
|
||||
fs.mkdirSync(path.join(extensionStorage, "settings"), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
|
||||
JSON.stringify({ mcpServers: { fromV1: { command: "node" } } }),
|
||||
)
|
||||
|
||||
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
|
||||
|
||||
result.migrated.should.be.true()
|
||||
result.globalStateCount.should.equal(0)
|
||||
result.secretsCount.should.equal(0)
|
||||
result.workspaceStateCount.should.equal(0)
|
||||
result.mcpServersAdded.should.equal(1)
|
||||
;(storageContext.globalState.get("mode") === undefined).should.be.true()
|
||||
;(storageContext.workspaceState.get("localClineRulesToggles") === undefined).should.be.true()
|
||||
storageContext.globalState.get("__vscodeMigrationVersion")!.should.equal(2)
|
||||
storageContext.workspaceState.get("__vscodeMigrationVersion")!.should.equal(2)
|
||||
})
|
||||
|
||||
it("should skip everything when both sentinels are current version", async () => {
|
||||
// Pre-set BOTH sentinels
|
||||
storageContext.globalState.update("__vscodeMigrationVersion", 1)
|
||||
storageContext.workspaceState.set("__vscodeMigrationVersion", 1)
|
||||
storageContext.globalState.update("__vscodeMigrationVersion", 2)
|
||||
storageContext.workspaceState.set("__vscodeMigrationVersion", 2)
|
||||
|
||||
const mockCtx = createMockVSCodeContext()
|
||||
mockCtx._globalStateStore.set("mode", "plan")
|
||||
@@ -123,6 +162,7 @@ describe("vscode-to-file-migration", () => {
|
||||
result.migrated.should.be.false()
|
||||
result.globalStateCount.should.equal(0)
|
||||
result.workspaceStateCount.should.equal(0)
|
||||
result.mcpServersAdded.should.equal(0)
|
||||
// Should NOT have the VSCode values — migration was skipped
|
||||
const modeVal = storageContext.globalState.get("mode")
|
||||
;(modeVal === undefined).should.be.true()
|
||||
@@ -138,6 +178,9 @@ describe("vscode-to-file-migration", () => {
|
||||
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
|
||||
|
||||
result.migrated.should.be.false()
|
||||
result.globalStateCount.should.equal(0)
|
||||
result.workspaceStateCount.should.equal(0)
|
||||
result.mcpServersAdded.should.equal(0)
|
||||
})
|
||||
|
||||
it("should re-run migration if sentinels are lower version", async () => {
|
||||
@@ -173,7 +216,7 @@ describe("vscode-to-file-migration", () => {
|
||||
const stored = storageContext.workspaceState.get("localClineRulesToggles") as any
|
||||
stored.should.deepEqual({ "rule-1": true })
|
||||
// Workspace sentinel should now be set
|
||||
storageContext.workspaceState.get("__vscodeMigrationVersion")!.should.equal(1)
|
||||
storageContext.workspaceState.get("__vscodeMigrationVersion")!.should.equal(2)
|
||||
})
|
||||
|
||||
it("should migrate globals when workspace already migrated", async () => {
|
||||
@@ -194,7 +237,7 @@ describe("vscode-to-file-migration", () => {
|
||||
// Workspace state should NOT have been migrated
|
||||
result.workspaceStateCount.should.equal(0)
|
||||
// Global sentinel should now be set
|
||||
storageContext.globalState.get("__vscodeMigrationVersion")!.should.equal(1)
|
||||
storageContext.globalState.get("__vscodeMigrationVersion")!.should.equal(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -251,6 +294,238 @@ describe("vscode-to-file-migration", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("legacy MCP settings migration", () => {
|
||||
function sharedMcpSettingsPath() {
|
||||
return process.env.CLINE_MCP_SETTINGS_PATH!
|
||||
}
|
||||
|
||||
function readSharedMcpSettings() {
|
||||
return JSON.parse(fs.readFileSync(sharedMcpSettingsPath(), "utf8"))
|
||||
}
|
||||
|
||||
it("writes to the runtime MCP settings resolver path rather than storage.dataDir", async () => {
|
||||
const mockCtx = createMockVSCodeContext()
|
||||
const extensionStorage = path.join(tempDir, "vscode-global-storage")
|
||||
mockCtx.globalStorageUri.fsPath = extensionStorage
|
||||
fs.mkdirSync(path.join(extensionStorage, "settings"), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
|
||||
JSON.stringify({ mcpServers: { overrideTarget: { command: "node" } } }),
|
||||
)
|
||||
|
||||
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
|
||||
|
||||
result.mcpServersAdded.should.equal(1)
|
||||
fs.existsSync(sharedMcpSettingsPath()).should.be.true()
|
||||
fs.existsSync(path.join(storageContext.dataDir, "settings", "cline_mcp_settings.json")).should.be.false()
|
||||
readSharedMcpSettings().mcpServers.overrideTarget.should.deepEqual({
|
||||
transport: { type: "stdio", command: "node" },
|
||||
})
|
||||
})
|
||||
|
||||
it("skips a legacy source that resolves to the shared MCP settings file", async () => {
|
||||
const mockCtx = createMockVSCodeContext()
|
||||
const sharedSettingsDir = path.dirname(sharedMcpSettingsPath())
|
||||
fs.mkdirSync(sharedSettingsDir, { recursive: true })
|
||||
mockCtx.globalStorageUri.fsPath = path.dirname(sharedSettingsDir)
|
||||
fs.writeFileSync(sharedMcpSettingsPath(), JSON.stringify({ mcpServers: { alreadyShared: { command: "node" } } }))
|
||||
|
||||
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
|
||||
|
||||
result.mcpServersAdded.should.equal(0)
|
||||
const tombstone = storageContext.globalState.get("__vscodeLegacyMcpSettingsMigration") as any
|
||||
;(tombstone?.sources?.vscodeGlobalStorage === undefined).should.be.true()
|
||||
const settings = readSharedMcpSettings()
|
||||
settings.mcpServers.alreadyShared.should.deepEqual({ command: "node" })
|
||||
})
|
||||
|
||||
it("uses the MCP settings lock when writing migrated servers", async () => {
|
||||
const mockCtx = createMockVSCodeContext()
|
||||
const extensionStorage = path.join(tempDir, "vscode-global-storage")
|
||||
mockCtx.globalStorageUri.fsPath = extensionStorage
|
||||
fs.mkdirSync(path.join(extensionStorage, "settings"), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
|
||||
JSON.stringify({ mcpServers: { lockedServer: { command: "node" } } }),
|
||||
)
|
||||
|
||||
const sharedSettingsPath = sharedMcpSettingsPath()
|
||||
const lockDir = `${sharedSettingsPath}.lock`
|
||||
fs.mkdirSync(lockDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(lockDir, "owner.test"), "test")
|
||||
const freshMtime = new Date()
|
||||
fs.utimesSync(lockDir, freshMtime, freshMtime)
|
||||
|
||||
const migration = exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
|
||||
await new Promise((resolve) => setTimeout(resolve, 75))
|
||||
;(fs.existsSync(sharedSettingsPath) === false).should.be.true()
|
||||
fs.rmSync(lockDir, { recursive: true, force: true })
|
||||
|
||||
const result = await migration
|
||||
result.mcpServersAdded.should.equal(1)
|
||||
const settings = readSharedMcpSettings()
|
||||
settings.mcpServers.lockedServer.should.deepEqual({ transport: { type: "stdio", command: "node" } })
|
||||
})
|
||||
|
||||
it("merges missing legacy MCP servers from VSCode globalStorage without overwriting shared settings", async () => {
|
||||
const mockCtx = createMockVSCodeContext()
|
||||
const extensionStorage = path.join(tempDir, "vscode-global-storage")
|
||||
mockCtx.globalStorageUri.fsPath = extensionStorage
|
||||
|
||||
fs.mkdirSync(path.join(extensionStorage, "settings"), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
existing: { command: "legacy-existing", args: ["old"] },
|
||||
stdioLegacy: { command: "node", args: ["server.js"], env: { API_KEY: "abc" } },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const sharedSettingsDir = path.dirname(sharedMcpSettingsPath())
|
||||
fs.mkdirSync(sharedSettingsDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
sharedMcpSettingsPath(),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
existing: {
|
||||
transport: { type: "stdio", command: "shared-existing" },
|
||||
disabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
|
||||
|
||||
result.mcpServersAdded.should.equal(1)
|
||||
result.mcpServersSkippedExisting.should.equal(1)
|
||||
const settings = readSharedMcpSettings()
|
||||
settings.mcpServers.existing.should.deepEqual({
|
||||
transport: { type: "stdio", command: "shared-existing" },
|
||||
disabled: true,
|
||||
})
|
||||
settings.mcpServers.stdioLegacy.should.deepEqual({
|
||||
transport: { type: "stdio", command: "node", args: ["server.js"], env: { API_KEY: "abc" } },
|
||||
})
|
||||
})
|
||||
|
||||
it("preserves top-level URL for migrated remote-configured URL servers", async () => {
|
||||
const mockCtx = createMockVSCodeContext()
|
||||
const extensionStorage = path.join(tempDir, "vscode-global-storage")
|
||||
mockCtx.globalStorageUri.fsPath = extensionStorage
|
||||
fs.mkdirSync(path.join(extensionStorage, "settings"), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
managed: {
|
||||
url: "https://managed.example.com/mcp",
|
||||
type: "streamableHttp",
|
||||
remoteConfigured: true,
|
||||
disabled: true,
|
||||
autoApprove: ["tool-a"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
|
||||
|
||||
readSharedMcpSettings().mcpServers.managed.should.deepEqual({
|
||||
transport: { type: "streamableHttp", url: "https://managed.example.com/mcp" },
|
||||
disabled: true,
|
||||
autoApprove: ["tool-a"],
|
||||
remoteConfigured: true,
|
||||
url: "https://managed.example.com/mcp",
|
||||
})
|
||||
})
|
||||
|
||||
it("upgrades legacy transportType and OAuth secret format into SDK MCP settings format", async () => {
|
||||
const mockCtx = createMockVSCodeContext()
|
||||
const extensionStorage = path.join(tempDir, "vscode-global-storage")
|
||||
mockCtx.globalStorageUri.fsPath = extensionStorage
|
||||
const serverUrl = "https://linear.example.com/mcp"
|
||||
const serverHash = getServerAuthHash("linear", serverUrl)
|
||||
|
||||
mockCtx._secretsStore.set(
|
||||
"mcpOAuthSecrets",
|
||||
JSON.stringify({
|
||||
[serverHash]: {
|
||||
tokens: { access_token: "old-token", refresh_token: "refresh" },
|
||||
tokens_saved_at: 123456,
|
||||
client_info: { client_id: "client-id" },
|
||||
code_verifier: "verifier",
|
||||
redirect_url_at_registration: "http://127.0.0.1:1456/mcp/oauth/callback",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
fs.mkdirSync(path.join(extensionStorage, "settings"), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
linear: {
|
||||
transportType: "http",
|
||||
url: serverUrl,
|
||||
headers: { Authorization: "Bearer static" },
|
||||
disabled: false,
|
||||
timeout: 30,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
|
||||
|
||||
result.mcpServersAdded.should.equal(1)
|
||||
const settings = readSharedMcpSettings()
|
||||
settings.mcpServers.linear.should.deepEqual({
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: serverUrl,
|
||||
headers: { Authorization: "Bearer static" },
|
||||
},
|
||||
disabled: false,
|
||||
timeout: 30,
|
||||
oauth: {
|
||||
clientInformation: { client_id: "client-id" },
|
||||
tokens: { access_token: "old-token", refresh_token: "refresh" },
|
||||
codeVerifier: "verifier",
|
||||
redirectUrl: "http://127.0.0.1:1456/mcp/oauth/callback",
|
||||
lastAuthenticatedAt: 123456,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it("writes source tombstones and does not re-import a deleted migrated server", async () => {
|
||||
const mockCtx = createMockVSCodeContext()
|
||||
const extensionStorage = path.join(tempDir, "vscode-global-storage")
|
||||
mockCtx.globalStorageUri.fsPath = extensionStorage
|
||||
fs.mkdirSync(path.join(extensionStorage, "settings"), { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
|
||||
JSON.stringify({ mcpServers: { oneShot: { command: "node" } } }),
|
||||
)
|
||||
|
||||
await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
|
||||
const settingsPath = sharedMcpSettingsPath()
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ mcpServers: {} }))
|
||||
|
||||
const second = await exportVSCodeStorageToSharedFiles(mockCtx as any, storageContext)
|
||||
|
||||
second.mcpServersAdded.should.equal(0)
|
||||
const settings = readSharedMcpSettings()
|
||||
;(settings.mcpServers.oneShot === undefined).should.be.true()
|
||||
const tombstone = storageContext.globalState.get("__vscodeLegacyMcpSettingsMigration") as any
|
||||
tombstone.sources.vscodeGlobalStorage.path.should.equal(
|
||||
path.join(extensionStorage, "settings", "cline_mcp_settings.json"),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("secrets migration", () => {
|
||||
it("should migrate secret keys", async () => {
|
||||
const mockCtx = createMockVSCodeContext()
|
||||
|
||||
@@ -0,0 +1,378 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { getDocumentsPath } from "@/core/storage/documents-path"
|
||||
import type * as vscode from "vscode"
|
||||
import { updateMcpSettingsFile } from "@/services/mcp/settingsLock"
|
||||
import type { StorageContext } from "@/shared/storage/storage-context"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getServerAuthHash } from "@/utils/mcpAuth"
|
||||
import { arePathsEqual } from "@/utils/path"
|
||||
|
||||
const MCP_SETTINGS_FILE_NAME = "cline_mcp_settings.json"
|
||||
const MCP_SETTINGS_MIGRATION_KEY = "__vscodeLegacyMcpSettingsMigration"
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
export interface LegacyMcpSettingsMigrationResult {
|
||||
migrated: boolean
|
||||
sourcesChecked: number
|
||||
sourcesMigrated: number
|
||||
serversAdded: number
|
||||
serversSkippedExisting: number
|
||||
serversSkippedInvalid: number
|
||||
}
|
||||
|
||||
interface LegacyMcpSource {
|
||||
id: string
|
||||
path: string
|
||||
}
|
||||
|
||||
interface PreparedLegacyMcpSource {
|
||||
source: LegacyMcpSource
|
||||
servers: Record<string, JsonRecord>
|
||||
skippedInvalid: number
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function readJsonRecord(filePath: string): JsonRecord | undefined {
|
||||
try {
|
||||
if (!existsSync(filePath)) {
|
||||
return undefined
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(filePath, "utf8")) as unknown
|
||||
return isRecord(parsed) ? parsed : undefined
|
||||
} catch (error) {
|
||||
Logger.warn(`[Migration] Failed to read legacy MCP settings from ${filePath}:`, error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function getServers(settings: JsonRecord | undefined): JsonRecord {
|
||||
const servers = settings?.mcpServers
|
||||
return isRecord(servers) ? servers : {}
|
||||
}
|
||||
|
||||
function mapLegacyTransportType(value: unknown): "stdio" | "sse" | "streamableHttp" | undefined {
|
||||
if (value === "stdio" || value === "sse" || value === "streamableHttp") {
|
||||
return value
|
||||
}
|
||||
if (value === "http") {
|
||||
return "streamableHttp"
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function normalizeStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined
|
||||
}
|
||||
const strings = value.filter((item): item is string => typeof item === "string")
|
||||
return strings.length === value.length ? strings : undefined
|
||||
}
|
||||
|
||||
function normalizeStringRecord(value: unknown): Record<string, string> | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined
|
||||
}
|
||||
const entries = Object.entries(value)
|
||||
if (!entries.every(([, entryValue]) => typeof entryValue === "string")) {
|
||||
return undefined
|
||||
}
|
||||
return Object.fromEntries(entries) as Record<string, string>
|
||||
}
|
||||
|
||||
function compactRecord(record: JsonRecord): JsonRecord {
|
||||
return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined))
|
||||
}
|
||||
|
||||
function normalizeOauthState(value: unknown): JsonRecord | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined
|
||||
}
|
||||
const normalized = compactRecord({
|
||||
clientInformation: isRecord(value.clientInformation) ? value.clientInformation : undefined,
|
||||
tokens: isRecord(value.tokens) ? value.tokens : undefined,
|
||||
codeVerifier: typeof value.codeVerifier === "string" ? value.codeVerifier : undefined,
|
||||
discoveryState: isRecord(value.discoveryState) ? value.discoveryState : undefined,
|
||||
redirectUrl: typeof value.redirectUrl === "string" ? value.redirectUrl : undefined,
|
||||
lastError: typeof value.lastError === "string" ? value.lastError : undefined,
|
||||
lastAuthenticatedAt: typeof value.lastAuthenticatedAt === "number" ? value.lastAuthenticatedAt : undefined,
|
||||
})
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined
|
||||
}
|
||||
|
||||
function normalizeLegacyOAuthSecret(value: unknown): JsonRecord | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined
|
||||
}
|
||||
const normalized = compactRecord({
|
||||
clientInformation: isRecord(value.client_info) ? value.client_info : undefined,
|
||||
tokens: isRecord(value.tokens) ? value.tokens : undefined,
|
||||
codeVerifier: typeof value.code_verifier === "string" ? value.code_verifier : undefined,
|
||||
redirectUrl: typeof value.redirect_url_at_registration === "string" ? value.redirect_url_at_registration : undefined,
|
||||
lastAuthenticatedAt: typeof value.tokens_saved_at === "number" ? value.tokens_saved_at : undefined,
|
||||
})
|
||||
return Object.keys(normalized).length > 0 ? normalized : undefined
|
||||
}
|
||||
|
||||
function getUrlForAuthHash(registration: JsonRecord): string | undefined {
|
||||
const transport = isRecord(registration.transport) ? registration.transport : registration
|
||||
return typeof transport.url === "string" ? transport.url : undefined
|
||||
}
|
||||
|
||||
export function normalizeLegacyMcpServer(
|
||||
value: unknown,
|
||||
legacyOAuthSecrets: JsonRecord | undefined,
|
||||
serverName: string,
|
||||
): JsonRecord | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const source = isRecord(value.transport) ? { ...value.transport, ...value } : { ...value }
|
||||
delete source.transport
|
||||
|
||||
const explicitType = mapLegacyTransportType(source.type)
|
||||
const transportType = mapLegacyTransportType(source.transportType)
|
||||
const resolvedType = explicitType ?? transportType ?? (typeof source.command === "string" ? "stdio" : undefined)
|
||||
|
||||
let transport: JsonRecord | undefined
|
||||
if (resolvedType === "stdio" && typeof source.command === "string" && source.command.trim()) {
|
||||
transport = compactRecord({
|
||||
type: "stdio",
|
||||
command: source.command,
|
||||
args: normalizeStringArray(source.args),
|
||||
cwd: typeof source.cwd === "string" && source.cwd.trim() ? source.cwd : undefined,
|
||||
env: normalizeStringRecord(source.env),
|
||||
})
|
||||
} else {
|
||||
const urlType = resolvedType ?? "sse"
|
||||
if ((urlType === "sse" || urlType === "streamableHttp") && typeof source.url === "string") {
|
||||
transport = compactRecord({
|
||||
type: urlType,
|
||||
url: source.url,
|
||||
headers: normalizeStringRecord(source.headers),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!transport) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const normalized: JsonRecord = compactRecord({
|
||||
transport,
|
||||
disabled: typeof source.disabled === "boolean" ? source.disabled : undefined,
|
||||
metadata: isRecord(source.metadata) ? source.metadata : undefined,
|
||||
})
|
||||
|
||||
const autoApprove = normalizeStringArray(source.autoApprove)
|
||||
if (autoApprove) {
|
||||
normalized.autoApprove = autoApprove
|
||||
}
|
||||
if (typeof source.timeout === "number") {
|
||||
normalized.timeout = source.timeout
|
||||
}
|
||||
if (typeof source.remoteConfigured === "boolean") {
|
||||
normalized.remoteConfigured = source.remoteConfigured
|
||||
}
|
||||
// Remote-config sync historically keys URL-based remote servers by a top-level
|
||||
// `url`. Keep that compatibility field on migrated remote-configured servers
|
||||
// so the next sync does not delete/recreate them and lose user state.
|
||||
if (source.remoteConfigured === true && typeof transport.url === "string") {
|
||||
normalized.url = transport.url
|
||||
}
|
||||
|
||||
const inlineOAuth = normalizeOauthState(source.oauth)
|
||||
const serverUrl = getUrlForAuthHash(normalized)
|
||||
const legacyOAuth =
|
||||
serverUrl && legacyOAuthSecrets
|
||||
? normalizeLegacyOAuthSecret(legacyOAuthSecrets[getServerAuthHash(serverName, serverUrl)])
|
||||
: undefined
|
||||
const oauth = inlineOAuth ?? legacyOAuth
|
||||
if (oauth) {
|
||||
normalized.oauth = oauth
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function parseLegacyOAuthSecrets(raw: string | undefined): JsonRecord | undefined {
|
||||
if (!raw) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return isRecord(parsed) ? parsed : undefined
|
||||
} catch (error) {
|
||||
Logger.warn("[Migration] Failed to parse legacy MCP OAuth secrets:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function readLegacyOAuthSecrets(
|
||||
vscodeContext: vscode.ExtensionContext,
|
||||
storage: StorageContext,
|
||||
): Promise<JsonRecord | undefined> {
|
||||
let vscodeSecrets: JsonRecord | undefined
|
||||
try {
|
||||
vscodeSecrets = parseLegacyOAuthSecrets(await vscodeContext.secrets.get("mcpOAuthSecrets"))
|
||||
} catch (error) {
|
||||
Logger.warn("[Migration] Failed to read legacy MCP OAuth secrets from VSCode storage:", error)
|
||||
}
|
||||
const fileBackedSecrets = parseLegacyOAuthSecrets(storage.secrets.get("mcpOAuthSecrets"))
|
||||
if (!vscodeSecrets) {
|
||||
return fileBackedSecrets
|
||||
}
|
||||
if (!fileBackedSecrets) {
|
||||
return vscodeSecrets
|
||||
}
|
||||
return { ...vscodeSecrets, ...fileBackedSecrets }
|
||||
}
|
||||
|
||||
export async function getLegacyMcpSettingsSources(vscodeContext: vscode.ExtensionContext): Promise<LegacyMcpSource[]> {
|
||||
const sources: LegacyMcpSource[] = []
|
||||
const extensionStorageDir = vscodeContext.globalStorageUri?.fsPath
|
||||
if (extensionStorageDir) {
|
||||
sources.push({
|
||||
id: "vscodeGlobalStorage",
|
||||
path: path.join(extensionStorageDir, "settings", MCP_SETTINGS_FILE_NAME),
|
||||
})
|
||||
}
|
||||
const documentsDir = await getDocumentsPath()
|
||||
sources.push({
|
||||
id: "documentsClineMcp",
|
||||
path: path.join(documentsDir, "Cline", "MCP", MCP_SETTINGS_FILE_NAME),
|
||||
})
|
||||
return sources
|
||||
}
|
||||
|
||||
export function getSharedMcpSettingsPath(storage: StorageContext): string {
|
||||
const explicitPath = process.env.CLINE_MCP_SETTINGS_PATH?.trim()
|
||||
if (explicitPath) {
|
||||
return explicitPath
|
||||
}
|
||||
const explicitDataDir = process.env.CLINE_DATA_DIR?.trim()
|
||||
if (explicitDataDir) {
|
||||
return path.join(explicitDataDir, "settings", MCP_SETTINGS_FILE_NAME)
|
||||
}
|
||||
const clineDir = process.env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline")
|
||||
return path.join(clineDir, "data", "settings", MCP_SETTINGS_FILE_NAME)
|
||||
}
|
||||
|
||||
function readMigrationState(storage: StorageContext): JsonRecord {
|
||||
const value = storage.globalState.get(MCP_SETTINGS_MIGRATION_KEY)
|
||||
return isRecord(value) ? value : {}
|
||||
}
|
||||
|
||||
function writeMigrationState(storage: StorageContext, state: JsonRecord): void {
|
||||
storage.globalState.update(MCP_SETTINGS_MIGRATION_KEY, state)
|
||||
}
|
||||
|
||||
function prepareLegacySource(
|
||||
source: LegacyMcpSource,
|
||||
legacyOAuthSecrets: JsonRecord | undefined,
|
||||
): PreparedLegacyMcpSource | undefined {
|
||||
if (!existsSync(source.path)) {
|
||||
return undefined
|
||||
}
|
||||
const legacySettings = readJsonRecord(source.path)
|
||||
if (!legacySettings) {
|
||||
return undefined
|
||||
}
|
||||
const servers: Record<string, JsonRecord> = {}
|
||||
let skippedInvalid = 0
|
||||
for (const [serverName, serverValue] of Object.entries(getServers(legacySettings))) {
|
||||
const normalized = normalizeLegacyMcpServer(serverValue, legacyOAuthSecrets, serverName)
|
||||
if (!normalized) {
|
||||
skippedInvalid++
|
||||
continue
|
||||
}
|
||||
servers[serverName] = normalized
|
||||
}
|
||||
return { source, servers, skippedInvalid }
|
||||
}
|
||||
|
||||
export async function migrateLegacyMcpSettings(
|
||||
vscodeContext: vscode.ExtensionContext,
|
||||
storage: StorageContext,
|
||||
): Promise<LegacyMcpSettingsMigrationResult> {
|
||||
const result: LegacyMcpSettingsMigrationResult = {
|
||||
migrated: false,
|
||||
sourcesChecked: 0,
|
||||
sourcesMigrated: 0,
|
||||
serversAdded: 0,
|
||||
serversSkippedExisting: 0,
|
||||
serversSkippedInvalid: 0,
|
||||
}
|
||||
|
||||
const migrationState = readMigrationState(storage)
|
||||
const migratedSources = isRecord(migrationState.sources) ? migrationState.sources : {}
|
||||
const sharedSettingsPath = getSharedMcpSettingsPath(storage)
|
||||
const legacyOAuthSecrets = await readLegacyOAuthSecrets(vscodeContext, storage)
|
||||
const preparedSources: PreparedLegacyMcpSource[] = []
|
||||
|
||||
for (const source of await getLegacyMcpSettingsSources(vscodeContext)) {
|
||||
result.sourcesChecked++
|
||||
if (migratedSources[source.id] || arePathsEqual(source.path, sharedSettingsPath)) {
|
||||
continue
|
||||
}
|
||||
const prepared = prepareLegacySource(source, legacyOAuthSecrets)
|
||||
if (!prepared) {
|
||||
continue
|
||||
}
|
||||
preparedSources.push(prepared)
|
||||
result.serversSkippedInvalid += prepared.skippedInvalid
|
||||
}
|
||||
|
||||
if (preparedSources.length > 0) {
|
||||
const mergeResult = await updateMcpSettingsFile(sharedSettingsPath, (settings) => {
|
||||
const existingServersValue = settings.mcpServers
|
||||
const servers = isRecord(existingServersValue) ? { ...existingServersValue } : {}
|
||||
let serversAdded = 0
|
||||
let serversSkippedExisting = 0
|
||||
let sourcesMigrated = 0
|
||||
|
||||
for (const prepared of preparedSources) {
|
||||
let sourceAdded = 0
|
||||
for (const [serverName, serverConfig] of Object.entries(prepared.servers)) {
|
||||
if (Object.hasOwn(servers, serverName)) {
|
||||
serversSkippedExisting++
|
||||
continue
|
||||
}
|
||||
servers[serverName] = serverConfig
|
||||
serversAdded++
|
||||
sourceAdded++
|
||||
}
|
||||
if (sourceAdded > 0) {
|
||||
sourcesMigrated++
|
||||
}
|
||||
}
|
||||
|
||||
settings.mcpServers = servers
|
||||
return { serversAdded, serversSkippedExisting, sourcesMigrated }
|
||||
})
|
||||
|
||||
result.serversAdded += mergeResult.serversAdded
|
||||
result.serversSkippedExisting += mergeResult.serversSkippedExisting
|
||||
result.sourcesMigrated += mergeResult.sourcesMigrated
|
||||
|
||||
for (const prepared of preparedSources) {
|
||||
migratedSources[prepared.source.id] = {
|
||||
path: prepared.source.path,
|
||||
migratedAt: Date.now(),
|
||||
}
|
||||
}
|
||||
result.migrated = true
|
||||
}
|
||||
|
||||
if (result.migrated) {
|
||||
writeMigrationState(storage, { sources: migratedSources })
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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,6 +11,9 @@ 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
|
||||
@@ -172,6 +175,57 @@ 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
|
||||
@@ -285,48 +339,34 @@ 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 }
|
||||
})
|
||||
|
||||
// Open the terminal before the cwd setup command so VS Code has time
|
||||
// to initialize the terminal surface and shell integration.
|
||||
availableTerminal.terminal.show()
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||||
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}"`)
|
||||
|
||||
// Wait for the cd command to complete before proceeding
|
||||
await cdProcess
|
||||
|
||||
// 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()
|
||||
// Add a small delay to ensure terminal is ready after cd
|
||||
if (!didCwdCommandTimeOut) {
|
||||
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()
|
||||
}
|
||||
} else if (!didCwdCommandTimeOut) {
|
||||
await Promise.race([cwdPromise, new Promise((resolve) => setTimeout(resolve, CWD_STATE_TIMEOUT_MS))])
|
||||
}
|
||||
} finally {
|
||||
availableTerminal.pendingCwdChange = undefined
|
||||
availableTerminal.cwdResolved = undefined
|
||||
} 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
|
||||
}
|
||||
availableTerminal.busy = false
|
||||
}
|
||||
this.terminalIds.add(availableTerminal.id)
|
||||
// Cast to ITerminalInfo for interface compatibility
|
||||
|
||||
@@ -36,9 +36,16 @@ import type * as vscode from "vscode"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { GlobalStateAndSettingKeys, LocalStateKeys, SecretKeys } from "@/shared/storage/state-keys"
|
||||
import type { StorageContext } from "@/shared/storage/storage-context"
|
||||
import { migrateLegacyMcpSettings } from "./mcp-settings-legacy-migration"
|
||||
|
||||
/** Version 1 exported VSCode memento/secrets/workspace state to file-backed stores. */
|
||||
const FILE_BACKED_STORAGE_EXPORT_VERSION = 1
|
||||
|
||||
/** Version 2 imports legacy MCP settings files into the shared SDK/CLI settings path. */
|
||||
const MCP_SETTINGS_MIGRATION_VERSION = 2
|
||||
|
||||
/** Bump this when adding new migration steps. */
|
||||
const CURRENT_MIGRATION_VERSION = 1
|
||||
const CURRENT_MIGRATION_VERSION = MCP_SETTINGS_MIGRATION_VERSION
|
||||
|
||||
/** Sentinel key written to both globalState and workspaceState to track migration independently. */
|
||||
const MIGRATION_VERSION_KEY = "__vscodeMigrationVersion"
|
||||
@@ -59,6 +66,8 @@ export interface MigrationResult {
|
||||
secretsCount: number
|
||||
workspaceStateCount: number
|
||||
skippedExisting: number
|
||||
mcpServersAdded: number
|
||||
mcpServersSkippedExisting: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,16 +94,19 @@ export async function exportVSCodeStorageToSharedFiles(
|
||||
secretsCount: 0,
|
||||
workspaceStateCount: 0,
|
||||
skippedExisting: 0,
|
||||
mcpServersAdded: 0,
|
||||
mcpServersSkippedExisting: 0,
|
||||
}
|
||||
|
||||
// Check sentinels independently
|
||||
const globalVersion = storage.globalState.get<number>(MIGRATION_VERSION_KEY)
|
||||
const workspaceVersion = storage.workspaceState.get<number>(MIGRATION_VERSION_KEY)
|
||||
|
||||
const needGlobalMigration = globalVersion === undefined || globalVersion < CURRENT_MIGRATION_VERSION
|
||||
const needWorkspaceMigration = workspaceVersion === undefined || workspaceVersion < CURRENT_MIGRATION_VERSION
|
||||
const needGlobalMigration = globalVersion === undefined || globalVersion < FILE_BACKED_STORAGE_EXPORT_VERSION
|
||||
const needWorkspaceMigration = workspaceVersion === undefined || workspaceVersion < FILE_BACKED_STORAGE_EXPORT_VERSION
|
||||
const needMcpSettingsMigration = globalVersion === undefined || globalVersion < MCP_SETTINGS_MIGRATION_VERSION
|
||||
|
||||
if (!needGlobalMigration && !needWorkspaceMigration) {
|
||||
if (!needGlobalMigration && !needWorkspaceMigration && !needMcpSettingsMigration) {
|
||||
Logger.info(
|
||||
`[Migration] File-backed stores already current (global: v${globalVersion}, workspace: v${workspaceVersion}), skipping.`,
|
||||
)
|
||||
@@ -106,6 +118,13 @@ export async function exportVSCodeStorageToSharedFiles(
|
||||
)
|
||||
|
||||
try {
|
||||
// ─── 0. Migrate legacy MCP settings files (if needed) ───────────
|
||||
if (needMcpSettingsMigration) {
|
||||
const mcpMigration = await migrateLegacyMcpSettings(vscodeContext, storage)
|
||||
result.mcpServersAdded = mcpMigration.serversAdded
|
||||
result.mcpServersSkippedExisting = mcpMigration.serversSkippedExisting
|
||||
}
|
||||
|
||||
// ─── 1. Migrate global state + secrets (if needed) ─────────────
|
||||
if (needGlobalMigration) {
|
||||
// Batch global state keys
|
||||
@@ -130,7 +149,8 @@ export async function exportVSCodeStorageToSharedFiles(
|
||||
result.globalStateCount++
|
||||
}
|
||||
|
||||
// Add sentinel to batch
|
||||
// Add sentinel to batch. This advances straight to CURRENT because the
|
||||
// v2 MCP migration already ran above when needed.
|
||||
globalStateBatch[MIGRATION_VERSION_KEY] = CURRENT_MIGRATION_VERSION
|
||||
|
||||
// Write all global state in one operation
|
||||
@@ -182,19 +202,32 @@ export async function exportVSCodeStorageToSharedFiles(
|
||||
result.workspaceStateCount++
|
||||
}
|
||||
|
||||
// Add sentinel to batch
|
||||
// Add sentinel to batch. This advances straight to CURRENT because any
|
||||
// global v2-only migrations already ran above when needed.
|
||||
workspaceStateBatch[MIGRATION_VERSION_KEY] = CURRENT_MIGRATION_VERSION
|
||||
|
||||
// Write all workspace state in one operation
|
||||
storage.workspaceState.setBatch(workspaceStateBatch)
|
||||
}
|
||||
|
||||
result.migrated = true
|
||||
// If the original v1 export was already complete, still advance sentinels
|
||||
// for this workspace after the v2 MCP migration attempt so future startups
|
||||
// don't re-run it. New workspaces still have no workspace sentinel and will
|
||||
// get their v1 workspace-state export when first opened.
|
||||
if (!needGlobalMigration && needMcpSettingsMigration) {
|
||||
storage.globalState.update(MIGRATION_VERSION_KEY, CURRENT_MIGRATION_VERSION)
|
||||
}
|
||||
if (!needWorkspaceMigration && needMcpSettingsMigration) {
|
||||
storage.workspaceState.set(MIGRATION_VERSION_KEY, CURRENT_MIGRATION_VERSION)
|
||||
}
|
||||
|
||||
result.migrated = needGlobalMigration || needWorkspaceMigration || needMcpSettingsMigration || result.mcpServersAdded > 0
|
||||
|
||||
Logger.info(
|
||||
`[Migration] Complete: ${result.globalStateCount} global state keys, ` +
|
||||
`${result.secretsCount} secrets, ${result.workspaceStateCount} workspace state keys migrated. ` +
|
||||
`${result.skippedExisting} keys skipped (already in file store).`,
|
||||
`${result.skippedExisting} keys skipped (already in file store). ` +
|
||||
`Legacy MCP migration added ${result.mcpServersAdded} server(s), skipped ${result.mcpServersSkippedExisting} existing server(s).`,
|
||||
)
|
||||
} catch (error) {
|
||||
Logger.error("[Migration] Fatal error during VSCode → file-backed migration:", error)
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
// cancelTask, …) to the Cline SDK (@cline/core) and bridges SDK events to
|
||||
// the webview's gRPC streams.
|
||||
import * as fs from "node:fs/promises"
|
||||
import * as os from "node:os"
|
||||
import * as path from "node:path"
|
||||
import {
|
||||
createUserInstructionConfigService,
|
||||
getProviderAuthStorageId,
|
||||
type PreparedRemoteConfigCoreIntegration,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
type SessionHistoryRecord,
|
||||
setTelemetryOptOutGlobally,
|
||||
type UserInstructionConfigService,
|
||||
@@ -232,8 +232,7 @@ export class Controller {
|
||||
this.mcpHub = new McpHub(
|
||||
() => ensureMcpServersDirectoryExists(),
|
||||
async () => {
|
||||
const clineDir = process.env.CLINE_DIR || path.join(os.homedir(), ".cline")
|
||||
const settingsDir = path.join(clineDir, "data", "settings")
|
||||
const settingsDir = path.dirname(resolveDefaultMcpSettingsPath())
|
||||
await fs.mkdir(settingsDir, { recursive: true })
|
||||
return settingsDir
|
||||
},
|
||||
@@ -259,10 +258,6 @@ export class Controller {
|
||||
this.sessionConfigBuilder = new SdkSessionConfigBuilder({
|
||||
stateManager: this.stateManager,
|
||||
emitHookMessage: (msg) => this.messages.emitHookMessage(msg),
|
||||
onSwitchToActMode: () => {
|
||||
this.mode.queueSwitchToActMode()
|
||||
},
|
||||
shouldStopAfterModeSwitch: () => this.mode.hasPendingModeChange(),
|
||||
onConsecutiveMistakeLimitReached: (context) => this.interactions.handleConsecutiveMistakeLimitReached(context),
|
||||
})
|
||||
this.interactions = new SdkInteractionCoordinator({
|
||||
@@ -611,6 +606,15 @@ 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
|
||||
@@ -620,11 +624,7 @@ export class Controller {
|
||||
}
|
||||
await this.setRemoteConfigCoreIntegration(undefined)
|
||||
this.isDisposed = true
|
||||
const userInstructionServicePromise = this.userInstructionService
|
||||
this.userInstructionService = undefined
|
||||
if (userInstructionServicePromise) {
|
||||
await userInstructionServicePromise.then((service) => service.stop()).catch(() => {})
|
||||
}
|
||||
await this.invalidateUserInstructionService()
|
||||
this.messages.cancelPendingSave()
|
||||
// Clear MCP tool list change callback before disposing McpHub
|
||||
this.mcpHub?.clearToolListChangeCallback()
|
||||
@@ -667,7 +667,11 @@ export class Controller {
|
||||
this.userInstructionService = (async () => {
|
||||
const service = createUserInstructionConfigService({
|
||||
workflows: { workspacePath: workspaceRoot },
|
||||
skills: { workspacePath: workspaceRoot },
|
||||
skills: {
|
||||
workspacePath: workspaceRoot,
|
||||
includePluginSkills: true,
|
||||
cwd: workspaceRoot,
|
||||
},
|
||||
rules: { workspacePath: workspaceRoot },
|
||||
})
|
||||
// start() runs the initial scan; await so the snapshot is populated
|
||||
@@ -989,6 +993,29 @@ 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.
|
||||
@@ -1029,6 +1056,8 @@ export class Controller {
|
||||
return
|
||||
}
|
||||
|
||||
const turnStateBefore = this.turnStateTracker.get()
|
||||
|
||||
// Answering an ask / continuing after completion / resuming a cancelled task all kick off a
|
||||
// new agent turn — move the authoritative phase to "streaming" so the footer shows
|
||||
// Thinking + Cancel (and not the stale resumable/completed/awaiting_followup buttons or the
|
||||
@@ -1038,7 +1067,7 @@ export class Controller {
|
||||
this.turnStateTracker.set("streaming")
|
||||
// Clear the previous turn's completion signal so this new turn's phase is computed fresh.
|
||||
this.messageTranslatorState.clearTurnOutcome()
|
||||
await this.followups.askResponse(prompt, images, files, this.task?.taskState?.askResponse)
|
||||
await this.followups.askResponse(prompt, images, files, this.task?.taskState?.askResponse, turnStateBefore.phase)
|
||||
}
|
||||
|
||||
async editMessageAndRegenerate(input: {
|
||||
|
||||
@@ -333,6 +333,21 @@ 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",
|
||||
|
||||
@@ -60,7 +60,7 @@ You are in Plan mode. Your role is to explore, analyze, and plan -- not to execu
|
||||
- Do NOT edit files, write code, run destructive commands, or make any changes
|
||||
- Do NOT implement anything -- focus on understanding and alignment first
|
||||
|
||||
Once the user has reviewed your plan and explicitly approved it in a follow-up message, use the switch_to_act_mode tool to switch to act mode and begin implementation. Calling switch_to_act_mode immediately starts execution, so never call it in the same turn you present a plan and never treat the original task request as approval -- end your turn after presenting the plan and wait for the user's response.`
|
||||
Once the user has reviewed your plan and wants implementation to begin, ask them to toggle to Act mode using the Plan/Act toggle. You cannot switch modes yourself. Do not implement until the user has manually switched to Act mode.`
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -133,6 +133,10 @@ 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
|
||||
@@ -160,7 +164,7 @@ export function normalizeProviderReasoningSettings(reasoning: ProviderReasoningS
|
||||
function resolveProviderReasoningConfig(providerId: string): SessionReasoningConfig {
|
||||
try {
|
||||
const manager = getProviderSettingsManager(resolveDataDir())
|
||||
const settings = manager.getProviderSettings(providerId)
|
||||
const settings = manager.getProviderSettings(providerSettingsProviderId(providerId))
|
||||
if (!settings) {
|
||||
return {}
|
||||
}
|
||||
@@ -333,7 +337,7 @@ export function resolveApiKey(providerId: string, config: ApiConfiguration): str
|
||||
// hardcoding provider exceptions.
|
||||
try {
|
||||
const manager = getProviderSettingsManager()
|
||||
const apiKey = resolveProviderApiKeyFromSettings(manager, providerId)?.trim()
|
||||
const apiKey = resolveProviderApiKeyFromSettings(manager, providerSettingsProviderId(providerId))?.trim()
|
||||
if (apiKey) {
|
||||
return apiKey
|
||||
}
|
||||
@@ -359,7 +363,7 @@ export function resolveApiKey(providerId: string, config: ApiConfiguration): str
|
||||
// startup.
|
||||
try {
|
||||
const manager = getProviderSettingsManager()
|
||||
const apiKey = resolveProviderApiKeyFromSettings(manager, providerId)?.trim()
|
||||
const apiKey = resolveProviderApiKeyFromSettings(manager, providerSettingsProviderId(providerId))?.trim()
|
||||
if (apiKey) {
|
||||
return apiKey
|
||||
}
|
||||
@@ -648,7 +652,6 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
}
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
const globalSubagentsEnabled = stateManager.getGlobalSettingsKey("subagentsEnabled") ?? false
|
||||
const globalUseAutoCondense = stateManager.getGlobalSettingsKey("useAutoCondense") ?? false
|
||||
const enableCheckpoints = stateManager.getGlobalSettingsKey("enableCheckpointsSetting") ?? true
|
||||
const useAutoCondense = input.taskSettings?.useAutoCondense ?? globalUseAutoCondense
|
||||
@@ -688,7 +691,7 @@ export async function buildSessionConfig(input: SessionConfigInput): Promise<Cor
|
||||
checkpoint: {
|
||||
enabled: enableCheckpoints,
|
||||
},
|
||||
enableSpawnAgent: input.taskSettings?.subagentsEnabled ?? globalSubagentsEnabled,
|
||||
enableSpawnAgent: false,
|
||||
enableAgentTeams: false,
|
||||
...(useAutoCondense
|
||||
? {
|
||||
|
||||
@@ -165,6 +165,41 @@ describe("translateSessionEvent — chunk events", () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// translateSessionEvent — pending prompts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("translateSessionEvent — pending prompts", () => {
|
||||
it("renders a submitted queued prompt as user feedback", () => {
|
||||
const state = new MessageTranslatorState()
|
||||
const event: CoreSessionEvent = {
|
||||
type: "pending_prompt_submitted",
|
||||
payload: {
|
||||
sessionId: "session-1",
|
||||
id: "pending-1",
|
||||
prompt: "please just finish",
|
||||
delivery: "queue",
|
||||
attachmentCount: 2,
|
||||
userImages: ["image.png"],
|
||||
userFiles: ["notes.txt"],
|
||||
},
|
||||
}
|
||||
|
||||
const result = translateSessionEvent(event, state)
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "say",
|
||||
say: "user_feedback",
|
||||
text: "please just finish",
|
||||
images: ["image.png"],
|
||||
files: ["notes.txt"],
|
||||
partial: false,
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// translateSessionEvent — agent_event (content_start)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1597,9 +1597,27 @@ export function translateSessionEvent(event: CoreSessionEvent, state: MessageTra
|
||||
break
|
||||
}
|
||||
|
||||
case "team_progress":
|
||||
case "pending_prompts":
|
||||
case "pending_prompt_submitted": {
|
||||
const { prompt, userImages, userFiles } = event.payload
|
||||
const hasPrompt = prompt.trim().length > 0
|
||||
const hasImages = (userImages?.length ?? 0) > 0
|
||||
const hasFiles = (userFiles?.length ?? 0) > 0
|
||||
if (hasPrompt || hasImages || hasFiles) {
|
||||
result.messages.push({
|
||||
ts: state.nextTs(),
|
||||
type: "say",
|
||||
say: "user_feedback",
|
||||
text: prompt,
|
||||
images: userImages,
|
||||
files: userFiles,
|
||||
partial: false,
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "team_progress":
|
||||
case "pending_prompts": {
|
||||
// These are handled by the team/subagent system, not translated
|
||||
// to ClineMessages at this layer
|
||||
break
|
||||
|
||||
@@ -94,9 +94,28 @@ 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,6 +2,7 @@ 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"]>
|
||||
@@ -192,7 +193,7 @@ function readAws(record: Record<string, unknown>): AwsProviderConfig | undefined
|
||||
|
||||
function readProviderSettings(providerId: ProviderId): ConfigParts {
|
||||
try {
|
||||
const settings: unknown = getProviderSettingsManager().getProviderSettings(providerId)
|
||||
const settings: unknown = getProviderSettingsManager().getProviderSettings(toSdkProviderId(providerId))
|
||||
if (!isPlainRecord(settings)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -181,13 +181,129 @@ 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" })
|
||||
|
||||
@@ -127,6 +127,10 @@ 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}`
|
||||
}
|
||||
@@ -280,12 +284,13 @@ function writeStateFields(providerId: ProviderId, patch: ProviderConfigPatch): v
|
||||
}
|
||||
|
||||
function getProviderSettings(providerId: ProviderId): ProviderSettingsRecord {
|
||||
const settings = getProviderSettingsManager().getProviderSettings(providerId)
|
||||
const settings = getProviderSettingsManager().getProviderSettings(providerSettingsProviderId(providerId))
|
||||
return isRecord(settings) ? settings : {}
|
||||
}
|
||||
|
||||
function saveProviderSettings(providerId: ProviderId, next: ProviderSettingsRecord): void {
|
||||
getProviderSettingsManager().saveProviderSettings({ provider: providerId, ...next }, { setLastUsed: false })
|
||||
const provider = providerSettingsProviderId(providerId)
|
||||
getProviderSettingsManager().saveProviderSettings({ ...next, provider }, { setLastUsed: false })
|
||||
}
|
||||
|
||||
function writeProviderSettingsFields(providerId: ProviderId, patch: ProviderConfigPatch): void {
|
||||
|
||||
@@ -21,7 +21,12 @@ describe("SdkFollowupCoordinator", () => {
|
||||
|
||||
await coordinator.askResponse("yes", undefined, undefined, "yesButtonClicked")
|
||||
|
||||
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith("yes", "yesButtonClicked")
|
||||
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith(
|
||||
"yes",
|
||||
"yesButtonClicked",
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
expect(options.sessions.fireAndForgetSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -72,6 +77,7 @@ describe("SdkFollowupCoordinator", () => {
|
||||
|
||||
await coordinator.askResponse("queued")
|
||||
|
||||
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
|
||||
expect(options.resetMessageTranslator).not.toHaveBeenCalled()
|
||||
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
|
||||
activeSession.sdkHost,
|
||||
@@ -83,6 +89,87 @@ describe("SdkFollowupCoordinator", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("queues a follow-up when the turn phase is still streaming even if the session running flag is stale", async () => {
|
||||
const activeSession = makeActiveSession({ isRunning: false })
|
||||
const task = makeTask("session-123")
|
||||
const { coordinator, options } = makeCoordinator({ activeSession, task })
|
||||
|
||||
await coordinator.askResponse("queued while streaming", undefined, undefined, "messageResponse", "streaming")
|
||||
|
||||
expect(options.waitForPendingModeRebuild).not.toHaveBeenCalled()
|
||||
expect(options.sessions.startNewSession).not.toHaveBeenCalled()
|
||||
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
|
||||
expect(options.resetMessageTranslator).not.toHaveBeenCalled()
|
||||
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
|
||||
activeSession.sdkHost,
|
||||
"session-123",
|
||||
"resolved: queued while streaming",
|
||||
undefined,
|
||||
undefined,
|
||||
"queue",
|
||||
)
|
||||
})
|
||||
|
||||
it("queues a chat-field message submitted while a tool approval is pending", async () => {
|
||||
const activeSession = makeActiveSession({ isRunning: false })
|
||||
const task = makeTask("session-123")
|
||||
const { coordinator, options } = makeCoordinator({ activeSession, task })
|
||||
options.interactions.resolvePendingToolApproval.mockReturnValue(false)
|
||||
|
||||
await coordinator.askResponse(
|
||||
"do the next thing after this",
|
||||
undefined,
|
||||
undefined,
|
||||
"messageResponse",
|
||||
"awaiting_approval",
|
||||
)
|
||||
|
||||
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()
|
||||
expect(options.resetMessageTranslator).not.toHaveBeenCalled()
|
||||
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
|
||||
activeSession.sdkHost,
|
||||
"session-123",
|
||||
"resolved: do the next thing after this",
|
||||
undefined,
|
||||
undefined,
|
||||
"queue",
|
||||
)
|
||||
})
|
||||
|
||||
it("sends immediately when the submit-time phase was completed", async () => {
|
||||
const activeSession = makeActiveSession({ isRunning: false })
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
|
||||
await coordinator.askResponse("next request", undefined, undefined, "messageResponse", "completed")
|
||||
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
type: "say",
|
||||
say: "user_feedback",
|
||||
text: "next request",
|
||||
}),
|
||||
],
|
||||
{ type: "status", payload: { sessionId: "session-123", status: "running" } },
|
||||
)
|
||||
expect(options.resetMessageTranslator).toHaveBeenCalledOnce()
|
||||
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
|
||||
activeSession.sdkHost,
|
||||
"session-123",
|
||||
"resolved: next request",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it("waits for an in-flight mode rebuild before deciding whether to resume a displayed task", async () => {
|
||||
const task = makeTask("task-1")
|
||||
const rebuiltSession = makeActiveSession({ isRunning: true })
|
||||
@@ -117,24 +204,20 @@ describe("SdkFollowupCoordinator", () => {
|
||||
)
|
||||
})
|
||||
|
||||
it("queues a message response after a pending tool approval is rejected", async () => {
|
||||
it("queues a message response after a pending tool approval is not resolved by chat text", async () => {
|
||||
const activeSession = makeActiveSession({ isRunning: true })
|
||||
const { coordinator, options } = makeCoordinator({ activeSession })
|
||||
options.interactions.resolvePendingToolApproval.mockReturnValue(false)
|
||||
|
||||
await coordinator.askResponse("just give me an answer", undefined, undefined, "messageResponse")
|
||||
|
||||
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith("just give me an answer", "messageResponse")
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith(
|
||||
[
|
||||
expect.objectContaining({
|
||||
type: "say",
|
||||
say: "user_feedback",
|
||||
text: "just give me an answer",
|
||||
}),
|
||||
],
|
||||
{ type: "status", payload: { sessionId: "session-123", status: "running" } },
|
||||
expect(options.interactions.resolvePendingToolApproval).toHaveBeenCalledWith(
|
||||
"just give me an answer",
|
||||
"messageResponse",
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
|
||||
expect(options.sessions.fireAndForgetSend).toHaveBeenCalledWith(
|
||||
activeSession.sdkHost,
|
||||
"session-123",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@shared/ClineAccount"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import type { ClineMessage, TurnPhase } from "@shared/ExtensionMessage"
|
||||
import type { Mode } from "@shared/storage/types"
|
||||
import type { ClineAskResponse } from "@shared/WebviewMessage"
|
||||
import type { StateManager } from "@/core/storage/StateManager"
|
||||
@@ -47,12 +47,18 @@ export interface SdkFollowupCoordinatorOptions {
|
||||
export class SdkFollowupCoordinator {
|
||||
constructor(private readonly options: SdkFollowupCoordinatorOptions) {}
|
||||
|
||||
async askResponse(prompt?: string, images?: string[], files?: string[], askResponse?: ClineAskResponse): Promise<void> {
|
||||
async askResponse(
|
||||
prompt?: string,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
askResponse?: ClineAskResponse,
|
||||
turnPhaseAtSubmit?: TurnPhase,
|
||||
): Promise<void> {
|
||||
if (this.options.interactions.resolvePendingMistakeLimit(prompt, askResponse)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.options.interactions.resolvePendingToolApproval(prompt, askResponse)) {
|
||||
if (this.options.interactions.resolvePendingToolApproval(prompt, askResponse, images, files)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -62,7 +68,9 @@ export class SdkFollowupCoordinator {
|
||||
|
||||
let activeSession = this.options.sessions.getActiveSession()
|
||||
const task = this.options.getTask()
|
||||
if (!activeSession?.isRunning && task) {
|
||||
const submittedDuringActiveTurn = turnPhaseAtSubmit === "streaming" || turnPhaseAtSubmit === "awaiting_approval"
|
||||
const isActiveTurnInProgress = () => !!activeSession && (activeSession.isRunning || submittedDuringActiveTurn)
|
||||
if (!isActiveTurnInProgress() && task) {
|
||||
// A mode rebuild clears the active session while the old stop is
|
||||
// awaited and only marks the replacement running after the
|
||||
// continuation send. Resuming in that window would start a parallel
|
||||
@@ -71,7 +79,7 @@ export class SdkFollowupCoordinator {
|
||||
await this.options.waitForPendingModeRebuild()
|
||||
activeSession = this.options.sessions.getActiveSession()
|
||||
}
|
||||
if (!activeSession?.isRunning && task) {
|
||||
if (!isActiveTurnInProgress() && task) {
|
||||
Logger.log(`[SdkController] askResponse: No active session but task exists (${task.taskId}), resuming...`)
|
||||
await this.tryResumeSessionFromTask(task.taskId, prompt, images, files)
|
||||
return
|
||||
@@ -83,17 +91,19 @@ export class SdkFollowupCoordinator {
|
||||
}
|
||||
|
||||
const { sdkHost, sessionId } = activeSession
|
||||
const wasAlreadyRunning = activeSession.isRunning
|
||||
const delivery = wasAlreadyRunning ? ("queue" as const) : undefined
|
||||
const shouldQueue = isActiveTurnInProgress()
|
||||
const delivery = shouldQueue ? ("queue" as const) : undefined
|
||||
|
||||
if (wasAlreadyRunning) {
|
||||
if (shouldQueue) {
|
||||
Logger.log(`[SdkController] Session is running - queuing follow-up message for session: ${sessionId}`)
|
||||
}
|
||||
|
||||
this.options.sessions.setRunning(true)
|
||||
this.emitUserFeedback(sessionId, prompt, images, files)
|
||||
if (!shouldQueue) {
|
||||
this.emitUserFeedback(sessionId, prompt, images, files)
|
||||
}
|
||||
|
||||
if (!wasAlreadyRunning) {
|
||||
if (!shouldQueue) {
|
||||
this.options.resetMessageTranslator()
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { MessageTranslatorState, translateSessionEvent } from "./message-transla
|
||||
import { SdkInteractionCoordinator } from "./sdk-interaction-coordinator"
|
||||
import { SdkMessageCoordinator } from "./sdk-message-coordinator"
|
||||
import { createTaskProxy } from "./task-proxy"
|
||||
import { DEFAULT_TOOL_APPROVAL_DENIAL_REASON, USER_MESSAGE_TOOL_APPROVAL_DENIAL_REASON } from "./tool-approval-denial"
|
||||
import { DEFAULT_TOOL_APPROVAL_DENIAL_REASON } from "./tool-approval-denial"
|
||||
|
||||
vi.mock("./webview-grpc-bridge", () => ({
|
||||
pushMessageToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -103,8 +103,9 @@ 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: new SdkMessageCoordinator({ getTask: () => task }),
|
||||
messages,
|
||||
getSessionId: () => "session-123",
|
||||
postStateToWebview: vi.fn().mockResolvedValue(undefined),
|
||||
recordApprovedToolMessage,
|
||||
@@ -125,13 +126,21 @@ describe("SdkInteractionCoordinator", () => {
|
||||
const clineMessages = task.messageStateHandler.getClineMessages()
|
||||
expect(clineMessages[0]).toMatchObject({ type: "ask", ask: "command", text: "npm test" })
|
||||
|
||||
expect(coordinator.resolvePendingToolApproval("too risky", "noButtonClicked")).toBe(true)
|
||||
expect(coordinator.resolvePendingToolApproval("too risky", "noButtonClicked", ["image.png"], ["a.ts"])).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" })
|
||||
})
|
||||
|
||||
it("routes message responses as follow-ups instead of tool denial text", async () => {
|
||||
it("routes message responses as queued follow-ups without resolving pending tool approval", async () => {
|
||||
const task = createTaskProxy("session-123", vi.fn(), vi.fn())
|
||||
const setTurnPhase = vi.fn()
|
||||
const recordDeniedToolApproval = vi.fn()
|
||||
@@ -155,16 +164,11 @@ describe("SdkInteractionCoordinator", () => {
|
||||
await vi.waitFor(() => expect(task.messageStateHandler.getClineMessages()).toHaveLength(1))
|
||||
|
||||
expect(coordinator.resolvePendingToolApproval("just give me an answer", "messageResponse")).toBe(false)
|
||||
await expect(approvalPromise).resolves.toEqual({
|
||||
approved: false,
|
||||
reason: USER_MESSAGE_TOOL_APPROVAL_DENIAL_REASON,
|
||||
})
|
||||
expect(recordDeniedToolApproval).toHaveBeenCalledWith(
|
||||
"tool-call",
|
||||
"fetch_web_content",
|
||||
USER_MESSAGE_TOOL_APPROVAL_DENIAL_REASON,
|
||||
)
|
||||
expect(setTurnPhase).toHaveBeenLastCalledWith("streaming")
|
||||
expect(recordDeniedToolApproval).not.toHaveBeenCalled()
|
||||
expect(setTurnPhase).toHaveBeenLastCalledWith("awaiting_approval", task.messageStateHandler.getClineMessages()[0].ts)
|
||||
|
||||
expect(coordinator.resolvePendingToolApproval(undefined, "yesButtonClicked")).toBe(true)
|
||||
await expect(approvalPromise).resolves.toEqual({ approved: true })
|
||||
})
|
||||
|
||||
it("records generic no-button approval denials for UI suppression", async () => {
|
||||
@@ -193,6 +197,7 @@ describe("SdkInteractionCoordinator", () => {
|
||||
approved: false,
|
||||
reason: DEFAULT_TOOL_APPROVAL_DENIAL_REASON,
|
||||
})
|
||||
expect(task.messageStateHandler.getClineMessages()).toHaveLength(1)
|
||||
expect(recordDeniedToolApproval).toHaveBeenCalledWith(
|
||||
"tool-call",
|
||||
"fetch_web_content",
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Logger } from "@/shared/services/Logger"
|
||||
import { MessageIdMinter } from "./message-id-minter"
|
||||
import { buildToolApprovalAskMessage } from "./message-translator"
|
||||
import type { SdkMessageCoordinator } from "./sdk-message-coordinator"
|
||||
import { DEFAULT_TOOL_APPROVAL_DENIAL_REASON, USER_MESSAGE_TOOL_APPROVAL_DENIAL_REASON } from "./tool-approval-denial"
|
||||
import { DEFAULT_TOOL_APPROVAL_DENIAL_REASON } from "./tool-approval-denial"
|
||||
|
||||
export interface ToolApprovalRequest {
|
||||
agentId: string
|
||||
@@ -127,31 +127,29 @@ export class SdkInteractionCoordinator {
|
||||
})
|
||||
}
|
||||
|
||||
resolvePendingToolApproval(prompt: string | undefined, responseType: ClineAskResponse | undefined): boolean {
|
||||
resolvePendingToolApproval(
|
||||
prompt: string | undefined,
|
||||
responseType: ClineAskResponse | undefined,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
): boolean {
|
||||
if (!this.pendingToolApprovalResolve) {
|
||||
return false
|
||||
}
|
||||
|
||||
const resolve = this.pendingToolApprovalResolve
|
||||
const pendingMessage = this.pendingToolApprovalMessage
|
||||
this.pendingToolApprovalResolve = undefined
|
||||
this.pendingToolApprovalMessage = undefined
|
||||
|
||||
if (responseType === "messageResponse") {
|
||||
Logger.log("[SdkController] Rejecting pending tool approval from user message and routing message as follow-up")
|
||||
this.options.setTurnPhase?.("streaming")
|
||||
if (pendingMessage) {
|
||||
this.options.recordDeniedToolApproval?.(
|
||||
pendingMessage.toolCallId,
|
||||
pendingMessage.toolName,
|
||||
USER_MESSAGE_TOOL_APPROVAL_DENIAL_REASON,
|
||||
)
|
||||
}
|
||||
resolve({ approved: false, reason: USER_MESSAGE_TOOL_APPROVAL_DENIAL_REASON })
|
||||
// The approval was resolved, but the chat message still needs normal follow-up routing.
|
||||
Logger.log("[SdkController] Leaving pending tool approval open and routing user message as queued follow-up")
|
||||
this.options.setTurnPhase?.("awaiting_approval", pendingMessage?.messageTs)
|
||||
// The approval remains pending. The chat message still needs normal follow-up routing.
|
||||
return false
|
||||
}
|
||||
|
||||
this.pendingToolApprovalResolve = undefined
|
||||
this.pendingToolApprovalMessage = undefined
|
||||
|
||||
const approved = responseType === "yesButtonClicked"
|
||||
Logger.log(`[SdkController] Resolving pending tool approval: approved=${approved} (responseType=${responseType})`)
|
||||
if (approved && pendingMessage) {
|
||||
@@ -162,6 +160,21 @@ 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)
|
||||
}
|
||||
|
||||
@@ -15,60 +15,21 @@ vi.mock("./hooks-adapter", () => ({
|
||||
}))
|
||||
|
||||
describe("SdkSessionConfigBuilder", () => {
|
||||
it("adds the CLI plan-mode switch_to_act_mode tool only in plan mode", async () => {
|
||||
const stateManager = {
|
||||
getGlobalSettingsKey: vi.fn(() => "plan"),
|
||||
}
|
||||
const onSwitchToActMode = vi.fn()
|
||||
const builder = new SdkSessionConfigBuilder({
|
||||
stateManager: stateManager as never,
|
||||
emitHookMessage: vi.fn(),
|
||||
onSwitchToActMode,
|
||||
})
|
||||
|
||||
mocks.buildSessionConfig.mockResolvedValueOnce({
|
||||
extraTools: [],
|
||||
hooks: {},
|
||||
})
|
||||
const planConfig = await builder.build({ cwd: "/workspace", mode: "plan" })
|
||||
const switchTool = planConfig.extraTools?.find((tool) => tool.name === "switch_to_act_mode")
|
||||
expect(switchTool).toBeDefined()
|
||||
// Ends the run cleanly after the tool result so the loop never starts an
|
||||
// iteration that the stop hook would abort (which surfaced in the webview
|
||||
// as "API Request Cancelled").
|
||||
expect(switchTool?.lifecycle?.completesRun).toBe(true)
|
||||
expect(await switchTool?.execute({}, {} as never)).toBe(
|
||||
"You successfully switched to act mode, proceed with the plan. You now have access to editing files and running commands. (The switch_to_act_mode tool is only available in plan mode.)",
|
||||
)
|
||||
expect(onSwitchToActMode).toHaveBeenCalledOnce()
|
||||
|
||||
mocks.buildSessionConfig.mockResolvedValueOnce({
|
||||
extraTools: [switchTool],
|
||||
hooks: {},
|
||||
})
|
||||
const actConfig = await builder.build({ cwd: "/workspace", mode: "act" })
|
||||
expect(actConfig.extraTools?.some((tool) => tool.name === "switch_to_act_mode")).toBe(false)
|
||||
})
|
||||
|
||||
it("stops before the next model call after switch_to_act_mode queues a mode change", async () => {
|
||||
const baseBeforeModel = vi.fn(async () => ({ metadata: "base" }))
|
||||
mocks.buildAgentHooks.mockReturnValueOnce({ beforeModel: baseBeforeModel })
|
||||
mocks.buildSessionConfig.mockResolvedValueOnce({ hooks: {} })
|
||||
|
||||
it("does not expose the SDK switch_to_act_mode tool in VS Code plan mode", async () => {
|
||||
const builder = new SdkSessionConfigBuilder({
|
||||
stateManager: {} as never,
|
||||
emitHookMessage: vi.fn(),
|
||||
onSwitchToActMode: vi.fn(),
|
||||
shouldStopAfterModeSwitch: () => true,
|
||||
})
|
||||
|
||||
const config = await builder.build({ cwd: "/workspace", mode: "act" })
|
||||
|
||||
await expect(config.hooks?.beforeModel?.({} as never)).resolves.toEqual({
|
||||
metadata: "base",
|
||||
stop: true,
|
||||
mocks.buildSessionConfig.mockResolvedValueOnce({
|
||||
extraTools: [{ name: "switch_to_act_mode" }, { name: "attempt_completion" }],
|
||||
hooks: {},
|
||||
})
|
||||
expect(baseBeforeModel).toHaveBeenCalledOnce()
|
||||
|
||||
const planConfig = await builder.build({ cwd: "/workspace", mode: "plan" })
|
||||
|
||||
expect(planConfig.extraTools?.some((tool) => tool.name === "switch_to_act_mode")).toBe(false)
|
||||
expect(planConfig.extraTools?.some((tool) => tool.name === "attempt_completion")).toBe(true)
|
||||
})
|
||||
|
||||
it("passes the mistake-limit callback into the SDK config without overriding SDK execution defaults", async () => {
|
||||
@@ -78,7 +39,6 @@ describe("SdkSessionConfigBuilder", () => {
|
||||
const builder = new SdkSessionConfigBuilder({
|
||||
stateManager: { getGlobalSettingsKey: vi.fn(() => 3) } as never,
|
||||
emitHookMessage: vi.fn(),
|
||||
onSwitchToActMode: vi.fn(),
|
||||
onConsecutiveMistakeLimitReached,
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { CoreSessionConfig } from "@cline/core"
|
||||
import { type AgentTool, createTool } from "@cline/shared"
|
||||
import type { StateManager } from "@/core/storage/StateManager"
|
||||
import { buildSessionConfig, type SessionConfigInput } from "./cline-session-factory"
|
||||
import { buildAgentHooks, type HookMessageEmitter } from "./hooks-adapter"
|
||||
@@ -7,8 +6,6 @@ import { buildAgentHooks, type HookMessageEmitter } from "./hooks-adapter"
|
||||
export interface SdkSessionConfigBuilderOptions {
|
||||
stateManager: StateManager
|
||||
emitHookMessage: HookMessageEmitter
|
||||
onSwitchToActMode: () => void
|
||||
shouldStopAfterModeSwitch?: () => boolean
|
||||
onConsecutiveMistakeLimitReached?: CoreSessionConfig["onConsecutiveMistakeLimitReached"]
|
||||
}
|
||||
|
||||
@@ -21,61 +18,9 @@ export class SdkSessionConfigBuilder {
|
||||
config.onConsecutiveMistakeLimitReached = this.options.onConsecutiveMistakeLimitReached
|
||||
}
|
||||
|
||||
const baseHooks = buildAgentHooks(this.options.stateManager, this.options.emitHookMessage)
|
||||
config.hooks = {
|
||||
...baseHooks,
|
||||
beforeModel: async (ctx) => {
|
||||
const baseControl = await baseHooks.beforeModel?.(ctx)
|
||||
if (this.options.shouldStopAfterModeSwitch?.()) {
|
||||
return {
|
||||
...baseControl,
|
||||
stop: true,
|
||||
}
|
||||
}
|
||||
return baseControl
|
||||
},
|
||||
}
|
||||
if (input.mode === "plan") {
|
||||
// Match the CLI interactive runtime: plan-mode sessions expose a
|
||||
// switch_to_act_mode tool in addition to the read-only planning tools.
|
||||
config.extraTools = [...(config.extraTools ?? []), this.createSwitchToActModeTool()]
|
||||
} else {
|
||||
// The switch tool is plan-only in the CLI and should disappear after
|
||||
// rebuilding the session in act mode.
|
||||
config.extraTools = config.extraTools?.filter((tool) => tool.name !== "switch_to_act_mode")
|
||||
}
|
||||
config.hooks = buildAgentHooks(this.options.stateManager, this.options.emitHookMessage)
|
||||
config.extraTools = config.extraTools?.filter((tool) => tool.name !== "switch_to_act_mode")
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
private createSwitchToActModeTool(): AgentTool {
|
||||
return createTool({
|
||||
name: "switch_to_act_mode",
|
||||
description:
|
||||
"Switch from plan mode to act mode. Switching to act mode immediately starts executing the plan, so only call this after the user has explicitly approved the plan in a message sent AFTER you presented it (e.g. 'looks good', 'go ahead', 'switch to act mode'). " +
|
||||
"Never call this in the same turn you present a plan, never call it proactively, and never treat the original task request as approval.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
retryable: false,
|
||||
maxRetries: 0,
|
||||
// End the run cleanly right after the tool result instead of letting the
|
||||
// loop start another iteration that the beforeModel stop hook would abort.
|
||||
// An aborted run leaves a dangling api_req_started spinner behind, which the
|
||||
// webview renders as "API Request Cancelled".
|
||||
lifecycle: {
|
||||
completesRun: true,
|
||||
},
|
||||
execute: async () => {
|
||||
const currentMode = this.options.stateManager.getGlobalSettingsKey("mode")
|
||||
if (currentMode === "act") {
|
||||
return "Already in act mode."
|
||||
}
|
||||
this.options.onSwitchToActMode()
|
||||
return "You successfully switched to act mode, proceed with the plan. You now have access to editing files and running commands. (The switch_to_act_mode tool is only available in plan mode.)"
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,62 @@ describe("SdkSessionEventCoordinator", () => {
|
||||
expect(options.postStateToWebview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("marks a submitted queued prompt as a new streaming turn", async () => {
|
||||
const message: ClineMessage = { ts: 1, type: "say", say: "user_feedback", text: "queued prompt" }
|
||||
const { coordinator, options } = makeCoordinator({
|
||||
translation: {
|
||||
messages: [message],
|
||||
sessionEnded: false,
|
||||
turnComplete: false,
|
||||
},
|
||||
})
|
||||
const clearTurnOutcome = vi.spyOn(options.messageTranslatorState, "clearTurnOutcome")
|
||||
const event: CoreSessionEvent = {
|
||||
type: "pending_prompt_submitted",
|
||||
payload: {
|
||||
sessionId: "session-123",
|
||||
id: "pending-1",
|
||||
prompt: "queued prompt",
|
||||
delivery: "queue",
|
||||
attachmentCount: 0,
|
||||
},
|
||||
} as CoreSessionEvent
|
||||
|
||||
await coordinator.handleSessionEvent(event)
|
||||
|
||||
expect(clearTurnOutcome).toHaveBeenCalledOnce()
|
||||
expect(options.sessions.setRunning).toHaveBeenCalledWith(true)
|
||||
expect(options.setTurnPhase).toHaveBeenCalledWith("streaming")
|
||||
expect(options.messages.appendAndEmit).toHaveBeenCalledWith([message], event)
|
||||
expect(options.postStateToWebview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("posts state for queued prompt turn start even when no transcript message is emitted", async () => {
|
||||
const { coordinator, options } = makeCoordinator({
|
||||
translation: {
|
||||
messages: [],
|
||||
sessionEnded: false,
|
||||
turnComplete: false,
|
||||
},
|
||||
})
|
||||
const event: CoreSessionEvent = {
|
||||
type: "pending_prompt_submitted",
|
||||
payload: {
|
||||
sessionId: "session-123",
|
||||
id: "pending-1",
|
||||
prompt: "",
|
||||
delivery: "queue",
|
||||
attachmentCount: 0,
|
||||
},
|
||||
} as CoreSessionEvent
|
||||
|
||||
await coordinator.handleSessionEvent(event)
|
||||
|
||||
expect(options.setTurnPhase).toHaveBeenCalledWith("streaming")
|
||||
expect(options.messages.appendAndEmit).not.toHaveBeenCalled()
|
||||
expect(options.postStateToWebview).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it("does NOT override the phase on a turn-complete straggler from an already-cancelled session", async () => {
|
||||
// After cancelTask sets phase "resumable" and aborts, the SDK may still emit a trailing
|
||||
// done/turnComplete. Because the session is no longer running, this straggler must NOT
|
||||
@@ -262,6 +318,7 @@ function makeCoordinator(input: Partial<MakeCoordinatorInput> = {}) {
|
||||
taskHistory: SdkSessionEventCoordinatorOptions["taskHistory"] & { updateTaskUsage: ReturnType<typeof vi.fn> }
|
||||
postStateToWebview: ReturnType<typeof vi.fn>
|
||||
translateSessionEvent: ReturnType<typeof vi.fn>
|
||||
messageTranslatorState: MessageTranslatorState
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -64,6 +64,11 @@ export class SdkSessionEventCoordinator {
|
||||
}
|
||||
|
||||
const result = this.translateSessionEvent(event, this.options.messageTranslatorState)
|
||||
if (event.type === "pending_prompt_submitted") {
|
||||
this.options.messageTranslatorState.clearTurnOutcome()
|
||||
this.options.sessions.setRunning(true)
|
||||
this.options.setTurnPhase?.("streaming")
|
||||
}
|
||||
const zeroCostPromise = this.zeroCostForFreeClineModel(result)
|
||||
if (zeroCostPromise) {
|
||||
await zeroCostPromise
|
||||
@@ -130,7 +135,12 @@ export class SdkSessionEventCoordinator {
|
||||
// completed/awaiting_followup/error above; without posting here the webview would stay on
|
||||
// the prior phase (footer stuck on the streaming/scroll state). The webview reducer gates
|
||||
// turnState by seq, so an extra no-message post is safe.
|
||||
if (result.messages.length > 0 || result.sessionEnded || result.turnComplete) {
|
||||
if (
|
||||
result.messages.length > 0 ||
|
||||
result.sessionEnded ||
|
||||
result.turnComplete ||
|
||||
event.type === "pending_prompt_submitted"
|
||||
) {
|
||||
this.options.postStateToWebview().catch((err) => {
|
||||
Logger.error("[SdkController] Failed to post state after event:", err)
|
||||
})
|
||||
|
||||
@@ -41,6 +41,24 @@ describe("SdkSessionLifecycle", () => {
|
||||
expect(lifecycle.getActiveSession()?.isRunning).toBe(true)
|
||||
})
|
||||
|
||||
it("disables file mutation tools for plan-mode sessions even without auto-approval settings", async () => {
|
||||
const sdkHost = makeSdkHost()
|
||||
mockCreateSessionHost.mockResolvedValueOnce(sdkHost)
|
||||
const lifecycle = makeLifecycle()
|
||||
|
||||
await lifecycle.startNewSession({ config: { mode: "plan" } } as StartInput)
|
||||
|
||||
expect(sdkHost.start).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
toolPolicies: expect.objectContaining({
|
||||
editor: { enabled: false, autoApprove: false },
|
||||
write_to_file: { enabled: false, autoApprove: false },
|
||||
run_commands: { autoApprove: false },
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("reuses the shared session host across sessions", async () => {
|
||||
const sdkHost = makeSdkHost({
|
||||
start: vi.fn().mockResolvedValueOnce({ sessionId: "session-1" }).mockResolvedValueOnce({ sessionId: "session-2" }),
|
||||
@@ -475,7 +493,7 @@ describe("SdkSessionLifecycle", () => {
|
||||
function makeLifecycle(overrides: Partial<ConstructorParameters<typeof SdkSessionLifecycle>[0]> = {}) {
|
||||
return new SdkSessionLifecycle({
|
||||
// biome-ignore lint/suspicious/noExplicitAny: focused fake for lifecycle unit test
|
||||
mcpHub: {} as any,
|
||||
mcpHub: { getServers: () => [] } as any,
|
||||
requestToolApproval: vi.fn(),
|
||||
askQuestion: vi.fn(),
|
||||
onSessionEvent: vi.fn(),
|
||||
|
||||
@@ -116,7 +116,11 @@ export class SdkSessionLifecycle {
|
||||
}
|
||||
|
||||
const autoApprovalSettings = StateManager.get().getGlobalSettingsKey("autoApprovalSettings")
|
||||
const toolPolicies = autoApprovalSettings ? buildToolPolicies(autoApprovalSettings, this.options.mcpHub) : undefined
|
||||
const mode = startInput.config?.mode === "plan" ? "plan" : "act"
|
||||
const toolPolicies =
|
||||
autoApprovalSettings || mode === "plan"
|
||||
? buildToolPolicies(autoApprovalSettings, this.options.mcpHub, mode)
|
||||
: undefined
|
||||
|
||||
const sdkHost = await this.getOrCreateSharedHost()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user