mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
88 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2f2be67c79 | |||
| 8a6441fddd | |||
| 7ef5b1e7af | |||
| 3e58e7b024 | |||
| d16667f0b8 | |||
| a740c49524 | |||
| 3bc1ee8382 | |||
| ecf354c753 | |||
| a66c5ee973 | |||
| 46659eebeb | |||
| 899ee0bfea | |||
| db4598e310 | |||
| 627e539266 | |||
| 87ff61915f | |||
| a305556452 | |||
| ca12e97288 | |||
| 0c89716849 | |||
| 1c401dbe1a | |||
| ed78404e4b | |||
| 0157ed9efb | |||
| 5574c95ff2 | |||
| 31ee8eb744 | |||
| 7952e230ae | |||
| 6bcfa34ba9 | |||
| cf18793434 | |||
| a7ba25d6e1 | |||
| 2af2028861 | |||
| 932c8e68e1 | |||
| 3cd13352eb | |||
| 2c842328a8 | |||
| d3b3ff1c33 | |||
| 2508e76af8 | |||
| a702bf9b38 | |||
| a8cea1dca3 | |||
| 2a351ffdd5 | |||
| 69f148bad9 | |||
| 6ca92794e1 | |||
| 27bd4c6c65 | |||
| 015b61924f | |||
| d238239a0f | |||
| e81c35d7c0 | |||
| 1ac5525fee | |||
| a4d0937030 | |||
| 6d5c61f044 | |||
| 8a6d031e8f | |||
| 3f808b369e | |||
| e3b3e2306e | |||
| b601b6e623 | |||
| ed008bd36e | |||
| 6e964c3ef0 | |||
| 2e8579ebec | |||
| cb0515d5f4 | |||
| 8c00eb9a77 | |||
| 1a4122eb53 | |||
| e6fa037c4c | |||
| 6e0ae8db58 | |||
| 0b1fe6a982 | |||
| c18eef5d4c | |||
| 65443a01ae | |||
| 7d56dd1e69 | |||
| 501f512066 | |||
| 5810a5d1e7 | |||
| 6a262b395a | |||
| 5117b7b0b8 | |||
| 30ce8b1e72 | |||
| 2fcac58e24 | |||
| 112dbaa15b | |||
| 996a2f6a9c | |||
| 47f6d00f61 | |||
| 9ba2c932b4 | |||
| 01aaa5dd04 | |||
| 38bac25721 | |||
| b4d1b83bad | |||
| 8e6780201c | |||
| 087c6449c3 | |||
| 5cd72e5892 | |||
| 2b2fa8a473 | |||
| acca9186f1 | |||
| f3bddc4ec1 | |||
| 5c9cd557c7 | |||
| 0383be5375 | |||
| 4ee1ce507e | |||
| 22b3d654db | |||
| 52493c5ba1 | |||
| e65e5d5900 | |||
| b144baf102 | |||
| f70725dd57 | |||
| 3821f7e52e |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: use correct base URL for Vertex AI global endpoint with Claude models
|
||||
@@ -176,7 +176,7 @@ Present a final summary:
|
||||
- Slack message copied to clipboard: yes
|
||||
|
||||
Remind the user to:
|
||||
1. Manually trigger the publish release GitHub Action at: https://github.com/cline/cline/actions/workflows/publish.yml (paste `v{VERSION}` as the tag)
|
||||
1. Manually trigger the publish release GitHub Action at: https://github.com/cline/cline/actions/workflows/ext-vscode-publish-stable.yml (paste `v{VERSION}` as the tag)
|
||||
2. Post the Slack message to announce the hotfix
|
||||
|
||||
## Important Notes
|
||||
|
||||
@@ -43,7 +43,7 @@ git push origin v<version>
|
||||
### 4) Trigger publish workflow
|
||||
|
||||
Tell the maintainer to run:
|
||||
https://github.com/cline/cline/actions/workflows/publish.yml
|
||||
https://github.com/cline/cline/actions/workflows/ext-vscode-publish-stable.yml
|
||||
|
||||
Use `v<version>` as the release tag.
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
demo.gif filter=lfs diff=lfs merge=lfs -text
|
||||
assets/docs/demo.gif filter=lfs diff=lfs merge=lfs -text
|
||||
webview-ui/src/assets/cline_kanban_demo.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
webview-ui/src/assets/cline_kanban_demo.webm filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
* text=auto eol=lf
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Publish CLI to NPM
|
||||
name: cli-publish
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -88,9 +88,9 @@ jobs:
|
||||
|
||||
- name: Validate release tag
|
||||
id: version
|
||||
env:
|
||||
TAG: ${{ github.event.inputs.git_tag }}
|
||||
run: |
|
||||
TAG="${{ github.event.inputs.git_tag }}"
|
||||
|
||||
if [ -z "$TAG" ]; then
|
||||
echo "git_tag is required when publish_target=main"
|
||||
exit 1
|
||||
@@ -132,6 +132,15 @@ jobs:
|
||||
|
||||
- name: Build SDK packages
|
||||
run: bun run build:sdk
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
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 }}
|
||||
|
||||
- name: Run tests
|
||||
run: bun run test
|
||||
@@ -139,10 +148,20 @@ jobs:
|
||||
- name: Build platform binaries
|
||||
run: bun script/build.ts --install-native-variants --skip-sdk-build
|
||||
working-directory: sdk/apps/cli
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
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 }}
|
||||
|
||||
- name: Verify build output
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
EXPECTED=(
|
||||
"@cline/cli-darwin-arm64"
|
||||
"@cline/cli-darwin-x64"
|
||||
@@ -179,8 +198,9 @@ jobs:
|
||||
|
||||
- name: Get Previous CLI Tag
|
||||
id: prev_tag
|
||||
env:
|
||||
CURRENT_TAG: ${{ steps.version.outputs.tag }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.version.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 --match 'cli-v*' "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -200,13 +220,15 @@ jobs:
|
||||
name: "CLI v${{ steps.version.outputs.version }}"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
${{ steps.prev_tag.outputs.prev_tag != '' && format('Full Changelog: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.tag) || '' }}
|
||||
|
||||
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.tag) || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "Published cline@${VERSION} to npm with dist-tag 'latest'"
|
||||
echo "Install with: npm install -g cline"
|
||||
|
||||
@@ -309,6 +331,15 @@ jobs:
|
||||
- name: Build SDK packages
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun run build:sdk
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
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 }}
|
||||
|
||||
- name: Run tests
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
@@ -329,8 +360,9 @@ jobs:
|
||||
|
||||
- name: Update nightly package version
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const path = "apps/cli/package.json";
|
||||
@@ -339,18 +371,26 @@ jobs:
|
||||
fs.writeFileSync(path, `${JSON.stringify(pkg, null, "\t")}\n`);
|
||||
'
|
||||
cat apps/cli/package.json | grep '"version"'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
|
||||
- name: Build platform binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun script/build.ts --install-native-variants --skip-sdk-build
|
||||
working-directory: sdk/apps/cli
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
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 }}
|
||||
|
||||
- name: Verify build output
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
EXPECTED=(
|
||||
"@cline/cli-darwin-arm64"
|
||||
"@cline/cli-darwin-x64"
|
||||
@@ -388,7 +428,8 @@ jobs:
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
echo "Published cline@${VERSION} to npm with dist-tag 'nightly'"
|
||||
echo "Install with: npm install -g cline@nightly"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
name: Trigger Jetbrains Plugin <-> Cline Tests
|
||||
name: ext-jb-test-integration
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
+4
-10
@@ -1,4 +1,7 @@
|
||||
name: "Publish New SDK Extension Nightly"
|
||||
# TODO: Fold this workflow's SDK login changes into ext-vscode-publish-nightly.yml
|
||||
# and delete this file. Pinned to dpc/sdk-migration-simpler-login while Max is iterating.
|
||||
# Owner: Max Paulus
|
||||
name: ext-vscode-publish-nightly-sdk
|
||||
|
||||
on:
|
||||
schedule:
|
||||
@@ -46,15 +49,6 @@ jobs:
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Verify LFS media assets are resolved
|
||||
run: |
|
||||
for FILE in webview-ui/src/assets/cline_kanban_demo.mp4 webview-ui/src/assets/cline_kanban_demo.webm; do
|
||||
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
|
||||
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Publish SDK nightly extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
+3
-12
@@ -1,4 +1,4 @@
|
||||
name: "Publish Nightly Release"
|
||||
name: ext-vscode-publish-nightly
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -10,7 +10,7 @@ run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
# runs on the same ref can collide on the same version and cause publish failures
|
||||
# or inconsistent tagging. Runs on different branches proceed independently.
|
||||
concurrency:
|
||||
group: publish-nightly-${{ github.ref }}
|
||||
group: ext-vscode-publish-nightly-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
permissions:
|
||||
contents: read
|
||||
uses: ./.github/workflows/test.yml
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
@@ -61,15 +61,6 @@ jobs:
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Verify LFS media assets are resolved
|
||||
run: |
|
||||
for FILE in webview-ui/src/assets/cline_kanban_demo.mp4 webview-ui/src/assets/cline_kanban_demo.webm; do
|
||||
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
|
||||
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Publish Nightly Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
@@ -1,4 +1,4 @@
|
||||
name: "Publish Release"
|
||||
name: ext-vscode-publish-stable
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -29,7 +29,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/test.yml
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
@@ -135,15 +135,6 @@ jobs:
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify LFS media assets are resolved
|
||||
run: |
|
||||
for FILE in webview-ui/src/assets/cline_kanban_demo.mp4 webview-ui/src/assets/cline_kanban_demo.webm; do
|
||||
if grep -q "git-lfs.github.com/spec/v1" "$FILE"; then
|
||||
echo "Error: $FILE is still a Git LFS pointer in CI checkout"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
@@ -1,4 +1,4 @@
|
||||
name: E2E Tests
|
||||
name: ext-vscode-test-e2e
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,8 +12,53 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
name: Detect Changes
|
||||
outputs:
|
||||
e2e: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.e2e == 'true' }}
|
||||
steps:
|
||||
- id: force
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
run: echo "run_all=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
e2e:
|
||||
- 'src/**'
|
||||
- 'webview-ui/**'
|
||||
- 'proto/**'
|
||||
- 'tests/**'
|
||||
- 'scripts/**'
|
||||
- 'standalone/**'
|
||||
- 'assets/**'
|
||||
- 'walkthrough/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'buf.yaml'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- 'esbuild.mjs'
|
||||
- '.mocharc.json'
|
||||
- '.vscode-test.mjs'
|
||||
- '.vscodeignore'
|
||||
- 'playwright*.ts'
|
||||
- '.github/workflows/ext-vscode-test-e2e.yml'
|
||||
|
||||
matrix_prep:
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.e2e == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
@@ -23,7 +68,8 @@ jobs:
|
||||
echo 'matrix=[{"runner":"ubuntu"},{"runner":"windows"},{"runner":"macos"}]' >> $GITHUB_OUTPUT
|
||||
|
||||
e2e:
|
||||
needs: matrix_prep
|
||||
needs: [detect-changes, matrix_prep]
|
||||
if: needs.detect-changes.outputs.e2e == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Tests
|
||||
name: ext-vscode-test
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -13,9 +13,66 @@ on:
|
||||
# Set default permissions for all jobs
|
||||
permissions:
|
||||
contents: read # Needed to check out code
|
||||
pull-requests: read # Needed for changed-file detection on pull requests
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
name: Detect Changes
|
||||
outputs:
|
||||
vscode: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.vscode == 'true' }}
|
||||
testing_platform: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.testing_platform == 'true' }}
|
||||
steps:
|
||||
- id: force
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call'
|
||||
run: echo "run_all=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
if: steps.force.outputs.run_all != 'true'
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
vscode:
|
||||
- 'src/**'
|
||||
- 'webview-ui/**'
|
||||
- 'proto/**'
|
||||
- 'tests/**'
|
||||
- 'scripts/**'
|
||||
- 'standalone/**'
|
||||
- 'assets/**'
|
||||
- 'walkthrough/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'buf.yaml'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- 'esbuild.mjs'
|
||||
- '.mocharc.json'
|
||||
- '.nycrc*.json'
|
||||
- '.vscode-test.mjs'
|
||||
- 'test-setup.js'
|
||||
- '.github/workflows/ext-vscode-test.yml'
|
||||
testing_platform:
|
||||
- 'src/**'
|
||||
- 'proto/**'
|
||||
- 'standalone/**'
|
||||
- 'testing-platform/**'
|
||||
- 'tests/specs/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'buf.yaml'
|
||||
- 'tsconfig*.json'
|
||||
- 'esbuild.mjs'
|
||||
- '.vscodeignore'
|
||||
- 'scripts/**'
|
||||
- '.github/workflows/ext-vscode-test.yml'
|
||||
|
||||
quality-checks:
|
||||
needs: detect-changes
|
||||
if: needs.detect-changes.outputs.vscode == 'true' || needs.detect-changes.outputs.testing_platform == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
name: Quality Checks
|
||||
steps:
|
||||
@@ -42,8 +99,9 @@ jobs:
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: npm run ci:check-all
|
||||
|
||||
test:
|
||||
needs: quality-checks
|
||||
vscode-test:
|
||||
needs: [detect-changes, quality-checks]
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
env:
|
||||
VSCODE_TEST_VERSION: 1.103.0
|
||||
strategy:
|
||||
@@ -51,7 +109,7 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
name: ${{ matrix.os == 'ubuntu-latest' && 'test' || format('test ({0})', matrix.os) }}
|
||||
name: ${{ matrix.os == 'ubuntu-latest' && 'vscode test' || format('vscode test ({0})', matrix.os) }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -147,7 +205,8 @@ jobs:
|
||||
webview-ui/coverage/lcov.info
|
||||
|
||||
test-platform-integration:
|
||||
needs: quality-checks
|
||||
needs: [detect-changes, quality-checks]
|
||||
if: needs.detect-changes.outputs.testing_platform == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -190,8 +249,55 @@ jobs:
|
||||
name: test-platform-integration-core-coverage
|
||||
path: coverage/**/lcov.info
|
||||
|
||||
# Keep the required "test" check as a tiny aggregate gate instead of the conditional
|
||||
# VS Code matrix. GitHub treats conditionally skipped jobs as successful required
|
||||
# checks, so the gate below preserves the old required check name while making sure
|
||||
# whichever filtered test jobs were selected actually passed.
|
||||
test:
|
||||
needs: [detect-changes, quality-checks, vscode-test, test-platform-integration]
|
||||
if: ${{ !cancelled() }}
|
||||
runs-on: ubuntu-latest
|
||||
name: test
|
||||
steps:
|
||||
- name: Verify selected test jobs
|
||||
env:
|
||||
DETECT_CHANGES_RESULT: ${{ needs.detect-changes.result }}
|
||||
QUALITY_CHECKS_RESULT: ${{ needs.quality-checks.result }}
|
||||
VSCODE_CHANGED: ${{ needs.detect-changes.outputs.vscode }}
|
||||
TESTING_PLATFORM_CHANGED: ${{ needs.detect-changes.outputs.testing_platform }}
|
||||
VSCODE_TEST_RESULT: ${{ needs.vscode-test.result }}
|
||||
TEST_PLATFORM_RESULT: ${{ needs.test-platform-integration.result }}
|
||||
run: |
|
||||
if [ "$DETECT_CHANGES_RESULT" != "success" ]; then
|
||||
echo "detect-changes did not succeed: $DETECT_CHANGES_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$VSCODE_CHANGED" != "true" ] && [ "$TESTING_PLATFORM_CHANGED" != "true" ]; then
|
||||
echo "No root test paths changed; skipping root test requirements."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$QUALITY_CHECKS_RESULT" != "success" ]; then
|
||||
echo "quality-checks did not succeed: $QUALITY_CHECKS_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$VSCODE_CHANGED" = "true" ] && [ "$VSCODE_TEST_RESULT" != "success" ]; then
|
||||
echo "vscode-test did not succeed: $VSCODE_TEST_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$TESTING_PLATFORM_CHANGED" = "true" ] && [ "$TEST_PLATFORM_RESULT" != "success" ]; then
|
||||
echo "test-platform-integration did not succeed: $TEST_PLATFORM_RESULT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Selected root test jobs passed."
|
||||
|
||||
qlty:
|
||||
needs: [test, test-platform-integration]
|
||||
needs: [detect-changes, quality-checks, vscode-test, test-platform-integration]
|
||||
if: ${{ !cancelled() && needs.quality-checks.result == 'success' && (needs.vscode-test.result == 'success' || needs.vscode-test.result == 'skipped') && (needs.test-platform-integration.result == 'success' || needs.test-platform-integration.result == 'skipped') && (needs.detect-changes.outputs.vscode == 'true' || needs.detect-changes.outputs.testing_platform == 'true') }}
|
||||
runs-on: ubuntu-latest
|
||||
# Run on PRs to main, pushes to main, and manual dispatches
|
||||
steps:
|
||||
@@ -199,12 +305,14 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download unit tests coverage reports
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: .
|
||||
|
||||
- name: Upload core unit tests coverage to Qlty
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
@@ -214,6 +322,7 @@ jobs:
|
||||
tag: unit:core
|
||||
|
||||
- name: Upload webview-ui unit tests coverage to Qlty
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
@@ -224,6 +333,7 @@ jobs:
|
||||
add-prefix: webview-ui/
|
||||
|
||||
- name: Download test platform integration core coverage artifact
|
||||
if: needs.detect-changes.outputs.testing_platform == 'true'
|
||||
uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
id: download-integration-coverage
|
||||
@@ -232,7 +342,7 @@ jobs:
|
||||
path: integration-core-coverage-reports
|
||||
|
||||
- name: Upload core integration tests coverage to Qlty
|
||||
if: steps.download-integration-coverage.outcome == 'success'
|
||||
if: needs.detect-changes.outputs.testing_platform == 'true' && steps.download-integration-coverage.outcome == 'success'
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
name: Auto-label Issues
|
||||
name: repo-label-issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
script: |
|
||||
const body = context.payload.issue.body || '';
|
||||
const labels = context.payload.issue.labels.map(l => l.name);
|
||||
|
||||
|
||||
// Check if JetBrains Plugin is selected
|
||||
if (body.match(/###\s*Plugin Type\s*\n+JetBrains Plugin/i)) {
|
||||
if (!labels.includes('JetBrains')) {
|
||||
@@ -1,6 +1,6 @@
|
||||
# This workflow will only label and/or close 30 issues at a time in order to avoid exceeding a rate limit.
|
||||
# More info: https://docs.github.com/en/actions/use-cases-and-examples/project-management/closing-inactive-issues
|
||||
name: Close inactive issues
|
||||
name: repo-stale-issues
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Publish Main SDK Packages
|
||||
name: sdk-publish
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -59,19 +59,23 @@ jobs:
|
||||
|
||||
- name: Determine publish channel
|
||||
id: channel
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
INPUT_CHANNEL: ${{ inputs.channel }}
|
||||
run: |
|
||||
# Default to nightly for scheduled runs
|
||||
if [ "${{ github.event_name }}" = "schedule" ]; then
|
||||
if [ "$EVENT_NAME" = "schedule" ]; then
|
||||
echo "channel=nightly" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=${{ inputs.channel }}" >> $GITHUB_OUTPUT
|
||||
echo "channel=$INPUT_CHANNEL" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check for recent commits
|
||||
id: check_commits
|
||||
env:
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
FORCE_PUBLISH: ${{ inputs.force_publish }}
|
||||
run: |
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
|
||||
# Always publish for latest (production) releases
|
||||
if [ "$CHANNEL" = "latest" ]; then
|
||||
echo "Production release requested, proceeding with publish"
|
||||
@@ -79,7 +83,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.force_publish }}" = "true" ]; then
|
||||
if [ "$FORCE_PUBLISH" = "true" ]; then
|
||||
echo "force_publish enabled, proceeding with publish"
|
||||
echo "skip=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
@@ -141,8 +145,9 @@ jobs:
|
||||
- name: Generate shared version
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
id: version
|
||||
env:
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
run: |
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
BASE_VERSION=$(node -p "require('./packages/llms/package.json').version")
|
||||
|
||||
if [ "$CHANNEL" = "nightly" ]; then
|
||||
@@ -159,7 +164,9 @@ jobs:
|
||||
|
||||
- name: Update all package versions and lockfile
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun scripts/version.ts "${{ steps.version.outputs.version }}"
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: bun scripts/version.ts "$VERSION"
|
||||
|
||||
- name: Verify publishability
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
@@ -176,9 +183,10 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
echo "Publishing @cline/shared@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
|
||||
echo "Publishing @cline/shared@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd packages/shared
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
@@ -187,9 +195,10 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
echo "Publishing @cline/llms@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
|
||||
echo "Publishing @cline/llms@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd packages/llms
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
@@ -198,9 +207,10 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
echo "Publishing @cline/agents@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
|
||||
echo "Publishing @cline/agents@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd packages/agents
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
@@ -209,9 +219,10 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
echo "Publishing @cline/core@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
|
||||
echo "Publishing @cline/core@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd packages/core
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
@@ -220,18 +231,19 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
echo "Publishing @cline/sdk@${{ steps.version.outputs.version }} with tag '${CHANNEL}'..."
|
||||
echo "Publishing @cline/sdk@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd packages/sdk
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
- name: Create package tags for production publish
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
@@ -250,9 +262,10 @@ jobs:
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
CHANNEL="${{ steps.channel.outputs.channel }}"
|
||||
echo "Published SDK packages with tag '${CHANNEL}':"
|
||||
echo " - @cline/shared@${VERSION}"
|
||||
echo " - @cline/llms@${VERSION}"
|
||||
@@ -1,4 +1,4 @@
|
||||
name: SDK Tests
|
||||
name: sdk-test
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
name: Test Stale Issues Workflow
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
days-before-stale:
|
||||
description: "Days before an issue becomes stale"
|
||||
required: true
|
||||
default: "1"
|
||||
days-before-close:
|
||||
description: "Days before a stale issue is closed"
|
||||
required: true
|
||||
default: "1"
|
||||
|
||||
jobs:
|
||||
test-stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@28ca103
|
||||
with:
|
||||
days-before-issue-stale: ${{ github.event.inputs.days-before-stale }}
|
||||
days-before-issue-close: ${{ github.event.inputs.days-before-close }}
|
||||
stale-issue-label: "stale"
|
||||
stale-issue-message: "This issue is stale because it has been open for ${{ github.event.inputs.days-before-stale }} days with no activity."
|
||||
close-issue-message: "This issue was closed because it has been inactive for ${{ github.event.inputs.days-before-close }} days since being marked as stale."
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
exempt-issue-labels: "pinned,security"
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
debug-only: true
|
||||
+10
@@ -60,3 +60,13 @@ tests/**/cache
|
||||
# Backup created by scripts/marketplace-readme.mjs while publishing.
|
||||
# Should never be committed: only exists if a publish aborts mid-swap.
|
||||
.README.github.bak
|
||||
|
||||
|
||||
# SDK Session files / User data
|
||||
.cline/data
|
||||
.cline/tmp
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.cline/**/managed.json
|
||||
.cline/**/bundle.json
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
node 22
|
||||
|
||||
bun 1.3.13
|
||||
node 22
|
||||
Vendored
+2
-1
@@ -5,6 +5,7 @@
|
||||
"connor4312.esbuild-problem-matchers",
|
||||
"ms-vscode.extension-test-runner",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"biomejs.biome"
|
||||
"biomejs.biome",
|
||||
"oven.bun-vscode"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+113
@@ -199,6 +199,119 @@
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Launch Bun CLI (Prompt)",
|
||||
"type": "bun",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/sdk/apps/cli",
|
||||
"runtime": "bun",
|
||||
"runtimeArgs": [
|
||||
"--conditions=development"
|
||||
],
|
||||
"program": "${workspaceFolder}/sdk/apps/cli/src/index.ts",
|
||||
"args": [
|
||||
"${input:cliPrompt}"
|
||||
],
|
||||
"env": {
|
||||
"CLINE_BUILD_ENV": "development"
|
||||
},
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Launch RPC Server",
|
||||
"type": "bun",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/sdk/apps/cli",
|
||||
"runtime": "bun",
|
||||
"runtimeArgs": [
|
||||
"--conditions=development"
|
||||
],
|
||||
"program": "${workspaceFolder}/sdk/apps/cli/src/index.ts",
|
||||
"args": [
|
||||
"rpc",
|
||||
"start"
|
||||
],
|
||||
"env": {
|
||||
"CLINE_BUILD_ENV": "development",
|
||||
"CLINE_DEBUG_PORT_BASE": "9230"
|
||||
},
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Attach RPC Runtime (9230)",
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"url": "ws://127.0.0.1:9230",
|
||||
"localRoot": "${workspaceFolder}/sdk",
|
||||
"remoteRoot": "${workspaceFolder}/sdk",
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Attach Hook Worker (9231)",
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"url": "ws://127.0.0.1:9231",
|
||||
"localRoot": "${workspaceFolder}/sdk",
|
||||
"remoteRoot": "${workspaceFolder}/sdk",
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Attach Plugin Sandbox (9232)",
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"url": "ws://127.0.0.1:9232",
|
||||
"localRoot": "${workspaceFolder}/sdk",
|
||||
"remoteRoot": "${workspaceFolder}/sdk",
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Attach Connector Child (9233)",
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"url": "ws://127.0.0.1:9233",
|
||||
"localRoot": "${workspaceFolder}/sdk",
|
||||
"remoteRoot": "${workspaceFolder}/sdk",
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Launch RPC Server Debugger",
|
||||
"configurations": [
|
||||
"Launch RPC Server",
|
||||
"Attach RPC Runtime (9230)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Launch CLI Debugger",
|
||||
"configurations": [
|
||||
"Launch Bun CLI (Prompt)",
|
||||
"Attach RPC Runtime (9230)",
|
||||
"Attach Hook Worker (9231)",
|
||||
"Attach Plugin Sandbox (9232)",
|
||||
"Attach Connector Child (9233)"
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "cliPrompt",
|
||||
"type": "promptString",
|
||||
"description": "Prompt to send to the CLI",
|
||||
"default": "hey"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
// Place your settings in this file to overwrite default and user settings.
|
||||
{
|
||||
"files.insertFinalNewline": true,
|
||||
"files.exclude": {
|
||||
"out": false, // set this to true to hide the "out" folder with the compiled JS files
|
||||
"dist": false // set this to true to hide the "dist" folder with the compiled JS files
|
||||
|
||||
Vendored
+11
@@ -283,6 +283,17 @@
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "build-sdk",
|
||||
"type": "shell",
|
||||
"command": "bun run build:sdk",
|
||||
"problemMatcher": [
|
||||
"$tsc"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/sdk"
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [3.84.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add SAP AI Core support for additional hosted models
|
||||
|
||||
### Fixed
|
||||
|
||||
- Disable the MCP "Restart Server" button when a server is toggled off.
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove the Cline Kanban launch modal and bundled demo media from the VS Code extension startup flow.
|
||||
|
||||
## [3.83.0]
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -126,14 +126,14 @@ npm install @cline/sdk
|
||||
|
||||
## Index
|
||||
|
||||
| Product | Description | Location |
|
||||
|---------|------------|--------------|
|
||||
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) |
|
||||
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`sdk/apps/cli/`](https://github.com/cline/cline/tree/main/sdk/apps/cli) |
|
||||
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) |
|
||||
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins |
|
||||
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban) |
|
||||
| **Docs site** | Public documentation pages. | [`docs/`](https://docs.cline.bot/) |
|
||||
| Product | Description | Location | CHANGELOG |
|
||||
|---------|------------|--------------|--------------|
|
||||
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) |
|
||||
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`sdk/apps/cli/`](https://github.com/cline/cline/tree/main/sdk/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/apps/cli/CHANGELOG.md) |
|
||||
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
|
||||
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
|
||||
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban) | [CHANGELOG.md](https://github.com/cline/kanban/blob/main/CHANGELOG.md) |
|
||||
| **Docs site** | Public documentation pages. | [`docs/`](https://docs.cline.bot/) | - |
|
||||
|
||||
## Edits Code Across Your Project
|
||||
|
||||
@@ -212,7 +212,7 @@ cline schedule create "PR summary" \
|
||||
Chat with your agent from any messaging platform: Telegram, Slack, Discord, Google Chat, WhatsApp, and Linear. Each conversation thread maps to an agent session with full context. Set up access control to restrict who can interact with your agent.
|
||||
|
||||
```bash
|
||||
cline connect telegram -m my_bot -k $BOT_TOKEN
|
||||
cline connect telegram -k $BOT_TOKEN
|
||||
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: "Memory Bank"
|
||||
sidebarTitle: "Memory Bank"
|
||||
description: "A structured documentation system that helps Cline maintain context across sessions."
|
||||
---
|
||||
|
||||
Memory Bank is a documentation methodology that transforms Cline from a stateless assistant into a persistent development partner. Through structured markdown files, Cline can "remember" your project details across sessions.
|
||||
|
||||
## Quick Setup
|
||||
|
||||
1. Copy the [custom instructions below](#memory-bank-custom-instructions)
|
||||
2. Add them to a [Cline Rules file](/customization/cline-rules), such as `.clinerules/memory-bank.md`
|
||||
3. Ask Cline to "initialize memory bank"
|
||||
|
||||
## How It Works
|
||||
|
||||
Memory Bank files are regular markdown files in your project that both you and Cline can access. They're organized hierarchically to build a complete picture of your project:
|
||||
|
||||
```text
|
||||
memory-bank/
|
||||
├── projectbrief.md # Foundation document
|
||||
├── productContext.md # Why this project exists
|
||||
├── activeContext.md # Current work focus
|
||||
├── systemPatterns.md # Architecture & patterns
|
||||
├── techContext.md # Tech stack & setup
|
||||
└── progress.md # Status & milestones
|
||||
```
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/image%20(16).png" alt="Memory Bank file hierarchy showing projectbrief.md at the top flowing into productContext, systemPatterns, and techContext, which feed into activeContext and progress" />
|
||||
</Frame>
|
||||
|
||||
## Core Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `projectbrief.md` | Foundation document with core requirements and goals |
|
||||
| `productContext.md` | Why the project exists, problems it solves, UX goals |
|
||||
| `activeContext.md` | Current focus, recent changes, next steps (updates most frequently) |
|
||||
| `systemPatterns.md` | Architecture, design patterns, component relationships |
|
||||
| `techContext.md` | Tech stack, setup, constraints, dependencies |
|
||||
| `progress.md` | What works, what's left, known issues |
|
||||
|
||||
## Key Commands
|
||||
|
||||
- **"follow your custom instructions"** - Tells Cline to read Memory Bank and continue where you left off
|
||||
- **"initialize memory bank"** - Creates the initial structure for a new project
|
||||
- **"update memory bank"** - Triggers a full documentation review and update
|
||||
|
||||
These work alongside Cline's built-in [slash commands](/core-workflows/using-commands). In particular, [`/newtask`](/core-workflows/using-commands#newtask) and [`/smol`](/core-workflows/using-commands#smol) help you manage context windows without losing progress.
|
||||
|
||||
## Managing Context Windows
|
||||
|
||||
Every AI model has a [context window](/core-workflows/task-management#context-window) that limits how much information it can process at once. As you work, this window fills with conversation history, file contents, and tool results. Memory Bank helps you preserve important knowledge when you need to free up space.
|
||||
|
||||
### Manual approach
|
||||
|
||||
When your context window fills up:
|
||||
|
||||
1. Ask Cline to "update memory bank" to document the current state
|
||||
2. Start a new conversation
|
||||
3. Ask Cline to "follow your custom instructions"
|
||||
|
||||
This preserves important context in your Memory Bank files before the window clears, letting you continue seamlessly in a fresh conversation.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Start with a basic project brief and let structure evolve
|
||||
- Let Cline help create the initial structure
|
||||
- `activeContext.md` changes most frequently; update it after each session
|
||||
- `progress.md` tracks milestones; review it when resuming work
|
||||
- Update after significant milestones or direction changes
|
||||
- Use [Cline Rules](/customization/cline-rules) to store the Memory Bank instructions per project
|
||||
|
||||
---
|
||||
|
||||
## Memory Bank Custom Instructions
|
||||
|
||||
Copy this into a Cline Rules file (for example, `.clinerules/memory-bank.md`) or your global custom instructions:
|
||||
|
||||
```markdown
|
||||
# Cline's Memory Bank
|
||||
|
||||
I am Cline, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional.
|
||||
|
||||
## Memory Bank Structure
|
||||
|
||||
The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy:
|
||||
|
||||
### Core Files (Required)
|
||||
1. `projectbrief.md`
|
||||
- Foundation document that shapes all other files
|
||||
- Created at project start if it doesn't exist
|
||||
- Defines core requirements and goals
|
||||
- Source of truth for project scope
|
||||
|
||||
2. `productContext.md`
|
||||
- Why this project exists
|
||||
- Problems it solves
|
||||
- How it should work
|
||||
- User experience goals
|
||||
|
||||
3. `activeContext.md`
|
||||
- Current work focus
|
||||
- Recent changes
|
||||
- Next steps
|
||||
- Active decisions and considerations
|
||||
- Important patterns and preferences
|
||||
- Learnings and project insights
|
||||
|
||||
4. `systemPatterns.md`
|
||||
- System architecture
|
||||
- Key technical decisions
|
||||
- Design patterns in use
|
||||
- Component relationships
|
||||
- Critical implementation paths
|
||||
|
||||
5. `techContext.md`
|
||||
- Technologies used
|
||||
- Development setup
|
||||
- Technical constraints
|
||||
- Dependencies
|
||||
- Tool usage patterns
|
||||
|
||||
6. `progress.md`
|
||||
- What works
|
||||
- What's left to build
|
||||
- Current status
|
||||
- Known issues
|
||||
- Evolution of project decisions
|
||||
|
||||
### Additional Context
|
||||
Create additional files/folders within memory-bank/ when they help organize:
|
||||
- Complex feature documentation
|
||||
- Integration specifications
|
||||
- API documentation
|
||||
- Testing strategies
|
||||
- Deployment procedures
|
||||
|
||||
## Documentation Updates
|
||||
|
||||
Memory Bank updates occur when:
|
||||
1. Discovering new project patterns
|
||||
2. After implementing significant changes
|
||||
3. When user requests with **update memory bank** (MUST review ALL files)
|
||||
4. When context needs clarification
|
||||
|
||||
REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy.
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
**Custom instructions or Cline Rules?**
|
||||
Either works. Custom instructions apply globally across all projects. A [Cline Rules file](/customization/cline-rules) is project-specific and stored in your repo, which makes it easy to share with collaborators. You can also use [conditional rules](/customization/cline-rules#conditional-rules) to activate Memory Bank instructions only when working with `memory-bank/` files.
|
||||
|
||||
**How often should I update?**
|
||||
After significant milestones or direction changes. For active development, every few sessions. You can also let [Auto Compact](/features/auto-compact) handle routine context management and reserve manual "update memory bank" for important checkpoints.
|
||||
|
||||
**Does this work with other AI tools?**
|
||||
Yes. Memory Bank is a documentation methodology that works with any AI that can read docs. Commands may differ but the approach works across tools.
|
||||
|
||||
**Different from README files?**
|
||||
Memory Bank provides structured, comprehensive documentation designed for AI context management, going beyond what a single README covers. It includes files for active context and progress tracking that change frequently, unlike a typical README.
|
||||
@@ -171,7 +171,7 @@ cat payload.json | cline hook
|
||||
|
||||
### `plugin`
|
||||
|
||||
Manage Cline plugins. Install plugins from npm, git repositories, or local paths. See [Plugins](/customization/plugins) for full details and the plugin manifest format.
|
||||
Manage Cline plugins. Install plugins from file URLs, npm, git repositories, or local paths. See [Plugins](/customization/plugins) for full details and the plugin manifest format.
|
||||
|
||||
```bash
|
||||
cline plugin install <source> # Install a plugin
|
||||
|
||||
@@ -21,7 +21,7 @@ cline connect
|
||||
|
||||
| Platform | Direct Command | Required Credentials |
|
||||
|----------|---------------|---------------------|
|
||||
| Telegram | `cline connect telegram` | Bot username, bot token |
|
||||
| Telegram | `cline connect telegram` | Bot token |
|
||||
| Slack | `cline connect slack` | Bot token, signing secret, base URL |
|
||||
| Discord | `cline connect discord` | Application ID, bot token, public key, base URL |
|
||||
| Google Chat | `cline connect gchat` | Service account credentials JSON, base URL |
|
||||
@@ -41,8 +41,10 @@ cline connect
|
||||
|
||||
<Step title="Start the connector">
|
||||
```bash
|
||||
cline connect telegram -m <BOT-USERNAME> -k <BOT-TOKEN>
|
||||
cline connect telegram -k <BOT-TOKEN>
|
||||
```
|
||||
|
||||
The connector discovers the bot username from the token. Use `--bot-username` only if you need to override it.
|
||||
</Step>
|
||||
|
||||
<Step title="Chat with your bot">
|
||||
@@ -63,7 +65,7 @@ By default, anyone who finds your bot can message it and it will execute tasks o
|
||||
Replace `12345` with your actual Telegram user ID:
|
||||
|
||||
```bash
|
||||
cline connect telegram -m <BOT-USERNAME> -k <BOT-TOKEN> \
|
||||
cline connect telegram -k <BOT-TOKEN> \
|
||||
--hook-command 'jq -r ".payload.actor.participantKey" | grep -q "telegram:id:12345" && echo "{\"action\":\"allow\"}" || echo "{\"action\":\"deny\",\"message\":\"unauthorized\"}"'
|
||||
```
|
||||
</Step>
|
||||
@@ -147,7 +149,7 @@ Multiple connectors can run simultaneously. They all share the same hub:
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
cline connect telegram -m my_bot -k $TELEGRAM_TOKEN
|
||||
cline connect telegram -k $TELEGRAM_TOKEN
|
||||
|
||||
# Terminal 2
|
||||
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
|
||||
- name: Install Cline CLI
|
||||
if: steps.detect.outputs.hit == 'true'
|
||||
run: npm install -g @cline/cli
|
||||
run: npm install -g cline
|
||||
|
||||
- name: Configure Cline Authentication
|
||||
if: steps.detect.outputs.hit == 'true'
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
cache: "npm"
|
||||
|
||||
- name: Install Cline CLI
|
||||
run: npm install -g @cline/cli
|
||||
run: npm install -g cline
|
||||
|
||||
- name: Configure Cline Authentication
|
||||
# Replace 'anthropic' with your provider of choice (openai, openrouter, etc.)
|
||||
|
||||
@@ -100,7 +100,7 @@ cline schedule create "Code health" \
|
||||
Combine schedules with [connectors](/cli/connectors) to send results to messaging platforms:
|
||||
|
||||
```bash
|
||||
cline connect telegram -m my_bot -k $BOT_TOKEN
|
||||
cline connect telegram -k $BOT_TOKEN
|
||||
|
||||
cline schedule create "Morning briefing" \
|
||||
--cron "0 8 * * *" \
|
||||
|
||||
@@ -11,9 +11,17 @@ Plugins extend Cline with custom tools, lifecycle hooks, slash commands, and mor
|
||||
|
||||
## Installing Plugins via CLI
|
||||
|
||||
The `cline plugin install` command installs plugins from three source types:
|
||||
The `cline plugin install` command installs plugins from four source types:
|
||||
|
||||
<Tabs>
|
||||
<Tab title="File URL">
|
||||
```bash
|
||||
cline plugin install https://github.com/owner/repo/blob/main/plugins/my-plugin.ts
|
||||
cline plugin install https://raw.githubusercontent.com/owner/repo/main/plugins/my-plugin.ts
|
||||
```
|
||||
|
||||
File URLs install a single `.ts` or `.js` plugin file directly. GitHub `blob` and raw URLs are supported, and remote plugin file URLs must use `https://`.
|
||||
</Tab>
|
||||
<Tab title="Git Repository">
|
||||
```bash
|
||||
cline plugin install https://github.com/owner/repo.git
|
||||
@@ -100,15 +108,15 @@ If no `cline.plugins` field is present, the installer falls back to auto-discove
|
||||
|
||||
### Host-Provided Dependencies
|
||||
|
||||
Dependencies under the `@cline/` scope (like `@cline/core`, `@cline/shared`) are provided by the host runtime. The installer automatically strips these from the plugin's dependency list before running `npm install`, so you should declare them as `peerDependencies`:
|
||||
Dependencies under the `@cline/` scope are provided by the host runtime. The installer automatically strips these from the plugin's dependency list before running `npm install`, so declare any `@cline/*` package your plugin imports as an optional peer dependency. The host currently provides `@cline/sdk`, `@cline/core`, `@cline/agents`, `@cline/llms`, and `@cline/shared`.
|
||||
|
||||
```json
|
||||
{
|
||||
"peerDependencies": {
|
||||
"@cline/core": "*"
|
||||
"@cline/sdk": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cline/core": {
|
||||
"@cline/sdk": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
@@ -125,6 +133,7 @@ Plugins are stored in the `plugins` directory at two levels:
|
||||
_installed/ # Managed by `cline plugin install`
|
||||
npm/ # npm-sourced plugins
|
||||
git/ # git-sourced plugins
|
||||
remote/ # file URL-sourced plugins
|
||||
local/ # local-sourced plugins
|
||||
|
||||
.cline/ # Project root
|
||||
|
||||
+9
-3
@@ -155,6 +155,12 @@
|
||||
"features/subagents"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Best Practices",
|
||||
"pages": [
|
||||
"best-practices/memory-bank"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "IDE Specific Features",
|
||||
"pages": [
|
||||
@@ -430,11 +436,11 @@
|
||||
},
|
||||
{
|
||||
"source": "/prompting/cline-memory-bank",
|
||||
"destination": "/core-workflows/task-management"
|
||||
"destination": "/best-practices/memory-bank"
|
||||
},
|
||||
{
|
||||
"source": "/customization/memory-bank",
|
||||
"destination": "/core-workflows/task-management"
|
||||
"destination": "/best-practices/memory-bank"
|
||||
},
|
||||
{
|
||||
"source": "/customization/overview",
|
||||
@@ -650,7 +656,7 @@
|
||||
},
|
||||
{
|
||||
"source": "/features/memory-bank",
|
||||
"destination": "/core-workflows/task-management"
|
||||
"destination": "/best-practices/memory-bank"
|
||||
},
|
||||
{
|
||||
"source": "/features/focus-chain",
|
||||
|
||||
@@ -134,7 +134,7 @@ cline "Work on feature X"
|
||||
# Terminal 2: attach to the same session from VS Code or another CLI
|
||||
|
||||
# Terminal 3: Telegram connector
|
||||
cline connect telegram -m my_bot -k $TOKEN
|
||||
cline connect telegram -k $TOKEN
|
||||
|
||||
# All three share the same hub and can access the same sessions
|
||||
```
|
||||
|
||||
@@ -134,7 +134,7 @@ Combine scheduled agents with [connectors](/cli/connectors) in CLI to route resu
|
||||
|
||||
```bash
|
||||
# Start a Telegram connector
|
||||
cline connect telegram -m my_bot -k $BOT_TOKEN
|
||||
cline connect telegram -k $BOT_TOKEN
|
||||
|
||||
# Schedule an agent whose output gets sent to Telegram
|
||||
cline schedule create "Morning briefing" \
|
||||
|
||||
@@ -251,19 +251,31 @@ await cline.start({
|
||||
|
||||
## Distributing via CLI Install
|
||||
|
||||
To make your plugin installable with `cline plugin install`, add a `cline` field to your `package.json` that declares entry points and list `@cline/` imports as peer dependencies (the host runtime provides them):
|
||||
Single-file plugins can be installed directly from a file URL:
|
||||
|
||||
```bash
|
||||
cline plugin install https://github.com/your-org/your-repo/blob/main/plugins/github-plugin.ts
|
||||
```
|
||||
|
||||
For package or repository distribution, add a `cline` field to your `package.json` that declares entry points. Dependencies under the `@cline/` scope are provided by the host runtime. The installer automatically strips these from the plugin's dependency list before running `npm install`, so declare any `@cline/*` package your plugin imports as an optional peer dependency:
|
||||
|
||||
```json
|
||||
{
|
||||
"cline": {
|
||||
"plugins": [{ "paths": ["./github-plugin.ts"], "capabilities": ["tools", "hooks"] }]
|
||||
},
|
||||
"peerDependencies": { "@cline/core": "*" },
|
||||
"peerDependenciesMeta": { "@cline/core": { "optional": true } }
|
||||
"peerDependencies": {
|
||||
"@cline/sdk": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@cline/sdk": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Users can then install from git, npm, or a local path:
|
||||
Users can also install package plugins from git, npm, or a local path:
|
||||
|
||||
```bash
|
||||
cline plugin install https://github.com/your-org/cline-github-plugin.git
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
---
|
||||
title: "Plugin Examples"
|
||||
sidebarTitle: "Plugin Examples"
|
||||
description: "Explore ready-to-run plugin examples from the Cline SDK repository."
|
||||
description: "Explore installable plugin examples from the Cline SDK repository."
|
||||
---
|
||||
|
||||
The [SDK repository](https://github.com/cline/cline/tree/main/sdk) includes ready-to-run plugin examples under [`examples/plugins/`](https://github.com/cline/cline/tree/main/sdk/examples/plugins). Use them as starting points for tools, lifecycle hooks, message rewriting, policy enforcement, background jobs, and multi-agent workflows.
|
||||
The [SDK repository](https://github.com/cline/cline/tree/main/sdk) includes installable plugin examples under [`examples/plugins/`](https://github.com/cline/cline/tree/main/sdk/examples/plugins). Use them as starting points for tools, lifecycle hooks, message rewriting, policy enforcement, background jobs, and multi-agent workflows.
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -25,8 +25,7 @@ The [SDK repository](https://github.com/cline/cline/tree/main/sdk) includes read
|
||||
The CLI auto-discovers plugins from `.cline/plugins` in the workspace, `~/.cline/plugins`, and the system plugins folder.
|
||||
|
||||
```sh
|
||||
mkdir -p .cline/plugins
|
||||
cp examples/plugins/weather-metrics.ts .cline/plugins/
|
||||
cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/weather-metrics.ts --cwd .
|
||||
cline -i "What's the weather like in Tokyo and Paris?"
|
||||
```
|
||||
|
||||
@@ -37,8 +36,7 @@ Swap `weather-metrics.ts` for any other single-file plugin example.
|
||||
Use `gitignore-read-files-guard.ts` to block tools from reading or editing files ignored by workspace `.gitignore` files:
|
||||
|
||||
```sh
|
||||
mkdir -p .cline/plugins
|
||||
cp examples/plugins/gitignore-read-files-guard.ts .cline/plugins/
|
||||
cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/gitignore-read-files-guard.ts --cwd .
|
||||
cline -i "Read the ignored .env file"
|
||||
```
|
||||
|
||||
@@ -59,8 +57,7 @@ See [Installing Plugins](/sdk/plugin-install) for more install options.
|
||||
The `web-search.ts` plugin registers a `web_search` tool backed by Exa. Use `web_search` to discover relevant URLs, then use `fetch_web_content` when the agent needs to inspect a specific page.
|
||||
|
||||
```sh
|
||||
mkdir -p .cline/plugins
|
||||
cp examples/plugins/web-search.ts .cline/plugins/web-search.ts
|
||||
cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/web-search.ts --cwd .
|
||||
|
||||
export EXA_API_KEY=...
|
||||
export OPENROUTER_API_KEY=...
|
||||
@@ -92,4 +89,4 @@ Prefer the message-builder version for normal compaction. It runs in the core me
|
||||
| `get_background_command` | Reads job status plus recent stdout/stderr tails. |
|
||||
| `delete_background_command` | Deletes saved job metadata and optionally deletes captured logs. |
|
||||
|
||||
When `notifyParent` is true, the plugin emits a `steer_message` after the command exits, pushing a completion summary back into the active session so the agent can react to long-running commands without blocking the original tool call.
|
||||
When `notifyParent` is true, the plugin emits a `steer_message` after the command exits, pushing a completion summary back into the active session so the agent can react to long-running commands without blocking the original tool call.
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
---
|
||||
title: "Installing Plugins"
|
||||
sidebarTitle: "Installing Plugins"
|
||||
description: "Install plugins from npm, git, or local sources using the CLI or programmatically."
|
||||
description: "Install plugins from file URLs, npm, git, or local sources using the CLI or programmatically."
|
||||
---
|
||||
|
||||
The Cline CLI provides a `plugin install` command to install plugins from various sources. You can also load plugins programmatically via session config.
|
||||
|
||||
## CLI Installation
|
||||
|
||||
Use the `cline plugin install` command to install plugins from npm, git repositories, or local paths.
|
||||
Use the `cline plugin install` command to install plugins from file URLs, npm, git repositories, or local paths.
|
||||
|
||||
```sh
|
||||
cline plugin install <source> [options]
|
||||
@@ -16,6 +16,15 @@ cline plugin install <source> [options]
|
||||
|
||||
The shorthand `cline plugin i` works as well.
|
||||
|
||||
### Install from a File URL
|
||||
|
||||
```sh
|
||||
cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/weather-metrics.ts
|
||||
cline plugin install https://raw.githubusercontent.com/cline/cline/main/sdk/examples/plugins/weather-metrics.ts
|
||||
```
|
||||
|
||||
Installs a single `.ts` or `.js` plugin file from an HTTPS URL. GitHub `blob` URLs are normalized to raw file downloads automatically.
|
||||
|
||||
### Install from npm
|
||||
|
||||
```sh
|
||||
@@ -50,7 +59,7 @@ Supports both files and directories. Local installs copy the source, filter out
|
||||
| `--git` | Treat the source as a git repository URL |
|
||||
| `--force` | Overwrite an existing plugin at the same path |
|
||||
| `--json` | Output results as JSON (useful for scripting) |
|
||||
| `--cwd` | Working directory for relative paths |
|
||||
| `--cwd` | Install to `<path>/.cline/plugins` and resolve relative local paths from `<path>` |
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -58,6 +67,9 @@ Supports both files and directories. Local installs copy the source, filter out
|
||||
# Install a local plugin directory
|
||||
cline plugin install ./my-custom-plugin
|
||||
|
||||
# Install a single plugin file from GitHub
|
||||
cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/weather-metrics.ts
|
||||
|
||||
# Install an npm package as a plugin
|
||||
cline plugin install --npm @cline/plugin-sql
|
||||
|
||||
@@ -79,6 +91,27 @@ Installed plugins appear in the `plugins` section of your CLI config:
|
||||
cline config
|
||||
```
|
||||
|
||||
### Find All Your Plugins from your file system
|
||||
|
||||
Project-scoped plugins live in the `.cline/plugins/` folder at your workspace root. The CLI auto-discovers plugins from this folder when you run Cline in that project.
|
||||
|
||||
```text
|
||||
your-project/
|
||||
├── .cline/
|
||||
│ └── plugins/
|
||||
│ ├── my-plugin.ts
|
||||
│ └── another-plugin/
|
||||
└── ...
|
||||
```
|
||||
|
||||
To add a single-file plugin to a project, install it with `--cwd`:
|
||||
|
||||
```sh
|
||||
cline plugin install https://github.com/owner/repo/blob/main/plugins/my-plugin.ts --cwd .
|
||||
```
|
||||
|
||||
Global plugins are discovered from `~/.cline/plugins/`, and Cline may also discover plugins from the system Plugins folder. Use `--cwd .` when you want plugins to stay with a specific project.
|
||||
|
||||
## Programmatic Installation
|
||||
|
||||
### Using `pluginPaths` (ClineCore)
|
||||
@@ -137,4 +170,4 @@ await cline.start({
|
||||
|
||||
- **Learn about [Plugins](/sdk/plugins)** — Understand what plugins are and their benefits.
|
||||
- **Follow the [Writing Plugins](/sdk/guides/writing-plugins) guide** — Build and distribute your own plugin.
|
||||
- **Explore [Plugin Examples](/sdk/plugin-examples)** — Copy ready-to-run plugin examples from the SDK repository.
|
||||
- Explore [Plugin Examples](/sdk/plugin-examples) to install ready-to-run plugin examples from the SDK repository.
|
||||
|
||||
@@ -98,7 +98,7 @@ Plugin files export an `AgentPlugin`.
|
||||
|
||||
## Installing Plugins via CLI
|
||||
|
||||
Plugins can also be installed from npm, git, or local paths using `cline plugin install`. See [Plugins](/customization/plugins) for install commands, the manifest format, and directory layout.
|
||||
Plugins can also be installed from file URLs, npm, git, or local paths using `cline plugin install`. See [Plugins](/customization/plugins) for install commands, the manifest format, and directory layout.
|
||||
|
||||
## Hook Stages
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ description: "Use Cline CLI for interactive terminal sessions and automated head
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Cline CLI installed ([Install Guide](/getting-started/installing-cline))
|
||||
- Cline CLI installed (install via `npm i -g cline`)
|
||||
- Provider authenticated (`cline auth`) ([Authorization Guide](/getting-started/authorizing-with-cline#cli-setup))
|
||||
|
||||
## Quick Start
|
||||
|
||||
+1
-3
@@ -13,9 +13,7 @@ The TUI is Cline's interactive terminal interface. It is designed for conversati
|
||||
Install the CLI:
|
||||
|
||||
```bash
|
||||
npm install -g @cline/cli
|
||||
# or
|
||||
bun install -g @cline/cli
|
||||
npm install -g cline
|
||||
```
|
||||
|
||||
## Launch the TUI
|
||||
|
||||
Generated
+53
-1788
File diff suppressed because it is too large
Load Diff
+4
-7
@@ -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.83.0",
|
||||
"version": "3.84.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"workspaces": [
|
||||
"."
|
||||
@@ -449,7 +449,6 @@
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/chai": "^5.0.1",
|
||||
"@types/clone-deep": "^4.0.4",
|
||||
"@types/diff": "^5.2.1",
|
||||
"@types/get-folder-size": "^3.0.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/mocha": "^10.0.7",
|
||||
@@ -539,7 +538,7 @@
|
||||
"chrome-launcher": "^1.1.2",
|
||||
"clone-deep": "^4.0.1",
|
||||
"default-shell": "^2.2.0",
|
||||
"diff": "^5.2.0",
|
||||
"diff": "8.0.4",
|
||||
"exceljs": "^4.4.0",
|
||||
"execa": "^9.5.2",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
@@ -592,10 +591,8 @@
|
||||
"vite": "^7.1.11",
|
||||
"js-yaml": "^4.1.1",
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"protobufjs": "7.5.5",
|
||||
"mocha": {
|
||||
"diff": ">=8.0.3"
|
||||
}
|
||||
"protobufjs": "7.5.8",
|
||||
"diff": "8.0.4"
|
||||
},
|
||||
"c8": {
|
||||
"reporter": [
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
//
|
||||
// swapIn is idempotent: if README.md already matches README.marketplace.md
|
||||
// (e.g., an outer wrapper has already swapped), it no-ops instead of erroring
|
||||
// on the backup file. This lets nested callers (publish.yml wrapping the whole
|
||||
// on the backup file. This lets nested callers (ext-vscode-publish-stable.yml wrapping the whole
|
||||
// step, plus the individual npm scripts swapping internally) coexist safely.
|
||||
|
||||
import fs from "node:fs"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// flag to point it elsewhere, so we swap README.marketplace.md into place
|
||||
// first and restore the original on the way out. The swap helper is
|
||||
// idempotent, so this is safe to run nested under another wrapper (e.g., the
|
||||
// CI step in .github/workflows/publish.yml that also packages a .vsix for the
|
||||
// CI step in .github/workflows/ext-vscode-publish-stable.yml that also packages a .vsix for the
|
||||
// GitHub release artifact before invoking this script).
|
||||
//
|
||||
// Usage:
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"source": "cline-extension-remote-config",
|
||||
"version": "v1",
|
||||
"remoteConfig": {
|
||||
"version": "v1",
|
||||
"providerSettings": {},
|
||||
"telemetryEnabled": true,
|
||||
"kanbanEnabled": true,
|
||||
"mcpMarketplaceEnabled": true,
|
||||
"yoloModeAllowed": true,
|
||||
"openTelemetryEnabled": false,
|
||||
"openTelemetryOtlpInsecure": false,
|
||||
"enterpriseTelemetry": {
|
||||
"promptUploading": {
|
||||
"enabled": true,
|
||||
"type": "s3_access_keys",
|
||||
"s3AccessSettings": {
|
||||
"bucket": "cline-tasks",
|
||||
"accessKeyId": "AKIA44Y6CGDEKMATQQE2",
|
||||
"secretAccessKey": "sbQZ9qN5DdgIDL501gsK0ErV6+/ac8Yja27SbyMa",
|
||||
"region": "us-west-2"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"managedInstructions": [],
|
||||
"metadata": {
|
||||
"organizationId": "org-01JZPE4CHW5XTP73W95762121Y",
|
||||
"paths": {
|
||||
"pluginName": "remote-config",
|
||||
"pluginPath": "/Users/beatrix/dev/cline/sdk/.cline/remote-config",
|
||||
"workflowsPath": "/Users/beatrix/dev/cline/sdk/.cline/remote-config/workflows",
|
||||
"skillsPath": "/Users/beatrix/dev/cline/sdk/.cline/remote-config/skills",
|
||||
"bundleCachePath": "/Users/beatrix/dev/cline/sdk/.cline/remote-config/cache/bundle.json",
|
||||
"manifestPath": "/Users/beatrix/dev/cline/sdk/.cline/remote-config/managed.json",
|
||||
"rulesFilePath": "/Users/beatrix/dev/cline/sdk/.cline/remote-config/rules.md"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"source": "cline-extension-remote-config",
|
||||
"version": "v1",
|
||||
"files": []
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/.github/ @saoudrizwan @abeatrix @BarreiroT
|
||||
@@ -1 +0,0 @@
|
||||
22
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"biomejs.biome",
|
||||
"oven.bun-vscode"
|
||||
]
|
||||
}
|
||||
Vendored
-132
@@ -1,132 +0,0 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Run VS Code Extension",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/apps/examples/vscode",
|
||||
"--disable-extensions"
|
||||
],
|
||||
"outFiles": ["${workspaceFolder}/apps/examples/vscode/dist/**/*.js"],
|
||||
"preLaunchTask": "build-vscode-extension"
|
||||
},
|
||||
{
|
||||
"name": "Run VS Code Extension (Dev Webview)",
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/apps/examples/vscode",
|
||||
"--disable-extensions"
|
||||
],
|
||||
"outFiles": ["${workspaceFolder}/apps/examples/vscode/dist/**/*.js"],
|
||||
"env": {
|
||||
"VITE_DEV_SERVER_URL": "http://localhost:5173"
|
||||
},
|
||||
"preLaunchTask": "dev-all-vscode",
|
||||
"postDebugTask": "kill-vscode-dev"
|
||||
},
|
||||
{
|
||||
"name": "Launch Bun CLI (Prompt)",
|
||||
"type": "bun",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/apps/cli",
|
||||
"runtime": "bun",
|
||||
"runtimeArgs": ["--conditions=development"],
|
||||
"program": "${workspaceFolder}/apps/cli/src/index.ts",
|
||||
"args": ["${input:cliPrompt}"],
|
||||
"env": {
|
||||
"CLINE_BUILD_ENV": "development"
|
||||
},
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Launch RPC Server",
|
||||
"type": "bun",
|
||||
"request": "launch",
|
||||
"cwd": "${workspaceFolder}/apps/cli",
|
||||
"runtime": "bun",
|
||||
"runtimeArgs": ["--conditions=development"],
|
||||
"program": "${workspaceFolder}/apps/cli/src/index.ts",
|
||||
"args": ["rpc", "start"],
|
||||
"env": {
|
||||
"CLINE_BUILD_ENV": "development",
|
||||
"CLINE_DEBUG_PORT_BASE": "9230"
|
||||
},
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Attach RPC Runtime (9230)",
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"url": "ws://127.0.0.1:9230",
|
||||
"localRoot": "${workspaceFolder}",
|
||||
"remoteRoot": "${workspaceFolder}",
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Attach Hook Worker (9231)",
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"url": "ws://127.0.0.1:9231",
|
||||
"localRoot": "${workspaceFolder}",
|
||||
"remoteRoot": "${workspaceFolder}",
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Attach Plugin Sandbox (9232)",
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"url": "ws://127.0.0.1:9232",
|
||||
"localRoot": "${workspaceFolder}",
|
||||
"remoteRoot": "${workspaceFolder}",
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Attach Connector Child (9233)",
|
||||
"type": "bun",
|
||||
"request": "attach",
|
||||
"url": "ws://127.0.0.1:9233",
|
||||
"localRoot": "${workspaceFolder}",
|
||||
"remoteRoot": "${workspaceFolder}",
|
||||
"presentation": {
|
||||
"hidden": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "Launch RPC Server Debugger",
|
||||
"configurations": ["Launch RPC Server", "Attach RPC Runtime (9230)"]
|
||||
},
|
||||
{
|
||||
"name": "Launch CLI Debugger",
|
||||
"configurations": [
|
||||
"Launch Bun CLI (Prompt)",
|
||||
"Attach RPC Runtime (9230)",
|
||||
"Attach Hook Worker (9231)",
|
||||
"Attach Plugin Sandbox (9232)",
|
||||
"Attach Connector Child (9233)"
|
||||
]
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "cliPrompt",
|
||||
"type": "promptString",
|
||||
"description": "Prompt to send to the CLI",
|
||||
"default": "hey"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
-11
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"files.insertFinalNewline": true,
|
||||
"biome.enabled": true,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.removeUnused.biome": "always",
|
||||
"source.removeUnusedImports": "always",
|
||||
"source.organizeImports.biome": "always"
|
||||
}
|
||||
}
|
||||
Vendored
-88
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "build-sdk",
|
||||
"type": "shell",
|
||||
"command": "bun run build:sdk",
|
||||
"problemMatcher": ["$tsc"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "build-vscode-extension",
|
||||
"type": "shell",
|
||||
"command": "bun run build",
|
||||
"group": {
|
||||
"kind": "build",
|
||||
"isDefault": false
|
||||
},
|
||||
"dependsOn": ["build-sdk"],
|
||||
"problemMatcher": ["$tsc"],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/examples/vscode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "watch-vscode-extension",
|
||||
"type": "shell",
|
||||
"command": "bun run watch",
|
||||
"isBackground": true,
|
||||
"dependsOn": ["build-sdk"],
|
||||
"problemMatcher": {
|
||||
"pattern": {
|
||||
"regexp": "^.*$",
|
||||
"file": 0,
|
||||
"location": 0,
|
||||
"message": 0
|
||||
},
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": "^Bundled",
|
||||
"endsPattern": "^\\s*extension\\.js"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/examples/vscode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "dev-vscode-webview",
|
||||
"type": "shell",
|
||||
"command": "cd src/webview && bun run dev",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"pattern": {
|
||||
"regexp": "^.*$",
|
||||
"file": 0,
|
||||
"location": 0,
|
||||
"message": 0
|
||||
},
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": "VITE",
|
||||
"endsPattern": "Local:"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/examples/vscode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "dev-all-vscode",
|
||||
"dependsOn": ["watch-vscode-extension", "dev-vscode-webview"],
|
||||
"dependsOrder": "parallel",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"label": "kill-vscode-dev",
|
||||
"type": "shell",
|
||||
"command": "kill $(lsof -ti:5173) 2>/dev/null; exit 0",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "silent"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+32
-1
@@ -8,6 +8,12 @@ alwaysApply: true
|
||||
|
||||
Quick-reference for active development. For onboarding, workspace setup, publishing, and detailed workflow see [CONTRIBUTING.md](./CONTRIBUTING.md). For architecture and runtime flows see [ARCHITECTURE.md](./ARCHITECTURE.md). For API details see [DOC.md](./DOC.md).
|
||||
|
||||
## Repository Scope
|
||||
|
||||
This file applies to the SDK workspace rooted at this directory (`sdk/`). In this repo, "root" means the SDK workspace root unless explicitly stated otherwise. Ignore the legacy repository root for SDK development except for Git operations or repo-wide searches that are explicitly needed.
|
||||
|
||||
Run SDK commands from `sdk/`, not from the legacy repository root. Do not run direct root-level commands such as `bun test sdk/...`; they bypass the SDK workspace setup and can fail to resolve `workspace:*` packages correctly.
|
||||
|
||||
## Package Boundaries
|
||||
|
||||
### Published SDK Packages
|
||||
@@ -44,7 +50,20 @@ Route changes to the package that owns the concern:
|
||||
|
||||
## Verifying Changes
|
||||
|
||||
Root commands for cross-package confidence:
|
||||
Before testing in a fresh worktree, install SDK dependencies from the SDK workspace root:
|
||||
|
||||
```sh
|
||||
cd sdk
|
||||
bun install --frozen-lockfile
|
||||
```
|
||||
|
||||
SDK package exports resolve sibling packages through compiled `dist/` files. If `dist/` is missing, build the SDK packages before running package tests:
|
||||
|
||||
```sh
|
||||
bun run build:sdk
|
||||
```
|
||||
|
||||
SDK-root commands for cross-package confidence:
|
||||
|
||||
```sh
|
||||
bun run types # typecheck all packages
|
||||
@@ -52,6 +71,18 @@ bun run test # run all tests
|
||||
bun run check # lint + build + typecheck + check-publish
|
||||
```
|
||||
|
||||
For focused verification, prefer workspace package scripts from the SDK root:
|
||||
|
||||
```sh
|
||||
bun -F @cline/shared test
|
||||
bun -F @cline/llms test
|
||||
bun -F @cline/agents test
|
||||
bun -F @cline/core test:unit
|
||||
bun -F @cline/cli test:unit
|
||||
```
|
||||
|
||||
If a focused test command fails with a missing `@cline/*` export or missing `dist/` file, build the relevant dependency package or run `bun run build:sdk`, then rerun the same test command. Treat that as a workspace setup issue, not as evidence of a source-code bug.
|
||||
|
||||
If you touch hub/bootstrap/session flows, please update `ARCHITECTURE.md`.
|
||||
|
||||
## Practical Guidance
|
||||
|
||||
@@ -244,6 +244,7 @@ Design implication:
|
||||
- transport-specific translation belongs inside concrete hosts, not in top-level orchestration
|
||||
- `RuntimeHost` inputs stay transport-safe, while `ClineCore.start(...)` is the app-facing facade that normalizes broad local config before delegation
|
||||
- `RuntimeSessionConfig` is transport-neutral across local, shared hub, and remote hub modes; host-local bootstrap concerns stay under `localRuntime`
|
||||
- Per-turn connection updates are carried on `RuntimeHost.runTurn(...)` as transport-safe data. Local execution applies them before the turn starts, forwards the resolved connection defaults to delegated agents and teammates, and persists provider/model changes back to the session row and manifest so history reflects the latest active model.
|
||||
- client-local runtime behaviors that must survive hub mode, such as `defaultToolExecutors`, are attached at session start and proxied through hub capability requests instead of changing host selection
|
||||
- pending prompt list/update/delete are exposed through the grouped
|
||||
`ClineCore.pendingPrompts` service. Usage summary lookup and active-session
|
||||
|
||||
+3
-3
@@ -117,7 +117,7 @@ Use this for normal releases. Merge the release commit to `main`, create and pus
|
||||
```sh
|
||||
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
|
||||
git push origin refs/tags/cli-vX.Y.Z
|
||||
gh workflow run publish-cli.yaml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
|
||||
gh workflow run cli-publish.yml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
|
||||
```
|
||||
|
||||
The workflow checks out the provided `cli-vX.Y.Z` tag, verifies it matches `apps/cli/package.json`, builds the platform packages, publishes to npm with the `latest` dist-tag, creates the GitHub release, and posts to Slack.
|
||||
@@ -140,7 +140,7 @@ The local helper verifies the working tree is clean, verifies `cli-vX.Y.Z` point
|
||||
Nightly release:
|
||||
|
||||
```sh
|
||||
gh workflow run publish-cli.yaml -f publish_target=nightly
|
||||
gh workflow run cli-publish.yml -f publish_target=nightly
|
||||
```
|
||||
|
||||
Nightly also runs on a schedule. It publishes `X.Y.Z-nightly.TIMESTAMP` to npm with the `nightly` dist-tag and skips if there were no commits in the last 24 hours unless forced.
|
||||
@@ -185,7 +185,7 @@ bun pm ls @cline/core @cline/agents @cline/llms
|
||||
|
||||
### CI
|
||||
|
||||
The CI publish workflow (`.github/workflows/publish-sdk.yaml`) follows the same order: build → version → check-publish → publish (shared → llms → agents → core). It supports `nightly` and `latest` channels and is triggered by manual dispatch or a daily cron.
|
||||
The CI publish workflow (`.github/workflows/sdk-publish.yml`) follows the same order: build → version → check-publish → publish (shared → llms → agents → core). It supports `nightly` and `latest` channels and is triggered by manual dispatch or a daily cron.
|
||||
|
||||
### Root Automation Scope
|
||||
|
||||
|
||||
-201
@@ -1,201 +0,0 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2026 Cline Bot Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+1
-1
@@ -234,7 +234,7 @@ cline "Refactor the auth module to use JWT"
|
||||
cline schedule create "PR summary" --cron "0 9 * * MON-FRI" --prompt "Summarize open PRs"
|
||||
|
||||
# Connect a Telegram bot created with @BotFather
|
||||
cline connect telegram -m my_bot -k "$TELEGRAM_BOT_TOKEN"
|
||||
cline connect telegram -k "$TELEGRAM_BOT_TOKEN"
|
||||
# Then send /help or /start to the bot in Telegram
|
||||
```
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
We actively patch only the most recent minor release of Cline. Older versions receive fixes at our discretion.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions.
|
||||
|
||||
To report a security issue, please submit your report through our [Bugcrowd Vulnerability Disclosure Program](https://bugcrowd.com/engagements/clinebot-vdp-ess). Bugcrowd will manage communication and triage on our behalf.
|
||||
|
||||
When reporting, please include:
|
||||
|
||||
- A short summary of the issue
|
||||
- Steps to reproduce or a proof of concept
|
||||
- Any logs, stack traces, or screenshots that might help us understand the problem
|
||||
|
||||
Please keep the details private until a resolution has been reached.
|
||||
|
||||
## Escalation
|
||||
|
||||
If you are unable to submit through Bugcrowd, you may send an email to security@cline.bot.
|
||||
|
||||
Thank you for helping us keep Cline users safe.
|
||||
@@ -20,7 +20,7 @@ The skill should guide the user through one release preparation flow, then offer
|
||||
- Nightly release version: `X.Y.Z-nightly.TIMESTAMP`.
|
||||
- Release prep includes approved release notes, a version bump, and an `apps/cli/CHANGELOG.md` update.
|
||||
- Publish paths:
|
||||
- GitHub workflow: `.github/workflows/publish-cli.yaml`.
|
||||
- GitHub workflow: `.github/workflows/cli-publish.yml`.
|
||||
- Local publish helper: `bun release cli`.
|
||||
- npm dist-tags and git tags are separate. `--tag latest` and `--tag nightly` are npm registry channels. `cli-vX.Y.Z` is a git tag for source history and GitHub releases.
|
||||
- The GitHub main release workflow runs from `main`, requires an existing `cli-vX.Y.Z` tag, checks out that tag, and publishes from it.
|
||||
@@ -46,7 +46,7 @@ Find the latest CLI tag. If there is no `cli-v*` tag, use the first relevant CLI
|
||||
2. Collect release commits.
|
||||
|
||||
```sh
|
||||
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli packages scripts .github/workflows/publish-cli.yaml
|
||||
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli packages scripts .github/workflows/cli-publish.yml
|
||||
```
|
||||
|
||||
If the release includes broader SDK changes that affect the CLI, also inspect commits outside `apps/cli`.
|
||||
@@ -92,6 +92,8 @@ bun run test
|
||||
bun --cwd apps/cli run build:platforms
|
||||
```
|
||||
|
||||
Known local-only test failure: `src/commands/distribution-package.test.ts > rejects direct source package packing by default` will fail on machines that have `ignore-scripts=true` in `~/.npmrc` (set by the npm supply-chain hardening guide). Bun reads npm's `ignore-scripts` from `~/.npmrc`, so `bun pm pack --dry-run` skips the source-publish `prepack` guard and exits 0, which the test reads as a failure. CI does not set `ignore-scripts`, so the test passes there. Confirm by running `bun pm pack --dry-run` directly: with `~/.npmrc` in place it exits 0 with no guard output; with `~/.npmrc` moved aside it exits 1 and prints the guard message. This is not a release blocker by itself, but it does mean the local-publish path (`bun release cli`) will also bypass the source-publish guard on this machine; prefer the GitHub Actions publish path on machines with `ignore-scripts=true` set globally, or temporarily unset it (`npm config delete ignore-scripts` or `mv ~/.npmrc ~/.npmrc.bak`) for the duration of a local publish.
|
||||
|
||||
7. Commit release changes.
|
||||
|
||||
Only after the user approves the notes and version:
|
||||
@@ -126,20 +128,20 @@ Ask the user which path to use:
|
||||
For GitHub main release:
|
||||
|
||||
```sh
|
||||
gh workflow run publish-cli.yaml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
|
||||
gh run list --workflow=publish-cli.yaml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
|
||||
gh workflow run cli-publish.yml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
|
||||
gh run list --workflow=cli-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
For GitHub nightly release:
|
||||
|
||||
```sh
|
||||
gh workflow run publish-cli.yaml -f publish_target=nightly
|
||||
gh workflow run cli-publish.yml -f publish_target=nightly
|
||||
```
|
||||
|
||||
For forced GitHub nightly release:
|
||||
|
||||
```sh
|
||||
gh workflow run publish-cli.yaml -f publish_target=nightly -f force_nightly_publish=true
|
||||
gh workflow run cli-publish.yml -f publish_target=nightly -f force_nightly_publish=true
|
||||
```
|
||||
|
||||
For local publish:
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.13
|
||||
|
||||
- Show a loading dialog while resuming a session from history so the TUI no longer appears frozen during the load.
|
||||
- Speed up the `/clear` command by deferring new session creation until you send the next prompt, so clearing no longer blocks on spinning up an empty session.
|
||||
|
||||
## 3.0.12
|
||||
|
||||
- Show a loading dialog while the config screen switches provider or model so the transition no longer looks frozen.
|
||||
- Render the ask question tool prompt inline with the conversation so the question and suggested answers stay attached to the assistant turn that asked them, instead of appearing in a separate modal.
|
||||
- Allow manual `cline update` runs to install the latest published version immediately, bypassing the release age gate that delays automatic updates.
|
||||
- Refresh the bundled SDK to 0.0.42, updating the model catalog.
|
||||
|
||||
## 3.0.11
|
||||
|
||||
- Fix a regression in the ChatGPT OAuth provider where requests failed with `max_output_tokens not supported`, by restoring the full output token budget instead of applying an implicit cap.
|
||||
- Hide the `Space toggle` hint in the config footer when the highlighted row is not toggleable (rules, agents, hooks).
|
||||
- Authenticate Vertex Gemini through Google auth when `gcp.projectId` is configured, and surface the full Vertex model list instead of only Claude models.
|
||||
- Include tool names in tool result content blocks so message logs and session history consistently track which tool produced each result.
|
||||
|
||||
## 3.0.10
|
||||
|
||||
- Install plugins from `file://` URLs in addition to npm and git sources.
|
||||
- Show Ollama API key note in TUI settings so users know when to provide an API key.
|
||||
- Keep interactive sessions alive when idle or awaiting approval instead of treating them as ended, and stop reading message files for every session when `hydrate: false`.
|
||||
- Add Poolside as a provider.
|
||||
- Add Gemini 3.5 Flash to the Gemini provider model list.
|
||||
- Auto-detect Telegram bot username from the bot token so the Telegram connector no longer requires it to be configured separately.
|
||||
- Notify connectors when a scheduled execution fails, not just when it succeeds.
|
||||
- Bake OTEL telemetry variables into the CLI at build time so telemetry works in nightly and production builds.
|
||||
- Preserve model output token limits from the SDK model catalog so context window math matches the upstream provider.
|
||||
- Soften the visual treatment of rejected tool calls in the TUI.
|
||||
- Hide the skills tool from the system prompt when skills are disabled, and refresh slash commands after toggling a skill.
|
||||
- Restore AWS Bedrock profile-based auth during legacy config migration so profiles set via `awsAuthentication: "profile"` are preserved without `awsUseProfile`.
|
||||
- Cache global settings reads keyed by file mtime so repeated reads skip the JSON parse and zod validation on the hot path.
|
||||
|
||||
## 3.0.9
|
||||
|
||||
- Speed up CLI startup with plugins by loading sandboxed plugins concurrently and caching plugin tool descriptors per plugin, provider, and model.
|
||||
- Speed up plugin and tool config toggles by updating the TUI optimistically and persisting changes without reloading the full config or reimporting plugins.
|
||||
- Restore fuzzy ranking for the @-mention file picker so the most relevant files appear first.
|
||||
- Keep the interactive CLI session alive after cancelling a task instead of tearing the session down.
|
||||
- Accept dash-prefixed prompts when passed after `--`, so prompts starting with `-` are no longer parsed as flags.
|
||||
- Recover from hub abort cleanup failures so a cancel that hits an error no longer crashes the runtime host.
|
||||
- Route GLM thinking through provider metadata so thinking-enabled GLM models behave correctly through the gateway.
|
||||
|
||||
## 3.0.8
|
||||
|
||||
- Use Telegram numeric participant ids so renamed users stay linked to the same participant in the Telegram connector.
|
||||
- Keep failed plugins visible in the config UI with their load/setup phase and error details so broken plugin definitions are easier to diagnose.
|
||||
- Move the Create Session Fork shortcut from Opt+F to Opt+R so terminal word-right navigation works again.
|
||||
- Fix AWS Bedrock region and profile detection in the CLI onboarding, and surface bearer-token and additional Bedrock config fields in the provider config screens.
|
||||
- Fix inflated token usage counts caused by AgentRuntime.execute() not resetting usage between calls, which the local runtime host was then double-counting on top of the session baseline.
|
||||
|
||||
## 3.0.7
|
||||
|
||||
- Skip the ChatGPT OAuth model refresh on session startup so the CLI launches without the extra network round-trip.
|
||||
- Align the ChatGPT OAuth model catalog with the Codex provider list so the available models match the subscription tier.
|
||||
|
||||
## 3.0.6
|
||||
|
||||
- Fix ChatGPT provider model list to include the codex variants and the gpt-5.2, gpt-5.4, and gpt-5.4-mini subscription models.
|
||||
|
||||
## 3.0.5
|
||||
|
||||
- Show plugin-provided tools and slash commands in the CLI settings dialog by hydrating them through the sandbox.
|
||||
- Preserve hydrated plugin tools and config reload options when toggling settings, so they no longer disappear after a toggle.
|
||||
|
||||
## 3.0.4
|
||||
|
||||
- Improve light theme TUI colors so chat, status bar, tool output, and syntax highlighting render with better contrast on light terminals.
|
||||
- Fix plugin tools failing in the production npm build by bundling the SDK deps plugins import at runtime.
|
||||
|
||||
## 3.0.3
|
||||
|
||||
- Add `--worktree` flag that auto-creates a fresh git worktree under `~/.cline/worktrees/` and runs the task there. Works with `--taskId` and `--continue` so you can resume a task in an isolated worktree to try a different approach.
|
||||
|
||||
@@ -95,7 +95,7 @@ Use this path for normal releases.
|
||||
```bash
|
||||
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
|
||||
git push origin refs/tags/cli-vX.Y.Z
|
||||
gh workflow run publish-cli.yaml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
|
||||
gh workflow run cli-publish.yml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
|
||||
```
|
||||
|
||||
This path requires the release commit to be on `main` and the matching `cli-vX.Y.Z` tag to exist before the workflow runs. The workflow checks out the tag, publishes to npm with the `latest` dist-tag, creates the GitHub release, and posts to Slack.
|
||||
@@ -125,7 +125,7 @@ bun release cli --tag next
|
||||
|
||||
## CI Workflow
|
||||
|
||||
The GitHub workflow at `.github/workflows/publish-cli.yaml` automates publishing:
|
||||
The GitHub workflow at `.github/workflows/cli-publish.yml` automates publishing:
|
||||
|
||||
- Main releases are manual. Select `publish_target=main` and set `confirm_publish=publish`.
|
||||
- Main releases require `git_tag=cli-vX.Y.Z`, check out that tag, verify it matches `apps/cli/package.json`, run tests, build all platform packages, publish to npm with the `latest` dist-tag using trusted publishing, create a GitHub release, and post to Slack.
|
||||
|
||||
@@ -169,7 +169,7 @@ Bridge a chat surface into RPC-backed Cline sessions. Each conversation thread m
|
||||
|
||||
```sh
|
||||
# Telegram (polling mode)
|
||||
cline connect telegram -m my_bot -k 123456:ABCDEF...
|
||||
cline connect telegram -k 123456:ABCDEF...
|
||||
|
||||
# Slack (webhook mode)
|
||||
cline connect slack --bot-token $SLACK_BOT_TOKEN --signing-secret $SLACK_SIGNING_SECRET --base-url https://your-domain.com
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.3",
|
||||
"version": "3.0.13",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -68,26 +68,27 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.16.1",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@chat-adapter/discord": "^4.23.0",
|
||||
"@chat-adapter/gchat": "^4.23.0",
|
||||
"@chat-adapter/linear": "^4.23.0",
|
||||
"@chat-adapter/slack": "^4.23.0",
|
||||
"@chat-adapter/telegram": "^4.23.0",
|
||||
"@chat-adapter/whatsapp": "^4.23.0",
|
||||
"@clack/prompts": "^1.2.0",
|
||||
"@gramio/format": "^0.7.0",
|
||||
"chat": "^4.23.0",
|
||||
"commander": "^14.0.3",
|
||||
"@opentui-ui/dialog": "^0.1.2",
|
||||
"@opentui/core": "0.1.102",
|
||||
"@opentui/react": "0.1.102",
|
||||
"@opentui-ui/dialog": "^0.1.2",
|
||||
"chat": "^4.23.0",
|
||||
"commander": "^14.0.3",
|
||||
"fzf": "^0.5.2",
|
||||
"marked": "^15.0.12",
|
||||
"open": "^10.2.0",
|
||||
"opentui-spinner": "^0.0.6",
|
||||
"pino": "^10.3.1",
|
||||
"react": "19.2.4",
|
||||
"react-reconciler": "0.32.0",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"react-reconciler": "0.32.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^4.1.11"
|
||||
},
|
||||
|
||||
@@ -13,6 +13,30 @@ const cliDir = resolve(import.meta.dir, "..");
|
||||
const rootDir = resolve(cliDir, "../..");
|
||||
process.chdir(cliDir);
|
||||
|
||||
// Telemetry / OTEL environment variables that should be baked into the
|
||||
// compiled binary at build time. Mirrors the list of secrets injected by the
|
||||
// `cli-publish` GitHub Actions workflow. These are inlined via Bun's `define`
|
||||
// so the CLI ships with the production telemetry configuration without
|
||||
// requiring the end user to set any env vars.
|
||||
const BUILD_TIME_INLINED_ENV_VARS = [
|
||||
"TELEMETRY_SERVICE_API_KEY",
|
||||
"ERROR_SERVICE_API_KEY",
|
||||
"OTEL_TELEMETRY_ENABLED",
|
||||
"OTEL_LOGS_EXPORTER",
|
||||
"OTEL_METRICS_EXPORTER",
|
||||
"OTEL_EXPORTER_OTLP_PROTOCOL",
|
||||
"OTEL_EXPORTER_OTLP_ENDPOINT",
|
||||
"OTEL_EXPORTER_OTLP_HEADERS",
|
||||
] as const;
|
||||
|
||||
function buildInlinedEnvDefines(): Record<string, string> {
|
||||
const defines: Record<string, string> = {};
|
||||
for (const name of BUILD_TIME_INLINED_ENV_VARS) {
|
||||
defines[`process.env.${name}`] = JSON.stringify(process.env[name] ?? "");
|
||||
}
|
||||
return defines;
|
||||
}
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(cliDir, "package.json"), "utf-8"));
|
||||
const version: string = pkg.version;
|
||||
const repository: unknown = pkg.repository;
|
||||
@@ -128,6 +152,9 @@ async function buildCompiledBinary(input: {
|
||||
external: ["@anthropic-ai/vertex-sdk"],
|
||||
define: {
|
||||
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + parserWorkerPath,
|
||||
// Inline telemetry/OTEL env vars at build time so the compiled
|
||||
// binary ships with production telemetry configuration baked in.
|
||||
...buildInlinedEnvDefines(),
|
||||
},
|
||||
throw: false,
|
||||
});
|
||||
|
||||
@@ -41,6 +41,14 @@ const expectedPlatformPackages = [
|
||||
"@cline/cli-windows-x64",
|
||||
] as const;
|
||||
|
||||
const hostSdkPackages = [
|
||||
{ name: "@cline/sdk", directory: "sdk" },
|
||||
{ name: "@cline/core", directory: "core" },
|
||||
{ name: "@cline/agents", directory: "agents" },
|
||||
{ name: "@cline/llms", directory: "llms" },
|
||||
{ name: "@cline/shared", directory: "shared" },
|
||||
] as const;
|
||||
|
||||
interface PlatformPackageManifest {
|
||||
name: string;
|
||||
version: string;
|
||||
@@ -68,6 +76,26 @@ function isPlatformPackageManifest(
|
||||
);
|
||||
}
|
||||
|
||||
function readPackageVersion(name: string, packageJsonPath: string): string {
|
||||
const pkg: unknown = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
||||
if (!isRecord(pkg) || pkg.name !== name || typeof pkg.version !== "string") {
|
||||
console.error(`Invalid package manifest for ${name}: ${packageJsonPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
function buildHostSdkDependencies(): Record<string, string> {
|
||||
const dependencies: Record<string, string> = {};
|
||||
for (const pkg of hostSdkPackages) {
|
||||
dependencies[pkg.name] = readPackageVersion(
|
||||
pkg.name,
|
||||
join(cliDir, "../../packages", pkg.directory, "package.json"),
|
||||
);
|
||||
}
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
function removePackedTarballs(dir: string): void {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
if (entry.endsWith(".tgz")) {
|
||||
@@ -91,6 +119,28 @@ async function npmPackageVersionExists(
|
||||
return result.exitCode === 0;
|
||||
}
|
||||
|
||||
async function verifyPublishedDependencies(
|
||||
dependencies: Record<string, string>,
|
||||
): Promise<void> {
|
||||
const missingDependencies: string[] = [];
|
||||
for (const [name, version] of Object.entries(dependencies).sort()) {
|
||||
if (!(await npmPackageVersionExists(name, version))) {
|
||||
missingDependencies.push(`${name}@${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingDependencies.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Wrapper package dependencies are not published:");
|
||||
for (const dependency of missingDependencies) {
|
||||
console.error(` ${dependency}`);
|
||||
}
|
||||
console.error("Publish the SDK packages before publishing the CLI wrapper.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function publishPackage(input: {
|
||||
name: string;
|
||||
version: string;
|
||||
@@ -175,6 +225,7 @@ if (sourceVersion !== version) {
|
||||
}
|
||||
const sourceRepository =
|
||||
"repository" in sourcePkgRecord ? sourcePkgRecord.repository : undefined;
|
||||
const hostSdkDependencies = buildHostSdkDependencies();
|
||||
|
||||
console.log(`Publishing ${wrapperPackageName} v${version}`);
|
||||
console.log(` Tag: ${npmTag}`);
|
||||
@@ -184,6 +235,10 @@ for (const name of Object.keys(binaries)) {
|
||||
console.log(` ${name}`);
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
await verifyPublishedDependencies(hostSdkDependencies);
|
||||
}
|
||||
|
||||
// Step 1: Publish platform-specific packages (in parallel)
|
||||
console.log("\nPublishing platform packages...");
|
||||
const platformTasks = Object.keys(binaries)
|
||||
@@ -270,6 +325,7 @@ const wrapperPackageJson = {
|
||||
scripts: {
|
||||
postinstall: "node ./postinstall.mjs || true",
|
||||
},
|
||||
dependencies: hostSdkDependencies,
|
||||
optionalDependencies: binaries,
|
||||
};
|
||||
|
||||
|
||||
@@ -14,13 +14,17 @@ import {
|
||||
setClineDir,
|
||||
setHomeDir,
|
||||
} from "@cline/shared/storage";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
installPlugin,
|
||||
parsePluginSource,
|
||||
runPluginInstallCommand,
|
||||
} from "./plugin";
|
||||
|
||||
type FetchCall = (
|
||||
...args: Parameters<typeof fetch>
|
||||
) => ReturnType<typeof fetch>;
|
||||
|
||||
describe("plugin install command", () => {
|
||||
let root = "";
|
||||
let home = "";
|
||||
@@ -44,6 +48,8 @@ describe("plugin install command", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
@@ -85,6 +91,36 @@ describe("plugin install command", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("parses GitHub plugin file URLs as remote sources", () => {
|
||||
expect(
|
||||
parsePluginSource(
|
||||
"https://github.com/cline/cline/blob/main/sdk/examples/plugins/weather-metrics.ts",
|
||||
),
|
||||
).toEqual({
|
||||
type: "remote",
|
||||
url: "https://raw.githubusercontent.com/cline/cline/main/sdk/examples/plugins/weather-metrics.ts",
|
||||
filename: "weather-metrics.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses raw plugin file URLs as remote sources", () => {
|
||||
expect(
|
||||
parsePluginSource(
|
||||
"https://raw.githubusercontent.com/cline/cline/main/sdk/examples/plugins/weather-metrics.ts",
|
||||
),
|
||||
).toEqual({
|
||||
type: "remote",
|
||||
url: "https://raw.githubusercontent.com/cline/cline/main/sdk/examples/plugins/weather-metrics.ts",
|
||||
filename: "weather-metrics.ts",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects HTTP plugin file URLs", () => {
|
||||
expect(() =>
|
||||
parsePluginSource("http://example.com/plugins/weather-metrics.ts"),
|
||||
).toThrow(/must use https/);
|
||||
});
|
||||
|
||||
it("installs a local plugin file into the global plugin root", async () => {
|
||||
const source = join(root, "weather.ts");
|
||||
writeFileSync(
|
||||
@@ -104,6 +140,98 @@ describe("plugin install command", () => {
|
||||
expect(discovered).toEqual(result.entryPaths);
|
||||
});
|
||||
|
||||
it("installs a remote plugin file into the workspace plugin root", async () => {
|
||||
const source =
|
||||
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
|
||||
const fetchMock = vi.fn<FetchCall>(async (input) => {
|
||||
expect(String(input)).toBe(
|
||||
"https://raw.githubusercontent.com/acme/plugins/main/weather-metrics.ts",
|
||||
);
|
||||
return new Response(
|
||||
"export default { name: 'remote-weather', manifest: { capabilities: ['tools'] } };",
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await installPlugin({ source, cwd: workspace });
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.installPath).toContain(
|
||||
join(workspace, ".cline", "plugins", "_installed", "remote"),
|
||||
);
|
||||
expect(result.entryPaths).toHaveLength(1);
|
||||
expect(existsSync(result.entryPaths[0] ?? "")).toBe(true);
|
||||
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
|
||||
"remote-weather",
|
||||
);
|
||||
expect(
|
||||
discoverPluginModulePaths(join(workspace, ".cline", "plugins")),
|
||||
).toEqual(result.entryPaths);
|
||||
});
|
||||
|
||||
it("times out stalled remote plugin downloads", async () => {
|
||||
vi.useFakeTimers();
|
||||
const source =
|
||||
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
|
||||
const fetchMock = vi.fn<FetchCall>((_input, init) => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
init?.signal?.addEventListener("abort", () => {
|
||||
const error = new Error("Aborted");
|
||||
error.name = "AbortError";
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const install = installPlugin({ source, cwd: workspace });
|
||||
const rejection = expect(install).rejects.toThrow(/Timed out downloading/);
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it("rejects remote plugin files with oversized content length", async () => {
|
||||
const source =
|
||||
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
|
||||
const fetchMock = vi.fn<FetchCall>(async () => {
|
||||
return new Response(
|
||||
"export default { name: 'remote-weather', manifest: { capabilities: ['tools'] } };",
|
||||
{
|
||||
headers: {
|
||||
"content-length": String(10 * 1024 * 1024 + 1),
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(installPlugin({ source, cwd: workspace })).rejects.toThrow(
|
||||
/exceeds the 10485760 byte limit/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects remote plugin files that stream past the size limit", async () => {
|
||||
const source =
|
||||
"https://github.com/acme/plugins/blob/main/weather-metrics.ts";
|
||||
const fetchMock = vi.fn<FetchCall>(async () => {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array(10 * 1024 * 1024 + 1));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(installPlugin({ source, cwd: workspace })).rejects.toThrow(
|
||||
/exceeds the 10485760 byte limit/,
|
||||
);
|
||||
});
|
||||
|
||||
it("installs into cwd plugin root when cwd is provided", async () => {
|
||||
const source = join(root, "plugin-package");
|
||||
const npmLogPath = join(root, "npm-install.log");
|
||||
|
||||
@@ -52,12 +52,17 @@ type ParsedPluginSource =
|
||||
host: string;
|
||||
path: string;
|
||||
}
|
||||
| {
|
||||
type: "remote";
|
||||
url: string;
|
||||
filename: string;
|
||||
}
|
||||
| {
|
||||
type: "local";
|
||||
path: string;
|
||||
};
|
||||
|
||||
type PluginInstallSourceType = "npm" | "git" | "local";
|
||||
type PluginInstallSourceType = "npm" | "git" | "local" | "remote";
|
||||
|
||||
interface PluginPackageManifest {
|
||||
cline?: {
|
||||
@@ -72,6 +77,8 @@ interface PluginPackageManifest {
|
||||
|
||||
const INSTALLS_DIRECTORY_NAME = "_installed";
|
||||
const PACKAGE_DIRECTORY_NAME = "package";
|
||||
const REMOTE_PLUGIN_FETCH_TIMEOUT_MS = 30_000;
|
||||
const REMOTE_PLUGIN_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const HOST_PROVIDED_SDK_PREFIX = "@cline/";
|
||||
const DEPENDENCY_FIELDS = [
|
||||
"dependencies",
|
||||
@@ -189,6 +196,80 @@ function splitGitRef(input: string): { repo: string; ref?: string } {
|
||||
};
|
||||
}
|
||||
|
||||
function decodePathSegment(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function filenameFromUrlPath(pathname: string): string {
|
||||
const filename = basename(decodePathSegment(pathname));
|
||||
return filename || "plugin";
|
||||
}
|
||||
|
||||
function isGitHubFilePath(pathname: string): boolean {
|
||||
const parts = pathname.split("/").filter(Boolean);
|
||||
return parts.length >= 5 && (parts[2] === "blob" || parts[2] === "raw");
|
||||
}
|
||||
|
||||
function normalizeRemotePluginFileUrl(
|
||||
source: string,
|
||||
): Extract<ParsedPluginSource, { type: "remote" }> | null {
|
||||
if (!/^https?:\/\//i.test(source)) {
|
||||
return null;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(source);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const filename = filenameFromUrlPath(parsed.pathname);
|
||||
const isPluginFile = isPluginModulePath(filename);
|
||||
const isGitHubFile =
|
||||
(host === "github.com" || host === "www.github.com") &&
|
||||
isGitHubFilePath(parsed.pathname);
|
||||
|
||||
if (parsed.protocol !== "https:") {
|
||||
if (isGitHubFile || host === "raw.githubusercontent.com" || isPluginFile) {
|
||||
throw new Error(`Remote plugin file URLs must use https: ${source}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (host === "github.com" || host === "www.github.com") {
|
||||
const parts = parsed.pathname.split("/").filter(Boolean);
|
||||
if (!isGitHubFile) {
|
||||
return null;
|
||||
}
|
||||
if (!isPluginFile) {
|
||||
throw new Error(`Remote plugin file must be .js or .ts: ${source}`);
|
||||
}
|
||||
const rawParts = [parts[0], parts[1], ...parts.slice(3)];
|
||||
return {
|
||||
type: "remote",
|
||||
url: `https://raw.githubusercontent.com/${rawParts.join("/")}`,
|
||||
filename,
|
||||
};
|
||||
}
|
||||
|
||||
if (host === "raw.githubusercontent.com") {
|
||||
if (!isPluginFile) {
|
||||
throw new Error(`Remote plugin file must be .js or .ts: ${source}`);
|
||||
}
|
||||
return { type: "remote", url: parsed.toString(), filename };
|
||||
}
|
||||
|
||||
if (!isPluginFile) {
|
||||
return null;
|
||||
}
|
||||
return { type: "remote", url: parsed.toString(), filename };
|
||||
}
|
||||
|
||||
function parseGitSource(
|
||||
source: string,
|
||||
options: { force?: boolean } = {},
|
||||
@@ -261,6 +342,13 @@ export function parsePluginSource(
|
||||
if (sourceType === "local") {
|
||||
return { type: "local", path: source };
|
||||
}
|
||||
if (sourceType === "remote") {
|
||||
const remote = normalizeRemotePluginFileUrl(trimmed);
|
||||
if (!remote) {
|
||||
throw new Error(`Invalid remote plugin source: ${source}`);
|
||||
}
|
||||
return remote;
|
||||
}
|
||||
if (trimmed.startsWith("npm:")) {
|
||||
const spec = trimmed.slice("npm:".length).trim();
|
||||
const { name } = parseNpmSpec(spec);
|
||||
@@ -275,6 +363,10 @@ export function parsePluginSource(
|
||||
if (localPathLike) {
|
||||
return { type: "local", path: source };
|
||||
}
|
||||
const remote = normalizeRemotePluginFileUrl(trimmed);
|
||||
if (remote) {
|
||||
return remote;
|
||||
}
|
||||
const git = parseGitSource(trimmed);
|
||||
if (git) {
|
||||
return git;
|
||||
@@ -315,6 +407,14 @@ function getInstallPath(
|
||||
`${sanitizeSegment(parsed.path)}-${hashSource(sourceKey)}`,
|
||||
);
|
||||
}
|
||||
if (parsed.type === "remote") {
|
||||
return join(
|
||||
pluginRoot,
|
||||
INSTALLS_DIRECTORY_NAME,
|
||||
"remote",
|
||||
`${sanitizeSegment(parsed.filename)}-${hashSource(sourceKey)}`,
|
||||
);
|
||||
}
|
||||
return join(
|
||||
pluginRoot,
|
||||
INSTALLS_DIRECTORY_NAME,
|
||||
@@ -330,6 +430,9 @@ function getInstallSourceKey(parsed: ParsedPluginSource, cwd: string): string {
|
||||
if (parsed.type === "git") {
|
||||
return `git:${parsed.repo}${parsed.ref ? `#${parsed.ref}` : ""}`;
|
||||
}
|
||||
if (parsed.type === "remote") {
|
||||
return `remote:${parsed.url}`;
|
||||
}
|
||||
return `local:${resolve(cwd, resolveHomePath(parsed.path))}`;
|
||||
}
|
||||
|
||||
@@ -626,6 +729,96 @@ async function installGitPackage(
|
||||
return packageRoot;
|
||||
}
|
||||
|
||||
function remotePluginSizeLimitError(url: string): Error {
|
||||
return new Error(
|
||||
`Remote plugin file from ${url} exceeds the ${REMOTE_PLUGIN_MAX_BYTES} byte limit`,
|
||||
);
|
||||
}
|
||||
|
||||
function getContentLength(response: Response): number | undefined {
|
||||
const raw = response.headers.get("content-length");
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function readRemotePluginBody(
|
||||
response: Response,
|
||||
url: string,
|
||||
): Promise<Buffer> {
|
||||
const contentLength = getContentLength(response);
|
||||
if (contentLength !== undefined && contentLength > REMOTE_PLUGIN_MAX_BYTES) {
|
||||
throw remotePluginSizeLimitError(url);
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
const body = Buffer.from(await response.text(), "utf8");
|
||||
if (body.byteLength > REMOTE_PLUGIN_MAX_BYTES) {
|
||||
throw remotePluginSizeLimitError(url);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Buffer[] = [];
|
||||
let received = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
const chunk = Buffer.from(value);
|
||||
received += chunk.byteLength;
|
||||
if (received > REMOTE_PLUGIN_MAX_BYTES) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw remotePluginSizeLimitError(url);
|
||||
}
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks, received);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
async function installRemoteFile(
|
||||
parsed: Extract<ParsedPluginSource, { type: "remote" }>,
|
||||
stagingRoot: string,
|
||||
): Promise<string> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, REMOTE_PLUGIN_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(parsed.url, { signal: controller.signal });
|
||||
if (!response.ok) {
|
||||
const suffix = response.statusText ? ` ${response.statusText}` : "";
|
||||
throw new Error(
|
||||
`Failed to download plugin file from ${parsed.url}: ${response.status}${suffix}`,
|
||||
);
|
||||
}
|
||||
const body = await readRemotePluginBody(response, parsed.url);
|
||||
mkdirSync(stagingRoot, { recursive: true });
|
||||
await writeFile(join(stagingRoot, parsed.filename), body);
|
||||
return stagingRoot;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error(
|
||||
`Timed out downloading plugin file from ${parsed.url} after ${REMOTE_PLUGIN_FETCH_TIMEOUT_MS}ms`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function installLocalPackage(
|
||||
parsed: Extract<ParsedPluginSource, { type: "local" }>,
|
||||
stagingRoot: string,
|
||||
@@ -737,6 +930,8 @@ export async function installPlugin(
|
||||
packageRoot = await installNpmPackage(parsed, stagingRoot, npmCommand);
|
||||
} else if (parsed.type === "git") {
|
||||
packageRoot = await installGitPackage(parsed, stagingRoot, npmCommand);
|
||||
} else if (parsed.type === "remote") {
|
||||
packageRoot = await installRemoteFile(parsed, stagingRoot);
|
||||
} else {
|
||||
packageRoot = await installLocalPackage(
|
||||
parsed,
|
||||
@@ -747,7 +942,8 @@ export async function installPlugin(
|
||||
}
|
||||
|
||||
const entryPaths =
|
||||
parsed.type === "local" && packageRoot === stagingRoot
|
||||
(parsed.type === "local" || parsed.type === "remote") &&
|
||||
packageRoot === stagingRoot
|
||||
? collectPluginEntries(stagingRoot).map(
|
||||
(entry) => `./${toPosixPath(relative(stagingRoot, entry))}`,
|
||||
)
|
||||
|
||||
@@ -232,7 +232,7 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
|
||||
if (opts.id !== undefined) result.id = opts.id;
|
||||
|
||||
// Positional args → prompt
|
||||
const positional = program.args.filter((a) => !a.startsWith("-"));
|
||||
const positional = program.args;
|
||||
if (positional.length > 0) {
|
||||
result.prompt = positional.join(" ");
|
||||
}
|
||||
|
||||
@@ -121,6 +121,74 @@ describe("runScheduleCommand list output", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("runScheduleCommand create delivery metadata", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("maps --delivery-bot to delivery.userName", async () => {
|
||||
mockEnsureCliHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "test-token",
|
||||
});
|
||||
mockSendHubCommand.mockResolvedValue({
|
||||
ok: true,
|
||||
payload: { schedule: { scheduleId: "sched_delivery" } },
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const code = await runScheduleCommand(
|
||||
[
|
||||
"create",
|
||||
"Daily summary",
|
||||
"--cron",
|
||||
"0 9 * * *",
|
||||
"--prompt",
|
||||
"Summarize yesterday",
|
||||
"--workspace",
|
||||
"/tmp/workspace",
|
||||
"--delivery-adapter",
|
||||
"telegram",
|
||||
"--delivery-bot",
|
||||
"my_bot",
|
||||
"--delivery-thread",
|
||||
"telegram:123456789",
|
||||
"--address",
|
||||
"127.0.0.1:25463",
|
||||
],
|
||||
{
|
||||
writeln: (text?: string) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: (text: string) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual(['{\n "scheduleId": "sched_delivery"\n}']);
|
||||
expect(mockSendHubCommand).toHaveBeenCalledWith(
|
||||
{ host: "127.0.0.1", port: 25463, pathname: "/hub" },
|
||||
{
|
||||
clientId: "cline-schedule",
|
||||
command: "schedule.create",
|
||||
payload: expect.objectContaining({
|
||||
metadata: {
|
||||
delivery: {
|
||||
adapter: "telegram",
|
||||
threadId: "telegram:123456789",
|
||||
userName: "my_bot",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runScheduleCommand import", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -2,7 +2,11 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getInstallationInfo, PackageManager } from "./update";
|
||||
import {
|
||||
getInstallationInfo,
|
||||
PackageManager,
|
||||
withMinimumReleaseAgeBypass,
|
||||
} from "./update";
|
||||
|
||||
const originalArgv = [...process.argv];
|
||||
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
|
||||
@@ -67,3 +71,37 @@ describe("getInstallationInfo", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("withMinimumReleaseAgeBypass", () => {
|
||||
it("adds the package-manager-specific cooldown bypass", () => {
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"npm install -g cline@latest",
|
||||
PackageManager.NPM,
|
||||
).command,
|
||||
).toBe("npm install -g cline@latest --min-release-age=0");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass("bun add -g cline@latest", PackageManager.BUN)
|
||||
.command,
|
||||
).toBe("bun add -g cline@latest --minimum-release-age=0");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"yarn global add cline@latest",
|
||||
PackageManager.YARN,
|
||||
).command,
|
||||
).toBe("yarn global add cline@latest");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"yarn global add cline@latest",
|
||||
PackageManager.YARN,
|
||||
).env?.YARN_NPM_MINIMAL_AGE_GATE,
|
||||
).toBe("0");
|
||||
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"pnpm add -g cline@latest",
|
||||
PackageManager.PNPM,
|
||||
).env?.pnpm_config_minimum_release_age,
|
||||
).toBe("0");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,11 @@ interface InstallationInfo {
|
||||
updateCommand?: string;
|
||||
}
|
||||
|
||||
interface ManualUpdateCommand {
|
||||
command: string;
|
||||
env?: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
function isNightlyVersion(v: string): boolean {
|
||||
return v.includes("-nightly.");
|
||||
}
|
||||
@@ -133,6 +138,34 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
|
||||
};
|
||||
}
|
||||
|
||||
export function withMinimumReleaseAgeBypass(
|
||||
updateCommand: string,
|
||||
packageManager: PackageManager,
|
||||
): ManualUpdateCommand {
|
||||
switch (packageManager) {
|
||||
case PackageManager.NPM:
|
||||
return { command: `${updateCommand} --min-release-age=0` };
|
||||
case PackageManager.PNPM:
|
||||
return {
|
||||
command: updateCommand,
|
||||
env: {
|
||||
PNPM_CONFIG_MINIMUM_RELEASE_AGE: "0",
|
||||
pnpm_config_minimum_release_age: "0",
|
||||
npm_config_minimum_release_age: "0",
|
||||
},
|
||||
};
|
||||
case PackageManager.YARN:
|
||||
return {
|
||||
command: updateCommand,
|
||||
env: { YARN_NPM_MINIMAL_AGE_GATE: "0" },
|
||||
};
|
||||
case PackageManager.BUN:
|
||||
return { command: `${updateCommand} --minimum-release-age=0` };
|
||||
default:
|
||||
return { command: updateCommand };
|
||||
}
|
||||
}
|
||||
|
||||
async function getLatestVersion(
|
||||
packageName: CliPackageName,
|
||||
currentVersion: string,
|
||||
@@ -168,11 +201,15 @@ function waitForProcessExit(child: ChildProcess): Promise<number> {
|
||||
});
|
||||
}
|
||||
|
||||
async function runCliUpdate(updateCommand: string): Promise<number> {
|
||||
const updateProcess = spawn(updateCommand, {
|
||||
async function runCliUpdate(
|
||||
updateCommand: ManualUpdateCommand,
|
||||
): Promise<number> {
|
||||
const updateProcess = spawn(updateCommand.command, {
|
||||
stdio: "inherit",
|
||||
shell: true,
|
||||
env: process.env,
|
||||
env: updateCommand.env
|
||||
? { ...process.env, ...updateCommand.env }
|
||||
: process.env,
|
||||
windowsHide: true,
|
||||
});
|
||||
return waitForProcessExit(updateProcess);
|
||||
@@ -432,17 +469,21 @@ export async function checkForUpdates(
|
||||
);
|
||||
hadFailure = true;
|
||||
} else {
|
||||
const manualUpdateCommand = withMinimumReleaseAgeBypass(
|
||||
updateCommand,
|
||||
packageManager,
|
||||
);
|
||||
writeln(
|
||||
`${c.cyan}Installing ${packageName}@${latestVersion}…${c.reset}`,
|
||||
);
|
||||
try {
|
||||
const exitCode = await runCliUpdate(updateCommand);
|
||||
const exitCode = await runCliUpdate(manualUpdateCommand);
|
||||
if (exitCode === 0) {
|
||||
installedUpdates.push(`${packageName}@${latestVersion}`);
|
||||
await restartHubServerIfRunning();
|
||||
} else {
|
||||
writeErr(
|
||||
`Cline update failed (exit code ${exitCode}). Try running: ${updateCommand}`,
|
||||
`Cline update failed (exit code ${exitCode}). Try running: ${manualUpdateCommand.command}`,
|
||||
);
|
||||
hadFailure = true;
|
||||
}
|
||||
@@ -450,7 +491,7 @@ export async function checkForUpdates(
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
writeErr(
|
||||
`Failed to run Cline update command ${updateCommand}: ${message}`,
|
||||
`Failed to run Cline update command ${manualUpdateCommand.command}: ${message}`,
|
||||
);
|
||||
hadFailure = true;
|
||||
}
|
||||
|
||||
@@ -831,7 +831,10 @@ class DiscordConnector extends ConnectorBase<
|
||||
requestStop("rpc_server_shutting_down");
|
||||
return;
|
||||
}
|
||||
if (event.eventType !== "schedule.execution.completed") {
|
||||
if (
|
||||
event.eventType !== "schedule.execution.completed" &&
|
||||
event.eventType !== "schedule.execution.failed"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const scheduleId =
|
||||
|
||||
@@ -760,7 +760,10 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
requestStop("rpc_server_shutting_down");
|
||||
return;
|
||||
}
|
||||
if (event.eventType !== "schedule.execution.completed") {
|
||||
if (
|
||||
event.eventType !== "schedule.execution.completed" &&
|
||||
event.eventType !== "schedule.execution.failed"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const scheduleId =
|
||||
|
||||
@@ -797,7 +797,10 @@ class LinearConnector extends ConnectorBase<
|
||||
requestStop("rpc_server_shutting_down");
|
||||
return;
|
||||
}
|
||||
if (event.eventType !== "schedule.execution.completed") {
|
||||
if (
|
||||
event.eventType !== "schedule.execution.completed" &&
|
||||
event.eventType !== "schedule.execution.failed"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const scheduleId =
|
||||
|
||||
@@ -996,7 +996,10 @@ class SlackConnector extends ConnectorBase<
|
||||
requestStop("rpc_server_shutting_down");
|
||||
return;
|
||||
}
|
||||
if (event.eventType !== "schedule.execution.completed") {
|
||||
if (
|
||||
event.eventType !== "schedule.execution.completed" &&
|
||||
event.eventType !== "schedule.execution.failed"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const scheduleId =
|
||||
|
||||
@@ -8,29 +8,31 @@ Create a bot with `@BotFather`:
|
||||
|
||||
1. Open Telegram and start a chat with `@BotFather`.
|
||||
2. Send `/newbot` and follow the prompts.
|
||||
3. Copy the bot username without the leading `@`.
|
||||
4. Copy the bot token. Treat it like a password.
|
||||
3. Copy the bot token. Treat it like a password.
|
||||
|
||||
Start the connector:
|
||||
|
||||
```bash
|
||||
cline connect telegram -m my_bot -k "$TELEGRAM_BOT_TOKEN"
|
||||
cline connect telegram -k "$TELEGRAM_BOT_TOKEN"
|
||||
```
|
||||
|
||||
The connector discovers the bot username from the token. Use `--bot-username`
|
||||
only if you need to override it.
|
||||
|
||||
Useful variants:
|
||||
|
||||
```bash
|
||||
# Keep logs in the active terminal while debugging.
|
||||
cline connect telegram -i -m my_bot -k "$TELEGRAM_BOT_TOKEN"
|
||||
cline connect telegram -i -k "$TELEGRAM_BOT_TOKEN"
|
||||
|
||||
# Read credentials from env vars.
|
||||
TELEGRAM_BOT_USERNAME=my_bot TELEGRAM_BOT_TOKEN=123456:ABCDEF... cline connect telegram
|
||||
TELEGRAM_BOT_TOKEN=123456:ABCDEF... cline connect telegram
|
||||
|
||||
# Override the workspace and model used for Telegram sessions.
|
||||
cline connect telegram -m my_bot -k "$TELEGRAM_BOT_TOKEN" --cwd /path/to/repo --provider cline --model openai/gpt-5.3-codex
|
||||
cline connect telegram -k "$TELEGRAM_BOT_TOKEN" --cwd /path/to/repo --provider cline --model openai/gpt-5.3-codex
|
||||
|
||||
# Disable tools for untrusted Telegram surfaces.
|
||||
cline connect telegram -m my_bot -k "$TELEGRAM_BOT_TOKEN" --no-tools
|
||||
cline connect telegram -k "$TELEGRAM_BOT_TOKEN" --no-tools
|
||||
|
||||
# Stop Telegram connector processes and sessions.
|
||||
cline connect --stop telegram
|
||||
@@ -69,7 +71,7 @@ Tools are enabled by default for Telegram sessions. That means anyone who can su
|
||||
Use `--no-tools` when the Telegram surface is not trusted:
|
||||
|
||||
```bash
|
||||
cline connect telegram -m my_bot -k "$TELEGRAM_BOT_TOKEN" --no-tools
|
||||
cline connect telegram -k "$TELEGRAM_BOT_TOKEN" --no-tools
|
||||
```
|
||||
|
||||
When the connector starts with `--no-tools`, chat commands such as `/tools on` and `/yolo on` cannot re-enable tools for that connector run.
|
||||
@@ -109,6 +111,11 @@ cline schedule create "Daily summary" \
|
||||
--delivery-thread telegram:123456789
|
||||
```
|
||||
|
||||
`--delivery-bot` is still supported after token-only connector setup. Use
|
||||
the Telegram bot username shown by `/whereami` as `deliveryUserName` when you
|
||||
need to target one specific Telegram connector, or omit the flag when the
|
||||
schedule does not need to be restricted to a specific bot.
|
||||
|
||||
The connector must be running when the scheduled result is delivered, and the target thread must have an existing thread binding.
|
||||
|
||||
## Limitations
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ConnectTelegramOptions } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { __test__, telegramConnector } from "./telegram";
|
||||
|
||||
const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
|
||||
@@ -9,6 +12,28 @@ const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
|
||||
}
|
||||
).parseArgs(rawArgs);
|
||||
|
||||
const originalClineDataDir = process.env.CLINE_DATA_DIR;
|
||||
const tempDataDirs: string[] = [];
|
||||
|
||||
function useTempClineDataDir(): string {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "cline-telegram-test-"));
|
||||
tempDataDirs.push(dataDir);
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
if (originalClineDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalClineDataDir;
|
||||
}
|
||||
for (const dir of tempDataDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("telegramConnector", () => {
|
||||
it("honors --no-tools", () => {
|
||||
const options = parseTelegramArgs([
|
||||
@@ -36,6 +61,176 @@ describe("telegramConnector", () => {
|
||||
|
||||
expect(options.enableTools).toBe(true);
|
||||
});
|
||||
|
||||
it("does not require the bot username", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
]);
|
||||
|
||||
expect(options.botUsername).toBeUndefined();
|
||||
expect(options.botToken).toBe("123:test");
|
||||
});
|
||||
|
||||
it("normalizes an explicit bot username", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-username",
|
||||
" @test_bot ",
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
]);
|
||||
|
||||
expect(options.botUsername).toBe("test_bot");
|
||||
});
|
||||
|
||||
it("does not call getMe when the token-only connector is already running", async () => {
|
||||
const dataDir = useTempClineDataDir();
|
||||
const connectorDir = join(dataDir, "connectors", "telegram");
|
||||
mkdirSync(connectorDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(connectorDir, "resolved_bot.json"),
|
||||
JSON.stringify({
|
||||
botUsername: "resolved_bot",
|
||||
botId: "123",
|
||||
pid: process.pid,
|
||||
rpcAddress: "127.0.0.1:54321",
|
||||
startedAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error("unexpected getMe call");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchImpl);
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
await expect(
|
||||
telegramConnector.run(["--bot-token", "123:test", "--cwd", "/tmp/work"], {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => errors.push(text),
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual([
|
||||
`[telegram] connector already running pid=${process.pid} rpc=127.0.0.1:54321`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram bot username resolution", () => {
|
||||
it("reads the public Telegram bot id from a token", () => {
|
||||
expect(__test__.readTelegramBotId("123456:secret")).toBe("123456");
|
||||
expect(__test__.readTelegramBotId("not-a-token")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the configured username without calling Telegram", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error("unexpected fetch");
|
||||
});
|
||||
|
||||
await expect(
|
||||
__test__.resolveTelegramBotUsername(
|
||||
{
|
||||
botToken: "123:test",
|
||||
botUsername: "@configured_bot",
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
interactive: true,
|
||||
enableTools: true,
|
||||
rpcAddress: "127.0.0.1:0",
|
||||
},
|
||||
fetchImpl,
|
||||
),
|
||||
).resolves.toBe("configured_bot");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches the username from Telegram getMe when omitted", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
result: { username: "resolved_bot" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
__test__.fetchTelegramBotUsername("123:test", fetchImpl),
|
||||
).resolves.toBe("resolved_bot");
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://api.telegram.org/bot123:test/getMe",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces Telegram getMe failures", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
description: "Unauthorized",
|
||||
}),
|
||||
{ status: 401, statusText: "Unauthorized" },
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
__test__.fetchTelegramBotUsername("bad-token", fetchImpl),
|
||||
).rejects.toThrow("Telegram getMe failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram participant resolution", () => {
|
||||
it("uses the stable numeric Telegram user id when username is also present", () => {
|
||||
const result = __test__.resolveTelegramParticipant({
|
||||
message: {
|
||||
from: {
|
||||
id: 1201547643,
|
||||
username: "AraFatKatze",
|
||||
first_name: "Ara",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "telegram:id:1201547643",
|
||||
label: "arafatkatze",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to username when Telegram does not provide a numeric user id", () => {
|
||||
const result = __test__.resolveTelegramParticipant({
|
||||
message: {
|
||||
from: {
|
||||
username: "Alice",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "telegram:user:alice",
|
||||
label: "alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts string numeric user ids from raw Telegram payloads", () => {
|
||||
const result = __test__.resolveTelegramParticipant({
|
||||
message: {
|
||||
from: {
|
||||
id: "1201547643",
|
||||
username: "arafatkatze",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.key).toBe("telegram:id:1201547643");
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram binding lookup", () => {
|
||||
|
||||
@@ -63,13 +63,86 @@ import { postTelegramFormattedReply } from "./telegram-format";
|
||||
const TELEGRAM_SYSTEM_RULES = getConnectorSystemRules("Telegram");
|
||||
|
||||
const TELEGRAM_FIRST_CONTACT_MESSAGE = getConnectorFirstContactMessage();
|
||||
const TELEGRAM_API_BASE = "https://api.telegram.org";
|
||||
|
||||
type TelegramThreadState = ConnectorThreadState;
|
||||
type ResolvedTelegramOptions = ConnectTelegramOptions & { botUsername: string };
|
||||
type TelegramGetMeResponse = {
|
||||
ok?: boolean;
|
||||
description?: string;
|
||||
result?: {
|
||||
username?: string;
|
||||
};
|
||||
};
|
||||
type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
function truncateText(value: string, maxLength = 160): string {
|
||||
return truncateConnectorText(value, maxLength);
|
||||
}
|
||||
|
||||
function normalizeTelegramBotUsername(value: string): string {
|
||||
return value.trim().replace(/^@+/, "");
|
||||
}
|
||||
|
||||
function readTelegramBotId(botToken: string): string | undefined {
|
||||
const [botId] = botToken.trim().split(":", 1);
|
||||
return /^\d+$/.test(botId) ? botId : undefined;
|
||||
}
|
||||
|
||||
function describeTelegramGetMeFailure(
|
||||
response: Response,
|
||||
body: string,
|
||||
parsed: TelegramGetMeResponse | undefined,
|
||||
): string {
|
||||
const detail = parsed?.description || body.trim().slice(0, 240);
|
||||
return detail
|
||||
? `Telegram getMe failed (${response.status} ${response.statusText}): ${detail}`
|
||||
: `Telegram getMe failed (${response.status} ${response.statusText})`;
|
||||
}
|
||||
|
||||
async function readTelegramGetMeResponse(
|
||||
response: Response,
|
||||
): Promise<TelegramGetMeResponse> {
|
||||
const text = await response.text();
|
||||
let parsed: TelegramGetMeResponse;
|
||||
try {
|
||||
parsed = JSON.parse(text) as TelegramGetMeResponse;
|
||||
} catch {
|
||||
throw new Error(describeTelegramGetMeFailure(response, text, undefined));
|
||||
}
|
||||
if (!response.ok || parsed.ok !== true) {
|
||||
throw new Error(describeTelegramGetMeFailure(response, text, parsed));
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function fetchTelegramBotUsername(
|
||||
botToken: string,
|
||||
fetchImpl: FetchLike = fetch,
|
||||
apiBaseUrl = TELEGRAM_API_BASE,
|
||||
): Promise<string> {
|
||||
const response = await fetchImpl(`${apiBaseUrl}/bot${botToken}/getMe`);
|
||||
const payload = await readTelegramGetMeResponse(response);
|
||||
const username = normalizeTelegramBotUsername(payload.result?.username ?? "");
|
||||
if (!username) {
|
||||
throw new Error(
|
||||
"Telegram getMe did not return a bot username; pass --bot-username explicitly",
|
||||
);
|
||||
}
|
||||
return username;
|
||||
}
|
||||
|
||||
async function resolveTelegramBotUsername(
|
||||
options: ConnectTelegramOptions,
|
||||
fetchImpl?: FetchLike,
|
||||
): Promise<string> {
|
||||
const configured = normalizeTelegramBotUsername(options.botUsername ?? "");
|
||||
if (configured) {
|
||||
return configured;
|
||||
}
|
||||
return fetchTelegramBotUsername(options.botToken, fetchImpl);
|
||||
}
|
||||
|
||||
async function stopSessionsForBot(
|
||||
state: TelegramConnectorState,
|
||||
): Promise<number> {
|
||||
@@ -135,12 +208,12 @@ function resolveTelegramParticipant(
|
||||
const lastName = readString(from?.last_name);
|
||||
const label =
|
||||
username || [firstName, lastName].filter(Boolean).join(" ") || userId;
|
||||
if (username) {
|
||||
return { key: `telegram:user:${username}`, label };
|
||||
}
|
||||
if (userId) {
|
||||
return { key: `telegram:id:${userId}`, label };
|
||||
}
|
||||
if (username) {
|
||||
return { key: `telegram:user:${username}`, label };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -331,8 +404,11 @@ class TelegramConnector extends ConnectorBase<
|
||||
protected override createCommand(): Command {
|
||||
return super
|
||||
.createCommand()
|
||||
.usage("-m <TELEGRAM_BOT_USERNAME> -k <TELEGRAM_BOT_TOKEN> [options]")
|
||||
.option("-m, --bot-username <name>", "Telegram bot username")
|
||||
.usage("-k <TELEGRAM_BOT_TOKEN> [options]")
|
||||
.option(
|
||||
"-m, --bot-username <name>",
|
||||
"Telegram bot username; fetched from token if omitted",
|
||||
)
|
||||
.option("-k, --bot-token <token>", "Telegram bot token")
|
||||
.option("--provider <id>", "Provider override")
|
||||
.option("--model <id>", "Model override")
|
||||
@@ -358,6 +434,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
"Notes:",
|
||||
" - Without -i, the connector is launched in the background.",
|
||||
" - Tools are enabled by default for Telegram sessions.",
|
||||
" - Bot username is discovered from the Telegram bot token when omitted.",
|
||||
" - Provider/model default to the CLI's last-used provider settings.",
|
||||
].join("\n"),
|
||||
);
|
||||
@@ -379,18 +456,18 @@ class TelegramConnector extends ConnectorBase<
|
||||
hookCommand?: string;
|
||||
}>();
|
||||
const botUsername =
|
||||
opts.botUsername?.trim() || process.env.TELEGRAM_BOT_USERNAME?.trim();
|
||||
normalizeTelegramBotUsername(opts.botUsername ?? "") ||
|
||||
normalizeTelegramBotUsername(
|
||||
process.env.TELEGRAM_BOT_USERNAME?.trim() ?? "",
|
||||
);
|
||||
const botToken =
|
||||
opts.botToken?.trim() || process.env.TELEGRAM_BOT_TOKEN?.trim();
|
||||
if (!botUsername) {
|
||||
throw new Error("connect telegram requires -m/--bot-username <name>");
|
||||
}
|
||||
if (!botToken) {
|
||||
throw new Error("connect telegram requires -k/--bot-token <token>");
|
||||
}
|
||||
return {
|
||||
botToken,
|
||||
botUsername,
|
||||
...(botUsername ? { botUsername } : {}),
|
||||
cwd: opts.cwd || process.cwd(),
|
||||
model: opts.model,
|
||||
provider: opts.provider,
|
||||
@@ -445,6 +522,28 @@ class TelegramConnector extends ConnectorBase<
|
||||
this.writeStateFile(statePath, state);
|
||||
}
|
||||
|
||||
private findRunningConnectorStateByBotId(
|
||||
botId: string | undefined,
|
||||
): TelegramConnectorState | undefined {
|
||||
if (!botId) {
|
||||
return undefined;
|
||||
}
|
||||
for (const statePath of this.listConnectorStatePaths()) {
|
||||
const state = this.readConnectorState(statePath);
|
||||
if (!state) {
|
||||
continue;
|
||||
}
|
||||
if (!isProcessRunning(state.pid)) {
|
||||
this.removeStateFile(statePath);
|
||||
continue;
|
||||
}
|
||||
if (state.botId === botId) {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private async stopTelegramConnectorInstance(
|
||||
statePath: string,
|
||||
io: ConnectIo,
|
||||
@@ -475,10 +574,39 @@ class TelegramConnector extends ConnectorBase<
|
||||
}
|
||||
|
||||
protected override async runWithOptions(
|
||||
options: ConnectTelegramOptions,
|
||||
inputOptions: ConnectTelegramOptions,
|
||||
rawArgs: string[],
|
||||
io: ConnectIo,
|
||||
): Promise<number> {
|
||||
if (
|
||||
!inputOptions.botUsername &&
|
||||
!inputOptions.interactive &&
|
||||
process.env.CLINE_TELEGRAM_CONNECT_CHILD !== "1"
|
||||
) {
|
||||
const runningState = this.findRunningConnectorStateByBotId(
|
||||
readTelegramBotId(inputOptions.botToken),
|
||||
);
|
||||
if (runningState) {
|
||||
io.writeln(
|
||||
`[telegram] connector already running pid=${runningState.pid} rpc=${runningState.rpcAddress}`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
let resolvedBotUsername: string;
|
||||
try {
|
||||
resolvedBotUsername = await resolveTelegramBotUsername(inputOptions);
|
||||
} catch (error) {
|
||||
io.writeErr(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
const options: ResolvedTelegramOptions = {
|
||||
...inputOptions,
|
||||
botUsername: resolvedBotUsername,
|
||||
};
|
||||
const backgroundArgs = inputOptions.botUsername
|
||||
? rawArgs
|
||||
: [...rawArgs, "--bot-username", resolvedBotUsername];
|
||||
const statePath = this.resolveConnectorStatePath(options.botUsername);
|
||||
const bindingsPath = this.resolveBindingsPath(options.botUsername);
|
||||
this.removeStaleState(
|
||||
@@ -488,7 +616,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
);
|
||||
if (
|
||||
await this.maybeRunInBackground({
|
||||
rawArgs,
|
||||
rawArgs: backgroundArgs,
|
||||
io,
|
||||
interactive: options.interactive,
|
||||
childEnvVar: "CLINE_TELEGRAM_CONNECT_CHILD",
|
||||
@@ -574,6 +702,7 @@ class TelegramConnector extends ConnectorBase<
|
||||
await client.connect();
|
||||
this.writeConnectorState(statePath, {
|
||||
botUsername: options.botUsername,
|
||||
botId: readTelegramBotId(options.botToken),
|
||||
pid: process.pid,
|
||||
rpcAddress,
|
||||
startedAt: new Date().toISOString(),
|
||||
@@ -847,7 +976,10 @@ class TelegramConnector extends ConnectorBase<
|
||||
requestStop("rpc_server_shutting_down");
|
||||
return;
|
||||
}
|
||||
if (event.eventType !== "schedule.execution.completed") {
|
||||
if (
|
||||
event.eventType !== "schedule.execution.completed" &&
|
||||
event.eventType !== "schedule.execution.failed"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const scheduleId =
|
||||
@@ -957,6 +1089,10 @@ export const telegramConnector: ConnectCommandDefinition =
|
||||
new TelegramConnector();
|
||||
|
||||
export const __test__ = {
|
||||
fetchTelegramBotUsername,
|
||||
readTelegramBotId,
|
||||
resolveTelegramBotUsername,
|
||||
resolveTelegramParticipant,
|
||||
findBindingForThread: (
|
||||
bindings: ConnectorBindingStore<TelegramThreadState>,
|
||||
thread: Pick<Thread<TelegramThreadState>, "id" | "channelId" | "isDM"> & {
|
||||
|
||||
@@ -786,7 +786,10 @@ class WhatsAppConnector extends ConnectorBase<
|
||||
requestStop("rpc_server_shutting_down");
|
||||
return;
|
||||
}
|
||||
if (event.eventType !== "schedule.execution.completed") {
|
||||
if (
|
||||
event.eventType !== "schedule.execution.completed" &&
|
||||
event.eventType !== "schedule.execution.failed"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const scheduleId =
|
||||
|
||||
@@ -657,6 +657,27 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("treats dash-prefixed positional text after -- as a prompt", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"--",
|
||||
"- You are given a PyTorch state dictionary.",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"- You are given a PyTorch state dictionary.",
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies --auto-approve as a runtime policy without changing the config default", async () => {
|
||||
process.argv = ["bun", "src/index.ts", "--auto-approve", "false"];
|
||||
|
||||
|
||||
@@ -245,8 +245,11 @@ export async function runCli(): Promise<void> {
|
||||
const pluginInstallCmd = pluginCmd
|
||||
.command("install")
|
||||
.alias("i")
|
||||
.description("Install a Cline Plugin from npm, git, or a local path")
|
||||
.argument("<source>", "npm package, git URL, or local plugin path")
|
||||
.description("Install a Cline Plugin from npm, git, URL, or a local path")
|
||||
.argument(
|
||||
"<source>",
|
||||
"npm package, git URL, plugin file URL, or local plugin path",
|
||||
)
|
||||
.option("--npm", "Treat source as an npm package")
|
||||
.option("--git", "Treat source as a git repository")
|
||||
.option("--force", "Replace an existing install for the same source")
|
||||
|
||||
@@ -42,6 +42,7 @@ function makeRuntime(): InteractiveChatCommandRuntime {
|
||||
return {
|
||||
forkCurrentSession: vi.fn(async () => undefined),
|
||||
getActiveSessionId: vi.fn(() => "session-1"),
|
||||
resetForNewSession: vi.fn(async () => {}),
|
||||
restartEmpty: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
@@ -101,6 +102,34 @@ describe("runInteractiveChatCommand", () => {
|
||||
expect(runtime.restartEmpty).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("resets slash new without eagerly restarting the runtime", async () => {
|
||||
const config = makeConfig();
|
||||
const runtime = makeRuntime();
|
||||
|
||||
const result = await runInteractiveChatCommand({
|
||||
prompt: "/new",
|
||||
enabled: true,
|
||||
config,
|
||||
host: chatCommandHost,
|
||||
chatCommandState: makeState(config),
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
stop: () => {},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
handled: true,
|
||||
turnResult: {
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
iterations: 0,
|
||||
commandOutput: "Started a fresh session.",
|
||||
},
|
||||
});
|
||||
expect(runtime.resetForNewSession).toHaveBeenCalledOnce();
|
||||
expect(runtime.restartEmpty).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies chat command state updates and returns command output", async () => {
|
||||
const config = makeConfig();
|
||||
const runtime = makeRuntime();
|
||||
|
||||
@@ -18,7 +18,10 @@ type AutoApproveRef = {
|
||||
|
||||
export type InteractiveChatCommandRuntime = Pick<
|
||||
ReturnType<typeof createInteractiveSessionRuntime>,
|
||||
"forkCurrentSession" | "getActiveSessionId" | "restartEmpty"
|
||||
| "forkCurrentSession"
|
||||
| "getActiveSessionId"
|
||||
| "resetForNewSession"
|
||||
| "restartEmpty"
|
||||
>;
|
||||
|
||||
export type InteractiveChatCommandResult =
|
||||
@@ -79,7 +82,7 @@ export async function runInteractiveChatCommand(input: {
|
||||
commandOutput = text;
|
||||
},
|
||||
reset: async () => {
|
||||
await input.sessionRuntime.restartEmpty();
|
||||
await input.sessionRuntime.resetForNewSession();
|
||||
},
|
||||
stop: async () => {
|
||||
input.stop();
|
||||
|
||||
@@ -3,7 +3,14 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { UserInstructionConfigService } from "@cline/core";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { InteractiveConfigItem } from "../../tui/interactive-config";
|
||||
import {
|
||||
buildSlashCommandRegistry,
|
||||
expandUserCommandPrompt,
|
||||
} from "../../tui/commands/slash-command-registry";
|
||||
import {
|
||||
applyPluginFailures,
|
||||
type InteractiveConfigItem,
|
||||
} from "../../tui/interactive-config";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { createInteractiveConfigDataLoader } from "./config-data";
|
||||
|
||||
@@ -45,6 +52,30 @@ describe("interactive config data loader", () => {
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
async function writeSettingsPlugin(tempRoot: string): Promise<string> {
|
||||
const pluginsDir = join(tempRoot, ".cline", "plugins");
|
||||
await mkdir(pluginsDir, { recursive: true });
|
||||
const pluginPath = join(pluginsDir, "settings-plugin.js");
|
||||
await writeFile(
|
||||
pluginPath,
|
||||
[
|
||||
"export default {",
|
||||
" name: 'settings-plugin',",
|
||||
" manifest: { capabilities: ['tools'] },",
|
||||
" setup(api) {",
|
||||
" api.registerTool({",
|
||||
" name: 'settings_plugin_tool',",
|
||||
" description: 'Settings plugin tool',",
|
||||
" inputSchema: { type: 'object', properties: {} },",
|
||||
" execute: async () => 'ok',",
|
||||
" });",
|
||||
" },",
|
||||
"};",
|
||||
].join("\n"),
|
||||
);
|
||||
return pluginPath;
|
||||
}
|
||||
|
||||
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
@@ -64,6 +95,19 @@ Use this skill.`,
|
||||
calls.push(`refreshType:${type}`);
|
||||
refreshed = true;
|
||||
},
|
||||
listRuntimeCommands() {
|
||||
calls.push("listRuntimeCommands");
|
||||
return refreshed
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "skill-one",
|
||||
instructions: "Use this skill.",
|
||||
description: "Skill one",
|
||||
kind: "skill",
|
||||
},
|
||||
];
|
||||
},
|
||||
listRecords(type: string) {
|
||||
calls.push(`listRecords:${type}`);
|
||||
if (type !== "skill") {
|
||||
@@ -103,12 +147,97 @@ Use this skill.`,
|
||||
|
||||
expect(written).toContain("disabled: true");
|
||||
expect(data?.skills[0]?.enabled).toBe(false);
|
||||
expect(
|
||||
data?.workflowSlashCommands.map((command) => command.name),
|
||||
).not.toContain("skill-one");
|
||||
expect(calls).toContain("refreshType:skill");
|
||||
expect(calls.lastIndexOf("listRecords:skill")).toBeGreaterThan(
|
||||
calls.indexOf("refreshType:skill"),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns refreshed slash commands so disabled skills stop expanding before submit", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const skillPath = join(tempRoot, "SKILL.md");
|
||||
await writeFile(
|
||||
skillPath,
|
||||
`---
|
||||
name: find-skills
|
||||
---
|
||||
Find installable skills.`,
|
||||
);
|
||||
|
||||
let refreshed = false;
|
||||
const userInstructionService = {
|
||||
async refreshType() {
|
||||
refreshed = true;
|
||||
},
|
||||
listRuntimeCommands() {
|
||||
return refreshed
|
||||
? []
|
||||
: [
|
||||
{
|
||||
name: "find-skills",
|
||||
instructions: "Find installable skills.",
|
||||
description: "Find skills",
|
||||
kind: "skill",
|
||||
},
|
||||
];
|
||||
},
|
||||
listRecords(type: string) {
|
||||
if (type !== "skill") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "find-skills",
|
||||
type: "skill",
|
||||
filePath: skillPath,
|
||||
item: {
|
||||
name: "find-skills",
|
||||
disabled: refreshed,
|
||||
description: "Find skills",
|
||||
instructions: "Find installable skills.",
|
||||
frontmatter: {},
|
||||
},
|
||||
},
|
||||
];
|
||||
},
|
||||
} as unknown as UserInstructionConfigService;
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
userInstructionService,
|
||||
});
|
||||
const initialData = await loader.loadConfigData();
|
||||
const initialRegistry = buildSlashCommandRegistry({
|
||||
workflowSlashCommands: initialData.workflowSlashCommands,
|
||||
});
|
||||
|
||||
expect(
|
||||
expandUserCommandPrompt("/find-skills what can u do?", initialRegistry),
|
||||
).toContain("<user_command");
|
||||
|
||||
const nextData = await loader.onToggleConfigItem({
|
||||
id: "find-skills",
|
||||
name: "find-skills",
|
||||
path: skillPath,
|
||||
enabled: true,
|
||||
source: "workspace",
|
||||
kind: "skill",
|
||||
});
|
||||
const refreshedRegistry = buildSlashCommandRegistry({
|
||||
workflowSlashCommands: nextData?.workflowSlashCommands,
|
||||
});
|
||||
|
||||
expect(
|
||||
nextData?.workflowSlashCommands.map((command) => command.name),
|
||||
).not.toContain("find-skills");
|
||||
expect(
|
||||
expandUserCommandPrompt("/find-skills what can u do?", refreshedRegistry),
|
||||
).toBe("/find-skills what can u do?");
|
||||
});
|
||||
|
||||
it("keeps plugin tool toggle behavior", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
@@ -136,7 +265,126 @@ Use this skill.`,
|
||||
) as { disabledTools?: string[] };
|
||||
|
||||
expect(settings.disabledTools).toEqual(["plugin-tool"]);
|
||||
expect(data).toBeDefined();
|
||||
expect(data).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can skip plugin tool imports for fast settings open", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
||||
tempRoot,
|
||||
"global-settings.json",
|
||||
);
|
||||
const pluginPath = await writeSettingsPlugin(tempRoot);
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
|
||||
const data = await loader.loadConfigData({ includePluginTools: false });
|
||||
|
||||
expect(data.plugins.some((item) => item.path === pluginPath)).toBe(true);
|
||||
expect(
|
||||
data.tools.some((item) => item.pluginName === "settings-plugin"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("loads plugin tools when requested", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
||||
tempRoot,
|
||||
"global-settings.json",
|
||||
);
|
||||
await writeSettingsPlugin(tempRoot);
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
|
||||
const data = await loader.loadConfigData({ includePluginTools: true });
|
||||
|
||||
expect(
|
||||
data.tools.some(
|
||||
(item) =>
|
||||
item.pluginName === "settings-plugin" &&
|
||||
item.name === "settings_plugin_tool",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps failed plugins visible with their load error", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
||||
tempRoot,
|
||||
"global-settings.json",
|
||||
);
|
||||
const pluginsDir = join(tempRoot, ".cline", "plugins");
|
||||
await mkdir(pluginsDir, { recursive: true });
|
||||
const pluginPath = join(pluginsDir, "broken-plugin.js");
|
||||
const invalidPluginPath = join(pluginsDir, "invalid-plugin.js");
|
||||
await writeFile(
|
||||
pluginPath,
|
||||
[
|
||||
"export default {",
|
||||
" name: 'broken-plugin',",
|
||||
" manifest: { capabilities: ['tools'] },",
|
||||
" setup() {",
|
||||
" throw new Error('setup exploded');",
|
||||
" },",
|
||||
"};",
|
||||
].join("\n"),
|
||||
);
|
||||
await writeFile(invalidPluginPath, "export default {};\n", "utf8");
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
|
||||
const data = await loader.loadConfigData({ includePluginTools: true });
|
||||
const plugin = data.plugins.find((item) => item.path === pluginPath);
|
||||
|
||||
expect(plugin?.name).toBe("broken-plugin");
|
||||
expect(plugin?.loadErrorPhase).toBe("setup");
|
||||
expect(plugin?.loadError).toContain("setup failed: setup exploded");
|
||||
|
||||
const invalidPlugin = data.plugins.find(
|
||||
(item) => item.path === invalidPluginPath,
|
||||
);
|
||||
expect(invalidPlugin?.name).toBe("invalid-plugin");
|
||||
expect(invalidPlugin?.loadErrorPhase).toBe("load");
|
||||
expect(invalidPlugin?.loadError).toContain("load failed:");
|
||||
});
|
||||
|
||||
it("preserves multiple load failures for the same plugin path", () => {
|
||||
const plugin: InteractiveConfigItem = {
|
||||
id: "/tmp/plugin.js",
|
||||
name: "plugin",
|
||||
path: "/tmp/plugin.js",
|
||||
enabled: true,
|
||||
kind: "plugin",
|
||||
source: "workspace-plugin",
|
||||
};
|
||||
|
||||
applyPluginFailures(
|
||||
[plugin],
|
||||
[
|
||||
{
|
||||
pluginPath: "/tmp/plugin.js",
|
||||
pluginName: "plugin",
|
||||
phase: "setup",
|
||||
message: "first failure",
|
||||
},
|
||||
{
|
||||
pluginPath: "/tmp/plugin.js",
|
||||
phase: "setup",
|
||||
message: "second failure",
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
expect(plugin.loadError).toBe(
|
||||
"setup failed: first failure\nsetup failed: second failure",
|
||||
);
|
||||
expect(plugin.loadErrorPhase).toBeUndefined();
|
||||
});
|
||||
|
||||
it("toggles every SDK tool name for a displayed built-in tool", async () => {
|
||||
@@ -190,15 +438,20 @@ Use this skill.`,
|
||||
const data = await loader.loadConfigData();
|
||||
const plugin = data.plugins.find((item) => item.path === pluginPath);
|
||||
expect(plugin?.enabled).toBe(false);
|
||||
if (!plugin) {
|
||||
throw new Error("Expected workspace plugin to be listed");
|
||||
}
|
||||
|
||||
const nextData = await loader.onToggleConfigItem(plugin!);
|
||||
const nextData = await loader.onToggleConfigItem(plugin);
|
||||
const refreshedData = await loader.loadConfigData();
|
||||
const settings = JSON.parse(
|
||||
await readFile(process.env.CLINE_GLOBAL_SETTINGS_PATH, "utf8"),
|
||||
) as { disabledPlugins?: string[] };
|
||||
|
||||
expect(settings.disabledPlugins).toBeUndefined();
|
||||
expect(nextData).toBeUndefined();
|
||||
expect(
|
||||
nextData?.plugins.find((item) => item.path === pluginPath)?.enabled,
|
||||
refreshedData.plugins.find((item) => item.path === pluginPath)?.enabled,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
createCoreSettingsService,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
type InteractiveConfigData,
|
||||
type InteractiveConfigItem,
|
||||
type LoadInteractiveConfigDataOptions,
|
||||
loadInteractiveConfigData,
|
||||
} from "../../tui/interactive-config";
|
||||
import type { Config } from "../../utils/types";
|
||||
@@ -23,16 +25,20 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
enableSpawnAgent: input.config.enableSpawnAgent,
|
||||
enableAgentTeams: input.config.enableAgentTeams,
|
||||
});
|
||||
const loadConfigData = async (): Promise<InteractiveConfigData> =>
|
||||
const loadConfigData = async (
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<InteractiveConfigData> =>
|
||||
await loadInteractiveConfigData({
|
||||
userInstructionService: input.userInstructionService,
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: workspaceRoot(),
|
||||
availabilityContext: availabilityContext(),
|
||||
includePluginTools: options.includePluginTools,
|
||||
});
|
||||
|
||||
const onToggleConfigItem = async (
|
||||
item: InteractiveConfigItem,
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<InteractiveConfigData | undefined> => {
|
||||
const settings = createCoreSettingsService();
|
||||
if (item.kind === "skill" && typeof item.enabled === "boolean") {
|
||||
@@ -47,12 +53,12 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
userInstructionService: input.userInstructionService,
|
||||
availabilityContext: availabilityContext(),
|
||||
});
|
||||
return await loadConfigData();
|
||||
return await loadConfigData(options);
|
||||
}
|
||||
|
||||
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
|
||||
setDisabledPlugin(item.path, item.enabled);
|
||||
return await loadConfigData();
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (item.kind === "mcp" && typeof item.enabled === "boolean") {
|
||||
@@ -66,7 +72,7 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
workspaceRoot: workspaceRoot(),
|
||||
availabilityContext: availabilityContext(),
|
||||
});
|
||||
return await loadConfigData();
|
||||
return await loadConfigData(options);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -82,18 +88,22 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
? item.toolNames
|
||||
: [item.name];
|
||||
const toolNames = [...new Set(rawToolNames.filter(Boolean))];
|
||||
if (typeof item.enabled === "boolean") {
|
||||
setDisabledTools(toolNames, item.enabled);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const name of toolNames) {
|
||||
await settings.toggle({
|
||||
type: "tools",
|
||||
name,
|
||||
enabled: typeof item.enabled === "boolean" ? !item.enabled : undefined,
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: workspaceRoot(),
|
||||
userInstructionService: input.userInstructionService,
|
||||
availabilityContext: availabilityContext(),
|
||||
});
|
||||
}
|
||||
return await loadConfigData();
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import type {
|
||||
AgentEvent,
|
||||
ProviderSettingsManager,
|
||||
TeamEvent,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import type { AgentTool } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatCommandState } from "../../utils/chat-commands";
|
||||
import type { Config } from "../../utils/types";
|
||||
|
||||
const {
|
||||
mockCreateCliCore,
|
||||
mockCreateRuntimeHooks,
|
||||
mockLoadInteractiveResumeMessages,
|
||||
mockSetActiveCliSession,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateCliCore: vi.fn(),
|
||||
mockCreateRuntimeHooks: vi.fn(),
|
||||
mockLoadInteractiveResumeMessages: vi.fn(),
|
||||
mockSetActiveCliSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: mockCreateCliCore,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/hooks", () => ({
|
||||
createRuntimeHooks: mockCreateRuntimeHooks,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/output", () => ({
|
||||
setActiveCliSession: mockSetActiveCliSession,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/resume", () => ({
|
||||
loadInteractiveResumeMessages: mockLoadInteractiveResumeMessages,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/approval", () => ({
|
||||
submitAndExitInTerminal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../active-runtime", () => ({
|
||||
markAbortInProgress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../session-events", () => ({
|
||||
subscribeToAgentEvents: vi.fn(() => vi.fn()),
|
||||
subscribeToPendingPromptEvents: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
import { createInteractiveSessionRuntime } from "./session-runtime";
|
||||
|
||||
function makeConfig(): Config {
|
||||
return {
|
||||
apiKey: "",
|
||||
providerId: "cline",
|
||||
modelId: "openai/gpt-5.3-codex",
|
||||
verbose: false,
|
||||
sandbox: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
mode: "act",
|
||||
systemPrompt: "",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: false,
|
||||
defaultToolAutoApprove: false,
|
||||
toolPolicies: {},
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
};
|
||||
}
|
||||
|
||||
function makeChatCommandState(config: Config): ChatCommandState {
|
||||
return {
|
||||
enableTools: config.enableTools,
|
||||
autoApproveTools: config.defaultToolAutoApprove,
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot?.trim() || config.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSwitchToActModeTool(): AgentTool {
|
||||
return {
|
||||
name: "switch_to_act_mode",
|
||||
description: "Switch to act mode",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
execute: () => ({ ok: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function makeManager() {
|
||||
let startCount = 0;
|
||||
const start = vi.fn(async (_input?: unknown) => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: {
|
||||
session_id: sessionId,
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
start,
|
||||
stop: vi.fn(async () => {}),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
get: vi.fn(),
|
||||
readMessages: vi.fn(async () => []),
|
||||
readTranscript: vi.fn(),
|
||||
ingestHookEvent: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
updateSessionModel: vi.fn(),
|
||||
pendingPrompts: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
restore: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: { resumeSessionId?: string } = {},
|
||||
) {
|
||||
mockCreateCliCore.mockResolvedValue(manager);
|
||||
const config = makeConfig();
|
||||
return createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager: {} as ProviderSettingsManager,
|
||||
resumeSessionId: options.resumeSessionId,
|
||||
chatCommandState: makeChatCommandState(config),
|
||||
requestToolApproval: async (
|
||||
_request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => ({ approved: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: makeSwitchToActModeTool(),
|
||||
onAgentEvent: (_event: AgentEvent) => {},
|
||||
onTeamEvent: (_event: TeamEvent) => {},
|
||||
onPendingPrompts: () => {},
|
||||
onPendingPromptSubmitted: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("createInteractiveSessionRuntime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateRuntimeHooks.mockReturnValue({
|
||||
hooks: undefined,
|
||||
shutdown: vi.fn(async () => {}),
|
||||
});
|
||||
mockLoadInteractiveResumeMessages.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("defers creating the replacement session after a new-session reset", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
|
||||
await runtime.resetForNewSession();
|
||||
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
expect(runtime.getActiveSessionId()).toBe("");
|
||||
expect(mockSetActiveCliSession).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("starts fresh after resetting an initially resumed session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager, {
|
||||
resumeSessionId: "resumed-session",
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
manager,
|
||||
"resumed-session",
|
||||
);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "resumed-session",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await runtime.resetForNewSession();
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
manager,
|
||||
undefined,
|
||||
);
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
config: expect.not.objectContaining({
|
||||
sessionId: "resumed-session",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps explicit empty restarts eager for config-driven restarts", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
});
|
||||
@@ -74,8 +74,16 @@ export function createInteractiveSessionRuntime(input: {
|
||||
let shutdownRequested = false;
|
||||
let activeSessionId = "";
|
||||
let abortRequested = false;
|
||||
// A reset can happen while an earlier manager.start() is still in flight.
|
||||
// Bump this before resets and restarts so stale starts cannot become active.
|
||||
let sessionStartGeneration = 0;
|
||||
|
||||
const initialResumeSessionId = input.resumeSessionId?.trim() || undefined;
|
||||
let pendingResumeSessionId = input.resumeSessionId?.trim() || undefined;
|
||||
|
||||
const clearActiveSession = (): void => {
|
||||
activeSessionId = "";
|
||||
setActiveCliSession(undefined);
|
||||
};
|
||||
|
||||
const applyStartedSession = (started: StartedSession): void => {
|
||||
setActiveCliSession({
|
||||
@@ -155,6 +163,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
initial: Message[] = [],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
const generation = sessionStartGeneration;
|
||||
const manager = await ensureSessionManager();
|
||||
const started = await manager.start({
|
||||
source: SessionSource.CLI,
|
||||
@@ -168,6 +177,10 @@ export function createInteractiveSessionRuntime(input: {
|
||||
onTeamRestored: () => {},
|
||||
},
|
||||
});
|
||||
if (generation !== sessionStartGeneration) {
|
||||
await manager.stop(started.sessionId).catch(() => {});
|
||||
return;
|
||||
}
|
||||
applyStartedSession(started);
|
||||
};
|
||||
|
||||
@@ -175,6 +188,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
resumeId: string,
|
||||
initial: Message[] | undefined,
|
||||
): Promise<void> => {
|
||||
const generation = sessionStartGeneration;
|
||||
const manager = await ensureSessionManager();
|
||||
const started = await manager.start({
|
||||
source: SessionSource.CLI,
|
||||
@@ -190,24 +204,35 @@ export function createInteractiveSessionRuntime(input: {
|
||||
onTeamRestored: () => {},
|
||||
},
|
||||
});
|
||||
if (generation !== sessionStartGeneration) {
|
||||
await manager.stop(started.sessionId).catch(() => {});
|
||||
return;
|
||||
}
|
||||
applyStartedSession(started);
|
||||
};
|
||||
|
||||
const ensureReady = async (): Promise<void> => {
|
||||
if (activeSessionId) {
|
||||
return;
|
||||
}
|
||||
if (startupPromise) {
|
||||
return await startupPromise;
|
||||
}
|
||||
startupPromise = (async () => {
|
||||
const manager = await ensureSessionManager();
|
||||
const resumeSessionId = pendingResumeSessionId;
|
||||
const initialMessages = await loadInteractiveResumeMessages(
|
||||
manager,
|
||||
input.resumeSessionId,
|
||||
resumeSessionId,
|
||||
);
|
||||
if (shutdownRequested) {
|
||||
return;
|
||||
}
|
||||
if (initialResumeSessionId) {
|
||||
await startResumedSession(initialResumeSessionId, initialMessages);
|
||||
if (resumeSessionId) {
|
||||
await startResumedSession(resumeSessionId, initialMessages);
|
||||
if (pendingResumeSessionId === resumeSessionId) {
|
||||
pendingResumeSessionId = undefined;
|
||||
}
|
||||
} else {
|
||||
await startFreshSession(initialMessages);
|
||||
}
|
||||
@@ -226,8 +251,9 @@ export function createInteractiveSessionRuntime(input: {
|
||||
};
|
||||
|
||||
const stopCurrentSession = async (): Promise<void> => {
|
||||
if (sessionManager && activeSessionId) {
|
||||
await sessionManager.stop(activeSessionId);
|
||||
const sessionId = activeSessionId;
|
||||
if (sessionManager && sessionId) {
|
||||
await sessionManager.stop(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -262,7 +288,12 @@ export function createInteractiveSessionRuntime(input: {
|
||||
messages: Message[],
|
||||
sessionMetadata?: Record<string, unknown>,
|
||||
): Promise<void> => {
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupPromise = undefined;
|
||||
startupError = undefined;
|
||||
await stopCurrentSession();
|
||||
clearActiveSession();
|
||||
await startFreshSession(messages, sessionMetadata);
|
||||
};
|
||||
|
||||
@@ -275,6 +306,19 @@ export function createInteractiveSessionRuntime(input: {
|
||||
await restartWithMessages([]);
|
||||
};
|
||||
|
||||
const resetForNewSession = async (): Promise<void> => {
|
||||
sessionStartGeneration += 1;
|
||||
pendingResumeSessionId = undefined;
|
||||
startupPromise = undefined;
|
||||
startupError = undefined;
|
||||
const manager = sessionManager;
|
||||
const sessionId = activeSessionId;
|
||||
clearActiveSession();
|
||||
if (manager && sessionId) {
|
||||
await manager.stop(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const applyMode = async (mode: "plan" | "act"): Promise<void> => {
|
||||
await applyInteractiveModeConfig({
|
||||
config: input.config,
|
||||
@@ -537,6 +581,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
getAccumulatedUsage,
|
||||
readCurrentMessages,
|
||||
restartEmpty,
|
||||
resetForNewSession,
|
||||
restartWithMessages,
|
||||
restartWithCurrentMessages,
|
||||
resumeSession,
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
loadClineAccountSnapshot,
|
||||
switchClineAccount,
|
||||
} from "../tui/cline-account";
|
||||
import type { InteractiveConfigItem } from "../tui/interactive-config";
|
||||
import type {
|
||||
InteractiveConfigItem,
|
||||
LoadInteractiveConfigDataOptions,
|
||||
} from "../tui/interactive-config";
|
||||
import {
|
||||
type InteractiveSlashCommand,
|
||||
listInteractiveSlashCommands,
|
||||
@@ -68,24 +71,48 @@ export async function runInteractive(
|
||||
userInstructionService,
|
||||
);
|
||||
let interactiveChatCommandHost = chatCommandHost;
|
||||
let pluginChatCommandHostLoaded = false;
|
||||
let pluginChatSlashCommands: InteractiveSlashCommand[] = [];
|
||||
let pluginChatCommandHostShutdown: (() => Promise<void>) | undefined;
|
||||
let pluginChatCommandHostPromise:
|
||||
| Promise<InteractiveSlashCommand[]>
|
||||
| undefined;
|
||||
const configDataLoader = createInteractiveConfigDataLoader({
|
||||
config,
|
||||
userInstructionService,
|
||||
});
|
||||
const loadAdditionalSlashCommands = async (): Promise<
|
||||
const ensurePluginChatCommandHost = async (): Promise<
|
||||
InteractiveSlashCommand[]
|
||||
> => {
|
||||
const { host, pluginSlashCommands } = await createWorkspaceChatCommandHost({
|
||||
if (pluginChatCommandHostLoaded) {
|
||||
return pluginChatSlashCommands;
|
||||
}
|
||||
pluginChatCommandHostPromise ??= createWorkspaceChatCommandHost({
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot,
|
||||
logger: config.logger,
|
||||
});
|
||||
interactiveChatCommandHost = host;
|
||||
return pluginSlashCommands.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
instructions: "",
|
||||
description: cmd.description ?? "Plugin command",
|
||||
}));
|
||||
})
|
||||
.then(({ host, pluginSlashCommands, shutdown }) => {
|
||||
interactiveChatCommandHost = host;
|
||||
pluginChatCommandHostShutdown = shutdown;
|
||||
pluginChatSlashCommands = pluginSlashCommands.map((cmd) => ({
|
||||
name: cmd.name,
|
||||
instructions: "",
|
||||
description: cmd.description ?? "Plugin command",
|
||||
}));
|
||||
return pluginChatSlashCommands;
|
||||
})
|
||||
.finally(() => {
|
||||
pluginChatCommandHostLoaded = true;
|
||||
pluginChatCommandHostPromise = undefined;
|
||||
});
|
||||
return await pluginChatCommandHostPromise;
|
||||
};
|
||||
const loadAdditionalSlashCommands = async (): Promise<
|
||||
InteractiveSlashCommand[]
|
||||
> => await ensurePluginChatCommandHost();
|
||||
const shouldTryPluginChatCommands = (prompt: string): boolean => {
|
||||
return prompt.trimStart().startsWith("/");
|
||||
};
|
||||
|
||||
const enableChatCommands = true;
|
||||
@@ -220,9 +247,18 @@ export async function runInteractive(
|
||||
cleanupPromise = (async () => {
|
||||
process.off("SIGINT", handleSigint);
|
||||
process.off("SIGTERM", handleSigterm);
|
||||
const exitSummary = await sessionRuntime.cleanup();
|
||||
setActiveRuntimeAbort(undefined);
|
||||
setActiveRuntimeCleanup(undefined);
|
||||
let exitSummary: InteractiveExitSummary | undefined;
|
||||
try {
|
||||
exitSummary = await sessionRuntime.cleanup();
|
||||
} finally {
|
||||
await pluginChatCommandHostPromise?.catch(() => []);
|
||||
await pluginChatCommandHostShutdown?.().catch(() => {
|
||||
// Best effort cleanup for plugin command discovery sandbox.
|
||||
});
|
||||
pluginChatCommandHostShutdown = undefined;
|
||||
setActiveRuntimeAbort(undefined);
|
||||
setActiveRuntimeCleanup(undefined);
|
||||
}
|
||||
return exitSummary;
|
||||
})();
|
||||
return await cleanupPromise;
|
||||
@@ -276,10 +312,11 @@ export async function runInteractive(
|
||||
|
||||
const onToggleConfigItem = async (
|
||||
item: InteractiveConfigItem,
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<
|
||||
Awaited<ReturnType<typeof configDataLoader.onToggleConfigItem>>
|
||||
> => {
|
||||
const data = await configDataLoader.onToggleConfigItem(item);
|
||||
const data = await configDataLoader.onToggleConfigItem(item, options);
|
||||
if (data && shouldRefreshInteractiveSessionForConfigItem(item)) {
|
||||
await refreshInteractiveSessionPolicies();
|
||||
}
|
||||
@@ -386,7 +423,7 @@ export async function runInteractive(
|
||||
isRunning = true;
|
||||
}
|
||||
|
||||
const chatCommandResult = await runInteractiveChatCommand({
|
||||
let chatCommandResult = await runInteractiveChatCommand({
|
||||
prompt: input,
|
||||
enabled: enableChatCommands,
|
||||
config,
|
||||
@@ -400,6 +437,26 @@ export async function runInteractive(
|
||||
if (chatCommandResult.handled) {
|
||||
return chatCommandResult.turnResult;
|
||||
}
|
||||
if (
|
||||
shouldTryPluginChatCommands(input) &&
|
||||
!pluginChatCommandHostLoaded
|
||||
) {
|
||||
await ensurePluginChatCommandHost();
|
||||
chatCommandResult = await runInteractiveChatCommand({
|
||||
prompt: input,
|
||||
enabled: enableChatCommands,
|
||||
config,
|
||||
host: interactiveChatCommandHost,
|
||||
chatCommandState,
|
||||
autoApproveAllRef,
|
||||
setInteractiveAutoApprove,
|
||||
sessionRuntime,
|
||||
stop: () => tuiApp?.destroy(),
|
||||
});
|
||||
if (chatCommandResult.handled) {
|
||||
return chatCommandResult.turnResult;
|
||||
}
|
||||
}
|
||||
input = chatCommandResult.input;
|
||||
const {
|
||||
prompt: userInput,
|
||||
@@ -528,6 +585,9 @@ export async function runInteractive(
|
||||
}
|
||||
await applyModeChange(mode);
|
||||
},
|
||||
onNewSession: async () => {
|
||||
await sessionRuntime.resetForNewSession();
|
||||
},
|
||||
onModelChange: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
|
||||
@@ -28,7 +28,7 @@ test.describe("cline --model (interactive mode, flag ignored)", () => {
|
||||
|
||||
test("starts interactive mode", async ({ terminal }) => {
|
||||
await waitForChatReady(terminal);
|
||||
await expectVisible(terminal, "GPT-5.3-Codex");
|
||||
await expectVisible(terminal, "GPT-5.3 Codex");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -166,6 +166,32 @@ describe("slash command registry", () => {
|
||||
expect(expandUserCommandPrompt("/settings", registry)).toBe("/settings");
|
||||
});
|
||||
|
||||
it("does not expand commands omitted from a refreshed user-command registry", () => {
|
||||
const staleRegistry = buildSlashCommandRegistry({
|
||||
workflowSlashCommands: [
|
||||
{
|
||||
name: "find-skills",
|
||||
instructions: "Find installable skills.",
|
||||
description: "Find skills",
|
||||
kind: "skill",
|
||||
},
|
||||
],
|
||||
});
|
||||
const refreshedRegistry = buildSlashCommandRegistry({
|
||||
workflowSlashCommands: [],
|
||||
});
|
||||
|
||||
expect(
|
||||
expandUserCommandPrompt("/find-skills what can u do?", staleRegistry),
|
||||
).toContain("<user_command");
|
||||
expect(
|
||||
resolveSlashCommand(refreshedRegistry, "find-skills"),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
expandUserCommandPrompt("/find-skills what can u do?", refreshedRegistry),
|
||||
).toBe("/find-skills what can u do?");
|
||||
});
|
||||
|
||||
it("hides fork from autocomplete until a session has messages", () => {
|
||||
const emptySessionRegistry = buildSlashCommandRegistry({ canFork: false });
|
||||
const activeSessionRegistry = buildSlashCommandRegistry({ canFork: true });
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
getDefaultForeground,
|
||||
getModeInputBackground,
|
||||
palette,
|
||||
type TerminalTheme,
|
||||
} from "../palette";
|
||||
import type { ChatEntry } from "../types";
|
||||
import { getSyntaxStyle } from "../utils/syntax-style";
|
||||
import { isWarningToolError } from "../utils/tool-errors";
|
||||
import {
|
||||
parseApplyPatchInput,
|
||||
parseAskQuestionInput,
|
||||
@@ -211,6 +213,7 @@ function ToolCallView(props: {
|
||||
defaultFg,
|
||||
} = props;
|
||||
const failed = result?.error != null;
|
||||
const warningFailure = isWarningToolError(result?.error);
|
||||
const params = formatToolParams(toolName, props.rawInput, inputSummary);
|
||||
|
||||
return (
|
||||
@@ -219,6 +222,8 @@ function ToolCallView(props: {
|
||||
<box width={2}>
|
||||
{streaming ? (
|
||||
<spinner name="dots" color="gray" />
|
||||
) : warningFailure ? (
|
||||
<text fg="yellow">!</text>
|
||||
) : failed ? (
|
||||
<text fg="red">x</text>
|
||||
) : (
|
||||
@@ -251,8 +256,12 @@ function ToolCallView(props: {
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: { entry: ChatEntry; accent?: string }) {
|
||||
const { entry, accent = palette.act } = props;
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
terminalTheme: TerminalTheme;
|
||||
}) {
|
||||
const { entry, accent = palette.act, terminalTheme } = props;
|
||||
const terminalBg = useTerminalBackground();
|
||||
const defaultFg = getDefaultForeground(terminalBg);
|
||||
const userMsgBg = getModeInputBackground(
|
||||
@@ -316,7 +325,7 @@ export function ChatEntryView(props: { entry: ChatEntry; accent?: string }) {
|
||||
<box flexGrow={1}>
|
||||
<markdown
|
||||
content={content}
|
||||
syntaxStyle={getSyntaxStyle()}
|
||||
syntaxStyle={getSyntaxStyle(terminalTheme)}
|
||||
streaming={entry.streaming}
|
||||
fg={defaultFg}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
useRef,
|
||||
} from "react";
|
||||
import type { TranscriptCommand } from "../hooks/transcript-keybinds";
|
||||
import { useTerminalTheme } from "../hooks/use-terminal-background";
|
||||
import { getModeAccent } from "../palette";
|
||||
import type { ChatEntry } from "../types";
|
||||
import { ChatEntryView } from "./chat-entry";
|
||||
@@ -29,6 +30,8 @@ export const ChatMessageList = forwardRef<
|
||||
>(function ChatMessageList(props, ref) {
|
||||
const scrollboxRef = useRef<ScrollBoxRenderable | null>(null);
|
||||
const lastEntry = props.entries.at(-1);
|
||||
const terminalTheme = useTerminalTheme();
|
||||
const accent = getModeAccent(props.uiMode ?? "act", terminalTheme);
|
||||
const userSubmissionScrollKey =
|
||||
lastEntry?.kind === "user_submitted" ? props.entries.length : 0;
|
||||
|
||||
@@ -96,13 +99,14 @@ export const ChatMessageList = forwardRef<
|
||||
<ChatEntryView
|
||||
key={key}
|
||||
entry={entry}
|
||||
accent={getModeAccent(props.uiMode ?? "act")}
|
||||
accent={accent}
|
||||
terminalTheme={terminalTheme}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{props.isStreaming && (
|
||||
<box flexDirection="row" gap={1}>
|
||||
<spinner name="dots" color={getModeAccent(props.uiMode ?? "act")} />
|
||||
<spinner name="dots" color={accent} />
|
||||
<text fg="gray">Thinking... (esc to cancel)</text>
|
||||
</box>
|
||||
)}
|
||||
|
||||
@@ -87,7 +87,7 @@ const ACTION_ITEMS: Array<{
|
||||
{
|
||||
action: "fork",
|
||||
label: "Create Session Fork",
|
||||
shortcut: "Opt+F",
|
||||
shortcut: "Opt+R",
|
||||
description: "Branch the current conversation into a new session",
|
||||
keywords: ["fork", "session", "branch"],
|
||||
requiresFork: true,
|
||||
|
||||
@@ -114,4 +114,27 @@ describe("command palette", () => {
|
||||
})?.label,
|
||||
).toBe("Open Help");
|
||||
});
|
||||
|
||||
it("keeps option right available for word navigation", () => {
|
||||
const items = buildCommandPaletteItems({
|
||||
canForkSession: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
findCommandPaletteShortcut(items, {
|
||||
name: "f",
|
||||
meta: false,
|
||||
option: true,
|
||||
shift: false,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
findCommandPaletteShortcut(items, {
|
||||
name: "r",
|
||||
meta: false,
|
||||
option: true,
|
||||
shift: false,
|
||||
})?.label,
|
||||
).toBe("Create Session Fork");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { isToggleableInteractiveConfigItem } from "../../interactive-config";
|
||||
|
||||
export type ExtDetailRow =
|
||||
| { kind: "header"; name: string; source: string }
|
||||
| { kind: "field"; label: "Path" | "Description"; value: string[] }
|
||||
| { kind: "field"; label: "Path" | "Description" | "Error"; value: string[] }
|
||||
| { kind: "status"; enabled: boolean };
|
||||
|
||||
const TOGGLE_FOOTER = "Space toggle status, Tab/Enter/Esc to go back";
|
||||
@@ -77,6 +77,13 @@ export function getExtDetailRows(item: InteractiveConfigItem): ExtDetailRow[] {
|
||||
value: truncateDescription(item.description),
|
||||
});
|
||||
}
|
||||
if (item.loadError) {
|
||||
rows.push({
|
||||
kind: "field",
|
||||
label: "Error",
|
||||
value: truncateDescription(item.loadError),
|
||||
});
|
||||
}
|
||||
if (
|
||||
typeof item.enabled === "boolean" &&
|
||||
isToggleableInteractiveConfigItem(item)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import type {
|
||||
InteractiveConfigData,
|
||||
InteractiveConfigItem,
|
||||
LoadInteractiveConfigDataOptions,
|
||||
} from "../../interactive-config";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
@@ -18,6 +19,7 @@ export function ExtDetailContent(
|
||||
item: InteractiveConfigItem;
|
||||
onToggleConfigItem?: (
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
},
|
||||
) {
|
||||
@@ -31,23 +33,35 @@ export function ExtDetailContent(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const previousItem = item;
|
||||
setToggleError(undefined);
|
||||
if (typeof item.enabled === "boolean") {
|
||||
setItem({ ...item, enabled: !item.enabled });
|
||||
}
|
||||
try {
|
||||
const nextData = await props.onToggleConfigItem(item);
|
||||
const nextItem = [
|
||||
...(nextData?.workflows ?? []),
|
||||
...(nextData?.rules ?? []),
|
||||
...(nextData?.skills ?? []),
|
||||
...(nextData?.hooks ?? []),
|
||||
...(nextData?.agents ?? []),
|
||||
...(nextData?.plugins ?? []),
|
||||
...(nextData?.mcp ?? []),
|
||||
...(nextData?.tools ?? []),
|
||||
].find(
|
||||
(candidate) => candidate.id === item.id && candidate.path === item.path,
|
||||
);
|
||||
setItem(nextItem ?? { ...item, enabled: !item.enabled });
|
||||
const nextData = await props.onToggleConfigItem(item, {
|
||||
includePluginTools: false,
|
||||
});
|
||||
if (nextData) {
|
||||
const nextItem = [
|
||||
...nextData.workflows,
|
||||
...nextData.rules,
|
||||
...nextData.skills,
|
||||
...nextData.hooks,
|
||||
...nextData.agents,
|
||||
...nextData.plugins,
|
||||
...nextData.mcp,
|
||||
...nextData.tools,
|
||||
].find(
|
||||
(candidate) =>
|
||||
candidate.id === item.id && candidate.path === item.path,
|
||||
);
|
||||
if (nextItem) {
|
||||
setItem(nextItem);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
setItem(previousItem);
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setToggleError(`Failed to update ${item.name}: ${message}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { DialogActions, DialogId } from "@opentui-ui/dialog/react";
|
||||
|
||||
export type LoadingDialogActions = Pick<DialogActions, "show" | "close">;
|
||||
|
||||
export async function withShownDialog<T>(
|
||||
dialog: Pick<DialogActions, "close">,
|
||||
show: () => DialogId,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const loadingDialogId = show();
|
||||
// Give OpenTUI a microtask to mount the loading dialog before work starts.
|
||||
await Promise.resolve();
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
dialog.close(loadingDialogId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { DialogActions, DialogId } from "@opentui-ui/dialog/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withShownDialog } from "./loading-dialog-lifecycle";
|
||||
|
||||
type LoadingDialogCall =
|
||||
| {
|
||||
name: "show";
|
||||
}
|
||||
| {
|
||||
name: "close";
|
||||
id: DialogId | undefined;
|
||||
};
|
||||
|
||||
function createDialog(calls: LoadingDialogCall[]) {
|
||||
return {
|
||||
close: (id?: DialogId): DialogId | undefined => {
|
||||
calls.push({ name: "close", id });
|
||||
return id;
|
||||
},
|
||||
} satisfies Pick<DialogActions, "close">;
|
||||
}
|
||||
|
||||
function showLoading(calls: LoadingDialogCall[]): DialogId {
|
||||
calls.push({ name: "show" });
|
||||
return "loading-dialog";
|
||||
}
|
||||
|
||||
describe("withShownDialog", () => {
|
||||
it("shows a loading dialog while work runs", async () => {
|
||||
const calls: LoadingDialogCall[] = [];
|
||||
const events: string[] = [];
|
||||
const dialog = createDialog(calls);
|
||||
|
||||
const result = await withShownDialog(
|
||||
dialog,
|
||||
() => showLoading(calls),
|
||||
async () => {
|
||||
events.push("run");
|
||||
return 42;
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe(42);
|
||||
expect(events).toEqual(["run"]);
|
||||
expect(calls.map((call) => call.name)).toEqual(["show", "close"]);
|
||||
expect(calls[1]).toEqual({ name: "close", id: "loading-dialog" });
|
||||
});
|
||||
|
||||
it("closes the loading dialog when work fails", async () => {
|
||||
const calls: LoadingDialogCall[] = [];
|
||||
const dialog = createDialog(calls);
|
||||
|
||||
await expect(
|
||||
withShownDialog(
|
||||
dialog,
|
||||
() => showLoading(calls),
|
||||
async () => {
|
||||
throw new Error("failed");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("failed");
|
||||
|
||||
expect(calls.map((call) => call.name)).toEqual(["show", "close"]);
|
||||
expect(calls[1]).toEqual({ name: "close", id: "loading-dialog" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type {
|
||||
DialogId,
|
||||
DialogSize,
|
||||
DialogStyle,
|
||||
} from "@opentui-ui/dialog/react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
type LoadingDialogActions,
|
||||
withShownDialog,
|
||||
} from "./loading-dialog-lifecycle";
|
||||
|
||||
export interface LoadingDialogContentProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function LoadingDialogContent(props: LoadingDialogContentProps) {
|
||||
return (
|
||||
<box flexDirection="row" gap={1} paddingX={1}>
|
||||
<spinner name="dots" color="gray" />
|
||||
<text fg="gray">{props.message}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface LoadingDialogOptions {
|
||||
size?: DialogSize;
|
||||
style?: DialogStyle;
|
||||
}
|
||||
|
||||
export function showLoadingDialog(
|
||||
dialog: LoadingDialogActions,
|
||||
message: string,
|
||||
options?: LoadingDialogOptions,
|
||||
): DialogId {
|
||||
return dialog.show({
|
||||
size: options?.size ?? "small",
|
||||
style: options?.style,
|
||||
closeOnEscape: false,
|
||||
closeOnClickOutside: false,
|
||||
content: () => <LoadingDialogContent message={message} />,
|
||||
});
|
||||
}
|
||||
|
||||
export async function withLoadingDialog<T>(
|
||||
dialog: LoadingDialogActions,
|
||||
message: string,
|
||||
run: () => Promise<T>,
|
||||
options?: LoadingDialogOptions,
|
||||
): Promise<T> {
|
||||
return await withShownDialog(
|
||||
dialog,
|
||||
() => showLoadingDialog(dialog, message, options),
|
||||
run,
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user