Compare commits

..
Author SHA1 Message Date
Saoud Rizwan 984cae4e40 docs(cli): note comma separator limitation in TUI headers field 2026-06-09 19:25:59 -07:00
Saoud Rizwan ddb9ff6b90 fix(core): merge openai-compatible field metadata entries after rebase
The Azure Foundry CLI change on main and this branch both added an
openai-compatible entry to PROVIDER_CONFIG_FIELD_METADATA; the rebase
kept both keys, and the duplicate silently dropped the Azure API
version field. Combine them into one entry.
2026-06-09 19:11:17 -07:00
Saoud Rizwan 47d08ecbe8 feat(cli): merge auth headers on update and add --clear-headers
Repeated --header flags now merge into saved headers instead of
replacing the whole set, so incremental updates do not wipe unrelated
entries. --clear-headers removes all saved headers; combined with
--header it acts as a full replace. Also adds an e2e test running the
binary with the full set of new OpenAI-compatible flags.
2026-06-09 19:07:00 -07:00
Saoud Rizwan cfc0b0e9a9 test(cli): cover openai-compatible config fields in interactive auth TUI
Drives the onboarding flow to the OpenAI Compatible provider config
screen and asserts the new Custom Headers, Context Window, and Max
Output Tokens fields render alongside Base URL and API key.
2026-06-09 19:05:25 -07:00
Saoud Rizwan f5ef62f5e2 feat(cli): add OpenAI-compatible custom headers and model config options
Bring the CLI to parity with the VS Code extension's OpenAI Compatible
settings. Users could previously only set apiKey, model id, and base
URL.

cline auth quick setup gains:
- repeatable -H/--header key=value flags (value may contain =)
- --context-window and --max-output-tokens
- --supports-images / --no-supports-images (pins model capabilities)
- flags other than apikey/modelid update stored settings in place, so
  re-entering credentials is no longer required for incremental changes

The TUI onboarding flow and the in-session provider picker dialog gain
matching optional fields for openai-compatible: a custom headers line
(key=value, comma separated), context window, and max output tokens,
driven by new field metadata in provider-config-fields.
2026-06-09 19:05:24 -07:00
Saoud Rizwan 73950619a7 feat(core): honor stored model metadata overrides for custom provider models
Stored provider settings can carry contextWindow, maxTokens, and
capabilities for models that are not in any catalog, e.g. a custom model
id on the OpenAI-compatible provider. The runtime resolves compaction
thresholds, image support, and output token caps from knownModels
entries, so those settings were silently ignored.

