mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 570003eaa6 | |||
| d4733706d8 | |||
| a727d5c347 | |||
| ae306adc42 | |||
| 2f3002d215 | |||
| 41404e1a13 | |||
| d6244efda1 | |||
| be410c2927 | |||
| 6c67a0c8fc | |||
| 31e3c082ec | |||
| a75f4a63c8 | |||
| 0b059cf36f | |||
| 5d7f0f04d3 | |||
| 13cf28d8f4 | |||
| 6238fab366 | |||
| 7c26a7d16b | |||
| 7885c75a4f | |||
| 8994d125be | |||
| 79d88f7708 | |||
| 4d092bfba6 | |||
| dc5c6f916b | |||
| 50332ec46a | |||
| 5ccd062839 | |||
| ae9eceb113 | |||
| dd35448a9d | |||
| 32aa16612d | |||
| bffe5c4d2a | |||
| b18d7012aa | |||
| e0965821fc | |||
| 9e46e9fd22 | |||
| 890a1f7ac8 | |||
| b89a73c193 | |||
| 51927dba33 | |||
| 7634f22104 | |||
| fbf784f78b | |||
| 62bf50a659 | |||
| d850fbc0ad | |||
| 4dd6c6dcc7 | |||
| 8813f8252c | |||
| 3210c4bc4b | |||
| 9f3daa4151 | |||
| ac2db41815 | |||
| df1d33c751 | |||
| 361494d18f | |||
| dca0a8fa3e | |||
| 6d7213dc6a | |||
| 97a35d3868 | |||
| 4ec9155c46 | |||
| bffca989a1 | |||
| 0133b5d030 | |||
| a94c4be438 | |||
| d70792e539 | |||
| e4ddaac627 | |||
| 9478b600aa | |||
| d194e47bf6 | |||
| c9ff9cf1d5 | |||
| d7fa6b33c1 | |||
| 7699c52abf | |||
| 24281d98e1 | |||
| 0af7d0911d | |||
| 83af73a5f9 | |||
| 9d875e6222 | |||
| 125e2ad01f | |||
| fd46c97825 | |||
| 7426513245 | |||
| f49a856ee4 | |||
| 24568fed0d |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add gpt-5.2-codex OpenAI model support
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add claude 4.5 haiku
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Support CLINE_DIR environment variable in CLI (#8379)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Expose --version in cline cli command
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
OpenAI GPT-5 Codex models are now using Apply Patch tool for diff edits.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: add chat output on skill use
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add create-pull-request skill
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Adding telemetry for background exec terminal
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix the selection of remotely configured providers
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: removes retry message from UI after retry succeeds
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
add claude 4.5 opus into sap provider.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Limite Vertex and LiteLLM options when they're remote configured
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix crash when the Context Menu has a type but no options
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Harden act_mode_respond to prevent consecutive calls
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Throttle the remote config fetch
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Improve history view filter menu
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"cline": patch
|
||||
---
|
||||
|
||||
Add git worktree management UI for running parallel Cline sessions
|
||||
@@ -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.
|
||||
@@ -5,6 +5,7 @@ node_modules
|
||||
tmp
|
||||
.vscode-test/
|
||||
*.vsix
|
||||
/pkg
|
||||
|
||||
.DS_Store
|
||||
.idea
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Default
|
||||
.vscode/**
|
||||
.vscode-test/**
|
||||
.worktrees/**
|
||||
CLAUDE.local.md
|
||||
out/
|
||||
dist-standalone/
|
||||
node_modules/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
"**",
|
||||
|
||||
@@ -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
@@ -59,7 +59,8 @@
|
||||
},
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux"
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"cpu": [
|
||||
"x64",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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, "")
|
||||
|
||||
@@ -177,6 +177,7 @@
|
||||
"features/tasks/task-management"
|
||||
]
|
||||
},
|
||||
"features/worktrees",
|
||||
"features/yolo-mode"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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!
|
||||
Generated
+6076
-3516
File diff suppressed because it is too large
Load Diff
+25
-4
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ cd cli
|
||||
|
||||
# Define target platforms for cross-compilation
|
||||
PLATFORMS=(
|
||||
"windows/amd64"
|
||||
"darwin/arm64"
|
||||
"darwin/amd64"
|
||||
"linux/amd64"
|
||||
|
||||
@@ -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..."))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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({})
|
||||
}
|
||||
+5
-40
@@ -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 change’s [context_after] lines in the second change’s [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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user