mirror of
https://github.com/cline/cline.git
synced 2026-09-05 14:14:01 +08:00
Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06b05ddfe9 | |||
| 2670a4a171 | |||
| 1699c9a63a | |||
| 808dd42ae9 | |||
| 71af56f493 | |||
| 1167b4f3a6 | |||
| e8d6370b0c | |||
| e243376a39 | |||
| 4f1be9d512 | |||
| 94d36ce719 | |||
| 5df7498f03 | |||
| c2b87252ac | |||
| be353bb3da | |||
| 0a7791de1f | |||
| 7cca102e14 | |||
| 4f591de6a9 | |||
| 60436b3ddf | |||
| df5954052d | |||
| a66a2784c3 | |||
| 47031cea25 | |||
| e29740479e | |||
| 74f607ff8e | |||
| 0edf6d777b | |||
| de630c64d4 | |||
| 3ff63562c8 | |||
| 4d6f908fbd | |||
| 6521fdcc94 | |||
| 1393eace27 | |||
| eebb99c1e3 | |||
| 9ed44f7a83 | |||
| 6a95cc5f19 | |||
| 1d0637f39c | |||
| f2c16bae7a | |||
| 204f15ce1c | |||
| 8118e11596 | |||
| f7b593df35 | |||
| 2e0358a7a1 | |||
| 0fbc10f807 | |||
| c093ca1760 | |||
| dc85299a73 | |||
| 4533ed3ea8 | |||
| 66d7664d1b | |||
| 50aeb7098f | |||
| c2e6eafb45 | |||
| 0220628c39 | |||
| 5052220195 | |||
| abf3081e56 | |||
| 125cb78a31 | |||
| 17539228ea | |||
| bdb7cc36fa | |||
| d3e1065707 | |||
| 6303951f20 | |||
| fc184e07a2 | |||
| e88f4ea412 | |||
| 133ade3f1a | |||
| 857411c035 | |||
| c59f068584 | |||
| f7561c31fa | |||
| f0a97dafc8 | |||
| d422f689a6 | |||
| 00efcc1c2b | |||
| 59251e9de6 | |||
| 2df952f571 | |||
| 6e7dc55781 | |||
| ec879fc549 | |||
| 75b050de5c | |||
| 0c4acd9457 | |||
| 0dc3d7084d | |||
| 3cfafe6583 | |||
| d8aefbaabd | |||
| 1ff60522f1 | |||
| 42321e5b95 | |||
| b2634d2276 | |||
| 3755817060 | |||
| 8ae18e6a16 | |||
| 113d7fb292 | |||
| 592d045565 | |||
| 8ea54704a6 | |||
| 1734c669a0 | |||
| fb695602a4 | |||
| 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 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add gpt-5.2-codex OpenAI model support
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Support CLINE_DIR environment variable in CLI (#8379)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Lock the LiteLLM Api Key input when it's remotely configured
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
updated welcome card content and added ability to close each card
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add create-pull-request skill
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Fix the selection of remotely configured providers
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Harden act_mode_respond to prevent consecutive calls
|
||||
@@ -0,0 +1,194 @@
|
||||
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
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
## gRPC/Protobuf Communication
|
||||
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
|
||||
|
||||
**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.
|
||||
+25
-7
@@ -16,13 +16,6 @@
|
||||
TELEMETRY_SERVICE_API_KEY=your-posthog-telemetry-api-key
|
||||
ERROR_SERVICE_API_KEY=your-posthog-error-tracking-api-key
|
||||
|
||||
# ============================================================================
|
||||
# TELEMETRY PROVIDER CONTROL
|
||||
# ============================================================================
|
||||
# Control which telemetry providers are active
|
||||
POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: true)
|
||||
# Set to false to disable Telemetry completely
|
||||
|
||||
# ============================================================================
|
||||
# OPENTELEMETRY (Optional - for advanced telemetry)
|
||||
# ============================================================================
|
||||
@@ -91,6 +84,31 @@ POSTHOG_TELEMETRY_ENABLED=true # Enable PostHog telemetry (default: tru
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.example.com
|
||||
# OTEL_EXPORTER_OTLP_HEADERS=authorization=Bearer your-token
|
||||
|
||||
# ============================================================================
|
||||
# OBJECT STORE CONFIGURATION
|
||||
# ============================================================================
|
||||
# TO ENABLE S3 OR R2 STORAGE, UNCOMMENT AND FILL IN THE FOLLOWING:
|
||||
# CLINE_STORAGE_ADAPTER="s3" # Options: "s3" or "r2"
|
||||
# CLINE_STORAGE_BUCKET="cline"
|
||||
# CLINE_STORAGE_ACCESS_KEY_ID="key"
|
||||
# CLINE_STORAGE_SECRET_ACCESS_KEY="secrets"
|
||||
#
|
||||
# [OPTIONAL FIELDS FOR R2]
|
||||
# CLINE_STORAGE_ACCOUNT_ID = "account-id"
|
||||
# Default R2 endpoint (if not set): "https://<CLINE_STORAGE_ACCOUNT_ID>.r2.cloudflarestorage.com"
|
||||
# CLINE_STORAGE_ENDPOINT = "http://localhost:8333"
|
||||
#
|
||||
# [OPTIONAL FIELDS FOR S3]
|
||||
# CLINE_STORAGE_REGION = "us-west-1" # AWS Bucket Region (default: "us-east-1")
|
||||
# Default S3 endpoint (if not set): "https://s3.<CLINE_STORAGE_REGION>.amazonaws.com"
|
||||
# CLINE_STORAGE_ENDPOINT = "http://localhost:8333"
|
||||
#
|
||||
# [OPTIONAL FIELDS FOR ALL STORAGE TYPES]
|
||||
# CLINE_STORAGE_SYNC_INTERVAL_MS = 30000 # Interval for sync worker in milliseconds
|
||||
# CLINE_STORAGE_SYNC_MAX_RETRIES = 5 # Max retries for failed sync operations
|
||||
# CLINE_STORAGE_SYNC_BATCH_SIZE = 10 # Number of files to sync in each batch
|
||||
# CLINE_STORAGE_SYNC_BACKFILL_ENABLED = false # Enable backfill of existing data on startup
|
||||
|
||||
# ============================================================================
|
||||
# OPTIONAL DEVELOPMENT SETTINGS
|
||||
# ============================================================================
|
||||
|
||||
@@ -97,7 +97,6 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
|
||||
@@ -138,7 +138,6 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
POSTHOG_TELEMETRY_ENABLED: "true"
|
||||
run: npm run compile-standalone-npm
|
||||
|
||||
- name: Generate Protos (Second Pass - Bug Workaround)
|
||||
|
||||
@@ -187,20 +187,11 @@ jobs:
|
||||
if: steps.webview-cache.outputs.cache-hit != 'true'
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24'
|
||||
cache-dependency-path: cli/go.sum
|
||||
|
||||
- name: Build CLI binaries
|
||||
run: npm run compile-cli-all-platforms
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: npm run download-ripgrep
|
||||
|
||||
- name: Compile NPM package
|
||||
run: npm run compile-standalone-npm
|
||||
- name: Compile Standalone
|
||||
run: npm run compile-standalone
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
if: steps.testing-platform-cache.outputs.cache-hit != 'true'
|
||||
@@ -213,7 +204,7 @@ jobs:
|
||||
# This prevents the job from showing as failed and avoids distracting developers
|
||||
# until the integration tests are ready to be enforced.
|
||||
run: |
|
||||
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage || true
|
||||
npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
name: Trigger Jetbrains Plugin <-> Cline Tests
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
types: [opened, reopened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
concurrency:
|
||||
group: jetbrains-trigger-${{ github.event.number }}
|
||||
group: jetbrains-trigger-${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
trigger-integration-test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
# Run on PR open/reopen, or when someone comments /test-jetbrains on a PR
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/test-jetbrains'))
|
||||
steps:
|
||||
- name: Generate GitHub App Token
|
||||
id: app-token
|
||||
@@ -22,16 +31,28 @@ jobs:
|
||||
owner: cline
|
||||
repositories: intellij-plugin
|
||||
|
||||
- name: Get PR details (for issue_comment trigger)
|
||||
id: pr-details
|
||||
if: github.event_name == 'issue_comment'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
PR_DATA=$(gh api repos/${{ github.repository }}/pulls/${{ github.event.issue.number }})
|
||||
echo "head_ref=$(echo "$PR_DATA" | jq -r '.head.ref')" >> $GITHUB_OUTPUT
|
||||
echo "head_sha=$(echo "$PR_DATA" | jq -r '.head.sha')" >> $GITHUB_OUTPUT
|
||||
echo "title=$(echo "$PR_DATA" | jq -r '.title')" >> $GITHUB_OUTPUT
|
||||
echo "html_url=$(echo "$PR_DATA" | jq -r '.html_url')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Sanitize untrusted inputs
|
||||
id: sanitize
|
||||
env:
|
||||
RAW_BRANCH_NAME: ${{ github.head_ref }}
|
||||
RAW_PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
RAW_BRANCH_NAME: ${{ github.event_name == 'pull_request_target' && github.head_ref || steps.pr-details.outputs.head_ref }}
|
||||
RAW_PR_TITLE: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.title || steps.pr-details.outputs.title }}
|
||||
run: |
|
||||
# Sanitize branch name for JSON
|
||||
BRANCH_NAME_JSON=$(jq -n --arg b "$RAW_BRANCH_NAME" '$b')
|
||||
echo "branch_name=$BRANCH_NAME_JSON" >> $GITHUB_OUTPUT
|
||||
|
||||
|
||||
# Sanitize PR title for JSON
|
||||
PR_TITLE_JSON=$(jq -n --arg t "$RAW_PR_TITLE" '$t')
|
||||
echo "pr_title=$PR_TITLE_JSON" >> $GITHUB_OUTPUT
|
||||
@@ -40,6 +61,9 @@ jobs:
|
||||
env:
|
||||
BRANCH_NAME: ${{ steps.sanitize.outputs.branch_name }}
|
||||
PR_TITLE: ${{ steps.sanitize.outputs.pr_title }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || steps.pr-details.outputs.head_sha }}
|
||||
PR_URL: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.html_url || steps.pr-details.outputs.html_url }}
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}" \
|
||||
@@ -51,19 +75,23 @@ jobs:
|
||||
{
|
||||
"event_type": "cline-pr-check",
|
||||
"client_payload": {
|
||||
"pr_number": "${{ github.event.number }}",
|
||||
"pr_number": "$PR_NUMBER",
|
||||
"branch_name": $BRANCH_NAME,
|
||||
"action": "${{ github.event.action }}",
|
||||
"sha": "${{ github.event.pull_request.head.sha }}",
|
||||
"sha": "$PR_SHA",
|
||||
"pr_title": $PR_TITLE,
|
||||
"pr_url": "${{ github.event.pull_request.html_url }}"
|
||||
"pr_url": "$PR_URL"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
- name: Log trigger details
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_SHA: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.head.sha || steps.pr-details.outputs.head_sha }}
|
||||
run: |
|
||||
echo "Triggered IntelliJ Plugin integration test for:"
|
||||
echo " PR #${{ github.event.number }}"
|
||||
echo " PR #$PR_NUMBER"
|
||||
echo " Trigger: ${{ github.event_name }}"
|
||||
echo " Action: ${{ github.event.action }}"
|
||||
echo " SHA: ${{ github.event.pull_request.head.sha }}"
|
||||
echo " SHA: $PR_SHA"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Default
|
||||
.vscode/**
|
||||
.vscode-test/**
|
||||
.worktrees/**
|
||||
CLAUDE.local.md
|
||||
out/
|
||||
dist-standalone/
|
||||
node_modules/
|
||||
|
||||
+70
-1
@@ -1,5 +1,74 @@
|
||||
# Changelog
|
||||
|
||||
## [3.55.0]
|
||||
|
||||
- Add new model: Arcee Trinity Large Preview
|
||||
- Add new model: Moonshot Kimi K2.5
|
||||
- Add MCP prompts support - prompts from connected MCP servers now appear in slash command autocomplete as `/mcp:<server>:<prompt>`
|
||||
|
||||
## [3.54.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Native tool calls support for Ollama provider
|
||||
- Sonnet 4.5 is now the default Amazon Bedrock model id
|
||||
|
||||
### Fixed
|
||||
|
||||
- Prevent infinite retry loops when replace_in_file fails repeatedly. The system now detects repeated failures and provides better guidance to break out of retry cycles.
|
||||
- Skip diff error UI handling during streaming to prevent flickering. Error handling is deferred until streaming completes.
|
||||
- Strip notebook cell outputs when extracting text content from Jupyter notebooks, significantly reducing context size sent to the LLM.
|
||||
- Throttle diff view updates during streaming to reduce UI flickering and improve performance.
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed Mistral's Devstral-2512 free from the free models list
|
||||
- Removed deprecated zai-glm-4.6 model from Cerebras provider
|
||||
|
||||
## [3.53.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bug in responses API
|
||||
|
||||
## [3.53.0]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Removed grok model from free tier
|
||||
|
||||
## [3.52.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Users with ChatGPT Plus or Pro subscriptions can now use GPT-5 models directly through Cline without needing an API key. Authentication is handled via OAuth through OpenAI's authentication system.
|
||||
- Grok models are now moving out of free tier and into paid plans.
|
||||
- Introduces comprehensive Jupyter Notebook support for Cline, enabling AI-assisted editing of `.ipynb` files with full cell-level context awareness.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bugs in DiffViewProvider for file editing
|
||||
- Ollama's recommended models to use correct identifiers
|
||||
|
||||
## [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
|
||||
@@ -1718,4 +1787,4 @@ Add Opus 4.1 through Claude Code
|
||||
|
||||
## [0.0.6]
|
||||
|
||||
- Initial release
|
||||
- Initial release
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
@@ -147,6 +147,29 @@
|
||||
"src/dev/grit/vscode-api.grit"
|
||||
]
|
||||
},
|
||||
{
|
||||
// Do not use console logging directly, use the Logger service instead.
|
||||
"plugins": [
|
||||
"src/dev/grit/console-log.grit"
|
||||
],
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/webview-ui/**",
|
||||
"!**/evals/**",
|
||||
"!**/standalone/**",
|
||||
"!**/e2e/**",
|
||||
"!**/test/**",
|
||||
"!**/__tests__/**",
|
||||
"!**/*.test.ts",
|
||||
"!**/*.stories.ts",
|
||||
"!src/dev/**",
|
||||
"!**/*.mjs",
|
||||
"!**/*.js",
|
||||
"!**/scripts/**",
|
||||
"!**/*.tsx",
|
||||
"!**/testing-platform/**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"includes": [
|
||||
"**",
|
||||
|
||||
+81
-89
@@ -10,11 +10,12 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/cli"
|
||||
"github.com/cline/cli/pkg/cli/auth"
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/slash"
|
||||
"github.com/cline/cli/pkg/common"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -37,8 +38,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:
|
||||
@@ -70,8 +72,6 @@ see the manual page: man cline`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
var instanceAddress string
|
||||
|
||||
// Validate workspace paths exist
|
||||
if err := common.ValidateDirsExist(workspaces); err != nil {
|
||||
return err
|
||||
@@ -88,13 +88,13 @@ see the manual page: man cline`,
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("Starting new Cline instance...")
|
||||
}
|
||||
instance, err := global.Clients.StartNewInstance(ctx, allWorkspaces...)
|
||||
instance, err := global.Instances.StartNewInstance(ctx, allWorkspaces...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new instance: %w", err)
|
||||
}
|
||||
instanceAddress = instance.Address
|
||||
global.Config.CoreAddress = instance.CoreAddress
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Started instance at %s\n\n", instanceAddress)
|
||||
fmt.Printf("Started instance at %s\n\n", global.Config.CoreAddress)
|
||||
}
|
||||
|
||||
// Set up cleanup on exit
|
||||
@@ -102,38 +102,35 @@ see the manual page: man cline`,
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("\nCleaning up instance...")
|
||||
}
|
||||
registry := global.Clients.GetRegistry()
|
||||
if err := global.KillInstanceByAddress(context.Background(), registry, instanceAddress); err != nil {
|
||||
registry := global.Instances.GetRegistry()
|
||||
if err := global.KillInstanceByAddress(context.Background(), registry, global.Config.CoreAddress); err != nil {
|
||||
if global.Config.Verbose {
|
||||
fmt.Printf("Warning: Failed to clean up instance: %v\n", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Check if user has credentials configured
|
||||
if !isUserReadyToUse(ctx, instanceAddress) {
|
||||
// Create renderer for welcome messages
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
fmt.Printf("\n%s\n\n", renderer.Dim("Hey there! Looks like you're new here. Let's get you set up"))
|
||||
// Check if user has credentials configured
|
||||
if !isUserReadyToUse(ctx) {
|
||||
// Create renderer for welcome messages
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
fmt.Printf("\n%s\n\n", renderer.Dim("Hey there! Looks like you're new here. Let's get you set up"))
|
||||
|
||||
if err := auth.HandleAuthMenuNoArgs(ctx); err != nil {
|
||||
// Check if user cancelled - exit cleanly
|
||||
if err == huh.ErrUserAborted {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("auth setup failed: %w", err)
|
||||
if err := auth.HandleAuthMenuNoArgs(ctx); err != nil {
|
||||
// Check if user cancelled - exit cleanly
|
||||
if err == huh.ErrUserAborted {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Re-check after auth wizard
|
||||
if !isUserReadyToUse(ctx, instanceAddress) {
|
||||
return fmt.Errorf("credentials still not configured - please run 'cline auth' to complete setup")
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s\n\n", renderer.Dim("✓ Setup complete, you can now use the Cline CLI"))
|
||||
return fmt.Errorf("auth setup failed: %w", err)
|
||||
}
|
||||
} else {
|
||||
// User specified --address flag, use that
|
||||
instanceAddress = coreAddress
|
||||
|
||||
// Re-check after auth wizard
|
||||
if !isUserReadyToUse(ctx) {
|
||||
return fmt.Errorf("credentials still not configured - please run 'cline auth' to complete setup")
|
||||
}
|
||||
|
||||
fmt.Printf("\n%s\n\n", renderer.Dim("✓ Setup complete, you can now use the Cline CLI"))
|
||||
}
|
||||
|
||||
// Get content from both args and stdin
|
||||
@@ -142,10 +139,13 @@ see the manual page: man cline`,
|
||||
return fmt.Errorf("failed to read prompt: %w", err)
|
||||
}
|
||||
|
||||
// If no prompt from args or stdin, show interactive input
|
||||
if prompt == "" {
|
||||
// If no prompt (or just a mode switch with no message), show interactive input
|
||||
// Loop to allow mode switches without a message
|
||||
bannerShown := false
|
||||
for prompt == "" {
|
||||
// Pass the mode flag and workspaces to banner so it shows correct info
|
||||
prompt, err = promptForInitialTask(ctx, instanceAddress, mode, allWorkspaces)
|
||||
prompt, err = promptForInitialTask(ctx, mode, allWorkspaces, !bannerShown)
|
||||
bannerShown = true
|
||||
if err != nil {
|
||||
// Check if user cancelled - exit cleanly without error
|
||||
if err == huh.ErrUserAborted {
|
||||
@@ -153,6 +153,23 @@ see the manual page: man cline`,
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if user entered a mode switch command
|
||||
if newMode, remaining, isModeSwitch := slash.ParseModeSwitch(prompt); isModeSwitch {
|
||||
mode = newMode
|
||||
prompt = remaining
|
||||
// If just a mode switch with no message, continue loop to re-prompt
|
||||
if prompt == "" {
|
||||
renderer := display.NewRenderer(global.Config.OutputFormat)
|
||||
if mode == "act" {
|
||||
fmt.Printf("\n%s\n\n", renderer.Success("Switched to act mode"))
|
||||
} else {
|
||||
fmt.Printf("\n%s\n\n", renderer.Success("Switched to plan mode"))
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if prompt == "" {
|
||||
return fmt.Errorf("prompt required")
|
||||
}
|
||||
@@ -170,13 +187,15 @@ see the manual page: man cline`,
|
||||
Mode: mode,
|
||||
Settings: settings,
|
||||
Yolo: yolo,
|
||||
Address: instanceAddress,
|
||||
Address: global.Config.CoreAddress,
|
||||
Verbose: verbose,
|
||||
Workspaces: allWorkspaces,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
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)")
|
||||
@@ -204,51 +223,30 @@ see the manual page: man cline`,
|
||||
}
|
||||
}
|
||||
|
||||
func promptForInitialTask(ctx context.Context, instanceAddress, modeFlag string, workspaces []string) (string, error) {
|
||||
// Show session banner before the initial input
|
||||
showSessionBanner(ctx, instanceAddress, modeFlag, workspaces)
|
||||
|
||||
var prompt string
|
||||
|
||||
// Create custom theme with mode-colored cursor and title
|
||||
theme := huh.ThemeCharm()
|
||||
|
||||
// Set cursor and title color based on mode
|
||||
modeColor := lipgloss.Color("3") // Yellow for plan
|
||||
if modeFlag == "act" {
|
||||
modeColor = lipgloss.Color("39") // Blue for act
|
||||
func promptForInitialTask(ctx context.Context, modeFlag string, workspaces []string, showBanner bool) (string, error) {
|
||||
// Show session banner before the initial input (only on first prompt)
|
||||
if showBanner {
|
||||
showSessionBanner(ctx, modeFlag, workspaces)
|
||||
}
|
||||
|
||||
theme.Focused.TextInput.Cursor = theme.Focused.TextInput.Cursor.Foreground(modeColor)
|
||||
theme.Focused.Title = theme.Focused.Title.Foreground(modeColor)
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewText().
|
||||
Title("Start a new Cline task").
|
||||
Description("What would you like Cline to help you with?").
|
||||
Placeholder("e.g., Create a REST API with authentication...").
|
||||
Lines(5).
|
||||
Value(&prompt),
|
||||
),
|
||||
).WithWidth(48).WithTheme(theme)
|
||||
|
||||
err := form.Run()
|
||||
prompt, err := output.PromptForInitialTask(
|
||||
"Start a new Cline task",
|
||||
"/plan or /act to switch modes\ntab to autocomplete commands\nctrl+e to open editor\nctrl+c to exit",
|
||||
modeFlag,
|
||||
slash.NewRegistry(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
// Check if user cancelled with Control-C
|
||||
if err == huh.ErrUserAborted {
|
||||
// Return a special error that indicates clean cancellation
|
||||
// This allows deferred cleanup to run
|
||||
if err == output.ErrUserAborted {
|
||||
return "", huh.ErrUserAborted
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return strings.TrimSpace(prompt), nil
|
||||
return prompt, nil
|
||||
}
|
||||
|
||||
// showSessionBanner displays session info before initial prompt
|
||||
func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string, workspaces []string) {
|
||||
func showSessionBanner(ctx context.Context, modeFlag string, workspaces []string) {
|
||||
bannerInfo := display.BannerInfo{
|
||||
Version: global.CliVersion,
|
||||
Mode: modeFlag, // Use the mode from command flag, not state
|
||||
@@ -262,21 +260,18 @@ func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string, wo
|
||||
bannerInfo.Workdirs = workspaces
|
||||
|
||||
// Get provider/model using auth functions (same logic as auth menu)
|
||||
manager, err := cli.NewTaskManagerForAddress(ctx, instanceAddress)
|
||||
if err == nil {
|
||||
if providerList, err := auth.GetProviderConfigurations(ctx, manager); err == nil {
|
||||
// Show provider/model for the mode we'll be using
|
||||
var providerDisplay *auth.ProviderDisplay
|
||||
if bannerInfo.Mode == "plan" && providerList.PlanProvider != nil {
|
||||
providerDisplay = providerList.PlanProvider
|
||||
} else if bannerInfo.Mode == "act" && providerList.ActProvider != nil {
|
||||
providerDisplay = providerList.ActProvider
|
||||
}
|
||||
if providerList, err := auth.GetProviderConfigurations(ctx); err == nil {
|
||||
// Show provider/model for the mode we'll be using
|
||||
var providerDisplay *auth.ProviderDisplay
|
||||
if bannerInfo.Mode == "plan" && providerList.PlanProvider != nil {
|
||||
providerDisplay = providerList.PlanProvider
|
||||
} else if bannerInfo.Mode == "act" && providerList.ActProvider != nil {
|
||||
providerDisplay = providerList.ActProvider
|
||||
}
|
||||
|
||||
if providerDisplay != nil {
|
||||
bannerInfo.Provider = auth.GetProviderIDForEnum(providerDisplay.Provider)
|
||||
bannerInfo.ModelID = providerDisplay.ModelID
|
||||
}
|
||||
if providerDisplay != nil {
|
||||
bannerInfo.Provider = auth.GetProviderIDForEnum(providerDisplay.Provider)
|
||||
bannerInfo.ModelID = providerDisplay.ModelID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,25 +284,22 @@ func showSessionBanner(ctx context.Context, instanceAddress, modeFlag string, wo
|
||||
// isUserReadyToUse checks if the user has completed initial setup
|
||||
// Returns true if welcomeViewCompleted flag is set OR user is authenticated
|
||||
// Matches extension logic: welcomeViewCompleted = Boolean(globalState.welcomeViewCompleted || user?.uid)
|
||||
func isUserReadyToUse(ctx context.Context, instanceAddress string) bool {
|
||||
manager, err := cli.NewTaskManagerForAddress(ctx, instanceAddress)
|
||||
func isUserReadyToUse(ctx context.Context) bool {
|
||||
grpcClient, err := global.GetClientForAddress(ctx, global.Config.CoreAddress)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Get state
|
||||
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
state, err := grpcClient.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse state JSON
|
||||
stateMap := make(map[string]interface{})
|
||||
if err := json.Unmarshal([]byte(state.StateJson), &stateMap); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check 1: welcomeViewCompleted flag
|
||||
if welcomeCompleted, ok := stateMap["welcomeViewCompleted"].(bool); ok && welcomeCompleted {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestMultiInstanceDefaultUnchanged(t *testing.T) {
|
||||
if len(out1.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out1.CoreInstances))
|
||||
}
|
||||
firstAddr := out1.CoreInstances[0].Address
|
||||
firstAddr := out1.CoreInstances[0].CoreAddress
|
||||
waitForAddressHealthy(t, firstAddr, defaultTimeout)
|
||||
|
||||
// Start second instance
|
||||
@@ -56,29 +56,29 @@ func TestDefaultJsonUpdateAfterRemoval(t *testing.T) {
|
||||
|
||||
// Choose second as new default
|
||||
target := out.CoreInstances[1]
|
||||
waitForAddressHealthy(t, target.Address, defaultTimeout)
|
||||
waitForAddressHealthy(t, target.CoreAddress, defaultTimeout)
|
||||
|
||||
// Set as default
|
||||
_ = mustRunCLI(ctx, t, "instance", "use", target.Address)
|
||||
_ = mustRunCLI(ctx, t, "instance", "use", target.CoreAddress)
|
||||
|
||||
// Verify default switched
|
||||
out = listInstancesJSON(ctx, t)
|
||||
if out.DefaultInstance != target.Address {
|
||||
t.Fatalf("default_instance not updated to %s (got %s)", target.Address, out.DefaultInstance)
|
||||
if out.DefaultInstance != target.CoreAddress {
|
||||
t.Fatalf("default_instance not updated to %s (got %s)", target.CoreAddress, out.DefaultInstance)
|
||||
}
|
||||
|
||||
// Kill the default instance using runtime PID discovery
|
||||
corePID := getCorePID(t, target.Address)
|
||||
corePID := getCorePID(t, target.CoreAddress)
|
||||
if corePID <= 0 {
|
||||
t.Fatalf("could not find PID for core process at %s", target.Address)
|
||||
t.Fatalf("could not find PID for core process at %s", target.CoreAddress)
|
||||
}
|
||||
t.Logf("Killing cline-core process PID %d for instance %s", corePID, target.Address)
|
||||
t.Logf("Killing cline-core process PID %d for instance %s", corePID, target.CoreAddress)
|
||||
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill pid %d: %v", corePID, err)
|
||||
}
|
||||
|
||||
// Wait for removal
|
||||
waitForAddressRemoved(t, target.Address, longTimeout)
|
||||
waitForAddressRemoved(t, target.CoreAddress, longTimeout)
|
||||
|
||||
// Clean up dangling host process (SIGKILL leaves these behind by design)
|
||||
t.Logf("Cleaning up dangling host process on port %d", target.HostPort())
|
||||
@@ -91,7 +91,7 @@ func TestDefaultJsonUpdateAfterRemoval(t *testing.T) {
|
||||
if len(out.CoreInstances) > 0 {
|
||||
found := false
|
||||
for _, it := range out.CoreInstances {
|
||||
if out.DefaultInstance == it.Address {
|
||||
if out.DefaultInstance == it.CoreAddress {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ func listInstancesJSON(ctx context.Context, t *testing.T) common.InstancesOutput
|
||||
|
||||
func hasAddress(in common.InstancesOutput, addr string) bool {
|
||||
for _, it := range in.CoreInstances {
|
||||
if it.Address == addr {
|
||||
if it.CoreAddress == addr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -126,7 +126,7 @@ func hasAddress(in common.InstancesOutput, addr string) bool {
|
||||
|
||||
func getByAddress(in common.InstancesOutput, addr string) (common.CoreInstanceInfo, bool) {
|
||||
for _, it := range in.CoreInstances {
|
||||
if it.Address == addr {
|
||||
if it.CoreAddress == addr {
|
||||
return it, true
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,7 @@ func getCorePIDViaRPC(t *testing.T, address string) int {
|
||||
defer cancel()
|
||||
|
||||
// Get client for the address
|
||||
client, err := global.Clients.GetRegistry().GetClient(ctx, address)
|
||||
client, err := global.Instances.GetRegistry().GetClient(ctx, address)
|
||||
if err != nil {
|
||||
t.Logf("Warning: failed to get client for %s, falling back to lsof: %v", address, err)
|
||||
return getCorePIDViaLsof(t, address)
|
||||
|
||||
@@ -26,7 +26,7 @@ func TestMixedLocalhostVs127Coexist(t *testing.T) {
|
||||
t.Fatalf("expected at least 1 instance")
|
||||
}
|
||||
inst := out.CoreInstances[0]
|
||||
waitForAddressHealthy(t, inst.Address, defaultTimeout)
|
||||
waitForAddressHealthy(t, inst.CoreAddress, defaultTimeout)
|
||||
|
||||
// Manually add a SQLite entry for the same port but 127.0.0.1 host
|
||||
addr127 := fmt.Sprintf("127.0.0.1:%d", inst.CorePort())
|
||||
@@ -37,12 +37,12 @@ func TestMixedLocalhostVs127Coexist(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify both addresses appear and are healthy
|
||||
waitForAddressHealthy(t, inst.Address, defaultTimeout)
|
||||
waitForAddressHealthy(t, inst.CoreAddress, defaultTimeout)
|
||||
waitForAddressHealthy(t, addr127, defaultTimeout)
|
||||
|
||||
out = listInstancesJSON(ctx, t)
|
||||
if !hasAddress(out, inst.Address) || !hasAddress(out, addr127) {
|
||||
t.Fatalf("expected both %s and %s present", inst.Address, addr127)
|
||||
if !hasAddress(out, inst.CoreAddress) || !hasAddress(out, addr127) {
|
||||
t.Fatalf("expected both %s and %s present", inst.CoreAddress, addr127)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestStartStopStress(t *testing.T) {
|
||||
before := listInstancesJSON(ctx, t)
|
||||
beforeSet := map[string]struct{}{}
|
||||
for _, it := range before.CoreInstances {
|
||||
beforeSet[it.Address] = struct{}{}
|
||||
beforeSet[it.CoreAddress] = struct{}{}
|
||||
}
|
||||
|
||||
// Start a new instance
|
||||
@@ -69,8 +69,8 @@ func TestStartStopStress(t *testing.T) {
|
||||
waitFor(t, defaultTimeout, func() (bool, string) {
|
||||
after := listInstancesJSON(ctx, t)
|
||||
for _, it := range after.CoreInstances {
|
||||
if _, ok := beforeSet[it.Address]; !ok {
|
||||
newAddr = it.Address
|
||||
if _, ok := beforeSet[it.CoreAddress]; !ok {
|
||||
newAddr = it.CoreAddress
|
||||
return true, ""
|
||||
}
|
||||
}
|
||||
@@ -88,12 +88,12 @@ func TestStartStopStress(t *testing.T) {
|
||||
}
|
||||
|
||||
// Get PID using runtime discovery
|
||||
corePID := getCorePID(t, info.Address)
|
||||
corePID := getCorePID(t, info.CoreAddress)
|
||||
if corePID <= 0 {
|
||||
t.Fatalf("could not find PID for new instance at %s", info.Address)
|
||||
t.Fatalf("could not find PID for new instance at %s", info.CoreAddress)
|
||||
}
|
||||
|
||||
t.Logf("Killing new instance %s (PID %d) for iteration %d", info.Address, corePID, i)
|
||||
t.Logf("Killing new instance %s (PID %d) for iteration %d", info.CoreAddress, corePID, i)
|
||||
if err := syscall.Kill(corePID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill pid %d: %v", corePID, err)
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func readInstancesFromSQLite(t *testing.T, clineDir string) []common.CoreInstanc
|
||||
|
||||
// Create InstanceInfo
|
||||
info := common.CoreInstanceInfo{
|
||||
Address: heldBy,
|
||||
CoreAddress: heldBy,
|
||||
HostServiceAddress: lockTarget,
|
||||
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN, // Will be updated by health check
|
||||
LastSeen: time.Unix(lockedAt/1000, 0), // Convert from milliseconds
|
||||
|
||||
+14
-14
@@ -29,7 +29,7 @@ func TestStartAndList(t *testing.T) {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
|
||||
}
|
||||
|
||||
addr := out.CoreInstances[0].Address
|
||||
addr := out.CoreInstances[0].CoreAddress
|
||||
t.Logf("Instance address: %s, status: %s", addr, out.CoreInstances[0].Status)
|
||||
|
||||
t.Logf("Waiting for address %s to become healthy...", addr)
|
||||
@@ -44,8 +44,8 @@ func TestStartAndList(t *testing.T) {
|
||||
if out.DefaultInstance == "" {
|
||||
t.Fatalf("default_instance not set")
|
||||
}
|
||||
if out.DefaultInstance != out.CoreInstances[0].Address {
|
||||
t.Fatalf("expected default_instance=%s, got %s", out.CoreInstances[0].Address, out.DefaultInstance)
|
||||
if out.DefaultInstance != out.CoreInstances[0].CoreAddress {
|
||||
t.Fatalf("expected default_instance=%s, got %s", out.CoreInstances[0].CoreAddress, out.DefaultInstance)
|
||||
}
|
||||
|
||||
t.Logf("TestStartAndList completed successfully")
|
||||
@@ -64,7 +64,7 @@ func TestTaskNewDefault(t *testing.T) {
|
||||
if len(out.CoreInstances) != 1 {
|
||||
t.Fatalf("expected 1 instance, got %d", len(out.CoreInstances))
|
||||
}
|
||||
addr := out.CoreInstances[0].Address
|
||||
addr := out.CoreInstances[0].CoreAddress
|
||||
waitForAddressHealthy(t, addr, defaultTimeout)
|
||||
|
||||
// Create a new task at default (success is sufficient)
|
||||
@@ -108,21 +108,21 @@ func TestCrashCleanup(t *testing.T) {
|
||||
|
||||
// Test 1: Graceful shutdown (SIGTERM) - should clean up both processes
|
||||
gracefulTarget := out.CoreInstances[0]
|
||||
waitForAddressHealthy(t, gracefulTarget.Address, defaultTimeout)
|
||||
waitForAddressHealthy(t, gracefulTarget.CoreAddress, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery
|
||||
gracefulPID := getCorePID(t, gracefulTarget.Address)
|
||||
gracefulPID := getCorePID(t, gracefulTarget.CoreAddress)
|
||||
if gracefulPID <= 0 {
|
||||
t.Fatalf("could not find PID for graceful target at %s", gracefulTarget.Address)
|
||||
t.Fatalf("could not find PID for graceful target at %s", gracefulTarget.CoreAddress)
|
||||
}
|
||||
|
||||
t.Logf("Testing graceful shutdown (SIGTERM) for instance %s (PID %d)", gracefulTarget.Address, gracefulPID)
|
||||
t.Logf("Testing graceful shutdown (SIGTERM) for instance %s (PID %d)", gracefulTarget.CoreAddress, gracefulPID)
|
||||
if err := syscall.Kill(gracefulPID, syscall.SIGTERM); err != nil {
|
||||
t.Fatalf("kill SIGTERM pid %d: %v", gracefulPID, err)
|
||||
}
|
||||
|
||||
// Wait for registry cleanup
|
||||
waitForAddressRemoved(t, gracefulTarget.Address, longTimeout)
|
||||
waitForAddressRemoved(t, gracefulTarget.CoreAddress, longTimeout)
|
||||
|
||||
// Verify both core and host ports are freed (no dangling processes)
|
||||
waitForPortsClosed(t, gracefulTarget.CorePort(), gracefulTarget.HostPort(), defaultTimeout)
|
||||
@@ -132,21 +132,21 @@ func TestCrashCleanup(t *testing.T) {
|
||||
|
||||
// Test 2: Crash cleanup (SIGKILL) - creates dangling host process that we must clean up
|
||||
crashTarget := out.CoreInstances[1]
|
||||
waitForAddressHealthy(t, crashTarget.Address, defaultTimeout)
|
||||
waitForAddressHealthy(t, crashTarget.CoreAddress, defaultTimeout)
|
||||
|
||||
// Get PID using runtime discovery
|
||||
crashPID := getCorePID(t, crashTarget.Address)
|
||||
crashPID := getCorePID(t, crashTarget.CoreAddress)
|
||||
if crashPID <= 0 {
|
||||
t.Fatalf("could not find PID for crash target at %s", crashTarget.Address)
|
||||
t.Fatalf("could not find PID for crash target at %s", crashTarget.CoreAddress)
|
||||
}
|
||||
|
||||
t.Logf("Testing crash cleanup (SIGKILL) for instance %s (PID %d)", crashTarget.Address, crashPID)
|
||||
t.Logf("Testing crash cleanup (SIGKILL) for instance %s (PID %d)", crashTarget.CoreAddress, crashPID)
|
||||
if err := syscall.Kill(crashPID, syscall.SIGKILL); err != nil {
|
||||
t.Fatalf("kill SIGKILL pid %d: %v", crashPID, err)
|
||||
}
|
||||
|
||||
// Wait for registry cleanup
|
||||
waitForAddressRemoved(t, crashTarget.Address, longTimeout)
|
||||
waitForAddressRemoved(t, crashTarget.CoreAddress, longTimeout)
|
||||
|
||||
// Verify the instance is removed from SQLite (no file to check anymore)
|
||||
// The waitForAddressRemoved already confirms the instance is gone from the registry
|
||||
|
||||
@@ -298,6 +298,64 @@ cline task view
|
||||
\f[I]# Start interactive chat with this task\f[R]
|
||||
cline task chat
|
||||
.EE
|
||||
.SH ENVIRONMENT
|
||||
.TP
|
||||
\f[B]CLINE_COMMAND_PERMISSIONS\f[R]
|
||||
JSON configuration for restricting which shell commands Cline can
|
||||
execute.
|
||||
When set, commands are validated against allow/deny patterns before
|
||||
execution.
|
||||
When not set, all commands are allowed (backward compatibility).
|
||||
.RS
|
||||
.PP
|
||||
Format:
|
||||
\f[CR]{"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}\f[R]
|
||||
.PP
|
||||
\f[B]Fields:\f[R]
|
||||
.IP \(bu 2
|
||||
\f[B]allow\f[R] (array of strings): Glob patterns for allowed commands.
|
||||
If specified, only matching commands are permitted.
|
||||
Uses \f[CR]*\f[R] to match any characters and \f[CR]?\f[R] to match a
|
||||
single character.
|
||||
.IP \(bu 2
|
||||
\f[B]deny\f[R] (array of strings): Glob patterns for denied commands.
|
||||
Deny rules take precedence over allow rules.
|
||||
.IP \(bu 2
|
||||
\f[B]allowRedirects\f[R] (boolean): Whether to allow shell redirects
|
||||
(\f[CR]>\f[R], \f[CR]>>\f[R], \f[CR]<\f[R], etc.).
|
||||
Defaults to false.
|
||||
.PP
|
||||
\f[B]Rule evaluation:\f[R]
|
||||
.IP "1." 3
|
||||
Check for dangerous characters (backticks outside single quotes,
|
||||
unquoted newlines)
|
||||
.IP "2." 3
|
||||
Parse command into segments split by operators (\f[CR]&&\f[R],
|
||||
\f[CR]||\f[R], \f[CR]|\f[R], \f[CR];\f[R])
|
||||
.IP "3." 3
|
||||
If redirects detected and \f[CR]allowRedirects\f[R] is not true, command
|
||||
is denied
|
||||
.IP "4." 3
|
||||
Each segment is validated against deny rules first, then allow rules
|
||||
.IP "5." 3
|
||||
Subshell contents (\f[CR]$(...)\f[R] and \f[CR](...)\f[R]) are
|
||||
recursively validated
|
||||
.IP "6." 3
|
||||
All segments must pass for the command to be allowed
|
||||
.PP
|
||||
\f[B]Examples:\f[R]
|
||||
.IP
|
||||
.EX
|
||||
\f[I]# Allow only npm and git commands\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{"allow": ["npm *", "git *"]}\(aq
|
||||
|
||||
\f[I]# Allow development commands but deny dangerous ones\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{"allow": ["npm *", "git *", "node *"], "deny": ["rm \-rf *", "sudo *"]}\(aq
|
||||
|
||||
\f[I]# Allow file operations with redirects\f[R]
|
||||
export CLINE_COMMAND_PERMISSIONS=\(aq{"allow": ["cat *", "echo *"], "allowRedirects": true}\(aq
|
||||
.EE
|
||||
.RE
|
||||
.SH ARCHITECTURE
|
||||
Cline operates on a three\-layer architecture:
|
||||
.TP
|
||||
|
||||
@@ -323,6 +323,42 @@ cline task view
|
||||
cline task chat
|
||||
```
|
||||
|
||||
# ENVIRONMENT
|
||||
|
||||
**CLINE_COMMAND_PERMISSIONS**
|
||||
|
||||
: JSON configuration for restricting which shell commands Cline can execute. When set, commands are validated against allow/deny patterns before execution. When not set, all commands are allowed.
|
||||
|
||||
Format: `{"allow": ["pattern1", "pattern2"], "deny": ["pattern3"], "allowRedirects": true}`
|
||||
|
||||
**Fields:**
|
||||
|
||||
- **allow** (array of strings): Glob patterns for allowed commands. If specified, only matching commands are permitted. Uses `*` to match any characters and `?` to match a single character. Setting allow on anything will deny all others.
|
||||
- **deny** (array of strings): Glob patterns for denied commands. Deny rules take precedence over allow rules.
|
||||
- **allowRedirects** (boolean): Whether to allow shell redirects (`>`, `>>`, `<`, etc.). Defaults to false.
|
||||
|
||||
**Rule evaluation:**
|
||||
|
||||
1. Check for dangerous characters (backticks outside single quotes, unquoted newlines)
|
||||
2. Parse command into segments split by operators (`&&`, `||`, `|`, `;`)
|
||||
3. If redirects detected and `allowRedirects` is not true, command is denied
|
||||
4. Each segment is validated against deny rules first, then allow rules
|
||||
5. Subshell contents (`$(...)` and `(...)`) are recursively validated
|
||||
6. All segments must pass for the command to be allowed
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Allow only npm and git commands.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *"]}'
|
||||
|
||||
# Allow development commands but deny dangerous ones. Deny not strictly required here since allow is set.
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["npm *", "git *", "node *"], "deny": ["rm -rf *", "sudo *"]}'
|
||||
|
||||
# Allow file operations with redirects
|
||||
export CLINE_COMMAND_PERMISSIONS='{"allow": ["cat *", "echo *"], "allowRedirects": true}'
|
||||
```
|
||||
|
||||
# ARCHITECTURE
|
||||
|
||||
Cline operates on a three-layer architecture:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cline",
|
||||
"version": "1.0.9",
|
||||
"version": "1.0.10",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"main": "cline-core.js",
|
||||
"bin": {
|
||||
|
||||
@@ -46,21 +46,21 @@ const (
|
||||
// It spawns a fresh instance for auth operations and cleans it up when done
|
||||
func RunAuthFlow(ctx context.Context, args []string) error {
|
||||
// Spawn a fresh instance for auth operations
|
||||
instanceInfo, err := global.Clients.StartNewInstance(ctx)
|
||||
instanceInfo, err := global.Instances.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start auth instance: %w", err)
|
||||
}
|
||||
|
||||
// Cleanup when done (success, error, or panic)
|
||||
defer func() {
|
||||
verboseLog("Shutting down auth instance at %s", instanceInfo.Address)
|
||||
if err := global.KillInstanceByAddress(context.Background(), global.Clients.GetRegistry(), instanceInfo.Address); err != nil {
|
||||
verboseLog("Shutting down auth instance at %s", instanceInfo.CoreAddress)
|
||||
if err := global.KillInstanceByAddress(context.Background(), global.Instances.GetRegistry(), instanceInfo.CoreAddress); err != nil {
|
||||
verboseLog("Warning: Failed to kill auth instance: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Store instance address in context for all auth handlers to use
|
||||
authCtx := context.WithValue(ctx, authInstanceAddressKey, instanceInfo.Address)
|
||||
authCtx := context.WithValue(ctx, authInstanceAddressKey, instanceInfo.CoreAddress)
|
||||
|
||||
// Route to existing auth flow
|
||||
return HandleAuthCommand(authCtx, args)
|
||||
@@ -108,12 +108,10 @@ func HandleAuthMenuNoArgs(ctx context.Context) error {
|
||||
// Get current provider config for display
|
||||
var currentProvider string
|
||||
var currentModel string
|
||||
if manager, err := createTaskManager(ctx); err == nil {
|
||||
if providerList, err := GetProviderConfigurations(ctx, manager); err == nil {
|
||||
if providerList.ActProvider != nil {
|
||||
currentProvider = GetProviderDisplayName(providerList.ActProvider.Provider)
|
||||
currentModel = providerList.ActProvider.ModelID
|
||||
}
|
||||
if providerList, err := GetProviderConfigurations(ctx); err == nil {
|
||||
if providerList.ActProvider != nil {
|
||||
currentProvider = GetProviderDisplayName(providerList.ActProvider.Provider)
|
||||
currentModel = providerList.ActProvider.ModelID
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,13 +29,17 @@ type ProviderListResult struct {
|
||||
}
|
||||
|
||||
// GetProviderConfigurations retrieves and parses provider configurations from Cline Core state
|
||||
func GetProviderConfigurations(ctx context.Context, manager *task.Manager) (*ProviderListResult, error) {
|
||||
func GetProviderConfigurations(ctx context.Context) (*ProviderListResult, error) {
|
||||
if global.Config.Verbose {
|
||||
fmt.Println("[DEBUG] Retrieving provider configurations from Cline Core")
|
||||
}
|
||||
|
||||
// Get latest state from Cline Core
|
||||
state, err := manager.GetClient().State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
grpcClient, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get provider configs due to unable to get gRPC client: %w", err)
|
||||
}
|
||||
state, err := grpcClient.State.GetLatestState(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get state: %w", err)
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ func (pw *ProviderWizard) handleAddOcaProvider() error {
|
||||
|
||||
// handleListProviders retrieves and displays configured providers
|
||||
func (pw *ProviderWizard) handleListProviders() error {
|
||||
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
|
||||
result, err := GetProviderConfigurations(pw.ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
|
||||
}
|
||||
@@ -413,7 +413,7 @@ func (pw *ProviderWizard) manualModelEntry(provider cline.ApiProvider) (string,
|
||||
// handleChangeModel allows changing the model for any configured provider
|
||||
func (pw *ProviderWizard) handleChangeModel() error {
|
||||
// Step 1: Get current provider configurations
|
||||
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
|
||||
result, err := GetProviderConfigurations(pw.ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
|
||||
}
|
||||
@@ -585,11 +585,11 @@ func getProviderModelIDFromState(stateData map[string]interface{}, provider clin
|
||||
return ""
|
||||
}
|
||||
|
||||
// getProviderAPIKeyFromState retrieves the API key for a specific provider from state
|
||||
// getProviderAPIKeyFromState retrieves the API key for a specific provider from state
|
||||
func getProviderAPIKeyFromState(stateData map[string]interface{}, provider cline.ApiProvider) string {
|
||||
// OCA uses account authentication, not API keys. Consider it "present" if authenticated.
|
||||
if provider == cline.ApiProvider_OCA {
|
||||
if state, _ := GetLatestOCAState(context.TODO(), 2 * time.Second); state != nil && state.User != nil {
|
||||
if state, _ := GetLatestOCAState(context.TODO(), 2*time.Second); state != nil && state.User != nil {
|
||||
// Return a sentinel non-empty string so upstream checks pass.
|
||||
return "OCA_AUTH_VERIFIED"
|
||||
}
|
||||
@@ -648,7 +648,7 @@ func convertMapToOpenRouterModelInfo(data map[string]interface{}) *cline.OpenRou
|
||||
// handleRemoveProvider allows removing a configured provider by clearing its API key
|
||||
func (pw *ProviderWizard) handleRemoveProvider() error {
|
||||
// Step 1: Get current provider configurations
|
||||
result, err := GetProviderConfigurations(pw.ctx, pw.manager)
|
||||
result, err := GetProviderConfigurations(pw.ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to retrieve provider configurations: %w", err)
|
||||
}
|
||||
@@ -748,7 +748,6 @@ func (pw *ProviderWizard) clearProviderAPIKey(provider cline.ApiProvider) error
|
||||
return RemoveProviderPartial(pw.ctx, pw.manager, provider)
|
||||
}
|
||||
|
||||
|
||||
func signOutOca(ctx context.Context) error {
|
||||
client, err := global.GetDefaultClient(ctx)
|
||||
if err != nil {
|
||||
|
||||
@@ -40,7 +40,7 @@ func ensureConfigManager(ctx context.Context, address string) error {
|
||||
}
|
||||
|
||||
// Always set the instance we're using as the default
|
||||
registry := global.Clients.GetRegistry()
|
||||
registry := global.Instances.GetRegistry()
|
||||
if err := registry.SetDefaultInstance(instanceAddress); err != nil {
|
||||
// Log warning but don't fail - this is not critical
|
||||
fmt.Printf("Warning: failed to set default instance: %v\n", err)
|
||||
@@ -123,11 +123,11 @@ func setCommand() *cobra.Command {
|
||||
Use: "set <key=value> [key=value...]",
|
||||
Aliases: []string{"s"},
|
||||
Short: "Set configuration variables",
|
||||
Long: `Set one or more global configuration variables using key=value format.
|
||||
Long: `Set one or more global configuration variables using key=value format.
|
||||
|
||||
This command merges the provided settings with existing values, preserving
|
||||
unspecified fields. Only the fields you explicitly set will be updated.`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ func NewManager(ctx context.Context, address string) (*Manager, error) {
|
||||
|
||||
// Get the actual address being used
|
||||
clientAddress := address
|
||||
if address == "" && global.Clients != nil {
|
||||
clientAddress = global.Clients.GetRegistry().GetDefaultInstance()
|
||||
if address == "" && global.Instances != nil {
|
||||
clientAddress = global.Instances.GetRegistry().GetDefaultInstance()
|
||||
}
|
||||
|
||||
return &Manager{
|
||||
@@ -110,7 +110,6 @@ func (m *Manager) ListSettings(ctx context.Context) error {
|
||||
"dictationSettings",
|
||||
"autoCondenseThreshold",
|
||||
"autoApprovalSettings",
|
||||
"hooksEnabled",
|
||||
}
|
||||
|
||||
// Render each field using the renderer
|
||||
|
||||
@@ -79,7 +79,7 @@ func RenderField(key string, value interface{}, censor bool) error {
|
||||
"planActSeparateModelsSetting", "enableCheckpointsSetting",
|
||||
"terminalReuseEnabled", "mcpResponsesCollapsed", "strictPlanModeEnabled",
|
||||
"useAutoCondense", "yoloModeToggled", "shellIntegrationTimeout",
|
||||
"terminalOutputLineLimit", "autoCondenseThreshold", "hooksEnabled":
|
||||
"terminalOutputLineLimit", "autoCondenseThreshold":
|
||||
fmt.Printf("%s: %s\n", camelToKebab(key), formatValue(value, key, censor))
|
||||
return nil
|
||||
|
||||
|
||||
@@ -14,21 +14,20 @@ import (
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
// ClineClients manages Cline instances using the new registry system
|
||||
type ClineClients struct {
|
||||
registry *ClientRegistry
|
||||
type ClineInstances struct {
|
||||
registry *InstanceRegistry
|
||||
}
|
||||
|
||||
// NewClineClients creates a new ClineClients instance
|
||||
func NewClineClients(configPath string) *ClineClients {
|
||||
registry := NewClientRegistry(configPath)
|
||||
return &ClineClients{
|
||||
// NewClineInstances creates a new ClineInstances instance
|
||||
func NewClineInstances(configPath string) *ClineInstances {
|
||||
registry := NewInstanceRegistry(configPath)
|
||||
return &ClineInstances{
|
||||
registry: registry,
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize performs cleanup of stale instances
|
||||
func (c *ClineClients) Initialize(ctx context.Context) error {
|
||||
func (c *ClineInstances) Initialize(ctx context.Context) error {
|
||||
// Clean up stale entries (direct SQLite operations)
|
||||
_ = c.registry.CleanupStaleInstances(ctx)
|
||||
|
||||
@@ -36,7 +35,8 @@ func (c *ClineClients) Initialize(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// StartNewInstance starts a new Cline instance and waits for cline-core to self-register
|
||||
func (c *ClineClients) StartNewInstance(ctx context.Context, workspaces ...string) (*common.CoreInstanceInfo, error) {
|
||||
// An "instance" is a pair of cline-core and cline-host processes
|
||||
func (c *ClineInstances) StartNewInstance(ctx context.Context, workspaces ...string) (*common.CoreInstanceInfo, error) {
|
||||
// Find available ports
|
||||
corePort, hostPort, err := common.FindAvailablePortPair()
|
||||
if err != nil {
|
||||
@@ -102,7 +102,7 @@ func (c *ClineClients) StartNewInstance(ctx context.Context, workspaces ...strin
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Println("Services started and registered successfully!")
|
||||
fmt.Printf(" Address: %s\n", instance.Address)
|
||||
fmt.Printf(" Address: %s\n", instance.CoreAddress)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
|
||||
@@ -120,7 +120,7 @@ func (c *ClineClients) StartNewInstance(ctx context.Context, workspaces ...strin
|
||||
}
|
||||
|
||||
// StartNewInstanceAtPort starts a new Cline instance at the specified port and waits for self-registration
|
||||
func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int, workspaces ...string) (*common.CoreInstanceInfo, error) {
|
||||
func (c *ClineInstances) StartNewInstanceAtPort(ctx context.Context, corePort int, workspaces ...string) (*common.CoreInstanceInfo, error) {
|
||||
// Find available host port (core port + 1000)
|
||||
hostPort := corePort + 1000
|
||||
coreAddress := fmt.Sprintf("localhost:%d", corePort)
|
||||
@@ -189,7 +189,7 @@ func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int,
|
||||
|
||||
if Config.Verbose {
|
||||
fmt.Println("Services started and registered successfully!")
|
||||
fmt.Printf(" Address: %s\n", instance.Address)
|
||||
fmt.Printf(" Address: %s\n", instance.CoreAddress)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
fmt.Printf(" Process PID: %d\n", coreCmd.Process.Pid)
|
||||
@@ -207,12 +207,12 @@ func (c *ClineClients) StartNewInstanceAtPort(ctx context.Context, corePort int,
|
||||
}
|
||||
|
||||
// GetRegistry returns the client registry
|
||||
func (c *ClineClients) GetRegistry() *ClientRegistry {
|
||||
func (c *ClineInstances) GetRegistry() *InstanceRegistry {
|
||||
return c.registry
|
||||
}
|
||||
|
||||
// EnsureInstanceAtAddress ensures an instance exists at the given address, starting one if needed
|
||||
func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
func (c *ClineInstances) EnsureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
// Expect host:port everywhere
|
||||
normalized := address
|
||||
if normalized == "" {
|
||||
@@ -230,7 +230,7 @@ func (c *ClineClients) EnsureInstanceAtAddress(ctx context.Context, address stri
|
||||
return fmt.Errorf("invalid address format %s", address)
|
||||
}
|
||||
|
||||
// Use IPv6-compatible localhost detection
|
||||
// Use IPv6-compatible localhost detection
|
||||
if common.IsLocalAddress(host) {
|
||||
_, err := c.StartNewInstanceAtPort(ctx, port)
|
||||
if err != nil {
|
||||
@@ -305,7 +305,7 @@ func startClineHost(hostPort int, workspaces []string) (*exec.Cmd, error) {
|
||||
}
|
||||
|
||||
// KillInstanceByAddress kills a Cline instance by its address
|
||||
func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, address string) error {
|
||||
func KillInstanceByAddress(ctx context.Context, registry *InstanceRegistry, address string) error {
|
||||
// Check if the instance exists in the registry
|
||||
_, err := registry.GetInstance(address)
|
||||
if err != nil {
|
||||
@@ -355,9 +355,9 @@ func KillInstanceByAddress(ctx context.Context, registry *ClientRegistry, addres
|
||||
defaultInstance := registry.GetDefaultInstance()
|
||||
if defaultInstance == address || defaultInstance == "" {
|
||||
if len(instances) > 0 {
|
||||
if err := registry.SetDefaultInstance(instances[0].Address); err == nil {
|
||||
if err := registry.SetDefaultInstance(instances[0].CoreAddress); err == nil {
|
||||
if Config.Verbose {
|
||||
fmt.Printf("Updated default instance to: %s\n", instances[0].Address)
|
||||
fmt.Printf("Updated default instance to: %s\n", instances[0].CoreAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,8 @@ type GlobalConfig struct {
|
||||
}
|
||||
|
||||
var (
|
||||
Config *GlobalConfig
|
||||
Clients *ClineClients
|
||||
Config *GlobalConfig
|
||||
Instances *ClineInstances
|
||||
|
||||
// Version info - set at build time via ldflags
|
||||
// Version is the Cline Core version (from root package.json)
|
||||
@@ -61,11 +61,11 @@ func InitializeGlobalConfig(cfg *GlobalConfig) error {
|
||||
// Otherwise lipgloss auto-detects terminal capabilities (default behavior)
|
||||
|
||||
Config = cfg
|
||||
Clients = NewClineClients(cfg.ConfigPath)
|
||||
Instances = NewClineInstances(cfg.ConfigPath)
|
||||
|
||||
// Initialize the clients registry
|
||||
ctx := context.Background()
|
||||
if err := Clients.Initialize(ctx); err != nil {
|
||||
if err := Instances.Initialize(ctx); err != nil {
|
||||
return fmt.Errorf("failed to initialize clients: %w", err)
|
||||
}
|
||||
|
||||
@@ -76,25 +76,25 @@ func InitializeGlobalConfig(cfg *GlobalConfig) error {
|
||||
func GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
|
||||
if Config.CoreAddress != "" && Config.CoreAddress != fmt.Sprintf("localhost:%d", common.DEFAULT_CLINE_CORE_PORT) {
|
||||
// User specified a specific address, use that
|
||||
return Clients.GetRegistry().GetClient(ctx, Config.CoreAddress)
|
||||
return Instances.GetRegistry().GetClient(ctx, Config.CoreAddress)
|
||||
}
|
||||
|
||||
// Use the default instance from registry
|
||||
return Clients.GetRegistry().GetDefaultClient(ctx)
|
||||
return Instances.GetRegistry().GetDefaultClient(ctx)
|
||||
}
|
||||
|
||||
// GetClientForAddress returns a client for a specific address
|
||||
func GetClientForAddress(ctx context.Context, address string) (*client.ClineClient, error) {
|
||||
return Clients.GetRegistry().GetClient(ctx, address)
|
||||
return Instances.GetRegistry().GetClient(ctx, address)
|
||||
}
|
||||
|
||||
// EnsureDefaultInstance ensures a default instance exists
|
||||
func EnsureDefaultInstance(ctx context.Context) error {
|
||||
if Clients == nil {
|
||||
if Instances == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
|
||||
registry := Clients.GetRegistry()
|
||||
registry := Instances.GetRegistry()
|
||||
|
||||
// First, check if there are any instances already registered in SQLite
|
||||
instances := registry.ListInstances()
|
||||
@@ -108,11 +108,12 @@ func EnsureDefaultInstance(ctx context.Context) error {
|
||||
if registry.GetDefaultInstance() == "" {
|
||||
// No instances exist, start a new one
|
||||
// Note: StartNewInstance will automatically set it as default since it's the first instance
|
||||
_, err := Clients.StartNewInstance(ctx)
|
||||
_, err := Instances.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start new default instance: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,28 +18,28 @@ import (
|
||||
"google.golang.org/grpc/health/grpc_health_v1"
|
||||
)
|
||||
|
||||
// ClientRegistry manages Cline client connections using direct SQLite operations
|
||||
type ClientRegistry struct {
|
||||
// InstanceRegistry manages Cline client connections using direct SQLite operations
|
||||
type InstanceRegistry struct {
|
||||
lockManager *sqlite.LockManager
|
||||
configPath string
|
||||
}
|
||||
|
||||
// NewClientRegistry creates a new client registry
|
||||
func NewClientRegistry(configPath string) *ClientRegistry {
|
||||
// NewInstanceRegistry creates a new instance registry
|
||||
func NewInstanceRegistry(configPath string) *InstanceRegistry {
|
||||
lockManager, err := sqlite.NewLockManager(configPath)
|
||||
if err != nil {
|
||||
// Log error but continue - we can still function without SQLite
|
||||
log.Fatalf("Warning: Failed to initialize SQLite lock manager: %v\n", err)
|
||||
}
|
||||
|
||||
return &ClientRegistry{
|
||||
return &InstanceRegistry{
|
||||
lockManager: lockManager,
|
||||
configPath: configPath,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultInstance returns the default instance address from settings file
|
||||
func (r *ClientRegistry) GetDefaultInstance() string {
|
||||
func (r *InstanceRegistry) GetDefaultInstance() string {
|
||||
defaultAddr, err := sqlite.GetDefaultInstance(r.configPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
@@ -48,7 +48,7 @@ func (r *ClientRegistry) GetDefaultInstance() string {
|
||||
}
|
||||
|
||||
// SetDefaultInstance sets the default instance (writes default.json)
|
||||
func (r *ClientRegistry) SetDefaultInstance(address string) error {
|
||||
func (r *InstanceRegistry) SetDefaultInstance(address string) error {
|
||||
// Verify the instance exists in SQLite
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
@@ -64,7 +64,7 @@ func (r *ClientRegistry) SetDefaultInstance(address string) error {
|
||||
}
|
||||
|
||||
// GetInstance returns instance information directly from SQLite
|
||||
func (r *ClientRegistry) GetInstance(address string) (*common.CoreInstanceInfo, error) {
|
||||
func (r *InstanceRegistry) GetInstance(address string) (*common.CoreInstanceInfo, error) {
|
||||
if r.lockManager == nil {
|
||||
return nil, fmt.Errorf("lock manager not available")
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func (r *ClientRegistry) GetInstance(address string) (*common.CoreInstanceInfo,
|
||||
}
|
||||
|
||||
// GetClient returns a connected client for the given address (created on-demand)
|
||||
func (r *ClientRegistry) GetClient(ctx context.Context, address string) (*client.ClineClient, error) {
|
||||
func (r *InstanceRegistry) GetClient(ctx context.Context, address string) (*client.ClineClient, error) {
|
||||
// Verify instance exists in SQLite
|
||||
if r.lockManager != nil {
|
||||
exists, err := r.lockManager.HasInstanceAtAddress(address)
|
||||
@@ -104,7 +104,7 @@ func (r *ClientRegistry) GetClient(ctx context.Context, address string) (*client
|
||||
}
|
||||
|
||||
// GetDefaultClient returns a client for the default instance
|
||||
func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
|
||||
func (r *InstanceRegistry) GetDefaultClient(ctx context.Context) (*client.ClineClient, error) {
|
||||
defaultAddr := r.GetDefaultInstance()
|
||||
if defaultAddr == "" {
|
||||
return nil, fmt.Errorf("no default instance configured")
|
||||
@@ -117,7 +117,7 @@ func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineCli
|
||||
// Database is unavailable - Return error instead of attempting cleanup
|
||||
return nil, fmt.Errorf("cannot verify default instance: database unavailable: %w", err)
|
||||
}
|
||||
|
||||
|
||||
if !exists {
|
||||
// Instance doesn't exist in database but config file references it
|
||||
// This is a stale config - remove it and try to find another instance
|
||||
@@ -127,14 +127,14 @@ func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineCli
|
||||
} else {
|
||||
fmt.Printf("Removed stale default instance config (instance %s not found in database)\n", defaultAddr)
|
||||
}
|
||||
|
||||
|
||||
// Try to find and set a new default instance
|
||||
instances := r.ListInstances()
|
||||
if len(instances) > 0 {
|
||||
if err := r.EnsureDefaultInstance(instances); err != nil {
|
||||
return nil, fmt.Errorf("failed to set new default instance: %w", err)
|
||||
}
|
||||
|
||||
|
||||
// Retry with the new default
|
||||
newDefaultAddr := r.GetDefaultInstance()
|
||||
if newDefaultAddr != "" {
|
||||
@@ -142,7 +142,7 @@ func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineCli
|
||||
return r.GetClient(ctx, newDefaultAddr)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return nil, fmt.Errorf("no default instance configured")
|
||||
}
|
||||
}
|
||||
@@ -151,7 +151,7 @@ func (r *ClientRegistry) GetDefaultClient(ctx context.Context) (*client.ClineCli
|
||||
}
|
||||
|
||||
// ListInstances returns all registered instances directly from SQLite
|
||||
func (r *ClientRegistry) ListInstances() []*common.CoreInstanceInfo {
|
||||
func (r *InstanceRegistry) ListInstances() []*common.CoreInstanceInfo {
|
||||
if r.lockManager == nil {
|
||||
return []*common.CoreInstanceInfo{}
|
||||
}
|
||||
@@ -170,7 +170,7 @@ func (r *ClientRegistry) ListInstances() []*common.CoreInstanceInfo {
|
||||
}
|
||||
|
||||
// HasInstanceAtAddress checks if an instance exists at the given address (delegates to SQLite)
|
||||
func (r *ClientRegistry) HasInstanceAtAddress(address string) bool {
|
||||
func (r *InstanceRegistry) HasInstanceAtAddress(address string) bool {
|
||||
if r.lockManager == nil {
|
||||
return false
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func (r *ClientRegistry) HasInstanceAtAddress(address string) bool {
|
||||
}
|
||||
|
||||
// CleanupStaleInstances removes stale instances using direct SQLite operations
|
||||
func (r *ClientRegistry) CleanupStaleInstances(ctx context.Context) error {
|
||||
func (r *InstanceRegistry) CleanupStaleInstances(ctx context.Context) error {
|
||||
if r.lockManager == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -202,15 +202,15 @@ func (r *ClientRegistry) CleanupStaleInstances(ctx context.Context) error {
|
||||
// Try to gracefully shutdown the paired host process before cleanup
|
||||
|
||||
fmt.Printf("Attempting to shutdown dangling host service %s for stale cline core instance %s\n",
|
||||
instance.HostServiceAddress, instance.Address)
|
||||
instance.HostServiceAddress, instance.CoreAddress)
|
||||
r.tryShutdownHostProcess(instance.HostServiceAddress)
|
||||
|
||||
// Remove from SQLite database
|
||||
if err := r.lockManager.RemoveInstanceLock(instance.Address); err != nil {
|
||||
return fmt.Errorf("failed to remove stale instance %s: %w", instance.Address, err)
|
||||
if err := r.lockManager.RemoveInstanceLock(instance.CoreAddress); err != nil {
|
||||
return fmt.Errorf("failed to remove stale instance %s: %w", instance.CoreAddress, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Removed stale instance: %s\n", instance.Address)
|
||||
fmt.Printf("Removed stale instance: %s\n", instance.CoreAddress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,8 +218,7 @@ func (r *ClientRegistry) CleanupStaleInstances(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// tryShutdownHostProcess attempts to gracefully shutdown a host process via RPC
|
||||
// Best effort, don't throw errors i guess
|
||||
func (r *ClientRegistry) tryShutdownHostProcess(hostServiceAddress string) {
|
||||
func (r *InstanceRegistry) tryShutdownHostProcess(hostServiceAddress string) {
|
||||
err := common.RetryOperation(3, 2*time.Second, func() error {
|
||||
// Create context with timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
@@ -252,7 +251,7 @@ func (r *ClientRegistry) tryShutdownHostProcess(hostServiceAddress string) {
|
||||
}
|
||||
|
||||
// ListInstancesCleaned performs cleanup and returns instances with health checks
|
||||
func (r *ClientRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
|
||||
func (r *InstanceRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.CoreInstanceInfo, error) {
|
||||
// 1. Clean up stale entries (best-effort)
|
||||
_ = r.CleanupStaleInstances(ctx)
|
||||
|
||||
@@ -268,7 +267,7 @@ func (r *ClientRegistry) ListInstancesCleaned(ctx context.Context) ([]*common.Co
|
||||
}
|
||||
|
||||
// EnsureDefaultInstance ensures a default instance is set if instances exist but no default is configured
|
||||
func (r *ClientRegistry) EnsureDefaultInstance(instances []*common.CoreInstanceInfo) error {
|
||||
func (r *InstanceRegistry) EnsureDefaultInstance(instances []*common.CoreInstanceInfo) error {
|
||||
currentDefault := r.GetDefaultInstance()
|
||||
|
||||
// If we have no instances, clear any stale default and remove settings file
|
||||
@@ -283,13 +282,13 @@ func (r *ClientRegistry) EnsureDefaultInstance(instances []*common.CoreInstanceI
|
||||
|
||||
// If we have instances but no default, pick the first one
|
||||
if currentDefault == "" {
|
||||
return sqlite.SetDefaultInstance(r.configPath, instances[0].Address)
|
||||
return sqlite.SetDefaultInstance(r.configPath, instances[0].CoreAddress)
|
||||
}
|
||||
|
||||
// Validate current default still exists in the instances
|
||||
defaultExists := false
|
||||
for _, instance := range instances {
|
||||
if instance.Address == currentDefault {
|
||||
if instance.CoreAddress == currentDefault {
|
||||
defaultExists = true
|
||||
break
|
||||
}
|
||||
@@ -297,7 +296,7 @@ func (r *ClientRegistry) EnsureDefaultInstance(instances []*common.CoreInstanceI
|
||||
|
||||
if !defaultExists {
|
||||
// Current default doesn't exist, pick a new one from available instances
|
||||
return sqlite.SetDefaultInstance(r.configPath, instances[0].Address)
|
||||
return sqlite.SetDefaultInstance(r.configPath, instances[0].CoreAddress)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
+25
-25
@@ -92,12 +92,12 @@ func newInstanceKillCommand() *cobra.Command {
|
||||
return nil
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Clients == nil {
|
||||
if global.Instances == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
registry := global.Clients.GetRegistry()
|
||||
registry := global.Instances.GetRegistry()
|
||||
|
||||
if killAllCLI {
|
||||
return killAllCLIInstances(ctx, registry)
|
||||
@@ -112,7 +112,7 @@ func newInstanceKillCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) error {
|
||||
func killAllCLIInstances(ctx context.Context, registry *global.InstanceRegistry) error {
|
||||
// Get all instances from registry
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
if err != nil {
|
||||
@@ -135,7 +135,7 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
|
||||
cliInstances = append(cliInstances, instance)
|
||||
} else {
|
||||
skippedNonCLI++
|
||||
fmt.Printf("⊘ Skipping %s instance: %s\n", platform, instance.Address)
|
||||
fmt.Printf("⊘ Skipping %s instance: %s\n", platform, instance.CoreAddress)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,16 +160,16 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
|
||||
|
||||
// Kill all CLI instances
|
||||
for _, instance := range cliInstances {
|
||||
result := killInstanceProcess(ctx, registry, instance.Address)
|
||||
result := killInstanceProcess(ctx, registry, instance.CoreAddress)
|
||||
killResults = append(killResults, result)
|
||||
|
||||
if result.err != nil {
|
||||
fmt.Printf("✗ Failed to kill %s: %v\n", instance.Address, result.err)
|
||||
fmt.Printf("✗ Failed to kill %s: %v\n", instance.CoreAddress, result.err)
|
||||
} else if result.alreadyDead {
|
||||
fmt.Printf("⚠ Instance %s appears to be already dead\n", instance.Address)
|
||||
fmt.Printf("⚠ Instance %s appears to be already dead\n", instance.CoreAddress)
|
||||
} else {
|
||||
fmt.Printf("✓ Killed %s (PID %d)\n", instance.Address, result.pid)
|
||||
killedAddresses[instance.Address] = true
|
||||
fmt.Printf("✓ Killed %s (PID %d)\n", instance.CoreAddress, result.pid)
|
||||
killedAddresses[instance.CoreAddress] = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,8 +190,8 @@ func killAllCLIInstances(ctx context.Context, registry *global.ClientRegistry) e
|
||||
// Check if any of the killed instances are still in the registry
|
||||
stillPresent := []string{}
|
||||
for _, remaining := range remainingInstances {
|
||||
if killedAddresses[remaining.Address] {
|
||||
stillPresent = append(stillPresent, remaining.Address)
|
||||
if killedAddresses[remaining.CoreAddress] {
|
||||
stillPresent = append(stillPresent, remaining.CoreAddress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +247,7 @@ type killResult struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func killInstanceProcess(ctx context.Context, registry *global.ClientRegistry, address string) killResult {
|
||||
func killInstanceProcess(ctx context.Context, registry *global.InstanceRegistry, address string) killResult {
|
||||
// Get gRPC client and process info
|
||||
client, err := registry.GetClient(ctx, address)
|
||||
if err != nil {
|
||||
@@ -276,12 +276,12 @@ func newInstanceListCommand() *cobra.Command {
|
||||
Short: "List all registered Cline instances",
|
||||
Long: `List all registered Cline instances with their status and connection details.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if global.Clients == nil {
|
||||
if global.Instances == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
ctx := cmd.Context()
|
||||
registry := global.Clients.GetRegistry()
|
||||
registry := global.Instances.GetRegistry()
|
||||
|
||||
// Load, cleanup stale local entries, and update health
|
||||
instances, err := registry.ListInstancesCleaned(ctx)
|
||||
@@ -310,7 +310,7 @@ func newInstanceListCommand() *cobra.Command {
|
||||
var rows []instanceRow
|
||||
for _, instance := range instances {
|
||||
isDefault := ""
|
||||
if instance.Address == defaultInstance {
|
||||
if instance.CoreAddress == defaultInstance {
|
||||
isDefault = "✓"
|
||||
}
|
||||
|
||||
@@ -324,7 +324,7 @@ func newInstanceListCommand() *cobra.Command {
|
||||
platform := platformNA
|
||||
if instance.Status == grpc_health_v1.HealthCheckResponse_SERVING {
|
||||
// Get PID from core
|
||||
if client, err := registry.GetClient(ctx, instance.Address); err == nil {
|
||||
if client, err := registry.GetClient(ctx, instance.CoreAddress); err == nil {
|
||||
if processInfo, err := client.State.GetProcessInfo(ctx, &cline.EmptyRequest{}); err == nil {
|
||||
pid = fmt.Sprintf("%d", processInfo.ProcessId)
|
||||
// Update version from RPC if available
|
||||
@@ -341,7 +341,7 @@ func newInstanceListCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
rows = append(rows, instanceRow{
|
||||
address: instance.Address,
|
||||
address: instance.CoreAddress,
|
||||
status: instance.Status.String(),
|
||||
version: instance.Version,
|
||||
lastSeen: lastSeen,
|
||||
@@ -428,11 +428,11 @@ func newInstanceDefaultCommand() *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
address := args[0]
|
||||
|
||||
if global.Clients == nil {
|
||||
if global.Instances == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
registry := global.Clients.GetRegistry()
|
||||
registry := global.Instances.GetRegistry()
|
||||
|
||||
// Verify the instance exists
|
||||
_, err := registry.GetInstance(address)
|
||||
@@ -464,34 +464,34 @@ func newInstanceNewCommand() *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
if global.Clients == nil {
|
||||
if global.Instances == nil {
|
||||
return fmt.Errorf("clients not initialized")
|
||||
}
|
||||
|
||||
fmt.Println("Starting new Cline instance...")
|
||||
|
||||
instance, err := global.Clients.StartNewInstance(ctx)
|
||||
instance, err := global.Instances.StartNewInstance(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start instance: %w", err)
|
||||
}
|
||||
|
||||
fmt.Printf("Successfully started new instance:\n")
|
||||
fmt.Printf(" Address: %s\n", instance.Address)
|
||||
fmt.Printf(" Address: %s\n", instance.CoreAddress)
|
||||
fmt.Printf(" Core Port: %d\n", instance.CorePort())
|
||||
fmt.Printf(" Host Bridge Port: %d\n", instance.HostPort())
|
||||
|
||||
registry := global.Clients.GetRegistry()
|
||||
registry := global.Instances.GetRegistry()
|
||||
|
||||
// If --default flag provided, set this instance as the default
|
||||
if setDefault {
|
||||
if err := registry.SetDefaultInstance(instance.Address); err != nil {
|
||||
if err := registry.SetDefaultInstance(instance.CoreAddress); err != nil {
|
||||
fmt.Printf("Warning: Failed to set as default: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf(" Status: Set as default instance\n")
|
||||
}
|
||||
} else {
|
||||
// Otherwise, check if EnsureDefaultInstance already set it as default
|
||||
if registry.GetDefaultInstance() == instance.Address {
|
||||
if registry.GetDefaultInstance() == instance.CoreAddress {
|
||||
fmt.Printf(" Status: Default instance\n")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ func (m *InputModel) handleSubmit() (tea.Model, tea.Cmd) {
|
||||
return ChangeInputTypeMsg{
|
||||
InputType: InputTypeFeedback,
|
||||
Title: "Your feedback",
|
||||
Placeholder: "/plan or /act to switch modes\nctrl+e to open editor",
|
||||
Placeholder: "/plan or /act to switch modes\nctrl+e to open editor\nctrl+c to exit",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -547,3 +547,74 @@ func (m *InputModel) openEditor() tea.Cmd {
|
||||
func (m *InputModel) SetSlashRegistry(registry *slash.Registry) {
|
||||
m.completion.SetRegistry(registry)
|
||||
}
|
||||
|
||||
// initialPromptWrapper wraps InputModel to capture the submit result for initial task prompts
|
||||
type initialPromptWrapper struct {
|
||||
model *InputModel
|
||||
result string
|
||||
cancelled bool
|
||||
}
|
||||
|
||||
func (w *initialPromptWrapper) Init() tea.Cmd {
|
||||
return w.model.Init()
|
||||
}
|
||||
|
||||
func (w *initialPromptWrapper) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case InputSubmitMsg:
|
||||
w.result = msg.Value
|
||||
clearCodes := w.model.ClearScreen()
|
||||
if clearCodes != "" {
|
||||
fmt.Print(clearCodes)
|
||||
}
|
||||
return w, tea.Quit
|
||||
|
||||
case InputCancelMsg:
|
||||
w.cancelled = true
|
||||
clearCodes := w.model.ClearScreen()
|
||||
if clearCodes != "" {
|
||||
fmt.Print(clearCodes)
|
||||
}
|
||||
return w, tea.Quit
|
||||
}
|
||||
|
||||
// Forward to wrapped model
|
||||
_, cmd := w.model.Update(msg)
|
||||
return w, cmd
|
||||
}
|
||||
|
||||
func (w *initialPromptWrapper) View() string {
|
||||
return w.model.View()
|
||||
}
|
||||
|
||||
// ErrUserAborted is returned when the user cancels the input prompt
|
||||
var ErrUserAborted = fmt.Errorf("user aborted")
|
||||
|
||||
// PromptForInitialTask displays an interactive prompt for the initial task with slash command autocomplete.
|
||||
// Returns the entered text, or ErrUserAborted if cancelled.
|
||||
func PromptForInitialTask(title, placeholder, mode string, registry *slash.Registry) (string, error) {
|
||||
model := NewInputModelWithRegistry(
|
||||
InputTypeMessage,
|
||||
title,
|
||||
placeholder,
|
||||
mode,
|
||||
registry,
|
||||
)
|
||||
|
||||
wrapper := &initialPromptWrapper{
|
||||
model: &model,
|
||||
}
|
||||
|
||||
p := tea.NewProgram(wrapper)
|
||||
|
||||
_, err := p.Run()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("input prompt failed: %w", err)
|
||||
}
|
||||
|
||||
if wrapper.cancelled {
|
||||
return "", ErrUserAborted
|
||||
}
|
||||
|
||||
return strings.TrimSpace(wrapper.result), nil
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ package slash
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
)
|
||||
|
||||
@@ -32,25 +33,31 @@ var cliLocalCommands = []Command{
|
||||
}
|
||||
|
||||
// NewRegistry creates a new slash command registry
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{
|
||||
commands: make([]Command, 0),
|
||||
func NewRegistry(ctx context.Context) *Registry {
|
||||
defaultCommands := append([]Command{}, cliLocalCommands...)
|
||||
r := &Registry{
|
||||
commands: defaultCommands,
|
||||
}
|
||||
r.FetchFromBackend(ctx)
|
||||
return r
|
||||
}
|
||||
|
||||
// FetchFromBackend fetches available commands from cline-core backend
|
||||
func (r *Registry) FetchFromBackend(ctx context.Context, c *client.ClineClient) error {
|
||||
resp, err := c.Slash.GetAvailableSlashCommands(ctx, &cline.EmptyRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
func (r *Registry) FetchFromBackend(ctx context.Context) error {
|
||||
grpcClient, err := global.GetDefaultClient(ctx)
|
||||
if err != nil && global.Config.Verbose {
|
||||
fmt.Printf("Warning: could not get gRPC client: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
resp, err := grpcClient.Slash.GetAvailableSlashCommands(ctx, &cline.EmptyRequest{})
|
||||
if err != nil && global.Config.Verbose {
|
||||
fmt.Printf("Warning: could not get gRPC client: %v\n", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Start with CLI-local commands
|
||||
r.commands = append([]Command{}, cliLocalCommands...)
|
||||
|
||||
// Add backend commands (only CLI-compatible ones)
|
||||
for _, cmd := range resp.Commands {
|
||||
if cmd.CliCompatible {
|
||||
@@ -66,17 +73,6 @@ func (r *Registry) FetchFromBackend(ctx context.Context, c *client.ClineClient)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCommands returns all available commands
|
||||
func (r *Registry) GetCommands() []Command {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
// Return a copy to avoid race conditions
|
||||
result := make([]Command, len(r.commands))
|
||||
copy(result, r.commands)
|
||||
return result
|
||||
}
|
||||
|
||||
// GetMatching returns commands that start with the given prefix (case-insensitive)
|
||||
func (r *Registry) GetMatching(prefix string) []Command {
|
||||
r.mu.RLock()
|
||||
@@ -123,3 +119,24 @@ func (r *Registry) HasCommands() bool {
|
||||
defer r.mu.RUnlock()
|
||||
return len(r.commands) > 0
|
||||
}
|
||||
|
||||
// ParseModeSwitch checks if message starts with /act or /plan and extracts the mode and remaining message.
|
||||
// Returns (mode, remainingMessage, isModeSwitch).
|
||||
// This is a package-level function so it can be used both during initial task creation
|
||||
// and during interactive input handling.
|
||||
func ParseModeSwitch(message string) (string, string, bool) {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
lower := strings.ToLower(trimmed)
|
||||
|
||||
if strings.HasPrefix(lower, "/plan") {
|
||||
remaining := strings.TrimSpace(trimmed[5:])
|
||||
return "plan", remaining, true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lower, "/act") {
|
||||
remaining := strings.TrimSpace(trimmed[4:])
|
||||
return "act", remaining, true
|
||||
}
|
||||
|
||||
return "", message, false
|
||||
}
|
||||
|
||||
@@ -19,20 +19,20 @@ import (
|
||||
// Handles localhost/127.0.0.1 equivalence by returning both forms.
|
||||
func normalizeAddressVariants(address string) []string {
|
||||
variants := []string{address}
|
||||
|
||||
|
||||
// Extract host and port
|
||||
host, port, err := net.SplitHostPort(address)
|
||||
if err != nil {
|
||||
return variants
|
||||
}
|
||||
|
||||
|
||||
// Add the alternate form for localhost/127.0.0.1
|
||||
if host == "localhost" {
|
||||
variants = append(variants, net.JoinHostPort("127.0.0.1", port))
|
||||
} else if host == "127.0.0.1" {
|
||||
variants = append(variants, net.JoinHostPort("localhost", port))
|
||||
}
|
||||
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ func (lm *LockManager) GetInstanceInfo(address string) (*common.CoreInstanceInfo
|
||||
|
||||
query := common.SelectInstanceLockByHolderSQL
|
||||
variants := normalizeAddressVariants(address)
|
||||
|
||||
|
||||
var heldBy, lockTarget string
|
||||
var lockedAt int64
|
||||
var lastErr error
|
||||
@@ -193,7 +193,7 @@ func (lm *LockManager) GetInstanceInfo(address string) (*common.CoreInstanceInfo
|
||||
if err == nil {
|
||||
// Found it!
|
||||
return &common.CoreInstanceInfo{
|
||||
Address: heldBy,
|
||||
CoreAddress: heldBy,
|
||||
HostServiceAddress: lockTarget,
|
||||
Status: grpc_health_v1.HealthCheckResponse_UNKNOWN,
|
||||
LastSeen: time.Unix(lockedAt/1000, 0),
|
||||
@@ -204,7 +204,7 @@ func (lm *LockManager) GetInstanceInfo(address string) (*common.CoreInstanceInfo
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// None of the variants were found
|
||||
if lastErr != nil {
|
||||
return nil, fmt.Errorf("failed to query instance: %w", lastErr)
|
||||
@@ -235,7 +235,7 @@ func (lm *LockManager) ListInstancesWithHealthCheck(ctx context.Context) ([]*com
|
||||
}
|
||||
|
||||
info := &common.CoreInstanceInfo{
|
||||
Address: lock.HeldBy,
|
||||
CoreAddress: lock.HeldBy,
|
||||
HostServiceAddress: lock.LockTarget,
|
||||
Status: status,
|
||||
LastSeen: time.Unix(lock.LockedAt/1000, 0),
|
||||
|
||||
+5
-10
@@ -80,7 +80,7 @@ func ensureTaskManager(ctx context.Context, address string) error {
|
||||
}
|
||||
|
||||
// Always set the instance we're using as the default
|
||||
registry := global.Clients.GetRegistry()
|
||||
registry := global.Instances.GetRegistry()
|
||||
if err := registry.SetDefaultInstance(instanceAddress); err != nil {
|
||||
// Log warning but don't fail - this is not critical
|
||||
fmt.Printf("Warning: failed to set default instance: %v\n", err)
|
||||
@@ -91,10 +91,10 @@ func ensureTaskManager(ctx context.Context, address string) error {
|
||||
|
||||
// ensureInstanceAtAddress ensures an instance exists at the given address
|
||||
func ensureInstanceAtAddress(ctx context.Context, address string) error {
|
||||
if global.Clients == nil {
|
||||
if global.Instances == nil {
|
||||
return fmt.Errorf("global clients not initialized")
|
||||
}
|
||||
return global.Clients.EnsureInstanceAtAddress(ctx, address)
|
||||
return global.Instances.EnsureInstanceAtAddress(ctx, address)
|
||||
}
|
||||
|
||||
func newTaskNewCommand() *cobra.Command {
|
||||
@@ -117,7 +117,7 @@ func newTaskNewCommand() *cobra.Command {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Check if an instance exists when no address specified
|
||||
if address == "" && global.Clients.GetRegistry().GetDefaultInstance() == "" {
|
||||
if address == "" && global.Instances.GetRegistry().GetDefaultInstance() == "" {
|
||||
fmt.Println("No instances available for creating tasks")
|
||||
return nil
|
||||
}
|
||||
@@ -230,7 +230,7 @@ func newTaskSendCommand() *cobra.Command {
|
||||
ctx := cmd.Context()
|
||||
|
||||
// Check if an instance exists when no address specified
|
||||
if address == "" && global.Clients.GetRegistry().GetDefaultInstance() == "" {
|
||||
if address == "" && global.Instances.GetRegistry().GetDefaultInstance() == "" {
|
||||
fmt.Println("No instances available for sending messages")
|
||||
return nil
|
||||
}
|
||||
@@ -620,11 +620,6 @@ func CleanupTaskManager() {
|
||||
}
|
||||
}
|
||||
|
||||
// NewTaskManagerForAddress is an exported wrapper around task.NewManagerForAddress
|
||||
func NewTaskManagerForAddress(ctx context.Context, address string) (*task.Manager, error) {
|
||||
return task.NewManagerForAddress(ctx, address)
|
||||
}
|
||||
|
||||
// CreateAndFollowTask creates a new task and immediately follows it in interactive mode
|
||||
// This is used by the root command to provide a streamlined UX
|
||||
func CreateAndFollowTask(ctx context.Context, prompt string, opts TaskOptions) error {
|
||||
|
||||
@@ -13,38 +13,41 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/output"
|
||||
"github.com/cline/cli/pkg/cli/slash"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
)
|
||||
|
||||
// InputHandler manages interactive user input during follow mode
|
||||
type InputHandler struct {
|
||||
manager *Manager
|
||||
coordinator *StreamCoordinator
|
||||
cancelFunc context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
isRunning bool
|
||||
pollTicker *time.Ticker
|
||||
program *tea.Program
|
||||
programRunning bool
|
||||
programDoneChan chan struct{} // Signals when program actually exits
|
||||
resultChan chan output.InputSubmitMsg
|
||||
cancelChan chan struct{}
|
||||
feedbackApproval bool // Track if we're in feedback after approval
|
||||
feedbackApproved bool // Track the approval decision
|
||||
approvalMessage *types.ClineMessage // Store the approval message for determining action
|
||||
ctx context.Context // Context for restart callback
|
||||
manager *Manager
|
||||
coordinator *StreamCoordinator
|
||||
cancelFunc context.CancelFunc
|
||||
mu sync.RWMutex
|
||||
isRunning bool
|
||||
pollTicker *time.Ticker
|
||||
program *tea.Program
|
||||
programRunning bool
|
||||
programDoneChan chan struct{} // Signals when program actually exits
|
||||
resultChan chan output.InputSubmitMsg
|
||||
cancelChan chan struct{}
|
||||
feedbackApproval bool // Track if we're in feedback after approval
|
||||
feedbackApproved bool // Track the approval decision
|
||||
approvalMessage *types.ClineMessage // Store the approval message for determining action
|
||||
slashCommandRegistry *slash.Registry // Slash command registry for autocomplete
|
||||
ctx context.Context // Context for restart callback
|
||||
}
|
||||
|
||||
// NewInputHandler creates a new input handler
|
||||
func NewInputHandler(manager *Manager, coordinator *StreamCoordinator, cancelFunc context.CancelFunc) *InputHandler {
|
||||
return &InputHandler{
|
||||
manager: manager,
|
||||
coordinator: coordinator,
|
||||
cancelFunc: cancelFunc,
|
||||
isRunning: false,
|
||||
pollTicker: time.NewTicker(500 * time.Millisecond),
|
||||
resultChan: make(chan output.InputSubmitMsg, 1),
|
||||
cancelChan: make(chan struct{}, 1),
|
||||
manager: manager,
|
||||
coordinator: coordinator,
|
||||
cancelFunc: cancelFunc,
|
||||
isRunning: false,
|
||||
pollTicker: time.NewTicker(500 * time.Millisecond),
|
||||
resultChan: make(chan output.InputSubmitMsg, 1),
|
||||
slashCommandRegistry: slash.NewRegistry(context.Background()),
|
||||
cancelChan: make(chan struct{}, 1),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,7 +166,7 @@ func (ih *InputHandler) Start(ctx context.Context, errChan chan error) {
|
||||
|
||||
if shouldSend {
|
||||
// Check for mode switch commands first
|
||||
newMode, remainingMessage, isModeSwitch := ih.parseModeSwitch(message)
|
||||
newMode, remainingMessage, isModeSwitch := slash.ParseModeSwitch(message)
|
||||
if isModeSwitch {
|
||||
// Create styles for mode switch messages (respect global color profile)
|
||||
actStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("39")).Bold(true)
|
||||
@@ -284,9 +287,9 @@ func (ih *InputHandler) promptForInput(ctx context.Context) (string, bool, error
|
||||
model := output.NewInputModelWithRegistry(
|
||||
output.InputTypeMessage,
|
||||
"Cline is ready for your message...",
|
||||
"/plan or /act to switch modes\nctrl+e to open editor\ntab to autocomplete commands",
|
||||
"/plan or /act to switch modes\ntab to autocomplete commands\nctrl+e to open editor\nctrl+c to exit",
|
||||
currentMode,
|
||||
ih.manager.GetSlashRegistry(),
|
||||
ih.slashCommandRegistry,
|
||||
)
|
||||
|
||||
return ih.runInputProgram(ctx, model)
|
||||
@@ -302,7 +305,7 @@ func (ih *InputHandler) promptForApproval(ctx context.Context, msg *types.ClineM
|
||||
"Let Cline use this tool?",
|
||||
"",
|
||||
ih.manager.GetCurrentMode(),
|
||||
ih.manager.GetSlashRegistry(), // Pass registry for feedback input after approval
|
||||
ih.slashCommandRegistry,
|
||||
)
|
||||
|
||||
message, shouldSend, err := ih.runInputProgram(ctx, model)
|
||||
@@ -478,24 +481,6 @@ func (w *inputProgramWrapper) View() string {
|
||||
return w.model.View()
|
||||
}
|
||||
|
||||
// parseModeSwitch checks if message starts with /act or /plan and extracts the mode and remaining message
|
||||
func (ih *InputHandler) parseModeSwitch(message string) (string, string, bool) {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
lower := strings.ToLower(trimmed)
|
||||
|
||||
if strings.HasPrefix(lower, "/plan") {
|
||||
remaining := strings.TrimSpace(trimmed[5:])
|
||||
return "plan", remaining, true
|
||||
}
|
||||
|
||||
if strings.HasPrefix(lower, "/act") {
|
||||
remaining := strings.TrimSpace(trimmed[4:])
|
||||
return "act", remaining, true
|
||||
}
|
||||
|
||||
return "", message, false
|
||||
}
|
||||
|
||||
// handleSpecialCommand processes special commands like /cancel, /exit
|
||||
func (ih *InputHandler) handleSpecialCommand(ctx context.Context, message string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(message)) {
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"github.com/cline/cli/pkg/cli/display"
|
||||
"github.com/cline/cli/pkg/cli/global"
|
||||
"github.com/cline/cli/pkg/cli/handlers"
|
||||
"github.com/cline/cli/pkg/cli/slash"
|
||||
"github.com/cline/cli/pkg/cli/types"
|
||||
"github.com/cline/grpc-go/client"
|
||||
"github.com/cline/grpc-go/cline"
|
||||
@@ -38,7 +37,6 @@ type Manager struct {
|
||||
systemRenderer *display.SystemMessageRenderer
|
||||
streamingDisplay *display.StreamingDisplay
|
||||
handlerRegistry *handlers.HandlerRegistry
|
||||
slashRegistry *slash.Registry
|
||||
isStreamingMode bool
|
||||
isInteractive bool
|
||||
currentMode string // "plan" or "act"
|
||||
@@ -68,7 +66,6 @@ func NewManager(client *client.ClineClient) *Manager {
|
||||
systemRenderer: systemRenderer,
|
||||
streamingDisplay: streamingDisplay,
|
||||
handlerRegistry: registry,
|
||||
slashRegistry: slash.NewRegistry(),
|
||||
currentMode: "plan", // Default mode
|
||||
}
|
||||
}
|
||||
@@ -83,9 +80,6 @@ func NewManagerForAddress(ctx context.Context, address string) (*Manager, error)
|
||||
manager := NewManager(client)
|
||||
manager.clientAddress = address
|
||||
|
||||
// Fetch slash commands from backend (non-blocking, errors are logged)
|
||||
manager.fetchSlashCommands(ctx)
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
@@ -99,29 +93,13 @@ func NewManagerForDefault(ctx context.Context) (*Manager, error) {
|
||||
manager := NewManager(client)
|
||||
|
||||
// Get the default instance address
|
||||
if global.Clients != nil {
|
||||
manager.clientAddress = global.Clients.GetRegistry().GetDefaultInstance()
|
||||
if global.Instances != nil {
|
||||
manager.clientAddress = global.Instances.GetRegistry().GetDefaultInstance()
|
||||
}
|
||||
|
||||
// Fetch slash commands from backend (non-blocking, errors are logged)
|
||||
manager.fetchSlashCommands(ctx)
|
||||
|
||||
return manager, nil
|
||||
}
|
||||
|
||||
// fetchSlashCommands fetches available slash commands from the backend
|
||||
// This is non-blocking and errors are logged but don't prevent manager creation
|
||||
func (m *Manager) fetchSlashCommands(ctx context.Context) {
|
||||
if err := m.slashRegistry.FetchFromBackend(ctx, m.client); err != nil {
|
||||
if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Failed to fetch slash commands: %v", err)
|
||||
}
|
||||
// Non-fatal: CLI-local commands are still available
|
||||
} else if global.Config.Verbose {
|
||||
m.renderer.RenderDebug("Loaded %d slash commands", len(m.slashRegistry.GetCommands()))
|
||||
}
|
||||
}
|
||||
|
||||
// SwitchToInstance switches the manager to use a different Cline instance
|
||||
func (m *Manager) SwitchToInstance(ctx context.Context, address string) error {
|
||||
m.mu.Lock()
|
||||
@@ -1206,11 +1184,11 @@ func (m *Manager) displayMessage(msg *types.ClineMessage, isLast, isPartial bool
|
||||
m.mu.RUnlock()
|
||||
|
||||
dc := &handlers.DisplayContext{
|
||||
State: m.state,
|
||||
Renderer: m.renderer,
|
||||
ToolRenderer: m.toolRenderer,
|
||||
HookRenderer: m.hookRenderer,
|
||||
SystemRenderer: m.systemRenderer,
|
||||
State: m.state,
|
||||
Renderer: m.renderer,
|
||||
ToolRenderer: m.toolRenderer,
|
||||
HookRenderer: m.hookRenderer,
|
||||
SystemRenderer: m.systemRenderer,
|
||||
IsLast: isLast,
|
||||
IsPartial: isPartial,
|
||||
Verbose: global.Config.Verbose,
|
||||
@@ -1320,11 +1298,6 @@ func (m *Manager) GetCurrentMode() string {
|
||||
return m.currentMode
|
||||
}
|
||||
|
||||
// GetSlashRegistry returns the slash command registry
|
||||
func (m *Manager) GetSlashRegistry() *slash.Registry {
|
||||
return m.slashRegistry
|
||||
}
|
||||
|
||||
// extractModeFromState extracts the current mode from state JSON
|
||||
func (m *Manager) extractModeFromState(stateJson string) string {
|
||||
var rawState map[string]interface{}
|
||||
|
||||
@@ -290,12 +290,6 @@ func setSimpleField(settings *cline.Settings, key, value string) error {
|
||||
return err
|
||||
}
|
||||
settings.ActModeAwsBedrockCustomSelected = boolPtr(val)
|
||||
case "hooks_enabled":
|
||||
val, err := parseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings.HooksEnabled = boolPtr(val)
|
||||
case "azure_identity":
|
||||
val, err := parseBool(value)
|
||||
if 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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// This is the canonical definition used across all CLI packages
|
||||
type CoreInstanceInfo struct {
|
||||
// Full core address including port
|
||||
Address string `json:"address"`
|
||||
CoreAddress string `json:"address"`
|
||||
// Host bridge service address that core holds (host is ALWAYS running on localhost FYI)
|
||||
HostServiceAddress string `json:"host_port"`
|
||||
Status grpc_health_v1.HealthCheckResponse_ServingStatus `json:"status"`
|
||||
@@ -20,7 +20,7 @@ type CoreInstanceInfo struct {
|
||||
}
|
||||
|
||||
func (c *CoreInstanceInfo) CorePort() int {
|
||||
_, port, _ := ParseHostPort(c.Address)
|
||||
_, port, _ := ParseHostPort(c.CoreAddress)
|
||||
return port
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,10 @@ func (s *DiffService) generateDiffID() string {
|
||||
return fmt.Sprintf("diff_%d_%d", os.Getpid(), id)
|
||||
}
|
||||
|
||||
// splitLines splits content into lines, preserving line ending information
|
||||
// splitLines splits content into lines, preserving trailing newlines.
|
||||
// This matches the behavior of JavaScript's String.split("\n"):
|
||||
// - "hello\nworld\n" -> ["hello", "world", ""]
|
||||
// - "hello\nworld" -> ["hello", "world"]
|
||||
func splitLines(content string) []string {
|
||||
if content == "" {
|
||||
return []string{}
|
||||
@@ -65,10 +68,9 @@ func splitLines(content string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last line if it doesn't end with newline
|
||||
if current != "" {
|
||||
lines = append(lines, current)
|
||||
}
|
||||
// Always add the last segment - if content ends with newline, this will be
|
||||
// an empty string which preserves the trailing newline when joined back
|
||||
lines = append(lines, current)
|
||||
|
||||
return lines
|
||||
}
|
||||
@@ -176,9 +178,19 @@ func (s *DiffService) ReplaceText(ctx context.Context, req *proto.ReplaceTextReq
|
||||
endLine = startLine
|
||||
}
|
||||
|
||||
// Check if we're replacing to the end of the document
|
||||
replacingToEnd := endLine >= len(session.lines)
|
||||
|
||||
// Split new content into lines
|
||||
newLines := splitLines(newContent)
|
||||
|
||||
// Remove trailing empty line for proper splicing, BUT only when NOT replacing
|
||||
// to the end of the document. When replacing to the end, keep the trailing
|
||||
// empty string to preserve trailing newlines from the content.
|
||||
if !replacingToEnd && len(newLines) > 0 && newLines[len(newLines)-1] == "" {
|
||||
newLines = newLines[:len(newLines)-1]
|
||||
}
|
||||
|
||||
// Ensure we have enough lines in the current content
|
||||
for len(session.lines) < endLine {
|
||||
session.lines = append(session.lines, "")
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
package hostbridge
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
// WatchService implements the host.WatchServiceServer interface
|
||||
type WatchService struct {
|
||||
host.UnimplementedWatchServiceServer
|
||||
coreAddress string
|
||||
verbose bool
|
||||
}
|
||||
|
||||
// NewWatchService creates a new WatchService
|
||||
func NewWatchService(coreAddress string, verbose bool) *WatchService {
|
||||
return &WatchService{
|
||||
coreAddress: coreAddress,
|
||||
verbose: verbose,
|
||||
}
|
||||
}
|
||||
|
||||
// SubscribeToFile subscribes to file change notifications
|
||||
func (s *WatchService) SubscribeToFile(req *host.SubscribeToFileRequest, stream host.WatchService_SubscribeToFileServer) error {
|
||||
if s.verbose {
|
||||
log.Printf("SubscribeToFile called for path: %s", req.GetPath())
|
||||
}
|
||||
|
||||
// For console implementation, we'll just log that we would watch the file
|
||||
// In a real implementation, we'd use fsnotify or similar to watch file changes
|
||||
log.Printf("[Cline] Would watch file: %s", req.GetPath())
|
||||
|
||||
// Keep the stream open but don't send any events for now
|
||||
// In a real implementation, we'd send FileChangeEvent messages when files change
|
||||
<-stream.Context().Done()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"github.com/cline/grpc-go/cline"
|
||||
"github.com/cline/grpc-go/host"
|
||||
)
|
||||
|
||||
@@ -43,24 +44,25 @@ func (s *WorkspaceService) GetWorkspacePaths(ctx context.Context, req *host.GetW
|
||||
// SaveOpenDocumentIfDirty saves an open document if it has unsaved changes
|
||||
func (s *WorkspaceService) SaveOpenDocumentIfDirty(ctx context.Context, req *host.SaveOpenDocumentIfDirtyRequest) (*host.SaveOpenDocumentIfDirtyResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("SaveOpenDocumentIfDirty called for path: %s", req.GetPath())
|
||||
log.Printf("SaveOpenDocumentIfDirty called for path: %v", req.FilePath)
|
||||
}
|
||||
|
||||
// For console implementation, we'll assume the document is already saved
|
||||
// In a real implementation, we'd check if the file has unsaved changes
|
||||
f := false
|
||||
return &host.SaveOpenDocumentIfDirtyResponse{
|
||||
WasSaved: false, // Assume no changes to save
|
||||
WasSaved: &f, // Assume no changes to save
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDiagnostics returns diagnostic information for a file
|
||||
func (s *WorkspaceService) GetDiagnostics(ctx context.Context, req *host.GetDiagnosticsRequest) (*host.GetDiagnosticsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetDiagnostics called for path: %s", req.GetPath())
|
||||
log.Printf("GetDiagnostics called")
|
||||
}
|
||||
|
||||
// For console implementation, return empty diagnostics
|
||||
return &host.GetDiagnosticsResponse{
|
||||
Diagnostics: []*host.Diagnostic{},
|
||||
FileDiagnostics: []*cline.FileDiagnostics{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -104,14 +104,18 @@ func (s *EnvService) Shutdown(ctx context.Context, req *cline.EmptyRequest) (*cl
|
||||
return &cline.Empty{}, nil
|
||||
}
|
||||
|
||||
func (s *EnvService) isTelemetryEnabled() bool {
|
||||
// In CLI mode, check the CLINE_TELEMETRY_DISABLED environment variable
|
||||
return os.Getenv("CLINE_TELEMETRY_DISABLED") != "true"
|
||||
}
|
||||
|
||||
// GetTelemetrySettings returns the telemetry settings for CLI mode
|
||||
func (s *EnvService) GetTelemetrySettings(ctx context.Context, req *cline.EmptyRequest) (*host.GetTelemetrySettingsResponse, error) {
|
||||
if s.verbose {
|
||||
log.Printf("GetTelemetrySettings called")
|
||||
}
|
||||
|
||||
// In CLI mode, check the POSTHOG_TELEMETRY_ENABLED environment variable
|
||||
telemetryEnabled := os.Getenv("POSTHOG_TELEMETRY_ENABLED") == "true"
|
||||
telemetryEnabled := s.isTelemetryEnabled()
|
||||
|
||||
var setting host.Setting
|
||||
if telemetryEnabled {
|
||||
@@ -133,8 +137,7 @@ func (s *EnvService) SubscribeToTelemetrySettings(req *cline.EmptyRequest, strea
|
||||
log.Printf("SubscribeToTelemetrySettings called")
|
||||
}
|
||||
|
||||
// Send initial telemetry state
|
||||
telemetryEnabled := os.Getenv("POSTHOG_TELEMETRY_ENABLED") == "true"
|
||||
telemetryEnabled := s.isTelemetryEnabled()
|
||||
|
||||
var setting host.Setting
|
||||
if telemetryEnabled {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.2 MiB |
+18
-1
@@ -117,7 +117,13 @@
|
||||
"features/auto-compact",
|
||||
"features/background-edit",
|
||||
"features/checkpoints",
|
||||
"features/cline-rules",
|
||||
{
|
||||
"group": "Cline Rules",
|
||||
"pages": [
|
||||
"features/cline-rules/overview",
|
||||
"features/cline-rules/conditional-rules"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Commands & Shortcuts",
|
||||
"pages": [
|
||||
@@ -148,6 +154,7 @@
|
||||
"features/hooks/samples"
|
||||
]
|
||||
},
|
||||
"features/jupyter-notebooks",
|
||||
"features/multiroot-workspace",
|
||||
"features/plan-and-act",
|
||||
"features/skills",
|
||||
@@ -177,6 +184,7 @@
|
||||
"features/tasks/task-management"
|
||||
]
|
||||
},
|
||||
"features/worktrees",
|
||||
"features/yolo-mode"
|
||||
]
|
||||
},
|
||||
@@ -197,6 +205,7 @@
|
||||
"provider-config/anthropic",
|
||||
"provider-config/claude-code",
|
||||
"provider-config/openai",
|
||||
"provider-config/openai-codex",
|
||||
"provider-config/openrouter",
|
||||
"provider-config/cerebras",
|
||||
"provider-config/deepseek",
|
||||
@@ -421,6 +430,14 @@
|
||||
{
|
||||
"source": "/enterprise-solutions/team-management/roles-and-permissions",
|
||||
"destination": "/enterprise-solutions/team-management/managing-members"
|
||||
},
|
||||
{
|
||||
"source": "/features/cline-rules",
|
||||
"destination": "/features/cline-rules/overview"
|
||||
},
|
||||
{
|
||||
"source": "/features/conditional-rules",
|
||||
"destination": "/features/cline-rules/conditional-rules"
|
||||
}
|
||||
],
|
||||
"search": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
Cline Rules allow you to provide Cline with system-level guidance. Think of them as a persistent way to include context and preferences for your projects or globally for every conversation.
|
||||
|
||||
## Creating a Rule
|
||||
|
||||
You can create a rule by clicking the `+` button in the Rules tab. This will open a new file in your IDE which you can use to write your rule.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/cline-rules.png" alt="Create a Rule" />
|
||||
</Frame>
|
||||
|
||||
Once you save the file:
|
||||
|
||||
- Your rule will be stored in the `.clinerules/` directory in your project (if it's a Workspace Rule)
|
||||
- Or in the Global Rules directory (if it's a Global Rule):
|
||||
|
||||
### Global Rules Directory Location
|
||||
|
||||
The location of your Global Rules directory depends on your operating system:
|
||||
|
||||
| Operating System | Default Location | Notes |
|
||||
|------------------|------------------|-------|
|
||||
| **Windows** | `Documents\Cline\Rules` | Uses system Documents folder |
|
||||
| **macOS** | `~/Documents/Cline/Rules` | Uses user Documents folder |
|
||||
| **Linux/WSL** | `~/Documents/Cline/Rules` | May fall back to `~/Cline/Rules` on some systems |
|
||||
|
||||
> **Note for Linux/WSL users**: If you don't find your global rules in `~/Documents/Cline/Rules`, check `~/Cline/Rules` as the location may vary depending on your system configuration and whether the Documents directory exists.
|
||||
|
||||
You can also have Cline create a rule for you by using the [`/newrule` slash command](/features/slash-commands/new-rule) in the chat.
|
||||
|
||||
```markdown Example Cline Rule Structure [expandable]
|
||||
# Project Guidelines
|
||||
|
||||
## Documentation Requirements
|
||||
|
||||
- Update relevant documentation in /docs when modifying features
|
||||
- Keep README.md in sync with new capabilities
|
||||
- Maintain changelog entries in CHANGELOG.md
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
Create ADRs in /docs/adr for:
|
||||
|
||||
- Major dependency changes
|
||||
- Architectural pattern changes
|
||||
- New integration patterns
|
||||
- Database schema changes
|
||||
Follow template in /docs/adr/template.md
|
||||
|
||||
## Code Style & Patterns
|
||||
|
||||
- Generate API clients using OpenAPI Generator
|
||||
- Use TypeScript axios template
|
||||
- Place generated code in /src/generated
|
||||
- Prefer composition over inheritance
|
||||
- Use repository pattern for data access
|
||||
- Follow error handling pattern in /src/utils/errors.ts
|
||||
|
||||
## Testing Standards
|
||||
|
||||
- Unit tests required for business logic
|
||||
- Integration tests for API endpoints
|
||||
- E2E tests for critical user flows
|
||||
```
|
||||
|
||||
### Key Benefits
|
||||
|
||||
1. **Version Controlled**: The `.clinerules` file becomes part of your project's source code
|
||||
2. **Team Consistency**: Ensures consistent behavior across all team members
|
||||
3. **Project-Specific**: Rules and standards tailored to each project's needs
|
||||
4. **Institutional Knowledge**: Maintains project standards and practices in code
|
||||
|
||||
Place the `.clinerules` file in your project's root directory:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules
|
||||
├── src/
|
||||
├── docs/
|
||||
└── ...
|
||||
```
|
||||
|
||||
Cline's system prompt, on the other hand, is not user-editable ([here's where you can find it](https://github.com/cline/cline/blob/main/src/core/prompts/system.ts)). For a broader look at prompt engineering best practices, check out [this resource](https://docs.anthropic.com/en/docs/build-with-claude/prompt-engineering/overview).
|
||||
|
||||
### AGENTS.md Standard Support
|
||||
|
||||
Cline also supports the [AGENTS.md](https://agents.md/) standard as a fallback
|
||||
(in addition to Cline Rules) by automatically detecting `AGENTS.md` files in
|
||||
your workspace root. This allows you to use the same rules file across different AI
|
||||
coding tools.
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── AGENTS.md
|
||||
├── src/
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Tips for Writing Effective Cline Rules
|
||||
|
||||
- Be Clear and Concise: Use simple language and avoid ambiguity.
|
||||
- Focus on Desired Outcomes: Describe the results you want, not the specific steps.
|
||||
- Test and Iterate: Experiment to find what works best for your workflow.
|
||||
|
||||
### .clinerules/ Folder System
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules/ # Folder containing active rules
|
||||
│ ├── 01-coding.md # Core coding standards
|
||||
│ ├── 02-documentation.md # Documentation requirements
|
||||
│ └── current-sprint.md # Rules specific to current work
|
||||
├── src/
|
||||
└── ...
|
||||
```
|
||||
|
||||
Cline automatically processes **all Markdown files** inside the `.clinerules/` directory, combining them into a unified set of rules. The numeric prefixes (optional) help organize files in a logical sequence.
|
||||
|
||||
#### Using a Rules Bank
|
||||
|
||||
For projects with multiple contexts or teams, maintain a rules bank directory:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules/ # Active rules - automatically applied
|
||||
│ ├── 01-coding.md
|
||||
│ └── client-a.md
|
||||
│
|
||||
├── clinerules-bank/ # Repository of available but inactive rules
|
||||
│ ├── clients/ # Client-specific rule sets
|
||||
│ │ ├── client-a.md
|
||||
│ │ └── client-b.md
|
||||
│ ├── frameworks/ # Framework-specific rules
|
||||
│ │ ├── react.md
|
||||
│ │ └── vue.md
|
||||
│ └── project-types/ # Project type standards
|
||||
│ ├── api-service.md
|
||||
│ └── frontend-app.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
#### Benefits of the Folder Approach
|
||||
|
||||
1. **Contextual Activation**: Copy only relevant rules from the bank to the active folder
|
||||
2. **Easier Maintenance**: Update individual rule files without affecting others
|
||||
3. **Team Flexibility**: Different team members can activate rules specific to their current task
|
||||
4. **Reduced Noise**: Keep the active ruleset focused and relevant
|
||||
|
||||
#### Usage Examples
|
||||
|
||||
Switch between client projects:
|
||||
|
||||
```bash
|
||||
# Switch to Client B project
|
||||
rm .clinerules/client-a.md
|
||||
cp clinerules-bank/clients/client-b.md .clinerules/
|
||||
```
|
||||
|
||||
Adapt to different tech stacks:
|
||||
|
||||
```bash
|
||||
# Frontend React project
|
||||
cp clinerules-bank/frameworks/react.md .clinerules/
|
||||
```
|
||||
|
||||
#### Implementation Tips
|
||||
|
||||
- Keep individual rule files focused on specific concerns
|
||||
- Use descriptive filenames that clearly indicate the rule's purpose
|
||||
- Consider git-ignoring the active `.clinerules/` folder while tracking the `clinerules-bank/`
|
||||
- Create team scripts to quickly activate common rule combinations
|
||||
|
||||
The folder system transforms your Cline rules from a static document into a dynamic knowledge system that adapts to your team's changing contexts and requirements.
|
||||
|
||||
### Managing Rules with the Toggleable Popover
|
||||
|
||||
To make managing both single `.clinerules` files and the folder system even easier, Cline v3.13 introduces a dedicated popover UI directly accessible from the chat interface.
|
||||
|
||||
Located conveniently under the chat input field, this popover allows you to:
|
||||
|
||||
- **Instantly See Active Rules:** View which global rules (from your user settings) and workspace rules (`.clinerules` file or folder contents) are currently active.
|
||||
- **Quickly Toggle Rules:** Enable or disable specific rule files within your workspace `.clinerules/` folder with a single click. This is perfect for activating context-specific rules (like `react-rules.md` or `memory-bank.md`) only when needed.
|
||||
- **Easily Add/Manage Rules:** Quickly create a workspace `.clinerules` file or folder if one doesn't exist, or add new rule files to an existing folder.
|
||||
|
||||
This UI significantly simplifies switching contexts and managing different sets of instructions without needing to manually edit files or configurations during a conversation.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/image%20(1).png" alt="Cline Logo" />
|
||||
</Frame>
|
||||
@@ -0,0 +1,267 @@
|
||||
---
|
||||
title: "Conditional Rules"
|
||||
sidebarTitle: "Conditional Rules"
|
||||
description: "Activate rules automatically based on which files you're working with"
|
||||
---
|
||||
|
||||
Conditional rules let you scope rules to specific parts of your codebase. Rules activate only when you're working with matching files, keeping your context focused and relevant.
|
||||
|
||||
For an introduction to Cline Rules, see the [Overview](/features/cline-rules/overview).
|
||||
|
||||
- **Without conditionals**: every rule loads for every request.
|
||||
- **With conditionals**, rules activate only when your current files match their defined scope.
|
||||
|
||||
For example, React component rules should appear when you're working with React components, not when you're editing Python or documentation.
|
||||
|
||||
## How It Works
|
||||
|
||||
Conditional rules use YAML frontmatter at the top of your rule files. When Cline processes a request, it gathers context from your current work (open files, visible tabs, mentioned paths, edited files), evaluates each rule's conditions, and activates matching rules.
|
||||
|
||||
<Note>
|
||||
When a conditional rule activates, you'll see a notification: **"Conditional rules applied: workspace:frontend-rules.md"**
|
||||
</Note>
|
||||
|
||||
## Writing Conditional Rules
|
||||
|
||||
Add YAML frontmatter to the top of any rule file in your `.clinerules/` directory:
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "src/components/**"
|
||||
- "src/hooks/**"
|
||||
---
|
||||
|
||||
# React Component Guidelines
|
||||
|
||||
When creating or modifying React components:
|
||||
- Use functional components with React hooks
|
||||
- Extract reusable logic into custom React hooks
|
||||
- Keep components focused on a single responsibility
|
||||
```
|
||||
|
||||
The `---` markers delimit the frontmatter. Everything after the closing `---` is your rule content.
|
||||
|
||||
### The `paths` Conditional
|
||||
|
||||
Currently, `paths` is the supported conditional. It takes an array of glob patterns:
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "src/**" # All files under src/
|
||||
- "*.config.js" # Config files in root
|
||||
- "packages/*/src/" # Monorepo package sources
|
||||
---
|
||||
```
|
||||
|
||||
**Glob pattern syntax:**
|
||||
- `*` matches any characters except `/`
|
||||
- `**` matches any characters including `/` (recursive)
|
||||
- `?` matches a single character
|
||||
- `[abc]` matches any character in the brackets
|
||||
- `{a,b}` matches either pattern
|
||||
|
||||
**Examples:**
|
||||
|
||||
| Pattern | Matches |
|
||||
|---------|---------|
|
||||
| `src/**/*.ts` | All TypeScript files under `src/` |
|
||||
| `*.md` | Markdown files in root only |
|
||||
| `**/*.test.ts` | Test files anywhere in the project |
|
||||
| `packages/{web,api}/**` | Files in web or api packages |
|
||||
| `src/components/*.tsx` | TSX files directly in components (not nested) |
|
||||
|
||||
### Behavior Details
|
||||
|
||||
**Multiple patterns**: A rule activates if any pattern matches any file in your context.
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "frontend/**"
|
||||
- "mobile/**"
|
||||
---
|
||||
# Activates when working in frontend OR mobile
|
||||
```
|
||||
|
||||
**No frontmatter**: Rules without frontmatter are always active.
|
||||
|
||||
**Empty paths array**: `paths: []` means the rule never activates. Use this to temporarily disable a rule.
|
||||
|
||||
**Invalid YAML**: If frontmatter can't be parsed, Cline fails open: the rule activates with raw content visible to help debugging.
|
||||
|
||||
## What Counts as "Current Context"
|
||||
|
||||
Cline evaluates rules based on:
|
||||
|
||||
1. **Your message**: File paths mentioned in your prompt (e.g., "update `src/App.tsx`")
|
||||
2. **Open tabs**: Files currently open in your editor
|
||||
3. **Visible files**: Files visible in your active editor panes
|
||||
4. **Edited files**: Files Cline has created, modified, or deleted during the task
|
||||
5. **Pending operations**: Files Cline is about to edit
|
||||
|
||||
Conditional rules can activate on your first message, when relevant files are open, or mid-task when Cline starts working with matching files.
|
||||
|
||||
<Tip>
|
||||
Be explicit about file paths in your prompts. "Update `src/services/user.ts`" reliably triggers path-based rules; "update the user service" may not.
|
||||
</Tip>
|
||||
|
||||
## Practical Examples
|
||||
|
||||
Copy these patterns and adapt them to your project structure.
|
||||
|
||||
### Frontend vs Backend Rules
|
||||
|
||||
Keep frontend and backend rules separate to avoid noise. Frontend rules only load when working with UI code, backend rules only load when working with API or service code.
|
||||
|
||||
```yaml
|
||||
# .clinerules/frontend.md
|
||||
---
|
||||
paths:
|
||||
- "src/components/**"
|
||||
- "src/pages/**"
|
||||
- "src/hooks/**"
|
||||
---
|
||||
|
||||
# Frontend Guidelines
|
||||
|
||||
- Use Tailwind CSS for styling
|
||||
- Prefer server components where possible
|
||||
- Keep client components small and focused
|
||||
```
|
||||
|
||||
```yaml
|
||||
# .clinerules/backend.md
|
||||
---
|
||||
paths:
|
||||
- "src/api/**"
|
||||
- "src/services/**"
|
||||
- "src/db/**"
|
||||
---
|
||||
|
||||
# Backend Guidelines
|
||||
|
||||
- Use dependency injection for services
|
||||
- All database queries go through repositories
|
||||
- Return typed errors, not thrown exceptions
|
||||
```
|
||||
|
||||
### Test File Rules
|
||||
|
||||
Enforce testing standards automatically. This rule activates only when you're writing or modifying tests, so testing guidance appears exactly when you need it.
|
||||
|
||||
```yaml
|
||||
# .clinerules/testing.md
|
||||
---
|
||||
paths:
|
||||
- "**/*.test.ts"
|
||||
- "**/*.spec.ts"
|
||||
- "**/__tests__/**"
|
||||
---
|
||||
|
||||
# Testing Standards
|
||||
|
||||
- Use descriptive test names: "should [expected behavior] when [condition]"
|
||||
- One assertion per test when possible
|
||||
- Mock external dependencies, not internal modules
|
||||
- Use factories for test data, not fixtures
|
||||
```
|
||||
|
||||
### Documentation Rules
|
||||
|
||||
Apply documentation standards only when editing docs. Prevents style rules from cluttering your context when you're writing code.
|
||||
|
||||
```yaml
|
||||
# .clinerules/docs.md
|
||||
---
|
||||
paths:
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
- "**/*.mdx"
|
||||
---
|
||||
|
||||
# Documentation Guidelines
|
||||
|
||||
- Use sentence case for headings
|
||||
- Include code examples for all features
|
||||
- Keep paragraphs short (3-4 sentences max)
|
||||
- Link to related documentation
|
||||
```
|
||||
|
||||
## Combining with Rule Toggles
|
||||
|
||||
Conditional rules work alongside the rule toggle UI. Toggle off a conditional rule to disable it entirely (it won't activate even if paths match). Toggle on to let it activate when conditions are met.
|
||||
|
||||
This provides two levels of control: manual toggles and automatic condition-based activation.
|
||||
|
||||
## Tips for Effective Conditional Rules
|
||||
|
||||
### Start Broad, Then Narrow
|
||||
|
||||
Begin with broader patterns and refine as you learn what works:
|
||||
|
||||
```yaml
|
||||
# Start here
|
||||
paths:
|
||||
- "src/**"
|
||||
|
||||
# Then narrow down
|
||||
paths:
|
||||
- "src/features/auth/**"
|
||||
```
|
||||
|
||||
### Use Descriptive Filenames
|
||||
|
||||
Name your rule files to indicate their scope:
|
||||
|
||||
```
|
||||
.clinerules/
|
||||
├── api-endpoints.md # Rules for API code
|
||||
├── database-models.md # Rules for DB layer
|
||||
├── react-components.md # Rules for React
|
||||
└── universal.md # No frontmatter = always active
|
||||
```
|
||||
|
||||
### Keep Universal Rules Separate
|
||||
|
||||
Put always-on rules (coding standards, project conventions) in files without frontmatter. Reserve conditional rules for context-specific guidance.
|
||||
|
||||
### Test Your Patterns
|
||||
|
||||
Not sure if a pattern matches? Create a simple test rule:
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "your/pattern/here/**"
|
||||
---
|
||||
|
||||
TEST: This rule should activate for your/pattern/here files.
|
||||
```
|
||||
|
||||
Then work with a file in that path and check if you see the activation notification.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Rule not activating:**
|
||||
- Check that file paths in your context match the glob pattern
|
||||
- Verify the rule is toggled on in the rules panel
|
||||
- Ensure YAML frontmatter has proper `---` delimiters
|
||||
|
||||
**Rule activating unexpectedly:**
|
||||
- Review glob patterns: `**` is recursive and may match more than intended
|
||||
- Check for open files that match the pattern
|
||||
- File paths mentioned in your message also count as context
|
||||
|
||||
**Frontmatter showing in output:**
|
||||
- YAML couldn't be parsed
|
||||
- Check for syntax errors (unquoted special characters, improper indentation)
|
||||
|
||||
## Related
|
||||
|
||||
- [Cline Rules Overview](/features/cline-rules/overview) - Complete rules system guide
|
||||
- [Skills](/features/skills) - Load instructions on demand with `/skill` command
|
||||
- [Workflows](/features/slash-commands/workflows/index) - Define explicit task automation
|
||||
- [@ Mentions](/features/at-mentions/overview) - Add files to context explicitly
|
||||
- [Understanding Context Management](/prompting/understanding-context-management) - How Cline manages context window
|
||||
@@ -0,0 +1,205 @@
|
||||
---
|
||||
title: "Cline Rules"
|
||||
sidebarTitle: "Overview"
|
||||
description: "Add persistent instructions and context to guide Cline's behavior"
|
||||
---
|
||||
|
||||
Cline Rules provide system-level guidance for your projects. Rules persist across conversations, ensuring consistent behavior without repeating instructions in every chat.
|
||||
|
||||
## How It Works
|
||||
|
||||
Rules are loaded when Cline starts a task. Here's what happens:
|
||||
|
||||
**Loading order**: Cline checks for rules in this sequence:
|
||||
1. `.clinerules/` folder (all `.md` files inside)
|
||||
2. Single `.clinerules` file
|
||||
3. `AGENTS.md` file
|
||||
|
||||
**Scope precedence**: Workspace rules override global rules when both define the same guidance.
|
||||
|
||||
**Multiple files**: When using a `.clinerules/` folder, all Markdown files are combined into one ruleset. Numeric prefixes (like `01-`, `02-`) control the order.
|
||||
|
||||
**Conditional activation**: Rules with YAML frontmatter activate only when you're working with matching files. See [Conditional Rules](/features/cline-rules/conditional-rules) for details.
|
||||
|
||||
## Supported Rule Files
|
||||
|
||||
Cline reads rules from multiple file formats in your workspace root, letting you share rules across different AI coding tools:
|
||||
|
||||
| File/Folder | Source | Notes |
|
||||
|-------------|--------|-------|
|
||||
| `.clinerules/` | Cline | Folder with `.md` files (recommended) |
|
||||
| `.cursor/rules/` | Cursor | Folder with `.mdc` files |
|
||||
| `.windsurf/rules` | Windsurf | Folder with multiple `md` files |
|
||||
| `AGENTS.md` | Universal | Follows [agents.md](https://agents.md/) standard, searched recursively |
|
||||
|
||||
Cline prioritizes `.clinerules` when present. Other formats load only if no `.clinerules` exists (except `AGENTS.md`, which always searches subdirectories). All rules appear in the Rules popover where you can toggle them.
|
||||
|
||||
## Creating Rules
|
||||
|
||||
Click the `+` button in the Rules tab to create a new rule. This opens a file in your editor where you write your guidance.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/cline-rules.png" alt="Create a Rule" />
|
||||
</Frame>
|
||||
|
||||
When you save the file, it's stored in:
|
||||
- **Workspace rules**: `.clinerules/` in your project root
|
||||
- **Global rules**: Platform-specific location (see table below)
|
||||
|
||||
You can also use the [`/newrule` slash command](/features/slash-commands/new-rule) to have Cline generate a rule based on your description.
|
||||
|
||||
### Global Rules Location
|
||||
|
||||
| Operating System | Default Location |
|
||||
|------------------|------------------|
|
||||
| **Windows** | `Documents\Cline\Rules` |
|
||||
| **macOS** | `~/Documents/Cline/Rules` |
|
||||
| **Linux/WSL** | `~/Documents/Cline/Rules` or `~/Cline/Rules` |
|
||||
|
||||
<Note>
|
||||
Linux/WSL users: Check both locations if you don't find global rules in `~/Documents/Cline/Rules`.
|
||||
</Note>
|
||||
|
||||
## Managing Rules
|
||||
|
||||
The Rules popover (below the chat input) shows active rules and lets you toggle them on or off.
|
||||
|
||||
<Frame>
|
||||
<img src="https://storage.googleapis.com/cline_public_images/docs/assets/image%20(1).png" alt="Rules Popover" />
|
||||
</Frame>
|
||||
|
||||
The popover displays:
|
||||
- **Global rules**: From your user-level Rules directory
|
||||
- **Workspace rules**: From `.clinerules/` in your project
|
||||
|
||||
Toggle any rule to enable or disable it. Disabled rules won't load, even if they match conditions.
|
||||
|
||||
## When to Use Rules
|
||||
|
||||
Rules work best for persistent project context:
|
||||
|
||||
- **Code standards**: Formatting preferences, naming conventions, project-specific patterns
|
||||
- **Documentation requirements**: Where to add docs, what format to follow
|
||||
- **Architecture decisions**: Design patterns, dependency rules, module boundaries
|
||||
- **Team conventions**: PR processes, branch naming, commit message format
|
||||
- **Technology constraints**: Required libraries, banned APIs, version requirements
|
||||
|
||||
Rules are less effective for:
|
||||
- One-time instructions (just say it in the chat)
|
||||
- Complex multi-step workflows (use [Workflows](/features/slash-commands/workflows/index) instead)
|
||||
- Dynamic decisions that depend on runtime context
|
||||
|
||||
## Example Rule
|
||||
|
||||
```markdown
|
||||
# Backend API Guidelines
|
||||
|
||||
## Route Handlers
|
||||
|
||||
- Use async/await, not callbacks
|
||||
- Validate request bodies with Zod schemas
|
||||
- Return typed errors from `src/errors.ts`
|
||||
- All routes require authentication unless in `publicRoutes` array
|
||||
|
||||
## Database Access
|
||||
|
||||
- All queries go through repository classes in `src/repositories/`
|
||||
- Use transactions for multi-table updates
|
||||
- Never expose raw database errors to clients
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests for business logic in `src/services/`
|
||||
- Integration tests for route handlers in `src/routes/`
|
||||
- Mock external APIs, not internal modules
|
||||
```
|
||||
|
||||
This rule provides clear, actionable guidance without explaining obvious concepts or using vague language.
|
||||
|
||||
## Using a Folder Structure
|
||||
|
||||
For projects with many rules, organize them in a `.clinerules/` folder:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules/
|
||||
│ ├── 01-coding-standards.md
|
||||
│ ├── 02-documentation.md
|
||||
│ └── 03-testing.md
|
||||
├── src/
|
||||
└── ...
|
||||
```
|
||||
|
||||
Cline loads all Markdown files in `.clinerules/` automatically. The numeric prefixes help you control ordering, but they're optional.
|
||||
|
||||
### Organizing a Rules Bank
|
||||
|
||||
Maintain a separate folder for rules you might need but don't always want active:
|
||||
|
||||
```
|
||||
your-project/
|
||||
├── .clinerules/ # Active rules
|
||||
│ ├── 01-coding.md
|
||||
│ └── client-a.md
|
||||
│
|
||||
├── clinerules-bank/ # Available but inactive
|
||||
│ ├── clients/
|
||||
│ │ ├── client-a.md
|
||||
│ │ └── client-b.md
|
||||
│ └── frameworks/
|
||||
│ ├── react.md
|
||||
│ └── vue.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
Copy files from the bank to `.clinerules/` when you need them. This keeps your active context lean while maintaining a library of reusable guidance.
|
||||
|
||||
Switch contexts with simple file operations:
|
||||
|
||||
```bash
|
||||
# Switch to Client B
|
||||
rm .clinerules/client-a.md
|
||||
cp clinerules-bank/clients/client-b.md .clinerules/
|
||||
```
|
||||
|
||||
<Tip>
|
||||
Consider git-ignoring `.clinerules/` while tracking `clinerules-bank/` so team members can activate the rules relevant to their current work.
|
||||
</Tip>
|
||||
|
||||
## Conditional Rules
|
||||
|
||||
Scope rules to specific file patterns using YAML frontmatter. This keeps React guidance out of Python code and backend rules away from frontend work.
|
||||
|
||||
```yaml
|
||||
---
|
||||
paths:
|
||||
- "src/components/**"
|
||||
- "src/hooks/**"
|
||||
---
|
||||
|
||||
# React Guidelines
|
||||
|
||||
Use functional components with hooks. Extract reusable logic into custom hooks.
|
||||
```
|
||||
|
||||
This rule activates only when working with files matching those patterns. Read the [Conditional Rules guide](/features/cline-rules/conditional-rules) for pattern syntax, behavior details, and more examples.
|
||||
|
||||
## Tips for Effective Rules
|
||||
|
||||
**Be specific**: "Use async/await for all database calls" beats "write good async code."
|
||||
|
||||
**Show patterns**: Include file paths and real examples. "Follow the error handling in `src/utils/errors.ts`" gives Cline a concrete reference.
|
||||
|
||||
**Focus on outcomes**: Describe what you want, not step-by-step instructions. Let Cline figure out how.
|
||||
|
||||
**Test and refine**: Start with core standards. Add rules when you find yourself repeating the same feedback.
|
||||
|
||||
**Use conditional rules**: Load guidance only when relevant. This keeps context efficient and reduces noise.
|
||||
|
||||
## Related
|
||||
|
||||
- [Conditional Rules](/features/cline-rules/conditional-rules) - Activate rules based on file patterns
|
||||
- [Skills](/features/skills) - Load instructions on demand with `/skill` command
|
||||
- [Workflows](/features/slash-commands/workflows/index) - Define explicit task automation
|
||||
- [New Rule Slash Command](/features/slash-commands/new-rule) - Generate rules with AI assistance
|
||||
- [Plan and Act Mode](/features/plan-and-act) - Use different rules for planning vs execution
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
title: "Jupyter Notebooks"
|
||||
description: "AI-assisted editing of Jupyter notebooks with cell-level context awareness"
|
||||
---
|
||||
|
||||
# Jupyter Notebooks
|
||||
|
||||
Cline provides comprehensive support for Jupyter notebooks (`.ipynb` files), enabling AI-assisted editing with full cell-level context awareness. This feature was developed in collaboration with Amazon to bring AI coding assistance to data science workflows.
|
||||
|
||||
## Getting started
|
||||
|
||||
Jupyter notebook support is a built-in feature of Cline. To use it, you just need to have the Jupyter notebook extension enabled in VS Code. Once you open any `.ipynb` file, you'll see AI-assisted buttons in your notebook interface.
|
||||
|
||||
## How to use
|
||||
|
||||
### Generate Cell
|
||||
|
||||
Click the sparkle icon (✨) in the notebook toolbar to generate new cells with AI assistance.
|
||||
|
||||

|
||||
|
||||
**How it works:** The AI receives context from surrounding cells, so it understands the variables and imports already in scope. This means you can reference existing DataFrames, functions, and other objects without re-explaining them.
|
||||
|
||||
**Example prompt:** "Create a visualization showing the correlation matrix of numeric columns with a heatmap"
|
||||
|
||||
The cell is inserted with proper notebook JSON structure, preserving metadata and ready to execute.
|
||||
|
||||
### Explain Cell
|
||||
|
||||
Click the Explain button in any cell's title bar to get a detailed explanation of what the cell does.
|
||||
|
||||
This is useful for:
|
||||
|
||||
- Revisiting old notebooks
|
||||
- Onboarding to a teammate's analysis
|
||||
- Understanding complex transformations
|
||||
|
||||
**How it works:** Cline extracts the full cell context, including outputs, so explanations can reference actual results like column names, row counts, and computed values.
|
||||
|
||||
### Improve Cell
|
||||
|
||||
Click the Improve button in any cell's title bar to enhance existing cells with AI suggestions.
|
||||
|
||||

|
||||
|
||||
Use this to:
|
||||
|
||||
- Optimize slow pandas operations
|
||||
- Add error handling
|
||||
- Refactor for clarity
|
||||
- Convert loops to vectorized operations
|
||||
|
||||
**How it works:** Cline suggests improvements while preserving the cell's position and metadata in the notebook structure. The AI explains what was changed and why.
|
||||
|
||||
## How cell context works
|
||||
|
||||
Unlike traditional file editing, Jupyter notebooks are JSON documents containing arrays of cells. Each cell has its own type, source content, metadata, execution count, and outputs.
|
||||
|
||||
When you use a Jupyter command, Cline extracts structured context that includes:
|
||||
|
||||
- **Cell type** (code, markdown, or raw)
|
||||
- **Source content** as an array of lines
|
||||
- **Cell metadata** and configuration
|
||||
- **Execution count** for code cells
|
||||
- **Outputs** including data, text, and error traces
|
||||
|
||||
This structured representation allows the AI to understand not just the code, but its context within the notebook and its actual output.
|
||||
|
||||
### JSON structure preservation
|
||||
|
||||
Cline is designed to work carefully with the cell JSON structure, aiming to:
|
||||
|
||||
- Keep cell boundaries intact
|
||||
- Preserve execution counts
|
||||
- Maintain cell metadata
|
||||
- Keep outputs associated with their source cells
|
||||
|
||||
The AI is specifically prompted to preserve notebook structure, though you should always review changes to ensure your notebook format remains correct.
|
||||
|
||||
## Keyboard shortcuts
|
||||
|
||||
You can bind any of these commands to keyboard shortcuts for faster access:
|
||||
|
||||
1. Open VS Code keyboard shortcuts (Cmd/Ctrl + K, Cmd/Ctrl + S)
|
||||
2. Search for `cline.jupyterGenerateCell`, `cline.jupyterExplainCell`, or `cline.jupyterImproveCell`
|
||||
3. Assign your preferred key combinations
|
||||
|
||||
## Tips for best results
|
||||
|
||||
**For Generate Cell:**
|
||||
- Be specific about what you want the cell to do
|
||||
- Reference existing variables by name (the AI can see them)
|
||||
- Mention preferred libraries if you have a preference (e.g., "use seaborn" or "use plotly")
|
||||
|
||||
**For Explain Cell:**
|
||||
- Works best on cells that have been executed (outputs provide additional context)
|
||||
- Good for complex chained operations like pandas groupby/merge sequences
|
||||
|
||||
**For Improve Cell:**
|
||||
- Mention what aspect you want to improve (performance, readability, error handling)
|
||||
- The AI will explain the changes it suggests
|
||||
|
||||
## Limitations
|
||||
|
||||
- Notebook support requires the Jupyter notebook extension to be enabled in VS Code
|
||||
- Cell context extraction depends on VS Code's notebook API
|
||||
- Very large notebooks may require more context than some models can handle efficiently
|
||||
|
||||
## Related
|
||||
|
||||
- [Cline Tools Guide](/exploring-clines-tools/cline-tools-guide) -- Overview of all Cline commands
|
||||
- [Model Selection Guide](/core-features/model-selection-guide) -- Choosing the right model for your workflow
|
||||
@@ -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!
|
||||
@@ -25,11 +25,28 @@ The easiest way to get started is with **Cline** as your provider:
|
||||
|
||||
## Alternative: Use Another Provider
|
||||
|
||||
If you prefer to use your own API keys, you can select from providers like:
|
||||
If you prefer to use your own accounts or API keys, you have several options:
|
||||
|
||||
### Sign in with OpenAI (Recommended)
|
||||
|
||||
The easiest way to use OpenAI models is with **OpenAI Codex**—no API keys needed:
|
||||
|
||||
1. Select **"OpenAI Codex"** from the API Provider dropdown
|
||||
2. Click **"Sign in with OpenAI"**
|
||||
3. Authorize Cline in your browser
|
||||
4. Choose your model
|
||||
|
||||
<Card title="OpenAI Codex Setup Guide" icon="key" href="/provider-config/openai-codex">
|
||||
See the full setup guide with screenshots and troubleshooting tips.
|
||||
</Card>
|
||||
|
||||
### Other Providers
|
||||
|
||||
You can also use API keys with these providers:
|
||||
|
||||
- **OpenRouter** - Great value, multiple models
|
||||
- **Anthropic** - Direct access to Claude models
|
||||
- **OpenAI** - Access to GPT models
|
||||
- **OpenAI** - Access to GPT models via API key
|
||||
- **Google Gemini** - Google's AI models
|
||||
- **Ollama** - Run models locally on your computer
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ Cerebras delivers the world's fastest AI inference through their revolutionary w
|
||||
|
||||
Cline supports the following Cerebras models:
|
||||
|
||||
- `zai-glm-4.6` - Fast general-purpose model on Cerebras (up to 1,000 tokens/s). To be deprecated soon.
|
||||
- `zai-glm-4.7` - Highly capable general-purpose model on Cerebras (up to 1,000 tokens/s), competitive with leading proprietary models on coding tasks.
|
||||
- `qwen-3-235b-a22b-instruct-2507` - Advanced instruction-following model
|
||||
- `qwen-3-235b-a22b-thinking-2507` - Reasoning model with step-by-step thinking
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
---
|
||||
title: "OpenAI Codex"
|
||||
sidebarTitle: "OpenAI Codex"
|
||||
description: "Connect your OpenAI account to Cline via OAuth for seamless access to OpenAI models."
|
||||
---
|
||||
|
||||
OpenAI Codex lets you connect your OpenAI account directly to Cline using OAuth, meaning you don't need to manage API keys. Simply sign in with your OpenAI account through a one-click browser authentication, and you'll automatically have access to all the models available on your OpenAI plan. No additional configuration required.
|
||||
|
||||
<Tip>
|
||||
If you prefer to use API keys instead, see the [OpenAI (API Key)](/provider-config/openai) provider configuration.
|
||||
</Tip>
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- An [OpenAI account](https://platform.openai.com/signup)
|
||||
- An active OpenAI subscription or API access plan
|
||||
|
||||
<Note>
|
||||
The models available to you depend on your OpenAI account's subscription tier. See [OpenAI's pricing page](https://openai.com/pricing) for details on what's included in each plan.
|
||||
</Note>
|
||||
|
||||
## Connecting Your OpenAI Account
|
||||
|
||||
<Steps>
|
||||
<Step title="Open Cline Settings">
|
||||
Click the settings gear icon in the top-right corner of the Cline extension.
|
||||
</Step>
|
||||
<Step title="Select OpenAI Codex Provider">
|
||||
Choose **"OpenAI Codex"** from the "API Provider" dropdown menu.
|
||||
</Step>
|
||||
<Step title="Sign In with OpenAI">
|
||||
Click the **"Sign in with OpenAI"** button. This will open a browser window for authentication.
|
||||
</Step>
|
||||
<Step title="Authorize Cline">
|
||||
In the browser window that opens:
|
||||
1. Sign in to your OpenAI account (if not already signed in)
|
||||
2. Review the permissions Cline is requesting
|
||||
3. Click **"Authorize"** to grant access
|
||||
</Step>
|
||||
<Step title="Select Your Model">
|
||||
Once authorized, you'll be returned to Cline. Choose your desired model from the **"Model"** dropdown.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
If a model doesn't appear in your dropdown, it may not be available on your OpenAI subscription tier.
|
||||
</Note>
|
||||
|
||||
For the most current list of available models and their capabilities, see the official [OpenAI Models documentation](https://platform.openai.com/docs/models).
|
||||
|
||||
## Managing Your Connection
|
||||
|
||||
### Disconnecting Your Account
|
||||
|
||||
To disconnect your OpenAI account from Cline:
|
||||
|
||||
1. Open Cline Settings
|
||||
2. With OpenAI Codex selected as the provider, click **"Sign out"**
|
||||
3. Confirm the disconnection
|
||||
|
||||
This removes the OAuth connection. You can reconnect at any time by signing in again.
|
||||
|
||||
### Token Refresh
|
||||
|
||||
OAuth tokens are automatically refreshed by Cline. If you encounter authentication errors:
|
||||
|
||||
1. Try disconnecting and reconnecting your account
|
||||
2. Ensure your OpenAI account is in good standing
|
||||
3. Check that your subscription is active
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Authentication failed" error
|
||||
|
||||
- Ensure you're signing in with the correct OpenAI account
|
||||
- Check that your OpenAI subscription is active
|
||||
- Try clearing your browser cache and signing in again
|
||||
|
||||
### Models not appearing
|
||||
|
||||
- The available models depend on your OpenAI subscription tier
|
||||
- Some models may require specific plan upgrades
|
||||
- Check [OpenAI's pricing page](https://openai.com/pricing) for model availability by plan
|
||||
|
||||
### OAuth window doesn't open
|
||||
|
||||
- Check if pop-ups are blocked in your browser
|
||||
- Try using a different browser
|
||||
- Ensure you have a stable internet connection
|
||||
|
||||
### Connection keeps expiring
|
||||
|
||||
- This is rare but can happen if your OpenAI account session expired
|
||||
- Disconnect and reconnect to refresh your authentication
|
||||
|
||||
## Good to Know
|
||||
|
||||
Cline only requests the permissions necessary to make API calls on your behalf. Your OpenAI credentials are never stored by Cline.
|
||||
|
||||
When using OpenAI Codex, Cline accesses models through your OpenAI/ChatGPT subscription. There is no separate per-token API billing or consumption of OpenAI API credits for this provider; usage and limits are governed by your ChatGPT (or OpenAI account) subscription plan, not by Cline.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [OpenAI (API Key)](/provider-config/openai): Alternative configuration using API keys
|
||||
- [Model Selection Guide](/core-features/model-selection-guide): Help choosing the right model
|
||||
@@ -73,16 +73,16 @@ Open VS Code and configure Cline:
|
||||
|
||||
### Recommended Models
|
||||
|
||||
For the best experience with Cline, use **Qwen3 Coder 30B**. This model provides strong coding capabilities and reliable tool use for local development.
|
||||
For the best experience with Cline, use **Qwen 2.5 Coder 32B**. This model provides strong coding capabilities and reliable tool use for local development.
|
||||
|
||||
To download it:
|
||||
```bash
|
||||
ollama run qwen3-coder-30b
|
||||
ollama pull qwen2.5-coder:32b
|
||||
```
|
||||
|
||||
Other capable models include:
|
||||
- `mistral-small` - Good balance of performance and speed
|
||||
- `devstral-small` - Optimized for coding tasks
|
||||
- `mistral-small:latest` - Good balance of performance and speed
|
||||
- `codellama:34b-code` - Optimized for coding tasks
|
||||
|
||||
### Important Notes
|
||||
|
||||
|
||||
@@ -144,10 +144,6 @@ if (process.env.ERROR_SERVICE_API_KEY) {
|
||||
buildEnvVars["process.env.ERROR_SERVICE_API_KEY"] = JSON.stringify(process.env.ERROR_SERVICE_API_KEY)
|
||||
}
|
||||
|
||||
if (process.env.POSTHOG_TELEMETRY_ENABLED) {
|
||||
buildEnvVars["process.env.POSTHOG_TELEMETRY_ENABLED"] = JSON.stringify(process.env.POSTHOG_TELEMETRY_ENABLED)
|
||||
}
|
||||
|
||||
// OpenTelemetry configuration (injected at build time from GitHub secrets)
|
||||
// These provide production defaults that can be overridden at runtime via environment variables
|
||||
if (process.env.OTEL_TELEMETRY_ENABLED) {
|
||||
|
||||
Generated
+309
-231
File diff suppressed because it is too large
Load Diff
+44
-1
@@ -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.55.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
@@ -201,6 +201,24 @@
|
||||
"title": "Improve with Cline",
|
||||
"category": "Cline"
|
||||
},
|
||||
{
|
||||
"command": "cline.jupyterGenerateCell",
|
||||
"title": "Generate Jupyter Cell with Cline",
|
||||
"category": "Cline",
|
||||
"icon": "$(sparkle)"
|
||||
},
|
||||
{
|
||||
"command": "cline.jupyterExplainCell",
|
||||
"title": "Explain Jupyter Cell with Cline",
|
||||
"category": "Cline",
|
||||
"icon": "$(question)"
|
||||
},
|
||||
{
|
||||
"command": "cline.jupyterImproveCell",
|
||||
"title": "Improve Jupyter Cell with Cline",
|
||||
"category": "Cline",
|
||||
"icon": "$(lightbulb)"
|
||||
},
|
||||
{
|
||||
"command": "cline.openWalkthrough",
|
||||
"title": "Open Walkthrough",
|
||||
@@ -304,6 +322,25 @@
|
||||
"when": "config.git.enabled && scmProvider == git && cline.isGeneratingCommit"
|
||||
}
|
||||
],
|
||||
"notebook/toolbar": [
|
||||
{
|
||||
"command": "cline.jupyterGenerateCell",
|
||||
"group": "navigation/add@1",
|
||||
"when": "notebookType == 'jupyter-notebook'"
|
||||
}
|
||||
],
|
||||
"notebook/cell/title": [
|
||||
{
|
||||
"command": "cline.jupyterExplainCell",
|
||||
"group": "inline@1",
|
||||
"when": "notebookType == 'jupyter-notebook'"
|
||||
},
|
||||
{
|
||||
"command": "cline.jupyterImproveCell",
|
||||
"group": "inline@2",
|
||||
"when": "notebookType == 'jupyter-notebook'"
|
||||
}
|
||||
],
|
||||
"commandPalette": [
|
||||
{
|
||||
"command": "cline.generateGitCommitMessage",
|
||||
@@ -403,6 +440,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"
|
||||
]
|
||||
@@ -494,6 +535,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vscode/codicons": "^0.0.36",
|
||||
"archiver": "^7.0.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"axios": "^1.12.0",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"cheerio": "^1.0.0",
|
||||
@@ -528,6 +570,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",
|
||||
|
||||
@@ -44,6 +44,13 @@ service AccountService {
|
||||
|
||||
// Returns a link the webview can use to redirect back to the user's IDE.
|
||||
rpc getRedirectUrl(EmptyRequest) returns (String);
|
||||
|
||||
// OpenAI Codex OAuth authentication
|
||||
// Starts the OAuth flow and opens browser for user to sign in with ChatGPT Plus/Pro
|
||||
rpc openAiCodexSignIn(EmptyRequest) returns (Empty);
|
||||
|
||||
// Signs out of OpenAI Codex and clears stored credentials
|
||||
rpc openAiCodexSignOut(EmptyRequest) returns (Empty);
|
||||
}
|
||||
|
||||
message AuthStateChangedRequest {
|
||||
|
||||
@@ -75,6 +75,19 @@ message McpResourceTemplate {
|
||||
optional string description = 4;
|
||||
}
|
||||
|
||||
message McpPromptArgument {
|
||||
string name = 1;
|
||||
optional string description = 2;
|
||||
optional bool required = 3;
|
||||
}
|
||||
|
||||
message McpPrompt {
|
||||
string name = 1;
|
||||
optional string title = 2;
|
||||
optional string description = 3;
|
||||
repeated McpPromptArgument arguments = 4;
|
||||
}
|
||||
|
||||
enum McpServerStatus {
|
||||
// Protobuf enums (in proto3) must have a zero value defined, which serves as the default if the field isn't explicitly set.
|
||||
// To align with the required nature of the TypeScript type and avoid an unnecessary UNSPECIFIED state, we map one of the existing statuses to this zero value.
|
||||
@@ -95,6 +108,7 @@ message McpServer {
|
||||
optional int32 timeout = 9;
|
||||
optional bool oauth_required = 10;
|
||||
optional string oauth_auth_status = 11;
|
||||
repeated McpPrompt prompts = 12;
|
||||
}
|
||||
|
||||
message McpServers {
|
||||
|
||||
@@ -439,6 +439,7 @@ enum ApiProvider {
|
||||
HICAP = 37;
|
||||
AIHUBMIX = 38;
|
||||
NOUSRESEARCH = 39;
|
||||
OPENAI_CODEX = 40;
|
||||
}
|
||||
|
||||
enum ApiFormat {
|
||||
|
||||
+219
-172
@@ -54,184 +54,229 @@ 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;
|
||||
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 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;
|
||||
map<string, string> open_ai_headers = 173;
|
||||
}
|
||||
|
||||
message DictationSettings {
|
||||
@@ -338,6 +383,8 @@ message UpdateTaskSettingsRequest {
|
||||
|
||||
// Message for updating settings
|
||||
message UpdateSettingsRequest {
|
||||
reserved 26; // was hooks_enabled (removed - now always enabled on macOS/Linux)
|
||||
|
||||
Metadata metadata = 1;
|
||||
optional ModelsApiConfiguration api_configuration = 2;
|
||||
optional string telemetry_setting = 3;
|
||||
@@ -362,7 +409,6 @@ message UpdateSettingsRequest {
|
||||
optional DictationSettings dictation_settings = 23;
|
||||
optional double auto_condense_threshold = 24;
|
||||
optional bool multi_root_enabled = 25;
|
||||
optional bool hooks_enabled = 26;
|
||||
optional string vscode_terminal_execution_mode = 27;
|
||||
optional int32 max_consecutive_mistakes = 28;
|
||||
optional bool subagents_enabled = 29;
|
||||
@@ -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 {
|
||||
|
||||
@@ -70,6 +70,7 @@ enum ClineSay {
|
||||
HOOK_STATUS = 30;
|
||||
HOOK_OUTPUT_STREAM = 31;
|
||||
COMMAND_PERMISSION_DENIED = 32;
|
||||
CONDITIONAL_RULES_APPLIED = 33;
|
||||
}
|
||||
|
||||
// Enum for ClineSayTool tool types
|
||||
@@ -256,6 +257,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"
|
||||
}
|
||||
@@ -33,6 +33,9 @@ service EnvService {
|
||||
|
||||
// Initiates a graceful shutdown of the host bridge service.
|
||||
rpc shutdown(cline.EmptyRequest) returns (cline.Empty);
|
||||
|
||||
// Logs a debug message to the host environment's log/output console.
|
||||
rpc debugLog(cline.StringRequest) returns (cline.Empty);
|
||||
}
|
||||
|
||||
message GetHostVersionResponse {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -22,19 +22,34 @@ 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
|
||||
rm -rf "$PROJECT_ROOT/cli/bin"
|
||||
if command -v go >/dev/null 2>&1; then
|
||||
GO_BIN_DIR="$(go env GOPATH 2>/dev/null)/bin"
|
||||
if [ -d "$GO_BIN_DIR" ]; then
|
||||
export PATH="$GO_BIN_DIR:$PATH"
|
||||
fi
|
||||
fi
|
||||
if npm run compile-cli; 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}"
|
||||
@@ -57,6 +72,7 @@ rsync -a --exclude='bin' "$PROJECT_ROOT/dist-standalone/" "$INSTALL_DIR/"
|
||||
echo -e "${CYAN}→${NC} ${DIM}Installing runtime dependencies...${NC}"
|
||||
cd "$PROJECT_ROOT/standalone/runtime-files"
|
||||
npm install --silent 2>/dev/null || npm install
|
||||
rm -rf "$INSTALL_DIR/node_modules"
|
||||
cp -r node_modules "$INSTALL_DIR/"
|
||||
cp -r vscode "$INSTALL_DIR/node_modules/"
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
@@ -105,7 +105,7 @@ async function main(): Promise<void> {
|
||||
console.log("Extracting standalone.zip to extensions directory...")
|
||||
try {
|
||||
if (!fs.existsSync(extensionsDir)) {
|
||||
execSync(`unzip -q "${standaloneZipPath}" -d "${extensionsDir}"`, { stdio: "inherit" })
|
||||
execSync(`unzip -o -q "${standaloneZipPath}" -d "${extensionsDir}"`, { stdio: "inherit" })
|
||||
}
|
||||
console.log(`Successfully extracted standalone.zip to: ${extensionsDir}`)
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
import { afterEach, beforeEach, describe, it } from "mocha"
|
||||
import "should"
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import sinon from "sinon"
|
||||
import { ClineConfigurationError, ClineEndpoint, ClineEnv, Environment } from "../config"
|
||||
|
||||
describe("ClineEndpoint configuration", () => {
|
||||
let sandbox: sinon.SinonSandbox
|
||||
let tempDir: string
|
||||
let originalHomedir: typeof os.homedir
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox()
|
||||
tempDir = path.join(os.tmpdir(), `config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
await fs.mkdir(tempDir, { recursive: true })
|
||||
|
||||
// Create .cline directory
|
||||
await fs.mkdir(path.join(tempDir, ".cline"), { recursive: true })
|
||||
|
||||
// Stub os.homedir to return our temp directory
|
||||
originalHomedir = os.homedir
|
||||
sandbox
|
||||
.stub(os, "homedir")
|
||||
.returns(tempDir)
|
||||
|
||||
// Reset the singleton state using internal method
|
||||
;(ClineEndpoint as any)._instance = null
|
||||
;(ClineEndpoint as any)._initialized = false
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
sandbox.restore()
|
||||
// Reset singleton state
|
||||
;(ClineEndpoint as any)._instance = null
|
||||
;(ClineEndpoint as any)._initialized = false
|
||||
try {
|
||||
await fs.rm(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
})
|
||||
|
||||
describe("valid config parsing", () => {
|
||||
it("should parse valid endpoints.json with all required fields", async () => {
|
||||
const validConfig = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("https://app.enterprise.com")
|
||||
config.apiBaseUrl.should.equal("https://api.enterprise.com")
|
||||
config.mcpBaseUrl.should.equal("https://mcp.enterprise.com")
|
||||
config.environment.should.equal(Environment.selfHosted)
|
||||
})
|
||||
|
||||
it("should work without endpoints.json (standard mode)", async () => {
|
||||
// No endpoints.json file exists
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.environment.should.not.equal(Environment.selfHosted)
|
||||
// Should use production defaults
|
||||
config.appBaseUrl.should.equal("https://app.cline.bot")
|
||||
config.apiBaseUrl.should.equal("https://api.cline.bot")
|
||||
})
|
||||
|
||||
it("should accept URLs with ports", async () => {
|
||||
const validConfig = {
|
||||
appBaseUrl: "http://localhost:3000",
|
||||
apiBaseUrl: "http://localhost:7777",
|
||||
mcpBaseUrl: "http://localhost:8080/mcp",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("http://localhost:3000")
|
||||
config.apiBaseUrl.should.equal("http://localhost:7777")
|
||||
config.mcpBaseUrl.should.equal("http://localhost:8080/mcp")
|
||||
})
|
||||
|
||||
it("should accept URLs with paths", async () => {
|
||||
const validConfig = {
|
||||
appBaseUrl: "https://proxy.enterprise.com/cline/app",
|
||||
apiBaseUrl: "https://proxy.enterprise.com/cline/api",
|
||||
mcpBaseUrl: "https://proxy.enterprise.com/cline/mcp",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(validConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("https://proxy.enterprise.com/cline/app")
|
||||
})
|
||||
})
|
||||
|
||||
describe("invalid JSON handling", () => {
|
||||
it("should throw ClineConfigurationError for invalid JSON syntax", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "{ invalid json }", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("Invalid JSON")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for truncated JSON", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), '{"appBaseUrl": "https://test.com"', "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("Invalid JSON")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for empty file", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for non-object JSON", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), '"just a string"', "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must contain a JSON object")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for array JSON", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "[]", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
// Arrays pass the object check but fail on required fields
|
||||
error.message.should.containEql("Missing required field")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for null JSON", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "null", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must contain a JSON object")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("missing required fields", () => {
|
||||
it("should throw ClineConfigurationError when appBaseUrl is missing", async () => {
|
||||
const config = {
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql('Missing required field "appBaseUrl"')
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when apiBaseUrl is missing", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql('Missing required field "apiBaseUrl"')
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when mcpBaseUrl is missing", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql('Missing required field "mcpBaseUrl"')
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when all fields are missing", async () => {
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), "{}", "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("Missing required field")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when field is null", async () => {
|
||||
const config = {
|
||||
appBaseUrl: null,
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql('Missing required field "appBaseUrl"')
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when field is empty string", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("cannot be empty")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when field is whitespace only", async () => {
|
||||
const config = {
|
||||
appBaseUrl: " ",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("cannot be empty")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError when field is non-string", async () => {
|
||||
const config = {
|
||||
appBaseUrl: 12345,
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must be a string")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("invalid URL detection", () => {
|
||||
it("should throw ClineConfigurationError for invalid URL format", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "not-a-valid-url",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must be a valid URL")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for URL without protocol", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must be a valid URL")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw ClineConfigurationError for malformed URL", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql("must be a valid URL")
|
||||
}
|
||||
})
|
||||
|
||||
it("should include the invalid URL value in error message", async () => {
|
||||
const invalidUrl = "definitely-not-a-url"
|
||||
const config = {
|
||||
appBaseUrl: invalidUrl,
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
try {
|
||||
await ClineEndpoint.initialize()
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.should.be.instanceof(ClineConfigurationError)
|
||||
error.message.should.containEql(invalidUrl)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("environment switching blocked in self-hosted mode", () => {
|
||||
it("should throw error when trying to change environment in self-hosted mode", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
// Verify we're in self-hosted mode
|
||||
ClineEndpoint.config.environment.should.equal(Environment.selfHosted)
|
||||
|
||||
// Try to change environment - should throw
|
||||
try {
|
||||
ClineEnv.setEnvironment("staging")
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.containEql("Cannot change environment in on-premise mode")
|
||||
}
|
||||
})
|
||||
|
||||
it("should throw error for all environment values in self-hosted mode", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
const environments = ["staging", "local", "production", "anything"]
|
||||
for (const env of environments) {
|
||||
try {
|
||||
ClineEnv.setEnvironment(env)
|
||||
throw new Error(`Should have thrown for environment: ${env}`)
|
||||
} catch (error: any) {
|
||||
error.message.should.containEql("Cannot change environment in on-premise mode")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it("should allow environment switching in standard mode", async () => {
|
||||
// No endpoints.json file - standard mode
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
// Verify we're NOT in self-hosted mode
|
||||
ClineEndpoint.config.environment.should.not.equal(Environment.selfHosted)
|
||||
|
||||
// Should be able to change environment
|
||||
ClineEnv.setEnvironment("staging")
|
||||
ClineEnv.getEnvironment().environment.should.equal("staging")
|
||||
|
||||
ClineEnv.setEnvironment("local")
|
||||
ClineEnv.getEnvironment().environment.should.equal("local")
|
||||
|
||||
ClineEnv.setEnvironment("production")
|
||||
ClineEnv.getEnvironment().environment.should.equal("production")
|
||||
})
|
||||
})
|
||||
|
||||
describe("self-hosted mode behavior", () => {
|
||||
it("should report selfHosted environment in self-hosted mode", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
const envConfig = ClineEndpoint.config
|
||||
envConfig.environment.should.equal(Environment.selfHosted)
|
||||
})
|
||||
|
||||
it("should use custom endpoints from file", async () => {
|
||||
const customConfig = {
|
||||
appBaseUrl: "https://custom-app.internal",
|
||||
apiBaseUrl: "https://custom-api.internal",
|
||||
mcpBaseUrl: "https://custom-mcp.internal/v1",
|
||||
}
|
||||
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(customConfig), "utf8")
|
||||
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
const config = ClineEndpoint.config
|
||||
config.appBaseUrl.should.equal("https://custom-app.internal")
|
||||
config.apiBaseUrl.should.equal("https://custom-api.internal")
|
||||
config.mcpBaseUrl.should.equal("https://custom-mcp.internal/v1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("initialization behavior", () => {
|
||||
it("should only initialize once", async () => {
|
||||
await ClineEndpoint.initialize()
|
||||
ClineEndpoint.isInitialized().should.be.true()
|
||||
|
||||
// Second initialize should be a no-op
|
||||
await ClineEndpoint.initialize()
|
||||
ClineEndpoint.isInitialized().should.be.true()
|
||||
})
|
||||
|
||||
it("should throw error when accessing config before initialization", async () => {
|
||||
// Already reset in beforeEach, so accessing should throw
|
||||
try {
|
||||
const _ = ClineEndpoint.config
|
||||
throw new Error("Should have thrown")
|
||||
} catch (error: any) {
|
||||
error.message.should.containEql("not initialized")
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("isSelfHosted() method", () => {
|
||||
it("should return true when not initialized (safety fallback)", async () => {
|
||||
// Reset singleton state - already done in beforeEach, not initialized
|
||||
ClineEndpoint.isInitialized().should.be.false()
|
||||
ClineEndpoint.isSelfHosted().should.be.true()
|
||||
})
|
||||
|
||||
it("should return true when in self-hosted mode", async () => {
|
||||
const config = {
|
||||
appBaseUrl: "https://app.enterprise.com",
|
||||
apiBaseUrl: "https://api.enterprise.com",
|
||||
mcpBaseUrl: "https://mcp.enterprise.com",
|
||||
}
|
||||
await fs.writeFile(path.join(tempDir, ".cline", "endpoints.json"), JSON.stringify(config), "utf8")
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
ClineEndpoint.isSelfHosted().should.be.true()
|
||||
})
|
||||
|
||||
it("should return false when in normal mode (no endpoints.json)", async () => {
|
||||
// No endpoints.json file exists
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
ClineEndpoint.isSelfHosted().should.be.false()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,294 +0,0 @@
|
||||
import { ModelInfo } from "@shared/api"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { ApiHandler } from "../../core/api/index"
|
||||
import { ApiStream } from "../../core/api/transform/stream"
|
||||
|
||||
interface DifyHandlerOptions {
|
||||
difyApiKey?: string
|
||||
difyBaseUrl?: string
|
||||
}
|
||||
|
||||
export class DifyHandler implements ApiHandler {
|
||||
private options: DifyHandlerOptions
|
||||
private baseUrl: string
|
||||
private apiKey: string
|
||||
private conversationId: string | null = null
|
||||
|
||||
constructor(options: DifyHandlerOptions) {
|
||||
this.options = options
|
||||
this.apiKey = options.difyApiKey || ""
|
||||
this.baseUrl = options.difyBaseUrl || ""
|
||||
|
||||
console.log("[DIFY DEBUG] Constructor called with:", {
|
||||
hasApiKey: !!this.apiKey,
|
||||
baseUrl: this.baseUrl,
|
||||
})
|
||||
|
||||
if (!this.apiKey) {
|
||||
throw new Error("Dify API key is required")
|
||||
}
|
||||
if (!this.baseUrl) {
|
||||
throw new Error("Dify base URL is required")
|
||||
}
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
|
||||
console.log("[DIFY DEBUG] createMessage called with:", {
|
||||
systemPromptLength: systemPrompt?.length || 0,
|
||||
messagesCount: messages?.length || 0,
|
||||
})
|
||||
|
||||
// Convert messages to Dify format
|
||||
const query = this.convertMessagesToQuery(systemPrompt, messages)
|
||||
const requestBody = {
|
||||
inputs: {},
|
||||
query: query,
|
||||
response_mode: "streaming",
|
||||
conversation_id: this.conversationId || "",
|
||||
user: "cline-user", // A unique user identifier
|
||||
files: [],
|
||||
}
|
||||
|
||||
const fullUrl = `${this.baseUrl}/chat-messages`
|
||||
console.log("[DIFY DEBUG] Making request to:", fullUrl)
|
||||
console.log("[DIFY DEBUG] Request body:", JSON.stringify(requestBody, null, 2))
|
||||
console.log("[DIFY DEBUG] Current process environment variables (for proxy debugging):", process.env)
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(fullUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error("[DIFY DEBUG] Network error during fetch:", error)
|
||||
// Log more detailed error information if available (e.g., from undici)
|
||||
const cause = error.cause ? ` | Cause: ${error.cause}` : ""
|
||||
throw new Error(`Dify API network error: ${error.message}${cause}`)
|
||||
}
|
||||
|
||||
console.log("[DIFY DEBUG] Response status:", response.status)
|
||||
const headersObj: Record<string, string> = {}
|
||||
response.headers.forEach((value, key) => {
|
||||
headersObj[key] = value
|
||||
})
|
||||
console.log("[DIFY DEBUG] Response headers:", headersObj)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error("[DIFY DEBUG] Error response:", errorText)
|
||||
throw new Error(`Dify API error: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("No response body from Dify API")
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
let fullText = ""
|
||||
|
||||
console.log("[DIFY DEBUG] Starting to read streaming response...")
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
console.log("[DIFY DEBUG] Stream ended naturally")
|
||||
break
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
console.log("[DIFY DEBUG] Raw chunk received:", JSON.stringify(chunk))
|
||||
|
||||
buffer += chunk
|
||||
const lines = buffer.split("\n")
|
||||
|
||||
// Keep the last incomplete line in the buffer
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
console.log("[DIFY DEBUG] Processing line:", JSON.stringify(line))
|
||||
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim()
|
||||
console.log("[DIFY DEBUG] Extracted data:", JSON.stringify(data))
|
||||
|
||||
if (data === "[DONE]") {
|
||||
console.log("[DIFY DEBUG] Received [DONE] signal")
|
||||
return // Explicitly return on [DONE]
|
||||
}
|
||||
|
||||
if (data === "") {
|
||||
console.log("[DIFY DEBUG] Empty data line, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
console.log("[DIFY DEBUG] Parsed JSON:", parsed)
|
||||
|
||||
// Capture conversation_id as soon as it's available
|
||||
if (parsed.conversation_id && !this.conversationId) {
|
||||
this.conversationId = parsed.conversation_id
|
||||
console.log("[DIFY DEBUG] Captured conversation_id:", this.conversationId)
|
||||
}
|
||||
|
||||
// Handle different Dify event types based on actual Dify API
|
||||
if (parsed.event === "message") {
|
||||
console.log("[DIFY DEBUG] Message event, answer:", parsed.answer)
|
||||
// Dify sends the full text in each "answer" chunk, so we replace.
|
||||
if (typeof parsed.answer === "string") {
|
||||
fullText = parsed.answer
|
||||
console.log("[DIFY DEBUG] Updated fullText length:", fullText.length)
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
}
|
||||
} else if (parsed.event === "message_replace") {
|
||||
console.log("[DIFY DEBUG] Replace message event:", parsed)
|
||||
if (parsed.answer) {
|
||||
fullText = parsed.answer // Replace instead of append
|
||||
console.log("[DIFY DEBUG] Replaced fullText length:", fullText.length)
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
}
|
||||
} else if (parsed.event === "message_end") {
|
||||
console.log("[DIFY DEBUG] Message end event", parsed)
|
||||
// Message completed. Yield final text if we have any.
|
||||
if (fullText) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
}
|
||||
// Yield usage data if available
|
||||
if (parsed.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: parsed.usage.prompt_tokens || 0,
|
||||
outputTokens: parsed.usage.completion_tokens || parsed.usage.total_tokens || 0,
|
||||
totalCost: parsed.usage.total_price || 0,
|
||||
}
|
||||
}
|
||||
return // End of stream
|
||||
} else if (parsed.event === "error") {
|
||||
console.error("[DIFY DEBUG] Error event:", parsed)
|
||||
throw new Error(`Dify API error: ${parsed.message || "Unknown error"}`)
|
||||
} else if (parsed.event === "workflow_started" || parsed.event === "workflow_finished") {
|
||||
console.log("[DIFY DEBUG] Workflow event:", parsed.event)
|
||||
// These are informational events, continue processing
|
||||
} else if (parsed.event === "node_started" || parsed.event === "node_finished") {
|
||||
console.log("[DIFY DEBUG] Node event:", parsed.event, parsed.data)
|
||||
// These are informational events, continue processing
|
||||
} else if (parsed.event === "ping") {
|
||||
console.log("[DIFY DEBUG] Ping event received, keeping connection alive.")
|
||||
// Ping event, do nothing
|
||||
} else {
|
||||
console.log("[DIFY DEBUG] Unknown event type:", parsed.event, "Full object:", parsed)
|
||||
// Try to extract text from other possible fields
|
||||
if (parsed.text) {
|
||||
fullText += parsed.text
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
} else if (parsed.content) {
|
||||
fullText += parsed.content
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[DIFY DEBUG] Failed to parse JSON:", data, "Error:", e)
|
||||
}
|
||||
} else if (line.trim() !== "") {
|
||||
console.log(
|
||||
"[DIFY DEBUG] Non-data line (not starting with 'data:'), trying to parse as direct JSON:",
|
||||
JSON.stringify(line),
|
||||
)
|
||||
// Try to parse as direct JSON (fallback for non-SSE responses, though Dify uses SSE)
|
||||
try {
|
||||
const parsed = JSON.parse(line.trim())
|
||||
console.log("[DIFY DEBUG] Parsed direct JSON:", parsed)
|
||||
|
||||
// Handle the same event types as above
|
||||
if (parsed.event === "message" && parsed.answer) {
|
||||
fullText += parsed.answer
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
} else if (parsed.event === "message_end") {
|
||||
if (fullText) {
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
}
|
||||
}
|
||||
return
|
||||
} else if (parsed.event === "error") {
|
||||
console.error("[DIFY DEBUG] Direct JSON Error event:", parsed)
|
||||
throw new Error(`Dify API error: ${parsed.message || "Unknown error"}`)
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, continue
|
||||
console.log("[DIFY DEBUG] Line is not direct JSON, continuing")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
console.log("[DIFY DEBUG] Stream reader released")
|
||||
}
|
||||
}
|
||||
|
||||
private convertMessagesToQuery(systemPrompt: string, messages: ClineStorageMessage[]): string {
|
||||
// Dify's context is managed by `conversation_id`. The `query` should be the last user message.
|
||||
// The system prompt is typically configured in the Dify App itself.
|
||||
const lastUserMessage = messages.filter((m) => m.role === "user").pop()
|
||||
|
||||
if (!lastUserMessage) {
|
||||
return "" // Should not happen in normal flow
|
||||
}
|
||||
|
||||
const userQuery = Array.isArray(lastUserMessage.content)
|
||||
? lastUserMessage.content.map((c) => ("text" in c ? c.text : "")).join("\n")
|
||||
: (lastUserMessage.content as string)
|
||||
|
||||
// Only prepend the system prompt if it's the very first message of a new conversation.
|
||||
if (!this.conversationId && systemPrompt) {
|
||||
console.log("[DIFY DEBUG] Prepending system prompt for new conversation.")
|
||||
return `${systemPrompt}\n\n---\n\n${userQuery}`
|
||||
}
|
||||
|
||||
return userQuery
|
||||
}
|
||||
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
return {
|
||||
id: "dify-workflow",
|
||||
info: {
|
||||
maxTokens: 8192,
|
||||
contextWindow: 128000,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Dify workflow - model selection is configured in your Dify application",
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
+69
-11
@@ -2,54 +2,74 @@ import * as vscode from "vscode"
|
||||
import {
|
||||
cleanupMcpMarketplaceCatalogFromGlobalState,
|
||||
migrateCustomInstructionsToGlobalRules,
|
||||
migrateHooksEnabledToBoolean,
|
||||
migrateTaskHistoryToFile,
|
||||
migrateWelcomeViewCompleted,
|
||||
migrateWorkspaceToGlobalStorage,
|
||||
} from "./core/storage/state-migrations"
|
||||
import { WebviewProvider } from "./core/webview"
|
||||
import { Logger } from "./services/logging/Logger"
|
||||
import "./utils/path" // necessary to have access to String.prototype.toPosix
|
||||
|
||||
import { HostProvider } from "@/hosts/host-provider"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { FileContextTracker } from "./core/context/context-tracking/FileContextTracker"
|
||||
import { StateManager } from "./core/storage/StateManager"
|
||||
import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth"
|
||||
import { ExtensionRegistryInfo } from "./registry"
|
||||
import { BannerService } from "./services/banner/BannerService"
|
||||
import { audioRecordingService } from "./services/dictation/AudioRecordingService"
|
||||
import { ErrorService } from "./services/error"
|
||||
import { featureFlagsService } from "./services/feature-flags"
|
||||
import { initializeDistinctId } from "./services/logging/distinctId"
|
||||
import { getDistinctId, initializeDistinctId } from "./services/logging/distinctId"
|
||||
import { telemetryService } from "./services/telemetry"
|
||||
import { PostHogClientProvider } from "./services/telemetry/providers/posthog/PostHogClientProvider"
|
||||
import { ShowMessageType } from "./shared/proto/host/window"
|
||||
import { syncWorker } from "./shared/services/worker/sync"
|
||||
import { getBlobStoreSettingsFromEnv } from "./shared/services/worker/worker"
|
||||
import { getLatestAnnouncementId } from "./utils/announcements"
|
||||
import { arePathsEqual } from "./utils/path"
|
||||
|
||||
/**
|
||||
* Performs intialization for Cline that is common to all platforms.
|
||||
*
|
||||
* @param context
|
||||
* @returns The webview provider
|
||||
* @throws ClineConfigurationError if endpoints.json exists but is invalid
|
||||
*/
|
||||
export async function initialize(context: vscode.ExtensionContext): Promise<WebviewProvider> {
|
||||
// Configure the shared Logging class to use HostProvider's output channels and debug logger
|
||||
Logger.subscribe((msg: string) => HostProvider.get().logToChannel(msg)) // File system logging
|
||||
Logger.subscribe((msg: string) => HostProvider.env.debugLog({ value: msg })) // Host debug logging
|
||||
|
||||
// Initialize ClineEndpoint configuration first (reads ~/.cline/endpoints.json if present)
|
||||
// This must be done before any other code that calls ClineEnv.config()
|
||||
// Throws ClineConfigurationError if config file exists but is invalid
|
||||
const { ClineEndpoint } = await import("./config")
|
||||
await ClineEndpoint.initialize()
|
||||
|
||||
try {
|
||||
await StateManager.initialize(context)
|
||||
} catch (error) {
|
||||
console.error("[Controller] CRITICAL: Failed to initialize StateManager - extension may not function properly:", error)
|
||||
Logger.error("[Controller] CRITICAL: Failed to initialize StateManager - extension may not function properly:", error)
|
||||
HostProvider.window.showMessage({
|
||||
type: ShowMessageType.ERROR,
|
||||
message: "Failed to initialize Cline's application state. Please restart the extension.",
|
||||
})
|
||||
}
|
||||
|
||||
// Initialize OpenAI Codex OAuth manager with extension context for secrets storage
|
||||
openAiCodexOAuthManager.initialize(context)
|
||||
|
||||
// Set the distinct ID for logging and telemetry
|
||||
await initializeDistinctId(context)
|
||||
|
||||
// Initialize PostHog client provider
|
||||
PostHogClientProvider.getInstance()
|
||||
// Initialize PostHog client provider (skip in self-hosted mode)
|
||||
if (!ClineEndpoint.isSelfHosted()) {
|
||||
PostHogClientProvider.getInstance()
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -63,9 +83,6 @@ export async function initialize(context: vscode.ExtensionContext): Promise<Webv
|
||||
// Ensure taskHistory.json exists and migrate legacy state (runs once)
|
||||
await migrateTaskHistoryToFile(context)
|
||||
|
||||
// Migrate hooksEnabled from ClineFeatureSetting to boolean (one-time cleanup)
|
||||
await migrateHooksEnabledToBoolean(context)
|
||||
|
||||
// Clean up MCP marketplace catalog from global state (moved to disk cache)
|
||||
await cleanupMcpMarketplaceCatalogFromGlobalState(context)
|
||||
|
||||
@@ -76,12 +93,19 @@ 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)
|
||||
|
||||
telemetryService.captureExtensionActivated()
|
||||
|
||||
// Use remote config blobStoreConfig if available, otherwise fall back to env vars
|
||||
const blobStoreSettings = StateManager.get().getRemoteConfigSettings()?.blobStoreConfig ?? getBlobStoreSettingsFromEnv()
|
||||
syncWorker().init({ ...blobStoreSettings, userDistinctId: getDistinctId() })
|
||||
|
||||
return webview
|
||||
}
|
||||
|
||||
@@ -113,7 +137,40 @@ async function showVersionUpdateAnnouncement(context: vscode.ExtensionContext) {
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
console.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
|
||||
Logger.error(`Error during post-update actions: ${errorMessage}, Stack trace: ${error.stack}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,4 +187,5 @@ export async function tearDown(): Promise<void> {
|
||||
featureFlagsService.dispose()
|
||||
// Dispose all webview instances
|
||||
await WebviewProvider.disposeAllInstances()
|
||||
syncWorker().dispose()
|
||||
}
|
||||
|
||||
+227
-14
@@ -1,22 +1,38 @@
|
||||
export enum Environment {
|
||||
production = "production",
|
||||
staging = "staging",
|
||||
local = "local",
|
||||
}
|
||||
import * as fs from "fs/promises"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
import { Environment, type EnvironmentConfig } from "./shared/config-types"
|
||||
import { Logger } from "./shared/services/Logger"
|
||||
|
||||
export interface EnvironmentConfig {
|
||||
environment: Environment
|
||||
export { Environment, type EnvironmentConfig }
|
||||
|
||||
/**
|
||||
* Schema for the endpoints.json configuration file used in on-premise deployments.
|
||||
* All fields are required and must be valid URLs.
|
||||
*/
|
||||
interface EndpointsFileSchema {
|
||||
appBaseUrl: string
|
||||
apiBaseUrl: string
|
||||
mcpBaseUrl: string
|
||||
}
|
||||
|
||||
class ClineEndpoint {
|
||||
public static instance = new ClineEndpoint()
|
||||
public static get config() {
|
||||
return ClineEndpoint.instance.config()
|
||||
/**
|
||||
* Error thrown when the Cline configuration file exists but is invalid.
|
||||
* This error prevents Cline from starting to avoid misconfiguration in enterprise environments.
|
||||
*/
|
||||
export class ClineConfigurationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "ClineConfigurationError"
|
||||
}
|
||||
}
|
||||
|
||||
class ClineEndpoint {
|
||||
private static _instance: ClineEndpoint | null = null
|
||||
private static _initialized = false
|
||||
|
||||
// On-premise config loaded from file (null if not on-premise)
|
||||
private onPremiseConfig: EndpointsFileSchema | null = null
|
||||
private environment: Environment = Environment.production
|
||||
|
||||
private constructor() {
|
||||
@@ -24,15 +40,189 @@ class ClineEndpoint {
|
||||
const _env = process?.env?.CLINE_ENVIRONMENT_OVERRIDE || process?.env?.CLINE_ENVIRONMENT
|
||||
if (_env && Object.values(Environment).includes(_env as Environment)) {
|
||||
this.environment = _env as Environment
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the ClineEndpoint singleton.
|
||||
* Must be called before any other methods.
|
||||
* Reads the endpoints.json file if it exists and validates its schema.
|
||||
*
|
||||
* @throws ClineConfigurationError if the endpoints.json file exists but is invalid
|
||||
*/
|
||||
public static async initialize(): Promise<void> {
|
||||
if (ClineEndpoint._initialized) {
|
||||
return
|
||||
}
|
||||
|
||||
ClineEndpoint._instance = new ClineEndpoint()
|
||||
|
||||
// Try to load on-premise config from file
|
||||
const endpointsConfig = await ClineEndpoint.loadEndpointsFile()
|
||||
if (endpointsConfig) {
|
||||
ClineEndpoint._instance.onPremiseConfig = endpointsConfig
|
||||
Logger.log("Cline running in self-hosted mode with custom endpoints")
|
||||
}
|
||||
|
||||
ClineEndpoint._initialized = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the ClineEndpoint has been initialized.
|
||||
*/
|
||||
public static isInitialized(): boolean {
|
||||
return ClineEndpoint._initialized
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if Cline is running in self-hosted/on-premise mode.
|
||||
* @returns true if in selfHosted mode, or true if not initialized (safety fallback to prevent accidental external calls)
|
||||
*/
|
||||
public static isSelfHosted(): boolean {
|
||||
// Safety fallback: if not initialized, treat as selfHosted
|
||||
// to prevent accidental external service calls before configuration is loaded
|
||||
if (!ClineEndpoint._initialized) {
|
||||
return true
|
||||
}
|
||||
return ClineEndpoint.config.environment === Environment.selfHosted
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the singleton instance.
|
||||
* @throws Error if not initialized
|
||||
*/
|
||||
public static get instance(): ClineEndpoint {
|
||||
if (!ClineEndpoint._initialized || !ClineEndpoint._instance) {
|
||||
throw new Error("ClineEndpoint not initialized. Call ClineEndpoint.initialize() first.")
|
||||
}
|
||||
return ClineEndpoint._instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Static getter for convenient access to the current configuration.
|
||||
* @throws Error if not initialized
|
||||
*/
|
||||
public static get config(): EnvironmentConfig {
|
||||
return ClineEndpoint.instance.config()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the endpoints.json configuration file.
|
||||
* Located at ~/.cline/endpoints.json
|
||||
*/
|
||||
private static getEndpointsFilePath(): string {
|
||||
return path.join(os.homedir(), ".cline", "endpoints.json")
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads and validates the endpoints.json file.
|
||||
* @returns The validated endpoints config, or null if the file doesn't exist
|
||||
* @throws ClineConfigurationError if the file exists but is invalid
|
||||
*/
|
||||
private static async loadEndpointsFile(): Promise<EndpointsFileSchema | null> {
|
||||
const filePath = ClineEndpoint.getEndpointsFilePath()
|
||||
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
} catch {
|
||||
// File doesn't exist - not on-premise mode
|
||||
return null
|
||||
}
|
||||
|
||||
// File exists, must be valid or we fail
|
||||
try {
|
||||
const fileContent = await fs.readFile(filePath, "utf8")
|
||||
let data: unknown
|
||||
|
||||
try {
|
||||
data = JSON.parse(fileContent)
|
||||
} catch (parseError) {
|
||||
throw new ClineConfigurationError(
|
||||
`Invalid JSON in endpoints configuration file (${filePath}): ${parseError instanceof Error ? parseError.message : String(parseError)}`,
|
||||
)
|
||||
}
|
||||
|
||||
return ClineEndpoint.validateEndpointsSchema(data, filePath)
|
||||
} catch (error) {
|
||||
if (error instanceof ClineConfigurationError) {
|
||||
throw error
|
||||
}
|
||||
throw new ClineConfigurationError(
|
||||
`Failed to read endpoints configuration file (${filePath}): ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the provided data matches the EndpointsFileSchema.
|
||||
* All fields must be present and be valid URLs.
|
||||
*
|
||||
* @param data The parsed JSON data to validate
|
||||
* @param filePath The path to the file (for error messages)
|
||||
* @returns The validated EndpointsFileSchema
|
||||
* @throws ClineConfigurationError if validation fails
|
||||
*/
|
||||
private static validateEndpointsSchema(data: unknown, filePath: string): EndpointsFileSchema {
|
||||
if (typeof data !== "object" || data === null) {
|
||||
throw new ClineConfigurationError(`Endpoints configuration file (${filePath}) must contain a JSON object`)
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>
|
||||
const requiredFields = ["appBaseUrl", "apiBaseUrl", "mcpBaseUrl"] as const
|
||||
const result: Partial<EndpointsFileSchema> = {}
|
||||
|
||||
for (const field of requiredFields) {
|
||||
const value = obj[field]
|
||||
|
||||
if (value === undefined || value === null) {
|
||||
throw new ClineConfigurationError(
|
||||
`Missing required field "${field}" in endpoints configuration file (${filePath})`,
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof value !== "string") {
|
||||
throw new ClineConfigurationError(
|
||||
`Field "${field}" in endpoints configuration file (${filePath}) must be a string`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!value.trim()) {
|
||||
throw new ClineConfigurationError(
|
||||
`Field "${field}" in endpoints configuration file (${filePath}) cannot be empty`,
|
||||
)
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
try {
|
||||
new URL(value)
|
||||
} catch {
|
||||
throw new ClineConfigurationError(
|
||||
`Field "${field}" in endpoints configuration file (${filePath}) must be a valid URL. Got: "${value}"`,
|
||||
)
|
||||
}
|
||||
|
||||
result[field] = value
|
||||
}
|
||||
|
||||
return result as EndpointsFileSchema
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current environment configuration.
|
||||
*/
|
||||
public config(): EnvironmentConfig {
|
||||
return this.getEnvironment()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the current environment.
|
||||
* @throws Error if in on-premise mode (environment switching is disabled)
|
||||
*/
|
||||
public setEnvironment(env: string) {
|
||||
if (this.onPremiseConfig) {
|
||||
throw new Error("Cannot change environment in on-premise mode. Endpoints are configured via ~/.cline/endpoints.json")
|
||||
}
|
||||
|
||||
switch (env.toLowerCase()) {
|
||||
case "staging":
|
||||
this.environment = Environment.staging
|
||||
@@ -44,10 +234,24 @@ class ClineEndpoint {
|
||||
this.environment = Environment.production
|
||||
break
|
||||
}
|
||||
console.info("Cline environment updated: ", this.environment)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current environment configuration.
|
||||
* If running in on-premise mode, returns the custom endpoints.
|
||||
*/
|
||||
public getEnvironment(): EnvironmentConfig {
|
||||
// On-premise mode: use custom endpoints from file
|
||||
if (this.onPremiseConfig) {
|
||||
return {
|
||||
environment: Environment.selfHosted,
|
||||
appBaseUrl: this.onPremiseConfig.appBaseUrl,
|
||||
apiBaseUrl: this.onPremiseConfig.apiBaseUrl,
|
||||
mcpBaseUrl: this.onPremiseConfig.mcpBaseUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// Standard mode: use built-in environment URLs
|
||||
switch (this.environment) {
|
||||
case Environment.staging:
|
||||
return {
|
||||
@@ -79,5 +283,14 @@ class ClineEndpoint {
|
||||
* Usage:
|
||||
* - ClineEnv.config() to get the current config.
|
||||
* - ClineEnv.setEnvironment(Environment.local) to change the environment.
|
||||
*
|
||||
* IMPORTANT: ClineEndpoint.initialize() must be called before using ClineEnv.
|
||||
*/
|
||||
export const ClineEnv = ClineEndpoint.instance
|
||||
export const ClineEnv = {
|
||||
config: () => ClineEndpoint.config,
|
||||
setEnvironment: (env: string) => ClineEndpoint.instance.setEnvironment(env),
|
||||
getEnvironment: () => ClineEndpoint.instance.getEnvironment(),
|
||||
}
|
||||
|
||||
// Export the class for initialization
|
||||
export { ClineEndpoint }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiConfiguration, ModelInfo, QwenApiRegions } from "@shared/api"
|
||||
import { Mode } from "@shared/storage/types"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ClineTool } from "@/shared/tools"
|
||||
import { AIhubmixHandler } from "./providers/aihubmix"
|
||||
import { AnthropicHandler } from "./providers/anthropic"
|
||||
@@ -29,6 +30,7 @@ import { NousResearchHandler } from "./providers/nousresearch"
|
||||
import { OcaHandler } from "./providers/oca"
|
||||
import { OllamaHandler } from "./providers/ollama"
|
||||
import { OpenAiHandler } from "./providers/openai"
|
||||
import { OpenAiCodexHandler } from "./providers/openai-codex"
|
||||
import { OpenAiNativeHandler } from "./providers/openai-native"
|
||||
import { OpenRouterHandler } from "./providers/openrouter"
|
||||
import { QwenHandler } from "./providers/qwen"
|
||||
@@ -184,6 +186,12 @@ function createHandlerForProvider(
|
||||
thinkingBudgetTokens:
|
||||
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
|
||||
})
|
||||
case "openai-codex":
|
||||
return new OpenAiCodexHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
|
||||
apiModelId: mode === "plan" ? options.planModeApiModelId : options.actModeApiModelId,
|
||||
})
|
||||
case "deepseek":
|
||||
return new DeepSeekHandler({
|
||||
onRetryAttempt: options.onRetryAttempt,
|
||||
@@ -473,7 +481,7 @@ export function buildApiHandler(configuration: ApiConfiguration, mode: Mode): Ap
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("buildApiHandler error:", error)
|
||||
Logger.error("buildApiHandler error:", error)
|
||||
}
|
||||
|
||||
return createHandlerForProvider(apiProvider, options, mode)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AskSageModelId, askSageDefaultModelId, askSageDefaultURL, askSageModels, ModelInfo } from "@shared/api"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from ".."
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
@@ -58,7 +59,7 @@ export class AskSageHandler implements ApiHandler {
|
||||
private apiKey: string
|
||||
|
||||
constructor(options: AskSageHandlerOptions) {
|
||||
console.log("init api url", options.asksageApiUrl, askSageDefaultURL)
|
||||
Logger.log("init api url", options.asksageApiUrl, askSageDefaultURL)
|
||||
this.options = options
|
||||
this.apiKey = options.asksageApiKey || ""
|
||||
this.apiUrl = options.asksageApiUrl || askSageDefaultURL
|
||||
@@ -164,7 +165,7 @@ export class AskSageHandler implements ApiHandler {
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("Failed to fetch AskSage usage", await response.text())
|
||||
Logger.error("Failed to fetch AskSage usage", await response.text())
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -177,7 +178,7 @@ export class AskSageHandler implements ApiHandler {
|
||||
outputTokens: 0,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching AskSage usage:", error)
|
||||
Logger.error("Error fetching AskSage usage:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { BedrockModelId, bedrockDefaultModelId, bedrockModels, CLAUDE_SONNET_1M_
|
||||
import { calculateApiCostOpenAI, calculateApiCostQwen } from "@utils/cost"
|
||||
import { ExtensionRegistryInfo } from "@/registry"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
@@ -109,9 +110,13 @@ interface ProviderChainOptions {
|
||||
profile?: string
|
||||
}
|
||||
|
||||
// a special jp inference profile was created for sonnet 4.5
|
||||
// a special jp inference profile was created for sonnet 4.5 & haiku 4.5
|
||||
// https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-support.html
|
||||
const JP_SUPPORTED_CRIS_MODELS = ["anthropic.claude-sonnet-4-5-20250929-v1:0", "anthropic.claude-sonnet-4-5-20250929-v1:0:1m"]
|
||||
const JP_SUPPORTED_CRIS_MODELS = [
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0",
|
||||
"anthropic.claude-sonnet-4-5-20250929-v1:0:1m",
|
||||
"anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
]
|
||||
|
||||
// https://docs.anthropic.com/en/api/claude-on-amazon-bedrock
|
||||
export class AwsBedrockHandler implements ApiHandler {
|
||||
@@ -449,7 +454,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error parsing Deepseek response chunk:", error)
|
||||
Logger.error("Error parsing Deepseek response chunk:", error)
|
||||
// Propagate the error by yielding a text response with error information
|
||||
yield {
|
||||
type: "text",
|
||||
@@ -550,7 +555,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
|
||||
for await (const chunk of response.stream) {
|
||||
// Debug logging to see actual response structure
|
||||
// console.log("Bedrock chunk:", JSON.stringify(chunk, null, 2))
|
||||
// Logger.log("Bedrock chunk:", JSON.stringify(chunk, null, 2))
|
||||
|
||||
// Handle thinking response in additionalModelResponseFields (LangChain format)
|
||||
const metadata = chunk.metadata as ExtendedMetadata | undefined
|
||||
@@ -854,7 +859,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
// Log unsupported content types for debugging
|
||||
console.warn(`Unsupported content type: ${(item as ContentItem).type}`)
|
||||
Logger.warn(`Unsupported content type: ${(item as ContentItem).type}`)
|
||||
return null
|
||||
})
|
||||
.filter((item): item is ContentBlock => item !== null)
|
||||
@@ -912,7 +917,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process image content:", error)
|
||||
Logger.error("Failed to process image content:", error)
|
||||
// Return a text content indicating the error instead of null
|
||||
// This ensures users are aware of the issue
|
||||
return {
|
||||
@@ -1111,7 +1116,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error with OpenAI model via Converse API:", error)
|
||||
Logger.error("Error with OpenAI model via Converse API:", error)
|
||||
|
||||
// Try to extract more detailed error information
|
||||
let errorMessage = "Failed to process OpenAI model request"
|
||||
@@ -1246,7 +1251,7 @@ export class AwsBedrockHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error with Qwen model via Converse API:", error)
|
||||
Logger.error("Error with Qwen model via Converse API:", error)
|
||||
|
||||
// Try to extract more detailed error information
|
||||
let errorMessage = "Failed to process Qwen model request"
|
||||
|
||||
@@ -112,10 +112,11 @@ export class CerebrasHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
try {
|
||||
const model = this.getModel()
|
||||
const stream = await client.chat.completions.create({
|
||||
model: this.getModel().id,
|
||||
model: model.id,
|
||||
messages: cerebrasMessages,
|
||||
temperature: 0,
|
||||
temperature: model.info.temperature ?? 0,
|
||||
stream: true,
|
||||
max_tokens: CEREBRAS_DEFAULT_MAX_TOKENS,
|
||||
})
|
||||
|
||||
@@ -7,10 +7,10 @@ import { ClineEnv } from "@/config"
|
||||
import { ClineAccountService } from "@/services/account/ClineAccountService"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { buildClineExtraHeaders } from "@/services/EnvUtils"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@/shared/ClineAccount"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch, getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { createOpenRouterStream } from "../transform/openrouter-stream"
|
||||
@@ -35,10 +35,13 @@ export class ClineHandler implements ApiHandler {
|
||||
private clineAccountService = ClineAccountService.getInstance()
|
||||
private _authService: AuthService
|
||||
private client: OpenAI | undefined
|
||||
private readonly _baseUrl = ClineEnv.config().apiBaseUrl
|
||||
lastGenerationId?: string
|
||||
private lastRequestId?: string
|
||||
|
||||
private get _baseUrl(): string {
|
||||
return ClineEnv.config().apiBaseUrl
|
||||
}
|
||||
|
||||
constructor(options: ClineHandlerOptions) {
|
||||
this.options = options
|
||||
this._authService = AuthService.getInstance()
|
||||
@@ -126,7 +129,7 @@ export class ClineHandler implements ApiHandler {
|
||||
// openrouter returns an error object instead of the openai sdk throwing an error
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`Cline API Error: ${error?.code} - ${error?.message}`)
|
||||
Logger.error(`Cline API Error: ${error?.code} - ${error?.message}`)
|
||||
// Include metadata in the error message if available
|
||||
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
|
||||
throw new Error(`Cline API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
@@ -143,7 +146,7 @@ export class ClineHandler implements ApiHandler {
|
||||
const choiceWithError = choice as any
|
||||
if (choiceWithError.error) {
|
||||
const error = choiceWithError.error
|
||||
console.error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
|
||||
Logger.error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
|
||||
throw new Error(`Cline Mid-Stream Error: ${error.code || error.type || "Unknown"} - ${error.message}`)
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -199,7 +202,7 @@ export class ClineHandler implements ApiHandler {
|
||||
// @ts-ignore-next-line
|
||||
let totalCost = (chunk.usage.cost || 0) + (chunk.usage.cost_details?.upstream_inference_cost || 0)
|
||||
|
||||
if (["x-ai/grok-code-fast-1", "kwaipilot/kat-coder-pro"].includes(this.getModel().id)) {
|
||||
if (["kwaipilot/kat-coder-pro"].includes(this.getModel().id)) {
|
||||
totalCost = 0
|
||||
}
|
||||
|
||||
@@ -217,14 +220,14 @@ export class ClineHandler implements ApiHandler {
|
||||
|
||||
// Fallback to generation endpoint if usage chunk not returned
|
||||
if (!didOutputUsage) {
|
||||
console.warn("Cline API did not return usage chunk, fetching from generation endpoint")
|
||||
Logger.warn("Cline API did not return usage chunk, fetching from generation endpoint")
|
||||
const apiStreamUsage = await this.getApiStreamUsage()
|
||||
if (apiStreamUsage) {
|
||||
yield apiStreamUsage
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Cline API Error:", error)
|
||||
Logger.error("Cline API Error:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -260,7 +263,7 @@ export class ClineHandler implements ApiHandler {
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
console.error("Error fetching cline generation details:", error)
|
||||
Logger.error("Error fetching cline generation details:", error)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
|
||||
@@ -7,7 +7,7 @@ import { fetch } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
import { convertToR1Format } from "../transform/r1-format"
|
||||
import { addReasoningContent } from "../transform/r1-format"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
|
||||
@@ -81,14 +81,10 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
|
||||
const isDeepseekReasoner = model.id.includes("deepseek-reasoner")
|
||||
|
||||
let openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
|
||||
{ role: "system", content: systemPrompt },
|
||||
...convertToOpenAiMessages(messages),
|
||||
]
|
||||
|
||||
if (isDeepseekReasoner) {
|
||||
openAiMessages = convertToR1Format([{ role: "user", content: systemPrompt }, ...messages])
|
||||
}
|
||||
const convertedMessages = convertToOpenAiMessages(messages)
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepseekReasoner
|
||||
? [{ role: "system", content: systemPrompt }, ...addReasoningContent(convertedMessages, messages)]
|
||||
: [{ role: "system", content: systemPrompt }, ...convertedMessages]
|
||||
|
||||
const stream = await client.chat.completions.create({
|
||||
model: model.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ModelInfo } from "../../../shared/api"
|
||||
import { ApiHandler } from "../index"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
@@ -84,7 +85,7 @@ export class DifyHandler implements ApiHandler {
|
||||
this.apiKey = options.difyApiKey || ""
|
||||
this.baseUrl = options.difyBaseUrl || ""
|
||||
|
||||
console.log("[DIFY DEBUG] Constructor called with:", {
|
||||
Logger.log("[DIFY DEBUG] Constructor called with:", {
|
||||
hasApiKey: !!this.apiKey,
|
||||
baseUrl: this.baseUrl,
|
||||
})
|
||||
@@ -98,7 +99,7 @@ export class DifyHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
|
||||
console.log("[DIFY DEBUG] createMessage called with:", {
|
||||
Logger.log("[DIFY DEBUG] createMessage called with:", {
|
||||
systemPromptLength: systemPrompt?.length || 0,
|
||||
messagesCount: messages?.length || 0,
|
||||
})
|
||||
@@ -115,8 +116,8 @@ export class DifyHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
const fullUrl = `${this.baseUrl}/chat-messages`
|
||||
console.log("[DIFY DEBUG] Making request to:", fullUrl)
|
||||
console.log("[DIFY DEBUG] Request body:", JSON.stringify(requestBody, null, 2))
|
||||
Logger.log("[DIFY DEBUG] Making request to:", fullUrl)
|
||||
Logger.log("[DIFY DEBUG] Request body:", JSON.stringify(requestBody, null, 2))
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
@@ -129,21 +130,21 @@ export class DifyHandler implements ApiHandler {
|
||||
body: JSON.stringify(requestBody),
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error("[DIFY DEBUG] Network error during fetch:", error)
|
||||
Logger.error("[DIFY DEBUG] Network error during fetch:", error)
|
||||
const cause = error.cause ? ` | Cause: ${error.cause}` : ""
|
||||
throw new Error(`Dify API network error: ${error.message}${cause}`)
|
||||
}
|
||||
|
||||
console.log("[DIFY DEBUG] Response status:", response.status)
|
||||
Logger.log("[DIFY DEBUG] Response status:", response.status)
|
||||
const headersObj: Record<string, string> = {}
|
||||
response.headers.forEach((value, key) => {
|
||||
headersObj[key] = value
|
||||
})
|
||||
console.log("[DIFY DEBUG] Response headers:", headersObj)
|
||||
Logger.log("[DIFY DEBUG] Response headers:", headersObj)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error("[DIFY DEBUG] Error response:", errorText)
|
||||
Logger.error("[DIFY DEBUG] Error response:", errorText)
|
||||
throw new Error(`Dify API error: ${response.status} ${response.statusText} - ${errorText}`)
|
||||
}
|
||||
|
||||
@@ -159,14 +160,14 @@ export class DifyHandler implements ApiHandler {
|
||||
const processedEvents: string[] = []
|
||||
let lastEventTime = Date.now()
|
||||
|
||||
console.log("[DIFY DEBUG] Starting to read streaming response...")
|
||||
Logger.log("[DIFY DEBUG] Starting to read streaming response...")
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
console.log("[DIFY DEBUG] Stream ended naturally")
|
||||
console.log(
|
||||
Logger.log("[DIFY DEBUG] Stream ended naturally")
|
||||
Logger.log(
|
||||
"[DIFY DEBUG] Final state - hasYieldedContent:",
|
||||
hasYieldedContent,
|
||||
"fullText length:",
|
||||
@@ -178,7 +179,7 @@ export class DifyHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true })
|
||||
console.log("[DIFY DEBUG] Raw chunk received:", JSON.stringify(chunk))
|
||||
Logger.log("[DIFY DEBUG] Raw chunk received:", JSON.stringify(chunk))
|
||||
|
||||
buffer += chunk
|
||||
const lines = buffer.split("\n")
|
||||
@@ -187,41 +188,41 @@ export class DifyHandler implements ApiHandler {
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
console.log("[DIFY DEBUG] Processing line:", JSON.stringify(line))
|
||||
Logger.log("[DIFY DEBUG] Processing line:", JSON.stringify(line))
|
||||
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim()
|
||||
console.log("[DIFY DEBUG] Extracted data:", JSON.stringify(data))
|
||||
Logger.log("[DIFY DEBUG] Extracted data:", JSON.stringify(data))
|
||||
|
||||
if (data === "[DONE]") {
|
||||
console.log("[DIFY DEBUG] Received [DONE] signal")
|
||||
Logger.log("[DIFY DEBUG] Received [DONE] signal")
|
||||
break
|
||||
}
|
||||
|
||||
if (data === "") {
|
||||
console.log("[DIFY DEBUG] Empty data line, skipping")
|
||||
Logger.log("[DIFY DEBUG] Empty data line, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
console.log("[DIFY DEBUG] Parsed JSON:", parsed)
|
||||
Logger.log("[DIFY DEBUG] Parsed JSON:", parsed)
|
||||
processedEvents.push(parsed.event || "unknown")
|
||||
lastEventTime = Date.now()
|
||||
|
||||
// Capture conversation_id as soon as it's available
|
||||
if (parsed.conversation_id && !this.conversationId) {
|
||||
this.conversationId = parsed.conversation_id
|
||||
console.log("[DIFY DEBUG] Captured conversation_id:", this.conversationId)
|
||||
Logger.log("[DIFY DEBUG] Captured conversation_id:", this.conversationId)
|
||||
}
|
||||
|
||||
// Handle different Dify event types based on actual Dify API
|
||||
if (parsed.event === "message") {
|
||||
console.log("[DIFY DEBUG] Message event, answer:", parsed.answer)
|
||||
Logger.log("[DIFY DEBUG] Message event, answer:", parsed.answer)
|
||||
// Dify sends the full text in each "answer" chunk, so we replace.
|
||||
if (typeof parsed.answer === "string") {
|
||||
fullText = parsed.answer
|
||||
console.log("[DIFY DEBUG] Updated fullText length:", fullText.length)
|
||||
Logger.log("[DIFY DEBUG] Updated fullText length:", fullText.length)
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
@@ -229,10 +230,10 @@ export class DifyHandler implements ApiHandler {
|
||||
hasYieldedContent = true
|
||||
}
|
||||
} else if (parsed.event === "message_replace") {
|
||||
console.log("[DIFY DEBUG] Replace message event:", parsed)
|
||||
Logger.log("[DIFY DEBUG] Replace message event:", parsed)
|
||||
if (parsed.answer) {
|
||||
fullText = parsed.answer // Replace instead of append
|
||||
console.log("[DIFY DEBUG] Replaced fullText length:", fullText.length)
|
||||
Logger.log("[DIFY DEBUG] Replaced fullText length:", fullText.length)
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
@@ -240,7 +241,7 @@ export class DifyHandler implements ApiHandler {
|
||||
hasYieldedContent = true
|
||||
}
|
||||
} else if (parsed.event === "message_end") {
|
||||
console.log("[DIFY DEBUG] Message end event", parsed)
|
||||
Logger.log("[DIFY DEBUG] Message end event", parsed)
|
||||
// Message completed. Yield final text if we have any.
|
||||
if (fullText) {
|
||||
yield {
|
||||
@@ -260,19 +261,19 @@ export class DifyHandler implements ApiHandler {
|
||||
}
|
||||
return // End of stream
|
||||
} else if (parsed.event === "error") {
|
||||
console.error("[DIFY DEBUG] Error event:", parsed)
|
||||
Logger.error("[DIFY DEBUG] Error event:", parsed)
|
||||
throw new Error(`Dify API error: ${parsed.message || "Unknown error"}`)
|
||||
} else if (parsed.event === "workflow_started" || parsed.event === "workflow_finished") {
|
||||
console.log("[DIFY DEBUG] Workflow event:", parsed.event)
|
||||
Logger.log("[DIFY DEBUG] Workflow event:", parsed.event)
|
||||
// These are informational events, continue processing
|
||||
} else if (parsed.event === "node_started" || parsed.event === "node_finished") {
|
||||
console.log("[DIFY DEBUG] Node event:", parsed.event, parsed.data)
|
||||
Logger.log("[DIFY DEBUG] Node event:", parsed.event, parsed.data)
|
||||
// These are informational events, continue processing
|
||||
} else if (parsed.event === "ping") {
|
||||
console.log("[DIFY DEBUG] Ping event received, keeping connection alive.")
|
||||
Logger.log("[DIFY DEBUG] Ping event received, keeping connection alive.")
|
||||
// Ping event, do nothing
|
||||
} else {
|
||||
console.log("[DIFY DEBUG] Unknown event type:", parsed.event, "Full object:", parsed)
|
||||
Logger.log("[DIFY DEBUG] Unknown event type:", parsed.event, "Full object:", parsed)
|
||||
// Try to extract text from other possible fields
|
||||
if (parsed.text) {
|
||||
fullText += parsed.text
|
||||
@@ -299,17 +300,17 @@ export class DifyHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[DIFY DEBUG] Failed to parse JSON:", data, "Error:", e)
|
||||
Logger.warn("[DIFY DEBUG] Failed to parse JSON:", data, "Error:", e)
|
||||
}
|
||||
} else if (line.trim() !== "") {
|
||||
console.log(
|
||||
Logger.log(
|
||||
"[DIFY DEBUG] Non-data line (not starting with 'data:'), trying to parse as direct JSON:",
|
||||
JSON.stringify(line),
|
||||
)
|
||||
// Try to parse as direct JSON (fallback for non-SSE responses, though Dify uses SSE)
|
||||
try {
|
||||
const parsed = JSON.parse(line.trim())
|
||||
console.log("[DIFY DEBUG] Parsed direct JSON:", parsed)
|
||||
Logger.log("[DIFY DEBUG] Parsed direct JSON:", parsed)
|
||||
processedEvents.push(parsed.event || "direct-json")
|
||||
|
||||
// Handle the same event types as above
|
||||
@@ -330,7 +331,7 @@ export class DifyHandler implements ApiHandler {
|
||||
}
|
||||
return
|
||||
} else if (parsed.event === "error") {
|
||||
console.error("[DIFY DEBUG] Direct JSON Error event:", parsed)
|
||||
Logger.error("[DIFY DEBUG] Direct JSON Error event:", parsed)
|
||||
throw new Error(`Dify API error: ${parsed.message || "Unknown error"}`)
|
||||
} else if (parsed.answer || parsed.text || parsed.content) {
|
||||
// Fallback for any content in direct JSON
|
||||
@@ -344,7 +345,7 @@ export class DifyHandler implements ApiHandler {
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, continue
|
||||
console.log("[DIFY DEBUG] Line is not direct JSON, continuing")
|
||||
Logger.log("[DIFY DEBUG] Line is not direct JSON, continuing")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -359,11 +360,11 @@ export class DifyHandler implements ApiHandler {
|
||||
streamDuration: Date.now() - lastEventTime,
|
||||
conversationId: this.conversationId,
|
||||
}
|
||||
console.error("[DIFY DEBUG] No content was yielded! Diagnostic info:", diagnosticInfo)
|
||||
Logger.error("[DIFY DEBUG] No content was yielded! Diagnostic info:", diagnosticInfo)
|
||||
|
||||
// If we have any accumulated text at all, yield it as a fallback
|
||||
if (fullText.trim()) {
|
||||
console.log("[DIFY DEBUG] Yielding accumulated text as fallback:", fullText)
|
||||
Logger.log("[DIFY DEBUG] Yielding accumulated text as fallback:", fullText)
|
||||
yield {
|
||||
type: "text",
|
||||
text: fullText,
|
||||
@@ -380,7 +381,7 @@ export class DifyHandler implements ApiHandler {
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
console.log("[DIFY DEBUG] Stream reader released")
|
||||
Logger.log("[DIFY DEBUG] Stream reader released")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,7 +400,7 @@ export class DifyHandler implements ApiHandler {
|
||||
|
||||
// Only prepend the system prompt if it's the very first message of a new conversation.
|
||||
if (!this.conversationId && systemPrompt) {
|
||||
console.log("[DIFY DEBUG] Prepending system prompt for new conversation.")
|
||||
Logger.log("[DIFY DEBUG] Prepending system prompt for new conversation.")
|
||||
return `${systemPrompt}\n\n---\n\n${userQuery}`
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { GeminiModelId, geminiDefaultModelId, geminiModels, ModelInfo } from "@shared/api"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { RetriableError, withRetry } from "../retry"
|
||||
import { convertAnthropicMessageToGemini } from "../transform/gemini-format"
|
||||
@@ -332,7 +333,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
throughputTokensPerSec: throughputTokensPerSecSdk,
|
||||
})
|
||||
} else {
|
||||
console.warn("GeminiHandler: ulid not available for telemetry in createMessage.")
|
||||
Logger.warn("GeminiHandler: ulid not available for telemetry in createMessage.")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -408,7 +409,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
trace.cacheRead = { price: cacheReadsPrice, tokens: cacheReadTokens ?? 0, cost: cacheReadCost }
|
||||
}
|
||||
|
||||
// console.log(`[GeminiHandler] calculateCost -> ${totalCost}`, trace)
|
||||
// Logger.log(`[GeminiHandler] calculateCost -> ${totalCost}`, trace)
|
||||
return totalCost
|
||||
}
|
||||
|
||||
@@ -450,13 +451,13 @@ export class GeminiHandler implements ApiHandler {
|
||||
})
|
||||
|
||||
if (response.totalTokens === undefined) {
|
||||
console.warn("Gemini token counting returned undefined, using fallback")
|
||||
Logger.warn("Gemini token counting returned undefined, using fallback")
|
||||
return this.estimateTokens(content)
|
||||
}
|
||||
|
||||
return response.totalTokens
|
||||
} catch (error) {
|
||||
console.warn("Gemini token counting failed, using fallback", error)
|
||||
Logger.warn("Gemini token counting failed, using fallback", error)
|
||||
return this.estimateTokens(content)
|
||||
}
|
||||
}
|
||||
@@ -475,7 +476,7 @@ export class GeminiHandler implements ApiHandler {
|
||||
const jsonStr = JSON.stringify(block)
|
||||
return total + jsonStr.length
|
||||
} catch (e) {
|
||||
console.warn("Failed to stringify block for token estimation", e)
|
||||
Logger.warn("Failed to stringify block for token estimation", e)
|
||||
return total
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import OpenAI from "openai"
|
||||
import { StateManager } from "@/core/storage/StateManager"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { isAnthropicModelId } from "@/utils/model-utils"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from ".."
|
||||
import { withRetry } from "../retry"
|
||||
@@ -70,7 +71,7 @@ export async function fetchLiteLlmModelsInfo(baseUrl: string, apiKey: string): P
|
||||
const data: LiteLlmModelInfoResponse = await response.json()
|
||||
return data
|
||||
} else {
|
||||
console.error("Failed to fetch LiteLLM model info:", response.statusText)
|
||||
Logger.error("Failed to fetch LiteLLM model info:", response.statusText)
|
||||
// Try with Authorization header instead
|
||||
const retryResponse = await fetch(url, {
|
||||
method: "GET",
|
||||
@@ -84,12 +85,12 @@ export async function fetchLiteLlmModelsInfo(baseUrl: string, apiKey: string): P
|
||||
const data: LiteLlmModelInfoResponse = await retryResponse.json()
|
||||
return data
|
||||
} else {
|
||||
console.error("Failed to fetch LiteLLM model info with Authorization header:", retryResponse.statusText)
|
||||
Logger.error("Failed to fetch LiteLLM model info with Authorization header:", retryResponse.statusText)
|
||||
throw new Error(`Failed to fetch LiteLLM model info: ${retryResponse.statusText}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching LiteLLM model info:", error)
|
||||
Logger.error("Error fetching LiteLLM model info:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -169,7 +170,7 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Error getting LiteLLM model cost info:", error)
|
||||
Logger.warn("Error getting LiteLLM model cost info:", error)
|
||||
}
|
||||
|
||||
// Fallback to zero costs if we can't get the information
|
||||
@@ -200,7 +201,7 @@ export class LiteLlmHandler implements ApiHandler {
|
||||
|
||||
return totalCost
|
||||
} catch (error) {
|
||||
console.error("Error calculating spend:", error)
|
||||
Logger.error("Error calculating spend:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
OCI_HEADER_OPC_REQUEST_ID,
|
||||
} from "@/services/auth/oca/utils/constants"
|
||||
import { createOcaHeaders } from "@/services/auth/oca/utils/utils"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { OcaModelInfo } from "@/shared/api"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { ApiFormat } from "@/shared/proto/index.cline"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, type CommonApiHandlerOptions } from ".."
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
@@ -134,11 +134,11 @@ export class OcaHandler implements ApiHandler {
|
||||
const data: { cost: number } = await response.json()
|
||||
return data.cost
|
||||
} else {
|
||||
console.error("Error calculating spend:", response.statusText)
|
||||
Logger.error("Error calculating spend:", response.statusText)
|
||||
return undefined
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error calculating spend:", error)
|
||||
Logger.error("Error calculating spend:", error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -313,7 +313,7 @@ export class OcaHandler implements ApiHandler {
|
||||
|
||||
// Convert ChatCompletion tools to Responses API format if provided
|
||||
const responseTools = tools
|
||||
?.filter((tool) => tool.type === "function")
|
||||
?.filter((tool) => tool?.type === "function")
|
||||
.map((tool: any) => ({
|
||||
type: "function" as const,
|
||||
name: tool.function.name,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { type ModelInfo, openAiModelInfoSaneDefaults } from "@shared/api"
|
||||
import { type Config, type Message, Ollama } from "ollama"
|
||||
import type { ChatCompletionTool } from "openai/resources/chat/completions"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOllamaMessages } from "../transform/ollama-format"
|
||||
import type { ApiStream } from "../transform/stream"
|
||||
import { ToolCallProcessor } from "../transform/tool-call-processor"
|
||||
|
||||
interface OllamaHandlerOptions extends CommonApiHandlerOptions {
|
||||
ollamaBaseUrl?: string
|
||||
@@ -50,7 +53,7 @@ export class OllamaHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
@withRetry({ retryAllErrors: true })
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[]): ApiStream {
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: ChatCompletionTool[]): ApiStream {
|
||||
const client = this.ensureClient()
|
||||
const ollamaMessages: Message[] = [{ role: "system", content: systemPrompt }, ...convertToOllamaMessages(messages)]
|
||||
|
||||
@@ -69,20 +72,44 @@ export class OllamaHandler implements ApiHandler {
|
||||
options: {
|
||||
num_ctx: Number(this.options.ollamaApiOptionsCtxNum),
|
||||
},
|
||||
tools: tools as any,
|
||||
})
|
||||
|
||||
const toolCallProcessor = new ToolCallProcessor()
|
||||
|
||||
// Race the API request against the timeout
|
||||
const stream = (await Promise.race([apiPromise, timeoutPromise])) as Awaited<typeof apiPromise>
|
||||
|
||||
try {
|
||||
for await (const chunk of stream) {
|
||||
if (typeof chunk.message.content === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: chunk.message.content,
|
||||
}
|
||||
Logger.debug("[OllamaHandler] Message Chunk" + JSON.stringify(chunk))
|
||||
|
||||
const delta = chunk.message
|
||||
|
||||
if (delta?.tool_calls) {
|
||||
Logger.debug(`[OllamaHandler] Tool Calls Detected: ${JSON.stringify(delta.tool_calls)}`)
|
||||
yield* toolCallProcessor.processToolCallDeltas(
|
||||
delta.tool_calls?.map((tc, inx) => ({
|
||||
index: inx,
|
||||
id: `ollama-tool-${inx}`,
|
||||
function: {
|
||||
name: tc.function.name,
|
||||
arguments:
|
||||
typeof tc.function.arguments === "string"
|
||||
? tc.function.arguments
|
||||
: JSON.stringify(tc.function.arguments),
|
||||
},
|
||||
type: "function",
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof delta.content === "string") {
|
||||
yield {
|
||||
type: "text",
|
||||
text: delta.content,
|
||||
}
|
||||
}
|
||||
// Handle token usage if available
|
||||
if (chunk.eval_count !== undefined || chunk.prompt_eval_count !== undefined) {
|
||||
yield {
|
||||
@@ -93,7 +120,7 @@ export class OllamaHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
} catch (streamError: any) {
|
||||
console.error("Error processing Ollama stream:", streamError)
|
||||
Logger.error("Error processing Ollama stream:", streamError)
|
||||
throw new Error(`Ollama stream processing error: ${streamError.message || "Unknown error"}`)
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -107,7 +134,7 @@ export class OllamaHandler implements ApiHandler {
|
||||
const statusCode = error.status || error.statusCode
|
||||
const errorMessage = error.message || "Unknown error"
|
||||
|
||||
console.error(`Ollama API error (${statusCode || "unknown"}): ${errorMessage}`)
|
||||
Logger.error(`Ollama API error (${statusCode || "unknown"}): ${errorMessage}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import { ModelInfo, OpenAiCodexModelId, openAiCodexDefaultModelId, openAiCodexModels } from "@shared/api"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionTool } from "openai/resources/chat/completions"
|
||||
import * as os from "os"
|
||||
import { v7 as uuidv7 } from "uuid"
|
||||
import { openAiCodexOAuthManager } from "@/integrations/openai-codex/oauth"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { convertToOpenAIResponsesInput } from "../transform/openai-response-format"
|
||||
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
|
||||
|
||||
/**
|
||||
* OpenAI Codex base URL for API requests
|
||||
* Routes to chatgpt.com/backend-api/codex
|
||||
*/
|
||||
const CODEX_API_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
|
||||
interface OpenAiCodexHandlerOptions extends CommonApiHandlerOptions {
|
||||
reasoningEffort?: string
|
||||
apiModelId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenAiCodexHandler - Uses OpenAI Responses API with OAuth authentication
|
||||
*
|
||||
* Key differences from OpenAiNativeHandler:
|
||||
* - Uses OAuth Bearer tokens instead of API keys
|
||||
* - Routes requests to Codex backend (chatgpt.com/backend-api/codex)
|
||||
* - Subscription-based pricing (no per-token costs)
|
||||
* - Limited model subset
|
||||
* - Custom headers for Codex backend
|
||||
*/
|
||||
export class OpenAiCodexHandler implements ApiHandler {
|
||||
private options: OpenAiCodexHandlerOptions
|
||||
private client?: OpenAI
|
||||
// Session ID for the Codex API (persists for the lifetime of the handler)
|
||||
private readonly sessionId: string
|
||||
// Abort controller for cancelling ongoing requests
|
||||
private abortController?: AbortController
|
||||
// Track tool call identity for streaming
|
||||
private pendingToolCallId: string | undefined
|
||||
private pendingToolCallName: string | undefined
|
||||
|
||||
constructor(options: OpenAiCodexHandlerOptions) {
|
||||
this.options = options
|
||||
this.sessionId = uuidv7()
|
||||
}
|
||||
|
||||
private normalizeUsage(usage: any, model: { id: string; info: ModelInfo }): ApiStreamUsageChunk | undefined {
|
||||
if (!usage) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const inputDetails = usage.input_tokens_details ?? usage.prompt_tokens_details
|
||||
|
||||
const hasCachedTokens = typeof inputDetails?.cached_tokens === "number"
|
||||
const hasCacheMissTokens = typeof inputDetails?.cache_miss_tokens === "number"
|
||||
const cachedFromDetails = hasCachedTokens ? inputDetails.cached_tokens : 0
|
||||
const missFromDetails = hasCacheMissTokens ? inputDetails.cache_miss_tokens : 0
|
||||
|
||||
let totalInputTokens = usage.input_tokens ?? usage.prompt_tokens ?? 0
|
||||
if (totalInputTokens === 0 && inputDetails && (cachedFromDetails > 0 || missFromDetails > 0)) {
|
||||
totalInputTokens = cachedFromDetails + missFromDetails
|
||||
}
|
||||
|
||||
const totalOutputTokens = usage.output_tokens ?? usage.completion_tokens ?? 0
|
||||
const cacheWriteTokens = usage.cache_creation_input_tokens ?? usage.cache_write_tokens ?? 0
|
||||
const cacheReadTokens =
|
||||
usage.cache_read_input_tokens ?? usage.cache_read_tokens ?? usage.cached_tokens ?? cachedFromDetails ?? 0
|
||||
|
||||
const reasoningTokens =
|
||||
typeof usage.output_tokens_details?.reasoning_tokens === "number"
|
||||
? usage.output_tokens_details.reasoning_tokens
|
||||
: undefined
|
||||
|
||||
// Subscription-based: no per-token costs
|
||||
const out: ApiStreamUsageChunk = {
|
||||
type: "usage",
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
cacheWriteTokens,
|
||||
cacheReadTokens,
|
||||
...(typeof reasoningTokens === "number" ? { reasoningTokens } : {}),
|
||||
totalCost: 0, // Subscription-based pricing
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: ChatCompletionTool[]): ApiStream {
|
||||
const model = this.getModel()
|
||||
|
||||
// Reset state for this request
|
||||
this.pendingToolCallId = undefined
|
||||
this.pendingToolCallName = undefined
|
||||
|
||||
// Get access token from OAuth manager
|
||||
let accessToken = await openAiCodexOAuthManager.getAccessToken()
|
||||
if (!accessToken) {
|
||||
throw new Error("Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow in settings.")
|
||||
}
|
||||
|
||||
// Format conversation for Responses API
|
||||
const formattedInput = convertToOpenAIResponsesInput(messages)
|
||||
|
||||
// Build request body
|
||||
const requestBody = this.buildRequestBody(model, formattedInput, systemPrompt, tools)
|
||||
|
||||
// Make the request with retry on auth failure
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
yield* this.executeRequest(requestBody, model, accessToken)
|
||||
return
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const isAuthFailure = /unauthorized|invalid token|not authenticated|authentication|401/i.test(message)
|
||||
|
||||
if (attempt === 0 && isAuthFailure) {
|
||||
// Force refresh the token for retry
|
||||
const refreshed = await openAiCodexOAuthManager.forceRefreshAccessToken()
|
||||
if (!refreshed) {
|
||||
throw new Error(
|
||||
"Not authenticated with OpenAI Codex. Please sign in using the OpenAI Codex OAuth flow in settings.",
|
||||
)
|
||||
}
|
||||
accessToken = refreshed
|
||||
continue
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private buildRequestBody(
|
||||
model: { id: string; info: ModelInfo },
|
||||
formattedInput: any,
|
||||
systemPrompt: string,
|
||||
tools?: ChatCompletionTool[],
|
||||
): any {
|
||||
// Determine reasoning effort
|
||||
const reasoningEffort = this.options.reasoningEffort || "medium"
|
||||
|
||||
const body: any = {
|
||||
model: model.id,
|
||||
input: formattedInput,
|
||||
stream: true,
|
||||
store: false,
|
||||
instructions: systemPrompt,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
reasoning: {
|
||||
effort: reasoningEffort,
|
||||
summary: "auto",
|
||||
},
|
||||
}
|
||||
|
||||
// Add tools if provided
|
||||
// Pass through strict value from tool (MCP/custom tools have strict: false, built-in tools default to true)
|
||||
if (tools && tools.length > 0) {
|
||||
body.tools = tools
|
||||
.filter((tool: any) => tool?.type === "function")
|
||||
.map((tool: any) => ({
|
||||
type: "function",
|
||||
name: tool.function.name,
|
||||
description: tool.function.description,
|
||||
parameters: tool.function.parameters,
|
||||
strict: tool.function.strict ?? true,
|
||||
}))
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
private async *executeRequest(requestBody: any, model: { id: string; info: ModelInfo }, accessToken: string): ApiStream {
|
||||
// Create AbortController for cancellation
|
||||
this.abortController = new AbortController()
|
||||
|
||||
try {
|
||||
// Get ChatGPT account ID for organization subscriptions
|
||||
const accountId = await openAiCodexOAuthManager.getAccountId()
|
||||
|
||||
// Build Codex-specific headers
|
||||
const codexHeaders: Record<string, string> = {
|
||||
originator: "cline",
|
||||
session_id: this.sessionId,
|
||||
"User-Agent": `cline/${process.env.npm_package_version || "1.0.0"} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`,
|
||||
...(accountId ? { "ChatGPT-Account-Id": accountId } : {}),
|
||||
}
|
||||
|
||||
// Try using OpenAI SDK first
|
||||
try {
|
||||
const client =
|
||||
this.client ??
|
||||
new OpenAI({
|
||||
apiKey: accessToken,
|
||||
baseURL: CODEX_API_BASE_URL,
|
||||
defaultHeaders: codexHeaders,
|
||||
fetch, // Use shared fetch for proxy support
|
||||
})
|
||||
|
||||
const stream = (await (client as any).responses.create(requestBody, {
|
||||
signal: this.abortController.signal,
|
||||
headers: codexHeaders,
|
||||
})) as AsyncIterable<any>
|
||||
|
||||
if (typeof (stream as any)?.[Symbol.asyncIterator] !== "function") {
|
||||
throw new Error("OpenAI SDK did not return an AsyncIterable")
|
||||
}
|
||||
|
||||
for await (const event of stream) {
|
||||
if (this.abortController.signal.aborted) {
|
||||
break
|
||||
}
|
||||
|
||||
for await (const outChunk of this.processEvent(event, model)) {
|
||||
yield outChunk
|
||||
}
|
||||
}
|
||||
} catch (_sdkErr) {
|
||||
// Fallback to manual SSE via fetch
|
||||
yield* this.makeCodexRequest(requestBody, model, accessToken)
|
||||
}
|
||||
} finally {
|
||||
this.abortController = undefined
|
||||
}
|
||||
}
|
||||
|
||||
private async *makeCodexRequest(requestBody: any, model: { id: string; info: ModelInfo }, accessToken: string): ApiStream {
|
||||
const url = `${CODEX_API_BASE_URL}/responses`
|
||||
|
||||
// Get ChatGPT account ID for organization subscriptions
|
||||
const accountId = await openAiCodexOAuthManager.getAccountId()
|
||||
|
||||
// Build headers with required Codex-specific fields
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
originator: "cline",
|
||||
session_id: this.sessionId,
|
||||
"User-Agent": `cline/${process.env.npm_package_version || "1.0.0"} (${os.platform()} ${os.release()}; ${os.arch()}) node/${process.version.slice(1)}`,
|
||||
}
|
||||
|
||||
// Add ChatGPT-Account-Id if available
|
||||
if (accountId) {
|
||||
headers["ChatGPT-Account-Id"] = accountId
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: this.abortController?.signal,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
let errorMessage = `Codex API request failed: ${response.status}`
|
||||
|
||||
try {
|
||||
const errorJson = JSON.parse(errorText)
|
||||
if (errorJson.error?.message) {
|
||||
errorMessage = errorJson.error.message
|
||||
} else if (errorJson.message) {
|
||||
errorMessage = errorJson.message
|
||||
}
|
||||
} catch {
|
||||
if (errorText) {
|
||||
errorMessage += ` - ${errorText}`
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error("No response body from Codex API")
|
||||
}
|
||||
|
||||
yield* this.handleStreamResponse(response.body, model)
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Codex API error: ${error.message}`)
|
||||
}
|
||||
throw new Error("Unexpected error connecting to Codex API")
|
||||
}
|
||||
}
|
||||
|
||||
private async *handleStreamResponse(body: ReadableStream<Uint8Array>, model: { id: string; info: ModelInfo }): ApiStream {
|
||||
const reader = body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let buffer = ""
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
if (this.abortController?.signal.aborted) {
|
||||
break
|
||||
}
|
||||
|
||||
const { done, value } = await reader.read()
|
||||
if (done) {
|
||||
break
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split("\n")
|
||||
buffer = lines.pop() || ""
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
const data = line.slice(6).trim()
|
||||
if (data === "[DONE]") {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
|
||||
for await (const outChunk of this.processEvent(parsed, model)) {
|
||||
yield outChunk
|
||||
}
|
||||
} catch (e) {
|
||||
if (!(e instanceof SyntaxError)) {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
private async *processEvent(event: any, model: { id: string; info: ModelInfo }): ApiStream {
|
||||
// Handle text deltas
|
||||
if (event?.type === "response.text.delta" || event?.type === "response.output_text.delta") {
|
||||
if (event?.delta) {
|
||||
yield { type: "text", text: event.delta }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle reasoning deltas
|
||||
if (
|
||||
event?.type === "response.reasoning.delta" ||
|
||||
event?.type === "response.reasoning_text.delta" ||
|
||||
event?.type === "response.reasoning_summary.delta" ||
|
||||
event?.type === "response.reasoning_summary_text.delta"
|
||||
) {
|
||||
if (event?.delta) {
|
||||
yield { type: "reasoning", reasoning: event.delta }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle refusal deltas
|
||||
if (event?.type === "response.refusal.delta") {
|
||||
if (event?.delta) {
|
||||
yield { type: "text", text: `[Refusal] ${event.delta}` }
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle tool/function call deltas
|
||||
if (event?.type === "response.tool_call_arguments.delta" || event?.type === "response.function_call_arguments.delta") {
|
||||
const callId = event.call_id || event.tool_call_id || event.id || this.pendingToolCallId
|
||||
const name = event.name || event.function_name || this.pendingToolCallName
|
||||
const args = event.delta || event.arguments
|
||||
|
||||
if (typeof callId === "string" && callId.length > 0 && typeof name === "string" && name.length > 0) {
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
tool_call: {
|
||||
call_id: callId,
|
||||
function: {
|
||||
id: callId,
|
||||
name,
|
||||
arguments: typeof args === "string" ? args : "",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle output item events
|
||||
if (event?.type === "response.output_item.added" || event?.type === "response.output_item.done") {
|
||||
const item = event?.item
|
||||
if (item) {
|
||||
// Capture tool identity for subsequent argument deltas
|
||||
if (item.type === "function_call" || item.type === "tool_call") {
|
||||
const callId = item.call_id || item.tool_call_id || item.id
|
||||
const name = item.name || item.function?.name || item.function_name
|
||||
if (typeof callId === "string" && callId.length > 0) {
|
||||
this.pendingToolCallId = callId
|
||||
this.pendingToolCallName = typeof name === "string" ? name : undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (item.type === "text" && item.text) {
|
||||
yield { type: "text", text: item.text }
|
||||
} else if (item.type === "reasoning" && item.text) {
|
||||
yield { type: "reasoning", reasoning: item.text }
|
||||
} else if (item.type === "message" && Array.isArray(item.content)) {
|
||||
for (const content of item.content) {
|
||||
if ((content?.type === "text" || content?.type === "output_text") && content?.text) {
|
||||
yield { type: "text", text: content.text }
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
(item.type === "function_call" || item.type === "tool_call") &&
|
||||
event.type === "response.output_item.done"
|
||||
) {
|
||||
const callId = item.call_id || item.tool_call_id || item.id
|
||||
if (callId) {
|
||||
const args = item.arguments || item.function?.arguments || item.function_arguments
|
||||
yield {
|
||||
type: "tool_calls",
|
||||
id: callId,
|
||||
tool_call: {
|
||||
call_id: callId,
|
||||
function: {
|
||||
id: callId,
|
||||
name: item.name || item.function?.name || item.function_name || "",
|
||||
arguments: typeof args === "string" ? args : "{}",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle completion events
|
||||
if (event?.type === "response.done" || event?.type === "response.completed") {
|
||||
const usage = event?.response?.usage || event?.usage || undefined
|
||||
const usageData = this.normalizeUsage(usage, model)
|
||||
if (usageData) {
|
||||
yield usageData
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Fallbacks for legacy formats
|
||||
if (event?.choices?.[0]?.delta?.content) {
|
||||
yield { type: "text", text: event.choices[0].delta.content }
|
||||
return
|
||||
}
|
||||
|
||||
if (event?.usage) {
|
||||
const usageData = this.normalizeUsage(event.usage, model)
|
||||
if (usageData) {
|
||||
yield usageData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.abortController?.abort()
|
||||
}
|
||||
|
||||
getModel(): { id: OpenAiCodexModelId; info: ModelInfo } {
|
||||
const modelId = this.options.apiModelId
|
||||
|
||||
const id = modelId && modelId in openAiCodexModels ? (modelId as OpenAiCodexModelId) : openAiCodexDefaultModelId
|
||||
|
||||
const info: ModelInfo = openAiCodexModels[id]
|
||||
|
||||
return { id, info }
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,10 @@ import {
|
||||
import { calculateApiCostOpenAI } from "@utils/cost"
|
||||
import OpenAI from "openai"
|
||||
import type { ChatCompletionReasoningEffort, ChatCompletionTool } from "openai/resources/chat/completions"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { ApiFormat } from "@/shared/proto/cline/models"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { isGPT5ModelFamily } from "@/utils/model-utils"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
@@ -135,7 +135,7 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
try {
|
||||
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
|
||||
} catch (error) {
|
||||
console.error("Error processing tool call delta:", error, delta.tool_calls)
|
||||
Logger.error("Error processing tool call delta:", error, delta.tool_calls)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,7 +159,7 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
|
||||
// Convert ChatCompletion tools to Responses API format if provided
|
||||
const responseTools = tools
|
||||
?.filter((tool) => tool.type === "function")
|
||||
?.filter((tool) => tool?.type === "function")
|
||||
.map((tool: any) => ({
|
||||
type: "function" as const,
|
||||
name: tool.function.name,
|
||||
@@ -316,11 +316,11 @@ export class OpenAiNativeHandler implements ApiHandler {
|
||||
chunk.response?.status === "incomplete" &&
|
||||
chunk.response?.incomplete_details?.reason === "max_output_tokens"
|
||||
) {
|
||||
console.log("Ran out of tokens")
|
||||
Logger.log("Ran out of tokens")
|
||||
if (chunk.response?.output_text?.length > 0) {
|
||||
console.log("Partial output:", chunk.response.output_text)
|
||||
Logger.log("Partial output:", chunk.response.output_text)
|
||||
} else {
|
||||
console.log("Ran out of tokens during reasoning")
|
||||
Logger.log("Ran out of tokens during reasoning")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,30 +30,36 @@ export class OpenAiHandler implements ApiHandler {
|
||||
this.options = options
|
||||
}
|
||||
|
||||
private getAzureAudienceScope(baseUrl?: string): string {
|
||||
const url = baseUrl?.toLowerCase() ?? ""
|
||||
if (url.includes("azure.us")) return "https://cognitiveservices.azure.us/.default"
|
||||
if (url.includes("azure.com")) return "https://cognitiveservices.azure.com/.default"
|
||||
return "https://cognitiveservices.azure.com/.default"
|
||||
}
|
||||
|
||||
private ensureClient(): OpenAI {
|
||||
if (!this.client) {
|
||||
if (!this.options.openAiApiKey && !this.options.azureIdentity) {
|
||||
throw new Error("OpenAI API key or Azure Identity Authentication is required")
|
||||
}
|
||||
try {
|
||||
// Azure API shape slightly differs from the core API shape: https://github.com/openai/openai-node?tab=readme-ov-file#microsoft-azure-openai
|
||||
// Use azureApiVersion to determine if this is an Azure endpoint, since the URL may not always contain 'azure.com'
|
||||
const baseUrl = this.options.openAiBaseUrl?.toLowerCase() ?? ""
|
||||
const isAzureDomain = baseUrl.includes("azure.com") || baseUrl.includes("azure.us")
|
||||
// Azure API shape slightly differs from the core API shape...
|
||||
if (
|
||||
this.options.azureApiVersion ||
|
||||
((this.options.openAiBaseUrl?.toLowerCase().includes("azure.com") ||
|
||||
this.options.openAiBaseUrl?.toLowerCase().includes("azure.us")) &&
|
||||
!this.options.openAiModelId?.toLowerCase().includes("deepseek"))
|
||||
(isAzureDomain && !this.options.openAiModelId?.toLowerCase().includes("deepseek"))
|
||||
) {
|
||||
if (this.options.azureIdentity) {
|
||||
this.client = new AzureOpenAI({
|
||||
baseURL: this.options.openAiBaseUrl,
|
||||
azureADTokenProvider: getBearerTokenProvider(
|
||||
new DefaultAzureCredential(),
|
||||
"https://cognitiveservices.azure.com/.default",
|
||||
this.getAzureAudienceScope(this.options.openAiBaseUrl),
|
||||
),
|
||||
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
|
||||
defaultHeaders: this.options.openAiHeaders,
|
||||
fetch, // Use configured fetch with proxy support
|
||||
fetch,
|
||||
})
|
||||
} else {
|
||||
this.client = new AzureOpenAI({
|
||||
@@ -61,7 +67,7 @@ export class OpenAiHandler implements ApiHandler {
|
||||
apiKey: this.options.openAiApiKey,
|
||||
apiVersion: this.options.azureApiVersion || azureOpenAiDefaultApiVersion,
|
||||
defaultHeaders: this.options.openAiHeaders,
|
||||
fetch, // Use configured fetch with proxy support
|
||||
fetch,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
@@ -69,7 +75,7 @@ export class OpenAiHandler implements ApiHandler {
|
||||
baseURL: this.options.openAiBaseUrl,
|
||||
apiKey: this.options.openAiApiKey,
|
||||
defaultHeaders: this.options.openAiHeaders,
|
||||
fetch, // Use configured fetch with proxy support
|
||||
fetch,
|
||||
})
|
||||
}
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import OpenAI from "openai"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch, getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { createOpenRouterStream } from "../transform/openrouter-stream"
|
||||
@@ -80,7 +81,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
// Check for error field directly on chunk
|
||||
if ("error" in chunk) {
|
||||
const error = chunk.error as OpenRouterErrorResponse["error"]
|
||||
console.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
|
||||
Logger.error(`OpenRouter API Error: ${error?.code} - ${error?.message}`)
|
||||
// Include metadata in the error message if available
|
||||
const metadataStr = error.metadata ? `\nMetadata: ${JSON.stringify(error.metadata, null, 2)}` : ""
|
||||
throw new Error(`OpenRouter API Error ${error.code}: ${error.message}${metadataStr}`)
|
||||
@@ -95,7 +96,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
const choiceWithError = choice as any
|
||||
if (choiceWithError.error) {
|
||||
const error = choiceWithError.error
|
||||
console.error(
|
||||
Logger.error(
|
||||
`OpenRouter Mid-Stream Error: ${error?.code || "Unknown"} - ${error?.message || "Unknown error"}`,
|
||||
)
|
||||
// Format error details
|
||||
@@ -179,7 +180,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
try {
|
||||
const generationIterator = this.fetchGenerationDetails(this.lastGenerationId)
|
||||
const generation = (await generationIterator.next()).value
|
||||
// console.log("OpenRouter generation details:", generation)
|
||||
// Logger.log("OpenRouter generation details:", generation)
|
||||
return {
|
||||
type: "usage",
|
||||
cacheWriteTokens: 0,
|
||||
@@ -191,7 +192,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
console.error("Error fetching OpenRouter generation details:", error)
|
||||
Logger.error("Error fetching OpenRouter generation details:", error)
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
@@ -199,7 +200,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
|
||||
@withRetry({ maxRetries: 4, baseDelay: 250, maxDelay: 1000, retryAllErrors: true })
|
||||
async *fetchGenerationDetails(genId: string) {
|
||||
// console.log("Fetching generation details for:", genId)
|
||||
// Logger.log("Fetching generation details for:", genId)
|
||||
try {
|
||||
const response = await axios.get(`https://openrouter.ai/api/v1/generation?id=${genId}`, {
|
||||
headers: {
|
||||
@@ -211,7 +212,7 @@ export class OpenRouterHandler implements ApiHandler {
|
||||
yield response.data?.data
|
||||
} catch (error) {
|
||||
// ignore if fails
|
||||
console.error("Error fetching OpenRouter generation details:", error)
|
||||
Logger.error("Error fetching OpenRouter generation details:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as os from "os"
|
||||
import * as path from "path"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
@@ -76,7 +77,7 @@ export class QwenCodeHandler implements ApiHandler {
|
||||
const credsStr = await fs.readFile(keyFile, "utf-8")
|
||||
return JSON.parse(credsStr)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
Logger.error(
|
||||
`Error reading or parsing credentials file at ${getQwenCachedCredentialPath(this.options.qwenCodeOauthPath)}`,
|
||||
)
|
||||
throw new Error(`Failed to load Qwen OAuth credentials: ${error}`)
|
||||
|
||||
@@ -12,6 +12,7 @@ import OpenAI from "openai"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
@@ -134,7 +135,7 @@ export class QwenHandler implements ApiHandler {
|
||||
try {
|
||||
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
|
||||
} catch (error) {
|
||||
console.error("Error processing tool call delta:", error, delta.tool_calls)
|
||||
Logger.error("Error processing tool call delta:", error, delta.tool_calls)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import JSON5 from "json5"
|
||||
import OpenAI from "openai"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { convertToOpenAiMessages } from "../transform/openai-format"
|
||||
@@ -144,7 +145,7 @@ namespace Bedrock {
|
||||
}
|
||||
|
||||
// Log unsupported content types for debugging
|
||||
console.warn(`Unsupported content type: ${(item as ContentItem).type}`)
|
||||
Logger.warn(`Unsupported content type: ${(item as ContentItem).type}`)
|
||||
return null
|
||||
})
|
||||
.filter((item): item is BedrockContentBlock => item !== null)
|
||||
@@ -213,7 +214,7 @@ namespace Bedrock {
|
||||
},
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process image content:", error)
|
||||
Logger.error("Failed to process image content:", error)
|
||||
// Return a text content indicating the error instead of null
|
||||
// This ensures users are aware of the issue
|
||||
return {
|
||||
@@ -412,7 +413,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
url: destination.url || this.options.sapAiCoreBaseUrl!,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to create AI Core destination:", error)
|
||||
Logger.error("Failed to create AI Core destination:", error)
|
||||
throw new Error(`Unable to create AI Core destination: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
@@ -473,7 +474,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
})
|
||||
.filter((deployment: any) => deployment !== null)
|
||||
} catch (error) {
|
||||
console.error("Error fetching deployments:", error)
|
||||
Logger.error("Error fetching deployments:", error)
|
||||
throw new Error("Failed to fetch deployments")
|
||||
}
|
||||
}
|
||||
@@ -577,7 +578,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error in SAP orchestration mode:", error)
|
||||
Logger.error("Error in SAP orchestration mode:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -596,11 +597,13 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
|
||||
if (!deploymentId) {
|
||||
// Fallback to runtime deployment id fetching for users who haven't opened the SAP provider UI
|
||||
console.log(`No pre-configured deployment ID found for model ${model.id}, falling back to runtime fetching`)
|
||||
Logger.log(`No pre-configured deployment ID found for model ${model.id}, falling back to runtime fetching`)
|
||||
deploymentId = await this.getDeploymentForModel(model.id)
|
||||
}
|
||||
|
||||
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 +652,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 +784,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"
|
||||
@@ -794,8 +801,8 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
if (error.response) {
|
||||
// The request was made and the server responded with a status code
|
||||
// that falls out of the range of 2xx
|
||||
console.error("Error status:", error.response.status)
|
||||
console.error("Error headers:", error.response.headers)
|
||||
Logger.error("Error status:", error.response.status)
|
||||
Logger.error("Error headers:", error.response.headers)
|
||||
|
||||
// Handle error data - need to read stream if responseType was 'stream'
|
||||
let errorMessage = "Unknown error"
|
||||
@@ -824,10 +831,10 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
} else if (typeof error.response.data === "object") {
|
||||
errorMessage = JSON.stringify(error.response.data, null, 2)
|
||||
}
|
||||
console.error("Error data:", errorMessage)
|
||||
Logger.error("Error data:", errorMessage)
|
||||
} catch (e) {
|
||||
console.error("Failed to read error data:", e)
|
||||
console.error("Raw error data:", error.response.data)
|
||||
Logger.error("Failed to read error data:", e)
|
||||
Logger.error("Raw error data:", error.response.data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -840,11 +847,11 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
throw new Error(`HTTP ${error.response.status}: ${errorMessage}`)
|
||||
} else if (error.request) {
|
||||
// The request was made but no response was received
|
||||
console.error("Error request:", error.request)
|
||||
Logger.error("Error request:", error.request)
|
||||
throw new Error("No response received from server")
|
||||
} else {
|
||||
// Something happened in setting up the request that triggered an Error
|
||||
console.error("Error message:", error.message)
|
||||
Logger.error("Error message:", error.message)
|
||||
throw new Error(`Error setting up request: ${error.message}`)
|
||||
}
|
||||
}
|
||||
@@ -892,13 +899,13 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse JSON data:", error)
|
||||
Logger.error("Failed to parse JSON data:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error streaming completion:", error)
|
||||
Logger.error("Error streaming completion:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -959,7 +966,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse JSON data:", error)
|
||||
Logger.error("Failed to parse JSON data:", error)
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Failed to parse response data: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -969,7 +976,7 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error streaming completion:", error)
|
||||
Logger.error("Error streaming completion:", error)
|
||||
yield {
|
||||
type: "text",
|
||||
text: `[ERROR] Failed to process stream: ${error instanceof Error ? error.message : String(error)}`,
|
||||
@@ -1038,13 +1045,13 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse GPT JSON data:", error)
|
||||
Logger.error("Failed to parse GPT JSON data:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error streaming GPT completion:", error)
|
||||
Logger.error("Error streaming GPT completion:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
@@ -1103,13 +1110,13 @@ export class SapAiCoreHandler implements ApiHandler {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse Gemini JSON data:", error)
|
||||
Logger.error("Failed to parse Gemini JSON data:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error streaming Gemini completion:", error)
|
||||
Logger.error("Error streaming Gemini completion:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import OpenAI from "openai"
|
||||
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { fetch } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions } from "../index"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
@@ -125,11 +126,11 @@ export class VercelAIGatewayHandler implements ApiHandler {
|
||||
}
|
||||
|
||||
if (!didOutputUsage) {
|
||||
console.warn("Vercel AI Gateway did not provide usage information in stream")
|
||||
Logger.warn("Vercel AI Gateway did not provide usage information in stream")
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Vercel AI Gateway error details:", error)
|
||||
console.error("Error stack:", error.stack)
|
||||
Logger.error("Vercel AI Gateway error details:", error)
|
||||
Logger.error("Error stack:", error.stack)
|
||||
throw new Error(`Vercel AI Gateway error: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { SELECTOR_SEPARATOR, stringifyVsCodeLmModelSelector } from "@shared/vsCo
|
||||
import { calculateApiCostAnthropic } from "@utils/cost"
|
||||
import * as vscode from "vscode"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ApiHandler, CommonApiHandlerOptions, SingleCompletionHandler } from "../"
|
||||
import { withRetry } from "../retry"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
@@ -123,7 +124,7 @@ declare module "vscode" {
|
||||
* const systemPrompt = "You are a helpful assistant";
|
||||
* const messages = [{ role: "user", content: "Hello!" }];
|
||||
* for await (const chunk of handler.createMessage(systemPrompt, messages)) {
|
||||
* console.log(chunk);
|
||||
* Logger.log(chunk);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@@ -147,7 +148,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
this.client = null
|
||||
this.ensureCleanState()
|
||||
} catch (error) {
|
||||
console.error("Error during configuration change cleanup:", error)
|
||||
Logger.error("Error during configuration change cleanup:", error)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -279,7 +280,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
|
||||
private async getClient(): Promise<vscode.LanguageModelChat> {
|
||||
if (!this.client) {
|
||||
console.debug("Cline <Language Model API>: Getting client with options:", {
|
||||
Logger.debug("Cline <Language Model API>: Getting client with options:", {
|
||||
vsCodeLmModelSelector: this.options.vsCodeLmModelSelector,
|
||||
hasOptions: !!this.options,
|
||||
selectorKeys: this.options.vsCodeLmModelSelector ? Object.keys(this.options.vsCodeLmModelSelector) : [],
|
||||
@@ -288,11 +289,11 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
try {
|
||||
// Use default empty selector if none provided to get all available models
|
||||
const selector = this.options?.vsCodeLmModelSelector || {}
|
||||
console.debug("Cline <Language Model API>: Creating client with selector:", selector)
|
||||
Logger.debug("Cline <Language Model API>: Creating client with selector:", selector)
|
||||
this.client = await this.createClient(selector)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error"
|
||||
console.error("Cline <Language Model API>: Client creation failed:", message)
|
||||
Logger.error("Cline <Language Model API>: Client creation failed:", message)
|
||||
throw new Error(`Cline <Language Model API>: Failed to create client: ${message}`)
|
||||
}
|
||||
}
|
||||
@@ -413,7 +414,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
if (chunk instanceof vscode.LanguageModelTextPart) {
|
||||
// Validate text part value
|
||||
if (typeof chunk.value !== "string") {
|
||||
console.warn("Cline <Language Model API>: Invalid text part value received:", chunk.value)
|
||||
Logger.warn("Cline <Language Model API>: Invalid text part value received:", chunk.value)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -426,18 +427,18 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
try {
|
||||
// Validate tool call parameters
|
||||
if (!chunk.name || typeof chunk.name !== "string") {
|
||||
console.warn("Cline <Language Model API>: Invalid tool name received:", chunk.name)
|
||||
Logger.warn("Cline <Language Model API>: Invalid tool name received:", chunk.name)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!chunk.callId || typeof chunk.callId !== "string") {
|
||||
console.warn("Cline <Language Model API>: Invalid tool callId received:", chunk.callId)
|
||||
Logger.warn("Cline <Language Model API>: Invalid tool callId received:", chunk.callId)
|
||||
continue
|
||||
}
|
||||
|
||||
// Ensure input is a valid object
|
||||
if (!chunk.input || typeof chunk.input !== "object") {
|
||||
console.warn("Cline <Language Model API>: Invalid tool input received:", chunk.input)
|
||||
Logger.warn("Cline <Language Model API>: Invalid tool input received:", chunk.input)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -453,7 +454,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
accumulatedText += toolCallText
|
||||
|
||||
// Log tool call for debugging
|
||||
console.debug("Cline <Language Model API>: Processing tool call:", {
|
||||
Logger.debug("Cline <Language Model API>: Processing tool call:", {
|
||||
name: chunk.name,
|
||||
callId: chunk.callId,
|
||||
inputSize: JSON.stringify(chunk.input).length,
|
||||
@@ -464,10 +465,10 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
text: toolCallText,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Cline <Language Model API>: Failed to process tool call:", error)
|
||||
Logger.error("Cline <Language Model API>: Failed to process tool call:", error)
|
||||
}
|
||||
} else {
|
||||
console.warn("Cline <Language Model API>: Unknown chunk type received:", chunk)
|
||||
Logger.warn("Cline <Language Model API>: Unknown chunk type received:", chunk)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,7 +490,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
console.error("Cline <Language Model API>: Stream error details:", {
|
||||
Logger.error("Cline <Language Model API>: Stream error details:", {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
@@ -500,12 +501,12 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
} else if (typeof error === "object" && error !== null) {
|
||||
// Handle error-like objects
|
||||
const errorDetails = JSON.stringify(error, null, 2)
|
||||
console.error("Cline <Language Model API>: Stream error object:", errorDetails)
|
||||
Logger.error("Cline <Language Model API>: Stream error object:", errorDetails)
|
||||
throw new Error(`Cline <Language Model API>: Response stream error: ${errorDetails}`)
|
||||
} else {
|
||||
// Fallback for unknown error types
|
||||
const errorMessage = String(error)
|
||||
console.error("Cline <Language Model API>: Unknown stream error:", errorMessage)
|
||||
Logger.error("Cline <Language Model API>: Unknown stream error:", errorMessage)
|
||||
throw new Error(`Cline <Language Model API>: Response stream error: ${errorMessage}`)
|
||||
}
|
||||
}
|
||||
@@ -526,7 +527,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
// Log any missing properties for debugging
|
||||
for (const [prop, value] of Object.entries(requiredProps)) {
|
||||
if (!value && value !== 0) {
|
||||
console.warn(`Cline <Language Model API>: Client missing ${prop} property`)
|
||||
Logger.warn(`Cline <Language Model API>: Client missing ${prop} property`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -557,7 +558,7 @@ export class VsCodeLmHandler implements ApiHandler, SingleCompletionHandler {
|
||||
? stringifyVsCodeLmModelSelector(this.options.vsCodeLmModelSelector)
|
||||
: "vscode-lm"
|
||||
|
||||
console.debug("Cline <Language Model API>: No client available, using fallback model info")
|
||||
Logger.debug("Cline <Language Model API>: No client available, using fallback model info")
|
||||
|
||||
return {
|
||||
id: fallbackId,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user