Compare commits

...
Author SHA1 Message Date
Zhongying Qiao 07d8d96207 change: add clear, specific instructions on critical tests scenarios for schema changes 2026-01-08 15:52:52 -08:00
cryptoque 47a464defe add unit tests for MCP marketplace catalog filtering (#8441) 2026-01-08 15:38:14 -08:00
Robin Newhouse 38f619cfd9 docs(skills): add Skills feature documentation (#8397)
* docs: add Skills feature documentation

Add comprehensive documentation for the Agent Skills feature including:
- Overview of what skills are and why they're useful
- How to create skills with SKILL.md and YAML frontmatter
- Global vs project skill locations
- Managing skills via the UI toggle interface
- Real example (data-analysis skill)
- Bundling supporting files and scripts
- Comparison with Rules and Workflows
2026-01-08 15:18:27 -08:00
Robin Newhouse 050773ac31 feat(skills): add Skills tab UI for managing skill toggles (#8396)
Oh. Add a new Skills tab to the Rules/Workflows modal that allows users to
view and toggle skills (global and workspace), create new skills from
templates, and delete existing skills. The tab only appears when the
skillsEnabled setting is on.

Changes:
- Add proto definitions for skills operations (refreshSkills, toggleSkill,
  createSkillFile, deleteSkillFile) with corresponding message types
- Add globalSkillsToggles to Settings and localSkillsToggles to LocalState
- Implement controller handlers for skills operations
- Add skills toggle state management to ExtensionStateContext
- Add Skills tab component to ClineRulesToggleModal
- Update RuleRow and NewRuleRow components to support skill type
- Implement lazy discovery for skills in UseSkillToolHandler (skills are
  discovered on-demand at execution time and filtered by toggle state)
- Use Tailwind CSS classes for styling consistency
2026-01-08 15:07:00 -08:00
Andrei EternalandAndrei Edell 6d67ff0b94 simplify nightly versioning with timestamps (#8453)
Co-authored-by: Andrei Edell <andrei@nugbase.com>
2026-01-08 14:54:24 -08:00
celestial-vault 18f4ef8b49 remove settings dir codeowners (#8451) 2026-01-08 14:51:06 -08:00
BeeandCopilot 085e69d142 fix: prevent duplicate diff error messages during file edits (#8431)
* fix: prevent duplicate diff error messages during file edits

Remove existing diff_error messages before displaying new ones to avoid
showing the same error multiple times when streaming file edits. This
ensures users only see the error once per occurrence, improving the UX
during tool execution with parallel tool calling disabled.

* Update src/core/task/tools/handlers/WriteToFileToolHandler.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-08 14:33:12 -08:00
Robin Newhouse 46aa66ed9d feat: add skillsEnabled setting to gate Skills feature (#8395)
Add experimental "Enable Skills" toggle in Settings > Features that
controls whether the Skills system is active. When disabled (default),
no directory scanning occurs and the use_skill tool is not exposed.

- Add skillsEnabled to Settings interface and ExtensionState
- Add skills_enabled to proto definitions
- Gate skill discovery in Task.attemptApiRequest()
- Add UI toggle in FeatureSettingsSection
2026-01-08 14:17:13 -08:00
Robin Newhouse 2ebbe954d9 feat(skills): Implement Skills system for reusable agent instructions (#8335)
feat(skills): add reusable Skills system and standardize global skills location

- Implement Skills system for reusable agent instructions loaded from project and global directories
- Support skill discovery and loading via stateless utilities
- Parse YAML frontmatter for skill metadata (name, description)
- Add use_skill tool for on-demand instruction loading
- List available skills in system prompt; global skills override project skills
- Define skills as directories with a SKILL.md file
- Add unit tests for skill utilities
- Global skills in ~/.cline/skills
- Introduce getClineHomePath() and update docs and tests for new path
2026-01-08 13:44:06 -08:00
Andrei EternalandAndrei Edell 067f5eea09 Npm publish main and ripgrep and cleanup (#8449)
* since npm nightly worked, make npm main

* fix ripgrep, split npm and jetbrains packaging

* cli nightly package version update

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>
2026-01-08 12:51:43 -08:00
cryptoque 2a48bad28c feat: [extensions] remotely configure whether enterprise users can disable a remote MCP server or not (#8426)
* feat: Enable remotely configure whetherenterprise users can disable an MCP server or not
2026-01-08 10:54:20 -08:00
celestial-vault c6f4584f7d fix: prevent unwanted editor focus stealing (#8038)
* control focus stealing via new param to focusChatInput

* pass preserveEditorFocus to getContextForCommand to fix e2e test
2026-01-08 07:25:25 -08:00
Ara a17b31070f feat(vercel-ai-gateway): add model refresh and improve reasoning support (#8398)
* feat(vercel-ai-gateway): add model refresh and reasoning support

- Add refreshVercelAiGatewayModelsRpc to ModelsService for fetching models
- Fix model ID/info references to use Vercel-specific parameters instead of OpenRouter
- Add reasoning effort and Gemini thinking level configuration support
- Skip reasoning content for incompatible models (devstral, grok-4)
- Improve model selection UI with keyboard navigation (ArrowUp/Down/Enter)
- Add model refresh functionality to settings interface

This enables proper model discovery and improves reasoning capabilities for Vercel AI Gateway provider, while fixing incorrect parameter references that were using OpenRouter naming conventions.

* refactor

* refactor

* refactor

* refactor

* refactor
2026-01-08 05:57:36 -08:00
Andrei EternalandAndrei Edell cad82d518d Remove version auto-increment for npm-nightly workflow (#8442)
Co-authored-by: Andrei Edell <andrei@nugbase.com>
2026-01-07 17:31:13 -08:00
Andrei EternalandAndrei Edell b4d7ec187f fix npm workflow permissions again (#8440)
Co-authored-by: Andrei Edell <andrei@nugbase.com>
2026-01-07 17:11:27 -08:00
Andrei EternalandAndrei Edell 42af8414e4 Npm nightly workflow fix permissions (#8439)
* First pass at npm nightly publish workflow

* go & ripgrep improvements

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>
2026-01-07 17:09:33 -08:00
Andrei EternalandAndrei Edell 8f6b9e8362 First pass at npm nightly publish workflow (#8438)
* First pass at npm nightly publish workflow

* go & ripgrep improvements

---------

Co-authored-by: Andrei Edell <andrei@nugbase.com>
2026-01-07 17:06:54 -08:00
MaxandMax Paulus 🥪 f1430359db show command denied message in cline CLI (#8344)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-01-07 16:11:44 -08:00
Ara 932695f70b changes (#8437) 2026-01-07 16:04:14 -08:00
cryptoque 489ee936c2 feat: UI changes for remote configured MCP servers (#8409)
* feat: hide the delete server ui when user and the remote mcp server is managed by remote config

* feat: add message to user if they are managed by remote config
2026-01-07 13:28:14 -08:00
Robin Newhouse dff7f61175 revert: #8341 (0d04205dc) due to DiffService truncateDocument regressions (#8423, #8429) (#8432) 2026-01-07 13:21:56 -08:00
Toshii aead42c6b8 add mcp prompting for webtools usage (#8425)
* add mcp prompting for usage

* native tool call snap test update

* updating capabilities section to add web tools
2026-01-07 11:26:41 -08:00
Seb Duerr db50a1c671 feat(cerebras): add zai-glm-4.7 (#8411)
- Add zai-glm-4.7 to Cerebras model list\n- Update model metadata (context window + descriptions)\n- Update Cerebras provider docs\n- Include changeset for release notes
2026-01-07 10:28:12 -08:00
Chaitanya Erankiandellipsis-dev[bot] bb20f60f1d Adding Responses API support to the Oracle Code Assist(OCA) Provider (#8388)
* Made changes for adding responses suppport

* removed some logs

* Made change to disallow format

* Added logging for cline

* Fixed codex prompts

* Made changes to make cline work

* Removed extra changes

* Added reasoning effort also to chat completions

* Made changes to fix issues with cline based on bugbash

* removed extra console.log statements

* Added extra changes to make reasoningEffortOptions working properly(outputs undefined)

* Made changes to code that make it cleaner

* created utility function for responses

* Removed extra console.log lines

* Fixed issues with tests not working

* Added changeset

* Update webview-ui/src/components/settings/providers/OcaModelPicker.tsx

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* removing openai-native changes

* Switched to using api format instead of supportsResponsesApi and supportChatApi

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2026-01-06 22:35:40 -08:00
Tomás Barreiro a7333b7177 Remove remote OTEL config type casting (#8351) 2026-01-07 06:04:08 +01:00
Tomás Barreiro f30837a850 Do not request /users/me when fetching other data (#8410)
* Do not use /users/me when fetching other data

* Add changeset
2026-01-07 02:24:29 +01:00
MaxandMax Paulus 🥪 5660b2513f add cline pr review cline workflow action (#8284)
cline pr-review bot initial

cline permission system

Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-01-06 17:20:05 -08:00
Tomás Barreiro 64e7e5fa4c Replace process.env usage with a BUILD_CONSTANTS variable (#8349)
* Replace process.env usage with a BUILD_CONSTANTS variable

* Update import

* revert doc update

* Do not build IS_STANDALONE
2026-01-07 01:07:28 +01:00
Toshii b34166e99a add web tool docs (#8408) 2026-01-06 14:54:43 -08:00
Ara cd2d8f98a7 feat: remove kwaipilot/kat-coder-pro from free models list (#8406)
* feat: remove kwaipilot/kat-coder-pro from free models list

Remove the KwaiPilot KAT-Coder Pro model from the OpenRouter
free models picker, likely due to availability changes or
model deprecation.

* changes
2026-01-06 12:47:31 -08:00
github-actions[bot] 333468c9b6 v3.47.0 Release Notes (#8286)
- Added experimental support for Background Edits (allows editing files in background without opening the diff view)
- Updated free model to MiniMax M2.1 (replacing MiniMax M2)
- Added support for Azure based identity authentication in OpenAI Compatible provider and Azure OpenAI
- Add `supportsReasoning` property to Baseten models

- Prevent expired token usage in authenticated requests
- Exclude binary files without extensions from diffs
- Preserve file endings and trailing newlines
- Fix Cerebras rate limiting
- Fix Auto Compact for Claude Code provider
- Make Workspace and Favorites history filters independent
- Fix remote MCP server connection failures (404 response handling)
- Disable native tool calling for Deepseek 3.2 speciale
- Show notification instead of opening sidebar on update
- Fix Baseten model selector

- Modify prompts for parallel tool usage in Claude and Gemini 3 models

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-01-06 12:00:52 -08:00
Juan Pablo Flores 0da6ddc001 feat: Add Background Edit feature to enhance workflow efficiency (#8405)
* Introduced a new feature, Background Edit, allowing file changes without opening the diff editor.
* Updated documentation to explain how to enable and use Background Edit, including its benefits and relationship with other features.
2026-01-06 11:48:35 -08:00
Tomás Barreiro 612130366f fix: Verify selected index is not -1 when checking if an option is selectable in the context menu (#8404)
* Verify selected index is not -1 when checking if an option is selectable

* Add changeset
2026-01-06 18:59:53 +01:00
Ara 85206cfded fix: update Minimax model ID to m2.1 in picker and cost logic (#8402)
Updates the Minimax model identifier from `minimax/minimax-m2` to `minimax/minimax-m2.1` in the OpenRouter model picker configuration. Additionally, updates the Cline provider to ensure the new model version is correctly recognized as a free model for cost calculation purposes.
2026-01-06 09:48:48 -08:00
CandiedUniverse 6f8ed7aa56 Display simple indicator for hooks in the CLI [ENG-1376] (#8269)
* feat(hooks): Initial implementation of UI output in the CLI

* feat(hooks): Display hooks UI output in the CLI nicely

* feat(hooks): Improvements to the hooks CLI implementation

* feat(hooks): Changes as per Cline's code review of hooks CLI PR

* feat(hooks): Make comments more concise and to the point

* feat(hooks): Minor improvements to code complexity

* feat(cli): polish hook status output (headers, paths, spacing)

- Align hook headings with ToolRenderer-style language
- Prefer workspace-relative paths for hook scripts
- Document hook_output_stream suppression + future grouping
- Add unit tests for rendering + path formatting

* feat(hooks): Isolate hook handlers and harden path handling

- Move hook-specific SAY handling into say_handlers_hooks.go
- Use os.UserHomeDir + filepath.Rel for more portable hook path shortening
- Document why hooks render from state stream (ordering/reordering)
- Standardize on filepath for filesystem paths in cline-clients
- Avoid silently ignoring os.Getwd() errors in dev fallback resolution

* feat(hooks): Add pendingToolInfo to hook status in the CLI

* feat(hooks): Fix verbose output to CLI

* feat(hooks): Add changeset commit.

* feat(hooks): code review feedback - make paths OS-agnostic

* feat(hooks): code review feedback - use strings.Builder

* feat(hooks): code review feedback - no need to normalize say type

* feat(hooks): code review feedback - define HookOutputStreamMeta type

* feat(hooks): code review feedback - remove dynamic import

* feat(hooks): code review feedback - turn repetitive logic into helper function and make say type names reflect proto field names

* feat(hooks): code review feedback - remove unrelated changes

* feat(hooks): prepend hook script path with repo name
2026-01-06 08:09:06 -08:00
Tomás Barreiroandellipsis-dev[bot] f2c130a69e Prevent using expired tokens when making authenticated requests (#8386)
* Prevent using expired tokens when making authenticated requests

* Add changeset

* refactor

* Update AuthService.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2026-01-06 02:52:18 +01:00
Bee c870bfb454 refactor: migrate HistoryView & ServerRow UI to use shadcn Button (#8387)
* refactor: migrate HistoryView & ServerRow UI to use shadcn Button and Tailwind classes

- Replace VSCodeButton with shadcn Button component
- Convert inline styles to Tailwind CSS utility classes
- Replace DangerButton with Button variant="destructive"
- Add custom Tailwind color classes for VS Code theme variables
- Simplify JSX structure by removing redundant style objects
- Improve code readability and maintainability

* fix typo
2026-01-05 17:12:28 -08:00
valquaint 436bfdb535 Do not use native tool calling when using Deepseek 3.2 speciale (#8390)
* Do not use native tool calling when using Deepseek 3.2 speciale

* Add changeset
2026-01-05 17:00:22 -08:00
yuvalman e2a3652d26 fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode (#7099)
* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode

* fix: sap provider - support replace credentials in orchestration mode without the need to reload vscode
2026-01-05 13:04:43 -08:00
632aca225c feat: Support Azure Identity DefaultCredential for AzureOpenAI (OpenAI Compatible provider) (#8385)
* feat: support azure identity authentication

Signed-off-by: patst <patrick.steinig@googlemail.com>

* feat: support azure identity authentication

Signed-off-by: patst <patrick.steinig@googlemail.com>

* chore: format changes

* set azureIdentity in state

* ADD Openai Compat Azure AD managed identity support: added proto messages def for azure identity, updated OpenAI APi key missing if azure identity is checked, ...

* feat: Support Azure Identity DefaultCredential for AzureOpenAI (OpenAI Compatible provider)

* fixed azure identity version and missing state setting in proto

* added missing state setting in proto

---------

Signed-off-by: patst <patrick.steinig@googlemail.com>
Co-authored-by: patst <patrick.steinig@googlemail.com>
Co-authored-by: Wenceslas Wolfersperger <wenceslas.wolfersperger@idorsia.com>
2026-01-05 13:03:42 -08:00
yuvalman a1eaccdaef fix: sap provider - use messages_history field instead of messages which doesn't validate user input based on template syntax (#8280)
* fix: sap provider - use messages_history field instead of messages because of placeholder_values usage templating error

* fix: sap provider - use messages_history field instead of messages because of placeholder_values usage templating error

* fix: sap provider - use messages_history field instead of messages because of placeholder_values usage templating error

* fix: sap provider - use messages_history field instead of messages because of placeholder_values usage templating error
2026-01-05 10:26:01 -08:00
Ara f7e76f9bee feat: add MiniMax model support for OpenRouter and Vercel AI Gateway (#8376)
* feat: add MiniMax model support for OpenRouter and Vercel AI Gateway

Add MiniMax M2, M2.1, and M2.1-lightning models to the list of models
that require special system prompt handling in OpenRouter stream.
Also extend Vercel AI Gateway to apply the same system prompt format
for MiniMax models as used for Anthropic models.

* adding changes with debug logs
2026-01-04 17:18:07 -08:00
Robin Newhouse 0d04205dc4 fix: preserve file endings and trailing newlines across all edit tools (#8341) 2026-01-03 10:12:25 -08:00
Bee 4b9dbf11a0 feat: add Select UI component and Storybook story (#8355)
* feat: add Select UI component and Storybook story

Add @radix-ui/react-select dependency and introduce a Select Storybook
story to document and validate the new dropdown UI component.

* update position
2026-01-02 13:17:48 -08:00
199 changed files with 7990 additions and 2072 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add Skills system for reusable, on-demand agent instructions.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add zai-glm-4.7 to Cerebras model list
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Adding support for responses api to OCA provider
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Exclude files without extensions (and dotfiles) from getDiffSet results if they are binary
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix Cerebras rate limiting by using conservative max_tokens (16K) instead of model maximum.
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fixed Auto Compact not triggering when using Claude Code provider. Short model aliases like "sonnet" and "opus" are now correctly recognized as Claude 4+ models.
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix Workspace and Favorites history filters to work independently instead of being mutually exclusive
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fixed connection failures with remote MCP servers that return 404 instead of 405 for SSE stream checks. This was causing "Failed to open SSE stream: Not Found" errors after the v3.46.0 SDK upgrade.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
add bash command permission system to cline
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
feat(prompts): modify prompts for parallel tool usage in claude and gemini 3 models
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Stop automatically opening Cline sidebar on extension update - only show a notification
+6
View File
@@ -0,0 +1,6 @@
---
"claude-dev": patch
---
Revert #8341 (0d04205dc) due to regressions in diff view/document truncation (see #8423, #8429).
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
feat(vercel-ai-gateway): add model refresh and improve reasoning support
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
add supportsReasoning property to Baseten models
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
show cline command permission denials in the CLI
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
fix regression that broke JSON parsing for SAP AI Core provider in native API mode for claude models
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix Baseten model selector issue in model picker modal mode
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Reduce the number of network requests for the users profile
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: Verify selected index is not -1 when checking if an option is selectable in the context menu
-1
View File
@@ -1,4 +1,3 @@
/docs/
/.github/ @saoudrizwan @garoth @sjf
/README.md @saoudrizwan @nickbaumann98
/src/core/storage/ @celestial-vault @abeatrix
+130
View File
@@ -0,0 +1,130 @@
name: Publish NPM Release
on:
workflow_dispatch:
inputs:
confirm_publish:
description: 'Type "publish" to confirm you want to publish to NPM'
required: true
type: string
permissions:
contents: read
checks: write # Required by test workflow
pull-requests: write # Required by test workflow
jobs:
test:
uses: ./.github/workflows/test.yml
publish-npm-release:
needs: test
name: Publish Cline CLI to NPM
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main' && github.event.inputs.confirm_publish == 'publish'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: cli/go.sum
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
uses: actions/cache@v4
id: root-cache
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
uses: actions/cache@v4
id: webview-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Install root dependencies
if: steps.root-cache.outputs.cache-hit != 'true'
run: npm ci --include=optional
- name: Install webview-ui dependencies
if: steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci --include=optional
- name: Read release version
id: version
run: |
# Read version from cli/package.json (stable version)
VERSION=$(node -p "require('./cli/package.json').version")
echo "Release version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Download ripgrep binaries
run: npm run download-ripgrep
- name: Clean previous builds
run: rm -rf dist-standalone
- name: Generate Protos (First Pass)
run: npm run protos && npm run protos-go
- name: Compile CLI
run: npm run compile-cli
- name: Compile CLI for all platforms
run: npm run compile-cli-all-platforms
- name: Build standalone NPM package
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ENVIRONMENT: production
OTEL_TELEMETRY_ENABLED: "1"
OTEL_METRICS_EXPORTER: otlp
OTEL_LOGS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
POSTHOG_TELEMETRY_ENABLED: "true"
run: npm run compile-standalone-npm
- name: Generate Protos (Second Pass - Bug Workaround)
run: npm run protos && npm run protos-go
- name: Verify build output
run: |
echo "Checking dist-standalone directory..."
ls -la dist-standalone/
echo "Verifying CLI binaries..."
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
echo "Checking package.json in dist-standalone..."
cat dist-standalone/package.json | grep version
- name: Publish to NPM with latest tag
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }}
run: |
echo "Publishing version ${{ steps.version.outputs.version }} to NPM with tag 'latest'..."
cd dist-standalone
npm publish --tag latest --access public
- name: Summary
run: |
echo "✅ Successfully published cline@${{ steps.version.outputs.version }} to NPM with tag 'latest'"
echo ""
echo "📦 Install with: npm install -g cline"
echo "🔗 NPM: https://www.npmjs.com/package/cline/v/${{ steps.version.outputs.version }}"
+175
View File
@@ -0,0 +1,175 @@
name: Publish NPM Nightly
on:
schedule:
- cron: '0 12 * * *' # 4 AM PST (UTC-8) = 12 UTC
workflow_dispatch:
permissions:
contents: read
checks: write # Required by test workflow
pull-requests: write # Required by test workflow
jobs:
test:
uses: ./.github/workflows/test.yml
publish-npm-nightly:
needs: test
name: Publish Cline CLI (Nightly) to NPM
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Check for recent commits
id: check_commits
run: |
if [ $(git rev-list --count HEAD --since="24 hours ago") -eq 0 ]; then
echo "No commits in last 24 hours, skipping publish"
echo "skip=true" >> $GITHUB_OUTPUT
else
echo "Found recent commits, proceeding with publish"
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Setup Node.js
if: steps.check_commits.outputs.skip != 'true'
uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- name: Setup Go
if: steps.check_commits.outputs.skip != 'true'
uses: actions/setup-go@v5
with:
go-version: '1.24'
cache-dependency-path: cli/go.sum
# Cache root dependencies - only reuse if package-lock.json exactly matches
- name: Cache root dependencies
if: steps.check_commits.outputs.skip != 'true'
uses: actions/cache@v4
id: root-cache
with:
path: node_modules
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
- name: Cache webview-ui dependencies
if: steps.check_commits.outputs.skip != 'true'
uses: actions/cache@v4
id: webview-cache
with:
path: webview-ui/node_modules
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
- name: Install root dependencies
if: steps.check_commits.outputs.skip != 'true' && steps.root-cache.outputs.cache-hit != 'true'
run: npm ci --include=optional
- name: Install webview-ui dependencies
if: steps.check_commits.outputs.skip != 'true' && steps.webview-cache.outputs.cache-hit != 'true'
run: cd webview-ui && npm ci --include=optional
- name: Generate nightly version with timestamp
if: steps.check_commits.outputs.skip != 'true'
id: version
run: |
# Read base version from cli/package.json (e.g., "1.0.9")
BASE_VERSION=$(node -p "require('./cli/package.json').version")
# Generate timestamp (Unix epoch seconds)
TIMESTAMP=$(date +%s)
# Create unique nightly version: 1.0.9-nightly.1736365200
VERSION="${BASE_VERSION}-nightly.${TIMESTAMP}"
echo "Base version: $BASE_VERSION"
echo "Generated nightly version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Update cli/package.json with nightly version
if: steps.check_commits.outputs.skip != 'true'
run: |
# Update version with timestamp-based nightly version
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('cli/package.json', 'utf8'));
pkg.version = '${{ steps.version.outputs.version }}';
fs.writeFileSync('cli/package.json', JSON.stringify(pkg, null, '\t'));
"
echo "Using version ${{ steps.version.outputs.version }} for build"
cat cli/package.json | grep '"version"'
- name: Download ripgrep binaries
if: steps.check_commits.outputs.skip != 'true'
run: npm run download-ripgrep
- name: Clean previous builds
if: steps.check_commits.outputs.skip != 'true'
run: rm -rf dist-standalone
- name: Generate Protos (First Pass)
if: steps.check_commits.outputs.skip != 'true'
run: npm run protos && npm run protos-go
- name: Compile CLI
if: steps.check_commits.outputs.skip != 'true'
run: npm run compile-cli
- name: Compile CLI for all platforms
if: steps.check_commits.outputs.skip != 'true'
run: npm run compile-cli-all-platforms
- name: Build standalone NPM package
if: steps.check_commits.outputs.skip != 'true'
env:
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
CLINE_ENVIRONMENT: production
OTEL_TELEMETRY_ENABLED: "1"
OTEL_METRICS_EXPORTER: otlp
OTEL_LOGS_EXPORTER: otlp
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
POSTHOG_TELEMETRY_ENABLED: "true"
run: npm run compile-standalone-npm
- name: Generate Protos (Second Pass - Bug Workaround)
if: steps.check_commits.outputs.skip != 'true'
run: npm run protos && npm run protos-go
- name: Verify build output
if: steps.check_commits.outputs.skip != 'true'
run: |
echo "Checking dist-standalone directory..."
ls -la dist-standalone/
echo "Verifying CLI binaries..."
ls -lh cli/bin/cline-* || echo "Warning: CLI binaries not found"
echo "Checking package.json in dist-standalone..."
cat dist-standalone/package.json | grep version
- name: Publish to NPM with nightly tag
if: steps.check_commits.outputs.skip != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_RELEASE_TOKEN }}
run: |
echo "Publishing version ${{ steps.version.outputs.version }} to NPM with tag 'nightly'..."
cd dist-standalone
npm publish --tag nightly --access public
- name: Summary
if: steps.check_commits.outputs.skip != 'true'
run: |
echo "✅ Successfully published cline@${{ steps.version.outputs.version }} to NPM with tag 'nightly'"
echo ""
echo "📦 Install with: npm install -g cline@nightly"
echo "🔗 NPM: https://www.npmjs.com/package/cline/v/${{ steps.version.outputs.version }}"
+2
View File
@@ -29,6 +29,8 @@ coverage-unit
*evals.env
.env
.secrets
.github/act/.secrets
.worktrees
+25 -1
View File
@@ -1,12 +1,36 @@
# Changelog
## [3.47.0]
### Added
- Added experimental support for Background Edits (allows editing files in background without opening the diff view)
- Updated free model to MiniMax M2.1 (replacing MiniMax M2)
- Added support for Azure based identity authentication in OpenAI Compatible provider and Azure OpenAI
- Add `supportsReasoning` property to Baseten models
### Fixed
- Prevent expired token usage in authenticated requests
- Exclude binary files without extensions from diffs
- Preserve file endings and trailing newlines
- Fix Cerebras rate limiting
- Fix Auto Compact for Claude Code provider
- Make Workspace and Favorites history filters independent
- Fix remote MCP server connection failures (404 response handling)
- Disable native tool calling for Deepseek 3.2 speciale
- Show notification instead of opening sidebar on update
- Fix Baseten model selector
### Refactored
- Modify prompts for parallel tool usage in Claude and Gemini 3 models
## [3.46.1]
### Fixed
- Remove GLM 4.6 from free models
## [3.46.0]
### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "cline",
"version": "1.0.3",
"version": "1.0.9",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"main": "cline-core.js",
"bin": {
+144
View File
@@ -0,0 +1,144 @@
package display
import (
"fmt"
"strings"
"github.com/cline/cli/pkg/cli/types"
)
// HookRenderer renders hook status messages in a CLI-native style.
//
// Goals:
// - Match ToolRenderers markdown look
// - Keep executions ungrouped
// - Render status + high-signal metadata (script paths, error summary)
//
// Note: hook stdout/stderr currently arrives as separate `hook_output_stream` messages.
// The CLI suppresses those by default and prints them only in --verbose mode.
// Future work could group streamed output under the corresponding hook block.
//
// It returns markdown (or rendered markdown when enabled); callers should print the
// returned string.
type HookRenderer struct {
mdRenderer *MarkdownRenderer
outputFormat string
}
func NewHookRenderer(mdRenderer *MarkdownRenderer, outputFormat string) *HookRenderer {
return &HookRenderer{mdRenderer: mdRenderer, outputFormat: outputFormat}
}
func (hr *HookRenderer) RenderHookStatus(h types.HookMessage) string {
statusText := strings.TrimSpace(h.Status)
if statusText == "" {
statusText = "unknown"
}
// Header: aligned with ToolRenderers phrasing so transcripts scan consistently.
// Example: "### Cline hook completed: PreToolUse (tool: read_file) (exit 0)"
var headerBuilder strings.Builder
headerBuilder.WriteString(fmt.Sprintf("### Cline hook %s: %s", statusText, h.HookName))
if h.ToolName != "" {
headerBuilder.WriteString(" ")
headerBuilder.WriteString(fmt.Sprintf("(tool: %s)", h.ToolName))
}
if statusText == "failed" && h.ExitCode != 0 {
headerBuilder.WriteString(" ")
headerBuilder.WriteString(fmt.Sprintf("(exit %d)", h.ExitCode))
}
header := headerBuilder.String()
var lines []string
lines = append(lines, header)
// Pending tool info (PreToolUse): show one high-signal line directly under the header.
if h.PendingToolInfo != nil {
if pending := hr.formatPendingToolInfo(h.PendingToolInfo); pending != "" {
lines = append(lines, fmt.Sprintf("- Pending: %s", pending))
}
}
// Script paths: one per line.
paths := make([]string, 0, len(h.ScriptPaths))
for _, p := range h.ScriptPaths {
p = strings.TrimSpace(p)
if p != "" {
paths = append(paths, p)
}
}
if len(paths) == 0 {
// Fallback when no script paths are provided.
lines = append(lines, "- *(no hook scripts found)*")
} else {
for _, p := range paths {
lines = append(lines, fmt.Sprintf("- Running hook: `%s`", p))
}
}
// On failure, show a minimal summary (full stderr reserved for verbose).
if statusText == "failed" && h.Error != nil {
if msg := strings.TrimSpace(h.Error.Message); msg != "" {
lines = append(lines, fmt.Sprintf("- Error: %s", msg))
}
// If we have a specific script path, include it as a hint.
if sp := strings.TrimSpace(h.Error.ScriptPath); sp != "" {
lines = append(lines, fmt.Sprintf("- Script: `%s`", sp))
}
}
markdown := strings.Join(lines, "\n")
return hr.renderMarkdown(markdown)
}
func (hr *HookRenderer) formatPendingToolInfo(info *types.ToolInfo) string {
if info == nil {
return ""
}
tool := strings.TrimSpace(info.Tool)
if tool == "" {
return ""
}
// Keep this intentionally compact and readable.
// Format: "<tool> <identifier>" where identifier is the most relevant param.
var ident string
switch {
case strings.TrimSpace(info.Path) != "":
ident = strings.TrimSpace(info.Path)
case strings.TrimSpace(info.Command) != "":
ident = strings.TrimSpace(info.Command)
case strings.TrimSpace(info.Url) != "":
ident = strings.TrimSpace(info.Url)
case strings.TrimSpace(info.McpTool) != "" && strings.TrimSpace(info.McpServer) != "":
ident = fmt.Sprintf("%s %s", strings.TrimSpace(info.McpServer), strings.TrimSpace(info.McpTool))
case strings.TrimSpace(info.ResourceUri) != "":
ident = strings.TrimSpace(info.ResourceUri)
case strings.TrimSpace(info.Regex) != "":
ident = strings.TrimSpace(info.Regex)
default:
ident = ""
}
if ident != "" {
return fmt.Sprintf("%s %s", tool, ident)
}
return tool
}
func (hr *HookRenderer) renderMarkdown(markdown string) string {
// Align with ToolRenderer: in plain mode or non-TTY, return markdown as-is.
if hr.outputFormat == "plain" || !isTTY() {
return markdown
}
if hr.mdRenderer == nil {
return markdown
}
rendered, err := hr.mdRenderer.Render(markdown)
if err != nil {
return markdown
}
return rendered
}
+69
View File
@@ -0,0 +1,69 @@
package display
import (
"strings"
"testing"
"github.com/cline/cli/pkg/cli/types"
)
func TestHookRenderer_RenderHookStatus_FailedShowsErrorAndScript(t *testing.T) {
hr := NewHookRenderer(nil, "plain")
msg := hr.RenderHookStatus(types.HookMessage{
HookName: "PreToolUse",
ToolName: "execute_command",
Status: "failed",
ExitCode: 2,
ScriptPaths: []string{"repo/.clinerules/hooks/PreToolUse"},
Error: &types.HookError{
Message: "boom",
ScriptPath: "repo/.clinerules/hooks/PreToolUse",
},
})
if !strings.Contains(msg, "### Cline hook failed: PreToolUse") {
t.Fatalf("expected header in rendered output, got: %q", msg)
}
if !strings.Contains(msg, "- Error: boom") {
t.Fatalf("expected error line in rendered output, got: %q", msg)
}
if !strings.Contains(msg, "- Script: `repo/.clinerules/hooks/PreToolUse`") {
t.Fatalf("expected script line in rendered output, got: %q", msg)
}
}
func TestHookRenderer_RenderHookStatus_PendingToolInfoAppearsDirectlyUnderHeader(t *testing.T) {
hr := NewHookRenderer(nil, "plain")
msg := hr.RenderHookStatus(types.HookMessage{
HookName: "PreToolUse",
ToolName: "write_to_file",
Status: "running",
PendingToolInfo: &types.ToolInfo{
Tool: "write_to_file",
Path: "src/foo.ts",
},
ScriptPaths: []string{"repo/.clinerules/hooks/PreToolUse"},
})
header := "### Cline hook running: PreToolUse"
pending := "- Pending: write_to_file src/foo.ts"
runningHook := "- Running hook: `repo/.clinerules/hooks/PreToolUse`"
headerIdx := strings.Index(msg, header)
if headerIdx == -1 {
t.Fatalf("expected header %q in output, got: %q", header, msg)
}
pendingIdx := strings.Index(msg, pending)
if pendingIdx == -1 {
t.Fatalf("expected pending line %q in output, got: %q", pending, msg)
}
runningIdx := strings.Index(msg, runningHook)
if runningIdx == -1 {
t.Fatalf("expected running hook line %q in output, got: %q", runningHook, msg)
}
if !(headerIdx < pendingIdx && pendingIdx < runningIdx) {
t.Fatalf("expected header < pending < runningHook ordering, got indexes header=%d pending=%d running=%d\nfull=%q", headerIdx, pendingIdx, runningIdx, msg)
}
}
+13 -3
View File
@@ -39,9 +39,12 @@ func NewStreamingSegment(sayType, prefix string, mdRenderer *MarkdownRenderer, s
// Render rich header immediately when creating segment (if in rich mode and TTY)
if shouldMarkdown && outputFormat != "plain" && isTTY() {
header := ss.generateRichHeader()
rendered, _ := mdRenderer.Render(header)
output.Println("")
output.Print(rendered)
// Skip empty headers.
if strings.TrimSpace(header) != "" {
rendered, _ := mdRenderer.Render(header)
output.Println("")
output.Print(rendered)
}
}
return ss
@@ -110,6 +113,9 @@ func (ss *StreamingSegment) renderFinal(currentBuffer string) {
if err := json.Unmarshal([]byte(currentBuffer), &tool); err == nil {
bodyContent = ss.toolRenderer.GenerateToolContentBody(&tool)
}
} else if ss.sayType == string(types.SayTypeHookStatus) {
// Hooks are rendered via the state stream; nothing to render here.
bodyContent = ""
} else if ss.sayType == string(types.SayTypeCommand) {
// Command output
bodyContent = "```shell\n" + currentBuffer + "\n```"
@@ -160,6 +166,10 @@ func (ss *StreamingSegment) generateRichHeader() string {
case string(types.SayTypeTool):
return ss.generateToolHeader()
case string(types.SayTypeHookStatus):
// Hooks are rendered from the state stream; dont emit a partial-stream header.
return ""
case "ask":
// Check the specific ask type
@@ -0,0 +1,20 @@
package display
import (
"testing"
"github.com/cline/cli/pkg/cli/types"
)
func TestStreamingSegment_generateRichHeader_HookIsEmpty(t *testing.T) {
ss := &StreamingSegment{
sayType: string(types.SayTypeHookStatus),
prefix: "HOOK",
msg: &types.ClineMessage{},
}
header := ss.generateRichHeader()
if header != "" {
t.Fatalf("expected empty header for hook segments to avoid double-render, got: %q", header)
}
}
+19 -1
View File
@@ -38,6 +38,18 @@ func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error {
s.mu.Lock()
defer s.mu.Unlock()
// Render hooks from the state stream only (not partial stream) to avoid duplicates.
//
// Rationale: hook status messages are often updated/reordered by the backend (e.g. PreToolUse
// hooks are moved above the corresponding tool message). The state stream represents the
// authoritative, “final” message ordering, while the partial stream is best-effort for
// incremental display.
//
// Only suppress *partial* hook messages; complete ones still flow through dedupe.
if msg.Partial && msg.Say == string(types.SayTypeHookStatus) {
return nil
}
// Check for deduplication
if s.dedupe.IsDuplicate(msg) {
return nil
@@ -91,7 +103,11 @@ func (s *StreamingDisplay) HandlePartialMessage(msg *types.ClineMessage) error {
func (s *StreamingDisplay) shouldRenderMarkdown(sayType string) bool {
switch sayType {
case string(types.SayTypeReasoning), string(types.SayTypeText), string(types.SayTypeCompletionResult), string(types.SayTypeTool), "ask":
case string(types.SayTypeReasoning),
string(types.SayTypeText),
string(types.SayTypeCompletionResult),
string(types.SayTypeTool),
"ask":
return true
default:
return false
@@ -110,6 +126,8 @@ func (s *StreamingDisplay) getPrefix(sayType string) string {
return "ASK"
case string(types.SayTypeCommand):
return "TERMINAL"
case string(types.SayTypeHookStatus):
return "HOOK"
default:
return strings.ToUpper(sayType)
}
+1 -1
View File
@@ -264,6 +264,6 @@ func (sr *SystemMessageRenderer) RenderInfo(title, message string) error {
func (sr *SystemMessageRenderer) RenderCheckpoint(timestamp string, id int64) error {
markdown := fmt.Sprintf("## [%s] Checkpoint created `%d`", timestamp, id)
rendered := sr.renderer.RenderMarkdown(markdown)
fmt.Printf(rendered)
fmt.Print(rendered)
return nil
}
+7
View File
@@ -339,6 +339,13 @@ func (tr *ToolRenderer) RenderCommandOutput(output string) string {
return result.String()
}
func (tr *ToolRenderer) RenderCommandPermissionDenied(command string) string {
command = strings.TrimSpace(command)
rendered := tr.renderMarkdown("### Command was denied")
message := fmt.Sprintf("Cline does not have permission to execute this command: `%s`", command)
return fmt.Sprintf("\n%s\n\n%s\n", rendered, message)
}
// RenderUserResponse renders user approval/rejection feedback
func (tr *ToolRenderer) RenderUserResponse(approved bool, feedback string) string {
var symbol, status string
+1
View File
@@ -25,6 +25,7 @@ type DisplayContext struct {
State *types.ConversationState
Renderer *display.Renderer
ToolRenderer *display.ToolRenderer
HookRenderer *display.HookRenderer
SystemRenderer *display.SystemMessageRenderer
IsLast bool
IsPartial bool
+40 -12
View File
@@ -6,8 +6,8 @@ import (
"strings"
"github.com/cline/cli/pkg/cli/clerror"
"github.com/cline/cli/pkg/cli/types"
"github.com/cline/cli/pkg/cli/output"
"github.com/cline/cli/pkg/cli/types"
)
// SayHandler handles SAY type messages
@@ -90,6 +90,12 @@ func (h *SayHandler) Handle(msg *types.ClineMessage, dc *DisplayContext) error {
return h.handleInfo(msg, dc)
case string(types.SayTypeTaskProgress):
return h.handleTaskProgress(msg, dc)
case string(types.SayTypeHookStatus):
return h.handleHookStatus(msg, dc)
case string(types.SayTypeHookOutputStream):
return h.handleHookOutputStream(msg, dc)
case string(types.SayTypeCommandPermissionDenied):
return h.handleCommandPermissionDenied(msg, dc)
default:
return h.handleDefault(msg, dc)
}
@@ -242,18 +248,17 @@ func (h *SayHandler) handleCompletionResult(msg *types.ClineMessage, dc *Display
}
func formatUserMessage(text string) string {
lines := strings.Split(text, "\n")
// Wrap each line in backticks
for i, line := range lines {
if line != "" {
lines[i] = fmt.Sprintf("`%s`", line)
}
}
return strings.Join(lines, "\n")
}
lines := strings.Split(text, "\n")
// Wrap each line in backticks
for i, line := range lines {
if line != "" {
lines[i] = fmt.Sprintf("`%s`", line)
}
}
return strings.Join(lines, "\n")
}
// handleUserFeedback handles user feedback messages
func (h *SayHandler) handleUserFeedback(msg *types.ClineMessage, dc *DisplayContext) error {
@@ -343,6 +348,18 @@ func (h *SayHandler) handleCommandOutput(msg *types.ClineMessage, dc *DisplayCon
return nil
}
func (h *SayHandler) handleCommandPermissionDenied(msg *types.ClineMessage, dc *DisplayContext) error {
if msg.Text == "" {
return nil
}
// Use unified ToolRenderer
rendered := dc.ToolRenderer.RenderCommandPermissionDenied(msg.Text)
output.Print(rendered)
return nil
}
func (h *SayHandler) handleTool(msg *types.ClineMessage, dc *DisplayContext) error {
var tool types.ToolMessage
if err := json.Unmarshal([]byte(msg.Text), &tool); err != nil {
@@ -517,5 +534,16 @@ func (h *SayHandler) handleTaskProgress(msg *types.ClineMessage, dc *DisplayCont
// handleDefault handles unknown SAY message types
func (h *SayHandler) handleDefault(msg *types.ClineMessage, dc *DisplayContext) error {
// Debug: log unhandled say types to help identify missing cases using output.Printf for CLI consistency
if dc.Verbose {
output.Printf("[DEBUG] Unhandled SAY type: '%s' (text preview: %s)\n", msg.Say, truncateForDisplay(msg.Text, 50))
}
return dc.Renderer.RenderMessage("SAY", msg.Text, true)
}
func truncateForDisplay(text string, maxLen int) string {
if len(text) <= maxLen {
return text
}
return text[:maxLen] + "..."
}
+198
View File
@@ -0,0 +1,198 @@
package handlers
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/cline/cli/pkg/cli/output"
"github.com/cline/cli/pkg/cli/types"
)
// Hook-specific SAY handlers and helpers.
// Kept in a separate file to keep say_handlers.go focused on routing.
// handleHookStatus handles hook execution status messages.
func (h *SayHandler) handleHookStatus(msg *types.ClineMessage, dc *DisplayContext) error {
hook, err := parseHookMessage(msg.Text)
if err != nil {
// Fallback to basic output if JSON parsing fails
return dc.Renderer.RenderMessage("HOOK", msg.Text, true)
}
logHookDebug(hook, dc)
hook.ScriptPaths = formatHookPaths(hook.ScriptPaths)
return renderHookStatus(hook, dc)
}
// handleHookOutputStream handles streaming output from hooks.
//
// Hook stdout/stderr currently arrives line-by-line from the backend as
// `hook_output_stream` messages. The CLI intentionally suppresses these by default
// to keep the transcript high-signal.
//
// In --verbose mode, we print each non-empty line prefixed with "HOOK>" for easy grepping.
// Future work could associate these lines with a specific hook execution and render them
// as a grouped section under the hook status header.
func (h *SayHandler) handleHookOutputStream(msg *types.ClineMessage, dc *DisplayContext) error {
if !dc.Verbose {
return nil
}
line := strings.TrimRight(msg.Text, "\n")
if strings.TrimSpace(line) == "" {
return nil
}
output.Printf("HOOK> %s\n", line)
return nil
}
func parseHookMessage(jsonText string) (types.HookMessage, error) {
var hook types.HookMessage
if err := json.Unmarshal([]byte(jsonText), &hook); err != nil {
return types.HookMessage{}, err
}
return hook, nil
}
func logHookDebug(hook types.HookMessage, dc *DisplayContext) {
if dc.Verbose {
output.Printf("[DEBUG] Hook parsed: name=%s, status=%s, toolName=%s, scriptPaths=%v\n",
hook.HookName, hook.Status, hook.ToolName, hook.ScriptPaths)
}
}
func formatHookPaths(paths []string) []string {
if len(paths) == 0 {
return paths
}
formatted := make([]string, 0, len(paths))
for _, p := range paths {
if strings.TrimSpace(p) == "" {
continue
}
formatted = append(formatted, formatHookPath(p))
}
return formatted
}
func renderHookStatus(hook types.HookMessage, dc *DisplayContext) error {
if dc.HookRenderer != nil {
rendered := dc.HookRenderer.RenderHookStatus(hook)
// Match ToolRenderers spacing: one leading newline, one trailing newline.
output.Print("\n")
output.Print(rendered)
output.Print("\n")
return nil
}
// Fallback: if HookRenderer not available
return dc.Renderer.RenderMessage("HOOK", fmt.Sprintf("%s %s", hook.HookName, hook.Status), true)
}
func formatHookPath(fullPath string) string {
// Normalize for display and prefix checks. This is display-only; do not use for IO.
normalized := normalizeSlashes(fullPath)
// If this is a repo-scoped hook script (i.e. lives under <repo>/.clinerules/hooks/),
// always include the repo name for disambiguation even in single-repo workspaces.
//
// This intentionally runs before workspace-relative formatting, which would otherwise
// collapse to ".clinerules/hooks/..." and lose the repo context.
if p, ok := tryRepoScopedHooksPath(normalized); ok {
return p
}
// Prefer workspace-relative paths first for readability, since most hook scripts
// live inside the current project.
if p, ok := tryWorkspaceRelativeHookPath(normalized); ok {
return p
}
// Follow existing CLI pattern: resolve home via os.UserHomeDir.
if p, ok := tryHomeTildePath(normalized); ok {
return p
}
// Secondary heuristic: if hook lives under <repo>/.clinerules, collapse to repo-relative.
if p, ok := tryRepoRelativeHookPath(normalized); ok {
return p
}
return fallbackLastComponents(normalized, 3)
}
func normalizeSlashes(p string) string {
return filepath.ToSlash(p)
}
func tryWorkspaceRelativeHookPath(normalizedPath string) (string, bool) {
root, err := os.Getwd()
if err != nil {
return "", false
}
// filepath.Rel expects OS-native paths, so we need to convert the normalized path
// back to OS-native format before calling Rel, then normalize the result for display.
targetOS := filepath.FromSlash(normalizedPath)
rel, err := filepath.Rel(root, targetOS)
if err != nil {
return "", false
}
// If it's not within the workspace, Rel will start with "..".
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", false
}
return normalizeSlashes(rel), true
}
func tryHomeTildePath(normalizedPath string) (string, bool) {
homeDir, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(homeDir) == "" {
return "", false
}
homeDir = normalizeSlashes(homeDir)
if !strings.HasPrefix(normalizedPath, homeDir) {
return "", false
}
rel := strings.TrimPrefix(normalizedPath, homeDir)
rel = strings.TrimPrefix(rel, "/")
return "~/" + rel, true
}
func tryRepoRelativeHookPath(normalizedPath string) (string, bool) {
parts := strings.Split(normalizedPath, "/")
for i, part := range parts {
if part == ".clinerules" && i > 0 {
repoName := parts[i-1]
return repoName + "/" + strings.Join(parts[i:], "/"), true
}
}
return "", false
}
// tryRepoScopedHooksPath returns a repo-prefixed path like
// "myrepo/.clinerules/hooks/PreToolUse" when the given path points to a hook script
// under a repo's .clinerules/hooks directory.
//
// This is more specific than tryRepoRelativeHookPath and is used to ensure hook script
// paths always include repo context.
func tryRepoScopedHooksPath(normalizedPath string) (string, bool) {
// Fast path check to avoid split work.
if !strings.Contains(normalizedPath, "/.clinerules/hooks/") {
return "", false
}
return tryRepoRelativeHookPath(normalizedPath)
}
func fallbackLastComponents(normalizedPath string, n int) string {
parts := strings.Split(normalizedPath, "/")
if len(parts) >= n {
return strings.Join(parts[len(parts)-n:], "/")
}
return normalizedPath
}
@@ -0,0 +1,41 @@
package handlers
import (
"os"
"path/filepath"
"testing"
)
func TestFormatHookPath_PrefersWorkspaceRelative(t *testing.T) {
// Create a stable workspace root (avoid TempDir's nested ".../001" patterns)
// so that workspace-relative formatting is deterministic.
root := filepath.Join(t.TempDir(), "workspace")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
oldWd, err := os.Getwd()
if err != nil {
t.Fatalf("Getwd: %v", err)
}
defer func() { _ = os.Chdir(oldWd) }()
if err := os.Chdir(root); err != nil {
t.Fatalf("Chdir: %v", err)
}
inside := filepath.Join(root, ".clinerules", "hooks", "pre.sh")
got := formatHookPath(inside)
// Repo-scoped hook scripts should always include the repo name (the directory
// immediately containing .clinerules) even when running inside that repo.
expected := "workspace/" + filepath.ToSlash(filepath.Join(".clinerules", "hooks", "pre.sh"))
if got != expected {
t.Fatalf("expected formatted path to be %q. got=%q", expected, got)
}
}
func TestFormatHookPath_FallsBackToLastComponents(t *testing.T) {
// Use an obviously non-workspace path (relative, but not prefixed with cwd).
got := formatHookPath("/var/tmp/foo/bar/baz.sh")
if got != "foo/bar/baz.sh" {
t.Fatalf("expected last 3 components fallback, got=%q", got)
}
}
+35
View File
@@ -0,0 +1,35 @@
package handlers
import (
"os"
"testing"
)
func TestFormatHookPath_HomeDirToTilde(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil || home == "" {
t.Skip("home dir not available; skipping")
}
got := formatHookPath(home + "/Documents/Cline/Hooks/TaskStart")
want := "~/Documents/Cline/Hooks/TaskStart"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestFormatHookPath_WorkspaceRepoRelative(t *testing.T) {
got := formatHookPath("/Users/alice/dev/repo-name/.clinerules/hooks/TaskStart")
want := "repo-name/.clinerules/hooks/TaskStart"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
func TestFormatHookPath_FallbackLast3Components(t *testing.T) {
got := formatHookPath("/a/b/c/d/e")
want := "c/d/e"
if got != want {
t.Fatalf("expected %q, got %q", want, got)
}
}
+2 -2
View File
@@ -407,7 +407,7 @@ func newInstanceListCommand() *cobra.Command {
fmt.Print(strings.TrimLeft(rendered, "\n"))
}
fmt.Println("\n")
fmt.Println()
}
}
@@ -503,4 +503,4 @@ func newInstanceNewCommand() *cobra.Command {
cmd.Flags().BoolVarP(&setDefault, "default", "d", false, "set as default instance")
return cmd
}
}
+38 -4
View File
@@ -33,6 +33,7 @@ type Manager struct {
clientAddress string
state *types.ConversationState
renderer *display.Renderer
hookRenderer *display.HookRenderer
toolRenderer *display.ToolRenderer
systemRenderer *display.SystemMessageRenderer
streamingDisplay *display.StreamingDisplay
@@ -48,6 +49,7 @@ func NewManager(client *client.ClineClient) *Manager {
state := types.NewConversationState()
renderer := display.NewRenderer(global.Config.OutputFormat)
toolRenderer := display.NewToolRenderer(renderer.GetMdRenderer(), global.Config.OutputFormat)
hookRenderer := display.NewHookRenderer(renderer.GetMdRenderer(), global.Config.OutputFormat)
systemRenderer := display.NewSystemMessageRenderer(renderer, renderer.GetMdRenderer(), global.Config.OutputFormat)
streamingDisplay := display.NewStreamingDisplay(state, renderer)
@@ -61,6 +63,7 @@ func NewManager(client *client.ClineClient) *Manager {
clientAddress: "", // Will be set when client is provided
state: state,
renderer: renderer,
hookRenderer: hookRenderer,
toolRenderer: toolRenderer,
systemRenderer: systemRenderer,
streamingDisplay: streamingDisplay,
@@ -989,6 +992,15 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeCommandPermissionDenied):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
fmt.Println()
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeBrowserActionLaunch):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
@@ -1043,6 +1055,26 @@ func (m *Manager) processStateUpdate(stateUpdate *cline.State, coordinator *Stre
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeHookStatus):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
fmt.Println()
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeHookOutputStream):
// Hook stdout/stderr streaming arrives as hook_output_stream messages.
// These are intentionally suppressed unless verbose (see SayHandler.handleHookOutputStream),
// but we still need to route them through the normal handler pipeline in streaming/follow
// mode so verbose users actually see `HOOK> ...` lines.
msgKey := fmt.Sprintf("%d", msg.Timestamp)
if !coordinator.IsProcessedInCurrentTurn(msgKey) {
m.displayMessage(msg, false, false, i)
coordinator.MarkProcessedInCurrentTurn(msgKey)
}
case msg.Say == string(types.SayTypeAPIReqStarted):
msgKey := fmt.Sprintf("%d", msg.Timestamp)
apiInfo := types.APIRequestInfo{Cost: -1}
@@ -1174,12 +1206,14 @@ func (m *Manager) displayMessage(msg *types.ClineMessage, isLast, isPartial bool
m.mu.RUnlock()
dc := &handlers.DisplayContext{
State: m.state,
Renderer: m.renderer,
ToolRenderer: m.toolRenderer,
SystemRenderer: m.systemRenderer,
State: m.state,
Renderer: m.renderer,
ToolRenderer: m.toolRenderer,
HookRenderer: m.hookRenderer,
SystemRenderer: m.systemRenderer,
IsLast: isLast,
IsPartial: isPartial,
Verbose: global.Config.Verbose,
MessageIndex: messageIndex,
IsStreamingMode: isStreaming,
IsInteractive: isInteractive,
+6
View File
@@ -296,6 +296,12 @@ func setSimpleField(settings *cline.Settings, key, value string) error {
return err
}
settings.HooksEnabled = boolPtr(val)
case "azure_identity":
val, err := parseBool(value)
if err != nil {
return err
}
settings.AzureIdentity = boolPtr(val)
// Integer fields
case "request_timeout_ms":
+52 -5
View File
@@ -47,11 +47,11 @@ const (
AskTypeResumeTask AskType = "resume_task"
AskTypeResumeCompletedTask AskType = "resume_completed_task"
AskTypeMistakeLimitReached AskType = "mistake_limit_reached"
AskTypeBrowserActionLaunch AskType = "browser_action_launch"
AskTypeUseMcpServer AskType = "use_mcp_server"
AskTypeNewTask AskType = "new_task"
AskTypeCondense AskType = "condense"
AskTypeReportBug AskType = "report_bug"
AskTypeBrowserActionLaunch AskType = "browser_action_launch"
AskTypeUseMcpServer AskType = "use_mcp_server"
AskTypeNewTask AskType = "new_task"
AskTypeCondense AskType = "condense"
AskTypeReportBug AskType = "report_bug"
)
// SayType represents different types of SAY messages
@@ -87,6 +87,11 @@ const (
SayTypeLoadMcpDocumentation SayType = "load_mcp_documentation"
SayTypeInfo SayType = "info"
SayTypeTaskProgress SayType = "task_progress"
// Hook status streaming from the backend.
// These values must match the backend "say" strings emitted by the extension.
SayTypeHookStatus SayType = "hook_status"
SayTypeHookOutputStream SayType = "hook_output_stream"
SayTypeCommandPermissionDenied SayType = "command_permission_denied"
)
// ToolMessage represents a tool-related message
@@ -145,6 +150,42 @@ type APIRequestRetryStatus struct {
ErrorSnippet string `json:"errorSnippet,omitempty"`
}
// HookMessage represents hook execution metadata sent from the backend
type HookMessage struct {
HookName string `json:"hookName"` // Type of hook (TaskStart, PreToolUse, etc.)
ToolName string `json:"toolName,omitempty"` // Optional tool name for tool-specific hooks
Status string `json:"status"` // "running", "completed", "cancelled", or "failed"
ScriptPaths []string `json:"scriptPaths,omitempty"` // Full paths to hook script(s)
PendingToolInfo *ToolInfo `json:"pendingToolInfo,omitempty"` // Metadata about the pending tool execution (PreToolUse)
ExitCode int `json:"exitCode,omitempty"` // Exit code for completed/failed hooks
HasJsonResponse bool `json:"hasJsonResponse,omitempty"` // Whether hook returned JSON
Error *HookError `json:"error,omitempty"` // Error details if hook failed
}
// ToolInfo represents a compact subset of tool parameters for UI display.
// This mirrors the extension's pendingToolInfo shape and is used by the CLI to
// show what tool the PreToolUse hook is gating.
type ToolInfo struct {
Tool string `json:"tool"`
Path string `json:"path,omitempty"`
Command string `json:"command,omitempty"`
Content string `json:"content,omitempty"`
Diff string `json:"diff,omitempty"`
Regex string `json:"regex,omitempty"`
Url string `json:"url,omitempty"`
McpTool string `json:"mcpTool,omitempty"`
McpServer string `json:"mcpServer,omitempty"`
ResourceUri string `json:"resourceUri,omitempty"`
}
// HookError represents structured error information from a failed hook
type HookError struct {
Type string `json:"type"` // Error type: "execution", "timeout", "validation", etc.
Message string `json:"message"` // Human-readable error message
Details string `json:"details,omitempty"` // Additional error details
ScriptPath string `json:"scriptPath,omitempty"` // Path to script that failed
}
// GetTimestamp returns a formatted timestamp string
func (m *ClineMessage) GetTimestamp() string {
return time.Unix(m.Timestamp/1000, 0).Format("15:04:05")
@@ -324,6 +365,12 @@ func convertProtoSayType(sayType cline.ClineSay) string {
return string(SayTypeInfo)
case cline.ClineSay_TASK_PROGRESS:
return string(SayTypeTaskProgress)
case cline.ClineSay_HOOK_STATUS:
return string(SayTypeHookStatus)
case cline.ClineSay_HOOK_OUTPUT_STREAM:
return string(SayTypeHookOutputStream)
case cline.ClineSay_COMMAND_PERMISSION_DENIED:
return string(SayTypeCommandPermissionDenied)
default:
return "unknown"
}
+2
View File
@@ -115,6 +115,7 @@
},
"features/auto-approve",
"features/auto-compact",
"features/background-edit",
"features/checkpoints",
"features/cline-rules",
{
@@ -149,6 +150,7 @@
},
"features/multiroot-workspace",
"features/plan-and-act",
"features/skills",
{
"group": "Slash Commands",
"pages": [
+51
View File
@@ -0,0 +1,51 @@
---
title: "Background Edit"
sidebarTitle: "Background Edit"
---
Background Edit lets Cline make file changes without opening the diff editor, so you can keep writing code while Cline works on other files in the background.
<Note>
This feature is marked as experimental.
</Note>
## How It Works
By default, Cline opens a side-by-side diff editor tab for each file it modifies. With Background Edit enabled:
- Edits write directly to your files without opening new tabs
- Changes appear as collapsible diff blocks in the chat panel
- Your editor focus stays on whatever file you had open
## Enabling Background Edit
1. Click the settings icon (gear) in the top-right corner of the Cline panel
2. Go to "**Feature Settings**"
3. Toggle "**Enable Background Edit**" on
## Viewing Changes
File changes display directly in the chat panel with:
- **File action icons** showing whether the file was added, updated, or deleted
- **Stats** showing additions (+) and deletions (-) at a glance
- **Collapsible diffs** you can expand or collapse by clicking the file header
- **Real-time streaming** as changes appear line-by-line
Green highlights additions, red highlights deletions.
## When to Use It
This feature works well when you:
- Use [auto-approve mode](/features/auto-approve) and prefer reviewing changes after the fact
- Work on tasks with many small file changes
- Want to stay focused on your current file
Stick with the default diff editor if you prefer reviewing each change before it saves, or need to make inline edits to Cline's proposed changes.
## Relationship with Other Features
- **Checkpoints**: Still created after each file operation
- **Auto-approve**: Pairs well for uninterrupted workflows
- **Message editing**: Restoring from a previous message works as expected
+231
View File
@@ -0,0 +1,231 @@
---
title: "Skills"
sidebarTitle: "Skills"
description: "Extend Cline with reusable, on-demand instruction sets for specialized tasks"
---
Skills are modular instruction sets that extend Cline's capabilities for specific tasks. Each skill packages detailed guidance, workflows, and optional resources that Cline loads only when relevant to your request.
Unlike rules (which are always active), skills load on-demand. You can install dozens of skills without affecting context or performance because Cline only sees the skill name and description until it's actually needed.
<Note>
Skills is an experimental feature. Enable it in Settings → Features → Enable Skills.
</Note>
## Why Skills?
Consider how you'd onboard a new team member: you wouldn't dump every document on them at once. You'd give them a brief overview, then point them to detailed guides when they're working on specific tasks.
Skills work the same way:
- **At startup**: Cline sees only a brief description of each skill
- **When triggered**: Cline loads the full instructions for that specific skill
- **As needed**: Skills can bundle additional files that Cline reads only when referenced
This progressive loading means you can package extensive domain knowledge without burning context tokens on information that isn't relevant to the current task.
## Creating a Skill
Every skill is a directory containing a `SKILL.md` file with YAML frontmatter:
```
my-skill/
├── SKILL.md # Required: main instructions
├── docs/ # Optional: additional documentation
│ └── advanced.md
└── scripts/ # Optional: utility scripts
└── helper.sh
```
The `SKILL.md` file has two parts: metadata and instructions.
```yaml
---
name: my-skill
description: Brief description of what this skill does and when to use it.
---
# My Skill
Detailed instructions for Cline to follow when this skill is activated.
## Steps
1. First, do this
2. Then do that
3. For advanced usage, see [advanced.md](docs/advanced.md)
```
**Required fields:**
- `name`: Must exactly match the directory name
- `description`: Tells Cline when to use this skill (max 1024 characters)
The description is critical because it's how Cline decides whether to activate a skill. Be specific about what the skill does and when it should be used.
## Where Skills Live
Skills can be stored in two locations:
**Global Skills** apply to all your projects:
- **macOS/Linux:** `~/.cline/skills/`
- **Windows:** `C:\Users\USERNAME\.cline\skills\`
**Project Skills** apply only to the current workspace:
- `.cline/skills/` (recommended)
- `.clinerules/skills/`
- `.claude/skills/` (for Claude Code compatibility)
When a global skill and project skill have the same name, the global skill takes precedence. This lets you customize skills for your personal workflow while still using project defaults.
## Managing Skills
Click the scale icon below the chat input to open the rules and workflows panel. When skills are enabled, you'll see a Skills tab where you can:
- View all available skills (global and workspace)
- Toggle individual skills on or off
- Create new skills from a template
- Delete skills you no longer need
Skills are enabled by default when discovered. Toggle them off if you want them available but not active for the current project.
## How Cline Uses Skills
When you send a message, Cline sees a list of available skills with their descriptions. If your request matches a skill's description, Cline activates it using the `use_skill` tool, which loads the full instructions.
For example, if you have a skill for deploying to AWS:
```yaml
---
name: aws-deploy
description: Deploy applications to AWS using CDK. Use when deploying, updating infrastructure, or managing AWS resources.
---
```
Asking "deploy this to AWS" would trigger Cline to activate the skill, load its detailed instructions, and follow them to complete your request.
## Example: Data Analysis Skill
Here's a practical skill for data analysis tasks. Create a directory called `data-analysis/` with this `SKILL.md`:
```yaml
---
name: data-analysis
description: Analyze data files and generate insights. Use when working with CSV, Excel, or JSON data files that need exploration, cleaning, or visualization.
---
```
Then add the instructions in the body of the file:
````markdown
# Data Analysis
When analyzing data files, follow this workflow:
## 1. Understand the Data
- Read a sample of the file to understand its structure
- Identify column types and data quality issues
- Note any missing values or anomalies
## 2. Ask Clarifying Questions
Before diving in, ask the user:
- What specific insights are they looking for?
- Are there any known data quality issues?
- What format do they want for the output?
## 3. Perform Analysis
Use pandas for data manipulation:
```python
import pandas as pd
# Load and explore
df = pd.read_csv("data.csv")
print(df.head())
print(df.describe())
print(df.info())
```
For visualization, prefer matplotlib or seaborn depending on complexity.
## 4. Present Findings
- Start with a summary of key insights
- Support findings with specific numbers
- Include visualizations where they add clarity
- End with recommendations or next steps
````
## Bundling Supporting Files
Skills can include additional files that Cline accesses only when needed:
```
complex-skill/
├── SKILL.md
├── docs/
│ ├── setup.md
│ └── troubleshooting.md
├── templates/
│ └── config.yaml
└── scripts/
└── validate.py
```
Reference these in your instructions:
````markdown
For initial setup, follow [setup.md](docs/setup.md).
Use the config template at `templates/config.yaml` as a starting point.
Run the validation script to check your configuration:
```bash
python scripts/validate.py
```
````
Cline reads these files using `read_file` when the instructions reference them. Scripts can be executed directly, with only the output entering the context (not the script code itself).
## Ideas for Skills
Skills shine when you have tasks that:
- Require detailed, multi-step workflows
- Need domain-specific knowledge or best practices
- Would otherwise require repeating the same instructions across conversations
Some possibilities:
- **Release management**: Version bumping, changelog generation, git tagging, and publishing
- **Code review**: Your team's specific review checklist and quality standards
- **Database migrations**: Safely evolving schemas with rollback procedures
- **API integration**: Connecting to specific third-party services with proper error handling
- **Documentation**: Your preferred structure, style guide, and tooling
- **Debugging workflows**: Systematic approaches to diagnosing specific types of issues
- **Infrastructure**: Terraform/CDK patterns for your cloud setup
The best skills encode institutional knowledge that would otherwise live only in experienced developers' heads.
## Skills vs Rules vs Workflows
| Feature | Purpose | When Active |
|---------|---------|-------------|
| **Rules** | Define how Cline should behave | Always (or contextually) |
| **Workflows** | Step-by-step task automation | Invoked with `/workflow.md` |
| **Skills** | Domain expertise loaded on-demand | Triggered by matching requests |
**Rules** set constraints and preferences (like "always use TypeScript" or "follow this style guide").
**Workflows** are explicit sequences you invoke for specific tasks (like `/release.md` for a release process).
**Skills** are expertise that Cline activates automatically when relevant (like data analysis knowledge when you're working with CSV files).
Use rules for ongoing constraints, workflows for explicit automation, and skills for domain knowledge that should be available but not always active.
## Related Features
- [Cline Rules](/features/cline-rules) for always-active project guidance
- [Workflows](/features/slash-commands/workflows/index) for explicit task automation
- [Hooks](/features/hooks/index) for injecting custom logic at key moments
+55
View File
@@ -0,0 +1,55 @@
---
title: "Web Tools"
sidebarTitle: "Web Tools"
description: "Search the web and fetch content from URLs directly within Cline"
---
Web Tools give Cline the ability to search the internet and fetch content from specific URLs during your tasks. This is useful when you need up-to-date information, documentation lookups, or research that goes beyond your local codebase and the LLM's internal knowledge.
<Warning>
Web Tools require the **Cline provider**. They are not available when using other providers like OpenRouter, Anthropic, AWS Bedrock, etc.
</Warning>
## How Web Tools Work
Cline has two web tools:
- **web_search**: Searches the web and returns a list of relevant webpages based on your query
- **web_fetch**: Fetches and analyzes content from a specific URL
When Cline determines that web information would help complete your task, it will use these tools automatically. The tools call Cline's backend API, which handles the search or fetch operation and returns the results.
## Enabling Web Tools
Web Tools are available when using the Cline provider. To use them:
1. Make sure you're signed in to Cline
2. Ensure you're using the Cline provider
3. Enable the Web Tools toggle in the Feature Settings menu
<Note>
Web tools can be auto-approved using the "Use the browser" setting in [Auto Approve](/features/auto-approve).
</Note>
## Use Cases
### Looking Up Documentation
When working with unfamiliar libraries or APIs:
- Search for official documentation
- Fetch specific API reference pages
- Get examples and usage patterns
### Research Before Implementation
Before implementing a feature:
- Search for best practices and common patterns
- Find recent discussions about approaches
- Look up known issues or limitations
### Checking Latest Information
For time-sensitive information:
- Latest release notes and changelogs
- Recent bug fixes or security updates
- Current recommended versions
+3 -2
View File
@@ -18,7 +18,8 @@ Cerebras delivers the world's fastest AI inference through their revolutionary w
Cline supports the following Cerebras models:
- `zai-glm-4.6` - Intelligent general purpose model with 1,500 tokens/s
- `zai-glm-4.6` - Fast general-purpose model on Cerebras (up to 1,000 tokens/s). To be deprecated soon.
- `zai-glm-4.7` - Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.
- `qwen-3-235b-a22b-instruct-2507` - Advanced instruction-following model
- `qwen-3-235b-a22b-thinking-2507` - Reasoning model with step-by-step thinking
- `llama-3.3-70b` - Meta's Llama 3.3 model optimized for speed
@@ -89,7 +90,7 @@ Works with any OpenAI-compatible tool—Cursor, Continue.dev, Cline, or any othe
- **Speed Advantage:** Cerebras excels at making reasoning models practical for real-time use. Perfect for agentic workflows that require multiple LLM calls.
- **Free Tier:** Start with the free model to experience Cerebras speed before upgrading to paid plans.
- **Context Windows:** Models support context windows ranging from 64K to 128K tokens for including substantial code context.
- **Context Windows:** Models support context windows ranging from 64K to 131K tokens for including substantial code context.
- **Rate Limits:** Generous rate limits designed for development workflows. Check your dashboard for current limits.
- **Pricing:** Competitive pricing with significant speed advantages. Visit [Cerebras Cloud](https://cloud.cerebras.ai/) for current rates.
- **Real-Time Applications:** Ideal for applications where AI response time matters—code generation, debugging, and interactive development.
+2 -1
View File
@@ -16,7 +16,7 @@ This document focuses on setting up providers _other than_ the official OpenAI A
The key to using an OpenAI-compatible provider with Cline is to configure these main settings:
1. **Base URL:** This is the API endpoint specific to the provider. It will _not_ be `https://api.openai.com/v1` (that URL is for the official OpenAI API).
2. **API Key:** This is the secret key you obtain from your chosen provider.
2. **API Key:** This is the secret key you obtain from your chosen provider. (or **Use Azure Identity Authentication**)
3. **Model ID:** This is the specific name or identifier for the model you wish to use.
You'll find these settings in the Cline settings panel (click the ⚙️ icon):
@@ -25,6 +25,7 @@ You'll find these settings in the Cline settings panel (click the ⚙️ icon):
- **Base URL:** Enter the base URL provided by your chosen provider. **This is a crucial step.**
- **API Key:** Enter your API key from the provider.
- **Model:** Choose or enter the model ID.
- **Use Azure Identity Authentication:** Check the box to authenticate with your Azure managed identity (Note that this will not trigger the authentication, it will use the existing one (e.g., "az login"))
- **Model Configuration:** This section allows you to customize advanced parameters for the model, such as:
- Max Output Tokens
- Context Window size
+147 -146
View File
@@ -1,18 +1,19 @@
{
"name": "claude-dev",
"version": "3.46.1",
"version": "3.47.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.46.1",
"version": "3.47.0",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
"@aws-sdk/credential-providers": "^3.922.0",
"@azure/identity": "^4.13.0",
"@bufbuild/protobuf": "^2.2.5",
"@cerebras/cerebras_cloud_sdk": "^1.35.0",
"@google-cloud/vertexai": "^1.9.3",
@@ -43,6 +44,7 @@
"@playwright/test": "^1.55.1",
"@sap-ai-sdk/ai-api": "^2.1.0",
"@sap-ai-sdk/orchestration": "^2.1.0",
"@sap-cloud-sdk/connectivity": "^4.2.0",
"@streamparser/json": "^0.0.22",
"@tailwindcss/vite": "^4.1.14",
"@types/uuid": "^10.0.0",
@@ -87,6 +89,7 @@
"puppeteer-core": "^23.4.0",
"reconnecting-eventsource": "^1.6.4",
"serialize-error": "^11.0.3",
"shell-quote": "^1.8.3",
"simple-git": "^3.27.0",
"strip-ansi": "^7.1.2",
"tailwindcss": "^4.1.14",
@@ -109,10 +112,12 @@
"@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",
"@types/node": "20.x",
"@types/pdf-parse": "^1.1.4",
"@types/proxyquire": "^1.3.31",
"@types/shell-quote": "^1.7.5",
"@types/should": "^11.2.0",
"@types/sinon": "^17.0.4",
"@types/turndown": "^5.0.5",
@@ -1005,7 +1010,6 @@
},
"node_modules/@azure/abort-controller": {
"version": "2.1.2",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
@@ -1016,7 +1020,6 @@
},
"node_modules/@azure/core-auth": {
"version": "1.9.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
@@ -1029,7 +1032,6 @@
},
"node_modules/@azure/core-client": {
"version": "1.9.4",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
@@ -1046,7 +1048,6 @@
},
"node_modules/@azure/core-rest-pipeline": {
"version": "1.21.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
@@ -1063,7 +1064,6 @@
},
"node_modules/@azure/core-tracing": {
"version": "1.2.0",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.6.2"
@@ -1074,7 +1074,6 @@
},
"node_modules/@azure/core-util": {
"version": "1.12.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
@@ -1086,8 +1085,9 @@
}
},
"node_modules/@azure/identity": {
"version": "4.10.2",
"dev": true,
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/@azure/identity/-/identity-4.13.0.tgz",
"integrity": "sha512-uWC0fssc+hs1TGGVkkghiaFkkS7NkTxfnCH+Hdg+yTehTpMcehpok4PgUKKdyCH+9ldu6FhiHRv84Ntqj1vVcw==",
"license": "MIT",
"dependencies": {
"@azure/abort-controller": "^2.0.0",
@@ -1108,7 +1108,6 @@
},
"node_modules/@azure/logger": {
"version": "1.2.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@typespec/ts-http-runtime": "^0.2.2",
@@ -1120,7 +1119,6 @@
},
"node_modules/@azure/msal-browser": {
"version": "4.14.0",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure/msal-common": "15.8.0"
@@ -1131,7 +1129,6 @@
},
"node_modules/@azure/msal-common": {
"version": "15.8.0",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.8.0"
@@ -1139,7 +1136,6 @@
},
"node_modules/@azure/msal-node": {
"version": "3.6.2",
"dev": true,
"license": "MIT",
"dependencies": {
"@azure/msal-common": "15.8.0",
@@ -1152,7 +1148,6 @@
},
"node_modules/@azure/msal-node/node_modules/uuid": {
"version": "8.3.2",
"dev": true,
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
@@ -1187,6 +1182,7 @@
"integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.3",
@@ -2065,12 +2061,12 @@
}
},
"node_modules/@dabh/diagnostics": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz",
"integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz",
"integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==",
"license": "MIT",
"dependencies": {
"colorspace": "1.1.x",
"@so-ric/colorspace": "^1.1.6",
"enabled": "2.0.x",
"kuler": "^2.0.0"
}
@@ -2649,6 +2645,7 @@
"node_modules/@grpc/grpc-js": {
"version": "1.9.15",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"@grpc/proto-loader": "^0.7.8",
"@types/node": ">=12.12.47"
@@ -3232,6 +3229,7 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.7",
"ajv": "^8.17.1",
@@ -3300,6 +3298,7 @@
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": ">=8.0.0"
}
@@ -4915,8 +4914,7 @@
"optional": true,
"os": [
"android"
],
"peer": true
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.52.4",
@@ -4929,8 +4927,7 @@
"optional": true,
"os": [
"android"
],
"peer": true
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.52.4",
@@ -4943,8 +4940,7 @@
"optional": true,
"os": [
"darwin"
],
"peer": true
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.52.4",
@@ -4957,8 +4953,7 @@
"optional": true,
"os": [
"darwin"
],
"peer": true
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.52.4",
@@ -4971,8 +4966,7 @@
"optional": true,
"os": [
"freebsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.52.4",
@@ -4985,8 +4979,7 @@
"optional": true,
"os": [
"freebsd"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.52.4",
@@ -4999,8 +4992,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.52.4",
@@ -5013,8 +5005,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.52.4",
@@ -5027,8 +5018,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.52.4",
@@ -5041,8 +5031,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.52.4",
@@ -5055,8 +5044,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.52.4",
@@ -5069,8 +5057,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.52.4",
@@ -5083,8 +5070,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.52.4",
@@ -5097,8 +5083,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.52.4",
@@ -5111,8 +5096,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.52.4",
@@ -5125,8 +5109,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.52.4",
@@ -5139,8 +5122,7 @@
"optional": true,
"os": [
"linux"
],
"peer": true
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.52.4",
@@ -5153,8 +5135,7 @@
"optional": true,
"os": [
"openharmony"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.52.4",
@@ -5167,8 +5148,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.52.4",
@@ -5181,8 +5161,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.52.4",
@@ -5195,8 +5174,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.52.4",
@@ -5209,8 +5187,7 @@
"optional": true,
"os": [
"win32"
],
"peer": true
]
},
"node_modules/@sap-ai-sdk/ai-api": {
"version": "2.1.0",
@@ -5259,17 +5236,17 @@
}
},
"node_modules/@sap-cloud-sdk/connectivity": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@sap-cloud-sdk/connectivity/-/connectivity-4.1.2.tgz",
"integrity": "sha512-PJNBg+yhyo4Y/PZBcqbN8eD+RFVolgOcWgqVTdHef6Wch8t6SVlDqxLzf4mJ4nTpAY2E3ERFjIwlP+MUUXcf5A==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@sap-cloud-sdk/connectivity/-/connectivity-4.2.0.tgz",
"integrity": "sha512-dvQpjYWp9EhoUip4ftvnsVOG6BHJO+fOOuVs0R7V1IRyl7F49QPdpI/YoMzk3W7Rex9NozRa/0EXAe9L0CrFbA==",
"license": "Apache-2.0",
"dependencies": {
"@sap-cloud-sdk/resilience": "^4.1.2",
"@sap-cloud-sdk/util": "^4.1.2",
"@sap-cloud-sdk/resilience": "^4.2.0",
"@sap-cloud-sdk/util": "^4.2.0",
"@sap/xsenv": "^6.0.0",
"@sap/xssec": "^4.9.2",
"@sap/xssec": "^4.11.2",
"async-retry": "^1.3.3",
"axios": "^1.12.2",
"axios": "^1.13.2",
"jsonwebtoken": "^9.0.2"
}
},
@@ -5299,28 +5276,28 @@
}
},
"node_modules/@sap-cloud-sdk/resilience": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@sap-cloud-sdk/resilience/-/resilience-4.1.2.tgz",
"integrity": "sha512-rMcM6Sn0WswNQK9583UCBEwzqvPSETae8GzzFrTQ7+JM6RA6PU5/WiPmhqzQQw1MFQvwuX6uMDwPf4U3AdemMQ==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@sap-cloud-sdk/resilience/-/resilience-4.2.0.tgz",
"integrity": "sha512-ZR2UugUEKEawIu6SIfz9OM8nQO43s/37jnmU4L+vTQN+ptFdFg+2bJAjj+F3jyqSaBx6JbALs9r6X2eWRc+ZDQ==",
"license": "Apache-2.0",
"dependencies": {
"@sap-cloud-sdk/util": "^4.1.2",
"@sap-cloud-sdk/util": "^4.2.0",
"async-retry": "^1.3.3",
"axios": "^1.12.2",
"axios": "^1.13.2",
"opossum": "^9.0.0"
}
},
"node_modules/@sap-cloud-sdk/util": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/@sap-cloud-sdk/util/-/util-4.1.2.tgz",
"integrity": "sha512-lsxsBc60pokMDCHvs0/CXTa6fjVEMzTeSyB8CerG6L0sN4sK8Ppm63VVcSh/E+X+U4aVMHmLuOmmd6XyLGAc3Q==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@sap-cloud-sdk/util/-/util-4.2.0.tgz",
"integrity": "sha512-sAyb1hWATZKKu+NZWX65oIdSI4uKj//0UOl4vtzMPNgYXhF806aAWbj2tLZAHI4UTtQWedbfcHhwO7BLl9MvQQ==",
"license": "Apache-2.0",
"dependencies": {
"axios": "^1.12.2",
"axios": "^1.13.2",
"chalk": "^4.1.0",
"logform": "^2.7.0",
"voca": "^1.4.1",
"winston": "^3.17.0",
"winston": "^3.18.3",
"winston-transport": "^4.9.0"
}
},
@@ -5367,9 +5344,9 @@
}
},
"node_modules/@sap/xssec": {
"version": "4.10.0",
"resolved": "https://registry.npmjs.org/@sap/xssec/-/xssec-4.10.0.tgz",
"integrity": "sha512-6SxDorJpQRNjI0sCTwHoFkyECI5IrxnRZ3adoTY0XeGM8QcMoYmLnJIPXoa6AYSzMB89rjJ02j1PQVWACYt/Hg==",
"version": "4.12.1",
"resolved": "https://registry.npmjs.org/@sap/xssec/-/xssec-4.12.1.tgz",
"integrity": "sha512-LcCnFuoNKosXJ9H71Yd9vRNknB+LHsghro1eHgQ8jUmacSt/C2USL7BsJ4TDb4DxeeZB4wieUEy5HBPZM9T+Jw==",
"license": "SAP DEVELOPER LICENSE AGREEMENT",
"dependencies": {
"debug": "^4.4.3",
@@ -6247,6 +6224,16 @@
"node": ">=18.0.0"
}
},
"node_modules/@so-ric/colorspace": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz",
"integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==",
"license": "MIT",
"dependencies": {
"color": "^5.0.2",
"text-hex": "1.0.x"
}
},
"node_modules/@streamparser/json": {
"version": "0.0.22",
"license": "MIT"
@@ -6763,8 +6750,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/get-folder-size": {
"version": "3.0.4",
@@ -6779,6 +6765,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/mocha": {
"version": "10.0.7",
"dev": true,
@@ -6789,6 +6782,7 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.21.tgz",
"integrity": "sha512-CsGG2P3I5y48RPMfprQGfy4JPRZ6csfC3ltBZSRItG3ngggmNY/qs2uZKp4p9VbrpqNNSMzUZNFZKzgOGnd/VA==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -6827,6 +6821,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/shell-quote": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@types/shell-quote/-/shell-quote-1.7.5.tgz",
"integrity": "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/shimmer": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@types/shimmer/-/shimmer-1.2.0.tgz",
@@ -6881,7 +6882,6 @@
},
"node_modules/@typespec/ts-http-runtime": {
"version": "0.2.3",
"dev": true,
"license": "MIT",
"dependencies": {
"http-proxy-agent": "^7.0.0",
@@ -7486,6 +7486,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -7943,9 +7944,9 @@
}
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -8252,6 +8253,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741",
@@ -8815,13 +8817,16 @@
"license": "MIT"
},
"node_modules/color": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz",
"integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==",
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz",
"integrity": "sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==",
"license": "MIT",
"dependencies": {
"color-convert": "^1.9.3",
"color-string": "^1.6.0"
"color-convert": "^3.1.3",
"color-string": "^2.1.3"
},
"engines": {
"node": ">=18"
}
},
"node_modules/color-convert": {
@@ -8839,13 +8844,24 @@
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz",
"integrity": "sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
"color-name": "^2.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/color-string/node_modules/color-name": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
"license": "MIT",
"engines": {
"node": ">=12.20"
}
},
"node_modules/color-support": {
@@ -8856,35 +8872,31 @@
}
},
"node_modules/color/node_modules/color-convert": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.3.tgz",
"integrity": "sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==",
"license": "MIT",
"dependencies": {
"color-name": "1.1.3"
"color-name": "^2.0.0"
},
"engines": {
"node": ">=14.6"
}
},
"node_modules/color/node_modules/color-name": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz",
"integrity": "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==",
"license": "MIT",
"engines": {
"node": ">=12.20"
}
},
"node_modules/colorette": {
"version": "2.0.20",
"dev": true,
"license": "MIT"
},
"node_modules/colorspace": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz",
"integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==",
"license": "MIT",
"dependencies": {
"color": "^3.1.3",
"text-hex": "1.0.x"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"license": "MIT",
@@ -9455,7 +9467,8 @@
},
"node_modules/devtools-protocol": {
"version": "0.0.1342118",
"license": "BSD-3-Clause"
"license": "BSD-3-Clause",
"peer": true
},
"node_modules/diff": {
"version": "5.2.0",
@@ -12431,6 +12444,7 @@
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"license": "MIT",
"peer": true,
"bin": {
"jiti": "lib/jiti-cli.mjs"
}
@@ -12592,6 +12606,8 @@
},
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"license": "MIT",
"engines": {
"node": ">=18"
@@ -12662,6 +12678,7 @@
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz",
"integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==",
"license": "MPL-2.0",
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
@@ -15542,7 +15559,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@@ -15563,7 +15579,6 @@
}
],
"license": "MIT",
"peer": true,
"bin": {
"nanoid": "bin/nanoid.cjs"
},
@@ -16231,7 +16246,6 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.4.tgz",
"integrity": "sha512-CLEVl+MnPAiKh5pl4dEWSyMTpuflgNQiLGhMv8ezD5W/qP8AKvmYpCOKRRNOh7oRKnauBZ4SyeYkMS+1VSyKwQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/estree": "1.0.8"
},
@@ -16668,9 +16682,13 @@
}
},
"node_modules/shell-quote": {
"version": "1.8.1",
"dev": true,
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz",
"integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -16857,21 +16875,6 @@
"url": "https://github.com/steveukx/git-js?sponsor=1"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/sinon": {
"version": "19.0.2",
"dev": true,
@@ -17668,7 +17671,6 @@
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
@@ -17685,7 +17687,6 @@
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.0.0"
},
@@ -18049,6 +18050,7 @@
"version": "5.5.3",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -18308,7 +18310,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -18383,7 +18384,6 @@
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12.0.0"
},
@@ -18699,13 +18699,13 @@
}
},
"node_modules/winston": {
"version": "3.17.0",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz",
"integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==",
"version": "3.19.0",
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
"license": "MIT",
"dependencies": {
"@colors/colors": "^1.6.0",
"@dabh/diagnostics": "^2.0.2",
"@dabh/diagnostics": "^2.0.8",
"async": "^3.2.3",
"is-stream": "^2.0.0",
"logform": "^2.7.0",
@@ -19079,6 +19079,7 @@
"node_modules/zod": {
"version": "3.25.76",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+8 -4
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.46.1",
"version": "3.47.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -346,11 +346,10 @@
"compile-cli": "scripts/build-cli.sh",
"compile-cli-all-platforms": "scripts/build-cli-all-platforms.sh",
"compile-cli-man-page": "pandoc cli/man/cline.1.md -s -t man -o cli/man/cline.1",
"build:npm": "scripts/build-npm-package.sh",
"test:install": "bash scripts/test-install.sh",
"dev:cli:watch": "node scripts/dev-cli-watch.mjs",
"postcompile-standalone": "node scripts/package-standalone.mjs",
"postcompile-standalone-npm": "node scripts/package-standalone.mjs --target=npm",
"postcompile-standalone-npm": "node scripts/package-npm.mjs",
"dev": "npm run protos && npm run watch",
"watch": "npm-run-all -p watch:*",
"watch:esbuild": "node esbuild.mjs --watch",
@@ -367,7 +366,7 @@
"clean:all": "npm run clean:build && npm run clean:deps",
"compile-tests": "node ./scripts/build-tests.js",
"watch-tests": "tsc -p . -w --outDir out",
"check-types": "npm run protos && npx tsc --noEmit && cd webview-ui && npx tsc -b --noEmit",
"check-types": "npm run protos && npx tsc --noEmit && cd webview-ui && npx tsc --noEmit",
"lint": "biome lint --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error && npm run lint:proto",
"lint:proto": "bash ./scripts/proto-lint.sh",
"format": "biome format --changed --no-errors-on-unmatched --files-ignore-unknown=true --diagnostic-level=error",
@@ -417,10 +416,12 @@
"@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",
"@types/node": "20.x",
"@types/pdf-parse": "^1.1.4",
"@types/proxyquire": "^1.3.31",
"@types/shell-quote": "^1.7.5",
"@types/should": "^11.2.0",
"@types/sinon": "^17.0.4",
"@types/turndown": "^5.0.5",
@@ -456,6 +457,7 @@
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
"@aws-sdk/credential-providers": "^3.922.0",
"@azure/identity": "^4.13.0",
"@bufbuild/protobuf": "^2.2.5",
"@cerebras/cerebras_cloud_sdk": "^1.35.0",
"@google-cloud/vertexai": "^1.9.3",
@@ -486,6 +488,7 @@
"@playwright/test": "^1.55.1",
"@sap-ai-sdk/ai-api": "^2.1.0",
"@sap-ai-sdk/orchestration": "^2.1.0",
"@sap-cloud-sdk/connectivity": "^4.2.0",
"@streamparser/json": "^0.0.22",
"@tailwindcss/vite": "^4.1.14",
"@types/uuid": "^10.0.0",
@@ -530,6 +533,7 @@
"puppeteer-core": "^23.4.0",
"reconnecting-eventsource": "^1.6.4",
"serialize-error": "^11.0.3",
"shell-quote": "^1.8.3",
"simple-git": "^3.27.0",
"strip-ansi": "^7.1.2",
"tailwindcss": "^4.1.14",
+54
View File
@@ -81,6 +81,18 @@ service FileService {
// Deletes an existing hook file
rpc deleteHook(DeleteHookRequest) returns (DeleteHookResponse);
// Refreshes all skill toggles (discovers skills and their enabled state)
rpc refreshSkills(EmptyRequest) returns (RefreshedSkills);
// Toggles a skill on or off
rpc toggleSkill(ToggleSkillRequest) returns (SkillsToggles);
// Creates a new skill from template
rpc createSkillFile(CreateSkillRequest) returns (SkillsToggles);
// Deletes an existing skill directory
rpc deleteSkillFile(DeleteSkillRequest) returns (SkillsToggles);
}
// Response for refreshRules operation
@@ -278,3 +290,45 @@ message DeleteHookRequest {
message DeleteHookResponse {
HooksToggles hooks_toggles = 1;
}
// Skill information structure
message SkillInfo {
string name = 1; // Name of the skill (matches directory name)
string description = 2; // Description from SKILL.md frontmatter
string path = 3; // Full path to SKILL.md file
bool enabled = 4; // Whether the skill is enabled
}
// Response for refreshSkills operation
message RefreshedSkills {
repeated SkillInfo global_skills = 1;
repeated SkillInfo local_skills = 2;
}
// Maps from skill path to enabled/disabled status
message SkillsToggles {
map<string, bool> global_skills_toggles = 1;
map<string, bool> local_skills_toggles = 2;
}
// Request to toggle a skill
message ToggleSkillRequest {
Metadata metadata = 1;
string skill_path = 2; // Path to the skill directory
bool is_global = 3; // Whether this is a global or workspace skill
bool enabled = 4; // Whether to enable or disable the skill
}
// Request to create a skill
message CreateSkillRequest {
Metadata metadata = 1;
string skill_name = 2; // Name of the skill to create
bool is_global = 3; // Whether to create in global or workspace skills directory
}
// Request to delete a skill
message DeleteSkillRequest {
Metadata metadata = 1;
string skill_path = 2; // Path to the skill directory
bool is_global = 3; // Whether this is a global or workspace skill
}
+22 -12
View File
@@ -49,6 +49,8 @@ service ModelsService {
rpc refreshOcaModels(StringRequest) returns (OcaCompatibleModelInfo);
// Fetches available models from AIhubmix
rpc getAihubmixModels(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
// Refreshes and returns Vercel AI Gateway models
rpc refreshVercelAiGatewayModelsRpc(EmptyRequest) returns (OpenRouterCompatibleModelInfo);
}
// List of VS Code LM models
@@ -229,6 +231,7 @@ message ModelsApiOptions {
optional string minimax_api_line = 41;
optional string aihubmix_base_url = 42;
optional string aihubmix_app_code = 43;
optional bool azure_identity = 44;
// Plan mode configurations
optional ApiProvider plan_mode_api_provider = 100;
@@ -380,6 +383,10 @@ message OcaModelInfo {
string model_name = 17;
// The API format used by this model
optional ApiFormat api_format = 18;
// Supports reasoning
optional bool supports_reasoning = 19;
// reasoning effort options
repeated string reasoning_effort_options = 20;
}
// Aggregated OCA model catalog keyed by model identifier
@@ -567,6 +574,7 @@ message ModelsApiConfiguration {
optional string aihubmix_base_url = 83;
optional string aihubmix_app_code = 84;
optional string nous_research_api_key = 85;
optional bool azure_identity = 86;
// Plan mode configurations
optional ApiProvider plan_mode_api_provider = 100;
@@ -602,12 +610,13 @@ message ModelsApiConfiguration {
optional OpenRouterModelInfo plan_mode_vercel_ai_gateway_model_info = 130;
optional string plan_mode_oca_model_id = 131;
optional OcaModelInfo plan_mode_oca_model_info = 132;
optional string plan_mode_hicap_model_id = 133;
optional OpenRouterModelInfo plan_mode_hicap_model_info = 134;
optional string plan_mode_aihubmix_model_id = 135;
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 136;
optional string plan_mode_nous_research_model_id = 137;
optional string gemini_plan_mode_thinking_level = 138;
optional string plan_mode_oca_reasoning_effort = 133;
optional string plan_mode_hicap_model_id = 134;
optional OpenRouterModelInfo plan_mode_hicap_model_info = 135;
optional string plan_mode_aihubmix_model_id = 136;
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 137;
optional string plan_mode_nous_research_model_id = 138;
optional string gemini_plan_mode_thinking_level = 139;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
@@ -643,10 +652,11 @@ message ModelsApiConfiguration {
optional OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 230;
optional string act_mode_oca_model_id = 231;
optional OcaModelInfo act_mode_oca_model_info = 232;
optional string act_mode_hicap_model_id = 233;
optional OpenRouterModelInfo act_mode_hicap_model_info = 234;
optional string act_mode_aihubmix_model_id = 235;
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 236;
optional string act_mode_nous_research_model_id = 237;
optional string gemini_act_mode_thinking_level = 238;
optional string act_mode_oca_reasoning_effort = 233;
optional string act_mode_hicap_model_id = 234;
optional OpenRouterModelInfo act_mode_hicap_model_info = 235;
optional string act_mode_aihubmix_model_id = 236;
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 237;
optional string act_mode_nous_research_model_id = 238;
optional string gemini_act_mode_thinking_level = 239;
}
+4
View File
@@ -229,6 +229,8 @@ message Settings {
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 133;
optional bool cline_web_tools_enabled = 134;
optional bool hooks_enabled = 135;
optional bool azure_identity = 136;
optional bool skills_enabled = 137;
}
message DictationSettings {
@@ -370,6 +372,8 @@ message UpdateSettingsRequest {
optional bool cline_web_tools_enabled = 34;
optional bool enable_parallel_tool_calling = 35;
optional bool background_edit_enabled = 36;
optional string oca_reasoning_effort = 37;
optional bool skills_enabled = 38;
}
message UpdateTerminalConnectionTimeoutRequest {
+9 -5
View File
@@ -67,6 +67,9 @@ enum ClineSay {
TASK_PROGRESS = 27;
ERROR_RETRY = 28;
GENERATE_EXPLANATION = 29;
HOOK_STATUS = 30;
HOOK_OUTPUT_STREAM = 31;
COMMAND_PERMISSION_DENIED = 32;
}
// Enum for ClineSayTool tool types
@@ -220,6 +223,10 @@ message ClineMessage {
ClineModelInfo model_info = 23;
}
message ShowWebviewEvent {
bool preserve_editor_focus = 1; // When true, webview should not steal focus from editor
}
// UiService provides methods for managing UI interactions
service UiService {
// Scrolls to a specific settings section in the settings view
@@ -258,11 +265,8 @@ service UiService {
// Subscribe to relinquish control events
rpc subscribeToRelinquishControl(EmptyRequest) returns (stream Empty);
// Subscribe to focus chat input events
rpc subscribeToFocusChatInput(EmptyRequest) returns (stream Empty);
// Subscribe to webview visibility change events
rpc subscribeToDidBecomeVisible(EmptyRequest) returns (stream Empty);
// Subscribe to show webview events
rpc subscribeToShowWebview(EmptyRequest) returns (stream ShowWebviewEvent);
// Returns the HTML for the webview index page. This is only used by external clients, not by the vscode webview.
rpc getWebviewHtml(EmptyRequest) returns (String);
-154
View File
@@ -1,154 +0,0 @@
#!/usr/bin/env bash
# Script to build the Cline NPM package with telemetry keys injected
# This script ensures all environment variables are properly set and builds are successful
set -e # Exit on error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Required environment variables
REQUIRED_VARS=(
"TELEMETRY_SERVICE_API_KEY"
"ERROR_SERVICE_API_KEY"
)
# Optional but recommended environment variables
OPTIONAL_VARS=(
"CLINE_ENVIRONMENT"
"POSTHOG_TELEMETRY_ENABLED"
)
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE}Cline NPM Package Build Script${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
# Step 1: Verify required environment variables are set
echo -e "${BLUE}Step 1: Verifying environment variables...${NC}"
MISSING_VARS=()
for VAR in "${REQUIRED_VARS[@]}"; do
if [ -z "${!VAR}" ]; then
MISSING_VARS+=("$VAR")
echo -e "${RED}$VAR is not set${NC}"
else
# Show first 10 chars for verification (don't expose full key)
VAR_VALUE="${!VAR}"
echo -e "${GREEN}$VAR is set (${VAR_VALUE:0:10}...)${NC}"
fi
done
# Check optional variables
for VAR in "${OPTIONAL_VARS[@]}"; do
if [ -z "${!VAR}" ]; then
echo -e "${YELLOW}$VAR is not set (optional)${NC}"
else
echo -e "${GREEN}$VAR is set: ${!VAR}${NC}"
fi
done
if [ ${#MISSING_VARS[@]} -gt 0 ]; then
echo -e "\n${RED}Error: Missing required environment variables:${NC}"
printf '%s\n' "${MISSING_VARS[@]}"
echo -e "\n${YELLOW}Please set these variables before running the build:${NC}"
echo -e "export TELEMETRY_SERVICE_API_KEY=\"your_posthog_api_key\""
echo -e "export ERROR_SERVICE_API_KEY=\"your_error_tracking_api_key\""
exit 1
fi
# Step 2: Verify Node.js can see the environment variables
echo -e "\n${BLUE}Step 2: Verifying Node.js can access environment variables...${NC}"
if node -e "
const telemetryKey = process.env.TELEMETRY_SERVICE_API_KEY;
const errorKey = process.env.ERROR_SERVICE_API_KEY;
if (!telemetryKey || !errorKey) {
console.error('Node.js cannot see environment variables!');
process.exit(1);
}
console.log('✓ TELEMETRY_SERVICE_API_KEY visible to Node.js');
console.log('✓ ERROR_SERVICE_API_KEY visible to Node.js');
"; then
echo -e "${GREEN}✓ Node.js can access environment variables${NC}"
else
echo -e "${RED}✗ Node.js cannot access environment variables${NC}"
echo -e "${YELLOW}Make sure to use 'export' when setting variables:${NC}"
echo -e "export TELEMETRY_SERVICE_API_KEY=\"...\""
exit 1
fi
# Step 3: Clean previous builds
echo -e "\n${BLUE}Step 3: Cleaning previous builds...${NC}"
rm -rf dist-standalone
echo -e "${GREEN}✓ Cleaned dist-standalone directory${NC}"
# Step 4: Build Go CLI binaries for all platforms
echo -e "\n${BLUE}Step 4: Building Go CLI binaries for all platforms...${NC}"
if npm run compile-cli-all-platforms; then
echo -e "${GREEN}✓ Go CLI binaries built successfully${NC}"
# Verify binaries were created
if ls cli/bin/cline-* 1> /dev/null 2>&1; then
echo -e "${GREEN}✓ CLI binaries verified:${NC}"
ls -lh cli/bin/cline-* | awk '{print " " $9 " (" $5 ")"}'
else
echo -e "${RED}✗ No CLI binaries found in cli/bin/${NC}"
exit 1
fi
else
echo -e "${RED}✗ Failed to build Go CLI binaries${NC}"
exit 1
fi
# Step 5: Build the standalone package with esbuild
echo -e "\n${BLUE}Step 5: Building standalone package with esbuild...${NC}"
if npm run compile-standalone-npm; then
echo -e "${GREEN}✓ Standalone package built successfully${NC}"
else
echo -e "${RED}✗ Failed to build standalone package${NC}"
exit 1
fi
# Step 6: Verify telemetry keys were injected
echo -e "\n${BLUE}Step 6: Verifying telemetry keys were injected...${NC}"
# Check if the compiled file still has process.env references (bad)
if grep -q "process.env.TELEMETRY_SERVICE_API_KEY" dist-standalone/cline-core.js; then
echo -e "${RED}✗ Keys were NOT injected! Found 'process.env.TELEMETRY_SERVICE_API_KEY' in compiled code${NC}"
echo -e "${YELLOW}This means the environment variables were not replaced during build${NC}"
exit 1
fi
# Check if actual keys are present (good)
if grep -q "data.cline.bot" dist-standalone/cline-core.js; then
# Extract a snippet of the PostHog config
POSTHOG_CONFIG=$(grep -A 3 "data.cline.bot" dist-standalone/cline-core.js | head -5)
if echo "$POSTHOG_CONFIG" | grep -q "apiKey.*phc_"; then
echo -e "${GREEN}✓ Telemetry keys successfully injected into compiled code${NC}"
else
echo -e "${YELLOW}⚠ PostHog config found but apiKey format unclear${NC}"
echo -e "${YELLOW}Config snippet:${NC}"
echo "$POSTHOG_CONFIG"
fi
else
echo -e "${YELLOW}⚠ Could not verify PostHog config in compiled code${NC}"
fi
# Step 7: Display build summary
echo -e "\n${BLUE}========================================${NC}"
echo -e "${GREEN}Build completed successfully!${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
echo -e "${GREEN}Package location:${NC} dist-standalone/"
echo -e "${GREEN}Package version:${NC} $(node -p "require('./dist-standalone/package.json').version" 2>/dev/null || echo "unknown")"
echo ""
echo -e "${BLUE}Next steps:${NC}"
echo -e "1. Test locally: ${YELLOW}cd dist-standalone && npm link${NC}"
echo -e "2. Verify: ${YELLOW}cline version${NC}"
echo -e "3. Publish: ${YELLOW}cd dist-standalone && npm publish${NC}"
echo ""
echo -e "${YELLOW}Note: Check PostHog dashboard after running cline commands to verify telemetry${NC}"
+7
View File
@@ -43,6 +43,13 @@ const PLATFORMS = [
binaryPath: "rg",
isZip: false,
},
{
name: "linux-arm64",
archiveName: `ripgrep-${RIPGREP_VERSION}-aarch64-unknown-linux-gnu.tar.gz`,
url: `https://github.com/BurntSushi/ripgrep/releases/download/${RIPGREP_VERSION}/ripgrep-${RIPGREP_VERSION}-aarch64-unknown-linux-gnu.tar.gz`,
binaryPath: "rg",
isZip: false,
},
{
name: "win-x64",
archiveName: `ripgrep-${RIPGREP_VERSION}-x86_64-pc-windows-msvc.zip`,
+10 -1
View File
@@ -49,9 +49,18 @@ fi
# Create installation directory
mkdir -p "$INSTALL_DIR/bin"
# Copy standalone package first (includes node_modules, cline-core.js, etc.)
# Copy standalone package first (cline-core.js, wasm files, etc.)
rsync -a --exclude='bin' "$PROJECT_ROOT/dist-standalone/" "$INSTALL_DIR/"
# Install runtime dependencies (grpc-health-check, better-sqlite3, etc.)
# These are external dependencies not bundled into cline-core.js
echo -e "${CYAN}${NC} ${DIM}Installing runtime dependencies...${NC}"
cd "$PROJECT_ROOT/standalone/runtime-files"
npm install --silent 2>/dev/null || npm install
cp -r node_modules "$INSTALL_DIR/"
cp -r vscode "$INSTALL_DIR/node_modules/"
cd "$PROJECT_ROOT"
# Detect platform for native modules
os=$(uname -s | tr '[:upper:]' '[:lower:]')
arch=$(uname -m)
+486
View File
@@ -0,0 +1,486 @@
#!/usr/bin/env node
/**
* NPM Package Builder for Cline CLI
*
* This script builds the Cline CLI NPM package (dist-standalone/).
* It is completely independent from package-standalone.mjs (JetBrains build).
*
* Usage: node scripts/package-npm.mjs
*
* Prerequisites:
* - npm run protos && npm run protos-go
* - npm run compile-cli
* - npm run compile-cli-all-platforms
* - npm run download-ripgrep
*/
import { execSync } from "child_process"
import fs from "fs"
import { cp } from "fs/promises"
import path from "path"
const BUILD_DIR = "dist-standalone"
const RUNTIME_DEPS_DIR = "standalone/runtime-files"
const RIPGREP_BINARIES_DIR = `${BUILD_DIR}/ripgrep-binaries`
const CLI_BINARIES_DIR = "cli/bin"
const IS_VERBOSE = process.argv.includes("-v") || process.argv.includes("--verbose")
async function main() {
console.log("🚀 Building Cline NPM Package\n")
await installNodeDependencies()
await copyCliBinaries()
await copyRipgrepBinaries()
await copyProtoDescriptors()
await createNpmPackageFiles()
await createFakeNodeModules()
await createNpmIgnoreFile()
await createPostinstallScript()
console.log("\n✅ Build complete!")
console.log(`\n📦 NPM package ready in ${BUILD_DIR}/`)
console.log(`To publish: cd ${BUILD_DIR} && npm publish`)
}
/**
* Install node dependencies in the build directory
*/
async function installNodeDependencies() {
// Clean modules from any previous builds
await rmrf(path.join(BUILD_DIR, "node_modules"))
await cpr(RUNTIME_DEPS_DIR, BUILD_DIR)
console.log("Running npm install in distribution directory...")
execSync("npm install", { stdio: "inherit", cwd: BUILD_DIR })
// Move the vscode directory into node_modules.
// It can't be installed using npm because it will create a symlink which cannot be unzipped correctly on windows.
fs.renameSync(`${BUILD_DIR}/vscode`, `${BUILD_DIR}/node_modules/vscode`)
}
/**
* Copy CLI binaries (cline and cline-host) for all platforms
* The Go binaries are cross-compiled for darwin/linux arm64/amd64
*/
async function copyCliBinaries() {
console.log("Copying CLI binaries for all platforms...")
const platforms = [
{ os: "darwin", arch: "arm64" },
{ os: "darwin", arch: "amd64" },
{ os: "linux", arch: "amd64" },
{ os: "linux", arch: "arm64" },
]
const binDir = path.join(BUILD_DIR, "bin")
// Create bin directory
fs.mkdirSync(binDir, { recursive: true })
// Copy all platform-specific binaries
for (const { os, arch } of platforms) {
const platformSuffix = `${os}-${arch}`
// Copy cline binary
const clineSource = path.join(CLI_BINARIES_DIR, `cline-${platformSuffix}`)
const clineDest = path.join(binDir, `cline-${platformSuffix}`)
if (!fs.existsSync(clineSource)) {
console.error(`Error: CLI binary not found at ${clineSource}`)
console.error(`Please run: npm run compile-cli-all-platforms`)
process.exit(1)
}
await cpr(clineSource, clineDest)
fs.chmodSync(clineDest, 0o755)
console.log(`✓ cline-${platformSuffix} copied`)
// Copy cline-host binary
const hostSource = path.join(CLI_BINARIES_DIR, `cline-host-${platformSuffix}`)
const hostDest = path.join(binDir, `cline-host-${platformSuffix}`)
if (!fs.existsSync(hostSource)) {
console.error(`Error: CLI binary not found at ${hostSource}`)
console.error(`Please run: npm run compile-cli-all-platforms`)
process.exit(1)
}
await cpr(hostSource, hostDest)
fs.chmodSync(hostDest, 0o755)
console.log(`✓ cline-host-${platformSuffix} copied`)
}
console.log(`✓ All CLI binaries copied to ${binDir}`)
}
/**
* Copy ripgrep binaries for ALL platforms
* Ripgrep is needed by cline-core for file searching
* The postinstall script will select the correct binary for the user's platform
*/
async function copyRipgrepBinaries() {
console.log("Copying ripgrep binaries for all platforms...")
const platforms = [
{ dir: "darwin-arm64", binary: "rg" },
{ dir: "darwin-x64", binary: "rg" },
{ dir: "linux-x64", binary: "rg" },
{ dir: "linux-arm64", binary: "rg" },
// { dir: "win-x64", binary: "rg.exe" }, // Windows not supported yet
]
const ripgrepDir = path.join(BUILD_DIR, "ripgrep")
// Create ripgrep directory
fs.mkdirSync(ripgrepDir, { recursive: true })
// Check if ripgrep binaries exist, download if missing
const firstPlatform = platforms[0]
const firstBinaryPath = path.join(RIPGREP_BINARIES_DIR, firstPlatform.dir, firstPlatform.binary)
if (!fs.existsSync(firstBinaryPath)) {
console.log(`Ripgrep binaries not found, downloading...`)
try {
execSync("npm run download-ripgrep", { stdio: "inherit" })
} catch (error) {
console.error(`Error downloading ripgrep: ${error.message}`)
console.error(`Please run: npm run download-ripgrep`)
process.exit(1)
}
}
// Copy all platform-specific binaries
for (const { dir, binary } of platforms) {
const source = path.join(RIPGREP_BINARIES_DIR, dir, binary)
const dest = path.join(ripgrepDir, `rg-${dir}`)
if (!fs.existsSync(source)) {
console.error(`Error: Ripgrep binary not found at ${source}`)
console.error(`Please run: npm run download-ripgrep`)
process.exit(1)
}
await cpr(source, dest)
fs.chmodSync(dest, 0o755)
console.log(`✓ rg-${dir} copied`)
}
console.log(`✓ All ripgrep binaries copied to ${ripgrepDir}`)
}
/**
* Verify proto descriptors exist in the build directory
* The proto/descriptor_set.pb file is generated by build-proto.mjs to dist-standalone/proto/
* We do NOT copy from proto/ source because that would overwrite the freshly generated descriptor
*/
async function copyProtoDescriptors() {
console.log("Verifying proto descriptors...")
const protoDest = path.join(BUILD_DIR, "proto")
const descriptorPath = path.join(protoDest, "descriptor_set.pb")
// Check if descriptor_set.pb exists in the build directory
// It should have been generated by `npm run protos` which runs build-proto.mjs
if (!fs.existsSync(descriptorPath)) {
console.error(`Error: proto/descriptor_set.pb not found at ${descriptorPath}`)
console.error(`Please run: npm run protos`)
console.error(`Note: build-proto.mjs generates the descriptor to dist-standalone/proto/`)
process.exit(1)
}
// Verify the descriptor is recent (not stale)
const stats = fs.statSync(descriptorPath)
const ageMinutes = (Date.now() - stats.mtimeMs) / 1000 / 60
if (ageMinutes > 60) {
console.warn(`Warning: descriptor_set.pb is ${Math.round(ageMinutes)} minutes old`)
console.warn(`Consider running: npm run protos`)
}
console.log(`✓ Proto descriptors verified at ${protoDest}`)
}
/**
* Copy NPM package files (package.json, README.md, and man page) from cli/ directory
*/
async function createNpmPackageFiles() {
console.log("Copying NPM package files...")
// Copy package.json from cli/ directory
const packageJsonSource = path.join("cli", "package.json")
const packageJsonDest = path.join(BUILD_DIR, "package.json")
if (!fs.existsSync(packageJsonSource)) {
console.error(`Error: NPM package.json not found at ${packageJsonSource}`)
process.exit(1)
}
await cpr(packageJsonSource, packageJsonDest)
console.log(`✓ package.json copied from ${packageJsonSource}`)
// Copy README.md from cli/ directory
const readmeSource = path.join("cli", "README.md")
const readmeDest = path.join(BUILD_DIR, "README.md")
if (!fs.existsSync(readmeSource)) {
console.error(`Error: NPM README.md not found at ${readmeSource}`)
process.exit(1)
}
await cpr(readmeSource, readmeDest)
console.log(`✓ README.md copied from ${readmeSource}`)
// Copy man page from cli/man/ directory
const manPageSource = path.join("cli", "man", "cline.1")
const manDir = path.join(BUILD_DIR, "man")
const manPageDest = path.join(manDir, "cline.1")
if (!fs.existsSync(manPageSource)) {
console.error(`Error: Man page not found at ${manPageSource}`)
process.exit(1)
}
// Create man directory if it doesn't exist
fs.mkdirSync(manDir, { recursive: true })
await cpr(manPageSource, manPageDest)
console.log(`✓ Man page copied from ${manPageSource}`)
}
/**
* Create fake_node_modules directory with vscode stub
* This directory will be added to NODE_PATH so Node.js can find the vscode module
* without npm interfering with the real node_modules directory
*/
async function createFakeNodeModules() {
console.log("Creating fake_node_modules with vscode stub...")
const vscodeSource = path.join(BUILD_DIR, "node_modules", "vscode")
const fakeNodeModulesDir = path.join(BUILD_DIR, "fake_node_modules")
const vscodeDest = path.join(fakeNodeModulesDir, "vscode")
if (!fs.existsSync(vscodeSource)) {
console.error(`Error: vscode stub module not found at ${vscodeSource}`)
process.exit(1)
}
// Create fake_node_modules directory
fs.mkdirSync(fakeNodeModulesDir, { recursive: true })
// Copy vscode stub into fake_node_modules
await cpr(vscodeSource, vscodeDest)
console.log(`✓ fake_node_modules/vscode created at ${vscodeDest}`)
}
/**
* Create .npmignore file to ensure necessary files are included
*/
async function createNpmIgnoreFile() {
console.log("Creating .npmignore file...")
// Create .npmignore that excludes build artifacts
// Note: proto/ directory is NOT excluded because proto/descriptor_set.pb is needed at runtime
const npmignoreContent = `# Exclude build artifacts and unnecessary files
binaries/
ripgrep-binaries/
standalone.zip
cline-core.js.map
package-lock.json
tree-sitter*.wasm
node_modules/vscode
`
const npmignorePath = path.join(BUILD_DIR, ".npmignore")
fs.writeFileSync(npmignorePath, npmignoreContent)
console.log(`✓ .npmignore created`)
}
/**
* Create postinstall script for NPM package
* This script selects the correct platform-specific binary and creates symlinks
*/
async function createPostinstallScript() {
console.log("Creating postinstall script...")
const postinstallScript = `#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const os = require('os');
// Detect current platform and architecture
function getPlatformInfo() {
const platform = os.platform();
const arch = os.arch();
// Map Node.js arch names to Go arch names (for CLI binaries)
let goArch = arch;
if (arch === 'x64') {
goArch = 'amd64';
}
// Map for ripgrep binaries (uses different naming)
let rgArch = arch;
if (arch === 'arm64') {
rgArch = 'arm64';
} else if (arch === 'x64') {
rgArch = 'x64';
}
return { platform, arch, goArch, rgArch };
}
// Setup platform-specific binaries
function setupBinaries() {
const { platform, goArch, rgArch } = getPlatformInfo();
const cliPlatformSuffix = \`\${platform}-\${goArch}\`;
const rgPlatformSuffix = \`\${platform}-\${rgArch}\`;
console.log(\`Setting up Cline CLI for \${cliPlatformSuffix}...\`);
// Setup CLI binaries
const binDir = path.join(__dirname, 'bin');
// Check if platform-specific binaries exist
const clineSource = path.join(binDir, \`cline-\${cliPlatformSuffix}\`);
const clineHostSource = path.join(binDir, \`cline-host-\${cliPlatformSuffix}\`);
if (!fs.existsSync(clineSource)) {
console.error(\`Error: Binary not found for platform \${cliPlatformSuffix}\`);
console.error(\`Expected: \${clineSource}\`);
console.error(\`Supported platforms: darwin-arm64, darwin-amd64, linux-amd64, linux-arm64\`);
process.exit(1);
}
if (!fs.existsSync(clineHostSource)) {
console.error(\`Error: Binary not found for platform \${cliPlatformSuffix}\`);
console.error(\`Expected: \${clineHostSource}\`);
process.exit(1);
}
// Create symlinks or copies to the generic names
const clineTarget = path.join(binDir, 'cline');
const clineHostTarget = path.join(binDir, 'cline-host');
// Remove existing files if they exist
[clineTarget, clineHostTarget].forEach(target => {
if (fs.existsSync(target)) {
try {
fs.unlinkSync(target);
} catch (e) {
console.warn(\`Warning: Could not remove existing file \${target}: \${e.message}\`);
}
}
});
// On Unix, create symlinks; on Windows, copy files
if (platform === 'win32') {
// Windows: copy files
fs.copyFileSync(clineSource, clineTarget);
fs.copyFileSync(clineHostSource, clineHostTarget);
console.log('✓ Copied platform-specific CLI binaries');
} else {
// Unix: create symlinks
fs.symlinkSync(path.basename(clineSource), clineTarget);
fs.symlinkSync(path.basename(clineHostSource), clineHostTarget);
console.log('✓ Created symlinks to platform-specific CLI binaries');
// Make binaries executable
try {
fs.chmodSync(clineSource, 0o755);
fs.chmodSync(clineHostSource, 0o755);
fs.chmodSync(clineTarget, 0o755);
fs.chmodSync(clineHostTarget, 0o755);
} catch (error) {
console.warn(\`Warning: Could not set executable permissions: \${error.message}\`);
}
}
// Setup ripgrep binary
console.log(\`Setting up ripgrep for \${rgPlatformSuffix}...\`);
const ripgrepDir = path.join(__dirname, 'ripgrep');
const rgSource = path.join(ripgrepDir, \`rg-\${rgPlatformSuffix}\`);
const rgTarget = path.join(__dirname, 'rg');
if (!fs.existsSync(rgSource)) {
console.error(\`Error: ripgrep binary not found for platform \${rgPlatformSuffix}\`);
console.error(\`Expected: \${rgSource}\`);
console.error(\`Supported platforms: darwin-arm64, darwin-x64, linux-x64, linux-arm64\`);
process.exit(1);
}
// Remove existing rg if it exists
if (fs.existsSync(rgTarget)) {
try {
fs.unlinkSync(rgTarget);
} catch (e) {
console.warn(\`Warning: Could not remove existing ripgrep: \${e.message}\`);
}
}
// Copy ripgrep binary to root (where cline-core expects it)
fs.copyFileSync(rgSource, rgTarget);
// Make ripgrep executable (Unix only)
if (platform !== 'win32') {
try {
fs.chmodSync(rgTarget, 0o755);
} catch (error) {
console.warn(\`Warning: Could not set ripgrep executable permissions: \${error.message}\`);
}
}
console.log('✓ Copied platform-specific ripgrep binary');
console.log('✓ Cline CLI installation complete');
console.log('');
console.log('Usage:');
console.log(' cline - Start Cline CLI');
console.log(' cline-host - Start Cline host service');
console.log('');
console.log('Documentation: https://docs.cline.bot');
}
try {
setupBinaries();
} catch (error) {
console.error(\`Installation failed: \${error.message}\`);
console.error('Please report this issue at: https://github.com/cline/cline/issues');
process.exit(1);
}
`
const postinstallPath = path.join(BUILD_DIR, "postinstall.js")
fs.writeFileSync(postinstallPath, postinstallScript)
fs.chmodSync(postinstallPath, 0o755)
console.log(`✓ postinstall.js created`)
}
/* cp -r */
async function cpr(source, dest) {
log_verbose(`Copying ${source} -> ${dest}`)
await cp(source, dest, {
recursive: true,
preserveTimestamps: true,
dereference: false, // preserve symlinks instead of following them
})
}
/* rm -rf */
async function rmrf(dir) {
if (fs.existsSync(dir)) {
log_verbose(`Removing ${dir}`)
fs.rmSync(dir, { recursive: true, force: true })
}
}
function log_verbose(...args) {
if (IS_VERBOSE) {
console.log(...args)
}
}
await main()
+9 -459
View File
@@ -13,8 +13,6 @@ import { rmrf } from "./file-utils.mjs"
const BUILD_DIR = "dist-standalone"
const BINARIES_DIR = `${BUILD_DIR}/binaries`
const RUNTIME_DEPS_DIR = "standalone/runtime-files"
const RIPGREP_BINARIES_DIR = `${BUILD_DIR}/ripgrep-binaries`
const CLI_BINARIES_DIR = "cli/bin"
const IS_DEBUG_BUILD = process.env.IS_DEBUG_BUILD === "true"
// This should match the node version packaged with the JetBrains plugin.
@@ -30,63 +28,15 @@ const SUPPORTED_BINARY_MODULES = ["better-sqlite3"]
const UNIVERSAL_BUILD = !process.argv.includes("-s")
const IS_VERBOSE = process.argv.includes("-v") || process.argv.includes("--verbose")
// Parse --target flag (e.g., --target=npm)
// Default behavior is JetBrains build (no binaries)
// Use --target=npm for npm package build (CLI binaries but no Node.js)
const targetArg = process.argv.find((arg) => arg.startsWith("--target="))
const BUILD_TARGET = targetArg ? targetArg.split("=")[1] : "jetbrains"
const IS_NPM_BUILD = BUILD_TARGET === "npm"
// Detect current platform
function getCurrentPlatform() {
const platform = os.platform()
const arch = os.arch()
if (platform === "darwin") {
return arch === "arm64" ? "darwin-arm64" : "darwin-x64"
} else if (platform === "linux") {
return "linux-x64"
} else if (platform === "win32") {
return "win-x64"
}
throw new Error(`Unsupported platform: ${platform}-${arch}`)
}
async function main() {
const buildType = IS_NPM_BUILD ? "NPM Package" : "JetBrains"
console.log(`🚀 Building Cline ${buildType} Package\n`)
await installNodeDependencies()
if (IS_NPM_BUILD) {
await copyCliBinaries()
await copyRipgrepBinary()
await copyProtoDescriptors()
await createNpmPackageFiles()
await createFakeNodeModules()
await createNpmIgnoreFile()
await createPostinstallScript()
}
if (UNIVERSAL_BUILD && !IS_NPM_BUILD) {
console.log("\nBuilding universal package for all platforms...")
if (UNIVERSAL_BUILD) {
console.log("Building universal package for all platforms...")
await packageAllBinaryDeps()
} else if (IS_NPM_BUILD) {
console.log("\nNPM build: Keeping native modules in node_modules for npm to handle...")
} else {
console.log(`\nBuilding package for ${os.platform()}-${os.arch()}...`)
}
if (!IS_NPM_BUILD) {
console.log("\n📦 Creating final package...")
await zipDistribution()
}
console.log("\n✅ Build complete!")
if (IS_NPM_BUILD) {
console.log(`\n📦 NPM package ready in ${BUILD_DIR}/`)
console.log(`To publish: cd ${BUILD_DIR} && npm publish`)
console.log(`Building package for ${os.platform()}-${os.arch()}...`)
}
await zipDistribution()
}
async function installNodeDependencies() {
@@ -104,389 +54,6 @@ async function installNodeDependencies() {
fs.renameSync(`${BUILD_DIR}/vscode`, `${BUILD_DIR}/node_modules/vscode`)
}
/**
* Copy CLI binaries (cline and cline-host) for all platforms
* The Go binaries are cross-compiled for darwin/linux arm64/amd64
*/
async function copyCliBinaries() {
console.log("Copying CLI binaries for all platforms...")
const platforms = [
{ os: "darwin", arch: "arm64" },
{ os: "darwin", arch: "amd64" },
{ os: "linux", arch: "amd64" },
{ os: "linux", arch: "arm64" },
]
const binDir = path.join(BUILD_DIR, "bin")
// Create bin directory
fs.mkdirSync(binDir, { recursive: true })
// Copy all platform-specific binaries
for (const { os, arch } of platforms) {
const platformSuffix = `${os}-${arch}`
// Copy cline binary
const clineSource = path.join(CLI_BINARIES_DIR, `cline-${platformSuffix}`)
const clineDest = path.join(binDir, `cline-${platformSuffix}`)
if (!fs.existsSync(clineSource)) {
console.error(`Error: CLI binary not found at ${clineSource}`)
console.error(`Please run: npm run compile-cli`)
process.exit(1)
}
await cpr(clineSource, clineDest)
fs.chmodSync(clineDest, 0o755)
console.log(`✓ cline-${platformSuffix} copied`)
// Copy cline-host binary
const hostSource = path.join(CLI_BINARIES_DIR, `cline-host-${platformSuffix}`)
const hostDest = path.join(binDir, `cline-host-${platformSuffix}`)
if (!fs.existsSync(hostSource)) {
console.error(`Error: CLI binary not found at ${hostSource}`)
console.error(`Please run: npm run compile-cli`)
process.exit(1)
}
await cpr(hostSource, hostDest)
fs.chmodSync(hostDest, 0o755)
console.log(`✓ cline-host-${platformSuffix} copied`)
}
console.log(`✓ All platform binaries copied to ${binDir}`)
}
/**
* Copy proto descriptors directory
* The proto/descriptor_set.pb file is needed by cline-core for gRPC reflection
*/
async function copyProtoDescriptors() {
console.log("Copying proto descriptors...")
const protoSource = "proto"
const protoDest = path.join(BUILD_DIR, "proto")
// Check if proto directory exists
if (!fs.existsSync(protoSource)) {
console.error(`Error: proto directory not found at ${protoSource}`)
console.error(`Please ensure the proto files have been generated`)
process.exit(1)
}
// Check if descriptor_set.pb exists
const descriptorPath = path.join(protoSource, "descriptor_set.pb")
if (!fs.existsSync(descriptorPath)) {
console.error(`Error: proto/descriptor_set.pb not found at ${descriptorPath}`)
console.error(`Please run: npm run protos`)
process.exit(1)
}
// Copy the entire proto directory
await cpr(protoSource, protoDest)
console.log(`✓ Proto descriptors copied to ${protoDest}`)
}
/**
* Copy ripgrep binary for the current platform
* Ripgrep is needed by cline-core for file searching
*/
async function copyRipgrepBinary() {
const currentPlatform = getCurrentPlatform()
const binaryName = currentPlatform.startsWith("win") ? "rg.exe" : "rg"
const ripgrepBinarySource = path.join(RIPGREP_BINARIES_DIR, currentPlatform, binaryName)
const ripgrepBinaryDest = path.join(BUILD_DIR, binaryName)
console.log(`Copying ripgrep binary for ${currentPlatform}...`)
// Check if ripgrep binaries exist, download if missing
if (!fs.existsSync(ripgrepBinarySource)) {
console.log(`Ripgrep binary not found, downloading...`)
try {
execSync("npm run download-ripgrep", { stdio: "inherit" })
} catch (error) {
console.error(`Error downloading ripgrep: ${error.message}`)
console.error(`Please run: npm run download-ripgrep`)
process.exit(1)
}
// Check again after download
if (!fs.existsSync(ripgrepBinarySource)) {
console.error(`Error: Ripgrep binary still not found at ${ripgrepBinarySource}`)
console.error(`Download may have failed. Please run: npm run download-ripgrep`)
process.exit(1)
}
}
// Copy ripgrep binary to the root of dist-standalone (where cline-core.js is)
await cpr(ripgrepBinarySource, ripgrepBinaryDest)
// Make it executable (Unix only)
if (!currentPlatform.startsWith("win")) {
fs.chmodSync(ripgrepBinaryDest, 0o755)
}
console.log(`✓ Ripgrep binary copied to ${ripgrepBinaryDest}`)
}
/**
* Create a VERSION file with build metadata
*/
async function createVersionFile() {
const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8"))
const version = packageJson.version
const platform = getCurrentPlatform()
const buildDate = new Date().toISOString()
const versionInfo = {
version,
platform,
buildDate,
nodeVersion: TARGET_NODE_VERSION,
}
const versionPath = path.join(BUILD_DIR, "VERSION.txt")
fs.writeFileSync(versionPath, JSON.stringify(versionInfo, null, 2))
console.log(`✓ VERSION file created: ${version} (${platform})`)
}
/**
* Copy NPM package files (package.json, README.md, and man page) from cli/ directory
*/
async function createNpmPackageFiles() {
console.log("Copying NPM package files...")
// Copy package.json from cli/ directory
const packageJsonSource = path.join("cli", "package.json")
const packageJsonDest = path.join(BUILD_DIR, "package.json")
if (!fs.existsSync(packageJsonSource)) {
console.error(`Error: NPM package.json not found at ${packageJsonSource}`)
process.exit(1)
}
await cpr(packageJsonSource, packageJsonDest)
console.log(`✓ package.json copied from ${packageJsonSource}`)
// Copy README.md from cli/ directory
const readmeSource = path.join("cli", "README.md")
const readmeDest = path.join(BUILD_DIR, "README.md")
if (!fs.existsSync(readmeSource)) {
console.error(`Error: NPM README.md not found at ${readmeSource}`)
process.exit(1)
}
await cpr(readmeSource, readmeDest)
console.log(`✓ README.md copied from ${readmeSource}`)
// Copy man page from cli/man/ directory
const manPageSource = path.join("cli", "man", "cline.1")
const manDir = path.join(BUILD_DIR, "man")
const manPageDest = path.join(manDir, "cline.1")
if (!fs.existsSync(manPageSource)) {
console.error(`Error: Man page not found at ${manPageSource}`)
process.exit(1)
}
// Create man directory if it doesn't exist
fs.mkdirSync(manDir, { recursive: true })
await cpr(manPageSource, manPageDest)
console.log(`✓ Man page copied from ${manPageSource}`)
}
/**
* Create fake_node_modules directory with vscode stub
* This directory will be added to NODE_PATH so Node.js can find the vscode module
* without npm interfering with the real node_modules directory
*/
async function createFakeNodeModules() {
console.log("Creating fake_node_modules with vscode stub...")
const vscodeSource = path.join(BUILD_DIR, "node_modules", "vscode")
const fakeNodeModulesDir = path.join(BUILD_DIR, "fake_node_modules")
const vscodeDest = path.join(fakeNodeModulesDir, "vscode")
if (!fs.existsSync(vscodeSource)) {
console.error(`Error: vscode stub module not found at ${vscodeSource}`)
process.exit(1)
}
// Create fake_node_modules directory
fs.mkdirSync(fakeNodeModulesDir, { recursive: true })
// Copy vscode stub into fake_node_modules
await cpr(vscodeSource, vscodeDest)
console.log(`✓ fake_node_modules/vscode created at ${vscodeDest}`)
}
/**
* Create .npmignore file to ensure necessary files are included
*/
async function createNpmIgnoreFile() {
console.log("Creating .npmignore file...")
// Create .npmignore that excludes build artifacts
// Note: proto/ directory is NOT excluded because proto/descriptor_set.pb is needed at runtime
const npmignoreContent = `# Exclude build artifacts and unnecessary files
binaries/
ripgrep-binaries/
standalone.zip
cline-core.js.map
package-lock.json
tree-sitter*.wasm
node_modules/vscode
`
const npmignorePath = path.join(BUILD_DIR, ".npmignore")
fs.writeFileSync(npmignorePath, npmignoreContent)
console.log(`✓ .npmignore created`)
}
/**
* Create postinstall script for NPM package
* This script selects the correct platform-specific binary and creates symlinks
*/
async function createPostinstallScript() {
console.log("Creating postinstall script...")
const postinstallScript = `#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const os = require('os');
// Detect current platform and architecture
function getPlatformInfo() {
const platform = os.platform();
const arch = os.arch();
// Map Node.js arch names to Go arch names
let goArch = arch;
if (arch === 'x64') {
goArch = 'amd64';
}
let goPlatform = platform;
return { platform: goPlatform, arch: goArch };
}
// Setup platform-specific binaries
function setupBinaries() {
const { platform, arch } = getPlatformInfo();
const platformSuffix = \`\${platform}-\${arch}\`;
console.log(\`Setting up Cline CLI for \${platformSuffix}...\`);
const binDir = path.join(__dirname, 'bin');
// Check if platform-specific binaries exist
const clineSource = path.join(binDir, \`cline-\${platformSuffix}\`);
const clineHostSource = path.join(binDir, \`cline-host-\${platformSuffix}\`);
if (!fs.existsSync(clineSource)) {
console.error(\`Error: Binary not found for platform \${platformSuffix}\`);
console.error(\`Expected: \${clineSource}\`);
console.error(\`Supported platforms: darwin-arm64, darwin-amd64, linux-amd64, linux-arm64\`);
process.exit(1);
}
if (!fs.existsSync(clineHostSource)) {
console.error(\`Error: Binary not found for platform \${platformSuffix}\`);
console.error(\`Expected: \${clineHostSource}\`);
process.exit(1);
}
// Create symlinks or copies to the generic names
const clineTarget = path.join(binDir, 'cline');
const clineHostTarget = path.join(binDir, 'cline-host');
// Remove existing files if they exist
[clineTarget, clineHostTarget].forEach(target => {
if (fs.existsSync(target)) {
try {
fs.unlinkSync(target);
} catch (e) {
console.warn(\`Warning: Could not remove existing file \${target}: \${e.message}\`);
}
}
});
// On Unix, create symlinks; on Windows, copy files
if (platform === 'win32') {
// Windows: copy files
fs.copyFileSync(clineSource, clineTarget);
fs.copyFileSync(clineHostSource, clineHostTarget);
console.log('✓ Copied platform-specific binaries');
} else {
// Unix: create symlinks
fs.symlinkSync(path.basename(clineSource), clineTarget);
fs.symlinkSync(path.basename(clineHostSource), clineHostTarget);
console.log('✓ Created symlinks to platform-specific binaries');
// Make binaries executable
try {
fs.chmodSync(clineSource, 0o755);
fs.chmodSync(clineHostSource, 0o755);
fs.chmodSync(clineTarget, 0o755);
fs.chmodSync(clineHostTarget, 0o755);
} catch (error) {
console.warn(\`Warning: Could not set executable permissions: \${error.message}\`);
}
}
// Check ripgrep binary
const rgBinary = platform === 'win32' ? 'rg.exe' : 'rg';
const rgPath = path.join(__dirname, rgBinary);
if (!fs.existsSync(rgPath)) {
console.error(\`Error: ripgrep binary not found at \${rgPath}\`);
process.exit(1);
}
// Make ripgrep executable (Unix only)
if (platform !== 'win32') {
try {
fs.chmodSync(rgPath, 0o755);
} catch (error) {
console.warn(\`Warning: Could not set ripgrep executable permissions: \${error.message}\`);
}
}
console.log('✓ Cline CLI installation complete');
console.log('');
console.log('Usage:');
console.log(' cline - Start Cline CLI');
console.log(' cline-host - Start Cline host service');
console.log('');
console.log('Documentation: https://docs.cline.bot');
}
try {
setupBinaries();
} catch (error) {
console.error(\`Installation failed: \${error.message}\`);
console.error('Please report this issue at: https://github.com/cline/cline/issues');
process.exit(1);
}
`
const postinstallPath = path.join(BUILD_DIR, "postinstall.js")
fs.writeFileSync(postinstallPath, postinstallScript)
fs.chmodSync(postinstallPath, 0o755)
console.log(`✓ postinstall.js created`)
}
/**
* Downloads prebuilt binaries for each platform for the modules that include binaries. It uses `npx prebuild-install`
* to download the binary.
@@ -539,9 +106,8 @@ async function packageAllBinaryDeps() {
}
async function zipDistribution() {
// Default JetBrains build
const zipFilename = "standalone.zip"
const zipPath = path.join(BUILD_DIR, zipFilename)
// Zip the build directory (excluding any pre-existing output zip).
const zipPath = path.join(BUILD_DIR, "standalone.zip")
const output = fs.createWriteStream(zipPath)
const startTime = Date.now()
const archive = archiver("zip", { zlib: { level: 6 } })
@@ -559,31 +125,15 @@ async function zipDistribution() {
})
archive.pipe(output)
// Build ignore lists for build directory and extension directory
const ignorePatterns = ["standalone.zip", "standalone-cli.zip"]
const extensionIgnores = ["dist/**"]
// For JetBrains builds, exclude binaries from both directories
// JetBrains provides their own Node.js, so exclude all binaries
ignorePatterns.push(
"bin/**", // Exclude entire bin directory
"node-binaries/**", // Exclude all platform-specific Node.js binaries
)
extensionIgnores.push(
"cli/bin/**", // Exclude CLI binaries from extension
"node-binaries/**", // Exclude node-binaries from extension
)
console.log("JetBrains build: Excluding Node.js and CLI binaries (JetBrains provides its own Node.js)")
// Add all the files from the standalone build dir.
archive.glob("**/*", {
cwd: BUILD_DIR,
ignore: ignorePatterns,
ignore: ["standalone.zip"],
})
// Exclude the same files as the VCE vscode extension packager.
const isIgnored = createIsIgnored(extensionIgnores)
// Also ignore the dist directory, the build directory for the extension.
const isIgnored = createIsIgnored(["dist/**"])
// Add the whole cline directory under "extension", except the for the ignored files.
archive.directory(process.cwd(), "extension", (entry) => {
+8 -2
View File
@@ -140,6 +140,7 @@ function createHandlerForProvider(
openAiApiKey: options.openAiApiKey,
openAiBaseUrl: options.openAiBaseUrl,
azureApiVersion: options.azureApiVersion,
azureIdentity: options.azureIdentity,
openAiHeaders: options.openAiHeaders,
openAiModelId: mode === "plan" ? options.planModeOpenAiModelId : options.actModeOpenAiModelId,
openAiModelInfo: mode === "plan" ? options.planModeOpenAiModelInfo : options.actModeOpenAiModelInfo,
@@ -375,10 +376,14 @@ function createHandlerForProvider(
return new VercelAIGatewayHandler({
onRetryAttempt: options.onRetryAttempt,
vercelAiGatewayApiKey: options.vercelAiGatewayApiKey,
openRouterModelId: mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId,
openRouterModelInfo: mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo,
openRouterModelId:
mode === "plan" ? options.planModeVercelAiGatewayModelId : options.actModeVercelAiGatewayModelId,
openRouterModelInfo:
mode === "plan" ? options.planModeVercelAiGatewayModelInfo : options.actModeVercelAiGatewayModelInfo,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
geminiThinkingLevel: mode === "plan" ? options.geminiPlanModeThinkingLevel : options.geminiActModeThinkingLevel,
})
case "zai":
return new ZAiHandler({
@@ -393,6 +398,7 @@ function createHandlerForProvider(
ocaBaseUrl: options.ocaBaseUrl,
ocaModelId: mode === "plan" ? options.planModeOcaModelId : options.actModeOcaModelId,
ocaModelInfo: mode === "plan" ? options.planModeOcaModelInfo : options.actModeOcaModelInfo,
ocaReasoningEffort: mode === "plan" ? options.planModeOcaReasoningEffort : options.actModeOcaReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
ocaUsePromptCache:
+1 -1
View File
@@ -199,7 +199,7 @@ export class ClineHandler implements ApiHandler {
// @ts-ignore-next-line
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
if (["x-ai/grok-code-fast-1", "minimax/minimax-m2"].includes(this.getModel().id)) {
if (["x-ai/grok-code-fast-1", "minimax/minimax-m2.1"].includes(this.getModel().id)) {
totalCost = 0
}
+79 -9
View File
@@ -1,4 +1,4 @@
import { LiteLLMModelInfo, liteLlmDefaultModelId, liteLlmModelInfoSaneDefaults } from "@shared/api"
import { liteLlmDefaultModelId, liteLlmModelInfoSaneDefaults, ModelInfo } from "@shared/api"
import OpenAI, { APIError, OpenAIError } from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
@@ -9,18 +9,23 @@ import {
} from "@/services/auth/oca/utils/constants"
import { createOcaHeaders } from "@/services/auth/oca/utils/utils"
import { Logger } from "@/services/logging/Logger"
import { OcaModelInfo } from "@/shared/api"
import { ClineStorageMessage } from "@/shared/messages/content"
import { fetch } from "@/shared/net"
import { ApiFormat } from "@/shared/proto/index.cline"
import { ApiHandler, type CommonApiHandlerOptions } from ".."
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToOpenAIResponsesInput } from "../transform/openai-response-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
import { handleResponsesApiStreamResponse } from "../utils/responses_api_support"
export interface OcaHandlerOptions extends CommonApiHandlerOptions {
ocaBaseUrl?: string
ocaModelId?: string
ocaModelInfo?: LiteLLMModelInfo
ocaModelInfo?: OcaModelInfo
ocaReasoningEffort?: string
thinkingBudgetTokens?: number
ocaUsePromptCache?: boolean
taskId?: string
@@ -100,7 +105,7 @@ export class OcaHandler implements ApiHandler {
return this.client
}
async calculateCost(prompt_tokens: number, completion_tokens: number): Promise<number | undefined> {
async getApiCosts(prompt_tokens: number, completion_tokens: number): Promise<number | undefined> {
// Reference: https://github.com/BerriAI/litellm/blob/122ee634f434014267af104814022af1d9a0882f/litellm/proxy/spend_tracking/spend_management_endpoints.py#L1473
const client = this.ensureClient()
const modelId = this.options.ocaModelId || liteLlmDefaultModelId
@@ -138,8 +143,29 @@ export class OcaHandler implements ApiHandler {
}
}
async calculateCost(
modelInfo: ModelInfo,
inputTokens: number,
outputTokens: number,
_cacheWriteTokens?: number,
_cacheReadTokens?: number,
) {
const inputCost = (await this.getApiCosts(1e6, 0)) || 0
const outputCost = (await this.getApiCosts(0, 1e6)) || 0
const totalCost = (inputCost * inputTokens) / 1e6 + (outputCost * outputTokens) / 1e6
return totalCost
}
@withRetry()
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
if (this.options.ocaModelInfo?.apiFormat == ApiFormat.OPENAI_RESPONSES) {
yield* this.createMessageResponsesApi(systemPrompt, messages, tools)
} else {
yield* this.createMessageChatApi(systemPrompt, messages, tools)
}
}
async *createMessageChatApi(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
const client = this.ensureClient()
const formattedMessages = convertToOpenAiMessages(messages)
const systemMessage: OpenAI.Chat.ChatCompletionSystemMessageParam = {
@@ -193,7 +219,7 @@ export class OcaHandler implements ApiHandler {
const toolCallProcessor = new ToolCallProcessor()
const stream = await client.chat.completions.create({
const chatCompletionsParams: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming = {
model: this.options.ocaModelId || liteLlmDefaultModelId,
messages: [enhancedSystemMessage, ...enhancedMessages],
temperature,
@@ -206,10 +232,13 @@ export class OcaHandler implements ApiHandler {
litellm_session_id: `cline-${this.options.taskId}`,
...getOpenAIToolParams(tools),
}), // Add session ID for LiteLLM tracking
})
}
const inputCost = (await this.calculateCost(1e6, 0)) || 0
const outputCost = (await this.calculateCost(0, 1e6)) || 0
if (this.options.ocaModelInfo?.supportsReasoningEffort) {
chatCompletionsParams["reasoning_effort"] = this.options.ocaReasoningEffort || ("medium" as any)
}
const stream = await client.chat.completions.create(chatCompletionsParams)
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
@@ -241,8 +270,11 @@ export class OcaHandler implements ApiHandler {
// Handle token usage information
if (chunk.usage) {
const totalCost =
(inputCost * chunk.usage.prompt_tokens) / 1e6 + (outputCost * chunk.usage.completion_tokens) / 1e6
const totalCost = await this.calculateCost(
this.options.ocaModelInfo!,
chunk.usage.prompt_tokens,
chunk.usage.completion_tokens,
)
// Extract cache-related information if available
// Need to use type assertion since these properties are not in the standard OpenAI types
@@ -270,6 +302,44 @@ export class OcaHandler implements ApiHandler {
}
}
async *createMessageResponsesApi(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
console.log("Uses Responses API")
const client = this.ensureClient()
// Convert messages to Responses API input format
const input: OpenAI.Responses.ResponseInputItem[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAIResponsesInput(messages),
]
// Convert ChatCompletion tools to Responses API format if provided
const responseTools = tools
?.filter((tool) => tool.type === "function")
.map((tool: any) => ({
type: "function" as const,
name: tool.function.name,
description: tool.function.description,
parameters: tool.function.parameters,
strict: tool.function.strict ?? true, // Responses API defaults to strict mode
}))
const responsesParams: OpenAI.Responses.ResponseCreateParamsStreaming = {
model: this.options.ocaModelId || liteLlmDefaultModelId,
input,
stream: true,
tools: responseTools,
}
if (this.options.ocaModelInfo && this.options.ocaModelInfo.supportsReasoning) {
responsesParams["reasoning"] = { effort: this.options.ocaReasoningEffort as any, summary: "auto" }
}
// Create the response using Responses API
const stream = await client.responses.create(responsesParams)
yield* handleResponsesApiStreamResponse(stream, this.options.ocaModelInfo!, this.calculateCost.bind(this))
}
getModel() {
return {
id: this.options.ocaModelId || liteLlmDefaultModelId,
+24 -9
View File
@@ -1,3 +1,4 @@
import { DefaultAzureCredential, getBearerTokenProvider } from "@azure/identity"
import { azureOpenAiDefaultApiVersion, ModelInfo, OpenAiCompatibleModelInfo, openAiModelInfoSaneDefaults } from "@shared/api"
import OpenAI, { AzureOpenAI } from "openai"
import type { ChatCompletionReasoningEffort, ChatCompletionTool } from "openai/resources/chat/completions"
@@ -14,6 +15,7 @@ interface OpenAiHandlerOptions extends CommonApiHandlerOptions {
openAiApiKey?: string
openAiBaseUrl?: string
azureApiVersion?: string
azureIdentity?: boolean
openAiHeaders?: Record<string, string>
openAiModelId?: string
openAiModelInfo?: OpenAiCompatibleModelInfo
@@ -30,8 +32,8 @@ export class OpenAiHandler implements ApiHandler {
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.openAiApiKey) {
throw new Error("OpenAI API key is required")
if (!this.options.openAiApiKey && !this.options.azureIdentity) {
throw new Error("OpenAI API key or Azure Identity Authentication is required")
}
try {
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
@@ -42,13 +44,26 @@ export class OpenAiHandler implements ApiHandler {
this.options.openAiBaseUrl?.toLowerCase().includes("azure.us")) &&
!this.options.openAiModelId?.toLowerCase().includes("deepseek"))
) {
this.client = new AzureOpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
defaultHeaders: this.options.openAiHeaders,
fetch, // Use configured fetch with proxy support
})
if (this.options.azureIdentity) {
this.client = new AzureOpenAI({
baseURL: this.options.openAiBaseUrl,
azureADTokenProvider: getBearerTokenProvider(
new DefaultAzureCredential(),
"https://cognitiveservices.azure.com/.default",
),
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
defaultHeaders: this.options.openAiHeaders,
fetch, // Use configured fetch with proxy support
})
} else {
this.client = new AzureOpenAI({
baseURL: this.options.openAiBaseUrl,
apiKey: this.options.openAiApiKey,
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
defaultHeaders: this.options.openAiHeaders,
fetch, // Use configured fetch with proxy support
})
}
} else {
this.client = new OpenAI({
baseURL: this.options.openAiBaseUrl,
+55 -27
View File
@@ -5,6 +5,7 @@ import {
type Message as BedrockMessage,
} from "@aws-sdk/client-bedrock-runtime"
import { ChatMessage, OrchestrationClient, OrchestrationModuleConfig } from "@sap-ai-sdk/orchestration"
import { HttpDestination, transformServiceBindingToDestination } from "@sap-cloud-sdk/connectivity"
import { ModelInfo, SapAiCoreModelId, sapAiCoreDefaultModelId, sapAiCoreModels } from "@shared/api"
import axios from "axios"
import JSON5 from "json5"
@@ -355,7 +356,8 @@ export class SapAiCoreHandler implements ApiHandler {
private options: SapAiCoreHandlerOptions
private token?: Token
private deployments?: Deployment[]
private isAiCoreEnvSetup: boolean = false
private aiCoreDestination?: HttpDestination
private destinationExpiresAt?: number
constructor(options: SapAiCoreHandlerOptions) {
this.options = options
@@ -387,6 +389,34 @@ export class SapAiCoreHandler implements ApiHandler {
}
}
private async createAiCoreDestination(): Promise<HttpDestination> {
try {
const aiCoreServiceCredentials = {
clientid: this.options.sapAiCoreClientId!,
clientsecret: this.options.sapAiCoreClientSecret!,
url: this.options.sapAiCoreTokenUrl!,
serviceurls: {
AI_API_URL: this.options.sapAiCoreBaseUrl!,
},
}
const destination = await transformServiceBindingToDestination({
credentials: aiCoreServiceCredentials,
name: "aicore",
label: "aicore",
tags: ["aicore"],
})
return {
...destination,
url: destination.url || this.options.sapAiCoreBaseUrl!,
}
} catch (error) {
console.error("Failed to create AI Core destination:", error)
throw new Error(`Unable to create AI Core destination: ${error instanceof Error ? error.message : String(error)}`)
}
}
private async authenticate(): Promise<Token> {
this.validateCredentials()
@@ -481,33 +511,23 @@ export class SapAiCoreHandler implements ApiHandler {
}
// TODO: support credentials changes after initial setup
private ensureAiCoreEnvSetup(): void {
// Only set up once to avoid redundant operations
if (this.isAiCoreEnvSetup) {
return
private async ensureAiCoreEnvSetup() {
if (!this.aiCoreDestination || !this.destinationExpiresAt || this.destinationExpiresAt < Date.now()) {
this.validateCredentials()
this.aiCoreDestination = await this.createAiCoreDestination()
// Extract expiration from the destination's auth token
const expiresIn = this.aiCoreDestination.authTokens?.[0]?.expiresIn
if (!expiresIn) {
throw new Error("Destination is missing required authTokens with expiresIn")
}
this.destinationExpiresAt = Date.now() + parseInt(expiresIn, 10) * 1000
}
// Validate required credentials
this.validateCredentials()
const aiCoreServiceCredentials = {
clientid: this.options.sapAiCoreClientId!,
clientsecret: this.options.sapAiCoreClientSecret!,
url: this.options.sapAiCoreTokenUrl!,
serviceurls: {
AI_API_URL: this.options.sapAiCoreBaseUrl!,
},
}
process.env["AICORE_SERVICE_KEY"] = JSON.stringify(aiCoreServiceCredentials)
// Mark as set up to avoid redundant calls
this.isAiCoreEnvSetup = true
}
private async *createMessageWithOrchestration(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
try {
// Ensure AI Core environment variable is set up (only runs once)
this.ensureAiCoreEnvSetup()
await this.ensureAiCoreEnvSetup()
const model = this.getModel()
const orchestrationConfig: OrchestrationModuleConfig = {
@@ -526,14 +546,22 @@ export class SapAiCoreHandler implements ApiHandler {
},
}
const orchestrationClient = new OrchestrationClient(orchestrationConfig, {
resourceGroup: this.options.sapAiResourceGroup || "default",
})
const orchestrationClient = new OrchestrationClient(
orchestrationConfig,
{
resourceGroup: this.options.sapAiResourceGroup || "default",
},
this.aiCoreDestination,
)
const sapMessages = this.convertMessageParamToSAPMessages(messages)
// messagesHistory: Contains the conversation context (user/assistant messages).
// Unlike the `messages` field that validates input, this does not validate
// template placeholders such as {{?userResponse}}, allowing content to be
// sent directly to the LLM with the Cline system prompt without validation errors.
const response = await orchestrationClient.stream({
messages: sapMessages,
messagesHistory: sapMessages,
})
for await (const chunk of response.stream.toContentStream()) {
+10 -2
View File
@@ -1,4 +1,5 @@
import { ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import { shouldSkipReasoningForModel } from "@utils/model-utils"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ClineStorageMessage } from "@/shared/messages/content"
@@ -13,7 +14,9 @@ interface VercelAIGatewayHandlerOptions extends CommonApiHandlerOptions {
vercelAiGatewayApiKey?: string
openRouterModelId?: string
openRouterModelInfo?: ModelInfo
reasoningEffort?: string
thinkingBudgetTokens?: number
geminiThinkingLevel?: string
}
export class VercelAIGatewayHandler implements ApiHandler {
@@ -58,8 +61,10 @@ export class VercelAIGatewayHandler implements ApiHandler {
systemPrompt,
messages,
{ id: modelId, info: modelInfo },
this.options.reasoningEffort,
this.options.thinkingBudgetTokens,
tools,
this.options.geminiThinkingLevel,
)
let didOutputUsage: boolean = false
@@ -67,6 +72,7 @@ export class VercelAIGatewayHandler implements ApiHandler {
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta
if (delta?.content) {
yield {
type: "text",
@@ -79,7 +85,8 @@ export class VercelAIGatewayHandler implements ApiHandler {
}
// Reasoning tokens are returned separately from the content
if ("reasoning" in delta && delta.reasoning) {
// Skip reasoning content for models that don't support it (e.g., devstral, grok-4)
if ("reasoning" in delta && delta.reasoning && !shouldSkipReasoningForModel(this.options.openRouterModelId)) {
yield {
type: "reasoning",
reasoning: typeof delta.reasoning === "string" ? delta.reasoning : JSON.stringify(delta.reasoning),
@@ -91,7 +98,8 @@ export class VercelAIGatewayHandler implements ApiHandler {
"reasoning_details" in delta &&
delta.reasoning_details &&
// @ts-ignore-next-line
delta.reasoning_details.length // exists and non-0
delta.reasoning_details.length && // exists and non-0
!shouldSkipReasoningForModel(this.options.openRouterModelId)
) {
yield {
type: "reasoning",
+63 -11
View File
@@ -145,18 +145,13 @@ export function convertToOpenAiMessages(
const thinkingBlock = []
if (nonToolMessages.length > 0) {
nonToolMessages.forEach((part) => {
// @ts-ignore-next-line
if (part.type === "text" && part.reasoning_details) {
// @ts-ignore-next-line
if (Array.isArray(part.reasoning_details)) {
// @ts-ignore-next-line
reasoningDetails.push(...part.reasoning_details)
const anyPart = part as any
if (part.type === "text" && anyPart.reasoning_details) {
if (Array.isArray(anyPart.reasoning_details)) {
reasoningDetails.push(...anyPart.reasoning_details)
} else {
// @ts-ignore-next-line
reasoningDetails.push(part.reasoning_details)
reasoningDetails.push(anyPart.reasoning_details)
}
// @ts-ignore-next-line
// delete part.reasoning_details
}
if (part.type === "thinking" && part.thinking) {
// Reasoning details should have been moved to the text block
@@ -216,7 +211,7 @@ export function convertToOpenAiMessages(
// Cannot be an empty array. API expects an array with minimum length 1, and will respond with an error if it's empty
tool_calls: tool_calls?.length > 0 ? tool_calls : undefined,
// Only include reasoning_details when non-empty; sending [] can trigger provider validation issues.
// @ts-ignore-next-line
// @ts-expect-error
reasoning_details: consolidatedReasoningDetails.length > 0 ? consolidatedReasoningDetails : undefined,
})
}
@@ -404,3 +399,60 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
return anthropicMessage
}
/**
* Sanitizes OpenAI messages for Gemini models by removing tool_calls that lack reasoning_details.
*
* Gemini models require thought signatures for tool calls. When switching providers mid-conversation,
* historical tool calls may not include Gemini reasoning details, which can poison the next request.
* This function drops tool_calls that lack reasoning_details and their paired tool messages.
*
* @param messages - Array of OpenAI chat completion messages
* @param modelId - The model ID to check if sanitization is needed
* @returns Sanitized array of messages (unchanged if not a Gemini model)
*/
export function sanitizeGeminiMessages(
messages: OpenAI.Chat.ChatCompletionMessageParam[],
modelId: string,
): OpenAI.Chat.ChatCompletionMessageParam[] {
if (!modelId.includes("gemini")) {
return messages
}
const droppedToolCallIds = new Set<string>()
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = []
for (const msg of messages) {
if (msg.role === "assistant") {
const anyMsg = msg as any
const toolCalls = anyMsg.tool_calls
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
const reasoningDetails = anyMsg.reasoning_details
const hasReasoningDetails = Array.isArray(reasoningDetails) && reasoningDetails.length > 0
if (!hasReasoningDetails) {
for (const tc of toolCalls) {
if (tc?.id) {
droppedToolCallIds.add(tc.id)
}
}
// Keep any textual content, but drop the tool_calls themselves.
if (anyMsg.content) {
sanitized.push({ role: "assistant", content: anyMsg.content } as any)
}
continue
}
}
}
if (msg.role === "tool") {
const anyMsg = msg as any
if (anyMsg.tool_call_id && droppedToolCallIds.has(anyMsg.tool_call_id)) {
continue
}
}
sanitized.push(msg)
}
return sanitized
}
+9 -43
View File
@@ -9,7 +9,7 @@ import {
import { shouldSkipReasoningForModel } from "@utils/model-utils"
import OpenAI from "openai"
import { ChatCompletionTool } from "openai/resources/chat/completions"
import { convertToOpenAiMessages } from "./openai-format"
import { convertToOpenAiMessages, sanitizeGeminiMessages } from "./openai-format"
import { convertToR1Format } from "./r1-format"
import { getOpenAIToolParams } from "./tool-call-processor"
@@ -36,45 +36,8 @@ export async function createOpenRouterStream(
model.id = model.id.slice(0, -CLAUDE_SONNET_1M_SUFFIX.length)
}
// Gemini models require thought signatures for tool calls. When switching providers mid-conversation,
// historical tool calls may not include Gemini/OpenRouter reasoning details, which can poison the next request.
// Bandaid: for Gemini only, drop tool_calls that lack reasoning_details and their paired tool messages.
if (model.id.includes("gemini")) {
const droppedToolCallIds = new Set<string>()
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = []
for (const msg of openAiMessages) {
if (msg.role === "assistant") {
const anyMsg = msg as any
const toolCalls = anyMsg.tool_calls
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
const reasoningDetails = anyMsg.reasoning_details
const hasReasoningDetails = Array.isArray(reasoningDetails) && reasoningDetails.length > 0
if (!hasReasoningDetails) {
for (const tc of toolCalls) {
if (tc?.id) droppedToolCallIds.add(tc.id)
}
// Keep any textual content, but drop the tool_calls themselves.
if (anyMsg.content) {
sanitized.push({ role: "assistant", content: anyMsg.content } as any)
}
continue
}
}
}
if (msg.role === "tool") {
const anyMsg = msg as any
if (anyMsg.tool_call_id && droppedToolCallIds.has(anyMsg.tool_call_id)) {
continue
}
}
sanitized.push(msg)
}
openAiMessages = sanitized
}
// Sanitize messages for Gemini models (removes tool_calls without reasoning_details)
openAiMessages = sanitizeGeminiMessages(openAiMessages, model.id)
// prompt caching: https://openrouter.ai/docs/prompt-caching
// this was initially specifically for claude models (some models may 'support prompt caching' automatically without this)
@@ -105,13 +68,16 @@ export async function createOpenRouterStream(
case "anthropic/claude-3-haiku:beta":
case "anthropic/claude-3-opus":
case "anthropic/claude-3-opus:beta":
case "minimax/minimax-m2":
case "minimax/minimax-m2.1":
case "minimax/minimax-m2.1-lightning":
openAiMessages[0] = {
role: "system",
content: [
{
type: "text",
text: systemPrompt,
// @ts-ignore-next-line
// @ts-expect-error-next-line
cache_control: { type: "ephemeral" },
},
],
@@ -131,7 +97,7 @@ export async function createOpenRouterStream(
lastTextPart = { type: "text", text: "..." }
msg.content.push(lastTextPart)
}
// @ts-ignore-next-line
// @ts-expect-error-next-line
lastTextPart["cache_control"] = { type: "ephemeral" }
}
})
@@ -230,7 +196,7 @@ export async function createOpenRouterStream(
// Skip reasoning for models that don't support it (e.g., devstral, grok-4)
const includeReasoning = !shouldSkipReasoningForModel(model.id)
// @ts-ignore-next-line
// @ts-expect-error-next-line
const stream = await client.chat.completions.create({
model: model.id,
max_tokens: maxTokens,
@@ -5,9 +5,11 @@ import {
openRouterClaudeSonnet41mModelId,
openRouterClaudeSonnet451mModelId,
} from "@shared/api"
import { shouldSkipReasoningForModel } from "@utils/model-utils"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { convertToOpenAiMessages, sanitizeGeminiMessages } from "../transform/openai-format"
import { convertToR1Format } from "./r1-format"
import { getOpenAIToolParams } from "./tool-call-processor"
export async function createVercelAIGatewayStream(
@@ -15,76 +17,131 @@ export async function createVercelAIGatewayStream(
systemPrompt: string,
messages: Anthropic.Messages.MessageParam[],
model: { id: string; info: ModelInfo },
reasoningEffort?: string,
thinkingBudgetTokens?: number,
tools?: OpenAITool[],
geminiThinkingLevel?: string,
) {
// Convert Anthropic messages to OpenAI format
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
]
const isClaudeSonnet1m = model.id === openRouterClaudeSonnet41mModelId || model.id === openRouterClaudeSonnet451mModelId
if (isClaudeSonnet1m) {
// remove the custom :1m suffix, to create the model id openrouter API expects
// remove the custom :1m suffix, to create the model id the API expects
model.id = model.id.slice(0, -CLAUDE_SONNET_1M_SUFFIX.length)
}
const isAnthropicModel = model.id.startsWith("anthropic/")
// Sanitize messages for Gemini models (removes tool_calls without reasoning_details)
openAiMessages = sanitizeGeminiMessages(openAiMessages, model.id)
if (isAnthropicModel) {
// Prompt caching for supported models
// This handles cache_control for Claude and MiniMax models
const isAnthropicModel = model.id.startsWith("anthropic/")
const isMinimaxModel = model.id.startsWith("minimax/")
if (isAnthropicModel || isMinimaxModel) {
openAiMessages[0] = {
role: "system",
content: systemPrompt,
// @ts-ignore-next-line
cache_control: { type: "ephemeral" },
content: [
{
type: "text",
text: systemPrompt,
// @ts-expect-error-next-line
cache_control: { type: "ephemeral" },
},
],
}
// Add cache_control to the last two user messages for conversation context caching
// Add cache_control to the last two user messages
// (note: this works because we only ever add one user message at a time, but if we added multiple we'd need to mark the user message before the last assistant message)
const lastTwoUserMessages = openAiMessages.filter((msg) => msg.role === "user").slice(-2)
lastTwoUserMessages.forEach((msg) => {
if (typeof msg.content === "string" && msg.content.length > 0) {
if (typeof msg.content === "string") {
msg.content = [{ type: "text", text: msg.content }]
}
if (Array.isArray(msg.content)) {
// Find the last text part in the message content
const lastTextPart = msg.content.filter((part) => part.type === "text").pop()
// NOTE: this is fine since env details will always be added at the end. but if it weren't there, and the user added a image_url type message, it would pop a text part before it and then move it after to the end.
let lastTextPart = msg.content.filter((part) => part.type === "text").pop()
if (lastTextPart && lastTextPart.text && lastTextPart.text.length > 0) {
// @ts-ignore-next-line
lastTextPart["cache_control"] = { type: "ephemeral" }
if (!lastTextPart) {
lastTextPart = { type: "text", text: "..." }
msg.content.push(lastTextPart)
}
// @ts-expect-error-next-line
lastTextPart["cache_control"] = { type: "ephemeral" }
}
})
}
// Configure reasoning parameters similar to OpenRouter
let temperature: number | undefined = 0
// Use max tokens from model info (fetched from Vercel API)
const maxTokens = model.info?.maxTokens || undefined
// Use temperature from model info, default to 0
// Model-specific temperatures are derived in refreshVercelAiGatewayModels.ts
let temperature: number | undefined = model.info?.temperature ?? 0
let topP: number | undefined
// R1 format conversion for DeepSeek and similar reasoning models
const requiresR1Format =
model.id.startsWith("deepseek/deepseek-r1") ||
model.id === "perplexity/sonar-reasoning" ||
model.id === "qwen/qwq-32b:free" ||
model.id === "qwen/qwq-32b"
if (requiresR1Format) {
topP = 0.95
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
}
if (model.id.startsWith("google/gemini-3.0") || model.id === "google/gemini-3.0") {
// Recommended value from google
temperature = 1.0
}
// Reasoning/thinking budget configuration
let reasoning: { max_tokens: number } | undefined
if (isAnthropicModel) {
const budget_tokens = thinkingBudgetTokens || 0
const reasoningOn = budget_tokens !== 0
if (reasoningOn) {
// Check if it's an Anthropic Claude model that supports thinking
const isClaudeThinkingModel = model.id.startsWith("anthropic/claude") && model.info?.thinkingConfig
if (isClaudeThinkingModel) {
// For Claude models, match OpenRouter behavior: check even if thinkingBudgetTokens is 0
const budgetTokens = thinkingBudgetTokens || 0
if (budgetTokens !== 0) {
temperature = undefined // extended thinking does not support non-1 temperature
reasoning = { max_tokens: budget_tokens }
reasoning = { max_tokens: budgetTokens }
}
} else if (thinkingBudgetTokens && model.info?.thinkingConfig && thinkingBudgetTokens > 0) {
} else if (
thinkingBudgetTokens &&
thinkingBudgetTokens > 0 &&
model.info?.thinkingConfig &&
!(model.id.includes("gemini-3") && geminiThinkingLevel)
) {
// For other models with thinkingConfig, use the standard check
temperature = undefined // extended thinking does not support non-1 temperature
reasoning = { max_tokens: thinkingBudgetTokens }
}
// @ts-ignore-next-line
// Skip reasoning for models that don't support it (e.g., devstral, grok-4)
const includeReasoning = !shouldSkipReasoningForModel(model.id)
// @ts-expect-error-next-line
const stream = await client.chat.completions.create({
model: model.id,
max_tokens: model.info.maxTokens,
max_tokens: maxTokens,
temperature: temperature,
top_p: topP,
messages: openAiMessages,
stream: true,
stream_options: { include_usage: true },
include_reasoning: true,
include_reasoning: includeReasoning,
...(model.id.startsWith("openai/o") ? { reasoning_effort: reasoningEffort || "medium" } : {}),
...(reasoning ? { reasoning } : {}),
...getOpenAIToolParams(tools),
...(model.id.includes("gemini") && geminiThinkingLevel
? { thinking_config: { thinking_level: geminiThinkingLevel, include_thoughts: true } }
: {}),
})
return stream
+176
View File
@@ -0,0 +1,176 @@
import OpenAI from "openai"
import { Logger } from "@/services/logging/Logger"
import { ModelInfo } from "@/shared/api"
// Type that represents the OpenAI ResponseStream with its private properties
// The #private property issue can be resolved by using the AsyncIterable interface
export async function* handleResponsesApiStreamResponse(
stream: AsyncIterable<OpenAI.Responses.ResponseStreamEvent> & { _request_id?: string | null },
modelInfo: ModelInfo,
calculateCost: (
modelInfo: ModelInfo,
inputTokens: number,
outputTokens: number,
cacheWriteTokens: number,
cacheReadTokens: number,
) => Promise<number>,
) {
// Process the response stream
for await (const chunk of stream) {
// Handle different event types from Responses API
if (chunk.type === "response.output_item.added") {
const item = chunk.item
if (item.type === "function_call" && item.id) {
yield {
type: "tool_calls",
id: item.id,
tool_call: {
call_id: item.call_id,
function: {
id: item.id,
name: item.name,
arguments: item.arguments,
},
},
} as const
}
if (item.type === "reasoning" && item.encrypted_content && item.id) {
yield {
type: "reasoning",
id: item.id,
reasoning: "",
redacted_data: item.encrypted_content,
} as const
}
}
if (chunk.type === "response.output_item.done") {
const item = chunk.item
if (item.type === "function_call") {
yield {
type: "tool_calls",
id: item.id || item.call_id,
tool_call: {
call_id: item.call_id,
function: {
id: item.id,
name: item.name,
arguments: item.arguments,
},
},
} as const
}
if (item.type === "reasoning") {
yield {
type: "reasoning",
id: item.id,
details: item.summary,
reasoning: "",
} as const
}
}
if (chunk.type === "response.reasoning_summary_part.added") {
yield {
type: "reasoning",
id: chunk.item_id,
reasoning: chunk.part.text,
} as const
}
if (chunk.type === "response.reasoning_summary_text.delta") {
yield {
type: "reasoning",
id: chunk.item_id,
reasoning: chunk.delta,
} as const
}
if (chunk.type === "response.reasoning_summary_part.done") {
yield {
type: "reasoning",
id: chunk.item_id,
details: chunk.part,
reasoning: "",
} as const
}
if (chunk.type === "response.output_text.delta") {
// Handle text content deltas
if (chunk.delta) {
yield {
id: chunk.item_id,
type: "text",
text: chunk.delta,
} as const
}
}
if (chunk.type === "response.reasoning_text.delta") {
// Handle reasoning content deltas
if (chunk.delta) {
yield {
id: chunk.item_id,
type: "reasoning",
reasoning: chunk.delta,
} as const
}
}
if (chunk.type === "response.function_call_arguments.delta") {
yield {
type: "tool_calls",
tool_call: {
function: {
id: chunk.item_id,
name: chunk.item_id,
arguments: chunk.delta,
},
},
} as const
}
if (chunk.type === "response.function_call_arguments.done") {
// Handle completed function call
if (chunk.item_id && chunk.name && chunk.arguments) {
yield {
type: "tool_calls",
tool_call: {
function: {
id: chunk.item_id,
name: chunk.name,
arguments: chunk.arguments,
},
},
} as const
}
}
if (
chunk.type === "response.incomplete" &&
chunk.response?.status === "incomplete" &&
chunk.response?.incomplete_details?.reason === "max_output_tokens"
) {
console.log("Ran out of tokens")
if (chunk.response?.output_text?.length > 0) {
console.log("Partial output:", chunk.response.output_text)
} else {
console.log("Ran out of tokens during reasoning")
}
}
if (chunk.type === "response.completed" && chunk.response?.usage) {
// Handle usage information when response is complete
const usage = chunk.response.usage
const inputTokens = usage.input_tokens || 0
const outputTokens = usage.output_tokens || 0
const cacheReadTokens = usage.output_tokens_details?.reasoning_tokens || 0
const cacheWriteTokens = usage.input_tokens_details?.cached_tokens || 0
const totalTokens = usage.total_tokens || 0
const totalCost = await calculateCost(modelInfo, inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens)
Logger.log(`Total tokens from Responses API usage: ${totalTokens}`)
const nonCachedInputTokens = Math.max(0, inputTokens - cacheReadTokens - cacheWriteTokens)
yield {
type: "usage",
inputTokens: nonCachedInputTokens,
outputTokens: outputTokens,
cacheWriteTokens: cacheWriteTokens,
cacheReadTokens: cacheReadTokens,
totalCost: totalCost,
id: chunk.response.id,
} as const
}
}
}
+1
View File
@@ -48,6 +48,7 @@ export const toolParamNames = [
"input",
"from_ref",
"to_ref",
"skill_name",
] as const
export type ToolParamName = (typeof toolParamNames)[number]
@@ -0,0 +1,443 @@
/**
* Unit tests for skills utility functions
* Tests skill discovery, override resolution, toggle filtering, and content loading
*/
import { expect } from "chai"
import * as fs from "fs"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as path from "path"
import * as sinon from "sinon"
import * as disk from "@/core/storage/disk"
import * as fsUtils from "@/utils/fs"
import { discoverSkills, getAvailableSkills, getSkillContent } from "../skills"
describe("Skills Utility Functions", () => {
let sandbox: sinon.SinonSandbox
let fileExistsStub: sinon.SinonStub
let isDirectoryStub: sinon.SinonStub
let readdirStub: sinon.SinonStub
let statStub: sinon.SinonStub
let readFileStub: sinon.SinonStub
let ensureSkillsDirStub: sinon.SinonStub
// Use path.join for OS-independent paths
const TEST_CWD = path.join("/test", "project")
const GLOBAL_SKILLS_DIR = path.join("/home", "user", ".cline", "skills")
beforeEach(() => {
sandbox = sinon.createSandbox()
// Stub console.warn to avoid noise in test output
sandbox.stub(console, "warn")
// Stub filesystem utilities
fileExistsStub = sandbox.stub(fsUtils, "fileExistsAtPath")
isDirectoryStub = sandbox.stub(fsUtils, "isDirectory")
readdirStub = sandbox.stub(fs.promises, "readdir")
statStub = sandbox.stub(fs.promises, "stat")
readFileStub = sandbox.stub(fs.promises, "readFile")
ensureSkillsDirStub = sandbox.stub(disk, "ensureSkillsDirectoryExists")
// Default: global skills dir
ensureSkillsDirStub.resolves(GLOBAL_SKILLS_DIR)
// Default: no directories exist
fileExistsStub.resolves(false)
isDirectoryStub.resolves(false)
})
afterEach(() => {
sandbox.restore()
})
describe("discoverSkills", () => {
it("should discover skills from global directory", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: my-skill
description: A test skill
---
Instructions here`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("my-skill")
expect(skills[0].description).to.equal("A test skill")
expect(skills[0].source).to.equal("global")
})
it("should discover skills from project .clinerules/skills directory", async () => {
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
const skillDir = path.join(projectSkillsDir, "explaining-code")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(projectSkillsDir).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(projectSkillsDir).resolves(true)
readdirStub.withArgs(projectSkillsDir).resolves(["explaining-code"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: explaining-code
description: Explains code with diagrams and analogies
---
Use analogies and ASCII diagrams when explaining code.`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("explaining-code")
expect(skills[0].source).to.equal("project")
})
it("should discover skills from project .cline/skills directory", async () => {
const clineSkillsDir = path.join(TEST_CWD, ".cline", "skills")
const skillDir = path.join(clineSkillsDir, "debugging")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(clineSkillsDir).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(clineSkillsDir).resolves(true)
readdirStub.withArgs(clineSkillsDir).resolves(["debugging"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: debugging
description: Debug code systematically
---
Use systematic debugging approaches.`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("debugging")
expect(skills[0].source).to.equal("project")
})
it("should discover skills from project .claude/skills directory", async () => {
const claudeSkillsDir = path.join(TEST_CWD, ".claude", "skills")
const skillDir = path.join(claudeSkillsDir, "coding")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(claudeSkillsDir).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(claudeSkillsDir).resolves(true)
readdirStub.withArgs(claudeSkillsDir).resolves(["coding"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: coding
description: Write clean code
---
Follow best practices.`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("coding")
expect(skills[0].source).to.equal("project")
})
it("should handle empty skills directories gracefully", async () => {
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves([])
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
})
it("should skip non-directory entries in skills folder", async () => {
const readmePath = path.join(GLOBAL_SKILLS_DIR, "README.md")
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["README.md", "my-skill"])
statStub.withArgs(readmePath).resolves({ isDirectory: () => false })
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
fileExistsStub.withArgs(skillMdPath).resolves(true)
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: my-skill
description: A skill
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(1)
expect(skills[0].name).to.equal("my-skill")
})
it("should skip skill directories without SKILL.md", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "incomplete-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["incomplete-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
fileExistsStub.withArgs(skillMdPath).resolves(false)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
})
})
describe("getAvailableSkills - Override Resolution", () => {
it("should override project skill with global skill of same name", async () => {
const globalSkillDir = path.join(GLOBAL_SKILLS_DIR, "coding")
const globalSkillMdPath = path.join(globalSkillDir, "SKILL.md")
// Setup global skill (higher priority)
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(globalSkillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["coding"])
statStub.withArgs(globalSkillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(globalSkillMdPath, "utf-8").resolves(`---
name: coding
description: Global coding skill
---
Global instructions`)
// Setup project skill with same name (lower priority)
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
const projectSkillDir = path.join(projectSkillsDir, "coding")
const projectSkillMdPath = path.join(projectSkillDir, "SKILL.md")
fileExistsStub.withArgs(projectSkillsDir).resolves(true)
fileExistsStub.withArgs(projectSkillMdPath).resolves(true)
isDirectoryStub.withArgs(projectSkillsDir).resolves(true)
readdirStub.withArgs(projectSkillsDir).resolves(["coding"])
statStub.withArgs(projectSkillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(projectSkillMdPath, "utf-8").resolves(`---
name: coding
description: Project coding skill
---
Project instructions`)
const allSkills = await discoverSkills(TEST_CWD)
const skills = getAvailableSkills(allSkills)
expect(skills).to.have.lengthOf(1)
expect(skills[0].description).to.equal("Global coding skill")
expect(skills[0].source).to.equal("global")
})
it("should keep both skills when names are different", async () => {
const globalSkillDir = path.join(GLOBAL_SKILLS_DIR, "global-skill")
const globalSkillMdPath = path.join(globalSkillDir, "SKILL.md")
// Setup global skill
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(globalSkillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["global-skill"])
statStub.withArgs(globalSkillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(globalSkillMdPath, "utf-8").resolves(`---
name: global-skill
description: A global skill
---
Content`)
// Setup project skill with different name
const projectSkillsDir = path.join(TEST_CWD, ".clinerules", "skills")
const projectSkillDir = path.join(projectSkillsDir, "project-skill")
const projectSkillMdPath = path.join(projectSkillDir, "SKILL.md")
fileExistsStub.withArgs(projectSkillsDir).resolves(true)
fileExistsStub.withArgs(projectSkillMdPath).resolves(true)
isDirectoryStub.withArgs(projectSkillsDir).resolves(true)
readdirStub.withArgs(projectSkillsDir).resolves(["project-skill"])
statStub.withArgs(projectSkillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(projectSkillMdPath, "utf-8").resolves(`---
name: project-skill
description: A project skill
---
Content`)
const allSkills = await discoverSkills(TEST_CWD)
const skills = getAvailableSkills(allSkills)
expect(skills).to.have.lengthOf(2)
const names = skills.map((s) => s.name)
expect(names).to.include("global-skill")
expect(names).to.include("project-skill")
})
})
describe("Metadata Validation", () => {
it("should reject skill with missing name field", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "bad-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["bad-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
description: Missing name
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
sinon.assert.calledWithMatch(console.warn as sinon.SinonStub, /missing required 'name' field/)
})
it("should reject skill with missing description field", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "bad-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["bad-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: bad-skill
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
sinon.assert.calledWithMatch(console.warn as sinon.SinonStub, /missing required 'description' field/)
})
it("should reject skill when name doesn't match directory name", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-dir")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-dir"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: different-name
description: Mismatched name
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
sinon.assert.calledWithMatch(console.warn as sinon.SinonStub, /doesn't match directory/)
})
it("should handle malformed YAML frontmatter gracefully", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "bad-yaml")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["bad-yaml"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: [invalid yaml
description: broken
---
Content`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
})
it("should handle file without frontmatter", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "no-front")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["no-front"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`Just plain markdown content without frontmatter`)
const skills = await discoverSkills(TEST_CWD)
expect(skills).to.have.lengthOf(0)
})
})
describe("getSkillContent", () => {
it("should load full skill content with instructions", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: my-skill
description: Test skill
---
These are the detailed instructions.
## Step 1
Do this first.
## Step 2
Then do this.`)
const allSkills = await discoverSkills(TEST_CWD)
const availableSkills = getAvailableSkills(allSkills)
const content = await getSkillContent("my-skill", availableSkills)
expect(content).to.not.be.null
expect(content!.name).to.equal("my-skill")
expect(content!.instructions).to.include("These are the detailed instructions")
expect(content!.instructions).to.include("Step 1")
expect(content!.instructions).to.include("Step 2")
})
it("should return null for non-existent skill", async () => {
const content = await getSkillContent("non-existent", [])
expect(content).to.be.null
})
it("should trim whitespace from instructions", async () => {
const skillDir = path.join(GLOBAL_SKILLS_DIR, "my-skill")
const skillMdPath = path.join(skillDir, "SKILL.md")
fileExistsStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
fileExistsStub.withArgs(skillMdPath).resolves(true)
isDirectoryStub.withArgs(GLOBAL_SKILLS_DIR).resolves(true)
readdirStub.withArgs(GLOBAL_SKILLS_DIR).resolves(["my-skill"])
statStub.withArgs(skillDir).resolves({ isDirectory: () => true })
readFileStub.withArgs(skillMdPath, "utf-8").resolves(`---
name: my-skill
description: Test
---
Instructions with whitespace
`)
const allSkills = await discoverSkills(TEST_CWD)
const availableSkills = getAvailableSkills(allSkills)
const content = await getSkillContent("my-skill", availableSkills)
expect(content!.instructions).to.equal("Instructions with whitespace")
})
})
})
@@ -65,6 +65,7 @@ export const getLocalClineRules = async (cwd: string, toggles: ClineRulesToggles
const rulesFilePaths = await readDirectory(clineRulesFilePath, [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
[".clinerules", "skills"],
])
const rulesFilesTotalContent = await getRuleFilesTotalContent(rulesFilePaths, cwd, toggles)
@@ -110,6 +111,7 @@ export async function refreshClineRulesToggles(
const updatedLocalToggles = await synchronizeRuleToggles(localClineRulesFilePath, localClineRulesToggles, "", [
[".clinerules", "workflows"],
[".clinerules", "hooks"],
[".clinerules", "skills"],
])
controller.stateManager.setWorkspaceState("localClineRulesToggles", updatedLocalToggles)
@@ -0,0 +1,169 @@
import { ensureSkillsDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import type { SkillContent, SkillMetadata } from "@shared/skills"
import { fileExistsAtPath, isDirectory } from "@utils/fs"
import * as fs from "fs/promises"
import * as yaml from "js-yaml"
import * as path from "path"
/**
* Parse YAML frontmatter from markdown content.
*/
function parseFrontmatter(fileContent: string): { data: Record<string, unknown>; content: string } {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = fileContent.match(frontmatterRegex)
if (!match) {
return { data: {}, content: fileContent }
}
const [, yamlContent, body] = match
try {
const data = yaml.load(yamlContent) as Record<string, unknown>
return { data: data || {}, content: body }
} catch (error) {
console.warn("Failed to parse YAML frontmatter:", error)
return { data: {}, content: fileContent }
}
}
/**
* Scan a directory for skill subdirectories containing SKILL.md files.
*/
async function scanSkillsDirectory(dirPath: string, source: "global" | "project"): Promise<SkillMetadata[]> {
const skills: SkillMetadata[] = []
if (!(await fileExistsAtPath(dirPath)) || !(await isDirectory(dirPath))) {
return skills
}
try {
const entries = await fs.readdir(dirPath)
for (const entryName of entries) {
const entryPath = path.join(dirPath, entryName)
const stats = await fs.stat(entryPath).catch(() => null)
if (!stats?.isDirectory()) continue
const skill = await loadSkillMetadata(entryPath, source, entryName)
if (skill) {
skills.push(skill)
}
}
} catch (error: unknown) {
if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "EACCES") {
console.warn(`Permission denied reading skills directory: ${dirPath}`)
}
}
return skills
}
/**
* Load skill metadata from a skill directory.
*/
async function loadSkillMetadata(
skillDir: string,
source: "global" | "project",
skillName: string,
): Promise<SkillMetadata | null> {
const skillMdPath = path.join(skillDir, "SKILL.md")
if (!(await fileExistsAtPath(skillMdPath))) return null
try {
const fileContent = await fs.readFile(skillMdPath, "utf-8")
const { data: frontmatter } = parseFrontmatter(fileContent)
// Validate required fields
if (!frontmatter.name || typeof frontmatter.name !== "string") {
console.warn(`Skill at ${skillDir} missing required 'name' field`)
return null
}
if (!frontmatter.description || typeof frontmatter.description !== "string") {
console.warn(`Skill at ${skillDir} missing required 'description' field`)
return null
}
// Name must match directory name per spec
if (frontmatter.name !== skillName) {
console.warn(`Skill name "${frontmatter.name}" doesn't match directory "${skillName}"`)
return null
}
return {
name: skillName,
description: frontmatter.description,
path: skillMdPath,
source,
}
} catch (error) {
console.warn(`Failed to load skill at ${skillDir}:`, error)
return null
}
}
/**
* Discover all skills from global (~/.cline/skills) and project directories.
* Returns skills in order: project skills first, then global skills.
* Global skills take precedence over project skills with the same name.
*/
export async function discoverSkills(cwd: string): Promise<SkillMetadata[]> {
const skills: SkillMetadata[] = []
const globalSkillsDir = await ensureSkillsDirectoryExists()
const projectDirs = [
path.join(cwd, GlobalFileNames.clineruleSkillsDir),
path.join(cwd, GlobalFileNames.clineSkillsDir),
path.join(cwd, GlobalFileNames.claudeSkillsDir),
]
// Load project skills first (lower priority)
for (const dir of projectDirs) {
const projectSkills = await scanSkillsDirectory(dir, "project")
skills.push(...projectSkills)
}
// Load global skills last (~/.cline/skills) - higher priority
const globalSkills = await scanSkillsDirectory(globalSkillsDir, "global")
skills.push(...globalSkills)
return skills
}
/**
* Get available skills with override resolution (global > project).
*/
export function getAvailableSkills(skills: SkillMetadata[]): SkillMetadata[] {
const seen = new Set<string>()
const result: SkillMetadata[] = []
// Iterate backwards: global skills (added last) are seen first and take precedence
for (let i = skills.length - 1; i >= 0; i--) {
const skill = skills[i]
if (!seen.has(skill.name)) {
seen.add(skill.name)
result.unshift(skill)
}
}
return result
}
/**
* Get full skill content including instructions.
*/
export async function getSkillContent(skillName: string, availableSkills: SkillMetadata[]): Promise<SkillContent | null> {
const skill = availableSkills.find((s) => s.name === skillName)
if (!skill) return null
try {
const fileContent = await fs.readFile(skill.path, "utf-8")
const { content: body } = parseFrontmatter(fileContent)
return {
...skill,
instructions: body.trim(),
}
} catch {
return null
}
}
+105
View File
@@ -0,0 +1,105 @@
import { CreateSkillRequest, SkillsToggles } from "@shared/proto/cline/file"
import fs from "fs/promises"
import path from "path"
import { ensureSkillsDirectoryExists } from "@/core/storage/disk"
import { HostProvider } from "@/hosts/host-provider"
import { ShowMessageType } from "@/shared/proto/host/window"
import { fileExistsAtPath } from "@/utils/fs"
import { Controller } from ".."
import { openFile } from "./openFile"
const SKILL_TEMPLATE = `---
name: {{SKILL_NAME}}
description: Brief description of what this skill does
---
# {{SKILL_NAME}}
Instructions for the AI agent...
## Usage
Describe when and how to use this skill.
## Steps
1. First step
2. Second step
3. Third step
`
/**
* Creates a new skill from template
* @param controller The controller instance
* @param request The request containing skill name and isGlobal flag
* @returns The updated skills toggles
*/
export async function createSkillFile(controller: Controller, request: CreateSkillRequest): Promise<SkillsToggles> {
const { skillName, isGlobal } = request
if (!skillName || typeof skillName !== "string" || typeof isGlobal !== "boolean") {
console.error("createSkillFile: Missing or invalid parameters", {
skillName: typeof skillName === "string" ? skillName : `Invalid: ${typeof skillName}`,
isGlobal: typeof isGlobal === "boolean" ? isGlobal : `Invalid: ${typeof isGlobal}`,
})
throw new Error("Missing or invalid parameters for createSkillFile")
}
// Validate skill name (must be valid directory name)
const sanitizedName = skillName.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase()
if (!sanitizedName) {
throw new Error("Invalid skill name")
}
let skillDir: string
if (isGlobal) {
const globalSkillsDir = await ensureSkillsDirectoryExists()
skillDir = path.join(globalSkillsDir, sanitizedName)
} else {
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const primaryWorkspace = workspacePaths.paths[0]
if (!primaryWorkspace) {
throw new Error("No workspace folder open")
}
// Create in .cline/skills by default
const localSkillsDir = path.join(primaryWorkspace, ".cline", "skills")
await fs.mkdir(localSkillsDir, { recursive: true })
skillDir = path.join(localSkillsDir, sanitizedName)
}
// Check if skill already exists
if (await fileExistsAtPath(skillDir)) {
await HostProvider.window.showMessage({
type: ShowMessageType.WARNING,
message: `Skill "${sanitizedName}" already exists`,
})
// Return current toggles
const globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
const localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
return SkillsToggles.create({
globalSkillsToggles: globalToggles,
localSkillsToggles: localToggles,
})
}
// Create skill directory
await fs.mkdir(skillDir, { recursive: true })
// Create SKILL.md from template
const skillMdPath = path.join(skillDir, "SKILL.md")
const content = SKILL_TEMPLATE.replace(/\{\{SKILL_NAME\}\}/g, sanitizedName)
await fs.writeFile(skillMdPath, content, "utf-8")
// Open the file for editing
await openFile(controller, { value: skillMdPath })
// Return current toggles (new skill defaults to enabled)
const globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
const localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
return SkillsToggles.create({
globalSkillsToggles: globalToggles,
localSkillsToggles: localToggles,
})
}
@@ -0,0 +1,62 @@
import { DeleteSkillRequest, SkillsToggles } from "@shared/proto/cline/file"
import fs from "fs/promises"
import path from "path"
import { fileExistsAtPath } from "@/utils/fs"
import { Controller } from ".."
/**
* Deletes an existing skill directory
* @param controller The controller instance
* @param request The request containing skill path and isGlobal flag
* @returns The updated skills toggles
*/
export async function deleteSkillFile(controller: Controller, request: DeleteSkillRequest): Promise<SkillsToggles> {
const { skillPath, isGlobal } = request
if (!skillPath || typeof skillPath !== "string" || typeof isGlobal !== "boolean") {
console.error("deleteSkillFile: Missing or invalid parameters", {
skillPath: typeof skillPath === "string" ? skillPath : `Invalid: ${typeof skillPath}`,
isGlobal: typeof isGlobal === "boolean" ? isGlobal : `Invalid: ${typeof isGlobal}`,
})
throw new Error("Missing or invalid parameters for deleteSkillFile")
}
// Get the skill directory (skillPath points to SKILL.md, so get parent)
const skillDir = path.dirname(skillPath)
// Verify the path exists
if (!(await fileExistsAtPath(skillDir))) {
console.warn(`deleteSkillFile: Skill directory not found: ${skillDir}`)
// Return current toggles anyway
const globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
const localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
return SkillsToggles.create({
globalSkillsToggles: globalToggles,
localSkillsToggles: localToggles,
})
}
// Delete the skill directory
await fs.rm(skillDir, { recursive: true, force: true })
// Remove from toggles
let globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
let localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
if (isGlobal) {
const { [skillPath]: _, ...remaining } = globalToggles
globalToggles = remaining
controller.stateManager.setGlobalState("globalSkillsToggles", globalToggles)
} else {
const { [skillPath]: _, ...remaining } = localToggles
localToggles = remaining
controller.stateManager.setWorkspaceState("localSkillsToggles", localToggles)
}
await controller.postStateToWebview()
return SkillsToggles.create({
globalSkillsToggles: globalToggles,
localSkillsToggles: localToggles,
})
}
+131
View File
@@ -0,0 +1,131 @@
import { RefreshedSkills, SkillInfo } from "@shared/proto/cline/file"
import fs from "fs/promises"
import path from "path"
import { ensureSkillsDirectoryExists } from "@/core/storage/disk"
import { HostProvider } from "@/hosts/host-provider"
import { fileExistsAtPath, isDirectory } from "@/utils/fs"
import { Controller } from ".."
/**
* Parse YAML frontmatter from markdown content.
*/
function parseFrontmatter(fileContent: string): { data: Record<string, unknown>; content: string } {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = fileContent.match(frontmatterRegex)
if (!match) {
return { data: {}, content: fileContent }
}
const [, yamlContent, body] = match
// Simple YAML parsing for name and description
const data: Record<string, unknown> = {}
const lines = yamlContent.split("\n")
for (const line of lines) {
const colonIndex = line.indexOf(":")
if (colonIndex > 0) {
const key = line.slice(0, colonIndex).trim()
const value = line
.slice(colonIndex + 1)
.trim()
.replace(/^["']|["']$/g, "")
data[key] = value
}
}
return { data, content: body }
}
/**
* Scan a directory for skill subdirectories containing SKILL.md files.
*/
async function scanSkillsDirectory(dirPath: string): Promise<SkillInfo[]> {
const skills: SkillInfo[] = []
if (!(await fileExistsAtPath(dirPath)) || !(await isDirectory(dirPath))) {
return skills
}
try {
const entries = await fs.readdir(dirPath)
for (const entryName of entries) {
const entryPath = path.join(dirPath, entryName)
const stats = await fs.stat(entryPath).catch(() => null)
if (!stats?.isDirectory()) continue
const skillMdPath = path.join(entryPath, "SKILL.md")
if (!(await fileExistsAtPath(skillMdPath))) continue
try {
const fileContent = await fs.readFile(skillMdPath, "utf-8")
const { data: frontmatter } = parseFrontmatter(fileContent)
// Validate required fields
if (!frontmatter.name || typeof frontmatter.name !== "string") continue
if (!frontmatter.description || typeof frontmatter.description !== "string") continue
if (frontmatter.name !== entryName) continue
skills.push(
SkillInfo.create({
name: entryName,
description: frontmatter.description,
path: skillMdPath,
enabled: true, // Will be updated with toggle state
}),
)
} catch {
// Skip invalid skills
}
}
} catch {
// Directory read error, skip
}
return skills
}
/**
* Refreshes all skill toggles (discovers skills and their enabled state)
*/
export async function refreshSkills(controller: Controller): Promise<RefreshedSkills> {
const globalSkillsDir = await ensureSkillsDirectoryExists()
// Get workspace paths for local skills
const workspacePaths = await HostProvider.workspace.getWorkspacePaths({})
const primaryWorkspace = workspacePaths.paths[0]
// Scan global skills
const globalSkills = await scanSkillsDirectory(globalSkillsDir)
// Get global toggles and apply them
const globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
for (const skill of globalSkills) {
skill.enabled = globalToggles[skill.path] !== false
}
// Scan local skills from all possible directories
const localSkills: SkillInfo[] = []
if (primaryWorkspace) {
const localDirs = [
path.join(primaryWorkspace, ".clinerules", "skills"),
path.join(primaryWorkspace, ".cline", "skills"),
path.join(primaryWorkspace, ".claude", "skills"),
]
for (const dir of localDirs) {
const skills = await scanSkillsDirectory(dir)
localSkills.push(...skills)
}
}
// Get local toggles and apply them
const localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
for (const skill of localSkills) {
skill.enabled = localToggles[skill.path] !== false
}
return RefreshedSkills.create({
globalSkills,
localSkills,
})
}
+39
View File
@@ -0,0 +1,39 @@
import { SkillsToggles, ToggleSkillRequest } from "@shared/proto/cline/file"
import { Controller } from ".."
/**
* Toggles a skill on or off
* @param controller The controller instance
* @param request The request containing the skill path and enabled state
* @returns The updated skills toggles
*/
export async function toggleSkill(controller: Controller, request: ToggleSkillRequest): Promise<SkillsToggles> {
const { skillPath, isGlobal, enabled } = request
if (!skillPath || typeof enabled !== "boolean" || typeof isGlobal !== "boolean") {
console.error("toggleSkill: Missing or invalid parameters", {
skillPath,
isGlobal,
enabled: typeof enabled === "boolean" ? enabled : `Invalid: ${typeof enabled}`,
})
throw new Error("Missing or invalid parameters for toggleSkill")
}
let globalToggles = controller.stateManager.getGlobalSettingsKey("globalSkillsToggles") || {}
let localToggles = controller.stateManager.getWorkspaceStateKey("localSkillsToggles") || {}
if (isGlobal) {
globalToggles = { ...globalToggles, [skillPath]: enabled }
controller.stateManager.setGlobalState("globalSkillsToggles", globalToggles)
} else {
localToggles = { ...localToggles, [skillPath]: enabled }
controller.stateManager.setWorkspaceState("localSkillsToggles", localToggles)
}
await controller.postStateToWebview()
return SkillsToggles.create({
globalSkillsToggles: globalToggles,
localSkillsToggles: localToggles,
})
}
+6
View File
@@ -831,6 +831,8 @@ export class Controller {
const enableCheckpointsSetting = this.stateManager.getGlobalSettingsKey("enableCheckpointsSetting")
const globalClineRulesToggles = this.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
const globalWorkflowToggles = this.stateManager.getGlobalSettingsKey("globalWorkflowToggles")
const globalSkillsToggles = this.stateManager.getGlobalSettingsKey("globalSkillsToggles")
const localSkillsToggles = this.stateManager.getWorkspaceStateKey("localSkillsToggles")
const remoteRulesToggles = this.stateManager.getGlobalStateKey("remoteRulesToggles")
const remoteWorkflowToggles = this.stateManager.getGlobalStateKey("remoteWorkflowToggles")
const shellIntegrationTimeout = this.stateManager.getGlobalSettingsKey("shellIntegrationTimeout")
@@ -851,6 +853,7 @@ export class Controller {
const lastDismissedModelBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedModelBannerVersion") || 0
const lastDismissedCliBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedCliBannerVersion") || 0
const subagentsEnabled = this.stateManager.getGlobalSettingsKey("subagentsEnabled")
const skillsEnabled = this.stateManager.getGlobalSettingsKey("skillsEnabled")
const localClineRulesToggles = this.stateManager.getWorkspaceStateKey("localClineRulesToggles")
const localWindsurfRulesToggles = this.stateManager.getWorkspaceStateKey("localWindsurfRulesToggles")
@@ -914,6 +917,8 @@ export class Controller {
localAgentsRulesToggles: localAgentsRulesToggles || {},
localWorkflowToggles: workflowToggles || {},
globalWorkflowToggles: globalWorkflowToggles || {},
globalSkillsToggles: globalSkillsToggles || {},
localSkillsToggles: localSkillsToggles || {},
remoteRulesToggles: remoteRulesToggles,
remoteWorkflowToggles: remoteWorkflowToggles,
shellIntegrationTimeout,
@@ -955,6 +960,7 @@ export class Controller {
nativeToolCallSetting: this.stateManager.getGlobalStateKey("nativeToolCallEnabled"),
enableParallelToolCalling: this.stateManager.getGlobalSettingsKey("enableParallelToolCalling"),
backgroundEditEnabled: this.stateManager.getGlobalSettingsKey("backgroundEditEnabled"),
skillsEnabled,
}
}
+38 -2
View File
@@ -1,9 +1,14 @@
import { StringRequest } from "@shared/proto/cline/common"
import { OcaCompatibleModelInfo, OcaModelInfo } from "@shared/proto/cline/models"
import { ApiFormat, OcaCompatibleModelInfo, OcaModelInfo } from "@shared/proto/cline/models"
import axios from "axios"
import { HostProvider } from "@/hosts/host-provider"
import { OcaAuthService } from "@/services/auth/oca/OcaAuthService"
import { DEFAULT_EXTERNAL_OCA_BASE_URL, DEFAULT_INTERNAL_OCA_BASE_URL } from "@/services/auth/oca/utils/constants"
import {
CHAT_COMPLETIONS_API,
DEFAULT_EXTERNAL_OCA_BASE_URL,
DEFAULT_INTERNAL_OCA_BASE_URL,
RESPONSES_API,
} from "@/services/auth/oca/utils/constants"
import { createOcaHeaders } from "@/services/auth/oca/utils/utils"
import { Logger } from "@/services/logging/Logger"
import { getAxiosSettings } from "@/shared/net"
@@ -57,6 +62,11 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
defaultModelId = modelId
}
const modelInfo = model.model_info
const supportedApiList = modelInfo.supported_api_list ?? [CHAT_COMPLETIONS_API]
const apiFormat: ApiFormat = supportedApiList.includes(RESPONSES_API)
? ApiFormat.OPENAI_RESPONSES
: ApiFormat.OPENAI_CHAT
console.log(modelId, supportedApiList)
models[modelId] = OcaModelInfo.create({
maxTokens: model.litellm_params?.max_tokens || -1,
contextWindow: modelInfo.context_window,
@@ -73,6 +83,9 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
temperature: modelInfo.temperature || 0,
banner: modelInfo.banner,
modelName: modelId,
apiFormat: apiFormat,
supportsReasoning: modelInfo.is_reasoning_model || false,
reasoningEffortOptions: modelInfo.reasoning_effort_options || [],
})
}
console.log("OCA models fetched", models)
@@ -91,6 +104,25 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
? apiConfiguration.actModeOcaModelId
: defaultModelId!
let planModeOcaReasoningEffort
let actModeOcaReasoningEffort
if (
models[planModeSelectedModelId].supportsReasoning &&
models[planModeSelectedModelId].reasoningEffortOptions.length > 0
) {
planModeOcaReasoningEffort = apiConfiguration.planModeOcaReasoningEffort
? apiConfiguration.planModeOcaReasoningEffort
: models[planModeSelectedModelId].reasoningEffortOptions[0]
}
if (
models[actModeSelectedModelId].supportsReasoning &&
models[actModeSelectedModelId].reasoningEffortOptions.length > 0
) {
actModeOcaReasoningEffort = apiConfiguration.actModeOcaReasoningEffort
? apiConfiguration.actModeOcaReasoningEffort
: models[actModeSelectedModelId].reasoningEffortOptions[0]
}
// Build updates object based on plan/act mode setting
const updates: Partial<GlobalStateAndSettings> = {}
@@ -98,15 +130,19 @@ export async function refreshOcaModels(controller: Controller, request: StringRe
if (currentMode === "plan") {
updates.planModeOcaModelId = planModeSelectedModelId
updates.planModeOcaModelInfo = models[planModeSelectedModelId]
updates.planModeOcaReasoningEffort = planModeOcaReasoningEffort
} else {
updates.actModeOcaModelId = actModeSelectedModelId
updates.actModeOcaModelInfo = models[actModeSelectedModelId]
updates.actModeOcaReasoningEffort = actModeOcaReasoningEffort
}
} else {
updates.planModeOcaModelId = planModeSelectedModelId
updates.planModeOcaModelInfo = models[planModeSelectedModelId]
updates.planModeOcaReasoningEffort = planModeOcaReasoningEffort
updates.actModeOcaModelId = actModeSelectedModelId
updates.actModeOcaModelInfo = models[actModeSelectedModelId]
updates.actModeOcaReasoningEffort = actModeOcaReasoningEffort
}
// Update state directly using batch method
@@ -7,6 +7,73 @@ import path from "path"
import { getAxiosSettings } from "@/shared/net"
import { Controller } from ".."
/**
* Derives thinkingConfig from model ID and tags.
* The Vercel API only provides a "reasoning" tag to indicate support,
* so we derive the specific configuration based on model patterns.
*/
function deriveThinkingConfig(modelId: string, tags?: string[]): ModelInfo["thinkingConfig"] {
if (!tags?.includes("reasoning")) {
return undefined
}
// Anthropic Claude models
if (modelId.startsWith("anthropic/claude")) {
return { maxBudget: 8192 }
}
// Google Gemini models
if (modelId.includes("gemini-3")) {
return {
maxBudget: 32767,
supportsThinkingLevel: true,
geminiThinkingLevel: "high",
}
}
// DeepSeek R1 models
if (modelId.startsWith("deepseek/deepseek-r1")) {
return { maxBudget: 8192 }
}
// OpenAI o-series reasoning models
if (modelId.startsWith("openai/o1") || modelId.startsWith("openai/o3")) {
return { maxBudget: 32000 }
}
// Qwen QwQ models (specific IDs to match OpenRouter)
if (modelId === "qwen/qwq-32b:free" || modelId === "qwen/qwq-32b") {
return { maxBudget: 32000 }
}
// Default for other reasoning models
return { maxBudget: 32000 }
}
/**
* Derives recommended temperature for specific model types.
* Returns undefined to use the default (0).
*/
function deriveTemperature(modelId: string): number | undefined {
// DeepSeek R1 and similar reasoning models recommend 0.7
// Use specific model IDs to match OpenRouter behavior
if (
modelId.startsWith("deepseek/deepseek-r1") ||
modelId === "perplexity/sonar-reasoning" ||
modelId === "qwen/qwq-32b:free" ||
modelId === "qwen/qwq-32b"
) {
return 0.7
}
// Gemini 3.0 recommends temperature 1.0
if (modelId.startsWith("google/gemini-3.0") || modelId === "google/gemini-3.0") {
return 1.0
}
return undefined
}
/**
* Core function: Refreshes Vercel AI Gateway models and returns application types
* @param _controller The controller instance (unused)
@@ -18,7 +85,7 @@ export async function refreshVercelAiGatewayModels(_controller: Controller): Pro
let models: Record<string, ModelInfo> = {}
try {
const response = await axios.get("https://ai-gateway.vercel.sh/v1/models", getAxiosSettings())
const response = await axios.get("https://ai-gateway.vercel.sh/v1/models?include_mappings=true", getAxiosSettings())
if (response.data?.data) {
const rawModels = response.data.data
@@ -44,6 +111,8 @@ export async function refreshVercelAiGatewayModels(_controller: Controller): Pro
supportsImages: true, // assume all models support images since vercel ai doesn't give this info
supportsPromptCache: !!(rawModel.pricing?.input_cache_read && rawModel.pricing?.input_cache_write),
description: rawModel.description ?? "",
thinkingConfig: deriveThinkingConfig(rawModel.id, rawModel.tags),
temperature: deriveTemperature(rawModel.id),
}
models[rawModel.id] = modelInfo
@@ -363,6 +363,10 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("subagentsEnabled", !!request.subagentsEnabled)
}
if (request.skillsEnabled !== undefined) {
controller.stateManager.setGlobalState("skillsEnabled", !!request.skillsEnabled)
}
if (request.nativeToolCallEnabled !== undefined) {
controller.stateManager.setGlobalState("nativeToolCallEnabled", !!request.nativeToolCallEnabled)
if (controller.task) {
@@ -1,57 +0,0 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
import { Controller } from "../index"
// Keep track of active didBecomeVisible subscriptions
const activeDidBecomeVisibleSubscriptions = new Set<StreamingResponseHandler<Empty>>()
/**
* Subscribe to didBecomeVisible events
* @param controller The controller instance
* @param request The empty request
* @param responseStream The streaming response handler
* @param requestId The ID of the request (passed by the gRPC handler)
*/
export async function subscribeToDidBecomeVisible(
_controller: Controller,
_request: EmptyRequest,
responseStream: StreamingResponseHandler<Empty>,
requestId?: string,
): Promise<void> {
console.log(`[DEBUG] set up didBecomeVisible subscription`)
// Add this subscription to the active subscriptions
activeDidBecomeVisibleSubscriptions.add(responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
activeDidBecomeVisibleSubscriptions.delete(responseStream)
}
// Register the cleanup function with the request registry if we have a requestId
if (requestId) {
getRequestRegistry().registerRequest(requestId, cleanup, { type: "didBecomeVisible_subscription" }, responseStream)
}
}
/**
* Send a didBecomeVisible event to all active subscribers
*/
export async function sendDidBecomeVisibleEvent(): Promise<void> {
// Send the event to all active subscribers
const promises = Array.from(activeDidBecomeVisibleSubscriptions).map(async (responseStream) => {
try {
const event = Empty.create({})
await responseStream(
event,
false, // Not the last message
)
} catch (error) {
console.error("Error sending didBecomeVisible event:", error)
// Remove the subscription if there was an error
activeDidBecomeVisibleSubscriptions.delete(responseStream)
}
})
await Promise.all(promises)
}
@@ -1,55 +0,0 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
import type { Controller } from "../index"
// Keep track of active focus chat input subscriptions
const focusChatInputSubscriptions = new Set<StreamingResponseHandler<Empty>>()
/**
* Subscribe to focus chat input events
* @param controller The controller instance
* @param request The empty request
* @param responseStream The streaming response handler
* @param requestId The ID of the request
*/
export async function subscribeToFocusChatInput(
_controller: Controller,
_request: EmptyRequest,
responseStream: StreamingResponseHandler<Empty>,
requestId?: string,
): Promise<void> {
// Add this subscription to the active subscriptions
focusChatInputSubscriptions.add(responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
focusChatInputSubscriptions.delete(responseStream)
}
// Register the cleanup function with the request registry if we have a requestId
if (requestId) {
getRequestRegistry().registerRequest(requestId, cleanup, { type: "focus_chat_input_subscription" }, responseStream)
}
}
/**
* Send a focus chat input event to all active subscribers
*/
export async function sendFocusChatInputEvent(): Promise<void> {
// Send the event to all active subscribers
const promises = Array.from(focusChatInputSubscriptions).map(async (responseStream) => {
try {
const event = Empty.create({})
await responseStream(
event,
false, // Not the last message
)
} catch (error) {
console.error("Error sending focus chat input event:", error)
// Remove the subscription if there was an error
focusChatInputSubscriptions.delete(responseStream)
}
})
await Promise.all(promises)
}
@@ -0,0 +1,57 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { ShowWebviewEvent } from "@shared/proto/cline/ui"
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
import type { Controller } from "../index"
// Keep track of active show webview subscriptions
const showWebviewSubscriptions = new Set<StreamingResponseHandler<ShowWebviewEvent>>()
/**
* Subscribe to show webview events
* @param controller The controller instance
* @param request The show webview request containing preserveEditorFocus flag
* @param responseStream The streaming response handler
* @param requestId The ID of the request
*/
export async function subscribeToShowWebview(
_controller: Controller,
_request: EmptyRequest,
responseStream: StreamingResponseHandler<ShowWebviewEvent>,
requestId?: string,
): Promise<void> {
// Add this subscription to the active subscriptions
showWebviewSubscriptions.add(responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
showWebviewSubscriptions.delete(responseStream)
}
// Register the cleanup function with the request registry if we have a requestId
if (requestId) {
getRequestRegistry().registerRequest(requestId, cleanup, { type: "show_webview_subscription" }, responseStream)
}
}
/**
* Send a show webview event to all active subscribers
* @param preserveEditorFocus When true, the webview should not steal focus from the editor
*/
export async function sendShowWebviewEvent(preserveEditorFocus: boolean = false): Promise<void> {
// Send the event to all active subscribers
const promises = Array.from(showWebviewSubscriptions).map(async (responseStream) => {
try {
const event = ShowWebviewEvent.create({ preserveEditorFocus })
await responseStream(
event,
false, // Not the last message
)
} catch (error) {
console.error("Error sending show webview event:", error)
// Remove the subscription if there was an error
showWebviewSubscriptions.delete(responseStream)
}
})
await Promise.all(promises)
}
+55 -19
View File
@@ -1,4 +1,6 @@
import type { HookOutputStreamMeta } from "@shared/ExtensionMessage"
import { ClineMessage } from "@shared/ExtensionMessage"
import type { HookOutput } from "@shared/proto/cline/hooks"
import { MessageStateHandler } from "../task/message-state"
import { HookExecutionError } from "./HookError"
import { HookFactory } from "./hook-factory"
@@ -32,6 +34,20 @@ export interface HookExecutionResult {
wasCancelled: boolean
}
function fromHookOutput(output: HookOutput): HookExecutionResult {
// HookOutput is protobuf-generated, so fields are defaulted (e.g. ""). Treat empty
// strings as “unset” in the hook executor API.
const contextModification = output.contextModification?.trim() ? output.contextModification : undefined
const errorMessage = output.errorMessage?.trim() ? output.errorMessage : undefined
return {
cancel: output.cancel,
contextModification,
errorMessage,
wasCancelled: false,
}
}
/**
* Executes a hook with standardized error handling, status tracking, and cleanup.
* This consolidates the common pattern used across all hook execution sites.
@@ -61,23 +77,29 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
const hasHook = await hookFactory.hasHook(hookName)
if (!hasHook) {
return {
wasCancelled: false,
}
return { wasCancelled: false }
}
let hookMessageTs: number | undefined
const abortController = new AbortController()
// Declare hookInfo with empty default - populated inside try block.
// If getHookInfo throws, error handlers will use the empty default.
let hookInfo: { scriptPaths: string[] } = { scriptPaths: [] }
try {
// Get hook info including script paths
hookInfo = await hookFactory.getHookInfo(hookName)
// Show hook execution indicator and capture timestamp
const hookMetadata = {
hookName,
...(options.toolName && { toolName: options.toolName }),
status: "running",
scriptPaths: hookInfo.scriptPaths,
...(options.pendingToolInfo && { pendingToolInfo: options.pendingToolInfo }),
}
hookMessageTs = await say("hook", JSON.stringify(hookMetadata))
hookMessageTs = await say("hook_status", JSON.stringify(hookMetadata))
// Reorder messages immediately so hook UI appears above tool UI
// This must happen right after creating the hook message, before the hook runs
@@ -96,8 +118,23 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
}
// Create streaming callback
const streamCallback = async (line: string) => {
await say("hook_output", line)
const streamCallback = async (line: string, stream: "stdout" | "stderr", meta?: HookOutputStreamMeta) => {
// Preserve script identity for multi-hook (global + workspace) scenarios.
// Without this, concurrent hooks interleave output and it's hard to tell which
// script produced which line (and can look like only one hook is printing).
//
// NOTE: We keep backward compatibility by encoding metadata into the string.
// The CLI prints this as-is in verbose mode.
const prefixParts: string[] = []
if (meta?.source) prefixParts.push(meta.source)
prefixParts.push(stream)
// Use a shortened path for readability; full path is still available in hook_status.
if (meta?.scriptPath) {
const parts = meta.scriptPath.split(/[/\\]/).filter(Boolean)
prefixParts.push(parts.slice(-3).join("/"))
}
const prefix = prefixParts.length ? `[${prefixParts.join(" ")}] ` : ""
await say("hook_output_stream", prefix + line)
}
// Create and execute hook
@@ -116,6 +153,11 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
console.log(`[${hookName} Hook]`, result)
// NoOp hooks return proto defaults; preserve the minimal legacy return shape.
if (result.cancel === false && result.contextModification === "" && result.errorMessage === "") {
return { wasCancelled: false }
}
// Check if hook wants to cancel
if (result.cancel === true) {
// Update hook status to cancelled
@@ -126,15 +168,11 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
status: "cancelled",
exitCode: 130,
hasJsonResponse: true,
scriptPaths: hookInfo.scriptPaths,
})
}
return {
cancel: true,
contextModification: result.contextModification,
errorMessage: result.errorMessage,
wasCancelled: false,
}
return fromHookOutput(result)
}
// Clear active hook execution after successful completion (only if cancellable)
@@ -150,15 +188,11 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
status: "completed",
exitCode: 0,
hasJsonResponse: true,
scriptPaths: hookInfo.scriptPaths,
})
}
return {
cancel: result.cancel,
contextModification: result.contextModification,
errorMessage: result.errorMessage,
wasCancelled: false,
}
return fromHookOutput(result)
} catch (hookError) {
// Clear active hook execution (only if cancellable)
if (isCancellable && clearActiveHookExecution) {
@@ -173,6 +207,7 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
hookName,
status: "cancelled",
exitCode: 130,
scriptPaths: hookInfo.scriptPaths,
})
}
@@ -192,6 +227,7 @@ export async function executeHook<Name extends keyof Hooks>(options: HookExecuti
hookName,
status: "failed",
exitCode: errorInfo?.exitCode ?? 1,
scriptPaths: hookInfo.scriptPaths,
...(errorInfo && {
error: {
type: errorInfo.type,
@@ -266,7 +302,7 @@ async function reorderHookAndToolMessages(messageStateHandler: MessageStateHandl
// Check if there are any hook messages after the tool message
let hasHookMessagesAfterTool = false
for (let i = lastToolMessageIndex + 1; i < clineMessages.length; i++) {
if (clineMessages[i].say === "hook" || clineMessages[i].say === "hook_output") {
if (clineMessages[i].say === "hook_status" || clineMessages[i].say === "hook_output_stream") {
hasHookMessagesAfterTool = true
break
}
+32 -5
View File
@@ -215,16 +215,23 @@ class NoOpRunner<Name extends HookName> extends HookRunner<Name> {
* @returns A successful hook output (no cancellation)
*/
override async [exec](_: HookInput): Promise<HookOutput> {
return HookOutput.create({
cancel: false,
})
// HookOutput is a protobuf-generated type with non-optional fields.
// Protobuf defaults: cancel=false, contextModification="", errorMessage=""
return HookOutput.create({ cancel: false })
}
}
/**
* Callback type for streaming hook output
*/
export type HookStreamCallback = (line: string, stream: "stdout" | "stderr") => void
export type HookStreamCallback = (
line: string,
stream: "stdout" | "stderr",
meta?: {
source: "global" | "workspace"
scriptPath: string
},
) => void
/**
* Executes a hook script as a child process with real-time output streaming.
@@ -300,7 +307,12 @@ class StdioHookRunner<Name extends HookName> extends HookRunner<Name> {
if (this.streamCallback) {
const callback = this.streamCallback
hookProcess.on("line", (line: string, stream: "stdout" | "stderr") => {
callback(line, stream)
// NOTE: HookProcess emits a synthetic empty line (""), used as a "start of output" marker.
// Preserve it for now so downstream can keep existing behavior.
callback(line, stream, {
source: this.source,
scriptPath: this.scriptPath,
})
})
}
@@ -687,6 +699,21 @@ function isExpectedHookError(error: unknown): boolean {
}
export class HookFactory {
/**
* Get information about discovered hooks including their script paths
* @param hookName The type of hook to query
* @returns Object containing array of script paths
*/
async getHookInfo<Name extends HookName>(
hookName: Name,
): Promise<{
scriptPaths: string[]
}> {
const { HookDiscoveryCache } = await import("./HookDiscoveryCache")
const scripts = await HookDiscoveryCache.getInstance().get(hookName)
return { scriptPaths: scripts }
}
/**
* Check if any hook scripts exist for the given hook name
* @returns true if at least one hook script exists, false otherwise
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,292 @@
import { ParseEntry, parse } from "shell-quote"
import { COMMAND_PERMISSIONS_ENV_VAR, CommandPermissionConfig, PermissionValidationResult, ShellOperatorMatch } from "./types"
const OPERATOR_DESCRIPTIONS: Record<string, string> = {
";": "command chaining (semicolon)",
"&&": "command chaining (AND)",
"||": "command chaining (OR)",
"|": "pipe",
">": "output redirection",
">>": "append redirection",
"<": "input redirection",
">&": "file descriptor redirection",
"<&": "file descriptor duplication",
"|&": "pipe with stderr",
}
const LINE_SEPARATOR_REGEX = /[\n\r\u2028\u2029\u0085]/
const LINE_SEPARATOR_DESCRIPTIONS: Record<string, ShellOperatorMatch> = {
"\n": { operator: "\\n", description: "newline (command separator)" },
"\r": { operator: "\\r", description: "carriage return (potential command separator)" },
"\u2028": { operator: "U+2028", description: "unicode line separator" },
"\u2029": { operator: "U+2029", description: "unicode paragraph separator" },
"\u0085": { operator: "U+0085", description: "unicode next line" },
}
/**
* Controls command execution permissions based on environment variable configuration.
* Uses glob pattern matching to allow/deny specific commands.
*
* Configuration is read from the CLINE_COMMAND_PERMISSIONS environment variable.
* Format: {"allow": ["pattern1", "pattern2"], "deny": ["pattern3"]}
*
* Rule evaluation:
* 1. If shell operators are detected outside quotes → DENIED (security)
* 2. If deny rules are defined and command matches a deny pattern → DENIED
* 3. If allow rules are defined and command matches an allow pattern → ALLOWED
* 4. If allow rules are defined but command doesn't match any → DENIED (deny by default)
* 5. If no rules are defined (env var not set) → ALLOWED (backward compatibility)
*/
export class CommandPermissionController {
private config: CommandPermissionConfig | null = null
constructor() {
this.config = this.parseConfig()
}
/**
* Parse the CLINE_COMMAND_PERMISSIONS environment variable
* @returns Parsed configuration or null if not set or invalid
*/
private parseConfig(): CommandPermissionConfig | null {
const envValue = process.env[COMMAND_PERMISSIONS_ENV_VAR]
if (!envValue) {
return null
}
try {
const parsed = JSON.parse(envValue)
return {
allow: Array.isArray(parsed.allow) ? parsed.allow : undefined,
deny: Array.isArray(parsed.deny) ? parsed.deny : undefined,
allowOperators: Array.isArray(parsed.allowOperators) ? parsed.allowOperators : undefined,
}
} catch (error) {
console.error(`Failed to parse ${COMMAND_PERMISSIONS_ENV_VAR}:`, error)
return null
}
}
/**
* Check if an operator is in the allowOperators list
* @param operator - The operator to check
* @returns true if the operator is allowed
*/
private isOperatorAllowed(operator: string): boolean {
return Boolean(this.config?.allowOperators?.includes(operator))
}
/**
* Validate if a command is allowed to execute based on configured permissions
* @param command - The command string to validate
* @returns PermissionValidationResult indicating if command is allowed and why
*/
validateCommand(command: string): PermissionValidationResult {
// No config = allow everything (backward compatibility)
if (!this.config) {
return { allowed: true, reason: "no_config" }
}
// Check for shell operators FIRST (security check)
const shellOperator = this.detectShellOperator(command)
if (shellOperator) {
return {
allowed: false,
reason: "shell_operator_detected",
detectedOperator: shellOperator.operator,
}
}
// Check deny rules first (deny takes precedence)
if (this.config.deny) {
for (const pattern of this.config.deny) {
if (this.matchesPattern(command, pattern)) {
return { allowed: false, matchedPattern: pattern, reason: "denied" }
}
}
}
// Check allow rules
if (this.config.allow && this.config.allow.length > 0) {
for (const pattern of this.config.allow) {
if (this.matchesPattern(command, pattern)) {
return { allowed: true, matchedPattern: pattern, reason: "allowed" }
}
}
// Allow rules defined but no match = deny by default
return { allowed: false, reason: "no_match_deny_default" }
}
// No allow rules defined, and no deny matched = allow
return { allowed: true, reason: "no_config" }
}
/**
* Check if a command matches a wildcard pattern.
*
* Uses simple wildcard matching where `*` matches any characters (including `/` and newlines).
* This is different from file glob matching where `*` doesn't cross directory boundaries.
* For command permission matching, we want `*` to match any sequence of characters
* so that patterns like `gh pr comment *` match `gh pr comment 123 --body-file /tmp/file.txt`
* or commands with multiline arguments like `gh pr comment 123 --body "line1\nline2"`.
*
* Supported patterns:
* - `*` matches any sequence of characters (including / and newlines)
* - `?` matches exactly one character
*
* @param command - The command to check
* @param pattern - The wildcard pattern to match against
* @returns true if command matches the pattern
*/
private matchesPattern(command: string, pattern: string): boolean {
const regex = new RegExp(
"^" +
pattern
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape special regex chars
.replace(/\*/g, ".*") // * becomes .*
.replace(/\?/g, ".") + // ? becomes .
"$",
"s", // s flag enables dotAll (. matches newlines)
)
return regex.test(command)
}
/**
* Detect shell operators using shell-quote parser.
* This prevents command chaining/injection attacks like:
* gh pr view 123; rm -rf /
* gh pr view 123 && malicious_command
* gh pr view $(malicious_command)
*
* Operators inside quotes are allowed (they're literal characters):
* echo "hello; world" # OK - semicolon is inside quotes
*
* @param command - The command string to check
* @returns ShellOperatorMatch if an operator is found outside quotes, null otherwise
*/
private detectShellOperator(command: string): ShellOperatorMatch | null {
const dangerousCharMatch = this.detectDangerousCharsOutsideQuotes(command)
if (dangerousCharMatch) {
return dangerousCharMatch
}
try {
// Parse the command using shell-quote
// shell-quote returns an array where:
// - strings are regular arguments
// - objects with 'op' key are shell operators
// - objects with 'comment' key are comments
// - objects with 'pattern' key are glob patterns (we allow these)
const parsed = parse(command, (varName: string) => `$${varName}`)
// Check each parsed element for operators
for (const entry of parsed) {
const operatorMatch = this.checkParsedEntry(entry)
if (operatorMatch) {
return operatorMatch
}
}
return null
} catch {
// If parsing fails, be conservative and block the command
// This could indicate malformed shell syntax being used for injection
return { operator: "parse_error", description: "command parsing failed (potential injection)" }
}
}
/**
* Detect dangerous characters outside of quoted strings.
* This includes newlines, carriage returns, unicode line separators, and backticks.
*
* For newlines/carriage returns: They are safe inside ANY quotes (single or double)
* because they become literal characters in the argument value.
*
* For backticks: They are only safe inside SINGLE quotes because double quotes
* still allow command substitution.
*
* Examples:
* gh pr comment 123 --body "line1\nline2" -> ALLOWED (newline in quotes)
* gh pr comment 123\nrm -rf / -> BLOCKED (newline outside quotes)
* echo `date` -> BLOCKED (backtick outside quotes)
* echo "hello `date`" -> BLOCKED (backtick in double quotes - executes!)
* echo 'hello `date`' -> ALLOWED (backtick in single quotes - literal)
*
* @param command - The command string to check
* @returns ShellOperatorMatch if dangerous chars found outside appropriate quotes, null otherwise
*/
private detectDangerousCharsOutsideQuotes(command: string): ShellOperatorMatch | null {
let inSingleQuote = false
let inDoubleQuote = false
let isEscaped = false
for (let i = 0; i < command.length; i++) {
const char = command[i]
// If previous char was an unescaped backslash, this char is escaped
if (isEscaped) {
isEscaped = false
continue
}
// Check for escape sequence (only outside single quotes)
// In single quotes, backslashes are literal
if (char === "\\" && !inSingleQuote) {
isEscaped = true
continue
}
// Handle double quotes - we track them to know when single quotes are literal
if (char === '"' && !inSingleQuote) {
inDoubleQuote = !inDoubleQuote
continue
}
// Handle single quotes - only toggle when NOT inside double quotes
// Inside double quotes, single quotes are literal characters
if (char === "'" && !inDoubleQuote) {
inSingleQuote = !inSingleQuote
continue
}
const inAnyQuote = inSingleQuote || inDoubleQuote
// Check for newlines and carriage returns outside ANY quotes
// These are command separators when outside quotes
if (!inAnyQuote && LINE_SEPARATOR_REGEX.test(char)) {
return LINE_SEPARATOR_DESCRIPTIONS[char]
}
// Check for backticks outside SINGLE quotes only
// Backticks in double quotes ARE executed as command substitution in bash
if (char === "`" && !inSingleQuote) {
return { operator: "`", description: "command substitution (backtick)" }
}
}
return null
}
/**
* Check a parsed entry from shell-quote for dangerous operators.
*
* @param entry - A parsed entry from shell-quote
* @returns ShellOperatorMatch if dangerous operator found, null otherwise
*/
private checkParsedEntry(entry: ParseEntry): ShellOperatorMatch | null {
// null entries, string entries, glob patterns, and comments are safe
if (!entry || typeof entry === "string" || "pattern" in entry || "comment" in entry) {
return null
}
if (typeof entry.op === "string") {
if (this.isOperatorAllowed(entry.op)) {
return null
}
const description = OPERATOR_DESCRIPTIONS[entry.op] || `shell operator (${entry.op})`
return { operator: entry.op, description }
}
return null
}
}
+3
View File
@@ -0,0 +1,3 @@
export { CommandPermissionController } from "./CommandPermissionController"
export type { CommandPermissionConfig, PermissionValidationResult } from "./types"
export { COMMAND_PERMISSIONS_ENV_VAR } from "./types"
+32
View File
@@ -0,0 +1,32 @@
/**
* Configuration structure for command permissions from environment variable
*/
export interface CommandPermissionConfig {
allow?: string[] // Glob patterns for allowed commands
deny?: string[] // Glob patterns for denied commands
allowOperators?: string[] // Shell operators to allow (e.g., [">", ">>"] to allow file writing)
}
/**
* Result of a permission validation check
*/
export interface PermissionValidationResult {
allowed: boolean
matchedPattern?: string // The pattern that matched (for error messages)
reason: "no_config" | "allowed" | "denied" | "no_match_deny_default" | "shell_operator_detected"
detectedOperator?: string // The shell operator that was detected (for error messages)
}
/**
* Environment variable name for command permissions
*/
export const COMMAND_PERMISSIONS_ENV_VAR = "CLINE_COMMAND_PERMISSIONS"
/**
* Shell operators that indicate command chaining, piping, substitution, or redirection.
* These are security-sensitive because they can be used to bypass command restrictions.
*/
export interface ShellOperatorMatch {
operator: string
description: string
}
+3
View File
@@ -25,6 +25,9 @@ export const formatResponse = {
clineIgnoreError: (path: string) =>
`Access to ${path} is blocked by the .clineignore file settings. You must try to continue in the task without using this file, or ask the user to update the .clineignore file.`,
permissionDeniedError: (reason: string) =>
`Command execution blocked by CLINE_COMMAND_PERMISSIONS: ${reason}. You must try a different approach or ask the user to update the permission settings.`,
noToolsUsed: (usingNativeToolCalls: boolean) =>
usingNativeToolCalls
? "[ERROR] You did not use a tool in your previous response! Please retry with a tool use."
@@ -56,6 +56,7 @@ CAPABILITIES
- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Prefer non-interactive commands when possible: use flags to disable pagers (e.g., '--no-pager'), auto-confirm prompts (e.g., '-y' when safe), provide input via flags/arguments rather than stdin, suppress interactive behavior, etc. For long-running commands, the user may keep them running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.
- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. This tool may be useful at key stages of web development tasks-such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.
- For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser.
- When the task requires or could benefit from getting up to date information on a topic (e.g. latest best practices, latest documentation, latest news, etc.), use the web_search tool to find current results, then use the web_fetch tool to retrieve and analyze the content from relevant URLs.
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
====
@@ -54,6 +54,7 @@ CAPABILITIES
- You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task.
- For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the replace_in_file tool to implement changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed.
- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Prefer non-interactive commands when possible: use flags to disable pagers (e.g., '--no-pager'), auto-confirm prompts (e.g., '-y' when safe), provide input via flags/arguments rather than stdin, suppress interactive behavior, etc. For long-running commands, the user may keep them running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.
- When the task requires or could benefit from getting up to date information on a topic (e.g. latest best practices, latest documentation, latest news, etc.), use the web_search tool to find current results, then use the web_fetch tool to retrieve and analyze the content from relevant URLs.
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
====
@@ -36,6 +36,7 @@ CAPABILITIES
- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Prefer non-interactive commands when possible: use flags to disable pagers (e.g., '--no-pager'), auto-confirm prompts (e.g., '-y' when safe), provide input via flags/arguments rather than stdin, suppress interactive behavior, etc. For long-running commands, the user may keep them running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.
- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. This tool may be useful at key stages of web development tasks-such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.
- For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser.
- When the task requires or could benefit from getting up to date information on a topic (e.g. latest best practices, latest documentation, latest news, etc.), use the web_search tool to find current results, then use the web_fetch tool to retrieve and analyze the content from relevant URLs.
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
====
@@ -56,6 +56,7 @@ CAPABILITIES
- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Prefer non-interactive commands when possible: use flags to disable pagers (e.g., '--no-pager'), auto-confirm prompts (e.g., '-y' when safe), provide input via flags/arguments rather than stdin, suppress interactive behavior, etc. For long-running commands, the user may keep them running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.
- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. This tool may be useful at key stages of web development tasks-such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.
- For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser.
- When the task requires or could benefit from getting up to date information on a topic (e.g. latest best practices, latest documentation, latest news, etc.), use the web_search tool to find current results, then use the web_fetch tool to retrieve and analyze the content from relevant URLs.
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
====
@@ -618,6 +618,7 @@ CAPABILITIES
- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Prefer non-interactive commands when possible: use flags to disable pagers (e.g., '--no-pager'), auto-confirm prompts (e.g., '-y' when safe), provide input via flags/arguments rather than stdin, suppress interactive behavior, etc. For long-running commands, the user may keep them running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.
- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. This tool may be useful at key stages of web development tasks-such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.
- For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser.
- When the task requires or could benefit from getting up to date information on a topic (e.g. latest best practices, latest documentation, latest news, etc.), use the web_search tool to find current results, then use the web_fetch tool to retrieve and analyze the content from relevant URLs.
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
====
@@ -582,6 +582,7 @@ CAPABILITIES
- You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task.
- For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the replace_in_file tool to implement changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed.
- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Prefer non-interactive commands when possible: use flags to disable pagers (e.g., '--no-pager'), auto-confirm prompts (e.g., '-y' when safe), provide input via flags/arguments rather than stdin, suppress interactive behavior, etc. For long-running commands, the user may keep them running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.
- When the task requires or could benefit from getting up to date information on a topic (e.g. latest best practices, latest documentation, latest news, etc.), use the web_search tool to find current results, then use the web_fetch tool to retrieve and analyze the content from relevant URLs.
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
====
@@ -542,6 +542,7 @@ CAPABILITIES
- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Prefer non-interactive commands when possible: use flags to disable pagers (e.g., '--no-pager'), auto-confirm prompts (e.g., '-y' when safe), provide input via flags/arguments rather than stdin, suppress interactive behavior, etc. For long-running commands, the user may keep them running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.
- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. This tool may be useful at key stages of web development tasks-such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.
- For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser.
- When the task requires or could benefit from getting up to date information on a topic (e.g. latest best practices, latest documentation, latest news, etc.), use the web_search tool to find current results, then use the web_fetch tool to retrieve and analyze the content from relevant URLs.
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
====
@@ -598,6 +598,7 @@ CAPABILITIES
- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Prefer non-interactive commands when possible: use flags to disable pagers (e.g., '--no-pager'), auto-confirm prompts (e.g., '-y' when safe), provide input via flags/arguments rather than stdin, suppress interactive behavior, etc. For long-running commands, the user may keep them running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.
- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. This tool may be useful at key stages of web development tasks-such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.
- For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser.
- When the task requires or could benefit from getting up to date information on a topic (e.g. latest best practices, latest documentation, latest news, etc.), use the web_search tool to find current results, then use the web_fetch tool to retrieve and analyze the content from relevant URLs.
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.
====
@@ -265,7 +265,7 @@
"type": "function",
"function": {
"name": "web_fetch",
"description": "Fetches and analyzes content from a specified URL.",
"description": "Fetches and analyzes content from a specified URL. IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead of this one, as it may have fewer restrictions.",
"strict": false,
"parameters": {
"type": "object",
@@ -295,7 +295,7 @@
"type": "function",
"function": {
"name": "web_search",
"description": "Performs a web search and returns relevant results with titles and URLs.",
"description": "Performs a web search and returns relevant results with titles and URLs. IMPORTANT: If an MCP-provided web search tool is available, prefer using that tool instead of this one, as it may have fewer restrictions.",
"strict": false,
"parameters": {
"type": "object",
@@ -9,7 +9,7 @@ const getCapabilitiesTemplateText = (context: SystemPromptContext) => `CAPABILIT
- You can use search_files to perform regex searches across files in a specified directory, outputting context-rich results that include surrounding lines. This is particularly useful for understanding code patterns, finding specific implementations, or identifying areas that need refactoring.
- You can use the list_code_definition_names tool to get an overview of source code definitions for all files at the top level of a specified directory. This can be particularly useful when you need to understand the broader context and relationships between certain parts of the code. You may need to call this tool multiple times to understand various parts of the codebase related to the task.
- For example, when asked to make edits or improvements you might analyze the file structure in the initial environment_details to get an overview of the project, then use list_code_definition_names to get further insight using source code definitions for files located in relevant directories, then read_file to examine the contents of relevant files, analyze the code and suggest improvements or make necessary edits, then use the replace_in_file tool to implement changes. If you refactored code that could affect other parts of the codebase, you could use search_files to ensure you update other files as needed.
- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Prefer non-interactive commands when possible: use flags to disable pagers (e.g., '--no-pager'), auto-confirm prompts (e.g., '-y' when safe), provide input via flags/arguments rather than stdin, suppress interactive behavior, etc. For long-running commands, the user may keep them running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.{{BROWSER_CAPABILITIES}}
- You can use the execute_command tool to run commands on the user's computer whenever you feel it can help accomplish the user's task. When you need to execute a CLI command, you must provide a clear explanation of what the command does. Prefer to execute complex CLI commands over creating executable scripts, since they are more flexible and easier to run. Prefer non-interactive commands when possible: use flags to disable pagers (e.g., '--no-pager'), auto-confirm prompts (e.g., '-y' when safe), provide input via flags/arguments rather than stdin, suppress interactive behavior, etc. For long-running commands, the user may keep them running in the background and you will be kept updated on their status along the way. Each command you execute is run in a new terminal instance.{{BROWSER_CAPABILITIES}}{{WEB_TOOLS_CAPABILITIES}}
- You have access to MCP servers that may provide additional tools and resources. Each server may provide different capabilities that you can use to accomplish tasks more effectively.`
export async function getCapabilitiesSection(variant: PromptVariant, context: SystemPromptContext): Promise<string> {
@@ -20,10 +20,16 @@ export async function getCapabilitiesSection(variant: PromptVariant, context: Sy
? `\n- You can use the browser_action tool to interact with websites (including html files and locally running development servers) through a Puppeteer-controlled browser when you feel it is necessary in accomplishing the user's task. This tool is particularly useful for web development tasks as it allows you to launch a browser, navigate to pages, interact with elements through clicks and keyboard input, and capture the results through screenshots and console logs. This tool may be useful at key stages of web development tasks-such as after implementing new features, making substantial changes, when troubleshooting issues, or to verify the result of your work. You can analyze the provided screenshots to ensure correct rendering or identify errors, and review console logs for runtime issues.\n\t- For example, if asked to add a component to a react website, you might create the necessary files, use execute_command to run the site locally, then use browser_action to launch the browser, navigate to the local server, and verify the component renders & functions correctly before closing the browser.`
: ""
const webToolsCapabilities =
context.providerInfo.providerId === "cline" && context.clineWebToolsEnabled === true
? `\n- When the task requires or could benefit from getting up to date information on a topic (e.g. latest best practices, latest documentation, latest news, etc.), use the web_search tool to find current results, then use the web_fetch tool to retrieve and analyze the content from relevant URLs.`
: ""
const templateEngine = new TemplateEngine()
return templateEngine.resolve(template, context, {
BROWSER_SUPPORT: browserSupport,
BROWSER_CAPABILITIES: browserCapabilities,
WEB_TOOLS_CAPABILITIES: webToolsCapabilities,
CWD: context.cwd || process.cwd(),
})
}

Some files were not shown because too many files have changed in this diff Show More