mirror of
https://github.com/cline/cline.git
synced 2026-09-17 17:45:33 +08:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8915aab20b | ||
|
|
1ac5525fee | ||
|
|
a4d0937030 | ||
|
|
e44f9b1697 | ||
|
|
960ff55f83 | ||
|
|
6d5c61f044 | ||
|
|
8a6d031e8f | ||
|
|
3f808b369e | ||
|
|
e3b3e2306e | ||
|
|
b601b6e623 | ||
|
|
ed008bd36e | ||
|
|
6e964c3ef0 | ||
|
|
2e8579ebec | ||
|
|
07e2d0d3f8 | ||
|
|
cb0515d5f4 | ||
|
|
8c00eb9a77 | ||
|
|
ba471b7db9 | ||
|
|
72e2c8de2b | ||
|
|
5e7257d218 | ||
|
|
1a4122eb53 | ||
|
|
e6fa037c4c | ||
|
|
6e0ae8db58 | ||
|
|
dcef5b29d1 | ||
|
|
0b1fe6a982 | ||
|
|
c18eef5d4c | ||
|
|
65443a01ae | ||
|
|
7d56dd1e69 | ||
|
|
501f512066 | ||
|
|
5810a5d1e7 | ||
|
|
6a262b395a | ||
|
|
5117b7b0b8 | ||
|
|
6b530e5950 | ||
|
|
30ce8b1e72 | ||
|
|
26daf9cb48 | ||
|
|
2fcac58e24 | ||
|
|
112dbaa15b | ||
|
|
996a2f6a9c | ||
|
|
47f6d00f61 | ||
|
|
9ba2c932b4 | ||
|
|
01aaa5dd04 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -141,8 +141,9 @@ jobs:
|
||||
working-directory: sdk/apps/cli
|
||||
|
||||
- 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 +180,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 +202,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"
|
||||
|
||||
@@ -329,8 +333,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,8 +344,6 @@ 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'
|
||||
@@ -349,8 +352,9 @@ jobs:
|
||||
|
||||
- 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 +392,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"
|
||||
|
||||
@@ -49,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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
+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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.)
|
||||
|
||||
+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",
|
||||
|
||||
@@ -79,6 +79,28 @@ 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 manually, copy it into `.cline/plugins/`:
|
||||
|
||||
```sh
|
||||
mkdir -p .cline/plugins
|
||||
cp path/to/my-plugin.ts .cline/plugins/
|
||||
```
|
||||
|
||||
Global plugins are discovered from `~/.cline/plugins/`, and Cline may also discover plugins from the system Plugins folder. Use `.cline/plugins/` when you want plugins to stay with a specific project.
|
||||
|
||||
## Programmatic Installation
|
||||
|
||||
### Using `pluginPaths` (ClineCore)
|
||||
|
||||
@@ -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
+38
-1773
File diff suppressed because it is too large
Load Diff
+3
-6
@@ -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",
|
||||
@@ -593,9 +592,7 @@
|
||||
"js-yaml": "^4.1.1",
|
||||
"serialize-javascript": ">=7.0.3",
|
||||
"protobufjs": "7.5.5",
|
||||
"mocha": {
|
||||
"diff": ">=8.0.3"
|
||||
}
|
||||
"diff": "8.0.4"
|
||||
},
|
||||
"c8": {
|
||||
"reporter": [
|
||||
|
||||
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
|
||||
|
||||
-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,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.
|
||||
@@ -1,5 +1,13 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.7",
|
||||
"version": "3.0.8",
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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(" ");
|
||||
}
|
||||
|
||||
@@ -38,6 +38,53 @@ describe("telegramConnector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
|
||||
@@ -135,12 +135,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;
|
||||
}
|
||||
|
||||
@@ -957,6 +957,7 @@ export const telegramConnector: ConnectCommandDefinition =
|
||||
new TelegramConnector();
|
||||
|
||||
export const __test__ = {
|
||||
resolveTelegramParticipant,
|
||||
findBindingForThread: (
|
||||
bindings: ConnectorBindingStore<TelegramThreadState>,
|
||||
thread: Pick<Thread<TelegramThreadState>, "id" | "channelId" | "isDM"> & {
|
||||
|
||||
@@ -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"];
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ 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 {
|
||||
applyPluginFailures,
|
||||
type InteractiveConfigItem,
|
||||
} from "../../tui/interactive-config";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { createInteractiveConfigDataLoader } from "./config-data";
|
||||
|
||||
@@ -206,6 +209,82 @@ Use this skill.`,
|
||||
).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 () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderConfigFields,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
type ProviderConfigFieldKey,
|
||||
type ProviderConfigFieldRequirement,
|
||||
ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
@@ -21,6 +24,12 @@ import {
|
||||
import { isOAuthProvider } from "../../../utils/provider-auth";
|
||||
import { palette } from "../../palette";
|
||||
import { getProviderSection } from "../../utils/provider-sections";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
resolveProviderConfigAwsRegion,
|
||||
updateProviderConfigValue,
|
||||
type ProviderConfigValues,
|
||||
} from "../../utils/provider-config-values";
|
||||
import {
|
||||
getSearchableListRowsWindow,
|
||||
type SearchableItem,
|
||||
@@ -302,22 +311,33 @@ export function UseExistingOrReconfigureContent(
|
||||
);
|
||||
}
|
||||
|
||||
type ProviderConfigFieldKey = "apiKey" | "baseUrl";
|
||||
|
||||
const FIELD_LABELS: Record<ProviderConfigFieldKey, string> = {
|
||||
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
|
||||
apiKey: "API key",
|
||||
baseUrl: "Base URL",
|
||||
awsRegion: "AWS Region",
|
||||
awsProfile: "AWS Profile Name",
|
||||
};
|
||||
|
||||
const FIELD_PLACEHOLDERS: Record<ProviderConfigFieldKey, string> = {
|
||||
const DEFAULT_FIELD_PLACEHOLDERS: Partial<
|
||||
Record<ProviderConfigFieldKey, string>
|
||||
> = {
|
||||
apiKey: "sk-...",
|
||||
baseUrl: "",
|
||||
awsRegion: "us-east-1",
|
||||
awsProfile: "default",
|
||||
};
|
||||
|
||||
export interface ProviderConfigInputFields {
|
||||
apiKey?: { defaultValue?: string };
|
||||
baseUrl?: { defaultValue?: string };
|
||||
}
|
||||
/** Render order for cycling focus with Tab. */
|
||||
const FIELD_ORDER: ProviderConfigFieldKey[] = [
|
||||
"awsRegion",
|
||||
"baseUrl",
|
||||
"apiKey",
|
||||
"awsProfile",
|
||||
];
|
||||
|
||||
export type ProviderConfigInputFields = Partial<
|
||||
Record<ProviderConfigFieldKey, ProviderConfigFieldRequirement>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Single-purpose configure dialog: collects API key and (when applicable)
|
||||
@@ -340,43 +360,59 @@ export function ProviderConfigInputContent(
|
||||
dialogId,
|
||||
providerId,
|
||||
providerName,
|
||||
fields,
|
||||
providerSettingsManager,
|
||||
} = props;
|
||||
|
||||
// Render order: base URL first (when present) so local-server users land
|
||||
// on the actionable input. Cloud providers see just `apiKey`.
|
||||
const fieldKeys = useMemo<ProviderConfigFieldKey[]>(() => {
|
||||
const order: ProviderConfigFieldKey[] = ["baseUrl", "apiKey"];
|
||||
return order.filter((key) => fields[key] !== undefined);
|
||||
}, [fields]);
|
||||
|
||||
const [apiKey, setApiKey] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState(
|
||||
() =>
|
||||
providerSettingsManager
|
||||
.getProviderSettings(providerId)
|
||||
?.baseUrl?.trim() ??
|
||||
fields.baseUrl?.defaultValue ??
|
||||
"",
|
||||
const config = useMemo(
|
||||
() => getProviderConfigFields(providerId),
|
||||
[providerId],
|
||||
);
|
||||
const fieldKeys = useMemo<ProviderConfigFieldKey[]>(
|
||||
() => FIELD_ORDER.filter((key) => config.fields[key] !== undefined),
|
||||
[config],
|
||||
);
|
||||
|
||||
const initialFocus = fieldKeys[0] ?? "apiKey";
|
||||
const [focusedField, setFocusedField] =
|
||||
useState<ProviderConfigFieldKey>(initialFocus);
|
||||
const existingSettings =
|
||||
providerSettingsManager.getProviderSettings(providerId);
|
||||
const [values, setValues] = useState<ProviderConfigValues>(() => {
|
||||
const initial: ProviderConfigValues = {};
|
||||
if (config.fields.baseUrl) {
|
||||
initial.baseUrl =
|
||||
existingSettings?.baseUrl?.trim() ??
|
||||
config.fields.baseUrl?.defaultValue ??
|
||||
"";
|
||||
}
|
||||
if (config.fields.awsRegion) {
|
||||
const ep = existingSettings?.aws?.profile?.trim() ?? "";
|
||||
initial.awsRegion =
|
||||
existingSettings?.aws?.region?.trim() || getDefaultAwsRegion(ep);
|
||||
}
|
||||
if (config.fields.apiKey)
|
||||
initial.apiKey = existingSettings?.apiKey?.trim() ?? "";
|
||||
if (config.fields.awsProfile)
|
||||
initial.awsProfile = existingSettings?.aws?.profile?.trim() ?? "";
|
||||
return initial;
|
||||
});
|
||||
|
||||
const getValue = (key: ProviderConfigFieldKey): string =>
|
||||
key === "apiKey" ? apiKey : baseUrl;
|
||||
const setValue = (key: ProviderConfigFieldKey, value: string): void => {
|
||||
if (key === "apiKey") setApiKey(value);
|
||||
else setBaseUrl(value);
|
||||
};
|
||||
const [focusedField, setFocusedField] = useState<ProviderConfigFieldKey>(
|
||||
() => fieldKeys[0] ?? "apiKey",
|
||||
);
|
||||
|
||||
const submit = () => {
|
||||
const apiKey = values.apiKey?.trim();
|
||||
const awsProfile = values.awsProfile?.trim();
|
||||
const hasAwsFields = config.fields.awsRegion || config.fields.awsProfile;
|
||||
saveLocalProviderSettings(providerSettingsManager, {
|
||||
providerId,
|
||||
apiKey: fields.apiKey ? apiKey.trim() : undefined,
|
||||
baseUrl: fields.baseUrl ? baseUrl.trim() : undefined,
|
||||
apiKey: config.fields.apiKey ? apiKey : undefined,
|
||||
baseUrl: config.fields.baseUrl ? values.baseUrl?.trim() : undefined,
|
||||
aws: hasAwsFields
|
||||
? {
|
||||
region: resolveProviderConfigAwsRegion(values),
|
||||
authentication: apiKey ? "api-key" : "profile",
|
||||
profile: apiKey ? undefined : awsProfile || undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
resolve(true);
|
||||
};
|
||||
@@ -406,16 +442,20 @@ export function ProviderConfigInputContent(
|
||||
<strong>{providerName}</strong>
|
||||
</text>
|
||||
|
||||
{config.description && <text fg="gray">{config.description}</text>}
|
||||
|
||||
{fieldKeys.map((key) => {
|
||||
const requirement = fields[key];
|
||||
const requirement = config.fields[key];
|
||||
if (!requirement) return null;
|
||||
const label = requirement.label ?? DEFAULT_FIELD_LABELS[key] ?? key;
|
||||
const placeholder =
|
||||
key === "baseUrl" && requirement.defaultValue
|
||||
requirement.placeholder ??
|
||||
(key === "baseUrl" && requirement.defaultValue
|
||||
? requirement.defaultValue
|
||||
: FIELD_PLACEHOLDERS[key];
|
||||
: (DEFAULT_FIELD_PLACEHOLDERS[key] ?? ""));
|
||||
return (
|
||||
<box key={key} flexDirection="column">
|
||||
<text fg="gray">{FIELD_LABELS[key]}</text>
|
||||
<text fg="gray">{label}</text>
|
||||
<box
|
||||
border
|
||||
borderStyle="rounded"
|
||||
@@ -423,8 +463,12 @@ export function ProviderConfigInputContent(
|
||||
paddingX={1}
|
||||
>
|
||||
<input
|
||||
value={getValue(key)}
|
||||
onInput={(v: string) => setValue(key, v)}
|
||||
value={values[key] ?? ""}
|
||||
onInput={(v: string) =>
|
||||
setValues((prev) =>
|
||||
updateProviderConfigValue(prev, key, v),
|
||||
)
|
||||
}
|
||||
placeholder={placeholder}
|
||||
flexGrow={1}
|
||||
focused={focusedField === key}
|
||||
|
||||
@@ -198,13 +198,8 @@ export function useAutocomplete(opts: {
|
||||
);
|
||||
|
||||
const getFilteredMentionOptions = useCallback(
|
||||
(query: string): AutocompleteOption[] => {
|
||||
const q = query.toLowerCase();
|
||||
let filtered = mentionResults;
|
||||
if (q) {
|
||||
filtered = mentionResults.filter((f) => f.toLowerCase().includes(q));
|
||||
}
|
||||
return filtered.slice(0, MAX_COMPLETION_RESULTS).map((f) => ({
|
||||
(_query: string): AutocompleteOption[] => {
|
||||
return mentionResults.slice(0, MAX_COMPLETION_RESULTS).map((f) => ({
|
||||
display: f,
|
||||
value: formatMentionAutocompleteValue(f),
|
||||
}));
|
||||
@@ -233,6 +228,7 @@ export function useAutocomplete(opts: {
|
||||
if (searchTimerRef.current) {
|
||||
clearTimeout(searchTimerRef.current);
|
||||
}
|
||||
setMentionResults([]);
|
||||
const counter = ++searchCounterRef.current;
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
searchWorkspaceFilesForMention({
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
discoverPluginModulePaths,
|
||||
hasMcpSettingsFile,
|
||||
listHookConfigFiles,
|
||||
listPluginTools,
|
||||
listPluginToolsWithDiagnostics,
|
||||
type PluginInitializationFailure,
|
||||
type RuleConfig,
|
||||
readGlobalSettings,
|
||||
resolveAgentConfigSearchPaths,
|
||||
@@ -49,6 +50,8 @@ export interface InteractiveConfigItem {
|
||||
toolNames?: string[];
|
||||
configKind?: "tool" | "plugin";
|
||||
pluginName?: string;
|
||||
loadError?: string;
|
||||
loadErrorPhase?: PluginInitializationFailure["phase"];
|
||||
source:
|
||||
| "global"
|
||||
| "workspace"
|
||||
@@ -216,6 +219,36 @@ function getPluginDisplayName(filePath: string): string {
|
||||
return basename(filePath, extname(filePath));
|
||||
}
|
||||
|
||||
function formatPluginFailure(failure: PluginInitializationFailure): string {
|
||||
return `${failure.phase === "setup" ? "setup failed" : "load failed"}: ${failure.message}`;
|
||||
}
|
||||
|
||||
export function applyPluginFailures(
|
||||
plugins: InteractiveConfigItem[],
|
||||
failures: readonly PluginInitializationFailure[],
|
||||
): void {
|
||||
const pluginsByPath = new Map(plugins.map((plugin) => [plugin.path, plugin]));
|
||||
const failuresByPath = new Map<string, PluginInitializationFailure[]>();
|
||||
for (const failure of failures) {
|
||||
const failuresForPath = failuresByPath.get(failure.pluginPath) ?? [];
|
||||
failuresForPath.push(failure);
|
||||
failuresByPath.set(failure.pluginPath, failuresForPath);
|
||||
}
|
||||
for (const [pluginPath, failuresForPath] of failuresByPath) {
|
||||
const plugin = pluginsByPath.get(pluginPath);
|
||||
if (!plugin) {
|
||||
continue;
|
||||
}
|
||||
const namedFailure = failuresForPath.find((failure) => failure.pluginName);
|
||||
if (namedFailure?.pluginName) {
|
||||
plugin.name = namedFailure.pluginName;
|
||||
}
|
||||
plugin.loadError = failuresForPath.map(formatPluginFailure).join("\n");
|
||||
plugin.loadErrorPhase =
|
||||
failuresForPath.length === 1 ? failuresForPath[0]?.phase : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadInteractiveConfigData(input: {
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
cwd: string;
|
||||
@@ -356,12 +389,14 @@ export async function loadInteractiveConfigData(input: {
|
||||
);
|
||||
if (input.includePluginTools !== false) {
|
||||
try {
|
||||
for (const pluginTool of await listPluginTools({
|
||||
const pluginToolResult = await listPluginToolsWithDiagnostics({
|
||||
workspacePath: input.workspaceRoot,
|
||||
cwd: input.cwd,
|
||||
providerId: input.availabilityContext?.providerId,
|
||||
modelId: input.availabilityContext?.modelId,
|
||||
})) {
|
||||
});
|
||||
applyPluginFailures(plugins, pluginToolResult.failures);
|
||||
for (const pluginTool of pluginToolResult.tools) {
|
||||
tools.push({
|
||||
id: `${pluginTool.pluginName}:${pluginTool.name}:${pluginTool.path}`,
|
||||
name: pluginTool.name,
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rankMentionPaths } from "./interactive-welcome";
|
||||
|
||||
describe("TUI file mention search ranking", () => {
|
||||
it("keeps initialism matches for compact and hyphenated input", () => {
|
||||
const paths = [
|
||||
"src/components/Button.tsx",
|
||||
"src/domain/MyAmazingClassDefinition.ts",
|
||||
"docs/MACD.md",
|
||||
"packages/core/src/runtime/manager.ts",
|
||||
];
|
||||
|
||||
expect(rankMentionPaths(paths, "MACD", 10)).toEqual([
|
||||
"docs/MACD.md",
|
||||
"src/domain/MyAmazingClassDefinition.ts",
|
||||
]);
|
||||
expect(rankMentionPaths(paths, "M-A-C-D", 10)).toEqual([
|
||||
"docs/MACD.md",
|
||||
"src/domain/MyAmazingClassDefinition.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes common mention prefixes before matching workspace paths", () => {
|
||||
const paths = [
|
||||
"docs/architecture.md",
|
||||
"src/tui/interactive-welcome.ts",
|
||||
"src/tui/hooks/use-autocomplete.ts",
|
||||
];
|
||||
|
||||
expect(rankMentionPaths(paths, "./src/tui", 10)).toEqual([
|
||||
"src/tui/hooks/use-autocomplete.ts",
|
||||
"src/tui/interactive-welcome.ts",
|
||||
]);
|
||||
expect(rankMentionPaths(paths, "/docs", 10)).toEqual([
|
||||
"docs/architecture.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ranks filename matches ahead of path-only fuzzy matches", () => {
|
||||
const paths = [
|
||||
"src/migrations/add-column.ts",
|
||||
"src/domain/MyAmazingClassDefinition.ts",
|
||||
"docs/classes.md",
|
||||
];
|
||||
|
||||
expect(rankMentionPaths(paths, "class", 10)[0]).toBe("docs/classes.md");
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type ProviderSettings,
|
||||
type UserInstructionConfigService,
|
||||
} from "@cline/core";
|
||||
import { byLengthAsc, Fzf, type FzfResultItem } from "fzf";
|
||||
import type { Config } from "../utils/types";
|
||||
import { formatClineCredits, loadClineAccountSnapshot } from "./cline-account";
|
||||
|
||||
@@ -17,24 +18,95 @@ function normalizeLimit(limit: number | undefined): number {
|
||||
if (typeof limit !== "number" || Number.isNaN(limit)) {
|
||||
return 10;
|
||||
}
|
||||
return Math.min(50, Math.max(1, Math.trunc(limit)));
|
||||
return Math.min(200, Math.max(1, Math.trunc(limit)));
|
||||
}
|
||||
|
||||
function rankPath(path: string, query: string): number {
|
||||
if (query.length === 0) {
|
||||
return 3;
|
||||
function getPathLabel(filePath: string): string {
|
||||
const parts = filePath.split("/");
|
||||
return parts[parts.length - 1] ?? filePath;
|
||||
}
|
||||
|
||||
function normalizeMentionQuery(query: string): string {
|
||||
const trimmed = query.trim().replace(/^["']/, "");
|
||||
if (trimmed.startsWith("./")) {
|
||||
return trimmed.slice(2);
|
||||
}
|
||||
const lowerPath = path.toLowerCase();
|
||||
if (lowerPath.startsWith(query)) {
|
||||
return 0;
|
||||
if (trimmed.startsWith("/")) {
|
||||
return trimmed.slice(1);
|
||||
}
|
||||
if (lowerPath.includes(`/${query}`)) {
|
||||
return 1;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function compactSearchText(text: string): string {
|
||||
return text.toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
interface MentionPathItem {
|
||||
path: string;
|
||||
label: string;
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
function countGaps(positions: Iterable<number>): number {
|
||||
const sortedPositions = Array.from(positions).sort((a, b) => a - b);
|
||||
let gaps = 0;
|
||||
let previous = Number.NEGATIVE_INFINITY;
|
||||
for (const position of sortedPositions) {
|
||||
if (previous !== Number.NEGATIVE_INFINITY && position - previous > 1) {
|
||||
gaps++;
|
||||
}
|
||||
previous = position;
|
||||
}
|
||||
if (lowerPath.includes(query)) {
|
||||
return 2;
|
||||
return gaps;
|
||||
}
|
||||
|
||||
function orderByMatchScore(
|
||||
left: FzfResultItem<MentionPathItem>,
|
||||
right: FzfResultItem<MentionPathItem>,
|
||||
): number {
|
||||
return countGaps(left.positions) - countGaps(right.positions);
|
||||
}
|
||||
|
||||
export function rankMentionPaths(
|
||||
paths: Iterable<string>,
|
||||
query: string,
|
||||
limit: number,
|
||||
): string[] {
|
||||
const items = Array.from(paths, (path): MentionPathItem => {
|
||||
const label = getPathLabel(path);
|
||||
return {
|
||||
path,
|
||||
label,
|
||||
searchText: [
|
||||
label,
|
||||
label,
|
||||
path,
|
||||
compactSearchText(label),
|
||||
compactSearchText(path),
|
||||
].join(" "),
|
||||
};
|
||||
});
|
||||
|
||||
const normalizedQuery = normalizeMentionQuery(query);
|
||||
if (!normalizedQuery) {
|
||||
return items
|
||||
.sort((left, right) => left.path.localeCompare(right.path))
|
||||
.slice(0, limit)
|
||||
.map((item) => item.path);
|
||||
}
|
||||
return Number.POSITIVE_INFINITY;
|
||||
|
||||
const fzf = new Fzf(items, {
|
||||
selector: (item) => item.searchText,
|
||||
tiebreakers: [orderByMatchScore, byLengthAsc],
|
||||
limit,
|
||||
});
|
||||
|
||||
const rawResults = fzf.find(normalizedQuery);
|
||||
const results =
|
||||
rawResults.length > 0
|
||||
? rawResults
|
||||
: fzf.find(compactSearchText(normalizedQuery));
|
||||
return results.map((result) => result.item.path);
|
||||
}
|
||||
|
||||
export function listInteractiveSlashCommands(
|
||||
@@ -90,21 +162,9 @@ export async function searchWorkspaceFilesForMention(input: {
|
||||
if (!workspaceRoot) {
|
||||
return [];
|
||||
}
|
||||
const query = input.query.trim().toLowerCase();
|
||||
const limit = normalizeLimit(input.limit);
|
||||
const index = await getFileIndex(workspaceRoot);
|
||||
const allPaths = Array.from(index).sort((a, b) => a.localeCompare(b));
|
||||
return allPaths
|
||||
.map((path) => ({ path, rank: rankPath(path, query) }))
|
||||
.filter((item) => Number.isFinite(item.rank))
|
||||
.sort((left, right) => {
|
||||
if (left.rank !== right.rank) {
|
||||
return left.rank - right.rank;
|
||||
}
|
||||
return left.path.localeCompare(right.path);
|
||||
})
|
||||
.slice(0, limit)
|
||||
.map((item) => item.path);
|
||||
return rankMentionPaths(index, input.query, limit);
|
||||
}
|
||||
|
||||
export async function resolveClineWelcomeLine(input: {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
resolveProviderConfigAwsRegion,
|
||||
updateProviderConfigValue,
|
||||
} from "./provider-config-values";
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
describe("provider config values", () => {
|
||||
it("updates an auto-filled AWS region when the profile changes", () => {
|
||||
delete process.env.AWS_REGION;
|
||||
delete process.env.AWS_DEFAULT_REGION;
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-provider-config-"));
|
||||
try {
|
||||
const configPath = join(dir, "config");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
"[default]",
|
||||
"region = us-east-1",
|
||||
"[profile dev]",
|
||||
"region = ap-southeast-2",
|
||||
].join("\n"),
|
||||
);
|
||||
process.env.AWS_CONFIG_FILE = configPath;
|
||||
|
||||
const result = updateProviderConfigValue(
|
||||
{ awsProfile: "", awsRegion: getDefaultAwsRegion("") },
|
||||
"awsProfile",
|
||||
"dev",
|
||||
);
|
||||
|
||||
expect(result.awsRegion).toBe("ap-southeast-2");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves a manually entered AWS region when the profile changes", () => {
|
||||
const result = updateProviderConfigValue(
|
||||
{ awsProfile: "", awsRegion: "eu-central-1" },
|
||||
"awsProfile",
|
||||
"dev",
|
||||
);
|
||||
|
||||
expect(result.awsRegion).toBe("eu-central-1");
|
||||
});
|
||||
|
||||
it("resolves AWS region from the saved profile when region is blank", () => {
|
||||
process.env.AWS_REGION = "us-west-2";
|
||||
|
||||
expect(
|
||||
resolveProviderConfigAwsRegion({
|
||||
awsProfile: "dev",
|
||||
awsRegion: "",
|
||||
}),
|
||||
).toBe("us-west-2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ProviderConfigFieldKey } from "@cline/core";
|
||||
import { resolveAwsRegion } from "../../utils/aws-region";
|
||||
|
||||
export type ProviderConfigValues = Partial<
|
||||
Record<ProviderConfigFieldKey, string>
|
||||
>;
|
||||
|
||||
const DEFAULT_AWS_REGION = "us-east-1";
|
||||
|
||||
export function getDefaultAwsRegion(profile?: string): string {
|
||||
return (
|
||||
resolveAwsRegion({ profile: profile?.trim() || undefined }) ??
|
||||
DEFAULT_AWS_REGION
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveProviderConfigAwsRegion(
|
||||
values: ProviderConfigValues,
|
||||
): string {
|
||||
return values.awsRegion?.trim() || getDefaultAwsRegion(values.awsProfile);
|
||||
}
|
||||
|
||||
export function updateProviderConfigValue(
|
||||
previous: ProviderConfigValues,
|
||||
field: ProviderConfigFieldKey,
|
||||
value: string,
|
||||
): ProviderConfigValues {
|
||||
const next: ProviderConfigValues = { ...previous, [field]: value };
|
||||
if (field !== "awsProfile") {
|
||||
return next;
|
||||
}
|
||||
|
||||
const previousRegion = previous.awsRegion?.trim();
|
||||
const previousProfileRegion = getDefaultAwsRegion(previous.awsProfile);
|
||||
if (!previousRegion || previousRegion === previousProfileRegion) {
|
||||
next.awsRegion = getDefaultAwsRegion(value);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -235,6 +235,17 @@ function appendToolRows(
|
||||
}
|
||||
}
|
||||
|
||||
function getPluginLoadErrorLabel(
|
||||
item: InteractiveConfigItem,
|
||||
): string | undefined {
|
||||
if (!item.loadError) {
|
||||
return undefined;
|
||||
}
|
||||
const lines = item.loadError.split("\n");
|
||||
const first = lines[0] ?? item.loadError;
|
||||
return lines.length > 1 ? `${first} (+${lines.length - 1} more)` : first;
|
||||
}
|
||||
|
||||
export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
const { resolve, dismiss, dialogId, config, loadConfigData } = props;
|
||||
const { height } = useTerminalDimensions();
|
||||
@@ -264,7 +275,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeTab !== "tools" ||
|
||||
(activeTab !== "tools" && activeTab !== "plugins") ||
|
||||
pluginToolsLoaded ||
|
||||
pluginToolsError ||
|
||||
!loadConfigData
|
||||
@@ -288,7 +299,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setPluginToolsError(`Failed to load plugin tools: ${message}`);
|
||||
setPluginToolsError(`Failed to load plugin diagnostics: ${message}`);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
@@ -351,6 +362,19 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
enabled: item.enabled,
|
||||
description: item.description,
|
||||
item,
|
||||
rightLabel: getPluginLoadErrorLabel(item),
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsLoading) {
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: "Loading plugin diagnostics...",
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsError) {
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: pluginToolsError,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -635,8 +659,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
const rightLabel = row.rightLabel ?? "";
|
||||
const toggleable = isToggleableConfigItem(row.item);
|
||||
const prefix = " ".repeat(row.indent ?? 0);
|
||||
const rowColor =
|
||||
toggleable && enabledState === "enabled"
|
||||
const rowColor = row.item.loadError
|
||||
? "red"
|
||||
: toggleable && enabledState === "enabled"
|
||||
? palette.success
|
||||
: enabledState === "partial"
|
||||
? "yellow"
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getLocalProviderModels,
|
||||
getProviderConfigFields,
|
||||
listLocalProviders,
|
||||
type ProviderConfigFieldKey,
|
||||
type ProviderConfigFields,
|
||||
ProviderSettingsManager,
|
||||
refreshProviderModelsFromSource,
|
||||
@@ -28,12 +29,19 @@ import {
|
||||
} from "../../components/searchable-list";
|
||||
import { palette } from "../../palette";
|
||||
import { getProviderSection } from "../../utils/provider-sections";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
resolveProviderConfigAwsRegion,
|
||||
updateProviderConfigValue,
|
||||
type ProviderConfigValues,
|
||||
} from "../../utils/provider-config-values";
|
||||
import {
|
||||
isOnboardingOAuthProviderId,
|
||||
type OnboardingOAuthProviderId,
|
||||
runDeviceCodeAuthFlow,
|
||||
runOAuthAuthFlow,
|
||||
} from "./auth";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import { useOnboardingKeyboard } from "./keyboard";
|
||||
import {
|
||||
type ModelEntry,
|
||||
@@ -47,8 +55,6 @@ import {
|
||||
toProviderEntry,
|
||||
} from "./model";
|
||||
|
||||
type ByoFieldKey = "apiKey" | "baseUrl";
|
||||
|
||||
const CUSTOM_MODEL_ID_ACTION = "__custom_model_id__";
|
||||
|
||||
export interface OnboardingControllerProps {
|
||||
@@ -74,9 +80,10 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const [byoFields, setByoFields] = useState<ProviderConfigFields["fields"]>(
|
||||
{},
|
||||
);
|
||||
const [byoApiKey, setByoApiKey] = useState("");
|
||||
const [byoBaseUrl, setByoBaseUrl] = useState("");
|
||||
const [byoFocusedField, setByoFocusedField] = useState<ByoFieldKey>("apiKey");
|
||||
const [byoDescription, setByoDescription] = useState<string | undefined>();
|
||||
const [byoValues, setByoValues] = useState<ProviderConfigValues>({});
|
||||
const [byoFocusedField, setByoFocusedField] =
|
||||
useState<ProviderConfigFieldKey>("apiKey");
|
||||
const [codexCliStatus, setCodexCliStatus] = useState<
|
||||
CodexCliStatus | undefined
|
||||
>();
|
||||
@@ -363,17 +370,35 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setActiveProviderId(provider.id);
|
||||
setActiveProviderName(provider.name);
|
||||
setByoFields(config.fields);
|
||||
setByoApiKey("");
|
||||
setByoBaseUrl(
|
||||
providerSettingsManager
|
||||
.getProviderSettings(provider.id)
|
||||
?.baseUrl?.trim() ??
|
||||
setByoDescription(config.description);
|
||||
|
||||
// Build initial values from existing settings
|
||||
const existing = providerSettingsManager.getProviderSettings(provider.id);
|
||||
const initialValues: ProviderConfigValues = {};
|
||||
if (config.fields.baseUrl) {
|
||||
initialValues.baseUrl =
|
||||
existing?.baseUrl?.trim() ??
|
||||
config.fields.baseUrl?.defaultValue ??
|
||||
"",
|
||||
"";
|
||||
}
|
||||
if (config.fields.awsRegion) {
|
||||
const existingProfile = existing?.aws?.profile?.trim() ?? "";
|
||||
initialValues.awsRegion =
|
||||
existing?.aws?.region?.trim() || getDefaultAwsRegion(existingProfile);
|
||||
}
|
||||
if (config.fields.apiKey) {
|
||||
initialValues.apiKey = existing?.apiKey?.trim() ?? "";
|
||||
}
|
||||
if (config.fields.awsProfile) {
|
||||
initialValues.awsProfile = existing?.aws?.profile?.trim() ?? "";
|
||||
}
|
||||
setByoValues(initialValues);
|
||||
|
||||
// Focus the first visible field
|
||||
const firstField = FIELD_ORDER.find(
|
||||
(k) => config.fields[k] !== undefined,
|
||||
);
|
||||
// Focus base URL first when present (local-server users land on
|
||||
// the actionable input). Cloud providers see only `apiKey`.
|
||||
setByoFocusedField(config.fields.baseUrl ? "baseUrl" : "apiKey");
|
||||
setByoFocusedField(firstField ?? "apiKey");
|
||||
setStep("byo_apikey");
|
||||
},
|
||||
[providers, startOAuthFlow, refreshCodexCliStatus, providerSettingsManager],
|
||||
@@ -398,10 +423,21 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
// No required-field validation. If credentials are missing or wrong,
|
||||
// the provider's own auth response is the authoritative error and is
|
||||
// surfaced when the model picker / first turn runs.
|
||||
const apiKey = byoValues.apiKey?.trim();
|
||||
const awsProfile = byoValues.awsProfile?.trim();
|
||||
const hasAwsFields = byoFields.awsRegion || byoFields.awsProfile;
|
||||
|
||||
saveLocalProviderSettings(providerSettingsManager, {
|
||||
providerId: activeProviderId,
|
||||
apiKey: byoFields.apiKey ? byoApiKey.trim() : undefined,
|
||||
baseUrl: byoFields.baseUrl ? byoBaseUrl.trim() : undefined,
|
||||
apiKey: byoFields.apiKey ? apiKey : undefined,
|
||||
baseUrl: byoFields.baseUrl ? byoValues.baseUrl?.trim() : undefined,
|
||||
aws: hasAwsFields
|
||||
? {
|
||||
region: resolveProviderConfigAwsRegion(byoValues),
|
||||
authentication: apiKey ? "api-key" : "profile",
|
||||
profile: apiKey ? undefined : awsProfile || undefined,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
// Emit a single `user.provider_configured` event mirroring the
|
||||
// `{ provider }` payload shape used by the auth funnel. The save above
|
||||
@@ -411,8 +447,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
captureProviderConfigured(getCliTelemetryService(), activeProviderId);
|
||||
transitionToModelPicker(activeProviderId);
|
||||
}, [
|
||||
byoApiKey,
|
||||
byoBaseUrl,
|
||||
byoValues,
|
||||
byoFields,
|
||||
activeProviderId,
|
||||
providerSettingsManager,
|
||||
@@ -553,8 +588,8 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
setMenuSelected,
|
||||
resetByoFields: () => {
|
||||
setByoFields({});
|
||||
setByoApiKey("");
|
||||
setByoBaseUrl("");
|
||||
setByoValues({});
|
||||
setByoDescription(undefined);
|
||||
},
|
||||
byoFields,
|
||||
byoFocusedField,
|
||||
@@ -579,6 +614,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
loadModelsForProvider,
|
||||
saveClineModelSelection,
|
||||
saveCodexCliConfig,
|
||||
saveByoConfig,
|
||||
saveModelSelection,
|
||||
saveThinkingLevel,
|
||||
});
|
||||
@@ -588,10 +624,10 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
authError,
|
||||
authStatus,
|
||||
authUrl,
|
||||
byoApiKey,
|
||||
byoBaseUrl,
|
||||
byoDescription,
|
||||
byoFields,
|
||||
byoFocusedField,
|
||||
byoValues,
|
||||
codexCliChecking,
|
||||
codexCliStatus,
|
||||
clineEntries,
|
||||
@@ -607,8 +643,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
activeProviderId === "openai-compatible"
|
||||
? "Set model ID"
|
||||
: "Create custom model ID",
|
||||
handleByoApiKeyInput: setByoApiKey,
|
||||
handleByoBaseUrlInput: setByoBaseUrl,
|
||||
handleByoFieldInput: (field: ProviderConfigFieldKey, value: string) => {
|
||||
setByoValues((prev) => updateProviderConfigValue(prev, field, value));
|
||||
},
|
||||
handleCustomModelIdInput: (value: string) => {
|
||||
setCustomModelId(value);
|
||||
setCustomModelError("");
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ProviderConfigFieldKey } from "@cline/core";
|
||||
|
||||
/** Render order for provider config fields and Tab cycling. */
|
||||
export const FIELD_ORDER: ProviderConfigFieldKey[] = [
|
||||
"awsRegion",
|
||||
"baseUrl",
|
||||
"apiKey",
|
||||
"awsProfile",
|
||||
];
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { ProviderConfigFieldKey } from "@cline/core";
|
||||
import { useKeyboard } from "@opentui/react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { ClineModelPickerEntry } from "../../components/model-selector/cline-model-picker";
|
||||
import type { SearchableListState } from "../../components/searchable-list";
|
||||
import type { OnboardingOAuthProviderId } from "./auth";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import {
|
||||
MAIN_MENU,
|
||||
type OnboardingStep,
|
||||
@@ -24,9 +26,9 @@ export function useOnboardingKeyboard(input: {
|
||||
setStep: (step: OnboardingStep) => void;
|
||||
setMenuSelected: Dispatch<SetStateAction<number>>;
|
||||
resetByoFields: () => void;
|
||||
byoFields: { apiKey?: unknown; baseUrl?: unknown };
|
||||
byoFocusedField: "apiKey" | "baseUrl";
|
||||
setByoFocusedField: (field: "apiKey" | "baseUrl") => void;
|
||||
byoFields: Partial<Record<ProviderConfigFieldKey, unknown>>;
|
||||
byoFocusedField: ProviderConfigFieldKey;
|
||||
setByoFocusedField: Dispatch<SetStateAction<ProviderConfigFieldKey>>;
|
||||
setDeviceUserCode: (value: string) => void;
|
||||
setDeviceVerifyUrl: (value: string) => void;
|
||||
setDeviceError: (value: string) => void;
|
||||
@@ -43,6 +45,7 @@ export function useOnboardingKeyboard(input: {
|
||||
loadModelsForProvider: (providerId: string) => void;
|
||||
saveClineModelSelection: (modelId: string, modelName: string) => void;
|
||||
saveCodexCliConfig: () => void;
|
||||
saveByoConfig: () => void;
|
||||
saveModelSelection: () => void;
|
||||
saveThinkingLevel: (level: ThinkingLevel) => void;
|
||||
}) {
|
||||
@@ -178,7 +181,7 @@ export function useOnboardingKeyboard(input: {
|
||||
|
||||
if (input.step === "byo_apikey") {
|
||||
if (key.name === "tab") {
|
||||
const visible = (["baseUrl", "apiKey"] as const).filter(
|
||||
const visible = FIELD_ORDER.filter(
|
||||
(k) => input.byoFields[k] !== undefined,
|
||||
);
|
||||
if (visible.length > 1) {
|
||||
@@ -187,7 +190,7 @@ export function useOnboardingKeyboard(input: {
|
||||
? (idx - 1 + visible.length) % visible.length
|
||||
: (idx + 1) % visible.length;
|
||||
const next = visible[nextIdx];
|
||||
if (next) input.setByoFocusedField(next);
|
||||
if (next) input.setByoFocusedField(next as ProviderConfigFieldKey);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "../../components/tracked-robot";
|
||||
import { useTerminalBackground } from "../../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, palette } from "../../palette";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import { MAIN_MENU, THINKING_LEVELS } from "./model";
|
||||
|
||||
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
|
||||
@@ -213,37 +214,43 @@ export function OnboardingDeviceCodeScreen(props: {
|
||||
);
|
||||
}
|
||||
|
||||
type ByoFieldKey = "apiKey" | "baseUrl";
|
||||
import type {
|
||||
ProviderConfigFieldKey,
|
||||
ProviderConfigFieldRequirement,
|
||||
} from "@cline/core";
|
||||
|
||||
const BYO_FIELD_LABELS: Record<ByoFieldKey, string> = {
|
||||
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
|
||||
apiKey: "API key",
|
||||
baseUrl: "Base URL",
|
||||
awsRegion: "AWS Region",
|
||||
awsProfile: "AWS Profile Name",
|
||||
};
|
||||
|
||||
const BYO_FIELD_PLACEHOLDERS: Record<ByoFieldKey, string> = {
|
||||
const DEFAULT_FIELD_PLACEHOLDERS: Partial<
|
||||
Record<ProviderConfigFieldKey, string>
|
||||
> = {
|
||||
apiKey: "Paste your API key here...",
|
||||
baseUrl: "",
|
||||
awsRegion: "us-east-1",
|
||||
awsProfile: "default",
|
||||
};
|
||||
|
||||
export function OnboardingProviderConfigScreen(props: {
|
||||
activeProviderName: string;
|
||||
apiKeyValue: string;
|
||||
baseUrlValue: string;
|
||||
compact: boolean;
|
||||
contentWidth: number;
|
||||
fields: {
|
||||
apiKey?: { defaultValue?: string };
|
||||
baseUrl?: { defaultValue?: string };
|
||||
};
|
||||
focusedField: ByoFieldKey;
|
||||
description?: string;
|
||||
fields: Partial<
|
||||
Record<ProviderConfigFieldKey, ProviderConfigFieldRequirement>
|
||||
>;
|
||||
focusedField: ProviderConfigFieldKey;
|
||||
mouse: MouseTrackerState;
|
||||
onApiKeyInput: (value: string) => void;
|
||||
onBaseUrlInput: (value: string) => void;
|
||||
values: Partial<Record<ProviderConfigFieldKey, string>>;
|
||||
onFieldInput: (field: ProviderConfigFieldKey, value: string) => void;
|
||||
onSubmit: () => void;
|
||||
}) {
|
||||
const defaultFg = useDefaultFg();
|
||||
const fieldOrder: ByoFieldKey[] = ["baseUrl", "apiKey"];
|
||||
const visibleFields = fieldOrder.filter(
|
||||
const visibleFields = FIELD_ORDER.filter(
|
||||
(key) => props.fields[key] !== undefined,
|
||||
);
|
||||
|
||||
@@ -256,17 +263,18 @@ export function OnboardingProviderConfigScreen(props: {
|
||||
<box flexDirection="column" gap={1} alignItems="center">
|
||||
<text fg={defaultFg}>{props.activeProviderName}</text>
|
||||
|
||||
{props.description && <text fg="gray">{props.description}</text>}
|
||||
|
||||
{visibleFields.map((key) => {
|
||||
const requirement = props.fields[key];
|
||||
if (!requirement) return null;
|
||||
const label = requirement.label ?? DEFAULT_FIELD_LABELS[key] ?? key;
|
||||
const placeholder =
|
||||
key === "baseUrl" && requirement.defaultValue
|
||||
requirement.placeholder ??
|
||||
(key === "baseUrl" && requirement.defaultValue
|
||||
? requirement.defaultValue
|
||||
: BYO_FIELD_PLACEHOLDERS[key];
|
||||
const value =
|
||||
key === "apiKey" ? props.apiKeyValue : props.baseUrlValue;
|
||||
const onInput =
|
||||
key === "apiKey" ? props.onApiKeyInput : props.onBaseUrlInput;
|
||||
: (DEFAULT_FIELD_PLACEHOLDERS[key] ?? ""));
|
||||
const value = props.values[key] ?? "";
|
||||
const isFocused = props.focusedField === key;
|
||||
return (
|
||||
<box
|
||||
@@ -275,7 +283,7 @@ export function OnboardingProviderConfigScreen(props: {
|
||||
gap={0}
|
||||
width={props.contentWidth}
|
||||
>
|
||||
<text fg="gray">{BYO_FIELD_LABELS[key]}</text>
|
||||
<text fg="gray">{label}</text>
|
||||
<box
|
||||
border
|
||||
borderStyle="rounded"
|
||||
@@ -284,7 +292,7 @@ export function OnboardingProviderConfigScreen(props: {
|
||||
>
|
||||
<input
|
||||
value={value}
|
||||
onInput={onInput}
|
||||
onInput={(v: string) => props.onFieldInput(key, v)}
|
||||
onSubmit={props.onSubmit}
|
||||
placeholder={placeholder}
|
||||
textColor={defaultFg}
|
||||
|
||||
@@ -69,15 +69,14 @@ export function OnboardingView(props: OnboardingViewProps) {
|
||||
return (
|
||||
<OnboardingProviderConfigScreen
|
||||
activeProviderName={state.activeProviderName}
|
||||
apiKeyValue={state.byoApiKey}
|
||||
baseUrlValue={state.byoBaseUrl}
|
||||
compact={compact}
|
||||
contentWidth={contentWidth}
|
||||
description={state.byoDescription}
|
||||
fields={state.byoFields}
|
||||
focusedField={state.byoFocusedField}
|
||||
mouse={mouse}
|
||||
onApiKeyInput={state.handleByoApiKeyInput}
|
||||
onBaseUrlInput={state.handleByoBaseUrlInput}
|
||||
values={state.byoValues}
|
||||
onFieldInput={state.handleByoFieldInput}
|
||||
onSubmit={state.saveByoConfig}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { resolveAwsRegion } from "./aws-region";
|
||||
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
describe("resolveAwsRegion", () => {
|
||||
it("prefers explicit region", () => {
|
||||
process.env.AWS_REGION = "us-east-1";
|
||||
expect(resolveAwsRegion({ explicitRegion: "us-west-2" })).toBe("us-west-2");
|
||||
});
|
||||
|
||||
it("uses AWS_REGION before AWS_DEFAULT_REGION", () => {
|
||||
process.env.AWS_REGION = "eu-west-1";
|
||||
process.env.AWS_DEFAULT_REGION = "us-east-2";
|
||||
expect(resolveAwsRegion()).toBe("eu-west-1");
|
||||
});
|
||||
|
||||
it("reads selected profile region from AWS config", () => {
|
||||
delete process.env.AWS_REGION;
|
||||
delete process.env.AWS_DEFAULT_REGION;
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-aws-config-"));
|
||||
try {
|
||||
const configPath = join(dir, "config");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
[
|
||||
"[default]",
|
||||
"region = us-east-1",
|
||||
"[profile dev]",
|
||||
"region = ap-southeast-2",
|
||||
].join("\n"),
|
||||
);
|
||||
process.env.AWS_CONFIG_FILE = configPath;
|
||||
|
||||
expect(resolveAwsRegion({ profile: "dev" })).toBe("ap-southeast-2");
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
function parseAwsConfigProfiles(
|
||||
content: string,
|
||||
): Record<string, Record<string, string>> {
|
||||
const profiles: Record<string, Record<string, string>> = {};
|
||||
let currentProfile: string | undefined;
|
||||
|
||||
for (const rawLine of content.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#") || line.startsWith(";")) continue;
|
||||
|
||||
const sectionMatch = line.match(/^\[([^\]]+)]$/);
|
||||
if (sectionMatch) {
|
||||
const section = sectionMatch[1]?.trim() ?? "";
|
||||
currentProfile = section.startsWith("profile ")
|
||||
? section.slice("profile ".length).trim()
|
||||
: section;
|
||||
profiles[currentProfile] ??= {};
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!currentProfile) continue;
|
||||
const separatorIndex = line.indexOf("=");
|
||||
if (separatorIndex === -1) continue;
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
const value = line.slice(separatorIndex + 1).trim();
|
||||
profiles[currentProfile][key] = value;
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
function readAwsConfigRegion(profile: string): string | undefined {
|
||||
const configPath =
|
||||
process.env.AWS_CONFIG_FILE?.trim() || join(homedir(), ".aws", "config");
|
||||
if (!existsSync(configPath)) return undefined;
|
||||
|
||||
try {
|
||||
const profiles = parseAwsConfigProfiles(readFileSync(configPath, "utf8"));
|
||||
return profiles[profile]?.region?.trim() || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveAwsRegion(
|
||||
input: { explicitRegion?: string; profile?: string } = {},
|
||||
): string | undefined {
|
||||
const explicitRegion = input.explicitRegion?.trim();
|
||||
if (explicitRegion) return explicitRegion;
|
||||
|
||||
const envRegion =
|
||||
process.env.AWS_REGION?.trim() || process.env.AWS_DEFAULT_REGION?.trim();
|
||||
if (envRegion) return envRegion;
|
||||
|
||||
const profile =
|
||||
input.profile?.trim() || process.env.AWS_PROFILE?.trim() || "default";
|
||||
return (
|
||||
readAwsConfigRegion(profile) ??
|
||||
(profile !== "default" ? readAwsConfigRegion("default") : undefined)
|
||||
);
|
||||
}
|
||||
@@ -88,6 +88,50 @@ describe("provider readiness", () => {
|
||||
provider: "bedrock",
|
||||
aws: { profile: "default" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isProviderSettingsUsable("bedrock", {
|
||||
provider: "bedrock",
|
||||
apiKey: "bedrock-api-key",
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isProviderSettingsUsable("bedrock", {
|
||||
provider: "bedrock",
|
||||
apiKey: "bedrock-api-key",
|
||||
aws: { region: "us-east-1", authentication: "api-key" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isProviderSettingsUsable("bedrock", {
|
||||
provider: "bedrock",
|
||||
aws: { profile: "default", region: "us-west-2" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isProviderSettingsUsable("bedrock", {
|
||||
provider: "bedrock",
|
||||
aws: { authentication: "iam", region: "us-east-1" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isProviderSettingsUsable("bedrock", {
|
||||
provider: "bedrock",
|
||||
region: "us-east-1",
|
||||
aws: { authentication: "iam" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isProviderSettingsUsable("bedrock", {
|
||||
provider: "bedrock",
|
||||
aws: { authentication: "profile", region: "us-east-1" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isProviderSettingsUsable("bedrock", {
|
||||
provider: "bedrock",
|
||||
aws: { accessKey: "access", secretKey: "secret", region: "us-east-1" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isProviderSettingsUsable("vertex", {
|
||||
|
||||
@@ -18,7 +18,7 @@ function hasAwsCredentials(settings: ProviderSettings): boolean {
|
||||
if (!aws) {
|
||||
return false;
|
||||
}
|
||||
if (aws.authentication === "iam") {
|
||||
if (aws.authentication === "iam" || aws.authentication === "profile") {
|
||||
return true;
|
||||
}
|
||||
if (hasText(aws.profile)) {
|
||||
@@ -27,6 +27,10 @@ function hasAwsCredentials(settings: ProviderSettings): boolean {
|
||||
return hasText(aws.accessKey) && hasText(aws.secretKey);
|
||||
}
|
||||
|
||||
function hasAwsRegion(settings: ProviderSettings): boolean {
|
||||
return hasText(settings.aws?.region ?? settings.region);
|
||||
}
|
||||
|
||||
function hasGcpCredentials(settings: ProviderSettings): boolean {
|
||||
const gcp = settings.gcp;
|
||||
return hasText(gcp?.projectId);
|
||||
@@ -57,6 +61,13 @@ export function isProviderSettingsUsable(
|
||||
if (normalizeProviderId(settings.provider) !== normalizedProviderId) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedProviderId === "bedrock") {
|
||||
return (
|
||||
(Boolean(getPersistedProviderApiKey(normalizedProviderId, settings)) ||
|
||||
hasAwsCredentials(settings)) &&
|
||||
hasAwsRegion(settings)
|
||||
);
|
||||
}
|
||||
if (getPersistedProviderApiKey(normalizedProviderId, settings)) {
|
||||
return true;
|
||||
}
|
||||
@@ -67,9 +78,6 @@ export function isProviderSettingsUsable(
|
||||
if (fields.authMethod === "local") {
|
||||
return true;
|
||||
}
|
||||
if (normalizedProviderId === "bedrock") {
|
||||
return hasAwsCredentials(settings);
|
||||
}
|
||||
if (normalizedProviderId === "vertex") {
|
||||
return hasGcpCredentials(settings);
|
||||
}
|
||||
|
||||
@@ -32,13 +32,16 @@ Requires Node.js 22+.
|
||||
| Example | Description | Concepts |
|
||||
|---------|-------------|----------|
|
||||
| [quickstart](./quickstart) | Send one prompt, stream the response. ~15 lines of code. | `Agent`, `subscribe`, `run()` |
|
||||
| [quickstart-clinecore](./quickstart-clinecore) | Send one prompt through a persisted Core session. | `ClineCore`, `start()`, `CoreSessionEvent`, `dispose()` |
|
||||
| [cli-agent](./cli-agent) | Interactive terminal chat with a shell tool. | `createTool`, multi-turn `run()`/`continue()`, streaming |
|
||||
| [cline-core-cli-agent](./cline-core-cli-agent) | Interactive terminal chat powered by ClineCore. | `ClineCore.create()`, `cline.start()`, `cline.send()`, built-in tools, streaming |
|
||||
|
||||
### Intermediate
|
||||
|
||||
| Example | Description | Concepts |
|
||||
|---------|-------------|----------|
|
||||
| [code-review-bot](./code-review-bot) | AI code reviewer that reads git diffs and produces structured comments. | Multiple tools, `completesRun` lifecycle, `systemPrompt`, zod schemas |
|
||||
| [security-review-bot](./security-review-bot) | AI application security reviewer that reads git diffs and produces structured security findings. | Specialized agent prompts, multiple tools, security schemas, `completesRun` lifecycle |
|
||||
| [multi-agent](./multi-agent) | Web app that fans out to three specialist agents in parallel, streams results via SSE, then synthesizes a unified answer. | Concurrent agents, `Promise.all`, per-agent `subscribe()`, SSE streaming, agent composition |
|
||||
|
||||
### Advanced
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# Cline Core CLI Agent
|
||||
|
||||
An interactive terminal chat agent powered by the `ClineCore` runtime. This example is similar in spirit to [`cli-agent`](../cli-agent), but uses stateful ClineCore sessions and built-in runtime tools instead of the stateless `Agent` class, to leverage Cline's internal agent harness.
|
||||
|
||||
## Getting started
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run build:sdk
|
||||
```
|
||||
|
||||
Set an API key:
|
||||
|
||||
```bash
|
||||
export CLINE_API_KEY="sk_..."
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
Type any message at the `you:` prompt to see a streaming response. Type `exit` to quit.
|
||||
|
||||
## Optional model configuration
|
||||
|
||||
The example defaults to Cline's gateway provider and Claude Sonnet:
|
||||
|
||||
```bash
|
||||
export CLINE_PROVIDER_ID="cline"
|
||||
export CLINE_MODEL_ID="anthropic/claude-sonnet-4.6"
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
- Creates a local `ClineCore` runtime with `ClineCore.create()`
|
||||
- Starts one interactive session with `cline.start()`
|
||||
- Sends each user turn with `cline.send({ sessionId, prompt })`
|
||||
- Streams `agent_event` text to stdout as the assistant responds
|
||||
- Logs tool calls and tool results inline
|
||||
- Uses ClineCore's built-in tools instead of defining custom tools
|
||||
- Calls `cline.stop()` and `cline.dispose()` during shutdown
|
||||
|
||||
## Concepts demonstrated
|
||||
|
||||
- Stateful sessions with `ClineCore`
|
||||
- Multi-turn conversation using a single `sessionId`
|
||||
- `CoreSessionEvent` subscription via `cline.subscribe()`
|
||||
- Built-in runtime tools (`read_files`, `search_codebase`, `run_commands`, etc.)
|
||||
- Basic tool policies: file reads/search are auto-approved, other tools request approval
|
||||
|
||||
## Notes
|
||||
|
||||
Use this example when you want the full ClineCore runtime with sessions, persistence, and built-in tools. For the smallest possible SDK example, see [quickstart](../quickstart). For the lightweight stateless runtime, see [cli-agent](../cli-agent).
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@cline/example-cline-core-cli-agent",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
"build:sdk": "bun run --cwd ../../.. build:sdk",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cline/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import * as readline from "node:readline";
|
||||
import {
|
||||
type AgentEvent,
|
||||
ClineCore,
|
||||
type ToolApprovalRequest,
|
||||
} from "@cline/sdk";
|
||||
|
||||
// ClineCore does not choose a model automatically; each session config must provide one.
|
||||
// These example defaults use the Cline gateway with Claude Sonnet, and can be overridden with env vars.
|
||||
const providerId = process.env.CLINE_PROVIDER_ID ?? "cline";
|
||||
const modelId = process.env.CLINE_MODEL_ID ?? "anthropic/claude-sonnet-4.6";
|
||||
const apiKey = process.env.CLINE_API_KEY;
|
||||
const cwd = process.cwd();
|
||||
|
||||
const systemPrompt = `You are a helpful assistant in an interactive terminal chat.
|
||||
Be concise. You can use built-in tools to inspect files, search the workspace, and run shell commands when helpful.`;
|
||||
|
||||
let cline: ClineCore | undefined;
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let activeSessionId: string | undefined;
|
||||
let hasPrintedAssistantPrefix = false;
|
||||
|
||||
function printAssistantPrefix(): void {
|
||||
if (!hasPrintedAssistantPrefix) {
|
||||
process.stdout.write("\nagent: ");
|
||||
hasPrintedAssistantPrefix = true;
|
||||
}
|
||||
}
|
||||
|
||||
function formatToolValue(value: unknown): string {
|
||||
const output =
|
||||
typeof value === "string" ? value : (JSON.stringify(value, null, 2) ?? "");
|
||||
return output.length > 200 ? `${output.slice(0, 200)}...` : output;
|
||||
}
|
||||
|
||||
function handleAgentEvent(event: AgentEvent): void {
|
||||
switch (event.type) {
|
||||
case "content_start":
|
||||
if (event.contentType === "text" && event.text) {
|
||||
printAssistantPrefix();
|
||||
process.stdout.write(event.text);
|
||||
}
|
||||
if (event.contentType === "tool" && event.toolName) {
|
||||
console.log(
|
||||
`\n[tool] ${event.toolName}(${JSON.stringify(event.input ?? {})})`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "content_update":
|
||||
if (event.contentType === "tool" && event.toolName) {
|
||||
console.log(
|
||||
`[update] ${event.toolName}: ${formatToolValue(event.update)}`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
case "content_end":
|
||||
if (event.contentType === "tool" && event.toolName) {
|
||||
if (event.error) {
|
||||
console.log(`[error] ${event.toolName}: ${event.error}`);
|
||||
} else {
|
||||
console.log(`[result] ${formatToolValue(event.output)}`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "notice":
|
||||
console.log(`\n[notice] ${event.message}`);
|
||||
break;
|
||||
case "error":
|
||||
console.error(`\n[error] ${event.error.message}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureCline(): Promise<ClineCore> {
|
||||
if (cline) {
|
||||
return cline;
|
||||
}
|
||||
|
||||
cline = await ClineCore.create({
|
||||
clientName: "cline-core-cli-agent",
|
||||
backendMode: "local",
|
||||
capabilities: {
|
||||
requestToolApproval,
|
||||
},
|
||||
});
|
||||
unsubscribe = cline.subscribe((event) => {
|
||||
if (event.type === "agent_event") {
|
||||
handleAgentEvent(event.payload.event);
|
||||
}
|
||||
});
|
||||
return cline;
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
function ask(question: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async function requestToolApproval(request: ToolApprovalRequest) {
|
||||
console.log(`\n[approval] ${request.toolName} wants to run:`);
|
||||
console.log(formatToolValue(request.input));
|
||||
const answer = await ask("Approve? [y/N] ");
|
||||
const approved = answer.trim().toLowerCase() === "y";
|
||||
return {
|
||||
approved,
|
||||
...(approved ? {} : { reason: "User denied tool execution" }),
|
||||
};
|
||||
}
|
||||
|
||||
function prompt(): Promise<string> {
|
||||
return ask("\nyou: ");
|
||||
}
|
||||
|
||||
async function startSession(): Promise<string> {
|
||||
const runtime = await ensureCline();
|
||||
const result = await runtime.start({
|
||||
source: "cli",
|
||||
interactive: true,
|
||||
config: {
|
||||
providerId,
|
||||
modelId,
|
||||
apiKey,
|
||||
cwd,
|
||||
workspaceRoot: cwd,
|
||||
mode: "act",
|
||||
systemPrompt,
|
||||
maxIterations: 10,
|
||||
enableTools: true,
|
||||
enableSpawnAgent: false,
|
||||
enableAgentTeams: false,
|
||||
disableMcpSettingsTools: true,
|
||||
},
|
||||
toolPolicies: {
|
||||
"*": { autoApprove: false },
|
||||
read_files: { autoApprove: true },
|
||||
search_codebase: { autoApprove: true },
|
||||
},
|
||||
});
|
||||
return result.sessionId;
|
||||
}
|
||||
|
||||
async function runTurn(input: string): Promise<void> {
|
||||
activeSessionId ??= await startSession();
|
||||
hasPrintedAssistantPrefix = false;
|
||||
const runtime = await ensureCline();
|
||||
await runtime.send({
|
||||
sessionId: activeSessionId,
|
||||
prompt: input,
|
||||
});
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log("ClineCore CLI Agent (type 'exit' to quit)\n");
|
||||
console.log(`Provider: ${providerId}`);
|
||||
console.log(`Model: ${modelId}`);
|
||||
console.log(`CWD: ${cwd}`);
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const input = await prompt();
|
||||
const trimmed = input.trim();
|
||||
if (trimmed.toLowerCase() === "exit") {
|
||||
break;
|
||||
}
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await runTurn(trimmed);
|
||||
}
|
||||
} finally {
|
||||
rl.close();
|
||||
unsubscribe?.();
|
||||
if (activeSessionId && cline) {
|
||||
await cline.stop(activeSessionId).catch(() => undefined);
|
||||
}
|
||||
await cline?.dispose();
|
||||
console.log("Goodbye!");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.apps.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
# Code Review Bot
|
||||
# Code Review Bot Dashboard
|
||||
|
||||
An AI-powered code review agent that reads a git diff, analyzes it, and produces structured review comments with severity levels. Demonstrates custom tools, completion tools, and system prompts.
|
||||
An AI-powered code review dashboard for real GitHub pull requests. Paste a PR URL, inspect the actual changed files, stream an SDK-powered review over the real PR diff, and copy or optionally post the generated review.
|
||||
|
||||
<p align="center">
|
||||
<img src="./assets/dashboard.jpg" alt="Code Review Bot dashboard showing a loaded pull request diff" width="100%" />
|
||||
</p>
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -17,28 +21,36 @@ Set an API key:
|
||||
export CLINE_API_KEY="cline_..."
|
||||
```
|
||||
|
||||
Review the last commit:
|
||||
Optionally set a GitHub token. Public PRs can be loaded without one, but a token is recommended for private repositories and higher rate limits:
|
||||
|
||||
```bash
|
||||
export GITHUB_TOKEN="github_pat_..."
|
||||
```
|
||||
|
||||
Run the dashboard:
|
||||
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
Review against a specific ref:
|
||||
Open http://localhost:3457, paste a GitHub pull request URL, and click **Run Review**.
|
||||
|
||||
By default the app copies the generated review to your clipboard. To allow posting a summary comment back to GitHub, run with both `GITHUB_TOKEN` and:
|
||||
|
||||
```bash
|
||||
bun dev main
|
||||
bun dev HEAD~5
|
||||
bun dev abc123
|
||||
export ENABLE_GITHUB_REVIEW_POSTING=1
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
1. Reads a `git diff` against the specified ref (defaults to `HEAD~1`)
|
||||
2. Sends the diff to an agent with three custom tools:
|
||||
- `get_file_context` - reads full file contents for surrounding context
|
||||
- `add_review_comment` - records a review comment with file, line, severity, and message
|
||||
- `submit_review` - a completion tool that ends the run with a summary and approve/reject decision
|
||||
3. Prints all comments grouped by severity (critical, warning, suggestion)
|
||||
1. Fetches a real GitHub PR, including metadata, changed files, patches, and check status
|
||||
2. Renders the PR in a dashboard with file navigation, diff view, review lanes, and finding cards
|
||||
3. Sends the real PR diff to an agent with three custom tools:
|
||||
- `get_file_context` - reads full file contents from the PR head commit for surrounding context
|
||||
- `add_review_finding` - records a structured finding with file, line, severity, category, and suggestion
|
||||
- `submit_review` - a completion tool that ends the run with a summary and approve/request-changes decision
|
||||
4. Streams findings to the browser via Server-Sent Events as the agent reviews the PR
|
||||
5. Copies the final review locally, or posts it as a GitHub PR comment when explicitly enabled
|
||||
|
||||
## Concepts demonstrated
|
||||
|
||||
@@ -46,7 +58,9 @@ bun dev abc123
|
||||
- `lifecycle: { completesRun: true }` to make a tool end the agent loop
|
||||
- Rich `systemPrompt` with structured instructions
|
||||
- Event subscription filtered by tool name
|
||||
- Processing structured results after the run completes
|
||||
- GitHub REST API integration for pull request metadata and diffs
|
||||
- Server-Sent Events (SSE) for a live review dashboard
|
||||
- Guarded external writes through an explicit posting opt-in
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,7 @@
|
||||
### Preview
|
||||
|
||||

|
||||
|
||||
### Architecture Overview
|
||||
|
||||
```
|
||||
@@ -14,6 +18,12 @@ Menu Bar Sidecar (apps/examples/menubar/sidecar/index.ts) ← TypeScript/Bun pr
|
||||
│ JSON lines on stdout: hub_state / notification / ready
|
||||
▼
|
||||
Rust Tauri App (apps/examples/menubar/src-tauri/src/main.rs)
|
||||
│
|
||||
├── Hub Monitor Window (ui/index.html)
|
||||
│ ● Live hub status, uptime, clients, sessions
|
||||
│ ● Running session tracker and inspector
|
||||
│ ● Recent events and background-session launcher
|
||||
│
|
||||
│
|
||||
├── System Tray Icon with dynamic menu
|
||||
│ ● Hub Connected — 3 clients, 2 sessions
|
||||
@@ -24,3 +34,11 @@ Rust Tauri App (apps/examples/menubar/src-tauri/src/main.rs)
|
||||
│
|
||||
└── Logs notifications to stderr (with severity)
|
||||
```
|
||||
|
||||
### Dev Commands
|
||||
|
||||
From `sdk/apps/examples/menubar/`:
|
||||
|
||||
- `bun run dev:ui` - run only the Hub Monitor UI at `http://127.0.0.1:3466/` with preview data
|
||||
- `bun run dev` - run the full Tauri app with the real hub sidecar
|
||||
- `bun run typecheck` - TypeScript check
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev:ui": "python3 -m http.server 3466 --bind 127.0.0.1 --directory ui",
|
||||
"dev": "tauri dev",
|
||||
"build": "tauri build",
|
||||
"build:sidecar:bin": "bun run scripts/build-sidecar-bin.ts",
|
||||
|
||||
@@ -23,8 +23,18 @@ interface TrackedSession {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
workspaceRoot: string;
|
||||
cwd?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdByClientId?: string;
|
||||
title?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
prompt?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCost?: number;
|
||||
agentCount?: number;
|
||||
}
|
||||
|
||||
interface ClientSummary {
|
||||
@@ -33,6 +43,33 @@ interface ClientSummary {
|
||||
sessionCount: number;
|
||||
}
|
||||
|
||||
interface SessionSummary {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
status: string;
|
||||
workspaceRoot: string;
|
||||
workspaceName: string;
|
||||
cwd?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
createdByClientId?: string;
|
||||
prompt?: string;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalCost?: number;
|
||||
agentCount: number;
|
||||
}
|
||||
|
||||
interface EventRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
body: string;
|
||||
severity: "info" | "success" | "warn" | "error";
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
type ClientSummaryGroup = {
|
||||
label: string;
|
||||
name: string;
|
||||
@@ -67,8 +104,9 @@ interface ProviderLaunchAuth {
|
||||
}
|
||||
|
||||
interface SidecarCommand {
|
||||
type: "new_chat" | "shutdown_hub";
|
||||
type: "new_chat" | "shutdown_hub" | "abort_session";
|
||||
prompt?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
function isVisibleClient(clientType: string): boolean {
|
||||
@@ -103,6 +141,70 @@ function emitNotification(
|
||||
});
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function asNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function basename(value: string | undefined): string {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return "workspace";
|
||||
}
|
||||
const parts = trimmed.split(/[\\/]+/).filter(Boolean);
|
||||
return parts.at(-1) ?? trimmed;
|
||||
}
|
||||
|
||||
function shortSessionId(sessionId: string): string {
|
||||
return sessionId.length > 10 ? sessionId.slice(0, 10) : sessionId;
|
||||
}
|
||||
|
||||
function pushEvent(
|
||||
events: EventRecord[],
|
||||
title: string,
|
||||
body: string,
|
||||
severity: EventRecord["severity"] = "info",
|
||||
timestamp = Date.now(),
|
||||
): void {
|
||||
events.unshift({
|
||||
id: `${timestamp}-${events.length}-${title}`,
|
||||
title,
|
||||
body,
|
||||
severity,
|
||||
timestamp,
|
||||
});
|
||||
if (events.length > 30) {
|
||||
events.length = 30;
|
||||
}
|
||||
}
|
||||
|
||||
function metadataFor(
|
||||
session: SessionRecord | Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return asRecord(session.metadata) ?? {};
|
||||
}
|
||||
|
||||
function usageFor(
|
||||
session: SessionRecord | Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return (
|
||||
asRecord(session.aggregateUsage) ??
|
||||
asRecord(session.usage) ??
|
||||
asRecord(metadataFor(session).aggregateUsage) ??
|
||||
asRecord(metadataFor(session).usage) ??
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
function formatUptime(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
||||
const days = Math.floor(totalSeconds / 86_400);
|
||||
@@ -169,6 +271,87 @@ function summarizeClient(client: TrackedClient): {
|
||||
};
|
||||
}
|
||||
|
||||
function sessionTitle(session: SessionRecord | Record<string, unknown>): string {
|
||||
const raw = session as Record<string, unknown>;
|
||||
const metadata = metadataFor(session);
|
||||
const title = asString(metadata.title);
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
const prompt = asString(raw.prompt) ?? asString(metadata.prompt);
|
||||
if (prompt) {
|
||||
return prompt.length > 34 ? `${prompt.slice(0, 31)}...` : prompt;
|
||||
}
|
||||
return basename(asString(raw.workspaceRoot) ?? asString(raw.cwd));
|
||||
}
|
||||
|
||||
function trackedSessionFrom(
|
||||
session: SessionRecord | Record<string, unknown>,
|
||||
): TrackedSession | undefined {
|
||||
const raw = session as Record<string, unknown>;
|
||||
const sessionId = asString(raw.sessionId);
|
||||
if (!sessionId) {
|
||||
return undefined;
|
||||
}
|
||||
const metadata = metadataFor(session);
|
||||
const usage = usageFor(session);
|
||||
const createdAt =
|
||||
asNumber(raw.createdAt) ??
|
||||
asNumber(raw.startedAt) ??
|
||||
asNumber(metadata.createdAt) ??
|
||||
Date.now();
|
||||
return {
|
||||
sessionId,
|
||||
status: asString(raw.status) ?? "running",
|
||||
workspaceRoot: asString(raw.workspaceRoot) ?? asString(raw.cwd) ?? "",
|
||||
cwd: asString(raw.cwd),
|
||||
createdAt,
|
||||
updatedAt:
|
||||
asNumber(raw.updatedAt) ??
|
||||
asNumber(raw.endedAt) ??
|
||||
asNumber(metadata.updatedAt) ??
|
||||
createdAt,
|
||||
createdByClientId: asString(raw.createdByClientId),
|
||||
title: sessionTitle(session),
|
||||
provider: asString(raw.provider) ?? asString(metadata.provider),
|
||||
model: asString(raw.model) ?? asString(metadata.model),
|
||||
prompt: asString(raw.prompt) ?? asString(metadata.prompt),
|
||||
inputTokens:
|
||||
asNumber(usage.inputTokens) ??
|
||||
asNumber(usage.input) ??
|
||||
asNumber(usage.totalInputTokens),
|
||||
outputTokens:
|
||||
asNumber(usage.outputTokens) ??
|
||||
asNumber(usage.output) ??
|
||||
asNumber(usage.totalOutputTokens),
|
||||
totalCost: asNumber(usage.totalCost) ?? asNumber(metadata.totalCost),
|
||||
agentCount: Array.isArray(raw.participants)
|
||||
? Math.max(1, raw.participants.length)
|
||||
: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function toSessionSummary(session: TrackedSession): SessionSummary {
|
||||
return {
|
||||
sessionId: session.sessionId,
|
||||
title: session.title || basename(session.workspaceRoot),
|
||||
status: session.status,
|
||||
workspaceRoot: session.workspaceRoot,
|
||||
workspaceName: basename(session.workspaceRoot || session.cwd),
|
||||
cwd: session.cwd,
|
||||
model: session.model,
|
||||
provider: session.provider,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
createdByClientId: session.createdByClientId,
|
||||
prompt: session.prompt,
|
||||
inputTokens: session.inputTokens,
|
||||
outputTokens: session.outputTokens,
|
||||
totalCost: session.totalCost,
|
||||
agentCount: session.agentCount ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
function parseLastSessionContext(
|
||||
session: SessionRecord | Record<string, unknown> | undefined,
|
||||
): LastSessionContext | undefined {
|
||||
@@ -267,6 +450,7 @@ async function main(): Promise<void> {
|
||||
|
||||
const clients = new Map<string, TrackedClient>();
|
||||
const sessions = new Map<string, TrackedSession>();
|
||||
const events: EventRecord[] = [];
|
||||
let lastSessionContext: LastSessionContext | undefined;
|
||||
let hubStartedAt: string | undefined;
|
||||
|
||||
@@ -316,13 +500,10 @@ async function main(): Promise<void> {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
sessions.set(session.sessionId, {
|
||||
sessionId: session.sessionId,
|
||||
status: session.status,
|
||||
workspaceRoot: session.workspaceRoot,
|
||||
createdAt: session.createdAt,
|
||||
createdByClientId: session.createdByClientId,
|
||||
});
|
||||
const tracked = trackedSessionFrom(session);
|
||||
if (tracked) {
|
||||
sessions.set(tracked.sessionId, tracked);
|
||||
}
|
||||
}
|
||||
const mostRecentContext = [...knownSessions]
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
@@ -331,6 +512,12 @@ async function main(): Promise<void> {
|
||||
if (mostRecentContext) {
|
||||
lastSessionContext = mostRecentContext;
|
||||
}
|
||||
pushEvent(
|
||||
events,
|
||||
"Hub monitor connected",
|
||||
`${knownClients.length} clients and ${knownSessions.length} sessions discovered`,
|
||||
"success",
|
||||
);
|
||||
};
|
||||
|
||||
function emitState(): void {
|
||||
@@ -378,6 +565,10 @@ async function main(): Promise<void> {
|
||||
clients: Array.from(clients.values()),
|
||||
sessions: Array.from(sessions.values()),
|
||||
clientSummaries,
|
||||
sessionSummaries: Array.from(sessions.values())
|
||||
.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
.map(toSessionSummary),
|
||||
events,
|
||||
lastWorkspaceRoot: lastSessionContext?.workspaceRoot,
|
||||
hubStartedAt,
|
||||
hubUptime: hubStartedAt
|
||||
@@ -455,6 +646,30 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function abortBackgroundSession(sessionId: string): Promise<void> {
|
||||
const trimmedSessionId = sessionId.trim();
|
||||
if (!trimmedSessionId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sessionClient.abortRuntimeSession(trimmedSessionId);
|
||||
pushEvent(
|
||||
events,
|
||||
"Session abort requested",
|
||||
`Requested stop for ${shortSessionId(trimmedSessionId)}`,
|
||||
"warn",
|
||||
);
|
||||
emitState();
|
||||
} catch (error) {
|
||||
emit({
|
||||
type: "notification",
|
||||
title: "Stop session failed",
|
||||
body: error instanceof Error ? error.message : String(error),
|
||||
severity: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const stdin = createInterface({ input: process.stdin, terminal: false });
|
||||
stdin.on("line", (line) => {
|
||||
const trimmed = line.trim();
|
||||
@@ -476,10 +691,25 @@ async function main(): Promise<void> {
|
||||
if (command?.type === "shutdown_hub") {
|
||||
void shutdownHub();
|
||||
}
|
||||
if (command?.type === "abort_session") {
|
||||
if (typeof command.sessionId === "string") {
|
||||
void abortBackgroundSession(command.sessionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
uiClient.subscribeUI({
|
||||
onNotify(payload: HubUINotifyPayload) {
|
||||
pushEvent(
|
||||
events,
|
||||
payload.title,
|
||||
payload.body,
|
||||
payload.severity === "error"
|
||||
? "error"
|
||||
: payload.severity === "warning"
|
||||
? "warn"
|
||||
: "info",
|
||||
);
|
||||
emit({
|
||||
type: "notification",
|
||||
title: payload.title,
|
||||
@@ -510,13 +740,26 @@ async function main(): Promise<void> {
|
||||
: "unknown",
|
||||
connectedAt: Date.now(),
|
||||
});
|
||||
pushEvent(
|
||||
events,
|
||||
"Client connected",
|
||||
`${formatClientLabel(typeof payload.clientType === "string" ? payload.clientType : "unknown")} joined the hub`,
|
||||
"success",
|
||||
);
|
||||
emitState();
|
||||
},
|
||||
onClientDisconnected(payload) {
|
||||
const clientId =
|
||||
typeof payload.clientId === "string" ? payload.clientId : undefined;
|
||||
if (!clientId) return;
|
||||
const client = clients.get(clientId);
|
||||
clients.delete(clientId);
|
||||
pushEvent(
|
||||
events,
|
||||
"Client disconnected",
|
||||
`${client?.displayName ?? client?.clientType ?? clientId} left the hub`,
|
||||
"warn",
|
||||
);
|
||||
emitState();
|
||||
},
|
||||
onSessionCreated(payload) {
|
||||
@@ -543,19 +786,16 @@ async function main(): Promise<void> {
|
||||
emitState();
|
||||
return;
|
||||
}
|
||||
sessions.set(sessionId, {
|
||||
sessionId,
|
||||
status,
|
||||
workspaceRoot:
|
||||
typeof session.workspaceRoot === "string"
|
||||
? session.workspaceRoot
|
||||
: "",
|
||||
createdAt: Date.now(),
|
||||
createdByClientId:
|
||||
typeof session.createdByClientId === "string"
|
||||
? session.createdByClientId
|
||||
: undefined,
|
||||
});
|
||||
const tracked = trackedSessionFrom({ ...session, status });
|
||||
if (!tracked) return;
|
||||
sessions.set(sessionId, tracked);
|
||||
pushEvent(
|
||||
events,
|
||||
`Started session "${tracked.title ?? shortSessionId(sessionId)}"`,
|
||||
`${tracked.workspaceRoot || "workspace"} on ${tracked.model ?? "selected model"}`,
|
||||
"success",
|
||||
tracked.createdAt,
|
||||
);
|
||||
emitState();
|
||||
},
|
||||
onSessionUpdated(payload) {
|
||||
@@ -584,26 +824,42 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (existing) {
|
||||
const previousStatus = existing.status;
|
||||
existing.status = status;
|
||||
existing.updatedAt =
|
||||
asNumber(session.updatedAt) ?? asNumber(session.endedAt) ?? Date.now();
|
||||
existing.title = sessionTitle(session);
|
||||
existing.workspaceRoot =
|
||||
typeof session.workspaceRoot === "string"
|
||||
? session.workspaceRoot
|
||||
: existing.workspaceRoot;
|
||||
existing.cwd =
|
||||
typeof session.cwd === "string" ? session.cwd : existing.cwd;
|
||||
const metadata = metadataFor(session);
|
||||
existing.provider =
|
||||
asString(session.provider) ??
|
||||
asString(metadata.provider) ??
|
||||
existing.provider;
|
||||
existing.model =
|
||||
asString(session.model) ?? asString(metadata.model) ?? existing.model;
|
||||
existing.createdByClientId =
|
||||
typeof session.createdByClientId === "string"
|
||||
? session.createdByClientId
|
||||
: existing.createdByClientId;
|
||||
sessions.set(sessionId, existing);
|
||||
if (previousStatus !== status) {
|
||||
pushEvent(
|
||||
events,
|
||||
`Session ${status}`,
|
||||
`${existing.title ?? shortSessionId(sessionId)} changed from ${previousStatus}`,
|
||||
status === "running" ? "success" : status === "idle" ? "info" : "warn",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
sessions.set(sessionId, {
|
||||
sessionId,
|
||||
status,
|
||||
workspaceRoot:
|
||||
typeof session.workspaceRoot === "string"
|
||||
? session.workspaceRoot
|
||||
: "",
|
||||
createdAt: Date.now(),
|
||||
createdByClientId:
|
||||
typeof session.createdByClientId === "string"
|
||||
? session.createdByClientId
|
||||
: undefined,
|
||||
});
|
||||
const tracked = trackedSessionFrom({ ...session, status });
|
||||
if (tracked) {
|
||||
sessions.set(sessionId, tracked);
|
||||
}
|
||||
}
|
||||
emitState();
|
||||
},
|
||||
@@ -617,13 +873,20 @@ async function main(): Promise<void> {
|
||||
? session.sessionId
|
||||
: typeof payload.sessionId === "string"
|
||||
? payload.sessionId
|
||||
: undefined;
|
||||
: undefined;
|
||||
if (!sessionId) return;
|
||||
const participantCount = Array.isArray(session?.participants)
|
||||
? session.participants.length
|
||||
: 0;
|
||||
if (participantCount <= 0) {
|
||||
const tracked = sessions.get(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
pushEvent(
|
||||
events,
|
||||
"Session detached",
|
||||
`${tracked?.title ?? shortSessionId(sessionId)} has no active participants`,
|
||||
"warn",
|
||||
);
|
||||
emitState();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -23,6 +23,8 @@ struct HubState {
|
||||
hub_uptime: Option<String>,
|
||||
last_error: Option<String>,
|
||||
client_summaries: Vec<ClientSummary>,
|
||||
session_summaries: Vec<SessionSummary>,
|
||||
events: Vec<HubEventRecord>,
|
||||
notifications: Vec<NotificationRecord>,
|
||||
}
|
||||
|
||||
@@ -34,6 +36,37 @@ struct ClientSummary {
|
||||
session_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SessionSummary {
|
||||
session_id: String,
|
||||
title: String,
|
||||
status: String,
|
||||
workspace_root: String,
|
||||
workspace_name: String,
|
||||
cwd: Option<String>,
|
||||
model: Option<String>,
|
||||
provider: Option<String>,
|
||||
created_at: u64,
|
||||
updated_at: u64,
|
||||
created_by_client_id: Option<String>,
|
||||
prompt: Option<String>,
|
||||
input_tokens: Option<u64>,
|
||||
output_tokens: Option<u64>,
|
||||
total_cost: Option<f64>,
|
||||
agent_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct HubEventRecord {
|
||||
id: String,
|
||||
title: String,
|
||||
body: String,
|
||||
severity: String,
|
||||
timestamp: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct NotificationRecord {
|
||||
title: String,
|
||||
@@ -53,6 +86,8 @@ struct SidecarMessage {
|
||||
clients: Option<Vec<serde_json::Value>>,
|
||||
sessions: Option<Vec<serde_json::Value>>,
|
||||
client_summaries: Option<Vec<ClientSummary>>,
|
||||
session_summaries: Option<Vec<SessionSummary>>,
|
||||
events: Option<Vec<HubEventRecord>>,
|
||||
last_workspace_root: Option<String>,
|
||||
hub_uptime: Option<String>,
|
||||
title: Option<String>,
|
||||
@@ -132,7 +167,27 @@ fn push_notification(
|
||||
}
|
||||
|
||||
fn resolve_sidecar_script(workspace_root: &str, launch_cwd: &str) -> Option<PathBuf> {
|
||||
let candidates = [
|
||||
sidecar_script_candidates(workspace_root, launch_cwd)
|
||||
.into_iter()
|
||||
.find(|p| p.exists())
|
||||
}
|
||||
|
||||
fn sidecar_script_candidates(workspace_root: &str, launch_cwd: &str) -> Vec<PathBuf> {
|
||||
vec![
|
||||
PathBuf::from(workspace_root)
|
||||
.join("sdk")
|
||||
.join("apps")
|
||||
.join("examples")
|
||||
.join("menubar")
|
||||
.join("sidecar")
|
||||
.join("index.ts"),
|
||||
PathBuf::from(workspace_root)
|
||||
.join("sdk")
|
||||
.join("apps")
|
||||
.join("examples")
|
||||
.join("menubar")
|
||||
.join("sidecar")
|
||||
.join("index.ts"),
|
||||
PathBuf::from(workspace_root)
|
||||
.join("apps")
|
||||
.join("examples")
|
||||
@@ -140,8 +195,7 @@ fn resolve_sidecar_script(workspace_root: &str, launch_cwd: &str) -> Option<Path
|
||||
.join("sidecar")
|
||||
.join("index.ts"),
|
||||
PathBuf::from(launch_cwd).join("sidecar").join("index.ts"),
|
||||
];
|
||||
candidates.into_iter().find(|p| p.exists())
|
||||
]
|
||||
}
|
||||
|
||||
fn sidecar_binary_name() -> String {
|
||||
@@ -161,6 +215,16 @@ fn resolve_sidecar_binary(workspace_root: &str) -> Option<PathBuf> {
|
||||
let binary_name = sidecar_binary_name();
|
||||
let current_exe = std::env::current_exe().ok();
|
||||
let candidates = [
|
||||
Some(
|
||||
PathBuf::from(workspace_root)
|
||||
.join("sdk")
|
||||
.join("apps")
|
||||
.join("examples")
|
||||
.join("menubar")
|
||||
.join("src-tauri")
|
||||
.join("bin")
|
||||
.join(&binary_name),
|
||||
),
|
||||
Some(
|
||||
PathBuf::from(workspace_root)
|
||||
.join("apps")
|
||||
@@ -209,8 +273,13 @@ fn start_sidecar(
|
||||
.current_dir(workspace_root);
|
||||
c
|
||||
} else {
|
||||
let searched_paths = sidecar_script_candidates(workspace_root, launch_cwd)
|
||||
.into_iter()
|
||||
.map(|path| path.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
return Err(format!(
|
||||
"menubar sidecar not found under workspace_root={workspace_root}"
|
||||
"menubar sidecar not found under workspace_root={workspace_root}; searched: {searched_paths}"
|
||||
));
|
||||
};
|
||||
|
||||
@@ -305,6 +374,8 @@ fn handle_sidecar_message(state: &Arc<AppState>, msg: SidecarMessage, raw: &str)
|
||||
if let Ok(mut hub) = state.hub_state.lock() {
|
||||
hub.connected = connected;
|
||||
hub.client_summaries = msg.client_summaries.unwrap_or_default();
|
||||
hub.session_summaries = msg.session_summaries.unwrap_or_default();
|
||||
hub.events = msg.events.unwrap_or_default();
|
||||
hub.last_workspace_root = msg
|
||||
.last_workspace_root
|
||||
.and_then(|value| if value.trim().is_empty() { None } else { Some(value) });
|
||||
@@ -517,6 +588,9 @@ fn build_tray_menu(
|
||||
hub_state.last_workspace_root.is_some(),
|
||||
None::<&str>,
|
||||
)?;
|
||||
let open_dashboard_item =
|
||||
MenuItem::with_id(app, "open_dashboard", "Open Dashboard", true, None::<&str>)?;
|
||||
items.push(Box::new(open_dashboard_item));
|
||||
items.push(Box::new(new_chat_item));
|
||||
items.push(Box::new(PredefinedMenuItem::separator(app)?));
|
||||
|
||||
@@ -554,6 +628,8 @@ fn get_hub_state(state: tauri::State<'_, Arc<AppState>>) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"connected": hub.connected,
|
||||
"clientSummaries": hub.client_summaries,
|
||||
"sessionSummaries": hub.session_summaries,
|
||||
"events": hub.events,
|
||||
"lastWorkspaceRoot": hub.last_workspace_root,
|
||||
"hubUptime": hub.hub_uptime,
|
||||
"lastError": hub.last_error,
|
||||
@@ -562,6 +638,39 @@ fn get_hub_state(state: tauri::State<'_, Arc<AppState>>) -> serde_json::Value {
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_new_session(prompt: String, state: tauri::State<'_, Arc<AppState>>) -> Result<(), String> {
|
||||
let prompt = prompt.trim().to_string();
|
||||
if prompt.is_empty() {
|
||||
return Err("Prompt cannot be empty".to_string());
|
||||
}
|
||||
send_sidecar_command(
|
||||
state.inner(),
|
||||
serde_json::json!({
|
||||
"type": "new_chat",
|
||||
"prompt": prompt,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn abort_session(
|
||||
session_id: String,
|
||||
state: tauri::State<'_, Arc<AppState>>,
|
||||
) -> Result<(), String> {
|
||||
let session_id = session_id.trim().to_string();
|
||||
if session_id.is_empty() {
|
||||
return Err("Session id cannot be empty".to_string());
|
||||
}
|
||||
send_sidecar_command(
|
||||
state.inner(),
|
||||
serde_json::json!({
|
||||
"type": "abort_session",
|
||||
"sessionId": session_id,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let app_state = Arc::new(AppState::new());
|
||||
let launch_cwd = std::env::current_dir()
|
||||
@@ -649,6 +758,12 @@ fn main() {
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
app.exit(0);
|
||||
}
|
||||
"open_dashboard" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
"latest_error" => {
|
||||
let state = app.state::<Arc<AppState>>();
|
||||
let error = {
|
||||
@@ -709,7 +824,11 @@ fn main() {
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![get_hub_state])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_hub_state,
|
||||
start_new_session,
|
||||
abort_session
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error running menubar app");
|
||||
}
|
||||
|
||||
@@ -6,10 +6,23 @@
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin",
|
||||
"beforeBuildCommand": "bun run build:sidecar:bin",
|
||||
"frontendDist": "../ui",
|
||||
"beforeBundleCommand": "[ \"$(uname)\" = \"Darwin\" ] && [ -e ./target/release/bundle/macos/Cline\\ Hub.app ] && xattr -d com.apple.quarantine ./target/release/bundle/macos/Cline\\ Hub.app || true"
|
||||
},
|
||||
"app": {
|
||||
"windows": [],
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Cline Hub Monitor",
|
||||
"width": 1600,
|
||||
"height": 900,
|
||||
"minWidth": 1180,
|
||||
"minHeight": 720,
|
||||
"resizable": true,
|
||||
"decorations": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,8 @@
|
||||
# Multi-Agent Fan-Out
|
||||
# Multi-Agent War Room
|
||||
|
||||
A web app that spawns three specialist agents in parallel, streams their responses to the browser in real time via SSE, then feeds their findings into a synthesizer agent that produces a unified answer.
|
||||
A web app that spawns four specialist agents in parallel, streams their responses to the browser in real time via SSE, then feeds their findings into a synthesizer agent that produces a unified decision brief.
|
||||
|
||||

|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -23,17 +25,18 @@ Run:
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open http://localhost:3456 in your browser, enter a topic, and watch the agents work.
|
||||
Open http://localhost:3456 in your browser, enter a mission, and watch the agents work.
|
||||
|
||||
## What it does
|
||||
|
||||
1. You enter a topic in the browser
|
||||
2. The server spawns three `Agent` instances in parallel via `Promise.all`:
|
||||
- Technical Expert (engineering perspective)
|
||||
- Practical Analyst (real-world applications)
|
||||
- Critical Reviewer (limitations and trade-offs)
|
||||
1. You enter a mission in the browser
|
||||
2. The server spawns four `Agent` instances in parallel via `Promise.all`:
|
||||
- Architect (system design)
|
||||
- Security Analyst (audit)
|
||||
- Pragmatist (product)
|
||||
- Skeptic (red team)
|
||||
3. Each agent streams `assistant-text-delta` events to the browser via SSE, rendered in its own card
|
||||
4. Once all three finish, a fourth synthesizer agent combines their findings into a unified answer, also streamed live
|
||||
4. Once all specialists finish, a synthesizer agent combines their findings into a unified decision brief, also streamed live
|
||||
|
||||
## Concepts demonstrated
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 184 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
# Quickstart with ClineCore
|
||||
|
||||
A minimal Cline SDK example that uses `ClineCore` instead of the lightweight `Agent` runtime. It starts one local Core session, sends a single prompt, streams the assistant text to stdout, prints token usage, and then disposes the Core runtime.
|
||||
|
||||
## Getting started
|
||||
|
||||
Use Node.js 22 or newer.
|
||||
|
||||
Install dependencies and build the SDK packages:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run build:sdk
|
||||
```
|
||||
|
||||
Set an API key:
|
||||
|
||||
```bash
|
||||
export CLINE_API_KEY="cline_..."
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
Or pass a prompt from the command line:
|
||||
|
||||
```bash
|
||||
bun dev "Compare Agent and ClineCore in a markdown table."
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
1. Creates a local `ClineCore` runtime with `ClineCore.create()`
|
||||
2. Subscribes to `CoreSessionEvent` events with `cline.subscribe()`
|
||||
3. Reads a prompt from command-line arguments, falling back to the default prompt
|
||||
4. Starts a non-interactive session with `cline.start()`
|
||||
5. Renders assistant text from nested `agent_event` events
|
||||
6. Prints usage plus the persisted session ID
|
||||
7. Calls `cline.dispose()` to clean up runtime resources
|
||||
|
||||
## Agent vs ClineCore
|
||||
|
||||
The original [quickstart](../quickstart) uses `Agent`, while this example uses `ClineCore`.
|
||||
|
||||
| Area | `Agent` | `ClineCore` |
|
||||
| --- | --- | --- |
|
||||
| Best for | Simple in-process agent loops, custom tools, browser-compatible use cases | Full Cline runtime sessions, workspace-aware tools, persistence, automation, and multi-client/hub scenarios |
|
||||
| Construction | `new Agent({ providerId, modelId, apiKey })` | `await ClineCore.create({ clientName, backendMode })` |
|
||||
| Running | `await agent.run(prompt)` | `await cline.start({ prompt, config })` |
|
||||
| Events | `agent.subscribe()` emits `AgentRuntimeEvent` directly, e.g. `assistant-text-delta` | `cline.subscribe()` emits `CoreSessionEvent`; assistant events are usually nested under `event.type === "agent_event"` |
|
||||
| Result text | `result.outputText` | Session result is under `result.result`; stream text comes from events |
|
||||
| Tools | You provide tools explicitly with `tools` | Can use ClineCore built-in tools (`read_files`, `search_codebase`, shell/editor tools when enabled) and custom `extraTools` |
|
||||
| Persistence | Lightweight runtime state only | Creates persisted sessions with IDs, manifests, message artifacts, and history support |
|
||||
| Cleanup | Usually no explicit disposal needed for the simple runtime | Always call `await cline.dispose()` when done |
|
||||
|
||||
Use `Agent` when you want the smallest API surface and own all tools/context yourself. Use `ClineCore` when you want the SDK to manage Cline-like sessions, workspace context, built-in tools, persistence, hub/remote runtime options, or automation.
|
||||
|
||||
## Notes
|
||||
|
||||
This quickstart disables built-in tools (`enableTools: false`) to keep behavior close to the original one-prompt example. To explore ClineCore's workspace tools, enable tools and set tool policies appropriate for your app.
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@cline/example-quickstart-clinecore",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
"build:sdk": "bun run --cwd ../../.. build:sdk",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cline/sdk": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type AgentEvent, ClineCore } from "@cline/sdk";
|
||||
|
||||
const defaultPrompt = "Explain what an SDK is in two sentences.";
|
||||
const prompt = process.argv.slice(2).join(" ").trim() || defaultPrompt;
|
||||
|
||||
const cline = await ClineCore.create({
|
||||
clientName: "quickstart-clinecore",
|
||||
backendMode: "local",
|
||||
});
|
||||
|
||||
const unsubscribe = cline.subscribe((event) => {
|
||||
if (event.type === "agent_event") {
|
||||
renderAgentEvent(event.payload.event);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await cline.start({
|
||||
source: "cli",
|
||||
interactive: false,
|
||||
prompt,
|
||||
config: {
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: process.env.CLINE_API_KEY,
|
||||
cwd: process.cwd(),
|
||||
workspaceRoot: process.cwd(),
|
||||
mode: "act",
|
||||
systemPrompt: "You are a helpful assistant. Be concise.",
|
||||
maxIterations: 10,
|
||||
enableTools: true,
|
||||
enableSpawnAgent: false,
|
||||
enableAgentTeams: false,
|
||||
disableMcpSettingsTools: true,
|
||||
},
|
||||
});
|
||||
|
||||
const usage = result.result?.usage;
|
||||
console.log(
|
||||
`\n\nDone (${result.result?.iterations ?? 0} iteration, ${usage?.outputTokens ?? 0} output tokens)`,
|
||||
);
|
||||
console.log(`Session: ${result.sessionId}`);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await cline.dispose();
|
||||
}
|
||||
|
||||
function renderAgentEvent(event: AgentEvent) {
|
||||
if (event.type !== "content_start" || event.contentType !== "text") {
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(event.text ?? "");
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.apps.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -25,12 +25,19 @@ Run:
|
||||
bun dev
|
||||
```
|
||||
|
||||
Or pass a prompt from the command line:
|
||||
|
||||
```bash
|
||||
bun dev "Explain event streaming in AI apps to a frontend engineer."
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
1. Creates an `Agent` with a provider and model
|
||||
2. Subscribes to `assistant-text-delta` events to stream output
|
||||
3. Calls `agent.run()` with a prompt
|
||||
4. Prints token usage when done
|
||||
3. Reads a prompt from command-line arguments, falling back to the default prompt
|
||||
4. Calls `agent.run()` with that prompt
|
||||
5. Prints token usage when done
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { Agent } from "@cline/sdk";
|
||||
|
||||
const defaultPrompt = "Explain what an SDK is in two sentences.";
|
||||
const prompt = process.argv.slice(2).join(" ").trim() || defaultPrompt;
|
||||
|
||||
const agent = new Agent({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
@@ -13,7 +16,7 @@ agent.subscribe((event) => {
|
||||
}
|
||||
});
|
||||
|
||||
const result = await agent.run("Explain what an SDK is in two sentences.");
|
||||
const result = await agent.run(prompt);
|
||||
console.log(
|
||||
`\n\nDone (${result.iterations} iteration, ${result.usage.outputTokens} output tokens)`,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# Security Review Bot on git diffs
|
||||
|
||||
An AI-powered application security review agent that reads a git diff, analyzes it for security risk, and produces structured findings with severity, category, CWE/OWASP metadata, exploit scenarios, remediation guidance, and confidence levels.
|
||||
|
||||
This example is based on the `code-review-bot` example, but uses the `ClineCore` runtime instead of the lightweight `Agent` runtime. That means it can use ClineCore's built-in workspace tools, including `read_files` and `search_codebase`, while still adding custom security review tools through `extraTools`.
|
||||
|
||||
## Getting started
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run build:sdk
|
||||
```
|
||||
|
||||
Set an API key:
|
||||
|
||||
```bash
|
||||
export CLINE_API_KEY="cline_..."
|
||||
```
|
||||
|
||||
Security review the last commit:
|
||||
|
||||
```bash
|
||||
bun dev
|
||||
```
|
||||
|
||||
Security review against a specific ref:
|
||||
|
||||
```bash
|
||||
bun dev main
|
||||
bun dev HEAD~5
|
||||
bun dev abc123
|
||||
```
|
||||
|
||||
Add run-specific review instructions with `--prompt` or `-p`:
|
||||
|
||||
```bash
|
||||
bun dev main --prompt "Focus on authorization bypasses and SSRF"
|
||||
bun dev HEAD~1 -p "Treat test-only secrets as low severity"
|
||||
```
|
||||
|
||||
You can also pass extra positional text after the ref; it will be appended as additional review instructions:
|
||||
|
||||
```bash
|
||||
bun dev main "Prioritize auth, secrets, and unsafe file access"
|
||||
```
|
||||
|
||||
## Review a specific repo
|
||||
|
||||
The bot reviews the git repository in the current working directory. To review a different repo, run the example command from that repo's directory and pass the ref you want to compare against.
|
||||
|
||||
Using Bun directly from another repo:
|
||||
|
||||
```bash
|
||||
cd /path/to/repo-you-want-to-review
|
||||
bun --cwd /path/to/cline/sdk/apps/examples/security-review-bot dev main
|
||||
```
|
||||
|
||||
With extra instructions:
|
||||
|
||||
```bash
|
||||
cd /path/to/repo-you-want-to-review
|
||||
bun --cwd /path/to/cline/sdk/apps/examples/security-review-bot dev main --prompt "Focus on payment and admin flows"
|
||||
```
|
||||
|
||||
Or build the example once and run the compiled JavaScript from any git repo:
|
||||
|
||||
```bash
|
||||
cd /path/to/cline/sdk/apps/examples/security-review-bot
|
||||
bun run build
|
||||
|
||||
cd /path/to/repo-you-want-to-review
|
||||
node /path/to/cline/sdk/apps/examples/security-review-bot/dist/index.js main
|
||||
```
|
||||
|
||||
The argument is passed to `git diff <ref>` in the target repo. Common examples:
|
||||
|
||||
```bash
|
||||
# Review all changes on your branch compared with main
|
||||
bun --cwd /path/to/cline/sdk/apps/examples/security-review-bot dev main
|
||||
|
||||
# Review the last commit
|
||||
bun --cwd /path/to/cline/sdk/apps/examples/security-review-bot dev HEAD~1
|
||||
|
||||
# Review changes since a release tag
|
||||
bun --cwd /path/to/cline/sdk/apps/examples/security-review-bot dev v1.2.3
|
||||
```
|
||||
|
||||
If you want to review uncommitted changes in a target repo, compare against the branch or commit you started from, for example:
|
||||
|
||||
```bash
|
||||
cd /path/to/repo-you-want-to-review
|
||||
bun --cwd /path/to/cline/sdk/apps/examples/security-review-bot dev main
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
1. Reads a `git diff` against the specified ref (defaults to `HEAD~1`)
|
||||
2. Starts a local `ClineCore` session with built-in tools enabled
|
||||
3. Lets the model use ClineCore's `read_files` and `search_codebase` tools for surrounding file context
|
||||
4. Adds two custom security review tools through `extraTools`:
|
||||
- `add_security_finding` - records a structured security finding with severity, category, exploit scenario, remediation, and confidence
|
||||
- `submit_security_review` - a completion tool that ends the run with a summary, overall risk rating, and merge-blocking decision
|
||||
5. Prints findings grouped by severity (`critical`, `high`, `medium`, `low`, `info`)
|
||||
|
||||
## Finding schema
|
||||
|
||||
Each finding includes:
|
||||
|
||||
- `file` and `line` - where the issue appears
|
||||
- `severity` - `critical`, `high`, `medium`, `low`, or `info`
|
||||
- `category` - security category such as `injection`, `authorization`, `secrets`, `ssrf`, `xss`, or `cryptography`
|
||||
- `cwe` and `owasp` - optional vulnerability taxonomy metadata
|
||||
- `title` and `description` - concise summary and risk explanation
|
||||
- `exploitScenario` - realistic abuse case
|
||||
- `remediation` - concrete fix or mitigation
|
||||
- `confidence` - `high`, `medium`, or `low`
|
||||
|
||||
## Concepts demonstrated
|
||||
|
||||
- Using `ClineCore.create()` and `cline.start()` for a local runtime session
|
||||
- Enabling selected ClineCore built-in tools, including `read_files` and `search_codebase`
|
||||
- Adding domain-specific custom tools with `extraTools`
|
||||
- `lifecycle: { completesRun: true }` to make a tool end the agent loop
|
||||
- Subscribing to `CoreSessionEvent` events with `cline.subscribe()`
|
||||
- Rendering parsed `agent_event` text/tool events instead of printing serialized `chunk` payloads
|
||||
- Cleaning up runtime resources with `cline.dispose()`
|
||||
- Processing structured results after the run completes
|
||||
|
||||
## Notes
|
||||
|
||||
This is an AI-assisted review tool, not a replacement for SAST, dependency scanning, secret scanning, manual threat modeling, or human security review. Treat findings as review input and verify them before acting.
|
||||
|
||||
For a general-purpose reviewer, see [code-review-bot](../code-review-bot). For a simpler starting point, see [quickstart](../quickstart).
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@cline/example-security-review-bot",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun run src/index.ts",
|
||||
"build:sdk": "bun run --cwd ../../.. build:sdk",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cline/sdk": "workspace:*",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
type AgentEvent,
|
||||
type AgentTool,
|
||||
ClineCore,
|
||||
createTool,
|
||||
type ToolPolicy,
|
||||
} from "@cline/sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const SecurityFindingSchema = z.object({
|
||||
file: z.string().describe("File path"),
|
||||
line: z.number().describe("Line number (approximate is fine)"),
|
||||
severity: z.enum(["critical", "high", "medium", "low", "info"]),
|
||||
category: z
|
||||
.enum([
|
||||
"authentication",
|
||||
"authorization",
|
||||
"injection",
|
||||
"secrets",
|
||||
"cryptography",
|
||||
"data_exposure",
|
||||
"ssrf",
|
||||
"xss",
|
||||
"deserialization",
|
||||
"path_traversal",
|
||||
"dependency",
|
||||
"logging_monitoring",
|
||||
"configuration",
|
||||
"other",
|
||||
])
|
||||
.describe("Security weakness category"),
|
||||
cwe: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Relevant CWE identifier, such as CWE-89, if known"),
|
||||
owasp: z.string().optional().describe("Relevant OWASP category, if known"),
|
||||
title: z.string().describe("Short finding title"),
|
||||
description: z.string().describe("What is vulnerable and why it matters"),
|
||||
exploitScenario: z
|
||||
.string()
|
||||
.describe("A realistic abuse case or exploit path"),
|
||||
remediation: z.string().describe("Concrete recommended fix or mitigation"),
|
||||
confidence: z.enum(["high", "medium", "low"]),
|
||||
});
|
||||
|
||||
const SecurityReviewResultSchema = z.object({
|
||||
summary: z.string().describe("Brief overall security assessment"),
|
||||
overallRisk: z.enum(["critical", "high", "medium", "low", "none"]),
|
||||
blockMerge: z
|
||||
.boolean()
|
||||
.describe("Whether the changes should be blocked from merging"),
|
||||
});
|
||||
|
||||
const findings: z.infer<typeof SecurityFindingSchema>[] = [];
|
||||
let reviewResult: z.infer<typeof SecurityReviewResultSchema> | undefined;
|
||||
|
||||
const repoRoot = (() => {
|
||||
try {
|
||||
return execFileSync("git", ["rev-parse", "--show-toplevel"], {
|
||||
encoding: "utf-8",
|
||||
}).trim();
|
||||
} catch {
|
||||
console.error("Error: must be run from within a git repository.");
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
|
||||
const systemPrompt = `You are a senior application security engineer performing a focused security review of a git diff.
|
||||
|
||||
Your goal is to identify exploitable or defense-in-depth security issues introduced or modified by the diff. The repository root is ${repoRoot}. Use the read_files tool with absolute paths, or search_codebase, when you need surrounding code to determine whether a finding is real.
|
||||
|
||||
Focus on:
|
||||
- Authentication and authorization bypasses
|
||||
- Injection flaws, including SQL/NoSQL/command/template/LDAP injection
|
||||
- Cross-site scripting and unsafe HTML/script rendering
|
||||
- Server-side request forgery and unsafe URL fetching
|
||||
- Path traversal and unsafe file operations
|
||||
- Hardcoded secrets, tokens, private keys, and credential leakage
|
||||
- Cryptographic misuse, weak randomness, insecure hashing, or missing integrity checks
|
||||
- Sensitive data exposure in logs, errors, telemetry, APIs, or client bundles
|
||||
- Unsafe deserialization or dynamic code execution
|
||||
- Insecure dependency, environment, CORS, cookie, header, or cloud configuration changes
|
||||
- Missing validation, rate limits, audit logging, or security checks around sensitive operations
|
||||
|
||||
Only report actionable findings that are supported by the diff or file context. Do not report generic best practices unless they materially reduce a realistic risk. Prefer fewer high-quality findings over many speculative ones.
|
||||
|
||||
Severity guidance:
|
||||
- critical: likely remote code execution, auth bypass, mass data exposure, secret compromise, or trivially exploitable injection on sensitive data paths
|
||||
- high: practical privilege escalation, significant data exposure, SSRF with meaningful impact, or exploitable stored XSS
|
||||
- medium: plausible vulnerability requiring constraints, limited data exposure, missing authorization on lower-risk operations, or reflected XSS with user interaction
|
||||
- low: hardening issue with limited exploitability
|
||||
- info: noteworthy observation with minimal direct risk
|
||||
|
||||
For each real issue, call add_security_finding with clear evidence, exploit scenario, remediation, confidence, and CWE/OWASP metadata when applicable.
|
||||
|
||||
When you are done reviewing, call submit_security_review with a brief summary, an overall risk rating, and whether the changes should be blocked from merging.`;
|
||||
|
||||
const addSecurityFindingTool = createTool({
|
||||
name: "add_security_finding",
|
||||
description:
|
||||
"Add an actionable security finding on a specific file and line.",
|
||||
inputSchema: SecurityFindingSchema,
|
||||
async execute(input) {
|
||||
findings.push(input);
|
||||
return {
|
||||
success: true,
|
||||
message: `Security finding added (${findings.length} total)`,
|
||||
findingCount: findings.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const submitSecurityReviewTool = createTool({
|
||||
name: "submit_security_review",
|
||||
description: "Submit the completed security review with a risk summary.",
|
||||
inputSchema: SecurityReviewResultSchema,
|
||||
lifecycle: { completesRun: true },
|
||||
async execute(input) {
|
||||
reviewResult = input;
|
||||
return {
|
||||
success: true,
|
||||
summary: input.summary,
|
||||
overallRisk: input.overallRisk,
|
||||
blockMerge: input.blockMerge,
|
||||
findingCount: findings.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const securityReviewTools: AgentTool[] = [
|
||||
addSecurityFindingTool as AgentTool,
|
||||
submitSecurityReviewTool as AgentTool,
|
||||
];
|
||||
|
||||
const securityReviewToolPolicies = {
|
||||
"*": { autoApprove: true },
|
||||
read_files: { autoApprove: true },
|
||||
search_codebase: { autoApprove: true },
|
||||
add_security_finding: { autoApprove: true },
|
||||
submit_security_review: { autoApprove: true },
|
||||
run_commands: { enabled: false },
|
||||
fetch_web_content: { enabled: false },
|
||||
editor: { enabled: false },
|
||||
apply_patch: { enabled: false },
|
||||
skills: { enabled: false },
|
||||
ask_question: { enabled: false },
|
||||
} satisfies Record<string, ToolPolicy>;
|
||||
|
||||
let reasoningOpen = false;
|
||||
|
||||
function usage() {
|
||||
console.error("Usage: bun dev [git-ref] [--prompt <instructions>]");
|
||||
console.error(" e.g. bun dev HEAD~3");
|
||||
console.error(" e.g. bun dev main");
|
||||
console.error(
|
||||
' e.g. bun dev main --prompt "Focus on authorization bypasses and SSRF"',
|
||||
);
|
||||
console.error(
|
||||
' e.g. bun dev main "Prioritize auth, secrets, and unsafe file access"',
|
||||
);
|
||||
}
|
||||
|
||||
function parseArgs(args: string[]): { ref: string; extraPrompt?: string } {
|
||||
let ref = "HEAD~1";
|
||||
let hasRef = false;
|
||||
const promptParts: string[] = [];
|
||||
const positionalPromptParts: string[] = [];
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (arg === "--prompt" || arg === "-p") {
|
||||
const value = args[i + 1];
|
||||
if (!value) {
|
||||
console.error(`Missing value for ${arg}`);
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
promptParts.push(value);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("--prompt=")) {
|
||||
const value = arg.slice("--prompt=".length);
|
||||
if (!value) {
|
||||
console.error("Missing value for --prompt");
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
promptParts.push(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg.startsWith("-")) {
|
||||
console.error(`Unknown option: ${arg}`);
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!hasRef) {
|
||||
ref = arg;
|
||||
hasRef = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
positionalPromptParts.push(arg);
|
||||
}
|
||||
|
||||
if (positionalPromptParts.length > 0) {
|
||||
promptParts.push(positionalPromptParts.join(" "));
|
||||
}
|
||||
|
||||
return {
|
||||
ref,
|
||||
extraPrompt: promptParts.length > 0 ? promptParts.join("\n\n") : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function closeReasoning() {
|
||||
if (reasoningOpen) {
|
||||
process.stdout.write("\n");
|
||||
reasoningOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderToolStart(
|
||||
event: Extract<AgentEvent, { type: "content_start" }>,
|
||||
) {
|
||||
const toolName = event.toolName ?? "unknown_tool";
|
||||
if (toolName === "add_security_finding") {
|
||||
const parsed = SecurityFindingSchema.safeParse(event.input);
|
||||
if (!parsed.success) {
|
||||
console.log(`\n[tool] ${toolName}`);
|
||||
return;
|
||||
}
|
||||
const input = parsed.data;
|
||||
const icon =
|
||||
input.severity === "critical"
|
||||
? "X"
|
||||
: input.severity === "high"
|
||||
? "!"
|
||||
: input.severity === "medium"
|
||||
? "~"
|
||||
: "i";
|
||||
console.log(
|
||||
`\n [${icon}] ${input.severity.toUpperCase()} ${input.file}:${input.line} - ${input.title}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n[tool] ${toolName}`);
|
||||
}
|
||||
|
||||
function renderAgentEvent(event: AgentEvent) {
|
||||
switch (event.type) {
|
||||
case "content_start":
|
||||
if (event.contentType === "text" && event.text) {
|
||||
closeReasoning();
|
||||
process.stdout.write(event.text);
|
||||
return;
|
||||
}
|
||||
if (event.contentType === "reasoning") {
|
||||
if (event.redacted === true && !event.reasoning) {
|
||||
console.log("\n[thinking redacted]");
|
||||
return;
|
||||
}
|
||||
if (event.reasoning) {
|
||||
if (!reasoningOpen) {
|
||||
process.stdout.write("\n[thinking]\n");
|
||||
reasoningOpen = true;
|
||||
}
|
||||
process.stdout.write(event.reasoning);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.contentType === "tool") {
|
||||
closeReasoning();
|
||||
renderToolStart(event);
|
||||
return;
|
||||
}
|
||||
break;
|
||||
case "content_end":
|
||||
if (event.contentType === "reasoning") {
|
||||
closeReasoning();
|
||||
return;
|
||||
}
|
||||
if (event.contentType === "tool" && event.error) {
|
||||
closeReasoning();
|
||||
console.log(
|
||||
`\n[tool failed] ${event.toolName ?? "unknown_tool"}: ${event.error}`,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the diff to review
|
||||
const { ref, extraPrompt } = parseArgs(process.argv.slice(2));
|
||||
if (ref.startsWith("-")) {
|
||||
console.error(`Invalid ref: ${ref}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let diff: string;
|
||||
try {
|
||||
diff = execFileSync("git", ["diff", ref], {
|
||||
encoding: "utf-8",
|
||||
maxBuffer: 5 * 1024 * 1024,
|
||||
});
|
||||
} catch {
|
||||
console.error(`Failed to get diff for ref: ${ref}`);
|
||||
usage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!diff.trim()) {
|
||||
console.log("No diff found. Nothing to security review.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Security reviewing diff against ${ref} (${diff.split("\n").length} lines)...\n`,
|
||||
);
|
||||
if (extraPrompt) {
|
||||
console.log(`Additional review instructions: ${extraPrompt}\n`);
|
||||
}
|
||||
|
||||
const cline = await ClineCore.create({
|
||||
clientName: "security-review-bot",
|
||||
backendMode: "local",
|
||||
});
|
||||
|
||||
const unsubscribe = cline.subscribe((event) => {
|
||||
switch (event.type) {
|
||||
case "agent_event":
|
||||
renderAgentEvent(event.payload.event);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const prompt = `Security review this git diff.${
|
||||
extraPrompt ? `\n\nAdditional review instructions:\n${extraPrompt}` : ""
|
||||
}\n\n\`\`\`diff\n${diff}\n\`\`\``;
|
||||
|
||||
const result = await cline.start({
|
||||
source: "cli",
|
||||
interactive: false,
|
||||
prompt,
|
||||
config: {
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: process.env.CLINE_API_KEY,
|
||||
cwd: repoRoot,
|
||||
workspaceRoot: repoRoot,
|
||||
mode: "act",
|
||||
systemPrompt,
|
||||
maxIterations: 25,
|
||||
enableTools: true,
|
||||
enableSpawnAgent: false,
|
||||
enableAgentTeams: false,
|
||||
disableMcpSettingsTools: true,
|
||||
toolPolicies: securityReviewToolPolicies,
|
||||
},
|
||||
localRuntime: {
|
||||
extraTools: securityReviewTools,
|
||||
},
|
||||
});
|
||||
|
||||
console.log("\n\n--- Security Review Complete ---\n");
|
||||
|
||||
if (reviewResult) {
|
||||
console.log(`Summary: ${reviewResult.summary}`);
|
||||
console.log(`Overall risk: ${reviewResult.overallRisk}`);
|
||||
console.log(`Block merge: ${reviewResult.blockMerge ? "yes" : "no"}\n`);
|
||||
}
|
||||
|
||||
if (findings.length === 0) {
|
||||
console.log("No actionable security findings identified.");
|
||||
} else {
|
||||
const severityOrder = [
|
||||
"critical",
|
||||
"high",
|
||||
"medium",
|
||||
"low",
|
||||
"info",
|
||||
] as const;
|
||||
|
||||
for (const severity of severityOrder) {
|
||||
const group = findings.filter((finding) => finding.severity === severity);
|
||||
if (group.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`${severity.toUpperCase()}: ${group.length}`);
|
||||
for (const finding of group) {
|
||||
const metadata = [finding.cwe, finding.owasp]
|
||||
.filter(Boolean)
|
||||
.join(" | ");
|
||||
console.log(
|
||||
` ${finding.file}:${finding.line} - ${finding.title}${metadata ? ` (${metadata})` : ""}`,
|
||||
);
|
||||
console.log(` Category: ${finding.category}`);
|
||||
console.log(` Confidence: ${finding.confidence}`);
|
||||
console.log(` Risk: ${finding.description}`);
|
||||
console.log(` Exploit: ${finding.exploitScenario}`);
|
||||
console.log(` Fix: ${finding.remediation}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const usage = result.result?.usage;
|
||||
console.log(
|
||||
`\nSession: ${result.sessionId} | Status: ${result.result?.finishReason ?? "unknown"} | Iterations: ${result.result?.iterations ?? 0} | Tokens: ${usage?.outputTokens ?? 0} output`,
|
||||
);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
await cline.dispose();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.apps.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
+41
-1
@@ -19,7 +19,7 @@
|
||||
},
|
||||
"apps/cli": {
|
||||
"name": "@cline/cli",
|
||||
"version": "3.0.3",
|
||||
"version": "3.0.8",
|
||||
"bin": {
|
||||
"cline": "src/index.ts",
|
||||
},
|
||||
@@ -38,6 +38,7 @@
|
||||
"@opentui/react": "0.1.102",
|
||||
"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",
|
||||
@@ -66,6 +67,16 @@
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
"apps/examples/cline-core-cli-agent": {
|
||||
"name": "@cline/example-cline-core-cli-agent",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@cline/sdk": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
"apps/examples/code-review-bot": {
|
||||
"name": "@cline/example-code-review-bot",
|
||||
"version": "0.0.0",
|
||||
@@ -186,6 +197,27 @@
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
"apps/examples/quickstart-clinecore": {
|
||||
"name": "@cline/example-quickstart-clinecore",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@cline/sdk": "workspace:*",
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
"apps/examples/security-review-bot": {
|
||||
"name": "@cline/example-security-review-bot",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@cline/sdk": "workspace:*",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
},
|
||||
},
|
||||
"apps/examples/vscode": {
|
||||
"name": "@cline/vscode",
|
||||
"version": "0.0.0",
|
||||
@@ -605,12 +637,18 @@
|
||||
|
||||
"@cline/example-cli-agent": ["@cline/example-cli-agent@workspace:apps/examples/cli-agent"],
|
||||
|
||||
"@cline/example-cline-core-cli-agent": ["@cline/example-cline-core-cli-agent@workspace:apps/examples/cline-core-cli-agent"],
|
||||
|
||||
"@cline/example-code-review-bot": ["@cline/example-code-review-bot@workspace:apps/examples/code-review-bot"],
|
||||
|
||||
"@cline/example-multi-agent": ["@cline/example-multi-agent@workspace:apps/examples/multi-agent"],
|
||||
|
||||
"@cline/example-quickstart": ["@cline/example-quickstart@workspace:apps/examples/quickstart"],
|
||||
|
||||
"@cline/example-quickstart-clinecore": ["@cline/example-quickstart-clinecore@workspace:apps/examples/quickstart-clinecore"],
|
||||
|
||||
"@cline/example-security-review-bot": ["@cline/example-security-review-bot@workspace:apps/examples/security-review-bot"],
|
||||
|
||||
"@cline/llms": ["@cline/llms@workspace:packages/llms"],
|
||||
|
||||
"@cline/menubar": ["@cline/menubar@workspace:apps/examples/menubar"],
|
||||
@@ -2087,6 +2125,8 @@
|
||||
|
||||
"fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="],
|
||||
|
||||
"fzf": ["fzf@0.5.2", "", {}, "sha512-Tt4kuxLXFKHy8KT40zwsUPUkg1CrsgY25FxA2U/j/0WgEDCk3ddc/zLTCCcbSHX9FcKtLuVaDGtGE/STWC+j3Q=="],
|
||||
|
||||
"gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="],
|
||||
|
||||
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* ANTHROPIC_API_KEY=sk-... bun run examples/plugins/weather-plugin.example.ts
|
||||
*/
|
||||
|
||||
import { type AgentPlugin, ClineCore, createTool } from "@cline/core";
|
||||
import { type AgentPlugin, createTool } from "@cline/core";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin-level state — populated from setup context and available to all hook
|
||||
@@ -148,46 +148,5 @@ const plugin: AgentPlugin = {
|
||||
},
|
||||
};
|
||||
|
||||
async function runDemo(): Promise<void> {
|
||||
const sessionManager = await ClineCore.create({ backendMode: "local" });
|
||||
|
||||
try {
|
||||
const result = await sessionManager.start({
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
apiKey: process.env.ANTHROPIC_API_KEY ?? "",
|
||||
cwd: process.cwd(),
|
||||
enableTools: true,
|
||||
enableSpawnAgent: false,
|
||||
enableAgentTeams: false,
|
||||
systemPrompt: "You are a helpful assistant. Use tools when needed.",
|
||||
extensions: [plugin],
|
||||
// extensionContext.workspace is the authoritative source for
|
||||
// workspaceInfo that flows into setup(api, ctx). The CLI
|
||||
// and VS Code hosts populate this automatically from their runtime
|
||||
// state. When using the SDK directly, set it explicitly so plugins
|
||||
// always receive accurate workspace metadata.
|
||||
extensionContext: {
|
||||
workspace: {
|
||||
rootPath: process.cwd(),
|
||||
cwd: process.cwd(),
|
||||
},
|
||||
},
|
||||
},
|
||||
prompt: "What's the weather like in Tokyo and Paris?",
|
||||
interactive: false,
|
||||
});
|
||||
|
||||
console.log(`\n${result.result?.text ?? ""}`);
|
||||
} finally {
|
||||
await sessionManager.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
await runDemo();
|
||||
}
|
||||
|
||||
export { plugin, runDemo };
|
||||
export { plugin };
|
||||
export default plugin;
|
||||
|
||||
@@ -1388,4 +1388,40 @@ describe("AgentRuntime", () => {
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("resets usage between consecutive run/continue calls", async () => {
|
||||
const model = new ScriptedModel([
|
||||
() => [
|
||||
{
|
||||
type: "usage",
|
||||
usage: { inputTokens: 100, outputTokens: 20, totalCost: 0.5 },
|
||||
},
|
||||
{ type: "text-delta", text: "first" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
],
|
||||
() => [
|
||||
{
|
||||
type: "usage",
|
||||
usage: { inputTokens: 200, outputTokens: 40, totalCost: 1.0 },
|
||||
},
|
||||
{ type: "text-delta", text: "second" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
],
|
||||
]);
|
||||
const runtime = new AgentRuntime({ model });
|
||||
|
||||
const first = await runtime.run("Turn 1");
|
||||
expect(first.usage).toMatchObject({
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
totalCost: 0.5,
|
||||
});
|
||||
|
||||
const second = await runtime.continue("Turn 2");
|
||||
expect(second.usage).toMatchObject({
|
||||
inputTokens: 200,
|
||||
outputTokens: 40,
|
||||
totalCost: 1.0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -537,6 +537,7 @@ export class AgentRuntime {
|
||||
this.state.iteration = 0;
|
||||
this.state.pendingToolCalls = [];
|
||||
this.state.lastError = undefined;
|
||||
this.state.usage = cloneUsage(DEFAULT_USAGE);
|
||||
|
||||
try {
|
||||
await this.callBeforeRunHooks();
|
||||
|
||||
@@ -1097,7 +1097,86 @@ describe("HubRuntimeHost", () => {
|
||||
version: 1,
|
||||
event: "run.aborted",
|
||||
sessionId: "sess-1",
|
||||
payload: {},
|
||||
payload: {
|
||||
snapshot: {
|
||||
version: 1,
|
||||
sessionId: "sess-1",
|
||||
status: "running",
|
||||
interactive: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
type: "agent_event",
|
||||
payload: expect.objectContaining({
|
||||
event: expect.objectContaining({
|
||||
type: "done",
|
||||
reason: "aborted",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "ended",
|
||||
payload: expect.objectContaining({ sessionId: "sess-1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits ended for terminal interactive hub run events", async () => {
|
||||
let onEvent:
|
||||
| ((event: {
|
||||
version: 1;
|
||||
event: "run.aborted";
|
||||
sessionId: string;
|
||||
payload?: Record<string, unknown>;
|
||||
}) => void)
|
||||
| undefined;
|
||||
subscribeMock.mockImplementation((listener) => {
|
||||
onEvent = listener;
|
||||
return () => {};
|
||||
});
|
||||
commandMock.mockResolvedValue({
|
||||
payload: {
|
||||
session: {
|
||||
sessionId: "sess-1",
|
||||
status: "running",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
workspaceRoot: "/tmp/project",
|
||||
cwd: "/tmp/project",
|
||||
},
|
||||
},
|
||||
});
|
||||
const events: unknown[] = [];
|
||||
|
||||
const { HubRuntimeHost } = await import("./hub-runtime-host");
|
||||
const host = new HubRuntimeHost({ url: "ws://127.0.0.1:25463/hub" });
|
||||
host.subscribe((event) => events.push(event));
|
||||
|
||||
await host.startSession({
|
||||
config: createConfig(),
|
||||
source: SessionSource.CLI,
|
||||
prompt: "Hey",
|
||||
});
|
||||
|
||||
onEvent?.({
|
||||
version: 1,
|
||||
event: "run.aborted",
|
||||
sessionId: "sess-1",
|
||||
payload: {
|
||||
snapshot: {
|
||||
version: 1,
|
||||
sessionId: "sess-1",
|
||||
status: "cancelled",
|
||||
interactive: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(events).toEqual(
|
||||
|
||||
@@ -1671,6 +1671,7 @@ export class HubRuntimeHost implements RuntimeHost {
|
||||
case "run.completed":
|
||||
case "run.failed":
|
||||
case "run.aborted": {
|
||||
const snapshot = parseCoreSessionSnapshot(event.payload?.snapshot);
|
||||
const reason =
|
||||
typeof event.payload?.reason === "string"
|
||||
? event.payload.reason
|
||||
@@ -1686,6 +1687,9 @@ export class HubRuntimeHost implements RuntimeHost {
|
||||
reason,
|
||||
},
|
||||
});
|
||||
if (snapshot?.interactive === true && snapshot.status === "running") {
|
||||
return;
|
||||
}
|
||||
this.events.emit({
|
||||
type: "ended",
|
||||
payload: {
|
||||
|
||||
@@ -287,7 +287,6 @@ export async function handleRunAbort(
|
||||
(request) => request.sessionId === sessionId,
|
||||
reason,
|
||||
);
|
||||
ctx.publish(ctx.buildEvent("run.aborted", { reason }, sessionId));
|
||||
return okReply(envelope, { applied: true });
|
||||
}
|
||||
|
||||
|
||||
@@ -427,8 +427,14 @@ export {
|
||||
toggleDisabledTool,
|
||||
writeGlobalSettings,
|
||||
} from "./services/global-settings";
|
||||
export type { PluginToolSummary } from "./services/plugin-tools";
|
||||
export { listPluginTools } from "./services/plugin-tools";
|
||||
export type {
|
||||
ListPluginToolsResult,
|
||||
PluginToolSummary,
|
||||
} from "./services/plugin-tools";
|
||||
export {
|
||||
listPluginTools,
|
||||
listPluginToolsWithDiagnostics,
|
||||
} from "./services/plugin-tools";
|
||||
export {
|
||||
addLocalProvider,
|
||||
type DeleteLocalProviderRequest,
|
||||
|
||||
@@ -688,7 +688,7 @@ describe("LocalRuntimeHost", () => {
|
||||
expect(sessionService.readSessionManifest).toHaveBeenCalledWith(sessionId);
|
||||
});
|
||||
|
||||
it("marks interactive turns completed without disposing the session", async () => {
|
||||
it("keeps interactive sessions running after a completed turn", async () => {
|
||||
const sessionId = "sess-interactive-turn-status";
|
||||
const manifest = createManifest(sessionId);
|
||||
const sessionService = {
|
||||
@@ -741,20 +741,16 @@ describe("LocalRuntimeHost", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sessionService.updateSessionStatus).toHaveBeenCalledWith(
|
||||
sessionId,
|
||||
"completed",
|
||||
0,
|
||||
);
|
||||
expect(sessionService.updateSessionStatus).not.toHaveBeenCalled();
|
||||
await expect(manager.getSession(sessionId)).resolves.toMatchObject({
|
||||
sessionId,
|
||||
status: "completed",
|
||||
status: "running",
|
||||
});
|
||||
expect(agent.shutdown).not.toHaveBeenCalled();
|
||||
expect(runtime.shutdown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disposes idle interactive sessions without changing completed status", async () => {
|
||||
it("disposes idle interactive sessions without changing status", async () => {
|
||||
const sessionId = "sess-interactive-dispose";
|
||||
const manifest = createManifest(sessionId);
|
||||
const sessionService = {
|
||||
@@ -2138,6 +2134,110 @@ describe("LocalRuntimeHost", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the same live interactive session usable after aborting before the first response", async () => {
|
||||
const sessionId = "sess-abort-then-next-turn";
|
||||
const manifest = createManifest(sessionId);
|
||||
const sessionService = {
|
||||
ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"),
|
||||
createRootSessionWithArtifacts: vi.fn().mockResolvedValue({
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest,
|
||||
}),
|
||||
persistSessionMessages: vi.fn(),
|
||||
updateSessionStatus: vi.fn().mockResolvedValue({ updated: true }),
|
||||
writeSessionManifest: vi.fn(),
|
||||
listSessions: vi.fn().mockResolvedValue([]),
|
||||
deleteSession: vi.fn().mockResolvedValue({ deleted: true }),
|
||||
};
|
||||
const runtime = { tools: [], shutdown: vi.fn() };
|
||||
const runtimeBuilder = {
|
||||
build: vi.fn().mockReturnValue(runtime),
|
||||
};
|
||||
let messages: AgentResult["messages"] = [];
|
||||
let activeRun = false;
|
||||
let rejectRun: ((error: Error) => void) | undefined;
|
||||
let markRunStarted: (() => void) | undefined;
|
||||
const runStarted = new Promise<void>((resolve) => {
|
||||
markRunStarted = resolve;
|
||||
});
|
||||
const run = vi.fn(
|
||||
() =>
|
||||
new Promise<AgentResult>((_resolve, reject) => {
|
||||
activeRun = true;
|
||||
messages = [{ role: "user", content: "slow" }];
|
||||
rejectRun = (error) => {
|
||||
activeRun = false;
|
||||
reject(error);
|
||||
};
|
||||
markRunStarted?.();
|
||||
}),
|
||||
);
|
||||
const continueTurn = vi.fn().mockResolvedValue(
|
||||
createResult({
|
||||
text: "continued after abort",
|
||||
}),
|
||||
);
|
||||
const agent = {
|
||||
run,
|
||||
continue: continueTurn,
|
||||
abort: vi.fn(),
|
||||
subscribeEvents: vi.fn().mockReturnValue(() => {}),
|
||||
getAgentId: vi.fn().mockReturnValue("agent-root-1"),
|
||||
getConversationId: vi.fn().mockReturnValue("conv-root-1"),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
getMessages: vi.fn(() => messages),
|
||||
canStartRun: vi.fn(() => !activeRun),
|
||||
};
|
||||
agent.abort.mockImplementation(() => {
|
||||
rejectRun?.(new Error("user cancelled before first response"));
|
||||
});
|
||||
const manager = new RuntimeHostUnderTest({
|
||||
distinctId,
|
||||
sessionService: sessionService as never,
|
||||
runtimeBuilder: runtimeBuilder as never,
|
||||
createAgent: () => agent as never,
|
||||
});
|
||||
|
||||
await manager.startSession(
|
||||
normalizeStartInput({
|
||||
config: createConfig({ sessionId }),
|
||||
interactive: true,
|
||||
}),
|
||||
);
|
||||
const events: unknown[] = [];
|
||||
manager.subscribe((event) => events.push(event));
|
||||
|
||||
const firstTurn = manager.runTurn({ sessionId, prompt: "slow" });
|
||||
await runStarted;
|
||||
await manager.abort(sessionId, new Error("test abort"));
|
||||
await expect(firstTurn).resolves.toMatchObject({
|
||||
finishReason: "aborted",
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.runTurn({ sessionId, prompt: "next turn after abort" }),
|
||||
).resolves.toMatchObject({
|
||||
finishReason: "completed",
|
||||
text: "continued after abort",
|
||||
});
|
||||
await expect(manager.getSession(sessionId)).resolves.toMatchObject({
|
||||
sessionId,
|
||||
status: "running",
|
||||
});
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(continueTurn).toHaveBeenCalledTimes(1);
|
||||
expect(agent.shutdown).not.toHaveBeenCalled();
|
||||
expect(runtime.shutdown).not.toHaveBeenCalled();
|
||||
expect(sessionService.updateSessionStatus).not.toHaveBeenCalled();
|
||||
expect(events).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "ended",
|
||||
payload: expect.objectContaining({ sessionId, reason: "aborted" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves per-turn metadata on prior assistant messages across turns", async () => {
|
||||
const sessionId = "sess-meta-multi";
|
||||
const manifest = createManifest(sessionId);
|
||||
@@ -2822,12 +2922,7 @@ describe("LocalRuntimeHost", () => {
|
||||
"running",
|
||||
null,
|
||||
);
|
||||
expect(updateSessionStatus).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
sessionId,
|
||||
"completed",
|
||||
0,
|
||||
);
|
||||
expect(updateSessionStatus).toHaveBeenCalledTimes(1);
|
||||
const persistedMetadata = updateSession.mock.calls[0]?.[0]
|
||||
.metadata as Record<string, unknown>;
|
||||
expect(persistedMetadata.title).toBe("saved title");
|
||||
|
||||
@@ -559,6 +559,7 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
drainingPendingPrompts: false,
|
||||
pluginSandboxShutdown: bootstrap.pluginSandboxShutdown,
|
||||
submitAndExitObserved: false,
|
||||
lastInteractiveTurnFinishReason: undefined,
|
||||
};
|
||||
this.sessions.set(sessionId, active);
|
||||
this.emitStatus(sessionId, "running");
|
||||
@@ -747,6 +748,15 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
await this.releaseSessionRuntime(session, "session_stop");
|
||||
return;
|
||||
}
|
||||
if (session.interactive && session.agent.canStartRun()) {
|
||||
await this.shutdownSession(session, {
|
||||
status: this.resolveInteractiveStopStatus(session),
|
||||
exitCode: this.resolveInteractiveStopExitCode(session),
|
||||
shutdownReason: "session_stop",
|
||||
endReason: "stopped",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Abort the agent first if it's running, so shutdown can proceed
|
||||
session.aborting = true;
|
||||
session.agent.abort(new Error("session_stop"));
|
||||
@@ -765,12 +775,19 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
sessions.map((session) =>
|
||||
session.interactive && session.status !== "running"
|
||||
? this.releaseSessionRuntime(session, reason)
|
||||
: this.shutdownSession(session, {
|
||||
status: "cancelled",
|
||||
exitCode: 0,
|
||||
shutdownReason: reason,
|
||||
endReason: "disposed",
|
||||
}),
|
||||
: session.interactive && session.agent.canStartRun()
|
||||
? this.shutdownSession(session, {
|
||||
status: this.resolveInteractiveStopStatus(session),
|
||||
exitCode: this.resolveInteractiveStopExitCode(session),
|
||||
shutdownReason: reason,
|
||||
endReason: "disposed",
|
||||
})
|
||||
: this.shutdownSession(session, {
|
||||
status: "cancelled",
|
||||
exitCode: 0,
|
||||
shutdownReason: reason,
|
||||
endReason: "disposed",
|
||||
}),
|
||||
),
|
||||
);
|
||||
this.usageBySession.clear();
|
||||
@@ -945,24 +962,34 @@ export class LocalRuntimeHost implements RuntimeHost {
|
||||
finishReason: AgentResult["finishReason"],
|
||||
): Promise<void> {
|
||||
if (hasPendingTeamRunWork(session)) return;
|
||||
const isAborted = finishReason === "aborted" || session.aborting;
|
||||
const isError = finishReason === "error";
|
||||
await this.updateStatus(
|
||||
session,
|
||||
isAborted ? "cancelled" : isError ? "failed" : "completed",
|
||||
isError ? 1 : 0,
|
||||
);
|
||||
this.emit({
|
||||
type: "ended",
|
||||
payload: {
|
||||
sessionId: session.sessionId,
|
||||
reason: finishReason,
|
||||
ts: Date.now(),
|
||||
},
|
||||
});
|
||||
session.lastInteractiveTurnFinishReason = finishReason;
|
||||
await this.markTurnRunning(session);
|
||||
session.aborting = false;
|
||||
}
|
||||
|
||||
private resolveInteractiveStopStatus(session: ActiveSession): SessionStatus {
|
||||
const finishReason = session.lastInteractiveTurnFinishReason;
|
||||
if (!finishReason) return "cancelled";
|
||||
|
||||
switch (finishReason) {
|
||||
case "completed":
|
||||
return "completed";
|
||||
case "error":
|
||||
return "failed";
|
||||
case "aborted":
|
||||
case "max_iterations":
|
||||
case "mistake_limit":
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
const _exhaustive: never = finishReason;
|
||||
return _exhaustive;
|
||||
}
|
||||
|
||||
private resolveInteractiveStopExitCode(session: ActiveSession): number {
|
||||
return session.lastInteractiveTurnFinishReason === "error" ? 1 : 0;
|
||||
}
|
||||
|
||||
private async completeAbortedInteractiveTurn(
|
||||
session: ActiveSession,
|
||||
): Promise<AgentResult> {
|
||||
|
||||
@@ -156,4 +156,42 @@ describe("createAgentModelFromConfig", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards Bedrock AWS settings as gateway provider options", async () => {
|
||||
const { createAgentModelFromConfig } = await import("./handler-factory");
|
||||
|
||||
createAgentModelFromConfig(
|
||||
{
|
||||
providerId: "bedrock",
|
||||
modelId: "anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
systemPrompt: "",
|
||||
tools: [],
|
||||
providerConfig: {
|
||||
providerId: "bedrock",
|
||||
modelId: "anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
region: "us-west-2",
|
||||
aws: {
|
||||
authentication: "profile",
|
||||
profile: "dev-profile",
|
||||
},
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
expect(gatewayMock.createGateway).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
providerConfigs: [
|
||||
expect.objectContaining({
|
||||
providerId: "bedrock",
|
||||
options: expect.objectContaining({
|
||||
region: "us-west-2",
|
||||
authentication: "profile",
|
||||
profile: "dev-profile",
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,53 @@ import type {
|
||||
} from "@cline/shared";
|
||||
import type { ProviderConfig } from "./provider-settings";
|
||||
|
||||
function compactOptions(
|
||||
options: Record<string, unknown>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const compacted = Object.fromEntries(
|
||||
Object.entries(options).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
return Object.keys(compacted).length > 0 ? compacted : undefined;
|
||||
}
|
||||
|
||||
function buildGatewayProviderOptions(
|
||||
config: ProviderConfig,
|
||||
): Record<string, unknown> | undefined {
|
||||
const options: Record<string, unknown> = {
|
||||
region: config.region,
|
||||
apiLine: config.apiLine,
|
||||
openRouterProviderSorting: config.openRouterProviderSorting,
|
||||
modelCatalog: config.modelCatalog,
|
||||
};
|
||||
|
||||
if (config.providerId === "bedrock") {
|
||||
Object.assign(options, {
|
||||
authentication: config.aws?.authentication,
|
||||
profile: config.aws?.profile,
|
||||
accessKeyId: config.aws?.accessKey,
|
||||
secretAccessKey: config.aws?.secretKey,
|
||||
sessionToken: config.aws?.sessionToken,
|
||||
usePromptCache: config.aws?.usePromptCache,
|
||||
useCrossRegionInference: config.useCrossRegionInference,
|
||||
useGlobalInference: config.useGlobalInference,
|
||||
endpoint: config.aws?.endpoint,
|
||||
customModelBaseId: config.aws?.customModelBaseId,
|
||||
});
|
||||
}
|
||||
|
||||
if (config.providerId === "vertex") {
|
||||
const gcpRegion = config.gcp?.region ?? config.region;
|
||||
Object.assign(options, {
|
||||
project: config.gcp?.projectId,
|
||||
projectId: config.gcp?.projectId,
|
||||
location: gcpRegion,
|
||||
region: gcpRegion,
|
||||
});
|
||||
}
|
||||
|
||||
return compactOptions(options);
|
||||
}
|
||||
|
||||
export function resolveKnownModelsFromConfig(
|
||||
config: AgentConfig,
|
||||
): Record<string, ModelInfo> | undefined {
|
||||
@@ -105,6 +152,7 @@ export function createAgentModelFromConfig(
|
||||
apiKey: normalizedProviderConfig.apiKey,
|
||||
baseUrl: normalizedProviderConfig.baseUrl,
|
||||
headers: normalizedProviderConfig.headers,
|
||||
options: buildGatewayProviderOptions(normalizedProviderConfig),
|
||||
models: normalizedProviderConfig.knownModels
|
||||
? Object.entries(normalizedProviderConfig.knownModels).map(
|
||||
([id, model]) => toGatewayConfiguredModel(id, model),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import * as Llms from "@cline/llms";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearLiveModelsCatalogCache,
|
||||
@@ -14,6 +13,17 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("resolveProviderConfig", () => {
|
||||
it("returns bundled models for built-in providers without a base URL", async () => {
|
||||
const resolved = await resolveProviderConfig("bedrock");
|
||||
|
||||
expect(resolved?.baseUrl).toBeUndefined();
|
||||
expect(resolved?.modelId).toBe("minimax.minimax-m2.5");
|
||||
expect(resolved?.knownModels?.["amazon.nova-2-lite-v1:0"]?.name).toBe(
|
||||
"Nova 2 Lite",
|
||||
);
|
||||
expect(Object.keys(resolved?.knownModels ?? {}).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("uses catalog aliases when loading live models", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -167,11 +177,7 @@ describe("resolveProviderConfig", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not spawn Codex app-server while resolving ChatGPT OAuth models", async () => {
|
||||
const listModels = vi
|
||||
.spyOn(Llms, "listOpenAICodexModels")
|
||||
.mockRejectedValue(new Error("should not be called"));
|
||||
|
||||
it("resolves ChatGPT OAuth models from the filtered catalog", async () => {
|
||||
const resolved = await resolveProviderConfig(
|
||||
"openai-codex",
|
||||
{ cacheTtlMs: 1 },
|
||||
@@ -183,7 +189,6 @@ describe("resolveProviderConfig", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(listModels).not.toHaveBeenCalled();
|
||||
expect(Object.keys(resolved?.knownModels ?? {})).toEqual(
|
||||
expect.arrayContaining([
|
||||
"gpt-5.2",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user