diff --git a/.hai/skills/create-pull-request/SKILL.md b/.agents/skills/create-pull-request/SKILL.md similarity index 98% rename from .hai/skills/create-pull-request/SKILL.md rename to .agents/skills/create-pull-request/SKILL.md index 1d47e8f7747..9aa5add8263 100644 --- a/.hai/skills/create-pull-request/SKILL.md +++ b/.agents/skills/create-pull-request/SKILL.md @@ -1,6 +1,6 @@ --- name: create-pull-request -description: Create a GitHub pull request following project conventions. Use when the user asks to create a PR, submit changes for review, or open a pull request. Handles commit analysis, branch management, and PR creation using the gh CLI tool. +description: Create a GitHub pull request following project conventions. Use when the user asks to create a PR, submit changes for review, or open a pull request. Handles commit analysis, branch management, PR template usage, and PR creation using the gh CLI tool. --- # Create Pull Request diff --git a/.changeset/social-toes-melt.md b/.changeset/social-toes-melt.md new file mode 100644 index 00000000000..71a4981979c --- /dev/null +++ b/.changeset/social-toes-melt.md @@ -0,0 +1,5 @@ +--- +"hai-build-code-generator": patch +--- + +merge from cline version 3.72.0 diff --git a/.changie.yaml b/.changie.yaml deleted file mode 100644 index bf5f72b64c8..00000000000 --- a/.changie.yaml +++ /dev/null @@ -1,26 +0,0 @@ -changesDir: .changes -unreleasedDir: unreleased -headerPath: header.tpl.md -changelogPath: CHANGELOG.md -versionExt: md -versionFormat: '## {{.Version}} - {{.Time.Format "2006-01-02"}}' -kindFormat: "### {{.Kind}}" -changeFormat: "* {{.Body}}" -kinds: - - label: Added - auto: minor - - label: Changed - auto: major - - label: Deprecated - auto: minor - - label: Removed - auto: major - - label: Fixed - auto: patch - - label: Security - auto: patch -newlines: - afterChangelogHeader: 1 - beforeChangelogVersion: 1 - endOfVersion: 1 -envPrefix: CHANGIE_ diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..ac28929d31b --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,58 @@ +# Copilot Instructions for Cline + +This is a VS Code extension. Read `.clinerules/general.md` for tribal knowledge and nuanced patterns. + +## Architecture +- **Core** (`src/`): `extension.ts` → `WebviewProvider` → `Controller` (single source of truth) → `Task` (agent loop). +- **Webview** (`webview-ui/`): React/Vite app. State via `ExtensionStateContext.tsx`, synced through message passing. +- **CLI** (`cli/`): React Ink terminal UI sharing core logic. Update CLI when changing webview features. +- **Communication**: Protobuf-defined gRPC-like protocol over VS Code message passing. Schemas in `proto/`. +- **MCP**: `src/services/mcp/McpHub.ts`. + +## Build & Test (Critical — non-obvious commands) +- **Build**: `npm run compile` — NOT `npm run build`. +- **Watch**: `npm run watch` (extension + webview). +- **Protos**: `npm run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`. +- **Tests**: `npm run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true npm run test:unit`. + +## Protobuf RPC Workflow (4 steps) +1. **Define** in `proto/cline/*.proto`. Naming: `PascalCaseService`, `camelCase` RPCs, `PascalCase` Messages. Use `common.proto` shared types for simple data. +2. **Generate**: `npm run protos`. +3. **Backend handler**: `src/core/controller//`. +4. **Frontend call**: `UiServiceClient.myMethod(Request.create({...}))`. +- Adding enums (e.g. `ClineSay`) → also update `src/shared/proto-conversions/cline-message.ts`. + +## Adding API Providers (silent failure risk) +Three proto conversion updates are **required** or the provider silently resets to Anthropic: +1. `proto/cline/models.proto` — add to `ApiProvider` enum. +2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts`. +3. `convertProtoToApiProvider()` in the same file. + +Also update: `src/shared/api.ts`, `src/shared/providers/providers.json`, `src/core/api/index.ts`, `webview-ui/.../providerUtils.ts`, `webview-ui/.../validate.ts`, `webview-ui/.../ApiOptions.tsx`, and `cli/src/components/ModelPicker.tsx`. + +For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/model-utils.ts` and set `apiFormat: ApiFormat.OPENAI_RESPONSES` on models. + +## Adding Tools to System Prompt (5+ file chain) +1. Add enum to `ClineDefaultTool` in `src/shared/tools.ts`. +2. Create definition in `src/core/prompts/system-prompt/tools/` (export `[GENERIC]` minimum). +3. Register in `src/core/prompts/system-prompt/tools/init.ts`. +4. Whitelist in `src/core/prompts/system-prompt/variants/*/config.ts` for each model family. +5. Handler in `src/core/task/tools/handlers/`, wire in `ToolExecutor.ts`. +6. If tool has UI: add `ClineSay` enum in proto → `ExtensionMessage.ts` → `cline-message.ts` → `ChatRow.tsx`. +7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true npm run test:unit`. + +## Modifying System Prompt +Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes. + +## Global State Keys (silent failure risk) +Adding a key requires: type in `src/shared/storage/state-keys.ts`, read via `context.globalState.get()` in `src/core/storage/utils/state-helpers.ts` `readGlobalStateFromDisk()`, and add to return object. Missing the `.get()` call compiles fine but value is always `undefined`. + +## Slash Commands (3 places) +- `src/core/slash-commands/index.ts` — definitions. +- `src/core/prompts/commands.ts` — system prompt integration. +- `webview-ui/src/utils/slash-commands.ts` — webview autocomplete. + +## Conventions +- **Paths**: Always use `src/utils/path` helpers (`toPosixString`) for cross-platform compatibility. +- **Logging**: `src/shared/services/Logger.ts`. +- **Feature flags**: See PR #7566 as reference pattern. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 425c0548d76..2f969b59219 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,46 @@ + + +### Related Issue + + +**Issue:** #XXXX + ### Description - + + +### Test Procedure + + ### Type of Change @@ -20,13 +60,20 @@ - [ ] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs) - [ ] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`) -- [ ] I have created a changeset using `npm run changeset` (required for user-facing changes) -- [ ] I have reviewed [contributor guidelines](https://github.com/presidio-oss/cline-based-code-generator/blob/main/CONTRIBUTING.md) +- [ ] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md) ### Screenshots - + ### Additional Notes - \ No newline at end of file + diff --git a/.github/scripts/overwrite_changeset_changelog.py b/.github/scripts/overwrite_changeset_changelog.py deleted file mode 100644 index fee92719756..00000000000 --- a/.github/scripts/overwrite_changeset_changelog.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -This script updates a specific version's release notes section in CHANGELOG.md with new content -or reformats existing content. - -The script: -1. Takes a version number, changelog path, and optionally new content as input from environment variables -2. Finds the section in the changelog for the specified version -3. Either: - a) Replaces the content with new content if provided, or - b) Reformats existing content by: - - Removing the first two lines of the changeset format - - Ensuring version numbers are wrapped in square brackets -4. Writes the updated changelog back to the file - -Environment Variables: - CHANGELOG_PATH: Path to the changelog file (defaults to 'CHANGELOG.md') - VERSION: The version number to update/format - PREV_VERSION: The previous version number (used to locate section boundaries) - NEW_CONTENT: Optional new content to insert for this version -""" - -#!/usr/bin/env python3 - -import os - -CHANGELOG_PATH = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md") -VERSION = os.environ['VERSION'] -PREV_VERSION = os.environ.get("PREV_VERSION", "") -NEW_CONTENT = os.environ.get("NEW_CONTENT", "") - -def overwrite_changelog_section(changelog_text: str, new_content: str): - # Find the section for the specified version - version_pattern = f"## {VERSION}\n" - unformmatted_prev_version_pattern = f"## {PREV_VERSION}\n" - prev_version_pattern = f"## [{PREV_VERSION}]\n" - print(f"latest version: {VERSION}") - print(f"prev_version: {PREV_VERSION}") - - notes_start_index = changelog_text.find(version_pattern) + len(version_pattern) - notes_end_index = changelog_text.find(prev_version_pattern, notes_start_index) if PREV_VERSION and (prev_version_pattern in changelog_text or unformmatted_prev_version_pattern in changelog_text) else len(changelog_text) - - if new_content: - return changelog_text[:notes_start_index] + f"{new_content}\n" + changelog_text[notes_end_index:] - else: - changeset_lines = changelog_text[notes_start_index:notes_end_index].split("\n") - filtered_lines = [] - for line in changeset_lines: - # If the previous line is a changeset format - if len(filtered_lines) > 1 and filtered_lines[-1].startswith("### "): - # Remove the last two lines from the filted_lines - filtered_lines.pop() - filtered_lines.pop() - else: - filtered_lines.append(line.strip()) - - # Prepend a new line to the first line of filtered_lines - if filtered_lines: - filtered_lines[0] = "\n" + filtered_lines[0] - - # Print filted_lines wiht a "\n" at the end of each line - for line in filtered_lines: - print(line.strip()) - - parsed_lines = "\n".join(line for line in filtered_lines) - updated_changelog = changelog_text[:notes_start_index] + parsed_lines + changelog_text[notes_end_index:] - return updated_changelog - -with open(CHANGELOG_PATH, 'r') as f: - changelog_content = f.read() - -new_changelog = overwrite_changelog_section(changelog_content, NEW_CONTENT) -# print("----------------------------------------------------------------------------------") -# print(new_changelog) -# print("----------------------------------------------------------------------------------") -# Write back to CHANGELOG.md -with open(CHANGELOG_PATH, 'w') as f: - f.write(new_changelog) - -print(f"{CHANGELOG_PATH} updated successfully!") diff --git a/.gitignore b/.gitignore index 82dcf4b051d..48488744959 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,8 @@ test-results # Smoke test results (generated) evals/smoke-tests/results/ + +.tui-test +secrets.json +tui-traces +tests/**/cache diff --git a/.hairules/general.md b/.hairules/general.md index 1b6901af177..601ec9d3b76 100644 --- a/.hairules/general.md +++ b/.hairules/general.md @@ -14,9 +14,9 @@ This file is the secret sauce for working effectively in this codebase. It captu ## Miscellaneous - This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`). -- When creating PRs, if the change is user-facing and significant enough to warrant a changelog entry, run `npm run changeset` and create a patch changeset. Never create minor or major version bumps. Skip changesets for trivial fixes, internal refactors, or minor UI tweaks that users wouldn't notice. -- When adding new feature flags, see this PR as a reference https://github.com/presidio-oss/hai-build-codegen/pull/28 -- Additional instructions about making requests: @.hairules/network.md +- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process. +- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566 +- Additional instructions about making requests: @.clinerules/network.md ## gRPC/Protobuf Communication The extension and webview communicate via gRPC-like protocol over VS Code message passing. diff --git a/.hairules/storage.md b/.hairules/storage.md new file mode 100644 index 00000000000..6e1bc9485a1 --- /dev/null +++ b/.hairules/storage.md @@ -0,0 +1,64 @@ +# Storage Architecture + +Global settings, secrets and workspace state are stored in **file-backed JSON stores** under `~/.cline/data/`. This is the shared storage layer used by VSCode, CLI, and JetBrains. + +## Key Abstractions + +### `StorageContext` (src/shared/storage/storage-context.ts) +The entry point. Created via `createStorageContext()` and passed to `StateManager.initialize()`. Contains three `ClineFileStorage` instances: +- `globalState` → `~/.cline/data/globalState.json` +- `secrets` → `~/.cline/data/secrets.json` (mode 0o600) +- `workspaceState` → `~/.cline/data/workspaces//workspaceState.json` + +### `ClineFileStorage` (src/shared/storage/ClineFileStorage.ts) +Synchronous JSON key-value store backed by a single file. Supports `get()`, `set()`, `setBatch()`, `delete()`. Writes are atomic (write-then-rename). + +### `StateManager` (src/core/storage/StateManager.ts) +In-memory cache on top of `StorageContext`. All runtime reads hit the cache; writes update cache immediately and debounce-flush to disk. + +## ⚠️ Do NOT Use VSCode's ExtensionContext for Storage + +**Do not** read from or write to `context.globalState`, `context.workspaceState`, or `context.secrets` for persistent data. These are VSCode-specific and not available on CLI or JetBrains. + +Instead, use: +```typescript +// Reading state +StateManager.get().getGlobalStateKey("myKey") +StateManager.get().getSecretKey("mySecretKey") +StateManager.get().getWorkspaceStateKey("myWsKey") + +// Writing state +StateManager.get().setGlobalState("myKey", value) +StateManager.get().setSecret("mySecretKey", value) +StateManager.get().setWorkspaceState("myWsKey", value) +``` + +Remember that your data may be read by a different client than the one that wrote it. For example, a value written by Cline in JetBrains may be read by Cline CLI. + +## VSCode Migration (src/hosts/vscode/vscode-to-file-migration.ts) + +On VSCode startup, a migration copies data from VSCode's `ExtensionContext` storage into the file-backed stores. This runs in `src/common.ts` before `StateManager.initialize()`. + +- **Sentinel**: `__vscodeMigrationVersion` key in global state and workspace state — prevents re-migration. +- **Merge strategy**: File store wins. Existing values are never overwritten. +- **Safe downgrade**: VSCode storage is NOT cleared, so older extension versions still work. + +## Adding New Storage Keys + +1. Add to `src/shared/storage/state-keys.ts` (see existing patterns) +2. Read/write via `StateManager` (NOT via `context.globalState`) +3. If adding a secret, add to `SecretKeys` array in `state-keys.ts` + +## File Layout + +``` +~/.cline/ + data/ + globalState.json # Global settings & state + secrets.json # API keys (mode 0o600) + tasks/ + taskHistory.json # Task history (separate file) + workspaces/ + / + workspaceState.json # Per-workspace toggles +``` diff --git a/.hairules/workflows/address-pr-comments.md b/.hairules/workflows/address-pr-comments.md index 5d238cb7b42..f7db2b6a091 100644 --- a/.hairules/workflows/address-pr-comments.md +++ b/.hairules/workflows/address-pr-comments.md @@ -19,7 +19,7 @@ Review and address all comments on the current branch's PR. - Inline comments: `gh api repos/{owner}/{repo}/pulls/{pr_number}/comments` - General comments: `gh pr view {pr_number} --json comments,reviews` -4. Present a summary of all comments with your recommendation for each (apply, skip, or respond). Ignore bot noise (changeset-bot, CI status, etc.). +4. Present a summary of all comments with your recommendation for each (apply, skip, or respond). Ignore bot noise (release automation, CI status, etc.). 5. **Wait for my approval** before proceeding. diff --git a/.hairules/workflows/hotfix-release.md b/.hairules/workflows/hotfix-release.md index 99452b2646b..503ddcf66d5 100644 --- a/.hairules/workflows/hotfix-release.md +++ b/.hairules/workflows/hotfix-release.md @@ -89,16 +89,9 @@ On the main branch, create a commit that updates: 2. **package.json** - Update the version field to the new version -3. **Delete changesets** for the commits being included in the hotfix. This prevents the changeset bot from including duplicate entries in the next regular release. +3. No changelog-entry file cleanup is needed. Contributors do not create changelog-entry files in this repo. - Find and delete the changeset files associated with the selected commits: - ```bash - ls .changeset/ - ``` - - Each changeset file in `.changeset/` corresponds to a PR. Read them to identify which ones belong to the commits you're hotfixing, then delete those files. - -**Skip running `npm run install:all`** - the automation handles outdated lockfiles. +**Skip running `npm run install:all`** - release automation handles lockfile consistency as needed. Commit with message format: `v{VERSION} Release Notes (hotfix)` @@ -107,7 +100,7 @@ In the commit body, mention: - List the cherry-picked commits that will be included ```bash -git add CHANGELOG.md package.json .changeset/ +git add CHANGELOG.md package.json git commit -m "v3.40.1 Release Notes (hotfix) Hotfix release including: diff --git a/.hairules/workflows/pr-review.md b/.hairules/workflows/pr-review.md index 8ec516a5c76..90017d83b3b 100644 --- a/.hairules/workflows/pr-review.md +++ b/.hairules/workflows/pr-review.md @@ -347,8 +347,6 @@ A few notes: So until the settings page is update, and this is added to settings in a way that's clean and doesn't confuse new users, I don't think we can merge this. Please bear with us. -Also, don't forget to add a changeset since this fixes a user-facing bug. - The architectural change is solid - moving the focus logic to the command handlers makes sense. Just don't want to introduce subtle timing issues by removing those timeouts. diff --git a/.hairules/workflows/release.md b/.hairules/workflows/release.md index 9500f403cce..a1c0c117f7a 100644 --- a/.hairules/workflows/release.md +++ b/.hairules/workflows/release.md @@ -1,231 +1,64 @@ # Release -Prepare and publish a release from the open changeset PR. +Prepare and publish a release directly from `main`. ## Overview This workflow helps you: -1. Find and checkout the open changeset PR -2. Clean up the changelog (fix version format, wordsmith entries) -3. Push changes back to the PR branch -4. Merge with proper commit message format -5. Tag and push the release (after verifying the commit) -6. Trigger the publish workflow -7. Update GitHub release notes -8. Provide final summary with Slack announcement +1. Select/confirm the target version +2. Curate `CHANGELOG.md` entries manually for end users +3. Ensure `package.json` version matches the changelog +4. Create and push a release commit + tag +5. Trigger publish workflow +6. Update GitHub release notes and share a summary -## Step 1: Find the Changeset PR +## Process -Look for the open changeset PR: - -```bash -gh pr list --search "Changeset version bump" --state open --json number,title,headRefName,url -``` - -If no PR is found, inform the user there's no changeset PR ready. They may need to: -- Merge PRs with changesets to main first - -## Step 2: Gather PR Information - -Get the PR details: - -```bash -PR_NUMBER= -gh pr view $PR_NUMBER --json body,files,headRefName -``` - -Checkout the PR branch: - -```bash -git fetch origin changeset-release/main -git checkout changeset-release/main -``` - -If the branch has diverged from remote, reset to the remote version: - -```bash -git reset --hard origin/changeset-release/main -``` - -## Step 3: Analyze the Changes - -Read the current CHANGELOG.md to see what the automation generated: - -```bash -head -50 CHANGELOG.md -``` - -Get the version from package.json: - -```bash -cat package.json | grep '"version"' -``` - -**Present to the user:** -- The version number that will be released -- The raw changelog entries from the changeset PR -- Whether this is a patch, minor, or major release - -## Step 4: Clean Up the Changelog - -The changelog needs these fixes: - -1. **Add brackets to version number**: Change `## 3.44.1` to `## [3.44.1]` - -2. **No category headers**: Don't use `### Added`, `### Fixed`, etc. Just a flat list of bullet points. - -3. **Order entries from most important to least important**: - - Lead with major new features or significant fixes users care about - - End with minor fixes or internal changes - -4. **Write user-friendly descriptions**: - - This is for end users, not developers—explain what changed in plain language - - Remove commit hashes from the beginning of lines (the automation adds these) - - Look at the actual commit diffs (`git show `) and PRs to understand what changed - - Write colorful descriptions that explain the value and impact, not just technical details - - Consolidate related changes into single entries when appropriate - -**Ask the user** to review the proposed changelog changes before applying them. Show them: -- Current (raw) changelog section -- Proposed (cleaned) changelog section - -Once approved, apply the changes to CHANGELOG.md. - -## Step 5: Commit and Push Changes - -After making changelog edits: - -```bash -git add CHANGELOG.md -git commit -m "Clean up changelog formatting" -git push origin changeset-release/main -``` - -## Step 6: Merge the PR - -**Ask the user to confirm** they're ready to merge. - -Merge the PR with the proper commit message format: - -```bash -VERSION= -gh pr merge $PR_NUMBER --squash --subject "v${VERSION} Release Notes" --body "" -``` - -**If merge is blocked by branch protection:** -- Users with admin privileges can add the `--admin` flag to bypass -- Users without admin privileges need to get the PR approved through normal review first before merging - -## Step 7: Tag the Release - -After the merge completes, checkout main and pull: +### 1) Sync and determine version ```bash git checkout main git pull origin main +cat package.json | grep '"version"' ``` -**IMPORTANT: Verify the latest commit is the release commit before tagging:** - -```bash -git log -1 --oneline -``` - -Confirm the commit message matches `v{VERSION} Release Notes` (e.g., `v3.44.1 Release Notes`). Do NOT blindly tag HEAD without verification. - -Once verified, tag and push: - -```bash -VERSION= -git tag v${VERSION} -git push origin v${VERSION} -``` - -## Step 8: Trigger Publish Workflow - -**Copy the tag to clipboard** so the user can easily paste it into the GitHub Actions workflow: - -```bash -echo -n "v{VERSION}" | pbcopy -``` - -**Tell the user to trigger the publish workflow:** -1. Go to: https://github.com/presidio-oss/hai-build-codegen/actions/workflows/publish.yml -2. Select **"release"** for release-type -3. Paste **`v{VERSION}`** as the tag (already in clipboard) +Confirm the release version with the maintainer (patch/minor/major). -**Wait for the user** to confirm the publish workflow has completed before proceeding. +### 2) Curate changelog and version -## Step 9: Update GitHub Release Notes +- Edit `CHANGELOG.md` for the target version using human-friendly release notes. +- Ensure version headers use bracket format, e.g. `## [3.66.1]`. +- Update `package.json` version to the same value. -Once the user confirms the publish workflow is done, fetch the auto-generated release content: +### 3) Commit and tag ```bash -VERSION= -gh release view v${VERSION} --json body --jq '.body' +git add CHANGELOG.md package.json package-lock.json +git commit -m "v Release Notes" +git push origin main +git tag v +git push origin v ``` -The auto-generated release has: -- `## What's Changed` - PR list (we'll replace this with our changelog) -- `## New Contributors` - First-time contributors (keep this if present) -- `**Full Changelog**` - Comparison link (keep this) +### 4) Trigger publish workflow -Build the new release body: -1. Start with `## What's Changed` header -2. Add our changelog content (from CHANGELOG.md for this version) -3. Keep the `## New Contributors` section if it exists -4. Keep the `**Full Changelog**` link +Tell the maintainer to run: +https://github.com/cline/cline/actions/workflows/publish.yml -Update the release: +Use `v` as the release tag. -```bash -gh release edit v${VERSION} --notes "" -``` +### 5) Update GitHub release notes -Verify the release was updated: +After publish completes: ```bash -gh release view v${VERSION} +gh release view v --json body --jq '.body' +gh release edit v --notes "" ``` -## Step 10: Final Summary - -**Copy a Slack announcement message to clipboard** (include the full changelog, not just highlights): - -```bash -echo "VS Code v{VERSION} Released - -- Changelog entry 1 -- Changelog entry 2 -- Changelog entry 3" | pbcopy -``` - -**Present a final summary:** -- Version released: v{VERSION} -- PR merged: #{PR_NUMBER} -- Tag pushed: v{VERSION} -- Release: https://github.com/presidio-oss/hai-build-codegen/releases/tag/v{VERSION} -- Slack message copied to clipboard - -**Final reminder:** -Post the Slack message to announce the release - -## Handling Edge Cases - -### No changesets found -If the changeset PR body shows no changes, inform the user they need to merge PRs with changesets first. - -### Merge conflicts -If there are conflicts on the changeset branch, help the user resolve them: -```bash -git fetch origin main -git rebase origin/main -# resolve conflicts -git push origin changeset-release/main --force-with-lease -``` +### 6) Final summary -### User wants to add more changes -If the user wants to include additional PRs before releasing: -1. Ask them to merge those PRs to main first -2. The changeset automation will update the PR automatically -3. Re-run this workflow after the PR is updated +Provide: +- Released version/tag +- Link to release page +- Summary of top end-user changes diff --git a/.vscode/launch.json b/.vscode/launch.json index 834d77c948b..0e8179852ba 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,9 +13,11 @@ "--extensionDevelopmentPath=${workspaceFolder}", "--disable-workspace-trust", "--disable-extension", - "presidio-inc.hai-build-code-generator", // Avoid conflicts with installed Cline + "saoudrizwan.claude-dev", // Avoid conflicts with installed Cline "--disable-extension", - "${workspaceFolder}" + "saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly + "${workspaceFolder}", + "--disable-extensions" ], "outFiles": [ "${workspaceFolder}/dist/**/*.js" @@ -36,8 +38,9 @@ "--extensionDevelopmentPath=${workspaceFolder}", "--disable-workspace-trust", "--disable-extension", - "presidio-inc.hai-build-code-generator", // Avoid conflicts with installed Cline + "saoudrizwan.claude-dev", // Avoid conflicts with installed Cline "--disable-extension", + "saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly "${workspaceFolder}" ], "outFiles": [ @@ -59,8 +62,9 @@ "--extensionDevelopmentPath=${workspaceFolder}", "--disable-workspace-trust", "--disable-extension", - "presidio-inc.hai-build-code-generator", // Avoid conflicts with installed Cline + "saoudrizwan.claude-dev", // Avoid conflicts with installed Cline "--disable-extension", + "saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly "${workspaceFolder}" ], "outFiles": [ @@ -84,8 +88,9 @@ "--profile-temp", "--sync=off", "--disable-extension", - "presidio-inc.hai-build-code-generator", // Avoid conflicts with installed Cline + "saoudrizwan.claude-dev", // Avoid conflicts with installed Cline "--disable-extension", + "saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly "--extensionDevelopmentPath=${workspaceFolder}", "${workspaceFolder}" ], diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e925418720e..9d26807223c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -3,6 +3,24 @@ { "version": "2.0.0", "tasks": [ + { + "label": "npm: compile", + "type": "npm", + "script": "compile", + "group": "build", + "problemMatcher": [], + "presentation": { + "reveal": "always" + }, + "dependsOn": [ + "npm: protos" + ], + "options": { + "env": { + "IS_DEV": "true" + } + } + }, { "label": "compile-standalone", "type": "npm", @@ -30,7 +48,9 @@ }, { "label": "watch", + "dependsOrder": "sequence", "dependsOn": [ + "npm: compile", "npm: protos", "npm: build:webview", "npm: dev:webview", @@ -47,7 +67,9 @@ }, { "label": "watch:test", + "dependsOrder": "sequence", "dependsOn": [ + "npm: compile", "npm: protos", "npm: build:webview:test", "npm: dev:webview", @@ -64,7 +86,7 @@ "script": "build:webview", "group": "build", "problemMatcher": [], - "isBackground": true, + "isBackground": false, "label": "npm: build:webview", "dependsOn": [ "npm: protos" @@ -84,7 +106,7 @@ "script": "build:webview:test", "group": "build", "problemMatcher": [], - "isBackground": true, + "isBackground": false, "label": "npm: build:webview:test", "dependsOn": [ "npm: protos" diff --git a/.vscodeignore b/.vscodeignore index a69c3862d0c..b7e35b48430 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -35,12 +35,10 @@ cli/** eslint-rules/ old_docs/ evals/ -.changie.yaml .codespellrc .mocharc.json buf.yaml -.changeset/ -.hairules/ +.clinerules/ # Ignore all webview-ui files except the build directory (https://github.com/microsoft/vscode-webview-ui-toolkit-samples/blob/main/frameworks/hello-world-react-cra/.vscodeignore) webview-ui/src/** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 83df1d71481..f69d9a158af 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -57,25 +57,11 @@ We also welcome contributions to our [documentation](https://github.com/presidio ### Creating a Pull Request -1. Before creating a PR, generate a changeset entry: - ```bash - npm run changeset - ``` - This will prompt you for: - - Type of change (major, minor, patch) - - `major` → breaking changes (1.0.0 → 2.0.0) - - `minor` → new features (1.0.0 → 1.1.0) - - `patch` → bug fixes (1.0.0 → 1.0.1) - - Description of your changes - -2. Commit your changes and the generated `.changeset` file +1. Commit your changes. -3. Push your branch and create a PR on GitHub. Our CI will: +2. Push your branch and create a PR on GitHub. Our CI will: - Run tests and checks - - Changesetbot will create a comment showing the version impact - - When merged to main, changesetbot will create a Version Packages PR - - When the Version Packages PR is merged, a new release will be published -4. Testing +3. Testing - Run `npm run test` to run tests locally. - Before submitting PR, run `npm run format:fix` to format your code @@ -192,15 +178,10 @@ Anyone can contribute code to HAI, but we ask that you follow these guidelines t - Temporary workspaces with test fixtures - Video recording for failed tests -4. **Version Management with Changesets** +4. **Versioning & Changelog Notes** - - Create a changeset for any user-facing changes using `npm run changeset` - - Choose the appropriate version bump: - - `major` for breaking changes (1.0.0 → 2.0.0) - - `minor` for new features (1.0.0 → 1.1.0) - - `patch` for bug fixes (1.0.0 → 1.0.1) - - Write clear, descriptive changeset messages that explain the impact - - Documentation-only changes don't require changesets + - Contributors do not need to create changelog-entry files as part of PRs. + - Maintainers handle release versioning and changelog curation during the release process. 5. **Commit Guidelines** diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..a0328579de8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,27 @@ +# Security Policy + +## Supported Versions + +We actively patch only the most recent minor release of Cline. Older versions receive fixes at our discretion. + +## Reporting a Vulnerability + +We appreciate your efforts to responsibly disclose your findings and will make every effort to acknowledge your contributions. + +To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/cline/cline/security/advisories/new) tab. + +The team will send a response indicating the next steps in handling your report. After the initial reply, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance. + +When reporting, please include: + +- A short summary of the issue +- Steps to reproduce or a proof of concept +- Any logs, stack traces, or screenshots that might help us understand the problem + +We acknowledge reports within 48 hours and aim to release a fix or mitigation within 30 days. While we work on a resolution, please keep the details private. + +## Escalation + +If you do not receive an acknowledgement of your report within 5 business days, you may send an email to security@cline.bot. + +Thank you for helping us keep Cline users safe. diff --git a/cli/CHANGELOG.md b/cli/CHANGELOG.md index 9981233f7d8..4ab6e39a72c 100644 --- a/cli/CHANGELOG.md +++ b/cli/CHANGELOG.md @@ -1,7 +1,129 @@ # cline +## [2.7.0] + +### Added + +- Added MCP add shortcuts for stdio and HTTP servers +- Added `--continue` for the current directory +- Added `--auto-condense` flag for AI-powered context compaction +- Added `--hooks-dir` flag for runtime hook injection +- Enabled error autocapture +- Prompt rules now include test verification guidance and make `CLI_RULES` language-agnostic + +### Fixed + +- Fixed remount behavior so TUI remounts only on width resize +- Fixed startup prompt replay on resize remount +- Fixed task flags so they are applied before the welcome TUI mounts + +### Changed + +- Hooks: reintroduced feature toggle + +## [2.6.1] + +### Added + +- Added GPT-5.4 models for ChatGPT subscription users +- Hooks: Added a `Notification` hook for attention and completion boundaries +- Added `--hooks-dir` CLI flag for runtime hook injection +- Added `--auto-approve-all` CLI flag for interactive mode + +### Fixed + +- Handle streamable HTTP MCP reconnects more reliably + +## [2.6.0] + +### Added + +- Hook payloads now include `model.provider` and `model.slug` +- Token/cost updates now happen immediately as usage chunks arrive, not after tool execution + +### Fixed + +- Improve subagent context compaction logic +- Subagent stream retry delay increased to reduce noise from transient failures +- State serialization errors are now caught and logged instead of crashing +- Removed incorrect `max_tokens` from OpenRouter requests + +## [2.5.2] + +### Added + +- Added Windows PowerShell support for hooks (execution, resolution, and management), improving hook behavior on Windows for CLI and shared core workflows. + +### Fixed + +- Restored GPT-OSS native file editing for OpenAI-compatible models used through shared core tooling. +- Improved OpenRouter context overflow error handling so auto-compaction triggers correctly for wrapped 400 errors. +- Hardened checkpoint recovery by retrying nested git restore and preventing silent `.git_disabled` leftovers. +- Added a User-Agent header for requests to the Cline back-end to improve request handling consistency. + +## [2.5.1] + +### Added + +- Expanded CLI markdown rendering support (headings, lists, blockquotes, fenced code blocks, links, and nested lists). ### Fixed + +- Fixed CLI headless auth provider model metadata loading for Cline and Vercel AI Gateway by fetching model info from API with cache fallback. +- Increased flaky CLI import test timeout on Windows CI to reduce intermittent test failures. + +## [2.5.0] + +### Added + +- Added Cline SDK API interface for programmatic access to Cline features and tools, enabling integration into custom applications. +- Added Codex 5.3 model support + +### Fixed + +- Fix OpenAI Codex by setting `store` to `false` +- Use `isLocatedInPath()` instead of string matching for path containment checks + +## [2.4.3] + +### Added + +- Add /q command to quit CLI +- Fetch featured models from backend with local fallback + +### Fixed + +- Fix auth check for ACP mode +- Fix Cline auth with ACP flag +- Fix yolo mode to not persist yolo setting to disk + +## [2.4.2] + +### Added + +- Gemini-3.1 Pro Preview + +### Patch Changes + +- VSCode uses shared files for global, workspace and secret state. + +## [2.4.1] + +### Fixed + +- Fix infinite retry loop when write_to_file fails with missing content parameter. Provides progressive guidance to the model, escalating from suggestions to hard stops, with context window awareness to break the loop. + +## [2.4.0] + +### Added + +- Adding Anthropic Sonnet 4.6 +- Allows users to enter custom aws region when selecting bedrock as a provider in CLI +- Keep reasoning rows visible when low-stakes tool groups start immediately after reasoning. +- Restore reasoning trace visibility in chat and improve the thinking row UX so streamed reasoning is visible, then collapsible after completion. + +### Fixed + - Banners now display immediately when opening the extension instead of requiring user interaction first - Resolved 17 security vulnerabilities including high-severity DoS issues in dependencies (body-parser, axios, qs, tar, and others) diff --git a/cli/esbuild.mts b/cli/esbuild.mts index 515a3b1901a..c9b7cd79194 100644 --- a/cli/esbuild.mts +++ b/cli/esbuild.mts @@ -190,6 +190,7 @@ const buildEnvVars: Record = { const buildTimeEnvs = [ "TELEMETRY_SERVICE_API_KEY", "ERROR_SERVICE_API_KEY", + "ENABLE_ERROR_AUTOCAPTURE", "POSTHOG_TELEMETRY_ENABLED", "OTEL_TELEMETRY_ENABLED", "OTEL_LOGS_EXPORTER", @@ -213,8 +214,8 @@ if (production) { buildEnvVars["process.env.IS_DEV"] = "false" } -const config: esbuild.BuildOptions = { - entryPoints: [path.join(__dirname, "src", "index.ts")], +// Shared build options +const sharedOptions: Partial = { bundle: true, minify: production, sourcemap: !production, @@ -226,7 +227,6 @@ const config: esbuild.BuildOptions = { sourcesContent: false, platform: "node", target: "node20", - outfile: path.join(__dirname, "dist", "cli.mjs"), // These modules need to load files from the module directory at runtime external: [ "@grpc/reflection", @@ -242,6 +242,13 @@ const config: esbuild.BuildOptions = { "@vscode/ripgrep", // Uses __dirname to locate the binary ], supported: { "top-level-await": true }, +} + +// CLI executable configuration +const cliConfig: esbuild.BuildOptions = { + ...sharedOptions, + entryPoints: [path.join(__dirname, "src", "index.ts")], + outfile: path.join(__dirname, "dist", "cli.mjs"), banner: { js: `#!/usr/bin/env node // Suppress all Node.js warnings (deprecation, experimental, etc.) @@ -255,20 +262,45 @@ const __dirname = _dirname(__filename);`, }, } +// Library configuration for programmatic use +const libConfig: esbuild.BuildOptions = { + ...sharedOptions, + entryPoints: [path.join(__dirname, "src", "exports.ts")], + outfile: path.join(__dirname, "dist", "lib.mjs"), + banner: { + js: `// Cline Library - Programmatic API +import { createRequire as _createRequire } from 'module'; +import { fileURLToPath as _fileURLToPath } from 'url'; +import { dirname as _dirname } from 'path'; +const require = _createRequire(import.meta.url); +const __filename = _fileURLToPath(import.meta.url); +const __dirname = _dirname(__filename);`, + }, +} + async function main() { - const ctx = await esbuild.context(config) if (watch) { + // In watch mode, only watch the CLI (primary use case for development) + const ctx = await esbuild.context(cliConfig) await ctx.watch() // biome-ignore lint/suspicious/noConsole: Build script logging console.log("[cli] Watching for changes...") } else { - await ctx.rebuild() - await ctx.dispose() + // Build both CLI and library + console.log("[cli esbuild] Building CLI executable...") + const cliCtx = await esbuild.context(cliConfig) + await cliCtx.rebuild() + await cliCtx.dispose() + + console.log("[cli esbuild] Building library bundle...") + const libCtx = await esbuild.context(libConfig) + await libCtx.rebuild() + await libCtx.dispose() - // Make the output executable - const outfile = path.join(__dirname, "dist", "cli.mjs") - if (fs.existsSync(outfile)) { - fs.chmodSync(outfile, "755") + // Make the CLI output executable + const cliOutfile = path.join(__dirname, "dist", "cli.mjs") + if (fs.existsSync(cliOutfile)) { + fs.chmodSync(cliOutfile, "755") } } } diff --git a/cli/man/cline.1.md b/cli/man/cline.1.md index 5d105874071..43c91a45389 100644 --- a/cli/man/cline.1.md +++ b/cli/man/cline.1.md @@ -56,6 +56,8 @@ Run a new task with a prompt. **-y**, **\--yolo** : Enable yolo/yes mode (auto-approve all actions, output in plain mode, exit process automatically when task complete) +**-t**, **\--timeout** *seconds* : Optional timeout in seconds. Only applied when explicitly provided. + **-m**, **\--model** *model* : Model to use for the task **-i**, **\--images** *paths...* : Image file paths to include with the task @@ -144,6 +146,8 @@ When running **cline** with just a prompt (no subcommand), these options are ava **-y**, **\--yolo** : Enable yolo mode (auto-approve all actions). Also forces plain text output mode. +**-t**, **\--timeout** *seconds* : Optional timeout in seconds. Only applied when explicitly provided. + **-m**, **\--model** *model* : Model to use for the task **-v**, **\--verbose** : Show verbose output @@ -158,6 +162,8 @@ When running **cline** with just a prompt (no subcommand), these options are ava **-T**, **\--taskId** *id* : Resume an existing task by ID instead of starting a new one. The prompt becomes an optional follow-up message. +**\--continue** : Resume the most recent task from the current working directory instead of starting a new one. + # JSON OUTPUT FORMAT When using **\--json**, each message is output as a JSON object with these fields: @@ -264,6 +270,9 @@ cline -T abc123def # Resume a task with a follow-up message cline -T abc123def "Now add unit tests for the changes" +# Resume the most recent task from the current directory +cline --continue + # Resume in plan mode to review before continuing cline -T abc123def -p "What's left to do?" diff --git a/cli/package.json b/cli/package.json index a507ac3411f..3d9f1285857 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,11 +1,18 @@ { "name": "cline", - "version": "2.2.3", + "version": "2.7.0", "description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more", - "main": "dist/cli.mjs", + "main": "dist/lib.mjs", + "types": "dist/lib.d.ts", "bin": { "cline": "./dist/cli.mjs" }, + "exports": { + ".": { + "import": "./dist/lib.mjs", + "types": "./dist/lib.d.ts" + } + }, "os": [ "darwin", "linux", @@ -23,8 +30,9 @@ "scripts": { "package:brew": "npx tsx ./scripts/update-brew-formula.mts", "package": "npm pack --pack-destination ./dist", - "build": "npm run typecheck && npx tsx esbuild.mts", - "build:production": "npm run typecheck && npx tsx esbuild.mts --production", + "build": "npm run typecheck && npx tsx esbuild.mts && npm run build:types", + "build:production": "npm run typecheck && npx tsx esbuild.mts --production && npm run build:types", + "build:types": "(npx tsc -p tsconfig.lib.json || true) && cp dist/types/cli/src/exports.d.ts dist/lib.d.ts && mkdir -p dist/agent && cp dist/types/cli/src/agent/ClineAgent.d.ts dist/types/cli/src/agent/ClineSessionEmitter.d.ts dist/types/cli/src/agent/public-types.d.ts dist/agent/ && rm -rf dist/types", "watch": "npx tsx esbuild.mts --watch", "dev": "IS_DEV=true && npm run link && npm run watch ; npm run unlink", "clean": "rimraf dist", @@ -62,6 +70,7 @@ "url": "https://github.com/cline/cline/issues" }, "devDependencies": { + "@types/marked": "^5.0.2", "@types/node": "20.x", "@types/prompts": "^2.4.9", "@types/react": "^19.2.9", @@ -81,8 +90,9 @@ "ink": "npm:@jrichman/ink@6.4.7", "ink-picture": "^1.3.3", "ink-spinner": "^5.0.0", - "ora": "^8.0.1", + "marked": "^17.0.3", "nanoid": "^5.1.6", + "ora": "^8.0.1", "pino": "^10.0.0", "pino-roll": "^4.0.0", "prompts": "^2.4.2", diff --git a/cli/src/acp/ACPHostBridgeClientProvider.ts b/cli/src/acp/ACPHostBridgeClientProvider.ts index 5ed3acc284a..9699358688d 100644 --- a/cli/src/acp/ACPHostBridgeClientProvider.ts +++ b/cli/src/acp/ACPHostBridgeClientProvider.ts @@ -108,11 +108,7 @@ class ACPDiffServiceClient implements DiffServiceClientInterface { class ACPEnvServiceClient implements EnvServiceClientInterface { private readonly version: string - constructor( - _clientCapabilities: acp.ClientCapabilities | undefined, - _sessionIdResolver: SessionIdResolver, - version: string = "1.0.0", - ) { + constructor(_clientCapabilities: acp.ClientCapabilities | undefined, _sessionIdResolver: SessionIdResolver, version: string) { this.version = version } @@ -402,7 +398,7 @@ export class ACPHostBridgeClientProvider implements HostBridgeClientProvider { clientCapabilities: acp.ClientCapabilities | undefined, sessionIdResolver: SessionIdResolver, cwdResolver: CwdResolver, - version: string = "1.0.0", + version: string, ) { this.workspaceClient = new ACPWorkspaceServiceClient(clientCapabilities, sessionIdResolver, cwdResolver) this.envClient = new ACPEnvServiceClient(clientCapabilities, sessionIdResolver, version) diff --git a/cli/src/acp/AcpAgent.ts b/cli/src/acp/AcpAgent.ts index 202880067f3..eab5d548a4b 100644 --- a/cli/src/acp/AcpAgent.ts +++ b/cli/src/acp/AcpAgent.ts @@ -15,7 +15,7 @@ import type * as acp from "@agentclientprotocol/sdk" import { Logger } from "@/shared/services/Logger.js" import { ClineAgent } from "../agent/ClineAgent.js" -import type { AcpAgentOptions, SessionUpdateType } from "../agent/types.js" +import { type AcpAgentOptions, type SessionUpdateType } from "../agent/types.js" /** * ACP Agent wrapper that bridges stdio connection to ClineAgent. @@ -39,37 +39,21 @@ export class AcpAgent implements acp.Agent { this.clineAgent = new ClineAgent(options) // Wire up the permission handler to use the connection - this.clineAgent.setPermissionHandler(async (request, resolve) => { + this.clineAgent.setPermissionHandler(async (request) => { try { Logger.debug("[AcpAgent] Forwarding permission request to connection") - const response = await this.connection.requestPermission({ - sessionId: this.getCurrentSessionId() ?? "", + return await this.connection.requestPermission({ + sessionId: request.sessionId, toolCall: request.toolCall, options: request.options, }) - resolve(response) } catch (error) { Logger.debug("[AcpAgent] Error requesting permission:", error) - resolve({ outcome: "rejected" as unknown as acp.RequestPermissionOutcome }) + return { outcome: { outcome: "cancelled" } } } }) } - /** - * Get the current active session ID from the ClineAgent. - */ - private getCurrentSessionId(): string | undefined { - // Find the session that's currently processing - for (const [sessionId, session] of this.clineAgent.sessions) { - if (session.controller?.task) { - return sessionId - } - } - // Fall back to the first session if none is actively processing - const firstSession = this.clineAgent.sessions.keys().next() - return firstSession.done ? undefined : firstSession.value - } - /** * Subscribe to session events and forward them to the connection. */ diff --git a/cli/src/acp/index.ts b/cli/src/acp/index.ts index 55ac0949d8e..39ae1b0e5fd 100644 --- a/cli/src/acp/index.ts +++ b/cli/src/acp/index.ts @@ -15,22 +15,18 @@ import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk" import { Logger } from "@/shared/services/Logger" -import { version as CLI_VERSION } from "../../../package.json" import { AcpAgent } from "./AcpAgent.js" import { nodeToWebReadable, nodeToWebWritable } from "./streamUtils.js" // Re-export classes for programmatic use export { ClineAgent } from "../agent/ClineAgent.js" export { ClineSessionEmitter } from "../agent/ClineSessionEmitter.js" -// Re-export types export type { AcpAgentOptions, AcpSessionState, - ClineAcpSession, ClineAgentOptions, ClineSessionEvents, PermissionHandler, - PermissionResolver, } from "../agent/types.js" export { AcpAgent } from "./AcpAgent.js" @@ -77,6 +73,8 @@ export interface AcpModeOptions { config?: string /** Working directory (default: process.cwd()) */ cwd?: string + /** Additional runtime hooks directory */ + hooksDir?: string /** Enable verbose/debug logging to stderr */ verbose?: boolean } @@ -103,8 +101,8 @@ export async function runAcpMode(options: AcpModeOptions = {}): Promise { new AgentSideConnection((conn) => { agent = new AcpAgent(conn, { - version: CLI_VERSION, debug: Boolean(options.verbose), + hooksDir: options.hooksDir, }) return agent }, stream) diff --git a/cli/src/agent/ClineAgent.ts b/cli/src/agent/ClineAgent.ts index b07758be58f..f898fda03fe 100644 --- a/cli/src/agent/ClineAgent.ts +++ b/cli/src/agent/ClineAgent.ts @@ -38,11 +38,11 @@ import { } from "@shared/api" import type { ClineAsk, ClineMessage as ClineMessageType } from "@shared/ExtensionMessage" import { CLI_ONLY_COMMANDS, VSCODE_ONLY_COMMANDS } from "@shared/slashCommands" -import { ProviderToApiKeyMap } from "@shared/storage" import { getProviderModelIdKey } from "@shared/storage/provider-keys" import { ClineEndpoint } from "@/config.js" import { Controller } from "@/core/controller" import { getAvailableSlashCommands } from "@/core/controller/slash/getAvailableSlashCommands" +import { setRuntimeHooksDir } from "@/core/storage/disk" import { StateManager } from "@/core/storage/StateManager" import { AuthHandler } from "@/hosts/external/AuthHandler.js" import { ExternalCommentReviewController } from "@/hosts/external/ExternalCommentReviewController.js" @@ -53,18 +53,21 @@ import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth" import { StandaloneTerminalManager } from "@/integrations/terminal/index.js" import { AuthService } from "@/services/auth/AuthService.js" import { Logger } from "@/shared/services/Logger.js" -import { secretStorage } from "@/shared/storage/ClineSecretStorage" import type { Mode } from "@/shared/storage/types" import { openExternal } from "@/utils/env" +import { version as AGENT_VERSION } from "../../package.json" import { ACPDiffViewProvider } from "../acp/ACPDiffViewProvider.js" import { ACPHostBridgeClientProvider } from "../acp/ACPHostBridgeClientProvider.js" import { AcpTerminalManager } from "../acp/AcpTerminalManager.js" +import { isAuthConfigured } from "../utils/auth" import { fetchOpenRouterModels, usesOpenRouterModels } from "../utils/openrouter-models" import { CliContextResult, initializeCliContext } from "../vscode-context.js" import { ClineSessionEmitter } from "./ClineSessionEmitter.js" import { translateMessage } from "./messageTranslator.js" import { handlePermissionResponse } from "./permissionHandler.js" -import type { AcpSessionState, ClineAcpSession, ClineAgentOptions, PermissionHandler } from "./types.js" +import type { ClineAcpSession, ClineAgentOptions, PermissionHandler } from "./public-types.js" +import { AcpSessionStatus } from "./public-types.js" +import { type AcpSessionState } from "./types.js" // Map providers to their static model lists and defaults (copied from ModelPicker.tsx) const providerModels: Record; defaultId: string }> = { @@ -105,7 +108,12 @@ function getModelList(provider: string): string[] { export class ClineAgent implements acp.Agent { private readonly options: ClineAgentOptions private readonly ctx: CliContextResult - readonly sessions: Map = new Map() + + /** Map of active sessions by session ID */ + public readonly sessions: Map = new Map() + + /** WeakMap to associate ClineAcpSession with its Controller without exposing it to consumers */ + readonly #sessionControllers = new WeakMap() /** Runtime state for active sessions */ private readonly sessionStates: Map = new Map() @@ -133,7 +141,8 @@ export class ClineAgent implements acp.Agent { constructor(options: ClineAgentOptions) { this.options = options - this.ctx = initializeCliContext() + setRuntimeHooksDir(options.hooksDir) + this.ctx = initializeCliContext({ clineDir: options.clineDir }) } /** @@ -177,7 +186,7 @@ export class ClineAgent implements acp.Agent { this.clientCapabilities = params.clientCapabilities this.initializeHostProvider(this.clientCapabilities, connection) await ClineEndpoint.initialize(this.ctx.EXTENSION_DIR) - await StateManager.initialize(this.ctx.extensionContext) + await StateManager.initialize(this.ctx.storageContext) return { protocolVersion: PROTOCOL_VERSION, @@ -195,7 +204,7 @@ export class ClineAgent implements acp.Agent { }, agentInfo: { name: "cline", - version: this.options.version, + version: AGENT_VERSION, }, authMethods: [ { @@ -227,7 +236,7 @@ export class ClineAgent implements acp.Agent { clientCapabilities, () => this.currentActiveSessionId, () => this.sessions.get(this.currentActiveSessionId ?? "")?.cwd ?? process.cwd(), - this.options.version, + AGENT_VERSION, ) HostProvider.initialize( @@ -266,7 +275,7 @@ export class ClineAgent implements acp.Agent { */ async newSession(params: acp.NewSessionRequest): Promise { // Check if authentication is required - const isAuthenticated = await this.isAuthConfigured() + const isAuthenticated = await isAuthConfigured() if (!isAuthenticated) { throw RequestError.authRequired() } @@ -290,16 +299,16 @@ export class ClineAgent implements acp.Agent { mcpServers: params.mcpServers ?? [], createdAt: Date.now(), lastActivityAt: Date.now(), - controller, } + this.#sessionControllers.set(session, controller) + this.sessions.set(sessionId, session) // Initialize session state const sessionState: AcpSessionState = { sessionId, - isProcessing: false, - cancelled: false, + status: AcpSessionStatus.Idle, pendingToolCalls: new Map(), } @@ -436,11 +445,11 @@ export class ClineAgent implements acp.Agent { * * The prompt flow: * 1. Extract content from the ACP prompt (text, images, files) - * 2. Set up state broadcasting (subscribe to controller updates) - * 3. Initialize or continue task with Controller + * 2. Set up internal cline state subsription + * 3. Initialize or continue cline task * 4. Translate ClineMessages to ACP SessionUpdates * 5. Handle permission requests for tools/commands - * 6. Return when task completes, is cancelled, or needs user input + * 6. Return when cline task completes, is cancelled, or needs user input */ async prompt(params: acp.PromptRequest): Promise { const session = this.sessions.get(params.sessionId) @@ -450,11 +459,11 @@ export class ClineAgent implements acp.Agent { throw new Error(`Session not found: ${params.sessionId}`) } - if (sessionState.isProcessing) { + if (sessionState.status === AcpSessionStatus.Processing) { throw new Error(`Session ${params.sessionId} is already processing a prompt`) } - const controller = session.controller + const controller = this.#sessionControllers.get(session) if (!controller) { throw new Error("Controller not initialized for session. This is a bug in the ACP agent setup.") } @@ -465,8 +474,7 @@ export class ClineAgent implements acp.Agent { }) // Mark session as processing and set as current active session - sessionState.isProcessing = true - sessionState.cancelled = false + sessionState.status = AcpSessionStatus.Processing session.lastActivityAt = Date.now() this.currentActiveSessionId = params.sessionId @@ -587,7 +595,7 @@ export class ClineAgent implements acp.Agent { Logger.debug("[ClineAgent] Error during cleanup:", error) } } - sessionState.isProcessing = false + sessionState.status = AcpSessionStatus.Idle } } @@ -649,7 +657,13 @@ export class ClineAgent implements acp.Agent { permissionRequest: Omit, ): Promise { const session = this.sessions.get(sessionId) - const controller = session?.controller + + if (!session) { + Logger.debug("[ClineAgent] No session found for permission request") + return + } + + const controller = this.#sessionControllers.get(session) if (!controller?.task) { Logger.debug("[ClineAgent] No active task for permission request") @@ -830,7 +844,7 @@ export class ClineAgent implements acp.Agent { await this.emitSessionUpdate(sessionId, { sessionUpdate, - content: { type: "text", text: needsNewline ? "\n" + textDelta : textDelta }, + content: { type: "text", text: needsNewline ? `\n${textDelta}` : textDelta }, }) } @@ -883,18 +897,22 @@ export class ClineAgent implements acp.Agent { */ async cancel(params: acp.CancelNotification): Promise { const session = this.sessions.get(params.sessionId) + if (!session) { + Logger.debug("[ClineAgent] cancel called for non-existent session:", params.sessionId) + return + } const sessionState = this.sessionStates.get(params.sessionId) Logger.debug("[ClineAgent] cancel called:", { sessionId: params.sessionId, - isProcessing: sessionState?.isProcessing, + status: sessionState?.status, }) if (sessionState) { - sessionState.cancelled = true + sessionState.status = AcpSessionStatus.Cancelled // If we have an active controller task, cancel it - const controller = session?.controller + const controller = this.#sessionControllers.get(session) if (controller?.task) { try { await controller.cancelTask() @@ -935,7 +953,7 @@ export class ClineAgent implements acp.Agent { session.lastActivityAt = Date.now() // Update Controller mode if active - const controller = session.controller + const controller = this.#sessionControllers.get(session) if (controller) { controller.stateManager.setGlobalState("mode", session.mode) @@ -1007,13 +1025,14 @@ export class ClineAgent implements acp.Agent { const startTime = Date.now() while (Date.now() - startTime < AUTH_TIMEOUT_MS) { + const stateManager = StateManager.get() + // Check if auth data has been stored - const authData = await secretStorage.get("cline:clineAccountId") + const authData = stateManager.getSecretKey("cline:clineAccountId") if (authData) { Logger.debug("[ClineAgent] Authentication successful") // Set up the provider configuration for cline - const stateManager = StateManager.get() stateManager.setGlobalState("actModeApiProvider", "cline") stateManager.setGlobalState("planModeApiProvider", "cline") await stateManager.flushPendingState() @@ -1065,7 +1084,7 @@ export class ClineAgent implements acp.Agent { * @returns The permission response from the client */ protected async requestPermission( - _sessionId: string, + sessionId: string, toolCall: acp.ToolCallUpdate, options: acp.PermissionOption[], ): Promise { @@ -1080,17 +1099,15 @@ export class ClineAgent implements acp.Agent { return { outcome: "rejected" as unknown as acp.RequestPermissionOutcome } } - // Use the permission handler callback pattern - return new Promise((resolve) => { - this.permissionHandler!({ toolCall, options }, resolve) - }) + return await this.permissionHandler({ sessionId, toolCall, options }) } async shutdown(): Promise { for (const [sessionId, session] of this.sessions) { - await session.controller?.task?.abortTask() - await session.controller?.stateManager.flushPendingState() - await session.controller?.dispose() + const controller = this.#sessionControllers.get(session) + await controller?.task?.abortTask() + await controller?.stateManager.flushPendingState() + await controller?.dispose() this.sessions.delete(sessionId) this.sessionStates.delete(sessionId) } @@ -1146,48 +1163,6 @@ export class ClineAgent implements acp.Agent { } } - /** - * Check if the user has authentication configured. - * Returns true if they have either: - * - Cline provider with stored auth data - * - OpenAI Codex provider with OAuth credentials - * - BYO provider with an API key configured - */ - private async isAuthConfigured(): Promise { - const stateManager = StateManager.get() - const mode = stateManager.getGlobalSettingsKey("mode") as string - const providerKey = mode === "act" ? "actModeApiProvider" : "planModeApiProvider" - const currentProvider = (stateManager.getGlobalSettingsKey(providerKey) as string) || "cline" - - if (currentProvider === "cline") { - // For Cline provider, check if we have stored auth data - const values = await Promise.all(["clineApiKey", "clineAccountId"].map((key) => secretStorage.get(key))) - return values.some(Boolean) - } - - // For OpenAI Codex provider, check OAuth credentials - if (currentProvider === "openai-codex") { - openAiCodexOAuthManager.initialize(this.ctx.extensionContext) - return await openAiCodexOAuthManager.isAuthenticated() - } - - // For BYO providers, check if the API key is configured - const keyField = ProviderToApiKeyMap[currentProvider as keyof typeof ProviderToApiKeyMap] - if (!keyField) { - return false - } - - const fields = Array.isArray(keyField) ? keyField : [keyField] - for (const field of fields) { - const value = await secretStorage.get(field) - if (value) { - return true - } - } - - return false - } - /** * Handle OpenAI Codex OAuth authentication flow. * @@ -1201,9 +1176,6 @@ export class ClineAgent implements acp.Agent { Logger.debug("[ClineAgent] Starting OpenAI Codex OAuth flow...") try { - // Initialize the OAuth manager with extension context - openAiCodexOAuthManager.initialize(this.ctx.extensionContext) - // Get the authorization URL and start the callback server const authUrl = openAiCodexOAuthManager.startAuthorizationFlow() diff --git a/cli/src/agent/ClineSessionEmitter.ts b/cli/src/agent/ClineSessionEmitter.ts index ef9b6d32a73..a97efe285d5 100644 --- a/cli/src/agent/ClineSessionEmitter.ts +++ b/cli/src/agent/ClineSessionEmitter.ts @@ -8,7 +8,7 @@ */ import { EventEmitter } from "events" -import type { ClineSessionEvents } from "./types.js" +import type { ClineSessionEvents } from "./public-types.js" /** * Type-safe EventEmitter for ClineAgent session events. diff --git a/cli/src/agent/messageTranslator.test.ts b/cli/src/agent/messageTranslator.test.ts index 8f2df835907..c2ecd98e6e1 100644 --- a/cli/src/agent/messageTranslator.test.ts +++ b/cli/src/agent/messageTranslator.test.ts @@ -12,6 +12,7 @@ import type { ClineMessage } from "@shared/ExtensionMessage" import { beforeEach, describe, expect, it } from "vitest" import { createSessionState, translateMessage, translateMessages } from "./messageTranslator" import type { AcpSessionState } from "./types" +import { AcpSessionStatus } from "./types" // ============================================================================= // Test Helpers @@ -175,8 +176,7 @@ describe("createSessionState", () => { const state = createSessionState("my-session-123") expect(state.sessionId).toBe("my-session-123") - expect(state.isProcessing).toBe(false) - expect(state.cancelled).toBe(false) + expect(state.status).toBe(AcpSessionStatus.Idle) expect(state.pendingToolCalls).toBeInstanceOf(Map) expect(state.pendingToolCalls.size).toBe(0) expect(state.currentToolCallId).toBeUndefined() @@ -187,11 +187,11 @@ describe("createSessionState", () => { const state2 = createSessionState("session-2") // Modify state1 - state1.isProcessing = true + state1.status = AcpSessionStatus.Processing state1.pendingToolCalls.set("tool-1", {} as acp.ToolCall) // state2 should be unaffected - expect(state2.isProcessing).toBe(false) + expect(state2.status).toBe(AcpSessionStatus.Idle) expect(state2.pendingToolCalls.size).toBe(0) }) }) diff --git a/cli/src/agent/messageTranslator.ts b/cli/src/agent/messageTranslator.ts index 99953e3e473..3da28d502db 100644 --- a/cli/src/agent/messageTranslator.ts +++ b/cli/src/agent/messageTranslator.ts @@ -11,6 +11,7 @@ import type * as acp from "@agentclientprotocol/sdk" import type { ClineMessage, ClineSayBrowserAction, ClineSayTool } from "@shared/ExtensionMessage" import type { AcpSessionState, TranslatedMessage } from "./types.js" +import { AcpSessionStatus } from "./types.js" /** * Maps Cline tool types to ACP ToolKind values. @@ -1019,8 +1020,7 @@ export function translateMessages(messages: ClineMessage[], sessionState: AcpSes export function createSessionState(sessionId: string): AcpSessionState { return { sessionId, - isProcessing: false, - cancelled: false, + status: AcpSessionStatus.Idle, pendingToolCalls: new Map(), } } diff --git a/cli/src/agent/public-types.ts b/cli/src/agent/public-types.ts new file mode 100644 index 00000000000..4a370cb1c06 --- /dev/null +++ b/cli/src/agent/public-types.ts @@ -0,0 +1,258 @@ +/** + * Public types for the Cline library API. + * + * This file contains types that are safe to export to library consumers. + * It must NOT import any internal types (Controller, StateManager, etc.) + * to keep the generated declaration files clean. + * + * Internal-only extensions of these types live in ./types.ts. + */ + +import type * as acp from "@agentclientprotocol/sdk" + +// ============================================================ +// Session Update Type Utilities +// ============================================================ + +/** + * Different types of updates that can be sent during session processing. + * + * These updates provide real-time feedback about the agent's progress. + * + * See protocol docs: [Agent Reports Output](https://agentclientprotocol.com/protocol/prompt-turn#3-agent-reports-output) + */ +export type SessionUpdateType = acp.SessionUpdate["sessionUpdate"] + +/** + * Different types of update payloads that can be sent during session processing. + * + * Each update type has a corresponding payload structure defined in the ACP SessionUpdate union. + */ +export type SessionUpdatePayload = Omit< + Extract, + "sessionUpdate" +> + +// ============================================================ +// Permission Handler Callback Types +// ============================================================ + +/** + * Handler function for permission requests. + * Called when the agent needs permission for a tool call. + * The handler should present the request to the user and call resolve() with their response. + */ +export type PermissionHandler = (request: acp.RequestPermissionRequest) => Promise + +// ============================================================ +// Session Event Emitter Types +// ============================================================ + +/** + * Maps ACP SessionUpdate types to their event listener signatures. + * Uses the sessionUpdate discriminator to derive event names and payload types. + */ +export type ClineSessionEvents = { + [K in SessionUpdateType]: (payload: SessionUpdatePayload) => void +} & { + /** Error event for session-level errors (not part of ACP SessionUpdate) */ + error: (error: Error) => void +} + +// ============================================================ +// ClineAgent Options +// ============================================================ + +/** + * Options for creating a ClineAgent instance. + */ +export interface ClineAgentOptions { + /** Whether debug logging is enabled */ + debug?: boolean + /** Cline Config Directory (defaults to ~/.cline) */ + clineDir?: string + /** Additional runtime hooks directory */ + hooksDir?: string +} + +/** + * Options for creating an ACP agent instance. + */ +export interface AcpAgentOptions { + /** Whether debug logging is enabled */ + debug?: boolean + /** Additional runtime hooks directory */ + hooksDir?: string +} + +// ============================================================ +// Session Types +// ============================================================ +export type SessionID = string + +/** + * Extended session data stored by Cline for ACP sessions. + */ +export interface ClineAcpSession { + /** Unique session ID */ + sessionId: SessionID + /** Working directory for the session */ + cwd: string + /** Current mode (plan/act) */ + mode: "plan" | "act" + /** MCP servers passed from the client */ + mcpServers: acp.McpServer[] + /** Timestamp when session was created */ + createdAt: number + /** Timestamp of last activity */ + lastActivityAt: number + /** Whether this session was loaded from history (needs resume on first prompt) */ + isLoadedFromHistory?: boolean + /** Model ID override for plan mode (format: "provider/modelId") */ + planModeModelId?: string + /** Model ID override for act mode (format: "provider/modelId") */ + actModeModelId?: string +} + +/** + * Lifecycle status of an ACP session. + * + * Represents the state machine: + * Idle → Processing → Idle (normal completion) + * Idle → Processing → Cancelled (cancellation, then back to Idle on next prompt) + */ +export enum AcpSessionStatus { + /** Session is idle, waiting for a prompt */ + Idle = "idle", + /** Session is actively processing a prompt */ + Processing = "processing", + /** Session processing was cancelled */ + Cancelled = "cancelled", +} + +/** + * State tracking for an active ACP session within Cline. + */ +export interface AcpSessionState { + /** Session ID */ + sessionId: SessionID + /** Current lifecycle status of the session */ + status: AcpSessionStatus + /** Current tool call ID being executed (if any) */ + currentToolCallId?: string + /** Accumulated tool calls for permission batching */ + pendingToolCalls: Map +} + +// ============================================================ +// Agent Capabilities +// ============================================================ + +/** + * Cline-specific agent capabilities extending the ACP base capabilities. + */ +export interface ClineAgentCapabilities { + /** Support for loading sessions from disk */ + loadSession: boolean + /** Prompt capabilities for the agent */ + promptCapabilities: { + /** Support for image inputs */ + image: boolean + /** Support for audio inputs */ + audio: boolean + /** Support for embedded context (file resources) */ + embeddedContext: boolean + } + /** MCP server passthrough capabilities */ + mcpCapabilities: { + /** Support for HTTP MCP servers */ + http: boolean + /** Support for SSE MCP servers */ + sse: boolean + } +} + +/** + * Cline agent info for ACP initialization response. + */ +export interface ClineAgentInfo { + name: "cline" + title: "Cline" + version: string +} + +// ============================================================ +// Permission Options +// ============================================================ + +/** + * Permission option as presented to the ACP client. + */ +export interface ClinePermissionOption { + kind: acp.PermissionOptionKind + name: string + optionId: string +} + +// ============================================================ +// Message Translation +// ============================================================ + +/** + * Result of translating a Cline message to ACP session update(s). + * A single Cline message may produce multiple ACP updates. + */ +export interface TranslatedMessage { + /** The session updates to send */ + updates: acp.SessionUpdate[] + /** Whether this message requires a permission request */ + requiresPermission?: boolean + /** Permission request details if required */ + permissionRequest?: Omit + /** The toolCallId that was created/used (for tracking across streaming updates) */ + toolCallId?: string +} + +// ============================================================ +// Re-exported ACP Types +// ============================================================ + +export type { + Agent, + AgentSideConnection, + AudioContent, + CancelNotification, + ClientCapabilities, + ContentBlock, + ImageContent, + InitializeRequest, + InitializeResponse, + LoadSessionRequest, + LoadSessionResponse, + McpServer, + ModelInfo, + NewSessionRequest, + NewSessionResponse, + PermissionOption, + PermissionOptionKind, + PromptRequest, + PromptResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionConfigOption, + SessionModelState, + SessionNotification, + SessionUpdate, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, + SetSessionModelRequest, + SetSessionModelResponse, + SetSessionModeRequest, + SetSessionModeResponse, + StopReason, + TextContent, + ToolCall, + ToolCallStatus, + ToolCallUpdate, + ToolKind, +} from "@agentclientprotocol/sdk" diff --git a/cli/src/agent/types.ts b/cli/src/agent/types.ts index b22de33922b..90440635c7d 100644 --- a/cli/src/agent/types.ts +++ b/cli/src/agent/types.ts @@ -1,76 +1,13 @@ /** - * Custom types and extensions for ACP integration with Cline CLI. + * Internal types for ACP integration with Cline CLI. * - * This file extends the base ACP types with Cline-specific functionality. - */ - -import type * as acp from "@agentclientprotocol/sdk" -import type { Controller } from "@/core/controller" - -// ============================================================ -// Session Update Type Utilities -// ============================================================ - -/** - * Extract the sessionUpdate discriminator value from a SessionUpdate variant. - */ -export type SessionUpdateType = acp.SessionUpdate["sessionUpdate"] - -/** - * Extract the payload type for a given sessionUpdate discriminator value. - * This removes the `sessionUpdate` discriminator field from the type. - */ -export type SessionUpdatePayload = Omit< - Extract, - "sessionUpdate" -> - -// ============================================================ -// Permission Handler Callback Types -// ============================================================ - -/** - * Callback to resolve a permission request with the user's response. - */ -export type PermissionResolver = (response: acp.RequestPermissionResponse) => void - -/** - * Handler function for permission requests. - * Called when the agent needs permission for a tool call. - * The handler should present the request to the user and call resolve() with their response. - */ -export type PermissionHandler = (request: Omit, resolve: PermissionResolver) => void - -// ============================================================ -// Session Event Emitter Types -// ============================================================ - -/** - * Maps ACP SessionUpdate types to their event listener signatures. - * Uses the sessionUpdate discriminator to derive event names and payload types. - */ -export type ClineSessionEvents = { - [K in SessionUpdateType]: (payload: SessionUpdatePayload) => void -} & { - /** Error event for session-level errors (not part of ACP SessionUpdate) */ - error: (error: Error) => void -} - -// ============================================================ -// ClineAgent Options (decoupled from connection) -// ============================================================ - -/** - * Options for creating a ClineAgent instance (decoupled from connection). + * This file re-exports all public types from ./public-types.ts and adds + * internal-only Types that reference core modules (Controller, etc.). + * + * Library consumers should never import from this file directly — they + * get the public types via the library entrypoint (exports.ts). */ -export interface ClineAgentOptions { - /** CLI version string */ - version: string - /** Whether debug logging is enabled */ - debug?: boolean -} -// Re-export common ACP types for convenience export type { Agent, AgentSideConnection, @@ -114,134 +51,18 @@ export type { WriteTextFileResponse, } from "@agentclientprotocol/sdk" -/** - * Cline-specific agent capabilities extending the ACP base capabilities. - */ -export interface ClineAgentCapabilities { - /** Support for loading sessions from disk */ - loadSession: boolean - /** Prompt capabilities for the agent */ - promptCapabilities: { - /** Support for image inputs */ - image: boolean - /** Support for audio inputs */ - audio: boolean - /** Support for embedded context (file resources) */ - embeddedContext: boolean - } - /** MCP server passthrough capabilities */ - mcpCapabilities: { - /** Support for HTTP MCP servers */ - http: boolean - /** Support for SSE MCP servers */ - sse: boolean - } -} - -/** - * Cline agent info for ACP initialization response. - */ -export interface ClineAgentInfo { - name: "cline" - title: "Cline" - version: string -} - -/** - * Extended session data stored by Cline for ACP sessions. - * Maps to Cline's task history structure. - */ -export interface ClineAcpSession { - /** Unique session/task ID */ - sessionId: string - /** Working directory for the session */ - cwd: string - /** Current mode (plan/act) */ - mode: "plan" | "act" - /** MCP servers passed from the client */ - mcpServers: acp.McpServer[] - /** Timestamp when session was created */ - createdAt: number - /** Timestamp of last activity */ - lastActivityAt: number - /** Whether this session was loaded from history (needs resume on first prompt) */ - isLoadedFromHistory?: boolean - /** Controller instance for this session (manages task execution) */ - controller?: Controller - /** Model ID override for plan mode (format: "provider/modelId") */ - planModeModelId?: string - /** Model ID override for act mode (format: "provider/modelId") */ - actModeModelId?: string -} - -/** - * Permission option as presented to the ACP client. - */ -export interface ClinePermissionOption { - kind: acp.PermissionOptionKind - name: string - optionId: string -} - -/** - * Mapping of Cline message types to their ACP session update equivalents. - */ -export type ClineToAcpUpdateMapping = { - /** Text messages from the agent */ - text: "agent_message_chunk" - /** Reasoning/thinking from the agent */ - reasoning: "agent_thought_chunk" - /** Markdown content from the agent */ - markdown: "agent_message_chunk" - /** Tool execution */ - tool: "tool_call" - /** Command execution */ - command: "tool_call" - /** Command output */ - command_output: "tool_call_update" - /** Task completion */ - completion_result: "end_turn" - /** Error messages */ - error: "tool_call_update" | "error" -} - -/** - * Options for creating an ACP agent instance. - */ -export interface AcpAgentOptions { - /** CLI version string */ - version: string - /** Whether debug logging is enabled */ - debug?: boolean -} - -/** - * Result of translating a Cline message to ACP session update(s). - * A single Cline message may produce multiple ACP updates. - */ -export interface TranslatedMessage { - /** The session updates to send */ - updates: acp.SessionUpdate[] - /** Whether this message requires a permission request */ - requiresPermission?: boolean - /** Permission request details if required */ - permissionRequest?: Omit - /** The toolCallId that was created/used (for tracking across streaming updates) */ - toolCallId?: string -} - -/** - * State tracking for an active ACP session within Cline. - */ -export interface AcpSessionState { - /** Session ID */ - sessionId: string - /** Whether the session is currently processing a prompt */ - isProcessing: boolean - /** Current tool call ID being executed (if any) */ - currentToolCallId?: string - /** Whether the session has been cancelled */ - cancelled: boolean - /** Accumulated tool calls for permission batching */ - pendingToolCalls: Map -} +export type { + AcpAgentOptions, + AcpSessionState, + ClineAgentCapabilities, + ClineAgentInfo, + ClineAgentOptions, + ClinePermissionOption, + ClineSessionEvents, + PermissionHandler, + SessionUpdatePayload, + SessionUpdateType, + TranslatedMessage, +} from "./public-types.js" + +export { AcpSessionStatus } from "./public-types.js" diff --git a/cli/src/components/App.resize-initial-prompt.test.tsx b/cli/src/components/App.resize-initial-prompt.test.tsx new file mode 100644 index 00000000000..ffc2082f859 --- /dev/null +++ b/cli/src/components/App.resize-initial-prompt.test.tsx @@ -0,0 +1,136 @@ +import { Text } from "ink" +import { render } from "ink-testing-library" +import React from "react" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { App } from "./App" + +const CLEAR_SEQUENCE = "\x1b[2J\x1b[3J\x1b[H" + +function setTerminalSize(columns: number, rows: number) { + Object.defineProperty(process.stdout, "columns", { + configurable: true, + writable: true, + value: columns, + }) + + Object.defineProperty(process.stdout, "rows", { + configurable: true, + writable: true, + value: rows, + }) +} + +function hasClearSequenceCall(calls: unknown[][]): boolean { + return calls.some((call) => call[0] === CLEAR_SEQUENCE) +} + +vi.mock("./ChatView", () => ({ + ChatView: ({ controller, initialPrompt, initialImages }: any) => { + React.useEffect(() => { + if (initialPrompt || (initialImages && initialImages.length > 0)) { + controller?.initTask(initialPrompt || "", initialImages) + } + }, []) + + return React.createElement(Text, null, "ChatView") + }, +})) + +vi.mock("./TaskJsonView", () => ({ + TaskJsonView: () => React.createElement(Text, null, "TaskJsonView"), +})) + +vi.mock("./HistoryView", () => ({ + HistoryView: () => React.createElement(Text, null, "HistoryView"), +})) + +vi.mock("./ConfigView", () => ({ + ConfigView: () => React.createElement(Text, null, "ConfigView"), +})) + +vi.mock("./AuthView", () => ({ + AuthView: () => React.createElement(Text, null, "AuthView"), +})) + +vi.mock("../context/TaskContext", () => ({ + TaskContextProvider: ({ children }: any) => children, +})) + +vi.mock("../context/StdinContext", () => ({ + StdinProvider: ({ children }: any) => children, +})) + +describe("App startup prompt resize behavior", () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + delete (process.stdout as any).columns + delete (process.stdout as any).rows + }) + + it("does not replay initialPrompt after a width resize", async () => { + const initTask = vi.fn() + setTerminalSize(120, 40) + + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((...args: any[]) => { + const callback = args.find((arg) => typeof arg === "function") + if (callback) { + callback() + } + return true + }) as any) + + const { unmount } = render( + , + ) + + await vi.advanceTimersByTimeAsync(0) + expect(initTask).toHaveBeenCalledTimes(1) + writeSpy.mockClear() + + setTerminalSize(121, 40) + process.stdout.emit("resize") + await vi.advanceTimersByTimeAsync(350) + await vi.advanceTimersByTimeAsync(0) + + expect(initTask).toHaveBeenCalledTimes(1) + expect(hasClearSequenceCall(writeSpy.mock.calls as unknown[][])).toBe(true) + + unmount() + }) + + it("does not remount on height-only resize", async () => { + const initTask = vi.fn() + setTerminalSize(120, 40) + + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((...args: any[]) => { + const callback = args.find((arg) => typeof arg === "function") + if (callback) { + callback() + } + return true + }) as any) + + const { unmount } = render( + , + ) + + await vi.advanceTimersByTimeAsync(0) + expect(initTask).toHaveBeenCalledTimes(1) + writeSpy.mockClear() + + setTerminalSize(120, 45) + process.stdout.emit("resize") + await vi.advanceTimersByTimeAsync(350) + await vi.advanceTimersByTimeAsync(0) + + expect(initTask).toHaveBeenCalledTimes(1) + expect(hasClearSequenceCall(writeSpy.mock.calls as unknown[][])).toBe(false) + + unmount() + }) +}) diff --git a/cli/src/components/App.tsx b/cli/src/components/App.tsx index e01425a7272..bcc5b639ea4 100644 --- a/cli/src/components/App.tsx +++ b/cli/src/components/App.tsx @@ -3,14 +3,15 @@ * Routes between different views (task, history, config) */ -import { Box } from "ink" -import React, { ReactNode, useCallback, useState } from "react" +import { Box, useApp } from "ink" +import React, { ReactNode, useCallback, useEffect, useState } from "react" import { StdinProvider } from "../context/StdinContext" import { TaskContextProvider } from "../context/TaskContext" import { useTerminalSize } from "../hooks/useTerminalSize" import { AuthView } from "./AuthView" import { ChatView } from "./ChatView" import { ConfigView } from "./ConfigView" +import { ErrorBoundary } from "./ErrorBoundary" import { HistoryView } from "./HistoryView" import { TaskJsonView } from "./TaskJsonView" @@ -90,7 +91,17 @@ interface AppProps { isRawModeSupported?: boolean } -export const App: React.FC = ({ +export const App: React.FC = (props) => { + const { exit } = useApp() + + return ( + + + + ) +} + +const InternalApp: React.FC = ({ view: initialView, taskId, verbose = false, @@ -135,6 +146,17 @@ export const App: React.FC = ({ const { resizeKey } = useTerminalSize() const [currentView, setCurrentView] = useState(initialView) const [selectedTaskId, setSelectedTaskId] = useState(taskId) + const [pendingInitialPrompt, setPendingInitialPrompt] = useState(initialPrompt) + const [pendingInitialImages, setPendingInitialImages] = useState(initialImages) + + useEffect(() => { + if (!pendingInitialPrompt && (!pendingInitialImages || pendingInitialImages.length === 0)) { + return + } + + setPendingInitialPrompt(undefined) + setPendingInitialImages(undefined) + }, [pendingInitialPrompt, pendingInitialImages]) const handleSelectTask = useCallback((taskId: string) => { setSelectedTaskId(taskId) @@ -242,8 +264,8 @@ export const App: React.FC = ({ ) : ( { + (_, key) => { if (key.upArrow) { setSelectedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1)) } else if (key.downArrow) { @@ -142,7 +145,11 @@ const TextInput: React.FC<{ return ( - {displayValue || placeholder || ""} + {!displayValue && placeholder ? ( + e.g. {placeholder} + ) : ( + {displayValue || ""} + )} ) @@ -166,6 +173,7 @@ export const AuthView: React.FC = ({ controller, onComplete, onEr const [providerSearch, setProviderSearch] = useState("") const [providerIndex, setProviderIndex] = useState(0) const [clineModelIndex, setClineModelIndex] = useState(0) + const featuredModels = useClineFeaturedModels() const [importSources, setImportSources] = useState({ codex: false, opencode: false }) const [importSource, setImportSource] = useState(null) const [bedrockConfig, setBedrockConfig] = useState(null) @@ -248,6 +256,7 @@ export const AuthView: React.FC = ({ controller, onComplete, onEr }, []) // Reset provider index when search changes + // biome-ignore lint/correctness/useExhaustiveDependencies: we want to reset here useEffect(() => { setProviderIndex(0) }, [providerSearch]) @@ -273,7 +282,7 @@ export const AuthView: React.FC = ({ controller, onComplete, onEr return } - if (authState.user && authState.user.email) { + if (authState.user?.email) { // Auth succeeded - save configuration and transition to model selection await applyProviderConfig({ providerId: "cline", controller }) setSelectedProvider("cline") @@ -389,6 +398,33 @@ export const AuthView: React.FC = ({ controller, onComplete, onEr [selectedProvider], ) + // Save custom Bedrock ARN configuration with base model for capability detection + const saveCustomBedrockConfiguration = useCallback( + async (arn: string, baseModelId: string) => { + try { + if (!bedrockConfig) { + throw new Error("Bedrock configuration is missing") + } + await applyBedrockConfig({ + bedrockConfig, + modelId: arn, + customModelBaseId: baseModelId, + controller, + }) + + const stateManager = StateManager.get() + stateManager.setGlobalState("welcomeViewCompleted", true) + await stateManager.flushPendingState() + + setStep("success") + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : String(error)) + setStep("error") + } + }, + [bedrockConfig, controller], + ) + const saveConfiguration = useCallback( async (model: string, base: string) => { try { @@ -423,6 +459,12 @@ export const AuthView: React.FC = ({ controller, onComplete, onEr const handleModelIdSubmit = useCallback( (value: string) => { + // Intercept "Custom" selection for Bedrock — redirect to custom ARN input flow + if (value === CUSTOM_MODEL_ID && selectedProvider === "bedrock") { + setStep("bedrock_custom") + return + } + if (value.trim()) { setModelId(value) } @@ -530,6 +572,9 @@ export const AuthView: React.FC = ({ controller, onComplete, onEr // Go back to cline_model if we came from there (Cline provider) if (selectedProvider === "cline") { setStep("cline_model") + } else if (selectedProvider === "bedrock") { + // Bedrock skips the API key step — go back to Bedrock setup + setStep("bedrock") } else { setStep("apikey") } @@ -646,7 +691,7 @@ export const AuthView: React.FC = ({ controller, onComplete, onEr Model ID - e.g., claude-sonnet-4-20250514, gpt-4o + e.g., claude-sonnet-4-6, gpt-4o @@ -724,7 +769,7 @@ export const AuthView: React.FC = ({ controller, onComplete, onEr Choose a model - + ) } @@ -741,6 +786,18 @@ export const AuthView: React.FC = ({ controller, onComplete, onEr /> ) + case "bedrock_custom": + return ( + setStep("modelid")} + onComplete={(arn, baseModelId) => { + setStep("saving") + saveCustomBedrockConfiguration(arn, baseModelId) + }} + /> + ) + case "import": if (!importSource) { return null @@ -814,17 +871,17 @@ export const AuthView: React.FC = ({ controller, onComplete, onEr setProviderSearch((prev) => prev + input) } } else if (step === "cline_model") { - const maxIndex = getFeaturedModelMaxIndex() + const maxIndex = getFeaturedModelMaxIndex(featuredModels) if (key.upArrow) { setClineModelIndex((prev) => (prev > 0 ? prev - 1 : maxIndex)) } else if (key.downArrow) { setClineModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0)) } else if (key.return) { - if (isBrowseAllSelected(clineModelIndex)) { + if (isBrowseAllSelected(clineModelIndex, featuredModels)) { setStep("modelid") } else { - const selectedModel = getFeaturedModelAtIndex(clineModelIndex) + const selectedModel = getFeaturedModelAtIndex(clineModelIndex, featuredModels) if (selectedModel) { handleClineModelSelect(selectedModel.id) } diff --git a/cli/src/components/BedrockCustomModelFlow.tsx b/cli/src/components/BedrockCustomModelFlow.tsx new file mode 100644 index 00000000000..eeabaa399a8 --- /dev/null +++ b/cli/src/components/BedrockCustomModelFlow.tsx @@ -0,0 +1,111 @@ +/** + * Bedrock Custom Model Flow component + * Two-step flow: ARN/custom model ID input → base model selection for capability detection. + * Used by both AuthView (onboarding) and SettingsPanelContent (/settings). + */ + +import { Box, Text, useInput } from "ink" +// biome-ignore lint/correctness/noUnusedImports: React is needed for JSX at runtime +import React, { useCallback, useState } from "react" +import { COLORS } from "../constants/colors" +import { useStdinContext } from "../context/StdinContext" +import { getModelList } from "./ModelPicker" +import { SearchableList } from "./SearchableList" + +type FlowStep = "arn_input" | "base_model" + +interface BedrockCustomModelFlowProps { + /** Whether this component should capture keyboard input */ + isActive: boolean + /** Called when the user completes both steps (ARN + base model selection) */ + onComplete: (arn: string, baseModelId: string) => void + /** Called when the user presses Escape on the first step (ARN input) */ + onCancel: () => void +} + +export const BedrockCustomModelFlow: React.FC = ({ isActive, onComplete, onCancel }) => { + const { isRawModeSupported } = useStdinContext() + const [step, setStep] = useState("arn_input") + const [customArn, setCustomArn] = useState("") + + const handleArnSubmit = useCallback(() => { + if (customArn.trim()) { + setStep("base_model") + } + }, [customArn]) + + const handleBaseModelCancel = useCallback(() => { + setStep("arn_input") + }, []) + + useInput( + (input, key) => { + if (step === "arn_input") { + if (key.escape) { + onCancel() + } else if (key.return) { + handleArnSubmit() + } else if (key.backspace || key.delete) { + setCustomArn((prev) => prev.slice(0, -1)) + } else if (input && !key.ctrl && !key.meta) { + setCustomArn((prev) => prev + input) + } + return + } + + if (step === "base_model") { + if (key.escape) { + handleBaseModelCancel() + } + // Other input is handled by SearchableList + } + }, + { isActive: isActive && isRawModeSupported }, + ) + + if (step === "arn_input") { + return ( + + + Custom Model ID + + + Enter your Application Inference Profile ARN or custom model ID + + + {customArn ? ( + {customArn} + ) : ( + e.g. arn:aws:bedrock:region:account:application-inference-profile/... + )} + + + + Enter to continue, Esc to go back + + + ) + } + + // step === "base_model" + return ( + + + Base Inference Model + + Select the base model your inference profile uses (for capability detection) + + ({ id, label: id }))} + onSelect={(item) => { + onComplete(customArn, item.id) + }} + /> + + + Type to search, arrows to navigate, Enter to select, Esc to go back + + + ) +} diff --git a/cli/src/components/ChatMessage.markdown.test.tsx b/cli/src/components/ChatMessage.markdown.test.tsx new file mode 100644 index 00000000000..1da12c41439 --- /dev/null +++ b/cli/src/components/ChatMessage.markdown.test.tsx @@ -0,0 +1,53 @@ +import type { ClineMessage } from "@shared/ExtensionMessage" +import { render } from "ink-testing-library" +import React from "react" +import { describe, expect, it, vi } from "vitest" +import { ChatMessage } from "./ChatMessage" + +vi.mock("../hooks/useTerminalSize", () => ({ + useTerminalSize: () => ({ + columns: 120, + rows: 40, + resizeKey: 0, + }), +})) + +describe("ChatMessage markdown rendering", () => { + it("renders basic markdown elements correctly with appropriate styling", () => { + const message: ClineMessage = { + ts: Date.now(), + type: "say", + say: "text", + text: "# Heading 1\n\nThis is a **bold** and *italic* text with `inline code`.\n\n- List item 1\n- List item 2\n\n> Blockquote\n\n```javascript\nconst x = 1;\n```", + } + + const { lastFrame } = render(React.createElement(ChatMessage, { message, mode: "act" })) + const frame = lastFrame() || "" + + // Check for heading (bold) + // \x1B[1m is the ANSI escape code for bold + expect(frame).toMatch(/\x1B\[1mHeading 1\x1B\[22m/) + + // Check for bold text + expect(frame).toMatch(/\x1B\[1mbold\x1B\[22m/) + + // Check for italic text + // \x1B[3m is the ANSI escape code for italic + expect(frame).toMatch(/\x1B\[3mitalic\x1B\[23m/) + + // Check for inline code (no special styling in the current implementation, just text) + expect(frame).toContain("inline code") + + // Check for list items (gray bullet) + // \x1B[90m is the ANSI escape code for gray + expect(frame).toMatch(/\x1B\[90m• \x1B\[39mList item 1/) + expect(frame).toMatch(/\x1B\[90m• \x1B\[39mList item 2/) + + // Check for blockquote (gray pipe) + expect(frame).toMatch(/\x1B\[90m│ \x1B\[39mBlockquote/) + + // Check for code block (cyan text) + // \x1B[36m is the ANSI escape code for cyan + expect(frame).toMatch(/\x1B\[36mconst x = 1;\x1B\[39m/) + }) +}) diff --git a/cli/src/components/ChatMessage.tsx b/cli/src/components/ChatMessage.tsx index 73a5882ad9c..e9b8e13536e 100644 --- a/cli/src/components/ChatMessage.tsx +++ b/cli/src/components/ChatMessage.tsx @@ -11,6 +11,7 @@ import { COMMAND_OUTPUT_STRING } from "@shared/combineCommandSequences" import type { ClineAskUseMcpServer, ClineMessage } from "@shared/ExtensionMessage" import { Box, Text } from "ink" import Spinner from "ink-spinner" +import { lexer, type Token, type Tokens } from "marked" import React from "react" import { COLORS } from "../constants/colors" import { useTerminalSize } from "../hooks/useTerminalSize" @@ -20,13 +21,10 @@ import { DiffView } from "./DiffView" import { SubagentMessage } from "./SubagentMessage" /** - * Add "(Tab)" hint after "Act mode" mentions. + * Add "(Tab)" hint after "Act mode" mentions in plain text. * Case-insensitive, avoids double-adding if already present. - * Matches just "Act mode" without requiring "to " prefix because markdown - * processing may split "toggle to **Act mode**" into separate text chunks. */ function addActModeHint(text: string, keyPrefix: string): React.ReactNode[] { - // Match "Act mode" in various capitalizations, but not if already followed by (Tab) const actModeRegex = /\bact\s+mode\b(?!\s*\(tab\))/gi const parts = text.split(actModeRegex) const matches = text.match(actModeRegex) @@ -37,9 +35,7 @@ function addActModeHint(text: string, keyPrefix: string): React.ReactNode[] { const nodes: React.ReactNode[] = [] parts.forEach((part, i) => { - if (part) { - nodes.push(part) - } + if (part) nodes.push(part) if (matches[i]) { nodes.push( @@ -49,72 +45,146 @@ function addActModeHint(text: string, keyPrefix: string): React.ReactNode[] { ) } }) - return nodes } /** - * Render inline markdown: **bold**, *italic*, `code` - * Also adds "(Tab)" hints after "Act mode" mentions. - * Returns array of React nodes with appropriate styling + * Render an array of marked tokens as Ink React nodes. + * This is the entry point for recursive rendering — each token may + * contain child tokens (e.g. a paragraph contains inline tokens, + * a list contains items, etc.). */ -function renderInlineMarkdown(text: string): React.ReactNode[] { - const nodes: React.ReactNode[] = [] - let hintCallIndex = 0 - const addHintedText = (value: string) => addActModeHint(value, `hint-${hintCallIndex++}`) - // Match **bold**, *italic*, or `code` - order matters (** before *) - const regex = /(\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)/g - let lastIndex = 0 - let match - - while ((match = regex.exec(text)) !== null) { - // Add text before match (with Act Mode hint processing) - if (match.index > lastIndex) { - const beforeText = text.slice(lastIndex, match.index) - nodes.push(...addHintedText(beforeText)) +function renderTokens(tokens: Token[], color?: string): React.ReactNode[] { + return tokens.map((token, i) => renderToken(token, i, color)) +} + +/** + * Render a single marked token (block or inline) as an Ink React node. + * Handles both block-level tokens (heading, paragraph, list, code, etc.) + * and inline tokens (strong, em, codespan, link, text). + */ +function renderToken(token: Token, key: number, color?: string): React.ReactNode { + switch (token.type) { + // --- Block tokens --- + + case "heading": { + const { depth, tokens } = token as Tokens.Heading + return ( + + + {renderTokens(tokens, color)} + + + ) } - const fullMatch = match[0] - const key = `md-${match.index}` + case "paragraph": + return ( + + {renderTokens((token as Tokens.Paragraph).tokens, color)} + + ) - if (fullMatch.startsWith("**") && fullMatch.endsWith("**")) { - // Bold - also process for Act Mode hints inside bold text - const boldContent = fullMatch.slice(2, -2) - const hintedContent = addHintedText(boldContent) - nodes.push( - - {hintedContent} - , + case "code": + return ( + + {(token as Tokens.Code).text.split("\n").map((line, i) => ( + + {line || " "} + + ))} + ) - } else if (fullMatch.startsWith("*") && fullMatch.endsWith("*")) { - // Italic - nodes.push( - - {fullMatch.slice(1, -1)} - , + + case "list": { + const { ordered, start, items } = token as Tokens.List + return ( + + {items.map((item, i) => ( + + {ordered ? `${Number(start ?? 1) + i}. ` : "• "} + + {renderTokens(item.tokens, color)} + + + ))} + ) - } else if (fullMatch.startsWith("`") && fullMatch.endsWith("`")) { - // Inline code - nodes.push({fullMatch.slice(1, -1)}) } - lastIndex = regex.lastIndex - } + case "blockquote": + return ( + + + {renderTokens((token as Tokens.Blockquote).tokens, color)} + + ) - // Add remaining text (with Act Mode hint processing) - if (lastIndex < text.length) { - nodes.push(...addHintedText(text.slice(lastIndex))) - } + case "space": + return + + // --- Inline tokens --- + + case "strong": + return ( + + {renderTokens((token as Tokens.Strong).tokens, color)} + + ) + + case "em": + return ( + + {renderTokens((token as Tokens.Em).tokens, color)} + + ) + + case "codespan": + return {(token as Tokens.Codespan).text} + + case "link": { + const { text, href } = token as Tokens.Link + return ( + + {text && text !== href ? `${text} (${href})` : href} + + ) + } + + case "text": { + const { text, tokens } = token as Tokens.Text + if (tokens?.length) { + return ( + + {renderTokens(tokens, color)} + + ) + } + return ( + + {addActModeHint(text, `${key}`)} + + ) + } - return nodes.length > 0 ? nodes : addHintedText(text) + // Fallback for any unhandled token type + default: + return "raw" in token ? ( + + {(token as { raw: string }).raw} + + ) : null + } } /** - * Render text with inline markdown support + * Render a markdown string as Ink components. + * Uses marked's lexer to parse markdown into tokens, then renders + * each token to the appropriate Ink component. */ const MarkdownText: React.FC<{ children: string; color?: string }> = ({ children, color }) => { - const nodes = renderInlineMarkdown(children) - return {nodes} + const tokens = lexer(children) + return {renderTokens(tokens, color)} } interface ChatMessageProps { diff --git a/cli/src/components/ChatView.tsx b/cli/src/components/ChatView.tsx index 064cfc5dc66..6aabc513991 100644 --- a/cli/src/components/ChatView.tsx +++ b/cli/src/components/ChatView.tsx @@ -150,6 +150,7 @@ import { HighlightedInput } from "./HighlightedInput" import { HistoryPanelContent } from "./HistoryPanelContent" import { providerModels } from "./ModelPicker" import { SettingsPanelContent } from "./SettingsPanelContent" +import { SkillsPanelContent } from "./SkillsPanelContent" import { SlashCommandMenu } from "./SlashCommandMenu" import { ThinkingIndicator } from "./ThinkingIndicator" @@ -412,6 +413,7 @@ export const ChatView: React.FC = ({ | { type: "settings"; initialMode?: "model-picker" | "featured-models"; initialModelKey?: "actModelId" | "planModelId" } | { type: "history" } | { type: "help" } + | { type: "skills" } | null >(null) @@ -1156,13 +1158,21 @@ export const ChatView: React.FC = ({ setSlashMenuDismissed(true) return } + if (cmd.name === "skills") { + setActivePanel({ type: "skills" }) + setTextInput("") + setCursorPos(0) + setSelectedSlashIndex(0) + setSlashMenuDismissed(true) + return + } if (cmd.name === "clear") { clearViewAndResetTask() setSelectedSlashIndex(0) setSlashMenuDismissed(true) return } - if (cmd.name === "exit") { + if (cmd.name === "exit" || cmd.name === "q") { handleExit() return } @@ -1545,6 +1555,19 @@ export const ChatView: React.FC = ({ {/* Help panel */} {activePanel?.type === "help" && setActivePanel(null)} />} + {/* Skills panel */} + {activePanel?.type === "skills" && ctrl && ( + setActivePanel(null)} + onUseSkill={(skillPath) => { + setActivePanel(null) + setTextInput(`@${skillPath} `) + setCursorPos(skillPath.length + 2) + }} + /> + )} + {/* Slash command menu - below input (takes priority over file menu) */} {showSlashMenu && !activePanel && ( diff --git a/cli/src/components/ConfigView.test.tsx b/cli/src/components/ConfigView.test.tsx index 28508afff66..b0af98dd785 100644 --- a/cli/src/components/ConfigView.test.tsx +++ b/cli/src/components/ConfigView.test.tsx @@ -84,6 +84,16 @@ describe("ConfigView", () => { ) expect(lastFrame()).toContain("Global Settings") }) + + it("hides Hooks tab when hooks are disabled", () => { + const { lastFrame } = render() + expect(lastFrame()).not.toContain("Hooks") + }) + + it("shows Hooks tab when hooks are enabled", () => { + const { lastFrame } = render() + expect(lastFrame()).toContain("Hooks") + }) }) describe("value formatting", () => { diff --git a/cli/src/components/ErrorBoundary.tsx b/cli/src/components/ErrorBoundary.tsx new file mode 100644 index 00000000000..e3c0380f5c0 --- /dev/null +++ b/cli/src/components/ErrorBoundary.tsx @@ -0,0 +1,51 @@ +import { Box, Text } from "ink" +import React from "react" +import { ErrorService } from "@/services/error" +import { StaticRobotFrame } from "./AsciiMotionCli" + +type Props = React.PropsWithChildren<{ exit: (error?: Error) => void }> + +async function onReactError(props: Props, error: Error, errorInfo: React.ErrorInfo) { + try { + await ErrorService.get().captureException(error, { context: "ErrorBoundary", errorInfo }) + await ErrorService.get().dispose() + } catch { + // Ignore errors + } finally { + props.exit(error) + } +} + +export class ErrorBoundary extends React.Component { + override state = { hasError: false } + + constructor(props: Props) { + super(props) + } + + override componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void { + onReactError(this.props, error, errorInfo) + } + + static getDerivedStateFromError() { + return { hasError: true } + } + + override render() { + if (this.state.hasError) { + return ( + + + + + Something went wrong. We're sorry. + + Please check the logs for more details. + + + ) + } + + return this.props.children + } +} diff --git a/cli/src/components/FeaturedModelPicker.tsx b/cli/src/components/FeaturedModelPicker.tsx index 96f7cd9ebc1..d4dd9918179 100644 --- a/cli/src/components/FeaturedModelPicker.tsx +++ b/cli/src/components/FeaturedModelPicker.tsx @@ -7,13 +7,14 @@ import { Box, Text } from "ink" import React from "react" import { COLORS } from "../constants/colors" -import { type FeaturedModel, getAllFeaturedModels } from "../constants/featured-models" +import type { FeaturedModel } from "../constants/featured-models" interface FeaturedModelPickerProps { selectedIndex: number title?: string showBrowseAll?: boolean helpText?: string + featuredModels: FeaturedModel[] } export const FeaturedModelPicker: React.FC = ({ @@ -21,8 +22,9 @@ export const FeaturedModelPicker: React.FC = ({ title, showBrowseAll = true, helpText = "Arrows to navigate, Enter to select", + featuredModels, }) => { - const featuredModels = getAllFeaturedModels() + const models = featuredModels return ( @@ -35,11 +37,11 @@ export const FeaturedModelPicker: React.FC = ({ )} - {featuredModels.map((model, i) => { + {models.map((model, i) => { const isSelected = i === selectedIndex return ( - + {isSelected ? "❯ " : " "} @@ -64,8 +66,8 @@ export const FeaturedModelPicker: React.FC = ({ {showBrowseAll && ( - - {selectedIndex === featuredModels.length ? "❯ " : " "} + + {selectedIndex === models.length ? "❯ " : " "} Browse all models... @@ -81,24 +83,21 @@ export const FeaturedModelPicker: React.FC = ({ * Get the maximum valid index for the featured model picker * (includes "Browse all" option if showBrowseAll is true) */ -export function getFeaturedModelMaxIndex(showBrowseAll: boolean = true): number { - const featuredModels = getAllFeaturedModels() +export function getFeaturedModelMaxIndex(featuredModels: FeaturedModel[], showBrowseAll = true): number { return showBrowseAll ? featuredModels.length : featuredModels.length - 1 } /** * Check if the selected index is the "Browse all" option */ -export function isBrowseAllSelected(selectedIndex: number): boolean { - const featuredModels = getAllFeaturedModels() +export function isBrowseAllSelected(selectedIndex: number, featuredModels: FeaturedModel[]): boolean { return selectedIndex === featuredModels.length } /** * Get the featured model at the given index, or null if "Browse all" is selected */ -export function getFeaturedModelAtIndex(index: number): FeaturedModel | null { - const featuredModels = getAllFeaturedModels() +export function getFeaturedModelAtIndex(index: number, featuredModels: FeaturedModel[]): FeaturedModel | null { if (index >= 0 && index < featuredModels.length) { return featuredModels[index] } diff --git a/cli/src/components/HelpPanelContent.tsx b/cli/src/components/HelpPanelContent.tsx index 71bbb5c58b0..1c40bd8d644 100644 --- a/cli/src/components/HelpPanelContent.tsx +++ b/cli/src/components/HelpPanelContent.tsx @@ -88,6 +88,10 @@ export const HelpPanelContent: React.FC = ({ onClose }) = {" "} /clear - Start a fresh task + + {" "} + /q - Quit Cline + diff --git a/cli/src/components/ModelPicker.tsx b/cli/src/components/ModelPicker.tsx index d3a76ff3247..6f29a0100ce 100644 --- a/cli/src/components/ModelPicker.tsx +++ b/cli/src/components/ModelPicker.tsx @@ -62,6 +62,8 @@ import { sapAiCoreModels, vertexDefaultModelId, vertexModels, + wandbDefaultModelId, + wandbModels, xaiDefaultModelId, xaiModels, } from "@/shared/api" @@ -71,6 +73,9 @@ import { COLORS } from "../constants/colors" import { getOpenRouterDefaultModelId, usesOpenRouterModels } from "../utils/openrouter-models" import { SearchableList, SearchableListItem } from "./SearchableList" +// Special ID used to indicate the user wants to enter a custom model ID / ARN +export const CUSTOM_MODEL_ID = "__custom__" + // Map providers to their static model lists and defaults export const providerModels: Record; defaultId: string }> = { anthropic: { models: anthropicModels, defaultId: anthropicDefaultModelId }, @@ -98,6 +103,7 @@ export const providerModels: Record; d sambanova: { models: sambanovaModels, defaultId: sambanovaDefaultModelId }, sapaicore: { models: sapAiCoreModels, defaultId: sapAiCoreDefaultModelId }, vertex: { models: vertexModels, defaultId: vertexDefaultModelId }, + wandb: { models: wandbModels, defaultId: wandbDefaultModelId }, xai: { models: xaiModels, defaultId: xaiDefaultModelId }, zai: { models: internationalZAiModels, defaultId: internationalZAiDefaultModelId }, } @@ -169,12 +175,23 @@ export const ModelPicker: React.FC = ({ provider, controller, return getModelList(provider) }, [provider, asyncModels]) + // Providers that support custom model IDs (e.g., Bedrock Application Inference Profiles) + const supportsCustomModel = provider === "bedrock" + const items: SearchableListItem[] = useMemo(() => { - return modelList.map((modelId) => ({ + const list = modelList.map((modelId) => ({ id: modelId, label: modelId, })) - }, [modelList]) + // Add "Custom" option at the end for providers that support it + if (supportsCustomModel) { + list.push({ + id: CUSTOM_MODEL_ID, + label: "Custom (ARN / Inference Profile)", + }) + } + return list + }, [modelList, supportsCustomModel]) // For providers without a model picker, render nothing if (!hasModelPicker(provider)) { diff --git a/cli/src/components/QuitCommand.test.tsx b/cli/src/components/QuitCommand.test.tsx new file mode 100644 index 00000000000..d589862af6f --- /dev/null +++ b/cli/src/components/QuitCommand.test.tsx @@ -0,0 +1,112 @@ +import { render } from "ink-testing-library" +// biome-ignore lint/correctness/noUnusedImports: React must be in scope for JSX in this test file. +import React from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +// Mock ink's useApp +const mockExit = vi.fn() +vi.mock("ink", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useApp: () => ({ exit: mockExit }), + } +}) + +// Mock child_process +vi.mock("child_process", () => ({ + execSync: vi.fn().mockReturnValue(""), + exec: vi.fn(), +})) + +// Mock dependencies +vi.mock("@/core/controller/slash/getAvailableSlashCommands", () => ({ + getAvailableSlashCommands: vi.fn().mockResolvedValue({ commands: [] }), +})) + +vi.mock("@/core/storage/StateManager", () => ({ + StateManager: { + get: () => ({ + getGlobalSettingsKey: vi.fn().mockReturnValue("act"), + getGlobalStateKey: vi.fn().mockReturnValue([]), + getApiConfiguration: vi.fn().mockReturnValue({}), + }), + }, +})) + +vi.mock("@/services/telemetry", () => ({ + telemetryService: { + captureHostEvent: vi.fn(), + }, +})) + +vi.mock("@shared/services/Session", () => ({ + Session: { + get: () => ({ + getStats: vi.fn().mockReturnValue({}), + }), + }, +})) + +vi.mock("../context/TaskContext", () => ({ + useTaskContext: () => ({ + controller: {}, + clearState: vi.fn(), + }), + useTaskState: () => ({ + clineMessages: [], + }), +})) + +vi.mock("../hooks/useStateSubscriber", () => ({ + useIsSpinnerActive: () => ({ isActive: false, startTime: 0 }), +})) + +import { ChatView } from "./ChatView" + +// Helper to wait for async state updates +const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms)) + +describe("Quit Command (/q and /exit)", () => { + const mockOnExit = vi.fn() + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should exit the application when /q is selected from slash menu", async () => { + const { stdin } = render() + await delay() + + // Type /q + stdin.write("/q") + await delay() + + // Press Enter + stdin.write("\r") + + // handleExit has a 150ms timeout + await delay(200) + + expect(mockExit).toHaveBeenCalled() + expect(mockOnExit).toHaveBeenCalled() + }) + + it("should exit the application when /exit is selected from slash menu", async () => { + const { stdin } = render() + await delay() + + // Type /exit + stdin.write("/exit") + await delay() + + // Press Enter + stdin.write("\r") + + // handleExit has a 150ms timeout + await delay(200) + + expect(mockExit).toHaveBeenCalled() + expect(mockOnExit).toHaveBeenCalled() + }) +}) diff --git a/cli/src/components/SettingsPanelContent.tsx b/cli/src/components/SettingsPanelContent.tsx index 736b5a76186..63a8930df70 100644 --- a/cli/src/components/SettingsPanelContent.tsx +++ b/cli/src/components/SettingsPanelContent.tsx @@ -25,10 +25,12 @@ import { supportsReasoningEffortForModel } from "@/utils/model-utils" import { version as CLI_VERSION } from "../../package.json" import { COLORS } from "../constants/colors" import { useStdinContext } from "../context/StdinContext" +import { useClineFeaturedModels } from "../hooks/useClineFeaturedModels" import { useOcaAuth } from "../hooks/useOcaAuth" import { isMouseEscapeSequence } from "../utils/input" import { applyBedrockConfig, applyProviderConfig } from "../utils/provider-config" import { ApiKeyInput } from "./ApiKeyInput" +import { BedrockCustomModelFlow } from "./BedrockCustomModelFlow" import { type BedrockConfig, BedrockSetup } from "./BedrockSetup" import { Checkbox } from "./Checkbox" import { @@ -38,7 +40,7 @@ import { isBrowseAllSelected, } from "./FeaturedModelPicker" import { LanguagePicker } from "./LanguagePicker" -import { hasModelPicker, ModelPicker } from "./ModelPicker" +import { CUSTOM_MODEL_ID, hasModelPicker, ModelPicker } from "./ModelPicker" import { OcaEmployeeCheck } from "./OcaEmployeeCheck" import { OrganizationPicker } from "./OrganizationPicker" import { Panel, PanelTab } from "./Panel" @@ -160,6 +162,7 @@ export const SettingsPanelContent: React.FC = ({ ) const [isPickingFeaturedModel, setIsPickingFeaturedModel] = useState(initialMode === "featured-models") const [featuredModelIndex, setFeaturedModelIndex] = useState(0) + const featuredModels = useClineFeaturedModels() const [isPickingProvider, setIsPickingProvider] = useState(false) const [isPickingLanguage, setIsPickingLanguage] = useState(false) const [isEnteringApiKey, setIsEnteringApiKey] = useState(false) @@ -171,6 +174,9 @@ export const SettingsPanelContent: React.FC = ({ const [apiKeyValue, setApiKeyValue] = useState("") const [editValue, setEditValue] = useState("") + // Bedrock custom ARN flow state + const [isBedrockCustomFlow, setIsBedrockCustomFlow] = useState(false) + // Settings state - single object for feature toggles const [features, setFeatures] = useState>(() => { const initial: Record = {} @@ -944,10 +950,56 @@ export const SettingsPanelContent: React.FC = ({ setReasoningEffortForMode, ]) + // Handle completion of the Bedrock custom ARN flow (ARN + base model selected) + const handleBedrockCustomFlowComplete = useCallback( + async (arn: string, baseModelId: string) => { + if (!pickingModelKey) return + const apiConfig = stateManager.getApiConfiguration() + + // Build a minimal BedrockConfig from current state for applyBedrockConfig + const bedrockConfig: BedrockConfig = { + awsRegion: apiConfig.awsRegion ?? "us-east-1", + awsAuthentication: apiConfig.awsUseProfile ? "profile" : "credentials", + awsUseCrossRegionInference: Boolean(apiConfig.awsUseCrossRegionInference), + } + + await applyBedrockConfig({ + bedrockConfig, + modelId: arn, + customModelBaseId: baseModelId, + controller, + }) + + // Flush pending state to ensure everything is persisted + await stateManager.flushPendingState() + + // Rebuild API handler if there's an active task + rebuildTaskApi() + + refreshModelIds() + setIsBedrockCustomFlow(false) + setPickingModelKey(null) + + // If opened from /models command, close the entire settings panel + if (initialMode) { + onClose() + } + }, + [pickingModelKey, stateManager, controller, rebuildTaskApi, refreshModelIds, initialMode, onClose], + ) + // Handle model selection from picker const handleModelSelect = useCallback( async (modelId: string) => { if (!pickingModelKey) return + + // Intercept "Custom" selection for Bedrock — redirect to custom ARN input flow + if (modelId === CUSTOM_MODEL_ID && provider === "bedrock") { + setIsPickingModel(false) + setIsBedrockCustomFlow(true) + return + } + const apiConfig = stateManager.getApiConfiguration() const actProvider = apiConfig.actModeApiProvider const planProvider = apiConfig.planModeApiProvider || actProvider @@ -1008,7 +1060,7 @@ export const SettingsPanelContent: React.FC = ({ onClose() } }, - [pickingModelKey, separateModels, stateManager, controller, refreshModelIds, initialMode, onClose], + [pickingModelKey, separateModels, stateManager, controller, provider, refreshModelIds, initialMode, onClose], ) // Handle language selection from picker @@ -1242,7 +1294,7 @@ export const SettingsPanelContent: React.FC = ({ // Featured model picker mode (Cline provider) if (isPickingFeaturedModel) { - const maxIndex = getFeaturedModelMaxIndex() + const maxIndex = getFeaturedModelMaxIndex(featuredModels) if (key.escape) { setIsPickingFeaturedModel(false) @@ -1256,12 +1308,12 @@ export const SettingsPanelContent: React.FC = ({ } else if (key.downArrow) { setFeaturedModelIndex((prev) => (prev < maxIndex ? prev + 1 : 0)) } else if (key.return) { - if (isBrowseAllSelected(featuredModelIndex)) { + if (isBrowseAllSelected(featuredModelIndex, featuredModels)) { // Switch to full ModelPicker setIsPickingFeaturedModel(false) setIsPickingModel(true) } else { - const selectedModel = getFeaturedModelAtIndex(featuredModelIndex) + const selectedModel = getFeaturedModelAtIndex(featuredModelIndex, featuredModels) if (selectedModel && pickingModelKey) { handleModelSelect(selectedModel.id) setIsPickingFeaturedModel(false) @@ -1332,6 +1384,11 @@ export const SettingsPanelContent: React.FC = ({ return } + // Bedrock custom flow - input handled by BedrockCustomModelFlow component + if (isBedrockCustomFlow) { + return + } + if (isEditing) { if (key.escape) { setIsEditing(false) @@ -1467,6 +1524,7 @@ export const SettingsPanelContent: React.FC = ({ const label = pickingModelKey === "actModelId" ? "Model ID (Act)" : "Model ID (Plan)" return ( = ({ ) } + // Bedrock custom model flow (ARN input + base model selection) + if (isBedrockCustomFlow) { + return ( + { + setIsBedrockCustomFlow(false) + setIsPickingModel(true) + }} + onComplete={handleBedrockCustomFlowComplete} + /> + ) + } + // Account tab - loading state if (currentTab === "account" && isAccountLoading) { return ( @@ -1748,6 +1820,7 @@ export const SettingsPanelContent: React.FC = ({ isWaitingForClineAuth || isShowingOcaEmployeeCheck || isWaitingForOcaAuth || + isBedrockCustomFlow || isEditing return ( diff --git a/cli/src/components/SkillsPanelContent.test.tsx b/cli/src/components/SkillsPanelContent.test.tsx new file mode 100644 index 00000000000..91de712fc50 --- /dev/null +++ b/cli/src/components/SkillsPanelContent.test.tsx @@ -0,0 +1,230 @@ +/** + * Tests for SkillsPanelContent component + * + * Tests keyboard interactions and callbacks. + * Rendering tests are limited due to ink-testing-library constraints with nested components. + */ + +import { render } from "ink-testing-library" +// biome-ignore lint/correctness/noUnusedImports: React must be in scope for JSX in this test file. +import React from "react" +import { beforeEach, describe, expect, it, vi } from "vitest" + +// Mock refreshSkills +const mockRefreshSkills = vi.fn() +vi.mock("@/core/controller/file/refreshSkills", () => ({ + refreshSkills: () => mockRefreshSkills(), +})) + +// Mock toggleSkill +const mockToggleSkill = vi.fn() +vi.mock("@/core/controller/file/toggleSkill", () => ({ + toggleSkill: (...args: unknown[]) => mockToggleSkill(...args), +})) + +// Mock child_process exec +const mockExec = vi.fn() +vi.mock("node:child_process", () => ({ + exec: (...args: unknown[]) => mockExec(...args), +})) + +// Mock StdinContext +vi.mock("../context/StdinContext", () => ({ + useStdinContext: () => ({ isRawModeSupported: true }), +})) + +import { SkillsPanelContent } from "./SkillsPanelContent" + +// Helper to wait for async state updates +const delay = (ms = 60) => new Promise((resolve) => setTimeout(resolve, ms)) + +describe("SkillsPanelContent", () => { + const mockController = {} as any + const mockOnClose = vi.fn() + const mockOnUseSkill = vi.fn() + + const defaultProps = { + controller: mockController, + onClose: mockOnClose, + onUseSkill: mockOnUseSkill, + } + + beforeEach(() => { + vi.clearAllMocks() + mockRefreshSkills.mockResolvedValue({ + globalSkills: [], + localSkills: [], + }) + }) + + describe("keyboard interactions", () => { + it("should call onClose when Escape is pressed", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [], + localSkills: [], + }) + + const { stdin } = render() + await delay() + + stdin.write("\x1B") // Escape + await delay() + + expect(mockOnClose).toHaveBeenCalled() + }) + + it("should call onUseSkill with skill path when Enter is pressed on a skill", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }], + localSkills: [], + }) + + const { stdin } = render() + await delay() + + stdin.write("\r") // Enter + await delay() + + expect(mockOnUseSkill).toHaveBeenCalledWith("/test/path/SKILL.md") + }) + + it("should call toggleSkill when Space is pressed on a skill", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }], + localSkills: [], + }) + + const { stdin } = render() + await delay() + + stdin.write(" ") // Space + await delay() + + expect(mockToggleSkill).toHaveBeenCalledWith( + mockController, + expect.objectContaining({ + skillPath: "/test/path/SKILL.md", + isGlobal: true, + enabled: false, // toggled from true to false + }), + ) + }) + + it("should open marketplace URL when Enter is pressed on marketplace item", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [{ name: "skill", description: "desc", path: "/path", enabled: true }], + localSkills: [], + }) + + const { stdin } = render() + await delay() + + // Navigate down to marketplace (past the one skill) + stdin.write("\x1B[B") // Down arrow + await delay() + + stdin.write("\r") // Enter + await delay() + + // Should have called exec with open command + expect(mockExec).toHaveBeenCalled() + const execCall = mockExec.mock.calls[0][0] + expect(execCall).toContain("https://skills.sh/") + }) + + it("should navigate through skills with arrow keys", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [ + { name: "skill-1", description: "First", path: "/path1", enabled: true }, + { name: "skill-2", description: "Second", path: "/path2", enabled: true }, + ], + localSkills: [], + }) + + const { stdin } = render() + await delay() + + // Navigate down + stdin.write("\x1B[B") // Down arrow + await delay() + + // Press Enter - should use second skill + stdin.write("\r") + await delay() + + expect(mockOnUseSkill).toHaveBeenCalledWith("/path2") + }) + + it("should navigate with vim keys (j/k)", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [ + { name: "skill-1", description: "First", path: "/path1", enabled: true }, + { name: "skill-2", description: "Second", path: "/path2", enabled: true }, + ], + localSkills: [], + }) + + const { stdin } = render() + await delay() + + // Navigate down with j + stdin.write("j") + await delay() + + // Press Enter - should use second skill + stdin.write("\r") + await delay() + + expect(mockOnUseSkill).toHaveBeenCalledWith("/path2") + }) + + it("should revert optimistic toggle on failure", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [{ name: "test-skill", description: "Test", path: "/test/path/SKILL.md", enabled: true }], + localSkills: [], + }) + mockToggleSkill.mockRejectedValueOnce(new Error("toggle failed")) + + const { stdin, lastFrame } = render() + await delay() + + stdin.write(" ") // Space to toggle + await delay(100) + + // toggleSkill was called with enabled: false (toggled from true) + expect(mockToggleSkill).toHaveBeenCalledWith(mockController, expect.objectContaining({ enabled: false })) + const frame = lastFrame() || "" + expect(frame).toContain("● test-skill") + expect(frame).not.toContain("○ test-skill") + }) + + it("should wrap navigation at list boundaries", async () => { + mockRefreshSkills.mockResolvedValue({ + globalSkills: [{ name: "only-skill", description: "Only", path: "/only", enabled: true }], + localSkills: [], + }) + + const { stdin } = render() + await delay() + + // Navigate up from first item (should wrap to last - marketplace) + stdin.write("\x1B[A") // Up arrow + await delay() + + stdin.write("\r") // Enter + await delay() + + // Should have opened marketplace (wrapped to last item) + expect(mockExec).toHaveBeenCalled() + }) + }) + + describe("skill loading", () => { + it("should call refreshSkills on mount", async () => { + render() + await delay() + + expect(mockRefreshSkills).toHaveBeenCalled() + }) + }) +}) diff --git a/cli/src/components/SkillsPanelContent.tsx b/cli/src/components/SkillsPanelContent.tsx new file mode 100644 index 00000000000..891c46df733 --- /dev/null +++ b/cli/src/components/SkillsPanelContent.tsx @@ -0,0 +1,262 @@ +/** + * Skills panel content for inline display in ChatView + * Shows installed skills with toggle and use functionality + */ + +import { exec } from "node:child_process" +import os from "node:os" +import { Box, Text, useInput } from "ink" +import React, { useCallback, useEffect, useMemo, useState } from "react" +import type { Controller } from "@/core/controller" +import { refreshSkills } from "@/core/controller/file/refreshSkills" +import { toggleSkill } from "@/core/controller/file/toggleSkill" +import { Logger } from "@/shared/services/Logger" +import { COLORS } from "../constants/colors" +import { useStdinContext } from "../context/StdinContext" +import { isMouseEscapeSequence } from "../utils/input" +import { Panel } from "./Panel" + +const SKILLS_MARKETPLACE_URL = "https://skills.sh/" + +interface SkillInfo { + name: string + description: string + path: string + enabled: boolean +} + +interface SkillsPanelContentProps { + controller: Controller + onClose: () => void + onUseSkill: (skillPath: string) => void +} + +const MAX_VISIBLE = 8 + +export const SkillsPanelContent: React.FC = ({ controller, onClose, onUseSkill }) => { + const { isRawModeSupported } = useStdinContext() + const [globalSkills, setGlobalSkills] = useState([]) + const [localSkills, setLocalSkills] = useState([]) + const [selectedIndex, setSelectedIndex] = useState(0) + const [isLoading, setIsLoading] = useState(true) + + // Load skills on mount + useEffect(() => { + const loadSkills = async () => { + try { + const skillsData = await refreshSkills(controller) + setGlobalSkills(skillsData.globalSkills || []) + setLocalSkills(skillsData.localSkills || []) + } catch (_error) { + // Skills loading failed, show empty state + } finally { + setIsLoading(false) + } + } + loadSkills() + }, [controller]) + + // Build flat list of skills with source info (global first, then local, alphabetical within each) + const skillEntries = useMemo(() => { + const entries: { skill: SkillInfo; isGlobal: boolean }[] = [] + globalSkills.forEach((skill) => { + entries.push({ skill, isGlobal: true }) + }) + localSkills.forEach((skill) => { + entries.push({ skill, isGlobal: false }) + }) + return entries.sort((a, b) => { + if (a.isGlobal !== b.isGlobal) return a.isGlobal ? -1 : 1 + return a.skill.name.localeCompare(b.skill.name) + }) + }, [globalSkills, localSkills]) + + // Handle toggle + const handleToggle = useCallback(async () => { + const entry = skillEntries[selectedIndex] + if (!entry) return + + const newEnabled = !entry.skill.enabled + const setter = entry.isGlobal ? setGlobalSkills : setLocalSkills + const update = (enabled: boolean) => + setter((prev) => prev.map((s) => (s.path === entry.skill.path ? { ...s, enabled } : s))) + + // Optimistic update + update(newEnabled) + + try { + await toggleSkill(controller, { + metadata: undefined, + skillPath: entry.skill.path, + isGlobal: entry.isGlobal, + enabled: newEnabled, + }) + } catch { + // Revert on failure + update(!newEnabled) + } + }, [controller, skillEntries, selectedIndex]) + + // Handle use skill (insert @ mention) + const handleUse = useCallback(() => { + const entry = skillEntries[selectedIndex] + if (!entry) return + onUseSkill(entry.skill.path) + }, [skillEntries, selectedIndex, onUseSkill]) + + // Handle opening the marketplace URL + const openMarketplace = useCallback(() => { + const platform = os.platform() + let command: string + if (platform === "darwin") { + command = `open "${SKILLS_MARKETPLACE_URL}"` + } else if (platform === "win32") { + command = `start "${SKILLS_MARKETPLACE_URL}"` + } else { + command = `xdg-open "${SKILLS_MARKETPLACE_URL}"` + } + exec(command, (err) => { + if (err) { + // Fallback: show URL in terminal if browser open fails + Logger.error(`Failed to open skills marketplace. Visit: ${SKILLS_MARKETPLACE_URL}`, err) + } + }) + }, []) + + // Total items = skills + 1 for marketplace link + const totalItems = skillEntries.length + 1 + const isMarketplaceSelected = selectedIndex === skillEntries.length + + useInput( + (input, key) => { + if (isMouseEscapeSequence(input)) { + return + } + if (key.escape) { + onClose() + return + } + + // Navigation + if (key.upArrow || input === "k") { + setSelectedIndex((i) => (i > 0 ? i - 1 : totalItems - 1)) + return + } + if (key.downArrow || input === "j") { + setSelectedIndex((i) => (i < totalItems - 1 ? i + 1 : 0)) + return + } + + // Actions + if (key.return) { + if (isMarketplaceSelected) { + openMarketplace() + } else { + handleUse() + } + return + } + if (input === " " && !isMarketplaceSelected) { + handleToggle() + return + } + }, + { isActive: isRawModeSupported }, + ) + + // Scrolling window (includes marketplace row) + const halfVisible = Math.floor(MAX_VISIBLE / 2) + const startIndex = Math.max(0, Math.min(selectedIndex - halfVisible, totalItems - MAX_VISIBLE)) + + if (isLoading) { + return ( + + Loading skills... + + ) + } + + // Check if marketplace row is in visible window + const marketplaceIndex = skillEntries.length + const showMarketplace = marketplaceIndex >= startIndex && marketplaceIndex < startIndex + MAX_VISIBLE + + return ( + + + {skillEntries.length === 0 ? ( + + No skills installed. + + Install skills with: npx skills add owner/repo + + + ) : ( + + {skillEntries + .slice(startIndex, Math.min(startIndex + MAX_VISIBLE, skillEntries.length)) + .map((entry, idx) => { + const actualIndex = startIndex + idx + const prevEntry = skillEntries[actualIndex - 1] + const showHeader = actualIndex === 0 || (prevEntry && prevEntry.isGlobal !== entry.isGlobal) + + return ( + + {showHeader && ( + 0 ? 1 : 0}> + + {entry.isGlobal ? "Global Skills:" : "Workspace Skills:"} + + + )} + + + ) + })} + + )} + + {/* Marketplace link - selectable */} + {showMarketplace && ( + + + {isMarketplaceSelected ? "❯ " : " "} + Browse more skills at https://skills.sh/ + + + )} + + {/* Help text */} + + + ↑/↓ Navigate • Enter {isMarketplaceSelected ? "Open" : "Use"} + {!isMarketplaceSelected && " • Space Toggle"} + + + + + ) +} + +const SkillRow: React.FC<{ skill: SkillInfo; isSelected: boolean }> = ({ skill, isSelected }) => { + return ( + + + + {isSelected ? "❯ " : " "} + {skill.enabled ? "●" : "○"} + + + {skill.name} + + + + {skill.description && ( + + + {skill.description.length > 60 ? skill.description.slice(0, 57) + "..." : skill.description} + + + )} + + ) +} diff --git a/cli/src/constants/featured-models.test.ts b/cli/src/constants/featured-models.test.ts index 47fc7882c9f..03994075d16 100644 --- a/cli/src/constants/featured-models.test.ts +++ b/cli/src/constants/featured-models.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { getAllFeaturedModels } from "./featured-models" +import { getAllFeaturedModels, mapRecommendedModelsToFeaturedModels } from "./featured-models" describe("featured models", () => { it("includes display names for all featured models", () => { @@ -9,4 +9,15 @@ describe("featured models", () => { expect(model.name).toBeTruthy() } }) + + it("fills free model metadata from fallback when upstream payload is sparse", () => { + const models = mapRecommendedModelsToFeaturedModels({ + recommended: [], + free: [{ id: "trinity-large-preview:free", name: "trinity-large-preview:free", description: "", tags: [] }], + }) + + expect(models.free[0]?.name).toBe("Arcee AI Trinity Large Preview") + expect(models.free[0]?.description).toBe("Arcee AI's advanced large preview model in the Trinity series") + expect(models.free[0]?.labels).toContain("FREE") + }) }) diff --git a/cli/src/constants/featured-models.ts b/cli/src/constants/featured-models.ts index 6bf45387cf6..3c54e0cdf42 100644 --- a/cli/src/constants/featured-models.ts +++ b/cli/src/constants/featured-models.ts @@ -2,6 +2,7 @@ * Featured models shown in the Cline model picker during onboarding * These are curated models that work well with Cline */ +import { CLINE_RECOMMENDED_MODELS_FALLBACK } from "@shared/cline/recommended-models" export interface FeaturedModel { id: string @@ -10,49 +11,81 @@ export interface FeaturedModel { labels: string[] } -export const FEATURED_MODELS: { recommended: FeaturedModel[]; free: FeaturedModel[] } = { - recommended: [ - { - id: "anthropic/claude-opus-4.6", - name: "Claude Opus 4.6", - description: "State-of-the-art for complex coding", - labels: ["BEST"], - }, - { - id: "openai/gpt-5.2-codex", - name: "GPT 5.2 Codex", - description: "OpenAI's latest with strong coding abilities", - labels: ["NEW"], - }, - { - id: "google/gemini-3-pro-preview", - name: "Gemini 3 Pro", - description: "1M context window for large codebases", - labels: ["TRENDING"], - }, - ], - free: [ - { - id: "minimax/minimax-m2.5", - name: "MiniMax M2.5", - description: "MiniMax-M2.5 is a lightweight, state-of-the-art LLM optimized for coding and agentic workflows", - labels: ["FREE"], - }, - { - id: "kwaipilot/kat-coder-pro", - name: "KAT Coder Pro", - description: "KwaiKAT's most advanced agentic coding model in the KAT-Coder series", - labels: ["FREE"], - }, - { - id: "arcee-ai/trinity-large-preview:free", - name: "Trinity Large Preview", - description: "Arcee AI's advanced large preview model in the Trinity series", - labels: ["FREE"], - }, - ], -} - -export function getAllFeaturedModels(): FeaturedModel[] { - return [...FEATURED_MODELS.recommended, ...FEATURED_MODELS.free] +type RecommendedModelLike = { + id: string + name: string + description: string + tags: string[] +} + +export interface FeaturedModelsByTier { + recommended: FeaturedModel[] + free: FeaturedModel[] +} + +interface RecommendedModelsByTier { + recommended: RecommendedModelLike[] + free: RecommendedModelLike[] +} + +function toFeaturedModel(model: RecommendedModelLike): FeaturedModel { + return { + id: model.id, + name: model.name, + description: model.description, + labels: model.tags, + } +} + +function getModelIdSuffix(id: string): string { + const lastSlashIndex = id.lastIndexOf("/") + return lastSlashIndex >= 0 ? id.slice(lastSlashIndex + 1) : id +} + +function findFallbackFeaturedModelById(models: FeaturedModel[], id: string): FeaturedModel | undefined { + const idSuffix = getModelIdSuffix(id) + return models.find((model) => model.id === id || getModelIdSuffix(model.id) === idSuffix) +} + +function mapRecommendedModelToFeaturedModelWithFallback( + model: RecommendedModelLike, + fallbackModels: FeaturedModel[], + defaultLabels: string[] = [], +): FeaturedModel { + const fallbackModel = findFallbackFeaturedModelById(fallbackModels, model.id) + const upstreamNameLooksLikeFallback = model.name === model.id || model.name.trim().length === 0 + const name = upstreamNameLooksLikeFallback ? (fallbackModel?.name ?? model.name) : model.name + const description = model.description.trim().length > 0 ? model.description : (fallbackModel?.description ?? "") + const labels = model.tags.length > 0 ? model.tags : (fallbackModel?.labels ?? defaultLabels) + + return { + id: model.id, + name, + description, + labels, + } +} + +export const FEATURED_MODELS: FeaturedModelsByTier = { + recommended: CLINE_RECOMMENDED_MODELS_FALLBACK.recommended.map(toFeaturedModel), + free: CLINE_RECOMMENDED_MODELS_FALLBACK.free.map(toFeaturedModel), +} + +export function getAllFeaturedModels(modelsByTier: FeaturedModelsByTier = FEATURED_MODELS): FeaturedModel[] { + return [...modelsByTier.recommended, ...modelsByTier.free] +} + +export function mapRecommendedModelsToFeaturedModels(data: RecommendedModelsByTier): FeaturedModelsByTier { + return { + recommended: data.recommended.map((model) => + mapRecommendedModelToFeaturedModelWithFallback(model, FEATURED_MODELS.recommended), + ), + free: data.free.map((model) => mapRecommendedModelToFeaturedModelWithFallback(model, FEATURED_MODELS.free, ["FREE"])), + } +} + +export function withFeaturedModelFallback(modelsByTier: FeaturedModelsByTier): FeaturedModelsByTier { + const recommended = modelsByTier.recommended.length > 0 ? modelsByTier.recommended : FEATURED_MODELS.recommended + const free = modelsByTier.free.length > 0 ? modelsByTier.free : FEATURED_MODELS.free + return { recommended, free } } diff --git a/cli/src/exports.ts b/cli/src/exports.ts new file mode 100644 index 00000000000..4cd4561aff1 --- /dev/null +++ b/cli/src/exports.ts @@ -0,0 +1,71 @@ +/** + * Cline Library Exports + * + * This file exports the public API for programmatic use of Cline. + * Use these classes and types to embed Cline into your applications. + * + * @example + * ```typescript + * import { ClineAgent } from "cline" + * + * const agent = new ClineAgent() + * await agent.initialize({ clientCapabilities: {} }) + * const session = await agent.newSession({ cwd: process.cwd() }) + * ``` + * @module cline + */ + +export { ClineAgent } from "./agent/ClineAgent.js" +export { ClineSessionEmitter } from "./agent/ClineSessionEmitter.js" +export type { + AcpAgentOptions, + AcpSessionState, + AcpSessionStatus, + Agent, + AgentSideConnection, + AudioContent, + CancelNotification, + ClientCapabilities, + ClineAcpSession, + ClineAgentCapabilities, + ClineAgentInfo, + ClineAgentOptions, + ClinePermissionOption, + ClineSessionEvents, + ContentBlock, + ImageContent, + InitializeRequest, + InitializeResponse, + LoadSessionRequest, + LoadSessionResponse, + McpServer, + ModelInfo, + NewSessionRequest, + NewSessionResponse, + PermissionHandler, + PermissionOption, + PermissionOptionKind, + PromptRequest, + PromptResponse, + RequestPermissionRequest, + RequestPermissionResponse, + SessionConfigOption, + SessionModelState, + SessionNotification, + SessionUpdate, + SessionUpdatePayload, + SessionUpdateType, + SetSessionConfigOptionRequest, + SetSessionConfigOptionResponse, + SetSessionModelRequest, + SetSessionModelResponse, + SetSessionModeRequest, + SetSessionModeResponse, + StopReason, + TextContent, + ToolCall, + ToolCallStatus, + ToolCallUpdate, + ToolKind, + TranslatedMessage, +} from "./agent/public-types.js" diff --git a/cli/src/hooks/useClineFeaturedModels.ts b/cli/src/hooks/useClineFeaturedModels.ts new file mode 100644 index 00000000000..90b31c8692b --- /dev/null +++ b/cli/src/hooks/useClineFeaturedModels.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from "react" +import { refreshClineRecommendedModels } from "@/core/controller/models/refreshClineRecommendedModels" +import { + type FeaturedModel, + getAllFeaturedModels, + mapRecommendedModelsToFeaturedModels, + withFeaturedModelFallback, +} from "../constants/featured-models" + +export function useClineFeaturedModels(): FeaturedModel[] { + const [featuredModels, setFeaturedModels] = useState(() => getAllFeaturedModels()) + + useEffect(() => { + let cancelled = false + void (async () => { + try { + const recommendedModels = await refreshClineRecommendedModels() + const mappedModels = mapRecommendedModelsToFeaturedModels(recommendedModels) + const modelsWithFallback = withFeaturedModelFallback(mappedModels) + if (!cancelled) { + setFeaturedModels(getAllFeaturedModels(modelsWithFallback)) + } + } catch { + // Keep local fallback models on error. + } + })() + + return () => { + cancelled = true + } + }, []) + + return featuredModels +} diff --git a/cli/src/hooks/useTerminalSize.ts b/cli/src/hooks/useTerminalSize.ts index 667e1c55bc0..889110334a5 100644 --- a/cli/src/hooks/useTerminalSize.ts +++ b/cli/src/hooks/useTerminalSize.ts @@ -26,6 +26,9 @@ import { useCallback, useEffect, useRef, useState } from "react" * to unmount and remount everything from scratch. This resets Ink's internal tracking * AND re-renders Static content since the components are brand new instances. * + * We only run this full recovery when terminal width changes. Height-only resizes do not + * affect wrapping in the same way and should not restart the task view. + * * Gemini CLI does the same thing in AppContainer.tsx: debounce 300ms, then * stdout.write(ansiEscapes.clearTerminal) + setHistoryRemountKey(prev => prev + 1). * @@ -41,6 +44,8 @@ export function useTerminalSize() { }) const [resizeKey, setResizeKey] = useState(0) const debounceRef = useRef | null>(null) + const previousColumnsRef = useRef(process.stdout.columns || 80) + const pendingWidthRefreshRef = useRef(false) const refreshAfterResize = useCallback(() => { // Clear terminal + scrollback to wipe stale content from old width @@ -56,17 +61,33 @@ export function useTerminalSize() { useEffect(() => { function updateSize() { + const nextColumns = process.stdout.columns || 80 + const nextRows = process.stdout.rows || 24 + const didWidthChange = nextColumns !== previousColumnsRef.current + previousColumnsRef.current = nextColumns + setSize({ - columns: process.stdout.columns || 80, - rows: process.stdout.rows || 24, + columns: nextColumns, + rows: nextRows, }) + if (didWidthChange) { + pendingWidthRefreshRef.current = true + } + + if (!pendingWidthRefreshRef.current) { + return + } + // Debounce: wait 300ms after last resize event to do full recovery if (debounceRef.current) { clearTimeout(debounceRef.current) } debounceRef.current = setTimeout(() => { - refreshAfterResize() + if (pendingWidthRefreshRef.current) { + refreshAfterResize() + pendingWidthRefreshRef.current = false + } debounceRef.current = null }, 300) } @@ -76,6 +97,7 @@ export function useTerminalSize() { if (debounceRef.current) { clearTimeout(debounceRef.current) } + pendingWidthRefreshRef.current = false } }, [refreshAfterResize]) diff --git a/cli/src/index.test.ts b/cli/src/index.test.ts index 822ceb03e27..dd33c6a328c 100644 --- a/cli/src/index.test.ts +++ b/cli/src/index.test.ts @@ -1,5 +1,6 @@ import { Command } from "commander" -import { beforeEach, describe, expect, it } from "vitest" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { captureUnhandledException } from "." /** * Tests for CLI command parsing and structure @@ -25,6 +26,7 @@ describe("CLI Commands", () => { .option("-a, --act", "Run in act mode") .option("-p, --plan", "Run in plan mode") .option("-y, --yolo", "Enable yolo mode") + .option("--auto-approve-all", "Enable auto-approve all") .option("-m, --model ", "Model to use") .option("-i, --images ", "Image file paths") .option("-v, --verbose", "Show verbose output") @@ -33,6 +35,9 @@ describe("CLI Commands", () => { .option("--thinking [tokens]", "Enable extended thinking") .option("--reasoning-effort ", "Reasoning effort") .option("--max-consecutive-mistakes ", "Maximum consecutive mistakes") + .option("--double-check-completion", "Reject first completion attempt to force re-verification") + .option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation") + .option("--hooks-dir ", "Additional hooks directory") .action(() => {}) program @@ -62,6 +67,22 @@ describe("CLI Commands", () => { .option("--config ", "Configuration directory") .action(() => {}) + const mcpCommand = program.command("mcp").description("Manage MCP servers") + mcpCommand + .command("add") + .description("Add an MCP server shortcut") + .argument("", "MCP server name") + .argument("[targetOrCommand...]", "Command args for stdio, or URL for remote") + .option("--type ", "Transport type", "stdio") + .option("-c, --cwd ", "Working directory") + .option("--config ", "Configuration directory") + .action(() => {}) + + program + .command("kanban") + .description("Run npx kanban --agent cline") + .action(() => {}) + // Default command for interactive mode program .argument("[prompt]", "Task prompt") @@ -72,6 +93,11 @@ describe("CLI Commands", () => { .option("--thinking [tokens]", "Enable extended thinking") .option("--reasoning-effort ", "Reasoning effort") .option("--max-consecutive-mistakes ", "Maximum consecutive mistakes") + .option("--double-check-completion", "Reject first completion attempt to force re-verification") + .option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation") + .option("--hooks-dir ", "Additional hooks directory") + .option("--auto-approve-all", "Enable auto-approve all") + .option("--kanban", "Run npx kanban --agent cline") .action(() => {}) }) @@ -108,6 +134,13 @@ describe("CLI Commands", () => { expect(taskCmd.opts().yolo).toBe(true) }) + it("should parse --auto-approve-all flag", () => { + const taskCmd = program.commands.find((c) => c.name() === "task")! + const args = ["test prompt", "--auto-approve-all"] + taskCmd.parse(args, { from: "user" }) + expect(taskCmd.opts().autoApproveAll).toBe(true) + }) + it("should parse --model option", () => { const taskCmd = program.commands.find((c) => c.name() === "task")! const args = ["test prompt", "--model", "claude-sonnet-4-20250514"] @@ -171,6 +204,27 @@ describe("CLI Commands", () => { expect(taskCmd.opts().maxConsecutiveMistakes).toBe("999") }) + it("should parse --hooks-dir option", () => { + const taskCmd = program.commands.find((c) => c.name() === "task")! + const args = ["test prompt", "--hooks-dir", "/tmp/hooks"] + taskCmd.parse(args, { from: "user" }) + expect(taskCmd.opts().hooksDir).toBe("/tmp/hooks") + }) + + it("should parse --double-check-completion flag", () => { + const taskCmd = program.commands.find((c) => c.name() === "task")! + const args = ["test prompt", "--double-check-completion"] + taskCmd.parse(args, { from: "user" }) + expect(taskCmd.opts().doubleCheckCompletion).toBe(true) + }) + + it("should parse --auto-condense flag", () => { + const taskCmd = program.commands.find((c) => c.name() === "task")! + const args = ["test prompt", "--auto-condense"] + taskCmd.parse(args, { from: "user" }) + expect(taskCmd.opts().autoCondense).toBe(true) + }) + it("should parse short flags", () => { const taskCmd = program.commands.find((c) => c.name() === "task")! const args = ["test prompt", "-a", "-v", "-m", "gpt-4"] @@ -237,6 +291,13 @@ describe("CLI Commands", () => { }) }) + describe("kanban command", () => { + it("should parse kanban command", () => { + const args = ["node", "cli", "kanban"] + program.parse(args) + }) + }) + describe("auth command", () => { it("should parse auth command", () => { const args = ["node", "cli", "auth"] @@ -281,6 +342,32 @@ describe("CLI Commands", () => { }) }) + describe("mcp command", () => { + it("should parse mcp add stdio syntax", () => { + const args = ["node", "cli", "mcp", "add", "kanban", "--", "kanban", "mcp"] + program.parse(args) + }) + + it("should parse mcp add remote http syntax", () => { + const args = ["node", "cli", "mcp", "add", "linear", "https://mcp.linear.app/mcp", "--type", "http"] + program.parse(args) + }) + + it("should default mcp add type to stdio", () => { + const mcpCmd = program.commands.find((c) => c.name() === "mcp")! + const addCmd = mcpCmd.commands.find((c) => c.name() === "add")! + addCmd.parse(["kanban", "--", "kanban", "mcp"], { from: "user" }) + expect(addCmd.opts().type).toBe("stdio") + }) + + it("should parse mcp add type option", () => { + const mcpCmd = program.commands.find((c) => c.name() === "mcp")! + const addCmd = mcpCmd.commands.find((c) => c.name() === "add")! + addCmd.parse(["linear", "https://mcp.linear.app/mcp", "--type", "http"], { from: "user" }) + expect(addCmd.opts().type).toBe("http") + }) + }) + describe("default command (interactive mode)", () => { it("should parse optional prompt argument", () => { const args = ["node", "cli", "do something"] @@ -321,6 +408,21 @@ describe("CLI Commands", () => { program.parse(["node", "cli", "--max-consecutive-mistakes", "7"]) expect(program.opts().maxConsecutiveMistakes).toBe("7") }) + + it("should parse --hooks-dir option", () => { + program.parse(["node", "cli", "--hooks-dir", "/tmp/hooks"]) + expect(program.opts().hooksDir).toBe("/tmp/hooks") + }) + + it("should parse --auto-approve-all flag", () => { + program.parse(["node", "cli", "--auto-approve-all"]) + expect(program.opts().autoApproveAll).toBe(true) + }) + + it("should parse --kanban flag", () => { + program.parse(["node", "cli", "--kanban"]) + expect(program.opts().kanban).toBe(true) + }) }) describe("command structure", () => { @@ -330,6 +432,8 @@ describe("CLI Commands", () => { expect(commandNames).toContain("history") expect(commandNames).toContain("config") expect(commandNames).toContain("auth") + expect(commandNames).toContain("mcp") + expect(commandNames).toContain("kanban") }) it("should have correct aliases", () => { @@ -410,3 +514,42 @@ describe("getProviderModelIdKey", () => { expect(getProviderModelIdKey("unknown-provider", "act")).toBeNull() }) }) + +const mockCaptureException = vi.fn().mockResolvedValue(undefined) +const mockDispose = vi.fn().mockResolvedValue(undefined) + +vi.mock("@/services/error/ErrorService", () => { + return { + ErrorService: { + get: () => ({ + captureException: mockCaptureException, + dispose: mockDispose, + }), + }, + } +}) + +describe("captureUnhandledException", () => { + beforeEach(() => { + vi.resetAllMocks() + }) + + it("captures unhandled exceptions", async () => { + const testError = new Error("Test unhandled exception") + + await captureUnhandledException(testError, "unhandledRejection") + + expect(mockCaptureException).toHaveBeenCalledWith(testError, { context: "unhandledRejection" }) + expect(mockDispose).toHaveBeenCalled() + }) + + it("does not throw if captureException fails", async () => { + mockCaptureException.mockRejectedValueOnce(new Error("Capture failed")) + + const testError = new Error("Test unhandled exception") + + await expect(captureUnhandledException(testError, "unhandledRejection")).resolves.not.toThrow() + expect(mockCaptureException).toHaveBeenCalledWith(testError, { context: "unhandledRejection" }) + expect(mockDispose).not.toHaveBeenCalled() + }) +}) diff --git a/cli/src/index.ts b/cli/src/index.ts index 9c80c0dabdc..9cfadeea3b9 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -2,18 +2,20 @@ * Cline CLI - TypeScript implementation with React Ink */ +import { spawn } from "node:child_process" import { exit } from "node:process" import type { ApiProvider } from "@shared/api" import { Command } from "commander" import { render } from "ink" import React from "react" import { ClineEndpoint } from "@/config" -import { Controller } from "@/core/controller" +import type { Controller } from "@/core/controller" +import { getHooksEnabledSafe } from "@/core/hooks/hooks-utils" +import { setRuntimeHooksDir } from "@/core/storage/disk" import { StateManager } from "@/core/storage/StateManager" import { AuthHandler } from "@/hosts/external/AuthHandler" import { HostProvider } from "@/hosts/host-provider" import { FileEditProvider } from "@/integrations/editor/FileEditProvider" -import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth" import { StandaloneTerminalManager } from "@/integrations/terminal/standalone/StandaloneTerminalManager" import { ErrorService } from "@/services/error/ErrorService" import { telemetryService } from "@/services/telemetry" @@ -21,7 +23,7 @@ import { PostHogClientProvider } from "@/services/telemetry/providers/posthog/Po import { HistoryItem } from "@/shared/HistoryItem" import { Logger } from "@/shared/services/Logger" import { Session } from "@/shared/services/Session" -import { getProviderModelIdKey, ProviderToApiKeyMap } from "@/shared/storage" +import { getProviderModelIdKey } from "@/shared/storage" import { isOpenaiReasoningEffort, OPENAI_REASONING_EFFORT_OPTIONS, type OpenaiReasoningEffort } from "@/shared/storage/types" import { version as CLI_VERSION } from "../package.json" import { runAcpMode } from "./acp/index.js" @@ -30,8 +32,10 @@ import { checkRawModeSupport } from "./context/StdinContext" import { createCliHostBridgeProvider } from "./controllers" import { CliCommentReviewController } from "./controllers/CliCommentReviewController" import { CliWebviewProvider } from "./controllers/CliWebviewProvider" -import { restoreConsole } from "./utils/console" +import { isAuthConfigured } from "./utils/auth" +import { restoreConsole, suppressConsoleUnlessVerbose } from "./utils/console" import { printInfo, printWarning } from "./utils/display" +import { addMcpServerShortcut, type McpAddOptions } from "./utils/mcp" import { selectOutputMode } from "./utils/mode-selection" import { parseImagesFromInput, processImagePaths } from "./utils/parser" import { CLINE_CLI_DIR, getCliBinaryPath } from "./utils/path" @@ -39,28 +43,38 @@ import { readStdinIfPiped } from "./utils/piped" import { runPlainTextTask } from "./utils/plain-text-task" import { applyProviderConfig } from "./utils/provider-config" import { getValidCliProviders, isValidCliProvider } from "./utils/providers" +import { findMostRecentTaskForWorkspace } from "./utils/task-history" import { autoUpdateOnStartup, checkForUpdates } from "./utils/update" import { initializeCliContext } from "./vscode-context" import { CLI_LOG_FILE, shutdownEvent, window } from "./vscode-shim" +// CLI-only behavior: suppress console output unless verbose mode is enabled. +// Kept explicit here so importing the library bundle does not mutate global console methods. +suppressConsoleUnlessVerbose() + /** * Common options shared between runTask and resumeTask */ interface TaskOptions { act?: boolean plan?: boolean + kanban?: boolean model?: string verbose?: boolean cwd?: string + continue?: boolean config?: string thinking?: boolean | string reasoningEffort?: string maxConsecutiveMistakes?: string yolo?: boolean + autoApproveAll?: boolean doubleCheckCompletion?: boolean + autoCondense?: boolean timeout?: string json?: boolean stdinWasPiped?: boolean + hooksDir?: string } let telemetryDisposed = false @@ -74,24 +88,7 @@ async function disposeTelemetryServices(): Promise { await Promise.allSettled([telemetryService.dispose(), PostHogClientProvider.getInstance().dispose()]) } -/** - * Restore yoloModeToggled to its original value from before this CLI session. - * This ensures the --yolo flag is session-only and doesn't leak into future runs. - * Must be called before flushPendingState so the restored value gets persisted. - */ -function restoreYoloState(): void { - if (savedYoloModeToggled !== null) { - try { - StateManager.get().setGlobalState("yoloModeToggled", savedYoloModeToggled) - savedYoloModeToggled = null - } catch { - // StateManager may not be initialized (e.g., early exit before init) - } - } -} - async function disposeCliContext(ctx: CliContext): Promise { - restoreYoloState() await ctx.controller.stateManager.flushPendingState() await ctx.controller.dispose() await ErrorService.get().dispose() @@ -146,46 +143,43 @@ function normalizeMaxConsecutiveMistakes(value?: string): number | undefined { function applyTaskOptions(options: TaskOptions): void { // Apply mode flag if (options.plan) { - StateManager.get().setGlobalState("mode", "plan") + StateManager.get().setSessionOverride("mode", "plan") telemetryService.captureHostEvent("mode_flag", "plan") } else if (options.act) { - StateManager.get().setGlobalState("mode", "act") + StateManager.get().setSessionOverride("mode", "act") telemetryService.captureHostEvent("mode_flag", "act") } // Apply model override if specified if (options.model) { - const selectedMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan" + const selectedMode = (StateManager.get().getGlobalSettingsKey("mode") ?? "act") as "act" | "plan" const providerKey = selectedMode === "act" ? "actModeApiProvider" : "planModeApiProvider" const currentProvider = StateManager.get().getGlobalSettingsKey(providerKey) as ApiProvider const modelKey = getProviderModelIdKey(currentProvider, selectedMode) if (modelKey) { - StateManager.get().setGlobalState(modelKey, options.model) + StateManager.get().setSessionOverride(modelKey, options.model) } telemetryService.captureHostEvent("model_flag", options.model) } + const currentMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan" + // Set thinking budget based on --thinking flag (boolean or number) - let thinkingBudget = 0 - if (options.thinking) { + if (options.thinking !== undefined) { + let thinkingBudget = 1024 if (typeof options.thinking === "string") { const parsed = Number.parseInt(options.thinking, 10) if (Number.isNaN(parsed) || parsed < 0) { printWarning(`Invalid --thinking value '${options.thinking}'. Using default 1024.`) - thinkingBudget = 1024 } else { thinkingBudget = parsed } - } else { - thinkingBudget = 1024 } - } - const currentMode = (StateManager.get().getGlobalSettingsKey("mode") || "act") as "act" | "plan" - setModeScopedState(currentMode, (mode) => { - const thinkingKey = mode === "act" ? "actModeThinkingBudgetTokens" : "planModeThinkingBudgetTokens" - StateManager.get().setGlobalState(thinkingKey, thinkingBudget) - }) - if (options.thinking) { + + setModeScopedState(currentMode, (mode) => { + const thinkingKey = mode === "act" ? "actModeThinkingBudgetTokens" : "planModeThinkingBudgetTokens" + StateManager.get().setSessionOverride(thinkingKey, thinkingBudget) + }) telemetryService.captureHostEvent("thinking_flag", "true") } @@ -193,31 +187,40 @@ function applyTaskOptions(options: TaskOptions): void { if (reasoningEffort !== undefined) { setModeScopedState(currentMode, (mode) => { const reasoningKey = mode === "act" ? "actModeReasoningEffort" : "planModeReasoningEffort" - StateManager.get().setGlobalState(reasoningKey, reasoningEffort) + StateManager.get().setSessionOverride(reasoningKey, reasoningEffort) }) telemetryService.captureHostEvent("reasoning_effort_flag", reasoningEffort) } const maxConsecutiveMistakes = normalizeMaxConsecutiveMistakes(options.maxConsecutiveMistakes) if (maxConsecutiveMistakes !== undefined) { - StateManager.get().setGlobalState("maxConsecutiveMistakes", maxConsecutiveMistakes) + StateManager.get().setSessionOverride("maxConsecutiveMistakes", maxConsecutiveMistakes) telemetryService.captureHostEvent("max_consecutive_mistakes_flag", String(maxConsecutiveMistakes)) } - // Override yolo mode only if --yolo flag is explicitly passed. - // The original value is saved in initializeCli and restored on exit. + // Set yolo mode as a session-scoped override so AutoApprove picks it up, + // but it is never persisted to disk (setSessionOverride never touches pendingGlobalState). if (options.yolo) { - const state = StateManager.get() - savedYoloModeToggled = state.getGlobalSettingsKey("yoloModeToggled") ?? false - state.setGlobalState("yoloModeToggled", true) + StateManager.get().setSessionOverride("yoloModeToggled", true) telemetryService.captureHostEvent("yolo_flag", "true") } + // Set auto-approve-all as a session-scoped override so CLI flag does not + // persist user settings to disk. + if (options.autoApproveAll) { + StateManager.get().setSessionOverride("autoApproveAllToggled", true) + telemetryService.captureHostEvent("auto_approve_all_flag", "true") + } + // Set double-check completion based on flag if (options.doubleCheckCompletion) { - StateManager.get().setGlobalState("doubleCheckCompletionEnabled", true) + StateManager.get().setSessionOverride("doubleCheckCompletionEnabled", true) telemetryService.captureHostEvent("double_check_completion_flag", "true") } + + if (options.autoCondense) { + StateManager.get().setSessionOverride("useAutoCondense", true) + } } /** @@ -248,6 +251,36 @@ function getPlainTextModeReason(options: TaskOptions): string { return getModeSelection(options).reason } +function getNpxCommand(): string { + return process.platform === "win32" ? "npx.cmd" : "npx" +} + +function runKanbanAlias(): void { + const child = spawn(getNpxCommand(), ["-y", "kanban", "--agent", "cline"], { + stdio: "inherit", + }) + + child.on("error", () => { + printWarning("Failed to run 'npx kanban --agent cline'. Make sure npx is installed and available in PATH.") + exit(1) + }) + + child.on("close", (code) => { + exit(code ?? 1) + }) +} + +async function addMcpServer(name: string, targetOrCommand: string[] = [], options: McpAddOptions): Promise { + try { + const result = await addMcpServerShortcut(name, targetOrCommand, options) + const transportLabel = result.transportType === "streamableHttp" ? "http" : result.transportType + printInfo(`Added MCP server '${result.serverName}' (${transportLabel}) to ${result.settingsPath}`) + } catch (error) { + printWarning(error instanceof Error ? error.message : "Failed to add MCP server.") + exit(1) + } +} + /** * Run a task in plain text mode (no Ink UI). * Handles auth check, task execution, cleanup, and exit. @@ -314,9 +347,6 @@ let activeContext: CliContext | null = null let isShuttingDown = false // Track if we're in plain text mode (no Ink UI) - set by runTask when piped stdin detected let isPlainTextMode = false -// Track the original yoloModeToggled value from before this CLI session so we can restore it on exit. -// The --yolo flag should only affect the current invocation, not persist across runs. -let savedYoloModeToggled: boolean | null = null /** * Wait for stdout to fully drain before exiting. @@ -334,6 +364,42 @@ async function drainStdout(): Promise { }) } +export async function captureUnhandledException(reason: Error, context: string) { + try { + // ErrorService may not be initialized yet (e.g., error occurred before initializeCli()) + // so we guard with a try/get pattern rather than letting ErrorService.get() throw + let errorService: ErrorService | null = null + try { + errorService = ErrorService.get() + } catch { + // ErrorService not yet initialized; skip capture + } + if (errorService) { + await errorService.captureException(reason, { context }) + // dispose flushes any pending error captures to ensure they're sent before the process exits + return errorService.dispose() + } + } catch { + // Ignore errors during shutdown to avoid an infinite loop + Logger.info("Error capturing unhandled exception. Proceeding with shutdown.") + } +} + +const EXIT_TIMEOUT_MS = 3000 +function onUnhandledException(reason: unknown, context: string) { + Logger.error("Unhandled exception:", reason) + const finalError = reason instanceof Error ? reason : new Error(String(reason)) + + restoreConsole() + Logger.error("Unhandled exception after console restore", finalError) + + setTimeout(() => process.exit(1), EXIT_TIMEOUT_MS) + + captureUnhandledException(finalError, context).finally(() => { + process.exit(1) + }) +} + function setupSignalHandlers() { const shutdown = async (signal: string) => { if (isShuttingDown) { @@ -358,10 +424,6 @@ function setupSignalHandlers() { printWarning(`${signal} received, shutting down...`) try { - // Restore yolo state before any cleanup - this is idempotent and safe - // even if disposeCliContext also calls it (restoreYoloState checks savedYoloModeToggled !== null) - restoreYoloState() - if (activeContext) { const task = activeContext.controller.task if (task) { @@ -375,7 +437,11 @@ function setupSignalHandlers() { } catch { // StateManager may not be initialized yet } - await ErrorService.get().dispose() + try { + await ErrorService.get().dispose() + } catch { + // ErrorService may not be initialized yet + } await disposeTelemetryServices() } } catch { @@ -397,9 +463,14 @@ function setupSignalHandlers() { Logger.info("Suppressed unhandled rejection due to abort:", message) return } - // For other unhandled rejections, log to file via Logger (if available) + + // For other unhandled rejections, capture the exception and log to file via Logger (if available) // This won't show in terminal but will be in log files for debugging - Logger.error("Unhandled rejection:", reason) + onUnhandledException(reason, "unhandledRejection") + }) + + process.on("uncaughtException", (reason: unknown) => { + onUnhandledException(reason, "uncaughtException") }) } @@ -416,6 +487,7 @@ interface CliContext { interface InitOptions { config?: string cwd?: string + hooksDir?: string verbose?: boolean enableAuth?: boolean } @@ -425,7 +497,8 @@ interface InitOptions { */ async function initializeCli(options: InitOptions): Promise { const workspacePath = options.cwd || process.cwd() - const { extensionContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({ + setRuntimeHooksDir(options.hooksDir) + const { extensionContext, storageContext, DATA_DIR, EXTENSION_DIR } = initializeCliContext({ clineDir: options.config, workspaceDir: workspacePath, }) @@ -466,13 +539,9 @@ async function initializeCli(options: InitOptions): Promise { DATA_DIR, ) - await StateManager.initialize(extensionContext as any) - + await StateManager.initialize(storageContext) await ErrorService.initialize() - // Initialize OpenAI Codex OAuth manager with extension context for secrets storage - openAiCodexOAuthManager.initialize(extensionContext) - const webview = HostProvider.get().createWebviewProvider() as CliWebviewProvider const controller = webview.controller @@ -530,6 +599,11 @@ async function runTask(prompt: string, options: TaskOptions & { images?: string[ // Task without prompt starts in interactive mode telemetryService.captureHostEvent("task_command", prompt ? "task" : "interactive") + // Capture piped stdin telemetry now that HostProvider is initialized + if (options.stdinWasPiped) { + telemetryService.captureHostEvent("piped", "detached") + } + // Apply shared task options (mode, model, thinking, yolo) applyTaskOptions(options) await StateManager.get().flushPendingState() @@ -622,7 +696,7 @@ async function showConfig(options: { config?: string }) { dataDir: ctx.dataDir, globalState: stateManager.getAllGlobalStateEntries(), workspaceState: stateManager.getAllWorkspaceStateEntries(), - hooksEnabled: true, + hooksEnabled: getHooksEnabledSafe(stateManager.getGlobalSettingsKey("hooksEnabled")), skillsEnabled: true, isRawModeSupported: checkRawModeSupport(), }), @@ -754,7 +828,8 @@ program .option("-a, --act", "Run in act mode") .option("-p, --plan", "Run in plan mode") .option("-y, --yolo", "Enable yes/yolo mode (auto-approve actions)") - .option("-t, --timeout ", "Timeout in seconds for yes/yolo mode (default: 600)") + .option("--auto-approve-all", "Enable auto-approve all actions while keeping interactive mode") + .option("-t, --timeout ", "Optional timeout in seconds (applies only when provided)") .option("-m, --model ", "Model to use for the task") .option("-v, --verbose", "Show verbose output") .option("-c, --cwd ", "Working directory for the task") @@ -764,6 +839,8 @@ program .option("--max-consecutive-mistakes ", "Maximum consecutive mistakes before halting in yolo mode") .option("--json", "Output messages as JSON instead of styled text") .option("--double-check-completion", "Reject first completion attempt to force re-verification") + .option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation") + .option("--hooks-dir ", "Path to additional hooks directory for runtime hook injection") .option("-T, --taskId ", "Resume an existing task by ID") .action((prompt, options) => { if (options.taskId) { @@ -792,13 +869,25 @@ program .description("Authenticate a provider and configure what model is used") .option("-p, --provider ", "Provider ID for quick setup (e.g., openai-native, anthropic, moonshot)") .option("-k, --apikey ", "API key for the provider") - .option("-m, --modelid ", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-5-20250929, kimi-k2.5)") + .option("-m, --modelid ", "Model ID to configure (e.g., gpt-4o, claude-sonnet-4-6, kimi-k2.5)") .option("-b, --baseurl ", "Base URL (optional, only for openai provider)") .option("-v, --verbose", "Show verbose output") .option("-c, --cwd ", "Working directory for the task") .option("--config ", "Path to Cline configuration directory") .action(runAuth) +const mcpCommand = program.command("mcp").description("Manage MCP servers") + +mcpCommand + .command("add") + .description("Add an MCP server shortcut to cline_mcp_settings.json") + .argument("", "MCP server name") + .argument("[targetOrCommand...]", "For stdio: use -- [args]. For http/sse: provide .") + .option("--type ", "Transport type: stdio (default), http, or sse", "stdio") + .option("-c, --cwd ", "Working directory for config resolution") + .option("--config ", "Path to Cline configuration directory") + .action(addMcpServer) + program .command("version") .description("Show Cline CLI version number") @@ -810,6 +899,8 @@ program .option("-v, --verbose", "Show verbose output") .action(() => checkForUpdates(CLI_VERSION)) +program.command("kanban").description("Run npx kanban --agent cline").action(runKanbanAlias) + // Dev command with subcommands const devCommand = program.command("dev").description("Developer tools and utilities") @@ -821,68 +912,6 @@ devCommand await openExternal(CLI_LOG_FILE) }) -/** - * Check if the user has completed onboarding (has any provider configured). - * - * Uses `welcomeViewCompleted` as the single source of truth, matching the VS Code extension's approach. - * If `welcomeViewCompleted` is undefined (first run), checks if ANY provider has credentials - * and sets the flag accordingly. - */ -async function isAuthConfigured(): Promise { - const stateManager = StateManager.get() - - // Check welcomeViewCompleted first - this is the single source of truth - const welcomeViewCompleted = stateManager.getGlobalStateKey("welcomeViewCompleted") - if (welcomeViewCompleted !== undefined) { - return welcomeViewCompleted - } - - // welcomeViewCompleted is undefined - run migration logic to check if ANY provider has credentials - // This mirrors the extension's migrateWelcomeViewCompleted behavior - const hasAnyAuth = await checkAnyProviderConfigured() - - // Set welcomeViewCompleted based on what we found - stateManager.setGlobalState("welcomeViewCompleted", hasAnyAuth) - await stateManager.flushPendingState() - - return hasAnyAuth -} - -/** - * Check if ANY provider has valid credentials configured. - * Used for migration when welcomeViewCompleted is undefined. - */ -async function checkAnyProviderConfigured(): Promise { - const stateManager = StateManager.get() - const config = stateManager.getApiConfiguration() as Record - - // Check Cline account (stored as "cline:clineAccountId" in secrets, loaded into config) - if (config["clineApiKey"] || config["cline:clineAccountId"]) return true - - // Check OpenAI Codex OAuth (stored in SECRETS_KEYS, loaded into config) - if (config["openai-codex-oauth-credentials"]) return true - - // Check all BYO provider API keys (loaded into config from secrets) - for (const [provider, keyField] of Object.entries(ProviderToApiKeyMap)) { - // Skip cline - already checked above with the correct key - if (provider === "cline") continue - - const fields = Array.isArray(keyField) ? keyField : [keyField] - for (const field of fields) { - if (config[field]) return true - } - } - - // Check provider-specific settings that indicate configuration - // (for providers that don't require API keys like Bedrock with IAM, Ollama, LM Studio) - if (config.awsRegion) return true - if (config.vertexProjectId) return true - if (config.ollamaBaseUrl) return true - if (config.lmStudioBaseUrl) return true - - return false -} - /** * Validate that a task exists in history * @returns The task history item if found, null otherwise @@ -896,8 +925,8 @@ function findTaskInHistory(taskId: string): HistoryItem | null { * Resume an existing task by ID * Loads the task and optionally prefills the input with a prompt */ -async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt?: string }) { - const ctx = await initializeCli({ ...options, enableAuth: true }) +async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt?: string }, existingContext?: CliContext) { + const ctx = existingContext || (await initializeCli({ ...options, enableAuth: true })) // Validate task exists const historyItem = findTaskInHistory(taskId) @@ -910,6 +939,11 @@ async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt telemetryService.captureHostEvent("resume_task_command", options.initialPrompt ? "with_prompt" : "interactive") + // Capture piped stdin telemetry now that HostProvider is initialized + if (options.stdinWasPiped) { + telemetryService.captureHostEvent("piped", "detached") + } + // Apply shared task options (mode, model, thinking, yolo) applyTaskOptions(options) await StateManager.get().flushPendingState() @@ -944,16 +978,35 @@ async function resumeTask(taskId: string, options: TaskOptions & { initialPrompt ) } +async function continueTask(options: TaskOptions) { + const ctx = await initializeCli({ ...options, enableAuth: true }) + const historyItem = findMostRecentTaskForWorkspace(StateManager.get().getGlobalStateKey("taskHistory"), ctx.workspacePath) + + if (!historyItem) { + printWarning(`No previous task found for ${ctx.workspacePath}`) + printInfo("Start a new task or use 'cline history' to browse previous tasks.") + await disposeCliContext(ctx) + exit(1) + } + + return resumeTask(historyItem.id, options, ctx) +} + /** * Show welcome prompt and wait for user input * If auth is not configured, show auth flow first */ -async function showWelcome(options: { verbose?: boolean; cwd?: string; config?: string; thinking?: boolean }) { +async function showWelcome(options: TaskOptions) { const ctx = await initializeCli({ ...options, enableAuth: true }) // Check if auth is configured const hasAuth = await isAuthConfigured() + // Apply CLI task options in interactive startup too, so flags like + // --auto-approve-all and --yolo affect the initial TUI state. + applyTaskOptions(options) + await StateManager.get().flushPendingState() + let hadError = false await runInkApp( @@ -983,7 +1036,8 @@ program .option("-a, --act", "Run in act mode") .option("-p, --plan", "Run in plan mode") .option("-y, --yolo", "Enable yolo mode (auto-approve actions)") - .option("-t, --timeout ", "Timeout in seconds for yolo mode (default: 600)") + .option("--auto-approve-all", "Enable auto-approve all actions while keeping interactive mode") + .option("-t, --timeout ", "Optional timeout in seconds (applies only when provided)") .option("-m, --model ", "Model to use for the task") .option("-v, --verbose", "Show verbose output") .option("-c, --cwd ", "Working directory") @@ -993,14 +1047,29 @@ program .option("--max-consecutive-mistakes ", "Maximum consecutive mistakes before halting in yolo mode") .option("--json", "Output messages as JSON instead of styled text") .option("--double-check-completion", "Reject first completion attempt to force re-verification") + .option("--auto-condense", "Enable AI-powered context compaction instead of mechanical truncation") + .option("--hooks-dir ", "Path to additional hooks directory for runtime hook injection") .option("--acp", "Run in ACP (Agent Client Protocol) mode for editor integration") + .option("--kanban", "Run npx kanban --agent cline") .option("-T, --taskId ", "Resume an existing task by ID") + .option("--continue", "Resume the most recent task from the current working directory") .action(async (prompt, options) => { + if (options.kanban) { + if (prompt) { + printWarning("Use --kanban without a prompt.") + exit(1) + } + + runKanbanAlias() + return + } + // Check for ACP mode first - this takes precedence over everything else if (options.acp) { await runAcpMode({ config: options.config, cwd: options.cwd, + hooksDir: options.hooksDir, verbose: options.verbose, }) return @@ -1015,6 +1084,25 @@ program // stdinInput has content means stdin was piped with data const stdinWasPiped = stdinInput !== null + if (options.taskId && options.continue) { + printWarning("Use either --taskId or --continue, not both.") + exit(1) + } + + if (options.continue) { + if (prompt) { + printWarning("Use --continue without a prompt.") + exit(1) + } + if (stdinWasPiped) { + printWarning("Use --continue without piped input.") + exit(1) + } + + await continueTask(options) + return + } + // Error if stdin was piped but empty AND no prompt was provided // This handles: // - `echo "" | cline` -> error (empty stdin, no prompt) @@ -1035,8 +1123,6 @@ program effectivePrompt = stdinInput } - telemetryService.captureHostEvent("piped", "detached") - // Debug: show that we received piped input if (options.verbose) { process.stderr.write(`[debug] Received ${stdinInput.length} bytes from stdin\n`) @@ -1063,4 +1149,6 @@ program }) // Parse and run -program.parse() +if (process.env.VITEST !== "true") { + program.parse() +} diff --git a/cli/src/lib-import.test.ts b/cli/src/lib-import.test.ts new file mode 100644 index 00000000000..c7acaca889b --- /dev/null +++ b/cli/src/lib-import.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest" + +describe("library import side effects", () => { + it("importing library exports must not mutate console.log", async () => { + const originalConsoleLog = console.log + await import("./exports") + expect(console.log).toBe(originalConsoleLog) + }, 30000) +}) diff --git a/cli/src/utils/auth.ts b/cli/src/utils/auth.ts new file mode 100644 index 00000000000..4274c6cda46 --- /dev/null +++ b/cli/src/utils/auth.ts @@ -0,0 +1,64 @@ +import { StateManager } from "@/core/storage/StateManager" +import { ProviderToApiKeyMap } from "@/shared/storage" + +/** + * Check if the user has completed onboarding (has any provider configured). + * + * Uses `welcomeViewCompleted` as the single source of truth, matching the VS Code extension's approach. + * If `welcomeViewCompleted` is undefined (first run), checks if ANY provider has credentials + * and sets the flag accordingly. + */ +export async function isAuthConfigured(): Promise { + const stateManager = StateManager.get() + + // Check welcomeViewCompleted first - this is the single source of truth + const welcomeViewCompleted = stateManager.getGlobalStateKey("welcomeViewCompleted") + if (welcomeViewCompleted !== undefined) { + return welcomeViewCompleted + } + + // welcomeViewCompleted is undefined - run migration logic to check if ANY provider has credentials + // This mirrors the extension's migrateWelcomeViewCompleted behavior + const hasAnyAuth = await checkAnyProviderConfigured() + + // Set welcomeViewCompleted based on what we found + stateManager.setGlobalState("welcomeViewCompleted", hasAnyAuth) + await stateManager.flushPendingState() + + return hasAnyAuth +} + +/** + * Check if ANY provider has valid credentials configured. + * Used for migration when welcomeViewCompleted is undefined. + */ +export async function checkAnyProviderConfigured(): Promise { + const stateManager = StateManager.get() + const config = stateManager.getApiConfiguration() as Record + + // Check Cline account (stored as "cline:clineAccountId" in secrets, loaded into config) + if (config["clineApiKey"] || config["cline:clineAccountId"]) return true + + // Check OpenAI Codex OAuth (stored in SECRETS_KEYS, loaded into config) + if (config["openai-codex-oauth-credentials"]) return true + + // Check all BYO provider API keys (loaded into config from secrets) + for (const [provider, keyField] of Object.entries(ProviderToApiKeyMap)) { + // Skip cline - already checked above with the correct key + if (provider === "cline") continue + + const fields = Array.isArray(keyField) ? keyField : [keyField] + for (const field of fields) { + if (config[field]) return true + } + } + + // Check provider-specific settings that indicate configuration + // (for providers that don't require API keys like Bedrock with IAM, Ollama, LM Studio) + if (config.awsRegion) return true + if (config.vertexProjectId) return true + if (config.ollamaBaseUrl) return true + if (config.lmStudioBaseUrl) return true + + return false +} diff --git a/cli/src/utils/console.ts b/cli/src/utils/console.ts index 2fcb59adb45..25d7d7551b8 100644 --- a/cli/src/utils/console.ts +++ b/cli/src/utils/console.ts @@ -12,11 +12,19 @@ export const originalConsoleWarn = console.warn.bind(console) export const originalConsoleInfo = console.info.bind(console) export const originalConsoleDebug = console.debug.bind(console) -// Check for verbose flag early (before commander parses) -const isVerbose = process.argv.includes("-v") || process.argv.includes("--verbose") +/** + * Suppress console output unless verbose mode is enabled. + * + * This is intentionally opt-in and should only be called by the CLI entrypoint. + * Library consumers should not have their global console methods mutated as a + * side effect of importing the library bundle. + */ +export function suppressConsoleUnlessVerbose(argv: string[] = process.argv) { + const isVerbose = argv.includes("-v") || argv.includes("--verbose") + if (isVerbose) { + return + } -// Suppress console output unless verbose mode -if (!isVerbose) { console.log = () => {} console.warn = () => {} console.error = () => {} diff --git a/cli/src/utils/mcp.test.ts b/cli/src/utils/mcp.test.ts new file mode 100644 index 00000000000..bf1b8da76c3 --- /dev/null +++ b/cli/src/utils/mcp.test.ts @@ -0,0 +1,63 @@ +import * as fs from "node:fs/promises" +import os from "node:os" +import path from "node:path" +import { afterEach, describe, expect, it } from "vitest" +import { addMcpServerShortcut } from "./mcp" + +const tempDirs: string[] = [] + +async function createTempConfigDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "cline-mcp-test-")) + tempDirs.push(dir) + return dir +} + +type McpSettingsFile = { + mcpServers: Record> +} + +async function readMcpSettings(configDir: string): Promise { + const settingsPath = path.join(configDir, "data", "settings", "cline_mcp_settings.json") + return JSON.parse(await fs.readFile(settingsPath, "utf-8")) as McpSettingsFile +} + +afterEach(async () => { + for (const dir of tempDirs.splice(0, tempDirs.length)) { + await fs.rm(dir, { recursive: true, force: true }) + } +}) + +describe("addMcpServerShortcut", () => { + it("writes stdio servers with type=stdio", async () => { + const configDir = await createTempConfigDir() + + await addMcpServerShortcut("kanban", ["kanban", "mcp"], { config: configDir }) + const settings = await readMcpSettings(configDir) + + expect(settings.mcpServers.kanban).toEqual({ + command: "kanban", + args: ["mcp"], + type: "stdio", + }) + }) + + it("maps --type http to streamableHttp", async () => { + const configDir = await createTempConfigDir() + + await addMcpServerShortcut("linear", ["https://mcp.linear.app/mcp"], { config: configDir, type: "http" }) + const settings = await readMcpSettings(configDir) + + expect(settings.mcpServers.linear).toEqual({ + url: "https://mcp.linear.app/mcp", + type: "streamableHttp", + }) + }) + + it("errors when URL is provided without --type http", async () => { + const configDir = await createTempConfigDir() + + await expect(addMcpServerShortcut("linear", ["https://mcp.linear.app/mcp"], { config: configDir })).rejects.toThrow( + "Use --type http", + ) + }) +}) diff --git a/cli/src/utils/mcp.ts b/cli/src/utils/mcp.ts new file mode 100644 index 00000000000..67710a09efe --- /dev/null +++ b/cli/src/utils/mcp.ts @@ -0,0 +1,159 @@ +import * as fs from "node:fs/promises" +import path from "node:path" +import { getMcpSettingsFilePath } from "@/core/storage/disk" +import { ServerConfigSchema } from "@/services/mcp/schemas" +import { initializeCliContext } from "../vscode-context" + +export interface McpAddOptions { + type?: string + config?: string + cwd?: string +} + +export type McpAddTransportType = "stdio" | "streamableHttp" | "sse" + +export interface AddMcpServerResult { + serverName: string + transportType: McpAddTransportType + settingsPath: string +} + +function normalizeMcpTransportType(value?: string): McpAddTransportType { + const normalized = (value || "stdio").trim().toLowerCase() + + switch (normalized) { + case "stdio": + return "stdio" + case "http": + case "streamable-http": + case "streamablehttp": + return "streamableHttp" + case "sse": + return "sse" + default: + throw new Error(`Invalid MCP transport type '${value}'. Valid values: stdio, http, sse.`) + } +} + +function parseMcpSettings(content: string, settingsPath: string): Record { + const trimmedContent = content.trim() + if (!trimmedContent) { + return { mcpServers: {} } + } + + let parsed: unknown + try { + parsed = JSON.parse(content) + } catch { + throw new Error(`Invalid JSON in ${settingsPath}. Please fix the file and try again.`) + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`Invalid MCP settings file at ${settingsPath}. Expected a JSON object.`) + } + + const settings = parsed as Record + if (settings.mcpServers === undefined) { + settings.mcpServers = {} + } + + if (!settings.mcpServers || typeof settings.mcpServers !== "object" || Array.isArray(settings.mcpServers)) { + throw new Error(`Invalid MCP settings file at ${settingsPath}. Expected 'mcpServers' to be an object.`) + } + + return settings +} + +function createMcpServerConfig(targetOrCommand: string[], transportType: McpAddTransportType): Record { + if (transportType === "stdio") { + if (targetOrCommand.length < 1) { + throw new Error("Missing stdio command. Example: cline mcp add kanban -- kanban mcp") + } + + // Guard against common mistake: + // `cline mcp add ` without `--type http` + if (targetOrCommand.length === 1) { + const [value] = targetOrCommand + try { + const parsedUrl = new URL(value) + if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:") { + throw new Error( + `Looks like you provided a URL for '${value}'. Use --type http, for example: cline mcp add ${value} --type http`, + ) + } + } catch (error) { + if (error instanceof Error && error.message.startsWith("Looks like you provided a URL")) { + throw error + } + } + } + + const [command, ...args] = targetOrCommand + const config: Record = { + command, + type: "stdio", + } + + if (args.length > 0) { + config.args = args + } + + ServerConfigSchema.parse(config) + return config + } + + if (targetOrCommand.length !== 1) { + throw new Error( + "HTTP/SSE MCP servers require exactly one URL. Example: cline mcp add linear https://mcp.linear.app/mcp --type http", + ) + } + + const config = { + url: targetOrCommand[0], + type: transportType, + } + + ServerConfigSchema.parse(config) + return config +} + +export async function addMcpServerShortcut( + name: string, + targetOrCommand: string[] = [], + options: McpAddOptions, +): Promise { + const trimmedName = name.trim() + if (!trimmedName) { + throw new Error("Server name is required.") + } + + const transportType = normalizeMcpTransportType(options.type) + + const { DATA_DIR } = initializeCliContext({ + clineDir: options.config, + workspaceDir: options.cwd || process.cwd(), + }) + + const settingsDirectoryPath = path.join(DATA_DIR, "settings") + await fs.mkdir(settingsDirectoryPath, { recursive: true }) + const settingsPath = await getMcpSettingsFilePath(settingsDirectoryPath) + + const content = await fs.readFile(settingsPath, "utf-8") + const settings = parseMcpSettings(content, settingsPath) + const mcpServers = settings.mcpServers as Record + + if (mcpServers[trimmedName]) { + throw new Error(`An MCP server named '${trimmedName}' already exists.`) + } + + const serverConfig = createMcpServerConfig(targetOrCommand, transportType) + mcpServers[trimmedName] = serverConfig + + await fs.writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf-8") + + return { + serverName: trimmedName, + transportType, + settingsPath, + } +} diff --git a/cli/src/utils/plain-text-task.ts b/cli/src/utils/plain-text-task.ts index aee13c54292..d9530de7ee1 100644 --- a/cli/src/utils/plain-text-task.ts +++ b/cli/src/utils/plain-text-task.ts @@ -26,7 +26,7 @@ export interface PlainTextTaskOptions { imageDataUrls?: string[] verbose?: boolean jsonOutput?: boolean - /** Timeout in seconds (default: 600 = 10 minutes) */ + /** Timeout in seconds (only applied when explicitly provided) */ timeoutSeconds?: number /** Task ID to resume an existing task */ taskId?: string @@ -153,10 +153,14 @@ export async function runPlainTextTask(options: PlainTextTaskOptions): Promise setTimeout(() => reject(new Error("Timeout")), timeoutMs)) - await Promise.race([completionPromise, timeoutPromise]) + // Wait for task completion, with optional timeout only when explicitly configured + if (options.timeoutSeconds) { + const timeoutMs = options.timeoutSeconds * 1000 + const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeoutMs)) + await Promise.race([completionPromise, timeoutPromise]) + } else { + await completionPromise + } } catch (error) { const errMsg = error instanceof Error ? error.message : String(error) if (jsonOutput) { diff --git a/cli/src/utils/provider-config.ts b/cli/src/utils/provider-config.ts index fd4cc35af09..eefb8f2478f 100644 --- a/cli/src/utils/provider-config.ts +++ b/cli/src/utils/provider-config.ts @@ -7,6 +7,8 @@ import type { ApiProvider } from "@shared/api" import { getProviderModelIdKey, ProviderToApiKeyMap } from "@shared/storage" import { buildApiHandler } from "@/core/api" import type { Controller } from "@/core/controller" +import { refreshOpenRouterModels } from "@/core/controller/models/refreshOpenRouterModels" +import { refreshVercelAiGatewayModels } from "@/core/controller/models/refreshVercelAiGatewayModels" import { StateManager } from "@/core/storage/StateManager" import type { BedrockConfig } from "../components/BedrockSetup" import { getDefaultModelId } from "../components/ModelPicker" @@ -40,14 +42,22 @@ export async function applyProviderConfig(options: ApplyProviderConfigOptions): if (actModelKey) config[actModelKey] = finalModelId if (planModelKey) config[planModelKey] = finalModelId - // For cline/openrouter, also set model info (required for getModel() to return correct model) + // Fetch model info from the provider API (not just disk cache) so headless + // CLI auth gets correct maxTokens, thinkingConfig, etc. if ((providerId === "cline" || providerId === "openrouter") && controller) { - const openRouterModels = await controller.readOpenRouterModels() + const openRouterModels = await refreshOpenRouterModels(controller) const modelInfo = openRouterModels?.[finalModelId] if (modelInfo) { stateManager.setGlobalState("actModeOpenRouterModelInfo", modelInfo) stateManager.setGlobalState("planModeOpenRouterModelInfo", modelInfo) } + } else if (providerId === "vercel-ai-gateway" && controller) { + const vercelModels = await refreshVercelAiGatewayModels(controller) + const modelInfo = vercelModels?.[finalModelId] + if (modelInfo) { + stateManager.setGlobalState("actModeVercelAiGatewayModelInfo", modelInfo) + stateManager.setGlobalState("planModeVercelAiGatewayModelInfo", modelInfo) + } } } @@ -80,15 +90,18 @@ export async function applyProviderConfig(options: ApplyProviderConfigOptions): export interface ApplyBedrockConfigOptions { bedrockConfig: BedrockConfig modelId?: string + customModelBaseId?: string // Base model ID for custom ARN/Inference Profile (for capability detection) controller?: Controller } /** * Apply Bedrock provider configuration to state * Handles AWS-specific fields (authentication, region, credentials) + * When customModelBaseId is provided, sets the custom model flags so the system + * knows to use the ARN as the model ID and the base model for capability detection. */ export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Promise { - const { bedrockConfig, modelId, controller } = options + const { bedrockConfig, modelId, customModelBaseId, controller } = options const stateManager = StateManager.get() const config: Record = { @@ -108,6 +121,18 @@ export async function applyBedrockConfig(options: ApplyBedrockConfigOptions): Pr if (planModelKey) config[planModelKey] = finalModelId } + // Handle custom model (Application Inference Profile ARN) + if (customModelBaseId) { + config.actModeAwsBedrockCustomSelected = true + config.planModeAwsBedrockCustomSelected = true + config.actModeAwsBedrockCustomModelBaseId = customModelBaseId + config.planModeAwsBedrockCustomModelBaseId = customModelBaseId + } else { + // Ensure custom flags are cleared when using a standard model + config.actModeAwsBedrockCustomSelected = false + config.planModeAwsBedrockCustomSelected = false + } + // Add optional AWS credentials if (bedrockConfig.awsProfile !== undefined) config.awsProfile = bedrockConfig.awsProfile if (bedrockConfig.awsAccessKey) config.awsAccessKey = bedrockConfig.awsAccessKey diff --git a/cli/src/utils/task-history.test.ts b/cli/src/utils/task-history.test.ts new file mode 100644 index 00000000000..ceeab60f8ce --- /dev/null +++ b/cli/src/utils/task-history.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest" +import { findMostRecentTaskForWorkspace } from "./task-history" + +describe("findMostRecentTaskForWorkspace", () => { + it("returns the newest matching task for the workspace", () => { + const result = findMostRecentTaskForWorkspace( + [ + { + id: "older", + ts: 100, + task: "Older task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + cwdOnTaskInitialization: "/repo", + }, + { + id: "newer", + ts: 200, + task: "Newer task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + cwdOnTaskInitialization: "/repo", + }, + ], + "/repo", + ) + + expect(result?.id).toBe("newer") + }) + + it("falls back to shadowGitConfigWorkTree for older tasks", () => { + const result = findMostRecentTaskForWorkspace( + [ + { + id: "legacy", + ts: 200, + task: "Legacy task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + shadowGitConfigWorkTree: "/repo", + }, + ], + "/repo", + ) + + expect(result?.id).toBe("legacy") + }) + + it("returns null when there is no match", () => { + const result = findMostRecentTaskForWorkspace( + [ + { + id: "other", + ts: 200, + task: "Other task", + tokensIn: 0, + tokensOut: 0, + totalCost: 0, + cwdOnTaskInitialization: "/other", + }, + ], + "/repo", + ) + + expect(result).toBeNull() + }) +}) diff --git a/cli/src/utils/task-history.ts b/cli/src/utils/task-history.ts new file mode 100644 index 00000000000..fe4213132e3 --- /dev/null +++ b/cli/src/utils/task-history.ts @@ -0,0 +1,27 @@ +import { HistoryItem } from "@shared/HistoryItem" +import { arePathsEqual } from "@/utils/path" + +export function findMostRecentTaskForWorkspace( + taskHistory: HistoryItem[] | undefined, + workspacePath: string, +): HistoryItem | null { + if (!taskHistory?.length) { + return null + } + + return ( + [...taskHistory] + .filter((item) => { + if (!item.ts || !item.task) { + return false + } + + return Boolean( + (item.cwdOnTaskInitialization && arePathsEqual(item.cwdOnTaskInitialization, workspacePath)) || + (item.shadowGitConfigWorkTree && arePathsEqual(item.shadowGitConfigWorkTree, workspacePath)), + ) + }) + .sort((a, b) => b.ts - a.ts) + .at(0) ?? null + ) +} diff --git a/cli/src/vscode-context.ts b/cli/src/vscode-context.ts index c2da7513166..577413324af 100644 --- a/cli/src/vscode-context.ts +++ b/cli/src/vscode-context.ts @@ -1,23 +1,21 @@ /** * VSCode context stub for CLI mode - * Provides mock implementations of VSCode extension context + * Provides mock implementations of VSCode extension context. */ -import { mkdirSync } from "node:fs" import { fileURLToPath } from "node:url" import os from "os" import path from "path" import { ExtensionRegistryInfo } from "@/registry" import { ClineExtensionContext } from "@/shared/cline" -import { ClineFileStorage } from "@/shared/storage" +import type { ClineMemento } from "@/shared/storage/ClineStorage" +import { createStorageContext, type StorageContext } from "@/shared/storage/storage-context" import { EnvironmentVariableCollection, ExtensionKind, ExtensionMode, readJson, URI } from "./vscode-shim" // ES module equivalent of __dirname const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const SETTINGS_SUBFOLDER = "data" - /** * CLI-specific state overrides. * These values are always returned regardless of what's stored, @@ -35,115 +33,89 @@ const CLI_STATE_OVERRIDES: Record = { } /** - * File-based Memento store with optional key overrides. - * Implements VSCode's Memento interface using SyncJsonFileStorage. + * Memento adapter that wraps a ClineFileStorage with optional key overrides. + * Used for globalState where CLI needs to inject hardcoded overrides. */ -class MementoStore extends ClineFileStorage { - private overrides: Record - - constructor(filePath: string, overrides: Record = {}) { - super(filePath, "MementoStore") - this.overrides = overrides - } - - // VSCode Memento interface - override base class get() with overload support - override get(key: string): T | undefined - override get(key: string, defaultValue: T): T - override get(key: string, defaultValue?: T): T | undefined { +class MementoAdapter implements ClineMemento { + constructor( + private readonly store: ClineMemento, + private readonly overrides: Record = {}, + ) {} + + get(key: string): T | undefined + get(key: string, defaultValue: T): T + get(key: string, defaultValue?: T): T | undefined { if (key in this.overrides) { return this.overrides[key] as T } - const value = super.get(key) + const value = this.store.get(key) return value !== undefined ? value : defaultValue } - override async update(key: string, value: any): Promise { - if (key in this.overrides) { - return - } - this.set(key, value) + update(key: string, value: any): Thenable { + return this.setBatch({ [key]: value }) } - setKeysForSync(_keys: readonly string[]): void { - // No-op for CLI + keys(): readonly string[] { + return this.store.keys() } -} -/** - * File-based secret storage implementing VSCode's SecretStorage interface. - * Uses sync storage internally but exposes async API for VSCode compatibility. - */ -class SecretStore { - private storage: ClineFileStorage - private onDidChangeEmitter = { - event: () => ({ dispose: () => {} }), - fire: (_e: any) => {}, - dispose: () => {}, - } - - onDidChange = this.onDidChangeEmitter.event - - constructor(filePath: string) { - this.storage = new ClineFileStorage(filePath, "SecretStore") - } - - get(key: string): Promise { - return Promise.resolve(this.storage.get(key)) - } - - store(key: string, value: string): Promise { - this.storage.set(key, value) + setBatch(entries: Record): Thenable { + // Filter out overridden keys and delegate to underlying store + const filteredEntries: Record = {} + for (const [key, value] of Object.entries(entries)) { + if (!(key in this.overrides)) { + filteredEntries[key] = value + } + } + this.store.setBatch(filteredEntries) return Promise.resolve() } - delete(key: string): Promise { - this.storage.delete(key) - return Promise.resolve() + setKeysForSync(_keys: readonly string[]): void { + // No-op for CLI } } export interface CliContextConfig { clineDir?: string - /** The workspace directory being worked in (for hashing into storage path) */ + /** The workspace directory being worked in (used to compute workspace storage hash) */ workspaceDir?: string } -/** - * Create a short hash of a string for use in directory names - */ -function hashString(str: string): string { - let hash = 0 - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i) - hash = (hash << 5) - hash + char - hash = hash & hash // Convert to 32bit integer - } - return Math.abs(hash).toString(16).substring(0, 8) -} - export interface CliContextResult { extensionContext: ClineExtensionContext + storageContext: StorageContext DATA_DIR: string EXTENSION_DIR: string WORKSPACE_STORAGE_DIR: string } /** - * Initialize the VSCode-like context for CLI mode + * Initialize the VSCode-like context for CLI mode. + * + * Creates a shared StorageContext (the single source of truth for all storage) + * and wraps it in a ClineExtensionContext shell for legacy APIs that still + * expect the VSCode ExtensionContext shape. */ export function initializeCliContext(config: CliContextConfig = {}): CliContextResult { const CLINE_DIR = config.clineDir || process.env.CLINE_DIR || path.join(os.homedir(), ".cline") - const DATA_DIR = path.join(CLINE_DIR, SETTINGS_SUBFOLDER) - // Workspace storage should always be under ~/.cline/data/workspaces// - // where hash is derived from the workspace path to keep workspaces isolated - const workspacePath = config.workspaceDir || process.cwd() - const workspaceHash = hashString(workspacePath) - const WORKSPACE_STORAGE_DIR = process.env.WORKSPACE_STORAGE_DIR || path.join(DATA_DIR, "workspaces", workspaceHash) + // Create the shared StorageContext — this owns all ClineFileStorage instances. + // CLI, JetBrains, and VSCode all share this same file-backed implementation. + let storageContext = createStorageContext({ + clineDir: CLINE_DIR, + workspacePath: config.workspaceDir || process.cwd(), + workspaceStorageDir: process.env.WORKSPACE_STORAGE_DIR || undefined, + }) + storageContext = { + ...storageContext, + // Storage — delegates to storageContext stores (with CLI overrides for globalState) + globalState: new MementoAdapter(storageContext.globalState, CLI_STATE_OVERRIDES), + } - // Ensure directories exist - mkdirSync(DATA_DIR, { recursive: true }) - mkdirSync(WORKSPACE_STORAGE_DIR, { recursive: true }) + const DATA_DIR = storageContext.dataDir + const WORKSPACE_STORAGE_DIR = storageContext.workspaceStoragePath // For CLI, extension dir is the package root (one level up from dist/) const EXTENSION_DIR = path.resolve(__dirname, "..") @@ -160,38 +132,30 @@ export function initializeCliContext(config: CliContextConfig = {}): CliContextR extensionKind: ExtensionKind.UI, } + // Build the ClineExtensionContext shell. All storage delegates to storageContext — + // there are NO separate ClineFileStorage instances here. const extensionContext: ClineExtensionContext = { extension: extension, extensionMode: EXTENSION_MODE, - // Set up KV stores (globalState has CLI-specific overrides) - globalState: new MementoStore(path.join(DATA_DIR, "globalState.json"), CLI_STATE_OVERRIDES), - secrets: new SecretStore(path.join(DATA_DIR, "secrets.json")), - - // Set up URIs + // URIs / paths storageUri: URI.file(WORKSPACE_STORAGE_DIR), storagePath: WORKSPACE_STORAGE_DIR, globalStorageUri: URI.file(DATA_DIR), globalStoragePath: DATA_DIR, - - // Logs logUri: URI.file(DATA_DIR), logPath: DATA_DIR, - extensionUri: URI.file(EXTENSION_DIR), extensionPath: EXTENSION_DIR, asAbsolutePath: (relPath: string) => path.join(EXTENSION_DIR, relPath), subscriptions: [], - environmentVariableCollection: new EnvironmentVariableCollection() as any, - - // Workspace state - workspaceState: new MementoStore(path.join(WORKSPACE_STORAGE_DIR, "workspaceState.json")), } return { extensionContext, + storageContext, DATA_DIR, EXTENSION_DIR, WORKSPACE_STORAGE_DIR, diff --git a/cli/tsconfig.lib.json b/cli/tsconfig.lib.json new file mode 100644 index 00000000000..151cac6b02e --- /dev/null +++ b/cli/tsconfig.lib.json @@ -0,0 +1,20 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": false, + "noCheck": true, + "noResolve": true, + "outDir": "dist/types" + }, + "include": [ + "src/exports.ts", + "src/agent/public-types.ts", + "src/agent/ClineAgent.ts", + "src/agent/ClineSessionEmitter.ts", + "src/agent/types.ts", + "src/agent/messageTranslator.ts", + "src/agent/permissionHandler.ts" + ] +} diff --git a/cli/vitest.config.ts b/cli/vitest.config.ts index a24a3930b35..7a4a6ea8b77 100644 --- a/cli/vitest.config.ts +++ b/cli/vitest.config.ts @@ -5,11 +5,28 @@ export default defineConfig({ test: { globals: true, environment: "node", - include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"], coverage: { reporter: ["text", "json", "html"], exclude: ["node_modules/", "dist/"], }, + projects: [ + { + extends: true, + test: { + name: "unit", + include: ["src/**/*.test.{ts,tsx}", "tests/**/*.test.{ts,tsx}"], + exclude: ["src/**/*.markdown.test.tsx"], + }, + }, + { + extends: true, + test: { + name: "markdown", + include: ["src/**/*.markdown.test.tsx"], + env: { FORCE_COLOR: "3" }, + }, + }, + ], }, resolve: { alias: { diff --git a/docs/api/authentication.mdx b/docs/api/authentication.mdx new file mode 100644 index 00000000000..7b6127c4917 --- /dev/null +++ b/docs/api/authentication.mdx @@ -0,0 +1,137 @@ +--- +title: "Authentication" +sidebarTitle: "Authentication" +description: "How to authenticate with the Cline API using API keys or account tokens." +--- + +Every request to the Cline API requires authentication via a Bearer token in the `Authorization` header. + +## Authentication Methods + +There are two ways to authenticate: + +| Method | Use case | How to get it | +|--------|----------|---------------| +| **API key** | Direct API calls, scripts, CI/CD | Create at [app.cline.bot](https://app.cline.bot) Settings > API Keys | +| **Account auth token** | Cline extension and CLI | Generated automatically when you sign in | + +Both methods use the same header format: + +```bash +Authorization: Bearer YOUR_TOKEN +``` + +## API Keys + +API keys are the recommended authentication method for programmatic access. + +### Creating a Key + + + + Go to [app.cline.bot](https://app.cline.bot) and sign in. + + + Navigate to **Settings** > **API Keys**. + + + Create a new key. Copy it immediately as you will not be able to see it again. + + + +### Deleting a Key + +You can revoke an API key at any time from the same Settings > API Keys page. Deleted keys stop working immediately. + +You can also manage keys programmatically through the [Enterprise API](/enterprise-solutions/api-reference#api-keys): + +```bash +# List your keys +curl https://api.cline.bot/api/v1/api-keys \ + -H "Authorization: Bearer YOUR_TOKEN" + +# Delete a key +curl -X DELETE https://api.cline.bot/api/v1/api-keys/KEY_ID \ + -H "Authorization: Bearer YOUR_TOKEN" +``` + +## Account Auth Tokens + +When you sign in to the Cline extension (VS Code, JetBrains) or CLI, an account auth token is generated and managed automatically. You do not need to handle these tokens manually. + +The Cline CLI uses these tokens when you authenticate via: + +```bash +# Interactive sign-in +cline auth + +# Or quick setup with an API key +cline auth -p cline -k "YOUR_API_KEY" -m anthropic/claude-sonnet-4-6 +``` + +See the [CLI Reference](/cline-cli/cli-reference#cline-auth) for all auth options. + +## Security Best Practices + +**Do:** +- Store API keys in environment variables or a secrets manager +- Use different keys for development and production +- Rotate keys periodically +- Delete keys you no longer use + +**Do not:** +- Commit keys to version control +- Share keys in chat or email +- Embed keys in client-side code (browsers, mobile apps) +- Log keys in application output + +### Using Environment Variables + +```bash +# Set the key +export CLINE_API_KEY="your_api_key_here" + +# Use it in requests +curl -X POST https://api.cline.bot/api/v1/chat/completions \ + -H "Authorization: Bearer $CLINE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"model": "anthropic/claude-sonnet-4-6", "messages": [{"role": "user", "content": "Hello"}]}' +``` + +### Using a .env File + +```bash +# .env (add to .gitignore) +CLINE_API_KEY=your_api_key_here +``` + +```python +import os +from openai import OpenAI + +client = OpenAI( + base_url="https://api.cline.bot/api/v1", + api_key=os.environ["CLINE_API_KEY"], +) +``` + +## Custom Headers + +The Cline API accepts optional headers for tracking and identification: + +| Header | Description | +|--------|-------------| +| `HTTP-Referer` | Your application's URL. Helps with usage tracking. | +| `X-Title` | Your application's name. Appears in usage logs. | +| `X-Task-ID` | A unique task identifier. Used internally by the Cline extension. | + +## Related + + + + Create your first API key and make a request. + + + Manage API keys programmatically. + + diff --git a/docs/api/chat-completions.mdx b/docs/api/chat-completions.mdx new file mode 100644 index 00000000000..30f2817e47d --- /dev/null +++ b/docs/api/chat-completions.mdx @@ -0,0 +1,258 @@ +--- +title: "Chat Completions" +sidebarTitle: "Chat Completions" +description: "Full reference for the POST /chat/completions endpoint including all parameters, streaming, and tool calling." +--- + +The Chat Completions endpoint generates model responses from a conversation. It follows the [OpenAI Chat Completions](https://platform.openai.com/docs/api-reference/chat/create) format. + +## Endpoint + +``` +POST https://api.cline.bot/api/v1/chat/completions +``` + +## Request Headers + +| Header | Required | Description | +|--------|----------|-------------| +| `Authorization` | Yes | `Bearer YOUR_API_KEY` | +| `Content-Type` | Yes | `application/json` | +| `HTTP-Referer` | No | Your application URL (for usage tracking) | +| `X-Title` | No | Your application name (for usage logs) | + +## Request Body + +| Parameter | Type | Required | Default | Description | +|-----------|------|----------|---------|-------------| +| `model` | string | Yes | | Model ID in `provider/model` format. See [Models](/api/models). | +| `messages` | array | Yes | | Conversation messages. Each has `role` (`system`, `user`, `assistant`) and `content`. | +| `stream` | boolean | No | `true` | Return the response as a stream of Server-Sent Events. | +| `tools` | array | No | | Tool/function definitions in OpenAI format. | +| `temperature` | number | No | Model default | Sampling temperature (0.0 to 2.0). Lower values are more deterministic. | + +### Message Format + +Each message in the `messages` array has this structure: + +```json +{ + "role": "user", + "content": "Your message here" +} +``` + +**Roles:** + +| Role | Purpose | +|------|---------| +| `system` | Sets the model's behavior and persona. Place first in the array. | +| `user` | The human's input. | +| `assistant` | Previous model responses (for multi-turn conversations). | + +### Multi-Turn Conversation + +Include previous messages to maintain context: + +```json +{ + "model": "anthropic/claude-sonnet-4-6", + "messages": [ + {"role": "system", "content": "You are a helpful coding assistant."}, + {"role": "user", "content": "What is a closure in JavaScript?"}, + {"role": "assistant", "content": "A closure is a function that..."}, + {"role": "user", "content": "Can you show me an example?"} + ] +} +``` + +## Streaming Response + +When `stream: true` (the default), the response is a series of [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-Sent_Events): + +``` +data: {"id":"gen-abc123","choices":[{"delta":{"role":"assistant"},"index":0}],"model":"anthropic/claude-sonnet-4-6"} + +data: {"id":"gen-abc123","choices":[{"delta":{"content":"The capital"},"index":0}],"model":"anthropic/claude-sonnet-4-6"} + +data: {"id":"gen-abc123","choices":[{"delta":{"content":" of France"},"index":0}],"model":"anthropic/claude-sonnet-4-6"} + +data: {"id":"gen-abc123","choices":[{"delta":{"content":" is Paris."},"index":0,"finish_reason":"stop"}],"model":"anthropic/claude-sonnet-4-6","usage":{"prompt_tokens":14,"completion_tokens":8,"cost":0.000066}} + +data: [DONE] +``` + +Each `data:` line contains a JSON chunk. Key fields: + +| Field | Description | +|-------|-------------| +| `id` | Generation ID, consistent across all chunks | +| `choices[0].delta.content` | The new text in this chunk | +| `choices[0].delta.reasoning` | Reasoning/thinking content (for reasoning models) | +| `choices[0].finish_reason` | `stop` when complete, `error` on failure | +| `usage` | Token counts and cost (included in the final chunk) | + +### Usage Object + +The final chunk includes token usage and cost: + +```json +{ + "usage": { + "prompt_tokens": 25, + "completion_tokens": 42, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "cost": 0.000315 + } +} +``` + +| Field | Description | +|-------|-------------| +| `prompt_tokens` | Total input tokens | +| `completion_tokens` | Total output tokens | +| `prompt_tokens_details.cached_tokens` | Tokens served from cache (reduces cost) | +| `cost` | Total cost in USD for this request | + +## Non-Streaming Response + +When `stream: false`, the response is a single JSON object: + +```json +{ + "id": "gen-abc123", + "model": "anthropic/claude-sonnet-4-6", + "choices": [ + { + "message": { + "role": "assistant", + "content": "The capital of France is Paris." + }, + "finish_reason": "stop", + "index": 0 + } + ], + "usage": { + "prompt_tokens": 14, + "completion_tokens": 8 + } +} +``` + +## Tool Calling + +You can define tools that the model can call using the OpenAI function calling format: + +```json +{ + "model": "anthropic/claude-sonnet-4-6", + "messages": [ + {"role": "user", "content": "What's the weather in San Francisco?"} + ], + "tools": [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get the current weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": "City and state, e.g. San Francisco, CA" + } + }, + "required": ["location"] + } + } + } + ] +} +``` + +When the model decides to call a tool, the response includes a `tool_calls` array: + +```json +{ + "choices": [ + { + "message": { + "role": "assistant", + "tool_calls": [ + { + "id": "call_abc123", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"location\": \"San Francisco, CA\"}" + } + } + ] + }, + "finish_reason": "tool_calls" + } + ] +} +``` + +To continue the conversation after a tool call, include the tool result: + +```json +{ + "messages": [ + {"role": "user", "content": "What's the weather in San Francisco?"}, + {"role": "assistant", "tool_calls": [{"id": "call_abc123", "type": "function", "function": {"name": "get_weather", "arguments": "{\"location\": \"San Francisco, CA\"}"}}]}, + {"role": "tool", "tool_call_id": "call_abc123", "content": "{\"temperature\": 62, \"condition\": \"foggy\"}"}, + ] +} +``` + +## Reasoning Models + +Some models support extended thinking (reasoning). When using these models, the response may include reasoning content in the streaming delta: + +```json +{"choices":[{"delta":{"reasoning":"Let me think about this step by step..."}}]} +``` + +Reasoning tokens are separate from the main content and appear in the `delta.reasoning` field. Some providers return encrypted reasoning blocks via `delta.reasoning_details` that can be passed back in subsequent requests to preserve the reasoning trace. + + + Not all models support reasoning. See [Models](/api/models) for which models have reasoning capabilities. + + +## Complete Example + +```bash +curl -X POST https://api.cline.bot/api/v1/chat/completions \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "anthropic/claude-sonnet-4-6", + "messages": [ + {"role": "system", "content": "You are a concise assistant. Answer in one sentence."}, + {"role": "user", "content": "Explain what an API is."} + ], + "stream": true + }' +``` + +## Related + + + + Browse available models and their capabilities. + + + Handle errors and implement retry logic. + + + Use this endpoint from Python, Node.js, and more. + + + API key management and security practices. + + diff --git a/docs/api/errors.mdx b/docs/api/errors.mdx new file mode 100644 index 00000000000..4570051a421 --- /dev/null +++ b/docs/api/errors.mdx @@ -0,0 +1,152 @@ +--- +title: "Errors" +sidebarTitle: "Errors" +description: "Error codes, error formats, mid-stream errors, and retry strategies for the Cline API." +--- + +The Cline API returns errors in a consistent JSON format. Understanding these errors helps you build reliable integrations. + +## Error Format + +All errors follow the OpenAI error format: + +```json +{ + "error": { + "code": 401, + "message": "Invalid API key", + "metadata": {} + } +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `code` | number/string | HTTP status code or error identifier | +| `message` | string | Human-readable description of the error | +| `metadata` | object | Additional context (provider details, request IDs) | + +## Error Codes + +### HTTP Errors + +These are returned as the HTTP response status code and in the error body: + +| Code | Name | Cause | What to do | +|------|------|-------|------------| +| `400` | Bad Request | Malformed request body, missing required fields | Check your JSON syntax and required parameters | +| `401` | Unauthorized | Invalid or missing API key | Verify your API key in the `Authorization` header | +| `402` | Payment Required | Insufficient credits | Add credits at [app.cline.bot](https://app.cline.bot) | +| `403` | Forbidden | Key does not have access to this resource | Check key permissions | +| `404` | Not Found | Invalid endpoint or model ID | Verify the URL and model ID format | +| `429` | Too Many Requests | Rate limit exceeded | Wait and retry with exponential backoff | +| `500` | Internal Server Error | Server-side issue | Retry after a short delay | +| `502` | Bad Gateway | Upstream provider error | Retry after a short delay | +| `503` | Service Unavailable | Service temporarily down | Retry after a short delay | + +### Mid-Stream Errors + +When streaming, errors can occur after the response has started. These appear as a chunk with `finish_reason: "error"`: + +```json +{ + "choices": [ + { + "finish_reason": "error", + "error": { + "code": "context_length_exceeded", + "message": "The input exceeds the model's maximum context length." + } + } + ] +} +``` + +Common mid-stream error codes: + +| Code | Meaning | +|------|---------| +| `context_length_exceeded` | Input tokens exceed the model's context window | +| `content_filter` | Content was blocked by a safety filter | +| `rate_limit` | Rate limit hit during generation | +| `server_error` | Upstream provider failed during generation | + + + Mid-stream errors do not produce an HTTP error code (the connection was already 200 OK). Always check `finish_reason` in your streaming handler. + + +## Retry Strategies + +### Exponential Backoff + +For transient errors (429, 500, 502, 503), retry with exponential backoff: + +```python +import time +import requests + +def call_api_with_retry(payload, max_retries=3): + for attempt in range(max_retries): + response = requests.post( + "https://api.cline.bot/api/v1/chat/completions", + headers={ + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json", + }, + json=payload, + ) + + if response.status_code == 200: + return response.json() + + if response.status_code in (429, 500, 502, 503): + delay = (2 ** attempt) + 1 + print(f"Retrying in {delay}s (attempt {attempt + 1}/{max_retries})") + time.sleep(delay) + continue + + # Non-retryable error + response.raise_for_status() + + raise Exception("Max retries exceeded") +``` + +### When to Retry + +| Error | Retry? | Strategy | +|-------|--------|----------| +| `401 Unauthorized` | No | Fix your API key | +| `402 Payment Required` | No | Add credits | +| `429 Too Many Requests` | Yes | Exponential backoff (start at 1s) | +| `500 Internal Server Error` | Yes | Retry once after 1s | +| `502 Bad Gateway` | Yes | Retry up to 3 times with backoff | +| `503 Service Unavailable` | Yes | Retry up to 3 times with backoff | +| Mid-stream `error` | Depends | Retry the full request for transient errors | + +### Rate Limits + +If you hit rate limits frequently: + +- Add delays between requests +- Reduce the number of concurrent requests +- Contact support if you need higher limits + +## Debugging + +When reporting issues, include: + +1. The **error code and message** from the response +2. The **model ID** you were using +3. The **request ID** (from the `x-request-id` response header, if available) +4. Whether the error was **immediate** (HTTP error) or **mid-stream** (finish_reason error) + +## Related + + + + Endpoint reference with request and response schemas. + + + Verify your API key is configured correctly. + + diff --git a/docs/api/getting-started.mdx b/docs/api/getting-started.mdx new file mode 100644 index 00000000000..9f37ea2d9fc --- /dev/null +++ b/docs/api/getting-started.mdx @@ -0,0 +1,136 @@ +--- +title: "Getting Started" +sidebarTitle: "Getting Started" +description: "Create an API key and make your first request to the Cline API in under a minute." +--- + +This guide walks you through creating an API key and making your first Chat Completions request. + +## Prerequisites + +- A Cline account at [app.cline.bot](https://app.cline.bot) +- `curl` or any HTTP client (Python, Node.js, etc.) + +## Create an API Key + + + + Go to [app.cline.bot](https://app.cline.bot) and sign in with your account. + + + Open **Settings** and select **API Keys**. + + + Click **Create API Key**. Copy the key immediately. You will not be able to see it again after leaving this page. + + + + + Treat your API key like a password. Do not commit it to version control or share it publicly. + + +## Make Your First Request + +Replace `YOUR_API_KEY` with the key you just created: + +```bash +curl -X POST https://api.cline.bot/api/v1/chat/completions \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "anthropic/claude-sonnet-4-6", + "messages": [ + {"role": "user", "content": "What is the capital of France?"} + ], + "stream": false + }' +``` + +## Verify the Response + +You should get a JSON response like this: + +```json +{ + "id": "gen-abc123", + "model": "anthropic/claude-sonnet-4-6", + "choices": [ + { + "message": { + "role": "assistant", + "content": "The capital of France is Paris." + }, + "finish_reason": "stop", + "index": 0 + } + ], + "usage": { + "prompt_tokens": 14, + "completion_tokens": 8 + } +} +``` + +The `choices[0].message.content` field contains the model's reply. The `usage` field shows how many tokens were consumed. + +## Try Streaming + +For real-time output, set `stream: true`. The response arrives as Server-Sent Events: + +```bash +curl -X POST https://api.cline.bot/api/v1/chat/completions \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "anthropic/claude-sonnet-4-6", + "messages": [ + {"role": "user", "content": "Write a haiku about programming."} + ], + "stream": true + }' +``` + +Each chunk arrives as a `data:` line. The stream ends with `data: [DONE]`. + +## Try a Free Model + +To test without spending credits, use one of the [free models](/api/models#free-models): + +```bash +curl -X POST https://api.cline.bot/api/v1/chat/completions \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "minimax/minimax-m2.5", + "messages": [ + {"role": "user", "content": "Hello! What can you help me with?"} + ], + "stream": false + }' +``` + +## Troubleshooting + +| Problem | Solution | +|---------|----------| +| `401 Unauthorized` | Check that your API key is correct and included in the `Authorization` header | +| `402 Payment Required` | Your account has insufficient credits. Add credits at [app.cline.bot](https://app.cline.bot) | +| Empty response | Make sure `messages` is a non-empty array with at least one user message | +| Connection timeout | Verify your network can reach `api.cline.bot`. Check proxy settings if on a corporate network | + +## Next Steps + + + + Learn about API keys, token scoping, and security practices. + + + Full endpoint reference with all parameters and options. + + + Browse available models and find the right one for your use case. + + + Use the API from Python, Node.js, or the Cline CLI. + + diff --git a/docs/api/models.mdx b/docs/api/models.mdx new file mode 100644 index 00000000000..696e56235a6 --- /dev/null +++ b/docs/api/models.mdx @@ -0,0 +1,114 @@ +--- +title: "Models" +sidebarTitle: "Models" +description: "Available models, pricing tiers, free models, and how model IDs work in the Cline API." +--- + +The Cline API gives you access to models from multiple providers through a single endpoint. Model IDs follow the `provider/model-name` format, the same convention used by [OpenRouter](https://openrouter.ai). + +## Model ID Format + +Every model is identified by a string in the format: + +``` +provider/model-name +``` + +For example: +- `anthropic/claude-sonnet-4-6` - Claude Sonnet 4.6 from Anthropic +- `openai/gpt-4o` - GPT-4o from OpenAI +- `google/gemini-2.5-pro` - Gemini 2.5 Pro from Google + +Pass this string as the `model` parameter in your [Chat Completions](/api/chat-completions) request. + +## Popular Models + +| Model ID | Provider | Context Window | Reasoning | Best For | +|----------|----------|---------------|-----------|----------| +| `anthropic/claude-sonnet-4-6` | Anthropic | 200K | Yes | General coding, analysis, complex tasks | +| `anthropic/claude-sonnet-4-5` | Anthropic | 200K | Yes | Balanced performance and cost | +| `openai/gpt-4o` | OpenAI | 128K | No | Multimodal tasks, fast responses | +| `google/gemini-2.5-pro` | Google | 1M | Yes | Very long context, document analysis | +| `deepseek/deepseek-chat` | DeepSeek | 64K | No | Cost-effective coding tasks | +| `x-ai/grok-3` | xAI | 128K | Yes | Reasoning-heavy tasks | + + + Model availability and pricing change over time. Check [app.cline.bot](https://app.cline.bot) for the latest catalog. + + +## Free Models + +These models are available at no cost. They are a good starting point for experimentation and lightweight tasks: + +| Model ID | Provider | Context Window | +|----------|----------|---------------| +| `minimax/minimax-m2.5` | MiniMax | 1M | +| `kwaipilot/kat-coder-pro` | Kwaipilot | 32K | +| `z-ai/glm-5` | Z-AI | 128K | + +Free models have the same API interface as paid models. Just use their model ID: + +```bash +curl -X POST https://api.cline.bot/api/v1/chat/completions \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "minimax/minimax-m2.5", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +## Reasoning Models + +Some models support extended thinking, where the model reasons through a problem before responding. When using these models: + +- Reasoning content appears in `delta.reasoning` during streaming +- Some providers return encrypted reasoning blocks in `delta.reasoning_details` +- Reasoning tokens are counted separately from output tokens + +Models with reasoning support include most Claude, Gemini 2.5, and Grok 3 models. Check the model's `supportsReasoning` capability in the model catalog. + +## Choosing a Model + +| If you need... | Consider | +|----------------|----------| +| Best coding performance | `anthropic/claude-sonnet-4-6` | +| Long document analysis | `google/gemini-2.5-pro` (1M context) | +| Fast, cheap responses | `deepseek/deepseek-chat` | +| Free experimentation | `minimax/minimax-m2.5` | +| Multi-modal (text + images) | `openai/gpt-4o` or `anthropic/claude-sonnet-4-6` | +| Complex reasoning | Any model with reasoning support | + +For a deeper comparison of model capabilities and pricing, see the [Model Selection Guide](/core-features/model-selection-guide). + +## Image Support + +Models that support images accept base64-encoded image content in the `messages` array: + +```json +{ + "model": "anthropic/claude-sonnet-4-6", + "messages": [ + { + "role": "user", + "content": [ + {"type": "text", "text": "What's in this image?"}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,..."}} + ] + } + ] +} +``` + +Not all models support images. Check the model's `supportsImages` capability before sending image content. + +## Related + + + + Use these models in your API requests. + + + In-depth comparison for choosing the right model. + + diff --git a/docs/api/overview.mdx b/docs/api/overview.mdx new file mode 100644 index 00000000000..acda5a0fbf4 --- /dev/null +++ b/docs/api/overview.mdx @@ -0,0 +1,58 @@ +--- +title: "Cline API" +sidebarTitle: "Overview" +description: "Programmatic access to AI models through an OpenAI-compatible Chat Completions API." +--- + +Welcome to the Cline API documentation. Use the same models that power the Cline extension and CLI from any language, framework, or tool that speaks the OpenAI format. + +## What is the Cline API? + +The Cline API is an OpenAI-compatible Chat Completions endpoint. You authenticate once with a Cline API key and get access to models from Anthropic, OpenAI, Google, and more through a single base URL. No need to manage separate keys for each provider. + +``` +Your App → Cline API (api.cline.bot) → Anthropic / OpenAI / Google / etc. +``` + + + + Create an API key and make your first request in under a minute. + + + API keys, account tokens, key rotation, and security best practices. + + + Full endpoint reference with request schemas, streaming, and tool calling. + + + Ready-to-copy examples for Python, Node.js, curl, and the Cline CLI. + + + +## Explore the Reference + + + + Browse available models, free tier options, reasoning support, and selection guidance. + + + Error codes, mid-stream errors, retry strategies, and debugging tips. + + + Admin endpoints for managing users, organizations, billing, and API keys. + + + +## Quick Start + +```bash +curl -X POST https://api.cline.bot/api/v1/chat/completions \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "anthropic/claude-sonnet-4-6", + "messages": [{"role": "user", "content": "Hello!"}] + }' +``` + +Get your API key at [app.cline.bot](https://app.cline.bot) (Settings > API Keys), then follow the [Getting Started](/api/getting-started) guide. diff --git a/docs/api/reference.mdx b/docs/api/reference.mdx new file mode 100644 index 00000000000..b6300956603 --- /dev/null +++ b/docs/api/reference.mdx @@ -0,0 +1,257 @@ +--- +title: "Cline API Reference" +sidebarTitle: "API Reference" +description: "Reference for the Cline Chat Completions API, an OpenAI-compatible endpoint for programmatic access." +--- + +The Cline API provides an OpenAI-compatible Chat Completions endpoint. You can use it from the Cline extension, the CLI, or any HTTP client that speaks the OpenAI format. + +## Base URL + +``` +https://api.cline.bot/api/v1 +``` + +## Authentication + +All requests require a Bearer token in the `Authorization` header. You can use either: + +- **API key** created at [app.cline.bot](https://app.cline.bot) (Settings > API Keys) +- **Account auth token** (used automatically by the Cline extension and CLI when you sign in) + +```bash +Authorization: Bearer YOUR_API_KEY +``` + +### Getting an API Key + + + + Open [app.cline.bot](https://app.cline.bot) and sign in. + + + Navigate to **Settings**, then **API Keys**. + + + Create a new key and copy it. Store it securely. You will not be able to see it again. + + + +## Chat Completions + +Create a chat completion with streaming support. This endpoint follows the [OpenAI Chat Completions](https://platform.openai.com/docs/api-reference/chat/create) format. + +### Request + +``` +POST /chat/completions +``` + +**Headers:** + +| Header | Required | Description | +|--------|----------|-------------| +| `Authorization` | Yes | `Bearer YOUR_API_KEY` | +| `Content-Type` | Yes | `application/json` | +| `HTTP-Referer` | No | Your application URL | +| `X-Title` | No | Your application name | + +**Body parameters:** + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `model` | string | Yes | Model ID in `provider/model` format (e.g., `anthropic/claude-sonnet-4-6`) | +| `messages` | array | Yes | Array of message objects with `role` and `content` | +| `stream` | boolean | No | Enable SSE streaming (default: `true`) | +| `tools` | array | No | Tool definitions in OpenAI function calling format | +| `temperature` | number | No | Sampling temperature | + +### Example Request + +```bash +curl -X POST https://api.cline.bot/api/v1/chat/completions \ + -H "Authorization: Bearer YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "anthropic/claude-sonnet-4-6", + "messages": [ + {"role": "system", "content": "You are a helpful assistant."}, + {"role": "user", "content": "Explain what a context window is in 2 sentences."} + ], + "stream": true + }' +``` + +### Response (Streaming) + +When `stream: true`, the response is a series of [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-Sent_Events). Each event contains a JSON chunk: + +```json +data: {"id":"gen-abc123","choices":[{"delta":{"content":"A context"},"index":0}],"model":"anthropic/claude-sonnet-4-6"} + +data: {"id":"gen-abc123","choices":[{"delta":{"content":" window is"},"index":0}],"model":"anthropic/claude-sonnet-4-6"} + +data: [DONE] +``` + +The final chunk includes a `usage` object with token counts and cost: + +```json +{ + "usage": { + "prompt_tokens": 25, + "completion_tokens": 42, + "prompt_tokens_details": { + "cached_tokens": 0 + }, + "cost": 0.000315 + } +} +``` + +### Response (Non-Streaming) + +When `stream: false`, the response is a single JSON object: + +```json +{ + "id": "gen-abc123", + "model": "anthropic/claude-sonnet-4-6", + "choices": [ + { + "message": { + "role": "assistant", + "content": "A context window is the maximum amount of text..." + }, + "finish_reason": "stop", + "index": 0 + } + ], + "usage": { + "prompt_tokens": 25, + "completion_tokens": 42 + } +} +``` + +## Models + +Model IDs use the `provider/model-name` format, the same format used by [OpenRouter](https://openrouter.ai). Some examples: + +| Model ID | Description | +|----------|-------------| +| `anthropic/claude-sonnet-4-6` | Claude Sonnet 4.6 | +| `anthropic/claude-sonnet-4-5` | Claude Sonnet 4.5 | +| `google/gemini-2.5-pro` | Gemini 2.5 Pro | +| `openai/gpt-4o` | GPT-4o | + +### Free Models + +The following models are available at no cost: + +| Model ID | Provider | +|----------|----------| +| `minimax/minimax-m2.5` | MiniMax | +| `kwaipilot/kat-coder-pro` | Kwaipilot | +| `z-ai/glm-5` | Z-AI | + + + Model availability and pricing may change. Check [app.cline.bot](https://app.cline.bot) for the latest list. + + +## Error Handling + +Errors follow the OpenAI error format: + +```json +{ + "error": { + "code": 401, + "message": "Invalid API key", + "metadata": {} + } +} +``` + +Common error codes: + +| Code | Meaning | +|------|---------| +| `401` | Invalid or missing API key | +| `402` | Insufficient credits | +| `429` | Rate limit exceeded | +| `500` | Server error | +| `error` (finish_reason) | Mid-stream error from the upstream model provider | + +## Using with Cline + +The easiest way to use the Cline API is through the Cline extension or CLI, which handle authentication and streaming for you. + +### VS Code / JetBrains + +Select **Cline** as your provider in the model picker dropdown. Sign in with your Cline account and your API key is managed automatically. + +### Cline CLI + +Configure the CLI with your API key in one command: + +```bash +cline auth -p cline -k "YOUR_API_KEY" -m anthropic/claude-sonnet-4-6 +``` + +Then run tasks normally: + +```bash +cline "Write a one-line hello world in Python." +``` + +See the [CLI Reference](/cline-cli/cli-reference) for all available commands and options. + +## Using with Other Tools + +Because the Cline API is OpenAI-compatible, you can use it with any library or tool that supports custom OpenAI endpoints. + +### Python (OpenAI SDK) + +```python +from openai import OpenAI + +client = OpenAI( + base_url="https://api.cline.bot/api/v1", + api_key="YOUR_API_KEY", +) + +response = client.chat.completions.create( + model="anthropic/claude-sonnet-4-6", + messages=[{"role": "user", "content": "Hello!"}], +) +print(response.choices[0].message.content) +``` + +### Node.js (OpenAI SDK) + +```typescript +import OpenAI from "openai" + +const client = new OpenAI({ + baseURL: "https://api.cline.bot/api/v1", + apiKey: "YOUR_API_KEY", +}) + +const response = await client.chat.completions.create({ + model: "anthropic/claude-sonnet-4-6", + messages: [{ role: "user", content: "Hello!" }], +}) +console.log(response.choices[0].message.content) +``` + +## Related + + + + Full command reference for the Cline CLI, including auth setup. + + + Admin endpoints for user management, organizations, billing, and API keys. + + diff --git a/docs/api/sdk-examples.mdx b/docs/api/sdk-examples.mdx new file mode 100644 index 00000000000..5c130f70f0b --- /dev/null +++ b/docs/api/sdk-examples.mdx @@ -0,0 +1,275 @@ +--- +title: "SDK Examples" +sidebarTitle: "SDK Examples" +description: "Use the Cline API from Python, Node.js, curl, the Cline CLI, and the VS Code extension." +--- + +The Cline API is OpenAI-compatible, so any library or tool that works with OpenAI also works with the Cline API. Just change the base URL and API key. + +## curl + +### Non-Streaming + +```bash +curl -X POST https://api.cline.bot/api/v1/chat/completions \ + -H "Authorization: Bearer $CLINE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "anthropic/claude-sonnet-4-6", + "messages": [{"role": "user", "content": "What is 2+2?"}], + "stream": false + }' +``` + +### Streaming + +```bash +curl -X POST https://api.cline.bot/api/v1/chat/completions \ + -H "Authorization: Bearer $CLINE_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "anthropic/claude-sonnet-4-6", + "messages": [{"role": "user", "content": "Write a short poem about code."}], + "stream": true + }' +``` + +## Python + +### OpenAI SDK + +The [OpenAI Python SDK](https://github.com/openai/openai-python) works with the Cline API by setting `base_url`: + +```python +from openai import OpenAI + +client = OpenAI( + base_url="https://api.cline.bot/api/v1", + api_key="YOUR_API_KEY", +) + +# Non-streaming +response = client.chat.completions.create( + model="anthropic/claude-sonnet-4-6", + messages=[{"role": "user", "content": "Explain recursion in one sentence."}], +) +print(response.choices[0].message.content) +``` + +### Streaming in Python + +```python +from openai import OpenAI + +client = OpenAI( + base_url="https://api.cline.bot/api/v1", + api_key="YOUR_API_KEY", +) + +stream = client.chat.completions.create( + model="anthropic/claude-sonnet-4-6", + messages=[{"role": "user", "content": "Write a function to reverse a string in Python."}], + stream=True, +) + +for chunk in stream: + content = chunk.choices[0].delta.content + if content: + print(content, end="", flush=True) +print() +``` + +### Tool Calling in Python + +```python +from openai import OpenAI +import json + +client = OpenAI( + base_url="https://api.cline.bot/api/v1", + api_key="YOUR_API_KEY", +) + +tools = [ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather for a location", + "parameters": { + "type": "object", + "properties": { + "location": {"type": "string", "description": "City name"} + }, + "required": ["location"], + }, + }, + } +] + +response = client.chat.completions.create( + model="anthropic/claude-sonnet-4-6", + messages=[{"role": "user", "content": "What's the weather in Tokyo?"}], + tools=tools, +) + +# Check if the model wants to call a tool +choice = response.choices[0] +if choice.message.tool_calls: + tool_call = choice.message.tool_calls[0] + print(f"Tool: {tool_call.function.name}") + print(f"Args: {tool_call.function.arguments}") +``` + +### Using requests + +If you prefer not to use the OpenAI SDK: + +```python +import requests + +response = requests.post( + "https://api.cline.bot/api/v1/chat/completions", + headers={ + "Authorization": "Bearer YOUR_API_KEY", + "Content-Type": "application/json", + }, + json={ + "model": "anthropic/claude-sonnet-4-6", + "messages": [{"role": "user", "content": "Hello!"}], + "stream": False, + }, +) + +data = response.json() +print(data["choices"][0]["message"]["content"]) +``` + +## Node.js / TypeScript + +### OpenAI SDK + +The [OpenAI Node.js SDK](https://github.com/openai/openai-node) works with the Cline API by setting `baseURL`: + +```typescript +import OpenAI from "openai" + +const client = new OpenAI({ + baseURL: "https://api.cline.bot/api/v1", + apiKey: "YOUR_API_KEY", +}) + +// Non-streaming +const response = await client.chat.completions.create({ + model: "anthropic/claude-sonnet-4-6", + messages: [{ role: "user", content: "Explain async/await in one sentence." }], +}) +console.log(response.choices[0].message.content) +``` + +### Streaming in Node.js + +```typescript +import OpenAI from "openai" + +const client = new OpenAI({ + baseURL: "https://api.cline.bot/api/v1", + apiKey: "YOUR_API_KEY", +}) + +const stream = await client.chat.completions.create({ + model: "anthropic/claude-sonnet-4-6", + messages: [{ role: "user", content: "Write a haiku about TypeScript." }], + stream: true, +}) + +for await (const chunk of stream) { + const content = chunk.choices[0]?.delta?.content + if (content) { + process.stdout.write(content) + } +} +console.log() +``` + +### Using fetch + +```typescript +const response = await fetch("https://api.cline.bot/api/v1/chat/completions", { + method: "POST", + headers: { + Authorization: "Bearer YOUR_API_KEY", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "anthropic/claude-sonnet-4-6", + messages: [{ role: "user", content: "Hello!" }], + stream: false, + }), +}) + +const data = await response.json() +console.log(data.choices[0].message.content) +``` + +## Cline CLI + +The [Cline CLI](/cline-cli/cli-reference) is the fastest way to use the Cline API from your terminal. It handles authentication, streaming, and tool execution for you. + +### Setup + +```bash +# Install +npm install -g @anthropic-ai/cline + +# Authenticate with a Cline API key +cline auth -p cline -k "YOUR_API_KEY" -m anthropic/claude-sonnet-4-6 +``` + +### Run Tasks + +```bash +# Simple prompt +cline "Explain what a REST API is." + +# Pipe input +cat README.md | cline "Summarize this document." + +# Use a specific model +cline -m google/gemini-2.5-pro "Analyze this codebase." + +# YOLO mode for automation +cline -y "Run tests and fix failures." +``` + +See the [CLI Reference](/cline-cli/cli-reference) for all commands and options. + +## VS Code / JetBrains + +The Cline extension handles the API integration for you: + +1. Open the Cline panel in your editor +2. Select **Cline** as the provider in the model picker +3. Sign in with your Cline account +4. Start chatting or give Cline a task + +Your API key is managed automatically. No manual configuration needed. + +For setup instructions, see [Installing Cline](/getting-started/installing-cline) and [Authorizing with Cline](/getting-started/authorizing-with-cline). + +## Related + + + + Full endpoint reference with all parameters. + + + API key management and security practices. + + + Browse available models. + + + Complete Cline CLI command reference. + + diff --git a/docs/assets/jupyter-explain-improve-cell.gif b/docs/assets/jupyter-explain-improve-cell.gif new file mode 100644 index 00000000000..01d298b74f9 Binary files /dev/null and b/docs/assets/jupyter-explain-improve-cell.gif differ diff --git a/docs/assets/jupyter-generate-cell.gif b/docs/assets/jupyter-generate-cell.gif new file mode 100644 index 00000000000..8245f561753 Binary files /dev/null and b/docs/assets/jupyter-generate-cell.gif differ diff --git a/docs/cline-cli/cli-reference.mdx b/docs/cline-cli/cli-reference.mdx index d1b347ebf5f..dd4c6ab61a4 100644 --- a/docs/cline-cli/cli-reference.mdx +++ b/docs/cline-cli/cli-reference.mdx @@ -63,6 +63,9 @@ cline # Start a task directly cline "your prompt here" + +# Resume the latest task for the current directory +cline --continue ``` **Options:** @@ -77,6 +80,7 @@ cline "your prompt here" | `--thinking` | Enable extended thinking with a 1024 token budget. | | `--json` | Output messages as JSON (one object per line). Forces plain text mode. | | `--timeout ` | Maximum execution time before the task is stopped. | +| `--continue` | Resume the most recent task from the current working directory. | **Mode Behavior:** diff --git a/docs/cline-cli/configuration.mdx b/docs/cline-cli/configuration.mdx index 07bf0aa6674..57c0b1c9571 100644 --- a/docs/cline-cli/configuration.mdx +++ b/docs/cline-cli/configuration.mdx @@ -73,6 +73,8 @@ Cline stores configuration in `~/.cline/data/`: ├── data/ # Configuration directory │ ├── globalState.json # Global settings │ ├── secrets.json # API keys (encrypted) +│ ├── settings/ # Settings files +│ │ └── cline_mcp_settings.json # MCP server configuration │ ├── workspace/ # Workspace-specific state │ └── tasks/ # Task history and data └── log/ # Log files @@ -172,6 +174,56 @@ cline --config ~/.cline-work "review this PR" cline --config ~/.cline-personal "help me with this side project" ``` +## MCP Server Configuration + +Cline CLI supports [MCP (Model Context Protocol)](/mcp/mcp-overview) servers, giving you access to external tools and data sources directly from the terminal. The CLI uses the same MCP configuration format as the VS Code extension. + +### Setting Up MCP Servers + +You can add MCP servers from the CLI: + +```bash +# STDIO server +cline mcp add kanban -- kanban mcp + +# Remote HTTP server +cline mcp add linear https://mcp.linear.app/mcp --type http +``` + +These commands update: + +``` +~/.cline/data/settings/cline_mcp_settings.json +``` + +You can still edit this file directly. It uses the same JSON format as the VS Code extension: + +```json +{ + "mcpServers": { + "my-server": { + "command": "node", + "args": ["/path/to/server.js"], + "env": { + "API_KEY": "your_api_key" + }, + "alwaysAllow": ["tool1", "tool2"], + "disabled": false + } + } +} +``` + +For the full configuration reference including STDIO and SSE transport types, see [Adding and Configuring MCP Servers](/mcp/adding-and-configuring-servers). + + +The CLI does not yet have a `/mcp` slash command for interactive management inside the terminal UI. Use `cline mcp add` or edit `cline_mcp_settings.json` directly. + + +### Custom Config Directory + +If you use the `CLINE_DIR` environment variable or `--config` flag, the MCP settings file will be located at `/data/settings/cline_mcp_settings.json` instead. + ## Configuration for Local Providers ### Ollama diff --git a/docs/cline-cli/overview.mdx b/docs/cline-cli/overview.mdx index 43f0fc78f9a..a789cf89723 100644 --- a/docs/cline-cli/overview.mdx +++ b/docs/cline-cli/overview.mdx @@ -207,6 +207,15 @@ Chains multiple Cline invocations together for creative multi-step workflows. | Session summary | ✓ | - | | JSON output | - | `--json` | | Piped input | - | ✓ | +| [MCP servers](/cline-cli/configuration#mcp-server-configuration) | ✓ | ✓ | + +## MCP Server Support + +Cline CLI supports [MCP (Model Context Protocol)](/mcp/mcp-overview) servers, the same extensibility system available in the VS Code extension. MCP servers give Cline access to external tools and data sources, from databases and APIs to browser automation and project management. + +To use MCP servers with the CLI, add your server configuration to `~/.cline/data/settings/cline_mcp_settings.json`. The format is identical to the VS Code extension. + +[Configure MCP servers for the CLI →](/cline-cli/configuration#mcp-server-configuration) ## Learn More diff --git a/docs/cline-sdk/overview.md b/docs/cline-sdk/overview.md new file mode 100644 index 00000000000..287ac7d82d1 --- /dev/null +++ b/docs/cline-sdk/overview.md @@ -0,0 +1,675 @@ +--- +title: "Cline SDK" +description: "Embed Cline as a programmable coding agent in your Node.js applications using an ACP-compatible TypeScript API." +--- + +# Cline SDK + +The Cline SDK lets you embed Cline as a programmable coding agent in your Node.js applications. It exposes the same capabilities as the Cline CLI and VS Code extension — file editing, command execution, browser use, MCP servers — through a TypeScript API that conforms to the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/protocol/schema). + +## Installation + +```bash +npm install cline +``` + +If you want direct ACP type imports as well: + +```bash +npm install @agentclientprotocol/sdk +``` + +Requires Node.js 20+. + +## Quick Start + +```typescript +import { ClineAgent } from "cline"; + +const CLINE_DIR = "/Users/username/.cline"; +const agent = new ClineAgent({ clineDir: CLINE_DIR }); + +// 1. Initialize — negotiates capabilities +const initializeResponse = await agent.initialize({ + protocolVersion: 1, + // these are the capabilities that the client (you) supports + // The cline agent may or may not use them, but it needs to know about them to make informed decisions about what tools to use. + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: true, + }, +}); + +const { agentInfo, authMethods } = initializeResponse; +console.log("Agent info:", agentInfo); // contains things like agent name and version +console.log("Auth methods:", authMethods); // contains a list of supported authentication methods. More auth methods coming soon + +// 2. Authenticate if needed +// If you skip this step, ClineAgent will look in CLINE_DIR for any existing credentials and authenticate with those +await agent.authenticate({ methodId: "cline-oauth" }); + +// 3. Create a session. +// A session represents a conversation or task with the agent. You can have multiple sessions for different tasks or conversations. +const { sessionId } = await agent.newSession({ + cwd: process.cwd(), + mcpServers: [], // mcpServers field not supported yet, but exposed here to maintain conformance with acp protocol +}); + +// 4. Agent updates are sent via events. You can subscribe to these events to get real-time updates on the agent's progress, tool calls, and more. +const emitter = agent.emitterForSession(sessionId); + +emitter.on("agent_message_chunk", (payload) => { + process.stdout.write( + payload.content.type === "text" + ? payload.content.text + : `[${payload.content.type}]`, + ); +}); +emitter.on("agent_thought_chunk", (payload) => { + process.stdout.write( + payload.content.type === "text" + ? payload.content.text + : `[${payload.content.type}]`, + ); +}); +emitter.on("tool_call", (payload) => { + console.log(`[tool] ${payload.title}`); +}); +emitter.on("error", (err) => { + console.error("[session error]", err); +}); + +// 5. Send a prompt and wait for completion +const { stopReason } = await agent.prompt({ + sessionId, + prompt: [{ type: "text", text: "Create a hello world Express server" }], +}); + +console.log("Done:", stopReason); + +// 6. Clean up +await agent.shutdown(); + +``` + +## Core Concepts + +### Agent Lifecycle + +The SDK follows the ACP lifecycle: + +``` +initialize() → authenticate() → newSession() → prompt() ⇄ events → shutdown() +``` + +| Step | Method | Purpose | +|------|--------|---------| +| Init | `initialize()` | Exchange protocol version and capabilities | +| Auth | `authenticate()` | OAuth flow for Cline or OpenAI Codex accounts. Optional step if cline config directory already has credentials | +| Session | `newSession()` | Create an isolated conversation context | +| Prompt | `prompt()` | Send user messages; blocks until the turn ends | +| Cancel | `cancel()` | Abort an in-progress prompt turn | +| Mode | `setSessionMode()` | Switch between `"plan"` and `"act"` modes | +| Model | `unstable_setSessionModel()` | Change the backing LLM (experimental) | +| Shutdown | `shutdown()` | Abort all tasks, flush state, release resources | + +### Sessions + +A session is an independent conversation with its own task history and working directory. You can run multiple sessions concurrently. + +```typescript +const { sessionId, modes, models } = await agent.newSession({ + cwd: "/path/to/project", + mcpServers: [], // mcpServers field not supported yet, but exposed here to maintain conformance with acp protocol +}) +``` + +The response includes: +- `sessionId` — use this in all subsequent calls +- `modes` — available modes (`plan`, `act`) and the current mode +- `models` — available models and the current model ID + +Access session metadata via the read-only `sessions` map: + +```typescript +const session = agent.sessions.get(sessionId) +// { sessionId, cwd, mode, mcpServers, createdAt, lastActivityAt, ... } +``` + +### Prompting + +`prompt()` sends a user message and blocks until the agent finishes its turn. While the prompt is processing, the agent streams output via session events. + +```typescript +const response = await agent.prompt({ + sessionId, + prompt: [ + { type: "text", text: "Refactor the auth module to use JWT" }, + ], +}) +``` + +The prompt array accepts multiple content blocks: + +```typescript +// Text + image + file context +await agent.prompt({ + sessionId, + prompt: [ + { type: "text", text: "What's in this screenshot?" }, + { type: "image", data: base64ImageData, mimeType: "image/png" }, + { + type: "resource", + resource: { + uri: "file:///path/to/relevant-file.ts", + mimeType: "text/plain", + text: fileContents, + }, + }, + ], +}) +``` + +#### Content Block Types + +| Type | Fields | Description | +|------|--------|-------------| +| `TextContent` | `{ type: "text", text: string }` | Plain text message | +| `ImageContent` | `{ type: "image", mimeType: string, data: string }` | Base64-encoded image | +| `EmbeddedResource` | `{ type: "resource", resource: { uri: string, mimeType?: string, text?: string, blob?: string } }` | File or resource context | + +#### Stop Reasons + +`prompt()` resolves with a `stopReason`: + +| Value | Meaning | +|-------|---------| +| `"end_turn"` | Agent finished normally (completed task or waiting for user input) | +| `"error"` | An error occurred | + +### Streaming Events + +Subscribe to real-time output via `ClineSessionEmitter`. Each session has its own emitter. + +```typescript +const emitter = agent.emitterForSession(sessionId) +``` + +#### Event Types + +All events correspond to [ACP `SessionUpdate` types](https://agentclientprotocol.com/protocol/schema#SessionUpdate): + +| Event | Payload | Description | +|-------|---------|-------------| +| `agent_message_chunk` | `{ content: ContentBlock }` | Streamed text from the agent | +| `agent_thought_chunk` | `{ content: ContentBlock }` | Internal reasoning / chain-of-thought | +| `tool_call` | `ToolCall` | New tool invocation (file edit, command, etc.) | +| `tool_call_update` | `ToolCallUpdate` | Progress/result update for an existing tool call | +| `plan` | `{ entries: PlanEntry[] }` | Agent's execution plan | +| `available_commands_update` | `{ availableCommands: AvailableCommand[] }` | Slash commands the agent supports | +| `current_mode_update` | `{ currentModeId: string }` | Mode changed (plan/act) | +| `user_message_chunk` | `{ content: ContentBlock }` | User message chunks (for multi-turn) | +| `config_option_update` | `{ configOptions: SessionConfigOption[] }` | Configuration changed | +| `session_info_update` | Session metadata | Session metadata changed | +| `error` | `Error` | Session-level error (not an ACP update) | + +```typescript +emitter.on("agent_message_chunk", (payload) => { + // payload.content is a ContentBlock — usually { type: "text", text: "..." } + process.stdout.write(payload.content.text) +}) + +emitter.on("agent_thought_chunk", (payload) => { + console.log("[thinking]", payload.content.text) +}) + +emitter.on("tool_call", (payload) => { + console.log(`[${payload.kind}] ${payload.title} (${payload.status})`) +}) + +emitter.on("tool_call_update", (payload) => { + console.log(` → ${payload.toolCallId}: ${payload.status}`) +}) + +emitter.on("error", (err) => { + console.error("Session error:", err) +}) +``` + +The emitter supports `on`, `once`, `off`, and `removeAllListeners`. + +### Permission Handling + +When the agent wants to execute a tool (edit a file, run a command, etc.), it requests permission. You **must** set a permission handler or all tool calls will be auto-rejected. + +```typescript +agent.setPermissionHandler(async (request) => { + // request.toolCall — details about what the agent wants to do + // request.options — available choices (allow_once, reject_once, etc.) + + console.log(`Permission requested: ${request.toolCall.title}`) + console.log("Options:", request.options.map(o => `${o.optionId} (${o.kind})`)) + + // Auto-approve everything: + const allowOption = request.options.find(o => o.kind.includes("allow")) + if (allowOption) { + return { outcome: { outcome: "selected", optionId: allowOption.optionId } } + } else { + return { outcome: { outcome: "rejected" } } + } +}) +``` + +#### Permission Options + +Each permission request includes an array of `PermissionOption` objects: + +| `kind` | Meaning | +|--------|---------| +| `allow_once` | Approve this single operation | +| `allow_always` | Approve and remember for future operations | +| `reject_once` | Deny this single operation | +| `reject_always` | Deny and remember for future operations | + +**Important:** If no permission handler is set, all tool calls are rejected for safety. + +### Modes + +Cline supports two modes: + +- **`plan`** — The agent gathers information and creates a plan without executing actions +- **`act`** — The agent executes actions (file edits, commands, etc.) + +```typescript +// Switch to plan mode +await agent.setSessionMode({ sessionId, modeId: "plan" }) + +// Switch back to act mode +await agent.setSessionMode({ sessionId, modeId: "act" }) +``` + +The current mode is returned in `newSession()` + +### Model Selection + +Change the backing model with `unstable_setSessionModel()`. The model ID format is `"provider/modelId"`. + +```typescript +await agent.unstable_setSessionModel({ + sessionId, + modelId: "anthropic/claude-sonnet-4-20250514", +}) +``` + +This sets the model for both plan and act modes. Available providers include `anthropic`, `openai-native`, `gemini`, `bedrock`, `deepseek`, `mistral`, `groq`, `xai`, and others. Model Ids can be found in the NewSessionResponse object after calling `agent.newSession(..)` + +> **Note:** This API is experimental and may change. + +### Authentication + +The SDK supports two OAuth flows: + +```typescript +// Cline account (uses browser OAuth) +await agent.authenticate({ methodId: "cline-oauth" }) + +// OpenAI Codex / ChatGPT subscription +await agent.authenticate({ methodId: "openai-codex-oauth" }) +``` + +Both methods open a browser window for the OAuth flow and block until authentication completes (5-minute timeout for Cline OAuth). + +For BYO (bring-your-own) API key providers, configure the key through the cline config directory before creating a session. The `authenticate()` call is not needed for BYO providers. We plan to support more auth providers in the near future. + +### Cancellation + +Cancel an in-progress prompt turn: + +```typescript +await agent.cancel({ sessionId }) +``` + +## API Reference + +### Constructor + +```typescript +new ClineAgent(options: ClineAgentOptions) +``` + +```typescript +interface ClineAgentOptions { + /** Enable debug logging (default: false) */ + debug?: boolean + /** Custom Cline config directory (default: ~/.cline) */ + clineDir?: string +} +``` + +The `clineDir` option lets you isolate configuration and task history per-application: + +```typescript +const agent = new ClineAgent({ + clineDir: "/tmp/my-app-cline", +}) +``` + +### Methods + +#### `initialize(params): Promise` + +Initialize the agent and negotiate protocol capabilities. + +```typescript +const response = await agent.initialize({ + clientCapabilities: {}, + protocolVersion: 1, +}) + +// Response includes: +{ + protocolVersion: "0.9.0", + agentCapabilities: { + loadSession: true, + promptCapabilities: { image: true, audio: false, embeddedContext: true }, + mcpCapabilities: { http: true, sse: false } + }, + agentInfo: { name: "cline", version: "2.2.3" }, + authMethods: [ + { id: "cline-oauth", name: "Sign in with Cline", description: "..." }, + { id: "openai-codex-oauth", name: "Sign in with ChatGPT", description: "..." } + ] +} +``` + +#### `newSession(params): Promise` + +Create a new conversation session. + +```typescript +const session = await agent.newSession({ + cwd: "/path/to/project", + mcpServers: [ + { + type: "stdio", + name: "filesystem", + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"], + env: {}, + }, + ], +}) + +// Response includes: +{ + sessionId: "uuid-string", + modes: { + availableModes: [ + { id: "plan", name: "Plan", description: "Gather information and create a detailed plan" }, + { id: "act", name: "Act", description: "Execute actions to accomplish the task" } + ], + currentModeId: "act" + }, + models: { + currentModelId: "anthropic/claude-sonnet-4-5-20241022", + availableModels: [{ modelId: "anthropic/claude-3-5-sonnet-20241022", name: "..." }] + } +} +``` + +> **Note:** `newSession()` may throw an auth-required error if credentials are not configured yet. + +#### `prompt(params): Promise` + +Send a user prompt to the agent. This is the main method for interacting with Cline. Blocks until the agent finishes its turn. + +```typescript +const response = await agent.prompt({ + sessionId: session.sessionId, + prompt: [ + { type: "text", text: "Create a function that adds two numbers" }, + ], +}) + +// Response: { stopReason: "end_turn" | "max_tokens" | "cancelled" | "error" } +``` + +#### `cancel(params): Promise` + +Cancel an ongoing prompt operation. + +```typescript +await agent.cancel({ sessionId: session.sessionId }) +``` + +#### `setSessionMode(params): Promise` + +Switch between plan and act modes. + +```typescript +await agent.setSessionMode({ sessionId, modeId: "plan" }) +``` + +#### `unstable_setSessionModel(params): Promise` + +Change the model for the session. Model ID format depends on the inference provider. See NewSessionResponse object to get modelIds. + +```typescript +await agent.unstable_setSessionModel({ + sessionId, + modelId: "anthropic/claude-sonnet-4-20250514", +}) +``` + +#### `authenticate(params): Promise` + +Authenticate with a provider. Opens a browser window for OAuth flow. + +```typescript +await agent.authenticate({ methodId: "cline-oauth" }) +``` + +Current methodIds we support: + +| methodId | Description | +| -------------------- | ----------------------------- | +| `cline-oauth` | use cline inference provider | +| `openai-codex-oauth` | use your chatgpt subscription | +| more coming soon!... | | + +#### `shutdown(): Promise` + +Clean up all resources. Call this when done. + +```typescript +await agent.shutdown() +``` + +#### `setPermissionHandler(handler)` + +Set a callback to handle tool permission requests. + +```typescript +agent.setPermissionHandler((request, resolve) => { + resolve({ outcome: { outcome: "selected", optionId: "allow_once" } }) +}) +``` + +#### `emitterForSession(sessionId): ClineSessionEmitter` + +Get the typed event emitter for a session. + +```typescript +const emitter = agent.emitterForSession(session.sessionId) +``` + +#### `sessions` (read-only Map) + +Access active sessions: + +```typescript +for (const [sessionId, session] of agent.sessions) { + console.log(sessionId, session.cwd, session.mode) +} +``` + +## Full Example: Auto-Approve Agent + +```typescript +import { ClineAgent } from "cline"; + +async function runTask(taskPrompt: string, cwd: string) { + const agent = new ClineAgent({ clineDir: "/Users/maxpaulus/.cline" }); + + await agent.initialize({ + protocolVersion: 1, + clientCapabilities: {}, + }); + + const { sessionId } = await agent.newSession({ cwd, mcpServers: [] }); + + // Auto-approve all tool calls + agent.setPermissionHandler(async (request) => { + const allow = request.options.find((o) => o.kind === "allow_once"); + return { + outcome: allow + ? { outcome: "selected", optionId: allow.optionId } + : { outcome: "cancelled" }, + }; + }); + + // Collect output + const output: string[] = []; + const emitter = agent.emitterForSession(sessionId); + + emitter.on("agent_message_chunk", (p) => { + if (p.content.type === "text") output.push(p.content.text); + }); + + emitter.on("tool_call", (p) => { + console.log(`[tool] ${p.title}`); + }); + + const { stopReason } = await agent.prompt({ + sessionId, + prompt: [{ type: "text", text: taskPrompt }], + }); + + console.log("\n--- Agent Output ---"); + console.log(output.join("")); + console.log(`\nStop reason: ${stopReason}`); + + await agent.shutdown(); +} + +runTask("Create a README.md for this project", process.cwd()); +``` + +## Full Example: Interactive Permission Flow + +```typescript +import { ClineAgent, type PermissionHandler } from "cline"; +import * as readline from "readline"; + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, +}); +const ask = (q: string) => new Promise((res) => rl.question(q, res)); + +const interactivePermissions: PermissionHandler = async (request) => { + console.log(`\n⚠️ Permission: ${request.toolCall.title}`); + + for (const [i, opt] of request.options.entries()) { + console.log(` ${i + 1}. [${opt.kind}] ${opt.name}`); + } + + const choice = await ask("Choose (number): "); + const idx = parseInt(choice, 10) - 1; + const selected = request.options[idx]; + + if (selected) { + return { + outcome: { outcome: "selected", optionId: selected.optionId }, + }; + } else { + return { outcome: { outcome: "cancelled" } }; + } +}; + +async function main() { + const agent = new ClineAgent({}); + await agent.initialize({ protocolVersion: 1, clientCapabilities: {} }); + + const { sessionId } = await agent.newSession({ + cwd: process.cwd(), + mcpServers: [], + }); + + agent.setPermissionHandler(interactivePermissions); + + const emitter = agent.emitterForSession(sessionId); + emitter.on("agent_message_chunk", (p) => { + if (p.content.type === "text") process.stdout.write(p.content.text); + }); + + // Multi-turn conversation + while (true) { + const userInput = await ask("\n> "); + if (userInput === "exit") break; + + const { stopReason } = await agent.prompt({ + sessionId, + prompt: [{ type: "text", text: userInput }], + }); + + console.log(`\n[${stopReason}]`); + } + + await agent.shutdown(); + rl.close(); +} + +main(); + +``` + +## Exported Types + +All types are re-exported from the `cline` package. Key types: + +| Type | Description | +|------|-------------| +| `ClineAgent` | Main agent class | +| `ClineSessionEmitter` | Typed event emitter for session events | +| `ClineAgentOptions` | Constructor options | +| `ClineAcpSession` | Session metadata (read-only) | +| `ClineSessionEvents` | Event name → handler signature map | +| `PermissionHandler` | `(request, resolve) => void` callback | +| `PermissionResolver` | `(response) => void` callback | +| `SessionUpdate` | Union of all session update types | +| `SessionUpdateType` | Discriminator values (`"agent_message_chunk"`, `"tool_call"`, etc.) | +| `ToolCall` | Tool call details (id, title, kind, status, content) | +| `ToolCallUpdate` | Partial update to an existing tool call | +| `ToolCallStatus` | `"pending" \| "in_progress" \| "completed" \| "failed"` | +| `ToolKind` | `"read" \| "edit" \| "delete" \| "execute" \| "search" \| ...` | +| `StopReason` | `"end_turn" \| "cancelled" \| "error" \| "max_tokens" \| ...` | +| `ContentBlock` | `TextContent \| ImageContent \| AudioContent \| ...` | +| `McpServer` | MCP server configuration (stdio, http) | +| `PromptRequest` / `PromptResponse` | Prompt call types | +| `NewSessionRequest` / `NewSessionResponse` | Session creation types | +| `InitializeRequest` / `InitializeResponse` | Initialization types | + +See the [ACP Schema](https://agentclientprotocol.com/protocol/schema) for the full type definitions. + +## Relationship to ACP + +The Cline SDK implements the [Agent Client Protocol](https://agentclientprotocol.com) `Agent` interface. The key difference from a standard ACP stdio agent is that the SDK uses an **event emitter pattern** instead of a transport connection: + +| ACP Stdio (via `AcpAgent`) | SDK (via `ClineAgent`) | +|-----------------------------|------------------------| +| Session updates sent over JSON-RPC stdio | Session updates emitted via `ClineSessionEmitter` | +| Permissions requested via `connection.requestPermission()` | Permissions requested via `setPermissionHandler()` callback | +| Single process, single connection | Embeddable, multiple concurrent sessions | + +If you need stdio-based ACP communication (e.g., for IDE integration), use the `cline` CLI binary directly. The SDK is for embedding Cline in your own Node.js processes. diff --git a/docs/customization/hooks.mdx b/docs/customization/hooks.mdx index 06688d95cd8..fdc77935b90 100644 --- a/docs/customization/hooks.mdx +++ b/docs/customization/hooks.mdx @@ -143,16 +143,35 @@ echo '{"cancel":false}' - Save the script above as `~/Documents/Cline/Hooks/file-logger` (macOS/Linux) or create it through the Hooks UI. + Save the script above as `~/Documents/Cline/Hooks/file-logger` or create it through the Hooks UI. - Run `chmod +x ~/Documents/Cline/Hooks/file-logger` in your terminal. + On macOS/Linux, run `chmod +x ~/Documents/Cline/Hooks/file-logger`. - + In Cline's Hooks tab, find "file-logger" under PreToolUse hooks and toggle it on. + +On Windows, hooks are executed with PowerShell and run whenever the hook file exists. In this +foundation PR, hook enable/disable toggling is not yet supported on Windows. + + + +Coming next: JSON-backed hook enabled/disabled state across platforms, so toggle behavior is +consistent on Windows, macOS, and Linux. + + + +Hook filenames are platform-specific: + +- **Windows**: only `HookName.ps1` is supported (PowerShell script files) +- **macOS/Linux**: only extensionless `HookName` is supported (executable files like bash scripts or binaries) + +Wrong-platform naming is ignored by hook discovery. + + ### Test It Ask Cline to read any file in your project: "What's in package.json?" @@ -190,9 +209,15 @@ Every hook receives a JSON object with common fields plus hook-specific data: ```json { "taskId": "abc123", + "hookName": "PreToolUse", "clineVersion": "3.17.0", - "timestamp": 1736654400000, - "workspacePath": "/path/to/project", + "timestamp": "1736654400000", + "workspaceRoots": ["/path/to/project"], + "userId": "user_123", + "model": { + "provider": "openrouter", + "slug": "anthropic/claude-sonnet-4.5" + }, // Hook-specific field (name matches hook type in camelCase) "taskStart": { @@ -201,6 +226,17 @@ Every hook receives a JSON object with common fields plus hook-specific data: } ``` +`model.provider` and `model.slug` are machine-stable identifiers for the active provider/model at hook execution time. If unavailable, Cline sends deterministic fallback values: `"unknown"`. + + +Migration note for existing hook scripts: + +- `timestamp` is a string (milliseconds since epoch), not a number +- `workspaceRoots` is an array of workspace root paths and replaces the old singular `workspacePath` + +If your scripts previously read `.workspacePath`, switch to `.workspaceRoots[0]` (or iterate all roots). + + The hook-specific field name matches the hook type: - `taskStart`, `taskResume`, `taskCancel`, `taskComplete` contain `{ task: string }` - `preToolUse` contains `{ tool: string, parameters: object }` @@ -420,7 +456,7 @@ Inject project-specific information when a task begins: # TaskStart hook INPUT=$(cat) -WORKSPACE=$(echo "$INPUT" | jq -r '.workspacePath') +WORKSPACE=$(echo "$INPUT" | jq -r '.workspaceRoots[0] // empty') # Read project info if available if [[ -f "$WORKSPACE/.project-context" ]]; then @@ -444,14 +480,17 @@ cline config set hooks-enabled=true ``` -CLI hooks are only supported on macOS and Linux. +Windows hooks require PowerShell (`powershell.exe`) available on your PATH. ## Troubleshooting **Hook not running?** -- Check that the file is executable (`chmod +x hookname`) -- Verify the hook is enabled (toggle is on in the Hooks tab) +- On macOS/Linux, check that the file is executable (`chmod +x hookname`) +- On Windows, ensure PowerShell is available (`powershell -NoProfile -Command "$PSVersionTable.PSVersion"`) +- On Windows, ensure the hook file is named `.ps1` (for example `PreToolUse.ps1`) +- On macOS/Linux, ensure the hook file uses extensionless `` naming (for example `PreToolUse`) +- On macOS/Linux, verify the hook is enabled (toggle is on in the Hooks tab) - Check that Hooks are enabled globally in Settings **Hook output not parsed?** diff --git a/docs/docs.json b/docs/docs.json index 93ae58b0be7..ea7f177ccac 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -116,6 +116,7 @@ }, "cline-cli/configuration", "cline-cli/acp-editor-integrations", + "cline-sdk/overview", "cline-cli/cli-reference" ] }, @@ -286,7 +287,8 @@ { "group": "Control Other Cline Features", "pages": [ - "enterprise-solutions/configuration/infrastructure-configuration/control-other-cline-features/yolo-mode" + "enterprise-solutions/configuration/infrastructure-configuration/control-other-cline-features/yolo-mode", + "enterprise-solutions/configuration/infrastructure-configuration/control-other-cline-features/mcp-marketplace" ] }, { @@ -296,7 +298,36 @@ "enterprise-solutions/monitoring/telemetry", "enterprise-solutions/monitoring/opentelemetry" ] - } + }, + "enterprise-solutions/api-reference" + ] + } + ] + }, + { + "tab": "API", + "icon": "code", + "groups": [ + { + "group": "Cline API", + "pages": [ + "api/overview", + "api/getting-started", + "api/authentication" + ] + }, + { + "group": "Endpoints", + "pages": [ + "api/chat-completions" + ] + }, + { + "group": "Reference", + "pages": [ + "api/models", + "api/errors", + "api/sdk-examples" ] } ] @@ -606,6 +637,10 @@ { "source": "/features/skills", "destination": "/customization/skills" + }, + { + "source": "/api/reference", + "destination": "/api/overview" } ], "search": { diff --git a/docs/enterprise-solutions/api-reference.mdx b/docs/enterprise-solutions/api-reference.mdx new file mode 100644 index 00000000000..2aad193f417 --- /dev/null +++ b/docs/enterprise-solutions/api-reference.mdx @@ -0,0 +1,208 @@ +--- +title: "Enterprise API Reference" +sidebarTitle: "API Reference" +description: "REST API endpoints for managing users, organizations, billing, plans, and API keys." +--- + +The Enterprise API provides REST endpoints for account management, organization administration, billing, and API key management. These are separate from the [Chat Completions API](/api/reference), which handles model inference. + +## Base URL + +``` +https://api.cline.bot +``` + +## Authentication + +All endpoints require a Bearer token in the `Authorization` header: + +```bash +Authorization: Bearer YOUR_AUTH_TOKEN +``` + +Use the same API key or account auth token described in the [public API reference](/api/reference#authentication). + +## Quick Example + +```bash +# Get your user profile +curl https://api.cline.bot/api/v1/users/me \ + -H "Authorization: Bearer YOUR_AUTH_TOKEN" +``` + +```json +{ + "id": "user_abc123", + "email": "you@company.com", + "name": "Your Name", + "active_account_id": "org_xyz789" +} +``` + +--- + +## Users + +Manage user accounts, accept terms, check balances, view usage, and configure payment methods. + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/users/me` | Get current user profile | +| `PATCH` | `/api/v1/users/me` | Update current user profile | +| `DELETE` | `/api/v1/users/me` | Delete current user account | +| `POST` | `/api/v1/users/me/accept-terms` | Accept terms of service | +| `GET` | `/api/v1/users/me/remote-config` | Get remote configuration for the current user | +| `PUT` | `/api/v1/users/active-account` | Switch active account (personal or organization) | +| `GET` | `/api/v1/users/{id}/balance` | Get credit balance | +| `GET` | `/api/v1/users/{id}/usages` | Get usage history | + +### Payments and Credits + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/users/{id}/payments` | List payment history | +| `GET` | `/api/v1/users/{id}/payments/{paymentId}` | Get payment details | +| `GET` | `/api/v1/users/{id}/payments/{paymentId}/status` | Check payment status | +| `GET` | `/api/v1/users/{id}/payments/provider/{paymentId}` | Get provider-side payment details | +| `POST` | `/api/v1/users/credits/checkout` | Start a credit purchase checkout | +| `POST` | `/api/v1/users/{id}/credits/purchase` | Purchase credits directly | + +### Billing Configuration + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/users/{id}/auto-top-up` | Get auto top-up settings | +| `PUT` | `/api/v1/users/{id}/auto-top-up` | Configure auto top-up | +| `GET` | `/api/v1/users/{id}/payment-method/default` | Get default payment method | +| `POST` | `/api/v1/users/{id}/payment-method/setup-session` | Start payment method setup | +| `GET` | `/api/v1/users/{id}/promotions` | List active promotions | + +--- + +## Organizations + +Create and manage organizations. Organization admins can configure remote settings, manage members, and control billing. + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/v1/organizations` | Create a new organization | +| `GET` | `/api/v1/organizations/{id}` | Get organization details | +| `PUT` | `/api/v1/organizations/{id}` | Update organization settings | +| `DELETE` | `/api/v1/organizations/{id}` | Delete an organization | +| `GET` | `/api/v1/organizations/{id}/api-keys` | List organization API keys | +| `GET` | `/api/v1/organizations/{id}/remote-config` | Get remote config for the org | +| `GET` | `/api/v1/organizations/{orgId}/metrics` | Get organization usage metrics | + +--- + +## Organization Members + +Manage who has access to the organization and what role they hold. + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/organizations/{orgId}/members` | List all members | +| `DELETE` | `/api/v1/organizations/{orgId}/members` | Remove members | +| `GET` | `/api/v1/organizations/{orgId}/members/available-roles` | List assignable roles | +| `PUT` | `/api/v1/organizations/{orgId}/members/{memberId}/role` | Change a member's role | +| `GET` | `/api/v1/organizations/{orgId}/members/{memberId}/usages` | Get a member's usage | + + + For a walkthrough of member management in the UI, see [Managing Members](/enterprise-solutions/team-management/managing-members). + + +--- + +## Organization Invites + +Invite new members to join your organization. + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/organizations/{orgId}/invites` | List pending invites | +| `POST` | `/api/v1/organizations/{orgId}/invites` | Send new invites | +| `GET` | `/api/v1/organizations/{orgId}/invites/count` | Get invite count | +| `DELETE` | `/api/v1/organizations/{orgId}/invites/{inviteId}` | Revoke an invite | +| `POST` | `/api/v1/invites/accept` | Accept an invite (called by the invitee) | + +--- + +## Organization Balance and Payments + +Manage credits and payments at the organization level. These mirror the user-level payment endpoints but operate on the organization's account. + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/organizations/{orgId}/balance` | Get org credit balance | +| `GET` | `/api/v1/organizations/{orgId}/payments` | List payment history | +| `GET` | `/api/v1/organizations/{orgId}/payments/{paymentId}` | Get payment details | +| `GET` | `/api/v1/organizations/{orgId}/payments/{paymentId}/status` | Check payment status | +| `GET` | `/api/v1/organizations/{orgId}/payments/provider/{paymentId}` | Provider-side payment details | +| `POST` | `/api/v1/organizations/{orgId}/credits/checkout` | Start credit checkout | +| `POST` | `/api/v1/organizations/{orgId}/credits/purchase` | Purchase credits | +| `GET` | `/api/v1/organizations/{orgId}/auto-top-up` | Get auto top-up config | +| `PUT` | `/api/v1/organizations/{orgId}/auto-top-up` | Configure auto top-up | +| `GET` | `/api/v1/organizations/{orgId}/payment-method/default` | Get default payment method | +| `POST` | `/api/v1/organizations/{orgId}/payment-method/setup-session` | Start payment method setup | +| `GET` | `/api/v1/organizations/{id}/promotions` | List active promotions | + +--- + +## Organization Plans + +Subscribe to, upgrade, or cancel plans. Manage seat counts for your team. + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/plans` | List all available plans | +| `GET` | `/api/v1/organizations/{orgId}/plan` | Get current plan | +| `GET` | `/api/v1/organizations/{orgId}/plan/history` | View plan change history | +| `GET` | `/api/v1/organizations/{orgId}/plan/{planId}` | Get specific plan details | +| `POST` | `/api/v1/organizations/{orgId}/plan` | Subscribe to a plan | +| `PUT` | `/api/v1/organizations/{orgId}/plan/seats` | Update seat count | +| `DELETE` | `/api/v1/organizations/{orgId}/plan/{planId}` | Cancel a plan | + +--- + +## Organization Usage + +Track token consumption and costs across your organization. + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/organizations/{orgId}/usages` | Get aggregated usage data | + + + For dashboards and monitoring, see [Monitoring Overview](/enterprise-solutions/monitoring/overview) and [Telemetry](/enterprise-solutions/monitoring/telemetry). + + +--- + +## API Keys + +Create and manage API keys for programmatic access. Keys created here work with both the [Chat Completions API](/api/reference) and the endpoints on this page. + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/v1/api-keys` | List your API keys | +| `POST` | `/api/v1/api-keys` | Create a new API key | +| `DELETE` | `/api/v1/api-keys/{key_id}` | Delete an API key | + +--- + +## Related + + + + The public inference API for sending prompts and receiving completions. + + + Configure single sign-on for your organization. + + + Add, remove, and manage member roles in the UI. + + + Track usage, costs, and telemetry across your organization. + + diff --git a/docs/enterprise-solutions/configuration/infrastructure-configuration/control-other-cline-features/mcp-marketplace.mdx b/docs/enterprise-solutions/configuration/infrastructure-configuration/control-other-cline-features/mcp-marketplace.mdx new file mode 100644 index 00000000000..5feaa0aad05 --- /dev/null +++ b/docs/enterprise-solutions/configuration/infrastructure-configuration/control-other-cline-features/mcp-marketplace.mdx @@ -0,0 +1,298 @@ +--- +title: "MCP Marketplace" +sidebarTitle: "MCP Marketplace" +description: "Enterprise controls for MCP Marketplace access, server allowlisting, and remote MCP server management" +--- + +The MCP Marketplace lets developers discover and install MCP servers that extend Cline's capabilities. For Enterprise administrators, this page covers how to control marketplace access, restrict which servers are available, and push pre-configured MCP servers to your organization. + + +For complete details about the MCP Marketplace and how developers use it, see [MCP Made Easy](/mcp/mcp-marketplace). + + +## Overview + +Enterprise administrators have four configuration options to govern MCP server usage across their organization: + +| Setting | Purpose | +|---------|---------| +| `mcpMarketplaceEnabled` | Enable or disable the MCP Marketplace entirely | +| `allowedMCPServers` | Restrict the marketplace to only approved MCP servers | +| `remoteMCPServers` | Push pre-configured remote MCP servers to all users | +| `blockPersonalRemoteMCPServers` | Prevent users from adding their own remote MCP servers | + +These settings are applied through your organization's [remote configuration](/enterprise-solutions/configuration/remote-configuration/overview) and take effect immediately for all team members. + +## Disabling the MCP Marketplace + +To completely disable the MCP Marketplace for your organization, set `mcpMarketplaceEnabled` to `false`: + +```json +{ + "mcpMarketplaceEnabled": false +} +``` + +When `mcpMarketplaceEnabled` is set to `false`: +- The MCP Marketplace tab is hidden from all users +- Users cannot browse or install MCP servers from the marketplace +- Locally configured MCP servers are blocked +- Enterprise policy takes precedence over individual preferences + +When `mcpMarketplaceEnabled` is set to `true` or omitted: +- Users can freely browse and install MCP servers from the marketplace +- No organizational restrictions apply to marketplace access + + +Disabling the marketplace entirely also blocks locally configured MCP servers. If you want to allow specific servers while restricting others, use the allowlist approach described below instead. + + +## Restricting the Marketplace to Approved Servers + +Rather than disabling the marketplace entirely, you can restrict it to a curated list of approved MCP servers using the `allowedMCPServers` setting. This is the recommended approach for most enterprises — it lets developers benefit from MCP while ensuring only vetted servers are available. + +### Configuration + +Add an `allowedMCPServers` array to your remote configuration. Each entry requires an `id` field set to the server's GitHub repository path: + +```json +{ + "allowedMCPServers": [ + { "id": "github.com/modelcontextprotocol/server-filesystem" }, + { "id": "github.com/modelcontextprotocol/server-github" }, + { "id": "github.com/your-org/internal-mcp-server" } + ] +} +``` + +### How It Works + +When `allowedMCPServers` is configured: +- The marketplace catalog is filtered to show **only** the servers in your allowlist +- Users can browse, view details, and install any server on the list +- Servers not on the list are completely hidden from the marketplace +- The allowlist applies to all team members in the organization + +When `allowedMCPServers` is omitted or `undefined`: +- The full marketplace catalog is available with no restrictions + +When `allowedMCPServers` is set to an empty array (`[]`): +- The marketplace shows no servers — effectively disabling installation while keeping the UI visible + +### Finding Server IDs + +The `id` for each allowed server is its GitHub repository path (without the `https://` prefix). For example: + +| Server | ID | +|--------|----| +| Filesystem | `github.com/modelcontextprotocol/server-filesystem` | +| GitHub | `github.com/modelcontextprotocol/server-github` | +| Custom internal server | `github.com/your-org/your-mcp-server` | + +You can find the correct ID by checking the `githubUrl` field of any server in the [MCP Marketplace](/mcp/mcp-marketplace) and removing the `https://` prefix. + +## Pushing Pre-Configured Remote MCP Servers + +Use `remoteMCPServers` to push MCP servers directly to all users without requiring them to install anything from the marketplace. This is ideal for internal MCP servers or third-party servers that need specific configuration. + +### Configuration + +```json +{ + "remoteMCPServers": [ + { + "name": "Internal Code Search", + "url": "https://mcp.internal.yourcompany.com/code-search", + "alwaysEnabled": true, + "headers": { + "Authorization": "Bearer ${AUTH_TOKEN}" + } + }, + { + "name": "Documentation Server", + "url": "https://mcp.internal.yourcompany.com/docs", + "alwaysEnabled": false + } + ] +} +``` + +### Remote Server Options + +Each remote MCP server entry supports the following fields: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `name` | string | Yes | Display name for the server | +| `url` | string | Yes | The URL endpoint of the MCP server | +| `alwaysEnabled` | boolean | No | When `true`, users cannot disable this server | +| `headers` | object | No | Custom HTTP headers for authentication | + +### Always-Enabled Servers + +When `alwaysEnabled` is set to `true`: +- The server is automatically active for all users +- Users cannot toggle the server off +- The server appears in the user's MCP configuration but the disable control is locked +- This is useful for compliance, security, or internal tooling servers that must always be available + +## Blocking Personal Remote MCP Servers + +To prevent users from adding their own remote MCP servers, set `blockPersonalRemoteMCPServers` to `true`: + +```json +{ + "blockPersonalRemoteMCPServers": true +} +``` + +When `blockPersonalRemoteMCPServers` is `true`: +- Users cannot add or configure remote MCP servers on their own +- Only servers defined in the organization's `remoteMCPServers` configuration are available +- This ensures all remote MCP connections go through approved, organization-managed endpoints + +When `blockPersonalRemoteMCPServers` is `false` or omitted: +- Users can freely add their own remote MCP server connections + +## Combined Configuration Examples + +### Locked-Down Environment + +For organizations that need strict control over all MCP server access: + +```json +{ + "mcpMarketplaceEnabled": true, + "allowedMCPServers": [ + { "id": "github.com/modelcontextprotocol/server-filesystem" }, + { "id": "github.com/modelcontextprotocol/server-github" } + ], + "remoteMCPServers": [ + { + "name": "Internal API Gateway", + "url": "https://mcp.internal.yourcompany.com/gateway", + "alwaysEnabled": true, + "headers": { + "X-Api-Key": "org-managed-key" + } + } + ], + "blockPersonalRemoteMCPServers": true +} +``` + +This configuration: +- Allows the marketplace but limits it to two approved servers +- Pushes an always-enabled internal MCP server to all users +- Blocks users from adding their own remote MCP servers + +### Open Environment with Internal Servers + +For organizations that want flexibility with internal server access: + +```json +{ + "remoteMCPServers": [ + { + "name": "Company Knowledge Base", + "url": "https://mcp.yourcompany.com/kb", + "alwaysEnabled": true + } + ] +} +``` + +This configuration: +- Leaves the full marketplace open (no `allowedMCPServers` restriction) +- Ensures all developers have access to the company knowledge base +- Allows users to add their own remote MCP servers + +### Marketplace Disabled with Internal Servers Only + +For organizations that want to fully manage the MCP experience: + +```json +{ + "mcpMarketplaceEnabled": false, + "remoteMCPServers": [ + { + "name": "Approved Code Assistant", + "url": "https://mcp.internal.yourcompany.com/code-assist", + "alwaysEnabled": true + }, + { + "name": "Internal Docs Search", + "url": "https://mcp.internal.yourcompany.com/docs", + "alwaysEnabled": true + } + ], + "blockPersonalRemoteMCPServers": true +} +``` + +This configuration: +- Disables the marketplace completely +- Provides only organization-managed MCP servers +- Prevents users from adding any additional remote servers + +## Enterprise Policy Recommendations + +### Recommended Approach + +Most organizations should **use the allowlist** (`allowedMCPServers`) rather than disabling the marketplace entirely. This gives developers access to useful tools while ensuring security review of each server. + + + + Before adding an MCP server to your allowlist: + - Review the server's source code on GitHub + - Evaluate the server's permissions and data access patterns + - Check for active maintenance and security practices + - Assess whether the server's data handling meets your compliance requirements + - Test the server in a sandbox environment before approving + + + + For internal tooling, use `remoteMCPServers` with `alwaysEnabled: true`: + - Connect Cline to internal APIs, databases, and knowledge bases + - Ensure consistent access across all developers + - Manage authentication centrally through custom headers + - Use `blockPersonalRemoteMCPServers` to prevent shadow IT + + + + MCP servers can access external APIs and process data: + - Audit which servers handle sensitive data + - Ensure servers comply with your data residency requirements + - Document approved servers in your security policies + - Regularly review and update your allowlist + + + +### Recommendations by Organization Size + +#### Small Teams (5–20 developers) +- **Marketplace:** Open or lightly restricted with an allowlist +- **Remote Servers:** Push internal servers as needed +- **Personal Servers:** Allow with guidance +- **Review Cadence:** Quarterly allowlist review + +#### Medium Organizations (20–100 developers) +- **Marketplace:** Restricted to an approved allowlist +- **Remote Servers:** Push internal servers with `alwaysEnabled` +- **Personal Servers:** Consider blocking (`blockPersonalRemoteMCPServers: true`) +- **Review Cadence:** Monthly allowlist review + +#### Large Enterprises (100+ developers) +- **Marketplace:** Strictly restricted to a vetted allowlist +- **Remote Servers:** All MCP access through organization-managed servers +- **Personal Servers:** Blocked (`blockPersonalRemoteMCPServers: true`) +- **Review Cadence:** Formal approval process for new servers with security review + +## Support & Questions + +For help configuring MCP Marketplace policies: +- Review [Remote Configuration Overview](/enterprise-solutions/configuration/remote-configuration/overview) +- See [MCP Made Easy](/mcp/mcp-marketplace) for marketplace functionality details +- See [MCP Overview](/mcp/mcp-overview) for general MCP concepts +- Contact your Enterprise support representative +- Join our [Discord](https://discord.gg/cline) for community discussion diff --git a/docs/enterprise-solutions/sso-setup.mdx b/docs/enterprise-solutions/sso-setup.mdx new file mode 100644 index 00000000000..1eac9d581cb --- /dev/null +++ b/docs/enterprise-solutions/sso-setup.mdx @@ -0,0 +1,75 @@ +--- +title: "SSO Setup" +sidebarTitle: "SSO Setup" +description: "Configure Single Sign-On (SSO) for Cline Enterprise via WorkOS AuthKit." +--- + +## Overview +Cline Enterprise integrates with your identity provider (IdP) via **WorkOS AuthKit** for SSO. + +This page describes, at a high level, how SSO is set up for Cline Enterprise using WorkOS AuthKit. + +If you haven't completed initial onboarding, start with [Onboarding](/enterprise-solutions/onboarding). + +### Video Walkthrough + + +