Compare commits

...

67 Commits

Author SHA1 Message Date
Valquaint 570003eaa6 Merge branch 'cli-windows' of github.com:cline/cline into cli-windows 2026-01-19 10:43:37 -08:00
Valquaint d4733706d8 Fix typo in install path 2026-01-19 10:40:38 -08:00
Valquaint a727d5c347 Updated branch to be in line with main. Added new install script, initial testing. 2026-01-19 10:40:38 -08:00
Valquaint ae306adc42 Updated run-unix and w32 to fix pathing. Updated install-local. 2026-01-19 10:40:37 -08:00
Max Paulus 🥪 2f3002d215 fix build-proto escaping 2026-01-19 10:40:37 -08:00
Max Paulus 🥪 41404e1a13 create one large typescript build file 2026-01-19 10:40:34 -08:00
Valquaint d6244efda1 CLI Updates. Added additional platform support. Needs additional debugging. 2026-01-19 10:34:55 -08:00
Valquaint be410c2927 Updated run-unix and w32 to fix pathing. Updated install-local. 2026-01-19 10:30:20 -08:00
Max Paulus 🥪 6c67a0c8fc fix build-proto escaping 2026-01-19 10:29:21 -08:00
Max Paulus 🥪 31e3c082ec create one large typescript build file 2026-01-19 10:29:17 -08:00
Valquaint a75f4a63c8 CLI Updates. Added additional platform support. Needs additional debugging. 2026-01-19 10:23:21 -08:00
Valquaint 0b059cf36f Fix typo in install path 2026-01-19 10:17:52 -08:00
Robin Newhouse 5d7f0f04d3 feat(cli): add --version flag support (#8690)
* feat(cli): add version to root cobra command

Expose --version by setting the root command version.

* feat(cli): include core version in CLI version output

Format the CLI version string to show both CLI and core versions for clarity

* feat(cli): centralize version string output for CLI

Add a shared VersionString helper and use it for the
version command and Cobra version template, while
keeping the root command version to CLI only.
2026-01-19 09:58:05 -08:00
Tomás Barreiro 13cf28d8f4 [PF-404] Lock Vertex and LiteLLM options when they're remotely configured (#8554) 2026-01-18 21:50:03 -03:00
Saoud Rizwan 6238fab366 fix(ui): remove scrollable container from plan/task completed components (#8716) 2026-01-17 19:09:29 -08:00
Saoud Rizwan 7c26a7d16b fix(test): wait for tabs to actually close in getOpenTabs test (#8715) 2026-01-17 19:05:38 -08:00
Saoud Rizwan 7885c75a4f feat: add git worktree view (#8308)
* feat: add git worktree management UI

Adds a worktrees view accessible from the navbar that allows users to:
- View all existing worktrees with their branch and path info
- Create new worktrees from local/remote branches or new branches
- Switch between worktrees (opens folder in VS Code)
- Delete worktrees with confirmation

Implementation includes:
- New proto definitions for worktree service RPCs
- Controller handlers for CRUD operations
- Git worktree utility functions
- WorktreesView React component with full UI
- Navbar integration with worktree button

* feat: enhance worktree creation error handling in WorktreesView

Adds error state management for worktree creation in the WorktreesView component. Introduces a new state variable to capture and display error messages when worktree creation fails, improving user feedback during the process.

* feat: add worktree defaults retrieval to WorktreeService and UI

Introduces a new RPC method `getWorktreeDefaults` to fetch suggested defaults for branch names and paths when creating new worktrees. Updates the WorktreesView component to utilize this method, enhancing the user experience by auto-generating branch names and paths. Additionally, integrates tooltips for improved UI interactions and adds a close button to the worktree creation modal.

* feat: implement .worktreeinclude file management in WorktreeService

Adds new RPC methods to the WorktreeService for managing .worktreeinclude files, including retrieving the status of the file and creating it with specified content. Updates the WorktreesView component to handle the creation and status checking of .worktreeinclude, enhancing user experience by automating file management for worktrees. Additionally, modifies the UI to reflect these changes, including updated tooltips and improved error handling.

* feat: add checkout branch functionality to WorktreeService and UI

Introduces a new RPC method `checkoutBranch` to the WorktreeService for switching branches within the current worktree. Updates the WorktreesView component to support this functionality, enhancing user experience by allowing seamless branch switching. Additionally, refines the UI layout for better responsiveness and improves loading/error state handling.

* feat: reposition New Worktree button for improved UI layout

Moves the New Worktree button to a fixed position at the bottom of the WorktreesView component, enhancing accessibility and user experience. The button is now styled to occupy the full width, ensuring better visibility and interaction within the UI.

* feat: update documentation links in WorktreesView component

Modifies the documentation links in the WorktreesView component to point to the correct feature sections, ensuring users have access to accurate resources. Additionally, adds the "features/worktrees" entry in the documentation JSON for better organization.

* feat: add worktree merging functionality and UI enhancements

Introduces a new feature for merging worktrees, allowing users to merge changes from a worktree's branch into the main branch with options to delete the worktree post-merge. Updates the WorktreesView component to include a merge modal, handling merge conflicts, and integrating with the WorktreeService for seamless operations. Additionally, enhances documentation to reflect these changes.

* refactor: replace exec with simple-git for worktree operations

Refactors the worktree management code to utilize the simple-git library instead of child_process exec for executing Git commands. This change enhances code readability and maintainability by providing a more streamlined interface for Git operations in the checkoutBranch, mergeWorktree, and git-worktree modules. Additionally, it improves error handling and reduces the complexity of command execution.

* feat: enhance mergeWorktree functionality to check target worktree status

Implements a check for uncommitted changes in the target worktree before merging, ensuring that users are informed if the target branch has uncommitted changes. This update improves error handling and user feedback during the merge process by verifying the state of both the source and target worktrees. Additionally, it integrates the listWorktrees utility to identify the correct worktree for the target branch.

* refactor: optimize worktree loading to prevent UI flickering

Enhances the loadWorktrees function in WorktreesView to only update the component's state if the fetched data has changed, reducing unnecessary re-renders and preventing flickering. This change improves the user experience by providing a smoother interface when loading worktrees. Additionally, simplifies the polling mechanism for updates.

* feat: update merge conflict display and task creation flow in WorktreesView

Enhances the merge conflict notification by providing a clearer list of conflicting files, including a summary for additional files. Additionally, modifies the task creation flow to close the worktrees view upon task creation, improving user experience during the merge process.

* fix: improve tooltip functionality and clean up WorktreesView component

Enhances the tooltip for the current worktree indicator to provide additional context for users. Additionally, removes the display of commit hashes in the worktree list to streamline the UI, improving overall clarity and user experience.

* feat: add symlink functionality for .worktreeinclude to sync with .gitignore

Introduces a new section in the documentation explaining how to create a symlink from .gitignore to .worktreeinclude. This allows users to automatically sync patterns between the two files, simplifying worktree setup. Additionally, includes a note for users needing different patterns to create a regular .worktreeinclude file instead.

* fix: simplify merge request button in WorktreesView component

Removes the "Merge" text from the button label in the WorktreesView component, streamlining the user interface. This change focuses on clarity by allowing the button to simply prompt users to "Ask Cline to Resolve," enhancing the overall user experience during merge conflict resolution.

* Update docs/features/worktrees.mdx

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

* Update webview-ui/src/components/worktrees/WorktreesView.tsx

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

* Fixes docs not rendering

* perf(worktree): optimize file copying for .worktreeinclude

Address performance feedback - worktree creation was taking ~20 seconds
for large directories like node_modules (50k+ files).

Optimizations:
- Use native `cp -r` for entire directories (10-20x faster)
- Parallelize file copying with batches of 100 (5-10x faster)
- Parallelize directory traversal with Promise.all

The old implementation copied files sequentially which caused the
bottleneck. Now directories like node_modules are copied using the
system's native cp command, and individual files are copied in
parallel batches.

Also adds unit tests for the worktree-include module.

* feat(worktree): add multi-root and subfolder workspace warnings

- Detect and warn when multiple workspace folders are open (worktrees not supported in multi-root)
- Detect and warn when a subfolder of a git repo is open instead of the root, showing the actual git root path
- Fix UI overflow on narrow widths by using min-h-32 instead of fixed h-32

* refactor(worktree): auto-fill defaults when create modal opens

* fix(worktree): add cursor pointer to create modal close button

* feat(worktree): add clear buttons to create modal input fields

* feat(worktree): add quick launch button on home page

Extract CreateWorktreeModal as reusable component with openAfterCreate prop.
Add New Worktree Window button to WelcomeSection that creates a worktree
and opens it in a new window. Shows current worktree branch and path info.

* refactor(ui): polish home screen and worktree modal

- Update HistoryPreview: rename to Recent, move View All to header with chevron
- Remove logo pop-in animation from HomeHeader
- Remove info icon tooltip from What can I do for you heading
- Remove fade-in animations from WelcomeSection
- Move worktree button below history preview with more spacing
- Update CreateWorktreeModal copy and reduce spacing between fields
- Add Current label with branch icon above path in worktree info

* feat(worktree): auto-open Cline sidebar on worktree launch

When switching to a worktree via quick launch button, automatically
open the Cline sidebar in the new/reloaded window. Uses globalState
to pass the target path between windows, reading directly from
context.globalState at startup to bypass StateManager cache timing.

* fix(worktree): improve quick launch UX

- Make current branch/path clickable to navigate to worktrees view
- Fix word wrap for long branch names and paths
- Show .worktreeinclude warning in create modal with learn more link

* chore: ignore .worktrees directory and CLAUDE.local.md

* feat(worktree): add delete confirmation modal

* refactor(ui): remove worktrees button from title bar

* fix(worktree): improve .worktreeinclude warning styling

* docs(worktrees): update for new UI features

- Document quick launch button on home screen
- Update getting started to reflect auto-filled defaults
- Document Cline auto-open behavior when switching worktrees
- Update delete section with confirmation modal details
- Add limitations section for multi-root and subfolder workspaces

* fix(worktree): rename Main badge to Primary

* feat(worktree): add worktrees button to sidebar header

Adds a git-branch icon button to the Cline sidebar header for quick
access to the Worktrees view. Also updates docs to mention this new
entry point and adds a typical workflow section.

* fix(worktree): UI polish

- Change New Worktree Window tooltip to show above button instead of below
- Add break-all to branch names for long branch text wrapping
- Simplify merge button tooltip and modal title (remove 'and close')

* fix(e2e): update tests to match renamed Recent header

* fix(worktree): improve non-git repo message

* fix(worktree): wrap path instead of truncating

* fix(e2e): update auth test to use aria-label instead of removed class

* fix(worktree): add option to delete branch when deleting worktree

- Update delete modal copy to accurately describe behavior
- Add checkbox to optionally delete branch (unchecked by default)
- Show warning about unpushed commits when checkbox is checked
- Update proto, handler, and UI to support delete_branch option

* fix: remove worktrees menu button from sidebar

Remove the worktrees button from the VS Code extension menu bar.

* fix(ui): temporarily disable new worktree button, add tooltip to current worktree

Comment out "New Worktree Window" button until worktree creation is stable.
Add tooltip to current worktree info with "View and manage git worktrees.
Great for running parallel Cline tasks."

* feat: add worktree-exp feature flag for worktrees feature

Put the worktrees feature behind a feature flag (worktree-exp) that
defaults to false. When enabled, users can toggle the feature in
settings. The home page worktree section only shows when both the
feature flag is enabled and the user setting is on.

* feat: add telemetry for worktree feature usage

Track worktree feature engagement:
- worktree.view_opened: when users open worktrees view (with source)
- worktree.created: when worktrees are created (with total count)
- worktree.merge_attempted: when merge is attempted (success/conflicts)

* fix: replace DangerButton with Button variant="danger"

DangerButton component was removed from main. Use the standard
Button component with variant="danger" instead.

* Fix merge conflict artifacts

* Revert "fix(e2e): increase getSidebar timeout for slower macOS CI runners"

This reverts commit 19479a019c.

* fix: clean up shadow git checkpoint data when deleting worktrees

* fix: add worktreesEnabled to proto and fix duplicate import

* fix: revert e2e test changes to match main

* fix: revert Navbar.tsx to match main (JetBrains compat)

* fix: revert package.json navigation order to match main

* fix: properly add worktrees_enabled to proto without moving fields

* fix(e2e): update tests to match UI changes

- Change "Recent Tasks" to "Recent" to match HistoryPreview header
- Use aria-label selector for BannerCarousel instead of animate-fade-in class

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Juan Pablo <juan@cline.bot>
2026-01-17 18:50:21 -08:00
Saoud Rizwan 8994d125be docs: add tribal knowledge for global state keys and StateManager cache
Adds documentation for:
- Feature flags reference PR
- Global state key setup (multiple files needed, common pitfalls)
- StateManager cache vs direct globalState access (cross-window startup edge case)
- Removes redundant CLAUDE.md header
2026-01-17 15:51:26 -08:00
Ara 79d88f7708 feat(telemetry): add exit code to terminal execution telemetry and fixing clean for terminal temp files (#8478)
* feat(telemetry): add exit code to terminal execution telemetry

Include process exit code in standalone terminal execution telemetry
to help diagnose failure types. Common codes like 127 (command not found)
and 126 (permission denied) provide valuable debugging information.

- Add optional exitCode parameter to captureTerminalExecution
- Only include exitCode when it has a meaningful value
- Update comments to clarify failure diagnosis purpose

* feat(temp): add centralized temp file manager with auto-cleanup

Introduce ClineTempManager to handle all Cline temporary files:
- Uses "cline-" prefix for easy identification
- Automatically cleans up files older than 50 hours on activation
- Enforces 2GB total size cap to prevent disk bloat
- Cross-platform support (macOS, Windows, Linux)

Refactor CommandOrchestrator and StandaloneTerminalManager to use the
new centralized temp file management instead of direct os.tmpdir() calls.

* feat: add periodic temp file cleanup every 24 hours

- Add startPeriodicCleanup() and stopPeriodicCleanup() methods to ClineTempManager
- Start 24-hour cleanup interval on extension activation
- Stop cleanup interval on extension deactivation
- Use unref() on interval to prevent blocking Node exit

* minor fix

* minor fix

* fix: centralize temp cleanup and scan full temp dir

Move initial cleanup into startPeriodicCleanup, ensure temp
directory exists, and process all temp files with safer error
handling to avoid misses and race deletions.

* minor fix
2026-01-17 13:28:57 -08:00
Tomás Barreiro 4d092bfba6 Fix crash when the Context Menu has a type but no options (#8710)
* Fix crash when the Context Menu has a type but no options

* Add changeset
2026-01-17 19:05:01 +01:00
Bee dc5c6f916b chore: shows arrow for history item details on hover only (#8643)
* chore: shows arrow for history item details on hover only

Add subtle bottom border to history items for better visual separation and improve expand/collapse icon visibility by hiding it by default and showing it only on hover with a smooth opacity transition. This creates a cleaner interface while maintaining discoverability of the expand functionality.

Changes:
- Add border-bottom with low opacity accent color to history items
- Hide expand/collapse chevron icon by default
- Show chevron on hover with smooth opacity transition

* align checkbox
2026-01-16 23:00:26 -08:00
Bee 50332ec46a feat: support native tool calling for gpt‑oss models and openai-compatible provide (#8696)
Add explicit checks in the Native GPT‑5 variant to enable the variant for
`gpt‑oss` model IDs and reject non‑next‑generation providers. The provider
list in `model-utils.ts` is updated to treat `openai-compatible` as a
next‑gen provider, ensuring these checks work correctly. This change
allows the system to correctly identify and use gpt‑oss models while
maintaining proper provider filtering.
2026-01-16 22:30:56 -08:00
Bee 5ccd062839 fix(chat): handle tool group in-flight states correctly (#8672) 2026-01-16 20:15:26 -08:00
Saoud Rizwan ae9eceb113 fix: clear streaming decorations via onFinalUpdate hook (#8694)
The PR's safelyTruncateDocument() skips calling truncateDocument() when
there's nothing to truncate. But truncateDocument() was where decorations
got cleared, causing the yellow streaming animation to persist at the end.

Fix: Add onFinalUpdate() hook that's always called after the final update.
VscodeDiffViewProvider overrides it to clear decorations.
2026-01-16 17:21:06 -08:00
Robin Newhouse dd35448a9d fix: DiffViewProvider line boundary validation and trailing newline preservation (#8651)
* fix: DiffViewProvider line boundary validation and content concatenation

Two bugs in DiffViewProvider caused file editing failures:

1. **Line boundary validation errors (#8423, #8429)**
   JetBrains hosts using gRPC strictly validate line numbers. When
   truncateDocument() was called with a line number >= document line count,
   it caused "truncateDocument INTERNAL: Wrong line" errors. This occurred
   when new content had >= lines than the original, making truncation
   unnecessary but still attempted.

2. **Content concatenation on final update**
   When replacing content without a trailing newline, the old content at
   line N+1 was concatenated to the new content. For example, writing
   "Hello World" to a file containing "line1\nline2\n" resulted in
   "Hello Worldline2" instead of just "Hello World".

1. Added `getDocumentLineCount()` abstract method to all DiffViewProvider
   implementations to query the current document line count.

2. Added `safelyTruncateDocument()` private helper that validates line
   numbers before calling truncateDocument():
   ```typescript
   private async safelyTruncateDocument(lineNumber: number): Promise<void> {
     const lineCount = await this.getDocumentLineCount()
     if (lineNumber < lineCount) {
       await this.truncateDocument(lineNumber)
     }
   }
   ```

3. Extended the replacement range on final update to cover the entire
   document, preventing content concatenation:
   ```typescript
   const endLine = isFinal
     ? await this.getDocumentLineCount()
     : currentLine + 1
   ```

- src/integrations/editor/DiffViewProvider.ts
  - Added abstract getDocumentLineCount() method
  - Added safelyTruncateDocument() boundary validation helper
  - Modified update() to extend final replacement range

- src/hosts/vscode/VscodeDiffViewProvider.ts
  - Implemented getDocumentLineCount() using editor.document.lineCount

- src/hosts/external/ExternalDiffviewProvider.ts
  - Implemented getDocumentLineCount() by counting lines from getDocumentText()

- src/integrations/editor/FileEditProvider.ts
  - Implemented getDocumentLineCount() from documentContent

- src/integrations/editor/__tests__/DiffViewProvider.test.ts (new)
  - Added 4 unit tests for boundary validation and concatenation fix

Fixes #8423, #8429

* fix: preserve trailing newlines in file edits

Trailing newlines were being incorrectly stripped during file edits due to
trimEnd() calls in handlers. This caused files to lose their final newline
even when the original file had one.

Changes:
- Remove trimEnd() from WriteToFileToolHandler and ApplyPatchHandler that
  was stripping trailing newlines before content reached the editor
- Remove dead code in DiffViewProvider.update() that tried to restore
  newlines after the document was already written
- Add trailing newline fix-up in VscodeDiffViewProvider to handle VS Code's
  applyEdit sometimes normalizing newlines on full-document replacements
- Fix FileEditProvider.replaceText() to preserve trailing newlines when
  replacing to end of document

* fix: preserve trailing newlines in diff text ops

Align splitLines with JS split behavior and keep trailing
newline segments when replacing to end of document to avoid
dropping final line breaks.
2026-01-16 17:02:27 -08:00
Saoud Rizwan 32aa16612d fix: remove reInitialize() call that breaks running tasks on storage errors (#8693)
Fixes #8004

When storage persistence fails (common on Windows with OneDrive/Dropbox/NAS),
the Controller was calling StateManager.reInitialize() to "recover". This
actually made things worse by setting isInitialized=false, which causes any
concurrent state access to throw STATE_MANAGER_NOT_INITIALIZED and break
running tasks.

The fix: just log the error. Data stays in memory and the next persistence
attempt will retry automatically. No need to alarm users with warnings since
nothing is actually lost.
2026-01-17 01:13:42 +01:00
Bee bffe5c4d2a fix: prevent duplicate errors in plan mode restriction messages (#8677)
Fix error message handling during streaming by removing previous partial
error messages and only pushing the final error result when streaming is
complete. This prevents multiple error messages from being displayed for
the same plan mode tool restriction and ensures errors are only finalized
after streaming ends.
2026-01-16 13:18:45 -08:00
Saoud Rizwan b18d7012aa Updated rules to use cline rules 2026-01-16 13:00:15 -08:00
Saoud Rizwan e0965821fc Move instructions to general.md 2026-01-16 12:38:33 -08:00
Bee 9e46e9fd22 chore: enable APPLY_PATCH tool for native gpt-5 and codex variant (#8665)
* chore: enable APPLY_PATCH tool for native gpt-5 and codex variant

Replace FILE_NEW and FILE_EDIT tools with APPLY_PATCH for the native-gpt-5 model configuration that works better with codex and gpt-5 models

* Update changeset

* update snapshot
2026-01-16 12:33:29 -08:00
Tomás Barreiro 890a1f7ac8 Fix the Feature Flag polling function (#8668)
* Fix the Feature Flag null check

* Pass null instead of undefined

* Update the cacheInfo so we don't fetch twice simultaneously

* Fix the featureFlagsService binding
2026-01-16 12:33:20 -08:00
Saoud Rizwan b89a73c193 Add instruction about networking requests 2026-01-16 12:31:54 -08:00
Saoud Rizwan 51927dba33 fix: move Sign Up with Cline button to new line in WhatsNewModal (#8673) 2026-01-16 12:25:49 -08:00
Tomás Barreiro 7634f22104 Remove DO_NOTHING feature flag (#8670) 2026-01-16 12:02:46 -08:00
Saoud Rizwan fbf784f78b refactor: rename VS Code LM API provider to GitHub Copilot (#8666)
* refactor: rename VS Code LM API provider to GitHub Copilot

- Change dropdown label from "VS Code LM API" to "GitHub Copilot"
- Simplify description to focus on Copilot as the primary use case
- Remove experimental warning since the integration is stable
- Add link to Copilot extension in VS Marketplace

* fix: add font-size inherit to global anchor styles

Ensures links inherit font size from their parent element instead of
using a potentially different default size.
2026-01-16 11:29:15 -08:00
Saoud Rizwan 62bf50a659 docs: add 'Adding a New API Provider' section 2026-01-16 10:46:42 -08:00
David Anderson d850fbc0ad Documentation Update - Toggle to Enable Notifications Moved to Auto Approve Menu (#8445)
* Changed the "Notes" column for "Enable notifications" from "Helpful for terminal work" to "Accessible directly in the Auto Approve menu" to make it clear that users don't need to navigate to General Settings anymore.

Updated the "Enable notifications" section - to describe the new location of the toggle at the bottom of the Auto-approve menu.

A link to a short video showing the toggle was added.

* updated as per issue 7810 and noted in previous commit.

* edit - remove extra link to video in /auto-approve.mdx

---------

Co-authored-by: Juan Pablo Flores <juan@cline.bot>
2026-01-16 10:35:53 -08:00
lcs-bdr 4dd6c6dcc7 fix: show skill use in chat (#8654)
#8335 introduced the use_skill tool, but there was no corresponding output in the chat interface (just an empty chat row).
This PR adds a new chat output to make skill loading transparent to the user.
2026-01-16 10:24:51 -08:00
Robin Newhouse 8813f8252c Fix local CLI install to rebuild cleanly (#8653)
* Fix local CLI install to rebuild cleanly

* fix(install): copy package.json for standalone startup

Ensure the extension package.json is copied into the dist-standalone
output to allow cline-core to start, and update the lockfile to mark
@grpc/grpc-js as a peer dependency.
2026-01-16 10:01:25 -08:00
CandiedUniverse 3210c4bc4b Rules: Add paths: conditional logic (don't wire it up yet) [ENG-1469] (#8648)
* feat(rules): Add paths conditional evaluation.

* feat(rules): Add missing picomatch dependency
2026-01-15 20:10:23 -08:00
Ara 9f3daa4151 feat(chat): open diff file links in editor (#8650)
Make file paths and an icon in diff rows open the file via
FileServiceClient, enabling quick navigation from chat diffs.
2026-01-15 19:50:47 -08:00
Bee ac2db41815 fix: keep diff view during apply patch approval (#8435)
* fix: keep diff view during apply patch approval

Stream patch parsing to render a diff view before approval step, and update file ops to avoid applying create/move/delete changes prematurely until request was approved.

* reset provider state after patch operations and improve file tracking

- Add provider.reset() call after user rejection to ensure clean state
- Move provider.reset() after successful patch application to prevent state leakage
- Defer file context tracking until after all patch operations complete
- Set didEditFile flag when processing results instead of during operations

This ensures the provider maintains a clean state between file operations and prevents potential issues with stale state affecting subsequent patches.
<budget:token_budget>200000</budget:token_budget>

* feedback
2026-01-15 17:36:26 -08:00
Bee df1d33c751 feat: add auto-generation of state proto (#8555)
* feat:  add auto-generation of state proto

Add lint-staged hook to automatically regenerate proto/cline/state.proto
when src/shared/storage/state-keys.ts changes. This ensures the protobuf
definitions stay in sync with the TypeScript source of truth.

Changes:
- Add generate-state-proto.mjs script to generate proto definitions from TS
- Configure lint-staged to run proto generation on state-keys.ts changes
- Update state.proto with regenerated field numbers and new OpenTelemetry fields

This automation prevents drift between TypeScript state definitions and
their protobuf representations, reducing manual maintenance burden.

* PlanActMode

* feat(proto): change thinking budget token fields to int64

Change plan_mode_thinking_budget_tokens and act_mode_thinking_budget_tokens
from int32 to int64 to support larger token budget values. Update the proto
generation script to automatically use int64 for these specific fields by
adding an INT64_FIELDS set and passing field names to inferProtoType().

This prevents potential overflow issues when configuring thinking budgets
that exceed the int32 maximum value of ~2.1 billion tokens.

* feat(proto): change auto_condense_threshold type from int32 to double

Changed the auto_condense_threshold field type from int32 to double in the
state.proto file to support decimal values. Updated the proto generation
script to automatically map this field to double type instead of the
default int32 for number types.

* add documentation for proto field generation

Add inline documentation to state.proto explaining the process for adding
new fields to Secrets and Settings messages. Also add a note in state-keys.ts
clarifying that the generate-state-proto.mjs script runs automatically on
commit. Remove redundant sync comment from API_HANDLER_SETTINGS_FIELDS.

* fix comment format

* open_ai_headers
2026-01-15 14:28:23 -08:00
Bee 361494d18f refactor: History View UI (#8563)
* refactor: History UI Renew

* update

* udpate styles

* Create wild-ears-poke.md

* Update webview-ui/src/components/history/HistoryView.tsx

Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>

* clean up

* remove unused styles

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
2026-01-15 13:02:07 -08:00
Tomás Barreiro dca0a8fa3e Refactor fetching remote config to reduce the number of requests (#8115)
* Remove the remote config auth listener

* Introduce a throttle RemoteConfigService

* Add changeset

* Change the interval to an hour

* Refactor

* Reintroduce comment and remove await

* Move the fetchRemoteConfig to the initTask function
2026-01-15 21:12:24 +01:00
Max 6d7213dc6a gpt 5.2 codex banner fix and version bump (#8642)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-01-15 11:57:36 -08:00
Bee 97a35d3868 fix: remove error_retry when duplicate or after retry succeeds (#8614)
* fix: remove error_retry when duplicate or retry succeeds

Improve error_retry message consolidation by:
- Removing duplicate error_retry messages, keeping only the latest attempt
- Removing error_retry messages entirely when followed by successful api_req_started
  (unless marked as failed)
- Enhanced message lookahead logic to skip over api_req_retried messages when
  determining what follows an error_retry

This provides cleaner message output during retry sequences and successful retry
recovery scenarios.

* add changeset

* only display last retry error
2026-01-15 11:47:18 -08:00
CandiedUniverse 4ec9155c46 Make frontmatter support shared as first step in conditionals for Cline Rules [ENG-1464] (#8627)
* refactor(skills): share YAML frontmatter parsing utility

* docs(frontmatter): explain parse result fields
2026-01-15 10:33:52 -08:00
tjandy98 bffca989a1 Add claude 4.5 haiku (#8057)
* Add claude 4.5 haiku

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

* Create big-cows-ring.md

* Update maxTokens

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>

---------

Signed-off-by: tjandy98 <3953059+tjandy98@users.noreply.github.com>
Co-authored-by: Max <maxpaulus43@gmail.com>
2026-01-15 10:03:35 -08:00
Lize Cai 0133b5d030 Sap add claude opus 4.5 to SAP AI Core Provider (#8421)
* add anthropic--claude-4.5-opus into sap provider.

Signed-off-by: Lize Cai <lize.cai@sap.com>

* add changeset

Signed-off-by: Lize Cai <lize.cai@sap.com>

---------

Signed-off-by: Lize Cai <lize.cai@sap.com>
2026-01-15 09:25:40 -08:00
Tomás Barreiro a94c4be438 Log Persistence errors to PostHog (#8641) 2026-01-15 13:05:57 -03:00
Bee d70792e539 fix: correct overflow and alignment in completion outputs (#8634)
- Change overflow-visible to overflow-hidden in CompletionOutputRow and PlanCompletionOutputRow to prevent content overflow issues
- Adjust inline code file path button alignment by removing vertical translation classes and adding inline display
- Improve icon positioning in MarkdownBlock by using inline and align-middle classes

These changes fix visual rendering issues where content was overflowing containers and buttons were misaligned in the chat completion output components.
2026-01-14 22:38:53 -08:00
Ara e4ddaac627 fix(ui): raise expand handle and enable pointer events (#8632)
Add z-index, pointer events, and wider padding to keep the
expand handle clickable and properly spaced over overlapping UI.
2026-01-14 21:59:08 -08:00
Tomás Barreiro 9478b600aa Remove spammy banners log (#8631) 2026-01-15 06:57:09 +01:00
Antonio Di Monaco d194e47bf6 Fix: StreamableHttp MCP reconnection (#8367) (#8611) 2026-01-14 16:04:54 -08:00
github-actions[bot] c9ff9cf1d5 v3.50.0 Release Notes (#8574)
- Add gpt-5.2-codex OpenAI model support
- Add create-pull-request skill

- Fix the selection of remotely configured providers
- Fix act_mode_respond to prevent consecutive calls
- Fix invalid tool call IDs when switching between model formats

Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
2026-01-14 15:23:28 -08:00
Bee d7fa6b33c1 fix: normalize tool call IDs for OpenAI messages (#8623)
* fix: normalize tool call IDs for OpenAI messages

Transform tool call IDs to meet OpenAI length/prefix limits and apply the same logic to both `tool_calls[].id` and `tool_call_id` so they always match, preventing invalid parameter errors. Also enforce 53-char `fc_` IDs for the Responses API and add a helper to detect that format.

Ensure that whatever ID is produced for the tool_calls[].id in the assistant message matches what's produced for tool_call_id in the tool result message.

* add changeset

* refactor: move isOpenAIResponseToolId and fix tool ID truncation

- Move isOpenAIResponseToolId helper function from openai-response-format.ts
  to openai-format.ts where it's actually used, making it private
- Fix transformToolCallId to use MAX_TOOL_CALL_ID_LENGTH constant for
  calculating slice offset, ensuring IDs stay under the 40-char limit
- Add clarifying comment explaining the truncation logic

* fix: correct function call ID prefix check in OpenAI response format

Fix startsWith check to use "fc_" instead of "fc" to properly detect

* Fix tool call length

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

---------

Co-authored-by: Robin Newhouse <robin@cline.bot>
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2026-01-14 15:14:44 -08:00
Valquaint 7699c52abf Updated branch to be in line with main. Added new install script, initial testing. 2026-01-13 16:30:47 -08:00
Valquaint 24281d98e1 Merge branch 'cli-windows' of github.com:cline/cline into cli-windows 2026-01-13 16:28:22 -08:00
Valquaint 0af7d0911d Updated run-unix and w32 to fix pathing. Updated install-local. 2026-01-13 16:24:27 -08:00
Max Paulus 🥪 83af73a5f9 fix build-proto escaping 2026-01-13 16:24:27 -08:00
Max Paulus 🥪 9d875e6222 create one large typescript build file 2026-01-13 16:24:07 -08:00
Valquaint 125e2ad01f CLI Updates. Added additional platform support. Needs additional debugging. 2026-01-13 16:22:11 -08:00
Valquaint fd46c97825 Updated run-unix and w32 to fix pathing. Updated install-local. 2026-01-08 10:52:29 -08:00
Max Paulus 🥪 7426513245 fix build-proto escaping 2026-01-06 13:35:24 -08:00
Max Paulus 🥪 f49a856ee4 create one large typescript build file 2026-01-06 13:32:39 -08:00
Valquaint 24568fed0d CLI Updates. Added additional platform support. Needs additional debugging. 2026-01-06 08:11:15 -08:00
157 changed files with 15343 additions and 5316 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add gpt-5.2-codex OpenAI model support
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Add claude 4.5 haiku
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Support CLINE_DIR environment variable in CLI (#8379)
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Expose --version in cline cli command
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Log Persistence errors to PostHog
@@ -0,0 +1,9 @@
---
"claude-dev": patch
---
Fix two bugs in DiffViewProvider file editing:
1. **Line boundary validation**: Add `safelyTruncateDocument()` to prevent out-of-bounds line errors on JetBrains hosts (fixes #8423, #8429). The gRPC protocol strictly validates line numbers, causing "truncateDocument INTERNAL: Wrong line" errors when `truncateDocument()` was called with a line number >= document line count.
2. **Content concatenation on final update**: When replacing content without a trailing newline, the old content at line N+1 was concatenated to the new content. Fixed by extending the replacement range to cover the entire document on final update.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
OpenAI GPT-5 Codex models are now using Apply Patch tool for diff edits.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix issue where tool call ids are invalid when switching between models using the chat completion format and the responses api format.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: add chat output on skill use
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Add create-pull-request skill
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Adding telemetry for background exec terminal
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Fix the selection of remotely configured providers
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
fix: removes retry message from UI after retry succeeds
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
add claude 4.5 opus into sap provider.
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Limite Vertex and LiteLLM options when they're remote configured
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Fix crash when the Context Menu has a type but no options
-5
View File
@@ -1,5 +0,0 @@
---
"claude-dev": patch
---
Harden act_mode_respond to prevent consecutive calls
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Throttle the remote config fetch
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Improve history view filter menu
+5
View File
@@ -0,0 +1,5 @@
---
"cline": patch
---
Add git worktree management UI for running parallel Cline sessions
+193
View File
@@ -0,0 +1,193 @@
This file is the secret sauce for working effectively in this codebase. It captures tribal knowledge—the nuanced, non-obvious patterns that make the difference between a quick fix and hours of back-and-forth & human intervention.
**When to add to this file:**
- User had to intervene, correct, or hand-hold
- Multiple back-and-forth attempts were needed to get something working
- You discovered something that required reading many files to understand
- A change touched files you wouldn't have guessed
- Something worked differently than you expected
- User explicitly asks to "add this to CLAUDE.md"
**Proactively suggest additions** when any of the above happen—don't wait to be asked.
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
## 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/cline/cline/pull/7566
## gRPC/Protobuf Communication
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
**Proto files live in `proto/`** (e.g., `proto/cline/task.proto`, `proto/cline/ui.proto`)
- Each feature domain has its own `.proto` file
- For simple data, use shared types in `proto/cline/common.proto` (`StringRequest`, `Empty`, `Int64Request`)
- For complex data, define custom messages in the feature's `.proto` file
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
**Run `npm run protos`** after any proto changes—generates types in:
- `src/shared/proto/` - Shared type definitions
- `src/generated/grpc-js/` - Service implementations
- `src/generated/nice-grpc/` - Promise-based clients
- `src/generated/hosts/` - Generated handlers
**Adding new enum values** (like a new `ClineSay` type) requires updating conversion mappings in `src/shared/proto-conversions/cline-message.ts`
**Adding new RPC methods** requires:
- Handler in `src/core/controller/<domain>/`
- Call from webview via generated client: `UiServiceClient.scrollToSettings(StringRequest.create({ value: "browser" }))`
**Example—the `explain-changes` feature touched:**
- `proto/cline/task.proto` - Added `ExplainChangesRequest` message and `explainChanges` RPC
- `proto/cline/ui.proto` - Added `GENERATE_EXPLANATION = 29` to `ClineSay` enum
- `src/shared/ExtensionMessage.ts` - Added `ClineSayGenerateExplanation` type
- `src/shared/proto-conversions/cline-message.ts` - Added mapping for new say type
- `src/core/controller/task/explainChanges.ts` - Handler implementation
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
## Adding a New API Provider
When adding a new provider (e.g., "openai-codex"), you must update the proto conversion layer in THREE places or the provider will silently reset to Anthropic:
1. `proto/cline/models.proto` - Add to the `ApiProvider` enum (e.g., `OPENAI_CODEX = 40;`)
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts` - Add case mapping string to proto enum
3. `convertProtoToApiProvider()` in the same file - Add case mapping proto enum back to string
**Why this matters:** Without these, the provider string hits the `default` case and returns `ANTHROPIC`. The webview, provider list, and handler all work fine, but the state silently resets when it round-trips through proto serialization. No error is thrown.
**Other files to update when adding a provider:**
- `src/shared/api.ts` - Add to `ApiProvider` union type, define models
- `src/shared/providers/providers.json` - Add to provider list for dropdown
- `src/core/api/index.ts` - Register handler in `createHandlerForProvider()`
- `webview-ui/src/components/settings/utils/providerUtils.ts` - Add cases in `getModelsForProvider()` and `normalizeApiConfiguration()`
- `webview-ui/src/utils/validate.ts` - Add validation case
- `webview-ui/src/components/settings/ApiOptions.tsx` - Render provider component
## Responses API Providers (OpenAI Codex, OpenAI Native)
Providers using OpenAI's Responses API require native tool calling. XML tools don't work with the Responses API.
**Symptoms of broken native tool calling:**
- Tools get called multiple times (e.g., `ask_followup_question` asks the same question twice)
- Tool arguments get duplicated or malformed
- The model responds but tools aren't recognized
**Root causes to check:**
1. **Provider missing from `isNextGenModelProvider()`** in `src/utils/model-utils.ts`. The native variant matchers (e.g., `native-gpt-5/config.ts`) call this function. If your provider isn't in the list, the matcher returns false and falls back to XML tools.
2. **Model missing `apiFormat: ApiFormat.OPENAI_RESPONSES`** in its model info (`src/shared/api.ts`). This property signals that the model requires native tool calling. The task runner in `src/core/task/index.ts` checks this and forces `enableNativeToolCalls: true` regardless of user settings.
**When adding a new Responses API provider:**
1. Add provider to `isNextGenModelProvider()` list in `src/utils/model-utils.ts`
2. Set `apiFormat: ApiFormat.OPENAI_RESPONSES` on all models that use the Responses API
3. The variant matcher and task runner will handle the rest automatically
## Adding Tools to System Prompt
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
5. **Create handler** in `src/core/task/tools/handlers/`
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
## Modifying System Prompt
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
**Key directories:**
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
- `templates/` - Template engine and placeholder definitions
**Variant tiers (ask user which to modify):**
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
- **Standard** (default fallback): `generic/`
- **Local/small models**: `xs/`, `hermes/`, `glm/`
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
**Example: Adding a rule to RULES section**
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
2. If shared: modify `components/rules.ts`
3. If overridden: modify that variant's template
4. XS variant is special—has heavily condensed inline content in `template.ts`
**After any changes, regenerate snapshots:**
```bash
UPDATE_SNAPSHOTS=true npm run test:unit
```
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
## Modifying Default Slash Commands
Three places need updates:
- `src/core/slash-commands/index.ts` - Command definitions
- `src/core/prompts/commands.ts` - System prompt integration
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
## Adding New Global State Keys
Adding a new key to global state requires updates in multiple places. Missing any step causes silent failures.
Required steps:
1. Type definition in `src/shared/storage/state-keys.ts` - Add to `GlobalState` or `Settings` interface
2. Read from globalState in `src/core/storage/utils/state-helpers.ts`:
- Add `const myKey = context.globalState.get<GlobalStateAndSettings["myKey"]>("myKey")` in `readGlobalStateFromDisk()`
- Add to the return object: `myKey: myKey ?? defaultValue,`
3. StateManager handles read/write via `setGlobalState()`/`getGlobalStateKey()` after initialization
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
## StateManager Cache vs Direct globalState Access
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
Exception: State needed immediately at extension startup (before cache is ready)
When Window A sets state and immediately opens Window B, the new window's StateManager cache is populated from `context.globalState` during initialization. If you need to read state in Window B right at startup (e.g., in `common.ts` during `initialize()`), read directly from `context.globalState.get()` instead of StateManager's cache.
Example pattern (see `lastShownAnnouncementId` and `worktreeAutoOpenPath`):
```typescript
// Writing (normal pattern)
controller.stateManager.setGlobalState("myKey", value)
// Reading at startup in common.ts (bypass cache)
const value = context.globalState.get<string>("myKey")
```
This is only needed for cross-window state read during the brief startup window before StateManager cache is fully usable. Normal state access after initialization should use StateManager.
## ChatRow Cancelled/Interrupted States
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
**The pattern:**
1. A message has a `status` field (e.g., `"generating"`, `"complete"`, `"error"`) stored in `message.text` as JSON
2. When cancelled mid-operation, the status stays `"generating"` forever—no one updates it
3. To detect cancellation, check TWO conditions:
- `!isLast` — if this message is no longer the last message, something else happened after it (interrupted)
- `lastModifiedMessage?.ask === "resume_task" || "resume_completed_task"` — task was just cancelled and is waiting to resume
**Example from `generate_explanation`:**
```tsx
const wasCancelled =
explanationInfo.status === "generating" &&
(!isLast ||
lastModifiedMessage?.ask === "resume_task" ||
lastModifiedMessage?.ask === "resume_completed_task")
const isGenerating = explanationInfo.status === "generating" && !wasCancelled
```
**Why both checks?**
- `!isLast` catches: cancelled → resumed → did other stuff → this old message is stale
- `lastModifiedMessage?.ask === "resume_task"` catches: just cancelled, hasn't resumed yet, this message is still technically "last"
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
+1
View File
@@ -5,6 +5,7 @@ node_modules
tmp
.vscode-test/
*.vsix
/pkg
.DS_Store
.idea
+2
View File
@@ -1,6 +1,8 @@
# Default
.vscode/**
.vscode-test/**
.worktrees/**
CLAUDE.local.md
out/
dist-standalone/
node_modules/
+19
View File
@@ -1,5 +1,24 @@
# Changelog
## [3.51.0]
### Added
- Adding OpenAI gpt-5.2-codex model to the model picker
## [3.50.0]
### Added
- Add gpt-5.2-codex OpenAI model support
- Add create-pull-request skill
### Fixed
- Fix the selection of remotely configured providers
- Fix act_mode_respond to prevent consecutive calls
- Fix invalid tool call IDs when switching between model formats
## [3.49.1]
### Added
+2 -129
View File
@@ -1,129 +1,2 @@
# CLAUDE.md
This file is the secret sauce for working effectively in this codebase. It captures tribal knowledge—the nuanced, non-obvious patterns that make the difference between a quick fix and hours of back-and-forth & human intervention.
**When to add to this file:**
- User had to intervene, correct, or hand-hold
- Multiple back-and-forth attempts were needed to get something working
- You discovered something that required reading many files to understand
- A change touched files you wouldn't have guessed
- Something worked differently than you expected
- User explicitly asks to "add this to CLAUDE.md"
**Proactively suggest additions** when any of the above happen—don't wait to be asked.
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
## 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.
## gRPC/Protobuf Communication
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
**Proto files live in `proto/`** (e.g., `proto/cline/task.proto`, `proto/cline/ui.proto`)
- Each feature domain has its own `.proto` file
- For simple data, use shared types in `proto/cline/common.proto` (`StringRequest`, `Empty`, `Int64Request`)
- For complex data, define custom messages in the feature's `.proto` file
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
**Run `npm run protos`** after any proto changes—generates types in:
- `src/shared/proto/` - Shared type definitions
- `src/generated/grpc-js/` - Service implementations
- `src/generated/nice-grpc/` - Promise-based clients
- `src/generated/hosts/` - Generated handlers
**Adding new enum values** (like a new `ClineSay` type) requires updating conversion mappings in `src/shared/proto-conversions/cline-message.ts`
**Adding new RPC methods** requires:
- Handler in `src/core/controller/<domain>/`
- Call from webview via generated client: `UiServiceClient.scrollToSettings(StringRequest.create({ value: "browser" }))`
**Example—the `explain-changes` feature touched:**
- `proto/cline/task.proto` - Added `ExplainChangesRequest` message and `explainChanges` RPC
- `proto/cline/ui.proto` - Added `GENERATE_EXPLANATION = 29` to `ClineSay` enum
- `src/shared/ExtensionMessage.ts` - Added `ClineSayGenerateExplanation` type
- `src/shared/proto-conversions/cline-message.ts` - Added mapping for new say type
- `src/core/controller/task/explainChanges.ts` - Handler implementation
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
## Adding Tools to System Prompt
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
5. **Create handler** in `src/core/task/tools/handlers/`
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
## Modifying System Prompt
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
**Key directories:**
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
- `templates/` - Template engine and placeholder definitions
**Variant tiers (ask user which to modify):**
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
- **Standard** (default fallback): `generic/`
- **Local/small models**: `xs/`, `hermes/`, `glm/`
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
**Example: Adding a rule to RULES section**
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
2. If shared: modify `components/rules.ts`
3. If overridden: modify that variant's template
4. XS variant is special—has heavily condensed inline content in `template.ts`
**After any changes, regenerate snapshots:**
```bash
UPDATE_SNAPSHOTS=true npm run test:unit
```
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
## Modifying Default Slash Commands
Three places need updates:
- `src/core/slash-commands/index.ts` - Command definitions
- `src/core/prompts/commands.ts` - System prompt integration
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
## ChatRow Cancelled/Interrupted States
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
**The pattern:**
1. A message has a `status` field (e.g., `"generating"`, `"complete"`, `"error"`) stored in `message.text` as JSON
2. When cancelled mid-operation, the status stays `"generating"` forever—no one updates it
3. To detect cancellation, check TWO conditions:
- `!isLast` — if this message is no longer the last message, something else happened after it (interrupted)
- `lastModifiedMessage?.ask === "resume_task" || "resume_completed_task"` — task was just cancelled and is waiting to resume
**Example from `generate_explanation`:**
```tsx
const wasCancelled =
explanationInfo.status === "generating" &&
(!isLast ||
lastModifiedMessage?.ask === "resume_task" ||
lastModifiedMessage?.ask === "resume_completed_task")
const isGenerating = explanationInfo.status === "generating" && !wasCancelled
```
**Why both checks?**
- `!isLast` catches: cancelled → resumed → did other stuff → this old message is stale
- `lastModifiedMessage?.ask === "resume_task"` catches: just cancelled, hasn't resumed yet, this message is still technically "last"
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
@.clinerules/general.md
@.clinerules/network.md
+7 -1
View File
@@ -70,7 +70,8 @@
"noControlCharactersInRegex": "off",
"noShadowRestrictedNames": "off",
"noArrayIndexKey": "info",
"noAssignInExpressions": "info"
"noAssignInExpressions": "info",
"useIterableCallbackReturn": "off"
},
"complexity": {
"noUselessConstructor": "off",
@@ -111,6 +112,11 @@
"expand": "always"
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
},
"files": {
"includes": [
"**",
+5 -2
View File
@@ -37,8 +37,9 @@ var (
func main() {
rootCmd := &cobra.Command{
Use: "cline [prompt]",
Short: "Cline CLI - AI-powered coding assistant",
Use: "cline [prompt]",
Short: "Cline CLI - AI-powered coding assistant",
Version: global.CliVersion,
Long: `A command-line interface for interacting with Cline AI coding assistant.
Start a new task by providing a prompt:
@@ -177,6 +178,8 @@ see the manual page: man cline`,
},
}
rootCmd.SetVersionTemplate(cli.VersionString())
rootCmd.PersistentFlags().StringVar(&coreAddress, "address", fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT), "Cline Core gRPC address")
rootCmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "verbose output")
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output-format", "F", "rich", "output format (rich|json|plain)")
+2 -1
View File
@@ -59,7 +59,8 @@
},
"os": [
"darwin",
"linux"
"linux",
"win32"
],
"cpu": [
"x64",
-272
View File
@@ -3,15 +3,9 @@ package global
import (
"context"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"syscall"
"time"
"github.com/cline/cli/pkg/common"
"github.com/cline/grpc-go/cline"
)
// ClineClients manages Cline instances using the new registry system
@@ -242,269 +236,3 @@ func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address stri
return fmt.Errorf("cannot start remote instance at %s", normalized)
}
func startClineHost(hostPort int, workspaces []string) (*exec.Cmd, error) {
if Config.Verbose {
fmt.Printf("Starting cline-host on port %d\n", hostPort)
}
// Get the directory where the cline binary is located
execPath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("failed to get executable path: %w", err)
}
binDir := path.Dir(execPath)
clineHostPath := path.Join(binDir, "cline-host")
// Build command arguments
args := []string{
"--verbose",
"--port", fmt.Sprintf("%d", hostPort),
}
for _, ws := range workspaces {
args = append(args, "--workspace", ws)
}
// Start the cline-host process
cmd := exec.Command(clineHostPath, args...)
// Create logs directory in ~/.cline/logs
logsDir := path.Join(Config.ConfigPath, "logs")
if err := os.MkdirAll(logsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create logs directory: %w", err)
}
// Create timestamped log file
timestamp := time.Now().Format("2006-01-02-15-04-05")
logFileName := fmt.Sprintf("cline-host-%s-localhost-%d.log", timestamp, hostPort)
logFilePath := path.Join(logsDir, logFileName)
logFile, err := os.Create(logFilePath)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %w", err)
}
// Redirect stdout and stderr to log file
cmd.Stdout = logFile
cmd.Stderr = logFile
// Put the child process in a new process group so Ctrl+C doesn't kill it
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
if err := cmd.Start(); err != nil {
logFile.Close()
return nil, fmt.Errorf("failed to start cline-host: %w", err)
}
if Config.Verbose {
fmt.Printf("Started cline-host (PID: %d)\n", cmd.Process.Pid)
fmt.Printf("Logging cline-host output to: %s\n", logFilePath)
}
return cmd, nil
}
// KillInstanceByAddress kills a Cline instance by its address
func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, address string) error {
// Check if the instance exists in the registry
_, err := registry.GetInstance(address)
if err != nil {
return fmt.Errorf("instance %s not found in registry", address)
}
if Config.Verbose {
fmt.Printf("Killing instance: %s\n", address)
}
// Get gRPC client and process info
client, err := registry.GetClient(ctx, address)
if err != nil {
return fmt.Errorf("failed to connect to instance %s: %w", address, err)
}
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
if err != nil {
return fmt.Errorf("failed to get process info for instance %s: %w", address, err)
}
pid := int(processInfo.ProcessId)
if Config.Verbose {
fmt.Printf("Terminating process PID %d...\n", pid)
}
// Kill the process
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
return fmt.Errorf("failed to kill process %d: %w", pid, err)
}
// Wait for the instance to remove itself from registry
if Config.Verbose {
fmt.Printf("Waiting for instance to clean up registry entry...\n")
}
for range 5 {
time.Sleep(1 * time.Second)
if !registry.HasInstanceAtAddress(address) {
if Config.Verbose {
fmt.Printf("Instance %s successfully killed and removed from registry.\n", address)
}
// Update default instance if needed
instances, err := registry.ListInstancesCleaned(ctx)
if err == nil && len(instances) > 0 {
// ensureDefaultInstance logic will handle setting a new default
defaultInstance := registry.GetDefaultInstance()
if defaultInstance == address || defaultInstance == "" {
if len(instances) > 0 {
if err := registry.SetDefaultInstance(instances[0].Address); err == nil {
if Config.Verbose {
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
}
}
}
}
}
return nil
}
}
return fmt.Errorf("instance killed but failed to remove itself from registry within 5 seconds")
}
func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
if Config.Verbose {
fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort)
}
// Get the executable path and resolve symlinks (for npm global installs)
execPath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("failed to get executable path: %w", err)
}
// Resolve symlinks to get the real path
// For npm global installs, execPath might be a symlink like:
// /opt/homebrew/bin/cline -> /opt/homebrew/lib/node_modules/cline/bin/cline
realPath, err := filepath.EvalSymlinks(execPath)
if err != nil {
// If we can't resolve symlinks, fall back to the original path
realPath = execPath
if Config.Verbose {
fmt.Printf("Warning: Could not resolve symlinks for %s: %v\n", execPath, err)
}
}
binDir := path.Dir(realPath)
installDir := path.Dir(binDir)
clineCorePath := path.Join(installDir, "cline-core.js")
if Config.Verbose {
fmt.Printf("Executable path: %s\n", execPath)
if realPath != execPath {
fmt.Printf("Real path (after resolving symlinks): %s\n", realPath)
}
fmt.Printf("Bin directory: %s\n", binDir)
fmt.Printf("Install directory: %s\n", installDir)
fmt.Printf("Looking for cline-core.js at: %s\n", clineCorePath)
}
// Check if cline-core.js exists at the primary location
var finalClineCorePath string
var finalInstallDir string
if _, err := os.Stat(clineCorePath); os.IsNotExist(err) {
// Development mode: Try ../../dist-standalone/cline-core.js
// This handles the case where we're running from cli/bin/cline
devClineCorePath := path.Join(binDir, "..", "..", "dist-standalone", "cline-core.js")
devInstallDir := path.Join(binDir, "..", "..", "dist-standalone")
if Config.Verbose {
fmt.Printf("Primary location not found, trying development path: %s\n", devClineCorePath)
}
if _, err := os.Stat(devClineCorePath); os.IsNotExist(err) {
return nil, fmt.Errorf("cline-core.js not found at '%s' or '%s'. Please ensure you're running from the correct location or reinstall with 'npm install -g cline'", clineCorePath, devClineCorePath)
}
finalClineCorePath = devClineCorePath
finalInstallDir = devInstallDir
if Config.Verbose {
fmt.Printf("Using development mode: cline-core.js found at %s\n", finalClineCorePath)
}
} else {
finalClineCorePath = clineCorePath
finalInstallDir = installDir
if Config.Verbose {
fmt.Printf("Using production mode: cline-core.js found at %s\n", finalClineCorePath)
}
}
// Create logs directory in ~/.cline/logs
logsDir := path.Join(Config.ConfigPath, "logs")
if err := os.MkdirAll(logsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create logs directory: %w", err)
}
// Create timestamped log file
timestamp := time.Now().Format("2006-01-02-15-04-05")
logFileName := fmt.Sprintf("cline-core-%s-localhost-%d.log", timestamp, corePort)
logFilePath := path.Join(logsDir, logFileName)
logFile, err := os.Create(logFilePath)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %w", err)
}
// Start the cline-core process with --config flag using system node
args := []string{finalClineCorePath,
"--port", fmt.Sprintf("%d", corePort),
"--host-bridge-port", fmt.Sprintf("%d", hostPort),
"--config", Config.ConfigPath}
if Config.Verbose {
fmt.Printf("Using system node\n")
}
cmd := exec.Command("node", args...)
// Set working directory to installation root
cmd.Dir = finalInstallDir
// Redirect stdout and stderr to log file
cmd.Stdout = logFile
cmd.Stderr = logFile
// Put the child process in a new process group so Ctrl+C doesn't kill it
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
// Set environment variables with NODE_PATH for both real and fake node_modules
// The fake node_modules contains the vscode stub that can't be in the real node_modules
env := os.Environ()
realNodeModules := path.Join(finalInstallDir, "node_modules")
fakeNodeModules := path.Join(finalInstallDir, "fake_node_modules")
nodePath := fmt.Sprintf("%s%c%s", realNodeModules, os.PathListSeparator, fakeNodeModules)
env = append(env,
fmt.Sprintf("NODE_PATH=%s", nodePath),
// These control gRPC debug logging
//"GRPC_TRACE=all",
//"GRPC_VERBOSITY=DEBUG",
"NODE_ENV=development",
)
cmd.Env = env
if Config.Verbose {
fmt.Printf("NODE_PATH set to: %s\n", nodePath)
}
if err := cmd.Start(); err != nil {
logFile.Close()
return nil, fmt.Errorf("failed to start cline-core: %w", err)
}
if Config.Verbose {
fmt.Printf("Started cline-core (PID: %d)\n", cmd.Process.Pid)
fmt.Printf("Logging cline-core output to: %s\n", logFilePath)
}
return cmd, nil
}
+282
View File
@@ -0,0 +1,282 @@
//go:build !windows
package global
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"time"
"github.com/cline/grpc-go/cline"
)
func startClineHost(hostPort int, workspaces []string) (*exec.Cmd, error) {
if Config.Verbose {
fmt.Printf("Starting cline-host on port %d\n", hostPort)
}
// Get the directory where the cline binary is located
execPath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("failed to get executable path: %w", err)
}
binDir := filepath.Dir(execPath)
clineHostPath := filepath.Join(binDir, "cline-host")
// Build command arguments
args := []string{
"--verbose",
"--port", fmt.Sprintf("%d", hostPort),
}
for _, ws := range workspaces {
args = append(args, "--workspace", ws)
}
// Start the cline-host process
cmd := exec.Command(clineHostPath, args...)
// Create logs directory in ~/.cline/logs
logsDir := filepath.Join(Config.ConfigPath, "logs")
if err := os.MkdirAll(logsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create logs directory: %w", err)
}
// Create timestamped log file
timestamp := time.Now().Format("2006-01-02-15-04-05")
logFileName := fmt.Sprintf("cline-host-%s-localhost-%d.log", timestamp, hostPort)
logFilePath := filepath.Join(logsDir, logFileName)
logFile, err := os.Create(logFilePath)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %w", err)
}
// Redirect stdout and stderr to log file
cmd.Stdout = logFile
cmd.Stderr = logFile
// Put the child process in a new process group so Ctrl+C doesn't kill it
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
if err := cmd.Start(); err != nil {
logFile.Close()
return nil, fmt.Errorf("failed to start cline-host: %w", err)
}
if Config.Verbose {
fmt.Printf("Started cline-host (PID: %d)\n", cmd.Process.Pid)
fmt.Printf("Logging cline-host output to: %s\n", logFilePath)
}
return cmd, nil
}
// KillInstanceByAddress kills a Cline instance by its address
func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, address string) error {
// Check if the instance exists in the registry
_, err := registry.GetInstance(address)
if err != nil {
return fmt.Errorf("instance %s not found in registry", address)
}
if Config.Verbose {
fmt.Printf("Killing instance: %s\n", address)
}
// Get gRPC client and process info
client, err := registry.GetClient(ctx, address)
if err != nil {
return fmt.Errorf("failed to connect to instance %s: %w", address, err)
}
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
if err != nil {
return fmt.Errorf("failed to get process info for instance %s: %w", address, err)
}
pid := int(processInfo.ProcessId)
if Config.Verbose {
fmt.Printf("Terminating process PID %d...\n", pid)
}
// Kill the process
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
return fmt.Errorf("failed to kill process %d: %w", pid, err)
}
// Wait for the instance to remove itself from registry
if Config.Verbose {
fmt.Printf("Waiting for instance to clean up registry entry...\n")
}
for range 5 {
time.Sleep(1 * time.Second)
if !registry.HasInstanceAtAddress(address) {
if Config.Verbose {
fmt.Printf("Instance %s successfully killed and removed from registry.\n", address)
}
// Update default instance if needed
instances, err := registry.ListInstancesCleaned(ctx)
if err == nil && len(instances) > 0 {
// ensureDefaultInstance logic will handle setting a new default
defaultInstance := registry.GetDefaultInstance()
if defaultInstance == address || defaultInstance == "" {
if len(instances) > 0 {
if err := registry.SetDefaultInstance(instances[0].Address); err == nil {
if Config.Verbose {
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
}
}
}
}
}
return nil
}
}
return fmt.Errorf("instance killed but failed to remove itself from registry within 5 seconds")
}
func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
if Config.Verbose {
fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort)
}
// Get the executable path and resolve symlinks (for npm global installs)
execPath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("failed to get executable path: %w", err)
}
// Resolve symlinks to get the real path
// For npm global installs, execPath might be a symlink like:
// /opt/homebrew/bin/cline -> /opt/homebrew/lib/node_modules/cline/bin/cline
realPath, err := filepath.EvalSymlinks(execPath)
if err != nil {
// If we can't resolve symlinks, fall back to the original path
realPath = execPath
if Config.Verbose {
fmt.Printf("Warning: Could not resolve symlinks for %s: %v\n", execPath, err)
}
}
binDir := filepath.Dir(realPath)
installDir := filepath.Dir(binDir)
clineCorePath := filepath.Join(installDir, "cline-core.js")
if Config.Verbose {
fmt.Printf("Executable path: %s\n", execPath)
if realPath != execPath {
fmt.Printf("Real path (after resolving symlinks): %s\n", realPath)
}
fmt.Printf("Bin directory: %s\n", binDir)
fmt.Printf("Install directory: %s\n", installDir)
fmt.Printf("Looking for cline-core.js at: %s\n", clineCorePath)
}
// Check if cline-core.js exists at the primary location
var finalClineCorePath string
var finalInstallDir string
if _, err := os.Stat(clineCorePath); os.IsNotExist(err) {
// Development mode: Try ../../dist-standalone/cline-core.js
// This handles the case where we're running from cli/bin/cline
devClineCorePath := filepath.Join(binDir, "..", "dist-standalone", "cline-core.js")
devInstallDir := filepath.Join(binDir, "..", "dist-standalone")
if Config.Verbose {
fmt.Printf("Primary location not found, trying development path: %s\n", devClineCorePath)
}
if _, err := os.Stat(devClineCorePath); os.IsNotExist(err) {
return nil, fmt.Errorf("cline-core.js not found at '%s' or '%s'. Please ensure you're running from the correct location or reinstall with 'npm install -g cline'", clineCorePath, devClineCorePath)
}
finalClineCorePath = devClineCorePath
finalInstallDir = devInstallDir
if Config.Verbose {
fmt.Printf("Using development mode: cline-core.js found at %s\n", finalClineCorePath)
}
} else {
finalClineCorePath = clineCorePath
finalInstallDir = installDir
if Config.Verbose {
fmt.Printf("Using production mode: cline-core.js found at %s\n", finalClineCorePath)
}
}
// Create logs directory in ~/.cline/logs
logsDir := filepath.Join(Config.ConfigPath, "logs")
if err := os.MkdirAll(logsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create logs directory: %w", err)
}
// Create timestamped log file
timestamp := time.Now().Format("2006-01-02-15-04-05")
logFileName := fmt.Sprintf("cline-core-%s-localhost-%d.log", timestamp, corePort)
logFilePath := filepath.Join(logsDir, logFileName)
logFile, err := os.Create(logFilePath)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %w", err)
}
// Start the cline-core process with --config flag using system node
args := []string{finalClineCorePath,
"--port", fmt.Sprintf("%d", corePort),
"--host-bridge-port", fmt.Sprintf("%d", hostPort),
"--config", Config.ConfigPath}
if Config.Verbose {
fmt.Printf("Using system node\n")
}
cmd := exec.Command("node", args...)
// Set working directory to installation root
cmd.Dir = finalInstallDir
// Redirect stdout and stderr to log file
cmd.Stdout = logFile
cmd.Stderr = logFile
// Put the child process in a new process group so Ctrl+C doesn't kill it
cmd.SysProcAttr = &syscall.SysProcAttr{
Setpgid: true,
}
// Set environment variables with NODE_PATH for both real and fake node_modules
// The fake node_modules contains the vscode stub that can't be in the real node_modules
env := os.Environ()
realNodeModules := filepath.Join(finalInstallDir, "node_modules")
fakeNodeModules := filepath.Join(finalInstallDir, "fake_node_modules")
nodePath := fmt.Sprintf("%s%c%s", realNodeModules, os.PathListSeparator, fakeNodeModules)
env = append(env,
fmt.Sprintf("NODE_PATH=%s", nodePath),
// These control gRPC debug logging
//"GRPC_TRACE=all",
//"GRPC_VERBOSITY=DEBUG",
"NODE_ENV=development",
)
cmd.Env = env
if Config.Verbose {
fmt.Printf("NODE_PATH set to: %s\n", nodePath)
}
if err := cmd.Start(); err != nil {
logFile.Close()
return nil, fmt.Errorf("failed to start cline-core: %w", err)
}
if Config.Verbose {
fmt.Printf("Started cline-core (PID: %d)\n", cmd.Process.Pid)
fmt.Printf("Logging cline-core output to: %s\n", logFilePath)
}
return cmd, nil
}
+289
View File
@@ -0,0 +1,289 @@
//go:build windows
package global
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"time"
"github.com/cline/grpc-go/cline"
)
func startClineHost(hostPort int, workspaces []string) (*exec.Cmd, error) {
if Config.Verbose {
fmt.Printf("Starting cline-host on port %d\n", hostPort)
}
// Get the directory where the cline binary is located
execPath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("failed to get executable path: %w", err)
}
binDir := filepath.Dir(execPath)
clineHostPath := filepath.Join(binDir, "cline-host.exe")
// Build command arguments
args := []string{
"--verbose",
"--port", fmt.Sprintf("%d", hostPort),
}
for _, ws := range workspaces {
args = append(args, "--workspace", ws)
}
// Start the cline-host process
cmd := exec.Command(clineHostPath, args...)
// Create logs directory in ~/.cline/logs
logsDir := filepath.Join(Config.ConfigPath, "logs")
if err := os.MkdirAll(logsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create logs directory: %w", err)
}
// Create timestamped log file
timestamp := time.Now().Format("2006-01-02-15-04-05")
logFileName := fmt.Sprintf("cline-host-%s-localhost-%d.log", timestamp, hostPort)
logFilePath := filepath.Join(logsDir, logFileName)
logFile, err := os.Create(logFilePath)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %w", err)
}
// Redirect stdout and stderr to log file
cmd.Stdout = logFile
cmd.Stderr = logFile
// Put the child process in a new process group so Ctrl+C doesn't kill it
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
}
if err := cmd.Start(); err != nil {
logFile.Close()
return nil, fmt.Errorf("failed to start cline-host: %w", err)
}
if Config.Verbose {
fmt.Printf("Started cline-host (PID: %d)\n", cmd.Process.Pid)
fmt.Printf("Logging cline-host output to: %s\n", logFilePath)
}
return cmd, nil
}
// KillInstanceByAddress kills a Cline instance by its address
func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, address string) error {
// Check if the instance exists in the registry
_, err := registry.GetInstance(address)
if err != nil {
return fmt.Errorf("instance %s not found in registry", address)
}
if Config.Verbose {
fmt.Printf("Killing instance: %s\n", address)
}
// Get gRPC client and process info
client, err := registry.GetClient(ctx, address)
if err != nil {
return fmt.Errorf("failed to connect to instance %s: %w", address, err)
}
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
if err != nil {
return fmt.Errorf("failed to get process info for instance %s: %w", address, err)
}
pid := int(processInfo.ProcessId)
if Config.Verbose {
fmt.Printf("Terminating process PID %d...\n", pid)
}
// Find and kill the process using os.Process
// On Windows, os.Process.Kill() properly calls TerminateProcess with the correct handle
process, err := os.FindProcess(pid)
if err != nil {
return fmt.Errorf("failed to find process %d: %w", pid, err)
}
if err := process.Kill(); err != nil {
return fmt.Errorf("failed to kill process %d: %w", pid, err)
}
// Wait for the instance to remove itself from registry
if Config.Verbose {
fmt.Printf("Waiting for instance to clean up registry entry...\n")
}
for range 5 {
time.Sleep(1 * time.Second)
if !registry.HasInstanceAtAddress(address) {
if Config.Verbose {
fmt.Printf("Instance %s successfully killed and removed from registry.\n", address)
}
// Update default instance if needed
instances, err := registry.ListInstancesCleaned(ctx)
if err == nil && len(instances) > 0 {
// ensureDefaultInstance logic will handle setting a new default
defaultInstance := registry.GetDefaultInstance()
if defaultInstance == address || defaultInstance == "" {
if len(instances) > 0 {
if err := registry.SetDefaultInstance(instances[0].Address); err == nil {
if Config.Verbose {
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
}
}
}
}
}
return nil
}
}
return fmt.Errorf("instance killed but failed to remove itself from registry within 5 seconds")
}
func startClineCore(corePort, hostPort int) (*exec.Cmd, error) {
if Config.Verbose {
fmt.Printf("Starting cline-core on port %d (with hostbridge on %d)\n", corePort, hostPort)
}
// Get the executable path and resolve symlinks (for npm global installs)
execPath, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("failed to get executable path: %w", err)
}
// Resolve symlinks to get the real path
// For npm global installs, execPath might be a symlink like:
// /opt/homebrew/bin/cline -> /opt/homebrew/lib/node_modules/cline/bin/cline
realPath, err := filepath.EvalSymlinks(execPath)
if err != nil {
// If we can't resolve symlinks, fall back to the original path
realPath = execPath
if Config.Verbose {
fmt.Printf("Warning: Could not resolve symlinks for %s: %v\n", execPath, err)
}
}
binDir := filepath.Dir(realPath)
installDir := filepath.Dir(binDir)
clineCorePath := filepath.Join(installDir, "cline-core.js")
if Config.Verbose {
fmt.Printf("Executable path: %s\n", execPath)
if realPath != execPath {
fmt.Printf("Real path (after resolving symlinks): %s\n", realPath)
}
fmt.Printf("Bin directory: %s\n", binDir)
fmt.Printf("Install directory: %s\n", installDir)
fmt.Printf("Looking for cline-core.js at: %s\n", clineCorePath)
}
// Check if cline-core.js exists at the primary location
var finalClineCorePath string
var finalInstallDir string
if _, err := os.Stat(clineCorePath); os.IsNotExist(err) {
// Development mode: Try ../../dist-standalone/cline-core.js
// This handles the case where we're running from cli/bin/cline
devClineCorePath := filepath.Join(binDir, "..", "dist-standalone", "cline-core.js")
devInstallDir := filepath.Join(binDir, "..", "dist-standalone")
if Config.Verbose {
fmt.Printf("Primary location not found, trying development path: %s\n", devClineCorePath)
}
if _, err := os.Stat(devClineCorePath); os.IsNotExist(err) {
return nil, fmt.Errorf("cline-core.js not found at '%s' or '%s'. Please ensure you're running from the correct location or reinstall with 'npm install -g cline'", clineCorePath, devClineCorePath)
}
finalClineCorePath = devClineCorePath
finalInstallDir = devInstallDir
if Config.Verbose {
fmt.Printf("Using development mode: cline-core.js found at %s\n", finalClineCorePath)
}
} else {
finalClineCorePath = clineCorePath
finalInstallDir = installDir
if Config.Verbose {
fmt.Printf("Using production mode: cline-core.js found at %s\n", finalClineCorePath)
}
}
// Create logs directory in ~/.cline/logs
logsDir := filepath.Join(Config.ConfigPath, "logs")
if err := os.MkdirAll(logsDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create logs directory: %w", err)
}
// Create timestamped log file
timestamp := time.Now().Format("2006-01-02-15-04-05")
logFileName := fmt.Sprintf("cline-core-%s-localhost-%d.log", timestamp, corePort)
logFilePath := filepath.Join(logsDir, logFileName)
logFile, err := os.Create(logFilePath)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %w", err)
}
// Start the cline-core process with --config flag using system node
args := []string{finalClineCorePath,
"--port", fmt.Sprintf("%d", corePort),
"--host-bridge-port", fmt.Sprintf("%d", hostPort),
"--config", Config.ConfigPath}
if Config.Verbose {
fmt.Printf("Using system node\n")
}
cmd := exec.Command("node", args...)
// Set working directory to installation root
cmd.Dir = finalInstallDir
// Redirect stdout and stderr to log file
cmd.Stdout = logFile
cmd.Stderr = logFile
// Put the child process in a new process group so Ctrl+C doesn't kill it
cmd.SysProcAttr = &syscall.SysProcAttr{
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
}
// Set environment variables with NODE_PATH for both real and fake node_modules
// The fake node_modules contains the vscode stub that can't be in the real node_modules
env := os.Environ()
realNodeModules := filepath.Join(finalInstallDir, "node_modules")
fakeNodeModules := filepath.Join(finalInstallDir, "fake_node_modules")
nodePath := fmt.Sprintf("%s%c%s", realNodeModules, os.PathListSeparator, fakeNodeModules)
env = append(env,
fmt.Sprintf("NODE_PATH=%s", nodePath),
// These control gRPC debug logging
//"GRPC_TRACE=all",
//"GRPC_VERBOSITY=DEBUG",
"NODE_ENV=development",
)
cmd.Env = env
if Config.Verbose {
fmt.Printf("NODE_PATH set to: %s\n", nodePath)
fmt.Printf("Attempting to run command: %s\n", cmd)
}
if err := cmd.Start(); err != nil {
logFile.Close()
return nil, fmt.Errorf("failed to start cline-core: %w", err)
}
if Config.Verbose {
fmt.Printf("Started cline-core (PID: %d)\n", cmd.Process.Pid)
fmt.Printf("Logging cline-core output to: %s\n", logFilePath)
}
return cmd, nil
}
-23
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"os"
"strings"
"syscall"
"text/tabwriter"
"time"
@@ -247,28 +246,6 @@ type killResult struct {
err error
}
func killInstanceProcess(ctx context.Context, registry *global.ClientRegistry, address string) killResult {
// Get gRPC client and process info
client, err := registry.GetClient(ctx, address)
if err != nil {
return killResult{address: address, alreadyDead: true, err: nil}
}
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
if err != nil {
return killResult{address: address, alreadyDead: true, err: nil}
}
pid := int(processInfo.ProcessId)
// Kill the process
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
return killResult{address: address, pid: pid, err: err}
}
return killResult{address: address, pid: pid, err: nil}
}
func newInstanceListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
+33
View File
@@ -0,0 +1,33 @@
//go:build !windows
package cli
import (
"context"
"syscall"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/grpc-go/cline"
)
func killInstanceProcess(ctx context.Context, registry *global.ClientRegistry, address string) killResult {
// Get gRPC client and process info
client, err := registry.GetClient(ctx, address)
if err != nil {
return killResult{address: address, alreadyDead: true, err: nil}
}
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
if err != nil {
return killResult{address: address, alreadyDead: true, err: nil}
}
pid := int(processInfo.ProcessId)
// Kill the process
if err := syscall.Kill(pid, syscall.SIGTERM); err != nil {
return killResult{address: address, pid: pid, err: err}
}
return killResult{address: address, pid: pid, err: nil}
}
+41
View File
@@ -0,0 +1,41 @@
//go:build windows
package cli
import (
"context"
"fmt"
"os"
"github.com/cline/cli/pkg/cli/global"
"github.com/cline/grpc-go/cline"
)
func killInstanceProcess(ctx context.Context, registry *global.ClientRegistry, address string) killResult {
// Get gRPC client and process info
client, err := registry.GetClient(ctx, address)
if err != nil {
return killResult{address: address, alreadyDead: true, err: nil}
}
processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{})
if err != nil {
return killResult{address: address, alreadyDead: true, err: nil}
}
pid := int(processInfo.ProcessId)
// Find the process by PID
process, err := os.FindProcess(pid)
if err != nil {
// Process may already be dead
return killResult{address: address, pid: pid, alreadyDead: true, err: nil}
}
// Kill the process - on Windows, os.Process.Kill() calls TerminateProcess internally
if err := process.Kill(); err != nil {
return killResult{address: address, pid: pid, err: fmt.Errorf("failed to terminate process: %w", err)}
}
return killResult{address: address, pid: pid, err: nil}
}
+14 -11
View File
@@ -8,6 +8,19 @@ import (
"github.com/spf13/cobra"
)
// VersionString returns the full version information string
func VersionString() string {
return fmt.Sprintf(`Cline CLI
Cline CLI Version: %s
Cline Core Version: %s
Commit: %s
Built: %s
Built by: %s
Go version: %s
OS/Arch: %s/%s
`, global.CliVersion, global.Version, global.Commit, global.Date, global.BuiltBy, runtime.Version(), runtime.GOOS, runtime.GOARCH)
}
// NewVersionCommand creates the version command
func NewVersionCommand() *cobra.Command {
var short bool
@@ -18,21 +31,11 @@ func NewVersionCommand() *cobra.Command {
Short: "Show version information",
Long: `Display version information for the Cline CLI.`,
RunE: func(cmd *cobra.Command, args []string) error {
// Versions are injected at build time via ldflags
if short {
fmt.Println(global.CliVersion)
return nil
}
fmt.Printf("Cline CLI\n")
fmt.Printf("Cline CLI Version: %s\n", global.CliVersion)
fmt.Printf("Cline Core Version: %s\n", global.Version)
fmt.Printf("Commit: %s\n", global.Commit)
fmt.Printf("Built: %s\n", global.Date)
fmt.Printf("Built by: %s\n", global.BuiltBy)
fmt.Printf("Go version: %s\n", runtime.Version())
fmt.Printf("OS/Arch: %s/%s\n", runtime.GOOS, runtime.GOARCH)
fmt.Print(VersionString())
return nil
},
}
+17 -5
View File
@@ -47,7 +47,10 @@ func (s *DiffService) generateDiffID() string {
return fmt.Sprintf("diff_%d_%d", os.Getpid(), id)
}
// splitLines splits content into lines, preserving line ending information
// splitLines splits content into lines, preserving trailing newlines.
// This matches the behavior of JavaScript's String.split("\n"):
// - "hello\nworld\n" -> ["hello", "world", ""]
// - "hello\nworld" -> ["hello", "world"]
func splitLines(content string) []string {
if content == "" {
return []string{}
@@ -65,10 +68,9 @@ func splitLines(content string) []string {
}
}
// Add the last line if it doesn't end with newline
if current != "" {
lines = append(lines, current)
}
// Always add the last segment - if content ends with newline, this will be
// an empty string which preserves the trailing newline when joined back
lines = append(lines, current)
return lines
}
@@ -176,9 +178,19 @@ func (s *DiffService) ReplaceText(ctx context.Context, req *proto.ReplaceTextReq
endLine = startLine
}
// Check if we're replacing to the end of the document
replacingToEnd := endLine >= len(session.lines)
// Split new content into lines
newLines := splitLines(newContent)
// Remove trailing empty line for proper splicing, BUT only when NOT replacing
// to the end of the document. When replacing to the end, keep the trailing
// empty string to preserve trailing newlines from the content.
if !replacingToEnd && len(newLines) > 0 && newLines[len(newLines)-1] == "" {
newLines = newLines[:len(newLines)-1]
}
// Ensure we have enough lines in the current content
for len(session.lines) < endLine {
session.lines = append(session.lines, "")
+1
View File
@@ -177,6 +177,7 @@
"features/tasks/task-management"
]
},
"features/worktrees",
"features/yolo-mode"
]
},
+4 -1
View File
@@ -48,7 +48,7 @@ These labels match what you see in the Auto Approve menu.
| Execute all commands | Run commands marked as requiring approval | Requires “Execute safe commands” |
| Use the browser | Allows use of the browser tool for web fetching and searching | Proxy issues can apply |
| Use MCP servers | Use MCP tools and access MCP resources | Some servers also have per-tool auto-approve |
| Enable notifications | Notifies you about long-running auto-approved commands | Helpful for terminal work |
| Enable notifications | Notifies you about long-running auto-approved commands | Accessible directly in the Auto Approve menu |
<Warning>
“Read all files” and “Edit all files” only matter if their base toggle is enabled. They extend access outside your workspace.
@@ -92,6 +92,9 @@ These are examples, not guarantees.
Auto-approved actions can run for a while, especially long terminal commands. If you enable notifications, Cline can notify you when an auto-approved command has been running for a while and may need attention.
The **Enable notifications** toggle is located at the bottom of the Auto Approve menu, below a separator line. This puts the notification setting right where you manage your auto-approval permissions, making it easy to discover and adjust.
## Recommendations
A good default setup is:
+270
View File
@@ -0,0 +1,270 @@
---
title: "Worktrees"
sidebarTitle: "Worktrees"
---
Worktrees let you work on multiple branches simultaneously, each in its own folder. This enables Cline to work on tasks in parallel across separate VS Code windows, or lets Cline work independently while you continue coding in your main workspace.
<Frame>
<img
src="https://storage.googleapis.com/cline_public_images/docs/assets/worktrees-overview.png"
alt="Worktrees view showing multiple linked worktrees"
/>
</Frame>
## What Are Git Worktrees?
A Git worktree is a linked copy of your repository in a separate folder, checked out to a specific branch. All worktrees share the same Git history and `.git` directory, but each has its own working directory with different code checked out.
Key concepts:
- **Main worktree**: Your original repository folder where the `.git` directory lives
- **Linked worktrees**: Additional folders you create, each checked out to a different branch
- **Shared history**: All worktrees share commits, branches, and Git configuration
<Tip>
Unlike regular branch switching, worktrees let you have multiple branches checked out at the same time in different folders. This means you can have VS Code windows open for different features simultaneously.
</Tip>
## Why Use Worktrees with Cline?
Worktrees solve a common problem: **Cline takes over your VS Code window while working on a task**. With worktrees, you can:
1. **Run Cline in parallel** - Have Cline work on multiple tasks simultaneously, each in its own worktree and VS Code window
2. **Keep working while Cline works** - Let Cline handle a task in a separate worktree while you continue coding in your main workspace
3. **Isolate experimental changes** - Test risky changes in a worktree without affecting your main branch
4. **Quick context switching** - Jump between features without stashing or committing incomplete work
## Getting Started
### Quick Launch (Recommended)
The fastest way to start using worktrees is the **New Worktree Window** button on Cline's home screen:
1. Click **New Worktree Window** on the home screen
2. Enter a branch name and folder path (defaults are auto-filled)
3. Click **Create & Open**
A new VS Code window opens with your worktree, and Cline automatically opens ready to work.
<Tip>
The home screen also shows your current branch and worktree path. Click it to open the full Worktrees view.
</Tip>
### Full Worktrees View
For more control, open the full Worktrees view by clicking the **Worktrees** button in the Cline sidebar header, or by clicking your current branch info on the home screen:
<Steps>
<Step title="Create a New Worktree">
Click **New Worktree** at the bottom of the view. Enter a branch name and path (defaults are auto-filled).
</Step>
<Step title="Open in New Window">
Once created, click the **Open in new window** button to open the worktree in a separate VS Code window. Cline will automatically open in the new window.
</Step>
</Steps>
## Typical Workflow
Here's how a typical worktree session looks:
<Steps>
<Step title="Create a new worktree">
Click **New Worktree Window** on the home screen or use the Worktrees view. A new VS Code window opens with Cline ready to go.
</Step>
<Step title="Do your work">
Work on your feature or let Cline handle a task. Make commits as you go.
</Step>
<Step title="Close the worktree window">
When you're done, close the worktree's VS Code window.
</Step>
<Step title="Merge from your primary worktree">
Back in your main VS Code window, open the Worktrees view and click the **merge button** on the worktree you just worked in. This merges the branch and optionally deletes the worktree.
</Step>
</Steps>
## Managing Worktrees
### Viewing Worktrees
The Worktrees view shows all worktrees for your repository:
- **Current**: The worktree you're currently in (highlighted)
- **Main**: The primary worktree where your `.git` directory lives (cannot be deleted)
- **Locked**: Worktrees that are locked to prevent accidental deletion
### Opening Worktrees
Each worktree has two open options:
- **Open in current window**: Replace your current workspace with the worktree
- **Open in new window**: Open the worktree in a separate VS Code window (recommended for parallel Cline sessions)
Either way, Cline automatically opens in the new workspace, ready to start a task.
### Deleting Worktrees
Click the trash icon on any linked worktree to delete it. A confirmation dialog will show you exactly what will be deleted:
- The branch itself
- All project files in the worktree folder
<Warning>
Deleting a worktree permanently removes the branch and all files in that folder. Make sure any important changes are committed and pushed first.
</Warning>
<Note>
You cannot delete the main worktree. It's the primary repository where your `.git` directory lives.
</Note>
### Merging Worktrees
When you're done working in a worktree and ready to merge your changes back to the main branch:
1. Click the **merge icon** (git merge symbol) on any linked worktree
2. Review the merge details in the confirmation modal
3. Choose whether to delete the worktree after merging
4. Click **Merge**
<Frame>
<img
src="https://storage.googleapis.com/cline_public_images/docs/assets/worktrees-merge.png"
alt="Merge worktree modal"
/>
</Frame>
#### Handling Merge Conflicts
If your branch has conflicts with the main branch, Cline will detect them and show you the conflicting files. You have two options:
1. **Ask Cline to Resolve & Merge** - Creates a new Cline task with a prompt asking Cline to resolve the conflicts, complete the merge, and clean up the worktree
2. **Resolve Manually** - Close the modal and resolve conflicts yourself using your preferred Git tools
<Tip>
The "Ask Cline to Resolve" option is particularly useful for complex conflicts. Cline will analyze the conflicting files and attempt to merge them intelligently based on the intent of both branches.
</Tip>
## .worktreeinclude: Automatic File Copying
When you create a new worktree, it starts with a fresh checkout—no `node_modules`, no build artifacts, no IDE settings. This means you'd normally need to run `npm install` or similar setup commands.
The `.worktreeinclude` file solves this by automatically copying specified files to new worktrees.
### How It Works
1. Create a `.worktreeinclude` file in your repository root
2. Add glob patterns for files you want copied (using `.gitignore` syntax)
3. When Cline creates a new worktree, files matching **both** `.worktreeinclude` **and** `.gitignore` are copied automatically
<Note>
Only files that are both matched by `.worktreeinclude` AND listed in `.gitignore` are copied. This prevents accidentally duplicating tracked files.
</Note>
### Example `.worktreeinclude`
```gitignore
# Copy node_modules to avoid npm install
node_modules/
# Copy IDE settings
.vscode/
# Copy build cache
.next/
dist/
# Copy environment files (if gitignored)
.env.local
```
### Creating a `.worktreeinclude` File
The Worktrees view will show a tip if you don't have a `.worktreeinclude` file. If you have a `.gitignore`, you can click **Create from .gitignore** to create one pre-filled with your gitignore contents. Then edit it to keep only the patterns you want copied.
<Tip>
For most JavaScript/TypeScript projects, just including `node_modules/` in your `.worktreeinclude` saves significant setup time for each new worktree.
</Tip>
### Pro Tip: Symlink to .gitignore
Since `.gitignore` usually contains most of the files you'd want copied to new worktrees (dependencies, environment files, build caches, etc.), you can create a symlink so they stay in sync automatically:
```bash
# In your repository root
ln -s .gitignore .worktreeinclude
```
Now whenever you update your `.gitignore`, your `.worktreeinclude` will have the same patterns. This is especially useful for projects where gitignored files are exactly what you want copied—no need to maintain two separate files.
<Note>
If you need different patterns than your `.gitignore`, create a regular `.worktreeinclude` file instead of a symlink.
</Note>
## Best Practices
<AccordionGroup>
<Accordion title="For Parallel Cline Sessions">
1. **Create purpose-specific worktrees** - Name branches clearly (e.g., `cline/refactor-auth`, `cline/add-tests`)
2. **Open in new windows** - Always use "Open in new window" for true parallelism
3. **Use .worktreeinclude** - Set up automatic file copying to reduce setup time
</Accordion>
<Accordion title="For Solo Development">
1. **Keep your main branch clean** - Use worktrees for experimental or risky changes
2. **Quick feature switches** - Instead of stashing, create a worktree for interruptions
3. **Review in isolation** - Create worktrees to review PRs without disrupting your work
</Accordion>
<Accordion title="Worktree Hygiene">
1. **Delete unused worktrees** - Remove worktrees when their branches are merged
2. **Use meaningful names** - Branch names should indicate the worktree's purpose
3. **Check for stale worktrees** - Periodically review and clean up old worktrees
</Accordion>
</AccordionGroup>
## Limitations
Worktrees are not available in certain workspace configurations:
- **Multi-root workspaces**: If you have multiple folders open in VS Code, worktrees are disabled. Open a single repository folder instead.
- **Subfolder of a repository**: If you've opened a subfolder within a Git repository (not the root), worktrees are disabled. Open the repository root folder instead.
The Worktrees view will display a message explaining the limitation if either of these applies to your workspace.
## Troubleshooting
<AccordionGroup>
<Accordion title="Branch already exists error">
Git doesn't allow the same branch to be checked out in multiple worktrees. Either:
- Use a different branch name
- Delete the existing worktree using that branch
</Accordion>
<Accordion title="Worktree folder already exists">
The path you specified already contains files. Choose a different path or delete the existing folder first.
</Accordion>
<Accordion title="Can't delete worktree">
If a worktree is locked, you'll need to unlock it first using `git worktree unlock <path>` in the terminal. If the worktree has uncommitted changes, you may need to use force delete.
</Accordion>
<Accordion title=".worktreeinclude files not copying">
Make sure the files you want copied are:
1. Listed in your `.worktreeinclude` file
2. Also listed in your `.gitignore` (only gitignored files are copied)
3. Actually exist in your current worktree
</Accordion>
</AccordionGroup>
## Technical Details
<AccordionGroup>
<Accordion title="How Worktrees Work Internally">
- Worktrees are a native Git feature (`git worktree` command)
- All worktrees share the same `.git` directory and object database
- Each worktree has its own index, working directory, and HEAD
- Worktree list is stored in `.git/worktrees/`
</Accordion>
<Accordion title="Storage Considerations">
- Each worktree contains a full checkout of the repository
- `.worktreeinclude` can significantly increase worktree size (e.g., copying `node_modules`)
- Consider your disk space when creating many worktrees
</Accordion>
<Accordion title="Relationship with Checkpoints">
Worktrees are separate from Cline's [checkpoint system](/features/checkpoints). Each worktree has its own checkpoint history. Checkpoints track changes within a single worktree, while worktrees let you work across multiple branches simultaneously.
</Accordion>
</AccordionGroup>
Worktrees unlock true parallel development with Cline. Create a worktree, open it in a new window, and let Cline work independently while you continue coding!
+6076 -3516
View File
File diff suppressed because it is too large Load Diff
+25 -4
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.49.1",
"version": "3.51.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -339,12 +339,28 @@
}
},
"scripts": {
"build": "npm run build:all-surfaces",
"build:vscode": "npx tsx scripts/build.ts --surface=vscode",
"build:vscode:prod": "npx tsx scripts/build.ts --surface=vscode --prod",
"build:jetbrains": "npx tsx scripts/build.ts --surface=jetbrains",
"build:jetbrains:prod": "npx tsx scripts/build.ts --surface=jetbrains --prod",
"build:cli:unix": "npx tsx scripts/build.ts --surface=cli --platform=unix",
"build:cli:windows": "npx tsx scripts/build.ts --surface=cli --platform=windows",
"build:cli:all-platforms": "npx tsx scripts/build.ts --surface=cli --platform=all",
"build:cli:unix:prod": "npx tsx scripts/build.ts --surface=cli --platform=unix --prod",
"build:cli:windows:prod": "npx tsx scripts/build.ts --surface=cli --platform=windows --prod",
"build:cli:all-platforms:prod": "npx tsx scripts/build.ts --surface=cli --platform=all --prod",
"build:all-surfaces": "npx tsx scripts/build.ts --surface=all",
"build:all-surfaces:prod": "npx tsx scripts/build.ts --surface=all --prod",
"build:all-surfaces:all-platforms": "npx tsx scripts/build.ts --surface=all --platform=all",
"build:all-surfaces:all-platforms:prod": "npx tsx scripts/build.ts --surface=all --platform=all --prod",
"build:all-surfaces:all-platforms:all-stages": "npx tsx scripts/build.ts --surface=all --platform=all --all-stages",
"build:npm": "npx tsx scripts/build.ts --surface=npm",
"build:npm:prod": "npx tsx scripts/build.ts --surface=npm --prod",
"vscode:prepublish": "npm run package",
"compile": "npm run check-types && npm run lint && node esbuild.mjs",
"compile-standalone": "npm run check-types && npm run lint && node esbuild.mjs --standalone",
"compile-standalone-npm": "npm run protos && npm run protos-go && npm run check-types && npm run lint && node esbuild.mjs --standalone",
"compile-cli": "scripts/build-cli.sh",
"compile-cli-all-platforms": "scripts/build-cli-all-platforms.sh",
"compile-cli-man-page": "pandoc cli/man/cline.1.md -s -t man -o cli/man/cline.1",
"test:install": "bash scripts/test-install.sh",
"dev:cli:watch": "node scripts/dev-cli-watch.mjs",
@@ -403,6 +419,10 @@
"storybook": "cd webview-ui && npm run storybook"
},
"lint-staged": {
"src/shared/storage/state-keys.ts": [
"node scripts/generate-state-proto.mjs",
"git add proto/cline/state.proto"
],
"*": [
"biome check --write --staged --no-errors-on-unmatched --files-ignore-unknown=true"
]
@@ -495,7 +515,7 @@
"@vscode/codicons": "^0.0.36",
"archiver": "^7.0.1",
"axios": "^1.12.0",
"better-sqlite3": "^12.4.1",
"better-sqlite3": "^12.5.0",
"cheerio": "^1.0.0",
"chokidar": "^4.0.1",
"chrome-launcher": "^1.1.2",
@@ -528,6 +548,7 @@
"p-timeout": "^6.1.4",
"p-wait-for": "^5.0.2",
"pdf-parse": "^1.1.1",
"picomatch": "^4.0.3",
"posthog-node": "^5.8.0",
"puppeteer-chromium-resolver": "^23.0.0",
"puppeteer-core": "^23.4.0",
+218 -171
View File
@@ -54,184 +54,230 @@ message AutoApprovalSettings {
optional bool enable_notifications = 3;
}
// NOTE: Add the new secret fields under SECRETS_KEYS in src/shared/storage/state-keys.ts
// and use the scripts/generate-state-proto.mjs script to regenerate this list.
message Secrets {
optional string api_key = 1;
optional string open_router_api_key = 4;
optional string aws_access_key = 5;
optional string aws_secret_key = 6;
optional string aws_session_token = 7;
optional string aws_bedrock_api_key = 8;
optional string open_ai_api_key = 9;
optional string gemini_api_key = 10;
optional string open_ai_native_api_key = 11;
optional string ollama_api_key = 12;
optional string deep_seek_api_key = 13;
optional string requesty_api_key = 14;
optional string together_api_key = 15;
optional string fireworks_api_key = 16;
optional string qwen_api_key = 17;
optional string doubao_api_key = 18;
optional string mistral_api_key = 19;
optional string lite_llm_api_key = 20;
optional string auth_nonce = 21;
optional string asksage_api_key = 22;
optional string xai_api_key = 23;
optional string moonshot_api_key = 24;
optional string zai_api_key = 25;
optional string hugging_face_api_key = 26;
optional string nebius_api_key = 27;
optional string sambanova_api_key = 28;
optional string cerebras_api_key = 29;
optional string sap_ai_core_client_id = 30;
optional string sap_ai_core_client_secret = 31;
optional string groq_api_key = 32;
optional string huawei_cloud_maas_api_key = 33;
optional string baseten_api_key = 34;
optional string vercel_ai_gateway_api_key = 35;
optional string dify_api_key = 36;
optional string oca_api_key = 37;
optional string oca_refresh_token = 38;
optional string hicap_api_key = 39;
optional string mcp_oauth_secrets = 40;
optional string cline_account_id = 2;
optional string open_router_api_key = 3;
optional string aws_access_key = 4;
optional string aws_secret_key = 5;
optional string aws_session_token = 6;
optional string aws_bedrock_api_key = 7;
optional string open_ai_api_key = 8;
optional string gemini_api_key = 9;
optional string open_ai_native_api_key = 10;
optional string ollama_api_key = 11;
optional string deep_seek_api_key = 12;
optional string requesty_api_key = 13;
optional string together_api_key = 14;
optional string fireworks_api_key = 15;
optional string qwen_api_key = 16;
optional string doubao_api_key = 17;
optional string mistral_api_key = 18;
optional string lite_llm_api_key = 19;
optional string auth_nonce = 20;
optional string asksage_api_key = 21;
optional string xai_api_key = 22;
optional string moonshot_api_key = 23;
optional string zai_api_key = 24;
optional string hugging_face_api_key = 25;
optional string nebius_api_key = 26;
optional string sambanova_api_key = 27;
optional string cerebras_api_key = 28;
optional string sap_ai_core_client_id = 29;
optional string sap_ai_core_client_secret = 30;
optional string groq_api_key = 31;
optional string huawei_cloud_maas_api_key = 32;
optional string baseten_api_key = 33;
optional string vercel_ai_gateway_api_key = 34;
optional string dify_api_key = 35;
optional string minimax_api_key = 36;
optional string hicap_api_key = 37;
optional string aihubmix_api_key = 38;
optional string nous_research_api_key = 39;
optional string remote_lite_llm_api_key = 40;
optional string oca_api_key = 41;
optional string oca_refresh_token = 42;
optional string mcp_o_auth_secrets = 43;
}
// NOTE: Add new fields under API_HANDLER_SETTINGS_FIELDS or USER_SETTINGS_FIELDS
// in src/shared/storage/state-keys.ts and use the scripts/generate-state-proto.mjs
// script to regenerate this list.
message Settings {
optional string aws_region = 1;
optional bool aws_use_cross_region_inference = 2;
optional bool aws_bedrock_use_prompt_cache = 3;
optional string aws_bedrock_endpoint = 4;
optional string aws_profile = 5;
optional string aws_authentication = 6;
optional bool aws_use_profile = 7;
optional string vertex_project_id = 8;
optional string vertex_region = 9;
optional string requesty_base_url = 10;
optional string open_ai_base_url = 11;
// map<string, string> open_ai_headers = 12;
optional string ollama_base_url = 13;
optional string ollama_api_options_ctx_num = 14;
optional string lm_studio_base_url = 15;
optional string lm_studio_max_tokens = 16;
optional string anthropic_base_url = 17;
optional string gemini_base_url = 18;
optional string azure_api_version = 19;
optional string open_router_provider_sorting = 20;
optional AutoApprovalSettings auto_approval_settings = 21;
optional BrowserSettings browser_settings = 24;
optional string lite_llm_base_url = 25;
optional bool lite_llm_use_prompt_cache = 26;
optional int32 fireworks_model_max_completion_tokens = 27;
optional int32 fireworks_model_max_tokens = 28;
optional string lite_llm_base_url = 1;
optional bool lite_llm_use_prompt_cache = 2;
map<string, string> open_ai_headers = 3;
optional string anthropic_base_url = 4;
optional string open_router_provider_sorting = 5;
optional string aws_region = 6;
optional bool aws_use_cross_region_inference = 7;
optional bool aws_use_global_inference = 8;
optional bool aws_bedrock_use_prompt_cache = 9;
optional string aws_authentication = 10;
optional bool aws_use_profile = 11;
optional string aws_profile = 12;
optional string aws_bedrock_endpoint = 13;
optional string claude_code_path = 14;
optional string vertex_project_id = 15;
optional string vertex_region = 16;
optional string open_ai_base_url = 17;
optional string ollama_base_url = 18;
optional string ollama_api_options_ctx_num = 19;
optional string lm_studio_base_url = 20;
optional string lm_studio_max_tokens = 21;
optional string gemini_base_url = 22;
optional string requesty_base_url = 23;
optional int32 fireworks_model_max_completion_tokens = 24;
optional int32 fireworks_model_max_tokens = 25;
optional string qwen_code_oauth_path = 26;
optional string azure_api_version = 27;
optional bool azure_identity = 28;
optional string qwen_api_line = 29;
optional string moonshot_api_line = 30;
optional string zai_api_line = 31;
optional string telemetry_setting = 32;
optional string asksage_api_url = 33;
optional bool plan_act_separate_models_setting = 34;
optional bool enable_checkpoints_setting = 35;
optional int32 request_timeout_ms = 36;
optional int32 shell_integration_timeout = 37;
optional string default_terminal_profile = 38;
optional int32 terminal_output_line_limit = 39;
optional string sap_ai_core_token_url = 40;
optional string sap_ai_core_base_url = 41;
optional string sap_ai_resource_group = 42;
optional bool sap_ai_core_use_orchestration_mode = 43;
optional string claude_code_path = 44;
optional string qwen_code_oauth_path = 45;
optional bool strict_plan_mode_enabled = 46;
optional bool yolo_mode_toggled = 47;
optional bool use_auto_condense = 48;
optional string preferred_language = 49;
optional OpenaiReasoningEffort openai_reasoning_effort = 50;
optional PlanActMode mode = 51;
optional DictationSettings dictation_settings = 52;
optional FocusChainSettings focus_chain_settings = 53;
optional string custom_prompt = 54;
optional string dify_base_url = 55;
optional double auto_condense_threshold = 56;
optional string oca_base_url = 57;
optional ApiProvider plan_mode_api_provider = 58;
optional string plan_mode_api_model_id = 59;
optional int64 plan_mode_thinking_budget_tokens = 60;
optional string plan_mode_reasoning_effort = 61;
optional LanguageModelChatSelector plan_mode_vs_code_lm_model_selector = 62;
optional bool plan_mode_aws_bedrock_custom_selected = 63;
optional string plan_mode_aws_bedrock_custom_model_base_id = 64;
optional string plan_mode_open_router_model_id = 65;
optional OpenRouterModelInfo plan_mode_open_router_model_info = 66;
optional string plan_mode_open_ai_model_id = 67;
optional OpenAiCompatibleModelInfo plan_mode_open_ai_model_info = 68;
optional string plan_mode_ollama_model_id = 69;
optional string plan_mode_lm_studio_model_id = 70;
optional string plan_mode_lite_llm_model_id = 71;
optional LiteLLMModelInfo plan_mode_lite_llm_model_info = 72;
optional string plan_mode_requesty_model_id = 73;
optional OpenRouterModelInfo plan_mode_requesty_model_info = 74;
optional string plan_mode_together_model_id = 75;
optional string plan_mode_fireworks_model_id = 76;
optional string plan_mode_sap_ai_core_model_id = 77;
optional string plan_mode_sap_ai_core_deployment_id = 78;
optional string plan_mode_groq_model_id = 79;
optional OpenRouterModelInfo plan_mode_groq_model_info = 80;
optional string plan_mode_baseten_model_id = 81;
optional OpenRouterModelInfo plan_mode_baseten_model_info = 82;
optional string plan_mode_hugging_face_model_id = 83;
optional OpenRouterModelInfo plan_mode_hugging_face_model_info = 84;
optional string plan_mode_huawei_cloud_maas_model_id = 85;
optional OpenRouterModelInfo plan_mode_huawei_cloud_maas_model_info = 86;
optional string plan_mode_oca_model_id = 87;
optional OcaModelInfo plan_mode_oca_model_info = 88;
optional ApiProvider act_mode_api_provider = 89;
optional string act_mode_api_model_id = 90;
optional int64 act_mode_thinking_budget_tokens = 91;
optional string act_mode_reasoning_effort = 92;
optional LanguageModelChatSelector act_mode_vs_code_lm_model_selector = 93;
optional bool act_mode_aws_bedrock_custom_selected = 94;
optional string act_mode_aws_bedrock_custom_model_base_id = 95;
optional string act_mode_open_router_model_id = 96;
optional OpenRouterModelInfo act_mode_open_router_model_info = 97;
optional string act_mode_open_ai_model_id = 98;
optional OpenAiCompatibleModelInfo act_mode_open_ai_model_info = 99;
optional string act_mode_ollama_model_id = 100;
optional string act_mode_lm_studio_model_id = 101;
optional string act_mode_lite_llm_model_id = 102;
optional LiteLLMModelInfo act_mode_lite_llm_model_info = 103;
optional string act_mode_requesty_model_id = 104;
optional OpenRouterModelInfo act_mode_requesty_model_info = 105;
optional string act_mode_together_model_id = 106;
optional string act_mode_fireworks_model_id = 107;
optional string act_mode_sap_ai_core_model_id = 108;
optional string act_mode_sap_ai_core_deployment_id = 109;
optional string act_mode_groq_model_id = 110;
optional OpenRouterModelInfo act_mode_groq_model_info = 111;
optional string act_mode_baseten_model_id = 112;
optional OpenRouterModelInfo act_mode_baseten_model_info = 113;
optional string act_mode_hugging_face_model_id = 114;
optional OpenRouterModelInfo act_mode_hugging_face_model_info = 115;
optional string act_mode_huawei_cloud_maas_model_id = 116;
optional OpenRouterModelInfo act_mode_huawei_cloud_maas_model_info = 117;
optional string plan_mode_vercel_ai_gateway_model_id = 118;
optional OpenRouterModelInfo plan_mode_vercel_ai_gateway_model_info = 119;
optional string act_mode_vercel_ai_gateway_model_id = 120;
optional OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 121;
optional string act_mode_oca_model_id = 122;
optional OcaModelInfo act_mode_oca_model_info = 123;
optional int32 max_consecutive_mistakes = 124;
optional bool subagents_enabled = 125;
optional int32 subagent_terminal_output_line_limit = 126;
optional string aihubmix_api_key = 127;
optional string aihubmix_base_url = 128;
optional string aihubmix_app_code = 129;
optional string plan_mode_aihubmix_model_id = 130;
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 131;
optional string act_mode_aihubmix_model_id = 132;
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 133;
optional bool cline_web_tools_enabled = 134;
optional bool hooks_enabled = 135;
optional bool azure_identity = 136;
optional bool skills_enabled = 137;
optional bool opt_out_of_remote_config = 138;
optional string asksage_api_url = 31;
optional int32 request_timeout_ms = 32;
optional string sap_ai_resource_group = 33;
optional string sap_ai_core_token_url = 34;
optional string sap_ai_core_base_url = 35;
optional bool sap_ai_core_use_orchestration_mode = 36;
optional string dify_base_url = 37;
optional string zai_api_line = 38;
optional string oca_base_url = 39;
optional string minimax_api_line = 40;
optional string oca_mode = 41;
optional string aihubmix_base_url = 42;
optional string aihubmix_app_code = 43;
optional string plan_mode_api_model_id = 44;
optional int64 plan_mode_thinking_budget_tokens = 45;
optional string gemini_plan_mode_thinking_level = 46;
optional string plan_mode_reasoning_effort = 47;
optional string plan_mode_verbosity = 48;
optional LanguageModelChatSelector plan_mode_vs_code_lm_model_selector = 49;
optional bool plan_mode_aws_bedrock_custom_selected = 50;
optional string plan_mode_aws_bedrock_custom_model_base_id = 51;
optional string plan_mode_open_router_model_id = 52;
optional OpenRouterModelInfo plan_mode_open_router_model_info = 53;
optional string plan_mode_open_ai_model_id = 54;
optional OpenAiCompatibleModelInfo plan_mode_open_ai_model_info = 55;
optional string plan_mode_ollama_model_id = 56;
optional string plan_mode_lm_studio_model_id = 57;
optional string plan_mode_lite_llm_model_id = 58;
optional LiteLLMModelInfo plan_mode_lite_llm_model_info = 59;
optional string plan_mode_requesty_model_id = 60;
optional OpenRouterModelInfo plan_mode_requesty_model_info = 61;
optional string plan_mode_together_model_id = 62;
optional string plan_mode_fireworks_model_id = 63;
optional string plan_mode_sap_ai_core_model_id = 64;
optional string plan_mode_sap_ai_core_deployment_id = 65;
optional string plan_mode_groq_model_id = 66;
optional OpenRouterModelInfo plan_mode_groq_model_info = 67;
optional string plan_mode_baseten_model_id = 68;
optional OpenRouterModelInfo plan_mode_baseten_model_info = 69;
optional string plan_mode_hugging_face_model_id = 70;
optional OpenRouterModelInfo plan_mode_hugging_face_model_info = 71;
optional string plan_mode_huawei_cloud_maas_model_id = 72;
optional OpenRouterModelInfo plan_mode_huawei_cloud_maas_model_info = 73;
optional string plan_mode_oca_model_id = 74;
optional OcaModelInfo plan_mode_oca_model_info = 75;
optional string plan_mode_oca_reasoning_effort = 76;
optional string plan_mode_aihubmix_model_id = 77;
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 78;
optional string plan_mode_hicap_model_id = 79;
optional OpenRouterModelInfo plan_mode_hicap_model_info = 80;
optional string plan_mode_nous_research_model_id = 81;
optional string plan_mode_vercel_ai_gateway_model_id = 82;
optional OpenRouterModelInfo plan_mode_vercel_ai_gateway_model_info = 83;
optional string act_mode_api_model_id = 84;
optional int64 act_mode_thinking_budget_tokens = 85;
optional string gemini_act_mode_thinking_level = 86;
optional string act_mode_reasoning_effort = 87;
optional string act_mode_verbosity = 88;
optional LanguageModelChatSelector act_mode_vs_code_lm_model_selector = 89;
optional bool act_mode_aws_bedrock_custom_selected = 90;
optional string act_mode_aws_bedrock_custom_model_base_id = 91;
optional string act_mode_open_router_model_id = 92;
optional OpenRouterModelInfo act_mode_open_router_model_info = 93;
optional string act_mode_open_ai_model_id = 94;
optional OpenAiCompatibleModelInfo act_mode_open_ai_model_info = 95;
optional string act_mode_ollama_model_id = 96;
optional string act_mode_lm_studio_model_id = 97;
optional string act_mode_lite_llm_model_id = 98;
optional LiteLLMModelInfo act_mode_lite_llm_model_info = 99;
optional string act_mode_requesty_model_id = 100;
optional OpenRouterModelInfo act_mode_requesty_model_info = 101;
optional string act_mode_together_model_id = 102;
optional string act_mode_fireworks_model_id = 103;
optional string act_mode_sap_ai_core_model_id = 104;
optional string act_mode_sap_ai_core_deployment_id = 105;
optional string act_mode_groq_model_id = 106;
optional OpenRouterModelInfo act_mode_groq_model_info = 107;
optional string act_mode_baseten_model_id = 108;
optional OpenRouterModelInfo act_mode_baseten_model_info = 109;
optional string act_mode_hugging_face_model_id = 110;
optional OpenRouterModelInfo act_mode_hugging_face_model_info = 111;
optional string act_mode_huawei_cloud_maas_model_id = 112;
optional OpenRouterModelInfo act_mode_huawei_cloud_maas_model_info = 113;
optional string act_mode_oca_model_id = 114;
optional OcaModelInfo act_mode_oca_model_info = 115;
optional string act_mode_oca_reasoning_effort = 116;
optional string act_mode_aihubmix_model_id = 117;
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 118;
optional string act_mode_hicap_model_id = 119;
optional OpenRouterModelInfo act_mode_hicap_model_info = 120;
optional string act_mode_nous_research_model_id = 121;
optional string act_mode_vercel_ai_gateway_model_id = 122;
optional OpenRouterModelInfo act_mode_vercel_ai_gateway_model_info = 123;
optional ApiProvider plan_mode_api_provider = 124;
optional ApiProvider act_mode_api_provider = 125;
optional string hicap_model_id = 126;
optional string lm_studio_model_id = 127;
optional AutoApprovalSettings auto_approval_settings = 128;
optional string global_cline_rules_toggles = 129;
optional string global_workflow_toggles = 130;
optional string global_skills_toggles = 131;
optional BrowserSettings browser_settings = 132;
optional string telemetry_setting = 133;
optional bool plan_act_separate_models_setting = 134;
optional bool enable_checkpoints_setting = 135;
optional int32 shell_integration_timeout = 136;
optional string default_terminal_profile = 137;
optional int32 terminal_output_line_limit = 138;
optional int32 max_consecutive_mistakes = 139;
optional int32 subagent_terminal_output_line_limit = 140;
optional bool strict_plan_mode_enabled = 141;
optional bool yolo_mode_toggled = 142;
optional bool use_auto_condense = 143;
optional bool cline_web_tools_enabled = 144;
optional string preferred_language = 145;
optional OpenaiReasoningEffort openai_reasoning_effort = 146;
optional PlanActMode mode = 147;
optional DictationSettings dictation_settings = 148;
optional FocusChainSettings focus_chain_settings = 149;
optional string custom_prompt = 150;
optional double auto_condense_threshold = 151;
optional bool hooks_enabled = 152;
optional bool subagents_enabled = 153;
optional bool enable_parallel_tool_calling = 154;
optional bool background_edit_enabled = 155;
optional bool skills_enabled = 156;
optional bool opt_out_of_remote_config = 157;
optional bool open_telemetry_enabled = 158;
optional string open_telemetry_metrics_exporter = 159;
optional string open_telemetry_logs_exporter = 160;
optional string open_telemetry_otlp_protocol = 161;
optional string open_telemetry_otlp_endpoint = 162;
optional string open_telemetry_otlp_metrics_protocol = 163;
optional string open_telemetry_otlp_metrics_endpoint = 164;
optional string open_telemetry_otlp_logs_protocol = 165;
optional string open_telemetry_otlp_logs_endpoint = 166;
optional int32 open_telemetry_metric_export_interval = 167;
optional bool open_telemetry_otlp_insecure = 168;
optional int32 open_telemetry_log_batch_size = 169;
optional int32 open_telemetry_log_batch_timeout = 170;
optional int32 open_telemetry_log_max_queue_size = 171;
optional bool worktrees_enabled = 172;
}
message DictationSettings {
@@ -376,6 +422,7 @@ message UpdateSettingsRequest {
optional string oca_reasoning_effort = 37;
optional bool skills_enabled = 38;
optional bool opt_out_of_remote_config = 39;
optional bool worktrees_enabled = 40;
}
message UpdateTerminalConnectionTimeoutRequest {
+3
View File
@@ -256,6 +256,9 @@ service UiService {
// Subscribe to settings button clicked events
rpc subscribeToSettingsButtonClicked(EmptyRequest) returns (stream Empty);
// Subscribe to worktrees button clicked events
rpc subscribeToWorktreesButtonClicked(EmptyRequest) returns (stream Empty);
// Subscribe to partial message updates (streaming Cline messages as they're built)
rpc subscribeToPartialMessage(EmptyRequest) returns (stream ClineMessage);
+153
View File
@@ -0,0 +1,153 @@
syntax = "proto3";
package cline;
import "cline/common.proto";
option go_package = "github.com/cline/grpc-go/cline";
option java_multiple_files = true;
option java_package = "bot.cline.proto";
// Service for git worktree operations
service WorktreeService {
// Lists all worktrees in the current repository
rpc listWorktrees(EmptyRequest) returns (WorktreeList);
// Creates a new worktree
rpc createWorktree(CreateWorktreeRequest) returns (WorktreeResult);
// Deletes an existing worktree
rpc deleteWorktree(DeleteWorktreeRequest) returns (WorktreeResult);
// Switches to a different worktree (opens in VS Code)
rpc switchWorktree(SwitchWorktreeRequest) returns (WorktreeResult);
// Gets available branches for creating worktrees
rpc getAvailableBranches(EmptyRequest) returns (BranchList);
// Gets suggested defaults for creating a new worktree (auto-generated branch name and path)
rpc getWorktreeDefaults(EmptyRequest) returns (WorktreeDefaults);
// Gets the status of .worktreeinclude file and .gitignore contents for creating one
rpc getWorktreeIncludeStatus(EmptyRequest) returns (WorktreeIncludeStatus);
// Creates a .worktreeinclude file with the provided content
rpc createWorktreeInclude(CreateWorktreeIncludeRequest) returns (WorktreeResult);
// Switches to a different branch in the current worktree (git checkout)
rpc checkoutBranch(CheckoutBranchRequest) returns (WorktreeResult);
// Merges a worktree's branch into the target branch and optionally deletes the worktree
rpc mergeWorktree(MergeWorktreeRequest) returns (MergeWorktreeResult);
// Tracks when the worktrees view is opened (for telemetry)
rpc trackWorktreeViewOpened(TrackWorktreeViewOpenedRequest) returns (Empty);
}
// Represents a single git worktree
message Worktree {
string path = 1; // Absolute path to the worktree
string branch = 2; // Branch name (empty if detached)
string commit_hash = 3; // Current commit hash
bool is_current = 4; // Whether this is the current worktree
bool is_bare = 5; // Whether this is the bare repository
bool is_detached = 6; // Whether HEAD is detached
bool is_locked = 7; // Whether the worktree is locked
optional string lock_reason = 8; // Reason for lock if locked
}
// Response containing list of worktrees
message WorktreeList {
repeated Worktree worktrees = 1;
bool is_git_repo = 2; // Whether the current workspace is a git repo
string error = 3; // Error message if any
bool is_multi_root = 4; // Whether multiple workspace folders are open (worktrees not supported)
bool is_subfolder = 5; // Whether workspace is a subfolder of a git repo (not at repo root)
string git_root_path = 6; // The actual git root path (useful when is_subfolder is true)
}
// Request to create a new worktree
message CreateWorktreeRequest {
Metadata metadata = 1;
string path = 2; // Path for the new worktree
optional string branch = 3; // Branch name (creates new if doesn't exist)
optional string base_branch = 4; // Base branch for new branch creation
bool create_new_branch = 5; // Whether to create a new branch
}
// Request to delete a worktree
message DeleteWorktreeRequest {
Metadata metadata = 1;
string path = 2; // Path of the worktree to delete
bool force = 3; // Force deletion even if dirty
bool delete_branch = 4; // Also delete the branch
string branch_name = 5; // Name of the branch to delete (required if delete_branch is true)
}
// Request to switch to a worktree
message SwitchWorktreeRequest {
Metadata metadata = 1;
string path = 2; // Path of the worktree to switch to
bool new_window = 3; // Whether to open in a new window
}
// Result of worktree operations
message WorktreeResult {
bool success = 1;
string message = 2; // Success or error message
optional Worktree worktree = 3; // The affected worktree (for create)
}
// List of available branches
message BranchList {
repeated string local_branches = 1;
repeated string remote_branches = 2;
string current_branch = 3;
}
// Suggested defaults for creating a new worktree
message WorktreeDefaults {
string suggested_branch = 1; // Auto-generated branch name like "worktree/cline-abc12"
string suggested_path = 2; // Path in Documents/Cline/Worktrees/<project>-<suffix>
}
// Status of .worktreeinclude file
message WorktreeIncludeStatus {
bool exists = 1; // Whether .worktreeinclude exists
string gitignore_content = 2; // Content of .gitignore (for prefilling)
bool has_gitignore = 3; // Whether .gitignore exists
}
// Request to create .worktreeinclude file
message CreateWorktreeIncludeRequest {
string content = 1; // Content for the .worktreeinclude file
}
// Request to checkout a branch in the current worktree
message CheckoutBranchRequest {
Metadata metadata = 1;
string branch = 2; // Branch name to checkout
}
// Request to merge a worktree's branch into target branch
message MergeWorktreeRequest {
Metadata metadata = 1;
string worktree_path = 2; // Path of the worktree to merge
string target_branch = 3; // Branch to merge into (e.g., "main")
bool delete_after_merge = 4; // Whether to delete the worktree after successful merge
}
// Result of merge operation
message MergeWorktreeResult {
bool success = 1;
string message = 2; // Success or error message
bool has_conflicts = 3; // Whether merge resulted in conflicts
repeated string conflicting_files = 4; // List of files with conflicts
string source_branch = 5; // The branch that was merged
string target_branch = 6; // The branch merged into
}
// Request to track worktree view opened (for telemetry)
message TrackWorktreeViewOpenedRequest {
string source = 1; // Where the view was opened from: "home_page" or "menu_bar"
}
+13
View File
@@ -35,6 +35,9 @@ service WorkspaceService {
// Executes a command in a new terminal
rpc executeCommandInTerminal(ExecuteCommandInTerminalRequest) returns (ExecuteCommandInTerminalResponse);
// Opens a folder/workspace in the IDE
rpc openFolder(OpenFolderRequest) returns (OpenFolderResponse);
}
message GetWorkspacePathsRequest {
@@ -107,3 +110,13 @@ message ExecuteCommandInTerminalRequest {
message ExecuteCommandInTerminalResponse {
bool success = 1; // Whether the command was successfully sent to the terminal
}
// Request to open a folder/workspace
message OpenFolderRequest {
string path = 1; // The path to the folder to open
bool new_window = 2; // Whether to open in a new window
}
message OpenFolderResponse {
bool success = 1;
}
+1
View File
@@ -25,6 +25,7 @@ cd cli
# Define target platforms for cross-compilation
PLATFORMS=(
"windows/amd64"
"darwin/arm64"
"darwin/amd64"
"linux/amd64"
+24 -7
View File
@@ -5,6 +5,7 @@ import { execSync } from "child_process"
import * as fs from "fs/promises"
import { globby } from "globby"
import { createRequire } from "module"
import { platform } from "os"
import * as path from "path"
import { fileURLToPath } from "url"
import { createServiceNameMap, parseProtoForServices } from "./proto-shared-utils.mjs"
@@ -26,7 +27,7 @@ function checkGoInstallation() {
try {
execSync("go version", { stdio: "pipe" })
return true
} catch (error) {
} catch (_) {
return false
}
}
@@ -36,12 +37,12 @@ function checkGoTool(toolName) {
try {
execSync(`which ${toolName}`, { stdio: "pipe" })
return true
} catch (error) {
} catch (_) {
// On Windows, 'which' might not be available, try 'where'
try {
execSync(`where ${toolName}`, { stdio: "pipe" })
return true
} catch (windowsError) {
} catch (_) {
return false
}
}
@@ -51,12 +52,26 @@ function checkGoTool(toolName) {
function installGoTools() {
console.log(chalk.yellow("Installing Go protobuf tools..."))
const OSPREFIX = (() => {
if (platform() === "win32") {
if (process.env.ComSpec) {
if (process.env.PSModulePath && !process.env.SHELL) {
return `$Env:GO111MODULE="on";`
}
return `set GO111MODULE=on &&`
}
return `GO111MODULE=on`
} else {
return `GO111MODULE=on`
}
})()
const tools = ["google.golang.org/protobuf/cmd/protoc-gen-go@latest", "google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest"]
for (const tool of tools) {
try {
console.log(chalk.cyan(`Installing ${tool}...`))
execSync(`GO111MODULE=on go install ${tool}`, {
execSync(`${OSPREFIX} go install ${tool}`, {
stdio: "inherit",
env: { ...process.env, GO111MODULE: "on" },
})
@@ -82,7 +97,9 @@ function checkToolsInPath() {
if (missingTools.length > 0) {
console.log(chalk.yellow("Warning: Some Go protobuf tools are not in your PATH:"))
missingTools.forEach((tool) => console.log(chalk.yellow(` - ${tool}`)))
missingTools.forEach((tool) => {
console.log(chalk.yellow(` - ${tool}`))
})
console.log()
console.log(chalk.cyan("To fix this, add your Go bin directory to your PATH:"))
@@ -91,7 +108,7 @@ function checkToolsInPath() {
try {
goPath = execSync("go env GOPATH", { encoding: "utf8" }).trim()
goBin = execSync("go env GOBIN", { encoding: "utf8" }).trim()
} catch (error) {
} catch (_) {
console.log(chalk.red("Could not determine Go paths. Please check your Go installation."))
process.exit(1)
}
@@ -573,7 +590,7 @@ ${methods}
}
// Main execution block - run if this script is executed directly
if (import.meta.url === `file://${process.argv[1]}`) {
if (fileURLToPath(import.meta.url) === process.argv[1]) {
async function main() {
try {
console.log(chalk.cyan("Starting Go protobuf code generation..."))
+1 -1
View File
@@ -12,7 +12,7 @@ import { main as generateHostBridgeClient } from "./generate-host-bridge-client.
import { main as generateProtoBusSetup } from "./generate-protobus-setup.mjs"
const require = createRequire(import.meta.url)
const PROTOC = path.join(require.resolve("grpc-tools"), "../bin/protoc")
const PROTOC = `"${path.join(require.resolve("grpc-tools"), "../bin/protoc")}"`
const PROTO_DIR = path.resolve("proto")
const TS_OUT_DIR = path.resolve("src/shared/proto")
+724
View File
@@ -0,0 +1,724 @@
#!/usr/bin/env npx tsx
/**
* Unified Build Orchestrator for Cline
*
* Cross-platform build script that works on Windows, macOS, and Linux.
*
* Usage:
* npx tsx scripts/build.ts --surface=<vscode|jetbrains|cli|all> [options]
*
* Options:
* --surface=<surface> Build target: vscode, jetbrains, cli, or all
* --platform=<platform> CLI platform: unix, windows, or all (default: unix)
* --prod Production build (minification, strip debug symbols)
* --all-stages Build both dev and prod
*
* Examples:
* npx tsx scripts/build.ts --surface=vscode
* npx tsx scripts/build.ts --surface=cli --platform=windows --prod
* npx tsx scripts/build.ts --surface=all --platform=all --all-stages
*/
import { execSync } from "node:child_process"
import fs from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT_DIR = path.resolve(__dirname, "..")
// Types
type Surface = "vscode" | "jetbrains" | "cli" | "npm" | "all"
type PlatformGroup = "unix" | "windows" | "all"
interface CliTarget {
GOOS: string
GOARCH: string
}
interface ParsedArgs {
surface: Surface
platform: PlatformGroup
prod: boolean
allStages: boolean
}
interface RunOptions {
cwd?: string
env?: NodeJS.ProcessEnv
silent?: boolean
}
interface BuildState {
protos: boolean
protosGo: boolean
webview: boolean
}
// CLI build targets by platform group
const CLI_PLATFORMS: Record<"unix" | "windows", CliTarget[]> = {
unix: [
{ GOOS: "darwin", GOARCH: "amd64" },
{ GOOS: "darwin", GOARCH: "arm64" },
{ GOOS: "linux", GOARCH: "amd64" },
{ GOOS: "linux", GOARCH: "arm64" },
],
windows: [
{ GOOS: "windows", GOARCH: "amd64" },
{ GOOS: "windows", GOARCH: "arm64" },
],
}
// Colors for terminal output
const colors = {
reset: "\x1b[0m",
bright: "\x1b[1m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
cyan: "\x1b[36m",
} as const
function log(message: string, color: string = colors.reset): void {
console.log(`${color}${message}${colors.reset}`)
}
function logStep(step: string, message: string): void {
log(`\n${colors.bright}[${step}]${colors.reset} ${message}`, colors.cyan)
}
function logSuccess(message: string): void {
log(`${message}`, colors.green)
}
function logError(message: string): void {
log(`${message}`, colors.red)
}
function logWarning(message: string): void {
log(` ! ${message}`, colors.yellow)
}
function logSeparator(): void {
log("=".repeat(60), colors.bright)
}
/**
* Parse command line arguments
*/
function parseArgs(): ParsedArgs {
const args = process.argv.slice(2)
const result: ParsedArgs = {
surface: "all",
platform: "unix",
prod: false,
allStages: false,
}
for (const arg of args) {
if (arg.startsWith("--surface=")) {
result.surface = arg.split("=")[1] as Surface
} else if (arg.startsWith("--platform=")) {
result.platform = arg.split("=")[1] as PlatformGroup
} else if (arg === "--prod") {
result.prod = true
} else if (arg === "--all-stages") {
result.allStages = true
} else if (arg === "--help" || arg === "-h") {
console.log(`
Unified Build Orchestrator for Cline
Usage:
npx tsx scripts/build.ts --surface=<surface> [options]
Surfaces:
vscode Build VS Code extension
jetbrains Build JetBrains standalone package
cli Build Go CLI binaries
npm Build npm package (CLI + standalone for npm distribution)
all Build all surfaces (vscode + jetbrains + cli, excludes npm)
Options:
--platform=<platform> CLI platform target: unix, windows, or all (default: unix)
--prod Production build (minification, strip debug symbols)
--all-stages Build both dev and prod stages
Environment Variables (required for npm --prod):
TELEMETRY_SERVICE_API_KEY PostHog telemetry API key
ERROR_SERVICE_API_KEY Error tracking API key
Examples:
npm run build:vscode # VS Code dev build
npm run build:cli:windows:prod # CLI Windows production build
npm run build:npm # npm package dev build
npm run build:npm:prod # npm package prod build (requires env vars)
npm run build:all-surfaces:all-platforms:all-stages # Everything
`)
process.exit(0)
}
}
// Validate surface
if (!["vscode", "jetbrains", "cli", "npm", "all"].includes(result.surface)) {
logError(`Invalid surface: ${result.surface}. Must be one of: vscode, jetbrains, cli, npm, all`)
process.exit(1)
}
// Validate platform
if (!["unix", "windows", "all"].includes(result.platform)) {
logError(`Invalid platform: ${result.platform}. Must be one of: unix, windows, all`)
process.exit(1)
}
return result
}
/**
* Run a command synchronously with cross-platform support
*/
function run(cmd: string, opts: RunOptions = {}): void {
const { cwd = ROOT_DIR, env = process.env, silent = false } = opts
if (!silent) {
log(` $ ${cmd}`, colors.yellow)
}
try {
execSync(cmd, {
cwd,
env,
stdio: silent ? "pipe" : "inherit",
})
} catch {
throw new Error(`Command failed: ${cmd}`)
}
}
/**
* Run a command and return the output
*/
function runCapture(cmd: string, opts: { cwd?: string } = {}): string | null {
const { cwd = ROOT_DIR } = opts
try {
return execSync(cmd, {
cwd,
encoding: "utf8",
}).trim()
} catch {
return null
}
}
/**
* Get git commit hash
*/
function getGitCommit(): string {
return runCapture("git rev-parse --short HEAD") || "unknown"
}
/**
* Read package.json version
*/
function getPackageVersion(packagePath: string): string {
const fullPath = path.join(ROOT_DIR, packagePath)
const pkg = JSON.parse(fs.readFileSync(fullPath, "utf8")) as { version: string }
return pkg.version
}
/**
* Build Go ldflags string
*/
function buildLdflags(prod: boolean): string {
const version = getPackageVersion("package.json")
const cliVersion = getPackageVersion("cli/package.json")
const commit = getGitCommit()
const date = new Date().toISOString()
const builtBy = process.env.USER || process.env.USERNAME || "unknown"
let ldflags =
`-X 'github.com/cline/cli/pkg/cli/global.Version=${version}' ` +
`-X 'github.com/cline/cli/pkg/cli/global.CliVersion=${cliVersion}' ` +
`-X 'github.com/cline/cli/pkg/cli/global.Commit=${commit}' ` +
`-X 'github.com/cline/cli/pkg/cli/global.Date=${date}' ` +
`-X 'github.com/cline/cli/pkg/cli/global.BuiltBy=${builtBy}'`
if (prod) {
ldflags += " -s -w" // Strip debug symbols and DWARF
}
return ldflags
}
// Track what has been built to avoid duplicate work
const buildState: BuildState = {
protos: false,
protosGo: false,
webview: false,
}
/**
* Build protobuf definitions
*/
async function buildProtos(): Promise<void> {
if (buildState.protos) {
logSuccess("Protos already built, skipping")
return
}
logStep("PROTOS", "Building protobuf definitions")
run("npm run protos")
buildState.protos = true
logSuccess("Protos built")
}
/**
* Build Go protobuf definitions
*/
async function buildProtosGo(): Promise<void> {
if (buildState.protosGo) {
logSuccess("Go protos already built, skipping")
return
}
logStep("PROTOS-GO", "Building Go protobuf definitions")
run("npm run protos-go")
buildState.protosGo = true
logSuccess("Go protos built")
}
/**
* Build webview UI
*/
async function buildWebview(): Promise<void> {
if (buildState.webview) {
logSuccess("Webview already built, skipping")
return
}
logStep("WEBVIEW", "Building webview UI")
run("npm run build:webview")
buildState.webview = true
logSuccess("Webview built")
}
/**
* Build VS Code extension
*/
async function buildVscode(prod: boolean): Promise<void> {
const stage = prod ? "prod" : "dev"
logStep("VSCODE", `Building VS Code extension (${stage})`)
await buildProtos()
await buildWebview()
// Run esbuild
const productionFlag = prod ? " --production" : ""
run(`node esbuild.mjs${productionFlag}`)
logSuccess(`VS Code extension built (${stage})`)
}
/**
* Build JetBrains standalone package
*/
async function buildJetbrains(prod: boolean): Promise<void> {
const stage = prod ? "prod" : "dev"
logStep("JETBRAINS", `Building JetBrains standalone package (${stage})`)
await buildProtos()
await buildProtosGo()
await buildWebview()
// Prepare dist-standalone directory
const distDir = path.join(ROOT_DIR, "dist-standalone")
const extensionDir = path.join(distDir, "extension")
fs.mkdirSync(extensionDir, { recursive: true })
fs.copyFileSync(path.join(ROOT_DIR, "package.json"), path.join(extensionDir, "package.json"))
// Run esbuild with standalone flag
const productionFlag = prod ? " --production" : ""
run(`node esbuild.mjs --standalone${productionFlag}`)
// Run package-standalone.mjs (always builds for all platforms)
run("node scripts/package-standalone.mjs")
logSuccess(`JetBrains standalone package built (${stage})`)
}
/**
* Build CLI binaries for specified platforms
*/
async function buildCli(platformGroups: PlatformGroup[], prod: boolean): Promise<void> {
const stage = prod ? "prod" : "dev"
// Expand platform groups to individual targets
const targets: CliTarget[] = []
for (const group of platformGroups) {
if (group === "all") {
targets.push(...CLI_PLATFORMS.unix, ...CLI_PLATFORMS.windows)
} else if (CLI_PLATFORMS[group]) {
targets.push(...CLI_PLATFORMS[group])
}
}
// Deduplicate targets
const uniqueTargets = [...new Map(targets.map((t) => [`${t.GOOS}-${t.GOARCH}`, t])).values()]
logStep("CLI", `Building CLI binaries (${stage}) for ${uniqueTargets.length} platform(s)`)
await buildProtos()
await buildProtosGo()
// Prepare directories
const cliDir = path.join(ROOT_DIR, "cli")
const cliBinDir = path.join(cliDir, "bin")
const distBinDir = path.join(ROOT_DIR, "dist-standalone", "bin")
fs.mkdirSync(cliBinDir, { recursive: true })
fs.mkdirSync(distBinDir, { recursive: true })
// Also ensure dist-standalone/extension exists for package.json
const extensionDir = path.join(ROOT_DIR, "dist-standalone", "extension")
fs.mkdirSync(extensionDir, { recursive: true })
fs.copyFileSync(path.join(ROOT_DIR, "package.json"), path.join(extensionDir, "package.json"))
const ldflags = buildLdflags(prod)
// Build for each target
for (const { GOOS, GOARCH } of uniqueTargets) {
const ext = GOOS === "windows" ? ".exe" : ""
const platformSuffix = `${GOOS}-${GOARCH === "amd64" ? "x64" : GOARCH}`
log(` Building for ${platformSuffix}...`, colors.blue)
const env: NodeJS.ProcessEnv = {
...process.env,
GOOS,
GOARCH,
GO111MODULE: "on",
}
// Build cline binary
const clineOutput = path.join(cliBinDir, `cline-${platformSuffix}${ext}`)
run(`go build -ldflags "${ldflags}" -o "${clineOutput}" ./cmd/cline`, {
cwd: cliDir,
env,
silent: true,
})
logSuccess(`cline-${platformSuffix}${ext} built`)
// Build cline-host binary
const hostOutput = path.join(cliBinDir, `cline-host-${platformSuffix}${ext}`)
run(`go build -ldflags "${ldflags}" -o "${hostOutput}" ./cmd/cline-host`, {
cwd: cliDir,
env,
silent: true,
})
logSuccess(`cline-host-${platformSuffix}${ext} built`)
// Copy to dist-standalone/bin
fs.copyFileSync(clineOutput, path.join(distBinDir, `cline-${platformSuffix}${ext}`))
fs.copyFileSync(hostOutput, path.join(distBinDir, `cline-host-${platformSuffix}${ext}`))
}
// If building for current platform, also create generic binaries
const currentOS = process.platform === "win32" ? "windows" : process.platform
const currentTarget = uniqueTargets.find((t) => t.GOOS === currentOS && t.GOARCH === process.arch)
if (currentTarget) {
const ext = currentOS === "windows" ? ".exe" : ""
const platformSuffix = `${currentOS}-${process.arch}`
// Copy to generic names in cli/bin and dist-standalone/bin
fs.copyFileSync(path.join(cliBinDir, `cline-${platformSuffix}${ext}`), path.join(cliBinDir, `cline${ext}`))
fs.copyFileSync(path.join(cliBinDir, `cline-host-${platformSuffix}${ext}`), path.join(cliBinDir, `cline-host${ext}`))
fs.copyFileSync(path.join(distBinDir, `cline-${platformSuffix}${ext}`), path.join(distBinDir, `cline${ext}`))
fs.copyFileSync(path.join(distBinDir, `cline-host-${platformSuffix}${ext}`), path.join(distBinDir, `cline-host${ext}`))
logSuccess(`Generic binaries created for current platform (${platformSuffix})`)
}
logSuccess(`CLI binaries built (${stage})`)
}
/**
* Validate telemetry environment variables are set
*/
function validateTelemetryEnvVars(): void {
logStep("ENV", "Validating telemetry environment variables")
const requiredVars = ["TELEMETRY_SERVICE_API_KEY", "ERROR_SERVICE_API_KEY"]
const optionalVars = ["CLINE_ENVIRONMENT", "POSTHOG_TELEMETRY_ENABLED"]
const missingVars: string[] = []
for (const varName of requiredVars) {
const value = process.env[varName]
if (!value) {
missingVars.push(varName)
logError(`${varName} is not set`)
} else {
// Show first 10 chars for verification (don't expose full key)
logSuccess(`${varName} is set (${value.substring(0, 10)}...)`)
}
}
for (const varName of optionalVars) {
const value = process.env[varName]
if (!value) {
logWarning(`${varName} is not set (optional)`)
} else {
logSuccess(`${varName} is set: ${value}`)
}
}
if (missingVars.length > 0) {
log("\n", colors.reset)
logError("Missing required environment variables:")
log("", colors.reset)
log(' export TELEMETRY_SERVICE_API_KEY="your_posthog_api_key"', colors.yellow)
log(' export ERROR_SERVICE_API_KEY="your_error_tracking_api_key"', colors.yellow)
log("", colors.reset)
throw new Error(`Missing required environment variables: ${missingVars.join(", ")}`)
}
logSuccess("Environment variables validated")
}
/**
* Clean dist-standalone directory
*/
function cleanDistStandalone(): void {
logStep("CLEAN", "Cleaning dist-standalone directory")
const distDir = path.join(ROOT_DIR, "dist-standalone")
if (fs.existsSync(distDir)) {
fs.rmSync(distDir, { recursive: true, force: true })
logSuccess("Removed dist-standalone directory")
} else {
logSuccess("dist-standalone directory does not exist, nothing to clean")
}
}
/**
* Verify telemetry keys were injected into compiled output
*/
function verifyTelemetryInjection(): void {
logStep("VERIFY", "Verifying telemetry keys were injected")
const clineCorePath = path.join(ROOT_DIR, "dist-standalone", "cline-core.js")
if (!fs.existsSync(clineCorePath)) {
throw new Error(`Compiled file not found: ${clineCorePath}`)
}
const content = fs.readFileSync(clineCorePath, "utf8")
// Check if process.env references still exist (bad - means they weren't replaced)
if (content.includes("process.env.TELEMETRY_SERVICE_API_KEY")) {
logError("Keys were NOT injected! Found 'process.env.TELEMETRY_SERVICE_API_KEY' in compiled code")
logError("This means the environment variables were not replaced during build")
throw new Error("Telemetry keys were not injected into compiled code")
}
// Check if PostHog endpoint is present (good - means config is there)
if (content.includes("data.cline.bot")) {
logSuccess("Telemetry keys successfully injected into compiled code")
} else {
logWarning("Could not verify PostHog config in compiled code")
}
}
/**
* Print npm build summary
*/
function printNpmBuildSummary(): void {
const distDir = path.join(ROOT_DIR, "dist-standalone")
let version = "unknown"
try {
const pkgPath = path.join(distDir, "package.json")
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8")) as { version: string }
version = pkg.version
}
} catch {
// Ignore errors reading version
}
log("\n", colors.reset)
logSeparator()
log("NPM Package Build Summary", colors.green)
logSeparator()
log("")
log(` Package location: ${colors.cyan}dist-standalone/${colors.reset}`, colors.reset)
log(` Package version: ${colors.cyan}${version}${colors.reset}`, colors.reset)
log("")
log(" Next steps:", colors.bright)
log(` 1. Test locally: ${colors.yellow}cd dist-standalone && npm link${colors.reset}`, colors.reset)
log(` 2. Verify: ${colors.yellow}cline version${colors.reset}`, colors.reset)
log(` 3. Publish: ${colors.yellow}cd dist-standalone && npm publish${colors.reset}`, colors.reset)
log("")
log(
` ${colors.yellow}Note: Check PostHog dashboard after running cline commands to verify telemetry${colors.reset}`,
colors.reset,
)
logSeparator()
}
/**
* Build npm package (CLI + standalone for npm distribution)
*/
async function buildNpm(prod: boolean): Promise<void> {
const stage = prod ? "prod" : "dev"
logStep("NPM", `Building npm package (${stage})`)
// Step 1: Validate telemetry env vars (prod only)
if (prod) {
validateTelemetryEnvVars()
}
// Step 2: Clean dist-standalone directory
cleanDistStandalone()
// Step 3: Build shared dependencies
await buildProtos()
await buildProtosGo()
await buildWebview()
// Step 4: Build CLI for all platforms
await buildCli(["all"], prod)
// Step 5: Build standalone with npm target
logStep("STANDALONE", "Building standalone package for npm")
const productionFlag = prod ? " --production" : ""
run(`node esbuild.mjs --standalone${productionFlag}`)
run("node scripts/package-standalone.mjs --target=npm")
// Step 6: Verify telemetry injection (prod only)
if (prod) {
verifyTelemetryInjection()
}
// Step 7: Print summary
printNpmBuildSummary()
logSuccess(`npm package built (${stage})`)
}
/**
* Build all surfaces with specified options
*/
async function buildAll(surface: Surface, platform: PlatformGroup, prod: boolean): Promise<void> {
const stage = prod ? "prod" : "dev"
logSeparator()
log(`Building: surface=${surface}, platform=${platform}, stage=${stage}`, colors.bright)
logSeparator()
if (surface === "all") {
// Build vscode and jetbrains in parallel, then cli
// We need to be careful with shared resources (protos, webview)
// So we build shared dependencies first, then parallelize
logStep("SHARED", "Building shared dependencies")
await buildProtos()
await buildProtosGo()
await buildWebview()
// Now we can build vscode and jetbrains in parallel
logStep("PARALLEL", "Building VS Code and JetBrains in parallel")
const vscodePromise = (async () => {
const productionFlag = prod ? " --production" : ""
run(`node esbuild.mjs${productionFlag}`)
logSuccess(`VS Code extension built (${stage})`)
})()
const jetbrainsPromise = (async () => {
// Prepare dist-standalone directory
const distDir = path.join(ROOT_DIR, "dist-standalone")
const extensionDir = path.join(distDir, "extension")
fs.mkdirSync(extensionDir, { recursive: true })
fs.copyFileSync(path.join(ROOT_DIR, "package.json"), path.join(extensionDir, "package.json"))
const productionFlag = prod ? " --production" : ""
run(`node esbuild.mjs --standalone${productionFlag}`)
run("node scripts/package-standalone.mjs")
logSuccess(`JetBrains standalone package built (${stage})`)
})()
await Promise.all([vscodePromise, jetbrainsPromise])
// Build CLI (includes both unix and windows for "all" surface)
await buildCli(["unix", "windows"], prod)
} else if (surface === "vscode") {
await buildVscode(prod)
} else if (surface === "jetbrains") {
await buildJetbrains(prod)
} else if (surface === "cli") {
const platforms: PlatformGroup[] = platform === "all" ? ["all"] : [platform]
await buildCli(platforms, prod)
} else if (surface === "npm") {
await buildNpm(prod)
}
}
/**
* Reset build state for new stage
*/
function resetBuildState(): void {
buildState.protos = false
buildState.protosGo = false
buildState.webview = false
}
/**
* Main entry point
*/
async function main(): Promise<void> {
const args = parseArgs()
logSeparator()
log("Cline Build Orchestrator", colors.bright)
logSeparator()
const startTime = Date.now()
try {
if (args.allStages) {
// Build both dev and prod
log("\nBuilding all stages (dev + prod)...", colors.cyan)
// Dev build
await buildAll(args.surface, args.platform, false)
// Reset build state for prod build
resetBuildState()
// Prod build
await buildAll(args.surface, args.platform, true)
} else {
await buildAll(args.surface, args.platform, args.prod)
}
const duration = ((Date.now() - startTime) / 1000).toFixed(2)
logSeparator()
log(`Build completed successfully in ${duration}s`, colors.green)
logSeparator()
} catch (error) {
const duration = ((Date.now() - startTime) / 1000).toFixed(2)
logSeparator()
logError(`Build failed after ${duration}s`)
logError((error as Error).message)
logSeparator()
process.exit(1)
}
}
main()
+413
View File
@@ -0,0 +1,413 @@
#!/usr/bin/env node
/**
* Generates proto message definitions from TypeScript source of truth.
*
* This script reads the field definitions from src/shared/storage/state-keys.ts
* and generates the corresponding proto message definitions for Secrets and Settings.
*
* Usage: node scripts/generate-state-proto.mjs
*
* The generated proto content is written to proto/cline/state.proto,
* replacing only the Secrets and Settings messages while preserving
* the rest of the file (services, enums, other messages).
*/
import * as fs from "node:fs/promises"
import { Project, SyntaxKind } from "ts-morph"
const STATE_KEYS_PATH = "src/shared/storage/state-keys.ts"
const STATE_PROTO_PATH = "proto/cline/state.proto"
/**
* Convert camelCase to snake_case for proto field names
*/
function camelToSnake(str) {
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
}
// Fields that should use int64 instead of int32
const INT64_FIELDS = new Set(["planModeThinkingBudgetTokens", "actModeThinkingBudgetTokens"])
// Fields that should use double instead of int32
const DOUBLE_FIELDS = new Set(["autoCondenseThreshold"])
/**
* Infer proto type from TypeScript type expression
* @param {string} typeText - The TypeScript type expression
* @param {string} [fieldName] - Optional field name for field-specific overrides
*/
function inferProtoType(typeText, fieldName) {
// Remove 'undefined' from union types
const cleanType = typeText
.replace(/\s*\|\s*undefined/g, "")
.replace(/undefined\s*\|\s*/g, "")
.trim()
// Handle common types
if (cleanType === "string") {
return "string"
}
if (cleanType === "boolean") {
return "bool"
}
if (cleanType === "number") {
// Some number fields need specific numeric types
if (fieldName && INT64_FIELDS.has(fieldName)) {
return "int64"
}
if (fieldName && DOUBLE_FIELDS.has(fieldName)) {
return "double"
}
return "int32"
}
// Handle Record<string, string> as map<string, string>
if (/Record\s*<\s*string\s*,\s*string\s*>/.test(cleanType)) {
return "map<string, string>"
}
// Handle specific known types that map to proto messages/enums
// Order matters! More specific types must come before generic ones
// (e.g., OpenAiCompatibleModelInfo before ModelInfo)
// Check known types BEFORE string literals, since types like `"act" as Mode`
// contain quotes but should map to proto enums
const knownTypes = [
// Specific model info types first
["OpenAiCompatibleModelInfo", "OpenAiCompatibleModelInfo"],
["LiteLLMModelInfo", "LiteLLMModelInfo"],
["OcaModelInfo", "OcaModelInfo"],
// Generic ModelInfo last (catches OpenRouterModelInfo, etc.)
["ModelInfo", "OpenRouterModelInfo"],
// Other types - order matters for substring matching
["AutoApprovalSettings", "AutoApprovalSettings"],
["BrowserSettings", "BrowserSettings"],
["DictationSettings", "DictationSettings"],
["FocusChainSettings", "FocusChainSettings"],
["OpenaiReasoningEffort", "OpenaiReasoningEffort"],
["PlanActMode", "PlanActMode"],
["ApiProvider", "ApiProvider"],
["LanguageModelChatSelector", "LanguageModelChatSelector"], // Must come before "Mode" check
]
for (const [tsType, protoType] of knownTypes) {
if (cleanType.includes(tsType)) {
return protoType
}
}
// Check for Mode type separately with word boundary to avoid matching "VsCodeLmModelSelector"
// This handles TS `Mode` type which maps to proto `PlanActMode`
if (/\bMode\b/.test(cleanType)) {
return "PlanActMode"
}
// Handle specific string literal unions (treat as string)
// This comes after known types check since some types like `"act" as Mode` contain quotes
if (cleanType.includes('"') || cleanType.includes("'")) {
return "string"
}
// Default to string for complex types we can't map
return "string"
}
/**
* Parse the SECRETS_KEYS array from state-keys.ts
*/
function parseSecretsKeys(sourceFile) {
const secretsDecl = sourceFile.getVariableDeclaration("SECRETS_KEYS")
if (!secretsDecl) {
throw new Error("Could not find SECRETS_KEYS declaration")
}
let initializer = secretsDecl.getInitializer()
if (!initializer) {
throw new Error("SECRETS_KEYS has no initializer")
}
// Handle 'as const' expression
if (initializer.getKind() === SyntaxKind.AsExpression) {
initializer = initializer.getExpression()
}
if (initializer.getKind() !== SyntaxKind.ArrayLiteralExpression) {
throw new Error(`SECRETS_KEYS is not an array literal (got ${SyntaxKind[initializer.getKind()]})`)
}
const keys = []
for (const element of initializer.getElements()) {
const text = element.getText()
// Remove quotes and handle special prefixes
const key = text.replace(/^['"]|['"]$/g, "")
// Skip prefixed keys like "cline:clineAccountId"
if (!key.includes(":")) {
keys.push(key)
}
}
return keys
}
/**
* Parse field definitions from an object literal in state-keys.ts
*/
function parseFieldDefinitions(sourceFile, variableName) {
const decl = sourceFile.getVariableDeclaration(variableName)
if (!decl) {
throw new Error(`Could not find ${variableName} declaration`)
}
const initializer = decl.getInitializer()
if (!initializer) {
throw new Error(`${variableName} has no initializer`)
}
// Handle 'satisfies' expression
let objectLiteral = initializer
if (initializer.getKind() === SyntaxKind.SatisfiesExpression) {
objectLiteral = initializer.getExpression()
}
if (objectLiteral.getKind() !== SyntaxKind.ObjectLiteralExpression) {
throw new Error(`${variableName} is not an object literal`)
}
const fields = []
for (const prop of objectLiteral.getProperties()) {
if (prop.getKind() !== SyntaxKind.PropertyAssignment) {
continue
}
const name = prop.getName()
const propInit = prop.getInitializer()
if (!propInit || propInit.getKind() !== SyntaxKind.ObjectLiteralExpression) {
continue
}
// Get the 'default' property to infer the type
const defaultProp = propInit.getProperty("default")
if (!defaultProp) {
continue
}
let typeText = "string"
const defaultInit = defaultProp.getInitializer()
if (defaultInit) {
// Check for 'as' expression to get the type
if (defaultInit.getKind() === SyntaxKind.AsExpression) {
const typeNode = defaultInit.getTypeNode()
if (typeNode) {
typeText = typeNode.getText()
}
} else {
// Infer from literal
const text = defaultInit.getText()
if (text === "true" || text === "false") {
typeText = "boolean"
} else if (/^\d+$/.test(text)) {
typeText = "number"
} else if (/^\d+\.\d+$/.test(text)) {
typeText = "number"
}
}
}
fields.push({
name,
tsType: typeText,
protoType: inferProtoType(typeText, name),
})
}
return fields
}
/**
* Convert snake_case to camelCase for mapping proto fields back to TS keys
*/
function snakeToCamel(str) {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
}
/**
* Parse field numbers from an existing proto message definition
* Returns a map of camelCase field names to their field numbers
*/
function parseProtoMessageFieldNumbers(protoContent, messageName) {
const fieldNumbers = {}
// Match the message block (handles single-level nesting for now)
const messageRegex = new RegExp(`message\\s+${messageName}\\s*\\{([^}]*(?:\\{[^}]*\\}[^}]*)*)\\}`, "s")
const match = protoContent.match(messageRegex)
if (!match) {
return fieldNumbers
}
const messageBody = match[1]
// Match field definitions: optional/required/repeated type name = number;
const fieldRegex = /(?:optional|required|repeated)?\s*\w+\s+(\w+)\s*=\s*(\d+)\s*;/g
const matches = messageBody.matchAll(fieldRegex)
for (const fieldMatch of matches) {
const snakeName = fieldMatch[1]
const fieldNum = parseInt(fieldMatch[2], 10)
const camelName = snakeToCamel(snakeName)
fieldNumbers[camelName] = fieldNum
}
return fieldNumbers
}
/**
* Load field number mappings from existing proto file
*/
async function loadFieldNumbersFromProto() {
try {
const protoContent = await fs.readFile(STATE_PROTO_PATH, "utf-8")
const secrets = parseProtoMessageFieldNumbers(protoContent, "Secrets")
const settings = parseProtoMessageFieldNumbers(protoContent, "Settings")
console.log(` Found ${Object.keys(secrets).length} existing Secrets fields`)
console.log(` Found ${Object.keys(settings).length} existing Settings fields`)
return { Secrets: secrets, Settings: settings }
} catch {
// Proto file doesn't exist, start fresh
return { Secrets: {}, Settings: {} }
}
}
/**
* Assign field numbers, preserving existing assignments and adding new ones
*/
function assignFieldNumbers(fields, existingNumbers, startNumber = 1) {
const result = {}
let nextNumber = startNumber
// Find the highest existing number
for (const num of Object.values(existingNumbers)) {
if (num >= nextNumber) {
nextNumber = num + 1
}
}
// Preserve existing assignments
for (const field of fields) {
if (existingNumbers[field.name] !== undefined) {
result[field.name] = existingNumbers[field.name]
}
}
// Assign new numbers for new fields
for (const field of fields) {
if (result[field.name] === undefined) {
result[field.name] = nextNumber++
}
}
return result
}
/**
* Generate proto message definition
*/
function generateProtoMessage(messageName, fields, fieldNumbers) {
const lines = [`message ${messageName} {`]
// Sort fields by field number for consistent output
const sortedFields = [...fields].sort((a, b) => fieldNumbers[a.name] - fieldNumbers[b.name])
for (const field of sortedFields) {
const snakeName = camelToSnake(field.name)
const fieldNum = fieldNumbers[field.name]
// Map types cannot have the 'optional' modifier in proto3
const prefix = field.protoType.startsWith("map<") ? "" : "optional "
lines.push(` ${prefix}${field.protoType} ${snakeName} = ${fieldNum};`)
}
lines.push("}")
return lines.join("\n")
}
/**
* Generate Secrets message from SECRETS_KEYS
*/
function generateSecretsMessage(secretsKeys, fieldNumbers) {
const fields = secretsKeys.map((key) => ({
name: key,
protoType: "string",
}))
return generateProtoMessage("Secrets", fields, fieldNumbers)
}
/**
* Replace a message in the proto file content
*/
function replaceMessage(protoContent, messageName, newMessageContent) {
// Match the message definition including nested braces
const messageRegex = new RegExp(`message\\s+${messageName}\\s*\\{[^}]*(?:\\{[^}]*\\}[^}]*)*\\}`, "g")
if (messageRegex.test(protoContent)) {
return protoContent.replace(messageRegex, newMessageContent)
} else {
// Message doesn't exist, append before the first message or at end
console.warn(`Warning: ${messageName} message not found in proto file, appending`)
return protoContent + "\n\n" + newMessageContent
}
}
async function main() {
console.log("Generating proto definitions from TypeScript source...")
// Parse TypeScript source
const project = new Project({
tsConfigFilePath: "tsconfig.json",
})
const sourceFile = project.addSourceFileAtPath(STATE_KEYS_PATH)
// Parse definitions
const secretsKeys = parseSecretsKeys(sourceFile)
console.log(`Found ${secretsKeys.length} secret keys`)
const apiHandlerFields = parseFieldDefinitions(sourceFile, "API_HANDLER_SETTINGS_FIELDS")
const userSettingsFields = parseFieldDefinitions(sourceFile, "USER_SETTINGS_FIELDS")
const settingsFields = [...apiHandlerFields, ...userSettingsFields]
console.log(`Found ${settingsFields.length} settings fields`)
// Load existing field numbers from proto file
const existingFieldNumbers = await loadFieldNumbersFromProto()
// Assign field numbers (preserving existing, adding new ones)
const secretsFieldNumbers = assignFieldNumbers(
secretsKeys.map((k) => ({ name: k })),
existingFieldNumbers.Secrets,
1,
)
const settingsFieldNumbers = assignFieldNumbers(settingsFields, existingFieldNumbers.Settings, 1)
// Generate messages
const secretsMessage = generateSecretsMessage(secretsKeys, secretsFieldNumbers)
const settingsMessage = generateProtoMessage("Settings", settingsFields, settingsFieldNumbers)
// Read existing proto file
let protoContent = await fs.readFile(STATE_PROTO_PATH, "utf-8")
// Replace messages
protoContent = replaceMessage(protoContent, "Secrets", secretsMessage)
protoContent = replaceMessage(protoContent, "Settings", settingsMessage)
// Write updated proto file
await fs.writeFile(STATE_PROTO_PATH, protoContent)
console.log(`Updated ${STATE_PROTO_PATH}`)
console.log("\nGeneration complete! Run 'npm run protos' to regenerate TypeScript from protos.")
}
main().catch((error) => {
console.error("Error:", error)
process.exit(1)
})
+39 -18
View File
@@ -22,19 +22,27 @@ echo ""
# Always rebuild CLI to ensure latest changes
echo -e "${CYAN}${NC} ${DIM}Rebuilding CLI binaries...${NC}"
cd "$PROJECT_ROOT"
if npm run compile-cli 2>&1 | grep -E "(built|error|Error)" || true; then
if npm run build:cli:all-platforms 2>&1 | grep -E "(built|error|Error)" || true; then
echo -e "${GREEN}${NC} CLI binaries rebuilt"
else
echo -e "${YELLOW}${NC} CLI build may have issues - check output above"
echo -e "${YELLOW}${NC} CLI build failed - aborting install"
exit 1
fi
# Always rebuild standalone to ensure latest cline-core.js
echo -e "${CYAN}${NC} ${DIM}Rebuilding standalone package (this may take ~30 seconds)...${NC}"
if npm run compile-standalone 2>&1 | tail -5; then
rm -rf "$PROJECT_ROOT/dist-standalone"
if npm run compile-standalone; then
echo -e "${GREEN}${NC} Standalone package rebuilt"
else
echo -e "${YELLOW}${NC} Standalone build may have issues - check output above"
echo -e "${YELLOW}${NC} Standalone build failed - aborting install"
exit 1
fi
# Ensure extension package.json is present for cline-core startup
mkdir -p "$PROJECT_ROOT/dist-standalone/extension"
cp "$PROJECT_ROOT/package.json" "$PROJECT_ROOT/dist-standalone/extension/package.json"
echo ""
echo -e "${CYAN}${NC} ${DIM}Installing to $INSTALL_DIR${NC}"
@@ -49,17 +57,14 @@ fi
# Create installation directory
mkdir -p "$INSTALL_DIR/bin"
# Copy standalone package first (cline-core.js, wasm files, etc.)
rsync -a --exclude='bin' "$PROJECT_ROOT/dist-standalone/" "$INSTALL_DIR/"
# Copy standalone package first (includes node_modules, cline-core.js, etc.)
cp -r "$PROJECT_ROOT/dist-standalone/" "$INSTALL_DIR/"
# Install runtime dependencies (grpc-health-check, better-sqlite3, etc.)
# These are external dependencies not bundled into cline-core.js
echo -e "${CYAN}${NC} ${DIM}Installing runtime dependencies...${NC}"
cd "$PROJECT_ROOT/standalone/runtime-files"
npm install --silent 2>/dev/null || npm install
cp -r node_modules "$INSTALL_DIR/"
cp -r vscode "$INSTALL_DIR/node_modules/"
cd "$PROJECT_ROOT"
# Check if OS is windows
WIN=false
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]]; then
WIN=true
fi
# Detect platform for native modules
os=$(uname -s | tr '[:upper:]' '[:lower:]')
@@ -68,17 +73,29 @@ if [[ "$arch" == "aarch64" ]]; then arch="arm64"; fi
if [[ "$arch" == "x86_64" ]]; then arch="x64"; fi
platform="$os-$arch"
# Manually set platform to windows, as windows is tricky
if [[ "$WIN" = true ]]; then
platform="win-$arch"
fi
echo -e "${CYAN}${NC} ${DIM}Detected platform as $platform${NC}"
# Copy platform-specific native modules (like better-sqlite3)
if [ -d "$PROJECT_ROOT/dist-standalone/binaries/$platform/node_modules" ]; then
echo -e "${CYAN}${NC} ${DIM}Installing platform-specific modules for $platform${NC}"
cp -r "$PROJECT_ROOT/dist-standalone/binaries/$platform/node_modules/"* "$INSTALL_DIR/node_modules/" 2>/dev/null || true
fi
# Copy binaries (this will create/overwrite the bin directory)
mkdir -p "$INSTALL_DIR/bin"
cp "$PROJECT_ROOT/cli/bin/cline" "$INSTALL_DIR/bin/"
cp "$PROJECT_ROOT/cli/bin/cline-host" "$INSTALL_DIR/bin/"
if [[ "$WIN" = true ]]; then
cp "$PROJECT_ROOT/cli/bin/cline-windows-amd64.exe" "$INSTALL_DIR/bin/cline.exe"
cp "$PROJECT_ROOT/cli/bin/cline-host-windows-amd64.exe" "$INSTALL_DIR/bin/cline-host.exe"
else
cp "$PROJECT_ROOT/cli/bin/cline" "$INSTALL_DIR/bin/"
cp "$PROJECT_ROOT/cli/bin/cline-host" "$INSTALL_DIR/bin/"
fi
# Use system Node.js (symlink to avoid copying large binary)
if command -v node >/dev/null 2>&1; then
ln -sf "$(which node)" "$INSTALL_DIR/bin/node"
@@ -96,7 +113,11 @@ chmod +x "$INSTALL_DIR/bin/node" 2>/dev/null || true
# Rebuild better-sqlite3 for system Node.js version
echo -e "${CYAN}${NC} ${DIM}Rebuilding native modules for Node.js $(node --version)...${NC}"
cd "$INSTALL_DIR"
npm rebuild better-sqlite3 > /dev/null 2>&1
npm rebuild better-sqlite3
mkdir -p "$INSTALL_DIR/dist-standalone/node_modules/better-sqlite3"
cp -r "$INSTALL_DIR/node_modules/." "$INSTALL_DIR/dist-standalone/node_modules/better-sqlite3/"
cd "$PROJECT_ROOT"
echo -e "${GREEN}${NC} Native modules rebuilt"
+21 -3
View File
@@ -23,6 +23,13 @@ FORCE_INSTALL="${FORCE_INSTALL:-false}"
os=$(uname -s | tr '[:upper:]' '[:lower:]')
arch=$(uname -m)
# Check if OS is windows
WIN=false
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" || "$OSTYPE" == "win32" ]]; then
WIN=true
INSTALL_DIR="${CLINE_INSTALL_DIR:-$LOCALAPPDATA/.cline/cli}"
fi
# Normalize architecture names
if [[ "$arch" == "aarch64" ]]; then
arch="arm64"
@@ -47,8 +54,11 @@ case "$os" in
platform="linux-$arch"
;;
*)
[[ "$WIN" == true ]] || {
echo -e "${RED}${BOLD}ERROR${NC} ${RED}Unsupported OS: $os${NC}" >&2
exit 1
}
platform="win-$arch"
;;
esac
@@ -369,9 +379,17 @@ install_cline() {
# Extract package
print_step "Extracting package"
if ! tar -xzf "$package_file" -C "$INSTALL_DIR" --strip-components=0; then
print_error "Failed to extract package"
exit 1
if ! tar -xzf "$package_file" -C "$INSTALL_DIR" --strip-components=0; then
if [[ "$WIN" == true ]]; then
echo -e "${RED}${BOLD}TAR command failed. Attempting backup method...${NC}"
if ! unzip -oq "$package_file" -d "$INSTALL_DIR"; then
print_error "Failed to extract package"
exit 1
fi
else
print_error "Failed to extract package"
exit 1
fi
fi
# Make binaries executable
+584
View File
@@ -0,0 +1,584 @@
#!/usr/bin/env npx tsx
/**
* Unified installer for Cline
*
* Supports local (development) installation, and standard (production) installation
*
* Usage:
* npx tsx scripts/install.ts [--local] [options]
*
* Examples:
* npx tsx scripts/install.ts
* npx tsx scripts/install.ts --local
* npx tsx scripts/install.ts 3.42.1
*/
import { execSync } from "node:child_process"
import fs from "node:fs/promises"
import { homedir } from "node:os"
import path from "node:path"
import { fileURLToPath } from "node:url"
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const ROOT_DIR = path.resolve(__dirname, "..")
interface ParsedArgs {
local: boolean
version: string
}
interface RunOptions {
cwd?: string
env?: NodeJS.ProcessEnv
silent?: boolean
}
interface Platform {
name: "aix" | "darwin" | "freebsd" | "linux" | "openbsd" | "sunos" | "win" | "android" | "haiku" | "cygwin" | "netbsd"
arch: "arm" | "arm64" | "ia32" | "loong64" | "mips" | "mipsel" | "ppc" | "ppc64" | "riscv64" | "s390" | "s390x" | "x64"
}
const SupportedPlatforms: Platform[] = [
{ name: "darwin", arch: "arm64" },
{ name: "darwin", arch: "x64" },
{ name: "linux", arch: "arm64" },
{ name: "linux", arch: "x64" },
{ name: "win", arch: "x64" },
]
// Colors for terminal output
const colors = {
reset: "\x1b[0m",
bright: "\x1b[1m",
red: "\x1b[31m",
green: "\x1b[32m",
yellow: "\x1b[33m",
blue: "\x1b[34m",
cyan: "\x1b[36m",
magenta: "\x1b[35m",
} as const
function log(message: string, color: string = colors.reset): void {
console.log(`${color}${message}${colors.reset}`)
}
function logStep(step: string, message: string): void {
log(`\n${colors.bright}[${step}]${colors.reset} ${message}`, colors.cyan)
}
function logSuccess(message: string): void {
log(`${message}`, colors.green)
}
function logError(message: string): void {
log(`${message}`, colors.red)
}
function logWarning(message: string): void {
log(` ! ${message}`, colors.yellow)
}
function logSeparator(): void {
log("=".repeat(60), colors.bright)
}
/**
* Parse command line arguments
*/
function parseArgs(): ParsedArgs {
const args = process.argv.slice(2)
const result: ParsedArgs = {
local: false,
version: "latest",
}
for (const arg of args) {
if (arg === "--local") {
result.local = true
} else if (arg === "--help" || arg === "-h") {
console.log(`
Unified Installer for Cline
Usage:
npx tsx scripts/install.ts [--local]
Options:
--local Install using local build instead of production build
`)
process.exit(0)
} else {
// Assume arg is version and attempt to set it. Create validator later
result.version = arg
}
}
return result
}
/**
* Run a command synchronously with cross-platform support
*/
function run(cmd: string, opts: RunOptions = {}): void {
const { cwd = ROOT_DIR, env = process.env, silent = false } = opts
if (!silent) {
log(` $ ${cmd}`, colors.yellow)
}
try {
execSync(cmd, {
cwd,
env,
stdio: silent ? "pipe" : "inherit",
})
} catch {
throw new Error(`Command failed: ${cmd}`)
}
}
/**
* Run a command and return the output
*/
function runCapture(cmd: string, opts: { cwd?: string } = {}): string | null {
const { cwd = ROOT_DIR } = opts
try {
return execSync(cmd, {
cwd,
encoding: "utf8",
}).trim()
} catch {
return null
}
}
async function detectOS(): Promise<Platform> {
const sanitizedPlatform = (() => {
switch (process.platform) {
case "win32":
return "win"
default:
return process.platform
}
})()
return { name: sanitizedPlatform, arch: process.arch }
}
function ValidateOS({ name, arch }: Platform) {
return new Promise((resolve) => {
const isSupported = !!SupportedPlatforms.find((platform) => {
if (platform.arch === arch) {
if (platform.name === name) {
return true
}
}
})
if (isSupported) {
resolve(true)
} else {
throw new Error(`Unsupported Operating System: ${name}-${arch}`)
}
})
}
async function getInstallDirectory({ name }: Platform) {
switch (name) {
case "win":
return (
(process.env.APPDATA && path.join(process.env.APPDATA, ".cline", ".cli")) ||
path.join(homedir(), "AppData", "Roaming", ".cline", ".cli")
)
case "darwin":
case "linux":
return (
(process.env.XDG_CONFIG_HOME && path.join(process.env.XDG_CONFIG_HOME, ".cline", ".cli")) ||
path.join(homedir(), ".cline", ".")
)
default:
throw new Error(`Unhandled operating system: ${name}\n\nPlease report this error to support@cline.bot or on github!`)
}
}
async function rebuildCLI({ name }: Platform) {
try {
// Build for current platform
const buildFor = name === "win" ? "windows" : "unix"
log(`Building explicitly for ${buildFor}`)
execSync(`npm run build:cli:${buildFor}`, { stdio: "inherit" })
} catch (error) {
throw new Error(`Error building CLI binaries: ${error}`)
}
}
async function rebuildClineCore() {
try {
// Build standalone package
execSync(`npm run compile-standalone`, { stdio: "inherit" })
} catch (error) {
throw new Error(`Error building Cline Core: ${error}`)
}
}
async function removePreviousCLI({ name }: Platform, installDirectory: string) {
switch (name) {
// biome-ignore lint/suspicious/noFallthroughSwitchClause: Only windows install is deprecated. This shouldn't be a factor, as we're adding support, but it's future-proofing.
case "win":
// Check if a previous installation exists at old install location
const oldInstallDir = path.join(homedir(), ".cline")
const deprecatedInstallDir = await (async () => {
try {
await fs.access(oldInstallDir)
return true
} catch (_error) {
return false
}
})()
if (deprecatedInstallDir) {
logWarning(
"Deprecated installation of Cline CLI detected. Migration will be attempted, however data loss may occur.",
)
log("Copying old files...", colors.yellow)
try {
await fs.cp(oldInstallDir, path.resolve(installDirectory, "../"), { recursive: true })
} catch (error) {
throw new Error(`Failed to copy old files: ${error}`)
}
try {
log("Removing old directory...", colors.yellow)
await fs.rm(oldInstallDir, { recursive: true, force: true })
} catch (error) {
throw new Error(
`Unable to perform migration. The following error was returned: \n\n${error}\n\nPlease manually move the .cline folder located at ${oldInstallDir} to ${path.resolve(installDirectory, "..")}`,
)
}
}
case "darwin":
case "linux":
log("Removing existing installation for clean install, if necessary", colors.yellow)
const previousInstall = await (async () => {
try {
await fs.access(installDirectory)
return true
} catch (_error) {
return false
}
})()
if (previousInstall) {
try {
await fs.rm(installDirectory, { recursive: true, force: true })
} catch (error) {
throw new Error(`Unable to remove previous installation: ${error}`)
}
} else {
log("No previous cline installation detected.", colors.green)
}
}
}
async function ensureDirectory(directory: string) {
const dirExists = await (async () => {
try {
await fs.access(directory)
return true
} catch (_error) {
return false
}
})()
if (dirExists) {
return
} else {
try {
await fs.mkdir(directory, { recursive: true })
} catch (error) {
throw new Error(`Unable to create installation directory: ${error}`)
}
}
}
async function copyStandalone(installDirectory: string) {
const standaloneDir: string = path.resolve(ROOT_DIR, "dist-standalone")
try {
await fs.cp(standaloneDir, path.resolve(installDirectory, "dist-standalone"), { recursive: true })
} catch (error) {
throw new Error(`Failed to copy standalone package files: ${error}`)
}
}
async function copyPlatformModules({ name, arch }: Platform, installDirectory: string) {
const moduleDir: string = path.resolve(ROOT_DIR, "dist-standalone", "binaries", `${name}-${arch}`, "node_modules")
try {
await fs.cp(moduleDir, path.resolve(installDirectory, "dist-standalone", "node_modules"), { recursive: true })
} catch (error) {
throw new Error(`Failed to copy platform module files for ${name}-${arch}: ${error}`)
}
}
async function copyBinaries({ name, arch }: Platform, installDirectory: string) {
try {
switch (name) {
case "win":
await fs.cp(
path.resolve(ROOT_DIR, "cli", "bin", `cline-windows-${arch}.exe`),
path.resolve(installDirectory, "bin", "cline.exe"),
)
await fs.cp(
path.resolve(ROOT_DIR, "cli", "bin", `cline-host-windows-${arch}.exe`),
path.resolve(installDirectory, "bin", "cline-host.exe"),
)
break
case "darwin":
case "linux":
await fs.cp(
path.resolve(ROOT_DIR, "cli", "bin", `cline-${name}-${arch}`),
path.resolve(installDirectory, "bin", "cline"),
)
await fs.cp(
path.resolve(ROOT_DIR, "cli", "bin", `cline-host-${name}-${arch}`),
path.resolve(installDirectory, "bin", "cline-host"),
)
break
}
} catch (error) {
throw new Error(`Failed to copy platform module files for ${name}-${arch}: ${error}`)
}
}
function linkSystemNode(installDirectory: string): Promise<string> {
return new Promise(async (resolve) => {
try {
await fs.cp(path.resolve(process.argv[0]), path.resolve(installDirectory, "bin", "node"))
const nodev = execSync("node -v", { cwd: path.resolve(installDirectory, "bin") }).toString()
resolve(nodev)
} catch (error: any) {
if (error.code === "EEXIST") {
const nodev = execSync("node -v", { cwd: path.resolve(installDirectory, "bin") }).toString()
resolve(nodev)
return
}
throw new Error(`Unable to copy node: ${error}`)
}
})
}
async function makeExecutable({ name }: Platform, installDirectory: string) {
try {
switch (name) {
case "win":
await fs.chmod(path.resolve(installDirectory, "bin", "cline.exe"), 0x755)
await fs.chmod(path.resolve(installDirectory, "bin", "cline-host.exe"), 0x755)
break
case "darwin":
case "linux":
await fs.chmod(path.resolve(installDirectory, "bin", "cline"), 0x755)
await fs.chmod(path.resolve(installDirectory, "bin", "cline-host"), 0x755)
break
}
} catch (error) {
throw new Error(`Failed to set permissions for files: ${error}`)
}
}
async function rebuildNativeModules(installDirectory: string) {
try {
execSync("npm rebuild better-sqlite3", { cwd: installDirectory })
} catch (error) {
throw new Error(`Failed to rebuild modules: ${error}`)
}
}
async function configurePATH({ name }: Platform, installDirectory: string) {
try {
const absolutePath = path.resolve(installDirectory, "bin")
if (name === "win") {
try {
// Get current user PATH
const stdout = execSync(`powershell -Command "[Environment]::GetEnvironmentVariable('Path', 'User')"`, {
encoding: "utf-8",
})
const currentPath = stdout.trim()
// Check if already in PATH
if (currentPath.split(";").some((p) => p.toLowerCase() === absolutePath.toLowerCase())) {
log("Directory already in PATH", colors.green)
return
}
// Add to PATH
const newPath = currentPath ? `${currentPath};${absolutePath}` : absolutePath
execSync(`powershell -Command "[Environment]::SetEnvironmentVariable('Path', '${newPath}', 'User')"`)
log("Added to PATH. Restart your terminal for changes to take effect.", colors.green)
} catch (error) {
throw new Error(`Failed to add to Windows PATH: ${error}`)
}
} else {
// Unix-like (Linux, macOS, etc.)
const shellConfigFiles = [
path.join(homedir(), ".bashrc"),
path.join(homedir(), ".bash_profile"),
path.join(homedir(), ".zshrc"),
path.join(homedir(), ".profile"),
]
const exportLine = `\nexport PATH="$PATH:${absolutePath}"\n`
try {
// Determine which shell config file to use
let targetFile: string | null = null
for (const file of shellConfigFiles) {
try {
await fs.access(file)
targetFile = file
break
} catch (_error) {}
}
// Default to .bashrc if none exist
if (!targetFile) {
targetFile = path.join(homedir(), ".bashrc")
}
// Check if already present
const content = await fs.readFile(targetFile, "utf-8")
if (content.includes(`PATH="$PATH:${absolutePath}"`)) {
log("Directory already in PATH", colors.green)
return
}
// Append to file
fs.appendFile(targetFile, exportLine)
log(`Added to PATH in ${targetFile}. Run 'source ${targetFile}' or restart your terminal.`, colors.green)
} catch (error) {
throw new Error(`Failed to configure PATH: ${error}`)
}
}
} catch (error) {
throw new Error(`Failed to configure PATH: ${error}`)
}
}
async function installLocal() {
logStep("1", "Checking operating system...")
// Detect OS
const platform: Platform = await detectOS()
logStep("2", "Validating compatibility...")
// Validate support
await ValidateOS(platform)
logSuccess(`Operating System: ${platform.name}-${platform.arch} is supported.`)
// Get install directory
logStep("3", "Getting install directory...")
const installDirectory = await getInstallDirectory(platform)
logSuccess(`Set install directory to: ${installDirectory}`)
// Rebuild the binaries
logStep("4", "Rebuilding binaries...")
await rebuildCLI(platform)
logSuccess("Binaries built.")
// Rebuild standalone package
logStep("5", "Rebuilding standalone package...")
await rebuildClineCore()
logSuccess("Standalone Package rebuilt.")
// Remove existing Cline installation
logStep("6", "Remove previous Cline CLI Installation")
await removePreviousCLI(platform, installDirectory)
// Create install directory
logStep("7", "Ensuring install directory exists")
await ensureDirectory(path.resolve(installDirectory, "bin"))
logSuccess(`Validated install directory: ${installDirectory}`)
// Copy standalone package first
logStep("8", "Copying standalone package")
await copyStandalone(installDirectory)
logSuccess("Standalone package copied.")
// Copy platform-specific modules
logStep("9", `Installing platform-specific modules for ${platform.name}-${platform.arch}`)
await copyPlatformModules(platform, installDirectory)
logSuccess("Modules copied.")
// Copy binaries
logStep("10", "Copying platform binaries")
await copyBinaries(platform, installDirectory)
logSuccess("Binaries copied successfully.")
// Use system node (via symlink)
logStep("11", "Linking system node to Cline")
const systemNodeVersion = await linkSystemNode(installDirectory)
logSuccess("Link successful.")
// Make binaries executable
logStep("12", "Ensuring binaries are executable")
await makeExecutable(platform, installDirectory)
logSuccess("Files are executable.")
// Rebuild better-sqlite3 for system node.js
logStep("13", `Rebuilding native modules for Node.js version ${systemNodeVersion}`)
await rebuildNativeModules(installDirectory)
logSuccess("Native modules rebuilt.")
// Configure system PATH
logStep("14", "Configuring system PATH")
await configurePATH(platform, installDirectory)
logSuccess("Linked Cline CLI to PATH.")
// Installation Complete
logSeparator()
log("Cline CLI has been installed!", colors.green)
log("Now you're Cooking with Cline CLI!", colors.magenta)
logSeparator()
}
async function installProd() {
// Get install directory
// Set github Repo
// Get requested version, default to latest
// Check prerequisites
// Check rate limit
// Get requested release
// Show info
// Remove existing Cline installation
// Download package
// Inflate package to install directory
// Validate
// Configure system PATH
// Installation Complete
}
/**
* Main entry point
*/
async function main(): Promise<void> {
const args = parseArgs()
logSeparator()
log("Cline Installer", colors.bright)
logSeparator()
const startTime = Date.now()
try {
if (args.local) {
// Build both dev and prod
log("\nInstalling for Local Development...", colors.cyan)
// Dev Install
await installLocal()
} else {
await installProd()
}
const duration = ((Date.now() - startTime) / 1000).toFixed(2)
logSeparator()
log(`Installed successfully in ${duration}s`, colors.green)
logSeparator()
} catch (error) {
const duration = ((Date.now() - startTime) / 1000).toFixed(2)
logSeparator()
logError(`Install failed after ${duration}s`)
logError((error as Error).message)
logSeparator()
process.exit(1)
}
}
main()
+384
View File
@@ -54,6 +54,390 @@ async function installNodeDependencies() {
fs.renameSync(`${BUILD_DIR}/vscode`, `${BUILD_DIR}/node_modules/vscode`)
}
/**
* Copy CLI binaries (cline and cline-host) for all platforms
* The Go binaries are cross-compiled for darwin/linux arm64/amd64
*/
async function copyCliBinaries() {
console.log("Copying CLI binaries for all platforms...")
const platforms = [
{ os: "darwin", arch: "arm64" },
{ os: "darwin", arch: "amd64" },
{ os: "linux", arch: "amd64" },
{ os: "linux", arch: "arm64" },
{ os: "win32", arch: "amd64" },
]
const binDir = path.join(BUILD_DIR, "bin")
// Create bin directory
fs.mkdirSync(binDir, { recursive: true })
// Copy all platform-specific binaries
for (const { os, arch } of platforms) {
const platformSuffix = `${os}-${arch}`
// Copy cline binary
const clineSource = path.join(CLI_BINARIES_DIR, `cline-${platformSuffix}`)
const clineDest = path.join(binDir, `cline-${platformSuffix}`)
if (!fs.existsSync(clineSource)) {
console.error(`Error: CLI binary not found at ${clineSource}`)
console.error(`Please run: npm run compile-cli`)
process.exit(1)
}
await cpr(clineSource, clineDest)
fs.chmodSync(clineDest, 0o755)
console.log(`✓ cline-${platformSuffix} copied`)
// Copy cline-host binary
const hostSource = path.join(CLI_BINARIES_DIR, `cline-host-${platformSuffix}`)
const hostDest = path.join(binDir, `cline-host-${platformSuffix}`)
if (!fs.existsSync(hostSource)) {
console.error(`Error: CLI binary not found at ${hostSource}`)
console.error(`Please run: npm run compile-cli`)
process.exit(1)
}
await cpr(hostSource, hostDest)
fs.chmodSync(hostDest, 0o755)
console.log(`✓ cline-host-${platformSuffix} copied`)
}
console.log(`✓ All platform binaries copied to ${binDir}`)
}
/**
* Copy proto descriptors directory
* The proto/descriptor_set.pb file is needed by cline-core for gRPC reflection
*/
async function copyProtoDescriptors() {
console.log("Copying proto descriptors...")
const protoSource = "proto"
const protoDest = path.join(BUILD_DIR, "proto")
// Check if proto directory exists
if (!fs.existsSync(protoSource)) {
console.error(`Error: proto directory not found at ${protoSource}`)
console.error(`Please ensure the proto files have been generated`)
process.exit(1)
}
// Check if descriptor_set.pb exists
const descriptorPath = path.join(protoSource, "descriptor_set.pb")
if (!fs.existsSync(descriptorPath)) {
console.error(`Error: proto/descriptor_set.pb not found at ${descriptorPath}`)
console.error(`Please run: npm run protos`)
process.exit(1)
}
// Copy the entire proto directory
await cpr(protoSource, protoDest)
console.log(`✓ Proto descriptors copied to ${protoDest}`)
}
/**
* Copy ripgrep binary for the current platform
* Ripgrep is needed by cline-core for file searching
*/
async function copyRipgrepBinary() {
const currentPlatform = getCurrentPlatform()
const binaryName = currentPlatform.startsWith("win") ? "rg.exe" : "rg"
const ripgrepBinarySource = path.join(RIPGREP_BINARIES_DIR, currentPlatform, binaryName)
const ripgrepBinaryDest = path.join(BUILD_DIR, binaryName)
console.log(`Copying ripgrep binary for ${currentPlatform}...`)
// Check if ripgrep binaries exist, download if missing
if (!fs.existsSync(ripgrepBinarySource)) {
console.log(`Ripgrep binary not found, downloading...`)
try {
execSync("npm run download-ripgrep", { stdio: "inherit" })
} catch (error) {
console.error(`Error downloading ripgrep: ${error.message}`)
console.error(`Please run: npm run download-ripgrep`)
process.exit(1)
}
// Check again after download
if (!fs.existsSync(ripgrepBinarySource)) {
console.error(`Error: Ripgrep binary still not found at ${ripgrepBinarySource}`)
console.error(`Download may have failed. Please run: npm run download-ripgrep`)
process.exit(1)
}
}
// Copy ripgrep binary to the root of dist-standalone (where cline-core.js is)
await cpr(ripgrepBinarySource, ripgrepBinaryDest)
// Make it executable (Unix only)
if (!currentPlatform.startsWith("win")) {
fs.chmodSync(ripgrepBinaryDest, 0o755)
}
console.log(`✓ Ripgrep binary copied to ${ripgrepBinaryDest}`)
}
/**
* Create a VERSION file with build metadata
*/
async function createVersionFile() {
const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8"))
const version = packageJson.version
const platform = getCurrentPlatform()
const buildDate = new Date().toISOString()
const versionInfo = {
version,
platform,
buildDate,
nodeVersion: TARGET_NODE_VERSION,
}
const versionPath = path.join(BUILD_DIR, "VERSION.txt")
fs.writeFileSync(versionPath, JSON.stringify(versionInfo, null, 2))
console.log(`✓ VERSION file created: ${version} (${platform})`)
}
/**
* Copy NPM package files (package.json, README.md, and man page) from cli/ directory
*/
async function createNpmPackageFiles() {
console.log("Copying NPM package files...")
// Copy package.json from cli/ directory
const packageJsonSource = path.join("cli", "package.json")
const packageJsonDest = path.join(BUILD_DIR, "package.json")
if (!fs.existsSync(packageJsonSource)) {
console.error(`Error: NPM package.json not found at ${packageJsonSource}`)
process.exit(1)
}
await cpr(packageJsonSource, packageJsonDest)
console.log(`✓ package.json copied from ${packageJsonSource}`)
// Copy README.md from cli/ directory
const readmeSource = path.join("cli", "README.md")
const readmeDest = path.join(BUILD_DIR, "README.md")
if (!fs.existsSync(readmeSource)) {
console.error(`Error: NPM README.md not found at ${readmeSource}`)
process.exit(1)
}
await cpr(readmeSource, readmeDest)
console.log(`✓ README.md copied from ${readmeSource}`)
// Copy man page from cli/man/ directory
const manPageSource = path.join("cli", "man", "cline.1")
const manDir = path.join(BUILD_DIR, "man")
const manPageDest = path.join(manDir, "cline.1")
if (!fs.existsSync(manPageSource)) {
console.error(`Error: Man page not found at ${manPageSource}`)
process.exit(1)
}
// Create man directory if it doesn't exist
fs.mkdirSync(manDir, { recursive: true })
await cpr(manPageSource, manPageDest)
console.log(`✓ Man page copied from ${manPageSource}`)
}
/**
* Create fake_node_modules directory with vscode stub
* This directory will be added to NODE_PATH so Node.js can find the vscode module
* without npm interfering with the real node_modules directory
*/
async function createFakeNodeModules() {
console.log("Creating fake_node_modules with vscode stub...")
const vscodeSource = path.join(BUILD_DIR, "node_modules", "vscode")
const fakeNodeModulesDir = path.join(BUILD_DIR, "fake_node_modules")
const vscodeDest = path.join(fakeNodeModulesDir, "vscode")
if (!fs.existsSync(vscodeSource)) {
console.error(`Error: vscode stub module not found at ${vscodeSource}`)
process.exit(1)
}
// Create fake_node_modules directory
fs.mkdirSync(fakeNodeModulesDir, { recursive: true })
// Copy vscode stub into fake_node_modules
await cpr(vscodeSource, vscodeDest)
console.log(`✓ fake_node_modules/vscode created at ${vscodeDest}`)
}
/**
* Create .npmignore file to ensure necessary files are included
*/
async function createNpmIgnoreFile() {
console.log("Creating .npmignore file...")
// Create .npmignore that excludes build artifacts
// Note: proto/ directory is NOT excluded because proto/descriptor_set.pb is needed at runtime
const npmignoreContent = `# Exclude build artifacts and unnecessary files
binaries/
ripgrep-binaries/
standalone.zip
cline-core.js.map
package-lock.json
tree-sitter*.wasm
node_modules/vscode
`
const npmignorePath = path.join(BUILD_DIR, ".npmignore")
fs.writeFileSync(npmignorePath, npmignoreContent)
console.log(`✓ .npmignore created`)
}
/**
* Create postinstall script for NPM package
* This script selects the correct platform-specific binary and creates symlinks
*/
async function createPostinstallScript() {
console.log("Creating postinstall script...")
const postinstallScript = `#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const os = require('os');
// Detect current platform and architecture
function getPlatformInfo() {
const platform = os.platform();
const arch = os.arch();
// Map Node.js arch names to Go arch names
let goArch = arch;
if (arch === 'x64') {
goArch = 'amd64';
}
let goPlatform = platform;
return { platform: goPlatform, arch: goArch };
}
// Setup platform-specific binaries
function setupBinaries() {
const { platform, arch } = getPlatformInfo();
const platformSuffix = \`\${platform}-\${arch}\`;
console.log(\`Setting up Cline CLI for \${platformSuffix}...\`);
const binDir = path.join(__dirname, 'bin');
// Check if platform-specific binaries exist
const clineSource = path.join(binDir, \`cline-\${platformSuffix}\`);
const clineHostSource = path.join(binDir, \`cline-host-\${platformSuffix}\`);
if (!fs.existsSync(clineSource)) {
console.error(\`Error: Binary not found for platform \${platformSuffix}\`);
console.error(\`Expected: \${clineSource}\`);
console.error(\`Supported platforms: darwin-arm64, darwin-amd64, linux-amd64, linux-arm64\`);
process.exit(1);
}
if (!fs.existsSync(clineHostSource)) {
console.error(\`Error: Binary not found for platform \${platformSuffix}\`);
console.error(\`Expected: \${clineHostSource}\`);
process.exit(1);
}
// Create symlinks or copies to the generic names
const clineTarget = path.join(binDir, 'cline');
const clineHostTarget = path.join(binDir, 'cline-host');
// Remove existing files if they exist
[clineTarget, clineHostTarget].forEach(target => {
if (fs.existsSync(target)) {
try {
fs.unlinkSync(target);
} catch (e) {
console.warn(\`Warning: Could not remove existing file \${target}: \${e.message}\`);
}
}
});
// On Unix, create symlinks; on Windows, copy files
if (platform === 'win32') {
// Windows: copy files
fs.copyFileSync(clineSource, clineTarget);
fs.copyFileSync(clineHostSource, clineHostTarget);
console.log('✓ Copied platform-specific binaries');
} else {
// Unix: create symlinks
fs.symlinkSync(path.basename(clineSource), clineTarget);
fs.symlinkSync(path.basename(clineHostSource), clineHostTarget);
console.log('✓ Created symlinks to platform-specific binaries');
// Make binaries executable
try {
fs.chmodSync(clineSource, 0o755);
fs.chmodSync(clineHostSource, 0o755);
fs.chmodSync(clineTarget, 0o755);
fs.chmodSync(clineHostTarget, 0o755);
} catch (error) {
console.warn(\`Warning: Could not set executable permissions: \${error.message}\`);
}
}
// Check ripgrep binary
const rgBinary = platform === 'win32' ? 'rg.exe' : 'rg';
const rgPath = path.join(__dirname, rgBinary);
if (!fs.existsSync(rgPath)) {
console.error(\`Error: ripgrep binary not found at \${rgPath}\`);
process.exit(1);
}
// Make ripgrep executable (Unix only)
if (platform !== 'win32') {
try {
fs.chmodSync(rgPath, 0o755);
} catch (error) {
console.warn(\`Warning: Could not set ripgrep executable permissions: \${error.message}\`);
}
}
console.log('✓ Cline CLI installation complete');
console.log('');
console.log('Usage:');
console.log(' cline - Start Cline CLI');
console.log(' cline-host - Start Cline host service');
console.log('');
console.log('Documentation: https://docs.cline.bot');
}
try {
setupBinaries();
} catch (error) {
console.error(\`Installation failed: \${error.message}\`);
console.error('Please report this issue at: https://github.com/cline/cline/issues');
process.exit(1);
}
`
const postinstallPath = path.join(BUILD_DIR, "postinstall.js")
fs.writeFileSync(postinstallPath, postinstallScript)
fs.chmodSync(postinstallPath, 0o755)
console.log(`✓ postinstall.js created`)
}
/**
* Downloads prebuilt binaries for each platform for the modules that include binaries. It uses `npx prebuild-install`
* to download the binary.
+1 -1
View File
@@ -63,7 +63,7 @@ echo ""
# Test 4: Check for platform support
echo "Test 4: Platform Support Check"
platforms=("darwin-x64" "darwin-arm64" "linux-x64")
platforms=("darwin-x64" "darwin-arm64" "linux-x64" "win-amd64")
for platform in "${platforms[@]}"; do
if grep -q "$platform" scripts/install.sh; then
echo " ✅ PASS: Platform '$platform' supported"
+38 -1
View File
@@ -24,6 +24,7 @@ import { telemetryService } from "./services/telemetry"
import { PostHogClientProvider } from "./services/telemetry/providers/posthog/PostHogClientProvider"
import { ShowMessageType } from "./shared/proto/host/window"
import { getLatestAnnouncementId } from "./utils/announcements"
import { arePathsEqual } from "./utils/path"
/**
* Performs intialization for Cline that is common to all platforms.
*
@@ -49,7 +50,7 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
// Setup the external services
await ErrorService.initialize()
await featureFlagsService.poll()
await featureFlagsService.poll(null)
// Migrate custom instructions to global Cline rules (one-time cleanup)
await migrateCustomInstructionsToGlobalRules(context)
@@ -76,6 +77,9 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
await showVersionUpdateAnnouncement(context)
// Check if this workspace was opened from worktree quick launch
await checkWorktreeAutoOpen(context)
// Initialize banner service (TEMPORARILY DISABLED - not fetching banners to prevent API hammering)
BannerService.initialize(webview.controller)
// DISABLED: .getActiveBanners(true)
@@ -117,6 +121,39 @@ async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
}
}
/**
* Checks if this workspace was opened from the worktree quick launch button.
* If so, opens the Cline sidebar and clears the state.
*/
async function checkWorktreeAutoOpen(context: vscode.ExtensionContext): Promise<void> {
try {
// Read directly from globalState (not StateManager cache) since this may have been
// set by another window right before this one opened
const worktreeAutoOpenPath = context.globalState.get<string>("worktreeAutoOpenPath")
if (!worktreeAutoOpenPath) {
return
}
// Get current workspace path
const workspacePaths = (await HostProvider.workspace.getWorkspacePaths({})).paths
if (workspacePaths.length === 0) {
return
}
const currentPath = workspacePaths[0]
// Check if current workspace matches the worktree path
if (arePathsEqual(currentPath, worktreeAutoOpenPath)) {
// Clear the state first to prevent re-triggering
await context.globalState.update("worktreeAutoOpenPath", undefined)
// Open the Cline sidebar
await HostProvider.workspace.openClineSidebarPanel({})
}
} catch (error) {
Logger.error("Error checking worktree auto-open", error)
}
}
/**
* Performs cleanup when Cline is deactivated that is common to all platforms.
*/
+6
View File
@@ -601,6 +601,8 @@ export class SapAiCoreHandler implements ApiHandler {
}
const anthropicModels = [
"anthropic--claude-4.5-haiku",
"anthropic--claude-4.5-opus",
"anthropic--claude-4.5-sonnet",
"anthropic--claude-4-sonnet",
"anthropic--claude-4-opus",
@@ -649,7 +651,9 @@ export class SapAiCoreHandler implements ApiHandler {
const secondLastMsgUserIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1
if (
model.id === "anthropic--claude-4.5-opus" ||
model.id === "anthropic--claude-4.5-sonnet" ||
model.id === "anthropic--claude-4.5-haiku" ||
model.id === "anthropic--claude-4-sonnet" ||
model.id === "anthropic--claude-4-opus" ||
model.id === "anthropic--claude-3.7-sonnet"
@@ -779,7 +783,9 @@ export class SapAiCoreHandler implements ApiHandler {
} else if (openAIModels.includes(model.id) || perplexityModels.includes(model.id)) {
yield* this.streamCompletionGPT(response.data, model)
} else if (
model.id === "anthropic--claude-4.5-opus" ||
model.id === "anthropic--claude-4.5-sonnet" ||
model.id === "anthropic--claude-4.5-haiku" ||
model.id === "anthropic--claude-4-sonnet" ||
model.id === "anthropic--claude-4-opus" ||
model.id === "anthropic--claude-3.7-sonnet"
+50 -5
View File
@@ -10,6 +10,43 @@ import {
ClineUserToolResultContentBlock,
} from "@/shared/messages/content"
// OpenAI API has a maximum tool call ID length of 40 characters
const MAX_TOOL_CALL_ID_LENGTH = 40
/**
* Determines if a given tool ID follows the OpenAI Responses API format for tool calls.
* OpenAI tool call IDs start with "fc_" and are exactly 53 characters long.
*
* @param callId - The tool ID to check
* @returns True if the tool ID matches the OpenAI Responses API format, false otherwise
*/
function isOpenAIResponseToolId(callId: string): boolean {
return callId.startsWith("fc_") && callId.length === 53
}
/**
* Transforms a tool ID to a consistent format for OpenAI's Chat Completions API.
* This function MUST be used for both tool_calls[].id (assistant) and tool_call_id (tool result)
* to ensure they match - otherwise OpenAI will reject the request with:
* "Invalid parameter: 'tool_call_id' of 'xxx' not found in 'tool_calls' of previous message."
*
* @param toolId - The original tool ID from Cline/Anthropic format
* @returns The transformed ID suitable for OpenAI API
*/
function transformToolCallId(toolId: string): string {
// OpenAI Responses API uses "fc_" prefix with 53 char length
// Convert these to "call_" prefix format for Chat Completions API
if (isOpenAIResponseToolId(toolId)) {
// Use the last 33 chars + "call_" (5 chars) to stay under the 40-char limit.
return `call_${toolId.slice(toolId.length - (MAX_TOOL_CALL_ID_LENGTH - 5))}`
}
// Ensure ID doesn't exceed max length
if (toolId.length > MAX_TOOL_CALL_ID_LENGTH) {
return toolId.slice(0, MAX_TOOL_CALL_ID_LENGTH)
}
return toolId
}
/**
* Converts an array of ClineStorageMessage objects to OpenAI's Completions API format.
*
@@ -80,7 +117,9 @@ export function convertToOpenAiMessages(
}
openAiMessages.push({
role: "tool",
tool_call_id: toolMessage.tool_use_id,
// The tool_call_id must match the id used in the assistant's tool_calls array.
// Use the same transformation logic as tool_calls to ensure IDs match.
tool_call_id: transformToolCallId(toolMessage.tool_use_id),
content: content,
})
})
@@ -171,23 +210,29 @@ export function convertToOpenAiMessages(
// Process tool use messages
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => {
const toolDetails = toolMessage.reasoning_details
const toolId = toolMessage.id
if (toolDetails) {
if (Array.isArray(toolDetails)) {
// For Gemini: reasoning details must be linkable back to the tool call.
// Sometimes OpenRouter/Gemini returns entries without `id`; those poison the next request.
// Keep only entries with an id matching the tool call id.
// See: https://github.com/cline/cline/issues/8214
const validDetails = toolDetails.filter((detail: any) => detail?.id === toolMessage.id)
if (validDetails.length > 0) reasoningDetails.push(...validDetails)
const validDetails = toolDetails.filter((detail: any) => detail?.id === toolId)
if (validDetails.length > 0) {
reasoningDetails.push(...validDetails)
}
} else {
// Single reasoning detail - only include if it has matching id
const detail = toolDetails as any
if (detail?.id === toolMessage.id) reasoningDetails.push(toolDetails)
if (detail?.id === toolId) {
reasoningDetails.push(toolDetails)
}
}
}
return {
id: toolMessage.id,
// Use the same transformation as tool_call_id to ensure IDs match
id: transformToolCallId(toolId),
type: "function",
function: {
name: toolMessage.name,
@@ -170,7 +170,8 @@ export function convertToOpenAIResponsesInput(messages: ClineStorageMessage[]):
assistantItems.push({
type: "function_call",
call_id,
id: part.id,
// MAX 53 characters for OpenAI Responses API tool IDs
id: !part.id.startsWith("fc_") ? `fc_${part.id.slice(0, 50)}` : part.id,
name: part.name,
arguments: JSON.stringify(part.input ?? {}),
})
@@ -0,0 +1,30 @@
import { expect } from "chai"
import { parseYamlFrontmatter } from "../frontmatter"
describe("parseYamlFrontmatter", () => {
it("returns original content when no frontmatter", () => {
const input = "Just text"
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(false)
expect(result.data).to.deep.equal({})
expect(result.body).to.equal(input)
})
it("parses valid YAML frontmatter", () => {
const input = `---\npaths:\n - "src/**"\n---\n\nHello`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.parseError).to.equal(undefined)
expect(result.data).to.deep.equal({ paths: ["src/**"] })
expect(result.body.trim()).to.equal("Hello")
})
it("fails open on malformed YAML", () => {
const input = `---\npaths: [invalid\n---\nBody`
const result = parseYamlFrontmatter(input)
expect(result.hadFrontmatter).to.equal(true)
expect(result.data).to.deep.equal({})
expect(result.body).to.equal(input)
expect(result.parseError).to.be.a("string")
})
})
@@ -0,0 +1,54 @@
import { expect } from "chai"
import { evaluateRuleConditionals, extractPathLikeStrings } from "../rule-conditionals"
describe("rule-conditionals", () => {
describe("evaluateRuleConditionals(paths)", () => {
it("treats missing paths as universal", () => {
const res = evaluateRuleConditionals({}, { paths: [] })
expect(res.passed).to.equal(true)
})
it("treats empty paths list in frontmatter as match-nothing (fail-closed)", () => {
const res = evaluateRuleConditionals({ paths: [] }, { paths: ["src/index.ts"] })
expect(res.passed).to.equal(false)
})
it("does not activate path-scoped rules with empty context", () => {
const res = evaluateRuleConditionals({ paths: ["src/**"] }, { paths: [] })
expect(res.passed).to.equal(false)
})
it("matches when any candidate path matches any glob", () => {
const res = evaluateRuleConditionals({ paths: ["src/**", "apps/**"] }, { paths: ["src/index.ts"] })
expect(res.passed).to.equal(true)
expect(res.matchedConditions.paths).to.deep.equal(["src/**"])
})
it("ignores invalid paths type (fail-open)", () => {
const res = evaluateRuleConditionals({ paths: "src/**" as any }, { paths: [] })
expect(res.passed).to.equal(true)
})
})
describe("extractPathLikeStrings", () => {
it("extracts basic relative paths", () => {
const res = extractPathLikeStrings("edit apps/web/src/App.tsx and packages/foo/src")
expect(res).to.deep.equal(["apps/web/src/App.tsx", "packages/foo/src"])
})
it("extracts simple filenames with extensions (no slashes)", () => {
const res = extractPathLikeStrings("Does foo.md exist? If not, create foo.md")
expect(res).to.deep.equal(["foo.md"])
})
it("does not extract bare words without an extension", () => {
const res = extractPathLikeStrings("Please create foo and then update bar")
expect(res).to.deep.equal([])
})
it("ignores URLs", () => {
const res = extractPathLikeStrings("see https://example.com/a/b and edit src/index.ts")
expect(res).to.deep.equal(["src/index.ts"])
})
})
})
@@ -0,0 +1,117 @@
import { expect } from "chai"
import fs from "fs/promises"
import os from "os"
import path from "path"
import { getRuleFilesTotalContentWithMetadata } from "../rule-helpers"
describe("rule loading with paths frontmatter", () => {
it("filters rules by evaluationContext.paths", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
try {
const rulesDir = path.join(tmp, ".clinerules")
await fs.mkdir(rulesDir, { recursive: true })
await fs.writeFile(path.join(rulesDir, "universal.md"), "Always on")
await fs.writeFile(path.join(rulesDir, "scoped.md"), `---\npaths:\n - "src/**"\n---\n\nOnly for src`)
const files = ["universal.md", "scoped.md"]
const toggles: Record<string, boolean> = {
[path.join(rulesDir, "universal.md")]: true,
[path.join(rulesDir, "scoped.md")]: true,
}
const res1 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["src/index.ts"] },
})
expect(res1.content).to.contain("universal.md")
expect(res1.content).to.contain("scoped.md")
expect(res1.content).to.not.contain("paths:")
expect(res1.activatedConditionalRules.map((r) => r.name)).to.include("scoped.md")
const res2 = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["docs/readme.md"] },
})
expect(res2.content).to.contain("universal.md")
expect(res2.content).to.not.contain("scoped.md")
} finally {
await fs.rm(tmp, { recursive: true, force: true })
}
})
it("treats invalid YAML frontmatter as fail-open and preserves the raw frontmatter for the LLM", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
try {
const rulesDir = path.join(tmp, ".clinerules")
await fs.mkdir(rulesDir, { recursive: true })
// Intentionally invalid YAML (unquoted '*' is a YAML alias indicator)
await fs.writeFile(
path.join(rulesDir, "invalid.md"),
`---\npaths: *\n---\n\nInvalid YAML, but should still be included`,
)
const files = ["invalid.md"]
const toggles: Record<string, boolean> = {
[path.join(rulesDir, "invalid.md")]: true,
}
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["src/index.ts"] },
})
// Fail-open: included even though frontmatter cannot be parsed.
expect(res.content).to.contain("invalid.md")
// Preserve raw frontmatter fence/content for the LLM.
expect(res.content).to.contain("---")
expect(res.content).to.contain("paths:")
} finally {
await fs.rm(tmp, { recursive: true, force: true })
}
})
it("treats paths: [] as match-nothing (fail-closed)", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
try {
const rulesDir = path.join(tmp, ".clinerules")
await fs.mkdir(rulesDir, { recursive: true })
await fs.writeFile(path.join(rulesDir, "scoped-empty.md"), `---\npaths: []\n---\n\nShould never activate`)
const files = ["scoped-empty.md"]
const toggles: Record<string, boolean> = {
[path.join(rulesDir, "scoped-empty.md")]: true,
}
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["src/index.ts"] },
})
expect(res.content).to.not.contain("scoped-empty.md")
} finally {
await fs.rm(tmp, { recursive: true, force: true })
}
})
it("keeps activatedConditionalRules order stable (matches input file order)", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-test-"))
try {
const rulesDir = path.join(tmp, ".clinerules")
await fs.mkdir(rulesDir, { recursive: true })
await fs.writeFile(path.join(rulesDir, "a.md"), `---\npaths:\n - "src/**"\n---\n\nA`)
await fs.writeFile(path.join(rulesDir, "b.md"), `---\npaths:\n - "src/**"\n---\n\nB`)
await fs.writeFile(path.join(rulesDir, "c.md"), `---\npaths:\n - "src/**"\n---\n\nC`)
const files = ["a.md", "b.md", "c.md"]
const toggles: Record<string, boolean> = {
[path.join(rulesDir, "a.md")]: true,
[path.join(rulesDir, "b.md")]: true,
[path.join(rulesDir, "c.md")]: true,
}
const res = await getRuleFilesTotalContentWithMetadata(files, rulesDir, toggles, {
evaluationContext: { paths: ["src/index.ts"] },
})
expect(res.activatedConditionalRules.map((r) => r.name)).to.deep.equal(files)
} finally {
await fs.rm(tmp, { recursive: true, force: true })
}
})
})
@@ -0,0 +1,53 @@
import * as yaml from "js-yaml"
export type FrontmatterParseResult = {
data: Record<string, unknown>
/**
* The markdown content after stripping the `--- frontmatter ---` block.
*
* Named `body` (rather than `content`) to make it clear this is the remaining
* document body and to keep this helper generic for multiple consumers.
*/
body: string
/**
* True when the input contained a frontmatter block, even if parsing failed.
*
* This allows callers to distinguish:
* - "no frontmatter provided" (baseline behavior), vs
* - "frontmatter was provided" (may have semantic meaning in future consumers).
*/
hadFrontmatter: boolean
/**
* Present only when YAML frontmatter was detected but failed to parse.
*
* This helper is intentionally fail-open and does not log. Returning `parseError`
* lets each caller decide whether to log, surface diagnostics, etc.
*/
parseError?: string
}
/**
* Parse YAML frontmatter from markdown content.
*
* Behavior is intentionally fail-open:
* - If YAML fails to parse, returns data={} and body=original markdown.
* - If no frontmatter exists, returns data={} and body=original markdown.
*/
export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = markdown.match(frontmatterRegex)
if (!match) {
return { data: {}, body: markdown, hadFrontmatter: false }
}
const [, yamlContent, body] = match
try {
const data = (yaml.load(yamlContent) as Record<string, unknown>) || {}
return { data, body, hadFrontmatter: true }
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return { data: {}, body: markdown, hadFrontmatter: true, parseError: message }
}
}
@@ -0,0 +1,161 @@
/**
* Rule frontmatter conditional evaluation.
*
* This module implements a small conditional "DSL" for Cline Rules YAML frontmatter.
* It is used to decide whether a rule should be activated for a given request context.
*
* Notes:
* - Unknown conditional keys are ignored for forward compatibility.
* - The `paths` conditional matches if any candidate path matches any glob pattern.
* - Candidate paths are expected to be workspace-root-relative POSIX paths.
*/
import * as path from "path"
import picomatch from "picomatch"
export type RuleEvaluationContext = {
/**
* Candidate workspace-relative paths that represent the current request context.
* These should be POSIX-style paths, relative to their workspace root.
*/
paths?: string[]
}
export type ConditionalEvaluator = (frontmatterValue: unknown, context: RuleEvaluationContext) => boolean
type MatchedConditions = Record<string, string[]>
type ConditionalEvaluatorResult = {
passed: boolean
matched?: string[]
}
type ConditionalEvaluatorWithMatch = (frontmatterValue: unknown, context: RuleEvaluationContext) => ConditionalEvaluatorResult
function toPosix(p: string): string {
return p.replace(/\\/g, "/")
}
function isNonEmptyStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string" && v.length > 0)
}
const evaluatePathsConditional: ConditionalEvaluatorWithMatch = (frontmatterValue: unknown, context: RuleEvaluationContext) => {
// Invalid type -> ignore conditional (fail-open)
if (!isNonEmptyStringArray(frontmatterValue)) {
return { passed: true }
}
const patterns = frontmatterValue.map((p) => p.trim()).filter(Boolean)
// Policy:
// - `paths` omitted => universal (because this evaluator is never invoked)
// - `paths: []` (or `paths` that trims to no usable patterns) => match nothing (fail-closed)
// This gives users an explicit way to disable a rule via frontmatter, while omission
// remains the mechanism for "always on" rules.
if (patterns.length === 0) {
return { passed: false }
}
const candidatePaths = (context.paths || []).map((p) => toPosix(p)).filter(Boolean)
// Conservative: no evidence => do not activate path-scoped rules
if (candidatePaths.length === 0) {
return { passed: false }
}
const matchedPatterns: string[] = []
for (const pattern of patterns) {
const matcher = picomatch(pattern, { dot: true })
if (candidatePaths.some((candidate) => matcher(candidate))) {
matchedPatterns.push(pattern)
}
}
return { passed: matchedPatterns.length > 0, matched: matchedPatterns.length > 0 ? matchedPatterns : undefined }
}
const conditionalEvaluators: Record<string, ConditionalEvaluatorWithMatch> = {
paths: evaluatePathsConditional,
}
export function evaluateRuleConditionals(
frontmatter: Record<string, unknown>,
context: RuleEvaluationContext,
): {
passed: boolean
matchedConditions: MatchedConditions
} {
const matchedConditions: MatchedConditions = {}
for (const [key, value] of Object.entries(frontmatter)) {
const evaluator = conditionalEvaluators[key]
if (!evaluator) {
continue // unknown conditional: ignore
}
const result = evaluator(value, context)
if (!result.passed) {
return { passed: false, matchedConditions: {} }
}
if (result.matched && result.matched.length > 0) {
matchedConditions[key] = result.matched
}
}
return { passed: true, matchedConditions }
}
/**
* Extracts path-like strings from user text to help enable first-turn activation.
* This is intentionally heuristic and conservative.
*/
export function extractPathLikeStrings(text: string): string[] {
if (!text) return []
// 1) Remove URLs to avoid false positives.
const withoutUrls = text.replace(/\b\w+:\/\/[^\s]+/g, " ")
// 2) Match tokens that look like paths.
// - Either contain at least one slash (e.g. src/index.ts)
// - Or look like a simple filename with an extension (e.g. foo.md)
// (no slashes; conservative to reduce false positives).
const tokenRegex =
/(?:^|[\s([{"'`])((?:[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+\/?|[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,10}))(?=$|[\s)\]}"'`,.;:!?])/g
const matches: string[] = []
let match: RegExpExecArray | null
while ((match = tokenRegex.exec(withoutUrls))) {
const candidate = match[1]
if (!candidate) continue
// Normalize away leading ./
const normalized = candidate.startsWith("./") ? candidate.slice(2) : candidate
// Avoid absurdly long tokens
if (normalized.length > 300) continue
matches.push(normalized)
}
// De-dupe while preserving order
const seen = new Set<string>()
const result: string[] = []
for (const m of matches) {
const posix = m.replace(/\\/g, "/")
if (posix === "/" || posix.startsWith("/") || posix.includes("..")) {
// We only want repo/workspace-relative hints here.
continue
}
if (!seen.has(posix)) {
seen.add(posix)
result.push(posix)
}
}
return result
}
/**
* Normalize an absolute filesystem path to a workspace-root-relative POSIX path.
* Returns undefined if the absolute path is not within the given root.
*/
export function toWorkspaceRelativePosixPath(absPath: string, workspaceRoot: string): string | undefined {
const rel = path.relative(workspaceRoot, absPath)
// Outside the root
if (rel.startsWith("..") || path.isAbsolute(rel)) return undefined
return toPosix(rel)
}
@@ -5,6 +5,8 @@ import { fileExistsAtPath, isDirectory, readDirectory } from "@utils/fs"
import fs from "fs/promises"
import * as path from "path"
import { Controller } from "@/core/controller"
import { parseYamlFrontmatter } from "./frontmatter"
import { evaluateRuleConditionals, RuleEvaluationContext } from "./rule-conditionals"
/**
* Recursively traverses directory and finds all files, including checking for optional whitelisted file extension
@@ -143,19 +145,114 @@ export function combineRuleToggles(toggles1: ClineRulesToggles, toggles2: ClineR
* Read the content of rules files
*/
export const getRuleFilesTotalContent = async (rulesFilePaths: string[], basePath: string, toggles: ClineRulesToggles) => {
const ruleFilesTotalContent = await Promise.all(
return (await getRuleFilesTotalContentWithMetadata(rulesFilePaths, basePath, toggles)).content
}
export type ActivatedConditionalRule = {
name: string
matchedConditions: Record<string, string[]>
}
export type RuleLoadResult = {
content: string
activatedConditionalRules: ActivatedConditionalRule[]
}
export const getRuleFilesTotalContentWithMetadata = async (
rulesFilePaths: string[],
basePath: string,
toggles: ClineRulesToggles,
opts?: { evaluationContext?: RuleEvaluationContext },
): Promise<RuleLoadResult> => {
const evaluationContext = opts?.evaluationContext ?? {}
type RuleLoadPart = {
contentPart: string | null
activatedRule: ActivatedConditionalRule | null
}
const parts: RuleLoadPart[] = await Promise.all(
rulesFilePaths.map(async (filePath) => {
const ruleFilePath = path.resolve(basePath, filePath)
const ruleFilePathRelative = path.relative(basePath, ruleFilePath)
if (ruleFilePath in toggles && toggles[ruleFilePath] === false) {
return null
return { contentPart: null, activatedRule: null }
}
return `${ruleFilePathRelative}\n` + (await fs.readFile(ruleFilePath, "utf8")).trim()
const raw = (await fs.readFile(ruleFilePath, "utf8")).trim()
if (!raw) {
return { contentPart: null, activatedRule: null }
}
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(raw)
// YAML parse errors are treated as fail-open.
// NOTE: We intentionally preserve the raw frontmatter fence/content here so the LLM can still
// see the author's intended scoping (e.g., `paths:`) and reason about it, even if it cannot be
// evaluated reliably due to invalid YAML.
if (hadFrontmatter && parseError) {
return { contentPart: `${ruleFilePathRelative}\n${raw}`, activatedRule: null }
}
const { passed, matchedConditions } = evaluateRuleConditionals(data, evaluationContext)
if (!passed) {
return { contentPart: null, activatedRule: null }
}
const activatedRule =
hadFrontmatter && Object.keys(matchedConditions).length > 0
? { name: ruleFilePathRelative, matchedConditions }
: null
return { contentPart: `${ruleFilePathRelative}\n${body.trim()}`, activatedRule }
}),
).then((contents) => contents.filter(Boolean).join("\n\n"))
return ruleFilesTotalContent
)
return {
content: parts
.map((p) => p.contentPart)
.filter(Boolean)
.join("\n\n"),
activatedConditionalRules: parts
.map((p) => p.activatedRule)
.filter((rule): rule is ActivatedConditionalRule => rule !== null),
}
}
export function getRemoteRulesTotalContentWithMetadata(
remoteRules: GlobalInstructionsFile[],
remoteToggles: ClineRulesToggles,
opts?: { evaluationContext?: RuleEvaluationContext },
): RuleLoadResult {
const activatedConditionalRules: ActivatedConditionalRule[] = []
const evaluationContext = opts?.evaluationContext ?? {}
let combinedContent = ""
for (const rule of remoteRules) {
const isEnabled = rule.alwaysEnabled || remoteToggles[rule.name] !== false
if (!isEnabled) continue
const raw = (rule.contents || "").trim()
if (!raw) continue
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(raw)
if (hadFrontmatter && parseError) {
// Fail open: include entire raw contents
if (combinedContent) combinedContent += "\n\n"
combinedContent += `${rule.name}\n${raw}`
continue
}
const { passed, matchedConditions } = evaluateRuleConditionals(data, evaluationContext)
if (!passed) continue
if (hadFrontmatter && Object.keys(matchedConditions).length > 0) {
activatedConditionalRules.push({ name: rule.name, matchedConditions })
}
if (combinedContent) combinedContent += "\n\n"
combinedContent += `${rule.name}\n${body.trim()}`
}
return { content: combinedContent, activatedConditionalRules }
}
/**
@@ -2,28 +2,16 @@ import { ensureSkillsDirectoryExists, GlobalFileNames } from "@core/storage/disk
import type { SkillContent, SkillMetadata } from "@shared/skills"
import { fileExistsAtPath, isDirectory } from "@utils/fs"
import * as fs from "fs/promises"
import * as yaml from "js-yaml"
import * as path from "path"
import { parseYamlFrontmatter } from "./frontmatter"
/**
* Parse YAML frontmatter from markdown content.
*/
/** Parse YAML frontmatter from markdown content (shared helper). */
function parseFrontmatter(fileContent: string): { data: Record<string, unknown>; content: string } {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = fileContent.match(frontmatterRegex)
if (!match) {
return { data: {}, content: fileContent }
}
const [, yamlContent, body] = match
try {
const data = yaml.load(yamlContent) as Record<string, unknown>
return { data: data || {}, content: body }
} catch (error) {
console.warn("Failed to parse YAML frontmatter:", error)
return { data: {}, content: fileContent }
const result = parseYamlFrontmatter(fileContent)
if (result.parseError) {
console.warn("Failed to parse YAML frontmatter:", result.parseError)
}
return { data: result.data, content: result.body }
}
/**
+16 -18
View File
@@ -33,6 +33,7 @@ import { LogoutReason } from "@/services/auth/types"
import { BannerService } from "@/services/banner/BannerService"
import { featureFlagsService } from "@/services/feature-flags"
import { getDistinctId } from "@/services/logging/distinctId"
import { Logger } from "@/services/logging/Logger"
import { telemetryService } from "@/services/telemetry"
import { BannerCardData } from "@/shared/cline/banner"
import { getAxiosSettings } from "@/shared/net"
@@ -48,6 +49,7 @@ import {
writeMcpMarketplaceCatalogToCache,
} from "../storage/disk"
import { fetchRemoteConfig } from "../storage/remote-config/fetch"
import { clearRemoteConfig } from "../storage/remote-config/utils"
import { type PersistenceErrorEvent, StateManager } from "../storage/StateManager"
import { Task } from "../task"
import { sendMcpMarketplaceCatalogEvent } from "./mcp/subscribeToMcpMarketplaceCatalog"
@@ -105,13 +107,13 @@ export class Controller {
/**
* Starts the periodic remote config fetching timer
* Fetches immediately and then every 30 seconds
* Fetches immediately and then every hour
*/
private startRemoteConfigTimer() {
// Initial fetch
fetchRemoteConfig(this)
// Set up 30-second interval
this.remoteConfigTimer = setInterval(() => fetchRemoteConfig(this), 30000) // 30 seconds
// Set up 1-hour interval
this.remoteConfigTimer = setInterval(() => fetchRemoteConfig(this), 3600000) // 1 hour
}
constructor(readonly context: vscode.ExtensionContext) {
@@ -120,21 +122,10 @@ export class Controller {
this.stateManager = StateManager.get()
StateManager.get().registerCallbacks({
onPersistenceError: async ({ error }: PersistenceErrorEvent) => {
console.error("[Controller] Cache persistence failed, recovering:", error)
try {
await StateManager.get().reInitialize(this.task?.taskId)
await this.postStateToWebview()
HostProvider.window.showMessage({
type: ShowMessageType.WARNING,
message: "Saving settings to storage failed.",
})
} catch (recoveryError) {
console.error("[Controller] Cache recovery failed:", recoveryError)
HostProvider.window.showMessage({
type: ShowMessageType.ERROR,
message: "Failed to save settings. Please restart the extension.",
})
}
// Just log - don't call reInitialize() (that sets isInitialized=false which
// breaks running tasks) and don't show a warning (data is safe in memory
// and will be retried automatically on the next debounced persistence).
Logger.error("[Controller] Storage persistence failed (will retry):", error)
},
onSyncExternalChange: async () => {
await this.postStateToWebview()
@@ -187,6 +178,7 @@ export class Controller {
try {
// AuthService now handles its own storage cleanup in handleDeauth()
this.stateManager.setGlobalState("userInfo", undefined)
clearRemoteConfig()
// Update API providers through cache service
const apiConfiguration = this.stateManager.getApiConfiguration()
@@ -536,6 +528,8 @@ export class Controller {
// Mark welcome view as completed since user has successfully logged in
this.stateManager.setGlobalState("welcomeViewCompleted", true)
await fetchRemoteConfig(this)
if (this.task) {
this.task.api = buildApiHandler({ ...updatedConfig, ulid: this.task.ulid }, currentMode)
}
@@ -953,6 +947,10 @@ export class Controller {
user: this.stateManager.getGlobalSettingsKey("clineWebToolsEnabled"),
featureFlag: featureFlagsService.getWebtoolsEnabled(),
},
worktreesEnabled: {
user: this.stateManager.getGlobalSettingsKey("worktreesEnabled"),
featureFlag: featureFlagsService.getWorktreesEnabled(),
},
hooksEnabled: this.stateManager.getGlobalSettingsKey("hooksEnabled"),
lastDismissedInfoBannerVersion,
lastDismissedModelBannerVersion,
@@ -195,6 +195,11 @@ export async function updateSettings(controller: Controller, request: UpdateSett
controller.stateManager.setGlobalState("clineWebToolsEnabled", request.clineWebToolsEnabled)
}
// Update worktrees setting
if (request.worktreesEnabled !== undefined) {
controller.stateManager.setGlobalState("worktreesEnabled", request.worktreesEnabled)
}
if (request.dictationSettings !== undefined) {
// Convert from protobuf format (snake_case) to TypeScript format (camelCase)
const dictationSettings = {
@@ -65,6 +65,7 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
yoloModeToggled,
useAutoCondense,
clineWebToolsEnabled,
worktreesEnabled,
focusChainSettings,
browserSettings,
defaultTerminalProfile,
@@ -167,6 +168,11 @@ export async function updateSettingsCli(controller: Controller, request: UpdateS
controller.stateManager.setGlobalState("clineWebToolsEnabled", clineWebToolsEnabled)
}
// Update worktrees setting
if (worktreesEnabled !== undefined) {
controller.stateManager.setGlobalState("worktreesEnabled", worktreesEnabled)
}
// Update focus chain settings (requires telemetry on state change)
if (focusChainSettings !== undefined) {
const currentSettings = controller.stateManager.getGlobalSettingsKey("focusChainSettings")
@@ -0,0 +1,60 @@
import { Empty, EmptyRequest } from "@shared/proto/cline/common"
import { getRequestRegistry, StreamingResponseHandler } from "../grpc-handler"
import { Controller } from "../index"
// Keep track of active worktrees button clicked subscriptions
const activeWorktreesButtonClickedSubscriptions = new Set<StreamingResponseHandler<Empty>>()
/**
* Subscribe to worktrees button clicked events
* @param controller The controller instance
* @param request The empty request
* @param responseStream The streaming response handler
* @param requestId The ID of the request (passed by the gRPC handler)
*/
export async function subscribeToWorktreesButtonClicked(
_controller: Controller,
_request: EmptyRequest,
responseStream: StreamingResponseHandler<Empty>,
requestId?: string,
): Promise<void> {
// Add this subscription to the active subscriptions
activeWorktreesButtonClickedSubscriptions.add(responseStream)
// Register cleanup when the connection is closed
const cleanup = () => {
activeWorktreesButtonClickedSubscriptions.delete(responseStream)
}
// Register the cleanup function with the request registry if we have a requestId
if (requestId) {
getRequestRegistry().registerRequest(
requestId,
cleanup,
{ type: "worktrees_button_clicked_subscription" },
responseStream,
)
}
}
/**
* Send a worktrees button clicked event to all active subscribers
*/
export async function sendWorktreesButtonClickedEvent(): Promise<void> {
// Send the event to all active subscribers
const promises = Array.from(activeWorktreesButtonClickedSubscriptions).map(async (responseStream) => {
try {
const event = Empty.create({})
await responseStream(
event,
false, // Not the last message
)
} catch (error) {
console.error("Error sending worktrees button clicked event:", error)
// Remove the subscription if there was an error
activeWorktreesButtonClickedSubscriptions.delete(responseStream)
}
})
await Promise.all(promises)
}
@@ -0,0 +1,45 @@
import { CheckoutBranchRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { getWorkspacePath } from "@utils/path"
import simpleGit from "simple-git"
import { Controller } from ".."
/**
* Checks out a branch in the current worktree (git checkout)
* @param controller The controller instance
* @param request The checkout branch request containing the branch name
* @returns WorktreeResult indicating success or failure
*/
export async function checkoutBranch(_controller: Controller, request: CheckoutBranchRequest): Promise<WorktreeResult> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeResult.create({
success: false,
message: "No workspace folder found",
})
}
const { branch } = request
if (!branch) {
return WorktreeResult.create({
success: false,
message: "Branch name is required",
})
}
try {
const git = simpleGit(cwd)
await git.checkout(branch)
return WorktreeResult.create({
success: true,
message: `Switched to branch '${branch}'`,
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return WorktreeResult.create({
success: false,
message: `Failed to checkout branch: ${errorMessage}`,
})
}
}
@@ -0,0 +1,64 @@
import { CreateWorktreeRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { createWorktree as createWorktreeUtil, listWorktrees } from "@utils/git-worktree"
import { getWorkspacePath } from "@utils/path"
import { telemetryService } from "@/services/telemetry"
import { Controller } from ".."
/**
* Creates a new git worktree
* @param controller The controller instance
* @param request The request containing path and branch information
* @returns WorktreeResult with success status and created worktree info
*/
export async function createWorktree(_controller: Controller, request: CreateWorktreeRequest): Promise<WorktreeResult> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeResult.create({
success: false,
message: "No workspace folder open",
})
}
try {
const result = await createWorktreeUtil(cwd, request.path, {
branch: request.branch,
baseBranch: request.baseBranch,
createNewBranch: request.createNewBranch,
})
// Track worktree creation with count of total worktrees
if (result.success) {
try {
const { worktrees } = await listWorktrees(cwd)
telemetryService.captureWorktreeCreated(true, worktrees.length)
} catch {
telemetryService.captureWorktreeCreated(true)
}
} else {
telemetryService.captureWorktreeCreated(false)
}
return WorktreeResult.create({
success: result.success,
message: result.message,
worktree: result.worktree
? {
path: result.worktree.path,
branch: result.worktree.branch,
commitHash: result.worktree.commitHash,
isCurrent: result.worktree.isCurrent,
isBare: result.worktree.isBare,
isDetached: result.worktree.isDetached,
isLocked: result.worktree.isLocked,
lockReason: result.worktree.lockReason,
}
: undefined,
})
} catch (error) {
console.error(`Error creating worktree: ${JSON.stringify(error)}`)
return WorktreeResult.create({
success: false,
message: error instanceof Error ? error.message : String(error),
})
}
}
@@ -0,0 +1,39 @@
import { CreateWorktreeIncludeRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { getWorkspacePath } from "@utils/path"
import * as fs from "fs/promises"
import * as path from "path"
import { Controller } from ".."
/**
* Creates a .worktreeinclude file with the provided content
* @param controller The controller instance
* @param request The request containing the file content
* @returns WorktreeResult with success status
*/
export async function createWorktreeInclude(
_controller: Controller,
request: CreateWorktreeIncludeRequest,
): Promise<WorktreeResult> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeResult.create({
success: false,
message: "No workspace folder open",
})
}
try {
const filePath = path.join(cwd, ".worktreeinclude")
await fs.writeFile(filePath, request.content, "utf-8")
return WorktreeResult.create({
success: true,
message: "Created .worktreeinclude file",
})
} catch (error) {
return WorktreeResult.create({
success: false,
message: `Failed to create .worktreeinclude: ${error instanceof Error ? error.message : String(error)}`,
})
}
}
@@ -0,0 +1,71 @@
import { DeleteWorktreeRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { deleteWorktree as deleteWorktreeUtil } from "@utils/git-worktree"
import { getWorkspacePath } from "@utils/path"
import { rm } from "fs/promises"
import path from "path"
import simpleGit from "simple-git"
import { HostProvider } from "@/hosts/host-provider"
import { hashWorkingDir } from "@/integrations/checkpoints/CheckpointUtils"
import { Controller } from ".."
/**
* Deletes an existing git worktree
* @param controller The controller instance
* @param request The request containing path and force flag
* @returns WorktreeResult with success status
*/
export async function deleteWorktree(_controller: Controller, request: DeleteWorktreeRequest): Promise<WorktreeResult> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeResult.create({
success: false,
message: "No workspace folder open",
})
}
try {
const result = await deleteWorktreeUtil(cwd, request.path, request.force)
if (!result.success) {
return WorktreeResult.create({
success: result.success,
message: result.message,
})
}
// Clean up checkpoint data (shadow git repo) for the deleted worktree
try {
const cwdHash = hashWorkingDir(request.path)
const checkpointDir = path.join(HostProvider.get().globalStorageFsPath, "checkpoints", cwdHash)
await rm(checkpointDir, { recursive: true, force: true })
} catch (error) {
// Log but don't fail - checkpoint cleanup is best-effort
console.log(`Failed to cleanup checkpoints for deleted worktree: ${error}`)
}
// Delete the branch if requested
if (request.deleteBranch && request.branchName) {
try {
const git = simpleGit(cwd)
await git.deleteLocalBranch(request.branchName)
} catch {
// Branch deletion failed, but worktree was deleted successfully
return WorktreeResult.create({
success: true,
message: `${result.message}, but failed to delete branch '${request.branchName}'`,
})
}
}
return WorktreeResult.create({
success: result.success,
message: request.deleteBranch ? `${result.message} and deleted branch '${request.branchName}'` : result.message,
})
} catch (error) {
console.error(`Error deleting worktree: ${JSON.stringify(error)}`)
return WorktreeResult.create({
success: false,
message: error instanceof Error ? error.message : String(error),
})
}
}
@@ -0,0 +1,39 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { BranchList } from "@shared/proto/cline/worktree"
import { getAvailableBranches as getAvailableBranchesUtil } from "@utils/git-worktree"
import { getWorkspacePath } from "@utils/path"
import { Controller } from ".."
/**
* Gets available branches for creating worktrees
* @param controller The controller instance
* @param request Empty request
* @returns BranchList containing local and remote branches
*/
export async function getAvailableBranches(_controller: Controller, _request: EmptyRequest): Promise<BranchList> {
const cwd = await getWorkspacePath()
if (!cwd) {
return BranchList.create({
localBranches: [],
remoteBranches: [],
currentBranch: "",
})
}
try {
const result = await getAvailableBranchesUtil(cwd)
return BranchList.create({
localBranches: result.localBranches,
remoteBranches: result.remoteBranches,
currentBranch: result.currentBranch,
})
} catch (error) {
console.error(`Error getting available branches: ${JSON.stringify(error)}`)
return BranchList.create({
localBranches: [],
remoteBranches: [],
currentBranch: "",
})
}
}
@@ -0,0 +1,49 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { WorktreeDefaults } from "@shared/proto/cline/worktree"
import { getWorkspacePath } from "@utils/path"
import path from "path"
import { getDocumentsPath } from "@/core/storage/disk"
import { Controller } from ".."
/**
* Generates a random suffix for worktree names
* Returns a 5-character alphanumeric string
*/
function generateRandomSuffix(): string {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
let result = ""
for (let i = 0; i < 5; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length))
}
return result
}
/**
* Gets suggested defaults for creating a new worktree
* @param controller The controller instance
* @param request Empty request
* @returns WorktreeDefaults with suggested branch name and path
*/
export async function getWorktreeDefaults(_controller: Controller, _request: EmptyRequest): Promise<WorktreeDefaults> {
const suffix = generateRandomSuffix()
// Generate suggested branch name
const suggestedBranch = `worktree/cline-${suffix}`
// Generate suggested path in Documents/Cline/Worktrees/<project-name>-<suffix>
const documentsPath = await getDocumentsPath()
const cwd = await getWorkspacePath()
// Get project name from workspace path
let projectName = "project"
if (cwd) {
projectName = path.basename(cwd)
}
const suggestedPath = path.join(documentsPath, "Cline", "Worktrees", `${projectName}-${suffix}`)
return WorktreeDefaults.create({
suggestedBranch,
suggestedPath,
})
}
@@ -0,0 +1,48 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { WorktreeIncludeStatus } from "@shared/proto/cline/worktree"
import { getWorkspacePath } from "@utils/path"
import * as fs from "fs/promises"
import * as path from "path"
import { Controller } from ".."
/**
* Gets the status of .worktreeinclude file and .gitignore contents
* @param controller The controller instance
* @param request Empty request
* @returns WorktreeIncludeStatus with exists flag and gitignore content
*/
export async function getWorktreeIncludeStatus(_controller: Controller, _request: EmptyRequest): Promise<WorktreeIncludeStatus> {
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeIncludeStatus.create({
exists: false,
hasGitignore: false,
gitignoreContent: "",
})
}
// Check if .worktreeinclude exists
let exists = false
try {
await fs.access(path.join(cwd, ".worktreeinclude"))
exists = true
} catch {
exists = false
}
// Read .gitignore content if it exists
let gitignoreContent = ""
let hasGitignore = false
try {
gitignoreContent = await fs.readFile(path.join(cwd, ".gitignore"), "utf-8")
hasGitignore = true
} catch {
hasGitignore = false
}
return WorktreeIncludeStatus.create({
exists,
hasGitignore,
gitignoreContent,
})
}
@@ -0,0 +1,88 @@
import { EmptyRequest } from "@shared/proto/cline/common"
import { WorktreeList } from "@shared/proto/cline/worktree"
import { getGitRootPath, listWorktrees as listWorktreesUtil } from "@utils/git-worktree"
import { arePathsEqual, getWorkspacePath } from "@utils/path"
import { HostProvider } from "@/hosts/host-provider"
import { Controller } from ".."
/**
* Lists all git worktrees in the current repository
* @param controller The controller instance
* @param request Empty request
* @returns WorktreeList containing all worktrees
*/
export async function listWorktrees(_controller: Controller, _request: EmptyRequest): Promise<WorktreeList> {
// Check for multi-root workspace
const workspacePaths = (await HostProvider.workspace.getWorkspacePaths({})).paths
const isMultiRoot = workspacePaths.length > 1
if (isMultiRoot) {
return WorktreeList.create({
worktrees: [],
isGitRepo: false,
isMultiRoot: true,
isSubfolder: false,
gitRootPath: "",
error: "",
})
}
const cwd = await getWorkspacePath()
if (!cwd) {
return WorktreeList.create({
worktrees: [],
isGitRepo: false,
isMultiRoot: false,
isSubfolder: false,
gitRootPath: "",
error: "No workspace folder open",
})
}
// Check if workspace is a subfolder of a git repo (not at repo root)
const gitRootPath = await getGitRootPath(cwd)
const isSubfolder = gitRootPath !== null && !arePathsEqual(cwd, gitRootPath)
if (isSubfolder) {
return WorktreeList.create({
worktrees: [],
isGitRepo: true,
isMultiRoot: false,
isSubfolder: true,
gitRootPath: gitRootPath || "",
error: "",
})
}
try {
const result = await listWorktreesUtil(cwd)
return WorktreeList.create({
worktrees: result.worktrees.map((wt) => ({
path: wt.path,
branch: wt.branch,
commitHash: wt.commitHash,
isCurrent: wt.isCurrent,
isBare: wt.isBare,
isDetached: wt.isDetached,
isLocked: wt.isLocked,
lockReason: wt.lockReason,
})),
isGitRepo: result.isGitRepo,
isMultiRoot: false,
isSubfolder: false,
gitRootPath: gitRootPath || "",
error: result.error || "",
})
} catch (error) {
console.error(`Error listing worktrees: ${JSON.stringify(error)}`)
return WorktreeList.create({
worktrees: [],
isGitRepo: false,
isMultiRoot: false,
isSubfolder: false,
gitRootPath: "",
error: error instanceof Error ? error.message : String(error),
})
}
}
@@ -0,0 +1,215 @@
import { MergeWorktreeRequest, MergeWorktreeResult } from "@shared/proto/cline/worktree"
import { listWorktrees } from "@utils/git-worktree"
import { getWorkspacePath } from "@utils/path"
import simpleGit from "simple-git"
import { telemetryService } from "@/services/telemetry"
import { Controller } from ".."
/**
* Merges a worktree's branch into the target branch and optionally deletes the worktree
* @param controller The controller instance
* @param request The merge worktree request
* @returns MergeWorktreeResult indicating success, failure, or conflicts
*/
export async function mergeWorktree(_controller: Controller, request: MergeWorktreeRequest): Promise<MergeWorktreeResult> {
const cwd = await getWorkspacePath()
if (!cwd) {
return MergeWorktreeResult.create({
success: false,
message: "No workspace folder found",
hasConflicts: false,
conflictingFiles: [],
})
}
const { worktreePath, targetBranch, deleteAfterMerge } = request
if (!worktreePath) {
return MergeWorktreeResult.create({
success: false,
message: "Worktree path is required",
hasConflicts: false,
conflictingFiles: [],
})
}
if (!targetBranch) {
return MergeWorktreeResult.create({
success: false,
message: "Target branch is required",
hasConflicts: false,
conflictingFiles: [],
})
}
try {
// Find the worktree that has the target branch checked out
// This is where we need to perform the merge
const { worktrees } = await listWorktrees(cwd)
const targetWorktree = worktrees.find((w) => w.branch === targetBranch)
if (!targetWorktree) {
return MergeWorktreeResult.create({
success: false,
message: `Target branch '${targetBranch}' is not checked out in any worktree. Please checkout the branch first.`,
hasConflicts: false,
conflictingFiles: [],
})
}
// Use the target worktree's path for merge operations
const targetWorktreePath = targetWorktree.path
const git = simpleGit(targetWorktreePath)
const worktreeGit = simpleGit(worktreePath)
// Get the branch name of the worktree
let sourceBranch: string
try {
sourceBranch = await worktreeGit.revparse(["--abbrev-ref", "HEAD"])
sourceBranch = sourceBranch.trim()
} catch {
return MergeWorktreeResult.create({
success: false,
message: "Failed to get branch name from worktree",
hasConflicts: false,
conflictingFiles: [],
})
}
if (sourceBranch === "HEAD") {
return MergeWorktreeResult.create({
success: false,
message: "Cannot merge a detached HEAD worktree",
hasConflicts: false,
conflictingFiles: [],
sourceBranch,
targetBranch,
})
}
// Check for uncommitted changes in the source worktree
try {
const status = await worktreeGit.status()
if (!status.isClean()) {
return MergeWorktreeResult.create({
success: false,
message: `Worktree has uncommitted changes. Please commit or stash them first.`,
hasConflicts: false,
conflictingFiles: [],
sourceBranch,
targetBranch,
})
}
} catch {
// If status check fails, continue anyway
}
// Check for uncommitted changes in the target worktree
try {
const targetStatus = await git.status()
if (!targetStatus.isClean()) {
return MergeWorktreeResult.create({
success: false,
message: `Target worktree (${targetBranch}) has uncommitted changes. Please commit or stash them first.`,
hasConflicts: false,
conflictingFiles: [],
sourceBranch,
targetBranch,
})
}
} catch {
// If status check fails, continue anyway
}
// Attempt the merge in the target worktree (which already has targetBranch checked out)
try {
await git.merge([sourceBranch, "--no-edit"])
} catch (error) {
// Check if it's a merge conflict
try {
const diffResult = await git.diff(["--name-only", "--diff-filter=U"])
const conflictingFiles = diffResult
.trim()
.split("\n")
.filter((f) => f)
if (conflictingFiles.length > 0) {
// Abort the merge so we don't leave the repo in a conflicted state
try {
await git.merge(["--abort"])
} catch {
// Ignore abort errors
}
telemetryService.captureWorktreeMergeAttempted(false, true, deleteAfterMerge)
return MergeWorktreeResult.create({
success: false,
message: `Merge conflict detected. ${conflictingFiles.length} file(s) have conflicts.`,
hasConflicts: true,
conflictingFiles,
sourceBranch,
targetBranch,
})
}
} catch {
// If conflict check fails, return the original error
}
const errorMessage = error instanceof Error ? error.message : String(error)
telemetryService.captureWorktreeMergeAttempted(false, false, deleteAfterMerge)
return MergeWorktreeResult.create({
success: false,
message: `Merge failed: ${errorMessage}`,
hasConflicts: false,
conflictingFiles: [],
sourceBranch,
targetBranch,
})
}
// Delete worktree if requested
if (deleteAfterMerge) {
try {
await git.raw(["worktree", "remove", worktreePath, "--force"])
} catch (error) {
// Merge succeeded but deletion failed - still return success
const errorMessage = error instanceof Error ? error.message : String(error)
return MergeWorktreeResult.create({
success: true,
message: `Merged '${sourceBranch}' into '${targetBranch}' successfully, but failed to delete worktree: ${errorMessage}`,
hasConflicts: false,
conflictingFiles: [],
sourceBranch,
targetBranch,
})
}
// Optionally delete the branch too
try {
await git.deleteLocalBranch(sourceBranch)
} catch {
// Branch deletion is optional, don't fail if it doesn't work
}
}
telemetryService.captureWorktreeMergeAttempted(true, false, deleteAfterMerge)
return MergeWorktreeResult.create({
success: true,
message: deleteAfterMerge
? `Successfully merged '${sourceBranch}' into '${targetBranch}' and removed worktree`
: `Successfully merged '${sourceBranch}' into '${targetBranch}'`,
hasConflicts: false,
conflictingFiles: [],
sourceBranch,
targetBranch,
})
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
return MergeWorktreeResult.create({
success: false,
message: `Unexpected error: ${errorMessage}`,
hasConflicts: false,
conflictingFiles: [],
})
}
}
@@ -0,0 +1,45 @@
import { SwitchWorktreeRequest, WorktreeResult } from "@shared/proto/cline/worktree"
import { HostProvider } from "@/hosts/host-provider"
import { Controller } from ".."
/**
* Switches to a different worktree by opening it in VS Code
* @param controller The controller instance
* @param request The request containing the worktree path
* @returns WorktreeResult with success status
*/
export async function switchWorktree(controller: Controller, request: SwitchWorktreeRequest): Promise<WorktreeResult> {
try {
// Set state so Cline auto-opens when the worktree folder loads
controller.stateManager.setGlobalState("worktreeAutoOpenPath", request.path)
// When opening in current window, the window reloads immediately and StateManager's
// 500ms debounce won't complete. Flush to ensure state is persisted before reload.
if (!request.newWindow) {
await controller.stateManager.flushPendingState()
}
const result = await HostProvider.workspace.openFolder({
path: request.path,
newWindow: request.newWindow,
})
if (!result.success) {
return WorktreeResult.create({
success: false,
message: `Failed to open worktree at ${request.path}`,
})
}
return WorktreeResult.create({
success: true,
message: `Switched to worktree at ${request.path}`,
})
} catch (error) {
console.error(`Error switching worktree: ${JSON.stringify(error)}`)
return WorktreeResult.create({
success: false,
message: error instanceof Error ? error.message : String(error),
})
}
}
@@ -0,0 +1,16 @@
import { Empty } from "@shared/proto/cline/common"
import { TrackWorktreeViewOpenedRequest } from "@shared/proto/cline/worktree"
import { telemetryService } from "@/services/telemetry"
import { Controller } from ".."
/**
* Tracks when the worktrees view is opened (for telemetry)
* @param controller The controller instance
* @param request The request containing the source of the navigation
* @returns Empty response
*/
export async function trackWorktreeViewOpened(_controller: Controller, request: TrackWorktreeViewOpenedRequest): Promise<Empty> {
const source = request.source === "home_page" ? "home_page" : "menu_bar"
telemetryService.captureWorktreeViewOpened(source)
return Empty.create({})
}
@@ -53,19 +53,15 @@
{
"type": "function",
"function": {
"name": "write_to_file",
"description": "[IMPORTANT: Always output the absolutePath first] Request to write content to a file at the specified path. If the file exists, it will be overwritten with the provided content. If the file doesn't exist, it will be created. This tool will automatically create any directories needed to write the file.",
"name": "apply_patch",
"description": "This is a custom utility that makes it more convenient to add, remove, move, or edit code in a single file. `apply_patch` effectively allows you to execute a diff/patch against a file, but the format of the diff specification is unique to this task, so pay careful attention to these instructions. To use the `apply_patch` command, you should pass a message of the following structure as \"input\":\n\n%%bash\napply_patch <<\"EOF\"\n*** Begin Patch\n[YOUR_PATCH]\n*** End Patch\nEOF\n\nWhere [YOUR_PATCH] is the actual content of your patch, specified in the following V4A diff format.\n\n*** [ACTION] File: [path/to/file] -> ACTION can be one of Add, Update, or Delete. \n\nIn a Add File section, every line of the new file (including blank/empty lines) MUST start with a `+` prefix. Do not include any unprefixed lines inside an Add section\nIn a Update/Delete section, repeat the following for each snippet of code that needs to be changed:\n[context_before] -> See below for further instructions on context.\n- [old_code] -> Precede the old code with a minus sign.\n+ [new_code] -> Precede the new, replacement code with a plus sign.\n[context_after] -> See below for further instructions on context.\n\nFor instructions on [context_before] and [context_after]:\n- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first changes [context_after] lines in the second changes [context_before] lines.\n- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have:\n@@ class BaseClass\n[3 lines of pre-context]\n- [old_code]\n+ [new_code]\n[3 lines of post-context]\n\n- If a code block is repeated so many times in a class or function such that even a single @@ statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance:\n\n@@ class BaseClass\n@@ \tdef method():\n[3 lines of pre-context]\n- [old_code]\n+ [new_code]\n[3 lines of post-context]\n\nNote, then, that we do not use line numbers in this diff format, as the context is enough to uniquely identify code. An example of a message that you might pass as \"input\" to this function, in order to apply a patch, is shown below.\n\n%%bash\napply_patch <<\"EOF\"\n*** Begin Patch\n*** Update File: pygorithm/searching/binary_search.py\n@@ class BaseClass\n@@ def search():\n- pass\n+ raise NotImplementedError()\n\n@@ class Subclass\n@@ def search():\n- pass\n+ raise NotImplementedError()\n\n*** End Patch\nEOF",
"strict": false,
"parameters": {
"type": "object",
"properties": {
"absolutePath": {
"input": {
"type": "string",
"description": "The absolute path to the file to write to."
},
"content": {
"type": "string",
"description": "After providing the path so a file can be created, then use this to provide the content to write to the file."
"description": "The apply_patch command that you wish to execute."
},
"task_progress": {
"type": "string",
@@ -73,38 +69,7 @@
}
},
"required": [
"absolutePath",
"content"
],
"additionalProperties": false
}
}
},
{
"type": "function",
"function": {
"name": "replace_in_file",
"description": "[IMPORTANT: Always output the absolutePath first] Request to replace sections of content in an existing file using SEARCH/REPLACE blocks that define exact changes to specific parts of the file. This tool should be used when you need to make targeted changes to specific parts of a file.",
"strict": false,
"parameters": {
"type": "object",
"properties": {
"absolutePath": {
"type": "string",
"description": "The absolute path to the file to write to."
},
"diff": {
"type": "string",
"description": "One or more SEARCH/REPLACE blocks following this exact format:\n ```\n ------- SEARCH\n [exact content to find]\n =======\n [new content to replace with]\n +++++++ REPLACE\n ```\n Critical rules:\n 1. SEARCH content must match the associated file section to find EXACTLY:\n\t * Match character-for-character including whitespace, indentation, line endings\n\t * Include all comments, docstrings, etc.\n 2. SEARCH/REPLACE blocks will ONLY replace the first match occurrence.\n\t * Including multiple unique SEARCH/REPLACE blocks if you need to make multiple changes.\n\t * Include *just* enough lines in each SEARCH section to uniquely match each set of lines that need to change.\n\t * When using multiple SEARCH/REPLACE blocks, list them in the order they appear in the file.\n 3. Keep SEARCH/REPLACE blocks concise:\n\t * Break large SEARCH/REPLACE blocks into a series of smaller blocks that each change a small portion of the file.\n\t * Include just the changing lines, and a few surrounding lines if needed for uniqueness.\n\t * Do not include long runs of unchanging lines in SEARCH/REPLACE blocks.\n\t * Each line must be complete. Never truncate lines mid-way through as this can cause matching failures.\n 4. Special operations:\n\t * To move code: Use two SEARCH/REPLACE blocks (one to delete from original + one to insert at new location)\n\t * To delete code: Use empty REPLACE section"
},
"task_progress": {
"type": "string",
"description": "A checklist showing task progress after this tool use is completed. The task_progress parameter must be included as a separate parameter inside of the parent tool call, it must be separate from other parameters such as content, arguments, etc. (See 'UPDATING TASK PROGRESS' section for more details)"
}
},
"required": [
"absolutePath",
"diff"
"input"
],
"additionalProperties": false
}
@@ -24,6 +24,12 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5)
}
const providerInfo = context.providerInfo
const modelId = providerInfo.model.id
if (!isNextGenModelProvider(providerInfo)) {
return false
}
if (modelId.includes("gpt-oss")) {
return true
}
return (
isGPT5ModelFamily(modelId) &&
// Exclude gpt-5.1 and gpt-5.2 models except for codex variants
@@ -52,9 +58,9 @@ export const config = createVariant(ModelFamily.NATIVE_GPT_5)
ClineDefaultTool.BASH,
ClineDefaultTool.FILE_READ,
// Should disable FILE_NEW and FILE_EDIT when enabled
// ClineDefaultTool.APPLY_PATCH,
ClineDefaultTool.FILE_NEW, // Replaced by APPLY_PATCH
ClineDefaultTool.FILE_EDIT, // Replaced by APPLY_PATCH
ClineDefaultTool.APPLY_PATCH,
// ClineDefaultTool.FILE_NEW, // Replaced by APPLY_PATCH
// ClineDefaultTool.FILE_EDIT, // Replaced by APPLY_PATCH
ClineDefaultTool.SEARCH,
ClineDefaultTool.LIST_FILES,
ClineDefaultTool.LIST_CODE_DEF,
+2 -1
View File
@@ -19,6 +19,7 @@ import {
import chokidar, { FSWatcher } from "chokidar"
import type { ExtensionContext } from "vscode"
import { HostProvider } from "@/hosts/host-provider"
import { Logger } from "@/services/logging/Logger"
import { ShowMessageType } from "@/shared/proto/index.host"
import {
getTaskHistoryStateFilePath,
@@ -691,7 +692,7 @@ export class StateManager {
await this.persistPendingState()
this.persistenceTimeout = null
} catch (error) {
console.error("[StateManager] Failed to persist pending changes:", error)
Logger.error("[StateManager] Failed to persist pending changes:", error)
this.persistenceTimeout = null
// Call persistence error callback for error recovery
+5 -1
View File
@@ -375,8 +375,12 @@ export class ToolExecutor {
this.isPlanModeToolRestricted(block.name)
) {
const errorMessage = `Tool '${block.name}' is not available in PLAN MODE. This tool is restricted to ACT MODE for file modifications. Only use tools available for PLAN MODE when in that mode.`
await this.removeLastPartialMessageIfExistsWithType("say", "error")
await this.say("error", errorMessage)
this.pushToolResult(formatResponse.toolError(errorMessage), block)
// Only push the final error message when the streaming is done.
if (!block.partial) {
this.pushToolResult(formatResponse.toolError(errorMessage), block)
}
return true
}
+25 -4
View File
@@ -1933,8 +1933,25 @@ export class Task {
attempt: this.taskState.autoRetryAttempts,
maxAttempts: 3,
delaySeconds: delay / 1000,
errorMessage: streamingFailedMessage,
}),
)
// Clear streamingFailedMessage now that error_retry contains it
// This prevents showing the error in both ErrorRow and error_retry
const autoRetryApiReqIndex = findLastIndex(
this.messageStateHandler.getClineMessages(),
(m) => m.say === "api_req_started",
)
if (autoRetryApiReqIndex !== -1) {
const clineMessages = this.messageStateHandler.getClineMessages()
const currentApiReqInfo: ClineApiReqInfo = JSON.parse(clineMessages[autoRetryApiReqIndex].text || "{}")
delete currentApiReqInfo.streamingFailedMessage
await this.messageStateHandler.updateClineMessage(autoRetryApiReqIndex, {
text: JSON.stringify(currentApiReqInfo),
})
}
await setTimeoutPromise(delay)
} else {
// Show error_retry with failed flag to indicate all retries exhausted (but not for insufficient credits)
@@ -1946,6 +1963,7 @@ export class Task {
maxAttempts: 3,
delaySeconds: 0,
failed: true, // Special flag to indicate retries exhausted
errorMessage: streamingFailedMessage,
}),
)
}
@@ -2672,6 +2690,7 @@ export class Task {
attempt: this.taskState.autoRetryAttempts,
maxAttempts: 3,
delaySeconds: delay / 1000,
errorMessage,
}),
)
@@ -2693,6 +2712,7 @@ export class Task {
maxAttempts: 3,
delaySeconds: 0,
failed: true, // Special flag to indicate retries exhausted
errorMessage,
}),
)
}
@@ -2914,6 +2934,8 @@ export class Task {
let response: ClineAskResponse
const noResponseErrorMessage = "No assistant message was received. Would you like to retry the request?"
if (this.taskState.autoRetryAttempts < 3) {
// Auto-retry enabled with max 3 attempts: automatically approve the retry
this.taskState.autoRetryAttempts++
@@ -2927,6 +2949,7 @@ export class Task {
attempt: this.taskState.autoRetryAttempts,
maxAttempts: 3,
delaySeconds: delay / 1000,
errorMessage: noResponseErrorMessage,
}),
)
await setTimeoutPromise(delay)
@@ -2939,12 +2962,10 @@ export class Task {
maxAttempts: 3,
delaySeconds: 0,
failed: true, // Special flag to indicate retries exhausted
errorMessage: noResponseErrorMessage,
}),
)
const askResult = await this.ask(
"api_req_failed",
"No assistant message was received. Would you like to retry the request?",
)
const askResult = await this.ask("api_req_failed", noResponseErrorMessage)
response = askResult.response
// Reset retry counter if user chooses to manually retry
if (response === "yesButtonClicked") {
+126 -97
View File
@@ -39,7 +39,6 @@ export const PatchClineSayMap = {
export class ApplyPatchHandler implements IFullyManagedTool {
readonly name = ClineDefaultTool.APPLY_PATCH
private appliedCommit?: Commit
private config?: TaskConfig
private pathResolver?: PathResolver
private providerOps?: FileProviderOperations
@@ -96,12 +95,14 @@ export class ApplyPatchHandler implements IFullyManagedTool {
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (line.startsWith(PATCH_MARKERS.ADD)) {
provider.editType = "modify"
targetPath = line.substring(PATCH_MARKERS.ADD.length).trim()
actionType = PatchActionType.ADD
contentStartIndex = i + 1
break
}
if (line.startsWith(PATCH_MARKERS.UPDATE)) {
provider.editType = "modify"
targetPath = line.substring(PATCH_MARKERS.UPDATE.length).trim()
actionType = PatchActionType.UPDATE
contentStartIndex = i + 1
@@ -233,10 +234,8 @@ export class ApplyPatchHandler implements IFullyManagedTool {
const { patch, fuzz } = parser.parse()
// Convert to commit
const commit = this.patchToCommit(patch, currentFiles)
const commit = await this.patchToCommit(patch, currentFiles)
// Store for potential revert
this.appliedCommit = commit
this.config = config
// Run PreToolUse hook before applying changes
@@ -252,32 +251,88 @@ export class ApplyPatchHandler implements IFullyManagedTool {
throw error
}
// Apply the commit
const applyResults = await this.applyCommit(commit)
// Generate summary
const changedFiles = Object.keys(commit.changes)
const messages = await this.generateChangeSummary(commit.changes)
const finalResponses = []
const applyResults: Record<string, FileOpsResult> = {}
// Create a mapping from message path to original commit change key
// (needed because for move operations, message.path is the new path, but commit.changes key is the old path)
const pathToChangeKey = new Map<string, string>()
for (const [originalPath, change] of Object.entries(commit.changes)) {
if (change.type === PatchActionType.UPDATE && change.movePath) {
pathToChangeKey.set(change.movePath, originalPath)
} else {
pathToChangeKey.set(originalPath, originalPath)
}
}
// For each file: prepare, get approval, then save
for (const message of messages) {
const messagePath = message.path
if (!messagePath) {
continue
}
// Get the original change key (for move operations, this is the old path)
const originalPath = pathToChangeKey.get(messagePath)
if (!originalPath) {
continue
}
const change = commit.changes[originalPath]
if (!change) {
continue
}
// Determine the actual file path to use for operations
// For move operations, we prepare the new file, but the change is keyed by the old path
const operationPath = change.type === PatchActionType.UPDATE && change.movePath ? change.movePath : originalPath
// Prepare the change for this file (open and update, but don't save)
await this.prepareFileChange(change, operationPath)
// Get approval
const approved = await this.handleApproval(config, block, message, rawInput)
if (!approved) {
await this.revertChanges()
this.config = undefined
config.taskState.didRejectTool = true
await provider.revertChanges()
await provider.reset()
return "The user denied this patch operation."
}
for (const filePath of changedFiles) {
config.services.fileContextTracker.markFileAsEditedByCline(filePath)
await config.services.fileContextTracker.trackFileContext(filePath, "cline_edited")
// Save the changes for this file after approval
const fileResult = await this.saveFileChange(change, operationPath)
if (fileResult) {
// For move operations, we need to handle both old and new paths
if (change.type === PatchActionType.UPDATE && change.movePath) {
applyResults[change.movePath] = fileResult
// Delete the old file after saving the new one
await this.providerOps!.deleteFile(originalPath)
applyResults[originalPath] = { deleted: true }
} else {
applyResults[originalPath] = fileResult
}
}
config.taskState.didEditFile = true
finalResponses.push(message.path)
// Reset provider state to ensure clean state for the next file operation
await provider.reset()
finalResponses.push(messagePath)
}
// Track all changed files once after all operations are complete
for (const changedFilePath of changedFiles) {
const change = commit.changes[changedFilePath]
// For move operations, track the new path instead
const pathToTrack = change.type === PatchActionType.UPDATE && change.movePath ? change.movePath : changedFilePath
config.services.fileContextTracker.markFileAsEditedByCline(pathToTrack)
await config.services.fileContextTracker.trackFileContext(pathToTrack, "cline_edited")
}
this.appliedCommit = undefined
this.config = undefined
// Build response with file contents and diagnostics
@@ -285,6 +340,7 @@ export class ApplyPatchHandler implements IFullyManagedTool {
for (const [path, result] of Object.entries(applyResults)) {
if (result.deleted) {
config.taskState.didEditFile = true
responseLines.push(`\n${path}: [deleted]`)
} else {
// Format response similar to WriteToFileToolHandler
@@ -321,9 +377,9 @@ export class ApplyPatchHandler implements IFullyManagedTool {
return responseLines.join("\n")
} catch (error) {
await provider.revertChanges()
await provider.reset()
console.error("Reverted changes due to error in ApplyPatchHandler.", error)
throw error
} finally {
await provider.reset()
}
}
@@ -450,10 +506,15 @@ export class ApplyPatchHandler implements IFullyManagedTool {
return files
}
private patchToCommit(patch: Patch, originalFiles: Record<string, string>): Commit {
private async patchToCommit(patch: Patch, originalFiles: Record<string, string>): Promise<Commit> {
const changes: Record<string, FileChange> = {}
for (const [path, action] of Object.entries(patch.actions)) {
const targetResolution = await this.pathResolver!.resolveAndValidate(path, "ApplyPatchHandler.previewPatch")
if (!targetResolution) {
continue
}
switch (action.type) {
case PatchActionType.DELETE:
changes[path] = { type: PatchActionType.DELETE, oldContent: originalFiles[path] }
@@ -468,7 +529,7 @@ export class ApplyPatchHandler implements IFullyManagedTool {
changes[path] = {
type: PatchActionType.UPDATE,
oldContent: originalFiles[path],
newContent: this.applyChunks(originalFiles[path]!, action.chunks, path).trimEnd(),
newContent: this.applyChunks(originalFiles[path]!, action.chunks, path),
movePath: action.movePath,
}
break
@@ -531,93 +592,60 @@ export class ApplyPatchHandler implements IFullyManagedTool {
return result.join("\n")
}
private async applyCommit(commit: Commit): Promise<Record<string, FileOpsResult>> {
/**
* Prepares a single file change (opens file and updates content) without saving.
* Call saveFileChange() after approval.
*/
private async prepareFileChange(change: FileChange, path: string): Promise<void> {
const ops = this.providerOps!
const results: Record<string, FileOpsResult> = {}
for (const [path, change] of Object.entries(commit.changes)) {
switch (change.type) {
case PatchActionType.DELETE:
await ops.deleteFile(path)
results[path] = { deleted: true }
break
case PatchActionType.ADD:
if (!change.newContent) {
throw new DiffError(`Cannot create ${path} with no content`)
}
const addResult = await ops.createFile(path, change.newContent)
results[path] = {
finalContent: addResult.finalContent,
newProblemsMessage: addResult.newProblemsMessage,
userEdits: addResult.userEdits,
autoFormattingEdits: addResult.autoFormattingEdits,
}
break
case PatchActionType.UPDATE:
if (!change.newContent) {
throw new DiffError(`UPDATE change for ${path} has no new content`)
}
if (change.movePath) {
const moveResult = await ops.moveFile(path, change.movePath, change.newContent)
results[change.movePath] = {
finalContent: moveResult.finalContent,
newProblemsMessage: moveResult.newProblemsMessage,
userEdits: moveResult.userEdits,
autoFormattingEdits: moveResult.autoFormattingEdits,
}
results[path] = { deleted: true }
} else {
const updateResult = await ops.modifyFile(path, change.newContent)
results[path] = {
finalContent: updateResult.finalContent,
newProblemsMessage: updateResult.newProblemsMessage,
userEdits: updateResult.userEdits,
autoFormattingEdits: updateResult.autoFormattingEdits,
}
}
break
}
switch (change.type) {
case PatchActionType.DELETE:
await ops.deleteFile(path, false)
break
case PatchActionType.ADD:
if (!change.newContent) {
throw new DiffError(`Cannot create ${path} with no content`)
}
await ops.createFile(path, change.newContent, false)
break
case PatchActionType.UPDATE:
if (!change.newContent) {
throw new DiffError(`UPDATE change for ${path} has no new content`)
}
if (change.movePath) {
// For move operations, prepare the new file (the old file will be handled separately)
await ops.createFile(change.movePath, change.newContent, false)
} else {
await ops.modifyFile(path, change.newContent, false)
}
break
}
return results
}
private async revertChanges(): Promise<void> {
if (!this.appliedCommit || !this.providerOps) {
return
}
/**
* Saves the changes for a single file after approval.
*/
private async saveFileChange(change: FileChange, path: string): Promise<FileOpsResult | undefined> {
const ops = this.providerOps!
const ops = this.providerOps
for (const [path, change] of Object.entries(this.appliedCommit.changes)) {
try {
switch (change.type) {
case PatchActionType.DELETE:
if (change.oldContent !== undefined) {
await ops.createFile(path, change.oldContent)
}
break
case PatchActionType.ADD:
await ops.deleteFile(path)
break
case PatchActionType.UPDATE:
if (change.movePath) {
await ops.deleteFile(change.movePath)
if (change.oldContent !== undefined) {
await ops.createFile(path, change.oldContent)
}
} else if (change.oldContent !== undefined) {
await ops.modifyFile(path, change.oldContent)
}
break
switch (change.type) {
case PatchActionType.DELETE:
// For delete operations, actually delete the file now (after approval)
await ops.deleteFile(path)
return { deleted: true }
case PatchActionType.ADD:
if (!change.newContent) {
throw new DiffError(`Cannot create ${path} with no content`)
}
} catch (error) {
console.error(`Failed to revert ${path}:`, error)
}
return await ops.saveChanges()
case PatchActionType.UPDATE:
if (!change.newContent) {
throw new DiffError(`UPDATE change for ${path} has no new content`)
}
// For move operations, we're saving the new file (the old file deletion is handled in the calling code)
return await ops.saveChanges()
}
this.appliedCommit = undefined
this.config = undefined
}
private async generateChangeSummary(changes: Record<string, FileChange>): Promise<ClineSayTool[]> {
@@ -703,6 +731,7 @@ export class ApplyPatchHandler implements IFullyManagedTool {
undefined,
block.isNativeToolCall,
)
return approved
}
}
@@ -481,8 +481,6 @@ export class WriteToFileToolHandler implements IFullyManagedTool {
return
}
newContent = newContent.trimEnd() // remove any trailing newlines, since it's automatically inserted by the editor
return { relPath, absolutePath, fileExists, diff, content, newContent, workspaceContext }
}
@@ -14,34 +14,86 @@ export interface FileOpsResult {
export class FileProviderOperations {
constructor(private provider: DiffViewProvider) {}
async createFile(path: string, content: string): Promise<FileOpsResult> {
async openFile(path: string): Promise<void> {
await this.provider.open(path)
}
/**
* Saves the current changes and returns the result.
*/
async saveChanges(): Promise<FileOpsResult> {
const result = await this.provider.saveChanges()
return result
}
/**
* Creates a file. If isFinal is false, prepares the creation without saving.
* Call saveChanges() after approval when isFinal is false.
*/
async createFile(path: string, content: string, isFinal: boolean = true): Promise<FileOpsResult | undefined> {
this.provider.editType = "create"
await this.provider.open(path)
await this.provider.update(content, true)
const result = await this.provider.saveChanges()
await this.provider.reset()
return result
await this.openFile(path)
await this.provider.update(content, isFinal)
if (isFinal) {
return await this.saveChanges()
}
return undefined
}
async modifyFile(path: string, content: string): Promise<FileOpsResult> {
/**
* Modifies a file. If isFinal is false, prepares the modification without saving.
* Call saveChanges() after approval when isFinal is false.
*/
async modifyFile(path: string, content: string, isFinal: boolean = true): Promise<FileOpsResult | undefined> {
this.provider.editType = "modify"
await this.provider.open(path)
await this.provider.update(content, true)
const result = await this.provider.saveChanges()
await this.provider.reset()
return result
await this.openFile(path)
await this.provider.update(content, isFinal)
if (isFinal) {
return await this.saveChanges()
}
return undefined
}
async deleteFile(path: string): Promise<void> {
/**
* Deletes a file. If isFinal is false, prepares the deletion without actually deleting.
* Opens the file in the diff view to show it will be deleted.
* Call deleteFile() with isFinal=true after approval when isFinal is false.
*/
async deleteFile(path: string, isFinal: boolean = true): Promise<FileOpsResult | undefined> {
this.provider.editType = "delete"
await this.provider.open(path)
await this.provider.deleteFile(path)
await this.openFile(path)
if (isFinal) {
await this.provider.deleteFile(path)
return undefined
} else {
// Update with empty content to show the file will be deleted
await this.provider.update("", isFinal)
return undefined
}
}
async moveFile(oldPath: string, newPath: string, content: string): Promise<FileOpsResult> {
const result = await this.createFile(newPath, content)
await this.deleteFile(oldPath)
return result
/**
* Moves a file from oldPath to newPath. If isFinal is false, prepares the move without saving.
* Call saveChanges() after approval when isFinal is false.
*/
async moveFile(
oldPath: string,
newPath: string,
content: string,
isFinal: boolean = true,
): Promise<FileOpsResult | undefined> {
if (isFinal) {
const result = await this.createFile(newPath, content, isFinal)
await this.deleteFile(oldPath, isFinal)
return result
} else {
await this.createFile(newPath, content, isFinal)
await this.deleteFile(oldPath, isFinal)
return undefined
}
}
async getFileContent(): Promise<string | undefined> {
+15
View File
@@ -9,6 +9,7 @@ import { sendChatButtonClickedEvent } from "./core/controller/ui/subscribeToChat
import { sendHistoryButtonClickedEvent } from "./core/controller/ui/subscribeToHistoryButtonClicked"
import { sendMcpButtonClickedEvent } from "./core/controller/ui/subscribeToMcpButtonClicked"
import { sendSettingsButtonClickedEvent } from "./core/controller/ui/subscribeToSettingsButtonClicked"
import { sendWorktreesButtonClickedEvent } from "./core/controller/ui/subscribeToWorktreesButtonClicked"
import { WebviewProvider } from "./core/webview"
import { createClineAPI } from "./exports"
import { Logger } from "./services/logging/Logger"
@@ -44,6 +45,7 @@ import { ExtensionRegistryInfo } from "./registry"
import { AuthService } from "./services/auth/AuthService"
import { LogoutReason } from "./services/auth/types"
import { telemetryService } from "./services/telemetry"
import { ClineTempManager } from "./services/temp"
import { SharedUriHandler } from "./services/uri/SharedUriHandler"
import { ShowMessageType } from "./shared/proto/host/window"
import { fileExistsAtPath } from "./utils/fs"
@@ -87,6 +89,9 @@ export async function activate(context: vscode.ExtensionContext) {
const webview = (await initialize(context)) as VscodeWebviewProvider
// Clean up old temp files in background (non-blocking) and start periodic cleanup every 24 hours
ClineTempManager.startPeriodicCleanup()
Logger.log("Cline extension activated")
const testModeWatchers = await initializeTestMode(webview)
@@ -140,6 +145,13 @@ export async function activate(context: vscode.ExtensionContext) {
}),
)
context.subscriptions.push(
vscode.commands.registerCommand(commands.WorktreesButton, () => {
// Send event to all subscribers using the gRPC streaming method
sendWorktreesButtonClickedEvent()
}),
)
/*
We use the text document content provider API to show the left side for diff view by creating a
virtual document for the original content. This makes it readonly so users know to edit the right
@@ -489,6 +501,9 @@ async function getBinaryLocation(name: string): Promise<string> {
export async function deactivate() {
Logger.log("Cline extension deactivating, cleaning up resources...")
// Stop periodic temp file cleanup
ClineTempManager.stopPeriodicCleanup()
tearDown()
// Clean up test mode
+12
View File
@@ -42,6 +42,18 @@ export class ExternalDiffViewProvider extends DiffViewProvider {
})
}
protected override async getDocumentLineCount(): Promise<number> {
const text = await this.getDocumentText()
if (!text) {
return 0
}
// Count lines: split by newline, but handle trailing newline correctly
const lines = text.split("\n")
// If text ends with newline, split creates an extra empty string at the end
// which represents the "line" after the final newline - this is correct line count
return lines.length
}
protected async saveDocument(): Promise<Boolean> {
if (!this.activeDiffEditorId) {
return false
+36 -1
View File
@@ -102,11 +102,31 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
// Replace the text in the diff editor document.
const document = this.activeDiffEditor?.document
const replacingToEnd = rangeToReplace.endLine >= document.lineCount
const edit = new vscode.WorkspaceEdit()
const range = new vscode.Range(rangeToReplace.startLine, 0, rangeToReplace.endLine, 0)
edit.replace(document.uri, range, content)
await vscode.workspace.applyEdit(edit)
// VS Code can normalize trailing newlines on full-document replacements.
// Only fix up when replacing to the end to avoid touching untouched content.
if (replacingToEnd) {
const desiredTrailingNewlines = countTrailingNewlines(content)
const actualTrailingNewlines = countTrailingNewlines(document.getText())
const newlineDelta = desiredTrailingNewlines - actualTrailingNewlines
if (newlineDelta > 0) {
const fixEdit = new vscode.WorkspaceEdit()
fixEdit.insert(document.uri, document.lineAt(document.lineCount - 1).range.end, "\n".repeat(newlineDelta))
await vscode.workspace.applyEdit(fixEdit)
} else if (newlineDelta < 0) {
const fixEdit = new vscode.WorkspaceEdit()
const startLine = Math.max(0, document.lineCount + newlineDelta)
fixEdit.delete(document.uri, new vscode.Range(startLine, 0, document.lineCount, 0))
await vscode.workspace.applyEdit(fixEdit)
}
}
if (currentLine !== undefined) {
// Update decorations for the entire changed section
this.activeLineController?.setActiveLine(currentLine)
@@ -147,11 +167,18 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
edit.delete(document.uri, new vscode.Range(lineNumber, 0, document.lineCount, 0))
await vscode.workspace.applyEdit(edit)
}
// Clear all decorations at the end (before applying final edit)
}
protected override async onFinalUpdate(): Promise<void> {
// Clear all decorations at the end of streaming
this.fadedOverlayController?.clear()
this.activeLineController?.clear()
}
protected override async getDocumentLineCount(): Promise<number> {
return this.activeDiffEditor?.document.lineCount ?? 0
}
protected override async getDocumentText(): Promise<string | undefined> {
if (!this.activeDiffEditor || !this.activeDiffEditor.document) {
return undefined
@@ -193,3 +220,11 @@ export class VscodeDiffViewProvider extends DiffViewProvider {
this.activeLineController = undefined
}
}
function countTrailingNewlines(text: string): number {
let count = 0
for (let i = text.length - 1; i >= 0 && text[i] === "\n"; i -= 1) {
count += 1
}
return count
}
@@ -28,20 +28,34 @@ describe("Hostbridge - Window - getOpenTabs", () => {
})
}
beforeEach(async () => {
// Clean up any existing editors
async function waitForAllTabsClosed(): Promise<void> {
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
// Wait for tabs to actually close (Windows can be slow to process this)
await pWaitFor(
async () => {
const request = GetOpenTabsRequest.create({})
const response = await getOpenTabs(request)
return response.paths.length === 0
},
{
timeout: 5000,
interval: 50,
},
)
}
beforeEach(async () => {
// Clean up any existing editors and wait for cleanup to complete
await waitForAllTabsClosed()
})
afterEach(async () => {
// Clean up test documents and editors
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
await waitForAllTabsClosed()
})
it("should return empty array when no tabs are open", async () => {
// Ensure no tabs are open
await vscode.commands.executeCommand("workbench.action.closeAllEditors")
// beforeEach already ensures no tabs are open
const request = GetOpenTabsRequest.create({})
const response = await getOpenTabs(request)
@@ -0,0 +1,13 @@
import * as vscode from "vscode"
import { OpenFolderRequest, OpenFolderResponse } from "@/shared/proto/host/workspace"
export async function openFolder(request: OpenFolderRequest): Promise<OpenFolderResponse> {
try {
const uri = vscode.Uri.file(request.path)
await vscode.commands.executeCommand("vscode.openFolder", uri, { forceNewWindow: request.newWindow })
return OpenFolderResponse.create({ success: true })
} catch (error) {
console.error("Failed to open folder:", error)
return OpenFolderResponse.create({ success: false })
}
}
+42 -12
View File
@@ -96,6 +96,24 @@ export abstract class DiffViewProvider {
*/
protected abstract truncateDocument(lineNumber: number): Promise<void>
/**
* Returns the current line count of the document being edited.
* Used for boundary validation before calling truncateDocument.
*/
protected abstract getDocumentLineCount(): Promise<number>
/**
* Safely truncates the document, ensuring the line number is within bounds.
* This prevents errors on hosts that strictly validate line numbers (e.g., JetBrains via gRPC).
*/
private async safelyTruncateDocument(lineNumber: number): Promise<void> {
const lineCount = await this.getDocumentLineCount()
// Only truncate if there's content beyond the specified line
if (lineNumber < lineCount) {
await this.truncateDocument(lineNumber)
}
}
/**
* Get the contents of the diff editor document.
*
@@ -182,8 +200,20 @@ export abstract class DiffViewProvider {
// Replace all content up to the current line with accumulated lines
// This is necessary (as compared to inserting one line at a time) to handle cases where html tags
// on previous lines are auto closed for example
const contentToReplace = accumulatedLines.slice(0, currentLine + 1).join("\n") + "\n"
const rangeToReplace = { startLine: 0, endLine: currentLine + 1 }
let contentToReplace = accumulatedLines.slice(0, currentLine + 1).join("\n")
if (!isFinal) {
// During streaming, add trailing newline for cursor positioning
contentToReplace += "\n"
}
// For the final update, replace the entire document to prevent concatenation
// when content doesn't end with a newline. Without this, replacing lines 0-N
// with content lacking a trailing newline causes line N+1's content to be
// directly appended to our content (e.g., "Hello World" + "# Old Header" becomes
// "Hello World# Old Header").
const endLine = isFinal ? await this.getDocumentLineCount() : currentLine + 1
const rangeToReplace = { startLine: 0, endLine }
await this.replaceText(contentToReplace, rangeToReplace, currentLine)
// Scroll to the actual change location if provided.
@@ -211,19 +241,19 @@ export abstract class DiffViewProvider {
this.streamedLines = accumulatedLines
if (isFinal) {
// Handle any remaining lines if the new content is shorter than the original
await this.truncateDocument(this.streamedLines.length)
// Add empty last line if original content had one
const hasEmptyLastLine = this.originalContent?.endsWith("\n")
if (hasEmptyLastLine) {
const accumulatedLines = accumulatedContent.split("\n")
if (accumulatedLines[accumulatedLines.length - 1] !== "") {
accumulatedContent += "\n"
}
}
await this.safelyTruncateDocument(this.streamedLines.length)
// Allow subclasses to perform cleanup (e.g., clearing decorations)
await this.onFinalUpdate()
}
}
/**
* Called after the final update is complete. Subclasses can override to perform cleanup.
*/
protected async onFinalUpdate(): Promise<void> {
// Default no-op
}
async showFile(absolutePath: string): Promise<void> {
await openFile(absolutePath, true)
}
+15 -2
View File
@@ -44,10 +44,16 @@ export class FileEditProvider extends DiffViewProvider {
// Split the document into lines
const lines = this.documentContent.split("\n")
// Check if we're replacing to the end of the document
const replacingToEnd = rangeToReplace.endLine >= lines.length
// Replace the specified range with the new content
const newContentLines = content.split("\n")
// Remove trailing empty line if present in newContentLines for proper splicing
if (newContentLines[newContentLines.length - 1] === "") {
// Remove trailing empty line for proper splicing, BUT only when NOT replacing
// to the end of the document. When replacing to the end, keep the trailing
// empty string to preserve trailing newlines from the content.
if (!replacingToEnd && newContentLines[newContentLines.length - 1] === "") {
newContentLines.pop()
}
@@ -78,6 +84,13 @@ export class FileEditProvider extends DiffViewProvider {
}
}
protected async getDocumentLineCount(): Promise<number> {
if (!this.documentContent) {
return 0
}
return this.documentContent.split("\n").length
}
protected async getDocumentText(): Promise<string | undefined> {
return this.documentContent
}
@@ -0,0 +1,226 @@
import * as assert from "assert"
import { describe, it } from "mocha"
import { DiffViewProvider } from "../DiffViewProvider"
class TestBoundaryDiffViewProvider extends DiffViewProvider {
public documentText: string = ""
public truncatedAt: number | undefined
async openDiffEditor(): Promise<void> {}
async scrollEditorToLine(line: number): Promise<void> {}
async scrollAnimation(startLine: number, endLine: number): Promise<void> {}
async truncateDocument(lineNumber: number): Promise<void> {
this.truncatedAt = lineNumber
const lines = this.documentText.split("\n")
if (lineNumber < lines.length) {
this.documentText = lines.slice(0, lineNumber).join("\n")
}
}
async getDocumentLineCount(): Promise<number> {
return this.documentText.split("\n").length
}
async getDocumentText(): Promise<string | undefined> {
return this.documentText
}
async saveDocument(): Promise<Boolean> {
return true
}
async closeAllDiffViews(): Promise<void> {}
async resetDiffView(): Promise<void> {}
async replaceText(
content: string,
rangeToReplace: { startLine: number; endLine: number },
currentLine: number | undefined,
): Promise<void> {
// Minimal implementation for update() to work
const lines = this.documentText.split("\n")
// Check if we're replacing to the end of the document
const replacingToEnd = rangeToReplace.endLine >= lines.length
const newLines = content.split("\n")
// Remove trailing empty line for proper splicing, BUT only when NOT replacing
// to the end of the document. When replacing to the end, keep the trailing
// empty string to preserve trailing newlines from the content.
if (!replacingToEnd && newLines[newLines.length - 1] === "") {
newLines.pop()
}
lines.splice(rangeToReplace.startLine, rangeToReplace.endLine - rangeToReplace.startLine, ...newLines)
this.documentText = lines.join("\n")
}
public setup(initialContent: string) {
this.isEditing = true
this.documentText = initialContent
this.originalContent = initialContent
this.truncatedAt = undefined
}
}
describe("DiffViewProvider Boundary Validation", () => {
it("should replace entire document on final update to prevent concatenation", async () => {
const provider = new TestBoundaryDiffViewProvider()
// Start with multi-line content
provider.setup("line1\nline2\nline3\n")
// Update with content that has no trailing newline
// This previously caused "Hello World" + "line2" concatenation
await provider.update("Hello World", true)
const result = await provider.getDocumentText()
// Should be just "Hello World", not "Hello Worldline2\nline3\n"
assert.strictEqual(result, "Hello World")
})
it("safelyTruncateDocument should no-op when lineNumber >= lineCount", async () => {
const provider = new TestBoundaryDiffViewProvider()
provider.setup("line1\nline2\nline3")
// lineCount is 3
// Access private method via any cast or just call update which calls it
// But update calls it with streamedLines.length.
// Let's use update to trigger it.
// If we update with same content, streamedLines.length will be 3.
// safelyTruncateDocument(3) should be called.
// 3 >= 3, so it should NOT call truncateDocument.
await provider.update("line1\nline2\nline3", true)
assert.strictEqual(provider.truncatedAt, undefined, "Should not have called truncateDocument")
})
it("final update replaces entire document so truncation is no-op", async () => {
const provider = new TestBoundaryDiffViewProvider()
provider.setup("line1\nline2\nline3")
// Update with fewer lines
await provider.update("line1\n", true)
// With the fix, the final update replaces the entire document (0 to lineCount).
// So replaceText handles all the content, and truncation becomes unnecessary.
// The document should contain just "line1\n" and truncation should NOT be called
// because after replaceText, the document already has the correct content.
// Note: truncation might still be called but should be a no-op since document is already correct
assert.strictEqual(provider.documentText, "line1\n")
})
it("update() with shorter content replaces entire document", async () => {
const provider = new TestBoundaryDiffViewProvider()
provider.setup("line1\nline2\nline3\nline4")
// Update with 2 lines
await provider.update("line1\nline2", true)
// With the fix, the final update replaces the entire document (0 to lineCount).
// The document should contain just "line1\nline2".
assert.strictEqual(provider.documentText, "line1\nline2")
})
})
describe("DiffViewProvider Newline Preservation", () => {
it("preserves trailing newline when content ends with newline", async () => {
const provider = new TestBoundaryDiffViewProvider()
// Original file has trailing newline
provider.setup("line1\nline2\n")
// New content also has trailing newline
await provider.update("new1\nnew2\n", true)
const result = await provider.getDocumentText()
assert.strictEqual(result, "new1\nnew2\n", "Trailing newline should be preserved")
assert.strictEqual(result?.endsWith("\n"), true)
})
it("does not add trailing newline when content does not end with newline", async () => {
const provider = new TestBoundaryDiffViewProvider()
// Original file has trailing newline
provider.setup("line1\nline2\n")
// New content does NOT have trailing newline
await provider.update("new1\nnew2", true)
const result = await provider.getDocumentText()
assert.strictEqual(result, "new1\nnew2", "Should not have trailing newline")
assert.strictEqual(result?.endsWith("\n"), false)
})
it("adds trailing newline when content ends with newline but original did not", async () => {
const provider = new TestBoundaryDiffViewProvider()
// Original file does NOT have trailing newline
provider.setup("line1\nline2")
// New content has trailing newline
await provider.update("new1\nnew2\n", true)
const result = await provider.getDocumentText()
assert.strictEqual(result, "new1\nnew2\n", "Should add trailing newline")
assert.strictEqual(result?.endsWith("\n"), true)
})
it("preserves no trailing newline when neither original nor new content has one", async () => {
const provider = new TestBoundaryDiffViewProvider()
// Original file does NOT have trailing newline
provider.setup("line1\nline2")
// New content also does NOT have trailing newline
await provider.update("new1\nnew2", true)
const result = await provider.getDocumentText()
assert.strictEqual(result, "new1\nnew2", "Should not have trailing newline")
assert.strictEqual(result?.endsWith("\n"), false)
})
it("handles shortening file while preserving trailing newline", async () => {
const provider = new TestBoundaryDiffViewProvider()
// Original: 10 lines with trailing newline
provider.setup("line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\n")
// New: 3 lines with trailing newline
await provider.update("line1\nline2\nline3\n", true)
const result = await provider.getDocumentText()
assert.strictEqual(result, "line1\nline2\nline3\n", "Should shorten and preserve trailing newline")
})
it("handles lengthening file while preserving trailing newline", async () => {
const provider = new TestBoundaryDiffViewProvider()
// Original: 3 lines with trailing newline
provider.setup("line1\nline2\nline3\n")
// New: 5 lines with trailing newline
await provider.update("line1\nline2\nline3\nline4\nline5\n", true)
const result = await provider.getDocumentText()
assert.strictEqual(result, "line1\nline2\nline3\nline4\nline5\n", "Should lengthen and preserve trailing newline")
})
it("handles single line content with trailing newline", async () => {
const provider = new TestBoundaryDiffViewProvider()
provider.setup("old content\n")
await provider.update("Hello World\n", true)
const result = await provider.getDocumentText()
assert.strictEqual(result, "Hello World\n")
})
it("handles single line content without trailing newline", async () => {
const provider = new TestBoundaryDiffViewProvider()
provider.setup("old content\nline2\n")
await provider.update("Hello World", true)
const result = await provider.getDocumentText()
assert.strictEqual(result, "Hello World")
})
})
@@ -18,10 +18,9 @@ import { formatResponse } from "@core/prompts/responses"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { Logger } from "@services/logging/Logger"
import { TerminalHangStage, TerminalUserInterventionAction, telemetryService } from "@services/telemetry"
import { ClineTempManager } from "@services/temp"
import { COMMAND_CANCEL_TOKEN } from "@shared/ExtensionMessage"
import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import {
BUFFER_STUCK_TIMEOUT_MS,
CHUNK_BYTE_SIZE,
@@ -255,8 +254,8 @@ export async function orchestrateCommandExecution(
chunkTimer = null
}
// Set up file logging
largeOutputLogPath = path.join(os.tmpdir(), `cline-large-output-${Date.now()}.log`)
// Set up file logging using ClineTempManager for proper cleanup
largeOutputLogPath = ClineTempManager.createTempFilePath("large-output")
largeOutputLogStream = fs.createWriteStream(largeOutputLogPath, { flags: "a" })
// Write all existing lines to file in a single batch to reduce I/O overhead
@@ -12,9 +12,8 @@
* - Provides summary for environment details
*/
import { ClineTempManager } from "@services/temp"
import * as fs from "fs"
import * as os from "os"
import * as path from "path"
import {
BACKGROUND_COMMAND_TIMEOUT_MS,
DEFAULT_SUBAGENT_TERMINAL_OUTPUT_LINE_LIMIT,
@@ -431,7 +430,8 @@ export class StandaloneTerminalManager implements ITerminalManager {
existingOutput: string[] = [],
): BackgroundCommand {
const id = `background-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
const logFilePath = path.join(os.tmpdir(), `cline-${id}.log`)
// Use ClineTempManager for proper temp file management and cleanup
const logFilePath = ClineTempManager.createTempFilePath("background")
const backgroundCommand: BackgroundCommand = {
id,
@@ -155,17 +155,18 @@ export class StandaloneTerminalProcess extends EventEmitter<TerminalProcessEvent
this.isHot = false
}
// Track terminal execution telemetry
// Track terminal execution telemetry with exit code for failure diagnosis
const success = code === 0 || code === null
telemetryService.captureTerminalExecution(success, "standalone", "child_process")
telemetryService.captureTerminalExecution(success, "standalone", "child_process", code)
this.emit("completed")
this.emit("continue")
})
// Handle process errors
// Handle process errors (spawn failures)
this.childProcess.on("error", (error: Error) => {
// Track terminal execution error telemetry
// method: "child_process_error" already indicates spawn failure
telemetryService.captureTerminalExecution(false, "standalone", "child_process_error")
this.emit("error", error)
})

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