mirror of
https://github.com/cline/cline.git
synced 2026-09-11 05:47:07 +08:00
Compare commits
252
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
bebc581652 | ||
|
|
21ebee3638 | ||
|
|
7d78c5f074 | ||
|
|
352a23a6da | ||
|
|
176662ee20 | ||
|
|
309ad9da72 | ||
|
|
16a6926985 | ||
|
|
d6db723c9d | ||
|
|
ce1fc20a9f | ||
|
|
b780a5ec00 | ||
|
|
285cd6d54c | ||
|
|
ff845539e4 | ||
|
|
05b0aa7dcb | ||
|
|
6cdd882acb | ||
|
|
d055b0941f | ||
|
|
21a0e4c4f1 | ||
|
|
54d022b536 | ||
|
|
a1709d37e5 | ||
|
|
b4157df509 | ||
|
|
60dee73f0f | ||
|
|
2d66bd475e | ||
|
|
a1374ae4a5 | ||
|
|
b8c62ecbae | ||
|
|
96b29d787e | ||
|
|
5b96beb583 | ||
|
|
f2af0d700c | ||
|
|
a9c47f88fd | ||
|
|
49884a1194 | ||
|
|
49da86f60c | ||
|
|
84477dd84f | ||
|
|
bd662d81f6 | ||
|
|
d8a3086eaa | ||
|
|
8ecd136e52 | ||
|
|
14a28b0559 | ||
|
|
c5378d1847 | ||
|
|
b5388e5c8d | ||
|
|
65b3977bc5 | ||
|
|
f4a46cf8e7 | ||
|
|
0b47033a0f | ||
|
|
c94ef4b750 | ||
|
|
3e855f7d3f | ||
|
|
2cd062ce68 | ||
|
|
be6999209e | ||
|
|
9a1e6121c7 | ||
|
|
4ae0ff4d5a | ||
|
|
18737f1448 | ||
|
|
b56ce72fc7 | ||
|
|
425182c7c0 | ||
|
|
30fe302a71 | ||
|
|
eadbc09ffd | ||
|
|
3178487bdd | ||
|
|
f58941c500 | ||
|
|
8da5ffa874 | ||
|
|
9ed95d1dc2 | ||
|
|
5fc7341312 | ||
|
|
d5b38f38cb | ||
|
|
386401b41f | ||
|
|
6471d2475a | ||
|
|
fada079558 | ||
|
|
31d50ff54a | ||
|
|
efebc6380b | ||
|
|
2acc25cf6e | ||
|
|
1ebe99c676 | ||
|
|
f5bbbd7f08 | ||
|
|
8e4a2b8a19 | ||
|
|
dfdeffb558 | ||
|
|
1d22a51e30 | ||
|
|
c827537386 | ||
|
|
f73121e369 | ||
|
|
52bbda183d | ||
|
|
80036cef44 | ||
|
|
b37f872a1f | ||
|
|
ebe2b6498b | ||
|
|
365250c785 | ||
|
|
1e208472da | ||
|
|
ee974ea863 | ||
|
|
a6416d02c8 | ||
|
|
55cfeaec65 | ||
|
|
acfe779b20 | ||
|
|
cff8c22f06 | ||
|
|
8f2be20858 | ||
|
|
f1fd189631 | ||
|
|
9b03cb878d | ||
|
|
3dc020185f | ||
|
|
4829f08b3f | ||
|
|
82d1846a45 | ||
|
|
8636272eb5 | ||
|
|
815b0346f6 | ||
|
|
84fe95de4d | ||
|
|
36be3333d4 | ||
|
|
96c0bd70a4 | ||
|
|
6f621e4bf2 | ||
|
|
d7ea201c25 | ||
|
|
e3b3e6b0ad | ||
|
|
53d0f50494 | ||
|
|
141a61fd45 | ||
|
|
7578a1de36 | ||
|
|
5aa60cf116 | ||
|
|
b62e572ea9 | ||
|
|
268462ddcf | ||
|
|
072e237d01 | ||
|
|
31cff1f75e | ||
|
|
9a981f81d7 | ||
|
|
d8b1ce54d4 | ||
|
|
6bc771ba9d | ||
|
|
87338cacd1 | ||
|
|
827a4a2616 | ||
|
|
13d6fe3876 | ||
|
|
82c3d8d771 | ||
|
|
ee0c63a216 | ||
|
|
11e668ca23 | ||
|
|
50a4bfb435 | ||
|
|
2c53d36e6d | ||
|
|
e4ab360c3c | ||
|
|
5363b2a6d2 | ||
|
|
51d3865ec5 | ||
|
|
2a070cddf5 | ||
|
|
401154a42e | ||
|
|
3b02d5162e | ||
|
|
710cceab90 | ||
|
|
a469377d57 | ||
|
|
1689abf479 | ||
|
|
abd84b80cb | ||
|
|
9d4aa03d4d | ||
|
|
91077fd49e | ||
|
|
6aa4c33918 | ||
|
|
aa8e11ad47 | ||
|
|
ca128cfd9f | ||
|
|
78b1f30133 | ||
|
|
1f31738b32 | ||
|
|
6eeee7c4e6 | ||
|
|
e88e0c4994 | ||
|
|
4258b338df | ||
|
|
7b24026291 | ||
|
|
9a6c972b02 | ||
|
|
fae824bff0 | ||
|
|
9011051f76 | ||
|
|
c4068823a4 | ||
|
|
323492108d | ||
|
|
58b41fbaf5 | ||
|
|
3df2d6c780 | ||
|
|
60b1ab4d57 | ||
|
|
0755b58da1 | ||
|
|
9c030a93b6 | ||
|
|
f9fcf7f5ce | ||
|
|
c4cf2743d6 | ||
|
|
1dbfb50038 | ||
|
|
9b548347ef | ||
|
|
2dc5791d46 | ||
|
|
35e3bb4787 | ||
|
|
205a539491 | ||
|
|
0175548dd8 | ||
|
|
6acc231da5 | ||
|
|
d2ac39455b | ||
|
|
87543e6f47 | ||
|
|
3757a8fd0d | ||
|
|
0e9a5c0fc1 | ||
|
|
8aba515d6b | ||
|
|
a2194f4908 | ||
|
|
f5d0b4fd49 | ||
|
|
61ea8688c6 | ||
|
|
94f5a47a59 | ||
|
|
864419d8b0 | ||
|
|
218db38544 | ||
|
|
a3b3295461 | ||
|
|
d09270940f | ||
|
|
23c9bf1c05 | ||
|
|
52531d935a | ||
|
|
19d4248381 | ||
|
|
b2b29678a2 | ||
|
|
ad30714608 | ||
|
|
9eebc44f24 | ||
|
|
6ddf48d227 | ||
|
|
ee59f81706 | ||
|
|
92f6e28f13 | ||
|
|
27a78f0248 | ||
|
|
2c4980f42a | ||
|
|
64c5e48edb | ||
|
|
3c23f80a94 | ||
|
|
4be362892f | ||
|
|
cdff084652 | ||
|
|
299a4a9520 | ||
|
|
c497698beb | ||
|
|
292133989b | ||
|
|
f5aa035a6a | ||
|
|
bbcae25542 | ||
|
|
9b914912ac | ||
|
|
8c369eeed9 |
@@ -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:
|
||||
|
||||
Vendored
+14
-1
@@ -22,11 +22,24 @@
|
||||
},
|
||||
// Enable Lint and format using Biome
|
||||
"biome.enabled": true,
|
||||
"biome.requireConfiguration": true,
|
||||
"prettier.enable": false,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.removeUnused.biome": "always",
|
||||
"source.removeUnusedImports": "always",
|
||||
"source.organizeImports.biome": "always"
|
||||
},
|
||||
// Disable auto-forwarding ports to prevent Simple Browser from opening the Vite dev server
|
||||
|
||||
Vendored
+22
-2
@@ -169,7 +169,8 @@
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
"npm: protos",
|
||||
"build-sdk:debug"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -208,7 +209,8 @@
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild:test",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
"npm: protos",
|
||||
"build-sdk:debug"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -311,6 +313,24 @@
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "build-sdk:debug",
|
||||
"type": "shell",
|
||||
"command": "bun run build:sdk",
|
||||
"problemMatcher": [
|
||||
"$tsc"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"CLINE_SOURCEMAPS": "1"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
||||
@@ -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,46 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 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
|
||||
- Fixed Z.ai model metadata resolution for Z.ai models accessed through the Cline provider
|
||||
- Reverted the model-name-only display change from v3.0.28; the model picker, selector, and status bar return to their previous display behavior
|
||||
|
||||
## 3.0.28
|
||||
|
||||
- Added a ClinePass onboarding flow with selectable ClinePass models, plus improved ClinePass error handling
|
||||
- Added hub primitive catalogs and refreshed the hub dashboard design with a dedicated customizations breakout
|
||||
- Auto-approve toggles now apply immediately when changed
|
||||
- Feature flags now resolve using your user ID on startup
|
||||
- Fixed Cline model display names so they resolve by model name
|
||||
- Truncate large tool results by default (including MCP and custom tool output) to keep requests within context budget
|
||||
- Hardened parallel tool-call guidance for faster, more reliable multi-tool execution
|
||||
|
||||
## 3.0.27
|
||||
|
||||
- Added a `cline skill` command to install and manage skills, matching `cline plugin install` and `cline mcp` (installs default to the Cline agent directory)
|
||||
|
||||
+1
-1
@@ -121,7 +121,7 @@ const result = await Bun.build({
|
||||
},
|
||||
env: "OTEL_*",
|
||||
banner:
|
||||
'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
|
||||
'import { createRequire as __clineCreateRequire } from "node:module"; const require = __clineCreateRequire(import.meta.url);',
|
||||
});
|
||||
|
||||
if (result.logs.length > 0) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.27",
|
||||
"version": "3.0.31",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
import { installMcpServer } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildMcpInstallDefaults, runMcpInstallCommand } from "./mcp";
|
||||
import {
|
||||
buildMcpInstallDefaults,
|
||||
buildMcpInstallTransport,
|
||||
runMcpInstallCommand,
|
||||
} from "./mcp";
|
||||
|
||||
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", () => {
|
||||
@@ -88,6 +110,52 @@ describe("mcp install command", () => {
|
||||
).toThrow(/only http and https are supported/);
|
||||
});
|
||||
|
||||
it("builds direct stdio installs without shell-joining args", () => {
|
||||
expect(
|
||||
buildMcpInstallTransport({
|
||||
name: "fs",
|
||||
targetArgs: [
|
||||
"npx",
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp/my dir",
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "fs",
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/my dir"],
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("builds direct remote installs with headers and placeholder warnings", () => {
|
||||
expect(
|
||||
buildMcpInstallTransport({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
headers: ["Authorization: Bearer <token>"],
|
||||
targetArgs: ["https://example.com/mcp", "--header=X-Extra: yes"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "docs",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer <token>",
|
||||
"X-Extra": "yes",
|
||||
},
|
||||
},
|
||||
warnings: [
|
||||
'Header "Authorization" looks like it contains a placeholder. Update it in MCP settings before using this server.',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the add wizard with prefilled defaults", async () => {
|
||||
const runWizard = vi.fn(async () => 0);
|
||||
|
||||
@@ -124,11 +192,11 @@ describe("mcp install command", () => {
|
||||
expect(code).toBe(1);
|
||||
expect(runWizard).not.toHaveBeenCalled();
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks for TTY before validating install arguments", async () => {
|
||||
it("checks for TTY before validating wizard install arguments", async () => {
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
@@ -139,7 +207,65 @@ describe("mcp install command", () => {
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
);
|
||||
});
|
||||
|
||||
it("installs directly with --yes without requiring a TTY", async () => {
|
||||
const writeln = vi.fn();
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
targetArgs: [
|
||||
"https://example.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(installMcpServer).toHaveBeenCalledWith({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
targetArgs: [
|
||||
"https://example.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
expect(writeln).toHaveBeenCalledWith("Installed MCP server docs.");
|
||||
expect(writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints direct install JSON with --yes --json", async () => {
|
||||
const writeln = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "fs",
|
||||
targetArgs: ["node", "server.js"],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
json: true,
|
||||
io: { writeln, writeErr: vi.fn() },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toMatchObject({
|
||||
name: "fs",
|
||||
status: "installed",
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
import {
|
||||
type McpInstallOptions as CoreMcpInstallOptions,
|
||||
installMcpServer,
|
||||
type McpInstallResult,
|
||||
type McpServerTransportConfig,
|
||||
} from "@cline/core";
|
||||
import type { McpAddDefaults } from "../wizards/mcp";
|
||||
|
||||
export { buildMcpInstallTransport } from "@cline/core";
|
||||
|
||||
export interface McpCommandIo {
|
||||
writeln?: (text: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface McpInstallOptions {
|
||||
name: string;
|
||||
targetArgs?: string[];
|
||||
transport?: string;
|
||||
export interface McpInstallOptions extends CoreMcpInstallOptions {
|
||||
io?: McpCommandIo;
|
||||
isTty?: boolean;
|
||||
json?: boolean;
|
||||
runWizard?: (defaults: McpAddDefaults) => Promise<number>;
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
export interface McpInstallDirectResult {
|
||||
name: string;
|
||||
status: "installed";
|
||||
transport: McpServerTransportConfig;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function normalizeTransportType(
|
||||
value: string | undefined,
|
||||
): McpAddDefaults["type"] {
|
||||
): McpServerTransportConfig["type"] {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
@@ -91,6 +106,18 @@ export function buildMcpInstallDefaults(options: {
|
||||
};
|
||||
}
|
||||
|
||||
export function installMcpServerDirect(
|
||||
options: McpInstallOptions,
|
||||
): McpInstallDirectResult {
|
||||
const result: McpInstallResult = installMcpServer(options);
|
||||
return {
|
||||
name: result.name,
|
||||
status: result.status,
|
||||
transport: result.transport,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async function runPrefilledWizard(defaults: McpAddDefaults): Promise<number> {
|
||||
const { runMcpWizard } = await import("../wizards/mcp");
|
||||
return runMcpWizard({
|
||||
@@ -104,11 +131,23 @@ export async function runMcpInstallCommand(
|
||||
options: McpInstallOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
if (options.yes) {
|
||||
const result = installMcpServerDirect(options);
|
||||
if (options.json) {
|
||||
options.io?.writeln?.(JSON.stringify(result));
|
||||
} else {
|
||||
options.io?.writeln?.(`Installed MCP server ${result.name}.`);
|
||||
for (const warning of result.warnings) {
|
||||
options.io?.writeErr(warning);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const isTty =
|
||||
options.isTty ?? (process.stdin.isTTY && process.stdout.isTTY);
|
||||
if (!isTty) {
|
||||
throw new Error(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
);
|
||||
}
|
||||
const defaults = buildMcpInstallDefaults(options);
|
||||
|
||||
+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(
|
||||
|
||||
@@ -1,36 +1,2 @@
|
||||
export type ConnectorCatalogEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
|
||||
{
|
||||
name: "discord",
|
||||
description:
|
||||
"Discord interactions and gateway bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "gchat",
|
||||
description: "Google Chat webhook bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "linear",
|
||||
description: "Linear webhook bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "slack",
|
||||
description: "Slack webhook/socket bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "telegram",
|
||||
description: "Bridge Telegram bot messages into RPC chat sessions",
|
||||
},
|
||||
{
|
||||
name: "whatsapp",
|
||||
description: "Bridge WhatsApp webhook messages into RPC chat sessions",
|
||||
},
|
||||
];
|
||||
|
||||
export function listConnectorCatalog(): ConnectorCatalogEntry[] {
|
||||
return CONNECTOR_CATALOG.map((entry) => ({ ...entry }));
|
||||
}
|
||||
export type { ConnectorCatalogEntry } from "@cline/shared";
|
||||
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
|
||||
|
||||
@@ -1,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,6 +1,12 @@
|
||||
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,
|
||||
@@ -26,8 +32,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 +69,7 @@ describe("migration notice", () => {
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_FORCE_MIGRATION_NOTICE: "1",
|
||||
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
|
||||
}),
|
||||
).toBeDefined();
|
||||
});
|
||||
@@ -56,7 +79,7 @@ describe("migration notice", () => {
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_DISABLE_MIGRATION_NOTICE: "1",
|
||||
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
@@ -66,8 +89,8 @@ describe("migration notice", () => {
|
||||
|
||||
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 +101,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,9 +2,9 @@ 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;
|
||||
@@ -71,7 +71,7 @@ export function getClineCliMigrationNotice(
|
||||
}
|
||||
return {
|
||||
id: NOTICE_ID,
|
||||
title: "Welcome to the new Cline CLI",
|
||||
title: "Try ClinePass",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+133
-37
@@ -407,7 +407,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
|
||||
it("does not load interactive runtime for single-prompt mode", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
process.argv = ["bun", "src/index.ts", "say hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
@@ -417,6 +417,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 +454,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 +498,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 +507,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 +630,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", {
|
||||
@@ -727,7 +751,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");
|
||||
|
||||
@@ -1013,7 +1037,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 +1046,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 +1084,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 +1097,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 +1109,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 +1125,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 +1172,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 +1202,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 +1218,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 +1254,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 +1275,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 +1297,13 @@ 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 +1319,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 +1381,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 +1426,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 +1434,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 +1453,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 +1461,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: "",
|
||||
|
||||
+42
-25
@@ -42,6 +42,7 @@ import {
|
||||
isOAuthProvider,
|
||||
normalizeProviderId,
|
||||
} from "./utils/provider-auth";
|
||||
import { resolveCliReasoning } from "./utils/reasoning";
|
||||
import { rewriteTeamPrompt, TEAM_COMMAND_USAGE } from "./utils/team-command";
|
||||
import {
|
||||
captureCliExtensionActivated,
|
||||
@@ -112,6 +113,23 @@ export function resolveConfigDirArg(argv: string[]): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -404,15 +422,24 @@ export async function runCli(): Promise<void> {
|
||||
"--transport <transport>",
|
||||
"stdio, sse, http, streamable-http, or streamableHttp (default: stdio)",
|
||||
)
|
||||
.option("--header <header>", "Remote MCP request header", collectOption, [])
|
||||
.option("--yes", "Install noninteractively without opening the wizard")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(async (name: string, targetArgs: string[]) => {
|
||||
const opts = mcpInstallCmd.opts<{
|
||||
header?: string[];
|
||||
json?: boolean;
|
||||
transport?: string;
|
||||
yes?: boolean;
|
||||
}>();
|
||||
const { runMcpInstallCommand } = await import("./commands/mcp");
|
||||
ctx.exitCode = await runMcpInstallCommand({
|
||||
name,
|
||||
headers: opts.header,
|
||||
targetArgs,
|
||||
transport: opts.transport,
|
||||
json: opts.json === true || program.opts().json === true,
|
||||
yes: opts.yes === true,
|
||||
io,
|
||||
});
|
||||
});
|
||||
@@ -722,13 +749,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) {
|
||||
@@ -815,6 +835,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 =
|
||||
@@ -998,19 +1025,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",
|
||||
@@ -1046,11 +1066,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,
|
||||
|
||||
@@ -27,11 +27,28 @@ const outputMocks = vi.hoisted(() => ({
|
||||
c: { dim: "", reset: "" },
|
||||
}));
|
||||
|
||||
const sessionEventsMocks = vi.hoisted(() => ({
|
||||
listener: undefined as ((event: unknown) => void) | undefined,
|
||||
subscribeToAgentEvents: vi.fn(
|
||||
(_: unknown, listener: (event: unknown) => void) => {
|
||||
sessionEventsMocks.listener = listener;
|
||||
return () => {};
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_URL =
|
||||
"https://app.cline.bot/dashboard/subscription/";
|
||||
const CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true";
|
||||
const CLI_SUBSCRIPTION_URL =
|
||||
"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 =
|
||||
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
getClineOrgIndividualInferenceSubscriptionMessage: () =>
|
||||
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
|
||||
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
|
||||
isClineNotSubscribedError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClineNotSubscribedError",
|
||||
@@ -39,6 +56,15 @@ vi.mock("@cline/core", () => ({
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes("the user is not subscribed to required model plan"),
|
||||
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError",
|
||||
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes(
|
||||
"organization accounts cannot use individual model inference subscriptions",
|
||||
),
|
||||
prewarmFileIndex: vi.fn(async () => undefined),
|
||||
SessionSource: {
|
||||
CLI: "cli",
|
||||
@@ -88,7 +114,7 @@ vi.mock("./prompt", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./session-events", () => ({
|
||||
subscribeToAgentEvents: vi.fn(() => () => {}),
|
||||
subscribeToAgentEvents: sessionEventsMocks.subscribeToAgentEvents,
|
||||
}));
|
||||
|
||||
describe("runAgent", () => {
|
||||
@@ -112,6 +138,9 @@ describe("runAgent", () => {
|
||||
outputMocks.writeln.mockReset();
|
||||
outputMocks.emitJsonLine.mockReset();
|
||||
outputMocks.setActiveCliSession.mockReset();
|
||||
sessionEventsMocks.listener = undefined;
|
||||
sessionEventsMocks.subscribeToAgentEvents.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -523,7 +552,7 @@ describe("runAgent", () => {
|
||||
});
|
||||
|
||||
it("renders ClinePass subscription errors with friendly copy when startup throws", async () => {
|
||||
const error = new Error(CLINE_PASS_SUBSCRIPTION_MESSAGE);
|
||||
const error = new Error(SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE);
|
||||
error.name = "ClineNotSubscribedError";
|
||||
sessionManagerMocks.start.mockRejectedValue(error);
|
||||
|
||||
@@ -551,7 +580,7 @@ describe("runAgent", () => {
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith(
|
||||
CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -629,7 +658,7 @@ describe("runAgent", () => {
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
text: SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
@@ -673,10 +702,73 @@ describe("runAgent", () => {
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith(
|
||||
CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
CLI_CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not duplicate ClinePass subscription errors already displayed by agent events", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockImplementation(async () => {
|
||||
sessionEventsMocks.listener?.({
|
||||
type: "error",
|
||||
error: new Error(SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE),
|
||||
recoverable: false,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: SDK_CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
};
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces post-run bookkeeping failures after a completed result", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
@@ -938,4 +1030,121 @@ describe("runAgent", () => {
|
||||
expect.stringContaining("est. cost"),
|
||||
);
|
||||
});
|
||||
|
||||
it("zeros Cline free model costs in JSON results and agent events", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
sessionManagerMocks.start.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: {
|
||||
session_id: "session-1",
|
||||
},
|
||||
result: {
|
||||
text: "completed text",
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed",
|
||||
model: {
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
provider: "cline",
|
||||
info: {},
|
||||
},
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue({
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
aggregateUsage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
});
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
const { handleEvent } = await import("../utils/events");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: {
|
||||
maxConsecutiveMistakes: 3,
|
||||
},
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
outputMode: "json",
|
||||
providerId: "cline",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const runResult = outputMocks.emitJsonLine.mock.calls.find(
|
||||
([, payload]) =>
|
||||
(payload as { type?: string } | undefined)?.type === "run_result",
|
||||
)?.[1] as
|
||||
| {
|
||||
usage?: { totalCost?: number };
|
||||
aggregateUsage?: { totalCost?: number };
|
||||
}
|
||||
| undefined;
|
||||
expect(runResult?.usage?.totalCost).toBe(0);
|
||||
expect(runResult?.aggregateUsage?.totalCost).toBe(0);
|
||||
|
||||
sessionEventsMocks.listener?.({
|
||||
type: "usage",
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cost: 0.25,
|
||||
totalCost: 0.25,
|
||||
});
|
||||
|
||||
expect(handleEvent).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "usage",
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
} from "../utils/approval";
|
||||
import { formatCliErrorMessage } from "../utils/cline-pass-errors";
|
||||
import { handleEvent, handleTeamEvent } from "../utils/events";
|
||||
import {
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "../utils/free-model-cost";
|
||||
import { createRuntimeHooks } from "../utils/hooks";
|
||||
import {
|
||||
c,
|
||||
@@ -184,8 +189,10 @@ export async function runAgent(
|
||||
let reasoningChunkCount = 0;
|
||||
let redactedReasoningChunkCount = 0;
|
||||
const displayedErrorMessages = new Set<string>();
|
||||
const shouldZeroCost = await shouldZeroClineFreeModelCost(config);
|
||||
|
||||
const onAgentEvent = (event: AgentEvent): void => {
|
||||
const onAgentEvent = (rawEvent: AgentEvent): void => {
|
||||
const event = zeroCliAgentEventCost(rawEvent, shouldZeroCost);
|
||||
if (event.type === "content_start" && event.contentType === "reasoning") {
|
||||
reasoningChunkCount += 1;
|
||||
if (event.redacted) {
|
||||
@@ -197,7 +204,9 @@ export async function runAgent(
|
||||
(!event.recoverable || config.verbose) &&
|
||||
event.error.message.trim()
|
||||
) {
|
||||
displayedErrorMessages.add(event.error.message.trim());
|
||||
displayedErrorMessages.add(
|
||||
formatCliErrorMessage(event.error.message).trim(),
|
||||
);
|
||||
}
|
||||
handleEvent(event, config);
|
||||
};
|
||||
@@ -339,8 +348,14 @@ export async function runAgent(
|
||||
const usageSummary = await sessionManager.getAccumulatedUsage(
|
||||
started.sessionId,
|
||||
);
|
||||
const aggregateUsage = usageSummary?.aggregateUsage;
|
||||
const usage = aggregateUsage ?? usageSummary?.usage ?? result.usage;
|
||||
const aggregateUsage = zeroCliUsageCost(
|
||||
usageSummary?.aggregateUsage,
|
||||
shouldZeroCost,
|
||||
);
|
||||
const usage = zeroCliUsageCost(
|
||||
aggregateUsage ?? usageSummary?.usage ?? result.usage,
|
||||
shouldZeroCost,
|
||||
);
|
||||
|
||||
if (config.outputMode === "json") {
|
||||
emitJsonLine("stdout", {
|
||||
|
||||
@@ -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";
|
||||
@@ -24,6 +25,11 @@ import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
|
||||
import type { QueuedPromptItem } from "../tui/types";
|
||||
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
|
||||
import { applyCliCompactionMode } from "../utils/compaction-mode";
|
||||
import {
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "../utils/free-model-cost";
|
||||
import {
|
||||
prepareTerminalForPostTuiOutput,
|
||||
writeErr,
|
||||
@@ -52,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,
|
||||
@@ -153,6 +176,7 @@ export async function runInteractive(
|
||||
askQuestionRef: tuiAskQuestion,
|
||||
});
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
let zeroCurrentTurnCost = false;
|
||||
|
||||
const sessionRuntime = createInteractiveSessionRuntime({
|
||||
config,
|
||||
@@ -166,7 +190,7 @@ export async function runInteractive(
|
||||
resolveMistakeLimitDecision,
|
||||
switchToActModeTool,
|
||||
onAgentEvent: (event) => {
|
||||
uiEvents.emit("agent", event);
|
||||
uiEvents.emit("agent", zeroCliAgentEventCost(event, zeroCurrentTurnCost));
|
||||
},
|
||||
onTeamEvent: (event) => {
|
||||
uiEvents.emit("team", event);
|
||||
@@ -404,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,
|
||||
@@ -432,6 +462,7 @@ export async function runInteractive(
|
||||
},
|
||||
onSubmit: async (input, mode, delivery, attachments, onCommandOutput) => {
|
||||
let commandOutput: string | undefined;
|
||||
let zeroTurnCost = false;
|
||||
try {
|
||||
await sessionRuntime.ensureReady();
|
||||
await waitForSubmittedMode(mode);
|
||||
@@ -478,6 +509,8 @@ export async function runInteractive(
|
||||
}
|
||||
input = chatCommandResult.input;
|
||||
commandOutput = chatCommandResult.commandOutput;
|
||||
zeroTurnCost = await shouldZeroClineFreeModelCost(config);
|
||||
zeroCurrentTurnCost = zeroTurnCost;
|
||||
const {
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
@@ -519,8 +552,9 @@ export async function runInteractive(
|
||||
}
|
||||
if (result.finishReason !== "completed") {
|
||||
if (result.finishReason === "aborted" || isAbortInProgress()) {
|
||||
const usage = await sessionRuntime.getAccumulatedUsage(
|
||||
result.usage,
|
||||
const usage = zeroCliUsageCost(
|
||||
await sessionRuntime.getAccumulatedUsage(result.usage),
|
||||
zeroTurnCost,
|
||||
);
|
||||
return {
|
||||
usage,
|
||||
@@ -535,7 +569,10 @@ export async function runInteractive(
|
||||
errorText || `Turn finished with ${result.finishReason}`,
|
||||
);
|
||||
}
|
||||
const usage = await sessionRuntime.getAccumulatedUsage(result.usage);
|
||||
const usage = zeroCliUsageCost(
|
||||
await sessionRuntime.getAccumulatedUsage(result.usage),
|
||||
zeroTurnCost,
|
||||
);
|
||||
return {
|
||||
usage,
|
||||
currentContextSize: getCurrentContextSize(result.messages),
|
||||
@@ -559,6 +596,7 @@ export async function runInteractive(
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
zeroCurrentTurnCost = false;
|
||||
if (!delivery) {
|
||||
isRunning = false;
|
||||
clearAbortInProgress();
|
||||
@@ -623,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,11 +1,16 @@
|
||||
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 {
|
||||
getClinePassSubscriptionUrl,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getCliSubscriptionUrl,
|
||||
getIndividualPlanFeatures,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
import { getCliFeatureFlagsService } from "../../utils/feature-flags";
|
||||
import {
|
||||
CLINE_CREDITS_DASHBOARD_URL,
|
||||
isClineAccountCreditsErrorMessage,
|
||||
@@ -264,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="* " />
|
||||
@@ -279,7 +285,60 @@ 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">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 ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
const isClinePassEnabled =
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass");
|
||||
|
||||
if (isClinePassEnabled) {
|
||||
return <ClineCreditsClinePassErrorView defaultFg={props.defaultFg} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor="red"
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="red">Cline Credits depleted</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content={
|
||||
"You have run out of Cline credits. Add credits in the dashboard to continue."
|
||||
}
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Dashboard: </text>
|
||||
@@ -294,8 +353,34 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
|
||||
const subscriptionUrl = getClinePassSubscriptionUrl();
|
||||
function ClinePassSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
}) {
|
||||
const subscriptionUrl = getCliSubscriptionUrl();
|
||||
const [planFeatures, setPlanFeatures] = useState<string[]>([]);
|
||||
|
||||
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="* " />
|
||||
@@ -312,6 +397,17 @@ function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
|
||||
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>
|
||||
@@ -329,9 +425,34 @@ function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
}) {
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="yellow" content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor="yellow"
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="yellow">Personal ClinePass required</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content={getClineOrgIndividualInferenceSubscriptionMessage()}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const { entry, accent = palette.act, terminalTheme } = props;
|
||||
@@ -427,8 +548,22 @@ export function ChatEntryView(props: {
|
||||
if (isClineAccountCreditsErrorMessage(entry.text)) {
|
||||
return <ClineCreditsErrorView defaultFg={defaultFg} />;
|
||||
}
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClineOrgIndividualInferenceSubscriptionErrorView
|
||||
defaultFg={defaultFg}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (isClinePassSubscriptionError(entry.text)) {
|
||||
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
|
||||
return (
|
||||
<ClinePassSubscriptionErrorView
|
||||
defaultFg={defaultFg}
|
||||
loadIndividualSubscriptionPlans={
|
||||
props.loadIndividualSubscriptionPlans
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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,25 @@
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const CLINE_USAGE_BILLING_PATH = "/dashboard/account";
|
||||
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();
|
||||
}
|
||||
|
||||
export function buildClineUsageBillingPageUrl(
|
||||
appBaseUrl: string | undefined,
|
||||
): string {
|
||||
const url = new URL(
|
||||
CLINE_USAGE_BILLING_PATH,
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
);
|
||||
url.searchParams.set("tab", "credits");
|
||||
return url.toString();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
buildClineUsageBillingPageUrl,
|
||||
} 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClineUsageBillingPageUrl", () => {
|
||||
it("opens the credits tab on production by default", () => {
|
||||
expect(buildClineUsageBillingPageUrl(undefined)).toBe(
|
||||
"https://app.cline.bot/dashboard/account?tab=credits",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the configured app base URL", () => {
|
||||
expect(buildClineUsageBillingPageUrl("https://staging-app.cline.bot")).toBe(
|
||||
"https://staging-app.cline.bot/dashboard/account?tab=credits",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,10 @@ import {
|
||||
getSearchableListRowsWindow,
|
||||
type SearchableItem,
|
||||
} from "../searchable-list";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
buildClineUsageBillingPageUrl,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
interface ProviderItem {
|
||||
id: string;
|
||||
@@ -248,18 +252,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 +288,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 +333,106 @@ 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."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClineUsageBillingContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
providerName: string;
|
||||
},
|
||||
) {
|
||||
const usageBillingUrl = useMemo(
|
||||
() => buildClineUsageBillingPageUrl(getClineEnvironmentConfig().appBaseUrl),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<ClinePassBrowserPageContent
|
||||
{...props}
|
||||
pageLabel="Usage and billing"
|
||||
url={usageBillingUrl}
|
||||
openedStatus="Opened usage and billing 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) $0.12");
|
||||
).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)");
|
||||
).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()})`;
|
||||
if (!input.showCost) return tokens;
|
||||
return `${tokens} ${formatCost(input.totalCost)}`;
|
||||
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
|
||||
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,10 @@ import {
|
||||
import type { Config } from "../../utils/types";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import {
|
||||
ClinePassSubscriptionContent,
|
||||
ClineUsageBillingContent,
|
||||
CodexCliStatusContent,
|
||||
type ExistingProviderAction,
|
||||
type ExistingProviderOption,
|
||||
OAuthLoginContent,
|
||||
ProviderConfigInputContent,
|
||||
ProviderPickerContent,
|
||||
@@ -78,6 +80,52 @@ 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: "Open subscription page",
|
||||
onSelect: async () => {
|
||||
await input.dialog.choice<boolean>({
|
||||
style: { maxHeight: input.termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<ClinePassSubscriptionContent
|
||||
{...ctx}
|
||||
providerName={input.providerName}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
value: "open_usage_billing",
|
||||
label: "See usage and billing",
|
||||
onSelect: async () => {
|
||||
await input.dialog.choice<boolean>({
|
||||
style: { maxHeight: input.termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<ClineUsageBillingContent
|
||||
{...ctx}
|
||||
providerName={input.providerName}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function runProviderChange(
|
||||
dialog: DialogActions,
|
||||
config: Config,
|
||||
@@ -102,14 +150,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) {
|
||||
|
||||
@@ -879,6 +879,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,7 +10,12 @@ 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,
|
||||
@@ -20,6 +25,10 @@ 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 +57,8 @@ import {
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import { useOnboardingKeyboard } from "./keyboard";
|
||||
import {
|
||||
CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
type ClinePassSubscriptionStatus,
|
||||
getMainMenuOptions,
|
||||
type ModelEntry,
|
||||
type OnboardingResult,
|
||||
@@ -150,6 +161,18 @@ 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 modelItems: SearchableItem[] = useMemo(
|
||||
() =>
|
||||
@@ -265,6 +288,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 +367,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 +413,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 +439,47 @@ 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(() => {
|
||||
const subscriptionUrl = getCliSubscriptionUrl();
|
||||
setClinePassSubscriptionOpenStatus("Opening subscription page...");
|
||||
void open(subscriptionUrl, { wait: false })
|
||||
.then(() => {
|
||||
setClinePassSubscriptionOpenStatus(
|
||||
"Opened subscription page in your browser.",
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
setClinePassSubscriptionOpenStatus(
|
||||
`Could not open browser automatically. Open ${subscriptionUrl}`,
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
step === "cline_pass_subscription" &&
|
||||
clinePassSubscriptionStatus === "subscribed"
|
||||
) {
|
||||
transitionToModelPicker("cline-pass");
|
||||
}
|
||||
}, [step, clinePassSubscriptionStatus, transitionToModelPicker]);
|
||||
|
||||
const refreshCodexCliStatus = useCallback(() => {
|
||||
setCodexCliStatus(undefined);
|
||||
setCodexCliChecking(true);
|
||||
@@ -632,6 +761,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
modelList,
|
||||
clineEntries,
|
||||
clineModelSelected,
|
||||
clinePassSubscriptionStatus,
|
||||
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
clinePassSubscriptionSelected,
|
||||
thinkingSelected,
|
||||
setStep,
|
||||
setMenuSelected,
|
||||
@@ -648,7 +780,11 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setDeviceError,
|
||||
setDeviceStatus,
|
||||
setClineModelSelected,
|
||||
setClinePassSubscriptionSelected,
|
||||
setThinkingSelected,
|
||||
continueFromClinePassSubscription,
|
||||
refreshClinePassSubscriptionStatus,
|
||||
openClinePassSubscriptionPage,
|
||||
abortOAuth: () => {
|
||||
authAbortRef.current = true;
|
||||
},
|
||||
@@ -670,6 +806,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
return {
|
||||
activeProviderName,
|
||||
activeProviderId,
|
||||
authError,
|
||||
authStatus,
|
||||
authUrl,
|
||||
@@ -682,6 +819,13 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
clineEntries,
|
||||
clineKnownModels,
|
||||
clineModelSelected,
|
||||
clinePassCurrentPlanName,
|
||||
clinePassPlanFeatures,
|
||||
clinePassSubscriptionError,
|
||||
clinePassSubscriptionOpenStatus,
|
||||
clinePassSubscriptionOptions: CLINE_PASS_SUBSCRIPTION_OPTIONS,
|
||||
clinePassSubscriptionSelected,
|
||||
clinePassSubscriptionStatus,
|
||||
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,
|
||||
@@ -20,7 +22,12 @@ import {
|
||||
import { useTerminalBackground } from "../../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, 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 +36,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 +479,181 @@ 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;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
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 ? 14 : 18;
|
||||
|
||||
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 : "yellow"}
|
||||
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 : "yellow"} 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}>
|
||||
<text fg={defaultFg}>ClinePass includes:</text>
|
||||
{props.planFeatures.map((feature) => {
|
||||
if (
|
||||
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>
|
||||
)}
|
||||
</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,23 @@ 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.step === "model_picker") {
|
||||
return (
|
||||
<OnboardingModelPickerScreen
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
getCliNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getCliSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "./cline-pass-errors";
|
||||
|
||||
@@ -13,14 +16,34 @@ describe("cline-pass-errors", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const formatted = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getClinePassSubscriptionUrl()}`;
|
||||
const sdkFormatted =
|
||||
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/dashboard/subscription?personal=true";
|
||||
const formatted = getCliNotSubscribedMessage();
|
||||
expect(isClinePassSubscriptionError(sdkFormatted)).toBe(true);
|
||||
expect(isClinePassSubscriptionError(formatted)).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(sdkFormatted))).toBe(formatted);
|
||||
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
|
||||
});
|
||||
|
||||
it("formats the ClinePass subscription URL", () => {
|
||||
expect(getClinePassSubscriptionUrl()).toBe(
|
||||
"https://app.cline.bot/dashboard/subscription/",
|
||||
expect(getCliSubscriptionUrl()).toBe(
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes and formats organization account individual subscription errors", () => {
|
||||
const raw =
|
||||
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
|
||||
const formatted = getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
|
||||
expect(isClineOrgIndividualInferenceSubscriptionErrorMessage(raw)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
new Error(formatted),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
import {
|
||||
getClinePassSubscriptionUrl,
|
||||
type ClineSubscriptionPlan,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
export { getClinePassSubscriptionUrl };
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
return `${new URL(
|
||||
"/promo?code=CLI-8OFF&personal=true",
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()}`;
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -32,7 +56,33 @@ export function isClinePassSubscriptionError(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
error: unknown,
|
||||
): boolean {
|
||||
if (isClineOrgIndividualInferenceSubscriptionError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError" ||
|
||||
isClineOrgIndividualInferenceSubscriptionMessage(error.message) ||
|
||||
error.message === getClineOrgIndividualInferenceSubscriptionMessage()
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(isClineOrgIndividualInferenceSubscriptionMessage(error) ||
|
||||
error === getClineOrgIndividualInferenceSubscriptionMessage())
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClinePassSubscriptionError(error)) {
|
||||
return getCliNotSubscribedMessage();
|
||||
}
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
return getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearClineFreeModelCostCache,
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "./free-model-cost";
|
||||
|
||||
afterEach(() => {
|
||||
clearClineFreeModelCostCache();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("shouldZeroClineFreeModelCost", () => {
|
||||
it("uses the Cline free model list", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://cline.test/api/v1/ai/cline/recommended-models",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not zero non-Cline providers", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "openrouter",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not match a paid model by only the final path segment", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "acme/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("retries after a failed free model list fetch", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliUsageCost", () => {
|
||||
it("zeros total cost while preserving token usage", () => {
|
||||
expect(
|
||||
zeroCliUsageCost(
|
||||
{
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliAgentEventCost", () => {
|
||||
it("zeros usage event cost fields", () => {
|
||||
const event = {
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cost: 0.001,
|
||||
totalCost: 0.001,
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("zeros done event usage cost", () => {
|
||||
const event = {
|
||||
type: "done",
|
||||
reason: "completed",
|
||||
text: "ok",
|
||||
iterations: 1,
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
usage: { totalCost: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { Config } from "./types";
|
||||
|
||||
const CLINE_RECOMMENDED_MODELS_TIMEOUT_MS = 5_000;
|
||||
const freeModelIdsByBaseUrl = new Map<
|
||||
string,
|
||||
Promise<readonly string[] | undefined>
|
||||
>();
|
||||
|
||||
function normalizeModelId(modelId: string | undefined): string {
|
||||
return modelId?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function modelIdsMatch(selectedModelId: string, freeModelId: string): boolean {
|
||||
const selected = normalizeModelId(selectedModelId);
|
||||
const free = normalizeModelId(freeModelId);
|
||||
if (!selected || !free) return false;
|
||||
return selected === free;
|
||||
}
|
||||
|
||||
function resolveClineRecommendedModelsUrl(baseUrl: string): string {
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
const apiBaseUrl = normalizedBaseUrl.endsWith("/api/v1")
|
||||
? normalizedBaseUrl.slice(0, -"/api/v1".length)
|
||||
: normalizedBaseUrl;
|
||||
return `${apiBaseUrl}/api/v1/ai/cline/recommended-models`;
|
||||
}
|
||||
|
||||
async function fetchClineFreeModelIds(
|
||||
baseUrl: string,
|
||||
): Promise<readonly string[] | undefined> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
CLINE_RECOMMENDED_MODELS_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const json = (await response.json()) as { free?: unknown };
|
||||
return Array.isArray(json.free)
|
||||
? json.free
|
||||
.map((model) =>
|
||||
model && typeof model === "object"
|
||||
? (model as Record<string, unknown>).id
|
||||
: undefined,
|
||||
)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
: [];
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
|
||||
const cacheKey = baseUrl.trim();
|
||||
let cached = freeModelIdsByBaseUrl.get(cacheKey);
|
||||
if (!cached) {
|
||||
cached = fetchClineFreeModelIds(cacheKey).then((ids) => {
|
||||
if (!ids) freeModelIdsByBaseUrl.delete(cacheKey);
|
||||
return ids;
|
||||
});
|
||||
freeModelIdsByBaseUrl.set(cacheKey, cached);
|
||||
}
|
||||
return cached.then((ids) => ids ?? []);
|
||||
}
|
||||
|
||||
export async function shouldZeroClineFreeModelCost(
|
||||
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
|
||||
): Promise<boolean> {
|
||||
if (config.providerId !== "cline") return false;
|
||||
const modelId = normalizeModelId(config.modelId);
|
||||
if (!modelId) return false;
|
||||
|
||||
const baseUrl =
|
||||
config.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
|
||||
const freeModelIds = await getClineFreeModelIds(baseUrl);
|
||||
return freeModelIds.some((freeModelId) =>
|
||||
modelIdsMatch(modelId, freeModelId),
|
||||
);
|
||||
}
|
||||
|
||||
export function zeroCliUsageCost<T extends { totalCost?: number } | undefined>(
|
||||
usage: T,
|
||||
shouldZeroCost: boolean,
|
||||
): T {
|
||||
if (
|
||||
!shouldZeroCost ||
|
||||
!usage ||
|
||||
typeof usage.totalCost !== "number" ||
|
||||
usage.totalCost === 0
|
||||
) {
|
||||
return usage;
|
||||
}
|
||||
return { ...usage, totalCost: 0 } as T;
|
||||
}
|
||||
|
||||
export function zeroCliAgentEventCost(
|
||||
event: AgentEvent,
|
||||
shouldZeroCost: boolean,
|
||||
): AgentEvent {
|
||||
if (!shouldZeroCost) return event;
|
||||
if (event.type === "done" && event.usage) {
|
||||
return {
|
||||
...event,
|
||||
usage: zeroCliUsageCost(event.usage, true),
|
||||
};
|
||||
}
|
||||
if (event.type !== "usage") return event;
|
||||
const next = { ...event } as Record<string, unknown>;
|
||||
if (typeof next.cost === "number") next.cost = 0;
|
||||
if (typeof next.totalCost === "number") next.totalCost = 0;
|
||||
return next as unknown as AgentEvent;
|
||||
}
|
||||
|
||||
export function clearClineFreeModelCostCache(): void {
|
||||
freeModelIdsByBaseUrl.clear();
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -1,325 +1,16 @@
|
||||
export interface PlatformDef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: FieldDef[];
|
||||
security?: SecurityDef;
|
||||
}
|
||||
import {
|
||||
CONNECTOR_PLATFORMS,
|
||||
shouldIncludeConnectorField,
|
||||
} from "@cline/shared";
|
||||
|
||||
export interface FieldDef {
|
||||
flag: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: FieldCondition;
|
||||
}
|
||||
export type {
|
||||
ConnectorFieldCondition as FieldCondition,
|
||||
ConnectorFieldDef as FieldDef,
|
||||
ConnectorPlatformDef as PlatformDef,
|
||||
ConnectorSecurityDef as SecurityDef,
|
||||
ConnectorSecurityFieldDef as SecurityFieldDef,
|
||||
} from "@cline/shared";
|
||||
export { CONNECTOR_PLATFORMS, shouldIncludeConnectorField };
|
||||
|
||||
export type FieldCondition = {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
|
||||
export interface SecurityFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
help?: string[];
|
||||
requiredMessage: string;
|
||||
validate?: (value: string) => string | undefined;
|
||||
}
|
||||
|
||||
export interface SecurityDef {
|
||||
prompt: string;
|
||||
fields: SecurityFieldDef[];
|
||||
buildArgs: (values: Record<string, string>) => string[];
|
||||
}
|
||||
|
||||
export function shouldIncludeField(
|
||||
field: FieldDef,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
const condition = field.includeWhen;
|
||||
if (!condition) {
|
||||
return true;
|
||||
}
|
||||
const value = values[condition.flag] ?? "";
|
||||
if (condition.equals !== undefined && value !== condition.equals) {
|
||||
return false;
|
||||
}
|
||||
if (condition.notEquals !== undefined && value === condition.notEquals) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateTelegramUserId(value: string): string | undefined {
|
||||
return /^\d+$/.test(value)
|
||||
? undefined
|
||||
: "Telegram user ID must contain digits only";
|
||||
}
|
||||
|
||||
function validateSlackTeamId(value: string): string | undefined {
|
||||
return /^T[A-Z0-9]+$/.test(value)
|
||||
? undefined
|
||||
: "Slack workspace ID must start with T and contain uppercase letters or digits only";
|
||||
}
|
||||
|
||||
function validateSlackUserId(value: string): string | undefined {
|
||||
return /^[UW][A-Z0-9]+$/.test(value)
|
||||
? undefined
|
||||
: "Slack member ID must start with U or W and contain uppercase letters or digits only";
|
||||
}
|
||||
|
||||
export const PLATFORMS: PlatformDef[] = [
|
||||
{
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
type: "polling",
|
||||
hint: "Easiest to set up. No public URL needed.",
|
||||
fields: [
|
||||
{
|
||||
flag: "-k",
|
||||
label: "Bot token",
|
||||
placeholder: "7123456789:AAH...",
|
||||
required: true,
|
||||
help: [
|
||||
"Open Telegram and start a chat with @BotFather",
|
||||
"Send /newbot and follow the prompts",
|
||||
"BotFather gives you this after creating the bot",
|
||||
"It looks like 7123456789:AAHxxx...",
|
||||
],
|
||||
},
|
||||
],
|
||||
security: {
|
||||
prompt:
|
||||
"By default, anyone who finds your bot can message it and run tasks on your machine. Restrict access to your Telegram user ID?",
|
||||
fields: [
|
||||
{
|
||||
key: "userId",
|
||||
label: "Your Telegram user ID",
|
||||
placeholder: "123456789",
|
||||
help: [
|
||||
"Message @userinfobot on Telegram",
|
||||
"It will reply with your numeric user ID",
|
||||
],
|
||||
requiredMessage: "User ID is required to restrict access",
|
||||
validate: validateTelegramUserId,
|
||||
},
|
||||
],
|
||||
buildArgs: ({ userId }) => ["--allowed-user-id", userId ?? ""],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
type: "hybrid",
|
||||
hint: "Public URL for webhook mode; leave blank for socket mode.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--bot-token",
|
||||
label: "Bot token",
|
||||
placeholder: "xoxb-...",
|
||||
required: true,
|
||||
help: [
|
||||
"Go to api.slack.com/apps and create a new app",
|
||||
"Add Bot Token Scopes: chat:write, app_mentions:read, channels:history, channels:read, im:history, im:read, im:write, users:read",
|
||||
"Install to workspace and copy the Bot Token",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "leave blank for socket mode",
|
||||
help: [
|
||||
"Enter a publicly accessible URL for webhook mode",
|
||||
"Leave blank to use Slack socket mode instead",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--signing-secret",
|
||||
label: "Signing secret",
|
||||
required: true,
|
||||
help: ["Found in your app's Basic Information page"],
|
||||
includeWhen: { flag: "--base-url", notEquals: "" },
|
||||
},
|
||||
{
|
||||
flag: "--app-token",
|
||||
label: "App-level token",
|
||||
placeholder: "xapp-...",
|
||||
required: true,
|
||||
help: [
|
||||
"Enable Socket Mode in the Slack app",
|
||||
"Generate an app-level token with the connections:write scope",
|
||||
],
|
||||
includeWhen: { flag: "--base-url", equals: "" },
|
||||
},
|
||||
],
|
||||
security: {
|
||||
prompt: "Restrict which Slack users can interact with the bot?",
|
||||
fields: [
|
||||
{
|
||||
key: "teamId",
|
||||
label: "Allowed Slack workspace ID",
|
||||
placeholder: "T01ABC123",
|
||||
help: [
|
||||
"Open your Slack workspace URL in a browser",
|
||||
"The workspace ID is the segment after /client/, for example T01ABC123",
|
||||
],
|
||||
requiredMessage: "Workspace ID is required to restrict access",
|
||||
validate: validateSlackTeamId,
|
||||
},
|
||||
{
|
||||
key: "userId",
|
||||
label: "Allowed Slack member ID",
|
||||
placeholder: "U01ABC123",
|
||||
help: [
|
||||
"Click a user's name in Slack, then View full profile",
|
||||
"Click ... and Copy member ID",
|
||||
],
|
||||
requiredMessage: "Member ID is required to restrict access",
|
||||
validate: validateSlackUserId,
|
||||
},
|
||||
],
|
||||
buildArgs: ({ teamId, userId }) => [
|
||||
"--hook-command",
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:${teamId}:user:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
type: "webhook",
|
||||
hint: "Requires a Discord app and public URL.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--application-id",
|
||||
label: "Application ID",
|
||||
required: true,
|
||||
help: [
|
||||
"Go to discord.com/developers/applications",
|
||||
"Create a new app, copy the Application ID",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--bot-token",
|
||||
label: "Bot token",
|
||||
required: true,
|
||||
help: ["Go to Bot section, create a bot, copy the token"],
|
||||
},
|
||||
{
|
||||
flag: "--public-key",
|
||||
label: "Public key",
|
||||
required: true,
|
||||
help: ["Found in General Information of your app"],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
help: [
|
||||
"Base URL for the connector",
|
||||
"For Discord, set the Interactions Endpoint URL to <base-url>/api/webhooks/discord",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "whatsapp",
|
||||
name: "WhatsApp",
|
||||
type: "webhook",
|
||||
hint: "Requires Meta developer account and public URL.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--phone-number-id",
|
||||
label: "Phone number ID",
|
||||
required: true,
|
||||
help: ["From your WhatsApp Business account in Meta Developer portal"],
|
||||
},
|
||||
{
|
||||
flag: "--access-token",
|
||||
label: "Access token",
|
||||
required: true,
|
||||
help: ["Generate a permanent token in Meta Developer portal"],
|
||||
},
|
||||
{
|
||||
flag: "--app-secret",
|
||||
label: "App secret",
|
||||
required: true,
|
||||
help: ["Found in App Settings > Basic"],
|
||||
},
|
||||
{
|
||||
flag: "--verify-token",
|
||||
label: "Webhook verify token",
|
||||
placeholder: "my-verify-token",
|
||||
required: true,
|
||||
help: ["Any string you choose, used to verify webhook setup"],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gchat",
|
||||
name: "Google Chat",
|
||||
type: "webhook",
|
||||
hint: "Requires Google Cloud project and public URL.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--credentials-json",
|
||||
label: "Service account credentials JSON",
|
||||
required: true,
|
||||
help: [
|
||||
"Create a service account in Google Cloud Console",
|
||||
"Download the credentials JSON file",
|
||||
"Paste the JSON content here",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "linear",
|
||||
name: "Linear",
|
||||
type: "webhook",
|
||||
hint: "React to Linear issues and comments.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--api-key",
|
||||
label: "API key",
|
||||
required: true,
|
||||
help: ["Go to Linear Settings > API > Personal API keys"],
|
||||
},
|
||||
{
|
||||
flag: "--webhook-secret",
|
||||
label: "Webhook signing secret",
|
||||
required: true,
|
||||
help: [
|
||||
"Go to Settings > API > Webhooks, create one",
|
||||
"Copy the signing secret",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
export const PLATFORMS = CONNECTOR_PLATFORMS;
|
||||
export const shouldIncludeField = shouldIncludeConnectorField;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export interface ClineHubServerOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
@@ -47,6 +49,9 @@ function normalizePublicUrl(
|
||||
`PUBLIC_URL must use http: or https:, got ${parsed.protocol}`,
|
||||
);
|
||||
}
|
||||
if (shouldAddDashboardPortToPublicUrl(parsed, port)) {
|
||||
parsed.port = String(port);
|
||||
}
|
||||
parsed.hash = "";
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
@@ -85,12 +90,26 @@ export function resolveClineHubServerOptions(
|
||||
};
|
||||
}
|
||||
|
||||
function isDefaultProtocolPort(url: URL, port: number): boolean {
|
||||
return (
|
||||
(url.protocol === "http:" && port === 80) ||
|
||||
(url.protocol === "https:" && port === 443)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldAddDashboardPortToPublicUrl(url: URL, port: number): boolean {
|
||||
if (url.port || isDefaultProtocolPort(url, port)) return false;
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
||||
return hostname === "localhost" || isIP(hostname) !== 0;
|
||||
}
|
||||
|
||||
export function buildInviteUrl(
|
||||
publicUrl: string,
|
||||
roomSecret: string | undefined,
|
||||
): string {
|
||||
if (!roomSecret) return publicUrl;
|
||||
const url = new URL(publicUrl);
|
||||
url.searchParams.set("roomSecret", roomSecret);
|
||||
if (roomSecret) {
|
||||
url.searchParams.set("roomSecret", roomSecret);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
handleToolApprovalResponse,
|
||||
rejectOrphanedApprovals,
|
||||
} from "./server/approvals";
|
||||
import { isAuthorizedBrowserToDesktopRequest } from "./server/browser-auth";
|
||||
import {
|
||||
browserConfig,
|
||||
host,
|
||||
@@ -14,7 +15,11 @@ import {
|
||||
webviewDistDir,
|
||||
} from "./server/deps";
|
||||
import { handleDesktopCommand } from "./server/desktop-commands";
|
||||
import { createJsonResponse, WebviewAssets } from "./server/http";
|
||||
import {
|
||||
createJsonResponse,
|
||||
isWebviewRoute,
|
||||
WebviewAssets,
|
||||
} from "./server/http";
|
||||
import {
|
||||
attachHub,
|
||||
detachHub,
|
||||
@@ -53,17 +58,33 @@ export interface ClineHubDashboardServer {
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PUBLIC_BROWSER_PATHS = new Set([
|
||||
"/version",
|
||||
"/health",
|
||||
"/config.json",
|
||||
"/api/marketplace/catalog",
|
||||
"/icon.png",
|
||||
"/icon.svg",
|
||||
"/icon.ico",
|
||||
"/32x32.png",
|
||||
"/cline-logo-filled.svg",
|
||||
"/favicon.svg",
|
||||
]);
|
||||
|
||||
function isPublicStaticAssetPath(pathname: string): boolean {
|
||||
return pathname.startsWith("/assets/") || PUBLIC_BROWSER_PATHS.has(pathname);
|
||||
}
|
||||
|
||||
function isPublicBrowserRoute(_req: Request, url: URL): boolean {
|
||||
return isWebviewRoute(url.pathname) || isPublicStaticAssetPath(url.pathname);
|
||||
}
|
||||
|
||||
export async function startClineHubDashboardServer(): Promise<ClineHubDashboardServer> {
|
||||
const ctx = new HubContext();
|
||||
const assets = new WebviewAssets(webviewDistDir);
|
||||
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
|
||||
let stopped = false;
|
||||
|
||||
function isAuthorizedBrowserRequest(url: URL): boolean {
|
||||
if (!roomSecret) return true;
|
||||
return url.searchParams.get("roomSecret") === roomSecret;
|
||||
}
|
||||
|
||||
await attachHub(ctx);
|
||||
const healthInterval = setInterval(() => {
|
||||
void (async () => {
|
||||
@@ -77,6 +98,21 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
hostname: host,
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
if (
|
||||
!isAuthorizedBrowserToDesktopRequest(
|
||||
req,
|
||||
url,
|
||||
{
|
||||
bindHost: host,
|
||||
port,
|
||||
publicUrl,
|
||||
roomSecret,
|
||||
},
|
||||
isPublicBrowserRoute,
|
||||
)
|
||||
) {
|
||||
return createJsonResponse({ error: "unauthorized_browser" }, 403);
|
||||
}
|
||||
if (url.pathname === "/version") {
|
||||
return createJsonResponse({ coreVersion: CORE_BUILD_VERSION });
|
||||
}
|
||||
@@ -85,9 +121,6 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
return createJsonResponse(hubStatusPayload(ctx));
|
||||
}
|
||||
if (url.pathname === "/browser") {
|
||||
if (!isAuthorizedBrowserRequest(url)) {
|
||||
return createJsonResponse({ error: "invalid_room_secret" }, 401);
|
||||
}
|
||||
const displayName = `Browser ${Math.random().toString(36).slice(2, 6)}`;
|
||||
const data = {
|
||||
socket: undefined as never,
|
||||
|
||||
@@ -0,0 +1,359 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
allowedBrowserHosts,
|
||||
allowedBrowserOrigins,
|
||||
isAuthorizedBrowserRequest,
|
||||
isAuthorizedBrowserToDesktopRequest,
|
||||
requiresBrowserRequestAuth,
|
||||
} from "./browser-auth";
|
||||
|
||||
const defaultOptions = {
|
||||
bindHost: "127.0.0.1",
|
||||
port: 8787,
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
};
|
||||
|
||||
const publicRoute = (_req: Request, url: URL) => url.pathname === "/public";
|
||||
|
||||
function browserRequest(
|
||||
origin?: string,
|
||||
init?: Omit<RequestInit, "headers"> & {
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
): Request {
|
||||
return new Request("http://127.0.0.1:8787/browser", {
|
||||
...init,
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
...(origin === undefined ? {} : { origin }),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("allowedBrowserOrigins", () => {
|
||||
it("allows the configured public URL origin and local aliases for local binds", () => {
|
||||
expect([...allowedBrowserOrigins(defaultOptions)].sort()).toEqual([
|
||||
"http://127.0.0.1:8787",
|
||||
"http://[::1]:8787",
|
||||
"http://localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the configured public URL scheme for local aliases", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
...defaultOptions,
|
||||
publicUrl: "https://127.0.0.1:8787",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual([
|
||||
"https://127.0.0.1:8787",
|
||||
"https://[::1]:8787",
|
||||
"https://localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits default protocol ports for local alias origins", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 80,
|
||||
publicUrl: "http://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["http://127.0.0.1", "http://[::1]", "http://localhost"]);
|
||||
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 443,
|
||||
publicUrl: "https://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["https://127.0.0.1", "https://[::1]", "https://localhost"]);
|
||||
});
|
||||
|
||||
it("allows the configured public URL origin and explicit bind origin for non-local binds", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "https://example.ngrok-free.app",
|
||||
roomSecret: "secret",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["https://0.0.0.0:8787", "https://example.ngrok-free.app"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowedBrowserHosts", () => {
|
||||
it("allows the configured public URL host and local aliases for local binds", () => {
|
||||
expect([...allowedBrowserHosts(defaultOptions)].sort()).toEqual([
|
||||
"127.0.0.1:8787",
|
||||
"[::1]:8787",
|
||||
"localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits default protocol ports for local alias hosts", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 80,
|
||||
publicUrl: "http://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
|
||||
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 443,
|
||||
publicUrl: "https://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
|
||||
});
|
||||
|
||||
it("allows the configured public URL host and explicit bind host for non-local binds", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "https://example.ngrok-free.app",
|
||||
roomSecret: "secret",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["0.0.0.0:8787", "example.ngrok-free.app"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiresBrowserRequestAuth", () => {
|
||||
it("does not require browser auth for public GET routes", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/public"),
|
||||
new URL("http://127.0.0.1:8787/public"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires browser auth for unknown paths even when they use GET", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-api"),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for privileged paths even when they use GET", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/browser"),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for every WebSocket upgrade path", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-socket", {
|
||||
headers: { upgrade: "websocket" },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-socket"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for every unsafe HTTP method", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-api", { method: "POST" }),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedBrowserRequest", () => {
|
||||
it.each([
|
||||
"http://127.0.0.1:8787",
|
||||
"http://localhost:8787",
|
||||
"http://[::1]:8787",
|
||||
])("accepts local dashboard origin %s without a room secret", (origin) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest(origin),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"null",
|
||||
"not a url",
|
||||
"http://evil.attacker.example.com",
|
||||
"http://127.0.0.1:9999",
|
||||
"https://127.0.0.1:8787",
|
||||
])("rejects untrusted origin %s", (origin) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest(origin),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"evil.attacker.example.com",
|
||||
"127.0.0.1:9999",
|
||||
"localhost:9999",
|
||||
])("rejects untrusted host %s", (host) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787", {
|
||||
headers: host === undefined ? { host: "" } : { host },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows explicit wildcard bind host and origin when a room secret is configured", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://0.0.0.0:8787", {
|
||||
headers: { host: "0.0.0.0:8787" },
|
||||
}),
|
||||
new URL("http://0.0.0.0:8787/browser?roomSecret=invite-123"),
|
||||
{
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
roomSecret: "invite-123",
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires trusted origin, trusted host, and room secret when a room secret is configured", () => {
|
||||
const options = { ...defaultOptions, roomSecret: "invite-123" };
|
||||
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787"),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787"),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://evil.attacker.example.com"),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787", {
|
||||
headers: { host: "evil.attacker.example.com" },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedBrowserToDesktopRequest", () => {
|
||||
it("allows safe public GET routes without an origin", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/public"),
|
||||
new URL("http://127.0.0.1:8787/public"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects future WebSocket paths from untrusted origins by default", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-socket", {
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://evil.attacker.example.com",
|
||||
upgrade: "websocket",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-socket"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects future unsafe HTTP routes from untrusted origins by default", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://evil.attacker.example.com",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows future unsafe HTTP routes from trusted origins", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://127.0.0.1:8787",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { isNonLocalBindHost } from "../options";
|
||||
|
||||
export interface BrowserRequestAuthOptions {
|
||||
bindHost: string;
|
||||
port: number;
|
||||
publicUrl: string;
|
||||
roomSecret?: string;
|
||||
}
|
||||
|
||||
const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
export type PublicBrowserRoutePredicate = (req: Request, url: URL) => boolean;
|
||||
|
||||
function isWebSocketUpgrade(req: Request): boolean {
|
||||
return req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
||||
}
|
||||
|
||||
function parseOrigin(value: string | null): string | undefined {
|
||||
const origin = parseHeader(value);
|
||||
try {
|
||||
return new URL(origin ?? "").origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHeader(value: string | null): string | undefined {
|
||||
const host = value?.trim().toLowerCase();
|
||||
return host || undefined;
|
||||
}
|
||||
|
||||
function formatHostForOrigin(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function isDefaultProtocolPort(protocol: string, port: number): boolean {
|
||||
return (
|
||||
(protocol === "http:" && port === 80) ||
|
||||
(protocol === "https:" && port === 443)
|
||||
);
|
||||
}
|
||||
|
||||
function originForHost(protocol: string, host: string, port: number): string {
|
||||
return new URL(`${protocol}//${formatHostForOrigin(host)}:${port}`).origin;
|
||||
}
|
||||
|
||||
function hostHeaderForHost(
|
||||
protocol: string,
|
||||
host: string,
|
||||
port: number,
|
||||
): string {
|
||||
const formattedHost = formatHostForOrigin(host).toLowerCase();
|
||||
return isDefaultProtocolPort(protocol, port)
|
||||
? formattedHost
|
||||
: `${formattedHost}:${port}`;
|
||||
}
|
||||
|
||||
export function allowedBrowserOrigins({
|
||||
bindHost,
|
||||
port,
|
||||
publicUrl,
|
||||
}: BrowserRequestAuthOptions): Set<string> {
|
||||
const publicUrlParts = new URL(publicUrl);
|
||||
const origins = new Set<string>();
|
||||
origins.add(publicUrlParts.origin);
|
||||
|
||||
origins.add(originForHost(publicUrlParts.protocol, bindHost, port));
|
||||
|
||||
if (!isNonLocalBindHost(bindHost)) {
|
||||
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
|
||||
origins.add(originForHost(publicUrlParts.protocol, hostname, port));
|
||||
}
|
||||
}
|
||||
|
||||
return origins;
|
||||
}
|
||||
|
||||
export function allowedBrowserHosts({
|
||||
bindHost,
|
||||
port,
|
||||
publicUrl,
|
||||
}: BrowserRequestAuthOptions): Set<string> {
|
||||
const publicUrlParts = new URL(publicUrl);
|
||||
const hosts = new Set<string>();
|
||||
const publicHost = publicUrlParts.host.toLowerCase();
|
||||
hosts.add(publicHost);
|
||||
|
||||
hosts.add(hostHeaderForHost(publicUrlParts.protocol, bindHost, port));
|
||||
|
||||
if (!isNonLocalBindHost(bindHost)) {
|
||||
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
|
||||
hosts.add(hostHeaderForHost(publicUrlParts.protocol, hostname, port));
|
||||
}
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
export function requiresBrowserRequestAuth(
|
||||
req: Request,
|
||||
url: URL,
|
||||
isPublicBrowserRoute: PublicBrowserRoutePredicate,
|
||||
): boolean {
|
||||
if (isWebSocketUpgrade(req)) return true;
|
||||
if (!SAFE_HTTP_METHODS.has(req.method.toUpperCase())) return true;
|
||||
return !isPublicBrowserRoute(req, url);
|
||||
}
|
||||
|
||||
export function isAuthorizedBrowserRequest(
|
||||
req: Request,
|
||||
url: URL,
|
||||
options: BrowserRequestAuthOptions,
|
||||
): boolean {
|
||||
const host = parseHeader(req.headers.get("host"));
|
||||
if (!host || !allowedBrowserHosts(options).has(host)) return false;
|
||||
|
||||
const origin = parseOrigin(req.headers.get("origin"));
|
||||
if (!origin || !allowedBrowserOrigins(options).has(origin)) return false;
|
||||
|
||||
if (!options.roomSecret) return true;
|
||||
return url.searchParams.get("roomSecret") === options.roomSecret;
|
||||
}
|
||||
|
||||
export function isAuthorizedBrowserToDesktopRequest(
|
||||
req: Request,
|
||||
url: URL,
|
||||
options: BrowserRequestAuthOptions,
|
||||
isPublicBrowserRoute: PublicBrowserRoutePredicate,
|
||||
): boolean {
|
||||
return (
|
||||
!requiresBrowserRequestAuth(req, url, isPublicBrowserRoute) ||
|
||||
isAuthorizedBrowserRequest(req, url, options)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./connectors";
|
||||
|
||||
describe("connector launch command", () => {
|
||||
it("uses Bun conditions when launching the source CLI from Bun", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/Users/test/.bun/bin/bun",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "/Users/test/.bun/bin/bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"/repo/apps/cli/src/index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses compiled CLI subcommands without Bun flags", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/Applications/Cline/bin/cline",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "/Applications/Cline/bin/cline",
|
||||
childArgs: ["connect", "telegram", "--bot-token", "token"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Bun conditions when launching the source CLI from Node", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/usr/local/bin/node",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"/repo/apps/cli/src/index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("detects Windows Node when launching the source CLI", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "node.exe",
|
||||
cliPath: "C:\\repo\\apps\\cli\\src\\index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"C:\\repo\\apps\\cli\\src\\index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips terminal color codes from connector command failures", () => {
|
||||
expect(
|
||||
__test__.normalizeConnectorError(
|
||||
"\u001B[31merror:\u001B[0m error: unknown option '--conditions=development'",
|
||||
"connector start failed",
|
||||
),
|
||||
).toBe("unknown option '--conditions=development'");
|
||||
});
|
||||
|
||||
it("turns Telegram unauthorized responses into a token validation message", () => {
|
||||
expect(
|
||||
__test__.normalizeConnectorError(
|
||||
"\u001B[31merror:\u001B[0m Telegram getMe failed (401 Unauthorized): Unauthorized",
|
||||
"connector start failed",
|
||||
),
|
||||
).toBe(
|
||||
"Telegram rejected this bot token. Copy the token from @BotFather and try again.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { basename } from "node:path";
|
||||
import process from "node:process";
|
||||
import { withResolvedClineBuildEnv } from "@cline/shared";
|
||||
import { listConnectorCatalog } from "../../../cli/src/connectors/catalog";
|
||||
import { listActiveConnectors } from "../../../cli/src/connectors/status";
|
||||
import {
|
||||
@@ -13,6 +16,68 @@ import type {
|
||||
import { cliIndexPath, workspaceRoot } from "./deps";
|
||||
import { asRecord, asString } from "./utils";
|
||||
|
||||
type CliConnectCommand = {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
};
|
||||
|
||||
const ANSI_ESCAPE_PATTERN = new RegExp(
|
||||
[
|
||||
"[\\u001B\\u009B][[\\]()#;?]*",
|
||||
"(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)",
|
||||
"|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
|
||||
].join(""),
|
||||
"g",
|
||||
);
|
||||
|
||||
function stripAnsi(value: string): string {
|
||||
return value.replace(ANSI_ESCAPE_PATTERN, "");
|
||||
}
|
||||
|
||||
function normalizeConnectorError(rawMessage: string, fallback: string): string {
|
||||
const message =
|
||||
stripAnsi(rawMessage)
|
||||
.replace(/\r\n/g, "\n")
|
||||
.trim()
|
||||
.replace(/^(?:error:\s*)+/i, "")
|
||||
.trim() || fallback;
|
||||
|
||||
if (
|
||||
/^Telegram getMe failed \(401 Unauthorized\): Unauthorized$/i.test(message)
|
||||
) {
|
||||
return "Telegram rejected this bot token. Copy the token from @BotFather and try again.";
|
||||
}
|
||||
|
||||
return message.slice(0, 2_000);
|
||||
}
|
||||
|
||||
function buildCliConnectCommand(
|
||||
args: string[],
|
||||
options: {
|
||||
execPath?: string;
|
||||
cliPath?: string;
|
||||
exists?: (path: string) => boolean;
|
||||
} = {},
|
||||
): CliConnectCommand {
|
||||
const execPath = options.execPath ?? process.execPath;
|
||||
const cliPath = options.cliPath ?? cliIndexPath;
|
||||
const exists = options.exists ?? existsSync;
|
||||
const runtimeName = basename(execPath).toLowerCase();
|
||||
const isBunRuntime = runtimeName.includes("bun");
|
||||
const isNodeRuntime = runtimeName === "node" || runtimeName === "node.exe";
|
||||
const useBunSourceEntrypoint =
|
||||
(isBunRuntime || isNodeRuntime) && exists(cliPath);
|
||||
const launcher = isBunRuntime
|
||||
? execPath
|
||||
: useBunSourceEntrypoint
|
||||
? "bun"
|
||||
: execPath;
|
||||
const childArgs = useBunSourceEntrypoint
|
||||
? ["--conditions=development", cliPath, "connect", ...args]
|
||||
: ["connect", ...args];
|
||||
return { launcher, childArgs };
|
||||
}
|
||||
|
||||
export function connectorChannelsPayload(): WebviewConnectorChannelsResponse {
|
||||
const supported = new Set(
|
||||
listConnectorCatalog().map((connector) => connector.name),
|
||||
@@ -55,23 +120,14 @@ async function runCliConnectCommand(args: string[]): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}> {
|
||||
const launcher = (process.versions as Record<string, string | undefined>).bun
|
||||
? process.execPath
|
||||
: "bun";
|
||||
const child = spawn(
|
||||
launcher,
|
||||
["--conditions=development", cliIndexPath, "connect", ...args],
|
||||
{
|
||||
cwd: workspaceRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV ?? "development",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
const { launcher, childArgs } = buildCliConnectCommand(args);
|
||||
const child = spawn(launcher, childArgs, {
|
||||
cwd: workspaceRoot,
|
||||
env: withResolvedClineBuildEnv(process.env),
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout?.setEncoding("utf8");
|
||||
@@ -157,9 +213,10 @@ export async function startConnectorChannel(
|
||||
const result = await runCliConnectCommand(cliArgs);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
(result.stderr.trim() || result.stdout.trim() || "connector start failed")
|
||||
.trim()
|
||||
.slice(0, 2_000),
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector start failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(() =>
|
||||
@@ -168,6 +225,11 @@ export async function startConnectorChannel(
|
||||
return connectorChannelsPayload();
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
buildCliConnectCommand,
|
||||
normalizeConnectorError,
|
||||
};
|
||||
|
||||
export async function stopConnectorChannel(
|
||||
args?: Record<string, unknown>,
|
||||
): Promise<WebviewConnectorChannelsResponse> {
|
||||
@@ -182,9 +244,10 @@ export async function stopConnectorChannel(
|
||||
const result = await runCliConnectCommand([channel, "--stop"]);
|
||||
if (result.code !== 0) {
|
||||
throw new Error(
|
||||
(result.stderr.trim() || result.stdout.trim() || "connector stop failed")
|
||||
.trim()
|
||||
.slice(0, 2_000),
|
||||
normalizeConnectorError(
|
||||
result.stderr || result.stdout,
|
||||
"connector stop failed",
|
||||
),
|
||||
);
|
||||
}
|
||||
await waitForConnectorState(
|
||||
|
||||
@@ -51,18 +51,53 @@ describe("marketplace installer", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("maps remote MCP catalog args to the hub MCP upsert shape", () => {
|
||||
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([
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer <token>",
|
||||
]),
|
||||
).toEqual({
|
||||
name: "context7",
|
||||
transportType: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer <token>",
|
||||
},
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
@@ -283,8 +318,6 @@ describe("marketplace installer", () => {
|
||||
"remove",
|
||||
"cline-sdk",
|
||||
"-g",
|
||||
"-a",
|
||||
"cline",
|
||||
"-y",
|
||||
]);
|
||||
});
|
||||
@@ -348,7 +381,7 @@ describe("marketplace installer", () => {
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 1,
|
||||
stdout:
|
||||
"Authorization: Bearer stdout-token\napi key stdout-key\nOPENAI_API_KEY=compound-key",
|
||||
"Authorization: Bearer stdout-token\nAuthorization: Basic basic-token\napi key stdout-key\nOPENAI_API_KEY=compound-key",
|
||||
stderr:
|
||||
"TOKEN=stderr-token\npassword is stderr-password\nANTHROPIC_SECRET_KEY=anthropic-secret",
|
||||
}));
|
||||
@@ -370,13 +403,16 @@ describe("marketplace installer", () => {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
expect(message).toContain("Authorization: Bearer [redacted]");
|
||||
expect(message).toContain("Authorization: [redacted]");
|
||||
expect(message).not.toContain("Authorization: Bearer [redacted]]");
|
||||
expect(message).toContain("api key [redacted]");
|
||||
expect(message).toContain("OPENAI_API_KEY=[redacted]");
|
||||
expect(message).toContain("TOKEN=[redacted]");
|
||||
expect(message).toContain("password is [redacted]");
|
||||
expect(message).toContain("ANTHROPIC_SECRET_KEY=[redacted]");
|
||||
expect(message).not.toContain("stdout-token");
|
||||
expect(message).not.toContain("basic-token");
|
||||
expect(message).not.toContain("stdout-key");
|
||||
expect(message).not.toContain("compound-key");
|
||||
expect(message).not.toContain("stderr-token");
|
||||
@@ -465,19 +501,78 @@ describe("marketplace installer", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs official plugin uninstalls through the current Cline CLI", async () => {
|
||||
it("runs MCP installs through the current Cline CLI without prompts", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({
|
||||
name: "goal",
|
||||
installPath: "/tmp/plugin",
|
||||
removedPaths: ["/tmp/plugin"],
|
||||
entryPaths: [],
|
||||
name: "context7",
|
||||
status: "installed",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer token",
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "context7",
|
||||
type: "mcp",
|
||||
name: "Context7",
|
||||
install: {
|
||||
args: [
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Context7.",
|
||||
details: {
|
||||
name: "context7",
|
||||
status: "installed",
|
||||
},
|
||||
});
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"mcp",
|
||||
"install",
|
||||
"--yes",
|
||||
"--json",
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
]);
|
||||
});
|
||||
|
||||
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: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
uninstallMarketplaceEntry(
|
||||
{
|
||||
@@ -495,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 () => {
|
||||
@@ -544,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: "",
|
||||
}));
|
||||
|
||||
@@ -580,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 () => {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir, platform } from "node:os";
|
||||
import { homedir as osHomedir, platform } from "node:os";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
@@ -18,16 +18,15 @@ 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";
|
||||
import {
|
||||
deleteMcpServer,
|
||||
readMcpServersResponse,
|
||||
upsertMcpServer,
|
||||
} from "./mcp";
|
||||
import { deleteMcpServer, readMcpServersResponse } from "./mcp";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
|
||||
@@ -90,6 +89,12 @@ const MARKETPLACE_CATALOG_URL =
|
||||
"https://cline.github.io/marketplace/catalog.json";
|
||||
const SECRET_PATTERN =
|
||||
/(api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)/i;
|
||||
const SECRET_KEY_VALUE_PATTERN =
|
||||
/((?:^|[^\w])(?:[a-z0-9_]*?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|credential)[a-z0-9_]*)\s*[:=]\s*)(.+)$/gi;
|
||||
const SECRET_BEARER_VALUE_PATTERN =
|
||||
/((?:^|[^\w])authorization\s*[:=]\s*)bearer\s+([^\s,"'}\]]+)/gi;
|
||||
const SECRET_AUTHORIZATION_VALUE_PATTERN =
|
||||
/((?:^|[^\w])authorization\s*[:=])(?!\s*bearer\b)\s*(.+)$/gi;
|
||||
|
||||
export async function fetchMarketplaceCatalog(
|
||||
fetchImpl: CatalogFetch = fetch,
|
||||
@@ -270,11 +275,10 @@ function redactOutput(value: string): string {
|
||||
const lines = value.split(/\r?\n/).map((line) => {
|
||||
if (!SECRET_PATTERN.test(line)) return line;
|
||||
return line
|
||||
.replace(
|
||||
/((?:^|[^\w])(?:[a-z0-9_]*?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)[a-z0-9_]*)\s*[:=]\s*)(.+)$/gi,
|
||||
"$1[redacted]",
|
||||
)
|
||||
.replace(/\b(Bearer)\s+\S+/gi, "$1 [redacted]")
|
||||
.replace(SECRET_KEY_VALUE_PATTERN, "$1[redacted]")
|
||||
.replace(SECRET_BEARER_VALUE_PATTERN, "$1Bearer [redacted]")
|
||||
.replace(/\b(Bearer)\s+(?!\[redacted\])([^\s,"'}\]]+)/gi, "$1 [redacted]")
|
||||
.replace(SECRET_AUTHORIZATION_VALUE_PATTERN, "$1 [redacted]")
|
||||
.replace(
|
||||
/((?:^|[^\w])(?:api\s+key|access\s+token|refresh\s+token|auth(?:orization)?\s+token|secret|password|credential)\s+(?:is\s+)?)(\S+)/gi,
|
||||
"$1[redacted]",
|
||||
@@ -374,6 +378,7 @@ export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
|
||||
throw new Error("MCP marketplace install requires a server name");
|
||||
}
|
||||
let transportType = "stdio";
|
||||
const headers: Record<string, string> = {};
|
||||
const targetArgs: string[] = [];
|
||||
let parsingMarketplaceOptions = true;
|
||||
for (let index = 0; index < rest.length; index++) {
|
||||
@@ -389,11 +394,40 @@ export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const shouldParseHeader =
|
||||
parsingMarketplaceOptions ||
|
||||
normalizeTransport(transportType) !== "stdio";
|
||||
if (
|
||||
shouldParseHeader &&
|
||||
(arg === "--header" || arg?.startsWith("--header="))
|
||||
) {
|
||||
const rawHeader =
|
||||
arg === "--header" ? rest[++index] : arg.slice("--header=".length);
|
||||
if (!rawHeader) throw new Error("--header requires a value");
|
||||
const separatorIndex = rawHeader.indexOf(":");
|
||||
if (separatorIndex <= 0) {
|
||||
throw new Error(
|
||||
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
|
||||
);
|
||||
}
|
||||
const headerName = rawHeader.slice(0, separatorIndex).trim();
|
||||
const headerValue = rawHeader.slice(separatorIndex + 1).trim();
|
||||
if (!headerName || !headerValue) {
|
||||
throw new Error(
|
||||
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
|
||||
);
|
||||
}
|
||||
headers[headerName] = headerValue;
|
||||
continue;
|
||||
}
|
||||
parsingMarketplaceOptions = false;
|
||||
targetArgs.push(arg);
|
||||
}
|
||||
transportType = normalizeTransport(transportType);
|
||||
if (transportType === "stdio") {
|
||||
if (Object.keys(headers).length > 0) {
|
||||
throw new Error("Stdio MCP installs do not support request headers.");
|
||||
}
|
||||
const [command, ...commandArgs] = targetArgs;
|
||||
if (!command?.trim()) {
|
||||
throw new Error("Stdio MCP install requires a command");
|
||||
@@ -415,6 +449,7 @@ export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
|
||||
name,
|
||||
transportType,
|
||||
url,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
@@ -570,6 +605,12 @@ function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
return Boolean(installPath && existsSync(installPath));
|
||||
}
|
||||
|
||||
function resolveHomeDir(): string {
|
||||
return (
|
||||
process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || osHomedir()
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMatchValue(value: string | undefined): string {
|
||||
return (value ?? "")
|
||||
.toLowerCase()
|
||||
@@ -606,12 +647,12 @@ function getSkillInstallCandidates(entry: MarketplaceInstallInput): string[] {
|
||||
function getGlobalSkillPaths(skillName: string): string[] {
|
||||
return [
|
||||
join(resolveClineDir(), "skills", skillName, "SKILL.md"),
|
||||
join(homedir(), ".agents", "skills", skillName, "SKILL.md"),
|
||||
join(resolveHomeDir(), ".agents", "skills", skillName, "SKILL.md"),
|
||||
].filter((path, index, paths) => paths.indexOf(path) === index);
|
||||
}
|
||||
|
||||
function ensureGlobalSkillsDirWritable(): void {
|
||||
const skillsDir = join(homedir(), ".agents", "skills");
|
||||
const skillsDir = join(resolveHomeDir(), ".agents", "skills");
|
||||
try {
|
||||
mkdirSync(skillsDir, { recursive: true });
|
||||
const probePath = join(
|
||||
@@ -754,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,
|
||||
@@ -848,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 } = {},
|
||||
@@ -896,14 +852,38 @@ export async function installMarketplaceEntry(
|
||||
const entry = readInstallInput(args);
|
||||
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
|
||||
if (entry.type === "mcp") {
|
||||
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
|
||||
const response = upsertMcpServer(input);
|
||||
// Validate marketplace args before handing them to the CLI-backed installer.
|
||||
buildMarketplaceMcpInput(entry.install.args ?? []);
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"mcp",
|
||||
"install",
|
||||
"--yes",
|
||||
"--json",
|
||||
...(entry.install.args ?? []),
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`MCP install 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: "installed",
|
||||
message: `Installed ${entry.name ?? input.name ?? entry.id}.`,
|
||||
details: { mcp: response },
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
};
|
||||
}
|
||||
if (entry.type === "skill") {
|
||||
@@ -921,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(
|
||||
|
||||
@@ -41,6 +41,23 @@ expectEqual(
|
||||
"invite URL",
|
||||
);
|
||||
|
||||
const tailscale = resolveClineHubServerOptions({
|
||||
HOST: "0.0.0.0",
|
||||
CLINE_HUB_DASHBOARD_PORT: "8787",
|
||||
PUBLIC_URL: "http://100.82.5.118",
|
||||
ROOM_SECRET: "invite-123",
|
||||
});
|
||||
expectEqual(
|
||||
tailscale.publicUrl,
|
||||
"http://100.82.5.118:8787",
|
||||
"direct IP public URL gets dashboard port",
|
||||
);
|
||||
expectEqual(
|
||||
buildInviteUrl(tailscale.publicUrl, tailscale.roomSecret),
|
||||
"http://100.82.5.118:8787/?roomSecret=invite-123",
|
||||
"invite URL for direct IP public URL",
|
||||
);
|
||||
|
||||
expectThrows(
|
||||
() => resolveClineHubServerOptions({ HOST: "0.0.0.0" }),
|
||||
"non-local bind without ROOM_SECRET",
|
||||
|
||||
@@ -13,10 +13,9 @@
|
||||
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@rive-app/react-webgl2": "^4.27.2",
|
||||
"@shikijs/langs": "^4.2.0",
|
||||
"@shikijs/themes": "^4.2.0",
|
||||
"@streamdown/cjk": "^1.0.3",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@streamdown/math": "^1.0.2",
|
||||
"@streamdown/mermaid": "^1.0.2",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"ai": "^6.0.116",
|
||||
@@ -27,6 +26,7 @@
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"media-chrome": "^4.18.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"motion": "^12.38.0",
|
||||
"nanoid": "^5.1.7",
|
||||
"next-themes": "^0.4.6",
|
||||
|
||||
@@ -22,7 +22,14 @@ import {
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -53,19 +60,24 @@ import type {
|
||||
WebviewOutboundMessage,
|
||||
WebviewSessionSummary,
|
||||
} from "../../webview-protocol";
|
||||
import Chat from "./Chat";
|
||||
import { PageFrame, PageHeader } from "./components/views/page-layout";
|
||||
import {
|
||||
type CustomizationSection,
|
||||
CustomizationSectionView,
|
||||
} from "./components/views/settings/extensions-view";
|
||||
import {
|
||||
type SettingsSection,
|
||||
SettingsView,
|
||||
} from "./components/views/settings/settings-view";
|
||||
import type { CustomizationSection } from "./components/views/settings/extensions-view";
|
||||
import type { SettingsSection } from "./components/views/settings/settings-view";
|
||||
import { syncHubTheme } from "./lib/theme";
|
||||
import { postToHost } from "./vscode";
|
||||
|
||||
const Chat = lazy(() => import("./Chat"));
|
||||
const SettingsView = lazy(() =>
|
||||
import("./components/views/settings/settings-view").then((module) => ({
|
||||
default: module.SettingsView,
|
||||
})),
|
||||
);
|
||||
const CustomizationSectionView = lazy(() =>
|
||||
import("./components/views/settings/extensions-view").then((module) => ({
|
||||
default: module.CustomizationSectionView,
|
||||
})),
|
||||
);
|
||||
|
||||
type View =
|
||||
| "home"
|
||||
| "sessions"
|
||||
@@ -238,6 +250,14 @@ function currentPathWithSearch(): string {
|
||||
return `${window.location.pathname}${window.location.search}`;
|
||||
}
|
||||
|
||||
function ViewLoading() {
|
||||
return (
|
||||
<PageFrame>
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRelativeTime(timestamp?: number): string {
|
||||
if (!timestamp) return "unknown";
|
||||
const elapsed = Math.max(0, Date.now() - timestamp);
|
||||
@@ -1333,7 +1353,7 @@ function App() {
|
||||
|
||||
return (
|
||||
<Shell onNavigate={navigate} version={hubState.coreVersion} view={view}>
|
||||
{content}
|
||||
<Suspense fallback={<ViewLoading />}>{content}</Suspense>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import type {
|
||||
BundledLanguage,
|
||||
BundledTheme,
|
||||
HighlighterGeneric,
|
||||
HighlighterCore,
|
||||
LanguageRegistration,
|
||||
ThemedToken,
|
||||
} from "shiki";
|
||||
import { createHighlighter } from "shiki";
|
||||
ThemeRegistration,
|
||||
} from "shiki/core";
|
||||
import { createHighlighterCore } from "shiki/core";
|
||||
import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Select,
|
||||
@@ -36,6 +37,78 @@ const isUnderline = (fontStyle: number | undefined) =>
|
||||
// oxlint-disable-next-line eslint(no-bitwise)
|
||||
fontStyle && fontStyle & 4;
|
||||
|
||||
const SUPPORTED_LANGUAGES = [
|
||||
"bash",
|
||||
"css",
|
||||
"diff",
|
||||
"html",
|
||||
"javascript",
|
||||
"json",
|
||||
"jsonc",
|
||||
"jsx",
|
||||
"markdown",
|
||||
"python",
|
||||
"shellscript",
|
||||
"tsx",
|
||||
"typescript",
|
||||
"yaml",
|
||||
] as const;
|
||||
|
||||
export type SupportedCodeLanguage = (typeof SUPPORTED_LANGUAGES)[number];
|
||||
|
||||
const SUPPORTED_LANGUAGE_SET = new Set<string>(SUPPORTED_LANGUAGES);
|
||||
|
||||
const LANGUAGE_LOADERS: Record<
|
||||
SupportedCodeLanguage,
|
||||
() => Promise<LanguageRegistration[]>
|
||||
> = {
|
||||
bash: () => import("@shikijs/langs/bash").then((module) => module.default),
|
||||
css: () => import("@shikijs/langs/css").then((module) => module.default),
|
||||
diff: () => import("@shikijs/langs/diff").then((module) => module.default),
|
||||
html: () => import("@shikijs/langs/html").then((module) => module.default),
|
||||
javascript: () =>
|
||||
import("@shikijs/langs/javascript").then((module) => module.default),
|
||||
json: () => import("@shikijs/langs/json").then((module) => module.default),
|
||||
jsonc: () => import("@shikijs/langs/jsonc").then((module) => module.default),
|
||||
jsx: () => import("@shikijs/langs/jsx").then((module) => module.default),
|
||||
markdown: () =>
|
||||
import("@shikijs/langs/markdown").then((module) => module.default),
|
||||
python: () =>
|
||||
import("@shikijs/langs/python").then((module) => module.default),
|
||||
shellscript: () =>
|
||||
import("@shikijs/langs/shellscript").then((module) => module.default),
|
||||
tsx: () => import("@shikijs/langs/tsx").then((module) => module.default),
|
||||
typescript: () =>
|
||||
import("@shikijs/langs/typescript").then((module) => module.default),
|
||||
yaml: () => import("@shikijs/langs/yaml").then((module) => module.default),
|
||||
};
|
||||
|
||||
const LANGUAGE_ALIASES: Record<string, SupportedCodeLanguage> = {
|
||||
console: "shellscript",
|
||||
cjs: "javascript",
|
||||
htm: "html",
|
||||
js: "javascript",
|
||||
json5: "jsonc",
|
||||
md: "markdown",
|
||||
mjs: "javascript",
|
||||
py: "python",
|
||||
sh: "shellscript",
|
||||
shell: "shellscript",
|
||||
ts: "typescript",
|
||||
yml: "yaml",
|
||||
};
|
||||
|
||||
const normalizeLanguage = (
|
||||
language: string,
|
||||
): SupportedCodeLanguage | "text" => {
|
||||
const normalized = language.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return "text";
|
||||
}
|
||||
const aliased = LANGUAGE_ALIASES[normalized] ?? normalized;
|
||||
return SUPPORTED_LANGUAGE_SET.has(aliased) ? aliased : "text";
|
||||
};
|
||||
|
||||
// Transform tokens to include pre-computed keys to avoid noArrayIndexKey lint
|
||||
interface KeyedToken {
|
||||
token: ThemedToken;
|
||||
@@ -108,7 +181,7 @@ const LineSpan = ({
|
||||
// Types
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
language: string;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
@@ -128,10 +201,9 @@ const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
});
|
||||
|
||||
// Highlighter cache (singleton per language)
|
||||
const highlighterCache = new Map<
|
||||
string,
|
||||
Promise<HighlighterGeneric<BundledLanguage, BundledTheme>>
|
||||
>();
|
||||
let highlighterPromise: Promise<HighlighterCore> | undefined;
|
||||
let themesPromise: Promise<void> | undefined;
|
||||
const languagePromises = new Map<SupportedCodeLanguage, Promise<void>>();
|
||||
|
||||
// Token cache
|
||||
const tokensCache = new Map<string, TokenizedCode>();
|
||||
@@ -139,27 +211,44 @@ const tokensCache = new Map<string, TokenizedCode>();
|
||||
// Subscribers for async token updates
|
||||
const subscribers = new Map<string, Set<(result: TokenizedCode) => void>>();
|
||||
|
||||
const getTokensCacheKey = (code: string, language: BundledLanguage) => {
|
||||
const getTokensCacheKey = (code: string, language: string) => {
|
||||
const start = code.slice(0, 100);
|
||||
const end = code.length > 100 ? code.slice(-100) : "";
|
||||
return `${language}:${code.length}:${start}:${end}`;
|
||||
};
|
||||
|
||||
const getHighlighter = (
|
||||
language: BundledLanguage,
|
||||
): Promise<HighlighterGeneric<BundledLanguage, BundledTheme>> => {
|
||||
const cached = highlighterCache.get(language);
|
||||
const getHighlighter = (): Promise<HighlighterCore> => {
|
||||
if (!highlighterPromise) {
|
||||
highlighterPromise = createHighlighterCore({
|
||||
engine: createJavaScriptRegexEngine({ forgiving: true }),
|
||||
});
|
||||
}
|
||||
return highlighterPromise;
|
||||
};
|
||||
|
||||
const ensureThemes = (highlighter: HighlighterCore): Promise<void> => {
|
||||
if (!themesPromise) {
|
||||
themesPromise = Promise.all([
|
||||
import("@shikijs/themes/github-light").then((module) => module.default),
|
||||
import("@shikijs/themes/github-dark").then((module) => module.default),
|
||||
]).then((themes: ThemeRegistration[]) => highlighter.loadTheme(...themes));
|
||||
}
|
||||
return themesPromise;
|
||||
};
|
||||
|
||||
const ensureLanguage = (
|
||||
highlighter: HighlighterCore,
|
||||
language: SupportedCodeLanguage,
|
||||
): Promise<void> => {
|
||||
const cached = languagePromises.get(language);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const highlighterPromise = createHighlighter({
|
||||
langs: [language],
|
||||
themes: ["github-light", "github-dark"],
|
||||
});
|
||||
|
||||
highlighterCache.set(language, highlighterPromise);
|
||||
return highlighterPromise;
|
||||
const languagePromise = LANGUAGE_LOADERS[language]().then((registrations) =>
|
||||
highlighter.loadLanguage(...registrations),
|
||||
);
|
||||
languagePromises.set(language, languagePromise);
|
||||
return languagePromise;
|
||||
};
|
||||
|
||||
// Create raw tokens for immediate display while highlighting loads
|
||||
@@ -181,11 +270,16 @@ const createRawTokens = (code: string): TokenizedCode => ({
|
||||
// Synchronous highlight with callback for async results
|
||||
export const highlightCode = (
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
language: string,
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-callbacks)
|
||||
callback?: (result: TokenizedCode) => void,
|
||||
): TokenizedCode | null => {
|
||||
const tokensCacheKey = getTokensCacheKey(code, language);
|
||||
const langToUse = normalizeLanguage(language);
|
||||
if (langToUse === "text") {
|
||||
return createRawTokens(code);
|
||||
}
|
||||
|
||||
const tokensCacheKey = getTokensCacheKey(code, langToUse);
|
||||
|
||||
// Return cached result if available
|
||||
const cached = tokensCache.get(tokensCacheKey);
|
||||
@@ -202,11 +296,11 @@ export const highlightCode = (
|
||||
}
|
||||
|
||||
// Start highlighting in background - fire-and-forget async pattern
|
||||
getHighlighter(language)
|
||||
getHighlighter()
|
||||
// oxlint-disable-next-line eslint-plugin-promise(prefer-await-to-then)
|
||||
.then((highlighter) => {
|
||||
const availableLangs = highlighter.getLoadedLanguages();
|
||||
const langToUse = availableLangs.includes(language) ? language : "text";
|
||||
.then(async (highlighter) => {
|
||||
await ensureThemes(highlighter);
|
||||
await ensureLanguage(highlighter, langToUse);
|
||||
|
||||
const result = highlighter.codeToTokens(code, {
|
||||
lang: langToUse,
|
||||
@@ -376,7 +470,7 @@ export const CodeBlockContent = ({
|
||||
showLineNumbers = false,
|
||||
}: {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
language: string;
|
||||
showLineNumbers?: boolean;
|
||||
}) => {
|
||||
// Memoized raw tokens for immediate display
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import type { UIMessage } from "ai";
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
@@ -16,7 +12,6 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
|
||||
import {
|
||||
@@ -26,6 +21,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { HubStreamdown } from "./streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
@@ -316,24 +312,18 @@ export const MessageBranchPage = ({
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
export type MessageResponseProps = ComponentProps<typeof HubStreamdown>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
<HubStreamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className,
|
||||
)}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.children === nextProps.children &&
|
||||
nextProps.isAnimating === prevProps.isAnimating,
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import { code } from "@streamdown/code";
|
||||
import { math } from "@streamdown/math";
|
||||
import { mermaid } from "@streamdown/mermaid";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
@@ -17,7 +13,6 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -26,6 +21,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Shimmer } from "./shimmer";
|
||||
import { HubStreamdown } from "./streamdown";
|
||||
|
||||
interface ReasoningContextValue {
|
||||
isStreaming: boolean;
|
||||
@@ -204,8 +200,6 @@ export type ReasoningContentProps = ComponentProps<
|
||||
children: string;
|
||||
};
|
||||
|
||||
const streamdownPlugins = { cjk, code, math, mermaid };
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
@@ -216,7 +210,7 @@ export const ReasoningContent = memo(
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
|
||||
<HubStreamdown>{children}</HubStreamdown>
|
||||
</CollapsibleContent>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import type { MermaidConfig } from "mermaid";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement, memo } from "react";
|
||||
import {
|
||||
type Components,
|
||||
type DiagramPlugin,
|
||||
Streamdown,
|
||||
type StreamdownProps,
|
||||
} from "streamdown";
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockActions,
|
||||
CodeBlockCopyButton,
|
||||
CodeBlockFilename,
|
||||
CodeBlockHeader,
|
||||
CodeBlockTitle,
|
||||
} from "@/components/ai-elements/code-block";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MarkdownCodeProps = ComponentProps<"code"> & {
|
||||
"data-block"?: boolean | string;
|
||||
node?: {
|
||||
properties?: {
|
||||
metastring?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const LANGUAGE_CLASS_PATTERN = /(?:^|\s)language-([^\s]+)/;
|
||||
const START_LINE_PATTERN = /startLine=(\d+)/;
|
||||
const NO_LINE_NUMBERS_PATTERN = /\bnoLineNumbers\b/;
|
||||
|
||||
function codeText(children: ReactNode): string {
|
||||
if (typeof children === "string" || typeof children === "number") {
|
||||
return String(children);
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(codeText).join("");
|
||||
}
|
||||
if (isValidElement<{ children?: ReactNode }>(children)) {
|
||||
return codeText(children.props.children);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const MarkdownCode = ({
|
||||
children,
|
||||
className,
|
||||
node,
|
||||
"data-block": dataBlock,
|
||||
...props
|
||||
}: MarkdownCodeProps) => {
|
||||
const language = className?.match(LANGUAGE_CLASS_PATTERN)?.[1] ?? "text";
|
||||
|
||||
if (!dataBlock) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"rounded bg-muted px-1.5 py-0.5 font-mono text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = node?.properties?.metastring;
|
||||
const startLineMatch = meta?.match(START_LINE_PATTERN);
|
||||
const startLine = startLineMatch ? Number.parseInt(startLineMatch[1], 10) : 1;
|
||||
const showLineNumbers = meta ? !NO_LINE_NUMBERS_PATTERN.test(meta) : true;
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
code={codeText(children)}
|
||||
data-start-line={startLine > 1 ? startLine : undefined}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>
|
||||
<CodeBlockFilename>{language}</CodeBlockFilename>
|
||||
</CodeBlockTitle>
|
||||
<CodeBlockActions>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlockActions>
|
||||
</CodeBlockHeader>
|
||||
</CodeBlock>
|
||||
);
|
||||
};
|
||||
|
||||
const markdownComponents = {
|
||||
code: MarkdownCode,
|
||||
} satisfies Components;
|
||||
|
||||
const DEFAULT_MERMAID_CONFIG = {
|
||||
fontFamily: "monospace",
|
||||
securityLevel: "strict",
|
||||
startOnLoad: false,
|
||||
suppressErrorRendering: true,
|
||||
theme: "default",
|
||||
} satisfies MermaidConfig;
|
||||
|
||||
interface LazyMermaidInstance {
|
||||
initialize: (config: MermaidConfig) => void;
|
||||
render: (
|
||||
id: string,
|
||||
source: string,
|
||||
) => Promise<{
|
||||
svg: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function createLazyMermaidPlugin(): DiagramPlugin {
|
||||
let config: MermaidConfig = DEFAULT_MERMAID_CONFIG;
|
||||
let initialized = false;
|
||||
|
||||
const instance: LazyMermaidInstance = {
|
||||
initialize(nextConfig: MermaidConfig) {
|
||||
config = { ...DEFAULT_MERMAID_CONFIG, ...config, ...nextConfig };
|
||||
initialized = false;
|
||||
},
|
||||
async render(id: string, source: string) {
|
||||
const mermaidModule = await import("mermaid");
|
||||
const mermaid = mermaidModule.default;
|
||||
if (!initialized) {
|
||||
mermaid.initialize(config);
|
||||
initialized = true;
|
||||
}
|
||||
return mermaid.render(id, source);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
getMermaid(nextConfig?: MermaidConfig) {
|
||||
if (nextConfig) {
|
||||
instance.initialize(nextConfig);
|
||||
}
|
||||
return instance;
|
||||
},
|
||||
language: "mermaid",
|
||||
name: "mermaid",
|
||||
type: "diagram",
|
||||
};
|
||||
}
|
||||
|
||||
const streamdownPlugins = { cjk, mermaid: createLazyMermaidPlugin() };
|
||||
|
||||
export type HubStreamdownProps = StreamdownProps;
|
||||
|
||||
export const HubStreamdown = memo(
|
||||
({ className, components, ...props }: HubStreamdownProps) => {
|
||||
const mergedComponents = components
|
||||
? { ...markdownComponents, ...components }
|
||||
: markdownComponents;
|
||||
|
||||
return (
|
||||
<Streamdown
|
||||
className={className}
|
||||
components={mergedComponents}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
HubStreamdown.displayName = "HubStreamdown";
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
Puzzle,
|
||||
Search,
|
||||
Server,
|
||||
Star,
|
||||
Trash2,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
type MarketplacePrimitiveType,
|
||||
type MarketplaceTag,
|
||||
} from "@/lib/marketplace";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CommandBadge, PageFrame, PageHeader } from "./page-layout";
|
||||
|
||||
type EntryActionState =
|
||||
@@ -114,6 +116,13 @@ function entryKey(entry: Pick<MarketplaceEntry, "id" | "type">): string {
|
||||
return `${entry.type}:${entry.id}`;
|
||||
}
|
||||
|
||||
function compareFeaturedEntries(
|
||||
left: MarketplaceEntry,
|
||||
right: MarketplaceEntry,
|
||||
): number {
|
||||
return Number(Boolean(right.featured)) - Number(Boolean(left.featured));
|
||||
}
|
||||
|
||||
function normalizeMatchValue(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
@@ -257,6 +266,8 @@ function MarketplaceEntryCard({
|
||||
onToggleExpanded,
|
||||
onUninstall,
|
||||
matchedLocalItems = [],
|
||||
showFeatured = true,
|
||||
showTags = true,
|
||||
sourceLabel,
|
||||
tagLabels,
|
||||
}: {
|
||||
@@ -269,6 +280,8 @@ function MarketplaceEntryCard({
|
||||
onToggleExpanded: (entry: MarketplaceEntry) => void;
|
||||
onUninstall: (entry: MarketplaceEntry) => void;
|
||||
matchedLocalItems?: MarketplaceLocalInstalledItem[];
|
||||
showFeatured?: boolean;
|
||||
showTags?: boolean;
|
||||
sourceLabel?: string;
|
||||
tagLabels: Map<string, string>;
|
||||
}) {
|
||||
@@ -299,15 +312,63 @@ function MarketplaceEntryCard({
|
||||
: installed
|
||||
? "Uninstall"
|
||||
: "Install";
|
||||
const statusMessage = inlineMessage ? (
|
||||
<output
|
||||
className={cn(
|
||||
"text-xs",
|
||||
actionState?.status === "failed"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{inlineMessage}
|
||||
</output>
|
||||
) : setupNeeded ? (
|
||||
<span className="text-xs text-amber-700 dark:text-amber-300">
|
||||
Requires setup after install
|
||||
</span>
|
||||
) : null;
|
||||
const actionButton = (
|
||||
<Button
|
||||
disabled={!installedStatusReady || busy}
|
||||
onClick={handleActionClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={installed ? "destructive" : "default"}
|
||||
>
|
||||
{busy || !installedStatusReady ? <Spinner /> : null}
|
||||
{installed && !busy ? <Trash2 className="size-4" /> : null}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
);
|
||||
const content = (
|
||||
<>
|
||||
{!installed ? (
|
||||
<div
|
||||
className="absolute top-4 right-4"
|
||||
data-marketplace-entry-interactive
|
||||
>
|
||||
{actionButton}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2",
|
||||
!installed && "pr-24",
|
||||
)}
|
||||
>
|
||||
<EntryIcon className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
|
||||
<h2 className="min-w-0 truncate text-sm font-semibold text-foreground">
|
||||
{entry.name}
|
||||
</h2>
|
||||
{showFeatured && entry.featured ? (
|
||||
<Badge className="border border-violet-500/20 bg-violet-500/10 text-violet-700 dark:text-violet-300">
|
||||
<Star className="fill-current" />
|
||||
Featured
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||
{sourceLabel ? (
|
||||
@@ -349,23 +410,34 @@ function MarketplaceEntryCard({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{entry.description}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-stretch sm:justify-between">
|
||||
<div className="grid min-w-0 flex-1 gap-2">
|
||||
{showTags && entry.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{entry.tags.slice(0, 5).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="max-w-full text-muted-foreground"
|
||||
>
|
||||
<span className="truncate">{tagLabels.get(tag) ?? tag}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{entry.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{entry.tags.slice(0, 5).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="outline"
|
||||
className="max-w-full text-muted-foreground"
|
||||
>
|
||||
<span className="truncate">{tagLabels.get(tag) ?? tag}</span>
|
||||
</Badge>
|
||||
))}
|
||||
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
|
||||
{entry.description}
|
||||
</p>
|
||||
{statusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{installed ? (
|
||||
<div className="flex shrink-0 flex-col items-start gap-1 sm:items-end sm:justify-end">
|
||||
{actionButton}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{matchedLocalItems.some((item) => item.renderMatchedDetails) ? (
|
||||
<div className="grid gap-2" data-marketplace-entry-details>
|
||||
@@ -377,36 +449,6 @@ function MarketplaceEntryCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="min-h-5 text-xs text-muted-foreground">
|
||||
{inlineMessage ? (
|
||||
<output
|
||||
className={
|
||||
actionState?.status === "failed"
|
||||
? "text-destructive"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{inlineMessage}
|
||||
</output>
|
||||
) : setupNeeded ? (
|
||||
<span className="text-amber-700 dark:text-amber-300">
|
||||
Requires setup after install
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
disabled={!installedStatusReady || busy}
|
||||
onClick={handleActionClick}
|
||||
type="button"
|
||||
variant={installed ? "destructive" : "default"}
|
||||
>
|
||||
{busy || !installedStatusReady ? <Spinner /> : null}
|
||||
{installed && !busy ? <Trash2 className="size-4" /> : null}
|
||||
{actionLabel}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{expanded && hasExpandableDetails ? (
|
||||
<EntryDetails actionState={actionState} entry={entry} />
|
||||
) : null}
|
||||
@@ -415,7 +457,7 @@ function MarketplaceEntryCard({
|
||||
|
||||
if (!hasExpandableDetails) {
|
||||
return (
|
||||
<div className="grid gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
|
||||
<div className="relative grid gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
@@ -426,7 +468,7 @@ function MarketplaceEntryCard({
|
||||
<div
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
|
||||
className="grid cursor-pointer gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
className="relative grid cursor-pointer gap-2 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
onClick={(event) => {
|
||||
if (
|
||||
event.target instanceof HTMLElement &&
|
||||
@@ -487,6 +529,7 @@ function MarketplaceSection({
|
||||
emptyMessage,
|
||||
entries,
|
||||
expandedEntryKey,
|
||||
headerContent,
|
||||
installedEntryKeys,
|
||||
installedStatusReady,
|
||||
localOnlyInstalledItems = [],
|
||||
@@ -494,6 +537,8 @@ function MarketplaceSection({
|
||||
onInstall,
|
||||
onToggleExpanded,
|
||||
onUninstall,
|
||||
showFeaturedBadges = true,
|
||||
showEntryTags = true,
|
||||
sourceLabel,
|
||||
tagLabels,
|
||||
title,
|
||||
@@ -502,6 +547,7 @@ function MarketplaceSection({
|
||||
emptyMessage: string;
|
||||
entries: MarketplaceEntry[];
|
||||
expandedEntryKey: string | null;
|
||||
headerContent?: ReactNode;
|
||||
installedEntryKeys: Set<string>;
|
||||
installedStatusReady: boolean;
|
||||
localOnlyInstalledItems?: MarketplaceLocalInstalledItem[];
|
||||
@@ -509,6 +555,8 @@ function MarketplaceSection({
|
||||
onInstall: (entry: MarketplaceEntry) => void;
|
||||
onToggleExpanded: (entry: MarketplaceEntry) => void;
|
||||
onUninstall: (entry: MarketplaceEntry) => void;
|
||||
showFeaturedBadges?: boolean;
|
||||
showEntryTags?: boolean;
|
||||
sourceLabel?: string;
|
||||
tagLabels: Map<string, string>;
|
||||
title: string;
|
||||
@@ -520,6 +568,7 @@ function MarketplaceSection({
|
||||
<h2 className="text-base font-semibold text-foreground">{title}</h2>
|
||||
<span className="text-sm text-muted-foreground">{totalCount}</span>
|
||||
</div>
|
||||
{headerContent}
|
||||
{totalCount > 0 ? (
|
||||
<div className="grid gap-3">
|
||||
{localOnlyInstalledItems.map((item) => item.render())}
|
||||
@@ -537,6 +586,8 @@ function MarketplaceSection({
|
||||
onToggleExpanded={onToggleExpanded}
|
||||
onUninstall={onUninstall}
|
||||
matchedLocalItems={matchedLocalItemsByEntryKey?.get(key) ?? []}
|
||||
showFeatured={showFeaturedBadges}
|
||||
showTags={showEntryTags}
|
||||
sourceLabel={sourceLabel}
|
||||
tagLabels={tagLabels}
|
||||
/>
|
||||
@@ -626,19 +677,48 @@ export function MarketplaceView({
|
||||
);
|
||||
|
||||
const primitiveEntries = useMemo(
|
||||
() => catalog?.entries.filter((entry) => entry.type === primitive) ?? [],
|
||||
() =>
|
||||
(catalog?.entries.filter((entry) => entry.type === primitive) ?? []).sort(
|
||||
compareFeaturedEntries,
|
||||
),
|
||||
[catalog?.entries, primitive],
|
||||
);
|
||||
|
||||
const queryFilteredEntries = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return primitiveEntries.filter((entry) => {
|
||||
return (
|
||||
normalizedQuery.length === 0 ||
|
||||
entrySearchText(entry, tagLabels).includes(normalizedQuery)
|
||||
);
|
||||
});
|
||||
}, [primitiveEntries, query, tagLabels]);
|
||||
|
||||
const installedEntries = useMemo(
|
||||
() =>
|
||||
queryFilteredEntries.filter((entry) =>
|
||||
installedEntryKeys.has(entryKey(entry)),
|
||||
),
|
||||
[queryFilteredEntries, installedEntryKeys],
|
||||
);
|
||||
|
||||
const marketplaceEntriesBeforeTag = useMemo(
|
||||
() =>
|
||||
queryFilteredEntries.filter(
|
||||
(entry) => !installedEntryKeys.has(entryKey(entry)),
|
||||
),
|
||||
[queryFilteredEntries, installedEntryKeys],
|
||||
);
|
||||
|
||||
const tagCounts = useMemo(() => {
|
||||
const counts = new Map<string, number>();
|
||||
for (const entry of primitiveEntries) {
|
||||
for (const entry of marketplaceEntriesBeforeTag) {
|
||||
for (const tag of entry.tags) {
|
||||
counts.set(tag, (counts.get(tag) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [primitiveEntries]);
|
||||
}, [marketplaceEntriesBeforeTag]);
|
||||
|
||||
const primitiveTags = useMemo(
|
||||
() =>
|
||||
@@ -646,26 +726,20 @@ export function MarketplaceView({
|
||||
[catalog?.tags, tagCounts],
|
||||
);
|
||||
|
||||
const filteredEntries = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return primitiveEntries.filter((entry) => {
|
||||
const matchesTag = !selectedTag || entry.tags.includes(selectedTag);
|
||||
const matchesQuery =
|
||||
normalizedQuery.length === 0 ||
|
||||
entrySearchText(entry, tagLabels).includes(normalizedQuery);
|
||||
return matchesTag && matchesQuery;
|
||||
});
|
||||
}, [primitiveEntries, query, selectedTag, tagLabels]);
|
||||
const catalogEntries = useMemo(
|
||||
() =>
|
||||
marketplaceEntriesBeforeTag.filter(
|
||||
(entry) => !selectedTag || entry.tags.includes(selectedTag),
|
||||
),
|
||||
[marketplaceEntriesBeforeTag, selectedTag],
|
||||
);
|
||||
|
||||
const matchedLocalItemsByEntryKey = useMemo(() => {
|
||||
const matched = new Map<string, MarketplaceLocalInstalledItem[]>();
|
||||
for (const item of installedItems ?? []) {
|
||||
for (const entry of filteredEntries) {
|
||||
for (const entry of installedEntries) {
|
||||
const key = entryKey(entry);
|
||||
if (
|
||||
!installedEntryKeys.has(key) ||
|
||||
!entryMatchesLocalItem(entry, item)
|
||||
) {
|
||||
if (!entryMatchesLocalItem(entry, item)) {
|
||||
continue;
|
||||
}
|
||||
const items = matched.get(key) ?? [];
|
||||
@@ -674,49 +748,78 @@ export function MarketplaceView({
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}, [filteredEntries, installedEntryKeys, installedItems]);
|
||||
}, [installedEntries, installedItems]);
|
||||
|
||||
const matchedLocalItemKeys = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
[...matchedLocalItemsByEntryKey.values()].flatMap((items) =>
|
||||
items.map((item) => item.key),
|
||||
),
|
||||
),
|
||||
[matchedLocalItemsByEntryKey],
|
||||
);
|
||||
const matchedLocalItemKeys = useMemo(() => {
|
||||
const matched = new Set<string>();
|
||||
const installedMarketplaceEntries = primitiveEntries.filter((entry) =>
|
||||
installedEntryKeys.has(entryKey(entry)),
|
||||
);
|
||||
for (const item of installedItems ?? []) {
|
||||
if (
|
||||
installedMarketplaceEntries.some((entry) =>
|
||||
entryMatchesLocalItem(entry, item),
|
||||
)
|
||||
) {
|
||||
matched.add(item.key);
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}, [installedEntryKeys, installedItems, primitiveEntries]);
|
||||
|
||||
const localOnlyInstalledItems = useMemo(
|
||||
() =>
|
||||
(installedItems ?? []).filter(
|
||||
(item) => !matchedLocalItemKeys.has(item.key),
|
||||
),
|
||||
[installedItems, matchedLocalItemKeys],
|
||||
);
|
||||
const localOnlyInstalledItems = useMemo(() => {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
return (installedItems ?? []).filter((item) => {
|
||||
if (matchedLocalItemKeys.has(item.key)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalizedQuery.length === 0 ||
|
||||
item.matchValues
|
||||
.map(normalizeMatchValue)
|
||||
.some((value) => value.includes(normalizedQuery))
|
||||
);
|
||||
});
|
||||
}, [installedItems, matchedLocalItemKeys, query]);
|
||||
|
||||
const installedEntries = useMemo(
|
||||
() =>
|
||||
filteredEntries.filter((entry) =>
|
||||
installedEntryKeys.has(entryKey(entry)),
|
||||
),
|
||||
[filteredEntries, installedEntryKeys],
|
||||
);
|
||||
|
||||
const catalogEntries = useMemo(
|
||||
() =>
|
||||
filteredEntries.filter(
|
||||
(entry) => !installedEntryKeys.has(entryKey(entry)),
|
||||
),
|
||||
[filteredEntries, installedEntryKeys],
|
||||
);
|
||||
|
||||
const activeFilters = query.trim().length > 0 || selectedTag !== null;
|
||||
const installedStatusReady = installedStatusState === "ready";
|
||||
|
||||
const clearFilters = () => {
|
||||
setQuery("");
|
||||
setSelectedTag(null);
|
||||
};
|
||||
const marketplaceTagFilters =
|
||||
primitiveTags.length > 0 ? (
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{primitiveTags.map((tag) => (
|
||||
<TagButton
|
||||
active={selectedTag === tag.id}
|
||||
count={tagCounts.get(tag.id) ?? 0}
|
||||
key={tag.id}
|
||||
onClick={() =>
|
||||
setSelectedTag((current) =>
|
||||
current === tag.id ? null : tag.id,
|
||||
)
|
||||
}
|
||||
tag={tag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex min-h-8 shrink-0 items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{catalogEntries.length}
|
||||
</span>
|
||||
<span>{catalogEntries.length === 1 ? "result" : "results"}</span>
|
||||
{selectedTag ? (
|
||||
<Button
|
||||
onClick={() => setSelectedTag(null)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
const setEntryState = (entry: MarketplaceEntry, state: EntryActionState) => {
|
||||
const key = entryKey(entry);
|
||||
@@ -863,43 +966,7 @@ export function MarketplaceView({
|
||||
value={query}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex min-h-8 items-center gap-2 text-sm text-muted-foreground">
|
||||
<span className="font-medium text-foreground">
|
||||
{filteredEntries.length}
|
||||
</span>
|
||||
<span>
|
||||
{filteredEntries.length === 1 ? "result" : "results"}
|
||||
</span>
|
||||
{activeFilters ? (
|
||||
<Button
|
||||
onClick={clearFilters}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{primitiveTags.length > 0 ? (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{primitiveTags.map((tag) => (
|
||||
<TagButton
|
||||
active={selectedTag === tag.id}
|
||||
count={tagCounts.get(tag.id) ?? 0}
|
||||
key={tag.id}
|
||||
onClick={() =>
|
||||
setSelectedTag((current) =>
|
||||
current === tag.id ? null : tag.id,
|
||||
)
|
||||
}
|
||||
tag={tag}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<MarketplaceSection
|
||||
@@ -914,6 +981,8 @@ export function MarketplaceView({
|
||||
onInstall={installEntry}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onUninstall={uninstallEntry}
|
||||
showFeaturedBadges={false}
|
||||
showEntryTags={false}
|
||||
sourceLabel="Marketplace"
|
||||
tagLabels={tagLabels}
|
||||
title="Installed"
|
||||
@@ -924,6 +993,7 @@ export function MarketplaceView({
|
||||
emptyMessage={pageDetails.emptyCatalog}
|
||||
entries={catalogEntries}
|
||||
expandedEntryKey={expandedEntryKey}
|
||||
headerContent={marketplaceTagFilters}
|
||||
installedEntryKeys={installedEntryKeys}
|
||||
installedStatusReady={installedStatusReady}
|
||||
onInstall={installEntry}
|
||||
|
||||
@@ -39,10 +39,10 @@ export type CustomizationSection =
|
||||
const sectionDescriptions: Record<CustomizationSection, string> = {
|
||||
Rules: "Review project and global rule files that shape Cline behavior.",
|
||||
Hooks: "Inspect hook configuration and recent execution status.",
|
||||
MCP: "Manage installed MCP servers and add new servers from the catalog.",
|
||||
Skills: "Manage installed skills and add new skills from the catalog.",
|
||||
MCP: "Manage installed MCP servers and add new servers from the marketplace.",
|
||||
Skills: "Manage installed skills and add new skills from the marketplace.",
|
||||
Agents: "Review configured agents discovered from local settings.",
|
||||
Plugins: "Manage installed plugins and add new plugins from the catalog.",
|
||||
Plugins: "Manage installed plugins and add new plugins from the marketplace.",
|
||||
Tools: "Inspect built-in tools and tools contributed by plugins.",
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export type MarketplaceEntry = {
|
||||
id: string;
|
||||
type: MarketplacePrimitiveType;
|
||||
name: string;
|
||||
featured?: boolean;
|
||||
tagline: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
@@ -100,7 +101,7 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch marketplace catalog: ${response.status}`);
|
||||
throw new Error(`Failed to fetch marketplace: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const baseUrl = typeof data?.baseUrl === "string" ? data.baseUrl : undefined;
|
||||
@@ -152,6 +153,10 @@ export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
|
||||
id: candidate.id,
|
||||
type: candidate.type,
|
||||
name: candidate.name,
|
||||
featured:
|
||||
typeof candidate.featured === "boolean"
|
||||
? candidate.featured
|
||||
: undefined,
|
||||
tagline: candidate.tagline,
|
||||
description: candidate.description,
|
||||
tags: toStringArray(candidate.tags),
|
||||
|
||||
@@ -3,6 +3,27 @@ import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
const mermaidChunkGroups = [
|
||||
{
|
||||
name: "mermaid-parser",
|
||||
maxSize: 450_000,
|
||||
test: /node_modules[\\/](?:\.bun[\\/])?@mermaid-js[+]parser/,
|
||||
},
|
||||
{
|
||||
name: "mermaid-langium",
|
||||
test: /node_modules[\\/](?:\.bun[\\/])?langium/,
|
||||
},
|
||||
{
|
||||
name: "mermaid-layout",
|
||||
maxSize: 450_000,
|
||||
test: /node_modules[\\/](?:\.bun[\\/])?(?:cytoscape|cytoscape-cose-bilkent|dagre|elkjs)/,
|
||||
},
|
||||
{
|
||||
name: "mermaid-markup",
|
||||
test: /node_modules[\\/](?:\.bun[\\/])?(?:katex|dompurify)/,
|
||||
},
|
||||
];
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
@@ -25,5 +46,13 @@ export default defineConfig({
|
||||
outDir: "../../dist/webview",
|
||||
emptyOutDir: true,
|
||||
cssMinify: "esbuild",
|
||||
chunkSizeWarningLimit: 600,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: mermaidChunkGroups,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -105,15 +105,3 @@ webview-ui/.vite-port
|
||||
*storybook.log
|
||||
storybook-static
|
||||
**/StorybookDecorator.tsx
|
||||
|
||||
# Worktree symlinks to directories. In a git worktree these paths are symlinks
|
||||
# pointing at the shared checkout. vsce treats a symlinked directory as a single
|
||||
# file entry, and the trailing-slash ignores above only exclude directory
|
||||
# *contents* — not the symlink entry itself — so secretlint then tries to read
|
||||
# the symlink as a file and crashes with EISDIR. Exclude the entries themselves.
|
||||
out
|
||||
test-results
|
||||
.vscode-test
|
||||
.cline
|
||||
dist-standalone
|
||||
webview-ui/node_modules
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
<div align="center"><sub>
|
||||
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
|
||||
</sub></div>
|
||||
|
||||
# Cline
|
||||
<div align="center">
|
||||
<table>
|
||||
|
||||
@@ -1,392 +0,0 @@
|
||||
# VSCode Extension → Cline SDK Migration Audit
|
||||
|
||||
> Status: **living document**. Updated 2026-06-21.
|
||||
>
|
||||
> **The extension host has now been demolished to a bare-bones inert shell** so it
|
||||
> can be rebuilt from near-zero on the Cline SDK, the way `apps/cli` is. See
|
||||
> [Bare-bones demolition](#bare-bones-demolition-2026-06-21) for exactly what was
|
||||
> removed and what survived. The earlier sections below describe the architecture
|
||||
> and the original incremental plan; they remain useful as the blueprint for the
|
||||
> rebuild.
|
||||
|
||||
## TL;DR
|
||||
|
||||
The VSCode extension still carries a large amount of **provider-, model-, and
|
||||
session-specific code that the Cline SDK is designed to own**. The CLI
|
||||
(`apps/cli`) already proves the target architecture: it is a thin shell that
|
||||
delegates *everything* — provider selection, auth, model catalog, session
|
||||
lifecycle, the agent loop, persistence, streaming — to the SDK
|
||||
(`@cline/core`, `@cline/llms`, `@cline/agents`). The extension should converge on
|
||||
that same pattern.
|
||||
|
||||
This document inventories what should move to the SDK, what was removed in the
|
||||
first pass, and a prioritized roadmap for the rest.
|
||||
|
||||
---
|
||||
|
||||
## Rebuilt on the SDK (2026-06-21)
|
||||
|
||||
After the demolition (below), the host was **rebuilt on the Cline SDK**, mirroring
|
||||
how `apps/cli` consumes it. The extension now **builds on the SDK** — verified:
|
||||
`bun run protos && tsc --noEmit` (host) ✅, webview `tsc --noEmit` ✅,
|
||||
`bun esbuild.mjs` → `dist/extension.js` ✅, `bun run build` (vite webview) ✅.
|
||||
|
||||
### New SDK integration layer (`src/core/sdk/`)
|
||||
|
||||
| Module | Role |
|
||||
|---|---|
|
||||
| `session-manager.ts` | Wraps `ClineCore.create()` + `start`/`send`/`subscribe`/`abort` (mirrors the CLI's core lifecycle) |
|
||||
| `session-config.ts` | Maps the legacy `ApiConfiguration` → SDK `CoreSessionConfig` (provider/model/key/reasoning) |
|
||||
| `message-translator.ts` | Translates SDK `CoreSessionEvent`/`AgentEvent` → webview `ClineMessage[]` (text/reasoning/tool/usage/done/error), with SDK→classic tool-name mapping |
|
||||
| `message-id-minter.ts` | `ts`/`seq`/`epoch` authority for the webview's convergent-replica merge protocol |
|
||||
| `webview-bridge.ts` | Holds the active `subscribeToPartialMessage` + `subscribeToState` response streams and pushes to them |
|
||||
| `state-store.ts` | `ApiConfiguration`/mode/history persisted via `context.globalState`/secrets; builds a valid `ExtensionState` |
|
||||
|
||||
The `Controller` (`src/core/controller/index.ts`) was rewritten to own these and
|
||||
implement `initTask`/`askResponse`/`cancelTask`/`clearTask`/`getStateToPostToWebview`,
|
||||
delegating all inference to the SDK. The critical-path gRPC handlers were re-wired
|
||||
to it: `state/{subscribeToState,getLatestState}`, `task/{newTask,askResponse,cancelTask,clearTask}`,
|
||||
`ui/{subscribeToPartialMessage,initializeWebview}`, `models/{listProviders,resolveProviderModels,updateApiConfiguration*}`.
|
||||
|
||||
### Verified vs not
|
||||
|
||||
- **Verified (builds):** host tsc, webview tsc, esbuild, vite, and `vsce package`
|
||||
all green.
|
||||
- **Verified (runtime, real VS Code):** using the Playwright/Electron e2e harness
|
||||
(`closed-loop-extension` skill), two e2e tests pass in a real VS Code instance
|
||||
(`src/test/e2e/sdk-smoke.test.ts`):
|
||||
1. *renders the Cline webview* — extension activates, the webview mounts, the
|
||||
gRPC state subscription reaches the host, and the host returns a valid
|
||||
`ExtensionState` (the chat UI renders).
|
||||
2. *submits a chat message and starts a task through the SDK* — sending a
|
||||
message clears the input (proves `newTask` dispatched), and the task starts
|
||||
with a resolved provider/model (no empty-model error), exercising
|
||||
webview → gRPC → `Controller.initTask` → `ClineCore` → `CoreSessionEvent`
|
||||
→ translator → render.
|
||||
- **NOT yet verified:** a full assistant *response* rendering end-to-end. The
|
||||
default `cline` provider requires account auth (signin) and the cline endpoint
|
||||
routed to the mock; the MVP left account/auth and cline-endpoint routing inert,
|
||||
so the LLM call doesn't complete in the harness. Wiring those is the next step.
|
||||
- **MVP scope:** secondary surfaces (MCP, checkpoints, browser, task-history
|
||||
persistence, plan/act switching, spawn-agent aggregation, account/auth) are
|
||||
minimal/inert but compile. They are the follow-on work to reach feature parity.
|
||||
|
||||
### Bugs found & fixed via the closed loop
|
||||
|
||||
- **Double `acquireVsCodeApi()`** crashed the webview's VS Code API/gRPC client
|
||||
(zero requests reached the host → blank UI). Made acquisition idempotent in
|
||||
`webview-ui/src/config/platform.config.ts` (cache on `window`, tolerate a throw).
|
||||
- **Empty `modelId`** — `session-config` didn't default a model when the user
|
||||
hadn't selected one, so `ClineCore` rejected the session (Zod
|
||||
`model: expected string to have >=1 characters`). Now falls back to the SDK
|
||||
catalog default via `getProviderCollectionSync(...).provider.defaultModelId`
|
||||
(imported from `@cline/llms`, not `@cline/core`).
|
||||
- **Packaging in a git worktree** — `vsce` can't follow the worktree's symlinked
|
||||
`dist` / `webview-ui/build` directories, and `.vscodeignore`'s trailing-slash
|
||||
entries didn't exclude symlinked-dir entries (`out`, `test-results`, …), so
|
||||
`secretlint` crashed with `EISDIR`. Dereferenced the two required dirs and added
|
||||
bare-name `.vscodeignore` entries for the worktree symlinks.
|
||||
- A leftover empty interface (`ProviderCatalogController`) failed lint (blocked
|
||||
packaging) and then a naive fix broke types — aliased it to `Controller`.
|
||||
|
||||
---
|
||||
|
||||
## Bare-bones demolition (2026-06-21)
|
||||
|
||||
We decided to stop doing this incrementally and instead **gut the extension host
|
||||
down to a minimal inert shell**, then rebuild it on the SDK like `apps/cli`. The
|
||||
demolition was executed by a multi-agent workflow and verified to compile.
|
||||
|
||||
### Result
|
||||
|
||||
- **571 files changed · 752 insertions · 93,256 deletions** (363 files deleted,
|
||||
208 modified).
|
||||
- **`tsc --noEmit` is green for both the host and the webview.**
|
||||
- Host check: `cd apps/vscode && bun run protos && bunx tsc --noEmit`
|
||||
(the `protos` step regenerates `src/generated/**`, which is a build artifact
|
||||
and not committed — you must run it before typechecking).
|
||||
- Webview check: `cd apps/vscode/webview-ui && bunx tsc --noEmit`.
|
||||
- The webview UI is **fully intact** — it renders and you can click around, but
|
||||
every backend action is inert.
|
||||
|
||||
### The demolition seam
|
||||
|
||||
The webview talks to the host **only** through generated proto service clients
|
||||
over a postMessage bridge, and never imports handler implementations. So the
|
||||
strategy was:
|
||||
|
||||
1. **Decouple the handler layer.** Every gRPC handler under
|
||||
`src/core/controller/<group>/` was gutted to an inert stub that returns an
|
||||
empty/default proto response and imports nothing downward. This severed the
|
||||
handlers from all implementation.
|
||||
2. **Delete the implementation** now that nothing references it.
|
||||
3. **Rewrite the shell** (`extension.ts`, `common.ts`, `Controller`, `hosts/`)
|
||||
to the minimum needed to render the webview and route gRPC.
|
||||
4. **Reconverge** with an iterative `tsc`-fix loop until host + webview were clean.
|
||||
|
||||
### What was deleted (entire areas)
|
||||
|
||||
- `src/sdk/**` (the entire SDK bridge — ~14.6k lines)
|
||||
- `src/services/**` (mcp, auth, telemetry, feature-flags, … — ~12.6k lines)
|
||||
- `src/integrations/**` (~4.5k lines)
|
||||
- `src/core/task/**`, `src/core/context/**`, `src/core/hooks/**`,
|
||||
`src/core/storage/**`, `src/core/prompts/**`, `src/core/mentions/**`,
|
||||
`src/core/ignore/**`, `src/core/locks/**`
|
||||
- Most of `src/hosts/vscode/**` (terminal, diff view, comment review,
|
||||
commit-message generation, file migrations, …)
|
||||
- **All host-side test files** (`src/**/*.test.ts`, `src/__tests__`). Webview
|
||||
tests were kept.
|
||||
|
||||
### What survived (the shell)
|
||||
|
||||
- `src/extension.ts` — **760 → 106 lines**: HostProvider setup, storage context,
|
||||
register the sidebar webview provider, route gRPC. Throwing stubs for removed
|
||||
host creators (diff/terminal/review).
|
||||
- `src/core/controller/` — `grpc-handler.ts`, `grpc-request-registry.ts`,
|
||||
`grpc-recorder/**`, `index.ts` (a **minimal inert `Controller`** with
|
||||
`any`-typed/no-op members), and all the **gutted handler files** (kept because
|
||||
the generated `protobus-services.ts` imports them by path; bodies return empty
|
||||
responses).
|
||||
- `src/core/webview/**` — the `WebviewProvider` (slimmed to construct the minimal
|
||||
Controller and load the webview HTML).
|
||||
- `src/hosts/` — `host-provider.ts`, `VscodeWebviewProvider.ts`, and the
|
||||
hostbridge client/handler needed for the webview + gRPC path.
|
||||
- `src/shared/**` (incl. `src/shared/proto/**`), `src/generated/**`,
|
||||
`src/utils/**`, `src/registry.ts`, `src/config.ts` — kept (types + generated
|
||||
glue the webview and shell compile against).
|
||||
- `webview-ui/**` — kept entirely (the inert UI).
|
||||
|
||||
### Caveats / not-yet-verified
|
||||
|
||||
- **Only `tsc` was verified.** The actual bundle builds
|
||||
(`bun esbuild.mjs` for the host, `bun run build:webview` for vite) and a real
|
||||
Extension-Development-Host launch have **not** been run yet. Compiling ≠ runs.
|
||||
- `package.json` still declares many `commands`/menus whose handlers were removed.
|
||||
They'll be dead/no-op in the UI until cleaned up.
|
||||
- The minimal `Controller` uses `any` in several places by design — this is
|
||||
throwaway scaffolding to be replaced by the SDK-backed implementation.
|
||||
|
||||
### Rebuilding on the SDK (next)
|
||||
|
||||
The shell is the foundation. Rebuild the host the way `apps/cli` does it
|
||||
(`apps/cli/src/session/session.ts`, `apps/cli/src/runtime/run-agent.ts`): create a
|
||||
`ClineCore`, `core.start()` sessions, `core.subscribe()` to events, and translate
|
||||
those SDK events into the webview's gRPC/`ClineMessage` shape inside the surviving
|
||||
handler/`Controller` layer. The (now empty) gRPC handlers are the exact insertion
|
||||
points: re-implement each by delegating to `@cline/core` / `@cline/llms` instead of
|
||||
the deleted in-extension code. The [target architecture](#the-target-architecture-what-the-cli-demonstrates)
|
||||
table below is the contract for what the SDK should own.
|
||||
|
||||
---
|
||||
|
||||
## The target architecture (what the CLI demonstrates)
|
||||
|
||||
The CLI hands these responsibilities entirely to the SDK and keeps zero
|
||||
provider-specific code:
|
||||
|
||||
| Responsibility | SDK owner |
|
||||
|---|---|
|
||||
| Provider selection / settings persistence | `ProviderSettingsManager` (`@cline/core`) |
|
||||
| Provider auth / OAuth flows | `@cline/core/auth/*`, `getProviderAuthHandler()` |
|
||||
| API handler creation (per-protocol HTTP) | `createHandler()` (`@cline/llms`) |
|
||||
| Model catalog (ids, pricing, context windows) | `getProviderCollection()`, `resolveProviderConfig()` (`@cline/llms`) |
|
||||
| Session start / resume / events | `ClineCore.create()/start()/send()/subscribe()` (`@cline/core`) |
|
||||
| Agent loop (model → tools → repeat) | `Agent` / `AgentRuntime` (`@cline/agents`) |
|
||||
| Conversation history & persistence | `CoreSessionService`, `persistence-service.ts` (`@cline/core`) |
|
||||
| Context window mgmt / truncation / tokens | `MessageBuilder` (`@cline/core`) |
|
||||
| Checkpoints / restore | `SessionVersioningService` (`@cline/core`) |
|
||||
| MCP servers | `createMcpTools()`, `InMemoryMcpManager` (`@cline/core`) |
|
||||
|
||||
The CLI's consumer pattern (see `apps/cli/src/session/session.ts`,
|
||||
`apps/cli/src/runtime/run-agent.ts`, `apps/cli/src/main.ts`):
|
||||
|
||||
```
|
||||
ClineCore.create({ clientName, backendMode, ... })
|
||||
→ core.start({ config, prompt, localRuntime })
|
||||
→ core.subscribe(sessionId, event => render(event))
|
||||
```
|
||||
|
||||
**Already done well in the extension:** the legacy per-provider *API handlers*
|
||||
are gone. `src/core/api/index.ts` is types-only and all inference routes through
|
||||
`@cline/llms` via `apps/vscode/src/sdk/sdk-api-handler.ts`. The remaining work is
|
||||
mostly in the **webview settings UI**, the **model-refresh/catalog layer**, and
|
||||
**proto/config conversion**.
|
||||
|
||||
---
|
||||
|
||||
## The core anti-pattern: per-provider files
|
||||
|
||||
The headline divergence from the CLI is that the extension has **one file (often
|
||||
several) per provider**:
|
||||
|
||||
- **`webview-ui/src/components/settings/providers/*Provider.tsx`** — ~35 bespoke
|
||||
settings components (`AnthropicProvider.tsx`, `BedrockProvider.tsx`,
|
||||
`GroqProvider.tsx`, …), ~6,000 lines.
|
||||
- **`webview-ui/src/components/settings/*ModelPicker.tsx`** — ~10 bespoke model
|
||||
pickers (`OpenRouterModelPicker.tsx`, `GroqModelPicker.tsx`, …), ~3,200 lines.
|
||||
- **`src/core/controller/models/refresh*.ts` / `get*Models.ts`** — ~23
|
||||
per-provider model-refresh RPC handlers, ~2,350 lines.
|
||||
|
||||
The replacement already exists and works: **`GenericProviderSettings.tsx`** is a
|
||||
fully-featured, catalog-driven settings shell (API key, optional base URL,
|
||||
model picker, reasoning selector, model info) that takes only a `providerId` plus
|
||||
presentation metadata. It pulls its model list from the unified
|
||||
`resolveProviderModels` RPC (`useProviderModels(providerId)`), i.e. the SDK
|
||||
catalog — not from per-provider state.
|
||||
|
||||
`providers/providerSettingsRegistry.ts` is the cutover mechanism:
|
||||
|
||||
- `CUSTOM_PROVIDER_SETTINGS_IDS` — providers that *still* have bespoke UIs.
|
||||
- `getGenericProviderSettings(id, listing)` — builds generic settings from an SDK
|
||||
`ProviderListing` (requires `protocol ∈ {anthropic, gemini, openai-chat,
|
||||
openai-responses}`).
|
||||
- `getFallbackGenericProviderSettings(id)` — static fallback so a generic
|
||||
provider renders **before** SDK listings load.
|
||||
|
||||
In `ApiOptions.tsx`, providers mid-migration are dispatched with a
|
||||
`&& !genericProviderSettings` guard, meaning the bespoke component is now just a
|
||||
**pre-listing fallback**. Completing a provider's migration =
|
||||
make the generic path always resolve, then delete the fallback component, its
|
||||
model picker, its dispatch block, and its import.
|
||||
|
||||
---
|
||||
|
||||
## What was removed this pass
|
||||
|
||||
Three providers were fully cut over to the generic SDK-driven path and their
|
||||
bespoke code deleted. These were chosen because they were already dual-pathed
|
||||
(`&& !genericProviderSettings`) and have simple API-key-only config.
|
||||
|
||||
**Deleted (6 files, ~1,066 lines):**
|
||||
|
||||
- `webview-ui/src/components/settings/providers/GroqProvider.tsx`
|
||||
- `webview-ui/src/components/settings/providers/BasetenProvider.tsx`
|
||||
- `webview-ui/src/components/settings/providers/VercelAIGatewayProvider.tsx`
|
||||
- `webview-ui/src/components/settings/GroqModelPicker.tsx`
|
||||
- `webview-ui/src/components/settings/BasetenModelPicker.tsx`
|
||||
- `webview-ui/src/components/settings/VercelModelPicker.tsx`
|
||||
|
||||
**Modified:**
|
||||
|
||||
- `providers/providerSettingsRegistry.ts` — added `groq`, `baseten`,
|
||||
`vercel-ai-gateway` to `FALLBACK_GENERIC_PROVIDER_NAMES` so the generic path
|
||||
*always* renders for them (no dependency on the catalog listing being loaded).
|
||||
- `ApiOptions.tsx` — removed the 3 imports and the 3 `selectedProvider === …`
|
||||
dispatch blocks. These providers now fall through to the single
|
||||
`genericProviderSettings && <GenericProviderSettings … />` block.
|
||||
- `context/ExtensionStateContext.tsx` — updated a now-stale comment about
|
||||
`BasetenModelPicker`; flagged the legacy `refreshBasetenModelsRpc` /
|
||||
`basetenModels` state as orphaned (see follow-ups).
|
||||
- `providers/providerSettingsRegistry.test.ts` — added fallback assertions for
|
||||
the 3 migrated providers.
|
||||
|
||||
**Verification:** `tsc -b` clean; `vitest run src/components/settings/` →
|
||||
10 files / 87 tests passing.
|
||||
|
||||
> ⚠️ **Runtime check still recommended.** Typecheck + unit tests confirm the
|
||||
> wiring, but the settings UI for these 3 providers should be smoke-tested in a
|
||||
> running extension host (open Settings → select Groq / Baseten / Vercel AI
|
||||
> Gateway → confirm API-key field, model picker, and model selection persist).
|
||||
|
||||
**Orphaned-but-left (intentional, to keep this pass small):** the legacy
|
||||
per-provider model-refresh plumbing for baseten/vercel (`refreshBasetenModelsRpc`,
|
||||
`setBasetenModels`, and the analogous vercel state in `ExtensionStateContext.tsx`
|
||||
+ `src/core/controller/models/refresh*.ts`) is no longer consumed by the UI but
|
||||
was not removed here — it touches RPC services and shared state. Queued under
|
||||
[Roadmap §3](#3-per-provider-model-refresh-layer).
|
||||
|
||||
---
|
||||
|
||||
## Roadmap (prioritized)
|
||||
|
||||
Ordered by value/risk. Each item notes whether it can be done **extension-only**
|
||||
or **needs SDK changes** (deferred per this pass's scope).
|
||||
|
||||
### 1. Finish migrating "simple" providers to `GenericProviderSettings`
|
||||
|
||||
**Extension-only. Low risk. High value.** Same recipe as this pass. Candidates
|
||||
that are already dual-pathed or API-key-only and likely covered by the generic
|
||||
shell + SDK catalog:
|
||||
|
||||
- `huggingface` (already `&& !genericProviderSettings` guarded — next easiest)
|
||||
- `xai`, `zai`, `qwen`, `moonshot`, `aihubmix`, `asksage`, `dify`, `requesty`
|
||||
|
||||
Recipe per provider:
|
||||
1. Confirm SDK `ProviderListing.protocol` is one of the generic protocols (or add
|
||||
to `getFallbackGenericProviderSettings`).
|
||||
2. Add to `FALLBACK_GENERIC_PROVIDER_NAMES` (+ overrides for base URL / signup).
|
||||
3. Delete `*Provider.tsx`, its `*ModelPicker.tsx`, the dispatch block, the import.
|
||||
4. `tsc -b` + settings tests + runtime smoke test.
|
||||
|
||||
### 2. Collapse the `ApiOptions.tsx` dispatch chain
|
||||
|
||||
**Extension-only. Medium risk.** Today it's a ~30-branch
|
||||
`selectedProvider === "x" && <XProvider/>` ladder. As §1 empties it, the file
|
||||
should converge to: `genericProviderSettings ? <GenericProviderSettings/> :
|
||||
<remaining custom providers>`. The genuinely-custom set that needs bespoke UI
|
||||
(multi-field auth, special flows) is small: `bedrock`, `vertex`, `sapaicore`,
|
||||
`oca`, `openai` (compatible), `lmstudio`, `ollama`, `vscode-lm`, `cline`,
|
||||
`cline-pass`, `claude-code`, `openai-codex`.
|
||||
|
||||
### 3. Per-provider model-refresh layer
|
||||
|
||||
**Needs SDK alignment. Medium/high value.** `src/core/controller/models/`
|
||||
contains ~23 `refresh*Models.ts` / `get*Models.ts` handlers that fetch model
|
||||
lists from provider APIs. The SDK's unified `resolveProviderModels` RPC +
|
||||
`@cline/llms` catalog is meant to own live model discovery. Migrate consumers to
|
||||
the unified RPC, then delete the per-provider handlers and their
|
||||
`ExtensionStateContext` state (`basetenModels`, `vercelAiGatewayModels`,
|
||||
`groqModels`, `openRouterModels`, …). The orphaned baseten/vercel state from this
|
||||
pass is the first cleanup here.
|
||||
|
||||
### 4. ApiConfiguration ↔ proto conversion & provider type unions
|
||||
|
||||
**Needs SDK changes (deferred).** `src/shared/proto-conversions/models/`
|
||||
(~1,000 lines, esp. `api-configuration-conversion.ts` at ~800) and the provider
|
||||
type unions / model metadata in `src/shared/api.ts` + `src/shared/providers/*.json`
|
||||
duplicate what the SDK's `ProviderConfig` / catalog already model. The extension
|
||||
should consume SDK provider/config types directly rather than maintaining a
|
||||
parallel `ApiConfiguration` shape. **SDK gap:** needs the SDK to expose its
|
||||
provider config + listing metadata richly enough that the webview can drive all
|
||||
settings UIs from it. Document the exact fields needed, then implement on the SDK
|
||||
side in a follow-up.
|
||||
|
||||
### 5. In-extension model catalog (`src/sdk/model-catalog/`)
|
||||
|
||||
**Needs SDK changes (deferred).** ~3,000 lines (`catalog.ts`, `store.ts`,
|
||||
`effective-config.ts`, `contracts.ts`, `shape-adapter.ts`, …) that re-derive an
|
||||
effective model/provider config inside the extension. Much of this is a shim that
|
||||
exists because the SDK catalog isn't yet consumed end-to-end. As §3/§4 land, this
|
||||
layer should shrink to a thin host adapter or disappear.
|
||||
|
||||
### 6. Session / task / state overlap
|
||||
|
||||
**Mostly SDK-owned already; keep VSCode glue.** The agent loop, context-window
|
||||
management, truncation, and token counting are already owned by the SDK
|
||||
(`@cline/agents` `agent-runtime.ts`, `@cline/core` `message-builder.ts`,
|
||||
`session-runtime-orchestrator.ts`). The extension's `src/sdk/*` is largely a
|
||||
legitimate bridge (webview gRPC, VSCode UI concerns). Candidates to consolidate
|
||||
*into* the SDK over time (deferred — needs SDK changes):
|
||||
|
||||
- `src/sdk/sdk-task-history.ts` (~747) overlaps `persistence-service.ts` /
|
||||
`session-data.ts`.
|
||||
- `src/sdk/tool-approval-denial.ts` + `sdk-tool-policies.ts` — tool policy logic
|
||||
the SDK could own generically.
|
||||
- `src/core/hooks/*` + `src/sdk/hooks-adapter.ts` overlap the SDK hook system.
|
||||
|
||||
Keep in the extension (genuinely VSCode-specific): `message-translator.ts`
|
||||
(webview message format), `StateManager.ts` (VSCode storage), the
|
||||
`sdk-*-coordinator.ts` bridge layer, `vscode-run-commands-tool.ts` (terminal),
|
||||
`McpHub.ts` (connection lifecycle), `vscode-lm/*` (GitHub Copilot models).
|
||||
|
||||
---
|
||||
|
||||
## Appendix: key files
|
||||
|
||||
- Generic settings shell: `webview-ui/src/components/settings/providers/GenericProviderSettings.tsx`
|
||||
- Cutover registry: `webview-ui/src/components/settings/providers/providerSettingsRegistry.ts`
|
||||
- Provider dispatch: `webview-ui/src/components/settings/ApiOptions.tsx`
|
||||
- Unified model RPC hook: `webview-ui/src/hooks/useProviderModels.ts`
|
||||
- SDK API handler bridge: `apps/vscode/src/sdk/sdk-api-handler.ts`
|
||||
- Session factory: `apps/vscode/src/sdk/cline-session-factory.ts`
|
||||
- CLI reference (target pattern): `apps/cli/src/session/session.ts`, `apps/cli/src/runtime/run-agent.ts`
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.89.0",
|
||||
"version": "4.0.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -42,8 +42,7 @@
|
||||
"activationEvents": [
|
||||
"onLanguage",
|
||||
"onUri",
|
||||
"onStartupFinished",
|
||||
"workspaceContains:evals.env"
|
||||
"onStartupFinished"
|
||||
],
|
||||
"main": "./dist/extension.js",
|
||||
"contributes": {
|
||||
@@ -138,6 +137,11 @@
|
||||
"title": "MCP Servers",
|
||||
"icon": "$(server)"
|
||||
},
|
||||
{
|
||||
"command": "cline.marketplaceButtonClicked",
|
||||
"title": "Customize",
|
||||
"icon": "$(wrench)"
|
||||
},
|
||||
{
|
||||
"command": "cline.historyButtonClicked",
|
||||
"title": "History",
|
||||
@@ -261,7 +265,7 @@
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
{
|
||||
"command": "cline.mcpButtonClicked",
|
||||
"command": "cline.marketplaceButtonClicked",
|
||||
"group": "navigation@2",
|
||||
"when": "view == claude-dev.SidebarProvider"
|
||||
},
|
||||
@@ -391,7 +395,7 @@
|
||||
"test:e2e:ui": "bun scripts/interactive-playwright.ts",
|
||||
"install:all": "bun install",
|
||||
"dev:webview": "cd webview-ui && bun run dev",
|
||||
"build:webview": "cd webview-ui && bun run build",
|
||||
"build:webview": "bun run protos && cd webview-ui && bun run build",
|
||||
"test:webview": "cd webview-ui && bun run test",
|
||||
"publish:marketplace": "node scripts/publish-marketplace.mjs",
|
||||
"publish:marketplace:prerelease": "node scripts/publish-marketplace.mjs --pre-release",
|
||||
|
||||
@@ -3,17 +3,13 @@ syntax = "proto3";
|
||||
package cline;
|
||||
|
||||
import "cline/common.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_multiple_files = true;
|
||||
option java_package = "bot.cline.proto";
|
||||
|
||||
service CheckpointsService {
|
||||
rpc checkpointDiff(Int64Request) returns (Empty);
|
||||
rpc checkpointRestore(CheckpointRestoreRequest) returns (Empty);
|
||||
rpc subscribeToCheckpoints(CheckpointSubscriptionRequest) returns (stream CheckpointEvent);
|
||||
rpc getCwdHash(StringArrayRequest) returns (PathHashMap);
|
||||
}
|
||||
|
||||
message CheckpointRestoreRequest {
|
||||
@@ -22,26 +18,3 @@ message CheckpointRestoreRequest {
|
||||
string restore_type = 3;
|
||||
optional int64 offset = 4;
|
||||
}
|
||||
|
||||
message CheckpointSubscriptionRequest {
|
||||
string cwd_hash = 1;
|
||||
}
|
||||
|
||||
message CheckpointEvent {
|
||||
enum OperationType {
|
||||
CHECKPOINT_INIT = 0;
|
||||
CHECKPOINT_COMMIT = 1;
|
||||
CHECKPOINT_RESTORE = 2;
|
||||
}
|
||||
|
||||
OperationType operation = 1;
|
||||
string cwd_hash = 2;
|
||||
bool is_active = 3;
|
||||
google.protobuf.Timestamp timestamp = 4;
|
||||
optional string task_id = 5;
|
||||
optional string commit_hash = 6;
|
||||
}
|
||||
|
||||
message PathHashMap {
|
||||
map<string, string> path_hash = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package cline;
|
||||
|
||||
import "cline/common.proto";
|
||||
|
||||
option go_package = "github.com/cline/grpc-go/cline";
|
||||
option java_multiple_files = true;
|
||||
option java_package = "bot.cline.proto";
|
||||
|
||||
service MarketplaceService {
|
||||
rpc getMarketplaceCatalog(EmptyRequest) returns (MarketplaceCatalog);
|
||||
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 {
|
||||
string label = 1;
|
||||
optional string color = 2;
|
||||
}
|
||||
|
||||
message MarketplaceCounts {
|
||||
optional int32 tools = 1;
|
||||
optional int32 prompts = 2;
|
||||
optional int32 resources = 3;
|
||||
}
|
||||
|
||||
message MarketplaceEnvVar {
|
||||
string name = 1;
|
||||
bool required = 2;
|
||||
optional string description = 3;
|
||||
optional string url = 4;
|
||||
}
|
||||
|
||||
message MarketplaceInstall {
|
||||
repeated string args = 1;
|
||||
repeated MarketplaceEnvVar env = 2;
|
||||
optional string command = 3;
|
||||
optional string notes = 4;
|
||||
}
|
||||
|
||||
message MarketplaceEntry {
|
||||
string id = 1;
|
||||
string type = 2;
|
||||
string name = 3;
|
||||
optional string tagline = 4;
|
||||
optional string description = 5;
|
||||
repeated string tags = 6;
|
||||
repeated MarketplaceTag tag_objects = 7;
|
||||
optional string author = 8;
|
||||
optional string source_url = 9;
|
||||
optional string homepage_url = 10;
|
||||
optional MarketplaceCounts counts = 11;
|
||||
optional MarketplaceInstall install = 12;
|
||||
}
|
||||
|
||||
message MarketplaceCatalog {
|
||||
repeated MarketplaceEntry entries = 1;
|
||||
}
|
||||
|
||||
message MarketplaceEntriesRequest {
|
||||
repeated MarketplaceEntry entries = 1;
|
||||
}
|
||||
|
||||
message MarketplaceInstalledEntries {
|
||||
repeated string installed_keys = 1;
|
||||
}
|
||||
|
||||
message MarketplaceLocalInstalledEntry {
|
||||
string id = 1;
|
||||
string type = 2;
|
||||
string name = 3;
|
||||
optional string description = 4;
|
||||
optional string path = 5;
|
||||
optional string source = 6;
|
||||
bool enabled = 7;
|
||||
}
|
||||
|
||||
message MarketplaceLocalInstalledEntries {
|
||||
repeated MarketplaceLocalInstalledEntry entries = 1;
|
||||
}
|
||||
|
||||
message ToggleMarketplaceLocalInstalledEntryRequest {
|
||||
MarketplaceLocalInstalledEntry entry = 1;
|
||||
bool enabled = 2;
|
||||
}
|
||||
|
||||
message MarketplaceLocalInstalledEntryRequest {
|
||||
MarketplaceLocalInstalledEntry entry = 1;
|
||||
}
|
||||
|
||||
message MarketplaceEntryRequest {
|
||||
MarketplaceEntry entry = 1;
|
||||
}
|
||||
|
||||
message MarketplaceInstallResult {
|
||||
string id = 1;
|
||||
string type = 2;
|
||||
string status = 3;
|
||||
string message = 4;
|
||||
optional string output = 5;
|
||||
}
|
||||
@@ -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,53 +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;
|
||||
}
|
||||
|
||||
enum ApiFormat {
|
||||
ANTHROPIC_CHAT = 0;
|
||||
GEMINI_CHAT = 1;
|
||||
@@ -754,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;
|
||||
@@ -800,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;
|
||||
|
||||
@@ -112,6 +112,9 @@ message Secrets {
|
||||
// in src/shared/storage/state-keys.ts and use the scripts/generate-state-proto.mjs
|
||||
// script to regenerate this list.
|
||||
message Settings {
|
||||
reserved 144, 154, 176;
|
||||
reserved "cline_web_tools_enabled", "enable_parallel_tool_calling", "double_check_completion_enabled";
|
||||
|
||||
optional string lite_llm_base_url = 1;
|
||||
optional bool lite_llm_use_prompt_cache = 2;
|
||||
optional string anthropic_base_url = 4;
|
||||
@@ -234,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;
|
||||
@@ -248,18 +251,15 @@ message Settings {
|
||||
optional bool enable_checkpoints_setting = 135;
|
||||
optional int32 shell_integration_timeout = 136;
|
||||
optional string default_terminal_profile = 137;
|
||||
optional int32 terminal_output_line_limit = 138;
|
||||
optional int32 max_consecutive_mistakes = 139;
|
||||
optional bool yolo_mode_toggled = 142;
|
||||
optional bool use_auto_condense = 143;
|
||||
optional bool cline_web_tools_enabled = 144;
|
||||
optional string preferred_language = 145;
|
||||
optional PlanActMode mode = 147;
|
||||
optional FocusChainSettings focus_chain_settings = 149;
|
||||
optional string custom_prompt = 150;
|
||||
optional bool hooks_enabled = 152;
|
||||
optional bool subagents_enabled = 153;
|
||||
optional bool enable_parallel_tool_calling = 154;
|
||||
optional bool background_edit_enabled = 155;
|
||||
optional bool opt_out_of_remote_config = 157;
|
||||
optional bool open_telemetry_enabled = 158;
|
||||
@@ -278,7 +278,6 @@ message Settings {
|
||||
optional int32 open_telemetry_log_max_queue_size = 171;
|
||||
optional bool worktrees_enabled = 172;
|
||||
optional bool auto_approve_all_toggled = 174;
|
||||
optional bool double_check_completion_enabled = 176;
|
||||
map<string, string> open_ai_headers = 177;
|
||||
optional string plan_mode_cline_model_id = 178;
|
||||
optional OpenRouterModelInfo plan_mode_cline_model_info = 179;
|
||||
@@ -388,8 +387,11 @@ message UpdateTaskSettingsRequest {
|
||||
message UpdateSettingsRequest {
|
||||
reserved 15; // was openai_reasoning_effort (moved to mode-scoped reasoning effort)
|
||||
reserved 23; // was dictation_settings (dictation removed)
|
||||
reserved 32, 34, 35, 41;
|
||||
reserved 38; // was skills_enabled (removed - now always enabled)
|
||||
reserved 43; // was lazy_teammate_mode_enabled (removed)
|
||||
reserved 12; // was terminal_output_line_limit (removed; SDK command output limits are character-based)
|
||||
reserved "native_tool_call_enabled", "cline_web_tools_enabled", "enable_parallel_tool_calling", "double_check_completion_enabled";
|
||||
|
||||
Metadata metadata = 1;
|
||||
optional ModelsApiConfiguration api_configuration = 2;
|
||||
@@ -401,7 +403,6 @@ message UpdateSettingsRequest {
|
||||
optional bool terminal_reuse_enabled = 9;
|
||||
optional bool mcp_responses_collapsed = 10;
|
||||
optional McpDisplayMode mcp_display_mode = 11;
|
||||
optional int32 terminal_output_line_limit = 12;
|
||||
optional PlanActMode mode = 13;
|
||||
optional string preferred_language = 14;
|
||||
reserved 16; // was strict_plan_mode_enabled (removed)
|
||||
@@ -418,15 +419,11 @@ message UpdateSettingsRequest {
|
||||
optional bool subagents_enabled = 29;
|
||||
optional int32 subagent_terminal_output_line_limit = 30;
|
||||
optional string cline_env = 31;
|
||||
optional bool native_tool_call_enabled = 32;
|
||||
optional OnboardingModelGroup onboarding_models = 33;
|
||||
optional bool cline_web_tools_enabled = 34;
|
||||
optional bool enable_parallel_tool_calling = 35;
|
||||
optional bool background_edit_enabled = 36;
|
||||
optional string oca_reasoning_effort = 37;
|
||||
optional bool opt_out_of_remote_config = 39;
|
||||
optional bool worktrees_enabled = 40;
|
||||
optional bool double_check_completion_enabled = 41;
|
||||
optional bool show_feature_tips = 42;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -36,8 +38,6 @@ service TaskService {
|
||||
rpc editMessageAndRegenerate(EditMessageAndRegenerateRequest) returns (Empty);
|
||||
// Records task feedback (thumbs up/down)
|
||||
rpc taskFeedback(StringRequest) returns (Empty);
|
||||
// Shows task completion changes diff in a view
|
||||
rpc taskCompletionViewChanges(Int64Request) returns (Empty);
|
||||
// Executes a quick win task with command and title
|
||||
rpc executeQuickWin(ExecuteQuickWinRequest) returns (Empty);
|
||||
// Deletes all task history
|
||||
|
||||
@@ -254,6 +254,9 @@ service UiService {
|
||||
// Subscribe to MCP button clicked events
|
||||
rpc subscribeToMcpButtonClicked(EmptyRequest) returns (stream Empty);
|
||||
|
||||
// Subscribe to customize button clicked events
|
||||
rpc subscribeToMarketplaceButtonClicked(EmptyRequest) returns (stream Empty);
|
||||
|
||||
// Subscribe to history button click events
|
||||
rpc subscribeToHistoryButtonClicked(EmptyRequest) returns (stream Empty);
|
||||
|
||||
|
||||
@@ -0,0 +1,705 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
|
||||
import "should"
|
||||
import fs from "fs/promises"
|
||||
import * as actualOs from "os"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
|
||||
// The SUT does `import * as os from "os"; os.homedir()`. Under bun, sinon's
|
||||
// `stub(os, "homedir")` on the test's own `os` binding does NOT propagate to the
|
||||
// SUT's namespace import, so inject a module-level homedir stub via mock.module
|
||||
// (the rest of `os` — tmpdir() etc. — keeps its real behavior).
|
||||
const homedirStub = sinon.stub()
|
||||
const osMockNamespace = { ...actualOs, homedir: homedirStub }
|
||||
const osMock = () => ({ ...osMockNamespace, default: osMockNamespace })
|
||||
mock.module("os", osMock)
|
||||
mock.module("node:os", osMock)
|
||||
|
||||
import os from "os"
|
||||
import { ClineConfigurationError, ClineEndpoint, ClineEnv, Environment } from "../config"
|
||||
|
||||
describe("ClineEndpoint configuration", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let tempDir: string
|
||||
let originalHomedir: typeof os.homedir
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
tempDir = path.join(os.tmpdir(), `config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
|
||||
// Create .cline directory
|
||||
await fs.mkdir(path.join(tempDir, ".cline"), { recursive: true })
|
||||
|
||||
// Stub os.homedir to return our temp directory (via mock.module homedirStub)
|
||||
originalHomedir = os.homedir
|
||||
homedirStub.reset()
|
||||
homedirStub.returns(tempDir)
|
||||
|
||||
// Reset the singleton state using internal method
|
||||
;(ClineEndpoint as any)._instance = null
|
||||
;(ClineEndpoint as any)._initialized = false
|
||||
;(ClineEndpoint as any)._extensionFsPath = undefined
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
// Reset singleton state
|
||||
;(ClineEndpoint as any)._instance = null
|
||||
;(ClineEndpoint as any)._initialized = false
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
describe("valid config parsing", () => {
|
||||
it("should parse valid endpoints.json with all required fields", async () => {
|
||||
const validConfig = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("https://app.enterprise.com")
|
||||
config.apiBaseUrl.should.equal("https://api.enterprise.com")
|
||||
config.mcpBaseUrl.should.equal("https://mcp.enterprise.com")
|
||||
config.environment.should.equal(Environment.selfHosted)
|
||||
})
|
||||
|
||||
it("should work without endpoints.json (standard mode)", async () => {
|
||||
// No endpoints.json file exists
|
||||
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.environment.should.not.equal(Environment.selfHosted)
|
||||
// Should use production defaults
|
||||
config.appBaseUrl.should.equal("https://app.cline.bot")
|
||||
config.apiBaseUrl.should.equal("https://api.cline.bot")
|
||||
})
|
||||
|
||||
it("should accept URLs with ports", async () => {
|
||||
const validConfig = {
|
||||
appBaseUrl: "http://localhost:3000",
|
||||
apiBaseUrl: "http://localhost:7777",
|
||||
mcpBaseUrl: "http://localhost:8080/mcp",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("http://localhost:3000")
|
||||
config.apiBaseUrl.should.equal("http://localhost:7777")
|
||||
config.mcpBaseUrl.should.equal("http://localhost:8080/mcp")
|
||||
})
|
||||
|
||||
it("should accept URLs with paths", async () => {
|
||||
const validConfig = {
|
||||
appBaseUrl: "https://proxy.enterprise.com/cline/app",
|
||||
apiBaseUrl: "https://proxy.enterprise.com/cline/api",
|
||||
mcpBaseUrl: "https://proxy.enterprise.com/cline/mcp",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("https://proxy.enterprise.com/cline/app")
|
||||
})
|
||||
})
|
||||
|
||||
describe("invalid JSON handling", () => {
|
||||
it("should throw ClineConfigurationError for invalid JSON syntax", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "{ invalid json }", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("Invalid JSON")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for truncated JSON", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), '{"appBaseUrl": "https://test.com"', "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("Invalid JSON")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for empty file", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for non-object JSON", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), '"just a string"', "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must contain a JSON object")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for array JSON", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "[]", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
// Arrays pass the object check but fail on required fields
|
||||
error.message.should.containEql("Missing required field")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for null JSON", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "null", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must contain a JSON object")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("missing required fields", () => {
|
||||
it("should throw ClineConfigurationError when appBaseUrl is missing", async () => {
|
||||
const config = {
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql('Missing required field "appBaseUrl"')
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when apiBaseUrl is missing", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql('Missing required field "apiBaseUrl"')
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when mcpBaseUrl is missing", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql('Missing required field "mcpBaseUrl"')
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when all fields are missing", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "{}", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("Missing required field")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when field is null", async () => {
|
||||
const config = {
|
||||
appBaseUrl: null,
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql('Missing required field "appBaseUrl"')
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when field is empty string", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("cannot be empty")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when field is whitespace only", async () => {
|
||||
const config = {
|
||||
appBaseUrl: " ",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("cannot be empty")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when field is non-string", async () => {
|
||||
const config = {
|
||||
appBaseUrl: 12345,
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must be a string")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("invalid URL detection", () => {
|
||||
it("should throw ClineConfigurationError for invalid URL format", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "not-a-valid-url",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must be a valid URL")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for URL without protocol", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must be a valid URL")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for malformed URL", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must be a valid URL")
|
||||
}
|
||||
})
|
||||
|
||||
it("should include the invalid URL value in error message", async () => {
|
||||
const invalidUrl = "definitely-not-a-url"
|
||||
const config = {
|
||||
appBaseUrl: invalidUrl,
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql(invalidUrl)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("environment switching blocked in self-hosted mode", () => {
|
||||
it("should throw error when trying to change environment in self-hosted mode", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
|
||||
// Verify we're in self-hosted mode
|
||||
ClineEndpoint.config.environment.should.equal(Environment.selfHosted)
|
||||
|
||||
// Try to change environment - should throw
|
||||
try {
|
||||
ClineEnv.setEnvironment("staging")
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.containEql("Cannot change environment in on-premise mode")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw error for all environment values in self-hosted mode", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
|
||||
const environments = ["staging", "local", "production", "anything"]
|
||||
for (const env of environments) {
|
||||
try {
|
||||
ClineEnv.setEnvironment(env)
|
||||
throw new Error(`Should have thrown for environment: ${env}`)
|
||||
} catch (error: any) {
|
||||
error.message.should.containEql("Cannot change environment in on-premise mode")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should allow environment switching in standard mode", async () => {
|
||||
// No endpoints.json file - standard mode
|
||||
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
|
||||
// Verify we're NOT in self-hosted mode
|
||||
ClineEndpoint.config.environment.should.not.equal(Environment.selfHosted)
|
||||
|
||||
// Should be able to change environment
|
||||
ClineEnv.setEnvironment("staging")
|
||||
ClineEnv.getEnvironment().environment.should.equal("staging")
|
||||
|
||||
ClineEnv.setEnvironment("local")
|
||||
ClineEnv.getEnvironment().environment.should.equal("local")
|
||||
|
||||
ClineEnv.setEnvironment("production")
|
||||
ClineEnv.getEnvironment().environment.should.equal("production")
|
||||
})
|
||||
})
|
||||
|
||||
describe("self-hosted mode behavior", () => {
|
||||
it("should report selfHosted environment in self-hosted mode", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
|
||||
const envConfig = ClineEndpoint.config
|
||||
envConfig.environment.should.equal(Environment.selfHosted)
|
||||
})
|
||||
|
||||
it("should use custom endpoints from file", async () => {
|
||||
const customConfig = {
|
||||
appBaseUrl: "https://custom-app.internal",
|
||||
apiBaseUrl: "https://custom-api.internal",
|
||||
mcpBaseUrl: "https://custom-mcp.internal/v1",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(customConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("https://custom-app.internal")
|
||||
config.apiBaseUrl.should.equal("https://custom-api.internal")
|
||||
config.mcpBaseUrl.should.equal("https://custom-mcp.internal/v1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("initialization behavior", () => {
|
||||
it("should only initialize once", async () => {
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
ClineEndpoint.isInitialized().should.be.true()
|
||||
|
||||
// Second initialize should be a no-op
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
ClineEndpoint.isInitialized().should.be.true()
|
||||
})
|
||||
|
||||
it("should throw error when accessing config before initialization", async () => {
|
||||
// Already reset in beforeEach, so accessing should throw
|
||||
try {
|
||||
const _ = ClineEndpoint.config
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.containEql("not initialized")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSelfHosted() method", () => {
|
||||
it("should return true when not initialized (safety fallback)", async () => {
|
||||
// Reset singleton state - already done in beforeEach, not initialized
|
||||
ClineEndpoint.isInitialized().should.be.false()
|
||||
ClineEndpoint.isSelfHosted().should.be.true()
|
||||
})
|
||||
|
||||
it("should return true when in self-hosted mode", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
|
||||
ClineEndpoint.isSelfHosted().should.be.true()
|
||||
})
|
||||
|
||||
it("should return false when in normal mode (no endpoints.json)", async () => {
|
||||
// No endpoints.json file exists
|
||||
await ClineEndpoint.initialize(tempDir)
|
||||
|
||||
ClineEndpoint.isSelfHosted().should.be.false()
|
||||
})
|
||||
})
|
||||
|
||||
describe("bundled endpoints.json behavior", () => {
|
||||
let bundledDir: string
|
||||
let setVscodeHostProviderMock: (mock: { extensionFsPath: string; globalStorageFsPath: string }) => void
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a separate directory for bundled config
|
||||
bundledDir = path.join(os.tmpdir(), `config-bundled-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
await fs.mkdir(bundledDir, { recursive: true })
|
||||
|
||||
// Import HostProvider utilities
|
||||
const hostProviderModule = await import("../test/host-provider-test-utils")
|
||||
setVscodeHostProviderMock = hostProviderModule.setVscodeHostProviderMock
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await fs.rm(bundledDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
it("should use bundled endpoints.json when available", async () => {
|
||||
const bundledConfig = {
|
||||
appBaseUrl: "https://bundled.enterprise.com",
|
||||
apiBaseUrl: "https://bundled-api.enterprise.com",
|
||||
mcpBaseUrl: "https://bundled-mcp.enterprise.com",
|
||||
}
|
||||
|
||||
// Set up bundled config
|
||||
await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(bundledConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize(bundledDir)
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("https://bundled.enterprise.com")
|
||||
config.apiBaseUrl.should.equal("https://bundled-api.enterprise.com")
|
||||
config.mcpBaseUrl.should.equal("https://bundled-mcp.enterprise.com")
|
||||
config.environment.should.equal(Environment.selfHosted)
|
||||
})
|
||||
|
||||
it("should prefer bundled endpoints.json over user file", async () => {
|
||||
const bundledConfig = {
|
||||
appBaseUrl: "https://bundled.enterprise.com",
|
||||
apiBaseUrl: "https://bundled-api.enterprise.com",
|
||||
mcpBaseUrl: "https://bundled-mcp.enterprise.com",
|
||||
}
|
||||
|
||||
const userConfig = {
|
||||
appBaseUrl: "https://user.enterprise.com",
|
||||
apiBaseUrl: "https://user-api.enterprise.com",
|
||||
mcpBaseUrl: "https://user-mcp.enterprise.com",
|
||||
}
|
||||
|
||||
// Set up both configs
|
||||
await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(bundledConfig), "utf8")
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(userConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize(bundledDir)
|
||||
|
||||
// Should use bundled config, not user config
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("https://bundled.enterprise.com")
|
||||
config.apiBaseUrl.should.equal("https://bundled-api.enterprise.com")
|
||||
config.mcpBaseUrl.should.equal("https://bundled-mcp.enterprise.com")
|
||||
})
|
||||
|
||||
it("should fall back to user endpoints.json when bundled is not present", async () => {
|
||||
const userConfig = {
|
||||
appBaseUrl: "https://user.enterprise.com",
|
||||
apiBaseUrl: "https://user-api.enterprise.com",
|
||||
mcpBaseUrl: "https://user-mcp.enterprise.com",
|
||||
}
|
||||
|
||||
// Only create user config, no bundled config
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(userConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize(bundledDir)
|
||||
|
||||
// Should use user config
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("https://user.enterprise.com")
|
||||
config.apiBaseUrl.should.equal("https://user-api.enterprise.com")
|
||||
config.mcpBaseUrl.should.equal("https://user-mcp.enterprise.com")
|
||||
})
|
||||
|
||||
it("should use standard mode when neither bundled nor user file exists", async () => {
|
||||
// No config files at all
|
||||
|
||||
await ClineEndpoint.initialize(bundledDir)
|
||||
|
||||
// Should use production defaults
|
||||
const config = ClineEndpoint.config
|
||||
config.environment.should.not.equal(Environment.selfHosted)
|
||||
config.appBaseUrl.should.equal("https://app.cline.bot")
|
||||
config.apiBaseUrl.should.equal("https://api.cline.bot")
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for invalid bundled file", async () => {
|
||||
const invalidConfig = {
|
||||
appBaseUrl: "not-a-url",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
// Set up invalid bundled config
|
||||
await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(invalidConfig), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(bundledDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must be a valid URL")
|
||||
error.message.should.containEql("bundled")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for invalid JSON in bundled file", async () => {
|
||||
// Set up invalid JSON in bundled file
|
||||
await fs.writeFile(path.join(bundledDir, "endpoints.json"), "{ invalid json }", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(bundledDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("Invalid JSON")
|
||||
error.message.should.containEql("bundled")
|
||||
}
|
||||
})
|
||||
|
||||
it("should indicate bundled source in error messages", async () => {
|
||||
const incompleteConfig = {
|
||||
appBaseUrl: "https://bundled.enterprise.com",
|
||||
// Missing apiBaseUrl and mcpBaseUrl
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(bundledDir, "endpoints.json"), JSON.stringify(incompleteConfig), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize(bundledDir)
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("Missing required field")
|
||||
error.message.should.containEql(path.join(bundledDir, "endpoints.json"))
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
+149
-10
@@ -4,38 +4,177 @@ import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { StorageContext } from "@/shared/storage/storage-context"
|
||||
import { clearOnboardingModelsCache } from "./core/controller/models/getClineOnboardingModels"
|
||||
import { HookDiscoveryCache } from "./core/hooks/HookDiscoveryCache"
|
||||
import { HookProcessRegistry } from "./core/hooks/HookProcessRegistry"
|
||||
import { StateManager } from "./core/storage/StateManager"
|
||||
import { AgentConfigLoader } from "./core/task/tools/subagent/AgentConfigLoader"
|
||||
import { ExtensionRegistryInfo } from "./registry"
|
||||
import { registerVsCodeLmHandler } from "./sdk/vscode-lm/register-vscode-lm"
|
||||
import { ErrorService } from "./services/error"
|
||||
import { featureFlagsService } from "./services/feature-flags"
|
||||
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 { ShowMessageType } from "./shared/proto/host/window"
|
||||
import { syncWorker } from "./shared/services/worker/sync"
|
||||
import { getBlobStoreSettingsFromEnv } from "./shared/services/worker/worker"
|
||||
import { getLatestAnnouncementId } from "./utils/announcements"
|
||||
import { arePathsEqual } from "./utils/path"
|
||||
|
||||
/**
|
||||
* Performs initialization for Cline that is common to all platforms.
|
||||
* Performs intialization for Cline that is common to all platforms.
|
||||
*
|
||||
* This is the MINIMAL inert-shell version: it only wires up logging,
|
||||
* initializes the bundled endpoint configuration, and creates the webview
|
||||
* provider so the UI can render. All heavy services (state manager,
|
||||
* telemetry, error service, sync worker, hooks, etc.) have been removed.
|
||||
*
|
||||
* @param _storageContext kept for signature compatibility with callers.
|
||||
* @param context
|
||||
* @returns The webview provider
|
||||
* @throws ClineConfigurationError if endpoints.json exists but is invalid
|
||||
*/
|
||||
export async function initialize(_storageContext: StorageContext): Promise<WebviewProvider> {
|
||||
export async function initialize(storageContext: StorageContext): Promise<WebviewProvider> {
|
||||
// Configure the shared Logging class to use HostProvider's output channels and debug logger
|
||||
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg)) // File system logging
|
||||
Logger.subscribe((msg: string) => HostProvider.env.debugLog({ value: msg })) // Host debug logging
|
||||
|
||||
// Initialize ClineEndpoint configuration (reads bundled and ~/.cline/endpoints.json if present)
|
||||
// This must be done before any other code that calls ClineEnv.config()
|
||||
// Throws ClineConfigurationError if config file exists but is invalid
|
||||
const { ClineEndpoint } = await import("./config")
|
||||
await ClineEndpoint.initialize(HostProvider.get().extensionFsPath)
|
||||
|
||||
try {
|
||||
await StateManager.initialize(storageContext)
|
||||
} catch (error) {
|
||||
Logger.error("[Cline] CRITICAL: Failed to initialize StateManager:", error)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Failed to initialize storage. Please check logs for details or try restarting the client.",
|
||||
})
|
||||
}
|
||||
|
||||
// Register host-only SDK provider handlers (e.g. VS Code Language Model API),
|
||||
// which depend on the `vscode` module and cannot live in the SDK package.
|
||||
// Must run before any handler is built (standalone utilities or task loop).
|
||||
registerVsCodeLmHandler()
|
||||
|
||||
// =============== External services ===============
|
||||
await ErrorService.initialize()
|
||||
// Initialize PostHog client provider (skip in self-hosted mode)
|
||||
if (!ClineEndpoint.isSelfHosted()) {
|
||||
PostHogClientProvider.getInstance()
|
||||
}
|
||||
|
||||
// =============== Webview services ===============
|
||||
const webview = HostProvider.get().createWebviewProvider()
|
||||
|
||||
const stateManager = StateManager.get()
|
||||
// Non-blocking announcement check and display
|
||||
showVersionUpdateAnnouncement(stateManager)
|
||||
// Check if this workspace was opened from worktree quick launch
|
||||
await checkWorktreeAutoOpen(stateManager)
|
||||
|
||||
// =============== Background sync and cleanup tasks ===============
|
||||
// Use remote config blobStoreConfig if available, otherwise fall back to env vars
|
||||
const blobStoreSettings = stateManager.getRemoteConfigSettings()?.blobStoreConfig ?? getBlobStoreSettingsFromEnv()
|
||||
syncWorker().init({ ...blobStoreSettings, userDistinctId: getDistinctId() })
|
||||
// Clean up old temp files in background (non-blocking) and start periodic cleanup every 24 hours
|
||||
ClineTempManager.startPeriodicCleanup()
|
||||
|
||||
telemetryService.captureExtensionActivated()
|
||||
|
||||
return webview
|
||||
}
|
||||
|
||||
async function showVersionUpdateAnnouncement(stateManager: StateManager) {
|
||||
// Version checking for autoupdate notification
|
||||
const currentVersion = ExtensionRegistryInfo.version
|
||||
const previousVersion = stateManager.getGlobalStateKey("clineVersion")
|
||||
// Perform post-update actions if necessary
|
||||
try {
|
||||
if (!previousVersion || currentVersion !== previousVersion) {
|
||||
Logger.log(`Cline version changed: ${previousVersion} -> ${currentVersion}. First run or update detected.`)
|
||||
|
||||
// Check if there's a new announcement to show
|
||||
const lastShownAnnouncementId = stateManager.getGlobalStateKey("lastShownAnnouncementId")
|
||||
const latestAnnouncementId = getLatestAnnouncementId()
|
||||
|
||||
if (lastShownAnnouncementId !== latestAnnouncementId) {
|
||||
// Show notification when there's a new announcement (major/minor updates or fresh installs)
|
||||
const message = previousVersion
|
||||
? `Cline has been updated to v${currentVersion}`
|
||||
: `Welcome to Cline v${currentVersion}`
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.INFORMATION,
|
||||
message,
|
||||
})
|
||||
}
|
||||
// Always update the main version tracker for the next launch.
|
||||
stateManager.setGlobalState("clineVersion", currentVersion)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Logger.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this workspace was opened from the worktree quick launch button.
|
||||
* If so, opens the Cline sidebar and clears the state.
|
||||
*/
|
||||
async function checkWorktreeAutoOpen(stateManager: StateManager): Promise<void> {
|
||||
try {
|
||||
// Read directly from globalState (not StateManager cache) since this may have been
|
||||
// set by another window right before this one opened
|
||||
const worktreeAutoOpenPath = stateManager.getGlobalStateKey("worktreeAutoOpenPath")
|
||||
if (!worktreeAutoOpenPath) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get current workspace path
|
||||
const workspacePaths = (await HostProvider.workspace.getWorkspacePaths({})).paths
|
||||
if (workspacePaths.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentPath = workspacePaths[0]
|
||||
|
||||
// Check if current workspace matches the worktree path
|
||||
if (arePathsEqual(currentPath, worktreeAutoOpenPath)) {
|
||||
// Clear the state first to prevent re-triggering
|
||||
stateManager.setGlobalState("worktreeAutoOpenPath", undefined)
|
||||
// Open the Cline sidebar
|
||||
await HostProvider.workspace.openClineSidebarPanel({})
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error checking worktree auto-open", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs cleanup when Cline is deactivated that is common to all platforms.
|
||||
*/
|
||||
export async function tearDown(): Promise<void> {
|
||||
// Dispose all webview instances
|
||||
await WebviewProvider.disposeAllInstances()
|
||||
try {
|
||||
AgentConfigLoader.getInstance()?.dispose()
|
||||
PostHogClientProvider.getInstance().dispose()
|
||||
telemetryService.dispose()
|
||||
ErrorService.get().dispose()
|
||||
featureFlagsService.dispose()
|
||||
// Dispose all webview instances
|
||||
await WebviewProvider.disposeAllInstances()
|
||||
syncWorker().dispose()
|
||||
clearOnboardingModelsCache()
|
||||
|
||||
// Kill any running hook processes to prevent zombies
|
||||
await HookProcessRegistry.terminateAll()
|
||||
// Clean up hook discovery cache
|
||||
HookDiscoveryCache.getInstance().dispose()
|
||||
// Stop periodic temp file cleanup
|
||||
ClineTempManager.stopPeriodicCleanup()
|
||||
} finally {
|
||||
try {
|
||||
await StateManager.get().flushPendingState()
|
||||
} catch (error) {
|
||||
Logger.error("[Cline] Failed to flush pending state during teardown:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Type definitions for FileContextTracker
|
||||
export interface FileMetadataEntry {
|
||||
path: string
|
||||
record_state: "active" | "stale"
|
||||
record_source: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned"
|
||||
cline_read_date: number | null
|
||||
cline_edit_date: number | null
|
||||
user_edit_date?: number | null
|
||||
}
|
||||
|
||||
interface ModelMetadataEntry {
|
||||
ts: number
|
||||
model_id: string
|
||||
model_provider_id: string
|
||||
mode: string
|
||||
}
|
||||
|
||||
interface EnvironmentMetadataEntry {
|
||||
ts: number
|
||||
os_name: string
|
||||
os_version: string
|
||||
os_arch: string
|
||||
host_name: string
|
||||
host_version: string
|
||||
cline_version: string
|
||||
}
|
||||
|
||||
export interface TaskMetadata {
|
||||
files_in_context: FileMetadataEntry[]
|
||||
model_usage: ModelMetadataEntry[]
|
||||
environment_history: EnvironmentMetadataEntry[]
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
|
||||
import * as actualDiskModule from "@core/storage/disk"
|
||||
import { expect } from "chai"
|
||||
import * as actualChokidar from "chokidar"
|
||||
import * as path from "path"
|
||||
import * as sinon from "sinon"
|
||||
import * as vscode from "vscode"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
|
||||
import type { FileMetadataEntry, TaskMetadata } from "./ContextTrackerTypes"
|
||||
|
||||
// bun loads real ESM, so sinon cannot stub the `@core/storage/disk` and
|
||||
// `chokidar` namespace exports ("ES Modules cannot be stubbed"). Inject
|
||||
// module-level sinon stubs via mock.module so the full sinon stub API keeps
|
||||
// working. (`vscode` is the writable unit-test stub, still sinon-stubbed below.)
|
||||
const getTaskMetadataStub: sinon.SinonStub = sinon.stub()
|
||||
const saveTaskMetadataStub: sinon.SinonStub = sinon.stub()
|
||||
const chokidarWatchStub: sinon.SinonStub = sinon.stub()
|
||||
const diskMock = () => ({
|
||||
...actualDiskModule,
|
||||
getTaskMetadata: getTaskMetadataStub,
|
||||
saveTaskMetadata: saveTaskMetadataStub,
|
||||
})
|
||||
const chokidarNamespace = { ...actualChokidar, watch: chokidarWatchStub }
|
||||
const chokidarMock = () => ({ ...chokidarNamespace, default: chokidarNamespace })
|
||||
mock.module("@core/storage/disk", diskMock)
|
||||
mock.module("@/core/storage/disk", diskMock)
|
||||
mock.module("chokidar", chokidarMock)
|
||||
|
||||
import { FileContextTracker } from "./FileContextTracker"
|
||||
|
||||
describe("FileContextTracker", () => {
|
||||
const filePath = "src/test-file.ts"
|
||||
const taskId = "test-task-id"
|
||||
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let _mockWorkspace: sinon.SinonStub
|
||||
let mockFileSystemWatcher: any
|
||||
let tracker: FileContextTracker
|
||||
let mockTaskMetadata: TaskMetadata
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Mock vscode workspace
|
||||
_mockWorkspace = sandbox.stub(vscode.workspace, "workspaceFolders").value([
|
||||
{
|
||||
uri: {
|
||||
fsPath: "/mock/workspace",
|
||||
},
|
||||
} as vscode.WorkspaceFolder,
|
||||
])
|
||||
|
||||
// Mock chokidar file watcher
|
||||
mockFileSystemWatcher = {
|
||||
close: sandbox.stub().resolves(),
|
||||
on: sandbox.stub(),
|
||||
}
|
||||
// Return the watcher itself for chaining
|
||||
mockFileSystemWatcher.on.returns(mockFileSystemWatcher)
|
||||
|
||||
// Reset the module-level chokidar.watch stub to return our mock watcher
|
||||
chokidarWatchStub.reset()
|
||||
chokidarWatchStub.returns(mockFileSystemWatcher as any)
|
||||
|
||||
// Reset the module-level disk stubs
|
||||
mockTaskMetadata = { files_in_context: [], model_usage: [], environment_history: [] }
|
||||
getTaskMetadataStub.reset()
|
||||
getTaskMetadataStub.resolves(mockTaskMetadata)
|
||||
saveTaskMetadataStub.reset()
|
||||
saveTaskMetadataStub.resolves()
|
||||
|
||||
setVscodeHostProviderMock()
|
||||
|
||||
// Create tracker instance
|
||||
tracker = new FileContextTracker({} as Controller, taskId)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
it("should add a record when a file is read by a tool", async () => {
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Verify getTaskMetadata was called
|
||||
expect(getTaskMetadataStub.calledOnce).to.be.true
|
||||
expect(getTaskMetadataStub.firstCall.args[0]).to.equal(taskId)
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
|
||||
expect(savedMetadata.files_in_context.length).to.equal(1)
|
||||
|
||||
const fileEntry = savedMetadata.files_in_context[0]
|
||||
expect(fileEntry.path).to.equal(filePath)
|
||||
expect(fileEntry.record_state).to.equal("active")
|
||||
expect(fileEntry.record_source).to.equal("read_tool")
|
||||
expect(fileEntry.cline_read_date).to.be.a("number")
|
||||
expect(fileEntry.cline_edit_date).to.be.null
|
||||
})
|
||||
|
||||
it("should add a record when a file is edited by Cline", async () => {
|
||||
await tracker.trackFileContext(filePath, "cline_edited")
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
expect(saveTaskMetadataStub.calledOnce).to.be.true
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
|
||||
|
||||
// Check that we have at least one entry in files_in_context
|
||||
expect(savedMetadata.files_in_context).to.be.an("array").that.is.not.empty
|
||||
|
||||
// Find the active entry for this file
|
||||
const activeEntry = savedMetadata.files_in_context.find(
|
||||
(entry: FileMetadataEntry) => entry.path === filePath && entry.record_state === "active",
|
||||
)
|
||||
|
||||
// Assert that we found an active entry
|
||||
expect(activeEntry).to.exist
|
||||
|
||||
// Now check the properties of the active entry
|
||||
expect(activeEntry.path).to.equal(filePath)
|
||||
expect(activeEntry.record_state).to.equal("active")
|
||||
expect(activeEntry.record_source).to.equal("cline_edited")
|
||||
expect(activeEntry.cline_read_date).to.be.a("number")
|
||||
expect(activeEntry.cline_edit_date).to.be.a("number")
|
||||
})
|
||||
|
||||
it("should add a record when a file is mentioned", async () => {
|
||||
await tracker.trackFileContext(filePath, "file_mentioned")
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
|
||||
const fileEntry = savedMetadata.files_in_context[0]
|
||||
|
||||
expect(fileEntry.path).to.equal(filePath)
|
||||
expect(fileEntry.record_state).to.equal("active")
|
||||
expect(fileEntry.record_source).to.equal("file_mentioned")
|
||||
expect(fileEntry.cline_read_date).to.be.a("number")
|
||||
expect(fileEntry.cline_edit_date).to.be.null
|
||||
})
|
||||
|
||||
it("should add a record when a file is edited by the user", async () => {
|
||||
await tracker.trackFileContext(filePath, "user_edited")
|
||||
|
||||
// Verify saveTaskMetadata was called with the correct data
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
|
||||
const fileEntry = savedMetadata.files_in_context[0]
|
||||
|
||||
expect(fileEntry.path).to.equal(filePath)
|
||||
expect(fileEntry.record_state).to.equal("active")
|
||||
expect(fileEntry.record_source).to.equal("user_edited")
|
||||
expect(fileEntry.user_edit_date).to.be.a("number")
|
||||
|
||||
// Verify the file was added to recentlyModifiedFiles
|
||||
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
|
||||
expect(modifiedFiles).to.include(filePath)
|
||||
})
|
||||
|
||||
it("should mark existing entries as stale when adding a new entry for the same file", async () => {
|
||||
// Add an initial entry
|
||||
mockTaskMetadata.files_in_context = [
|
||||
{
|
||||
path: filePath,
|
||||
record_state: "active",
|
||||
record_source: "read_tool",
|
||||
cline_read_date: Date.now() - 1000, // 1 second ago
|
||||
cline_edit_date: null,
|
||||
user_edit_date: null,
|
||||
},
|
||||
]
|
||||
|
||||
// Track a new operation on the same file
|
||||
await tracker.trackFileContext(filePath, "cline_edited")
|
||||
|
||||
// Verify the metadata now has two entries - one stale and one active
|
||||
const savedMetadata = saveTaskMetadataStub.firstCall.args[1]
|
||||
expect(savedMetadata.files_in_context.length).to.equal(2)
|
||||
|
||||
// First entry should be marked as stale
|
||||
expect(savedMetadata.files_in_context[0].record_state).to.equal("stale")
|
||||
|
||||
// New entry should be active
|
||||
const newEntry = savedMetadata.files_in_context[1]
|
||||
expect(newEntry.record_state).to.equal("active")
|
||||
expect(newEntry.record_source).to.equal("cline_edited")
|
||||
})
|
||||
|
||||
it("should setup a file watcher for tracked files", async () => {
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Verify chokidar.watch was called
|
||||
expect(chokidarWatchStub.called).to.be.true
|
||||
|
||||
// Verify change listener was set up
|
||||
expect(mockFileSystemWatcher.on.called).to.be.true
|
||||
})
|
||||
|
||||
it("should track user edits when file watcher detects changes", async () => {
|
||||
// First track the file to set up the watcher
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Reset the stubs to check the next calls
|
||||
getTaskMetadataStub.resetHistory()
|
||||
saveTaskMetadataStub.resetHistory()
|
||||
|
||||
// Create a spy on trackFileContext to verify it's called with the right parameters
|
||||
const trackFileContextSpy = sandbox.spy(tracker, "trackFileContext")
|
||||
|
||||
// Get the callback that was registered with chokidar "change" event
|
||||
const callback = mockFileSystemWatcher.on.firstCall.args[1]
|
||||
|
||||
// Directly call the callback to simulate a file change event
|
||||
callback(vscode.Uri.file(path.resolve("/mock/workspace", filePath)))
|
||||
|
||||
// Verify trackFileContext was called with the right parameters
|
||||
expect(trackFileContextSpy.calledWith(filePath, "user_edited")).to.be.true
|
||||
|
||||
// Verify the file was added to recentlyModifiedFiles
|
||||
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
|
||||
expect(modifiedFiles).to.include(filePath)
|
||||
})
|
||||
|
||||
it("should not track Cline edits as user edits", async () => {
|
||||
// First track the file to set up the watcher
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Mark the file as edited by Cline
|
||||
tracker.markFileAsEditedByCline(filePath)
|
||||
|
||||
// Reset the stubs to check the next calls
|
||||
getTaskMetadataStub.resetHistory()
|
||||
saveTaskMetadataStub.resetHistory()
|
||||
|
||||
// Create a spy on trackFileContext to verify it's not called
|
||||
const trackFileContextSpy = sandbox.spy(tracker, "trackFileContext")
|
||||
|
||||
// Get the callback that was registered with chokidar "change" event
|
||||
const callback = mockFileSystemWatcher.on.firstCall.args[1]
|
||||
|
||||
// Directly call the callback to simulate a file change event
|
||||
callback(vscode.Uri.file(path.resolve("/mock/workspace", filePath)))
|
||||
|
||||
// Verify trackFileContext was not called with user_edited
|
||||
expect(trackFileContextSpy.calledWith(filePath, "user_edited")).to.be.false
|
||||
|
||||
// Verify the file was not added to recentlyModifiedFiles
|
||||
const modifiedFiles = tracker.getAndClearRecentlyModifiedFiles()
|
||||
expect(modifiedFiles).to.not.include(filePath)
|
||||
})
|
||||
|
||||
it("should dispose file watchers when dispose is called", async () => {
|
||||
// Track a file to set up the watcher
|
||||
await tracker.trackFileContext(filePath, "read_tool")
|
||||
|
||||
// Call dispose
|
||||
await tracker.dispose()
|
||||
|
||||
// Verify the watcher was closed
|
||||
expect(mockFileSystemWatcher.close.called).to.be.true
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,279 @@
|
||||
import { getTaskMetadata, saveTaskMetadata } from "@core/storage/disk"
|
||||
import type { ClineMessage } from "@shared/ExtensionMessage"
|
||||
import chokidar, { FSWatcher } from "chokidar"
|
||||
import * as path from "path"
|
||||
import { Controller } from "@/core/controller"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getCwd } from "@/utils/path"
|
||||
import type { FileMetadataEntry } from "./ContextTrackerTypes"
|
||||
|
||||
// This class is responsible for tracking file operations that may result in stale context.
|
||||
// If a user modifies a file outside of Cline, the context may become stale and need to be updated.
|
||||
// We do not want Cline to reload the context every time a file is modified, so we use this class merely
|
||||
// to inform Cline that the change has occurred, and tell Cline to reload the file before making
|
||||
// any changes to it. This fixes an issue with diff editing, where Cline was unable to complete a diff edit.
|
||||
// a diff edit because the file was modified since Cline last read it.
|
||||
|
||||
// FileContextTracker
|
||||
/**
|
||||
This class is responsible for tracking file operations.
|
||||
If the full contents of a file are passed to Cline via a tool, mention, or edit, the file is marked as active.
|
||||
If a file is modified outside of Cline, we detect and track this change to prevent stale context.
|
||||
This is used when restoring a task (non-git "checkpoint" restore), and mid-task.
|
||||
*/
|
||||
export class FileContextTracker {
|
||||
private controller: Controller
|
||||
readonly taskId: string
|
||||
|
||||
// File tracking and watching
|
||||
private fileWatchers = new Map<string, FSWatcher>()
|
||||
private recentlyModifiedFiles = new Set<string>()
|
||||
private recentlyEditedByCline = new Set<string>()
|
||||
|
||||
constructor(controller: Controller, taskId: string) {
|
||||
this.controller = controller
|
||||
this.taskId = taskId
|
||||
}
|
||||
|
||||
/**
|
||||
* File watchers are set up for each file that is tracked in the task metadata.
|
||||
*/
|
||||
async setupFileWatcher(filePath: string) {
|
||||
// Only setup watcher if it doesn't already exist for this file
|
||||
if (this.fileWatchers.has(filePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
const cwd = await getCwd()
|
||||
if (!cwd) {
|
||||
Logger.info("No workspace folder available - cannot determine current working directory")
|
||||
return
|
||||
}
|
||||
|
||||
// Create a chokidar file watcher for this specific file
|
||||
const resolvedFilePath = path.resolve(cwd, filePath)
|
||||
const watcher = chokidar.watch(resolvedFilePath, {
|
||||
persistent: true, // Keep process alive while watching
|
||||
ignoreInitial: true, // Don't emit events for existing files on startup
|
||||
atomic: true, // Handle atomic writes (editors that use temp files)
|
||||
awaitWriteFinish: {
|
||||
// Wait for writes to finish before emitting events
|
||||
stabilityThreshold: 100, // Wait 100ms for file size to stabilize
|
||||
pollInterval: 100, // Check every 100ms while waiting
|
||||
},
|
||||
})
|
||||
|
||||
// Track file changes
|
||||
watcher.on("change", () => {
|
||||
if (this.recentlyEditedByCline.has(filePath)) {
|
||||
this.recentlyEditedByCline.delete(filePath) // This was an edit by Cline, no need to inform Cline
|
||||
} else {
|
||||
this.recentlyModifiedFiles.add(filePath) // This was a user edit, we will inform Cline
|
||||
this.trackFileContext(filePath, "user_edited") // Update the task metadata with file tracking
|
||||
}
|
||||
})
|
||||
|
||||
// Store the watcher so we can dispose it later
|
||||
this.fileWatchers.set(filePath, watcher)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks a file operation in metadata and sets up a watcher for the file
|
||||
* This is the main entry point for FileContextTracker and is called when a file is passed to Cline via a tool, mention, or edit.
|
||||
*/
|
||||
async trackFileContext(filePath: string, operation: "read_tool" | "user_edited" | "cline_edited" | "file_mentioned") {
|
||||
try {
|
||||
const cwd = await getCwd()
|
||||
if (!cwd) {
|
||||
Logger.info("No workspace folder available - cannot determine current working directory")
|
||||
return
|
||||
}
|
||||
|
||||
// Add file to metadata
|
||||
await this.addFileToFileContextTracker(this.taskId, filePath, operation)
|
||||
|
||||
// Set up file watcher for this file
|
||||
await this.setupFileWatcher(filePath)
|
||||
} catch (error) {
|
||||
Logger.error("Failed to track file operation:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a file to the metadata tracker
|
||||
* This handles the business logic of determining if the file is new, stale, or active.
|
||||
* It also updates the metadata with the latest read/edit dates.
|
||||
*/
|
||||
async addFileToFileContextTracker(taskId: string, filePath: string, source: FileMetadataEntry["record_source"]) {
|
||||
try {
|
||||
const metadata = await getTaskMetadata(taskId)
|
||||
const now = Date.now()
|
||||
|
||||
// Mark existing entries for this file as stale
|
||||
metadata.files_in_context.forEach((entry) => {
|
||||
if (entry.path === filePath && entry.record_state === "active") {
|
||||
entry.record_state = "stale"
|
||||
}
|
||||
})
|
||||
|
||||
// Helper to get the latest date for a specific field and file
|
||||
const getLatestDateForField = (path: string, field: keyof FileMetadataEntry): number | null => {
|
||||
const relevantEntries = metadata.files_in_context
|
||||
.filter((entry) => entry.path === path && entry[field])
|
||||
.sort((a, b) => (b[field] as number) - (a[field] as number))
|
||||
|
||||
return relevantEntries.length > 0 ? (relevantEntries[0][field] as number) : null
|
||||
}
|
||||
|
||||
const newEntry: FileMetadataEntry = {
|
||||
path: filePath,
|
||||
record_state: "active",
|
||||
record_source: source,
|
||||
cline_read_date: getLatestDateForField(filePath, "cline_read_date"),
|
||||
cline_edit_date: getLatestDateForField(filePath, "cline_edit_date"),
|
||||
user_edit_date: getLatestDateForField(filePath, "user_edit_date"),
|
||||
}
|
||||
|
||||
switch (source) {
|
||||
// user_edited: The user has edited the file
|
||||
case "user_edited":
|
||||
newEntry.user_edit_date = now
|
||||
this.recentlyModifiedFiles.add(filePath)
|
||||
break
|
||||
|
||||
// cline_edited: Cline has edited the file
|
||||
case "cline_edited":
|
||||
newEntry.cline_read_date = now
|
||||
newEntry.cline_edit_date = now
|
||||
break
|
||||
|
||||
// read_tool/file_mentioned: Cline has read the file via a tool or file mention
|
||||
case "read_tool":
|
||||
case "file_mentioned":
|
||||
newEntry.cline_read_date = now
|
||||
break
|
||||
}
|
||||
|
||||
metadata.files_in_context.push(newEntry)
|
||||
await saveTaskMetadata(taskId, metadata)
|
||||
} catch (error) {
|
||||
Logger.error("Failed to add file to metadata:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns (and then clears) the set of recently modified files
|
||||
*/
|
||||
getAndClearRecentlyModifiedFiles(): string[] {
|
||||
const files = Array.from(this.recentlyModifiedFiles)
|
||||
this.recentlyModifiedFiles.clear()
|
||||
return files
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a file as edited by Cline to prevent false positives in file watchers
|
||||
*/
|
||||
markFileAsEditedByCline(filePath: string): void {
|
||||
this.recentlyEditedByCline.add(filePath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes all file watchers
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
const closePromises = Array.from(this.fileWatchers.values()).map((watcher) => watcher.close())
|
||||
await Promise.all(closePromises)
|
||||
this.fileWatchers.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects files that were edited by Cline or users after a specific message timestamp
|
||||
* This is used when restoring checkpoints to warn about potential file content mismatches
|
||||
*/
|
||||
async detectFilesEditedAfterMessage(messageTs: number, deletedMessages: ClineMessage[]): Promise<string[]> {
|
||||
const editedFiles: string[] = []
|
||||
|
||||
try {
|
||||
// Check task metadata for files that were edited by Cline or users after the message timestamp
|
||||
const taskMetadata = await getTaskMetadata(this.taskId)
|
||||
|
||||
if (taskMetadata?.files_in_context) {
|
||||
for (const fileEntry of taskMetadata.files_in_context) {
|
||||
const clineEditedAfter = fileEntry.cline_edit_date && fileEntry.cline_edit_date > messageTs
|
||||
const userEditedAfter = fileEntry.user_edit_date && fileEntry.user_edit_date > messageTs
|
||||
|
||||
if (clineEditedAfter || userEditedAfter) {
|
||||
editedFiles.push(fileEntry.path)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error checking file context metadata:", error)
|
||||
}
|
||||
|
||||
// Also check deleted task messages for file operations
|
||||
for (const message of deletedMessages) {
|
||||
if (message.say === "tool" && message.text) {
|
||||
try {
|
||||
const toolData = JSON.parse(message.text)
|
||||
if ((toolData.tool === "editedExistingFile" || toolData.tool === "newFileCreated") && toolData.path) {
|
||||
if (!editedFiles.includes(toolData.path)) {
|
||||
editedFiles.push(toolData.path)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error checking task messages:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...new Set(editedFiles)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores pending file context warning in workspace state so it persists across task reinitialization
|
||||
*/
|
||||
async storePendingFileContextWarning(files: string[]): Promise<void> {
|
||||
try {
|
||||
const key = `pendingFileContextWarning_${this.taskId}`
|
||||
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
|
||||
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
|
||||
this.controller.stateManager.setWorkspaceState(key as any, files)
|
||||
} catch (error) {
|
||||
Logger.error("Error storing pending file context warning:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves pending file context warning from workspace state (without clearing it)
|
||||
*/
|
||||
async retrievePendingFileContextWarning(): Promise<string[] | undefined> {
|
||||
try {
|
||||
const key = `pendingFileContextWarning_${this.taskId}`
|
||||
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
|
||||
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
|
||||
const files = this.controller.stateManager.getWorkspaceStateKey(key as any) as string[]
|
||||
return files
|
||||
} catch (error) {
|
||||
Logger.error("Error retrieving pending file context warning:", error)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and clears pending file context warning from workspace state
|
||||
*/
|
||||
async retrieveAndClearPendingFileContextWarning(): Promise<string[] | undefined> {
|
||||
try {
|
||||
const files = await this.retrievePendingFileContextWarning()
|
||||
if (files) {
|
||||
// NOTE: Using 'as any' because dynamic keys like pendingFileContextWarning_${taskId}
|
||||
// are legitimate workspace state keys but don't fit the strict LocalStateKey type system
|
||||
this.controller.stateManager.setWorkspaceState(`pendingFileContextWarning_${this.taskId}` as any, undefined)
|
||||
return files
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("Error retrieving pending file context warning:", error)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import { describe, it } from "bun:test"
|
||||
import { expect } from "chai"
|
||||
import { parseYamlFrontmatter } from "../frontmatter"
|
||||
|
||||
describe("parseYamlFrontmatter", () => {
|
||||
it("returns original content when no frontmatter", () => {
|
||||
const input = "Just text"
|
||||
const result = parseYamlFrontmatter(input)
|
||||
expect(result.hadFrontmatter).to.equal(false)
|
||||
expect(result.data).to.deep.equal({})
|
||||
expect(result.body).to.equal(input)
|
||||
})
|
||||
|
||||
it("parses valid YAML frontmatter", () => {
|
||||
const input = `---\npaths:\n - "src/**"\n---\n\nHello`
|
||||
const result = parseYamlFrontmatter(input)
|
||||
expect(result.hadFrontmatter).to.equal(true)
|
||||
expect(result.parseError).to.equal(undefined)
|
||||
expect(result.data).to.deep.equal({ paths: ["src/**"] })
|
||||
expect(result.body.trim()).to.equal("Hello")
|
||||
})
|
||||
|
||||
it("fails open on malformed YAML", () => {
|
||||
const input = `---\npaths: [invalid\n---\nBody`
|
||||
const result = parseYamlFrontmatter(input)
|
||||
expect(result.hadFrontmatter).to.equal(true)
|
||||
expect(result.data).to.deep.equal({})
|
||||
expect(result.body).to.equal(input)
|
||||
expect(result.parseError).to.be.a("string")
|
||||
})
|
||||
|
||||
it("rejects YAML custom tags (security: prevents unsafe deserialization)", () => {
|
||||
// !!js/function is the classic RCE vector in js-yaml v3.
|
||||
// With JSON_SCHEMA, any custom tag should be rejected.
|
||||
const input = `---\nfoo: !!js/function 'function(){ return 1 }'\n---\nBody`
|
||||
const result = parseYamlFrontmatter(input)
|
||||
expect(result.hadFrontmatter).to.equal(true)
|
||||
expect(result.data).to.deep.equal({})
|
||||
expect(result.body).to.equal(input)
|
||||
expect(result.parseError).to.be.a("string")
|
||||
})
|
||||
|
||||
it("rejects !!python/object YAML tag", () => {
|
||||
const input = `---\nfoo: !!python/object:os.system 'echo pwned'\n---\nBody`
|
||||
const result = parseYamlFrontmatter(input)
|
||||
expect(result.hadFrontmatter).to.equal(true)
|
||||
expect(result.data).to.deep.equal({})
|
||||
expect(result.parseError).to.be.a("string")
|
||||
})
|
||||
|
||||
it("parses JSON-compatible YAML values correctly", () => {
|
||||
const input = `---\ncount: 42\nenabled: true\ntags:\n - "a"\n - "b"\n---\nContent`
|
||||
const result = parseYamlFrontmatter(input)
|
||||
expect(result.hadFrontmatter).to.equal(true)
|
||||
expect(result.parseError).to.equal(undefined)
|
||||
expect(result.data).to.deep.equal({ count: 42, enabled: true, tags: ["a", "b"] })
|
||||
expect(result.body.trim()).to.equal("Content")
|
||||
})
|
||||
})
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import { describe, it } from "bun:test"
|
||||
import { expect } from "chai"
|
||||
import { evaluateRuleConditionals, extractPathLikeStrings } from "../rule-conditionals"
|
||||
|
||||
describe("rule-conditionals", () => {
|
||||
describe("evaluateRuleConditionals(paths)", () => {
|
||||
it("treats missing paths as universal", () => {
|
||||
const res = evaluateRuleConditionals({}, { paths: [] })
|
||||
expect(res.passed).to.equal(true)
|
||||
})
|
||||
|
||||
it("treats empty paths list in frontmatter as match-nothing (fail-closed)", () => {
|
||||
const res = evaluateRuleConditionals({ paths: [] }, { paths: ["src/index.ts"] })
|
||||
expect(res.passed).to.equal(false)
|
||||
})
|
||||
|
||||
it("does not activate path-scoped rules with empty context", () => {
|
||||
const res = evaluateRuleConditionals({ paths: ["src/**"] }, { paths: [] })
|
||||
expect(res.passed).to.equal(false)
|
||||
})
|
||||
|
||||
it("matches when any candidate path matches any glob", () => {
|
||||
const res = evaluateRuleConditionals({ paths: ["src/**", "apps/**"] }, { paths: ["src/index.ts"] })
|
||||
expect(res.passed).to.equal(true)
|
||||
expect(res.matchedConditions.paths).to.deep.equal(["src/**"])
|
||||
})
|
||||
|
||||
it("ignores invalid paths type (fail-open)", () => {
|
||||
const res = evaluateRuleConditionals({ paths: "src/**" as any }, { paths: [] })
|
||||
expect(res.passed).to.equal(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractPathLikeStrings", () => {
|
||||
it("extracts basic relative paths", () => {
|
||||
const res = extractPathLikeStrings("edit apps/web/src/App.tsx and packages/foo/src")
|
||||
expect(res).to.deep.equal(["apps/web/src/App.tsx", "packages/foo/src"])
|
||||
})
|
||||
|
||||
it("extracts simple filenames with extensions (no slashes)", () => {
|
||||
const res = extractPathLikeStrings("Does foo.md exist? If not, create foo.md")
|
||||
expect(res).to.deep.equal(["foo.md"])
|
||||
})
|
||||
|
||||
it("does not extract bare words without an extension", () => {
|
||||
const res = extractPathLikeStrings("Please create foo and then update bar")
|
||||
expect(res).to.deep.equal([])
|
||||
})
|
||||
|
||||
it("ignores URLs", () => {
|
||||
const res = extractPathLikeStrings("see https://example.com/a/b and edit src/index.ts")
|
||||
expect(res).to.deep.equal(["src/index.ts"])
|
||||
})
|
||||
|
||||
it("ignores fenced code blocks", () => {
|
||||
const text =
|
||||
"Please update src/index.ts\n\n```ts\n// example code\nconst p = 'apps/web/src/App.tsx'\n// also: packages/foo/src\n```\n\nThanks!"
|
||||
const res = extractPathLikeStrings(text)
|
||||
expect(res).to.deep.equal(["src/index.ts"])
|
||||
})
|
||||
|
||||
it("does not extract URLs inside code fences", () => {
|
||||
const text = "```\nSee https://example.com/a/b and src/index.ts\n```\nBut edit docs/readme.md"
|
||||
const res = extractPathLikeStrings(text)
|
||||
expect(res).to.deep.equal(["docs/readme.md"])
|
||||
})
|
||||
|
||||
it("extracts paths from stack traces (outside code fences)", () => {
|
||||
const text = "Error: boom\n at foo (src/index.ts:12:3)\n at bar (apps/web/src/App.tsx:5:1)"
|
||||
const res = extractPathLikeStrings(text)
|
||||
expect(res).to.deep.equal(["src/index.ts", "apps/web/src/App.tsx"])
|
||||
})
|
||||
})
|
||||
})
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { describe, it } from "bun:test"
|
||||
import { expect } from "chai"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import { getRuleFilesTotalContentWithMetadata } from "../rule-helpers"
|
||||
|
||||
describe("rule loading with paths frontmatter", () => {
|
||||
it("filters rules by evaluationContext.paths", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
|
||||
try {
|
||||
const rulesDir = path.join(tmp, ".clinerules")
|
||||
await fs.mkdir(rulesDir, { recursive: true })
|
||||
await fs.writeFile(path.join(rulesDir, "universal.md"), "Always on")
|
||||
await fs.writeFile(path.join(rulesDir, "scoped.md"), `---\npaths:\n - "src/**"\n---\n\nOnly for src`)
|
||||
|
||||
const files = ["universal.md", "scoped.md"]
|
||||
const toggles: Record<string, boolean> = {
|
||||
[path.join(rulesDir, "universal.md")]: true,
|
||||
[path.join(rulesDir, "scoped.md")]: true,
|
||||
}
|
||||
|
||||
const res1 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
|
||||
evaluationContext: { paths: ["src/index.ts"] },
|
||||
})
|
||||
expect(res1.content).to.contain("universal.md")
|
||||
expect(res1.content).to.contain("scoped.md")
|
||||
expect(res1.content).to.not.contain("paths:")
|
||||
expect(res1.activatedConditionalRules.map((r) => r.name)).to.include("global:scoped.md")
|
||||
|
||||
const res2 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
|
||||
evaluationContext: { paths: ["docs/readme.md"] },
|
||||
})
|
||||
expect(res2.content).to.contain("universal.md")
|
||||
expect(res2.content).to.not.contain("scoped.md")
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("treats invalid YAML frontmatter as fail-open and preserves the raw frontmatter for the LLM", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
|
||||
try {
|
||||
const rulesDir = path.join(tmp, ".clinerules")
|
||||
await fs.mkdir(rulesDir, { recursive: true })
|
||||
// Intentionally invalid YAML (unquoted '*' is a YAML alias indicator)
|
||||
await fs.writeFile(
|
||||
path.join(rulesDir, "invalid.md"),
|
||||
`---\npaths: *\n---\n\nInvalid YAML, but should still be included`,
|
||||
)
|
||||
|
||||
const files = ["invalid.md"]
|
||||
const toggles: Record<string, boolean> = {
|
||||
[path.join(rulesDir, "invalid.md")]: true,
|
||||
}
|
||||
|
||||
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
|
||||
evaluationContext: { paths: ["src/index.ts"] },
|
||||
})
|
||||
|
||||
// Fail-open: included even though frontmatter cannot be parsed.
|
||||
expect(res.content).to.contain("invalid.md")
|
||||
// Preserve raw frontmatter fence/content for the LLM.
|
||||
expect(res.content).to.contain("---")
|
||||
expect(res.content).to.contain("paths:")
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("treats paths: [] as match-nothing (fail-closed)", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
|
||||
try {
|
||||
const rulesDir = path.join(tmp, ".clinerules")
|
||||
await fs.mkdir(rulesDir, { recursive: true })
|
||||
await fs.writeFile(path.join(rulesDir, "scoped-empty.md"), `---\npaths: []\n---\n\nShould never activate`)
|
||||
|
||||
const files = ["scoped-empty.md"]
|
||||
const toggles: Record<string, boolean> = {
|
||||
[path.join(rulesDir, "scoped-empty.md")]: true,
|
||||
}
|
||||
|
||||
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
|
||||
evaluationContext: { paths: ["src/index.ts"] },
|
||||
})
|
||||
|
||||
expect(res.content).to.not.contain("scoped-empty.md")
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it("keeps activatedConditionalRules order stable (matches input file order)", async () => {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
|
||||
try {
|
||||
const rulesDir = path.join(tmp, ".clinerules")
|
||||
await fs.mkdir(rulesDir, { recursive: true })
|
||||
await fs.writeFile(path.join(rulesDir, "a.md"), `---\npaths:\n - "src/**"\n---\n\nA`)
|
||||
await fs.writeFile(path.join(rulesDir, "b.md"), `---\npaths:\n - "src/**"\n---\n\nB`)
|
||||
await fs.writeFile(path.join(rulesDir, "c.md"), `---\npaths:\n - "src/**"\n---\n\nC`)
|
||||
|
||||
const files = ["a.md", "b.md", "c.md"]
|
||||
const toggles: Record<string, boolean> = {
|
||||
[path.join(rulesDir, "a.md")]: true,
|
||||
[path.join(rulesDir, "b.md")]: true,
|
||||
[path.join(rulesDir, "c.md")]: true,
|
||||
}
|
||||
|
||||
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
|
||||
evaluationContext: { paths: ["src/index.ts"] },
|
||||
})
|
||||
|
||||
expect(res.activatedConditionalRules.map((r) => r.name)).to.deep.equal(files.map((f) => `global:${f}`))
|
||||
} finally {
|
||||
await fs.rm(tmp, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,837 @@
|
||||
/**
|
||||
* Unit tests for skills utility functions
|
||||
* Tests skill discovery, override resolution, toggle filtering, and content loading
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, it, mock } from "bun:test"
|
||||
import { expect } from "chai"
|
||||
import * as actualFsPromises from "fs/promises"
|
||||
import * as path from "path"
|
||||
import * as sinon from "sinon"
|
||||
|
||||
import * as actualSkillDirectories from "@/core/storage/skill-directories"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import * as actualFsUtils from "@/utils/fs"
|
||||
|
||||
// bun loads real ESM, so sinon cannot stub the `@utils/fs`,
|
||||
// `@core/storage/skill-directories`, or the `fs/promises` namespace exports
|
||||
// ("ES Modules cannot be stubbed"). Crucially, under bun `fs.promises` and the
|
||||
// `fs/promises` module are NOT the same object, so stubbing `fs.promises.X`
|
||||
// (which worked under mocha/ts-node) does not affect the SUT's
|
||||
// `import * as fs from "fs/promises"` bindings. Inject module-level sinon stubs
|
||||
// via bun's mock.module so the full sinon stub API (.withArgs/.resolves/etc.)
|
||||
// keeps working through the exact specifiers the SUT imports.
|
||||
const fileExistsAtPathStub = sinon.stub()
|
||||
const isDirectoryStub_ = sinon.stub()
|
||||
const getSkillsDirectoriesForScanStub = sinon.stub()
|
||||
const readdirStub_ = sinon.stub()
|
||||
const statStub_ = sinon.stub()
|
||||
const readFileStub_ = sinon.stub()
|
||||
const writeFileStub_ = sinon.stub()
|
||||
const fsUtilsMock = () => ({
|
||||
...actualFsUtils,
|
||||
fileExistsAtPath: fileExistsAtPathStub,
|
||||
isDirectory: isDirectoryStub_,
|
||||
})
|
||||
const skillDirsMock = () => ({
|
||||
...actualSkillDirectories,
|
||||
getSkillsDirectoriesForScan: getSkillsDirectoriesForScanStub,
|
||||
})
|
||||
const fsPromisesMockNamespace = {
|
||||
...actualFsPromises,
|
||||
readdir: readdirStub_,
|
||||
stat: statStub_,
|
||||
readFile: readFileStub_,
|
||||
writeFile: writeFileStub_,
|
||||
}
|
||||
const fsPromisesMock = () => ({ ...fsPromisesMockNamespace, default: fsPromisesMockNamespace })
|
||||
// The SUT imports via the `@utils/*` and `@core/*` tsconfig path aliases;
|
||||
// register both the `@/`-prefixed and bare-alias forms to be safe.
|
||||
mock.module("@utils/fs", fsUtilsMock)
|
||||
mock.module("@/utils/fs", fsUtilsMock)
|
||||
mock.module("@core/storage/skill-directories", skillDirsMock)
|
||||
mock.module("@/core/storage/skill-directories", skillDirsMock)
|
||||
mock.module("fs/promises", fsPromisesMock)
|
||||
mock.module("node:fs/promises", fsPromisesMock)
|
||||
|
||||
import { parseYamlFrontmatter } from "../frontmatter"
|
||||
import {
|
||||
discoverSkills,
|
||||
getAvailableSkills,
|
||||
getSkillContent,
|
||||
parseRemoteSkillEntries,
|
||||
setSkillDisabledInFrontmatter,
|
||||
updateSkillMarkdownDisabledState,
|
||||
} from "../skills"
|
||||
|
||||
describe("Skills Utility Functions", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let fileExistsStub: sinon.SinonStub
|
||||
let isDirectoryStub: sinon.SinonStub
|
||||
let readdirStub: sinon.SinonStub
|
||||
let statStub: sinon.SinonStub
|
||||
let readFileStub: sinon.SinonStub
|
||||
|
||||
// Use path.join for OS-independent paths
|
||||
const TEST_CWD = path.join("/test", "project")
|
||||
const GLOBAL_SKILLS_DIR = path.join("/home", "user", ".cline", "skills")
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
|
||||
// Stub Logger.warn to avoid noise in test output
|
||||
sandbox.stub(Logger, "warn")
|
||||
|
||||
// Reset the module-level sinon stubs (injected via mock.module above) and
|
||||
// re-point the per-test handles at them.
|
||||
fileExistsAtPathStub.reset()
|
||||
isDirectoryStub_.reset()
|
||||
getSkillsDirectoriesForScanStub.reset()
|
||||
readdirStub_.reset()
|
||||
statStub_.reset()
|
||||
readFileStub_.reset()
|
||||
writeFileStub_.reset()
|
||||
fileExistsStub = fileExistsAtPathStub
|
||||
isDirectoryStub = isDirectoryStub_
|
||||
readdirStub = readdirStub_
|
||||
statStub = statStub_
|
||||
readFileStub = readFileStub_
|
||||
|
||||
getSkillsDirectoriesForScanStub.returns([
|
||||
{ path: path.join(TEST_CWD, ".clinerules", "skills"), source: "project" },
|
||||
{ path: path.join(TEST_CWD, ".cline", "skills"), source: "project" },
|
||||
{ path: path.join(TEST_CWD, ".claude", "skills"), source: "project" },
|
||||
{ path: path.join(TEST_CWD, ".agents", "skills"), source: "project" },
|
||||
{ path: GLOBAL_SKILLS_DIR, source: "global" },
|
||||
{ path: path.join("/home", "user", ".agents", "skills"), source: "global" },
|
||||
])
|
||||
|
||||
// Default: no directories exist
|
||||
fileExistsStub.resolves(false)
|
||||
isDirectoryStub.resolves(false)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore()
|
||||
})
|
||||
|
||||
describe("discoverSkills", () => {
|
||||
it("should discover skills from global directory", async () => {
|
||||
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
|
||||
name: my-skill
|
||||
description: A test skill
|
||||
---
|
||||
Instructions here`)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(1)
|
||||
expect(skills[0].name).to.equal("my-skill")
|
||||
expect(skills[0].description).to.equal("A test skill")
|
||||
expect(skills[0].source).to.equal("global")
|
||||
})
|
||||
|
||||
it("should discover skills from project .clinerules/skills directory", async () => {
|
||||
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
|
||||
const skillDir = path.join(projectSkillsDir, "explaining-code")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(projectSkillsDir).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(projectSkillsDir).resolves(true)
|
||||
readdirStub.withArgs(projectSkillsDir).resolves(["explaining-code"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
|
||||
name: explaining-code
|
||||
description: Explains code with diagrams and analogies
|
||||
---
|
||||
Use analogies and ASCII diagrams when explaining code.`)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(1)
|
||||
expect(skills[0].name).to.equal("explaining-code")
|
||||
expect(skills[0].source).to.equal("project")
|
||||
})
|
||||
|
||||
it("should discover skills from project .cline/skills directory", async () => {
|
||||
const clineSkillsDir = path.join(TEST_CWD, ".cline", "skills")
|
||||
const skillDir = path.join(clineSkillsDir, "debugging")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(clineSkillsDir).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(clineSkillsDir).resolves(true)
|
||||
readdirStub.withArgs(clineSkillsDir).resolves(["debugging"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
|
||||
name: debugging
|
||||
description: Debug code systematically
|
||||
---
|
||||
Use systematic debugging approaches.`)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(1)
|
||||
expect(skills[0].name).to.equal("debugging")
|
||||
expect(skills[0].source).to.equal("project")
|
||||
})
|
||||
|
||||
it("should discover skills from project .claude/skills directory", async () => {
|
||||
const claudeSkillsDir = path.join(TEST_CWD, ".claude", "skills")
|
||||
const skillDir = path.join(claudeSkillsDir, "coding")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(claudeSkillsDir).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(claudeSkillsDir).resolves(true)
|
||||
readdirStub.withArgs(claudeSkillsDir).resolves(["coding"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
|
||||
name: coding
|
||||
description: Write clean code
|
||||
---
|
||||
Follow best practices.`)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(1)
|
||||
expect(skills[0].name).to.equal("coding")
|
||||
expect(skills[0].source).to.equal("project")
|
||||
})
|
||||
|
||||
it("should discover skills from project .agents/skills directory", async () => {
|
||||
const agentsSkillsDir = path.join(TEST_CWD, ".agents", "skills")
|
||||
const skillDir = path.join(agentsSkillsDir, "testing")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(agentsSkillsDir).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(agentsSkillsDir).resolves(true)
|
||||
readdirStub.withArgs(agentsSkillsDir).resolves(["testing"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
|
||||
name: testing
|
||||
description: Write comprehensive tests
|
||||
---
|
||||
Always write tests.`)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(1)
|
||||
expect(skills[0].name).to.equal("testing")
|
||||
expect(skills[0].source).to.equal("project")
|
||||
})
|
||||
|
||||
it("should handle empty skills directories gracefully", async () => {
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves([])
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(0)
|
||||
})
|
||||
|
||||
it("should skip non-directory entries in skills folder", async () => {
|
||||
const readmePath = path.join(GLOBAL_SKILLS_DIR, "README.md")
|
||||
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["README.md", "my-skill"])
|
||||
statStub.withArgs(readmePath).resolves({ isDirectory: () => false })
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
|
||||
name: my-skill
|
||||
description: A skill
|
||||
---
|
||||
Content`)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(1)
|
||||
expect(skills[0].name).to.equal("my-skill")
|
||||
})
|
||||
|
||||
it("should skip skill directories without SKILL.md", async () => {
|
||||
const skillDir = path.join(GLOBAL_SKILLS_DIR, "incomplete-skill")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["incomplete-skill"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(false)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAvailableSkills - Override Resolution", () => {
|
||||
it("should override project skill with global skill of same name", async () => {
|
||||
const globalSkillDir = path.join(GLOBAL_SKILLS_DIR, "coding")
|
||||
const globalSkillMdPath = path.join(globalSkillDir, "SKILL.md")
|
||||
|
||||
// Setup global skill (higher priority)
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
fileExistsStub.withArgs(globalSkillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["coding"])
|
||||
statStub.withArgs(globalSkillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(globalSkillMdPath, "utf-8").resolves(`---
|
||||
name: coding
|
||||
description: Global coding skill
|
||||
---
|
||||
Global instructions`)
|
||||
|
||||
// Setup project skill with same name (lower priority)
|
||||
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
|
||||
const projectSkillDir = path.join(projectSkillsDir, "coding")
|
||||
const projectSkillMdPath = path.join(projectSkillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(projectSkillsDir).resolves(true)
|
||||
fileExistsStub.withArgs(projectSkillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(projectSkillsDir).resolves(true)
|
||||
readdirStub.withArgs(projectSkillsDir).resolves(["coding"])
|
||||
statStub.withArgs(projectSkillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(projectSkillMdPath, "utf-8").resolves(`---
|
||||
name: coding
|
||||
description: Project coding skill
|
||||
---
|
||||
Project instructions`)
|
||||
|
||||
const allSkills = await discoverSkills(TEST_CWD)
|
||||
const skills = getAvailableSkills(allSkills)
|
||||
|
||||
expect(skills).to.have.lengthOf(1)
|
||||
expect(skills[0].description).to.equal("Global coding skill")
|
||||
expect(skills[0].source).to.equal("global")
|
||||
})
|
||||
|
||||
it("should keep both skills when names are different", async () => {
|
||||
const globalSkillDir = path.join(GLOBAL_SKILLS_DIR, "global-skill")
|
||||
const globalSkillMdPath = path.join(globalSkillDir, "SKILL.md")
|
||||
|
||||
// Setup global skill
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
fileExistsStub.withArgs(globalSkillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["global-skill"])
|
||||
statStub.withArgs(globalSkillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(globalSkillMdPath, "utf-8").resolves(`---
|
||||
name: global-skill
|
||||
description: A global skill
|
||||
---
|
||||
Content`)
|
||||
|
||||
// Setup project skill with different name
|
||||
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
|
||||
const projectSkillDir = path.join(projectSkillsDir, "project-skill")
|
||||
const projectSkillMdPath = path.join(projectSkillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(projectSkillsDir).resolves(true)
|
||||
fileExistsStub.withArgs(projectSkillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(projectSkillsDir).resolves(true)
|
||||
readdirStub.withArgs(projectSkillsDir).resolves(["project-skill"])
|
||||
statStub.withArgs(projectSkillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(projectSkillMdPath, "utf-8").resolves(`---
|
||||
name: project-skill
|
||||
description: A project skill
|
||||
---
|
||||
Content`)
|
||||
|
||||
const allSkills = await discoverSkills(TEST_CWD)
|
||||
const skills = getAvailableSkills(allSkills)
|
||||
|
||||
expect(skills).to.have.lengthOf(2)
|
||||
const names = skills.map((s) => s.name)
|
||||
expect(names).to.include("global-skill")
|
||||
expect(names).to.include("project-skill")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Metadata Validation", () => {
|
||||
it("should reject skill with missing name field", async () => {
|
||||
const skillDir = path.join(GLOBAL_SKILLS_DIR, "bad-skill")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["bad-skill"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
|
||||
description: Missing name
|
||||
---
|
||||
Content`)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(0)
|
||||
sinon.assert.calledWithMatch(Logger.warn as sinon.SinonStub, /missing required 'name' field/)
|
||||
})
|
||||
|
||||
it("should reject skill with missing description field", async () => {
|
||||
const skillDir = path.join(GLOBAL_SKILLS_DIR, "bad-skill")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["bad-skill"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
|
||||
name: bad-skill
|
||||
---
|
||||
Content`)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(0)
|
||||
sinon.assert.calledWithMatch(Logger.warn as sinon.SinonStub, /missing required 'description' field/)
|
||||
})
|
||||
|
||||
it("should reject skill when name doesn't match directory name", async () => {
|
||||
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-dir")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-dir"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
|
||||
name: different-name
|
||||
description: Mismatched name
|
||||
---
|
||||
Content`)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(0)
|
||||
sinon.assert.calledWithMatch(Logger.warn as sinon.SinonStub, /doesn't match directory/)
|
||||
})
|
||||
|
||||
it("should handle malformed YAML frontmatter gracefully", async () => {
|
||||
const skillDir = path.join(GLOBAL_SKILLS_DIR, "bad-yaml")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["bad-yaml"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
|
||||
name: [invalid yaml
|
||||
description: broken
|
||||
---
|
||||
Content`)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(0)
|
||||
})
|
||||
|
||||
it("should handle file without frontmatter", async () => {
|
||||
const skillDir = path.join(GLOBAL_SKILLS_DIR, "no-front")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["no-front"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`Just plain markdown content without frontmatter`)
|
||||
|
||||
const skills = await discoverSkills(TEST_CWD)
|
||||
|
||||
expect(skills).to.have.lengthOf(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSkillContent", () => {
|
||||
it("should load full skill content with instructions", async () => {
|
||||
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
|
||||
name: my-skill
|
||||
description: Test skill
|
||||
---
|
||||
These are the detailed instructions.
|
||||
|
||||
## Step 1
|
||||
Do this first.
|
||||
|
||||
## Step 2
|
||||
Then do this.`)
|
||||
|
||||
const allSkills = await discoverSkills(TEST_CWD)
|
||||
const availableSkills = getAvailableSkills(allSkills)
|
||||
const content = await getSkillContent("my-skill", availableSkills)
|
||||
|
||||
expect(content).to.not.be.null
|
||||
expect(content!.name).to.equal("my-skill")
|
||||
expect(content!.instructions).to.include("These are the detailed instructions")
|
||||
expect(content!.instructions).to.include("Step 1")
|
||||
expect(content!.instructions).to.include("Step 2")
|
||||
})
|
||||
|
||||
it("should return null for non-existent skill", async () => {
|
||||
const content = await getSkillContent("non-existent", [])
|
||||
|
||||
expect(content).to.be.null
|
||||
})
|
||||
|
||||
it("should trim whitespace from instructions", async () => {
|
||||
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
fileExistsStub.withArgs(skillMdPath).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
|
||||
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
|
||||
name: my-skill
|
||||
description: Test
|
||||
---
|
||||
|
||||
Instructions with whitespace
|
||||
|
||||
`)
|
||||
|
||||
const allSkills = await discoverSkills(TEST_CWD)
|
||||
const availableSkills = getAvailableSkills(allSkills)
|
||||
const content = await getSkillContent("my-skill", availableSkills)
|
||||
|
||||
expect(content!.instructions).to.equal("Instructions with whitespace")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Remote Skills", () => {
|
||||
// entry.name must match frontmatter name (enforced by parseRemoteSkillEntries)
|
||||
const makeEntry = (name: string, desc: string, body = "Instructions", alwaysEnabled = false) => ({
|
||||
name,
|
||||
alwaysEnabled,
|
||||
contents: `---\nname: ${name}\ndescription: ${desc}\n---\n${body}`,
|
||||
})
|
||||
|
||||
describe("parseRemoteSkillEntries", () => {
|
||||
it("should return validated entries when entry.name matches frontmatter.name", () => {
|
||||
const entries = [
|
||||
{ name: "Deploy", alwaysEnabled: true, contents: `---\nname: Deploy\ndescription: CI/CD\n---\nBody` },
|
||||
]
|
||||
const result = parseRemoteSkillEntries(entries)
|
||||
expect(result).to.have.lengthOf(1)
|
||||
expect(result[0].name).to.equal("Deploy")
|
||||
expect(result[0].description).to.equal("CI/CD")
|
||||
expect(result[0].alwaysEnabled).to.equal(true)
|
||||
})
|
||||
|
||||
it("should warn but still include entries where entry.name drifts from frontmatter.name", () => {
|
||||
const entries = [
|
||||
{
|
||||
name: "entry-key",
|
||||
alwaysEnabled: false,
|
||||
contents: `---\nname: Different Name\ndescription: Desc\n---\nBody`,
|
||||
},
|
||||
]
|
||||
const result = parseRemoteSkillEntries(entries)
|
||||
expect(result).to.have.lengthOf(1)
|
||||
expect(result[0].name).to.equal("Different Name")
|
||||
sinon.assert.calledWithMatch(Logger.warn as sinon.SinonStub, /does not match frontmatter\.name/)
|
||||
})
|
||||
|
||||
it("should skip entries with missing frontmatter name", () => {
|
||||
const entries = [{ name: "bad", alwaysEnabled: false, contents: `---\ndescription: No name\n---\nContent` }]
|
||||
const result = parseRemoteSkillEntries(entries)
|
||||
expect(result).to.have.lengthOf(0)
|
||||
})
|
||||
|
||||
it("should skip entries with missing frontmatter description", () => {
|
||||
const entries = [{ name: "No Desc", alwaysEnabled: false, contents: `---\nname: No Desc\n---\nContent` }]
|
||||
const result = parseRemoteSkillEntries(entries)
|
||||
expect(result).to.have.lengthOf(0)
|
||||
})
|
||||
|
||||
it("should handle empty array", () => {
|
||||
expect(parseRemoteSkillEntries([])).to.have.lengthOf(0)
|
||||
})
|
||||
|
||||
it("should include all entries with valid frontmatter even with drift", () => {
|
||||
const entries = [
|
||||
{ name: "Good", alwaysEnabled: false, contents: `---\nname: Good\ndescription: Valid\n---\nBody` },
|
||||
{ name: "drift", alwaysEnabled: false, contents: `---\nname: Different\ndescription: Drifted\n---\nBody` },
|
||||
{
|
||||
name: "Also Good",
|
||||
alwaysEnabled: true,
|
||||
contents: `---\nname: Also Good\ndescription: Valid too\n---\nBody`,
|
||||
},
|
||||
]
|
||||
const result = parseRemoteSkillEntries(entries)
|
||||
expect(result).to.have.lengthOf(3)
|
||||
expect(result[0].name).to.equal("Good")
|
||||
expect(result[1].name).to.equal("Different")
|
||||
expect(result[2].name).to.equal("Also Good")
|
||||
})
|
||||
})
|
||||
|
||||
describe("discoverSkills - remote skill discovery", () => {
|
||||
it("should include remote skills from remote config", async () => {
|
||||
const entries = [makeEntry("Deploy Pipeline", "Handles CI/CD deployment", "Deploy instructions")]
|
||||
const skills = await discoverSkills(TEST_CWD, entries)
|
||||
|
||||
const remoteSkill = skills.find((s) => s.name === "Deploy Pipeline")
|
||||
expect(remoteSkill).to.not.be.undefined
|
||||
expect(remoteSkill!.path).to.equal("remote:Deploy Pipeline")
|
||||
expect(remoteSkill!.source).to.equal("global")
|
||||
expect(remoteSkill!.description).to.equal("Handles CI/CD deployment")
|
||||
})
|
||||
|
||||
it("should use frontmatter.name as identity even when entry.name drifts", async () => {
|
||||
const entries = [
|
||||
{ name: "entry-key", alwaysEnabled: false, contents: `---\nname: Actual Name\ndescription: Desc\n---\nBody` },
|
||||
]
|
||||
const skills = await discoverSkills(TEST_CWD, entries)
|
||||
const remoteSkill = skills.find((s) => s.path?.startsWith("remote:"))
|
||||
expect(remoteSkill).to.not.be.undefined
|
||||
expect(remoteSkill!.name).to.equal("Actual Name")
|
||||
expect(remoteSkill!.path).to.equal("remote:Actual Name")
|
||||
})
|
||||
|
||||
it("should skip remote skills with missing frontmatter name", async () => {
|
||||
const entries = [{ name: "bad", alwaysEnabled: false, contents: `---\ndescription: No name\n---\nContent` }]
|
||||
const skills = await discoverSkills(TEST_CWD, entries)
|
||||
expect(skills.find((s) => s.path?.startsWith("remote:"))).to.be.undefined
|
||||
})
|
||||
|
||||
it("should skip remote skills with missing frontmatter description", async () => {
|
||||
const entries = [{ name: "No Desc", alwaysEnabled: false, contents: `---\nname: No Desc\n---\nContent` }]
|
||||
const skills = await discoverSkills(TEST_CWD, entries)
|
||||
expect(skills.find((s) => s.path?.startsWith("remote:"))).to.be.undefined
|
||||
})
|
||||
|
||||
it("should handle empty and undefined entries gracefully", async () => {
|
||||
for (const val of [[], undefined]) {
|
||||
const skills = await discoverSkills(TEST_CWD, val)
|
||||
expect(skills.filter((s) => s.path?.startsWith("remote:"))).to.have.lengthOf(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("Override resolution (remote > disk-global > project)", () => {
|
||||
it("remote overrides disk-global skill of same name", async () => {
|
||||
const entries = [makeEntry("coding", "Remote coding")]
|
||||
const diskGlobalDir = path.join(GLOBAL_SKILLS_DIR, "coding")
|
||||
const diskGlobalMd = path.join(diskGlobalDir, "SKILL.md")
|
||||
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
fileExistsStub.withArgs(diskGlobalMd).resolves(true)
|
||||
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
|
||||
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["coding"])
|
||||
statStub.withArgs(diskGlobalDir).resolves({ isDirectory: () => true })
|
||||
readFileStub
|
||||
.withArgs(diskGlobalMd, "utf-8")
|
||||
.resolves(`---\nname: coding\ndescription: Disk global coding\n---\nDisk`)
|
||||
|
||||
const available = getAvailableSkills(await discoverSkills(TEST_CWD, entries))
|
||||
expect(available).to.have.lengthOf(1)
|
||||
expect(available[0].description).to.equal("Remote coding")
|
||||
expect(available[0].path).to.equal("remote:coding")
|
||||
})
|
||||
|
||||
it("remote overrides project skill of same name", async () => {
|
||||
const entries = [makeEntry("coding", "Remote coding")]
|
||||
const projDir = path.join(TEST_CWD, ".clinerules", "skills")
|
||||
const projSkillDir = path.join(projDir, "coding")
|
||||
const projMd = path.join(projSkillDir, "SKILL.md")
|
||||
fileExistsStub.withArgs(projDir).resolves(true)
|
||||
fileExistsStub.withArgs(projMd).resolves(true)
|
||||
isDirectoryStub.withArgs(projDir).resolves(true)
|
||||
readdirStub.withArgs(projDir).resolves(["coding"])
|
||||
statStub.withArgs(projSkillDir).resolves({ isDirectory: () => true })
|
||||
readFileStub.withArgs(projMd, "utf-8").resolves(`---\nname: coding\ndescription: Project coding\n---\nProject`)
|
||||
|
||||
const available = getAvailableSkills(await discoverSkills(TEST_CWD, entries))
|
||||
expect(available).to.have.lengthOf(1)
|
||||
expect(available[0].description).to.equal("Remote coding")
|
||||
expect(available[0].path).to.equal("remote:coding")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getSkillContent - remote skill content loading", () => {
|
||||
it("should load content from provided entries for remote skills", async () => {
|
||||
const entries = [makeEntry("Deploy Pipeline", "Deployment skill", "These are the deployment instructions.")]
|
||||
const skill = {
|
||||
name: "Deploy Pipeline",
|
||||
description: "Deployment skill",
|
||||
path: "remote:Deploy Pipeline",
|
||||
source: "global" as const,
|
||||
}
|
||||
const content = await getSkillContent("Deploy Pipeline", [skill], entries)
|
||||
|
||||
expect(content).to.not.be.null
|
||||
expect(content!.name).to.equal("Deploy Pipeline")
|
||||
expect(content!.instructions).to.equal("These are the deployment instructions.")
|
||||
})
|
||||
|
||||
it("should trim whitespace from remote skill instructions", async () => {
|
||||
const entries = [makeEntry("Trim Skill", "Test", "\n Instructions with whitespace \n\n")]
|
||||
const skill = { name: "Trim Skill", description: "Test", path: "remote:Trim Skill", source: "global" as const }
|
||||
const content = await getSkillContent("Trim Skill", [skill], entries)
|
||||
expect(content!.instructions).to.equal("Instructions with whitespace")
|
||||
})
|
||||
|
||||
it("should return null if remote skill entry not found in entries", async () => {
|
||||
const skill = { name: "Gone", description: "Removed", path: "remote:Gone", source: "global" as const }
|
||||
const content = await getSkillContent("Gone", [skill], [])
|
||||
expect(content).to.be.null
|
||||
})
|
||||
|
||||
it("should not attempt disk read for remote skills", async () => {
|
||||
const entries = [makeEntry("Remote Only", "Test", "Remote content")]
|
||||
const skill = { name: "Remote Only", description: "Test", path: "remote:Remote Only", source: "global" as const }
|
||||
await getSkillContent("Remote Only", [skill], entries)
|
||||
sinon.assert.notCalled(readFileStub)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("updateSkillMarkdownDisabledState", () => {
|
||||
it("adds disabled: true when disabling a skill with existing frontmatter", () => {
|
||||
const input = ["---", "name: my-skill", "description: A skill", "---", "Body here"].join("\n")
|
||||
const output = updateSkillMarkdownDisabledState(input, false)
|
||||
expect(output).to.contain("disabled: true")
|
||||
expect(output).to.contain("name: my-skill")
|
||||
expect(output).to.contain("Body here")
|
||||
})
|
||||
|
||||
it("removes disabled flag when enabling a previously-disabled skill", () => {
|
||||
const input = ["---", "name: my-skill", "description: A skill", "disabled: true", "---", "Body"].join("\n")
|
||||
const output = updateSkillMarkdownDisabledState(input, true)
|
||||
expect(output).to.not.contain("disabled")
|
||||
expect(output).to.contain("name: my-skill")
|
||||
expect(output).to.contain("Body")
|
||||
})
|
||||
|
||||
it("also clears a stale enabled: false when enabling", () => {
|
||||
const input = ["---", "name: my-skill", "enabled: false", "---", "Body"].join("\n")
|
||||
const output = updateSkillMarkdownDisabledState(input, true)
|
||||
expect(output).to.not.contain("enabled: false")
|
||||
})
|
||||
|
||||
it("drops the frontmatter block entirely when enabling leaves it empty", () => {
|
||||
const input = ["---", "disabled: true", "---", "Body only"].join("\n")
|
||||
const output = updateSkillMarkdownDisabledState(input, true)
|
||||
expect(output).to.equal("Body only")
|
||||
})
|
||||
|
||||
it("returns content unchanged when enabling a doc with no frontmatter", () => {
|
||||
const input = "Just body, no frontmatter"
|
||||
expect(updateSkillMarkdownDisabledState(input, true)).to.equal(input)
|
||||
})
|
||||
|
||||
it("is idempotent: disabling an already-disabled skill keeps disabled: true once", () => {
|
||||
const input = ["---", "name: s", "disabled: true", "---", "B"].join("\n")
|
||||
const output = updateSkillMarkdownDisabledState(input, false)
|
||||
expect(output.match(/disabled: true/g)).to.have.lengthOf(1)
|
||||
})
|
||||
|
||||
// Frontmatter block whose YAML is genuinely invalid (asserted below). The
|
||||
// `---` markers are well-formed so parseYamlFrontmatter detects frontmatter
|
||||
// and then fails to parse it, exercising the parseError branch.
|
||||
const MALFORMED_FRONTMATTER = ["---", "name: s", "description: : : bad", " - nope", "---", "Body"].join("\n")
|
||||
|
||||
it("uses a fixture whose frontmatter YAML is actually invalid", () => {
|
||||
// Guards the two tests below from rotting into false positives: if this
|
||||
// fixture ever became valid YAML, updateSkillMarkdownDisabledState would
|
||||
// take a different (rewriting) path and the "untouched" assertions could
|
||||
// pass for the wrong reason.
|
||||
const parsed = parseYamlFrontmatter(MALFORMED_FRONTMATTER)
|
||||
expect(parsed.hadFrontmatter).to.be.true
|
||||
expect(parsed.parseError, "fixture frontmatter should be invalid YAML").to.be.a("string")
|
||||
})
|
||||
|
||||
it("leaves malformed-frontmatter files untouched when disabling (no double header)", () => {
|
||||
// parseYamlFrontmatter fails open and returns the full original document
|
||||
// as the body. Disabling must not prepend a second `---` block and corrupt
|
||||
// the file.
|
||||
const output = updateSkillMarkdownDisabledState(MALFORMED_FRONTMATTER, false)
|
||||
expect(output).to.equal(MALFORMED_FRONTMATTER)
|
||||
// Exactly one frontmatter opener/closer pair, not two.
|
||||
expect(output.match(/^---$/gm)).to.have.lengthOf(2)
|
||||
})
|
||||
|
||||
it("leaves malformed-frontmatter files untouched when enabling", () => {
|
||||
expect(updateSkillMarkdownDisabledState(MALFORMED_FRONTMATTER, true)).to.equal(MALFORMED_FRONTMATTER)
|
||||
})
|
||||
})
|
||||
|
||||
describe("setSkillDisabledInFrontmatter", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let readFileStub: sinon.SinonStub
|
||||
let writeFileStub: sinon.SinonStub
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox = sinon.createSandbox()
|
||||
sandbox.stub(Logger, "warn")
|
||||
// Use the module-level fs/promises stubs (mock.module above) since under
|
||||
// bun fs.promises !== the `fs/promises` module the SUT imports.
|
||||
readFileStub_.reset()
|
||||
writeFileStub_.reset()
|
||||
readFileStub = readFileStub_
|
||||
writeFileStub = writeFileStub_
|
||||
writeFileStub.resolves()
|
||||
})
|
||||
|
||||
afterEach(() => sandbox.restore())
|
||||
|
||||
it("writes disabled: true to the SKILL.md when disabling a disk skill", async () => {
|
||||
const skillPath = path.join("/home", "user", ".cline", "skills", "s", "SKILL.md")
|
||||
readFileStub.withArgs(skillPath, "utf-8").resolves(["---", "name: s", "description: d", "---", "Body"].join("\n"))
|
||||
|
||||
const ok = await setSkillDisabledInFrontmatter(skillPath, false)
|
||||
|
||||
expect(ok).to.be.true
|
||||
sinon.assert.calledOnce(writeFileStub)
|
||||
const written = writeFileStub.getCall(0).args[1] as string
|
||||
expect(written).to.contain("disabled: true")
|
||||
})
|
||||
|
||||
it("does not write for remote skills (no backing file)", async () => {
|
||||
const ok = await setSkillDisabledInFrontmatter("remote:Some Skill", false)
|
||||
expect(ok).to.be.false
|
||||
sinon.assert.notCalled(readFileStub)
|
||||
sinon.assert.notCalled(writeFileStub)
|
||||
})
|
||||
|
||||
it("skips the write when content is unchanged", async () => {
|
||||
const skillPath = path.join("/home", "user", ".cline", "skills", "s", "SKILL.md")
|
||||
// Already disabled; disabling again yields identical content.
|
||||
readFileStub.withArgs(skillPath, "utf-8").resolves(["---", "name: s", "disabled: true", "---", "B"].join("\n"))
|
||||
|
||||
const ok = await setSkillDisabledInFrontmatter(skillPath, false)
|
||||
|
||||
expect(ok).to.be.true
|
||||
sinon.assert.notCalled(writeFileStub)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
|
||||
import { ensureRulesDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import path from "path"
|
||||
import { Controller } from "@/core/controller"
|
||||
|
||||
export async function refreshClineRulesToggles(
|
||||
controller: Controller,
|
||||
workingDirectory: string,
|
||||
): Promise<{
|
||||
globalToggles: ClineRulesToggles
|
||||
localToggles: ClineRulesToggles
|
||||
}> {
|
||||
// Global toggles
|
||||
const globalClineRulesToggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
|
||||
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
|
||||
const updatedGlobalToggles = await synchronizeRuleToggles(globalClineRulesFilePath, globalClineRulesToggles)
|
||||
controller.stateManager.setGlobalState("globalClineRulesToggles", updatedGlobalToggles)
|
||||
|
||||
// Local toggles
|
||||
const localClineRulesToggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
|
||||
const localClineRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.clineRules)
|
||||
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles, "", [
|
||||
[".clinerules", "workflows"],
|
||||
[".clinerules", "hooks"],
|
||||
[".clinerules", "skills"],
|
||||
])
|
||||
controller.stateManager.setWorkspaceState("localClineRulesToggles", updatedLocalToggles)
|
||||
|
||||
return {
|
||||
globalToggles: updatedGlobalToggles,
|
||||
localToggles: updatedLocalToggles,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { combineRuleToggles, synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
|
||||
import { GlobalFileNames } from "@core/storage/disk"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import path from "path"
|
||||
import { Controller } from "@/core/controller"
|
||||
|
||||
/**
|
||||
* Refreshes the toggles for windsurf, cursor, and agents rules
|
||||
*/
|
||||
export async function refreshExternalRulesToggles(
|
||||
controller: Controller,
|
||||
workingDirectory: string,
|
||||
): Promise<{
|
||||
windsurfLocalToggles: ClineRulesToggles
|
||||
cursorLocalToggles: ClineRulesToggles
|
||||
agentsLocalToggles: ClineRulesToggles
|
||||
}> {
|
||||
// local windsurf toggles
|
||||
const localWindsurfRulesToggles = controller.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
|
||||
const localWindsurfRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.windsurfRules)
|
||||
const updatedLocalWindsurfToggles = await synchronizeRuleToggles(localWindsurfRulesFilePath, localWindsurfRulesToggles)
|
||||
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", updatedLocalWindsurfToggles)
|
||||
|
||||
// local cursor toggles
|
||||
const localCursorRulesToggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
|
||||
|
||||
// cursor has two valid locations for rules files, so we need to check both and combine
|
||||
// synchronizeRuleToggles will drop whichever rules files are not in each given path, but combining the results will result in no data loss
|
||||
let localCursorRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.cursorRulesDir)
|
||||
const updatedLocalCursorToggles1 = await synchronizeRuleToggles(localCursorRulesFilePath, localCursorRulesToggles, ".mdc")
|
||||
|
||||
localCursorRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.cursorRulesFile)
|
||||
const updatedLocalCursorToggles2 = await synchronizeRuleToggles(localCursorRulesFilePath, localCursorRulesToggles)
|
||||
|
||||
const updatedLocalCursorToggles = combineRuleToggles(updatedLocalCursorToggles1, updatedLocalCursorToggles2)
|
||||
controller.stateManager.setWorkspaceState("localCursorRulesToggles", updatedLocalCursorToggles)
|
||||
|
||||
// local agents toggles
|
||||
const localAgentsRulesToggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
|
||||
const localAgentsRulesFilePath = path.resolve(workingDirectory, GlobalFileNames.agentsRulesFile)
|
||||
const updatedLocalAgentsToggles = await synchronizeRuleToggles(localAgentsRulesFilePath, localAgentsRulesToggles)
|
||||
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", updatedLocalAgentsToggles)
|
||||
|
||||
return {
|
||||
windsurfLocalToggles: updatedLocalWindsurfToggles,
|
||||
cursorLocalToggles: updatedLocalCursorToggles,
|
||||
agentsLocalToggles: updatedLocalAgentsToggles,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import * as yaml from "js-yaml"
|
||||
|
||||
export type FrontmatterParseResult = {
|
||||
data: Record<string, unknown>
|
||||
/**
|
||||
* The markdown content after stripping the `--- frontmatter ---` block.
|
||||
*
|
||||
* Named `body` (rather than `content`) to make it clear this is the remaining
|
||||
* document body and to keep this helper generic for multiple consumers.
|
||||
*/
|
||||
body: string
|
||||
|
||||
/**
|
||||
* True when the input contained a frontmatter block, even if parsing failed.
|
||||
*
|
||||
* This allows callers to distinguish:
|
||||
* - "no frontmatter provided" (baseline behavior), vs
|
||||
* - "frontmatter was provided" (may have semantic meaning in future consumers).
|
||||
*/
|
||||
hadFrontmatter: boolean
|
||||
/**
|
||||
* Present only when YAML frontmatter was detected but failed to parse.
|
||||
*
|
||||
* This helper is intentionally fail-open and does not log. Returning `parseError`
|
||||
* lets each caller decide whether to log, surface diagnostics, etc.
|
||||
*/
|
||||
parseError?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from markdown content.
|
||||
*
|
||||
* Behavior is intentionally fail-open:
|
||||
* - If YAML fails to parse, returns data={} and body=original markdown.
|
||||
* - If no frontmatter exists, returns data={} and body=original markdown.
|
||||
*/
|
||||
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
|
||||
const match = markdown.match(frontmatterRegex)
|
||||
|
||||
if (!match) {
|
||||
return { data: {}, body: markdown, hadFrontmatter: false }
|
||||
}
|
||||
|
||||
const [, yamlContent, body] = match
|
||||
try {
|
||||
const data = (yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA }) as Record<string, unknown>) || {}
|
||||
return { data, body, hadFrontmatter: true }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Rule frontmatter conditional evaluation.
|
||||
*
|
||||
* This module implements a small conditional "DSL" for Cline Rules YAML frontmatter.
|
||||
* It is used to decide whether a rule should be activated for a given request context.
|
||||
*
|
||||
* Notes:
|
||||
* - Unknown conditional keys are ignored for forward compatibility.
|
||||
* - The `paths` conditional matches if any candidate path matches any glob pattern.
|
||||
* - Candidate paths are expected to be workspace-root-relative POSIX paths.
|
||||
*/
|
||||
import picomatch from "picomatch"
|
||||
|
||||
export type RuleEvaluationContext = {
|
||||
/**
|
||||
* Candidate workspace-relative paths that represent the current request context.
|
||||
* These should be POSIX-style paths, relative to their workspace root.
|
||||
*/
|
||||
paths?: string[]
|
||||
}
|
||||
|
||||
type MatchedConditions = Record<string, string[]>
|
||||
|
||||
type ConditionalEvaluatorResult = {
|
||||
passed: boolean
|
||||
matched?: string[]
|
||||
}
|
||||
|
||||
type ConditionalEvaluatorWithMatch = (frontmatterValue: unknown, context: RuleEvaluationContext) => ConditionalEvaluatorResult
|
||||
|
||||
function toPosix(p: string): string {
|
||||
return p.replace(/\\/g, "/")
|
||||
}
|
||||
|
||||
function isNonEmptyStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((v) => typeof v === "string" && v.length > 0)
|
||||
}
|
||||
|
||||
const evaluatePathsConditional: ConditionalEvaluatorWithMatch = (frontmatterValue: unknown, context: RuleEvaluationContext) => {
|
||||
// Invalid type -> ignore conditional (fail-open)
|
||||
if (!isNonEmptyStringArray(frontmatterValue)) {
|
||||
return { passed: true }
|
||||
}
|
||||
|
||||
const patterns = frontmatterValue.map((p) => p.trim()).filter(Boolean)
|
||||
|
||||
// Policy:
|
||||
// - `paths` omitted => universal (because this evaluator is never invoked)
|
||||
// - `paths: []` (or `paths` that trims to no usable patterns) => match nothing (fail-closed)
|
||||
// This gives users an explicit way to disable a rule via frontmatter, while omission
|
||||
// remains the mechanism for "always on" rules.
|
||||
if (patterns.length === 0) {
|
||||
return { passed: false }
|
||||
}
|
||||
|
||||
const candidatePaths = (context.paths || []).map((p) => toPosix(p)).filter(Boolean)
|
||||
// Conservative: no evidence => do not activate path-scoped rules
|
||||
if (candidatePaths.length === 0) {
|
||||
return { passed: false }
|
||||
}
|
||||
|
||||
const matchedPatterns: string[] = []
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const matcher = picomatch(pattern, { dot: true })
|
||||
if (candidatePaths.some((candidate) => matcher(candidate))) {
|
||||
matchedPatterns.push(pattern)
|
||||
}
|
||||
}
|
||||
|
||||
return { passed: matchedPatterns.length > 0, matched: matchedPatterns.length > 0 ? matchedPatterns : undefined }
|
||||
}
|
||||
|
||||
const conditionalEvaluators: Record<string, ConditionalEvaluatorWithMatch> = {
|
||||
paths: evaluatePathsConditional,
|
||||
}
|
||||
|
||||
export function evaluateRuleConditionals(
|
||||
frontmatter: Record<string, unknown>,
|
||||
context: RuleEvaluationContext,
|
||||
): {
|
||||
passed: boolean
|
||||
matchedConditions: MatchedConditions
|
||||
} {
|
||||
const matchedConditions: MatchedConditions = {}
|
||||
|
||||
for (const [key, value] of Object.entries(frontmatter)) {
|
||||
const evaluator = conditionalEvaluators[key]
|
||||
if (!evaluator) {
|
||||
continue // unknown conditional: ignore
|
||||
}
|
||||
|
||||
const result = evaluator(value, context)
|
||||
if (!result.passed) {
|
||||
return { passed: false, matchedConditions: {} }
|
||||
}
|
||||
if (result.matched && result.matched.length > 0) {
|
||||
matchedConditions[key] = result.matched
|
||||
}
|
||||
}
|
||||
|
||||
return { passed: true, matchedConditions }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts path-like strings from user text to help enable first-turn activation.
|
||||
* This is intentionally heuristic and conservative.
|
||||
*/
|
||||
export function extractPathLikeStrings(text: string): string[] {
|
||||
if (!text) return []
|
||||
|
||||
// 0) Strip fenced code blocks to avoid extracting paths from pasted code.
|
||||
// This dramatically reduces false positives from snippets containing `foo/bar` or `a.b.c`.
|
||||
// Note: We intentionally keep this simple and fail-open (if fences are unbalanced, we do nothing special).
|
||||
const withoutCodeFences = text.replace(/```[\s\S]*?```/g, " ")
|
||||
|
||||
// 1) Remove URLs to avoid false positives.
|
||||
const withoutUrls = withoutCodeFences.replace(/\b\w+:\/\/[^\s]+/g, " ")
|
||||
|
||||
// 2) Match tokens that look like paths.
|
||||
// - Either contain at least one slash (e.g. src/index.ts)
|
||||
// - Or look like a simple filename with an extension (e.g. foo.md)
|
||||
// (no slashes; conservative to reduce false positives).
|
||||
const tokenRegex =
|
||||
/(?:^|[\s([{"'`])((?:[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\/?|[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,10}))(?=$|[\s)\]}"'`,.;:!?])/g
|
||||
const matches: string[] = []
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = tokenRegex.exec(withoutUrls))) {
|
||||
const candidate = match[1]
|
||||
if (!candidate) continue
|
||||
// Normalize away leading ./
|
||||
const normalized = candidate.startsWith("./") ? candidate.slice(2) : candidate
|
||||
// Avoid absurdly long tokens
|
||||
if (normalized.length > 300) continue
|
||||
matches.push(normalized)
|
||||
}
|
||||
|
||||
// De-dupe while preserving order
|
||||
const seen = new Set<string>()
|
||||
const result: string[] = []
|
||||
for (const m of matches) {
|
||||
const posix = m.replace(/\\/g, "/")
|
||||
if (posix === "/" || posix.startsWith("/") || posix.includes("..")) {
|
||||
// We only want repo/workspace-relative hints here.
|
||||
continue
|
||||
}
|
||||
if (!seen.has(posix)) {
|
||||
seen.add(posix)
|
||||
result.push(posix)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import { GlobalInstructionsFile } from "@shared/remote-config/schema"
|
||||
import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
|
||||
import fs from "fs/promises"
|
||||
import * as path from "path"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { parseYamlFrontmatter } from "./frontmatter"
|
||||
import { evaluateRuleConditionals, RuleEvaluationContext } from "./rule-conditionals"
|
||||
|
||||
/**
|
||||
* Recursively traverses directory and finds all files, including checking for optional whitelisted file extension
|
||||
*/
|
||||
async function readDirectoryRecursive(
|
||||
directoryPath: string,
|
||||
allowedFileExtension: string,
|
||||
excludedPaths: string[][] = [],
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readDirectory(directoryPath, excludedPaths)
|
||||
const results: string[] = []
|
||||
for (const entry of entries) {
|
||||
if (allowedFileExtension !== "") {
|
||||
const fileExtension = path.extname(entry)
|
||||
if (fileExtension !== allowedFileExtension) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
results.push(entry)
|
||||
}
|
||||
return results
|
||||
} catch (error) {
|
||||
Logger.error(`Error reading directory ${directoryPath}: ${error}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the up to date toggles
|
||||
*/
|
||||
export async function synchronizeRuleToggles(
|
||||
rulesDirectoryPath: string,
|
||||
currentToggles: ClineRulesToggles,
|
||||
allowedFileExtension = "",
|
||||
excludedPaths: string[][] = [],
|
||||
): Promise<ClineRulesToggles> {
|
||||
// Create a copy of toggles to modify
|
||||
const updatedToggles = { ...currentToggles }
|
||||
|
||||
try {
|
||||
const pathExists = await fileExistsAtPath(rulesDirectoryPath)
|
||||
|
||||
if (pathExists) {
|
||||
const isDir = await isDirectory(rulesDirectoryPath)
|
||||
|
||||
if (isDir) {
|
||||
// DIRECTORY CASE
|
||||
const filePaths = await readDirectoryRecursive(rulesDirectoryPath, allowedFileExtension, excludedPaths)
|
||||
const existingRulePaths = new Set<string>()
|
||||
|
||||
for (const filePath of filePaths) {
|
||||
const ruleFilePath = path.resolve(rulesDirectoryPath, filePath)
|
||||
existingRulePaths.add(ruleFilePath)
|
||||
|
||||
const pathHasToggle = ruleFilePath in updatedToggles
|
||||
if (!pathHasToggle) {
|
||||
updatedToggles[ruleFilePath] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up toggles for non-existent files
|
||||
for (const togglePath in updatedToggles) {
|
||||
const pathExists = existingRulePaths.has(togglePath)
|
||||
if (!pathExists) {
|
||||
delete updatedToggles[togglePath]
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// FILE CASE
|
||||
// Add toggle for this file
|
||||
const pathHasToggle = rulesDirectoryPath in updatedToggles
|
||||
if (!pathHasToggle) {
|
||||
updatedToggles[rulesDirectoryPath] = true
|
||||
}
|
||||
|
||||
// Remove toggles for any other paths
|
||||
for (const togglePath in updatedToggles) {
|
||||
if (togglePath !== rulesDirectoryPath) {
|
||||
delete updatedToggles[togglePath]
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// PATH DOESN'T EXIST CASE
|
||||
// Clear all toggles since the path doesn't exist
|
||||
for (const togglePath in updatedToggles) {
|
||||
delete updatedToggles[togglePath]
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to synchronize rule toggles for path: ${rulesDirectoryPath}`, error)
|
||||
}
|
||||
|
||||
return updatedToggles
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes remote rule toggles with current remote config
|
||||
* Removes toggles for rules that no longer exist, adds defaults for new rules
|
||||
*/
|
||||
export function synchronizeRemoteRuleToggles(
|
||||
remoteRules: GlobalInstructionsFile[],
|
||||
currentToggles: ClineRulesToggles,
|
||||
): ClineRulesToggles {
|
||||
const updatedToggles: ClineRulesToggles = {}
|
||||
|
||||
// Create set of current remote rule names
|
||||
const existingRuleNames = new Set(remoteRules.map((rule) => rule.name))
|
||||
|
||||
// Keep toggles only for rules that still exist
|
||||
for (const [ruleName, enabled] of Object.entries(currentToggles)) {
|
||||
if (existingRuleNames.has(ruleName)) {
|
||||
updatedToggles[ruleName] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
// Add default toggles for new rules (default to enabled)
|
||||
for (const rule of remoteRules) {
|
||||
if (!(rule.name in updatedToggles)) {
|
||||
updatedToggles[rule.name] = true
|
||||
}
|
||||
}
|
||||
|
||||
return updatedToggles
|
||||
}
|
||||
|
||||
/**
|
||||
* Certain project rules have more than a single location where rules are allowed to be stored
|
||||
*/
|
||||
export function combineRuleToggles(toggles1: ClineRulesToggles, toggles2: ClineRulesToggles): ClineRulesToggles {
|
||||
return { ...toggles1, ...toggles2 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the content of rules files
|
||||
*/
|
||||
const getRuleFilesTotalContent = async (rulesFilePaths: string[], basePath: string, toggles: ClineRulesToggles) => {
|
||||
return (await getRuleFilesTotalContentWithMetadata(rulesFilePaths, basePath, toggles)).content
|
||||
}
|
||||
|
||||
const LOCAL_RULE_PATHS = {
|
||||
clineRules: ".clinerules",
|
||||
workflows: ".clinerules/workflows",
|
||||
} as const
|
||||
|
||||
type ActivatedConditionalRule = {
|
||||
name: string
|
||||
matchedConditions: Record<string, string[]>
|
||||
}
|
||||
|
||||
type RuleFileController = {
|
||||
stateManager: {
|
||||
getGlobalSettingsKey(key: "globalWorkflowToggles" | "globalClineRulesToggles"): ClineRulesToggles
|
||||
setGlobalState(key: "globalWorkflowToggles" | "globalClineRulesToggles", value: ClineRulesToggles): void
|
||||
getWorkspaceStateKey(
|
||||
key:
|
||||
| "workflowToggles"
|
||||
| "localCursorRulesToggles"
|
||||
| "localWindsurfRulesToggles"
|
||||
| "localAgentsRulesToggles"
|
||||
| "localClineRulesToggles",
|
||||
): ClineRulesToggles
|
||||
setWorkspaceState(
|
||||
key:
|
||||
| "workflowToggles"
|
||||
| "localCursorRulesToggles"
|
||||
| "localWindsurfRulesToggles"
|
||||
| "localAgentsRulesToggles"
|
||||
| "localClineRulesToggles",
|
||||
value: ClineRulesToggles,
|
||||
): void
|
||||
}
|
||||
}
|
||||
|
||||
// Prefixes used to make activated conditional rule identifiers self-explanatory in the UI.
|
||||
// NOTE: These are display identifiers (not toggle keys).
|
||||
export const RULE_SOURCE_PREFIX = {
|
||||
workspace: "workspace",
|
||||
global: "global",
|
||||
remote: "remote",
|
||||
} as const
|
||||
|
||||
export type RuleLoadResult = {
|
||||
content: string
|
||||
activatedConditionalRules: ActivatedConditionalRule[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Result type for rule loading functions that return formatted instructions.
|
||||
* Used by getGlobalClineRules and getLocalClineRules.
|
||||
*/
|
||||
type RuleLoadResultWithInstructions = {
|
||||
instructions?: string
|
||||
activatedConditionalRules: ActivatedConditionalRule[]
|
||||
}
|
||||
|
||||
export const getRuleFilesTotalContentWithMetadata = async (
|
||||
rulesFilePaths: string[],
|
||||
basePath: string,
|
||||
toggles: ClineRulesToggles,
|
||||
opts?: { evaluationContext?: RuleEvaluationContext; ruleNamePrefix?: keyof typeof RULE_SOURCE_PREFIX },
|
||||
): Promise<RuleLoadResult> => {
|
||||
const evaluationContext = opts?.evaluationContext ?? {}
|
||||
const prefix = RULE_SOURCE_PREFIX[opts?.ruleNamePrefix ?? "global"]
|
||||
|
||||
const parts = await Promise.all(
|
||||
rulesFilePaths.map(async (filePath) => {
|
||||
const ruleFilePath = path.resolve(basePath, filePath)
|
||||
const ruleFilePathRelative = path.relative(basePath, ruleFilePath)
|
||||
|
||||
if (ruleFilePath in toggles && toggles[ruleFilePath] === false) {
|
||||
return { contentPart: null, activatedRule: null }
|
||||
}
|
||||
|
||||
const raw = (await fs.readFile(ruleFilePath, "utf8")).trim()
|
||||
if (!raw) {
|
||||
return { contentPart: null, activatedRule: null }
|
||||
}
|
||||
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(raw)
|
||||
// YAML parse errors are treated as fail-open.
|
||||
// NOTE: We intentionally preserve the raw frontmatter fence/content here so the LLM can still
|
||||
// see the author's intended scoping (e.g., `paths:`) and reason about it, even if it cannot be
|
||||
// evaluated reliably due to invalid YAML.
|
||||
if (hadFrontmatter && parseError) {
|
||||
return { contentPart: `${ruleFilePathRelative}\n${raw}`, activatedRule: null }
|
||||
}
|
||||
|
||||
const { passed, matchedConditions } = evaluateRuleConditionals(data, evaluationContext)
|
||||
if (!passed) {
|
||||
return { contentPart: null, activatedRule: null }
|
||||
}
|
||||
const activatedRule =
|
||||
hadFrontmatter && Object.keys(matchedConditions).length > 0
|
||||
? { name: `${prefix}:${ruleFilePathRelative}`, matchedConditions }
|
||||
: null
|
||||
|
||||
return { contentPart: `${ruleFilePathRelative}\n${body.trim()}`, activatedRule }
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
content: parts
|
||||
.map((p) => p.contentPart)
|
||||
.filter(Boolean)
|
||||
.join("\n\n"),
|
||||
activatedConditionalRules: parts
|
||||
.map((p) => p.activatedRule)
|
||||
.filter((rule): rule is ActivatedConditionalRule => rule !== null),
|
||||
}
|
||||
}
|
||||
|
||||
function getRemoteRulesTotalContentWithMetadata(
|
||||
remoteRules: GlobalInstructionsFile[],
|
||||
remoteToggles: ClineRulesToggles,
|
||||
opts?: { evaluationContext?: RuleEvaluationContext },
|
||||
): RuleLoadResult {
|
||||
const activatedConditionalRules: ActivatedConditionalRule[] = []
|
||||
const evaluationContext = opts?.evaluationContext ?? {}
|
||||
let combinedContent = ""
|
||||
|
||||
for (const rule of remoteRules) {
|
||||
const isEnabled = rule.alwaysEnabled || remoteToggles[rule.name] !== false
|
||||
if (!isEnabled) continue
|
||||
|
||||
const raw = (rule.contents || "").trim()
|
||||
if (!raw) continue
|
||||
|
||||
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(raw)
|
||||
if (hadFrontmatter && parseError) {
|
||||
// Fail open: include entire raw contents
|
||||
if (combinedContent) combinedContent += "\n\n"
|
||||
combinedContent += `${rule.name}\n${raw}`
|
||||
continue
|
||||
}
|
||||
|
||||
const { passed, matchedConditions } = evaluateRuleConditionals(data, evaluationContext)
|
||||
if (!passed) continue
|
||||
|
||||
if (hadFrontmatter && Object.keys(matchedConditions).length > 0) {
|
||||
activatedConditionalRules.push({ name: `${RULE_SOURCE_PREFIX.remote}:${rule.name}`, matchedConditions })
|
||||
}
|
||||
|
||||
if (combinedContent) combinedContent += "\n\n"
|
||||
combinedContent += `${rule.name}\n${body.trim()}`
|
||||
}
|
||||
|
||||
return { content: combinedContent, activatedConditionalRules }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles converting any directory into a file (specifically used for .clinerules and .clinerules/workflows)
|
||||
* The old .clinerules file or .clinerules/workflows file will be renamed to a default filename
|
||||
* Doesn't do anything if the dir already exists or doesn't exist
|
||||
* Returns whether there are any uncaught errors
|
||||
*/
|
||||
async function ensureLocalClineDirExists(clinerulePath: string, defaultRuleFilename: string): Promise<boolean> {
|
||||
try {
|
||||
const exists = await fileExistsAtPath(clinerulePath)
|
||||
|
||||
if (exists && !(await isDirectory(clinerulePath))) {
|
||||
// logic to convert .clinerules file into directory, and rename the rules file to {defaultRuleFilename}
|
||||
const content = await fs.readFile(clinerulePath, "utf8")
|
||||
const tempPath = clinerulePath + ".bak"
|
||||
await fs.rename(clinerulePath, tempPath) // create backup
|
||||
try {
|
||||
await fs.mkdir(clinerulePath, { recursive: true })
|
||||
await fs.writeFile(path.join(clinerulePath, defaultRuleFilename), content, "utf8")
|
||||
await fs.unlink(tempPath).catch(() => {}) // delete backup
|
||||
|
||||
return false // conversion successful with no errors
|
||||
} catch (_conversionError) {
|
||||
// attempt to restore backup on conversion failure
|
||||
try {
|
||||
await fs.rm(clinerulePath, { recursive: true, force: true }).catch(() => {})
|
||||
await fs.rename(tempPath, clinerulePath) // restore backup
|
||||
} catch (_restoreError) {}
|
||||
return true // in either case here we consider this an error
|
||||
}
|
||||
}
|
||||
// exists and is a dir or doesn't exist, either of these cases we dont need to handle here
|
||||
return false
|
||||
} catch (_error) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a rule file or workflow file
|
||||
*/
|
||||
export const createRuleFile = async (isGlobal: boolean, filename: string, cwd: string, type: string) => {
|
||||
try {
|
||||
let filePath: string
|
||||
if (isGlobal) {
|
||||
const disk = require("@core/storage/disk") as {
|
||||
ensureWorkflowsDirectoryExists: () => Promise<string>
|
||||
ensureRulesDirectoryExists: () => Promise<string>
|
||||
}
|
||||
if (type === "workflow") {
|
||||
const globalClineWorkflowFilePath = await disk.ensureWorkflowsDirectoryExists()
|
||||
filePath = path.join(globalClineWorkflowFilePath, filename)
|
||||
} else {
|
||||
const globalClineRulesFilePath = await disk.ensureRulesDirectoryExists()
|
||||
filePath = path.join(globalClineRulesFilePath, filename)
|
||||
}
|
||||
} else {
|
||||
const localClineRulesFilePath = path.resolve(cwd, LOCAL_RULE_PATHS.clineRules)
|
||||
|
||||
const hasError = await ensureLocalClineDirExists(localClineRulesFilePath, "default-rules.md")
|
||||
if (hasError === true) {
|
||||
return { filePath: null, fileExists: false }
|
||||
}
|
||||
|
||||
await fs.mkdir(localClineRulesFilePath, { recursive: true })
|
||||
|
||||
if (type === "workflow") {
|
||||
const localWorkflowsFilePath = path.resolve(cwd, LOCAL_RULE_PATHS.workflows)
|
||||
|
||||
const hasError = await ensureLocalClineDirExists(localWorkflowsFilePath, "default-workflows.md")
|
||||
if (hasError === true) {
|
||||
return { filePath: null, fileExists: false }
|
||||
}
|
||||
|
||||
await fs.mkdir(localWorkflowsFilePath, { recursive: true })
|
||||
|
||||
filePath = path.join(localWorkflowsFilePath, filename)
|
||||
} else {
|
||||
// clinerules file creation
|
||||
filePath = path.join(localClineRulesFilePath, filename)
|
||||
}
|
||||
}
|
||||
|
||||
const fileExists = await fileExistsAtPath(filePath)
|
||||
|
||||
if (fileExists) {
|
||||
return { filePath, fileExists }
|
||||
}
|
||||
|
||||
await fs.writeFile(filePath, "", "utf8")
|
||||
|
||||
return { filePath, fileExists: false }
|
||||
} catch (_error) {
|
||||
return { filePath: null, fileExists: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a rule file or workflow file
|
||||
*/
|
||||
export async function deleteRuleFile(
|
||||
controller: RuleFileController,
|
||||
rulePath: string,
|
||||
isGlobal: boolean,
|
||||
type: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
// Check if file exists
|
||||
const fileExists = await fileExistsAtPath(rulePath)
|
||||
if (!fileExists) {
|
||||
return {
|
||||
success: false,
|
||||
message: `File does not exist: ${rulePath}`,
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the file from disk
|
||||
await fs.rm(rulePath, { force: true })
|
||||
|
||||
// Get the filename for messages
|
||||
const fileName = path.basename(rulePath)
|
||||
|
||||
// Update the appropriate toggles
|
||||
if (isGlobal) {
|
||||
if (type === "workflow") {
|
||||
const toggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
|
||||
delete toggles[rulePath]
|
||||
controller.stateManager.setGlobalState("globalWorkflowToggles", toggles)
|
||||
} else {
|
||||
const toggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
|
||||
delete toggles[rulePath]
|
||||
controller.stateManager.setGlobalState("globalClineRulesToggles", toggles)
|
||||
}
|
||||
} else {
|
||||
if (type === "workflow") {
|
||||
const toggles = controller.stateManager.getWorkspaceStateKey("workflowToggles")
|
||||
delete toggles[rulePath]
|
||||
controller.stateManager.setWorkspaceState("workflowToggles", toggles)
|
||||
} else if (type === "cursor") {
|
||||
const toggles = controller.stateManager.getWorkspaceStateKey("localCursorRulesToggles")
|
||||
delete toggles[rulePath]
|
||||
controller.stateManager.setWorkspaceState("localCursorRulesToggles", toggles)
|
||||
} else if (type === "windsurf") {
|
||||
const toggles = controller.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
|
||||
delete toggles[rulePath]
|
||||
controller.stateManager.setWorkspaceState("localWindsurfRulesToggles", toggles)
|
||||
} else if (type === "agents") {
|
||||
const toggles = controller.stateManager.getWorkspaceStateKey("localAgentsRulesToggles")
|
||||
delete toggles[rulePath]
|
||||
controller.stateManager.setWorkspaceState("localAgentsRulesToggles", toggles)
|
||||
} else {
|
||||
const toggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
|
||||
delete toggles[rulePath]
|
||||
controller.stateManager.setWorkspaceState("localClineRulesToggles", toggles)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `File "${fileName}" deleted successfully`,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
Logger.error(`Error deleting file: ${errorMessage}`, error)
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to delete file.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import { getSkillsDirectoriesForScan } from "@core/storage/skill-directories"
|
||||
import type { GlobalInstructionsFile } from "@shared/remote-config/schema"
|
||||
import type { SkillContent, SkillMetadata } from "@shared/skills"
|
||||
import { fileExistsAtPath, isDirectory } from "@utils/fs"
|
||||
import * as fs from "fs/promises"
|
||||
import * as yaml from "js-yaml"
|
||||
import * as path from "path"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { parseYamlFrontmatter } from "./frontmatter"
|
||||
|
||||
/**
|
||||
* Update the `disabled` frontmatter flag of a SKILL.md document.
|
||||
*
|
||||
* The SDK (which builds the model's skill list and the `skills` tool) reads a
|
||||
* skill's enabled state from the SKILL.md frontmatter `disabled` field, not from
|
||||
* the extension's UI toggle state. Toggling a skill in the VS Code sidebar must
|
||||
* therefore also write this flag so the change is reflected for the model
|
||||
* (ENG-1995). This mirrors the SDK's updateSkillMarkdownEnabledState but lives in
|
||||
* the extension and uses js-yaml (the extension's frontmatter parser).
|
||||
*
|
||||
* - enabled=false → sets `disabled: true`.
|
||||
* - enabled=true → removes `disabled` (and a stale `enabled: false`), dropping
|
||||
* the frontmatter block entirely if it becomes empty.
|
||||
*
|
||||
* Returns the original content unchanged when enabling a document that has no
|
||||
* frontmatter (nothing to clear).
|
||||
*/
|
||||
export function updateSkillMarkdownDisabledState(content: string, enabled: boolean): string {
|
||||
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(content)
|
||||
|
||||
if (!hadFrontmatter && enabled) {
|
||||
return content
|
||||
}
|
||||
|
||||
// parseYamlFrontmatter fails open on malformed YAML: it returns data={} and
|
||||
// body=<full original content> (frontmatter block included). Serializing here
|
||||
// would prepend a second `---` block and corrupt the file, so leave it
|
||||
// untouched and let the user fix the frontmatter.
|
||||
if (parseError) {
|
||||
return content
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
delete data.disabled
|
||||
if (data.enabled === false) {
|
||||
delete data.enabled
|
||||
}
|
||||
if (Object.keys(data).length === 0) {
|
||||
return body
|
||||
}
|
||||
return serializeSkillFrontmatter(data, body)
|
||||
}
|
||||
|
||||
data.disabled = true
|
||||
return serializeSkillFrontmatter(data, body)
|
||||
}
|
||||
|
||||
function serializeSkillFrontmatter(data: Record<string, unknown>, body: string): string {
|
||||
const yamlText = yaml.dump(data, { schema: yaml.JSON_SCHEMA }).trimEnd()
|
||||
return `---\n${yamlText}\n---\n${body}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a skill's enabled/disabled state to its SKILL.md frontmatter on disk.
|
||||
* No-op (returns false) for remote skills, which have no backing file.
|
||||
*/
|
||||
export async function setSkillDisabledInFrontmatter(skillMdPath: string, enabled: boolean): Promise<boolean> {
|
||||
if (!skillMdPath || skillMdPath.startsWith("remote:")) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const content = await fs.readFile(skillMdPath, "utf-8")
|
||||
const updated = updateSkillMarkdownDisabledState(content, enabled)
|
||||
if (updated !== content) {
|
||||
await fs.writeFile(skillMdPath, updated)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
Logger.warn(`Failed to update skill frontmatter at ${skillMdPath}:`, error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A remote skill entry after frontmatter validation.
|
||||
* name is always frontmatter.name (canonical). A warning is logged if entry.name drifts.
|
||||
*/
|
||||
export interface ValidatedRemoteSkill {
|
||||
name: string
|
||||
description: string
|
||||
alwaysEnabled: boolean
|
||||
contents: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate remote skill entries from GlobalInstructionsFile[].
|
||||
*
|
||||
* Validates:
|
||||
* - frontmatter.name and frontmatter.description are present strings
|
||||
* - Warns if entry.name does not match frontmatter.name (drift)
|
||||
*
|
||||
* Returns only valid entries. Callers share this single validation point
|
||||
* instead of duplicating frontmatter parsing.
|
||||
*/
|
||||
export function parseRemoteSkillEntries(entries: GlobalInstructionsFile[]): ValidatedRemoteSkill[] {
|
||||
return entries
|
||||
.map((entry) => {
|
||||
const { data: frontmatter } = parseYamlFrontmatter(entry.contents)
|
||||
if (!frontmatter.name || typeof frontmatter.name !== "string") return null
|
||||
if (!frontmatter.description || typeof frontmatter.description !== "string") return null
|
||||
// Warn on drift but use frontmatter.name as the canonical identity.
|
||||
// The dashboard should keep entry.name in sync, but we don't reject on mismatch
|
||||
// since that would silently hide org-configured skills from users.
|
||||
if (entry.name !== frontmatter.name) {
|
||||
Logger.warn(`Remote skill entry.name "${entry.name}" does not match frontmatter.name "${frontmatter.name}"`)
|
||||
}
|
||||
return {
|
||||
name: frontmatter.name,
|
||||
description: frontmatter.description as string,
|
||||
alwaysEnabled: entry.alwaysEnabled,
|
||||
contents: entry.contents,
|
||||
}
|
||||
})
|
||||
.filter((e): e is NonNullable<typeof e> => e !== null)
|
||||
}
|
||||
|
||||
/** Parse YAML frontmatter from markdown content (shared helper). */
|
||||
function parseFrontmatter(fileContent: string): { data: Record<string, unknown>; content: string } {
|
||||
const result = parseYamlFrontmatter(fileContent)
|
||||
if (result.parseError) {
|
||||
Logger.warn("Failed to parse YAML frontmatter:", result.parseError)
|
||||
}
|
||||
return { data: result.data, content: result.body }
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan a directory for skill subdirectories containing SKILL.md files.
|
||||
*/
|
||||
async function scanSkillsDirectory(dirPath: string, source: "global" | "project"): Promise<SkillMetadata[]> {
|
||||
const skills: SkillMetadata[] = []
|
||||
|
||||
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 skill = await loadSkillMetadata(entryPath, source, entryName)
|
||||
if (skill) {
|
||||
skills.push(skill)
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "EACCES") {
|
||||
Logger.warn(`Permission denied reading skills directory: ${dirPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skill metadata from a skill directory.
|
||||
*/
|
||||
async function loadSkillMetadata(
|
||||
skillDir: string,
|
||||
source: "global" | "project",
|
||||
skillName: string,
|
||||
): Promise<SkillMetadata | null> {
|
||||
const skillMdPath = path.join(skillDir, "SKILL.md")
|
||||
if (!(await fileExistsAtPath(skillMdPath))) return null
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(skillMdPath, "utf-8")
|
||||
const { data: frontmatter } = parseFrontmatter(fileContent)
|
||||
|
||||
// Validate required fields
|
||||
if (!frontmatter.name || typeof frontmatter.name !== "string") {
|
||||
Logger.warn(`Skill at ${skillDir} missing required 'name' field`)
|
||||
return null
|
||||
}
|
||||
if (!frontmatter.description || typeof frontmatter.description !== "string") {
|
||||
Logger.warn(`Skill at ${skillDir} missing required 'description' field`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Name must match directory name per spec
|
||||
if (frontmatter.name !== skillName) {
|
||||
Logger.warn(`Skill name "${frontmatter.name}" doesn't match directory "${skillName}"`)
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
name: skillName,
|
||||
description: frontmatter.description,
|
||||
path: skillMdPath,
|
||||
source,
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.warn(`Failed to load skill at ${skillDir}:`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all skills from global (~/.cline/skills), remote config, and project directories.
|
||||
*
|
||||
* Precedence (highest wins on name collision via getAvailableSkills):
|
||||
* remote (enterprise) > disk-global (user personal) > project (workspace)
|
||||
*
|
||||
* This is achieved by the array order + getAvailableSkills iterating in reverse (last wins):
|
||||
* [project..., disk-global..., remote...]
|
||||
*/
|
||||
export async function discoverSkills(cwd: string, remoteSkillEntries?: GlobalInstructionsFile[]): Promise<SkillMetadata[]> {
|
||||
const skills: SkillMetadata[] = []
|
||||
|
||||
const scanDirs = getSkillsDirectoriesForScan(cwd)
|
||||
|
||||
// Collect project and disk-global skills separately so we can insert remote between them
|
||||
const projectSkills: SkillMetadata[] = []
|
||||
const diskGlobalSkills: SkillMetadata[] = []
|
||||
|
||||
for (const dir of scanDirs) {
|
||||
const dirSkills = await scanSkillsDirectory(dir.path, dir.source)
|
||||
if (dir.source === "project") {
|
||||
projectSkills.push(...dirSkills)
|
||||
} else {
|
||||
diskGlobalSkills.push(...dirSkills)
|
||||
}
|
||||
}
|
||||
|
||||
// Remote skills: validated via parseRemoteSkillEntries and keyed by frontmatter.name.
|
||||
const remoteSkills: SkillMetadata[] = parseRemoteSkillEntries(remoteSkillEntries || []).map((entry) => ({
|
||||
name: entry.name,
|
||||
description: entry.description,
|
||||
path: `remote:${entry.name}`,
|
||||
source: "global" as const,
|
||||
}))
|
||||
|
||||
// Insert in order: project → disk-global → remote
|
||||
// getAvailableSkills iterates backwards so remote (last) wins, then disk-global, then project
|
||||
skills.push(...projectSkills, ...diskGlobalSkills, ...remoteSkills)
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available skills with override resolution (global > project).
|
||||
*/
|
||||
export function getAvailableSkills(skills: SkillMetadata[]): SkillMetadata[] {
|
||||
const seen = new Set<string>()
|
||||
const result: SkillMetadata[] = []
|
||||
|
||||
// Iterate backwards: global skills (added last) are seen first and take precedence
|
||||
for (let i = skills.length - 1; i >= 0; i--) {
|
||||
const skill = skills[i]
|
||||
if (!seen.has(skill.name)) {
|
||||
seen.add(skill.name)
|
||||
result.unshift(skill)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get full skill content including instructions.
|
||||
* For remote skills, pass remoteSkillEntries so content can be loaded without disk I/O.
|
||||
*/
|
||||
export async function getSkillContent(
|
||||
skillName: string,
|
||||
availableSkills: SkillMetadata[],
|
||||
remoteSkillEntries?: GlobalInstructionsFile[],
|
||||
): Promise<SkillContent | null> {
|
||||
const skill = availableSkills.find((s) => s.name === skillName)
|
||||
if (!skill) return null
|
||||
|
||||
// Remote skills have no file on disk — retrieve content from the provided entries.
|
||||
// Try entry.name first (fast path when dashboard is in sync), fall back to frontmatter match.
|
||||
if (skill.path.startsWith("remote:")) {
|
||||
let entry = (remoteSkillEntries || []).find((e) => e.name === skillName)
|
||||
if (!entry) {
|
||||
entry = (remoteSkillEntries || []).find((e) => {
|
||||
const { data } = parseYamlFrontmatter(e.contents)
|
||||
return typeof data.name === "string" && data.name === skillName
|
||||
})
|
||||
}
|
||||
if (!entry) return null
|
||||
const { body } = parseYamlFrontmatter(entry.contents)
|
||||
return {
|
||||
...skill,
|
||||
instructions: body.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(skill.path, "utf-8")
|
||||
const { content: body } = parseFrontmatter(fileContent)
|
||||
|
||||
return {
|
||||
...skill,
|
||||
instructions: body.trim(),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
|
||||
import { ensureWorkflowsDirectoryExists, GlobalFileNames } from "@core/storage/disk"
|
||||
import { ClineRulesToggles } from "@shared/cline-rules"
|
||||
import path from "path"
|
||||
import { Controller } from "@/core/controller"
|
||||
|
||||
/**
|
||||
* Refresh the workflow toggles
|
||||
*/
|
||||
export async function refreshWorkflowToggles(
|
||||
controller: Controller,
|
||||
workingDirectory: string,
|
||||
): Promise<{
|
||||
globalWorkflowToggles: ClineRulesToggles
|
||||
localWorkflowToggles: ClineRulesToggles
|
||||
}> {
|
||||
// Global workflows
|
||||
const globalWorkflowToggles = controller.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
|
||||
const globalClineWorkflowsFilePath = await ensureWorkflowsDirectoryExists()
|
||||
const updatedGlobalWorkflowToggles = await synchronizeRuleToggles(globalClineWorkflowsFilePath, globalWorkflowToggles)
|
||||
controller.stateManager.setGlobalState("globalWorkflowToggles", updatedGlobalWorkflowToggles)
|
||||
|
||||
const workflowRulesToggles = controller.stateManager.getWorkspaceStateKey("workflowToggles")
|
||||
const workflowsDirPath = path.resolve(workingDirectory, GlobalFileNames.workflows)
|
||||
const updatedWorkflowToggles = await synchronizeRuleToggles(workflowsDirPath, workflowRulesToggles)
|
||||
controller.stateManager.setWorkspaceState("workflowToggles", updatedWorkflowToggles)
|
||||
|
||||
return {
|
||||
globalWorkflowToggles: updatedGlobalWorkflowToggles,
|
||||
localWorkflowToggles: updatedWorkflowToggles,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { EmptyRequest, String } from "@shared/proto/cline/common"
|
||||
import { AuthService } from "@/sdk/auth-service"
|
||||
import { Controller } from "../index"
|
||||
|
||||
/**
|
||||
* Starts Cline account sign-in. In e2e/local this performs a browserless mock token exchange; in
|
||||
* prod it runs the SDK OAuth flow. Returns a URL/status string for the webview.
|
||||
* Handles the user clicking the login link in the UI.
|
||||
* Uses the SDK-backed AuthService to initiate the Cline OAuth flow.
|
||||
* The SDK spawns a local callback server and opens the browser.
|
||||
*
|
||||
* @param controller The controller instance.
|
||||
* @returns The login URL as a string.
|
||||
*/
|
||||
export async function accountLoginClicked(controller: Controller, _: EmptyRequest): Promise<String> {
|
||||
return controller.authService.createAuthRequest()
|
||||
export async function accountLoginClicked(_controller: Controller, _: EmptyRequest): Promise<String> {
|
||||
return await AuthService.getInstance().createAuthRequest()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,14 @@ import type { EmptyRequest } from "@shared/proto/cline/common"
|
||||
import { Empty } from "@shared/proto/cline/common"
|
||||
import type { Controller } from "../index"
|
||||
|
||||
/**
|
||||
* Handles the account logout action.
|
||||
* Delegates to the SdkController which uses the SDK-backed AuthService.
|
||||
* @param controller The controller instance
|
||||
* @param _request The empty request object
|
||||
* @returns Empty response
|
||||
*/
|
||||
export async function accountLogoutClicked(controller: Controller, _request: EmptyRequest): Promise<Empty> {
|
||||
await controller.authService.signOut()
|
||||
await controller.handleSignOut()
|
||||
return Empty.create({})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { AuthState, AuthStateChangedRequest } from "@shared/proto/cline/account"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { Controller } from "../index"
|
||||
|
||||
export async function authStateChanged(controller: Controller, _request: AuthStateChangedRequest): Promise<AuthState> {
|
||||
return controller.authService.getInfo()
|
||||
/**
|
||||
* Handles authentication state changes from the Firebase context.
|
||||
* Updates the user info in global state and returns the updated value.
|
||||
* @param controller The controller instance
|
||||
* @param request The auth state change request
|
||||
* @returns The updated user info
|
||||
*/
|
||||
export async function authStateChanged(controller: Controller, request: AuthStateChangedRequest): Promise<AuthState> {
|
||||
try {
|
||||
// Store the user info directly in global state
|
||||
controller.stateManager.setGlobalState("userInfo", request.user)
|
||||
|
||||
// Return the same user info
|
||||
return AuthState.create({ user: request.user })
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to update auth state: ${error}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,57 @@
|
||||
import { GetOrganizationCreditsRequest, OrganizationCreditsData } from "@shared/proto/cline/account"
|
||||
import { GetOrganizationCreditsRequest, OrganizationCreditsData, OrganizationUsageTransaction } from "@shared/proto/cline/account"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { Controller } from "../index"
|
||||
|
||||
/**
|
||||
* Handles fetching all organization credits data (balance, usage, payments)
|
||||
* @param controller The controller instance
|
||||
* @param request Organization credits request
|
||||
* @returns Organization credits data response
|
||||
*/
|
||||
export async function getOrganizationCredits(
|
||||
_controller: Controller,
|
||||
_request: GetOrganizationCreditsRequest,
|
||||
controller: Controller,
|
||||
request: GetOrganizationCreditsRequest,
|
||||
): Promise<OrganizationCreditsData> {
|
||||
return OrganizationCreditsData.create({})
|
||||
try {
|
||||
if (!controller.accountService) {
|
||||
throw new Error("Account service not available")
|
||||
}
|
||||
|
||||
// Call the individual RPC variants in parallel
|
||||
const [balanceData, usageTransactions] = await Promise.all([
|
||||
controller.accountService.fetchOrganizationCreditsRPC(request.organizationId),
|
||||
controller.accountService.fetchOrganizationUsageTransactionsRPC(request.organizationId),
|
||||
])
|
||||
|
||||
// If balance call fails (returns undefined), throw an error
|
||||
if (!balanceData) {
|
||||
throw new Error("Failed to fetch organization credits data")
|
||||
}
|
||||
|
||||
return OrganizationCreditsData.create({
|
||||
balance: balanceData ? { currentBalance: balanceData.balance / 100 } : { currentBalance: 0 },
|
||||
organizationId: balanceData?.organizationId || "",
|
||||
usageTransactions:
|
||||
usageTransactions?.map((tx: any) =>
|
||||
OrganizationUsageTransaction.create({
|
||||
aiInferenceProviderName: tx.aiInferenceProviderName,
|
||||
aiModelName: tx.aiModelName,
|
||||
aiModelTypeName: tx.aiModelTypeName,
|
||||
completionTokens: tx.completionTokens,
|
||||
costUsd: tx.costUsd,
|
||||
createdAt: tx.createdAt,
|
||||
creditsUsed: tx.creditsUsed,
|
||||
generationId: tx.generationId,
|
||||
organizationId: tx.organizationId,
|
||||
promptTokens: tx.promptTokens,
|
||||
totalTokens: tx.totalTokens,
|
||||
userId: tx.userId,
|
||||
operation: tx.operation,
|
||||
}),
|
||||
) || [],
|
||||
})
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to fetch organization credits data: ${error}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user