Synthesize a knownModels entry for the configured model during local
runtime bootstrap when stored metadata overrides exist, and let
provider settings maxTokens flow through handler-factory as the request
max output tokens when the host sets no per-turn cap.
2026-06-09 18:57:31 -07:00
223 changed files with 5745 additions and 18162 deletions
-14
View File
@@ -1,19 +1,5 @@
# Changelog
## [3.89.2]
### Fixed
- Complete the fix for the Anthropic provider on VS Code 1.123 and later by upgrading the bundled Anthropic SDK to a release compatible with the Node 24 runtime.
- Update the Vertex AI provider to a compatible Anthropic Vertex SDK release so it works with the upgraded Anthropic SDK.
## [3.89.1]
### Fixed
- Restore the Anthropic provider on VS Code 1.123 and later, where the updated Node 24 runtime broke the bundled Anthropic SDK.
- Handle the DeepSeek V4 reasoning format.
## [3.89.0]
### Added
-26
View File
@@ -1,31 +1,5 @@
# Cline CLI Changelog
## 3.0.25
- Added ClinePass support, with selectable ClinePass models in the model picker
- Made model picker sections expandable
- Added MCP server support to plugins, including authorizing plugin MCP OAuth during install
- Encouraged parallel tool calls for faster task execution
- Capped tool output for bash commands and file reads to keep large output within context limits
- Allowed ranged reads on large files
- Fixed apply_patch to fail when a hunk is skipped
- Fixed run_commands to return captured stdout on failure and handle split heredocs
- Fixed search tools to treat zero results as success
- Fixed disabled-reasoning handling for StepFun flash
- Fixed history resume rendering isolation
- Fixed the Hugging Face URL
- Fixed Cline OAuth token formatting in provider config
## 3.0.24
- Plugin commands can now submit prompts to the agent
- Added support for overriding the API base URL
- Open the verification URL automatically when starting device authentication
- Enforced a single shared Cline Hub, so a stale hub is respawned after an upgrade
- Suppressed flickering console windows on Windows
- Fixed truncation of structured tool operation result strings so oversized tool output stays within limits
- Stopped echoing the full command text in run_commands tool results
## 3.0.23
- Fixed Vertex AI GCP settings configuration
+10
View File
@@ -161,6 +161,16 @@ cline --json "Summarize this repository"
# Quick provider setup
cline auth --provider anthropic --apikey sk-... --modelid claude-sonnet-4-6
cline auth --provider openai-native --apikey sk-... --modelid gpt-5 --baseurl https://api.example.com/v1
# OpenAI-compatible endpoint with custom headers and model configuration
cline auth --provider openai-compatible --apikey sk-... --modelid my-model \
--baseurl https://llm.example.com/v1 \
--header "X-Org=abc" --header "Authorization-Extra=token" \
--context-window 128000 --max-output-tokens 8192 --no-supports-images
# Update saved settings later without re-entering credentials
cline auth -p openai-compatible -H "X-Org=new-value"
cline auth -p openai-compatible --clear-headers
```
### Connectors
-14
View File
@@ -85,20 +85,6 @@ const result = await Bun.build({
],
define: {
"process.env.NODE_ENV": '"production"',
...(process.env.TELEMETRY_SERVICE_API_KEY
? {
"process.env.TELEMETRY_SERVICE_API_KEY": defineProcessEnv(
"TELEMETRY_SERVICE_API_KEY",
),
}
: {}),
...(process.env.ERROR_SERVICE_API_KEY
? {
"process.env.ERROR_SERVICE_API_KEY": defineProcessEnv(
"ERROR_SERVICE_API_KEY",
),
}
: {}),
"process.env.OTEL_TELEMETRY_ENABLED": defineProcessEnv(
"OTEL_TELEMETRY_ENABLED",
),
+1 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.25",
"version": "3.0.23",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -87,7 +87,6 @@
"open": "^10.2.0",
"opentui-spinner": "^0.0.6",
"pino": "^10.3.1",
"posthog-node": "^5.8.0",
"react": "19.2.4",
"react-devtools-core": "^7.0.1",
"react-reconciler": "0.32.0",
+22 -94
View File
@@ -18,8 +18,6 @@ interface KeyStep {
const INITIAL_RENDER_DELAY_SECONDS = 2.5;
const POST_ACTION_SETTLE_SECONDS = 1.0;
const INTERACTIVE_TEST_TIMEOUT_MS = 40_000;
const HISTORY_PICKER_READY_DELAY_SECONDS = 8.0;
const HISTORY_RESUME_READY_DELAY_SECONDS = 15.0;
function normalizeTerminalOutput(output: string): string {
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally strips ANSI escape sequences
@@ -53,40 +51,16 @@ function buildScriptCommand(scriptedInput: string, launchArgs: string): string {
return `(${scriptedInput}) | script ${quietFlag} /dev/null ${toShellSingleQuotedLiteral(bunExec)} ${launchArgs}`;
}
function createCliEnv(): NodeJS.ProcessEnv {
function runInteractiveCli(
steps: KeyStep[],
options?: { launchConfigView?: boolean },
): CliResult {
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-home-"));
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-data-"));
const sessionDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-sessions-"));
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-teams-"));
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
return {
...process.env,
HOME: homeDir,
CLINE_DATA_DIR: dataDir,
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
CLINE_SESSION_DATA_DIR: sessionDir,
CLINE_TEAM_DATA_DIR: teamDir,
CLINE_SESSION_BACKEND_MODE: "local",
CLINE_PROVIDER_SETTINGS_PATH: path.join(
dataDir,
"settings",
"providers.json",
),
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
};
}
function runInteractiveCli(
steps: KeyStep[],
options?: {
launchConfigView?: boolean;
launchArgs?: string[];
env?: NodeJS.ProcessEnv;
},
): CliResult {
const env = options?.env ?? createCliEnv();
const scriptedInput = [
...steps,
// Exit each interactive run explicitly so tests do not idle until timeout.
@@ -106,13 +80,9 @@ function runInteractiveCli(
"-k",
"test-key",
];
const launchArgs = (
options?.launchArgs
? [cliEntry, ...options.launchArgs]
: options?.launchConfigView
? [...baseArgs, "config"]
: baseArgs
)
const launchArgs = [
...(options?.launchConfigView ? [...baseArgs, "config"] : baseArgs),
]
.map((arg) => toShellSingleQuotedLiteral(arg))
.join(" ");
const command = buildScriptCommand(scriptedInput, launchArgs);
@@ -120,7 +90,21 @@ function runInteractiveCli(
return spawnSync("bash", ["-lc", command], {
cwd: cliRoot,
encoding: "utf8",
env,
env: {
...process.env,
HOME: homeDir,
CLINE_DATA_DIR: dataDir,
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
CLINE_SESSION_DATA_DIR: sessionDir,
CLINE_TEAM_DATA_DIR: teamDir,
CLINE_SESSION_BACKEND_MODE: "local",
CLINE_PROVIDER_SETTINGS_PATH: path.join(
dataDir,
"settings",
"providers.json",
),
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
},
timeout: INTERACTIVE_TEST_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
});
@@ -204,62 +188,6 @@ describe("cli interactive e2e", () => {
expect(output).toContain("/ for commands · @ for files");
});
it("resumes a history-picked session and survives Ctrl+C without a native crash", {
timeout: 120_000,
}, () => {
const env = createCliEnv();
// Seed one session; the invalid key makes the run fail fast while
// still persisting a resumable session record.
const seed = spawnSync(
bunExec,
[
cliEntry,
"--provider",
"anthropic",
"-m",
"claude-sonnet-4-6",
"-k",
"test-key",
"hello",
],
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
);
expect(seed.error).toBeUndefined();
const history = spawnSync(bunExec, [cliEntry, "history", "--json"], {
cwd: cliRoot,
encoding: "utf8",
env,
timeout: 60_000,
});
expect(history.error).toBeUndefined();
expect(history.status).toBe(0);
const historyRows = JSON.parse(history.stdout) as unknown[];
expect(historyRows.length).toBeGreaterThan(0);
// history picker -> Enter resumes the seeded session in the
// interactive TUI -> double Ctrl+C exits it. Regression guard for
// the Bun "panic(main thread): Segmentation fault" that occurred
// when the resumed TUI shared the picker's process (a second
// OpenTUI renderer in one process crashes natively on teardown).
const result = runInteractiveCli(
[
// Select the seeded session in the picker.
{ delaySeconds: HISTORY_PICKER_READY_DELAY_SECONDS, input: "\r" },
// Give the resumed TUI time to start, then double-press
// Ctrl+C; the harness appends the final press 0.2s later.
{ delaySeconds: HISTORY_RESUME_READY_DELAY_SECONDS, input: "\u0003" },
],
{ launchArgs: ["history"], env },
);
const output = outputOf(result);
// The exit summary only prints after the resumed interactive TUI ran
// and shut down cleanly; the history picker alone never prints it.
expect(output).toContain("Session Summary");
expect(output).not.toContain("panic(");
expect(output).not.toContain("Segmentation fault");
expect(result.status).toBe(0);
});
it("launches config view directly with `cline config`", () => {
const result = runInteractiveCli(
[{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "" }],
+318 -1
View File
@@ -1,14 +1,43 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import type { ProviderSettingsManager } from "@cline/core";
import { ProviderSettingsManager } from "@cline/core";
import { describe, expect, it, vi } from "vitest";
import {
getPersistedProviderApiKey,
normalizeAuthProviderId,
parseAuthCommandArgs,
parseHeaderFlags,
runAuthCommand,
saveOAuthProviderSettings,
} from "./auth";
function createTempProviderSettingsManager(): ProviderSettingsManager {
const dir = mkdtempSync(join(tmpdir(), "cline-auth-test-"));
return new ProviderSettingsManager({
filePath: join(dir, "settings", "providers.json"),
});
}
function createAuthIo() {
const out: string[] = [];
const err: string[] = [];
return {
io: {
writeln: (text?: string) => {
out.push(text ?? "");
},
writeErr: (text: string) => {
err.push(text);
},
},
out,
err,
};
}
describe("parseAuthCommandArgs", () => {
it("parses Azure API version quick setup option", () => {
expect(
@@ -97,6 +126,294 @@ describe("getPersistedProviderApiKey", () => {
});
});
describe("parseAuthCommandArgs", () => {
it("parses repeatable --header flags and model configuration options", () => {
const parsed = parseAuthCommandArgs([
"--provider",
"openai-compatible",
"--header",
"X-Org=abc",
"-H",
"Authorization=Bearer a=b",
"--context-window",
"128000",
"--max-output-tokens",
"8192",
"--supports-images",
]);
expect(parsed.parseError).toBeUndefined();
expect(parsed.explicitProvider).toBe("openai-compatible");
expect(parsed.header).toEqual(["X-Org=abc", "Authorization=Bearer a=b"]);
expect(parsed.contextWindow).toBe("128000");
expect(parsed.maxOutputTokens).toBe("8192");
expect(parsed.supportsImages).toBe(true);
});
it("parses --no-supports-images as an explicit false", () => {
const parsed = parseAuthCommandArgs([
"--provider",
"openai-compatible",
"--no-supports-images",
]);
expect(parsed.supportsImages).toBe(false);
});
it("leaves supportsImages undefined when neither flag is given", () => {
const parsed = parseAuthCommandArgs(["--provider", "openai-compatible"]);
expect(parsed.supportsImages).toBeUndefined();
});
});
describe("parseHeaderFlags", () => {
it("splits each header at the first equals sign", () => {
expect(
parseHeaderFlags(["X-Org=abc", "Authorization=Bearer a=b=c"]),
).toEqual({
headers: {
"X-Org": "abc",
Authorization: "Bearer a=b=c",
},
});
});
it("rejects headers without a key", () => {
expect(parseHeaderFlags(["=value"]).error).toMatch(/invalid --header/);
expect(parseHeaderFlags(["no-separator"]).error).toMatch(
/invalid --header/,
);
});
it("returns no headers for empty input", () => {
expect(parseHeaderFlags(undefined)).toEqual({});
expect(parseHeaderFlags([])).toEqual({});
});
});
describe("runAuthCommand quick setup", () => {
it("persists headers and model configuration for openai-compatible", async () => {
const manager = createTempProviderSettingsManager();
const { io, err } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "openai-compatible",
apikey: "sk-test",
modelid: "my-custom-model",
baseurl: "https://llm.example.com/v1",
header: ["X-Org=abc", "Authorization=Bearer token=1"],
contextWindow: "128000",
maxOutputTokens: "8192",
supportsImages: true,
});
expect(err).toEqual([]);
expect(exitCode).toBe(0);
expect(manager.getProviderSettings("openai-compatible")).toMatchObject({
provider: "openai-compatible",
apiKey: "sk-test",
model: "my-custom-model",
baseUrl: "https://llm.example.com/v1",
headers: {
"X-Org": "abc",
Authorization: "Bearer token=1",
},
contextWindow: 128_000,
maxTokens: 8_192,
});
expect(
manager.getProviderSettings("openai-compatible")?.capabilities,
).toEqual(expect.arrayContaining(["streaming", "tools", "vision"]));
});
it("updates stored settings without requiring the api key again", async () => {
const manager = createTempProviderSettingsManager();
manager.saveProviderSettings({
provider: "openai-compatible",
apiKey: "sk-existing",
model: "my-custom-model",
baseUrl: "https://llm.example.com/v1",
});
const { io, err } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "openai-compatible",
header: ["X-Team=infra"],
});
expect(err).toEqual([]);
expect(exitCode).toBe(0);
expect(manager.getProviderSettings("openai-compatible")).toMatchObject({
apiKey: "sk-existing",
model: "my-custom-model",
headers: { "X-Team": "infra" },
});
});
it("merges new headers into existing ones", async () => {
const manager = createTempProviderSettingsManager();
manager.saveProviderSettings({
provider: "openai-compatible",
apiKey: "sk-existing",
model: "my-custom-model",
headers: { "X-Org": "abc", "X-Team": "old" },
});
const { io } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "openai-compatible",
header: ["X-Team=infra"],
});
expect(exitCode).toBe(0);
expect(manager.getProviderSettings("openai-compatible")?.headers).toEqual({
"X-Org": "abc",
"X-Team": "infra",
});
});
it("removes saved headers with --clear-headers", async () => {
const manager = createTempProviderSettingsManager();
manager.saveProviderSettings({
provider: "openai-compatible",
apiKey: "sk-existing",
model: "my-custom-model",
headers: { "X-Org": "abc" },
});
const { io } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "openai-compatible",
clearHeaders: true,
});
expect(exitCode).toBe(0);
expect(
manager.getProviderSettings("openai-compatible")?.headers,
).toBeUndefined();
});
it("replaces headers when --clear-headers is combined with --header", async () => {
const manager = createTempProviderSettingsManager();
manager.saveProviderSettings({
provider: "openai-compatible",
apiKey: "sk-existing",
model: "my-custom-model",
headers: { "X-Org": "abc", "X-Team": "old" },
});
const { io } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "openai-compatible",
clearHeaders: true,
header: ["X-Fresh=1"],
});
expect(exitCode).toBe(0);
expect(manager.getProviderSettings("openai-compatible")?.headers).toEqual({
"X-Fresh": "1",
});
});
it("removes the vision capability with --no-supports-images", async () => {
const manager = createTempProviderSettingsManager();
manager.saveProviderSettings({
provider: "openai-compatible",
apiKey: "sk-existing",
model: "my-custom-model",
capabilities: ["streaming", "tools", "vision"],
});
const { io } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "openai-compatible",
supportsImages: false,
});
expect(exitCode).toBe(0);
const capabilities =
manager.getProviderSettings("openai-compatible")?.capabilities;
expect(capabilities).toEqual(
expect.arrayContaining(["streaming", "tools"]),
);
expect(capabilities).not.toContain("vision");
});
it("rejects custom headers for providers without endpoint customization", async () => {
const manager = createTempProviderSettingsManager();
const { io, err } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "anthropic",
apikey: "sk-ant",
modelid: "claude-sonnet-4-20250514",
header: ["X-Org=abc"],
});
expect(exitCode).toBe(1);
expect(err.join("\n")).toMatch(/custom headers are only supported/i);
});
it("rejects model configuration options for non openai-compatible providers", async () => {
const manager = createTempProviderSettingsManager();
const { io, err } = createAuthIo();
const exitCode = await runAuthCommand({
providerSettingsManager: manager,
io,
explicitProvider: "anthropic",
apikey: "sk-ant",
modelid: "claude-sonnet-4-20250514",
contextWindow: "128000",
});
expect(exitCode).toBe(1);
expect(err.join("\n")).toMatch(/model configuration options/i);
});
it("rejects malformed header and numeric flag values", async () => {
const manager = createTempProviderSettingsManager();
const malformedHeader = createAuthIo();
expect(
await runAuthCommand({
providerSettingsManager: manager,
io: malformedHeader.io,
explicitProvider: "openai-compatible",
apikey: "sk-test",
modelid: "my-custom-model",
header: ["missing-separator"],
}),
).toBe(1);
expect(malformedHeader.err.join("\n")).toMatch(/invalid --header/);
const malformedNumber = createAuthIo();
expect(
await runAuthCommand({
providerSettingsManager: manager,
io: malformedNumber.io,
explicitProvider: "openai-compatible",
apikey: "sk-test",
modelid: "my-custom-model",
maxOutputTokens: "not-a-number",
}),
).toBe(1);
expect(malformedNumber.err.join("\n")).toMatch(/--max-output-tokens/);
});
});
describe("normalizeAuthProviderId", () => {
it("keeps CLI-only codex shorthand in CLI parsing", () => {
expect(normalizeAuthProviderId("codex")).toBe("openai-codex");
+197 -15
View File
@@ -4,6 +4,7 @@ import {
createOAuthClientCallbacks,
ensureCustomProvidersLoaded,
getProviderAuthHandler,
listLocalProviders,
loginAndSaveProviderOAuthCredentials,
type ProviderSettings,
type ProviderSettingsManager,
@@ -21,8 +22,6 @@ import {
type OAuthCredentials,
toProviderApiKey,
} from "../utils/provider-auth";
import { listLocalProviders } from "../utils/provider-catalog";
import { identifyTelemetryAccount } from "../utils/telemetry";
export {
getPersistedProviderApiKey,
@@ -51,6 +50,11 @@ type AuthQuickSetupInput = {
modelid: string;
baseurl?: string;
azureApiVersion?: string;
headers?: Record<string, string>;
clearHeaders?: boolean;
contextWindow?: number;
maxOutputTokens?: number;
supportsImages?: boolean;
};
type AuthCommandInput = {
@@ -61,6 +65,11 @@ type AuthCommandInput = {
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
header?: string[];
clearHeaders?: boolean;
contextWindow?: string;
maxOutputTokens?: string;
supportsImages?: boolean;
};
type ParsedAuthCommandArgs = {
@@ -69,6 +78,11 @@ type ParsedAuthCommandArgs = {
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
header?: string[];
clearHeaders?: boolean;
contextWindow?: string;
maxOutputTokens?: string;
supportsImages?: boolean;
parseError?: string;
};
@@ -79,6 +93,10 @@ type ParsedAuthCommandArgs = {
* which intentionally shadows the global `-p` (--plan) and `-m` (--model)
* short flags. Commander scopes options per-command, so there is no conflict.
*/
function collectRepeatable(value: string, previous: string[]): string[] {
return [...previous, value];
}
export function createAuthCommand(): Command {
const cmd = new Command("auth")
.description("Authenticate with an LLM provider")
@@ -89,7 +107,24 @@ export function createAuthCommand(): Command {
.option("-k, --apikey <key>", "API key")
.option("-m, --modelid <id>", "model id")
.option("-b, --baseurl <url>", "base URL")
.option("--azure-api-version <version>", "Azure API version");
.option("--azure-api-version <version>", "Azure API version")
.option(
"-H, --header <key=value>",
"custom HTTP header sent on every request (repeatable)",
collectRepeatable,
[],
)
.option("--context-window <tokens>", "context window size for the model")
.option(
"--max-output-tokens <tokens>",
"max output tokens per request for the model",
)
.option("--supports-images", "mark the model as supporting image input")
.option(
"--no-supports-images",
"mark the model as not supporting image input",
)
.option("--clear-headers", "remove all saved custom headers");
return cmd;
}
@@ -107,6 +142,11 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
header?: string[];
clearHeaders?: boolean;
contextWindow?: string;
maxOutputTokens?: string;
supportsImages?: boolean;
}>();
const positionalProvider = cmd.args[0];
return {
@@ -115,9 +155,56 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
modelid: opts.modelid,
baseurl: opts.baseurl,
azureApiVersion: opts.azureApiVersion,
header: opts.header,
clearHeaders: opts.clearHeaders,
contextWindow: opts.contextWindow,
maxOutputTokens: opts.maxOutputTokens,
supportsImages: opts.supportsImages,
};
}
export function parseHeaderFlags(values: string[] | undefined): {
headers?: Record<string, string>;
error?: string;
} {
if (!values || values.length === 0) {
return {};
}
const headers: Record<string, string> = {};
for (const value of values) {
// Split at the first "=" so header values may contain "=" themselves.
const separatorIndex = value.indexOf("=");
const key = separatorIndex > 0 ? value.slice(0, separatorIndex).trim() : "";
if (!key) {
return {
error: `invalid --header "${value}" (expected format: key=value)`,
};
}
headers[key] = value.slice(separatorIndex + 1).trim();
}
return { headers };
}
function parsePositiveInteger(
value: string | undefined,
flag: string,
): { parsed?: number; error?: string } {
if (value === undefined) {
return {};
}
const parsed = Number.parseInt(value, 10);
if (
!Number.isFinite(parsed) ||
parsed <= 0 ||
String(parsed) !== value.trim()
) {
return {
error: `invalid ${flag} "${value}" (expected a positive integer)`,
};
}
return { parsed };
}
async function loadProviderCatalog(
providerSettingsManager: ProviderSettingsManager,
): Promise<Array<{ id: string; name: string }>> {
@@ -141,10 +228,15 @@ async function ensureQuickSetupInputValid(
if (!providerCatalog.some((provider) => provider.id === normalizedProvider)) {
return `invalid provider "${input.provider}"`;
}
if (!input.apikey.trim()) {
const existing =
providerSettingsManager.getProviderSettings(normalizedProvider);
const hasStoredApiKey = Boolean(
existing?.apiKey?.trim() || existing?.auth?.accessToken?.trim(),
);
if (!input.apikey.trim() && !hasStoredApiKey) {
return "auth quick setup requires --apikey <key>";
}
if (!input.modelid.trim()) {
if (!input.modelid.trim() && !existing?.model?.trim()) {
return "auth quick setup requires --modelid <id>";
}
if (
@@ -160,6 +252,22 @@ async function ensureQuickSetupInputValid(
) {
return "Azure API version is only supported for OpenAI-compatible providers";
}
if (
((input.headers && Object.keys(input.headers).length > 0) ||
input.clearHeaders) &&
normalizedProvider !== BUILT_IN_PROVIDER.OPENAI_COMPATIBLE &&
normalizedProvider !== BUILT_IN_PROVIDER.OPENAI_NATIVE
) {
return "custom headers are only supported for OpenAI and OpenAI-compatible providers";
}
if (
(input.contextWindow !== undefined ||
input.maxOutputTokens !== undefined ||
input.supportsImages !== undefined) &&
normalizedProvider !== BUILT_IN_PROVIDER.OPENAI_COMPATIBLE
) {
return "model configuration options (--context-window, --max-output-tokens, --supports-images) are only supported for the OpenAI-compatible provider";
}
return undefined;
}
@@ -170,6 +278,11 @@ function saveQuickAuthProviderSettings(input: {
modelid: string;
baseurl?: string;
azureApiVersion?: string;
headers?: Record<string, string>;
clearHeaders?: boolean;
contextWindow?: number;
maxOutputTokens?: number;
supportsImages?: boolean;
}): void {
const existing = input.providerSettingsManager.getProviderSettings(
input.providerId,
@@ -179,9 +292,13 @@ function saveQuickAuthProviderSettings(input: {
provider: input.providerId as ProviderSettings["provider"],
}),
provider: input.providerId as ProviderSettings["provider"],
apiKey: input.apikey,
model: input.modelid,
};
if (input.apikey.trim()) {
nextSettings.apiKey = input.apikey;
}
if (input.modelid.trim()) {
nextSettings.model = input.modelid;
}
if (input.baseurl?.trim()) {
nextSettings.baseUrl = input.baseurl.trim();
}
@@ -191,6 +308,35 @@ function saveQuickAuthProviderSettings(input: {
apiVersion: input.azureApiVersion.trim(),
};
}
if (input.clearHeaders) {
delete nextSettings.headers;
}
if (input.headers && Object.keys(input.headers).length > 0) {
nextSettings.headers = {
...(input.clearHeaders ? {} : (existing?.headers ?? {})),
...input.headers,
};
}
if (input.contextWindow !== undefined) {
nextSettings.contextWindow = input.contextWindow;
}
if (input.maxOutputTokens !== undefined) {
nextSettings.maxTokens = input.maxOutputTokens;
}
if (input.supportsImages !== undefined) {
// Model capabilities default to "images allowed" when unset, so an
// explicit flag pins the capability list either way. Streaming and
// tools stay on; they are table stakes for any model Cline can drive.
const capabilities = new Set<
NonNullable<ProviderSettings["capabilities"]>[number]
>(existing?.capabilities ?? ["streaming", "tools"]);
if (input.supportsImages) {
capabilities.add("vision");
} else {
capabilities.delete("vision");
}
nextSettings.capabilities = [...capabilities];
}
input.providerSettingsManager.saveProviderSettings(nextSettings);
}
@@ -287,6 +433,27 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
const modelid = input.modelid?.trim() ?? "";
const baseurl = input.baseurl?.trim();
const azureApiVersion = input.azureApiVersion?.trim();
const { headers, error: headerError } = parseHeaderFlags(input.header);
if (headerError) {
input.io.writeErr(headerError);
return 1;
}
const contextWindow = parsePositiveInteger(
input.contextWindow,
"--context-window",
);
if (contextWindow.error) {
input.io.writeErr(contextWindow.error);
return 1;
}
const maxOutputTokens = parsePositiveInteger(
input.maxOutputTokens,
"--max-output-tokens",
);
if (maxOutputTokens.error) {
input.io.writeErr(maxOutputTokens.error);
return 1;
}
const validationError = await ensureQuickSetupInputValid(
{
provider: providerId,
@@ -294,6 +461,11 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
modelid,
baseurl,
azureApiVersion,
headers,
clearHeaders: input.clearHeaders,
contextWindow: contextWindow.parsed,
maxOutputTokens: maxOutputTokens.parsed,
supportsImages: input.supportsImages,
},
input.providerSettingsManager,
);
@@ -308,9 +480,18 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
modelid,
baseurl,
azureApiVersion,
headers,
clearHeaders: input.clearHeaders,
contextWindow: contextWindow.parsed,
maxOutputTokens: maxOutputTokens.parsed,
supportsImages: input.supportsImages,
});
const configuredModelId =
modelid ||
input.providerSettingsManager.getProviderSettings(providerId)?.model ||
"";
input.io.writeln(
`${c.green}Provider configured:${c.reset} ${c.cyan}${providerId}${c.reset} (${modelid})`,
`${c.green}Provider configured:${c.reset} ${c.cyan}${providerId}${c.reset} (${configuredModelId})`,
);
return 0;
}
@@ -393,12 +574,17 @@ export async function runAuthCommand(input: AuthCommandInput): Promise<number> {
typeof input.apikey === "string" ||
typeof input.modelid === "string" ||
typeof input.baseurl === "string" ||
typeof input.azureApiVersion === "string";
typeof input.azureApiVersion === "string" ||
(input.header?.length ?? 0) > 0 ||
input.clearHeaders === true ||
typeof input.contextWindow === "string" ||
typeof input.maxOutputTokens === "string" ||
typeof input.supportsImages === "boolean";
if (hasQuickSetupFlags) {
if (!input.explicitProvider?.trim()) {
input.io.writeErr(
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl/--azure-api-version",
"auth quick setup requires --provider <id> when using auth options like --apikey/--modelid/--baseurl/--header",
);
return 1;
}
@@ -435,15 +621,11 @@ export async function runAuthProviderCommand(
return 1;
}
try {
const settings = await loginAndSaveProviderOAuthCredentials(
await loginAndSaveProviderOAuthCredentials(
providerSettingsManager,
providerId,
{ callbacks: createOAuthCallbacks(io) },
);
identifyTelemetryAccount({
id: settings.auth?.accountId,
provider: providerId,
});
io.writeln(
`${c.green}You are now logged in to ${c.cyan}${providerId}${c.reset}`,
);
+2 -24
View File
@@ -14,7 +14,6 @@ import { getCliBuildInfo } from "../utils/common";
const {
mockSpawnSync,
mockResolveClineDataDir,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockReadHubDiscovery,
mockProbeHubServer,
@@ -25,15 +24,6 @@ const {
} = vi.hoisted(() => ({
mockSpawnSync: vi.fn(),
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"production.json",
),
})),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: path.join(
@@ -62,7 +52,6 @@ vi.mock("node:child_process", () => ({
vi.mock("@cline/core", () => ({
resolveClineDataDir: mockResolveClineDataDir,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
clearHubDiscovery: mockClearHubDiscovery,
probeHubServer: mockProbeHubServer,
@@ -87,15 +76,6 @@ describe("runDoctorCommand", () => {
afterEach(() => {
vi.clearAllMocks();
mockResolveClineDataDir.mockReturnValue("/tmp/cline-data");
mockResolveProductionHubOwnerContext.mockReturnValue({
ownerId: "hub-production",
discoveryPath: path.join(
"/tmp/cline-data",
"locks",
"hub",
"production.json",
),
});
mockStopLocalHubServerGracefully.mockResolvedValue(false);
mockStopAllConnectors.mockResolvedValue({
stoppedProcesses: 0,
@@ -130,8 +110,7 @@ describe("runDoctorCommand", () => {
command === "pgrep" &&
Array.isArray(args) &&
args[0] === "-fal" &&
args[1] === "--" &&
args[2] === "/apps/cli/src/index.ts"
args[1] === "/apps/cli/src/index.ts"
) {
return {
status: 0,
@@ -282,8 +261,7 @@ describe("runDoctorCommand", () => {
command === "pgrep" &&
Array.isArray(args) &&
args[0] === "-fal" &&
args[1] === "--" &&
args[2] === "/src-tauri/bin/code-sidecar"
args[1] === "/src-tauri/bin/code-sidecar"
) {
return {
status: 0,
+9 -60
View File
@@ -7,11 +7,10 @@ import {
probeHubServer,
readHubDiscovery,
resolveClineDataDir,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { formatUptime } from "@cline/shared";
import { Command } from "commander";
import open from "open";
import { isProcessRunning } from "../connectors/common";
@@ -55,7 +54,6 @@ type DoctorStatus = {
hubStartedAt?: string;
hubUptime?: string;
listeningPids: number[];
staleHubPids: number[];
hubStartupLocks: StartupArtifact[];
staleCliPids: number[];
staleSidecarPids: number[];
@@ -79,11 +77,7 @@ function listMatchingProcesses(pattern: string): ProcessRecord[] {
if (process.platform === "win32") {
return [];
}
// "--" stops pgrep's option parsing so patterns that start with dashes
// (e.g. the "--cline-hub-daemon" marker) are treated as patterns.
const result = spawnSync("pgrep", ["-fal", "--", pattern], {
encoding: "utf8",
});
const result = spawnSync("pgrep", ["-fal", pattern], { encoding: "utf8" });
if (result.status !== 0 && result.status !== 1) {
return [];
}
@@ -154,25 +148,6 @@ function listStaleCliPids(): number[] {
.map((record) => record.pid);
}
function listStaleHubPids(currentHubPids: number[]): number[] {
const current = new Set(currentHubPids.filter((pid) => pid > 0));
const patterns = [
"/sdk/packages/core/src/hub/daemon/entry.ts",
"/sdk/packages/core/dist/hub/daemon/entry.js",
"--cline-hub-daemon",
];
const records = new Map<number, ProcessRecord>();
for (const pattern of patterns) {
for (const record of listMatchingProcesses(pattern)) {
if (current.has(record.pid) || /\bpgrep\s+-fal\b/.test(record.command)) {
continue;
}
records.set(record.pid, record);
}
}
return [...records.values()].map((record) => record.pid);
}
function listStaleSidecarPids(): number[] {
const patterns = [
"/apps/examples/desktop-app/sidecar/index.ts",
@@ -260,7 +235,7 @@ function readStartupArtifact(path: string): StartupArtifact | undefined {
}
function listHubStartupLocks(_cwd: string): StartupArtifact[] {
const owner = resolveCliHubOwnerContext();
const owner = resolveSharedHubOwnerContext();
const ownerPath = join(`${owner.discoveryPath}.lock`, "owner.json");
if (!existsSync(ownerPath)) {
return [];
@@ -284,7 +259,7 @@ async function clearHubStartupArtifacts(
_cwd: string,
options?: { clearDiscovery?: boolean },
): Promise<{ startupLocks: number; discovery: number }> {
const owner = resolveCliHubOwnerContext();
const owner = resolveSharedHubOwnerContext();
const startupLocks = listHubStartupLocks(_cwd);
let clearedStartupLocks = 0;
for (const artifact of startupLocks) {
@@ -316,25 +291,14 @@ function formatHubUptimeFromStartedAt(
return formatUptime(Date.now() - timestamp);
}
function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
const owner = resolveCliHubOwnerContext();
const owner = resolveSharedHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
const health = discovery?.url
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
? await probeHubServer(discovery.url)
: undefined;
const current = health ?? discovery;
const hubUptime = formatHubUptimeFromStartedAt(health?.startedAt);
const listeningPids = listListeningPids(current?.port);
const currentHubPids = [
...(current?.pid ? [current.pid] : []),
...listeningPids,
];
return {
cwd,
hubUrl: current?.url,
@@ -342,8 +306,7 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
hubPid: current?.pid,
hubStartedAt: health?.startedAt,
hubUptime,
listeningPids,
staleHubPids: listStaleHubPids(currentHubPids),
listeningPids: listListeningPids(current?.port),
hubStartupLocks: listHubStartupLocks(cwd),
staleCliPids: listStaleCliPids(),
staleSidecarPids: listStaleSidecarPids(),
@@ -425,7 +388,6 @@ export async function runDoctorCommand(
);
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
writeln(formatPidList("hub listeners", before.listeningPids));
writeln(formatPidList("stale hub daemons", before.staleHubPids));
writeln(
formatPidList(
"hub startup locks",
@@ -450,7 +412,6 @@ export async function runDoctorCommand(
}
if (
before.listeningPids.length > 0 ||
before.staleHubPids.length > 0 ||
before.staleCliPids.length > 0 ||
before.staleSidecarPids.length > 0
) {
@@ -462,9 +423,7 @@ export async function runDoctorCommand(
}
const gracefullyStoppedHub = before.hubHealthy
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
() => false,
)
? await stopLocalHubServerGracefully().catch(() => false)
: false;
const refreshedAfterGracefulStop = gracefullyStoppedHub
? await collectDoctorStatus(opts.cwd)
@@ -472,20 +431,13 @@ export async function runDoctorCommand(
const killedHub = gracefullyStoppedHub
? 0
: killPids(refreshedAfterGracefulStop.listeningPids);
const staleHubTargets = before.staleHubPids.filter(
(pid) => !refreshedAfterGracefulStop.listeningPids.includes(pid),
);
const killedStaleHubs = killPids(staleHubTargets);
const staleCliTargets = before.staleCliPids.filter(
(pid) =>
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
!staleHubTargets.includes(pid),
(pid) => !refreshedAfterGracefulStop.listeningPids.includes(pid),
);
const killedCli = killPids(staleCliTargets);
const staleSidecarTargets = before.staleSidecarPids.filter(
(pid) =>
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
!staleHubTargets.includes(pid) &&
!staleCliTargets.includes(pid),
);
const killedSidecars = killPids(staleSidecarTargets);
@@ -507,7 +459,6 @@ export async function runDoctorCommand(
after,
killed: {
hubListeners: killedHub,
staleHubDaemons: killedStaleHubs,
cliProcesses: killedCli,
sidecarProcesses: killedSidecars,
connectorProcesses: stoppedConnectors.stoppedProcesses,
@@ -520,7 +471,6 @@ export async function runDoctorCommand(
return 0;
}
writeln(`killed hub listeners ${c.dim}${killedHub}${c.reset}`);
writeln(`killed stale hub daemons ${c.dim}${killedStaleHubs}${c.reset}`);
writeln(`killed cli processes ${c.dim}${killedCli}${c.reset}`);
writeln(`killed sidecar processes ${c.dim}${killedSidecars}${c.reset}`);
writeln(
@@ -537,7 +487,6 @@ export async function runDoctorCommand(
);
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
writeln(formatPidList("remaining hub listeners", after.listeningPids));
writeln(formatPidList("remaining stale hub daemons", after.staleHubPids));
writeln(
formatPidList(
"remaining hub startup locks",
+1 -51
View File
@@ -1,11 +1,10 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
const {
mockClearHubDiscovery,
mockEnsureDetachedHubServer,
mockProbeHubServer,
mockReadHubDiscovery,
mockResolveProductionHubOwnerContext,
mockResolveSharedHubOwnerContext,
mockStopLocalHubServerGracefully,
} = vi.hoisted(() => ({
@@ -13,10 +12,6 @@ const {
mockEnsureDetachedHubServer: vi.fn(),
mockProbeHubServer: vi.fn(),
mockReadHubDiscovery: vi.fn(),
mockResolveProductionHubOwnerContext: vi.fn(() => ({
ownerId: "hub-production",
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
})),
mockResolveSharedHubOwnerContext: vi.fn(() => ({
ownerId: "hub-owner",
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
@@ -29,25 +24,13 @@ vi.mock("@cline/core", () => ({
ensureDetachedHubServer: mockEnsureDetachedHubServer,
probeHubServer: mockProbeHubServer,
readHubDiscovery: mockReadHubDiscovery,
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
}));
import { createHubCommand } from "./hub";
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
describe("createHubCommand", () => {
afterEach(() => {
vi.clearAllMocks();
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
});
it("includes uptime in hub status output", async () => {
vi.spyOn(Date, "now").mockReturnValue(
new Date("2026-01-01T00:01:05.000Z").getTime(),
@@ -90,37 +73,4 @@ describe("createHubCommand", () => {
uptime: "1m 5s",
});
});
it("passes the selected owner to graceful stop", async () => {
process.env.CLINE_BUILD_ENV = "development";
mockReadHubDiscovery.mockResolvedValue({
url: "ws://127.0.0.1:25466/hub",
port: 25466,
pid: 50174,
});
mockStopLocalHubServerGracefully.mockResolvedValue(true);
const output: string[] = [];
let exitCode = 0;
const cmd = createHubCommand(
{
writeln: (text) => {
output.push(text ?? "");
},
writeErr: () => {},
},
(code) => {
exitCode = code;
},
);
await cmd.parseAsync(["stop"], { from: "user" });
expect(exitCode).toBe(0);
expect(mockStopLocalHubServerGracefully).toHaveBeenCalledWith({
ownerId: "hub-owner",
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
});
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
});
});
+5 -14
View File
@@ -3,11 +3,10 @@ import {
ensureDetachedHubServer,
probeHubServer,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
import { formatUptime } from "@cline/shared";
import { Command } from "commander";
interface HubCommandIo {
@@ -16,9 +15,9 @@ interface HubCommandIo {
}
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
const owner = resolveCliHubOwnerContext();
const owner = resolveSharedHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (await stopLocalHubServerGracefully(owner)) {
if (await stopLocalHubServerGracefully()) {
await clearHubDiscovery(owner.discoveryPath);
return true;
}
@@ -47,12 +46,6 @@ function formatHubUptimeFromStartedAt(
return formatUptime(Date.now() - timestamp);
}
function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
export function createHubCommand(
io: HubCommandIo,
setExitCode: (code: number) => void,
@@ -119,12 +112,10 @@ export function createHubCommand(
hub.command("status").action(
action(async () => {
const owner = resolveCliHubOwnerContext();
const owner = resolveSharedHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
const health = discovery?.url
? await probeHubServer(discovery.url, {
authToken: discovery.authToken,
})
? await probeHubServer(discovery.url)
: undefined;
const uptime = formatHubUptimeFromStartedAt(health?.startedAt);
io.writeln(
-6
View File
@@ -168,8 +168,6 @@ export function buildKanbanSpawnOptions(
detached: shouldDetachKanbanProcess(platform),
...(platform === "win32" ? { shell: true } : {}),
...options,
// Prevent a console window from flashing on Windows.
windowsHide: true,
};
}
@@ -180,8 +178,6 @@ function buildKanbanInstallSpawnOptions(
return {
detached: false,
stdio: "inherit",
// Prevent a console window from flashing on Windows.
windowsHide: true,
...(platform === "win32" ? { shell: true } : {}),
...options,
};
@@ -207,8 +203,6 @@ export function getInstalledKanbanVersion(): string | null {
const result = spawnSync(getKanbanCommand(), ["--version"], {
encoding: "utf8",
shell: process.platform === "win32",
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
if (result.status !== 0) {
return null;
-338
View File
@@ -17,7 +17,6 @@ import {
} from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
collectPluginMcpOAuthCandidates,
installPlugin,
isOfficialPluginSlug,
parsePluginSource,
@@ -36,7 +35,6 @@ describe("plugin install command", () => {
let originalHome: string | undefined;
let originalClineDir: string | undefined;
let originalClineDataDir: string | undefined;
let originalMcpSettingsPath: string | undefined;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "cli-plugin-install-"));
@@ -45,7 +43,6 @@ describe("plugin install command", () => {
originalHome = process.env.HOME;
originalClineDir = process.env.CLINE_DIR;
originalClineDataDir = process.env.CLINE_DATA_DIR;
originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
process.env.HOME = home;
process.env.CLINE_DIR = join(home, ".cline");
process.env.CLINE_DATA_DIR = join(home, ".cline", "data");
@@ -94,11 +91,6 @@ describe("plugin install command", () => {
} else {
process.env.CLINE_DATA_DIR = originalClineDataDir;
}
if (originalMcpSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
}
rmSync(root, { recursive: true, force: true });
});
@@ -684,341 +676,11 @@ describe("plugin install command", () => {
expect(code).toBe(0);
const parsed = JSON.parse(stdout.join("")) as { installPath: string };
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
expect("mcpOAuthCandidates" in parsed).toBe(false);
} finally {
process.stdout.write = originalWrite;
}
});
it("does not run MCP OAuth follow-up for JSON plugin installs", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "json-oauth-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "json-oauth-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "json-oauth-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const stdout: string[] = [];
const originalWrite = process.stdout.write;
const authorize = vi.fn();
process.stdout.write = ((chunk: string | Uint8Array) => {
stdout.push(String(chunk));
return true;
}) as typeof process.stdout.write;
try {
const code = await runPluginInstallCommand({
source,
json: true,
io: {
writeln: () => {},
writeErr: () => {},
},
mcpOAuth: {
interactive: true,
selectCandidates: async (candidates) => candidates,
authorize,
},
});
expect(code).toBe(0);
expect(authorize).not.toHaveBeenCalled();
const parsed = JSON.parse(stdout.join("")) as {
installPath: string;
mcpOAuthCandidates?: unknown;
};
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
expect(parsed.mcpOAuthCandidates).toBeUndefined();
} finally {
process.stdout.write = originalWrite;
}
});
it("warns when plugin MCP settings sync fails after install", async () => {
const source = join(root, "mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "mcp-plugin",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const blockedDirectory = join(root, "not-a-directory");
writeFileSync(blockedDirectory, "file", "utf8");
const originalSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
process.env.CLINE_MCP_SETTINGS_PATH = join(
blockedDirectory,
"cline_mcp_settings.json",
);
const output: string[] = [];
try {
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
});
expect(code).toBe(0);
expect(output.join("\n")).toContain("Installed plugin from");
expect(output.join("\n")).toContain(
"Warning: failed to sync plugin MCP servers",
);
expect(output.join("\n")).toContain("mcp-plugin");
} finally {
if (originalSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = originalSettingsPath;
}
}
});
it("detects plugin-owned remote MCP servers as OAuth candidates", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "oauth-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "oauth-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "oauth-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const result = await installPlugin({ source });
expect(result.mcpOAuthCandidates).toEqual([
expect.objectContaining({
name: "oauth-docs",
pluginName: "oauth-mcp-plugin",
transportType: "streamableHttp",
}),
]);
});
it("does not treat remote MCP servers with static headers as OAuth candidates", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "headers-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "headers-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "headers-docs",
transport: {
type: "streamableHttp",
url: "https://example.com/mcp",
headers: { Authorization: "Bearer token" },
},
})
},
}
`,
"utf8",
);
const result = await installPlugin({ source });
expect(result.mcpOAuthCandidates).toEqual([]);
});
it("skips plugin MCP OAuth candidates that already have tokens", async () => {
const settingsPath = join(root, "mcp-settings.json");
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const source = join(root, "authorized-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "authorized-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "authorized-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const result = await installPlugin({ source });
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as {
mcpServers?: Record<string, { oauth?: unknown }>;
};
const server = settings.mcpServers?.["authorized-docs"];
if (!server) {
throw new Error("Expected authorized-docs MCP server to be written");
}
server.oauth = { tokens: { access_token: "oauth-token" } };
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8");
expect(
collectPluginMcpOAuthCandidates({
pluginPaths: result.entryPaths,
settingsPath,
}),
).toEqual([]);
});
it("authorizes selected plugin MCP OAuth candidates during interactive installs", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "interactive-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "interactive-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "interactive-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const authorized: string[] = [];
const output: string[] = [];
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
mcpOAuth: {
interactive: true,
selectCandidates: async (candidates) => candidates,
authorize: async (candidate) => {
authorized.push(candidate.name);
},
},
});
expect(code).toBe(0);
expect(authorized).toEqual(["interactive-docs"]);
expect(output.join("\n")).toContain("Installed plugin from");
});
it("keeps plugin install successful when MCP OAuth authorization fails", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "failing-oauth-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "failing-oauth-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "failing-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const output: string[] = [];
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
mcpOAuth: {
interactive: true,
selectCandidates: async (candidates) => candidates,
authorize: async () => {
throw new Error("oauth unavailable");
},
},
});
expect(code).toBe(0);
expect(output.join("\n")).toContain(
"Warning: failed to authorize MCP server failing-docs: oauth unavailable",
);
});
it("prints guidance for plugin MCP OAuth candidates in non-interactive installs", async () => {
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
const source = join(root, "non-interactive-mcp-plugin.js");
writeFileSync(
source,
`
export default {
name: "non-interactive-mcp-plugin",
manifest: { capabilities: ["mcp"] },
setup(api) {
api.registerMcpServer({
name: "non-interactive-docs",
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
})
},
}
`,
"utf8",
);
const output: string[] = [];
const authorize = vi.fn();
const code = await runPluginInstallCommand({
source,
io: {
writeln: (text = "") => output.push(text),
writeErr: (text) => output.push(text),
},
mcpOAuth: {
interactive: false,
authorize,
},
});
expect(code).toBe(0);
expect(authorize).not.toHaveBeenCalled();
expect(output.join("\n")).toContain(
"Plugin MCP servers may require OAuth authorization",
);
expect(output.join("\n")).toContain("non-interactive-docs");
expect(output.join("\n")).toContain('Run "cline mcp"');
});
it("prints JSON output for official plugin installs", async () => {
const officialPluginsRepo = await createOfficialPluginsRepo({
"json-plugin": {
+3 -240
View File
@@ -21,15 +21,7 @@ import {
resolve,
sep,
} from "node:path";
import {
type McpServerRegistration,
type PluginMcpSettingsSyncResult,
type PluginUninstallOptions,
resolveDefaultMcpSettingsPath,
resolveMcpServerRegistrations,
syncPluginMcpServersToSettings,
uninstallPlugin,
} from "@cline/core";
import { type PluginUninstallOptions, uninstallPlugin } from "@cline/core";
import {
isPluginModulePath,
resolveClineDir,
@@ -44,31 +36,12 @@ export interface PluginInstallOptions {
npmCommand?: string;
officialPluginsRepo?: string;
io?: PluginInstallIo;
mcpOAuth?: PluginInstallMcpOAuthOptions;
}
export interface PluginInstallResult {
source: string;
installPath: string;
entryPaths: string[];
mcpSyncFailures: PluginMcpSettingsSyncResult["failures"];
mcpOAuthCandidates: PluginMcpOAuthCandidate[];
}
export interface PluginMcpOAuthCandidate {
name: string;
pluginName: string;
pluginPath: string;
transportType: "sse" | "streamableHttp";
lastError?: string;
}
export interface PluginInstallMcpOAuthOptions {
interactive?: boolean;
selectCandidates?: (
candidates: PluginMcpOAuthCandidate[],
) => Promise<PluginMcpOAuthCandidate[]>;
authorize?: (candidate: PluginMcpOAuthCandidate) => Promise<void>;
}
export interface PluginInstallIo {
@@ -533,8 +506,6 @@ async function runCommand(
cwd: options.cwd,
stdio: ["ignore", "ignore", "pipe"],
env: process.env,
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
let stderr = "";
child.stderr.on("data", (chunk) => {
@@ -1032,81 +1003,6 @@ function replaceInstallPath(
}
}
function hasStaticHeaders(registration: McpServerRegistration): boolean {
const transport = registration.transport;
if (transport.type === "stdio") {
return false;
}
return (
transport.headers !== undefined && Object.keys(transport.headers).length > 0
);
}
function hasOAuthAccessToken(registration: McpServerRegistration): boolean {
const accessToken = registration.oauth?.tokens?.access_token;
return typeof accessToken === "string" && accessToken.trim().length > 0;
}
function getPluginOwner(
registration: McpServerRegistration,
): { pluginName: string; pluginPath: string } | undefined {
const metadata = registration.metadata;
if (
!metadata ||
metadata.source !== "plugin" ||
typeof metadata.pluginName !== "string" ||
typeof metadata.pluginPath !== "string"
) {
return undefined;
}
return {
pluginName: metadata.pluginName,
pluginPath: metadata.pluginPath,
};
}
export function collectPluginMcpOAuthCandidates(input: {
pluginPaths: readonly string[];
settingsPath?: string;
}): PluginMcpOAuthCandidate[] {
const pluginPaths = new Set(input.pluginPaths.map((path) => resolve(path)));
if (pluginPaths.size === 0) {
return [];
}
let registrations: McpServerRegistration[];
try {
registrations = resolveMcpServerRegistrations({
filePath: input.settingsPath ?? resolveDefaultMcpSettingsPath(),
});
} catch {
return [];
}
const candidates: PluginMcpOAuthCandidate[] = [];
for (const registration of registrations) {
const owner = getPluginOwner(registration);
if (!owner || !pluginPaths.has(resolve(owner.pluginPath))) {
continue;
}
const transportType = registration.transport.type;
if (transportType === "stdio") {
continue;
}
if (hasStaticHeaders(registration) || hasOAuthAccessToken(registration)) {
continue;
}
candidates.push({
name: registration.name,
pluginName: owner.pluginName,
pluginPath: owner.pluginPath,
transportType,
lastError: registration.oauth?.lastError,
});
}
return candidates.sort((left, right) => left.name.localeCompare(right.name));
}
export async function installPlugin(
options: PluginInstallOptions,
): Promise<PluginInstallResult> {
@@ -1173,161 +1069,28 @@ export async function installPlugin(
}
replaceInstallPath(stagingRoot, installPath, force);
const result = {
return {
source,
installPath,
entryPaths: entryPaths.map((entry) => resolve(installPath, entry)),
mcpSyncFailures: [] as PluginMcpSettingsSyncResult["failures"],
mcpOAuthCandidates: [] as PluginMcpOAuthCandidate[],
};
const syncResult = await syncPluginMcpServersToSettings({
pluginPaths: result.entryPaths,
cwd,
workspacePath: cwd,
});
result.mcpSyncFailures = syncResult.failures;
result.mcpOAuthCandidates = collectPluginMcpOAuthCandidates({
pluginPaths: result.entryPaths,
});
return result;
} catch (error) {
rmSync(stagingRoot, { recursive: true, force: true });
throw error;
}
}
function serializePluginInstallResult(
result: PluginInstallResult,
): Omit<PluginInstallResult, "mcpOAuthCandidates"> {
return {
source: result.source,
installPath: result.installPath,
entryPaths: result.entryPaths,
mcpSyncFailures: result.mcpSyncFailures,
};
}
function isInteractivePluginInstall(
options: PluginInstallOptions & { json?: boolean },
): boolean {
return (
options.mcpOAuth?.interactive ??
(options.json !== true && process.stdin.isTTY && process.stdout.isTTY)
);
}
async function selectMcpOAuthCandidatesWithClack(
candidates: PluginMcpOAuthCandidate[],
): Promise<PluginMcpOAuthCandidate[]> {
const p = await import("@clack/prompts");
const action = await p.select({
message: "Authorize plugin MCP servers now?",
options: [
{
value: "all",
label: "Authorize all",
hint: "open browser authorization for each server",
},
{
value: "choose",
label: "Choose servers",
hint: "select which servers to authorize",
},
{
value: "skip",
label: "Skip",
},
],
});
if (p.isCancel(action) || action === "skip") {
return [];
}
if (action === "all") {
return candidates;
}
const selectedNames = await p.multiselect({
message: "Select MCP servers to authorize",
options: candidates.map((candidate) => ({
value: candidate.name,
label: candidate.name,
hint: `${candidate.transportType} [${candidate.pluginName}]`,
})),
required: false,
});
if (p.isCancel(selectedNames) || !Array.isArray(selectedNames)) {
return [];
}
const selected = new Set(selectedNames);
return candidates.filter((candidate) => selected.has(candidate.name));
}
async function authorizeMcpOAuthCandidate(
candidate: PluginMcpOAuthCandidate,
): Promise<void> {
const { authorizeMcpServerOAuthWithBrowser } = await import(
"../wizards/mcp/oauth"
);
await authorizeMcpServerOAuthWithBrowser(candidate.name);
}
async function runPluginMcpOAuthFollowup(
candidates: PluginMcpOAuthCandidate[],
options: PluginInstallOptions & { json?: boolean },
): Promise<void> {
if (candidates.length === 0 || options.json === true) {
return;
}
if (!isInteractivePluginInstall(options)) {
options.io?.writeln("Plugin MCP servers may require OAuth authorization:");
for (const candidate of candidates) {
options.io?.writeln(
` ${candidate.name} (${candidate.transportType}, plugin: ${candidate.pluginName})`,
);
}
options.io?.writeln(
'Run "cline mcp" and choose "Authorize OAuth" to authorize them.',
);
return;
}
const selected =
options.mcpOAuth?.selectCandidates !== undefined
? await options.mcpOAuth.selectCandidates(candidates)
: await selectMcpOAuthCandidatesWithClack(candidates);
const authorize = options.mcpOAuth?.authorize ?? authorizeMcpOAuthCandidate;
for (const candidate of selected) {
try {
await authorize(candidate);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(
`Warning: failed to authorize MCP server ${candidate.name}: ${message}`,
);
}
}
}
export async function runPluginInstallCommand(
options: PluginInstallOptions & { json?: boolean },
): Promise<number> {
try {
const result = await installPlugin(options);
if (options.json) {
process.stdout.write(
JSON.stringify(serializePluginInstallResult(result)),
);
process.stdout.write(JSON.stringify(result));
return 0;
}
options.io?.writeln(`Installed plugin from ${result.source}`);
options.io?.writeln(` Path: ${result.installPath}`);
for (const failure of result.mcpSyncFailures) {
options.io?.writeErr(
`Warning: failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
);
}
await runPluginMcpOAuthFollowup(result.mcpOAuthCandidates, options);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
-67
View File
@@ -7,14 +7,10 @@ import {
checkForUpdates,
getInstallationInfo,
PackageManager,
resolveCliHubOwnerContext,
withMinimumReleaseAgeBypass,
} from "./update";
const originalArgv = [...process.argv];
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
const originalDataDir = process.env.CLINE_DATA_DIR;
const originalHubDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
const originalIsDev = process.env.IS_DEV;
@@ -36,21 +32,6 @@ function createTempFile(pathSuffix: string): string {
describe("getInstallationInfo", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
@@ -115,21 +96,6 @@ describe("getInstallationInfo", () => {
describe("auto update settings", () => {
afterEach(() => {
process.argv = [...originalArgv];
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
@@ -187,39 +153,6 @@ describe("auto update settings", () => {
});
});
describe("hub restart owner selection", () => {
afterEach(() => {
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
if (originalDataDir === undefined) {
delete process.env.CLINE_DATA_DIR;
} else {
process.env.CLINE_DATA_DIR = originalDataDir;
}
if (originalHubDiscoveryPath === undefined) {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
} else {
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
}
});
it("uses the shared hub owner outside production builds", () => {
process.env.CLINE_BUILD_ENV = "development";
process.env.CLINE_DATA_DIR = "/tmp/cline-update-test-data";
delete process.env.CLINE_HUB_DISCOVERY_PATH;
const owner = resolveCliHubOwnerContext();
expect(owner.discoveryPath).toContain("/locks/hub/owners/");
expect(owner.discoveryPath).not.toBe(
"/tmp/cline-update-test-data/locks/hub/production.json",
);
});
});
describe("withMinimumReleaseAgeBypass", () => {
it("adds the package-manager-specific cooldown bypass", () => {
expect(
+7 -23
View File
@@ -5,11 +5,9 @@ import {
isAutoUpdateEnabledGlobally,
probeHubServer,
readHubDiscovery,
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
stopLocalHubServerGracefully,
} from "@cline/core";
import { resolveClineBuildEnv } from "@cline/shared";
import { version } from "../../package.json";
import { ensureCliHubServer } from "../utils/hub-runtime";
import { c, writeErr, writeln } from "../utils/output";
@@ -271,22 +269,13 @@ export function getPreferredKanbanInstaller(
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
export function resolveCliHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
async function waitForHubToStop(
url: string,
authToken: string | undefined,
timeoutMs: number,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const check = await probeHubServer(url, { authToken }).catch(
() => undefined,
);
const check = await probeHubServer(url).catch(() => undefined);
if (!check?.url) return true;
await sleep(100);
}
@@ -299,22 +288,20 @@ async function waitForHubToStop(
* clears stale discovery, then re-ensures a fresh instance is spawned.
*/
async function restartHubServerIfRunning(): Promise<void> {
const owner = resolveCliHubOwnerContext();
const owner = resolveSharedHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
() => undefined,
);
const health = discovery?.url
? await probeHubServer(discovery.url, {
authToken: discovery.authToken,
}).catch(() => undefined)
? await probeHubServer(discovery.url).catch(() => undefined)
: undefined;
if (!discovery || !health?.url) return;
if (!health?.url) return;
const pid = discovery?.pid;
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
let stopped = await stopLocalHubServerGracefully().catch(() => false);
if (!stopped && pid) {
try {
process.kill(pid, "SIGTERM");
@@ -323,14 +310,14 @@ async function restartHubServerIfRunning(): Promise<void> {
}
}
stopped = await waitForHubToStop(health.url, discovery.authToken, 3_000);
stopped = await waitForHubToStop(health.url, 3_000);
if (!stopped && pid) {
try {
process.kill(pid, "SIGKILL");
} catch {
// best-effort
}
stopped = await waitForHubToStop(health.url, discovery.authToken, 2_000);
stopped = await waitForHubToStop(health.url, 2_000);
}
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
@@ -375,9 +362,6 @@ export function autoUpdateOnStartup(): void {
env: autoUpdateCommand.env
? { ...process.env, ...autoUpdateCommand.env }
: process.env,
// Prevent a console window from flashing on Windows; detached
// processes otherwise allocate a new visible console.
windowsHide: true,
});
const exitCode = await waitForProcessExit(child);
if (exitCode === 0) {
-3
View File
@@ -194,9 +194,6 @@ export function spawnDetachedConnector(
...withResolvedClineBuildEnv(process.env),
[childEnvKey]: "1",
},
// Prevent a console window from appearing on Windows; detached
// processes otherwise allocate a new visible console.
windowsHide: true,
});
logSpawnedProcess({
component: options?.component ?? "connectors",
@@ -1,17 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
const {
mockGetLastUsedProviderSettings,
mockGetProviderSettings,
mockResolveSystemPrompt,
mockGetProviderCollection,
mockGetBooleanFlagEnabled,
} = vi.hoisted(() => ({
mockGetLastUsedProviderSettings: vi.fn(),
mockGetProviderSettings: vi.fn(),
mockResolveSystemPrompt: vi.fn(),
mockGetProviderCollection: vi.fn(),
mockGetBooleanFlagEnabled: vi.fn(),
}));
vi.mock("@cline/core", async () => {
@@ -20,8 +18,8 @@ vi.mock("@cline/core", async () => {
return {
...actual,
ProviderSettingsManager: class {
getLastUsedProviderSettings(options?: unknown) {
return mockGetLastUsedProviderSettings(options);
getLastUsedProviderSettings() {
return mockGetLastUsedProviderSettings();
}
getProviderSettings(providerId: string) {
@@ -45,12 +43,6 @@ vi.mock("../utils/helpers", () => ({
resolveWorkspaceRoot: vi.fn((cwd: string) => cwd),
}));
vi.mock("../utils/feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: mockGetBooleanFlagEnabled,
}),
}));
vi.mock("../commands/auth", async () => {
const actual =
await vi.importActual<typeof import("../commands/auth")>(
@@ -65,10 +57,6 @@ vi.mock("../commands/auth", async () => {
import { buildConnectorStartRequest } from "./session-runtime";
describe("buildConnectorStartRequest", () => {
beforeEach(() => {
mockGetBooleanFlagEnabled.mockReturnValue(false);
});
afterEach(() => {
vi.clearAllMocks();
delete process.env.OPENROUTER_API_KEY;
@@ -100,64 +88,5 @@ describe("buildConnectorStartRequest", () => {
expect(request.provider).toBe("openrouter");
expect(request.apiKey).toBe("env-openrouter-key");
expect(request.model).toBe("anthropic/claude-sonnet-4.6");
expect(mockGetLastUsedProviderSettings).toHaveBeenCalledWith({
isClinePassEnabled: false,
});
});
it("uses auth material resolved by provider settings manager", async () => {
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
mockGetProviderSettings.mockReturnValue({
provider: "cline-pass",
auth: { accessToken: "workos:resolved-token" },
});
mockGetProviderCollection.mockReturnValue({
provider: { env: ["CLINE_API_KEY"] },
});
mockResolveSystemPrompt.mockResolvedValue("system");
const request = await buildConnectorStartRequest({
options: {
cwd: "/tmp/work",
mode: "act",
enableTools: false,
},
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.1",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.1");
});
it("uses auth material resolved by provider settings manager", async () => {
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
mockGetProviderSettings.mockReturnValue({
provider: "cline-pass",
auth: { accessToken: "workos:resolved-token" },
});
mockGetProviderCollection.mockReturnValue({
provider: { env: ["CLINE_API_KEY"] },
});
mockResolveSystemPrompt.mockResolvedValue("system");
const request = await buildConnectorStartRequest({
options: {
cwd: "/tmp/work",
mode: "act",
enableTools: false,
},
io: { writeln: vi.fn(), writeErr: vi.fn() },
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
systemRules: "Rules",
defaultModel: "cline-pass/glm-5.1",
});
expect(request.provider).toBe("cline-pass");
expect(request.apiKey).toBe("workos:resolved-token");
expect(request.model).toBe("cline-pass/glm-5.1");
});
});
+1 -5
View File
@@ -16,7 +16,6 @@ import {
import type { CliLoggerAdapter } from "../logging/adapter";
import { resolveSystemPrompt } from "../runtime/prompt";
import { resolveCliSessionMetadata } from "../utils/enterprise";
import { getCliFeatureFlagsService } from "../utils/feature-flags";
import { resolveWorkspaceRoot } from "../utils/helpers";
import {
parseLocalRowMetadata,
@@ -63,10 +62,7 @@ export async function buildConnectorStartRequest(input: {
}): Promise<ChatStartSessionRequest> {
const providerSettingsManager = new ProviderSettingsManager();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings({
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
providerSettingsManager.getLastUsedProviderSettings();
const provider = normalizeProviderId(
input.options.provider?.trim() ||
lastUsedProviderSettings?.provider ||
+6 -62
View File
@@ -29,9 +29,7 @@ const authMocks = vi.hoisted(() => ({
runAuthCommand: vi.fn(),
}));
const providerSettingsMocks = vi.hoisted(() => ({
getLastUsedProviderSettings: vi.fn<(options?: unknown) => unknown>(
() => undefined,
),
getLastUsedProviderSettings: vi.fn<() => unknown>(() => undefined),
getProviderConfig: vi.fn<(providerId: string, options?: unknown) => unknown>(
() => undefined,
),
@@ -84,11 +82,6 @@ const historyMocks = vi.hoisted(() => ({
runHistoryExport: vi.fn(async () => 0),
runHistoryUpdate: vi.fn(async () => 0),
}));
const historyResumeMocks = vi.hoisted(() => ({
spawnHistoryResume: vi.fn<() => Promise<number | undefined>>(
async () => undefined,
),
}));
const loggingMocks = vi.hoisted(() => ({
createCliLoggerAdapter: vi.fn(() => ({
core: {
@@ -108,13 +101,10 @@ const hubRuntimeMocks = vi.hoisted(() => ({
}));
const telemetryMocks = vi.hoisted(() => ({
captureCliExtensionActivated: vi.fn(),
identifyTelemetryAccount: vi.fn(),
identifyCliTelemetryAccount: vi.fn(),
getCliTelemetryService: vi.fn(),
disposeCliTelemetryService: vi.fn(async () => {}),
}));
const featureFlagMocks = vi.hoisted(() => ({
getBooleanFlagEnabled: vi.fn(() => false),
}));
function forcePromptModeInput() {
Object.defineProperty(process.stdin, "isTTY", {
@@ -158,8 +148,8 @@ vi.mock("@cline/core", () => {
stop: vi.fn(),
})),
ProviderSettingsManager: class {
getLastUsedProviderSettings(options?: unknown) {
return providerSettingsMocks.getLastUsedProviderSettings(options);
getLastUsedProviderSettings() {
return providerSettingsMocks.getLastUsedProviderSettings();
}
getProviderSettings(providerId: string) {
return providerSettingsMocks.getProviderSettings(providerId);
@@ -174,12 +164,6 @@ vi.mock("@cline/core", () => {
};
});
vi.mock("./utils/provider-auth", () => authMocks);
vi.mock("./utils/feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
}),
refreshCliFeatureFlagsInBackground: vi.fn(),
}));
vi.mock("./runtime/prompt", () => ({
resolveSystemPrompt: promptMocks.resolveSystemPrompt,
}));
@@ -188,7 +172,6 @@ vi.mock("./commands/dashboard", () => dashboardMocks);
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
vi.mock("./commands/update", () => updateMocks);
vi.mock("./commands/history", () => historyMocks);
vi.mock("./utils/history-resume", () => historyResumeMocks);
vi.mock("./logging/adapter", () => loggingMocks);
vi.mock("./utils/hub-runtime", () => hubRuntimeMocks);
vi.mock("./utils/telemetry", () => telemetryMocks);
@@ -208,8 +191,6 @@ describe("runCli lightweight command dispatch", () => {
historyMocks.runHistoryExport.mockResolvedValue(0);
historyMocks.runHistoryUpdate.mockReset();
historyMocks.runHistoryUpdate.mockResolvedValue(0);
historyResumeMocks.spawnHistoryResume.mockReset();
historyResumeMocks.spawnHistoryResume.mockResolvedValue(undefined);
sessionMocks.getSessionRow.mockReset();
sessionMocks.getSessionRow.mockResolvedValue({
sessionId: "sess_123",
@@ -265,7 +246,7 @@ describe("runCli lightweight command dispatch", () => {
updateMocks.getPreferredKanbanInstaller.mockReset();
updateMocks.getPreferredKanbanInstaller.mockReturnValue(undefined);
telemetryMocks.captureCliExtensionActivated.mockReset();
telemetryMocks.identifyTelemetryAccount.mockReset();
telemetryMocks.identifyCliTelemetryAccount.mockReset();
telemetryMocks.getCliTelemetryService.mockReset();
telemetryMocks.disposeCliTelemetryService.mockReset();
telemetryMocks.disposeCliTelemetryService.mockResolvedValue(undefined);
@@ -738,47 +719,10 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("resumes a history-picked session in a child process", async () => {
it("forces chat view when resuming from history picker", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(0);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledTimes(1);
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "sess_from_history",
normalizedArgs: ["history"],
remainingArgs: ["history"],
}),
);
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
expect(process.exitCode).toBe(0);
});
it("propagates the child exit code when resuming from history picker", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(3);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(3);
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
});
it("forces chat view when the history-picker child cannot launch", async () => {
historyMocks.runHistoryList.mockImplementationOnce(
async () => "sess_from_history",
);
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(undefined);
process.argv = ["bun", "src/index.ts", "history"];
const { runCli } = await import("./main");
+28 -24
View File
@@ -19,10 +19,6 @@ import {
buildCliCompactionConfig,
CLI_COMPACTION_MODE_EXPECTED_TEXT,
} from "./utils/compaction-mode";
import {
getCliFeatureFlagsService,
refreshCliFeatureFlagsInBackground,
} from "./utils/feature-flags";
import {
configureSandboxEnvironment,
normalizeAutoApproveArgs,
@@ -157,6 +153,23 @@ export async function runCli(): Promise<void> {
.option("-m, --modelid <id>", "Model ID")
.option("-b, --baseurl <url>", "Base URL")
.option("--azure-api-version <version>", "Azure API version")
.option(
"-H, --header <key=value>",
"Custom HTTP header sent on every request (repeatable)",
(value: string, previous: string[]) => [...previous, value],
[],
)
.option("--context-window <tokens>", "Context window size for the model")
.option(
"--max-output-tokens <tokens>",
"Max output tokens per request for the model",
)
.option("--supports-images", "Mark the model as supporting image input")
.option(
"--no-supports-images",
"Mark the model as not supporting image input",
)
.option("--clear-headers", "Remove all saved custom headers")
.option("--config <dir>", "configuration directory")
.option("-c, --cwd <path>", "Working directory")
.option(
@@ -171,6 +184,11 @@ export async function runCli(): Promise<void> {
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
header?: string[];
clearHeaders?: boolean;
contextWindow?: string;
maxOutputTokens?: string;
supportsImages?: boolean;
config?: string;
cwd?: string;
dataDir?: string;
@@ -202,6 +220,11 @@ export async function runCli(): Promise<void> {
modelid: opts.modelid,
baseurl: opts.baseurl,
azureApiVersion: opts.azureApiVersion,
header: opts.header,
clearHeaders: opts.clearHeaders,
contextWindow: opts.contextWindow,
maxOutputTokens: opts.maxOutputTokens,
supportsImages: opts.supportsImages,
io,
});
});
@@ -667,21 +690,6 @@ export async function runCli(): Promise<void> {
let resumeSessionId: string | undefined = ctx.resumeSessionId;
if (resumeSessionId) {
// The history picker already created (and tore down) an OpenTUI renderer
// in this process; starting the interactive TUI here would create a
// second one, which can crash natively during teardown. Resume in a
// fresh `cline --id <session-id>` child process instead.
const { spawnHistoryResume } = await import("./utils/history-resume");
const childExitCode = await spawnHistoryResume({
sessionId: resumeSessionId,
normalizedArgs,
remainingArgs: program.args,
configDir,
});
if (childExitCode !== undefined) {
process.exitCode = childExitCode;
return;
}
args = {
...args,
interactive: true,
@@ -855,12 +863,8 @@ export async function runCli(): Promise<void> {
};
registerDisposable(stopUserInstructionService);
try {
refreshCliFeatureFlagsInBackground();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings({
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
providerSettingsManager.getLastUsedProviderSettings();
const provider = normalizeProviderId(
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
);
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
import {
type ChatCommandState,
chatCommandHost,
createChatCommandHost,
} from "../../utils/chat-commands";
import type { Config } from "../../utils/types";
import {
@@ -163,37 +162,4 @@ describe("runInteractiveChatCommand", () => {
expect(state.autoApproveTools).toBe(true);
expect(setInteractiveAutoApprove).toHaveBeenCalledWith(true);
});
it("returns plugin command submit prompts as model input", async () => {
const config = makeConfig();
const runtime = makeRuntime();
const onCommandOutput = vi.fn();
const host = createChatCommandHost().register("command", {
names: ["/goal"],
run: async ({ args }, context) => {
await context.reply(`Goal guard set: ${args.join(" ")}`);
await context.submitPrompt?.(args.join(" "));
},
});
const result = await runInteractiveChatCommand({
prompt: "/goal fix tests",
enabled: true,
config,
host,
chatCommandState: makeState(config),
autoApproveAllRef: { current: false },
setInteractiveAutoApprove: () => {},
sessionRuntime: runtime,
stop: () => {},
onCommandOutput,
});
expect(result).toEqual({
handled: false,
input: "fix tests",
commandOutput: "Goal guard set: fix tests",
});
expect(onCommandOutput).toHaveBeenCalledWith("Goal guard set: fix tests");
});
});
@@ -26,7 +26,7 @@ export type InteractiveChatCommandRuntime = Pick<
export type InteractiveChatCommandResult =
| { handled: true; turnResult: InteractiveTurnResult }
| { handled: false; input: string; commandOutput?: string };
| { handled: false; input: string };
function commandTurnResult(commandOutput?: string): InteractiveTurnResult {
return {
@@ -46,7 +46,6 @@ export async function runInteractiveChatCommand(input: {
setInteractiveAutoApprove: (enabled: boolean) => void;
sessionRuntime: InteractiveChatCommandRuntime;
stop: () => void;
onCommandOutput?: (text: string) => void;
}): Promise<InteractiveChatCommandResult> {
let prompt = input.prompt;
const rewrittenTeamPrompt = rewriteTeamPrompt(prompt);
@@ -65,7 +64,6 @@ export async function runInteractiveChatCommand(input: {
}
let commandOutput: string | undefined;
let submitPrompt: string | undefined;
const handled = await maybeHandleChatCommand(prompt, {
enabled: input.enabled,
host: input.host,
@@ -82,13 +80,6 @@ export async function runInteractiveChatCommand(input: {
},
reply: async (text) => {
commandOutput = text;
input.onCommandOutput?.(text);
},
submitPrompt: async (text) => {
const trimmed = text.trim();
if (trimmed) {
submitPrompt = trimmed;
}
},
reset: async () => {
await input.sessionRuntime.resetForNewSession();
@@ -107,13 +98,6 @@ export async function runInteractiveChatCommand(input: {
fork: input.sessionRuntime.forkCurrentSession,
});
if (handled) {
if (submitPrompt) {
return {
handled: false,
input: submitPrompt,
...(commandOutput ? { commandOutput } : {}),
};
}
return {
handled: true,
turnResult: commandTurnResult(commandOutput),
@@ -1,11 +1,4 @@
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { UserInstructionConfigService } from "@cline/core";
@@ -50,17 +43,9 @@ describe("interactive config data loader", () => {
};
afterEach(async () => {
if (envSnapshot.CLINE_GLOBAL_SETTINGS_PATH === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
}
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
}
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
await Promise.all(
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
);
@@ -91,28 +76,6 @@ describe("interactive config data loader", () => {
return pluginPath;
}
async function writeMcpSettingsPlugin(tempRoot: string): Promise<string> {
const pluginsDir = join(tempRoot, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
const pluginPath = join(pluginsDir, "settings-mcp-plugin.js");
await writeFile(
pluginPath,
[
"export default {",
" name: 'settings-mcp-plugin',",
" manifest: { capabilities: ['mcp'] },",
" setup(api) {",
" api.registerMcpServer({",
" name: 'smoke',",
" transport: { type: 'stdio', command: process.execPath, args: ['-e', 'process.exit(0)'] },",
" });",
" },",
"};",
].join("\n"),
);
return pluginPath;
}
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -348,70 +311,6 @@ Find installable skills.`,
).toBe(true);
});
it("loads plugin-owned MCP servers from settings", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
smoke: {
transport: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
metadata: {
source: "plugin",
pluginName: "settings-mcp-plugin",
pluginPath,
},
},
},
},
null,
2,
)}\n`,
);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: true });
expect(
data.mcp.some(
(item) =>
item.name === "smoke" &&
item.pluginName === "settings-mcp-plugin" &&
item.pluginPath === pluginPath &&
item.kind === "mcp",
),
).toBe(true);
});
it("does not load plugin MCP rows directly from plugin diagnostics", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: true });
expect(data.plugins.some((item) => item.path === pluginPath)).toBe(true);
expect(data.mcp.some((item) => item.pluginPath === pluginPath)).toBe(false);
});
it("keeps failed plugins visible with their load error", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -832,142 +731,6 @@ Review with the bundled skill.`,
).toBe(false);
});
it("disables and re-syncs plugin-owned MCP servers when toggling plugins", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
smoke: {
transport: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
oauth: {
tokens: {
access_token: "token",
},
},
metadata: {
source: "plugin",
pluginName: "settings-mcp-plugin",
pluginPath,
},
},
},
},
null,
2,
)}\n`,
);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const item: InteractiveConfigItem = {
id: pluginPath,
name: "settings-mcp-plugin",
path: pluginPath,
enabled: true,
source: "workspace-plugin",
kind: "plugin",
};
await loader.onToggleConfigItem(item);
let settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<
string,
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
>;
};
expect(settings.mcpServers?.smoke?.disabled).toBe(true);
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
"token",
);
await loader.onToggleConfigItem({ ...item, enabled: false });
settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<
string,
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
>;
};
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
"token",
);
});
it.skipIf(process.platform === "win32")(
"does not mark plugin disabled when MCP disable write fails",
async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
const globalSettingsPath = join(tempRoot, "global-settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = globalSettingsPath;
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
smoke: {
transport: {
type: "stdio",
command: process.execPath,
args: ["-e", "process.exit(0)"],
},
metadata: {
source: "plugin",
pluginName: "settings-mcp-plugin",
pluginPath,
},
},
},
},
null,
2,
)}\n`,
);
await chmod(settingsPath, 0o444);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
try {
await expect(
loader.onToggleConfigItem({
id: pluginPath,
name: "settings-mcp-plugin",
path: pluginPath,
enabled: true,
source: "workspace-plugin",
kind: "plugin",
}),
).rejects.toThrow();
} finally {
await chmod(settingsPath, 0o644);
}
await expect(readFile(globalSettingsPath, "utf8")).rejects.toThrow();
const settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
mcpServers?: Record<string, { disabled?: boolean }>;
};
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
},
);
it("surfaces MCP OAuth status and errors", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -1,9 +1,7 @@
import {
createCoreSettingsService,
disablePluginMcpServersInSettings,
setDisabledPlugin,
setDisabledTools,
syncPluginMcpServersToSettings,
type UserInstructionConfigService,
uninstallPlugin,
} from "@cline/core";
@@ -72,32 +70,7 @@ export function createInteractiveConfigDataLoader(input: {
}
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
if (item.enabled) {
disablePluginMcpServersInSettings({ pluginPaths: [item.path] });
setDisabledPlugin(item.path, true);
} else {
const ownedMcpMutations = disablePluginMcpServersInSettings({
pluginPaths: [item.path],
});
const result = await syncPluginMcpServersToSettings({
pluginPaths: [item.path],
cwd: input.config.cwd,
workspacePath: workspaceRoot(),
providerId: input.config.providerId,
modelId: input.config.modelId,
});
if (ownedMcpMutations.length > 0 && result.failures.length > 0) {
throw new Error(
`Failed to sync plugin MCP servers: ${result.failures
.map((failure) => {
const plugin = failure.pluginName ?? failure.pluginPath;
return `${plugin}: ${failure.message}`;
})
.join("; ")}`,
);
}
setDisabledPlugin(item.path, false);
}
setDisabledPlugin(item.path, item.enabled);
return undefined;
}
+1 -24
View File
@@ -8,7 +8,6 @@ import type { CliMigrationNotice } from "../kanban-migration/notice";
import { logCliError } from "../logging/errors";
import {
loadClineAccountSnapshot,
onProviderChange,
switchClineAccount,
} from "../tui/cline-account";
import type {
@@ -428,8 +427,7 @@ export async function runInteractive(
uiEvents.off("pending-prompt-submitted", onPendingPromptSubmitted);
};
},
onSubmit: async (input, mode, delivery, attachments, onCommandOutput) => {
let commandOutput: string | undefined;
onSubmit: async (input, mode, delivery, attachments) => {
try {
await sessionRuntime.ensureReady();
await waitForSubmittedMode(mode);
@@ -448,7 +446,6 @@ export async function runInteractive(
setInteractiveAutoApprove,
sessionRuntime,
stop: () => tuiApp?.destroy(),
onCommandOutput,
});
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
@@ -468,14 +465,12 @@ export async function runInteractive(
setInteractiveAutoApprove,
sessionRuntime,
stop: () => tuiApp?.destroy(),
onCommandOutput,
});
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
}
}
input = chatCommandResult.input;
commandOutput = chatCommandResult.commandOutput;
const {
prompt: userInput,
userImages,
@@ -512,7 +507,6 @@ export async function runInteractive(
iterations: 0,
finishReason: "queued",
queued: delivery === "queue" || delivery === "steer",
commandOutput,
};
}
if (result.finishReason !== "completed") {
@@ -525,7 +519,6 @@ export async function runInteractive(
currentContextSize: getCurrentContextSize(result.messages),
iterations: result.iterations,
finishReason: "aborted",
commandOutput,
};
}
const errorText = result.text.trim();
@@ -539,7 +532,6 @@ export async function runInteractive(
currentContextSize: getCurrentContextSize(result.messages),
iterations: result.iterations,
finishReason: result.finishReason,
commandOutput,
};
} catch (error) {
if (isAbortInProgress()) {
@@ -547,7 +539,6 @@ export async function runInteractive(
usage: { inputTokens: 0, outputTokens: 0 },
iterations: 0,
finishReason: "aborted",
commandOutput,
};
}
logCliError(config.logger, "Interactive turn failed", {
@@ -612,10 +603,6 @@ export async function runInteractive(
},
onModelChange: async () => {
await sessionRuntime.ensureReady();
await onProviderChange({
config,
providerId: config.providerId,
});
const existing = providerSettingsManager.getProviderSettings(
config.providerId,
) ?? {
@@ -636,16 +623,6 @@ export async function runInteractive(
},
onAccountChange: async () => {
await sessionRuntime.ensureReady();
await loadClineAccountSnapshot({
config,
clineApiBaseUrl: options?.clineApiBaseUrl,
}).catch((error) => {
logCliError(
config.logger,
"Cline account refresh after account change failed",
{ error },
);
});
await sessionRuntime.restartWithCurrentMessages();
},
onResumeSession: async (sessionId: string) => {
-9
View File
@@ -12,8 +12,6 @@ const createCore = vi.fn();
const getCliTelemetryService = vi.fn(() => undefined);
const resolveSessionBackend = vi.fn();
const listSessionHistoryFromBackend = vi.fn();
const featureFlagsPoll = vi.fn(async () => {});
const featureFlagsDispose = vi.fn(async () => {});
vi.mock("@cline/core", async () => {
const actual =
@@ -51,10 +49,6 @@ describe("createCliCore", () => {
listSessionHistoryFromBackend.mockReset();
createCore.mockResolvedValue({
runtimeAddress: "127.0.0.1:25463",
featureFlags: {
poll: featureFlagsPoll,
dispose: featureFlagsDispose,
},
start: vi.fn(),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
@@ -74,8 +68,6 @@ describe("createCliCore", () => {
delete process.env.CLINE_RPC_ADDRESS;
delete process.env.CLINE_SESSION_BACKEND_MODE;
delete process.env.CLINE_VCR;
featureFlagsPoll.mockClear();
featureFlagsDispose.mockClear();
});
afterEach(() => {
@@ -116,7 +108,6 @@ describe("createCliCore", () => {
backendMode: expect.anything(),
}),
);
expect(featureFlagsPoll).toHaveBeenCalledTimes(1);
});
it("forces the local backend when requested by the caller", async () => {
+1 -13
View File
@@ -15,7 +15,6 @@ import {
createCliMessagesArtifactUploader,
prepareCliEnterpriseIntegration,
} from "../utils/enterprise";
import { getCliFeatureFlagsService } from "../utils/feature-flags";
import { resolveWorkspaceRoot } from "../utils/helpers";
import { getCliTelemetryService } from "../utils/telemetry";
import type { ConversationHistory } from "./export";
@@ -41,11 +40,6 @@ export async function createCliCore(options?: {
const cwd = options?.cwd?.trim() || process.cwd();
const workspaceRoot =
options?.workspaceRoot?.trim() || resolveWorkspaceRoot(cwd);
const telemetry = getCliTelemetryService(options?.logger);
const featureFlags = getCliFeatureFlagsService({
logger: options?.logger,
telemetry,
});
const core = await ClineCore.create({
...(explicitBackendMode ? { backendMode: explicitBackendMode } : {}),
...(options?.forceLocalBackend !== true
@@ -59,18 +53,12 @@ export async function createCliCore(options?: {
}
: {}),
capabilities: options?.capabilities,
telemetry,
featureFlags,
telemetry: getCliTelemetryService(options?.logger),
logger: options?.logger,
toolPolicies: options?.toolPolicies,
messagesArtifactUploader: createCliMessagesArtifactUploader(),
prepare: prepareCliEnterpriseIntegration,
});
try {
await core.featureFlags.poll();
} catch (error) {
options?.logger?.error?.("Error polling CLI feature flags", { error });
}
options?.logger?.log("CLI core runtime routing selected", {
backendMode: explicitBackendMode ?? "env-managed",
rpcAddress: core.runtimeAddress,
+74 -1
View File
@@ -18,7 +18,11 @@ import {
} from "../helpers/constants.js";
import { clineEnv } from "../helpers/env.js";
import { waitForAuthScreen } from "../helpers/page-objects/auth.js";
import { expectExitCode, expectVisible } from "../helpers/terminal.js";
import {
expectExitCode,
expectVisible,
typeAndSubmit,
} from "../helpers/terminal.js";
test.describe("cline auth (interactive screen)", () => {
test.use({
@@ -280,6 +284,75 @@ test.describe("cline auth --baseurl with non-OpenAI-compatible provider", () =>
});
});
test.describe("cline auth -p -k -m with headers and model config flags", () => {
test.use({
program: {
file: CLINE_BIN,
args: [
"auth",
"--provider",
"openai-compatible",
"--apikey",
"sk-test-key-12345",
"--modelid",
"my-model",
"--baseurl",
"https://api.example.com/v1",
"-H",
"X-Org=abc",
"-H",
"Authorization-Extra=Bearer a=b",
"--context-window",
"128000",
"--max-output-tokens",
"8192",
"--no-supports-images",
],
},
...TERMINAL_WIDE,
env: clineEnv("unauthenticated"),
});
test("exits successfully with full OpenAI-compatible configuration", async ({
terminal,
}) => {
await expectExitCode(terminal, EXIT_CODE_SUCCESS);
});
});
test.describe("cline auth openai-compatible config fields (interactive)", () => {
test.use({
program: { file: CLINE_BIN, args: ["auth"] },
...TERMINAL_WIDE,
env: clineEnv("unauthenticated"),
});
test("shows custom headers and model configuration fields", async ({
terminal,
}) => {
const settle = (ms = 500) =>
new Promise((resolve) => setTimeout(resolve, ms));
await waitForAuthScreen(terminal);
// Navigate to "Bring your own provider" (third menu entry) and open it
terminal.keyDown();
await settle();
terminal.keyDown();
await settle();
terminal.submit();
// Filter the provider list down to OpenAI Compatible and select it
await expectVisible(terminal, "Search providers...");
await settle();
await typeAndSubmit(terminal, "openai-compatible");
await expectVisible(terminal, [
"Base URL",
"API key",
"Custom Headers (optional)",
"Context Window (optional)",
"Max Output Tokens (optional)",
]);
});
});
test.describe("cline auth with invalid provider", () => {
test.use({
program: {
-86
View File
@@ -9,15 +9,9 @@ const coreMocks = vi.hoisted(() => {
return {
getProviderSettings: vi.fn(),
saveProviderSettings: vi.fn(),
fetchMe: vi.fn(),
fetchBalance: vi.fn(),
fetchOrganizationBalance: vi.fn(),
serviceOptions,
};
});
const telemetryMocks = vi.hoisted(() => ({
identifyTelemetryAccount: vi.fn(),
}));
vi.mock("@cline/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@cline/core")>();
@@ -30,15 +24,6 @@ vi.mock("@cline/core", async (importOriginal) => {
}) {
coreMocks.serviceOptions.push(options);
}
fetchMe() {
return coreMocks.fetchMe();
}
fetchBalance(userId?: string) {
return coreMocks.fetchBalance(userId);
}
fetchOrganizationBalance(organizationId: string) {
return coreMocks.fetchOrganizationBalance(organizationId);
}
},
ProviderSettingsManager: class {
getProviderSettings(providerId: string) {
@@ -51,10 +36,6 @@ vi.mock("@cline/core", async (importOriginal) => {
};
});
vi.mock("../utils/telemetry", () => ({
identifyTelemetryAccount: telemetryMocks.identifyTelemetryAccount,
}));
function makeConfig(overrides: Partial<Config> = {}): Config {
return {
providerId: "cline",
@@ -97,11 +78,7 @@ describe("createClineAccountService", () => {
vi.unstubAllGlobals();
coreMocks.getProviderSettings.mockReset();
coreMocks.saveProviderSettings.mockReset();
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
afterEach(() => {
@@ -186,66 +163,3 @@ describe("createClineAccountService", () => {
);
});
});
describe("loadClineAccountSnapshot", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
coreMocks.getProviderSettings.mockReset();
coreMocks.saveProviderSettings.mockReset();
coreMocks.fetchMe.mockReset();
coreMocks.fetchBalance.mockReset();
coreMocks.fetchOrganizationBalance.mockReset();
coreMocks.serviceOptions.length = 0;
telemetryMocks.identifyTelemetryAccount.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("identifies the loaded Cline account for telemetry and feature flags", async () => {
coreMocks.getProviderSettings.mockReturnValue({
provider: "cline",
apiKey: "account-token",
});
const { loadClineAccountSnapshot } = await import("./cline-account");
coreMocks.fetchMe.mockResolvedValue({
id: "user-1",
email: "user@example.com",
displayName: "User One",
photoUrl: "",
createdAt: "",
updatedAt: "",
organizations: [
{
active: true,
memberId: "member-1",
name: "Acme",
organizationId: "org-1",
roles: ["member"],
},
],
});
coreMocks.fetchBalance.mockResolvedValue({ balance: 10, userId: "user-1" });
coreMocks.fetchOrganizationBalance.mockResolvedValue({
balance: 20,
organizationId: "org-1",
});
await loadClineAccountSnapshot({ config: makeConfig() });
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
{
id: "user-1",
email: "user@example.com",
provider: "cline",
organizationId: "org-1",
organizationName: "Acme",
memberId: "member-1",
},
expect.any(Object),
);
});
});
+1 -37
View File
@@ -14,15 +14,12 @@ import {
} from "@cline/core";
import { getClineEnvironmentConfig } from "@cline/shared";
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
import { identifyTelemetryAccount } from "../utils/telemetry";
import type { Config } from "../utils/types";
export const CLINE_CREDITS_DASHBOARD_URL =
"https://app.cline.bot/dashboard/account?tab=credits";
type ClineAccountConfig = Pick<Config, "apiKey" | "logger" | "providerId">;
const CLINE_PASS_PROVIDER_ID = "cline-pass";
type ClineAccountConfig = Pick<Config, "apiKey" | "providerId">;
export interface ClineAccountSnapshot {
user: ClineAccountUser;
@@ -170,15 +167,6 @@ export async function loadClineAccountSnapshot(input: {
const displayedBalance = activeOrganization
? (organizationBalance?.balance ?? balance.balance)
: balance.balance;
const accountContext = {
id: user.id,
email: user.email,
provider: "cline",
organizationId: activeOrganization?.organizationId,
organizationName: activeOrganization?.name,
memberId: activeOrganization?.memberId,
};
identifyTelemetryAccount(accountContext, input.config.logger);
return {
user,
@@ -202,27 +190,3 @@ export async function switchClineAccount(input: {
}
await service.switchAccount(input.organizationId);
}
async function onChangeToClinePass(config: ClineAccountConfig) {
try {
await switchClineAccount({
config: config,
organizationId: null,
});
} catch (error) {
config.logger?.debug("Failed to switch ClinePass to personal account", {
error,
});
}
}
export async function onProviderChange(input: {
config: ClineAccountConfig;
providerId: string;
}): Promise<void> {
if (input.providerId === CLINE_PASS_PROVIDER_ID) {
return onChangeToClinePass(input.config);
}
return;
}
@@ -58,11 +58,7 @@ describe("mcp manager dialog helpers", () => {
};
afterEach(async () => {
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
}
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
await Promise.all(
tempRoots.map((directory) =>
rm(directory, { recursive: true, force: true }),
@@ -111,44 +107,6 @@ describe("mcp manager dialog helpers", () => {
).toBeUndefined();
});
it("does not toggle plugin-owned servers", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-mcp-manager-"));
tempRoots.push(tempRoot);
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
await writeFile(
settingsPath,
`${JSON.stringify(
{
mcpServers: {
docs: {
transport: {
type: "stdio",
command: "node",
},
},
},
},
null,
2,
)}\n`,
);
const result = toggleMcpServer({
name: "docs",
path: settingsPath,
enabled: true,
pluginName: "repo-docs",
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.message).toContain('managed by plugin "repo-docs"');
}
expect((await readSettings(settingsPath)).mcpServers?.docs?.disabled).toBe(
undefined,
);
});
it("returns a visible error message when toggling fails", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-mcp-manager-"));
tempRoots.push(tempRoot);
@@ -13,7 +13,6 @@ export interface McpEntry {
enabled?: boolean;
description?: string;
lastError?: string;
pluginName?: string;
}
export type McpServerToggleResult =
@@ -37,12 +36,6 @@ export function getMcpManagerEntryStatus(
}
export function toggleMcpServer(server: McpEntry): McpServerToggleResult {
if (server.pluginName) {
return {
ok: false,
message: `MCP server "${server.name}" is managed by plugin "${server.pluginName}". Disable the plugin to disable this server.`,
};
}
try {
const currentlyEnabled = server.enabled !== false;
setMcpServerDisabled({
@@ -78,7 +71,6 @@ export function McpManagerContent(
const settingsPath = servers[0]?.path ?? resolveDefaultMcpSettingsPath();
const itemCount = servers.length;
const selectedServer = servers[selected];
const hasPluginOwnedServers = servers.some((server) => server.pluginName);
useDialogKeyboard((key) => {
if (key.name === "escape") {
@@ -158,7 +150,6 @@ export function McpManagerContent(
{isSel ? "\u25b8 " : " "}
{enabledIcon}
{srv.name}
{srv.pluginName ? " *" : ""}
</text>
{status && (
<text fg={srv.lastError ? palette.error : "gray"}>
@@ -193,12 +184,6 @@ export function McpManagerContent(
</box>
)}
{hasPluginOwnedServers && (
<text fg="gray" marginTop={1}>
* managed by plugin; disable the plugin to disable the server.
</text>
)}
<text fg="gray" marginTop={1}>
<em>{getMcpManagerFooterText(servers.length > 0)}</em>
</text>
@@ -2,6 +2,7 @@ import {
completeClineDeviceAuth,
getProviderConfigFields,
isOAuthProvider,
listLocalProviders,
loginLocalProvider,
type ProviderConfigFieldKey,
type ProviderConfigFieldRequirement,
@@ -21,14 +22,16 @@ import {
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { palette } from "../../palette";
import {
formatProviderConfigHeaders,
getDefaultAwsRegion,
type ProviderConfigValues,
resolveProviderConfigAwsRegion,
resolveProviderConfigAzure,
resolveProviderConfigGcp,
resolveProviderConfigHeadersPatch,
resolveProviderConfigPositiveInteger,
resolveProviderConfigSap,
updateProviderConfigValue,
} from "../../utils/provider-config-values";
@@ -318,6 +321,9 @@ const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
apiKey: "API key",
baseUrl: "Base URL",
azureApiVersion: "Azure API Version",
headers: "Custom Headers",
contextWindow: "Context Window",
maxOutputTokens: "Max Output Tokens",
awsRegion: "AWS Region",
awsProfile: "AWS Profile Name",
gcpProjectId: "Google Cloud Project ID",
@@ -335,6 +341,9 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
apiKey: "sk-...",
baseUrl: "",
azureApiVersion: "2025-01-01-preview",
headers: "X-Header=value, X-Other=value",
contextWindow: "",
maxOutputTokens: "",
awsRegion: "us-east-1",
awsProfile: "default",
gcpProjectId: "my-gcp-project",
@@ -354,6 +363,9 @@ const FIELD_ORDER: ProviderConfigFieldKey[] = [
"baseUrl",
"azureApiVersion",
"apiKey",
"headers",
"contextWindow",
"maxOutputTokens",
"awsProfile",
"sapClientId",
"sapClientSecret",
@@ -427,6 +439,18 @@ export function ProviderConfigInputContent(
"us-central1";
if (config.fields.apiKey)
initial.apiKey = existingSettings?.apiKey?.trim() ?? "";
if (config.fields.headers)
initial.headers = formatProviderConfigHeaders(existingSettings?.headers);
if (config.fields.contextWindow)
initial.contextWindow =
existingSettings?.contextWindow !== undefined
? String(existingSettings.contextWindow)
: "";
if (config.fields.maxOutputTokens)
initial.maxOutputTokens =
existingSettings?.maxTokens !== undefined
? String(existingSettings.maxTokens)
: "";
if (config.fields.awsProfile)
initial.awsProfile = existingSettings?.aws?.profile?.trim() ?? "";
if (config.fields.sapClientId)
@@ -466,6 +490,28 @@ export function ProviderConfigInputContent(
apiKey: config.fields.apiKey ? apiKey : undefined,
baseUrl: config.fields.baseUrl ? values.baseUrl?.trim() : undefined,
azure: hasAzureFields ? resolveProviderConfigAzure(values) : undefined,
...(config.fields.headers
? {
headers: resolveProviderConfigHeadersPatch(
values.headers,
existingSettings?.headers,
),
}
: {}),
...(config.fields.contextWindow
? {
contextWindow: resolveProviderConfigPositiveInteger(
values.contextWindow,
),
}
: {}),
...(config.fields.maxOutputTokens
? {
maxTokens: resolveProviderConfigPositiveInteger(
values.maxOutputTokens,
),
}
: {}),
aws: hasAwsFields
? {
region: resolveProviderConfigAwsRegion(values),
+13 -20
View File
@@ -17,9 +17,7 @@ export async function renderHistoryStandalone(input: {
});
return new Promise((resolve) => {
let result: number | string = 0;
let resolved = false;
let destroyStarted = false;
let settled = false;
let unmounted = false;
const root = createRoot(renderer);
@@ -31,29 +29,24 @@ export async function renderHistoryStandalone(input: {
root.unmount();
};
// Resolve only once teardown has finished, so callers never run while
// the renderer is still restoring the terminal.
renderer.on("destroy", () => {
unmountRoot();
if (!resolved) {
resolved = true;
resolve(result);
}
});
const settle = (value: number | string) => {
if (destroyStarted) {
if (settled) {
return;
}
destroyStarted = true;
result = value;
settled = true;
unmountRoot();
// Let OpenTUI finish parsing the current stdin batch before teardown.
queueMicrotask(() => {
renderer.destroy();
});
renderer.destroy();
resolve(value);
};
renderer.on("destroy", () => {
unmountRoot();
if (!settled) {
settled = true;
resolve(0);
}
});
root.render(
React.createElement(HistoryStandaloneContent, {
rows: input.rows,
@@ -17,7 +17,6 @@ function toMcpEntries(items: InteractiveConfigItem[]): McpEntry[] {
enabled: item.enabled,
description: item.description,
lastError: item.loadError,
pluginName: item.pluginName,
}));
}
@@ -179,6 +179,7 @@ async function runProviderChange(
config.providerId = newProviderId;
config.apiKey = newApiKey;
const resolved = await resolveProviderConfig(
newProviderId,
{
@@ -327,14 +327,6 @@ export function usePromptInputController(input: {
}
const startedAt = performance.now();
let commandOutputAppended = false;
const appendCommandOutput = (text: string) => {
commandOutputAppended = true;
session.appendEntry({
kind: "status",
text,
});
};
try {
const result = await onSubmit(
promptForSubmit,
@@ -343,9 +335,8 @@ export function usePromptInputController(input: {
activeUserImages.length > 0
? { userImages: activeUserImages }
: undefined,
appendCommandOutput,
);
if (result.commandOutput && !commandOutputAppended) {
if (result.commandOutput) {
session.appendEntry({
kind: "status",
text: result.commandOutput,
+5 -23
View File
@@ -86,7 +86,6 @@ export interface InteractiveConfigData {
mcp: InteractiveConfigItem[];
tools: InteractiveConfigItem[];
workflowSlashCommands: InteractiveSlashCommand[];
pluginDiagnosticsLoaded?: boolean;
}
export interface LoadInteractiveConfigDataOptions {
@@ -94,14 +93,12 @@ export interface LoadInteractiveConfigDataOptions {
}
export function isToggleableInteractiveConfigItem(
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
item: Pick<InteractiveConfigItem, "kind" | "source">,
): boolean {
if (item.kind === "mcp") {
return !item.pluginName;
}
return (
item.kind === "skill" ||
item.kind === "plugin" ||
item.kind === "mcp" ||
item.source === "builtin" ||
item.source === "workspace-plugin" ||
item.source === "global-plugin"
@@ -245,10 +242,9 @@ function readPackageName(packageJsonPath: string): string | undefined {
}
}
function getPluginDisplayName(filePath: string, searchRoot: string): string {
function getPluginDisplayName(filePath: string): string {
let current = dirname(filePath);
const root = resolve(searchRoot);
while (isPathWithin(root, current)) {
for (let depth = 0; depth < 4; depth++) {
const packageJsonPath = join(current, "package.json");
if (existsSync(packageJsonPath)) {
const packageName = readPackageName(packageJsonPath);
@@ -388,7 +384,7 @@ export async function loadInteractiveConfigData(input: {
for (const filePath of discoverPluginModulePaths(directory)) {
plugins.push({
id: filePath,
name: getPluginDisplayName(filePath, directory),
name: getPluginDisplayName(filePath),
path: filePath,
enabled: !disabledPlugins.has(filePath),
kind: "plugin",
@@ -462,16 +458,6 @@ export async function loadInteractiveConfigData(input: {
for (const registration of resolveMcpServerRegistrations({
filePath: mcpSettingsPath,
})) {
const pluginName =
registration.metadata?.source === "plugin" &&
typeof registration.metadata.pluginName === "string"
? registration.metadata.pluginName
: undefined;
const pluginPath =
registration.metadata?.source === "plugin" &&
typeof registration.metadata.pluginPath === "string"
? registration.metadata.pluginPath
: undefined;
mcp.push({
id: registration.name,
name: registration.name,
@@ -481,8 +467,6 @@ export async function loadInteractiveConfigData(input: {
source: detectSource(mcpSettingsPath, input.workspaceRoot),
description: getMcpDescription(registration),
loadError: registration.oauth?.lastError,
pluginName,
pluginPath,
});
}
} catch {
@@ -530,7 +514,6 @@ export async function loadInteractiveConfigData(input: {
toolNames: [pluginTool.name],
configKind: "tool",
pluginName: pluginTool.pluginName,
pluginPath: pluginTool.path,
source: pluginTool.source,
description: pluginTool.description,
});
@@ -550,6 +533,5 @@ export async function loadInteractiveConfigData(input: {
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
tools: toSorted(tools),
workflowSlashCommands,
pluginDiagnosticsLoaded: input.includePluginTools !== false,
};
}
-1
View File
@@ -921,7 +921,6 @@ function App(props: TuiProps) {
if (result.reasoningEffort !== undefined) {
props.config.reasoningEffort = result.reasoningEffort;
}
handleModelChange().then(() => setAppView("home"));
}}
onExit={() => {
-1
View File
@@ -152,7 +152,6 @@ export interface TuiProps {
mode: AgentMode,
delivery?: "queue" | "steer",
attachments?: UserInputAttachments,
onCommandOutput?: (text: string) => void,
) => Promise<InteractiveTurnResult>;
onUpdatePendingPrompt: (input: {
promptId: string;
+1 -3
View File
@@ -107,13 +107,12 @@ describe("copyTextToSystemClipboard", () => {
expect(spawnMock).toHaveBeenNthCalledWith(1, "wl-copy", [], {
stdio: ["pipe", "ignore", "ignore"],
windowsHide: true,
});
expect(spawnMock).toHaveBeenNthCalledWith(
2,
"xclip",
["-selection", "clipboard"],
{ stdio: ["pipe", "ignore", "ignore"], windowsHide: true },
{ stdio: ["pipe", "ignore", "ignore"] },
);
expect(failed.getInput()).toBe("selected text");
expect(succeeded.getInput()).toBe("selected text");
@@ -135,7 +134,6 @@ describe("copyTextToSystemClipboard", () => {
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith("wl-copy", [], {
stdio: ["pipe", "ignore", "ignore"],
windowsHide: true,
});
expect(wlcopy.getInput()).toBe("plain linux");
});
-2
View File
@@ -142,8 +142,6 @@ function runClipboardCommand(
const child = spawn(command.command, command.args, {
stdio: ["pipe", "ignore", "ignore"],
...(command.env ? { env: command.env } : {}),
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
let settled = false;
-2
View File
@@ -115,8 +115,6 @@ async function runCommand(
return await new Promise((resolve) => {
const child = spawn(command, args, {
stdio: ["ignore", "pipe", "ignore"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
const chunks: Buffer[] = [];
let total = 0;
@@ -3,10 +3,14 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
formatProviderConfigHeaders,
getDefaultAwsRegion,
parseProviderConfigHeaders,
resolveProviderConfigAwsRegion,
resolveProviderConfigAzure,
resolveProviderConfigGcp,
resolveProviderConfigHeadersPatch,
resolveProviderConfigPositiveInteger,
resolveProviderConfigSap,
updateProviderConfigValue,
} from "./provider-config-values";
@@ -117,4 +121,50 @@ describe("provider config values", () => {
apiVersion: "",
});
});
it("round-trips headers through format and parse", () => {
const headers = {
"X-Org": "abc",
Authorization: "Bearer a=b",
};
const formatted = formatProviderConfigHeaders(headers);
expect(formatted).toBe("X-Org=abc, Authorization=Bearer a=b");
expect(parseProviderConfigHeaders(formatted)).toEqual(headers);
});
it("parses headers leniently, dropping malformed entries", () => {
expect(
parseProviderConfigHeaders("X-Org=abc, broken, =nokey, X-Other = v "),
).toEqual({
"X-Org": "abc",
"X-Other": "v",
});
expect(parseProviderConfigHeaders("")).toEqual({});
expect(parseProviderConfigHeaders(undefined)).toEqual({});
});
it("builds a headers patch that deletes removed entries", () => {
expect(
resolveProviderConfigHeadersPatch("X-Org=abc", {
"X-Org": "old",
"X-Removed": "gone",
}),
).toEqual({
"X-Org": "abc",
"X-Removed": "",
});
expect(resolveProviderConfigHeadersPatch("", undefined)).toBeUndefined();
expect(resolveProviderConfigHeadersPatch("", { "X-Org": "old" })).toEqual({
"X-Org": "",
});
});
it("parses optional numeric fields, clearing blank or invalid input", () => {
expect(resolveProviderConfigPositiveInteger("128000")).toBe(128_000);
expect(resolveProviderConfigPositiveInteger(" 8192 ")).toBe(8_192);
expect(resolveProviderConfigPositiveInteger("")).toBeUndefined();
expect(resolveProviderConfigPositiveInteger(undefined)).toBeUndefined();
expect(resolveProviderConfigPositiveInteger("abc")).toBeUndefined();
expect(resolveProviderConfigPositiveInteger("-5")).toBeUndefined();
});
});
@@ -62,6 +62,81 @@ export function resolveProviderConfigAzure(values: ProviderConfigValues): {
return { apiVersion: values.azureApiVersion?.trim() ?? "" };
}
/** Serialize stored headers into the single-line "key=value, key2=value2" form. */
export function formatProviderConfigHeaders(
headers: Record<string, string> | undefined,
): string {
if (!headers) {
return "";
}
return Object.entries(headers)
.map(([key, value]) => `${key}=${value}`)
.join(", ");
}
/**
* Parse the single-line headers field. Entries are comma separated; each
* entry splits at its first "=" so values may themselves contain "=".
* Commas are reserved as entry separators, so header values containing a
* comma (e.g. Accept=text/html, application/json) are not supported here:
* the text after the comma parses as a new entry and is dropped if it has
* no "=". Use `cline auth -H` for such values. Entries without a key are
* dropped (the form is deliberately forgiving; provider errors are the
* authoritative feedback).
*/
export function parseProviderConfigHeaders(
value: string | undefined,
): Record<string, string> {
const headers: Record<string, string> = {};
if (!value) {
return headers;
}
for (const entry of value.split(",")) {
const separatorIndex = entry.indexOf("=");
if (separatorIndex <= 0) {
continue;
}
const key = entry.slice(0, separatorIndex).trim();
if (!key) {
continue;
}
headers[key] = entry.slice(separatorIndex + 1).trim();
}
return headers;
}
/**
* Build the headers patch for `saveLocalProviderSettings` so the text field
* is authoritative: parsed entries are upserted and existing keys missing
* from the field are emptied, which the merge layer treats as deletion.
*/
export function resolveProviderConfigHeadersPatch(
value: string | undefined,
existingHeaders: Record<string, string> | undefined,
): Record<string, string> | undefined {
const parsed = parseProviderConfigHeaders(value);
const patch: Record<string, string> = {};
for (const key of Object.keys(existingHeaders ?? {})) {
if (!(key in parsed)) {
patch[key] = "";
}
}
Object.assign(patch, parsed);
return Object.keys(patch).length > 0 ? patch : undefined;
}
/** Parse an optional numeric field; blank or invalid input clears the setting. */
export function resolveProviderConfigPositiveInteger(
value: string | undefined,
): number | undefined {
const trimmed = value?.trim();
if (!trimmed) {
return undefined;
}
const parsed = Number.parseInt(trimmed, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
export function updateProviderConfigValue(
previous: ProviderConfigValues,
field: ProviderConfigFieldKey,
@@ -229,15 +229,3 @@ export function getConfigFooterText({
export function getConfigItemDisplayName(name: string): string {
return name;
}
export function getPluginDiagnosticsLoadingText(
tab: InteractiveConfigTab,
): string | undefined {
if (tab === "tools") {
return "Loading plugin tools...";
}
if (tab === "plugins") {
return "Loading plugin diagnostics...";
}
return undefined;
}
@@ -59,18 +59,6 @@ describe("config view helpers", () => {
expect(isToggleableConfigItem(createItem({ kind: "mcp" }))).toBe(true);
});
it("does not treat plugin MCP rows as toggleable", () => {
expect(
isToggleableConfigItem(
createItem({
kind: "mcp",
pluginName: "plugin",
source: "workspace-plugin",
}),
),
).toBe(false);
});
it("resolves Enter/Tab on a skill row to details", () => {
const skill = createItem({
kind: "skill",
+15 -31
View File
@@ -25,7 +25,6 @@ import {
getConfigFooterText,
getConfigItemDisplayName,
getConfigTabs,
getPluginDiagnosticsLoadingText,
isInlineConfigAction,
isToggleableConfigItem,
resolveActiveConfigItems,
@@ -199,7 +198,6 @@ function appendToolGroupRows(
rightLabel: `${enabledCount}/${groupItems.length} tools enabled`,
indent: 2,
});
for (const item of sortBySourceThenName(groupItems)) {
rows.push({
kind: "ext",
@@ -247,24 +245,17 @@ function appendToolRows(
appendExtRows(rows, builtinTools);
}
const pluginToolItems = items.filter((item) => item.pluginName);
const pluginGroups = groupToolItems(pluginToolItems);
const pluginGroups = groupToolItems(items.filter((item) => item.pluginName));
if (pluginGroups.length > 0) {
rows.push({ kind: "head", label: "Plugins" });
appendToolGroupRows(
rows,
pluginGroups,
getSharedToolNames(pluginToolItems),
getSharedToolNames(items.filter((item) => item.pluginName)),
);
}
}
function hasPluginDiagnostics(data: InteractiveConfigData): boolean {
return (
data.pluginDiagnosticsLoaded || data.tools.some((item) => item.pluginName)
);
}
function appendSkillRows(
rows: ConfigRow[],
items: InteractiveConfigItem[],
@@ -314,18 +305,11 @@ function withOptimisticToggle(
).filter(Boolean),
);
const updateItems = (items: InteractiveConfigItem[]) =>
items.map((candidate) => {
if (matchesItem(candidate)) {
return { ...candidate, enabled: nextEnabled };
}
if (
item.kind === "plugin" &&
(candidate.path === item.path || candidate.pluginPath === item.path)
) {
return { ...candidate, enabled: nextEnabled };
}
return candidate;
});
items.map((candidate) =>
matchesItem(candidate)
? { ...candidate, enabled: nextEnabled }
: candidate,
);
const updateTools = (items: InteractiveConfigItem[]) =>
items.map((candidate) => {
if (matchesItem(candidate)) {
@@ -397,7 +381,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
);
const [configData, setConfigData] = useState(props.configData);
const [pluginToolsLoaded, setPluginToolsLoaded] = useState(
hasPluginDiagnostics(props.configData),
props.configData.tools.some((item) => item.pluginName),
);
const [pluginToolsLoading, setPluginToolsLoading] = useState(false);
const [pluginToolsError, setPluginToolsError] = useState<
@@ -481,11 +465,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
} else if (activeTab === "tools") {
appendToolRows(r, activeItems);
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
if (pluginToolsLoading && loadingText) {
if (pluginToolsLoading) {
r.push({
kind: "detail",
text: loadingText,
text: "Loading plugin tools...",
});
}
if (pluginToolsError) {
@@ -516,10 +499,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
}
if (activeTab === "plugins" && pluginToolsLoading) {
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
r.push({
kind: "detail",
text: loadingText ?? "Loading plugin diagnostics...",
text: "Loading plugin diagnostics...",
});
}
if (activeTab === "plugins" && pluginToolsError) {
@@ -567,13 +549,15 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
if (nextData) {
setConfigData(nextData);
setPluginToolsLoaded(hasPluginDiagnostics(nextData));
setPluginToolsLoaded(nextData.tools.some((tool) => tool.pluginName));
} else if (item.kind === "plugin" && loadConfigData) {
const refreshedData = await loadConfigData({
includePluginTools: true,
});
setConfigData(refreshedData);
setPluginToolsLoaded(hasPluginDiagnostics(refreshedData));
setPluginToolsLoaded(
refreshedData.tools.some((tool) => tool.pluginName),
);
setPluginToolsError(undefined);
}
} catch (error) {
@@ -146,50 +146,5 @@ describe("onboarding auth telemetry forwarding", () => {
// emitted by completeClineDeviceAuth, so passing telemetry to the start
// helper would double-emit the event.
expect(hoisted.startClineDeviceAuth).toHaveBeenCalledWith();
expect(hoisted.openMock).toHaveBeenCalledWith(
"https://verify?user_code=uc",
{ wait: false },
);
});
it("falls back to displaying the device auth URL when browser open fails", async () => {
hoisted.openMock.mockRejectedValueOnce(new Error("no browser"));
hoisted.startClineDeviceAuth.mockResolvedValueOnce({
deviceCode: "dc",
userCode: "uc",
verificationUri: "https://verify",
verificationUriComplete: "https://verify?user_code=uc",
expiresInSeconds: 600,
pollIntervalSeconds: 5,
});
hoisted.completeClineDeviceAuth.mockResolvedValueOnce({
access: "a",
refresh: "r",
expires: 0,
});
const setStatus = vi.fn();
runDeviceCodeAuthFlow({
providerId: "cline",
providerSettingsManager: makeManager(),
isAborted: () => false,
setUserCode: vi.fn(),
setVerifyUrl: vi.fn(),
setStatus,
setError: vi.fn(),
onComplete: vi.fn(),
});
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(hoisted.openMock).toHaveBeenCalledWith(
"https://verify?user_code=uc",
{ wait: false },
);
expect(setStatus).toHaveBeenCalledWith(
"Could not open browser. Visit the URL below.",
);
});
});
+3 -10
View File
@@ -89,18 +89,11 @@ export function runDeviceCodeAuthFlow(input: {
startClineDeviceAuth()
.then((result) => {
if (input.isAborted()) return;
const verifyUrl =
result.verificationUriComplete || result.verificationUri;
input.setUserCode(result.userCode);
input.setVerifyUrl(verifyUrl);
input.setVerifyUrl(
result.verificationUriComplete || result.verificationUri,
);
input.setStatus("Enter the code at the URL below");
try {
void open(verifyUrl, { wait: false }).catch(() => {
input.setStatus("Could not open browser. Visit the URL below.");
});
} catch {
input.setStatus("Could not open browser. Visit the URL below.");
}
completeClineDeviceAuth({
deviceCode: result.deviceCode,
@@ -2,6 +2,7 @@ import {
captureProviderConfigured,
getLocalProviderModels,
getProviderConfigFields,
listLocalProviders,
type ProviderConfigFieldKey,
type ProviderConfigFields,
ProviderSettingsManager,
@@ -16,7 +17,6 @@ import {
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { getCliTelemetryService } from "../../../utils/telemetry";
import {
buildClineModelEntries,
@@ -29,10 +29,13 @@ import {
} from "../../components/searchable-list";
import { palette } from "../../palette";
import {
formatProviderConfigHeaders,
getDefaultAwsRegion,
type ProviderConfigValues,
resolveProviderConfigAwsRegion,
resolveProviderConfigAzure,
resolveProviderConfigHeadersPatch,
resolveProviderConfigPositiveInteger,
resolveProviderConfigSap,
updateProviderConfigValue,
} from "../../utils/provider-config-values";
@@ -395,6 +398,19 @@ export function useOnboardingController(props: OnboardingControllerProps) {
if (config.fields.apiKey) {
initialValues.apiKey = existing?.apiKey?.trim() ?? "";
}
if (config.fields.headers) {
initialValues.headers = formatProviderConfigHeaders(existing?.headers);
}
if (config.fields.contextWindow) {
initialValues.contextWindow =
existing?.contextWindow !== undefined
? String(existing.contextWindow)
: "";
}
if (config.fields.maxOutputTokens) {
initialValues.maxOutputTokens =
existing?.maxTokens !== undefined ? String(existing.maxTokens) : "";
}
if (config.fields.awsProfile) {
initialValues.awsProfile = existing?.aws?.profile?.trim() ?? "";
}
@@ -458,11 +474,35 @@ export function useOnboardingController(props: OnboardingControllerProps) {
byoFields.sapResourceGroup ||
byoFields.sapDeploymentId;
const existingSettings =
providerSettingsManager.getProviderSettings(activeProviderId);
saveLocalProviderSettings(providerSettingsManager, {
providerId: activeProviderId,
apiKey: byoFields.apiKey ? apiKey : undefined,
baseUrl: byoFields.baseUrl ? byoValues.baseUrl?.trim() : undefined,
azure: hasAzureFields ? resolveProviderConfigAzure(byoValues) : undefined,
...(byoFields.headers
? {
headers: resolveProviderConfigHeadersPatch(
byoValues.headers,
existingSettings?.headers,
),
}
: {}),
...(byoFields.contextWindow
? {
contextWindow: resolveProviderConfigPositiveInteger(
byoValues.contextWindow,
),
}
: {}),
...(byoFields.maxOutputTokens
? {
maxTokens: resolveProviderConfigPositiveInteger(
byoValues.maxOutputTokens,
),
}
: {}),
aws: hasAwsFields
? {
region: resolveProviderConfigAwsRegion(byoValues),
@@ -6,6 +6,9 @@ export const FIELD_ORDER: ProviderConfigFieldKey[] = [
"baseUrl",
"azureApiVersion",
"apiKey",
"headers",
"contextWindow",
"maxOutputTokens",
"awsProfile",
"sapClientId",
"sapClientSecret",
@@ -223,6 +223,9 @@ const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
apiKey: "API key",
baseUrl: "Base URL",
azureApiVersion: "Azure API Version",
headers: "Custom Headers",
contextWindow: "Context Window",
maxOutputTokens: "Max Output Tokens",
awsRegion: "AWS Region",
awsProfile: "AWS Profile Name",
sapClientId: "Client ID",
@@ -238,6 +241,9 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
apiKey: "Paste your API key here...",
baseUrl: "",
azureApiVersion: "2025-01-01-preview",
headers: "X-Header=value, X-Other=value",
contextWindow: "",
maxOutputTokens: "",
awsRegion: "us-east-1",
awsProfile: "default",
sapClientId: "sb-...|xsuaa_std!b...",
-1
View File
@@ -28,7 +28,6 @@ export type ChatCommandContext = {
getState: () => Promise<ChatCommandState> | ChatCommandState;
setState: (next: ChatCommandState) => Promise<void> | void;
reply: (text: string) => Promise<void> | void;
submitPrompt?: (prompt: string) => Promise<void> | void;
reset?: () => Promise<void> | void;
abort?: () => Promise<void> | void;
stop?: () => Promise<void> | void;
-19
View File
@@ -1,19 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import {
disposeCliFeatureFlagsService,
getCliFeatureFlagsService,
} from "./feature-flags";
describe("CLI feature flags singleton", () => {
afterEach(async () => {
await disposeCliFeatureFlagsService();
});
it("recreates the singleton after disposal", async () => {
const service = getCliFeatureFlagsService();
await disposeCliFeatureFlagsService();
expect(getCliFeatureFlagsService()).not.toBe(service);
});
});
-111
View File
@@ -1,111 +0,0 @@
import { join } from "node:path";
import {
type BasicLogger,
type FeatureFlagsContext,
FeatureFlagsService,
type ITelemetryService,
NoOpFeatureFlagsProvider,
registerDisposable,
resolveCoreDistinctId,
} from "@cline/core";
import {
buildClinePostHogClient,
PostHogFeatureFlagsProvider,
} from "@cline/core/services/feature-flags/posthog";
import { resolveClineDataDir } from "@cline/shared/storage";
let cliFeatureFlagsContext: FeatureFlagsContext = { clientName: "cline-cli" };
let cliFeatureFlagsService: FeatureFlagsService | undefined;
const CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
function resolveCliFeatureFlagsCachePath(): string {
return join(resolveClineDataDir(), "cache", "feature-flags.json");
}
function ensureCliDistinctId(): string {
const distinctId = cliFeatureFlagsContext.distinctId?.trim();
if (distinctId) {
return distinctId;
}
const resolved = resolveCoreDistinctId();
cliFeatureFlagsContext.distinctId = resolved;
return resolved;
}
export function getCliFeatureFlagsContext(): FeatureFlagsContext {
ensureCliDistinctId();
return { ...cliFeatureFlagsContext };
}
export function getCliFeatureFlagsService(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): FeatureFlagsService {
if (!cliFeatureFlagsService) {
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
const provider =
apiKey &&
process.env.IS_TEST !== "true" &&
process.env.E2E_TEST !== "true"
? new PostHogFeatureFlagsProvider({
client: buildClinePostHogClient(apiKey),
config: {
logger: options?.logger,
},
})
: new NoOpFeatureFlagsProvider();
cliFeatureFlagsService = new FeatureFlagsService({
provider,
telemetry: options?.telemetry,
logger: options?.logger,
context: getCliFeatureFlagsContext(),
cacheFilePath: resolveCliFeatureFlagsCachePath(),
persistentCacheMaxAgeMs: CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
});
registerDisposable(disposeCliFeatureFlagsService);
}
return cliFeatureFlagsService;
}
export function refreshCliFeatureFlagsInBackground(logger?: BasicLogger): void {
const service = getCliFeatureFlagsService({ logger });
void service.poll().catch((error) => {
logger?.error?.("Error refreshing CLI feature flags", { error });
});
}
export async function disposeCliFeatureFlagsService(): Promise<void> {
if (!cliFeatureFlagsService) {
return;
}
const current = cliFeatureFlagsService;
cliFeatureFlagsService = undefined;
await current.dispose();
}
export async function identifyFeatureFlagsAccount(
account: { id?: string; email?: string },
logger?: BasicLogger,
): Promise<void> {
const accountId = account.id?.trim();
cliFeatureFlagsContext = {
...cliFeatureFlagsContext,
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
...(account.email?.trim() ? { email: account.email.trim() } : {}),
};
if (!cliFeatureFlagsService) {
return;
}
cliFeatureFlagsService.setContext(getCliFeatureFlagsContext());
try {
await cliFeatureFlagsService.poll();
} catch (error) {
logger?.error?.("Error polling CLI feature flags", { error });
}
}
-99
View File
@@ -1,99 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildHistoryResumeArgs } from "./history-resume";
describe("buildHistoryResumeArgs", () => {
it("replaces the history subcommand with --id", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history"],
remainingArgs: ["history"],
}),
).toEqual(["--id", "sess_1"]);
});
it("preserves global flags that precede the subcommand", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: [
"--data-dir",
"/tmp/data",
"-m",
"claude-sonnet-4-6",
"history",
"--limit",
"5",
],
remainingArgs: ["history", "--limit", "5"],
}),
).toEqual([
"--data-dir",
"/tmp/data",
"-m",
"claude-sonnet-4-6",
"--id",
"sess_1",
]);
});
it("keeps a global flag value that matches the subcommand alias", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["-m", "h", "h"],
remainingArgs: ["h"],
}),
).toEqual(["-m", "h", "--id", "sess_1"]);
});
it("forwards a config dir passed as a subcommand option", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history", "--config", "/tmp/conf"],
remainingArgs: ["history", "--config", "/tmp/conf"],
configDir: "/tmp/conf",
}),
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
});
it("does not duplicate a config dir already in the global flags", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["--config", "/tmp/conf", "history"],
remainingArgs: ["history"],
configDir: "/tmp/conf",
}),
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
});
it("recognizes the --config=<dir> spelling in global flags", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["--config=/tmp/conf", "history"],
remainingArgs: ["history"],
configDir: "/tmp/conf",
}),
).toEqual(["--config=/tmp/conf", "--id", "sess_1"]);
});
it("returns undefined when remaining args are not a suffix of argv", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history", "--limit", "5"],
remainingArgs: ["history", "--limit", "9"],
}),
).toBeUndefined();
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history"],
remainingArgs: ["extra", "history"],
}),
).toBeUndefined();
});
});
-115
View File
@@ -1,115 +0,0 @@
import { resolveCliLaunchSpec } from "./internal-launch";
export interface HistoryResumeCommand {
launcher: string;
childArgs: string[];
}
export interface BuildHistoryResumeArgsInput {
sessionId: string;
/** Full normalized CLI args (process.argv.slice(2) after normalization). */
normalizedArgs: string[];
/**
* Commander's `program.args` after parsing: the `history` subcommand token
* and everything following it. Must be a suffix of `normalizedArgs`.
*/
remainingArgs: string[];
/**
* Config dir resolved from the full argv. Forwarded explicitly because
* `--config` may have been passed as a `history` subcommand option, which
* would otherwise be dropped with the rest of the subcommand args.
*/
configDir?: string;
}
/**
* Builds argv for relaunching the CLI as `cline <globalFlags> --id <sessionId>`
* after a session is picked in `cline history`. Returns undefined when the
* global-flag prefix cannot be derived safely (caller falls back to resuming
* in-process).
*/
export function buildHistoryResumeArgs(
input: BuildHistoryResumeArgsInput,
): string[] | undefined {
const { sessionId, normalizedArgs, remainingArgs, configDir } = input;
const splitIndex = normalizedArgs.length - remainingArgs.length;
if (splitIndex < 0) {
return undefined;
}
for (let i = 0; i < remainingArgs.length; i++) {
if (normalizedArgs[splitIndex + i] !== remainingArgs[i]) {
return undefined;
}
}
const globalArgs = normalizedArgs.slice(0, splitIndex);
const args = [...globalArgs];
const hasConfigFlag = globalArgs.some(
(arg) => arg === "--config" || arg.startsWith("--config="),
);
if (configDir && !hasConfigFlag) {
args.push("--config", configDir);
}
args.push("--id", sessionId);
return args;
}
export function buildHistoryResumeCommand(
input: BuildHistoryResumeArgsInput,
): HistoryResumeCommand | undefined {
const childArgs = buildHistoryResumeArgs(input);
if (!childArgs) {
return undefined;
}
const spec = resolveCliLaunchSpec();
if (!spec) {
return undefined;
}
return {
launcher: spec.launcher,
childArgs: [...spec.childArgsPrefix, ...childArgs],
};
}
/**
* Resumes a history-picked session in a fresh `cline --id <sessionId>` child
* process with inherited stdio, and returns its exit code. Creating a second
* OpenTUI renderer in the picker's process can crash natively during teardown
* (Bun "panic(main thread): Segmentation fault" on Ctrl+C), so the resumed
* interactive TUI must get a process of its own.
*
* Returns undefined when the child cannot be launched; the caller should fall
* back to resuming in-process.
*/
export async function spawnHistoryResume(
input: BuildHistoryResumeArgsInput,
): Promise<number | undefined> {
const command = buildHistoryResumeCommand(input);
if (!command) {
return undefined;
}
const { spawn } = await import("node:child_process");
return await new Promise<number | undefined>((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(command.launcher, command.childArgs, {
stdio: "inherit",
});
} catch {
resolve(undefined);
return;
}
// The child shares this foreground process group, so terminal-generated
// Ctrl+C already reaches it. Keep the parent alive to reap the child
// without re-forwarding a second signal into the TUI teardown path.
const suppressParentSignal = () => {};
process.on("SIGINT", suppressParentSignal);
process.on("SIGTERM", suppressParentSignal);
const finish = (value: number | undefined) => {
process.off("SIGINT", suppressParentSignal);
process.off("SIGTERM", suppressParentSignal);
resolve(value);
};
child.once("error", () => finish(undefined));
child.once("exit", (code, signal) => finish(signal ? 1 : (code ?? 0)));
});
}
@@ -65,55 +65,4 @@ describe("plugin chat commands", () => {
expect(reply).toHaveBeenCalledWith("echo:hello plugin");
await shutdown?.();
});
it("bridges plugin command submit prompts onto the chat command context", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-plugin-commands-"));
tempRoots.push(tempRoot);
const pluginsDir = join(tempRoot, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
await writeFile(
join(pluginsDir, "submit.js"),
[
"export default {",
" name: 'submit-plugin',",
" manifest: { capabilities: ['commands'] },",
" setup(api) {",
" api.registerCommand({",
" name: 'goal',",
" description: 'Set a goal and submit it',",
" handler: async (input) => ({",
" reply: 'goal:' + input,",
" submitPrompt: input",
" })",
" });",
" },",
"};",
].join("\n"),
);
const { host, shutdown } = await createWorkspaceChatCommandHost({
cwd: tempRoot,
workspaceRoot: tempRoot,
});
const reply = vi.fn(async () => undefined);
const submitPrompt = vi.fn(async () => undefined);
const handled = await host.handle("/goal fix tests", {
enabled: true,
getState: async () => ({
enableTools: false,
autoApproveTools: false,
cwd: tempRoot,
workspaceRoot: tempRoot,
}),
setState: async () => undefined,
reply,
submitPrompt,
});
expect(handled).toBe(true);
expect(reply).toHaveBeenCalledWith("goal:fix tests");
expect(submitPrompt).toHaveBeenCalledWith("fix tests");
await shutdown?.();
});
});
+2 -31
View File
@@ -1,6 +1,5 @@
import {
type AgentExtensionCommand,
type AgentExtensionCommandResult,
type BasicLogger,
createContributionRegistry,
resolveAndLoadAgentPlugins,
@@ -46,41 +45,13 @@ function createPluginCommandDefinition(
names: [normalizedName.toLowerCase()],
run: async ({ args }, context) => {
const result = await command.handler?.(args.join(" "));
const { reply, submitPrompt } = normalizeCommandResult(result);
if (reply) {
await context.reply(reply);
}
if (submitPrompt) {
await context.submitPrompt?.(submitPrompt);
if (typeof result === "string" && result.trim()) {
await context.reply(result);
}
},
};
}
function normalizeCommandResult(
result: AgentExtensionCommandResult | undefined,
): { reply?: string; submitPrompt?: string } {
if (typeof result === "string") {
const reply = result.trim();
return reply ? { reply } : {};
}
if (!result || typeof result !== "object") {
return {};
}
const reply =
typeof result.reply === "string" && result.reply.trim()
? result.reply.trim()
: undefined;
const submitPrompt =
typeof result.submitPrompt === "string" && result.submitPrompt.trim()
? result.submitPrompt.trim()
: undefined;
return {
...(reply ? { reply } : {}),
...(submitPrompt ? { submitPrompt } : {}),
};
}
export async function createWorkspaceChatCommandHost(input: {
cwd: string;
workspaceRoot?: string;
@@ -1,34 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
listLocalProviders: vi.fn(async () => ({ providers: [], settingsPath: "" })),
getBooleanFlagEnabled: vi.fn(() => true),
}));
vi.mock("@cline/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@cline/core")>();
return {
...actual,
listLocalProviders: mocks.listLocalProviders,
};
});
vi.mock("./feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
}),
}));
describe("listLocalProviders", () => {
it("passes the ClinePass feature flag into the SDK provider list", async () => {
const { listLocalProviders } = await import("./provider-catalog");
const manager = {} as never;
await listLocalProviders(manager);
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith("ext-cline-pass");
expect(mocks.listLocalProviders).toHaveBeenCalledWith(manager, {
isClinePassEnabled: true,
});
});
});
-14
View File
@@ -1,14 +0,0 @@
import {
listLocalProviders as internalListLocalProviders,
type ProviderSettingsManager,
} from "@cline/core";
import { getCliFeatureFlagsService } from "./feature-flags";
export async function listLocalProviders(
manager: ProviderSettingsManager,
): ReturnType<typeof internalListLocalProviders> {
return await internalListLocalProviders(manager, {
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
}
-3
View File
@@ -18,12 +18,9 @@ export async function readRepoStatus(cwd: string): Promise<RepoStatus> {
const [branchResult, diffResult] = await Promise.allSettled([
execFileAsync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
encoding: "utf8",
// Prevent a console window from flashing on Windows.
windowsHide: true,
}),
execFileAsync("git", ["-C", cwd, "diff", "--shortstat"], {
encoding: "utf8",
windowsHide: true,
}),
]);
@@ -34,7 +34,7 @@ vi.mock("./telemetry", async (importOriginal) => {
import {
captureCliExtensionActivated,
identifyTelemetryAccount,
identifyCliTelemetryAccount,
} from "./telemetry";
import { resetCliExtensionActivationForTests } from "./telemetry.test-helpers";
@@ -93,7 +93,7 @@ describe("captureCliExtensionActivated", () => {
});
});
describe("identifyTelemetryAccount", () => {
describe("identifyCliTelemetryAccount", () => {
beforeEach(() => {
hoisted.identifyAccount.mockClear();
hoisted.getCliTelemetryService.mockClear();
@@ -107,7 +107,7 @@ describe("identifyTelemetryAccount", () => {
memberId: "member-7",
provider: "cline",
};
identifyTelemetryAccount(account);
identifyCliTelemetryAccount(account);
expect(hoisted.identifyAccount).toHaveBeenCalledWith(undefined, account);
});
});
+1 -4
View File
@@ -9,7 +9,6 @@ import {
TelemetryLoggerSink,
} from "@cline/core";
import { getCliBuildInfo } from "./common";
import { identifyFeatureFlagsAccount } from "./feature-flags";
import {
markActivationCaptured,
wasActivationCaptured,
@@ -103,12 +102,11 @@ export interface CliTelemetryAccountContext {
* Safe to call multiple times; the latest values win, mirroring the legacy
* singleton-based behavior.
*/
export function identifyTelemetryAccount(
export function identifyCliTelemetryAccount(
account: CliTelemetryAccountContext,
logger?: BasicLogger,
): void {
identifyAccount(getCliTelemetryService(logger), account);
void identifyFeatureFlagsAccount(account, logger);
}
/**
@@ -136,7 +134,6 @@ export function captureCliExtensionActivated(
const telemetry = getCliTelemetryService(logger);
if (account) {
identifyAccount(telemetry, account);
void identifyFeatureFlagsAccount(account, logger);
}
captureExtensionActivated(telemetry);
}
+35 -1
View File
@@ -1,5 +1,6 @@
import * as p from "@clack/prompts";
import { authorizeMcpServerOAuthWithBrowser as authorizeOAuth } from "./oauth";
import { authorizeMcpServerOAuth } from "@cline/core";
import open from "open";
import {
addServer,
clearServerOAuth,
@@ -16,6 +17,16 @@ function isCancel(value: unknown): value is symbol {
return p.isCancel(value);
}
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
const message = error.message.trim();
if (message.length > 0) {
return message;
}
}
return String(error);
}
function transportLabel(t: McpTransport): string {
if (t.type === "stdio") return `stdio: ${t.command}`;
return `${t.type}: ${t.url}`;
@@ -211,6 +222,29 @@ async function collectUrlTransport(
};
}
async function authorizeOAuth(name: string): Promise<void> {
p.log.info("Opening browser for MCP OAuth authorization");
try {
const result = await authorizeMcpServerOAuth({
serverName: name,
filePath: getSettingsPath(),
openUrl: async (url) => {
p.log.message(`Authorization URL: ${url}`);
await open(url, { wait: false });
},
onServerListening: (info) => {
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
},
});
p.log.success(result.message);
} catch (error) {
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
p.log.warn(
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
);
}
}
async function actionAdd(): Promise<void> {
const name = await p.text({
message: "Server name",
-41
View File
@@ -1,41 +0,0 @@
import * as p from "@clack/prompts";
import {
authorizeMcpServerOAuth,
resolveDefaultMcpSettingsPath,
} from "@cline/core";
import open from "open";
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
const message = error.message.trim();
if (message.length > 0) {
return message;
}
}
return String(error);
}
export async function authorizeMcpServerOAuthWithBrowser(
name: string,
): Promise<void> {
p.log.info("Opening browser for MCP OAuth authorization");
try {
const result = await authorizeMcpServerOAuth({
serverName: name,
filePath: resolveDefaultMcpSettingsPath(),
openUrl: async (url) => {
p.log.message(`Authorization URL: ${url}`);
await open(url, { wait: false });
},
onServerListening: (info) => {
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
},
});
p.log.success(result.message);
} catch (error) {
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
p.log.warn(
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
);
}
}
-2
View File
@@ -68,8 +68,6 @@ async function runCliConnectCommand(args: string[]): Promise<{
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV ?? "development",
},
stdio: ["ignore", "pipe", "pipe"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
},
);
let stdout = "";
+1 -7
View File
@@ -134,12 +134,6 @@ export function openExternalUrl(url: string): void {
const command =
platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
const child = spawn(command, args, {
stdio: "ignore",
detached: true,
// Prevent a console window from flashing on Windows; the launched
// browser/app still opens normally.
windowsHide: true,
});
const child = spawn(command, args, { stdio: "ignore", detached: true });
child.unref();
}
+2 -6
View File
@@ -8,7 +8,7 @@ import {
Llms,
ProviderSettingsManager,
stopLocalHubServerGracefully,
toHubStatusUrl,
toHubHealthUrl,
} from "@cline/core";
import type { HubUINotifyPayload, SessionRecord } from "@cline/shared";
@@ -460,11 +460,7 @@ async function main(): Promise<void> {
const syncHealthState = async (): Promise<void> => {
try {
const response = await fetch(toHubStatusUrl(hubUrl), {
headers: hubAuthToken
? { authorization: `Bearer ${hubAuthToken}` }
: undefined,
});
const response = await fetch(toHubHealthUrl(hubUrl));
if (!response.ok) {
return;
}
+2 -6
View File
@@ -701,9 +701,7 @@ class CoreChatWebviewController implements vscode.Disposable {
const owner = resolveSharedHubOwnerContext();
if (this.hubUrl) {
const healthy = await probeHubServer(this.hubUrl, {
authToken: this.hubAuthToken,
});
const healthy = await probeHubServer(this.hubUrl);
if (healthy?.url) {
return {
url: rememberRecoverableLocalHubUrl(healthy.url, this.hubAuthToken),
@@ -735,9 +733,7 @@ class CoreChatWebviewController implements vscode.Disposable {
): Promise<HubResolution | undefined> {
const discovery = await readHubDiscovery(discoveryPath);
if (!discovery?.url) return undefined;
const healthy = await probeHubServer(discovery.url, {
authToken: discovery.authToken,
});
const healthy = await probeHubServer(discovery.url);
return healthy?.url
? {
url: rememberRecoverableLocalHubUrl(healthy.url, discovery.authToken),
+38 -20
View File
@@ -1,16 +1,16 @@
{
"name": "claude-dev",
"version": "3.89.2",
"version": "3.89.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.89.2",
"version": "3.89.0",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.50.4",
"@anthropic-ai/vertex-sdk": "^0.11.5",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
"@aws-sdk/credential-providers": "^3.922.0",
"@azure/identity": "^4.13.0",
@@ -156,24 +156,42 @@
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.50.4",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.50.4.tgz",
"integrity": "sha512-zZOWyIuznx2uqiRcCNuidkAsLC8IBHgS9lTwSVEB29sUCwEcqL95MWpWP1nccHSKfiZga+hbZaI0btR2zjuMmw==",
"license": "MIT",
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@anthropic-ai/vertex-sdk": {
"version": "0.11.5",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.11.5.tgz",
"integrity": "sha512-V7sB5nY80unEQu8lSQaEzh1WhYwpIdpC3iXNRHUskghkuQDhS6dQu2ASZBgA5MNuJ1Yv5PhY61NM15dLaQhPQw==",
"version": "0.37.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz",
"integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": ">=0.50.3 <1",
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
}
},
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@anthropic-ai/sdk/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/@anthropic-ai/vertex-sdk": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.6.4.tgz",
"integrity": "sha512-rMBlO2jF53TfMRmsQMm1bPO2JRUh4jYddjq/OJLj8DSAkfbCrNWhc0yhDed6oLYJg5s+VpDbvlPzMggqHhTfMw==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": ">=0.35 <1",
"google-auth-library": "^9.4.2"
}
},
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.89.2",
"version": "3.89.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -389,7 +389,7 @@
"protos": "node scripts/build-proto.mjs",
"protos-python": "node scripts/build-python-proto.mjs",
"download-ripgrep": "node scripts/download-ripgrep.mjs",
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/generated webview-ui/src/services/grpc-client.ts --write --no-errors-on-unmatched",
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched",
"clean:build": "rimraf dist dist-standalone webview-ui/build src/generated out/",
"clean:deps": "rimraf node_modules webview-ui/node_modules",
"clean:all": "npm run clean:build && npm run clean:deps",
@@ -486,8 +486,8 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.50.4",
"@anthropic-ai/vertex-sdk": "^0.11.5",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
"@aws-sdk/credential-providers": "^3.922.0",
"@azure/identity": "^4.13.0",
@@ -1,43 +0,0 @@
import "should";
import {
huggingFaceDefaultModelId,
huggingFaceModels,
} from "../../../../shared/api";
import { HuggingFaceHandler } from "../huggingface";
describe("HuggingFaceHandler", () => {
it("uses dynamic Hugging Face model info for models outside the static list", () => {
const modelInfo = {
maxTokens: 8192,
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Available on providers: test-provider",
};
const handler = new HuggingFaceHandler({
huggingFaceApiKey: "test-api-key",
huggingFaceModelId: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
huggingFaceModelInfo: modelInfo,
});
handler.getModel().should.deepEqual({
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
info: modelInfo,
});
});
it("preserves unknown model IDs when model info is unavailable", () => {
const handler = new HuggingFaceHandler({
huggingFaceApiKey: "test-api-key",
huggingFaceModelId: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
});
handler.getModel().should.deepEqual({
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
info: huggingFaceModels[huggingFaceDefaultModelId],
});
});
});
@@ -1,66 +1,56 @@
import {
huggingFaceDefaultModelId,
huggingFaceModels,
type ModelInfo,
} from "@shared/api";
import { calculateApiCostOpenAI } from "@utils/cost";
import type OpenAI from "openai";
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions";
import type { ClineStorageMessage } from "@/shared/messages/content";
import { createOpenAIClient } from "@/shared/net";
import type { ApiHandler, CommonApiHandlerOptions } from "../";
import { withRetry } from "../retry";
import { convertToOpenAiMessages } from "../transform/openai-format";
import type { ApiStream } from "../transform/stream";
import {
getOpenAIToolParams,
ToolCallProcessor,
} from "../transform/tool-call-processor";
import { HuggingFaceModelId, huggingFaceDefaultModelId, huggingFaceModels, ModelInfo } from "@shared/api"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ClineStorageMessage } from "@/shared/messages/content"
import { createOpenAIClient } from "@/shared/net"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface HuggingFaceHandlerOptions extends CommonApiHandlerOptions {
huggingFaceApiKey?: string;
huggingFaceModelId?: string;
huggingFaceModelInfo?: ModelInfo;
huggingFaceApiKey?: string
huggingFaceModelId?: string
huggingFaceModelInfo?: ModelInfo
}
export class HuggingFaceHandler implements ApiHandler {
private options: HuggingFaceHandlerOptions;
private client: OpenAI | undefined;
private cachedModel: { id: string; info: ModelInfo } | undefined;
private options: HuggingFaceHandlerOptions
private client: OpenAI | undefined
private cachedModel: { id: HuggingFaceModelId; info: ModelInfo } | undefined
constructor(options: HuggingFaceHandlerOptions) {
this.options = options;
this.options = options
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.huggingFaceApiKey) {
throw new Error("Hugging Face API key is required");
throw new Error("Hugging Face API key is required")
}
try {
this.client = createOpenAIClient({
baseURL: "https://router.huggingface.co/v1",
apiKey: this.options.huggingFaceApiKey,
});
})
} catch (error: any) {
throw new Error(`Error creating Hugging Face client: ${error.message}`);
throw new Error(`Error creating Hugging Face client: ${error.message}`)
}
}
return this.client;
return this.client
}
private async *yieldUsage(
info: ModelInfo,
usage: OpenAI.Completions.CompletionUsage | undefined,
): ApiStream {
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
if (!usage) {
return;
return
}
const inputTokens = usage.prompt_tokens || 0;
const outputTokens = usage.completion_tokens || 0;
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens);
const inputTokens = usage.prompt_tokens || 0
const outputTokens = usage.completion_tokens || 0
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens)
const usageData = {
type: "usage" as const,
@@ -69,25 +59,21 @@ export class HuggingFaceHandler implements ApiHandler {
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalCost: totalCost,
};
}
yield usageData;
yield usageData
}
@withRetry()
async *createMessage(
systemPrompt: string,
messages: ClineStorageMessage[],
tools?: OpenAITool[],
): ApiStream {
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
try {
const client = this.ensureClient();
const model = this.getModel();
const client = this.ensureClient()
const model = this.getModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
];
]
const requestParams = {
model: model.id,
@@ -97,71 +83,66 @@ export class HuggingFaceHandler implements ApiHandler {
stream_options: { include_usage: true },
temperature: 0,
...getOpenAIToolParams(tools),
};
}
const toolCallProcessor = new ToolCallProcessor();
const stream = (await client.chat.completions.create(
requestParams,
)) as any;
const toolCallProcessor = new ToolCallProcessor()
const stream = (await client.chat.completions.create(requestParams)) as any
let _chunkCount = 0;
let _totalContent = "";
let _chunkCount = 0
let _totalContent = ""
for await (const chunk of stream) {
_chunkCount++;
const delta = chunk.choices?.[0]?.delta;
_chunkCount++
const delta = chunk.choices?.[0]?.delta
if (delta?.content) {
_totalContent += delta.content;
_totalContent += delta.content
yield {
type: "text",
text: delta.content,
};
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls);
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage);
yield* this.yieldUsage(model.info, chunk.usage)
}
}
} catch (error: any) {
throw error;
throw error
}
}
getModel(): { id: string; info: ModelInfo } {
getModel(): { id: HuggingFaceModelId; info: ModelInfo } {
// Return cached model if available
if (this.cachedModel) {
return this.cachedModel;
return this.cachedModel
}
const modelId = this.options.huggingFaceModelId;
let result: { id: string; info: ModelInfo };
const modelId = this.options.huggingFaceModelId
// List all available models for debugging
const _availableModels = Object.keys(huggingFaceModels)
let result: { id: HuggingFaceModelId; info: ModelInfo }
if (modelId && modelId in huggingFaceModels) {
const id = modelId as keyof typeof huggingFaceModels;
const modelInfo = huggingFaceModels[id];
result = { id, info: modelInfo };
} else if (modelId) {
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId];
result = {
id: modelId,
info: this.options.huggingFaceModelInfo || defaultInfo,
};
const id = modelId as HuggingFaceModelId
const modelInfo = huggingFaceModels[id]
result = { id, info: modelInfo }
} else {
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId];
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId]
result = {
id: huggingFaceDefaultModelId,
info: defaultInfo,
};
}
}
// Cache the result for future calls
this.cachedModel = result;
this.cachedModel = result
return result;
return result
}
}
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,5 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import {
type ClineStorageMessage,
convertClineStorageToAnthropicMessage,
} from "@/shared/messages/content";
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineStorageMessage, convertClineStorageToAnthropicMessage } from "@/shared/messages/content"
/**
* Converts Cline storage messages to Anthropic API format with optional cache control.
@@ -15,7 +12,7 @@ import {
* @returns Array of Anthropic-compatible messages with cache control applied
*/
export function sanitizeAnthropicMessages(
clineMessages: ClineStorageMessage[],
clineMessages: Array<ClineStorageMessage | Anthropic.MessageParam>,
supportCache: boolean,
): Array<Anthropic.MessageParam> {
// The latest message will be the new user message, one before will be the assistant message from a previous request,
@@ -24,37 +21,32 @@ export function sanitizeAnthropicMessages(
// know the last message to retrieve from the cache for the current request.
const userMsgIndices = clineMessages.reduce((acc, msg, index) => {
if (msg.role === "user") {
acc.push(index);
acc.push(index)
}
return acc;
}, [] as number[]);
return acc
}, [] as number[])
// Set to -1 if there are no user messages so the indices are invalid
const indicesLength = userMsgIndices.length ?? -1;
const lastUserMsgIndex = userMsgIndices[indicesLength - 1];
const secondLastMsgUserIndex = userMsgIndices[indicesLength - 2];
const indicesLength = userMsgIndices.length ?? -1
const lastUserMsgIndex = userMsgIndices[indicesLength - 1]
const secondLastMsgUserIndex = userMsgIndices[indicesLength - 2]
return clineMessages.map((msg, index) => {
const anthropicMsg = convertClineStorageToAnthropicMessage(msg);
const anthropicMsg = convertClineStorageToAnthropicMessage(msg)
// Add cache control to the last two user messages
if (
supportCache &&
(index === lastUserMsgIndex || index === secondLastMsgUserIndex)
) {
return addCacheControl(anthropicMsg);
if (supportCache && (index === lastUserMsgIndex || index === secondLastMsgUserIndex)) {
return addCacheControl(anthropicMsg)
}
return anthropicMsg;
});
return anthropicMsg
})
}
const isThinkingBlock = (
block: Anthropic.ContentBlockParam,
): block is
| Anthropic.Messages.ThinkingBlockParam
| Anthropic.Messages.RedactedThinkingBlockParam => {
return block.type === "thinking" || block.type === "redacted_thinking";
};
): block is Anthropic.Messages.ThinkingBlockParam | Anthropic.Messages.RedactedThinkingBlockParam => {
return block.type === "thinking" || block.type === "redacted_thinking"
}
/**
* Adds ephemeral cache control to the last content block of a message.
@@ -63,9 +55,7 @@ const isThinkingBlock = (
* @param message - The Anthropic message to add cache control to
* @returns A new message with cache control added to the last content block
*/
function addCacheControl(
message: Anthropic.MessageParam,
): Anthropic.MessageParam {
function addCacheControl(message: Anthropic.MessageParam): Anthropic.MessageParam {
// Convert string content to array format
if (typeof message.content === "string") {
return {
@@ -77,24 +67,24 @@ function addCacheControl(
cache_control: { type: "ephemeral" },
} satisfies Anthropic.TextBlockParam,
],
};
}
}
// Handle array content - add cache control to the last block
const content = [...message.content];
const lastIndex = content.length - 1;
const content = [...message.content]
const lastIndex = content.length - 1
if (lastIndex >= 0) {
const lastBlock = content[lastIndex];
const lastBlock = content[lastIndex]
// Only add cache_control to block types that support it (not ThinkingBlockParam)
if (!isThinkingBlock(lastBlock)) {
content[lastIndex] = {
...lastBlock,
cache_control: { type: "ephemeral" },
} satisfies Anthropic.ContentBlockParam;
} satisfies Anthropic.ContentBlockParam
}
}
return { ...message, content };
return { ...message, content }
}
@@ -1,6 +1,6 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type { Content, GenerateContentResponse, Part } from "@google/genai";
import type { ClineStorageMessage } from "@/shared/messages/content";
import { Anthropic } from "@anthropic-ai/sdk"
import { Content, GenerateContentResponse, Part } from "@google/genai"
import { ClineStorageMessage } from "@/shared/messages/content"
// Source: https://ai.google.dev/gemini-api/docs/thought-signatures#faqs
// While injecting custom function call blocks into the request is strongly discouraged,
@@ -8,29 +8,27 @@ import type { ClineStorageMessage } from "@/shared/messages/content";
// calls and responses that were executed deterministically by the client, or transferring a
// trace from a different model that does not include thought signatures, you can set the following dummy signatures of either
// "context_engineering_is_the_way_to_go" or "skip_thought_signature_validator" in the thought signature field to skip validation.
const GEMINI_DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator";
const GEMINI_DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
export function convertAnthropicContentToGemini(
content: string | ClineStorageMessage["content"],
): Part[] {
export function convertAnthropicContentToGemini(content: string | ClineStorageMessage["content"]): Part[] {
if (typeof content === "string") {
return [{ text: content }];
return [{ text: content }]
}
return content
.flatMap((block): Part | undefined => {
switch (block.type) {
case "text":
return { text: block.text, thoughtSignature: block.signature };
return { text: block.text, thoughtSignature: block.signature }
case "image":
if (block.source.type !== "base64") {
throw new Error("Unsupported image source type");
throw new Error("Unsupported image source type")
}
return {
inlineData: {
data: block.source.data,
mimeType: block.source.media_type,
},
};
}
case "tool_use":
return {
functionCall: {
@@ -39,7 +37,7 @@ export function convertAnthropicContentToGemini(
},
// Thought signature is required, so provide a dummy one if not present
thoughtSignature: block.signature || GEMINI_DUMMY_THOUGHT_SIGNATURE,
};
}
case "tool_result":
return {
functionResponse: {
@@ -48,66 +46,57 @@ export function convertAnthropicContentToGemini(
result: block.content,
},
},
};
}
case "thinking":
return {
text: block.thinking,
thought: true,
thoughtSignature: block.signature || GEMINI_DUMMY_THOUGHT_SIGNATURE,
};
}
default:
return undefined;
return undefined
}
})
.filter((part): part is Part => part !== undefined); // Filter out unsupported blocks
.filter((part): part is Part => part !== undefined) // Filter out unsupported blocks
}
export function convertAnthropicMessageToGemini(
message: ClineStorageMessage,
): Content {
export function convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam): Content {
return {
role: message.role === "assistant" ? "model" : "user",
parts: convertAnthropicContentToGemini(message.content),
};
}
}
/*
It looks like gemini likes to double escape certain characters when writing file contents: https://discuss.ai.google.dev/t/function-call-string-property-is-double-escaped/37867
*/
export function unescapeGeminiContent(content: string) {
return content
.replace(/\\n/g, "\n")
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\r/g, "\r")
.replace(/\\t/g, "\t");
return content.replace(/\\n/g, "\n").replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\r/g, "\r").replace(/\\t/g, "\t")
}
export function convertGeminiResponseToAnthropic(
response: GenerateContentResponse,
): Anthropic.Messages.Message {
const content: Anthropic.Messages.ContentBlock[] = [];
export function convertGeminiResponseToAnthropic(response: GenerateContentResponse): Anthropic.Messages.Message {
const content: Anthropic.Messages.ContentBlock[] = []
const text = response.text;
const text = response.text
if (text) {
content.push({ type: "text", text, citations: null });
content.push({ type: "text", text, citations: null })
}
let stop_reason: Anthropic.Messages.Message["stop_reason"] = null;
const finishReason = response.candidates?.[0]?.finishReason;
let stop_reason: Anthropic.Messages.Message["stop_reason"] = null
const finishReason = response.candidates?.[0]?.finishReason
if (finishReason) {
switch (finishReason) {
case "STOP":
stop_reason = "end_turn";
break;
stop_reason = "end_turn"
break
case "MAX_TOKENS":
stop_reason = "max_tokens";
break;
stop_reason = "max_tokens"
break
case "SAFETY":
case "RECITATION":
case "OTHER":
stop_reason = "stop_sequence";
break;
stop_reason = "stop_sequence"
break
}
}
@@ -124,7 +113,6 @@ export function convertGeminiResponseToAnthropic(
output_tokens: response.usageMetadata?.candidatesTokenCount ?? 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
};
}
}
@@ -1,32 +1,29 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage";
import type { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage";
import type { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage";
import type { UserMessage } from "@mistralai/mistralai/models/components/usermessage";
import { getImageDataUrl } from "@/shared/messages/content";
import { Anthropic } from "@anthropic-ai/sdk"
import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage"
import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
import { UserMessage } from "@mistralai/mistralai/models/components/usermessage"
export type MistralMessage =
| (SystemMessage & { role: "system" })
| (UserMessage & { role: "user" })
| (AssistantMessage & { role: "assistant" })
| (ToolMessage & { role: "tool" });
| (ToolMessage & { role: "tool" })
export function convertToMistralMessages(
anthropicMessages: Anthropic.Messages.MessageParam[],
): MistralMessage[] {
const mistralMessages: MistralMessage[] = [];
export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): MistralMessage[] {
const mistralMessages: MistralMessage[] = []
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
mistralMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
});
})
} else {
if (anthropicMessage.role === "user") {
// Filter to only include text and image blocks
const textAndImageBlocks = anthropicMessage.content.filter(
(part) => part.type === "text" || part.type === "image",
);
)
if (textAndImageBlocks.length > 0) {
mistralMessages.push({
@@ -36,31 +33,29 @@ export function convertToMistralMessages(
return {
type: "image_url",
imageUrl: {
url: getImageDataUrl(part.source),
url: `data:${part.source.media_type};base64,${part.source.data}`,
},
};
}
}
return { type: "text", text: part.text };
return { type: "text", text: part.text }
}),
});
})
}
} else if (anthropicMessage.role === "assistant") {
// Only process text blocks - assistant cannot send images or other content types in Mistral's API format
const textBlocks = anthropicMessage.content.filter(
(part) => part.type === "text",
);
const textBlocks = anthropicMessage.content.filter((part) => part.type === "text")
if (textBlocks.length > 0) {
const content = textBlocks.map((part) => part.text).join("\n");
const content = textBlocks.map((part) => part.text).join("\n")
mistralMessages.push({
role: "assistant",
content,
});
})
}
}
}
}
return mistralMessages;
return mistralMessages
}
+75 -90
View File
@@ -1,6 +1,6 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type OpenAI from "openai";
import { Logger } from "@/shared/services/Logger";
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { Logger } from "@/shared/services/Logger"
const o1SystemPrompt = (systemPrompt: string) => `
# System Prompt
@@ -164,7 +164,7 @@ I've analyzed the project structure, but I need more information to proceed. Let
<ask_followup_question>
<question>Which specific feature would you like me to implement in the example.py file?</question>
</ask_followup_question>
`;
`
export function convertToO1Messages(
openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[],
@@ -176,26 +176,26 @@ export function convertToO1Messages(
acc.push({
role: "user",
content: message.content || "",
});
})
} else if (message.role === "assistant" && message.tool_calls) {
// Convert tool calls to content and remove tool_calls
let content = message.content || "";
let content = message.content || ""
message.tool_calls.forEach((toolCall) => {
if (toolCall.type === "function") {
content += `\nTool Call: ${toolCall.function.name}\nArguments: ${toolCall.function.arguments}`;
content += `\nTool Call: ${toolCall.function.name}\nArguments: ${toolCall.function.arguments}`
}
});
})
acc.push({
role: "assistant",
content: content,
tool_calls: undefined,
});
})
} else {
// Keep other messages as they are
acc.push(message);
acc.push(message)
}
return acc;
}, [] as OpenAI.Chat.ChatCompletionMessageParam[]);
return acc
}, [] as OpenAI.Chat.ChatCompletionMessageParam[])
// Find the index of the last assistant message
// const lastAssistantIndex = findLastIndex(toolsReplaced, (message) => message.role === "assistant")
@@ -207,7 +207,7 @@ export function convertToO1Messages(
content: o1SystemPrompt(systemPrompt),
} as OpenAI.Chat.ChatCompletionUserMessageParam,
...toolsReplaced,
];
]
// If there's an assistant message, insert the system prompt after it
// if (lastAssistantIndex !== -1) {
@@ -226,12 +226,12 @@ export function convertToO1Messages(
// })
// }
return messagesWithSystemPrompt;
return messagesWithSystemPrompt
}
interface ToolCall {
tool: string;
tool_input: Record<string, string>;
tool: string
tool_input: Record<string, string>
}
const toolNames = [
@@ -243,116 +243,106 @@ const toolNames = [
"write_to_file",
"ask_followup_question",
"attempt_completion",
];
]
function parseAIResponse(response: string): {
normalText: string;
toolCalls: ToolCall[];
normalText: string
toolCalls: ToolCall[]
} {
// Create a regex pattern to match any tool call opening tag
const toolCallPattern = new RegExp(`<(${toolNames.join("|")})`, "i");
const match = response.match(toolCallPattern);
const toolCallPattern = new RegExp(`<(${toolNames.join("|")})`, "i")
const match = response.match(toolCallPattern)
if (!match) {
// No tool calls found
return { normalText: response.trim(), toolCalls: [] };
return { normalText: response.trim(), toolCalls: [] }
}
const toolCallStart = match.index!;
const normalText = response.slice(0, toolCallStart).trim();
const toolCallsText = response.slice(toolCallStart);
const toolCallStart = match.index!
const normalText = response.slice(0, toolCallStart).trim()
const toolCallsText = response.slice(toolCallStart)
const toolCalls = parseToolCalls(toolCallsText);
const toolCalls = parseToolCalls(toolCallsText)
return { normalText, toolCalls };
return { normalText, toolCalls }
}
function parseToolCalls(toolCallsText: string): ToolCall[] {
const toolCalls: ToolCall[] = [];
const toolCalls: ToolCall[] = []
let remainingText = toolCallsText;
let remainingText = toolCallsText
while (remainingText.length > 0) {
const toolMatch = toolNames.find((tool) =>
new RegExp(`<${tool}`, "i").test(remainingText),
);
const toolMatch = toolNames.find((tool) => new RegExp(`<${tool}`, "i").test(remainingText))
if (!toolMatch) {
break; // No more tool calls found
break // No more tool calls found
}
const startTag = `<${toolMatch}`;
const endTag = `</${toolMatch}>`;
const startIndex = remainingText.indexOf(startTag);
const endIndex = remainingText.indexOf(endTag, startIndex);
const startTag = `<${toolMatch}`
const endTag = `</${toolMatch}>`
const startIndex = remainingText.indexOf(startTag)
const endIndex = remainingText.indexOf(endTag, startIndex)
if (endIndex === -1) {
break; // Malformed XML, no closing tag found
break // Malformed XML, no closing tag found
}
const toolCallContent = remainingText.slice(
startIndex,
endIndex + endTag.length,
);
remainingText = remainingText.slice(endIndex + endTag.length).trim();
const toolCallContent = remainingText.slice(startIndex, endIndex + endTag.length)
remainingText = remainingText.slice(endIndex + endTag.length).trim()
const toolCall = parseToolCall(toolMatch, toolCallContent);
const toolCall = parseToolCall(toolMatch, toolCallContent)
if (toolCall) {
toolCalls.push(toolCall);
toolCalls.push(toolCall)
}
}
return toolCalls;
return toolCalls
}
function parseToolCall(toolName: string, content: string): ToolCall | null {
const tool_input: Record<string, string> = {};
const tool_input: Record<string, string> = {}
// Remove the outer tool tags
const innerContent = content
.replace(new RegExp(`^<${toolName}>|</${toolName}>$`, "g"), "")
.trim();
const innerContent = content.replace(new RegExp(`^<${toolName}>|</${toolName}>$`, "g"), "").trim()
// Parse nested XML elements
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs;
let match: RegExpExecArray | null;
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs
let match: RegExpExecArray | null
while ((match = paramRegex.exec(innerContent)) !== null) {
const [, paramName, paramValue] = match;
const [, paramName, paramValue] = match
// Preserve newlines and trim only leading/trailing whitespace
tool_input[paramName] = paramValue.replace(/^\s+|\s+$/g, "");
tool_input[paramName] = paramValue.replace(/^\s+|\s+$/g, "")
}
// Validate required parameters
if (!validateToolInput(toolName, tool_input)) {
Logger.error(`Invalid tool call for ${toolName}:`, content);
return null;
Logger.error(`Invalid tool call for ${toolName}:`, content)
return null
}
return { tool: toolName, tool_input };
return { tool: toolName, tool_input }
}
function validateToolInput(
toolName: string,
tool_input: Record<string, string>,
): boolean {
function validateToolInput(toolName: string, tool_input: Record<string, string>): boolean {
switch (toolName) {
case "execute_command":
return "command" in tool_input;
return "command" in tool_input
case "read_file":
case "list_code_definition_names":
case "list_files":
return "path" in tool_input;
return "path" in tool_input
case "search_files":
return "path" in tool_input && "regex" in tool_input;
return "path" in tool_input && "regex" in tool_input
case "write_to_file":
return "path" in tool_input && "content" in tool_input;
return "path" in tool_input && "content" in tool_input
case "ask_followup_question":
return "question" in tool_input;
return "question" in tool_input
case "attempt_completion":
return "result" in tool_input;
return "result" in tool_input
default:
return false;
return false
}
}
@@ -376,10 +366,8 @@ function validateToolInput(
export function convertO1ResponseToAnthropicMessage(
completion: OpenAI.Chat.Completions.ChatCompletion,
): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message;
const { normalText, toolCalls } = parseAIResponse(
openAiMessage.content || "",
);
const openAiMessage = completion.choices[0].message
const { normalText, toolCalls } = parseAIResponse(openAiMessage.content || "")
const anthropicMessage: Anthropic.Messages.Message = {
id: completion.id,
@@ -396,14 +384,14 @@ export function convertO1ResponseToAnthropicMessage(
stop_reason: (() => {
switch (completion.choices[0].finish_reason) {
case "stop":
return "end_turn";
return "end_turn"
case "length":
return "max_tokens";
return "max_tokens"
case "tool_calls":
return "tool_use";
return "tool_use"
case "content_filter": // Anthropic doesn't have an exact equivalent
default:
return null;
return null
}
})(),
stop_sequence: null, // which custom stop_sequence was generated, if any (not applicable if you don't use stop_sequence)
@@ -412,26 +400,23 @@ export function convertO1ResponseToAnthropicMessage(
output_tokens: completion.usage?.completion_tokens || 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
};
}
if (toolCalls.length > 0) {
anthropicMessage.content.push(
...toolCalls.map(
(toolCall: ToolCall, index: number): Anthropic.ToolUseBlock => {
return {
type: "tool_use",
id: `call_${index}_${Date.now()}`, // Generate a unique ID for each tool call
name: toolCall.tool,
input: toolCall.tool_input,
};
},
),
);
...toolCalls.map((toolCall: ToolCall, index: number): Anthropic.ToolUseBlock => {
return {
type: "tool_use",
id: `call_${index}_${Date.now()}`, // Generate a unique ID for each tool call
name: toolCall.tool,
input: toolCall.tool_input,
}
}),
)
}
return anthropicMessage;
return anthropicMessage
}
// Example usage:
@@ -1,68 +1,64 @@
import type { Message } from "ollama";
import { Message } from "ollama"
import {
type ClineAssistantToolUseBlock,
type ClineImageContentBlock,
type ClineStorageMessage,
type ClineTextContentBlock,
type ClineUserToolResultContentBlock,
getImageDataUrl,
} from "@/shared/messages/content";
ClineAssistantToolUseBlock,
ClineImageContentBlock,
ClineStorageMessage,
ClineTextContentBlock,
ClineUserToolResultContentBlock,
} from "@/shared/messages/content"
export function convertToOllamaMessages(
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
): Message[] {
const ollamaMessages: Message[] = [];
export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[]): Message[] {
const ollamaMessages: Message[] = []
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
ollamaMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
});
})
} else {
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
toolMessages: ClineUserToolResultContentBlock[];
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
}
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineUserToolResultContentBlock[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool result messages FIRST since they must follow the tool use messages
const toolResultImages: string[] = [];
const toolResultImages: string[] = []
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string;
let content: string
if (typeof toolMessage.content === "string") {
content = toolMessage.content;
content = toolMessage.content
} else {
content =
toolMessage.content
?.map((part) => {
if (part.type === "image") {
toolResultImages.push(getImageDataUrl(part.source));
return "(see following user message for image)";
toolResultImages.push(`data:${part.source.media_type};base64,${part.source.data}`)
return "(see following user message for image)"
}
return part.text;
return part.text
})
.join("\n") ?? "";
.join("\n") ?? ""
}
ollamaMessages.push({
role: "user",
images: toolResultImages.length > 0 ? toolResultImages : undefined,
content: content,
});
});
})
})
// Process non-tool messages
if (nonToolMessages.length > 0) {
@@ -71,50 +67,49 @@ export function convertToOllamaMessages(
content: nonToolMessages
.map((part) => {
if (part.type === "image") {
return getImageDataUrl(part.source);
return `data:${part.source.media_type};base64,${part.source.data}`
}
return part.text;
return part.text
})
.join("\n"),
});
})
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
toolMessages: ClineAssistantToolUseBlock[];
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
} // assistant cannot send tool_result messages
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineAssistantToolUseBlock[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process non-tool messages
let content: string = "";
let content: string = ""
if (nonToolMessages.length > 0) {
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
return ""; // impossible as the assistant cannot send images
return "" // impossible as the assistant cannot send images
}
return part.text;
return part.text
})
.join("\n");
.join("\n")
}
ollamaMessages.push({
role: "assistant",
content,
});
})
}
}
}
return ollamaMessages;
return ollamaMessages
}
@@ -1,19 +1,19 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type OpenAI from "openai";
import type { ApiProvider } from "@/shared/api";
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiProvider } from "@/shared/api"
import {
type ClineAssistantRedactedThinkingBlock,
type ClineAssistantThinkingBlock,
type ClineAssistantToolUseBlock,
type ClineImageContentBlock,
type ClineTextContentBlock,
type ClineUserToolResultContentBlock,
getImageDataUrl,
} from "@/shared/messages/content";
import { Logger } from "@/shared/services/Logger";
ClineAssistantRedactedThinkingBlock,
ClineAssistantThinkingBlock,
ClineAssistantToolUseBlock,
ClineImageContentBlock,
ClineStorageMessage,
ClineTextContentBlock,
ClineUserToolResultContentBlock,
} from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
// OpenAI API has a maximum tool call ID length of 40 characters
const MAX_TOOL_CALL_ID_LENGTH = 40;
const MAX_TOOL_CALL_ID_LENGTH = 40
/**
* Determines if a given tool ID follows the OpenAI Responses API format for tool calls.
@@ -23,7 +23,7 @@ const MAX_TOOL_CALL_ID_LENGTH = 40;
* @returns True if the tool ID matches the OpenAI Responses API format, false otherwise
*/
function isOpenAIResponseToolId(callId: string): boolean {
return callId.startsWith("fc_") && callId.length === 53;
return callId.startsWith("fc_") && callId.length === 53
}
/**
@@ -37,24 +37,21 @@ function isOpenAIResponseToolId(callId: string): boolean {
* @param provider - The API provider that the OpenAI formatted messages will be sent to
* @returns The transformed ID suitable for OpenAI API
*/
function transformToolCallIdForNativeApi(
toolId: string,
provider?: ApiProvider,
): string {
function transformToolCallIdForNativeApi(toolId: string, provider?: ApiProvider): string {
// OpenAI Responses API uses "fc_" prefix with 53 char length
// Convert these to "call_" prefix format for Chat Completions API
if (isOpenAIResponseToolId(toolId)) {
// Use the last 33 chars + "call_" (5 chars) to stay under the 40-char limit.
return `call_${toolId.slice(toolId.length - (MAX_TOOL_CALL_ID_LENGTH - 5))}`;
return `call_${toolId.slice(toolId.length - (MAX_TOOL_CALL_ID_LENGTH - 5))}`
}
if (provider !== "openai-native") {
return toolId;
return toolId
}
// Ensure ID doesn't exceed max length
if (toolId.length > MAX_TOOL_CALL_ID_LENGTH) {
return toolId.slice(0, MAX_TOOL_CALL_ID_LENGTH);
return toolId.slice(0, MAX_TOOL_CALL_ID_LENGTH)
}
return toolId;
return toolId
}
/**
@@ -68,17 +65,17 @@ function transformToolCallIdForNativeApi(
* @returns Array of OpenAI.Chat.ChatCompletionMessageParam objects
*/
export function convertToOpenAiMessages(
anthropicMessages: Anthropic.Messages.MessageParam[],
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
provider?: ApiProvider,
): OpenAI.Chat.ChatCompletionMessageParam[] {
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [];
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
openAiMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
});
})
} else {
// image_url.url is base64 encoded image data
// ensure it contains the content-type of the image: data:image/png;base64,
@@ -89,56 +86,52 @@ export function convertToOpenAiMessages(
{ role: "tool", tool_call_id: "", content: ""}
*/
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
toolMessages: ClineUserToolResultContentBlock[];
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
} // user cannot send tool_use messages
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineUserToolResultContentBlock[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // user cannot send tool_use messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool result messages FIRST since they must follow the tool use messages
const toolResultImages: ClineImageContentBlock[] = [];
const toolResultImages: ClineImageContentBlock[] = []
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the OpenAI SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string;
let content: string
if (typeof toolMessage.content === "string") {
content = toolMessage.content;
content = toolMessage.content
} else if (Array.isArray(toolMessage.content)) {
content =
toolMessage.content
?.map((part) => {
if (part.type === "image") {
toolResultImages.push(part);
return "(see following user message for image)";
toolResultImages.push(part)
return "(see following user message for image)"
}
return part.text;
return part.text
})
.join("\n") ?? "";
.join("\n") ?? ""
} else {
// Handle undefined content
content = "";
content = ""
}
openAiMessages.push({
role: "tool",
// The tool_call_id must match the id used in the assistant's tool_calls array.
// Use the same transformation logic as tool_calls to ensure IDs match.
tool_call_id: transformToolCallIdForNativeApi(
toolMessage.tool_use_id,
provider,
),
tool_call_id: transformToolCallIdForNativeApi(toolMessage.tool_use_id, provider),
content: content,
});
});
})
})
// If tool results contain images, send as a separate user message
// I ran into an issue where if I gave feedback for one of many tool uses, the request would fail.
@@ -151,9 +144,9 @@ export function convertToOpenAiMessages(
role: "user",
content: toolResultImages.map((part) => ({
type: "image_url",
image_url: { url: getImageDataUrl(part.source) },
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
})),
});
})
}
// Process non-tool messages
@@ -165,117 +158,106 @@ export function convertToOpenAiMessages(
return {
type: "image_url",
image_url: {
url: getImageDataUrl(part.source),
url: `data:${part.source.media_type};base64,${part.source.data}`,
},
};
}
}
return { type: "text", text: part.text };
return { type: "text", text: part.text }
}),
});
})
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (
| ClineTextContentBlock
| ClineImageContentBlock
| ClineAssistantThinkingBlock
| ClineAssistantRedactedThinkingBlock
)[];
toolMessages: ClineAssistantToolUseBlock[];
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
} // assistant cannot send tool_result messages
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (
| ClineTextContentBlock
| ClineImageContentBlock
| ClineAssistantThinkingBlock
| ClineAssistantRedactedThinkingBlock
)[]
toolMessages: ClineAssistantToolUseBlock[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process non-tool messages
let content: string | undefined;
const reasoningDetails: any[] = [];
const thinkingBlock = [];
let content: string | undefined
const reasoningDetails: any[] = []
const thinkingBlock = []
if (nonToolMessages.length > 0) {
nonToolMessages.forEach((part) => {
const anyPart = part as any;
const anyPart = part as any
if (part.type === "text" && anyPart.reasoning_details) {
if (Array.isArray(anyPart.reasoning_details)) {
reasoningDetails.push(...anyPart.reasoning_details);
reasoningDetails.push(...anyPart.reasoning_details)
} else {
reasoningDetails.push(anyPart.reasoning_details);
reasoningDetails.push(anyPart.reasoning_details)
}
}
if (part.type === "thinking" && part.thinking) {
// Reasoning details should have been moved to the text block
thinkingBlock.push(part);
thinkingBlock.push(part)
}
});
})
content = nonToolMessages
.map((part) => {
if (part.type === "text" && part.text) {
return part.text;
return part.text
}
return "";
return ""
})
.join("\n");
.join("\n")
}
// Process tool use messages
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] =
toolMessages.map((toolMessage) => {
const toolDetails = toolMessage.reasoning_details;
const toolId = toolMessage.id;
if (toolDetails) {
if (Array.isArray(toolDetails)) {
// For Gemini: reasoning details must be linkable back to the tool call.
// Sometimes OpenRouter/Gemini returns entries without `id`; those poison the next request.
// Keep only entries with an id matching the tool call id.
// See: https://github.com/cline/cline/issues/8214
const validDetails = toolDetails.filter(
(detail: any) => detail?.id === toolId,
);
if (validDetails.length > 0) {
reasoningDetails.push(...validDetails);
}
} else {
// Single reasoning detail - only include if it has matching id
const detail = toolDetails as any;
if (detail?.id === toolId) {
reasoningDetails.push(toolDetails);
}
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => {
const toolDetails = toolMessage.reasoning_details
const toolId = toolMessage.id
if (toolDetails) {
if (Array.isArray(toolDetails)) {
// For Gemini: reasoning details must be linkable back to the tool call.
// Sometimes OpenRouter/Gemini returns entries without `id`; those poison the next request.
// Keep only entries with an id matching the tool call id.
// See: https://github.com/cline/cline/issues/8214
const validDetails = toolDetails.filter((detail: any) => detail?.id === toolId)
if (validDetails.length > 0) {
reasoningDetails.push(...validDetails)
}
} else {
// Single reasoning detail - only include if it has matching id
const detail = toolDetails as any
if (detail?.id === toolId) {
reasoningDetails.push(toolDetails)
}
}
}
return {
// Use the same transformation as tool_call_id to ensure IDs match
id: transformToolCallIdForNativeApi(toolId, provider),
type: "function",
function: {
name: toolMessage.name,
// json string
arguments: JSON.stringify(toolMessage.input),
},
};
});
return {
// Use the same transformation as tool_call_id to ensure IDs match
id: transformToolCallIdForNativeApi(toolId, provider),
type: "function",
function: {
name: toolMessage.name,
// json string
arguments: JSON.stringify(toolMessage.input),
},
}
})
// Set content to blank when tool_calls are present but content has no text, per OpenAI API spec
const hasToolCalls = tool_calls.length > 0;
const hasMeaningfulContent =
content !== undefined && content.trim() !== "";
const finalContent = hasMeaningfulContent
? content
: hasToolCalls
? null
: undefined;
const hasToolCalls = tool_calls.length > 0
const hasMeaningfulContent = content !== undefined && content.trim() !== ""
const finalContent = hasMeaningfulContent ? content : hasToolCalls ? null : undefined
const consolidatedReasoningDetails =
reasoningDetails.length > 0
? consolidateReasoningDetails(reasoningDetails as any)
: [];
reasoningDetails.length > 0 ? consolidateReasoningDetails(reasoningDetails as any) : []
openAiMessages.push({
role: "assistant",
@@ -284,91 +266,86 @@ export function convertToOpenAiMessages(
tool_calls: tool_calls?.length > 0 ? tool_calls : undefined,
// Only include reasoning_details when non-empty; sending [] can trigger provider validation issues.
// @ts-expect-error
reasoning_details:
consolidatedReasoningDetails.length > 0
? consolidatedReasoningDetails
: undefined,
});
reasoning_details: consolidatedReasoningDetails.length > 0 ? consolidatedReasoningDetails : undefined,
})
}
}
}
return openAiMessages;
return openAiMessages
}
// Type for OpenRouter's reasoning detail elements
// https://openrouter.ai/docs/use-cases/reasoning-tokens#streaming-response
type ReasoningDetail = {
// https://openrouter.ai/docs/use-cases/reasoning-tokens#reasoning-detail-types
type: string; // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text"
text?: string;
data?: string; // Encrypted reasoning data
signature?: string | null;
id?: string | null; // Unique identifier for the reasoning detail
type: string // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text"
text?: string
data?: string // Encrypted reasoning data
signature?: string | null
id?: string | null // Unique identifier for the reasoning detail
/*
The format of the reasoning detail, with possible values:
"unknown" - Format is not specified
"openai-responses-v1" - OpenAI responses format version 1
"anthropic-claude-v1" - Anthropic Claude format version 1 (default)
*/
format: string; //"unknown" | "openai-responses-v1" | "anthropic-claude-v1" | "xai-responses-v1"
index?: number; // Sequential index of the reasoning detail
};
format: string //"unknown" | "openai-responses-v1" | "anthropic-claude-v1" | "xai-responses-v1"
index?: number // Sequential index of the reasoning detail
}
// Helper function to convert reasoning_details array to the format OpenRouter API expects
// Takes an array of reasoning detail objects and consolidates them by index
function consolidateReasoningDetails(
reasoningDetails: ReasoningDetail[],
): ReasoningDetail[] {
function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): ReasoningDetail[] {
if (!reasoningDetails || reasoningDetails.length === 0) {
return [];
return []
}
// Group by index
const groupedByIndex = new Map<number, ReasoningDetail[]>();
const groupedByIndex = new Map<number, ReasoningDetail[]>()
for (const detail of reasoningDetails) {
// Drop corrupted encrypted reasoning blocks that would otherwise trigger:
// "Invalid input: expected string, received undefined" for reasoning_details.*.data
// See: https://github.com/cline/cline/issues/8214
if (detail.type === "reasoning.encrypted" && !detail.data) continue;
if (detail.type === "reasoning.encrypted" && !detail.data) continue
const index = detail.index ?? 0;
const index = detail.index ?? 0
if (!groupedByIndex.has(index)) {
groupedByIndex.set(index, []);
groupedByIndex.set(index, [])
}
groupedByIndex.get(index)!.push(detail);
groupedByIndex.get(index)!.push(detail)
}
// Consolidate each group
const consolidated: ReasoningDetail[] = [];
const consolidated: ReasoningDetail[] = []
for (const [index, details] of groupedByIndex.entries()) {
// Concatenate all text parts
let concatenatedText = "";
let signature: string | undefined;
let id: string | undefined;
let format = "unknown";
let type = "reasoning.text";
let concatenatedText = ""
let signature: string | undefined
let id: string | undefined
let format = "unknown"
let type = "reasoning.text"
for (const detail of details) {
if (detail.text) {
concatenatedText += detail.text;
concatenatedText += detail.text
}
// Keep the signature from the last item that has one
if (detail.signature) {
signature = detail.signature;
signature = detail.signature
}
// Keep the id from the last item that has one
if (detail.id) {
id = detail.id;
id = detail.id
}
// Keep format and type from any item (they should all be the same)
if (detail.format) {
format = detail.format;
format = detail.format
}
if (detail.type) {
type = detail.type;
type = detail.type
}
}
@@ -381,12 +358,12 @@ function consolidateReasoningDetails(
id: id,
format: format,
index: index,
};
consolidated.push(consolidatedEntry);
}
consolidated.push(consolidatedEntry)
}
// For encrypted chunks (data), only keep the last one
let lastDataEntry: ReasoningDetail | undefined;
let lastDataEntry: ReasoningDetail | undefined
for (const detail of details) {
if (detail.data) {
lastDataEntry = {
@@ -396,25 +373,23 @@ function consolidateReasoningDetails(
id: detail.id,
format: detail.format,
index: index,
};
}
}
}
if (lastDataEntry) {
consolidated.push(lastDataEntry);
consolidated.push(lastDataEntry)
}
}
return consolidated;
return consolidated
}
// Unique name to use to filter out tool call that cannot be parsed correctly
const UNIQUE_ERROR_TOOL_NAME = "_cline_error_unknown_function_";
const UNIQUE_ERROR_TOOL_NAME = "_cline_error_unknown_function_"
// Convert OpenAI response to Anthropic format
export function convertToAnthropicMessage(
completion: OpenAI.Chat.Completions.ChatCompletion,
): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message;
export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.ChatCompletion): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message
const anthropicMessage: Anthropic.Messages.Message = {
id: completion.id,
type: "message",
@@ -430,14 +405,14 @@ export function convertToAnthropicMessage(
stop_reason: (() => {
switch (completion.choices[0].finish_reason) {
case "stop":
return "end_turn";
return "end_turn"
case "length":
return "max_tokens";
return "max_tokens"
case "tool_calls":
return "tool_use";
return "tool_use"
case "content_filter": // Anthropic doesn't have an exact equivalent
default:
return null;
return null
}
})(),
stop_sequence: null, // which custom stop_sequence was generated, if any (not applicable if you don't use stop_sequence)
@@ -446,40 +421,37 @@ export function convertToAnthropicMessage(
output_tokens: completion.usage?.completion_tokens || 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
};
}
try {
if (openAiMessage?.tool_calls?.length) {
const functionCalls = openAiMessage.tool_calls.filter(
(tc: any) => tc?.type === "function" && tc.function,
);
const functionCalls = openAiMessage.tool_calls.filter((tc: any) => tc?.type === "function" && tc.function)
if (functionCalls.length > 0) {
anthropicMessage.content.push(
...functionCalls.map((toolCall: any): Anthropic.ToolUseBlock => {
let parsedInput = {};
let parsedInput = {}
try {
parsedInput = JSON.parse(toolCall.function?.arguments || "{}");
parsedInput = JSON.parse(toolCall.function?.arguments || "{}")
} catch (error) {
Logger.error("Failed to parse tool arguments:", error);
Logger.error("Failed to parse tool arguments:", error)
}
return {
type: "tool_use",
id: toolCall.id,
name: toolCall.function?.name || UNIQUE_ERROR_TOOL_NAME,
input: parsedInput,
};
}
}),
);
)
}
return anthropicMessage;
return anthropicMessage
}
} catch (error) {
Logger.error("Error converting OpenAI message to Anthropic format:", error);
Logger.error("Error converting OpenAI message to Anthropic format:", error)
}
return anthropicMessage;
return anthropicMessage
}
/**
@@ -498,47 +470,43 @@ export function sanitizeGeminiMessages(
modelId: string,
): OpenAI.Chat.ChatCompletionMessageParam[] {
if (!modelId.includes("gemini")) {
return messages;
return messages
}
const droppedToolCallIds = new Set<string>();
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = [];
const droppedToolCallIds = new Set<string>()
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = []
for (const msg of messages) {
if (msg.role === "assistant") {
const anyMsg = msg as any;
const toolCalls = anyMsg.tool_calls;
const anyMsg = msg as any
const toolCalls = anyMsg.tool_calls
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
const reasoningDetails = anyMsg.reasoning_details;
const hasReasoningDetails =
Array.isArray(reasoningDetails) && reasoningDetails.length > 0;
const reasoningDetails = anyMsg.reasoning_details
const hasReasoningDetails = Array.isArray(reasoningDetails) && reasoningDetails.length > 0
if (!hasReasoningDetails) {
for (const tc of toolCalls) {
if (tc?.id) {
droppedToolCallIds.add(tc.id);
droppedToolCallIds.add(tc.id)
}
}
// Keep any textual content, but drop the tool_calls themselves.
if (anyMsg.content) {
sanitized.push({
role: "assistant",
content: anyMsg.content,
} as any);
sanitized.push({ role: "assistant", content: anyMsg.content } as any)
}
continue;
continue
}
}
}
if (msg.role === "tool") {
const anyMsg = msg as any;
const anyMsg = msg as any
if (anyMsg.tool_call_id && droppedToolCallIds.has(anyMsg.tool_call_id)) {
continue;
continue
}
}
sanitized.push(msg);
sanitized.push(msg)
}
return sanitized;
return sanitized
}
@@ -1,13 +1,5 @@
import type {
ResponseInput,
ResponseInputMessageContentList,
ResponseReasoningItem,
} from "openai/resources/responses/responses";
import {
type ClineStorageMessage,
getBase64ImageSource,
getImageDataUrl,
} from "@/shared/messages/content";
import { ResponseInput, ResponseInputMessageContentList, ResponseReasoningItem } from "openai/resources/responses/responses"
import { ClineStorageMessage } from "@/shared/messages/content"
/**
* Converts an array of ClineStorageMessage objects (extension of Anthropic format) to a ResponseInput array to use with OpenAI's Responses API.
@@ -83,69 +75,56 @@ export function convertToOpenAIResponsesInput(
_messages: ClineStorageMessage[],
options?: { usePreviousResponseId?: boolean },
): {
input: ResponseInput;
previousResponseId?: string;
input: ResponseInput
previousResponseId?: string
} {
// Chain from the latest stored Responses API assistant message when available.
// When chaining, only send new items after that assistant turn.
let previousResponseId: string | undefined;
let messages = _messages;
let previousResponseId: string | undefined
let messages = _messages
if (options?.usePreviousResponseId) {
for (let i = _messages.length - 1; i >= 0; i--) {
const msg = _messages[i];
const msg = _messages[i]
// Must be less than 24 hours old to be considered for chaining as the previous Id is only valid for 24 hours.
// Set to 23 hours to account for any potential delays in processing.
const isLessThan23HoursOld = msg.ts
? Date.now() - msg.ts < 23 * 60 * 60 * 1000
: false;
const isLessThan23HoursOld = msg.ts ? Date.now() - msg.ts < 23 * 60 * 60 * 1000 : false
if (msg.role === "assistant" && msg.id && isLessThan23HoursOld) {
previousResponseId = msg.id;
messages = _messages.slice(i + 1);
break;
previousResponseId = msg.id
messages = _messages.slice(i + 1)
break
}
}
}
const allItems: any[] = [];
const toolUseIdToCallId = new Map<string, string>();
const allItems: any[] = []
const toolUseIdToCallId = new Map<string, string>()
for (const m of messages) {
if (typeof m.content === "string") {
allItems.push({
role: m.role,
content: [{ type: "input_text", text: m.content }],
});
continue;
allItems.push({ role: m.role, content: [{ type: "input_text", text: m.content }] })
continue
}
if (m.role === "assistant") {
// For assistant messages, we must ensure reasoning items are IMMEDIATELY followed
// by their corresponding message or function_call. Process the entire assistant
// turn and ensure proper pairing.
const assistantItems: any[] = [];
const assistantItems: any[] = []
for (const part of m.content) {
switch (part.type) {
case "thinking": {
case "thinking":
// Only include reasoning item if it has actual content (thinking text or summary)
// Empty reasoning items cause API errors: "Item 'rs_...' of type 'reasoning' was provided without its required following item"
const hasThinkingContent =
part.thinking && part.thinking.trim().length > 0;
const hasSummaryContent =
part.summary &&
Array.isArray(part.summary) &&
part.summary.length > 0;
const hasThinkingContent = part.thinking && part.thinking.trim().length > 0
const hasSummaryContent = part.summary && Array.isArray(part.summary) && part.summary.length > 0
if (
part.call_id &&
part.call_id.length > 0 &&
(hasThinkingContent || hasSummaryContent)
) {
if (part.call_id && part.call_id.length > 0 && (hasThinkingContent || hasSummaryContent)) {
// Use summary if available, otherwise use thinking text
let summary: any[] = [];
let summary: any[] = []
if (hasSummaryContent) {
// part.summary is already in the correct format from OpenAI Responses API
summary = part.summary as any[];
summary = part.summary as any[]
} else if (hasThinkingContent) {
// Convert thinking text to summary format
summary = [
@@ -153,17 +132,16 @@ export function convertToOpenAIResponsesInput(
type: "summary_text",
text: part.thinking,
},
];
]
}
assistantItems.push({
id: part.call_id,
type: "reasoning",
summary,
} as ResponseReasoningItem);
} as ResponseReasoningItem)
}
break;
}
break
case "redacted_thinking":
// Include reasoning item with encrypted content if it has a call_id
// Even if data is missing, we need to maintain the reasoning-function_call pairing
@@ -172,115 +150,100 @@ export function convertToOpenAIResponsesInput(
id: part.call_id,
type: "reasoning",
summary: [],
};
}
// Only include encrypted_content if data exists
if (part.data) {
reasoningItem.encrypted_content = part.data;
reasoningItem.encrypted_content = part.data
}
assistantItems.push(reasoningItem as ResponseReasoningItem);
assistantItems.push(reasoningItem as ResponseReasoningItem)
}
break;
case "text": {
break
case "text":
// Message ID goes at the message level, not in the content
// The reasoning item and message can have different IDs - they just need to be adjacent
const messageItem: any = {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: part.text }],
};
}
// Set message-level id if available
if (part.call_id) {
messageItem.id = part.call_id;
messageItem.id = part.call_id
}
assistantItems.push(messageItem);
break;
}
case "image": {
assistantItems.push(messageItem)
break
case "image":
// Message ID goes at the message level, not in the content
const imageItem: any = {
type: "message",
role: "assistant",
content: [
{
type: "output_text",
text: `[image:${getBase64ImageSource(part.source).mediaType}]`,
},
],
};
content: [{ type: "output_text", text: `[image:${part.source.media_type}]` }],
}
// Set message-level id if available (though images typically don't have call_id)
if (part.call_id) {
imageItem.id = part.call_id;
imageItem.id = part.call_id
}
assistantItems.push(imageItem);
break;
}
assistantItems.push(imageItem)
break
case "tool_use": {
// Function calls use call_id, not related to reasoning item ID
const call_id = part.call_id || part.id;
const call_id = part.call_id || part.id
if (part.call_id) {
toolUseIdToCallId.set(part.id, part.call_id);
toolUseIdToCallId.set(part.id, part.call_id)
}
assistantItems.push({
type: "function_call",
call_id,
// MAX 53 characters for OpenAI Responses API tool IDs
id: !part.id.startsWith("fc_")
? `fc_${part.id.slice(0, 50)}`
: part.id,
id: !part.id.startsWith("fc_") ? `fc_${part.id.slice(0, 50)}` : part.id,
name: part.name,
arguments: JSON.stringify(part.input ?? {}),
});
break;
})
break
}
}
}
allItems.push(...assistantItems);
allItems.push(...assistantItems)
} else {
// User messages - collect all content
const messageContent: ResponseInputMessageContentList = [];
const messageContent: ResponseInputMessageContentList = []
for (const part of m.content) {
switch (part.type) {
case "text":
messageContent.push({ type: "input_text", text: part.text });
break;
messageContent.push({ type: "input_text", text: part.text })
break
case "image":
messageContent.push({
type: "input_image",
detail: "auto",
image_url: getImageDataUrl(part.source),
});
break;
image_url: `data:${part.source.media_type};base64,${part.source.data}`,
})
break
case "tool_result": {
// Flush any pending message content before adding tool result
if (messageContent.length > 0) {
allItems.push({ role: m.role, content: [...messageContent] });
messageContent.length = 0;
allItems.push({ role: m.role, content: [...messageContent] })
messageContent.length = 0
}
const call_id =
part.call_id ||
toolUseIdToCallId.get(part.tool_use_id) ||
part.tool_use_id;
const call_id = part.call_id || toolUseIdToCallId.get(part.tool_use_id) || part.tool_use_id
allItems.push({
type: "function_call_output",
call_id,
output:
typeof part.content === "string"
? part.content
: JSON.stringify(part.content),
});
break;
output: typeof part.content === "string" ? part.content : JSON.stringify(part.content),
})
break
}
}
}
// Flush any remaining user message content
if (messageContent.length > 0) {
allItems.push({ role: m.role, content: [...messageContent] });
allItems.push({ role: m.role, content: [...messageContent] })
}
}
}
return { input: allItems, previousResponseId };
return { input: allItems, previousResponseId }
}
+88 -114
View File
@@ -1,17 +1,13 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type OpenAI from "openai";
import {
type ClineAssistantThinkingBlock,
type ClineStorageMessage,
getImageDataUrl,
} from "@/shared/messages/content";
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ClineAssistantThinkingBlock, ClineStorageMessage } from "@/shared/messages/content"
/**
* DeepSeek Reasoner message format with reasoning_content support.
*/
export type DeepSeekReasonerMessage = OpenAI.Chat.ChatCompletionMessageParam & {
reasoning_content?: string;
};
reasoning_content?: string
}
/**
* Adds reasoning_content to OpenAI messages for DeepSeek Reasoner.
@@ -25,45 +21,43 @@ export function addReasoningContent(
// Find last user message index (start of current turn)
// If no user message exists (lastUserIndex = -1), all messages are in the "current turn",
// so reasoning_content will be added to all assistant messages. This is intentional.
let lastUserIndex = -1;
let lastUserIndex = -1
for (let i = openAiMessages.length - 1; i >= 0; i--) {
if (openAiMessages[i].role === "user") {
lastUserIndex = i;
break;
lastUserIndex = i
break
}
}
// Extract thinking content from original messages, keyed by assistant index
const thinkingByIndex = new Map<number, string>();
let assistantIdx = 0;
const thinkingByIndex = new Map<number, string>()
let assistantIdx = 0
for (const msg of originalMessages) {
if (msg.role === "assistant") {
if (Array.isArray(msg.content)) {
const thinking = msg.content
.filter(
(p): p is ClineAssistantThinkingBlock => p.type === "thinking",
)
.filter((p): p is ClineAssistantThinkingBlock => p.type === "thinking")
.map((p) => p.thinking)
.join("\n");
.join("\n")
if (thinking) {
thinkingByIndex.set(assistantIdx, thinking);
thinkingByIndex.set(assistantIdx, thinking)
}
}
assistantIdx++;
assistantIdx++
}
}
// Add reasoning_content only to assistant messages in current turn
let aiIdx = 0;
let aiIdx = 0
return openAiMessages.map((msg, i): DeepSeekReasonerMessage => {
if (msg.role === "assistant") {
const thinking = thinkingByIndex.get(aiIdx++);
const thinking = thinkingByIndex.get(aiIdx++)
if (thinking && i >= lastUserIndex) {
return { ...msg, reasoning_content: thinking };
return { ...msg, reasoning_content: thinking }
}
}
return msg;
});
return msg
})
}
/**
@@ -74,104 +68,84 @@ export function addReasoningContent(
* @param messages Array of Anthropic messages
* @returns Array of OpenAI messages where consecutive messages with the same role are merged together
*/
export function convertToR1Format(
messages: Anthropic.Messages.MessageParam[],
): OpenAI.Chat.ChatCompletionMessageParam[] {
return messages.reduce<OpenAI.Chat.ChatCompletionMessageParam[]>(
(merged, message) => {
const lastMessage = merged[merged.length - 1];
let messageContent:
| string
| (
| OpenAI.Chat.ChatCompletionContentPartText
| OpenAI.Chat.ChatCompletionContentPartImage
)[] = "";
let hasImages = false;
export function convertToR1Format(messages: Anthropic.Messages.MessageParam[]): OpenAI.Chat.ChatCompletionMessageParam[] {
return messages.reduce<OpenAI.Chat.ChatCompletionMessageParam[]>((merged, message) => {
const lastMessage = merged[merged.length - 1]
let messageContent: string | (OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage)[] =
""
let hasImages = false
if (Array.isArray(message.content)) {
const textParts: string[] = [];
const imageParts: OpenAI.Chat.ChatCompletionContentPartImage[] = [];
if (Array.isArray(message.content)) {
const textParts: string[] = []
const imageParts: OpenAI.Chat.ChatCompletionContentPartImage[] = []
message.content.forEach((part) => {
if (part.type === "text") {
textParts.push(part.text);
}
if (part.type === "image") {
hasImages = true;
imageParts.push({
type: "image_url",
image_url: { url: getImageDataUrl(part.source) },
});
}
});
if (hasImages) {
const parts: (
| OpenAI.Chat.ChatCompletionContentPartText
| OpenAI.Chat.ChatCompletionContentPartImage
)[] = [];
if (textParts.length > 0) {
parts.push({ type: "text", text: textParts.join("\n") });
}
parts.push(...imageParts);
messageContent = parts;
} else {
messageContent = textParts.join("\n");
message.content.forEach((part) => {
if (part.type === "text") {
textParts.push(part.text)
}
if (part.type === "image") {
hasImages = true
imageParts.push({
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
})
}
})
if (hasImages) {
const parts: (OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage)[] = []
if (textParts.length > 0) {
parts.push({ type: "text", text: textParts.join("\n") })
}
parts.push(...imageParts)
messageContent = parts
} else {
messageContent = message.content;
messageContent = textParts.join("\n")
}
} else {
messageContent = message.content
}
// If the last message has the same role, merge the content
if (lastMessage?.role === message.role) {
if (
typeof lastMessage.content === "string" &&
typeof messageContent === "string"
) {
lastMessage.content += `\n${messageContent}`;
} else {
const lastContent = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text" as const, text: lastMessage.content || "" }];
const newContent = Array.isArray(messageContent)
? messageContent
: [{ type: "text" as const, text: messageContent }];
if (message.role === "assistant") {
const mergedContent = [
...lastContent,
...newContent,
] as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"];
lastMessage.content = mergedContent;
} else {
const mergedContent = [
...lastContent,
...newContent,
] as OpenAI.Chat.ChatCompletionUserMessageParam["content"];
lastMessage.content = mergedContent;
}
}
// If the last message has the same role, merge the content
if (lastMessage?.role === message.role) {
if (typeof lastMessage.content === "string" && typeof messageContent === "string") {
lastMessage.content += `\n${messageContent}`
} else {
// Adds new message with the correct type based on role
const lastContent = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text" as const, text: lastMessage.content || "" }]
const newContent = Array.isArray(messageContent)
? messageContent
: [{ type: "text" as const, text: messageContent }]
if (message.role === "assistant") {
const newMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = {
role: "assistant",
content:
messageContent as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"],
};
merged.push(newMessage);
const mergedContent = [
...lastContent,
...newContent,
] as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"]
lastMessage.content = mergedContent
} else {
const newMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
role: "user",
content:
messageContent as OpenAI.Chat.ChatCompletionUserMessageParam["content"],
};
merged.push(newMessage);
const mergedContent = [...lastContent, ...newContent] as OpenAI.Chat.ChatCompletionUserMessageParam["content"]
lastMessage.content = mergedContent
}
}
return merged;
},
[],
);
} else {
// Adds new message with the correct type based on role
if (message.role === "assistant") {
const newMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = {
role: "assistant",
content: messageContent as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"],
}
merged.push(newMessage)
} else {
const newMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
role: "user",
content: messageContent as OpenAI.Chat.ChatCompletionUserMessageParam["content"],
}
merged.push(newMessage)
}
}
return merged
}, [])
}
@@ -1,6 +1,6 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import * as vscode from "vscode";
import { Logger } from "@/shared/services/Logger";
import { Anthropic } from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import { Logger } from "@/shared/services/Logger"
/**
* Safely converts a value into a plain object.
@@ -8,31 +8,31 @@ import { Logger } from "@/shared/services/Logger";
export function asObjectSafe(value: any): object {
// Handle null/undefined
if (!value) {
return {};
return {}
}
try {
// Handle strings that might be JSON
if (typeof value === "string") {
return JSON.parse(value);
return JSON.parse(value)
}
// Handle pre-existing objects
if (typeof value === "object") {
return Object.assign({}, value);
return Object.assign({}, value)
}
return {};
return {}
} catch (error) {
Logger.warn("Cline <Language Model API>: Failed to parse object:", error);
return {};
Logger.warn("Cline <Language Model API>: Failed to parse object:", error)
return {}
}
}
export function convertToVsCodeLmMessages(
anthropicMessages: Anthropic.Messages.MessageParam[],
): vscode.LanguageModelChatMessage[] {
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [];
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = []
for (const anthropicMessage of anthropicMessages) {
// Handle simple string messages
@@ -41,31 +41,27 @@ export function convertToVsCodeLmMessages(
anthropicMessage.role === "assistant"
? vscode.LanguageModelChatMessage.Assistant(anthropicMessage.content)
: vscode.LanguageModelChatMessage.User(anthropicMessage.content),
);
continue;
)
continue
}
// Handle complex message structures
switch (anthropicMessage.role) {
case "user": {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
)[];
toolMessages: Anthropic.ToolResultBlockParam[];
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
}
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolResultBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool messages first then non-tool messages
const contentParts = [
@@ -78,55 +74,46 @@ export function convertToVsCodeLmMessages(
: (toolMessage.content?.map((part) => {
if (part.type === "image") {
return new vscode.LanguageModelTextPart(
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
);
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
)
}
return new vscode.LanguageModelTextPart(part.text);
}) ?? [new vscode.LanguageModelTextPart("")]);
return new vscode.LanguageModelTextPart(part.text)
}) ?? [new vscode.LanguageModelTextPart("")])
return new vscode.LanguageModelToolResultPart(
toolMessage.tool_use_id,
toolContentParts,
);
return new vscode.LanguageModelToolResultPart(toolMessage.tool_use_id, toolContentParts)
}),
// Convert non-tool messages to TextParts after tool messages
...nonToolMessages.map((part) => {
if (part.type === "image") {
return new vscode.LanguageModelTextPart(
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
);
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
)
}
return new vscode.LanguageModelTextPart(part.text);
return new vscode.LanguageModelTextPart(part.text)
}),
];
]
// Add single user message with all content parts
vsCodeLmMessages.push(
vscode.LanguageModelChatMessage.User(contentParts),
);
break;
vsCodeLmMessages.push(vscode.LanguageModelChatMessage.User(contentParts))
break
}
case "assistant": {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
)[];
toolMessages: Anthropic.ToolUseBlockParam[];
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
}
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool messages first then non-tool messages
const contentParts = [
@@ -143,24 +130,20 @@ export function convertToVsCodeLmMessages(
// Convert non-tool messages to TextParts after tool messages
...nonToolMessages.map((part) => {
if (part.type === "image") {
return new vscode.LanguageModelTextPart(
"[Image generation not supported by VSCode LM API]",
);
return new vscode.LanguageModelTextPart("[Image generation not supported by VSCode LM API]")
}
return new vscode.LanguageModelTextPart(part.text);
return new vscode.LanguageModelTextPart(part.text)
}),
];
]
// Add the assistant message to the list of messages
vsCodeLmMessages.push(
vscode.LanguageModelChatMessage.Assistant(contentParts),
);
break;
vsCodeLmMessages.push(vscode.LanguageModelChatMessage.Assistant(contentParts))
break
}
}
}
return vsCodeLmMessages;
return vsCodeLmMessages
}
export function convertToAnthropicRole(
@@ -168,22 +151,18 @@ export function convertToAnthropicRole(
): Anthropic.Messages.MessageParam["role"] | null {
switch (vsCodeLmMessageRole) {
case vscode.LanguageModelChatMessageRole.Assistant:
return "assistant";
return "assistant"
case vscode.LanguageModelChatMessageRole.User:
return "user";
return "user"
default:
return null;
return null
}
}
export function convertToAnthropicMessage(
vsCodeLmMessage: vscode.LanguageModelChatMessage,
): Anthropic.Messages.Message {
const anthropicRole = convertToAnthropicRole(vsCodeLmMessage.role);
export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelChatMessage): Anthropic.Messages.Message {
const anthropicRole = convertToAnthropicRole(vsCodeLmMessage.role)
if (anthropicRole !== "assistant") {
throw new Error(
"Cline <Language Model API>: Only assistant messages are supported.",
);
throw new Error("Cline <Language Model API>: Only assistant messages are supported.")
}
return {
@@ -198,7 +177,7 @@ export function convertToAnthropicMessage(
type: "text",
text: part.value,
citations: null,
};
}
}
if (part instanceof vscode.LanguageModelToolCallPart) {
@@ -207,10 +186,10 @@ export function convertToAnthropicMessage(
id: part.callId || crypto.randomUUID(),
name: part.name,
input: asObjectSafe(part.input),
};
}
}
return null;
return null
})
.filter((part): part is Anthropic.ContentBlock => part !== null),
stop_reason: null,
@@ -220,7 +199,6 @@ export function convertToAnthropicMessage(
output_tokens: 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
};
}
}
+86 -132
View File
@@ -1,33 +1,33 @@
import { findLastIndex } from "@shared/array";
import type { ClineMessage } from "@shared/ExtensionMessage";
import type { ClineStorageMessage } from "@shared/messages/content";
import { Logger } from "@/shared/services/Logger";
import type { ContextManager } from "../context/context-management/ContextManager";
import type { MessageStateHandler } from "../task/message-state";
import type { HookModelInputContext } from "./hook-factory";
import { findLastIndex } from "@shared/array"
import type { ClineMessage } from "@shared/ExtensionMessage"
import type { ClineStorageMessage } from "@shared/messages/content"
import { Logger } from "@/shared/services/Logger"
import type { ContextManager } from "../context/context-management/ContextManager"
import type { MessageStateHandler } from "../task/message-state"
import type { HookModelInputContext } from "./hook-factory"
/**
* Active hook execution state
* Represents a hook process that is currently running
*/
export type HookExecution = {
hookName: string;
toolName?: string;
messageTs: number;
abortController: AbortController;
};
hookName: string
toolName?: string
messageTs: number
abortController: AbortController
}
/**
* Custom error class for hook cancellation
* Used to signal that a hook cancelled an operation
*/
export class HookCancellationError extends Error {
public readonly wasCancelled: boolean;
public readonly wasCancelled: boolean
constructor(wasCancelled: boolean) {
super("Hook cancelled the operation");
this.name = "HookCancellationError";
this.wasCancelled = wasCancelled;
super("Hook cancelled the operation")
this.name = "HookCancellationError"
this.wasCancelled = wasCancelled
}
}
@@ -35,10 +35,10 @@ export class HookCancellationError extends Error {
* Token usage information extracted from an API request message
*/
export interface TokenUsage {
tokensIn: number;
tokensOut: number;
tokensInCache: number;
tokensOutCache: number;
tokensIn: number
tokensOut: number
tokensInCache: number
tokensOutCache: number
}
/**
@@ -46,34 +46,29 @@ export interface TokenUsage {
* @param message The API request message to parse
* @returns Token usage information, or zeros if parsing fails
*/
export function extractTokenUsageFromMessage(
message: ClineMessage | undefined,
): TokenUsage {
export function extractTokenUsageFromMessage(message: ClineMessage | undefined): TokenUsage {
const defaultUsage: TokenUsage = {
tokensIn: 0,
tokensOut: 0,
tokensInCache: 0,
tokensOutCache: 0,
};
}
if (!message?.text) {
return defaultUsage;
return defaultUsage
}
try {
const apiReqInfo = JSON.parse(message.text);
const apiReqInfo = JSON.parse(message.text)
return {
tokensIn: apiReqInfo.tokensIn || 0,
tokensOut: apiReqInfo.tokensOut || 0,
tokensInCache: apiReqInfo.cacheWrites || 0,
tokensOutCache: apiReqInfo.cacheReads || 0,
};
}
} catch (error) {
Logger.error(
"[PreCompact] Failed to parse API request token usage:",
error,
);
return defaultUsage;
Logger.error("[PreCompact] Failed to parse API request token usage:", error)
return defaultUsage
}
}
@@ -81,9 +76,9 @@ export function extractTokenUsageFromMessage(
* Context files written for hook access
*/
export interface PreCompactContextFiles {
contextJsonPath: string;
contextRawPath: string;
hookTimestamp: number;
contextJsonPath: string
contextRawPath: string
hookTimestamp: number
}
/**
@@ -96,32 +91,23 @@ export async function writePreCompactContextFiles(
taskId: string,
currentContext: ClineStorageMessage[],
): Promise<PreCompactContextFiles> {
const { writeConversationHistoryJson, writeConversationHistoryText } =
await import("../storage/disk");
const { writeConversationHistoryJson, writeConversationHistoryText } = await import("../storage/disk")
// Generate single timestamp for both files to ensure they match
const hookTimestamp = Date.now();
const hookTimestamp = Date.now()
// Write context files for hook access
const contextJsonPath = await writeConversationHistoryJson(
taskId,
currentContext,
hookTimestamp,
);
const contextRawPath = await writeConversationHistoryText(
taskId,
currentContext,
hookTimestamp,
);
const contextJsonPath = await writeConversationHistoryJson(taskId, currentContext, hookTimestamp)
const contextRawPath = await writeConversationHistoryText(taskId, currentContext, hookTimestamp)
return { contextJsonPath, contextRawPath, hookTimestamp };
return { contextJsonPath, contextRawPath, hookTimestamp }
}
/**
* Task state interface for cancellation handling
*/
export interface TaskStateForCancellation {
didFinishAbortingStream: boolean;
didFinishAbortingStream: boolean
}
/**
@@ -131,61 +117,53 @@ export interface TaskStateForCancellation {
export interface PreCompactHookParams {
// Task identification
/** Task identifier */
taskId: string;
taskId: string
/** ULID for telemetry */
ulid: string;
ulid: string
/** Active hook model context */
modelContext: HookModelInputContext;
modelContext: HookModelInputContext
// Conversation state
/** API conversation history */
apiConversationHistory: ClineStorageMessage[];
apiConversationHistory: ClineStorageMessage[]
/** Current deleted range (if any) */
conversationHistoryDeletedRange?: [number, number];
conversationHistoryDeletedRange?: [number, number]
/** Cline messages for extracting token usage */
clineMessages: ClineMessage[];
clineMessages: ClineMessage[]
// Services
/** Context manager for getting truncated messages */
contextManager: ContextManager;
contextManager: ContextManager
/** Message state handler for accessing conversation data */
messageStateHandler: MessageStateHandler;
messageStateHandler: MessageStateHandler
// Compaction metadata
/** Compaction strategy to report in hook data */
compactionStrategy: string;
compactionStrategy: string
/** Optional: Pre-calculated deleted range to report */
deletedRange?: [number, number];
deletedRange?: [number, number]
// UI callbacks
/** Callback to display messages */
say: (
type: any,
text?: string,
images?: string[],
files?: string[],
partial?: boolean,
) => Promise<number | undefined>;
say: (type: any, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
/** Callback to save state and post to webview */
postStateToWebview: () => Promise<void>;
postStateToWebview: () => Promise<void>
// Hook management callbacks
/** Callback to set active hook execution */
setActiveHookExecution: (
hookExecution: HookExecution | undefined,
) => Promise<void>;
setActiveHookExecution: (hookExecution: HookExecution | undefined) => Promise<void>
/** Callback to clear active hook execution */
clearActiveHookExecution: () => Promise<void>;
clearActiveHookExecution: () => Promise<void>
// Cancellation dependencies
/** Task state object for setting abort flag */
taskState: TaskStateForCancellation;
taskState: TaskStateForCancellation
/** Callback to cancel the task */
cancelTask: () => Promise<void>;
cancelTask: () => Promise<void>
// Configuration
/** Whether hooks are enabled */
hooksEnabled: boolean;
hooksEnabled: boolean
}
/**
@@ -193,7 +171,7 @@ export interface PreCompactHookParams {
*/
export interface PreCompactHookResult {
/** Context modification provided by the hook */
contextModification?: string;
contextModification?: string
}
/**
@@ -206,52 +184,37 @@ export interface PreCompactHookResult {
* @throws HookCancellationError if the hook cancels the operation
* @throws Re-throws other errors after cleanup (caller should handle gracefully)
*/
export async function executePreCompactHookWithCleanup(
params: PreCompactHookParams,
): Promise<PreCompactHookResult> {
const { executeHook } = await import("./hook-executor");
const { cleanupConversationHistoryFile } = await import("../storage/disk");
export async function executePreCompactHookWithCleanup(params: PreCompactHookParams): Promise<PreCompactHookResult> {
const { executeHook } = await import("./hook-executor")
const { cleanupConversationHistoryFile } = await import("../storage/disk")
let contextJsonPath: string | undefined;
let contextRawPath: string | undefined;
let contextJsonPath: string | undefined
let contextRawPath: string | undefined
try {
// Get current active context (respects previous compactions).
// getTruncatedMessages types its output as Anthropic.MessageParam[], but it slices the Cline-stored
// conversation history (ClineStorageMessage[]) passed in, so narrow it back here.
// Get current active context (respects previous compactions)
const currentContext = params.contextManager.getTruncatedMessages(
params.apiConversationHistory,
params.conversationHistoryDeletedRange,
) as ClineStorageMessage[];
)
// Write context files for hook access
const contextFiles = await writePreCompactContextFiles(
params.taskId,
currentContext,
);
contextJsonPath = contextFiles.contextJsonPath;
contextRawPath = contextFiles.contextRawPath;
const contextFiles = await writePreCompactContextFiles(params.taskId, currentContext)
contextJsonPath = contextFiles.contextJsonPath
contextRawPath = contextFiles.contextRawPath
// Extract token usage from the most recent API request
const previousApiReqIndex = findLastIndex(
params.clineMessages,
(m) => m.say === "api_req_started",
);
const previousRequest =
previousApiReqIndex !== -1
? params.clineMessages[previousApiReqIndex]
: undefined;
const { tokensIn, tokensOut, tokensInCache, tokensOutCache } =
extractTokenUsageFromMessage(previousRequest);
const previousApiReqIndex = findLastIndex(params.clineMessages, (m) => m.say === "api_req_started")
const previousRequest = previousApiReqIndex !== -1 ? params.clineMessages[previousApiReqIndex] : undefined
const { tokensIn, tokensOut, tokensInCache, tokensOutCache } = extractTokenUsageFromMessage(previousRequest)
// Extract truncation range - use provided range or extract from conversationHistoryDeletedRange
let deletedRangeStart = 0;
let deletedRangeEnd = 0;
let deletedRangeStart = 0
let deletedRangeEnd = 0
if (params.deletedRange) {
[deletedRangeStart, deletedRangeEnd] = params.deletedRange;
;[deletedRangeStart, deletedRangeEnd] = params.deletedRange
} else if (params.conversationHistoryDeletedRange) {
[deletedRangeStart, deletedRangeEnd] =
params.conversationHistoryDeletedRange;
;[deletedRangeStart, deletedRangeEnd] = params.conversationHistoryDeletedRange
}
// Execute the hook
@@ -282,62 +245,53 @@ export async function executePreCompactHookWithCleanup(
taskId: params.taskId,
hooksEnabled: params.hooksEnabled,
model: params.modelContext,
});
})
// Handle cancellation from hook
if (preCompactResult.cancel === true) {
// Log cancellation for debugging
const cancellationSource = preCompactResult.wasCancelled
? "user"
: "PreCompact hook";
Logger.log(
`[PreCompact] Context compaction cancelled by ${cancellationSource} for task ${params.taskId}`,
);
const cancellationSource = preCompactResult.wasCancelled ? "user" : "PreCompact hook"
Logger.log(`[PreCompact] Context compaction cancelled by ${cancellationSource} for task ${params.taskId}`)
// Internalized cancellation state management (replaces handleCancellation callback)
// Always save state before cancelling, regardless of cancellation source
params.taskState.didFinishAbortingStream = true;
await params.messageStateHandler.saveClineMessagesAndUpdateHistory();
params.taskState.didFinishAbortingStream = true
await params.messageStateHandler.saveClineMessagesAndUpdateHistory()
await params.messageStateHandler.overwriteApiConversationHistory(
params.messageStateHandler.getApiConversationHistory(),
);
await params.postStateToWebview();
)
await params.postStateToWebview()
// Trigger full cancellation flow
await params.cancelTask();
await params.cancelTask()
// Throw error to signal cancellation to caller
throw new HookCancellationError(preCompactResult.wasCancelled);
throw new HookCancellationError(preCompactResult.wasCancelled)
}
// Hook completed successfully - log if context modification provided
if (preCompactResult.contextModification) {
Logger.log(
`[PreCompact] Hook provided context modification for task ${params.taskId}`,
);
Logger.log(`[PreCompact] Hook provided context modification for task ${params.taskId}`)
}
return {
contextModification: preCompactResult.contextModification,
};
}
} catch (error) {
// Re-throw error for caller to handle
throw error;
throw error
} finally {
// Clean up temporary files - always executed regardless of success or error
// Wrap in try-catch to prevent cleanup failures from masking original errors
try {
if (contextJsonPath) {
await cleanupConversationHistoryFile(contextJsonPath);
await cleanupConversationHistoryFile(contextJsonPath)
}
if (contextRawPath) {
await cleanupConversationHistoryFile(contextRawPath);
await cleanupConversationHistoryFile(contextRawPath)
}
} catch (cleanupError) {
Logger.error(
"[PreCompact] Failed to cleanup context files:",
cleanupError,
);
Logger.error("[PreCompact] Failed to cleanup context files:", cleanupError)
// Don't throw - cleanup failure shouldn't mask original error
}
}
+237 -359
View File
@@ -1,26 +1,22 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type {
EnvironmentMetadataEntry,
TaskMetadata,
} from "@core/context/context-tracking/ContextTrackerTypes";
import { execa } from "@packages/execa";
import type { ClineMessage } from "@shared/ExtensionMessage";
import type { HistoryItem } from "@shared/HistoryItem";
import type { RemoteConfig } from "@shared/remote-config/schema";
import type { GlobalState, Settings } from "@shared/storage/state-keys";
import { fileExistsAtPath, isDirectory } from "@utils/fs";
import fs from "fs/promises";
import os from "os";
import * as path from "path";
import { HostProvider } from "@/hosts/host-provider";
import { ExtensionRegistryInfo } from "@/registry";
import { telemetryService } from "@/services/telemetry";
import type { McpMarketplaceCatalog } from "@/shared/mcp";
import type { ClineStorageMessage } from "@/shared/messages/content";
import { Logger } from "@/shared/services/Logger";
import { syncWorker } from "@/shared/services/worker/sync";
import { reconstructTaskHistory } from "../commands/reconstructTaskHistory";
import { StateManager } from "./StateManager";
import { Anthropic } from "@anthropic-ai/sdk"
import { EnvironmentMetadataEntry, TaskMetadata } from "@core/context/context-tracking/ContextTrackerTypes"
import { execa } from "@packages/execa"
import { ClineMessage } from "@shared/ExtensionMessage"
import { HistoryItem } from "@shared/HistoryItem"
import { RemoteConfig } from "@shared/remote-config/schema"
import { GlobalState, Settings } from "@shared/storage/state-keys"
import { fileExistsAtPath, isDirectory } from "@utils/fs"
import fs from "fs/promises"
import os from "os"
import * as path from "path"
import { HostProvider } from "@/hosts/host-provider"
import { ExtensionRegistryInfo } from "@/registry"
import { telemetryService } from "@/services/telemetry"
import { McpMarketplaceCatalog } from "@/shared/mcp"
import { Logger } from "@/shared/services/Logger"
import { syncWorker } from "@/shared/services/worker/sync"
import { reconstructTaskHistory } from "../commands/reconstructTaskHistory"
import { StateManager } from "./StateManager"
/**
* Atomically write data to a file using temp file + rename pattern.
@@ -32,16 +28,16 @@ import { StateManager } from "./StateManager";
* @param data - The data to write
*/
async function atomicWriteFile(filePath: string, data: string): Promise<void> {
const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).substring(7)}.json`;
const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).substring(7)}.json`
try {
// Write to temporary file first
await fs.writeFile(tmpPath, data, "utf8");
await fs.writeFile(tmpPath, data, "utf8")
// Rename temp file to target (atomic in most cases)
await fs.rename(tmpPath, filePath);
await fs.rename(tmpPath, filePath)
} catch (error) {
// Clean up temp file if it exists
fs.unlink(tmpPath).catch(() => {});
throw error;
fs.unlink(tmpPath).catch(() => {})
throw error
}
}
@@ -71,7 +67,7 @@ export const GlobalFileNames = {
taskMetadata: "task_metadata.json",
mcpMarketplaceCatalog: "mcp_marketplace_catalog.json",
remoteConfig: (orgId: string) => `remote_config_${orgId}.json`,
};
}
export async function getDocumentsPath(): Promise<string> {
if (process.platform === "win32") {
@@ -80,37 +76,33 @@ export async function getDocumentsPath(): Promise<string> {
"-NoProfile", // Ignore user's PowerShell profile(s)
"-Command",
"[System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::MyDocuments)",
]);
const trimmedPath = docsPath.trim();
])
const trimmedPath = docsPath.trim()
if (trimmedPath) {
return trimmedPath;
return trimmedPath
}
} catch (_err) {
Logger.error(
"Failed to retrieve Windows Documents path. Falling back to homedir/Documents.",
);
Logger.error("Failed to retrieve Windows Documents path. Falling back to homedir/Documents.")
}
} else if (process.platform === "linux") {
try {
// First check if xdg-user-dir exists
await execa("which", ["xdg-user-dir"]);
await execa("which", ["xdg-user-dir"])
// If it exists, try to get XDG documents path
const { stdout } = await execa("xdg-user-dir", ["DOCUMENTS"]);
const trimmedPath = stdout.trim();
const { stdout } = await execa("xdg-user-dir", ["DOCUMENTS"])
const trimmedPath = stdout.trim()
if (trimmedPath) {
return trimmedPath;
return trimmedPath
}
} catch {
// Log error but continue to fallback
Logger.error(
"Failed to retrieve XDG Documents path. Falling back to homedir/Documents.",
);
Logger.error("Failed to retrieve XDG Documents path. Falling back to homedir/Documents.")
}
}
// Default fallback for all platforms
return path.join(os.homedir(), "Documents");
return path.join(os.homedir(), "Documents")
}
/**
@@ -123,68 +115,66 @@ export async function getDocumentsPath(): Promise<string> {
* This is intended to eventually replace ~/Documents/Cline as the global config location.
*/
export function getClineHomePath(): string {
return path.join(os.homedir(), ".cline");
return path.join(os.homedir(), ".cline")
}
export async function ensureTaskDirectoryExists(
taskId: string,
): Promise<string> {
return getGlobalStorageDir("tasks", taskId);
export async function ensureTaskDirectoryExists(taskId: string): Promise<string> {
return getGlobalStorageDir("tasks", taskId)
}
export async function ensureRulesDirectoryExists(): Promise<string> {
const userDocumentsPath = await getDocumentsPath();
const clineRulesDir = path.join(userDocumentsPath, "Cline", "Rules");
const userDocumentsPath = await getDocumentsPath()
const clineRulesDir = path.join(userDocumentsPath, "Cline", "Rules")
try {
await fs.mkdir(clineRulesDir, { recursive: true });
await fs.mkdir(clineRulesDir, { recursive: true })
} catch (_error) {
return path.join(os.homedir(), "Documents", "Cline", "Rules"); // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
return path.join(os.homedir(), "Documents", "Cline", "Rules") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
}
return clineRulesDir;
return clineRulesDir
}
export async function ensureWorkflowsDirectoryExists(): Promise<string> {
const userDocumentsPath = await getDocumentsPath();
const clineWorkflowsDir = path.join(userDocumentsPath, "Cline", "Workflows");
const userDocumentsPath = await getDocumentsPath()
const clineWorkflowsDir = path.join(userDocumentsPath, "Cline", "Workflows")
try {
await fs.mkdir(clineWorkflowsDir, { recursive: true });
await fs.mkdir(clineWorkflowsDir, { recursive: true })
} catch (_error) {
return path.join(os.homedir(), "Documents", "Cline", "Workflows"); // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
return path.join(os.homedir(), "Documents", "Cline", "Workflows") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
}
return clineWorkflowsDir;
return clineWorkflowsDir
}
export async function ensureMcpServersDirectoryExists(): Promise<string> {
const userDocumentsPath = await getDocumentsPath();
const mcpServersDir = path.join(userDocumentsPath, "Cline", "MCP");
const userDocumentsPath = await getDocumentsPath()
const mcpServersDir = path.join(userDocumentsPath, "Cline", "MCP")
try {
await fs.mkdir(mcpServersDir, { recursive: true });
await fs.mkdir(mcpServersDir, { recursive: true })
} catch (_error) {
return path.join(os.homedir(), "Documents", "Cline", "MCP"); // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
return path.join(os.homedir(), "Documents", "Cline", "MCP") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine since this path is only ever used in the system prompt
}
return mcpServersDir;
return mcpServersDir
}
export async function ensureHooksDirectoryExists(): Promise<string> {
const userDocumentsPath = await getDocumentsPath();
const clineHooksDir = path.join(userDocumentsPath, "Cline", "Hooks");
const userDocumentsPath = await getDocumentsPath()
const clineHooksDir = path.join(userDocumentsPath, "Cline", "Hooks")
try {
await fs.mkdir(clineHooksDir, { recursive: true });
await fs.mkdir(clineHooksDir, { recursive: true })
} catch (_error) {
return path.join(os.homedir(), "Documents", "Cline", "Hooks"); // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
return path.join(os.homedir(), "Documents", "Cline", "Hooks") // in case creating a directory in documents fails for whatever reason (e.g. permissions) - this is fine because we will fail gracefully with a path that does not exist
}
return clineHooksDir;
return clineHooksDir
}
/**
* Returns the global skills directory path (~/.cline/skills) without creating it.
*/
function getClineSkillsDirectoryPath(): string {
return path.join(getClineHomePath(), "skills");
return path.join(getClineHomePath(), "skills")
}
function getAgentSkillsDirectoryPath(): string {
return path.join(os.homedir(), ".agents", "skills");
return path.join(os.homedir(), ".agents", "skills")
}
/**
@@ -192,55 +182,41 @@ function getAgentSkillsDirectoryPath(): string {
* Creates the directory if it doesn't exist.
* This is the opinionated location for new global skills.
*/
export async function ensureAgentSkillsDirectoryExists(options: {
isGlobal: boolean;
workspacePath?: string;
}): Promise<string> {
export async function ensureAgentSkillsDirectoryExists(options: { isGlobal: boolean; workspacePath?: string }): Promise<string> {
const agentSkillsDir = options.isGlobal
? getAgentSkillsDirectoryPath()
: path.join(options.workspacePath ?? "", GlobalFileNames.agentsSkillsDir);
: path.join(options.workspacePath ?? "", GlobalFileNames.agentsSkillsDir)
try {
await fs.mkdir(agentSkillsDir, { recursive: true });
await fs.mkdir(agentSkillsDir, { recursive: true })
} catch (_error) {
// Fallback - return the path even if mkdir fails, we'll fail gracefully later
return agentSkillsDir;
return agentSkillsDir
}
return agentSkillsDir;
return agentSkillsDir
}
export type SkillsScanDirectory = {
path: string;
source: "project" | "global";
};
path: string
source: "project" | "global"
}
/**
* Returns the list of skills directories to scan without creating them.
* Order is project directories first, then global directories.
*/
export function getSkillsDirectoriesForScan(
cwd: string,
): SkillsScanDirectory[] {
export function getSkillsDirectoriesForScan(cwd: string): SkillsScanDirectory[] {
return [
{
path: path.join(cwd, GlobalFileNames.clineruleSkillsDir),
source: "project",
},
{ path: path.join(cwd, GlobalFileNames.clineruleSkillsDir), source: "project" },
{ path: path.join(cwd, GlobalFileNames.clineSkillsDir), source: "project" },
{
path: path.join(cwd, GlobalFileNames.claudeSkillsDir),
source: "project",
},
{
path: path.join(cwd, GlobalFileNames.agentsSkillsDir),
source: "project",
},
{ path: path.join(cwd, GlobalFileNames.claudeSkillsDir), source: "project" },
{ path: path.join(cwd, GlobalFileNames.agentsSkillsDir), source: "project" },
{ path: getClineSkillsDirectoryPath(), source: "global" },
{ path: getAgentSkillsDirectoryPath(), source: "global" },
];
]
}
export async function ensureSettingsDirectoryExists(): Promise<string> {
return getGlobalStorageDir("settings");
return getGlobalStorageDir("settings")
}
/**
@@ -248,93 +224,63 @@ export async function ensureSettingsDirectoryExists(): Promise<string> {
* @param settingsDirectoryPath Path to the settings directory
* @returns Path to the MCP settings file
*/
export async function getMcpSettingsFilePath(
settingsDirectoryPath: string,
): Promise<string> {
const mcpSettingsFilePath = path.join(
settingsDirectoryPath,
GlobalFileNames.mcpSettings,
);
const fileExists = await fileExistsAtPath(mcpSettingsFilePath);
export async function getMcpSettingsFilePath(settingsDirectoryPath: string): Promise<string> {
const mcpSettingsFilePath = path.join(settingsDirectoryPath, GlobalFileNames.mcpSettings)
const fileExists = await fileExistsAtPath(mcpSettingsFilePath)
if (!fileExists) {
await fs.writeFile(
mcpSettingsFilePath,
JSON.stringify({ mcpServers: {} }, null, 2),
);
await fs.writeFile(mcpSettingsFilePath, JSON.stringify({ mcpServers: {} }, null, 2))
}
return mcpSettingsFilePath;
return mcpSettingsFilePath
}
export async function getSavedApiConversationHistory(
taskId: string,
): Promise<ClineStorageMessage[]> {
const filePath = path.join(
await ensureTaskDirectoryExists(taskId),
GlobalFileNames.apiConversationHistory,
);
const fileExists = await fileExistsAtPath(filePath);
export async function getSavedApiConversationHistory(taskId: string): Promise<Anthropic.MessageParam[]> {
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.apiConversationHistory)
const fileExists = await fileExistsAtPath(filePath)
if (fileExists) {
return JSON.parse(await fs.readFile(filePath, "utf8"));
return JSON.parse(await fs.readFile(filePath, "utf8"))
}
return [];
return []
}
export async function saveApiConversationHistory(
taskId: string,
apiConversationHistory: Anthropic.MessageParam[],
) {
export async function saveApiConversationHistory(taskId: string, apiConversationHistory: Anthropic.MessageParam[]) {
try {
if (apiConversationHistory.length > 0) {
const fileName = GlobalFileNames.apiConversationHistory;
const data = JSON.stringify(apiConversationHistory);
const fileName = GlobalFileNames.apiConversationHistory
const data = JSON.stringify(apiConversationHistory)
// Queue for remote sync without blocking
syncWorker().enqueue(taskId, fileName, data);
syncWorker().enqueue(taskId, fileName, data)
// Store locally
const filePath = path.join(
await ensureTaskDirectoryExists(taskId),
fileName,
);
await atomicWriteFile(filePath, data);
const filePath = path.join(await ensureTaskDirectoryExists(taskId), fileName)
await atomicWriteFile(filePath, data)
}
} catch (error) {
// in the off chance this fails, we don't want to stop the task
Logger.error("Failed to save API conversation history:", error);
Logger.error("Failed to save API conversation history:", error)
}
}
export async function getSavedClineMessages(
taskId: string,
): Promise<ClineMessage[]> {
const filePath = path.join(
await ensureTaskDirectoryExists(taskId),
GlobalFileNames.uiMessages,
);
export async function getSavedClineMessages(taskId: string): Promise<ClineMessage[]> {
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.uiMessages)
if (await fileExistsAtPath(filePath)) {
return JSON.parse(await fs.readFile(filePath, "utf8"));
return JSON.parse(await fs.readFile(filePath, "utf8"))
}
// check old location
const oldPath = path.join(
await ensureTaskDirectoryExists(taskId),
"claude_messages.json",
);
const oldPath = path.join(await ensureTaskDirectoryExists(taskId), "claude_messages.json")
if (await fileExistsAtPath(oldPath)) {
const data = JSON.parse(await fs.readFile(oldPath, "utf8"));
await fs.unlink(oldPath); // remove old file
return data;
const data = JSON.parse(await fs.readFile(oldPath, "utf8"))
await fs.unlink(oldPath) // remove old file
return data
}
return [];
return []
}
export async function saveClineMessages(
taskId: string,
uiMessages: ClineMessage[],
) {
export async function saveClineMessages(taskId: string, uiMessages: ClineMessage[]) {
try {
const taskDir = await ensureTaskDirectoryExists(taskId);
const filePath = path.join(taskDir, GlobalFileNames.uiMessages);
await atomicWriteFile(filePath, JSON.stringify(uiMessages));
const taskDir = await ensureTaskDirectoryExists(taskId)
const filePath = path.join(taskDir, GlobalFileNames.uiMessages)
await atomicWriteFile(filePath, JSON.stringify(uiMessages))
} catch (error) {
Logger.error("Failed to save ui messages:", error);
Logger.error("Failed to save ui messages:", error)
}
}
@@ -343,11 +289,9 @@ export async function saveClineMessages(
* This information is used for debugging and task portability.
* Returns metadata without timestamp - timestamp is added by EnvironmentContextTracker.
*/
export async function collectEnvironmentMetadata(): Promise<
Omit<EnvironmentMetadataEntry, "ts">
> {
export async function collectEnvironmentMetadata(): Promise<Omit<EnvironmentMetadataEntry, "ts">> {
try {
const hostVersion = await HostProvider.env.getHostVersion({});
const hostVersion = await HostProvider.env.getHostVersion({})
return {
os_name: os.platform(),
@@ -356,9 +300,9 @@ export async function collectEnvironmentMetadata(): Promise<
host_name: hostVersion.platform || "Unknown",
host_version: hostVersion.version || "Unknown",
cline_version: ExtensionRegistryInfo.version,
};
}
} catch (error) {
Logger.error("Failed to collect environment metadata:", error);
Logger.error("Failed to collect environment metadata:", error)
// Return fallback values if collection fails
return {
os_name: os.platform(),
@@ -367,245 +311,191 @@ export async function collectEnvironmentMetadata(): Promise<
host_name: "Unknown",
host_version: "Unknown",
cline_version: "Unknown",
};
}
}
}
export async function getTaskMetadata(taskId: string): Promise<TaskMetadata> {
const filePath = path.join(
await ensureTaskDirectoryExists(taskId),
GlobalFileNames.taskMetadata,
);
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.taskMetadata)
try {
if (await fileExistsAtPath(filePath)) {
return JSON.parse(await fs.readFile(filePath, "utf8"));
return JSON.parse(await fs.readFile(filePath, "utf8"))
}
} catch (error) {
Logger.error("Failed to read task metadata:", error);
Logger.error("Failed to read task metadata:", error)
}
return { files_in_context: [], model_usage: [], environment_history: [] };
return { files_in_context: [], model_usage: [], environment_history: [] }
}
export async function saveTaskMetadata(taskId: string, metadata: TaskMetadata) {
try {
const taskDir = await ensureTaskDirectoryExists(taskId);
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata);
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2));
const taskDir = await ensureTaskDirectoryExists(taskId)
const filePath = path.join(taskDir, GlobalFileNames.taskMetadata)
await fs.writeFile(filePath, JSON.stringify(metadata, null, 2))
} catch (error) {
Logger.error("Failed to save task metadata:", error);
Logger.error("Failed to save task metadata:", error)
}
}
export async function ensureStateDirectoryExists(): Promise<string> {
return getGlobalStorageDir("state");
return getGlobalStorageDir("state")
}
export async function ensureCacheDirectoryExists(): Promise<string> {
return getGlobalStorageDir("cache");
return getGlobalStorageDir("cache")
}
export async function readMcpMarketplaceCatalogFromCache(): Promise<
McpMarketplaceCatalog | undefined
> {
export async function readMcpMarketplaceCatalogFromCache(): Promise<McpMarketplaceCatalog | undefined> {
try {
const mcpMarketplaceCatalogFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.mcpMarketplaceCatalog,
);
const fileExists = await fileExistsAtPath(mcpMarketplaceCatalogFilePath);
const mcpMarketplaceCatalogFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.mcpMarketplaceCatalog)
const fileExists = await fileExistsAtPath(mcpMarketplaceCatalogFilePath)
if (fileExists) {
const fileContents = await fs.readFile(
mcpMarketplaceCatalogFilePath,
"utf8",
);
return JSON.parse(fileContents);
const fileContents = await fs.readFile(mcpMarketplaceCatalogFilePath, "utf8")
return JSON.parse(fileContents)
}
return undefined;
return undefined
} catch (error) {
Logger.error("Failed to read MCP marketplace catalog from cache:", error);
return undefined;
Logger.error("Failed to read MCP marketplace catalog from cache:", error)
return undefined
}
}
export async function writeMcpMarketplaceCatalogToCache(
catalog: McpMarketplaceCatalog,
): Promise<void> {
export async function writeMcpMarketplaceCatalogToCache(catalog: McpMarketplaceCatalog): Promise<void> {
try {
const mcpMarketplaceCatalogFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.mcpMarketplaceCatalog,
);
await fs.writeFile(mcpMarketplaceCatalogFilePath, JSON.stringify(catalog));
const mcpMarketplaceCatalogFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.mcpMarketplaceCatalog)
await fs.writeFile(mcpMarketplaceCatalogFilePath, JSON.stringify(catalog))
} catch (error) {
Logger.error("Failed to write MCP marketplace catalog to cache:", error);
Logger.error("Failed to write MCP marketplace catalog to cache:", error)
}
}
async function getGlobalStorageDir(...subdirs: string[]) {
const fullPath = path.resolve(
HostProvider.get().globalStorageFsPath,
...subdirs,
);
await fs.mkdir(fullPath, { recursive: true });
return fullPath;
const fullPath = path.resolve(HostProvider.get().globalStorageFsPath, ...subdirs)
await fs.mkdir(fullPath, { recursive: true })
return fullPath
}
export async function getTaskHistoryStateFilePath(): Promise<string> {
return path.join(await ensureStateDirectoryExists(), "taskHistory.json");
return path.join(await ensureStateDirectoryExists(), "taskHistory.json")
}
export async function taskHistoryStateFileExists(): Promise<boolean> {
const filePath = await getTaskHistoryStateFilePath();
return fileExistsAtPath(filePath);
const filePath = await getTaskHistoryStateFilePath()
return fileExistsAtPath(filePath)
}
export async function readTaskHistoryFromState(): Promise<HistoryItem[]> {
try {
const filePath = await getTaskHistoryStateFilePath();
const filePath = await getTaskHistoryStateFilePath()
if (!(await fileExistsAtPath(filePath))) {
return [];
return []
}
const contents = await fs.readFile(filePath, "utf8");
const contents = await fs.readFile(filePath, "utf8")
try {
return JSON.parse(contents);
return JSON.parse(contents)
} catch (parseError) {
telemetryService.captureExtensionStorageError(
parseError,
"parseError_attemptingRecovery",
);
telemetryService.captureExtensionStorageError(parseError, "parseError_attemptingRecovery")
const result = await reconstructTaskHistory(false);
const result = await reconstructTaskHistory(false)
if (result && result.reconstructedTasks > 0) {
// Read the reconstructed file
const newContents = await fs.readFile(filePath, "utf8");
return JSON.parse(newContents);
const newContents = await fs.readFile(filePath, "utf8")
return JSON.parse(newContents)
}
// Recovery failed, all we can do is return an empty array or throw an error, thus preventing the app from starting up
// This will wipe out the taskHistory
return [];
return []
}
} catch (error) {
// Filesystem or other errors - throw them for the caller to handle
telemetryService.captureExtensionStorageError(
error,
"readTaskHistoryFromState",
);
throw error;
telemetryService.captureExtensionStorageError(error, "readTaskHistoryFromState")
throw error
}
}
export async function writeTaskHistoryToState(
items: HistoryItem[],
): Promise<void> {
export async function writeTaskHistoryToState(items: HistoryItem[]): Promise<void> {
try {
const filePath = await getTaskHistoryStateFilePath();
await atomicWriteFile(filePath, JSON.stringify(items));
const filePath = await getTaskHistoryStateFilePath()
await atomicWriteFile(filePath, JSON.stringify(items))
} catch (error) {
Logger.error("[Disk] Failed to write task history:", error);
throw error;
Logger.error("[Disk] Failed to write task history:", error)
throw error
}
}
export async function readTaskSettingsFromStorage(
taskId: string,
): Promise<Partial<GlobalState>> {
export async function readTaskSettingsFromStorage(taskId: string): Promise<Partial<GlobalState>> {
try {
const taskDirectoryFilePath = await ensureTaskDirectoryExists(taskId);
const settingsFilePath = path.join(taskDirectoryFilePath, "settings.json");
const taskDirectoryFilePath = await ensureTaskDirectoryExists(taskId)
const settingsFilePath = path.join(taskDirectoryFilePath, "settings.json")
if (await fileExistsAtPath(settingsFilePath)) {
const settingsContent = await fs.readFile(settingsFilePath, "utf8");
return JSON.parse(settingsContent);
const settingsContent = await fs.readFile(settingsFilePath, "utf8")
return JSON.parse(settingsContent)
}
// Return empty object if settings file doesn't exist (new task)
return {};
return {}
} catch (error) {
Logger.error("[Disk] Failed to read task settings:", error);
throw error;
Logger.error("[Disk] Failed to read task settings:", error)
throw error
}
}
export async function writeTaskSettingsToStorage(
taskId: string,
settings: Partial<Settings>,
) {
export async function writeTaskSettingsToStorage(taskId: string, settings: Partial<Settings>) {
try {
const taskDirectoryFilePath = await ensureTaskDirectoryExists(taskId);
const settingsFilePath = path.join(taskDirectoryFilePath, "settings.json");
const taskDirectoryFilePath = await ensureTaskDirectoryExists(taskId)
const settingsFilePath = path.join(taskDirectoryFilePath, "settings.json")
let existingSettings = {};
let existingSettings = {}
if (await fileExistsAtPath(settingsFilePath)) {
const existingSettingsContent = await fs.readFile(
settingsFilePath,
"utf8",
);
existingSettings = JSON.parse(existingSettingsContent);
const existingSettingsContent = await fs.readFile(settingsFilePath, "utf8")
existingSettings = JSON.parse(existingSettingsContent)
}
const updatedSettings = { ...existingSettings, ...settings };
await fs.writeFile(
settingsFilePath,
JSON.stringify(updatedSettings, null, 2),
);
const updatedSettings = { ...existingSettings, ...settings }
await fs.writeFile(settingsFilePath, JSON.stringify(updatedSettings, null, 2))
} catch (error) {
Logger.error("[Disk] Failed to write task settings:", error);
throw error;
Logger.error("[Disk] Failed to write task settings:", error)
throw error
}
}
export async function readRemoteConfigFromCache(
organizationId: string,
): Promise<RemoteConfig | undefined> {
export async function readRemoteConfigFromCache(organizationId: string): Promise<RemoteConfig | undefined> {
try {
const remoteConfigFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.remoteConfig(organizationId),
);
const fileExists = await fileExistsAtPath(remoteConfigFilePath);
const remoteConfigFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.remoteConfig(organizationId))
const fileExists = await fileExistsAtPath(remoteConfigFilePath)
if (fileExists) {
const fileContents = await fs.readFile(remoteConfigFilePath, "utf8");
return JSON.parse(fileContents);
const fileContents = await fs.readFile(remoteConfigFilePath, "utf8")
return JSON.parse(fileContents)
}
return undefined;
return undefined
} catch (error) {
Logger.error("Failed to read remote config from cache:", error);
return undefined;
Logger.error("Failed to read remote config from cache:", error)
return undefined
}
}
export async function writeRemoteConfigToCache(
organizationId: string,
config: RemoteConfig,
): Promise<void> {
export async function writeRemoteConfigToCache(organizationId: string, config: RemoteConfig): Promise<void> {
try {
const remoteConfigFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.remoteConfig(organizationId),
);
await fs.writeFile(remoteConfigFilePath, JSON.stringify(config));
const remoteConfigFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.remoteConfig(organizationId))
await fs.writeFile(remoteConfigFilePath, JSON.stringify(config))
} catch (error) {
Logger.error("Failed to write remote config to cache:", error);
Logger.error("Failed to write remote config to cache:", error)
}
}
export async function deleteRemoteConfigFromCache(
organizationId: string,
): Promise<void> {
export async function deleteRemoteConfigFromCache(organizationId: string): Promise<void> {
try {
const remoteConfigFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.remoteConfig(organizationId),
);
const fileExists = await fileExistsAtPath(remoteConfigFilePath);
const remoteConfigFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.remoteConfig(organizationId))
const fileExists = await fileExistsAtPath(remoteConfigFilePath)
if (fileExists) {
await fs.unlink(remoteConfigFilePath);
await fs.unlink(remoteConfigFilePath)
}
} catch (error) {
Logger.error("Failed to delete remote config from cache:", error);
Logger.error("Failed to delete remote config from cache:", error)
}
}
@@ -614,11 +504,11 @@ export async function deleteRemoteConfigFromCache(
* Returns undefined if the directory doesn't exist.
*/
export async function getGlobalHooksDir(): Promise<string | undefined> {
const globalHooksDir = await ensureHooksDirectoryExists();
return (await isDirectory(globalHooksDir)) ? globalHooksDir : undefined;
const globalHooksDir = await ensureHooksDirectoryExists()
return (await isDirectory(globalHooksDir)) ? globalHooksDir : undefined
}
let runtimeHooksDir: string | undefined;
let runtimeHooksDir: string | undefined
/**
* Sets a runtime hooks directory, typically passed via the --hooks-dir CLI flag.
@@ -626,7 +516,7 @@ let runtimeHooksDir: string | undefined;
* when discovering hooks.
*/
export function setRuntimeHooksDir(dir: string | undefined): void {
runtimeHooksDir = dir;
runtimeHooksDir = dir
}
/**
@@ -641,24 +531,24 @@ export function setRuntimeHooksDir(dir: string | undefined): void {
* multi-root workspace may have multiple hooks directories.
*/
export async function getAllHooksDirs(): Promise<string[]> {
const hooksDirs: string[] = [];
const hooksDirs: string[] = []
// Add runtime hooks directory (set by --hooks-dir CLI flag)
if (runtimeHooksDir && (await isDirectory(runtimeHooksDir))) {
hooksDirs.push(runtimeHooksDir);
hooksDirs.push(runtimeHooksDir)
}
// Add global hooks directory (if it exists)
const globalHooksDir = await getGlobalHooksDir();
const globalHooksDir = await getGlobalHooksDir()
if (globalHooksDir) {
hooksDirs.push(globalHooksDir);
hooksDirs.push(globalHooksDir)
}
// Add workspace hooks directories
const workspaceHooksDirs = await getWorkspaceHooksDirs();
hooksDirs.push(...workspaceHooksDirs);
const workspaceHooksDirs = await getWorkspaceHooksDirs()
hooksDirs.push(...workspaceHooksDirs)
return hooksDirs;
return hooksDirs
}
/**
@@ -670,20 +560,17 @@ export async function getWorkspaceHooksDirs(): Promise<string[]> {
const workspaceRootPaths =
StateManager.get()
.getGlobalStateKey("workspaceRoots")
?.map((root) => root.path) || [];
?.map((root) => root.path) || []
return (
await Promise.all(
workspaceRootPaths.map(async (workspaceRootPath) => {
// Look for a .clinerules/hooks folder in this workspace root.
const candidate = path.join(
workspaceRootPath,
GlobalFileNames.hooksDir,
);
return (await isDirectory(candidate)) ? candidate : undefined;
const candidate = path.join(workspaceRootPath, GlobalFileNames.hooksDir)
return (await isDirectory(candidate)) ? candidate : undefined
}),
)
).filter((path): path is string => Boolean(path));
).filter((path): path is string => Boolean(path))
}
/**
@@ -701,20 +588,17 @@ export async function writeConversationHistoryJson(
apiConversationHistory: Anthropic.MessageParam[],
timestamp?: number,
): Promise<string> {
const taskDir = await ensureTaskDirectoryExists(taskId);
const fileTimestamp = timestamp ?? Date.now();
const tempFileName = `conversation_history_${fileTimestamp}.json`;
const tempFilePath = path.join(taskDir, tempFileName);
const taskDir = await ensureTaskDirectoryExists(taskId)
const fileTimestamp = timestamp ?? Date.now()
const tempFileName = `conversation_history_${fileTimestamp}.json`
const tempFilePath = path.join(taskDir, tempFileName)
try {
await atomicWriteFile(
tempFilePath,
JSON.stringify(apiConversationHistory, null, 2),
);
return tempFilePath;
await atomicWriteFile(tempFilePath, JSON.stringify(apiConversationHistory, null, 2))
return tempFilePath
} catch (error) {
Logger.error("Failed to write conversation history JSON for hook:", error);
throw error;
Logger.error("Failed to write conversation history JSON for hook:", error)
throw error
}
}
@@ -724,20 +608,14 @@ export async function writeConversationHistoryJson(
*
* @param filePath The path to the temporary file to delete
*/
export async function cleanupConversationHistoryFile(
filePath: string,
): Promise<void> {
export async function cleanupConversationHistoryFile(filePath: string): Promise<void> {
try {
if (await fileExistsAtPath(filePath)) {
await fs.unlink(filePath);
await fs.unlink(filePath)
}
} catch (error) {
// Silently handle errors - this is cleanup, not critical
Logger.debug(
"Failed to cleanup conversation history file:",
filePath,
error,
);
Logger.debug("Failed to cleanup conversation history file:", filePath, error)
}
}
@@ -756,59 +634,59 @@ export async function writeConversationHistoryText(
conversationHistory: Anthropic.MessageParam[],
timestamp?: number,
): Promise<string> {
const taskDir = await ensureTaskDirectoryExists(taskId);
const fileTimestamp = timestamp ?? Date.now();
const tempFileName = `conversation_history_${fileTimestamp}.txt`;
const tempFilePath = path.join(taskDir, tempFileName);
const taskDir = await ensureTaskDirectoryExists(taskId)
const fileTimestamp = timestamp ?? Date.now()
const tempFileName = `conversation_history_${fileTimestamp}.txt`
const tempFilePath = path.join(taskDir, tempFileName)
try {
// Build the formatted conversation history (excluding system prompt)
let fullContext = "=== CONVERSATION HISTORY ===\n\n";
let fullContext = "=== CONVERSATION HISTORY ===\n\n"
// Format each message in the conversation
for (let i = 0; i < conversationHistory.length; i++) {
const message = conversationHistory[i];
fullContext += `--- Message ${i + 1} (${message.role.toUpperCase()}) ---\n`;
const message = conversationHistory[i]
fullContext += `--- Message ${i + 1} (${message.role.toUpperCase()}) ---\n`
// Handle content which can be a string or array
if (typeof message.content === "string") {
fullContext += message.content;
fullContext += message.content
} else if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === "text") {
fullContext += block.text;
fullContext += block.text
} else if (block.type === "image") {
fullContext += `[IMAGE: ${block.source?.type || "unknown"}]`;
fullContext += `[IMAGE: ${block.source?.type || "unknown"}]`
} else if (block.type === "tool_use") {
fullContext += `[TOOL USE: ${block.name}]\n`;
fullContext += `Input: ${JSON.stringify(block.input, null, 2)}`;
fullContext += `[TOOL USE: ${block.name}]\n`
fullContext += `Input: ${JSON.stringify(block.input, null, 2)}`
} else if (block.type === "tool_result") {
fullContext += `[TOOL RESULT: ${block.tool_use_id}]\n`;
fullContext += `[TOOL RESULT: ${block.tool_use_id}]\n`
if (typeof block.content === "string") {
fullContext += block.content;
fullContext += block.content
} else if (Array.isArray(block.content)) {
for (const resultBlock of block.content) {
if (resultBlock.type === "text") {
fullContext += resultBlock.text;
fullContext += resultBlock.text
} else if (resultBlock.type === "image") {
fullContext += `[IMAGE]`;
fullContext += `[IMAGE]`
}
}
}
}
fullContext += "\n\n";
fullContext += "\n\n"
}
}
fullContext += "\n";
fullContext += "\n"
}
fullContext += "=== END OF CONTEXT ===\n";
fullContext += "=== END OF CONTEXT ===\n"
await atomicWriteFile(tempFilePath, fullContext);
return tempFilePath;
await atomicWriteFile(tempFilePath, fullContext)
return tempFilePath
} catch (error) {
Logger.error("Failed to write conversation history text for hook:", error);
throw error;
Logger.error("Failed to write conversation history text for hook:", error)
throw error
}
}
File diff suppressed because it is too large Load Diff
@@ -1,37 +1,33 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type { Anthropic } from "@anthropic-ai/sdk"
/**
* Filters out image blocks from messages since Claude Code doesn't support images.
* Replaces image blocks with text placeholders similar to how VSCode LM provider handles it.
*/
export function filterMessagesForClaudeCode(
messages: Anthropic.Messages.MessageParam[],
): Anthropic.Messages.MessageParam[] {
export function filterMessagesForClaudeCode(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] {
return messages.map((message) => {
// Handle simple string messages
if (typeof message.content === "string") {
return message;
return message
}
// Handle complex message structures
const filteredContent = message.content.map((block) => {
if (block.type === "image") {
// Replace image blocks with text placeholders
const sourceType = block.source?.type || "unknown";
const mediaType =
(block.source?.type === "base64" && block.source.media_type) ||
"unknown";
const sourceType = block.source?.type || "unknown"
const mediaType = block.source?.media_type || "unknown"
return {
type: "text" as const,
text: `[Image (${sourceType}): ${mediaType} not supported by Claude Code]`,
};
}
}
return block;
});
return block
})
return {
...message,
content: filteredContent,
};
});
}
})
}
+19 -31
View File
@@ -5107,17 +5107,17 @@ export const mainlandZAiModels = {
export type FireworksModelId = keyof typeof fireworksModels
export const fireworksDefaultModelId: FireworksModelId = "accounts/fireworks/models/kimi-k2p6"
export const fireworksModels = {
"accounts/fireworks/models/kimi-k2p7-code": {
maxTokens: 262000,
contextWindow: 262000,
"accounts/fireworks/models/kimi-k2p5": {
maxTokens: 256000,
contextWindow: 256000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.95,
outputPrice: 4,
inputPrice: 0.6,
outputPrice: 3,
cacheWritesPrice: 0,
cacheReadsPrice: 0.19,
cacheReadsPrice: 0.1,
description:
"Moonshot's latest open coding model. Kimi K2.7 Code unifies vision and text, thinking and non-thinking modes, and single-agent and multi-agent execution.",
"Moonshot's flagship open agentic model. Kimi K2.5 unifies vision and text, thinking and non-thinking modes, and single-agent and multi-agent execution.",
},
"accounts/fireworks/models/kimi-k2p6": {
maxTokens: 262000,
@@ -5143,18 +5143,6 @@ export const fireworksModels = {
description:
"Kimi K2.6 Turbo router for high-performance agentic workloads with vision and text reasoning.",
},
"accounts/fireworks/routers/kimi-k2p7-code-fast": {
maxTokens: 262000,
contextWindow: 262000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 1.9,
outputPrice: 8,
cacheWritesPrice: 0,
cacheReadsPrice: 0.38,
description:
"Kimi K2.7 Code Fast router for high-performance coding workloads with vision and text reasoning.",
},
"accounts/fireworks/models/deepseek-v4-flash": {
maxTokens: 384000,
contextWindow: 1000000,
@@ -5201,16 +5189,16 @@ export const fireworksModels = {
cacheReadsPrice: 0.52,
description: "GLM 5.1 Fast router for high-throughput coding, reasoning, and agentic workflows.",
},
"accounts/fireworks/models/minimax-m3": {
maxTokens: 512000,
contextWindow: 512000,
supportsImages: true,
"accounts/fireworks/models/minimax-m2p5": {
maxTokens: 196608,
contextWindow: 196608,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 0.3,
outputPrice: 1.2,
cacheWritesPrice: 0,
cacheReadsPrice: 0.06,
description: "MiniMax M3 is built for state-of-the-art coding, agentic tool use, and long-context multimodal tasks.",
cacheReadsPrice: 0.03,
description: "MiniMax M2.5 is built for state-of-the-art coding, agentic tool use.",
},
"accounts/fireworks/models/minimax-m2p7": {
maxTokens: 196608,
@@ -5223,16 +5211,16 @@ export const fireworksModels = {
cacheReadsPrice: 0.06,
description: "MiniMax M2.7 is tuned for strong real-world performance across coding, agent-driven, and workflow-heavy tasks.",
},
"accounts/fireworks/models/qwen3p7-plus": {
maxTokens: 262144,
"accounts/fireworks/models/qwen3p6-plus": {
maxTokens: 65536,
contextWindow: 262144,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 0.4,
outputPrice: 1.6,
inputPrice: 0.5,
outputPrice: 3,
cacheWritesPrice: 0,
cacheReadsPrice: 0.08,
description: "Qwen 3.7 Plus with strong multimodal reasoning, long context support, and function calling.",
cacheReadsPrice: 0.1,
description: "Qwen 3.6 Plus with strong multimodal reasoning, long context support, and function calling.",
},
"accounts/fireworks/models/gpt-oss-120b": {
maxTokens: 32768,
+44 -87
View File
@@ -1,84 +1,68 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type { ClineMessageMetricsInfo, ClineMessageModelInfo } from "./metrics";
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineMessageMetricsInfo, ClineMessageModelInfo } from "./metrics"
export type ClinePromptInputContent = string;
export type ClinePromptInputContent = string
export type ClineMessageRole = "user" | "assistant";
export type ClineMessageRole = "user" | "assistant"
export interface ClineReasoningDetailParam {
type: "reasoning.text" | string;
text: string;
signature: string;
format: "anthropic-claude-v1" | string;
index: number;
type: "reasoning.text" | string
text: string
signature: string
format: "anthropic-claude-v1" | string
index: number
}
interface ClineSharedMessageParam {
// The id of the response that the block belongs to
call_id?: string;
call_id?: string
}
export const REASONING_DETAILS_PROVIDERS = ["cline", "openrouter"];
export const REASONING_DETAILS_PROVIDERS = ["cline", "openrouter"]
/**
* An extension of Anthropic.MessageParam that includes Cline-specific fields: reasoning_details.
* This ensures backward compatibility where the messages were stored in Anthropic format with additional
* fields unknown to Anthropic SDK.
*/
export interface ClineTextContentBlock
extends Anthropic.TextBlockParam,
ClineSharedMessageParam {
export interface ClineTextContentBlock extends Anthropic.TextBlockParam, ClineSharedMessageParam {
// reasoning_details only exists for providers listed in REASONING_DETAILS_PROVIDERS
reasoning_details?: ClineReasoningDetailParam[];
reasoning_details?: ClineReasoningDetailParam[]
// Thought Signature associates with Gemini
signature?: string;
signature?: string
}
export interface ClineImageContentBlock
extends Anthropic.ImageBlockParam,
ClineSharedMessageParam {}
export interface ClineImageContentBlock extends Anthropic.ImageBlockParam, ClineSharedMessageParam {}
export interface ClineDocumentContentBlock
extends Anthropic.DocumentBlockParam,
ClineSharedMessageParam {}
export interface ClineDocumentContentBlock extends Anthropic.DocumentBlockParam, ClineSharedMessageParam {}
export interface ClineUserToolResultContentBlock
extends Anthropic.ToolResultBlockParam,
ClineSharedMessageParam {}
export interface ClineUserToolResultContentBlock extends Anthropic.ToolResultBlockParam, ClineSharedMessageParam {}
/**
* Assistant only content types
*/
export interface ClineAssistantToolUseBlock
extends Anthropic.ToolUseBlockParam,
ClineSharedMessageParam {
export interface ClineAssistantToolUseBlock extends Anthropic.ToolUseBlockParam, ClineSharedMessageParam {
// reasoning_details only exists for providers listed in REASONING_DETAILS_PROVIDERS
reasoning_details?: unknown[] | ClineReasoningDetailParam[];
reasoning_details?: unknown[] | ClineReasoningDetailParam[]
// Thought Signature associates with Gemini
signature?: string;
signature?: string
}
export interface ClineAssistantThinkingBlock
extends Anthropic.ThinkingBlock,
ClineSharedMessageParam {
export interface ClineAssistantThinkingBlock extends Anthropic.ThinkingBlock, ClineSharedMessageParam {
// The summary items returned by OpenAI response API
// The reasoning details that will be moved to the text block when finalized
summary?: unknown[] | ClineReasoningDetailParam[];
summary?: unknown[] | ClineReasoningDetailParam[]
}
export interface ClineAssistantRedactedThinkingBlock
extends Anthropic.RedactedThinkingBlockParam,
ClineSharedMessageParam {}
export interface ClineAssistantRedactedThinkingBlock extends Anthropic.RedactedThinkingBlockParam, ClineSharedMessageParam {}
export type ClineToolResponseContent =
| ClinePromptInputContent
| Array<ClineTextContentBlock | ClineImageContentBlock>;
export type ClineToolResponseContent = ClinePromptInputContent | Array<ClineTextContentBlock | ClineImageContentBlock>
export type ClineUserContent =
| ClineTextContentBlock
| ClineImageContentBlock
| ClineDocumentContentBlock
| ClineUserToolResultContentBlock;
| ClineUserToolResultContentBlock
export type ClineAssistantContent =
| ClineTextContentBlock
@@ -86,9 +70,9 @@ export type ClineAssistantContent =
| ClineDocumentContentBlock
| ClineAssistantToolUseBlock
| ClineAssistantThinkingBlock
| ClineAssistantRedactedThinkingBlock;
| ClineAssistantRedactedThinkingBlock
export type ClineContent = ClineUserContent | ClineAssistantContent;
export type ClineContent = ClineUserContent | ClineAssistantContent
/**
* An extension of Anthropic.MessageParam that includes Cline-specific fields.
@@ -100,24 +84,24 @@ export interface ClineStorageMessage extends Anthropic.MessageParam {
/**
* Response ID associated with this message
*/
id?: string;
role: ClineMessageRole;
content: ClinePromptInputContent | ClineContent[];
id?: string
role: ClineMessageRole
content: ClinePromptInputContent | ClineContent[]
/**
* NOTE: model information used when generating this message.
* Internal use for message conversion only.
* MUST be removed before sending message to any LLM provider.
*/
modelInfo?: ClineMessageModelInfo;
modelInfo?: ClineMessageModelInfo
/**
* LLM operational and performance metrics for this message
* Includes token counts, costs.
*/
metrics?: ClineMessageMetricsInfo;
metrics?: ClineMessageMetricsInfo
/**
* Timestamp of when the message was created
*/
ts?: number;
ts?: number
}
/**
@@ -128,50 +112,23 @@ export function convertClineStorageToAnthropicMessage(
clineMessage: ClineStorageMessage,
provider = "anthropic",
): Anthropic.MessageParam {
const { role, content } = clineMessage;
const { role, content } = clineMessage
// Handle string content - fast path
if (typeof content === "string") {
return { role, content };
return { role, content }
}
// Removes thinking block that has no signature (invalid thinking block that's incompatible with Anthropic API)
const filteredContent = content.filter(
(b) => b.type !== "thinking" || !!b.signature,
);
const filteredContent = content.filter((b) => b.type !== "thinking" || !!b.signature)
// Handle array content - strip Cline-specific fields for non-reasoning_details providers
const shouldCleanContent = !REASONING_DETAILS_PROVIDERS.includes(provider);
const shouldCleanContent = !REASONING_DETAILS_PROVIDERS.includes(provider)
const cleanedContent = shouldCleanContent
? filteredContent.map(cleanContentBlock)
: (filteredContent as Anthropic.MessageParam["content"]);
: (filteredContent as Anthropic.MessageParam["content"])
return { role, content: cleanedContent };
}
/**
* Cline stores images as base64, so an image block's source is always a base64 source.
* The Anthropic SDK types the source as a Base64ImageSource | URLImageSource union, so this
* narrows to the base64 variant for the transform layer. URL sources are not produced by Cline,
* so they degrade to empty values rather than throwing.
*/
export function getBase64ImageSource(
source: Anthropic.ImageBlockParam["source"],
): { mediaType: string; data: string } {
if (source.type === "base64") {
return { mediaType: source.media_type, data: source.data };
}
return { mediaType: "", data: "" };
}
/**
* Builds a base64 data URL from an image block's source. See getBase64ImageSource.
*/
export function getImageDataUrl(
source: Anthropic.ImageBlockParam["source"],
): string {
const { mediaType, data } = getBase64ImageSource(source);
return `data:${mediaType};base64,${data}`;
return { role, content: cleanedContent }
}
/**
@@ -183,19 +140,19 @@ export function cleanContentBlock(block: ClineContent): Anthropic.ContentBlock {
"reasoning_details" in block ||
"call_id" in block ||
"summary" in block ||
(block.type !== "thinking" && "signature" in block);
(block.type !== "thinking" && "signature" in block)
if (!hasClineFields) {
return block as Anthropic.ContentBlock;
return block as Anthropic.ContentBlock
}
// Removes Cline-specific fields & the signature field that's added for Gemini.
const { reasoning_details, call_id, summary, ...rest } = block as any;
const { reasoning_details, call_id, summary, ...rest } = block as any
// Remove signature from non-thinking blocks that were added for Gemini
if (block.type !== "thinking" && rest.signature) {
rest.signature = undefined;
rest.signature = undefined
}
return rest satisfies Anthropic.ContentBlock;
return rest satisfies Anthropic.ContentBlock
}
+1 -1
View File
@@ -98,7 +98,7 @@ export function getReadablePath(cwd: string, relPath?: string): string {
if (isLocatedInPath(cwd, absolutePath)) {
return normalizedRelPath.toPosix()
}
// we are outside the cwd, so show the absolute path (useful for when Cline passes in '../../' for example)
// we are outside the cwd, so show the absolute path (useful for when cline passes in '../../' for example)
return absolutePath.toPosix()
}
@@ -5,16 +5,19 @@ import { type ReactNode, useEffect, useState } from "react"
import { useExtensionState } from "./context/ExtensionStateContext"
export function CustomPostHogProvider({ children }: { children: ReactNode }) {
const { distinctId, version, userInfo, environment, telemetrySetting } = useExtensionState()
const { distinctId, version, userInfo, environment } = useExtensionState()
// Skip PostHog entirely in self-hosted mode or when environment is unknown (safety fallback)
const isSelfHostedOrUnknown = !environment || environment === "selfHosted"
const isTelemetryEnabled = telemetrySetting !== "disabled"
// NOTE: This is a hack to stop recording webview click events temporarily.
// Remove this to re-enable.
// const isTelemetryEnabled = telemetrySetting !== "disabled";
const isTelemetryEnabled = false
const [isActive, setIsActive] = useState(false)
useEffect(() => {
if (isSelfHostedOrUnknown || isActive || !posthogConfig.apiKey) {
if (isSelfHostedOrUnknown || isActive || !isTelemetryEnabled || !posthogConfig.apiKey) {
return
}
// At this point, we know apiKey is defined due to the check above
@@ -24,7 +27,7 @@ export function CustomPostHogProvider({ children }: { children: ReactNode }) {
ui_host: posthogConfig.uiHost,
disable_session_recording: true,
capture_pageview: false,
capture_dead_clicks: false,
capture_dead_clicks: true,
// Feature flags should work regardless of telemetry opt-out
advanced_disable_decide: false,
// Autocapture should respect telemetry settings
@@ -34,7 +37,7 @@ export function CustomPostHogProvider({ children }: { children: ReactNode }) {
}, [isSelfHostedOrUnknown])
useEffect(() => {
if (!isActive || !distinctId || !version) {
if (!isTelemetryEnabled || !isActive || !distinctId || !version) {
return
}
@@ -15,7 +15,7 @@ import ContextWindow from "./ContextWindow"
import { FocusChain } from "./FocusChain"
import { highlightText } from "./Highlights"
const IS_DEV = process.env.IS_DEV === "true"
const IS_DEV = process.env.IS_DEV === '"true"'
interface TaskHeaderProps {
task: ClineMessage
tokensIn: number
@@ -41,15 +41,13 @@ export const isChrome = userAgent.indexOf("Chrome") >= 0
export const isSafari = !isChrome && userAgent.indexOf("Safari") >= 0
declare const __NODE_PLATFORM__: string
/**
* Gets the current platform: 'windows', 'mac', or 'linux'
* Defaults to 'linux' if platform cannot be determined
*/
export function getCurrentPlatform() {
// Fallback to linux if platform is not available
switch (__NODE_PLATFORM__) {
switch (process?.platform) {
case "win32":
return "windows"
case "darwin":
+13 -17
View File
@@ -116,23 +116,19 @@ export default defineConfig({
},
define: {
__PLATFORM__: JSON.stringify(platform),
__NODE_PLATFORM__: JSON.stringify(process.platform),
"process.env.CLINE_ENVIRONMENT": JSON.stringify(process.env.CLINE_ENVIRONMENT ?? "production"),
"process.env.IS_DEV": JSON.stringify(process.env.IS_DEV),
"process.env.IS_TEST": JSON.stringify(process.env.IS_TEST),
"process.env.CI": JSON.stringify(process.env.CI),
// PostHog environment variables
"process.env.TELEMETRY_SERVICE_API_KEY": JSON.stringify(process.env.TELEMETRY_SERVICE_API_KEY),
"process.env.ERROR_SERVICE_API_KEY": JSON.stringify(process.env.ERROR_SERVICE_API_KEY),
"process.env.ENABLE_ERROR_AUTOCAPTURE": JSON.stringify(process.env.ENABLE_ERROR_AUTOCAPTURE),
// OpenTelemetry environment variables
"process.env.OTEL_TELEMETRY_ENABLED": JSON.stringify(process.env.OTEL_TELEMETRY_ENABLED),
"process.env.OTEL_METRICS_EXPORTER": JSON.stringify(process.env.OTEL_METRICS_EXPORTER),
"process.env.OTEL_LOGS_EXPORTER": JSON.stringify(process.env.OTEL_LOGS_EXPORTER),
"process.env.OTEL_EXPORTER_OTLP_PROTOCOL": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_PROTOCOL),
"process.env.OTEL_EXPORTER_OTLP_ENDPOINT": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_ENDPOINT),
"process.env.OTEL_EXPORTER_OTLP_HEADERS": JSON.stringify(process.env.OTEL_EXPORTER_OTLP_HEADERS),
"process.env.OTEL_METRIC_EXPORT_INTERVAL": JSON.stringify(process.env.OTEL_METRIC_EXPORT_INTERVAL),
process: JSON.stringify({
platform: JSON.stringify(process?.platform),
env: {
NODE_ENV: JSON.stringify(process?.env?.IS_DEV ? "development" : "production"),
CLINE_ENVIRONMENT: JSON.stringify(process?.env?.CLINE_ENVIRONMENT ?? "production"),
IS_DEV: JSON.stringify(process?.env?.IS_DEV),
IS_TEST: JSON.stringify(process?.env?.IS_TEST),
CI: JSON.stringify(process?.env?.CI),
// PostHog environment variables
TELEMETRY_SERVICE_API_KEY: JSON.stringify(process?.env?.TELEMETRY_SERVICE_API_KEY),
ERROR_SERVICE_API_KEY: JSON.stringify(process?.env?.ERROR_SERVICE_API_KEY),
},
}),
},
resolve: {
alias: {
+223 -519
View File
File diff suppressed because it is too large Load Diff
-29
View File
@@ -1,34 +1,5 @@
# Cline SDK Changelog
## 0.0.49
- Reverted ClinePass recommended-models support, removing the `clinePass` field from the recommended models data
## 0.0.48
- Added ClinePass support and ClinePass models
- Added MCP server support to plugins
- Updated the recommended/fixed model list
- Encouraged parallel tool calls for faster task execution
- Capped tool output ingestion for bash commands and file reads to keep large output within context limits
- Added a bounded media budget for provider requests, plus generic provider-request capture
- Allowed ranged reads on large files
- Fixed apply_patch to fail when a hunk is skipped instead of silently dropping it
- Fixed run_commands to return captured stdout on failure and to coalesce split heredocs
- Fixed search tools to treat zero results as a successful result
- Fixed search output cap and bash executor follow-up issues
- Fixed disabled-reasoning handling for StepFun flash
- Fixed the Hugging Face URL
- Fixed Cline OAuth token formatting in provider config
## 0.0.47
- Added support for overriding the API base URL
- Enforced a production singleton Cline Hub so only one hub daemon runs, and a stale hub is respawned after an upgrade
- Allowed plugin chat commands to submit prompts to the agent
- Fixed truncation of structured tool operation result strings so oversized tool output stays within limits
- Stopped echoing the full command text in run_commands tool results
## 0.0.46
- Added support for configured agents as subagent tools

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