Compare commits

...
Author SHA1 Message Date
Renee Huang 8915aab20b merge 2026-05-19 16:41:40 -07:00
Ara 1ac5525fee fix: route Poolside Laguna models through next-gen prompts (#10910) 2026-05-19 16:40:19 -07:00
Saoud Rizwan a4d0937030 fix(cli): restore fuzzy file mention ranking (#10909)
* fix(cli): restore fuzzy file mention ranking

* fix(cli): address mention autocomplete review feedback
2026-05-19 16:09:43 -07:00
Renee Huang e44f9b1697 add more iterations 2026-05-19 16:06:29 -07:00
Renee Huang 960ff55f83 add clinecore quickstart example 2026-05-19 15:53:34 -07:00
Bee 6d5c61f044 chore: gitignore SDK session and database files (#10907)
Ignore .cline session data, temp directories, SQLite artifacts, and generated metadata to prevent local SDK state and user data from being committed.
2026-05-19 13:54:53 -07:00
Mark Percival 8a6d031e8f fix(cli): keep interactive session live after cancel (#10903)
* fix(cli): keep interactive session live after cancel

* fix(sdk): use shared finish reason type
2026-05-19 13:44:59 -07:00
Renee Huang 3f808b369e sdk: add ClineCore CLI agent example (#10895)
* add a separate CLI agent using ClineCore, in comparison to the one built using Agent

* update SDK lockfile for ClineCore CLI example
2026-05-19 13:22:04 -07:00
Robin Newhouse e3b3e2306e fix(cli): accept dash-prefixed prompts after separator (#10905) 2026-05-19 12:47:07 -07:00
Tomás Barreiro b601b6e623 Update diff to 8.0.4 (#10904)
* Update diff to 8.0.4

* Remove types diff
2026-05-19 21:30:18 +02:00
Robin Newhouse ed008bd36e Route GLM thinking via provider metadata ENG-2019 (#10692)
* Route GLM thinking via provider metadata

* Address GLM provider routing review feedback
2026-05-19 12:12:51 -07:00
Saoud Rizwan 6e964c3ef0 chore(cli): release v3.0.8 2026-05-19 10:39:16 -07:00
MaxandMax Paulus 🥪 2e8579ebec bump version and update changelog (#10898)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-05-19 09:54:36 -07:00
Saoud RizwanandRenee Huang 07e2d0d3f8 fix: improve security review bot streaming (#10889)
Co-authored-by: Renee Huang <100229782+reneehuang1@users.noreply.github.com>
2026-05-19 08:44:22 -07:00
Ara cb0515d5f4 fix(cli): use Telegram numeric participant ids (#10879) 2026-05-19 08:16:38 -07:00
Bee 8c00eb9a77 fix(cli): keep failed plugins visible in config UI ENG-2073 (#10878)
* fix(cli): keep failed plugins visible in config UI ENG-2073

Preserve plugins that fail during load or setup and attach their
diagnostic phase and error message. Surface those errors in config detail
rows so users can identify and fix broken plugin definitions.

* fix(cli): preserve multiple plugin initialization failures

Aggregate all setup/load failures per plugin path instead of overwriting earlier errors. Summarize multiple errors in the config UI so rows remain readable while retaining the full failure details.
2026-05-18 22:06:57 -07:00
Renee Huang ba471b7db9 clarifications 2026-05-18 21:44:27 -07:00
Renee Huang 72e2c8de2b Merge branch 'main' of github.com:cline/cline into renee/more-updates 2026-05-18 21:43:25 -07:00
Renee Huang 5e7257d218 try to make it work; streaming doesnt work yet 2026-05-18 19:06:48 -07:00
Saoud Rizwan 1a4122eb53 fix(cli): avoid option right fork shortcut (#10883) 2026-05-18 19:06:22 -07:00
Ara e6fa037c4c Add real PR dashboard to code review bot example (#10812)
* add real PR dashboard to code review bot example

* docs: add code review bot dashboard screenshot
2026-05-18 17:49:24 -07:00
Ara 6e0ae8db58 Add Hub Monitor UI to menubar example (#10688)
* add menubar hub monitor dashboard

* docs: add menubar preview screenshot
2026-05-18 17:44:32 -07:00
Renee Huang dcef5b29d1 switch to clinecore runtime 2026-05-18 17:34:37 -07:00
Bee 0b1fe6a982 fix: remove demo runner from weather metrics example (#10875)
Remove the embedded runDemo entry point, ClineCore import, and runDemo export so the weather metrics example behaves as a plugin-only module.
2026-05-18 16:39:13 -07:00
Tomás BarreiroandSaoud Rizwan c18eef5d4c Remove unused stuff on sdk (#10869)
* Clean-up root files

* Clean vscode duplicated stuff

* Remove duplicated license

* Remove duplicated tailwind recommendation

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-05-19 01:36:34 +02:00
Saoud Rizwan 65443a01ae fix(sdk): stage readme during publish (#10870)
* fix(sdk): publish readme with sdk package

* fix(sdk): use absolute readme links

* fix(sdk): stage readme during publish
2026-05-18 16:22:08 -07:00
Robin Newhouse 7d56dd1e69 refactor(sdk): remove unused codex model list helper (#10874) 2026-05-18 16:21:45 -07:00
Saoud Rizwan 501f512066 chore: remove Kanban launch modal (#10864)
* chore: remove Kanban launch modal

* chore: remove unused Kanban state key

* test: tolerate missing post-login modal
2026-05-18 16:19:52 -07:00
John SimoneandRobin Newhouse 5810a5d1e7 Fix AWS Bedrock region and auth configuration in CLI (#10807)
* fix AWS region and profile detection logic

* add additional bedrock config fields in provider config screens

* address greptile feedback

* remove .cline directory

* Fix Bedrock catalog and AWS region handling

---------

Co-authored-by: Robin Newhouse <robin@cline.bot>
2026-05-18 15:56:08 -07:00
Ara 6a262b395a Polish multi-agent example war room demo (#10690)
* polish multi-agent war room demo

* docs: add multi-agent war room screenshot
2026-05-18 15:45:09 -07:00
Robin Newhouse 5117b7b0b8 docs: clarify SDK agent test workflow (#10860) 2026-05-18 15:35:31 -07:00
Renee Huang 6b530e5950 initial attempt; needs more thoughts 2026-05-18 14:55:48 -07:00
Tomás Barreiro 30ce8b1e72 Clean-up package-lock (#10872) 2026-05-18 23:43:30 +02:00
Renee Huang 26daf9cb48 fix to have the example run 2026-05-18 14:28:18 -07:00
Tomás Barreiro 2fcac58e24 Extract GH variables as env variables during workflows (#10862)
* Extract GH variables as env variables during workflows

* Update other variables
2026-05-18 13:41:34 -07:00
Saoud Rizwan 112dbaa15b ci(cli): align release changelog formatting (#10863) 2026-05-18 13:17:51 -07:00
Saoud Rizwan 996a2f6a9c fix(agents): reset usage at start of each execute() call (#10713)
AgentRuntime.execute() reset iteration, pendingToolCalls, and lastError
between run/continue calls but not usage. This caused result.usage to
accumulate across calls, and local-runtime-host then added the
already-cumulative result onto the session baseline, double-counting
every prior turn's tokens on each new turn.
2026-05-18 12:50:10 -07:00
Renee HuangandSaoud Rizwan 47f6d00f61 docs: add back memorybank; correct CLI install commands (#10830)
* add back memorybank; correct CLI install commands

* add more details on plugin

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-05-18 12:02:04 -07:00
Mikołaj Kondratekandgreptile-apps[bot] 9ba2c932b4 fix(mcp): disable Restart Server button when server is toggled off (#10852)
* fix(mcp): disable Restart Server button when server is toggled off

The expanded 'Restart Server' button in the MCP server row stayed clickable even when the server's toggle was off, but the backing restartMcpServer call is a no-op for disabled servers, so the click did nothing.

Bring the expanded button into parity with the collapsed icon-button (line 227 in the same file), which already gates on server.disabled. No state, label, or styling change beyond the disabled predicate.

Fixes cline/intellij-plugin#559.

* Update webview-ui/src/components/mcp/configuration/tabs/installed/server-row/ServerRow.tsx

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-05-18 18:55:47 +02:00
Robin Newhouse 01aaa5dd04 fix(e2e): harden VS Code teardown (#10796)
ENG-2064
2026-05-18 09:31:31 -07:00
135 changed files with 7749 additions and 3268 deletions
-2
View File
@@ -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
+16 -11
View File
@@ -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 }}
+34 -21
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1 +1 @@
lts/*
22
+2 -1
View File
@@ -1,2 +1,3 @@
node 22
bun 1.3.13
node 22
+2 -1
View File
@@ -5,6 +5,7 @@
"connor4312.esbuild-problem-matchers",
"ms-vscode.extension-test-runner",
"bradlc.vscode-tailwindcss",
"biomejs.biome"
"biomejs.biome",
"oven.bun-vscode"
]
}
+113
View File
@@ -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"
}
]
}
+1
View File
@@ -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
+11
View File
@@ -283,6 +283,17 @@
"IS_DEV": "true"
}
}
},
{
"label": "build-sdk",
"type": "shell",
"command": "bun run build:sdk",
"problemMatcher": [
"$tsc"
],
"options": {
"cwd": "${workspaceFolder}/sdk"
}
}
],
"inputs": [
+14
View File
@@ -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
+163
View File
@@ -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.
+1 -1
View File
@@ -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'
+1 -1
View File
@@ -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
View File
@@ -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",
+22
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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
+38 -1773
View File
File diff suppressed because it is too large Load Diff
+3 -6
View File
@@ -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": [
-1
View File
@@ -1 +0,0 @@
/.github/ @saoudrizwan @abeatrix @BarreiroT
-1
View File
@@ -1 +0,0 @@
22
-7
View File
@@ -1,7 +0,0 @@
{
"recommendations": [
"bradlc.vscode-tailwindcss",
"biomejs.biome",
"oven.bun-vscode"
]
}
-132
View File
@@ -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"
}
]
}
-11
View File
@@ -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"
}
}
-88
View File
@@ -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
View File
@@ -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
View File
@@ -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.
-25
View File
@@ -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.
+8
View File
@@ -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.
+7 -6
View File
@@ -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"
},
+1 -1
View File
@@ -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"> & {
+21
View File
@@ -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({
+38 -3
View File
@@ -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");
});
});
+85 -25
View File
@@ -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;
}
+29 -4
View File
@@ -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}
/>
);
+47
View File
@@ -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 });
}
});
});
+65
View File
@@ -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", {
+12 -4
View File
@@ -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);
}
+3
View File
@@ -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"]
}
+28 -14
View File
@@ -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
+18
View File
@@ -1,3 +1,7 @@
### Preview
![Cline Hub Monitor preview](./assets/hub-monitor-preview.jpg)
### 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

+1
View File
@@ -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",
+298 -35
View File
@@ -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();
}
},
+124 -5
View File
@@ -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
+12 -9
View File
@@ -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.
![Agent War Room interface](assets/agent-war-room.png)
## 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"]
}
+9 -2
View File
@@ -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
+4 -1
View File
@@ -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
View File
@@ -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=="],
+2 -43
View File
@@ -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,
});
});
});
+1
View File
@@ -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 });
}
+8 -2
View File
@@ -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