mirror of
https://github.com/cline/cline.git
synced 2026-09-12 17:19:32 +08:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf2c00f653 | ||
|
|
4de25dc68a | ||
|
|
e3b53255e2 | ||
|
|
7d1abb25a7 | ||
|
|
628aaa0675 | ||
|
|
af7fda87c0 | ||
|
|
9af6ced896 | ||
|
|
a3258dd79a | ||
|
|
ebb05ed963 | ||
|
|
8d03d176f2 | ||
|
|
32b3cfc081 | ||
|
|
a6500a07b4 | ||
|
|
81384089c4 | ||
|
|
6364792c47 | ||
|
|
40fc8879f1 | ||
|
|
e91cba4045 | ||
|
|
e5e1aa3455 | ||
|
|
d2893d2e93 | ||
|
|
4fc366df5f | ||
|
|
d8eb06318b | ||
|
|
fe4eb44c6b | ||
|
|
6c52bdc177 | ||
|
|
5260595472 | ||
|
|
a279388451 | ||
|
|
7810a81efe | ||
|
|
2a54e2a76e | ||
|
|
b7c38f76c9 | ||
|
|
d20e517831 | ||
|
|
fa3630da47 | ||
|
|
8229d0c9be | ||
|
|
efa14b6cab | ||
|
|
c10b417b78 | ||
|
|
9958e3f354 | ||
|
|
ec75291d5b | ||
|
|
a3a31da37d | ||
|
|
a69d650838 | ||
|
|
7934d367a9 | ||
|
|
7f9d5461f1 | ||
|
|
e1bdeeff68 | ||
|
|
49897830bb | ||
|
|
1f316a2734 | ||
|
|
9c1f9133c7 | ||
|
|
2faef2b40d | ||
|
|
64829bca8c | ||
|
|
4c9ba6b091 | ||
|
|
6138bdfe40 | ||
|
|
7d119351b1 | ||
|
|
de987a5246 | ||
|
|
90050426df | ||
|
|
205c5676ff | ||
|
|
1c13edd395 | ||
|
|
a2a1936709 | ||
|
|
35ce6a3f26 | ||
|
|
2c4aeae4f3 | ||
|
|
0c027d2731 | ||
|
|
6cc93c124e | ||
|
|
7e5b8be28c |
@@ -1,5 +1,19 @@
|
||||
# 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
|
||||
|
||||
+3
-1
@@ -1,7 +1,9 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"root": false,
|
||||
"extends": ["../sdk/biome.json"],
|
||||
"extends": [
|
||||
"../sdk/biome.json"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"a11y": {
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 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
|
||||
- Fixed the Azure Foundry API version
|
||||
- Added support for configured agents as subagent tools
|
||||
- Centralized OAuth management into the SDK
|
||||
- Fixed an error caused by disabled reasoning on Fable 5
|
||||
|
||||
## 3.0.22
|
||||
|
||||
- Added support for the Claude Fable 5 model
|
||||
- Fixed MiniMax M3 thinking controls so they route correctly across gateways
|
||||
|
||||
## 3.0.21
|
||||
|
||||
- Added a global auto-update setting that controls automatic updates on CLI startup
|
||||
|
||||
@@ -85,6 +85,20 @@ 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,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.21",
|
||||
"version": "3.0.24",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -87,6 +87,7 @@
|
||||
"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",
|
||||
|
||||
+24
-59
@@ -1,11 +1,6 @@
|
||||
import type { ProviderSettings, ProviderSettingsManager } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { OAuthCredentials } from "../commands/auth";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
saveOAuthProviderSettings,
|
||||
toProviderApiKey,
|
||||
} from "../commands/auth";
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { loginAndSaveProviderOAuthCredentials } from "@cline/core";
|
||||
import { getPersistedProviderApiKey } from "../commands/auth";
|
||||
import { writeDiagnostic } from "../utils/output";
|
||||
|
||||
/**
|
||||
@@ -30,37 +25,13 @@ export function isAcpAuthMethodId(id: string): id is AcpAuthMethodId {
|
||||
* If the OAuth flow requires interactive prompts (rare), defaults are used
|
||||
* when available; otherwise an error is thrown.
|
||||
*/
|
||||
async function performOAuthLogin(
|
||||
providerId: AcpAuthMethodId,
|
||||
existingSettings: ProviderSettings | undefined,
|
||||
): Promise<OAuthCredentials> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }, coreOAuth] =
|
||||
await Promise.all([
|
||||
import("@cline/core"),
|
||||
import("open"),
|
||||
import("@cline/core").then((m) => ({
|
||||
loginClineOAuth: m.loginClineOAuth as (input: {
|
||||
useWorkOSDeviceAuth?: boolean;
|
||||
apiBaseUrl: string;
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>,
|
||||
loginOpenAICodex: m.loginOpenAICodex as (input: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}) => Promise<OAuthCredentials>,
|
||||
})),
|
||||
]);
|
||||
async function performOAuthLogin(input: {
|
||||
providerId: AcpAuthMethodId;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
}): Promise<string> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }] = await Promise.all(
|
||||
[import("@cline/core"), import("open")],
|
||||
);
|
||||
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
onPrompt: ({ defaultValue }) => {
|
||||
@@ -82,18 +53,18 @@ async function performOAuthLogin(
|
||||
},
|
||||
});
|
||||
|
||||
if (providerId === "cline") {
|
||||
return coreOAuth.loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
existingSettings?.baseUrl?.trim() ||
|
||||
getClineEnvironmentConfig().apiBaseUrl,
|
||||
callbacks,
|
||||
useWorkOSDeviceAuth: true,
|
||||
});
|
||||
const settings = await loginAndSaveProviderOAuthCredentials(
|
||||
input.providerSettingsManager,
|
||||
input.providerId,
|
||||
{ callbacks },
|
||||
);
|
||||
const apiKey = getPersistedProviderApiKey(input.providerId, settings);
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
`OAuth login did not persist credentials for ${input.providerId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// openai-codex
|
||||
return coreOAuth.loginOpenAICodex(callbacks);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export interface AcpAuthResult {
|
||||
@@ -122,16 +93,10 @@ export async function authenticateAcpProvider(
|
||||
|
||||
// Perform a fresh OAuth login.
|
||||
writeDiagnostic(`[acp/auth] Starting OAuth login for ${methodId}…`);
|
||||
const credentials = await performOAuthLogin(methodId, existing);
|
||||
|
||||
saveOAuthProviderSettings(
|
||||
const apiKey = await performOAuthLogin({
|
||||
providerId: methodId,
|
||||
providerSettingsManager,
|
||||
methodId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
|
||||
const apiKey = toProviderApiKey(methodId, credentials);
|
||||
});
|
||||
writeDiagnostic(`[acp/auth] Successfully authenticated with ${methodId}`);
|
||||
return { providerId: methodId, apiKey };
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ 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
|
||||
@@ -51,16 +53,40 @@ function buildScriptCommand(scriptedInput: string, launchArgs: string): string {
|
||||
return `(${scriptedInput}) | script ${quietFlag} /dev/null ${toShellSingleQuotedLiteral(bunExec)} ${launchArgs}`;
|
||||
}
|
||||
|
||||
function runInteractiveCli(
|
||||
steps: KeyStep[],
|
||||
options?: { launchConfigView?: boolean },
|
||||
): CliResult {
|
||||
function createCliEnv(): NodeJS.ProcessEnv {
|
||||
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.
|
||||
@@ -80,9 +106,13 @@ function runInteractiveCli(
|
||||
"-k",
|
||||
"test-key",
|
||||
];
|
||||
const launchArgs = [
|
||||
...(options?.launchConfigView ? [...baseArgs, "config"] : baseArgs),
|
||||
]
|
||||
const launchArgs = (
|
||||
options?.launchArgs
|
||||
? [cliEntry, ...options.launchArgs]
|
||||
: options?.launchConfigView
|
||||
? [...baseArgs, "config"]
|
||||
: baseArgs
|
||||
)
|
||||
.map((arg) => toShellSingleQuotedLiteral(arg))
|
||||
.join(" ");
|
||||
const command = buildScriptCommand(scriptedInput, launchArgs);
|
||||
@@ -90,21 +120,7 @@ function runInteractiveCli(
|
||||
return spawnSync("bash", ["-lc", command], {
|
||||
cwd: cliRoot,
|
||||
encoding: "utf8",
|
||||
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"),
|
||||
},
|
||||
env,
|
||||
timeout: INTERACTIVE_TEST_TIMEOUT_MS,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
@@ -188,6 +204,62 @@ 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: "" }],
|
||||
|
||||
@@ -2,7 +2,37 @@ import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getPersistedProviderApiKey, saveOAuthProviderSettings } from "./auth";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
normalizeAuthProviderId,
|
||||
parseAuthCommandArgs,
|
||||
saveOAuthProviderSettings,
|
||||
} from "./auth";
|
||||
|
||||
describe("parseAuthCommandArgs", () => {
|
||||
it("parses Azure API version quick setup option", () => {
|
||||
expect(
|
||||
parseAuthCommandArgs([
|
||||
"--provider",
|
||||
"openai-compatible",
|
||||
"--apikey",
|
||||
"key",
|
||||
"--modelid",
|
||||
"gpt-4.1",
|
||||
"--baseurl",
|
||||
"https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
"--azure-api-version",
|
||||
"2025-01-01-preview",
|
||||
]),
|
||||
).toMatchObject({
|
||||
explicitProvider: "openai-compatible",
|
||||
apikey: "key",
|
||||
modelid: "gpt-4.1",
|
||||
baseurl: "https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveOAuthProviderSettings", () => {
|
||||
it("preserves existing manual apiKey while updating OAuth tokens", () => {
|
||||
@@ -67,6 +97,12 @@ describe("getPersistedProviderApiKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAuthProviderId", () => {
|
||||
it("keeps CLI-only codex shorthand in CLI parsing", () => {
|
||||
expect(normalizeAuthProviderId("codex")).toBe("openai-codex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAuthTuiRuntime", () => {
|
||||
it("loads OpenTUI React after provider catalog initialization", async () => {
|
||||
const cliRoot = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
+46
-125
@@ -3,11 +3,12 @@ import {
|
||||
BUILT_IN_PROVIDER,
|
||||
createOAuthClientCallbacks,
|
||||
ensureCustomProvidersLoaded,
|
||||
listLocalProviders,
|
||||
getProviderAuthHandler,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
saveProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import React from "react";
|
||||
@@ -20,6 +21,8 @@ import {
|
||||
type OAuthCredentials,
|
||||
toProviderApiKey,
|
||||
} from "../utils/provider-auth";
|
||||
import { listLocalProviders } from "../utils/provider-catalog";
|
||||
import { identifyTelemetryAccount } from "../utils/telemetry";
|
||||
|
||||
export {
|
||||
getPersistedProviderApiKey,
|
||||
@@ -37,40 +40,6 @@ const c = {
|
||||
green: "\x1b[32m",
|
||||
};
|
||||
|
||||
type CoreOAuthApi = {
|
||||
loginClineOAuth: (input: {
|
||||
apiBaseUrl: string;
|
||||
useWorkOSDeviceAuth?: boolean;
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>;
|
||||
loginOcaOAuth: (input: {
|
||||
mode?: "internal" | "external";
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>;
|
||||
loginOpenAICodex: (input: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}) => Promise<OAuthCredentials>;
|
||||
};
|
||||
|
||||
type AuthIo = {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
@@ -81,6 +50,7 @@ type AuthQuickSetupInput = {
|
||||
apikey: string;
|
||||
modelid: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
};
|
||||
|
||||
type AuthCommandInput = {
|
||||
@@ -90,6 +60,7 @@ type AuthCommandInput = {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
};
|
||||
|
||||
type ParsedAuthCommandArgs = {
|
||||
@@ -97,30 +68,10 @@ type ParsedAuthCommandArgs = {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
parseError?: string;
|
||||
};
|
||||
|
||||
let cachedCoreOAuthApi: Promise<CoreOAuthApi> | undefined;
|
||||
|
||||
async function getCoreOAuthApi(): Promise<CoreOAuthApi> {
|
||||
if (!cachedCoreOAuthApi) {
|
||||
cachedCoreOAuthApi = import("@cline/core").then((module) => {
|
||||
const runtimeApi = module as Partial<CoreOAuthApi>;
|
||||
if (
|
||||
typeof runtimeApi.loginClineOAuth !== "function" ||
|
||||
typeof runtimeApi.loginOcaOAuth !== "function" ||
|
||||
typeof runtimeApi.loginOpenAICodex !== "function"
|
||||
) {
|
||||
throw new Error(
|
||||
"Installed @cline/core does not expose OAuth login helpers required by the CLI",
|
||||
);
|
||||
}
|
||||
return runtimeApi as CoreOAuthApi;
|
||||
});
|
||||
}
|
||||
return cachedCoreOAuthApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the `auth` subcommand for Commander.
|
||||
*
|
||||
@@ -137,7 +88,8 @@ export function createAuthCommand(): Command {
|
||||
.option("-p, --provider <id>", "provider id")
|
||||
.option("-k, --apikey <key>", "API key")
|
||||
.option("-m, --modelid <id>", "model id")
|
||||
.option("-b, --baseurl <url>", "base URL");
|
||||
.option("-b, --baseurl <url>", "base URL")
|
||||
.option("--azure-api-version <version>", "Azure API version");
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -154,6 +106,7 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
}>();
|
||||
const positionalProvider = cmd.args[0];
|
||||
return {
|
||||
@@ -161,6 +114,7 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
|
||||
apikey: opts.apikey,
|
||||
modelid: opts.modelid,
|
||||
baseurl: opts.baseurl,
|
||||
azureApiVersion: opts.azureApiVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -200,6 +154,12 @@ async function ensureQuickSetupInputValid(
|
||||
) {
|
||||
return "base URL is only supported for OpenAI and OpenAI-compatible providers";
|
||||
}
|
||||
if (
|
||||
input.azureApiVersion?.trim() &&
|
||||
normalizedProvider !== BUILT_IN_PROVIDER.OPENAI_COMPATIBLE
|
||||
) {
|
||||
return "Azure API version is only supported for OpenAI-compatible providers";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -209,6 +169,7 @@ function saveQuickAuthProviderSettings(input: {
|
||||
apikey: string;
|
||||
modelid: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
}): void {
|
||||
const existing = input.providerSettingsManager.getProviderSettings(
|
||||
input.providerId,
|
||||
@@ -224,6 +185,12 @@ function saveQuickAuthProviderSettings(input: {
|
||||
if (input.baseurl?.trim()) {
|
||||
nextSettings.baseUrl = input.baseurl.trim();
|
||||
}
|
||||
if (input.azureApiVersion?.trim()) {
|
||||
nextSettings.azure = {
|
||||
...(nextSettings.azure ?? {}),
|
||||
apiVersion: input.azureApiVersion.trim(),
|
||||
};
|
||||
}
|
||||
input.providerSettingsManager.saveProviderSettings(nextSettings);
|
||||
}
|
||||
|
||||
@@ -272,64 +239,18 @@ function createOAuthCallbacks(io: AuthIo): {
|
||||
});
|
||||
}
|
||||
|
||||
async function loginWithOAuthProvider(
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
io: AuthIo,
|
||||
): Promise<OAuthCredentials> {
|
||||
const oauthApi = await getCoreOAuthApi();
|
||||
const callbacks = createOAuthCallbacks(io);
|
||||
|
||||
if (providerId === "cline") {
|
||||
return oauthApi.loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
useWorkOSDeviceAuth: true,
|
||||
callbacks,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "oca") {
|
||||
const mode = existing?.oca?.mode;
|
||||
return oauthApi.loginOcaOAuth({
|
||||
mode,
|
||||
callbacks,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "openai-codex") {
|
||||
return oauthApi.loginOpenAICodex(callbacks);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Provider "${providerId}" does not support CLI OAuth flow (supported: cline, openai-codex, oca)`,
|
||||
);
|
||||
}
|
||||
|
||||
export function saveOAuthProviderSettings(
|
||||
providerSettingsManager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
credentials: OAuthCredentials,
|
||||
): ProviderSettings {
|
||||
const auth = {
|
||||
...(existing?.auth ?? {}),
|
||||
accessToken: toProviderApiKey(providerId, credentials),
|
||||
refreshToken: credentials.refresh,
|
||||
accountId: credentials.accountId,
|
||||
} as ProviderSettings["auth"] & { expiresAt?: number };
|
||||
auth.expiresAt = credentials.expires;
|
||||
const merged: ProviderSettings = {
|
||||
...(existing ?? {
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
}),
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
auth,
|
||||
};
|
||||
providerSettingsManager.saveProviderSettings(merged, {
|
||||
tokenSource: "oauth",
|
||||
return saveProviderOAuthCredentials({
|
||||
manager: providerSettingsManager,
|
||||
providerId,
|
||||
settings: existing,
|
||||
credentials,
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function ensureOAuthProviderApiKey(input: {
|
||||
@@ -348,19 +269,14 @@ export async function ensureOAuthProviderApiKey(input: {
|
||||
selectedProviderSettings: input.existingSettings,
|
||||
};
|
||||
}
|
||||
const credentials = await loginWithOAuthProvider(
|
||||
input.providerId,
|
||||
input.existingSettings,
|
||||
input.io,
|
||||
);
|
||||
const selectedProviderSettings = saveOAuthProviderSettings(
|
||||
const selectedProviderSettings = await loginAndSaveProviderOAuthCredentials(
|
||||
input.providerSettingsManager,
|
||||
input.providerId,
|
||||
input.existingSettings,
|
||||
credentials,
|
||||
{ callbacks: createOAuthCallbacks(input.io) },
|
||||
);
|
||||
const handler = getProviderAuthHandler(input.providerId);
|
||||
return {
|
||||
apiKey: toProviderApiKey(input.providerId, credentials),
|
||||
apiKey: handler?.getApiKey(selectedProviderSettings),
|
||||
selectedProviderSettings,
|
||||
};
|
||||
}
|
||||
@@ -370,12 +286,14 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
|
||||
const apikey = input.apikey?.trim() ?? "";
|
||||
const modelid = input.modelid?.trim() ?? "";
|
||||
const baseurl = input.baseurl?.trim();
|
||||
const azureApiVersion = input.azureApiVersion?.trim();
|
||||
const validationError = await ensureQuickSetupInputValid(
|
||||
{
|
||||
provider: providerId,
|
||||
apikey,
|
||||
modelid,
|
||||
baseurl,
|
||||
azureApiVersion,
|
||||
},
|
||||
input.providerSettingsManager,
|
||||
);
|
||||
@@ -389,6 +307,7 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
|
||||
apikey,
|
||||
modelid,
|
||||
baseurl,
|
||||
azureApiVersion,
|
||||
});
|
||||
input.io.writeln(
|
||||
`${c.green}Provider configured:${c.reset} ${c.cyan}${providerId}${c.reset} (${modelid})`,
|
||||
@@ -473,12 +392,13 @@ export async function runAuthCommand(input: AuthCommandInput): Promise<number> {
|
||||
const hasQuickSetupFlags =
|
||||
typeof input.apikey === "string" ||
|
||||
typeof input.modelid === "string" ||
|
||||
typeof input.baseurl === "string";
|
||||
typeof input.baseurl === "string" ||
|
||||
typeof input.azureApiVersion === "string";
|
||||
|
||||
if (hasQuickSetupFlags) {
|
||||
if (!input.explicitProvider?.trim()) {
|
||||
input.io.writeErr(
|
||||
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl",
|
||||
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl/--azure-api-version",
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
@@ -515,14 +435,15 @@ export async function runAuthProviderCommand(
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const existing = providerSettingsManager.getProviderSettings(providerId);
|
||||
const credentials = await loginWithOAuthProvider(providerId, existing, io);
|
||||
saveOAuthProviderSettings(
|
||||
const settings = await loginAndSaveProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
{ 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}`,
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getCliBuildInfo } from "../utils/common";
|
||||
const {
|
||||
mockSpawnSync,
|
||||
mockResolveClineDataDir,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockReadHubDiscovery,
|
||||
mockProbeHubServer,
|
||||
@@ -24,6 +25,15 @@ 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(
|
||||
@@ -52,6 +62,7 @@ vi.mock("node:child_process", () => ({
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
resolveClineDataDir: mockResolveClineDataDir,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
@@ -76,6 +87,15 @@ 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,
|
||||
@@ -110,7 +130,8 @@ describe("runDoctorCommand", () => {
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "/apps/cli/src/index.ts"
|
||||
args[1] === "--" &&
|
||||
args[2] === "/apps/cli/src/index.ts"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
@@ -261,7 +282,8 @@ describe("runDoctorCommand", () => {
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "/src-tauri/bin/code-sidecar"
|
||||
args[1] === "--" &&
|
||||
args[2] === "/src-tauri/bin/code-sidecar"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
@@ -54,6 +55,7 @@ type DoctorStatus = {
|
||||
hubStartedAt?: string;
|
||||
hubUptime?: string;
|
||||
listeningPids: number[];
|
||||
staleHubPids: number[];
|
||||
hubStartupLocks: StartupArtifact[];
|
||||
staleCliPids: number[];
|
||||
staleSidecarPids: number[];
|
||||
@@ -77,7 +79,11 @@ function listMatchingProcesses(pattern: string): ProcessRecord[] {
|
||||
if (process.platform === "win32") {
|
||||
return [];
|
||||
}
|
||||
const result = spawnSync("pgrep", ["-fal", pattern], { encoding: "utf8" });
|
||||
// "--" 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",
|
||||
});
|
||||
if (result.status !== 0 && result.status !== 1) {
|
||||
return [];
|
||||
}
|
||||
@@ -148,6 +154,25 @@ 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",
|
||||
@@ -235,7 +260,7 @@ function readStartupArtifact(path: string): StartupArtifact | undefined {
|
||||
}
|
||||
|
||||
function listHubStartupLocks(_cwd: string): StartupArtifact[] {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const ownerPath = join(`${owner.discoveryPath}.lock`, "owner.json");
|
||||
if (!existsSync(ownerPath)) {
|
||||
return [];
|
||||
@@ -259,7 +284,7 @@ async function clearHubStartupArtifacts(
|
||||
_cwd: string,
|
||||
options?: { clearDiscovery?: boolean },
|
||||
): Promise<{ startupLocks: number; discovery: number }> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const startupLocks = listHubStartupLocks(_cwd);
|
||||
let clearedStartupLocks = 0;
|
||||
for (const artifact of startupLocks) {
|
||||
@@ -291,14 +316,25 @@ function formatHubUptimeFromStartedAt(
|
||||
return formatUptime(Date.now() - timestamp);
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url)
|
||||
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
|
||||
: 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,
|
||||
@@ -306,7 +342,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
hubPid: current?.pid,
|
||||
hubStartedAt: health?.startedAt,
|
||||
hubUptime,
|
||||
listeningPids: listListeningPids(current?.port),
|
||||
listeningPids,
|
||||
staleHubPids: listStaleHubPids(currentHubPids),
|
||||
hubStartupLocks: listHubStartupLocks(cwd),
|
||||
staleCliPids: listStaleCliPids(),
|
||||
staleSidecarPids: listStaleSidecarPids(),
|
||||
@@ -388,6 +425,7 @@ 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",
|
||||
@@ -412,6 +450,7 @@ export async function runDoctorCommand(
|
||||
}
|
||||
if (
|
||||
before.listeningPids.length > 0 ||
|
||||
before.staleHubPids.length > 0 ||
|
||||
before.staleCliPids.length > 0 ||
|
||||
before.staleSidecarPids.length > 0
|
||||
) {
|
||||
@@ -423,7 +462,9 @@ export async function runDoctorCommand(
|
||||
}
|
||||
|
||||
const gracefullyStoppedHub = before.hubHealthy
|
||||
? await stopLocalHubServerGracefully().catch(() => false)
|
||||
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
|
||||
() => false,
|
||||
)
|
||||
: false;
|
||||
const refreshedAfterGracefulStop = gracefullyStoppedHub
|
||||
? await collectDoctorStatus(opts.cwd)
|
||||
@@ -431,13 +472,20 @@ export async function runDoctorCommand(
|
||||
const killedHub = gracefullyStoppedHub
|
||||
? 0
|
||||
: killPids(refreshedAfterGracefulStop.listeningPids);
|
||||
const staleCliTargets = before.staleCliPids.filter(
|
||||
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),
|
||||
);
|
||||
const killedCli = killPids(staleCliTargets);
|
||||
const staleSidecarTargets = before.staleSidecarPids.filter(
|
||||
(pid) =>
|
||||
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
|
||||
!staleHubTargets.includes(pid) &&
|
||||
!staleCliTargets.includes(pid),
|
||||
);
|
||||
const killedSidecars = killPids(staleSidecarTargets);
|
||||
@@ -459,6 +507,7 @@ export async function runDoctorCommand(
|
||||
after,
|
||||
killed: {
|
||||
hubListeners: killedHub,
|
||||
staleHubDaemons: killedStaleHubs,
|
||||
cliProcesses: killedCli,
|
||||
sidecarProcesses: killedSidecars,
|
||||
connectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
@@ -471,6 +520,7 @@ 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(
|
||||
@@ -487,6 +537,7 @@ 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,10 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockClearHubDiscovery,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockProbeHubServer,
|
||||
mockReadHubDiscovery,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStopLocalHubServerGracefully,
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -12,6 +13,10 @@ 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",
|
||||
@@ -24,13 +29,25 @@ 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(),
|
||||
@@ -73,4 +90,37 @@ 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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,11 @@ import {
|
||||
ensureDetachedHubServer,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
|
||||
interface HubCommandIo {
|
||||
@@ -15,9 +16,9 @@ interface HubCommandIo {
|
||||
}
|
||||
|
||||
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (await stopLocalHubServerGracefully()) {
|
||||
if (await stopLocalHubServerGracefully(owner)) {
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return true;
|
||||
}
|
||||
@@ -46,6 +47,12 @@ function formatHubUptimeFromStartedAt(
|
||||
return formatUptime(Date.now() - timestamp);
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
export function createHubCommand(
|
||||
io: HubCommandIo,
|
||||
setExitCode: (code: number) => void,
|
||||
@@ -112,10 +119,12 @@ export function createHubCommand(
|
||||
|
||||
hub.command("status").action(
|
||||
action(async () => {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url)
|
||||
? await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
})
|
||||
: undefined;
|
||||
const uptime = formatHubUptimeFromStartedAt(health?.startedAt);
|
||||
io.writeln(
|
||||
|
||||
@@ -168,6 +168,8 @@ export function buildKanbanSpawnOptions(
|
||||
detached: shouldDetachKanbanProcess(platform),
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,6 +180,8 @@ function buildKanbanInstallSpawnOptions(
|
||||
return {
|
||||
detached: false,
|
||||
stdio: "inherit",
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
};
|
||||
@@ -203,6 +207,8 @@ 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;
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@cline/shared/storage";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectPluginMcpOAuthCandidates,
|
||||
installPlugin,
|
||||
isOfficialPluginSlug,
|
||||
parsePluginSource,
|
||||
@@ -35,6 +36,7 @@ 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-"));
|
||||
@@ -43,6 +45,7 @@ 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");
|
||||
@@ -91,6 +94,11 @@ 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 });
|
||||
});
|
||||
|
||||
@@ -676,11 +684,341 @@ 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": {
|
||||
|
||||
@@ -21,7 +21,15 @@ import {
|
||||
resolve,
|
||||
sep,
|
||||
} from "node:path";
|
||||
import { type PluginUninstallOptions, uninstallPlugin } from "@cline/core";
|
||||
import {
|
||||
type McpServerRegistration,
|
||||
type PluginMcpSettingsSyncResult,
|
||||
type PluginUninstallOptions,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
resolveMcpServerRegistrations,
|
||||
syncPluginMcpServersToSettings,
|
||||
uninstallPlugin,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
isPluginModulePath,
|
||||
resolveClineDir,
|
||||
@@ -36,12 +44,31 @@ 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 {
|
||||
@@ -506,6 +533,8 @@ 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) => {
|
||||
@@ -1003,6 +1032,81 @@ 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> {
|
||||
@@ -1069,28 +1173,161 @@ export async function installPlugin(
|
||||
}
|
||||
|
||||
replaceInstallPath(stagingRoot, installPath, force);
|
||||
return {
|
||||
const result = {
|
||||
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(result));
|
||||
process.stdout.write(
|
||||
JSON.stringify(serializePluginInstallResult(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);
|
||||
|
||||
@@ -78,6 +78,74 @@ describe("saveLocalProviderSettings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("merges and clears Azure provider settings", () => {
|
||||
const save = vi.fn();
|
||||
const manager = {
|
||||
read: vi.fn().mockReturnValue({
|
||||
providers: {},
|
||||
}),
|
||||
write: vi.fn(),
|
||||
getFilePath: vi.fn().mockReturnValue("/tmp/providers.json"),
|
||||
getProviderSettings: vi.fn().mockReturnValue({
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2024-10-21",
|
||||
useIdentity: true,
|
||||
},
|
||||
}),
|
||||
saveProviderSettings: save,
|
||||
};
|
||||
|
||||
saveLocalProviderSettings(
|
||||
manager as unknown as ProviderSettingsManager,
|
||||
{
|
||||
action: "saveProviderSettings",
|
||||
providerId: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
},
|
||||
} as SaveProviderSettingsActionRequest,
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
useIdentity: true,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
|
||||
save.mockClear();
|
||||
manager.getProviderSettings.mockReturnValue({
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
},
|
||||
});
|
||||
|
||||
saveLocalProviderSettings(
|
||||
manager as unknown as ProviderSettingsManager,
|
||||
{
|
||||
action: "saveProviderSettings",
|
||||
providerId: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "",
|
||||
},
|
||||
} as SaveProviderSettingsActionRequest,
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "openai-compatible",
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps OAuth auth fields when updating manual apiKey", () => {
|
||||
const save = vi.fn();
|
||||
const manager = {
|
||||
|
||||
@@ -7,10 +7,14 @@ 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;
|
||||
@@ -32,6 +36,21 @@ 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 {
|
||||
@@ -96,6 +115,21 @@ 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 {
|
||||
@@ -153,6 +187,39 @@ 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(
|
||||
|
||||
@@ -5,9 +5,11 @@ 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";
|
||||
@@ -269,13 +271,22 @@ 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).catch(() => undefined);
|
||||
const check = await probeHubServer(url, { authToken }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
if (!check?.url) return true;
|
||||
await sleep(100);
|
||||
}
|
||||
@@ -288,20 +299,22 @@ async function waitForHubToStop(
|
||||
* clears stale discovery, then re-ensures a fresh instance is spawned.
|
||||
*/
|
||||
async function restartHubServerIfRunning(): Promise<void> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url).catch(() => undefined)
|
||||
? await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
}).catch(() => undefined)
|
||||
: undefined;
|
||||
if (!health?.url) return;
|
||||
if (!discovery || !health?.url) return;
|
||||
|
||||
const pid = discovery?.pid;
|
||||
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
|
||||
|
||||
let stopped = await stopLocalHubServerGracefully().catch(() => false);
|
||||
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
@@ -310,14 +323,14 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
stopped = await waitForHubToStop(health.url, 3_000);
|
||||
stopped = await waitForHubToStop(health.url, discovery.authToken, 3_000);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
stopped = await waitForHubToStop(health.url, 2_000);
|
||||
stopped = await waitForHubToStop(health.url, discovery.authToken, 2_000);
|
||||
}
|
||||
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
@@ -362,6 +375,9 @@ 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) {
|
||||
|
||||
@@ -194,6 +194,9 @@ 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,15 +1,17 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, 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 () => {
|
||||
@@ -18,8 +20,8 @@ vi.mock("@cline/core", async () => {
|
||||
return {
|
||||
...actual,
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return mockGetLastUsedProviderSettings();
|
||||
getLastUsedProviderSettings(options?: unknown) {
|
||||
return mockGetLastUsedProviderSettings(options);
|
||||
}
|
||||
|
||||
getProviderSettings(providerId: string) {
|
||||
@@ -43,6 +45,12 @@ 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")>(
|
||||
@@ -57,6 +65,10 @@ 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;
|
||||
@@ -88,5 +100,64 @@ 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ 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,
|
||||
@@ -62,7 +63,10 @@ export async function buildConnectorStartRequest(input: {
|
||||
}): Promise<ChatStartSessionRequest> {
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings();
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
input.options.provider?.trim() ||
|
||||
lastUsedProviderSettings?.provider ||
|
||||
|
||||
+168
-6
@@ -29,7 +29,9 @@ const authMocks = vi.hoisted(() => ({
|
||||
runAuthCommand: vi.fn(),
|
||||
}));
|
||||
const providerSettingsMocks = vi.hoisted(() => ({
|
||||
getLastUsedProviderSettings: vi.fn<() => unknown>(() => undefined),
|
||||
getLastUsedProviderSettings: vi.fn<(options?: unknown) => unknown>(
|
||||
() => undefined,
|
||||
),
|
||||
getProviderConfig: vi.fn<(providerId: string, options?: unknown) => unknown>(
|
||||
() => undefined,
|
||||
),
|
||||
@@ -82,6 +84,11 @@ 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: {
|
||||
@@ -101,10 +108,13 @@ const hubRuntimeMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
const telemetryMocks = vi.hoisted(() => ({
|
||||
captureCliExtensionActivated: vi.fn(),
|
||||
identifyCliTelemetryAccount: vi.fn(),
|
||||
identifyTelemetryAccount: vi.fn(),
|
||||
getCliTelemetryService: vi.fn(),
|
||||
disposeCliTelemetryService: vi.fn(async () => {}),
|
||||
}));
|
||||
const featureFlagMocks = vi.hoisted(() => ({
|
||||
getBooleanFlagEnabled: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
function forcePromptModeInput() {
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
@@ -148,8 +158,8 @@ vi.mock("@cline/core", () => {
|
||||
stop: vi.fn(),
|
||||
})),
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return providerSettingsMocks.getLastUsedProviderSettings();
|
||||
getLastUsedProviderSettings(options?: unknown) {
|
||||
return providerSettingsMocks.getLastUsedProviderSettings(options);
|
||||
}
|
||||
getProviderSettings(providerId: string) {
|
||||
return providerSettingsMocks.getProviderSettings(providerId);
|
||||
@@ -164,6 +174,12 @@ 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,
|
||||
}));
|
||||
@@ -172,6 +188,7 @@ 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);
|
||||
@@ -191,6 +208,8 @@ 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",
|
||||
@@ -246,7 +265,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
updateMocks.getPreferredKanbanInstaller.mockReset();
|
||||
updateMocks.getPreferredKanbanInstaller.mockReturnValue(undefined);
|
||||
telemetryMocks.captureCliExtensionActivated.mockReset();
|
||||
telemetryMocks.identifyCliTelemetryAccount.mockReset();
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
telemetryMocks.getCliTelemetryService.mockReset();
|
||||
telemetryMocks.disposeCliTelemetryService.mockReset();
|
||||
telemetryMocks.disposeCliTelemetryService.mockResolvedValue(undefined);
|
||||
@@ -719,10 +738,47 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forces chat view when resuming from history picker", async () => {
|
||||
it("resumes a history-picked session in a child process", 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");
|
||||
@@ -953,6 +1009,76 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("defaults thinking to medium for reasoning-capable selected models", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
llmMocks.resolveProviderConfig.mockResolvedValue({
|
||||
knownModels: {
|
||||
"openai/gpt-5": {
|
||||
id: "openai/gpt-5",
|
||||
name: "GPT-5",
|
||||
capabilities: ["tools", "reasoning"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "-m", "openai/gpt-5", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
modelId: "openai/gpt-5",
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps thinking disabled when explicitly set to none for reasoning models", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
llmMocks.resolveProviderConfig.mockResolvedValue({
|
||||
knownModels: {
|
||||
"openai/gpt-5": {
|
||||
id: "openai/gpt-5",
|
||||
name: "GPT-5",
|
||||
capabilities: ["tools", "reasoning"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = [
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"-m",
|
||||
"openai/gpt-5",
|
||||
"--thinking",
|
||||
"none",
|
||||
"hello",
|
||||
];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
modelId: "openai/gpt-5",
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("maps --thinking to medium effort", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
@@ -1004,6 +1130,42 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps persisted disabled reasoning when --thinking is not provided", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
model: "openai/gpt-5",
|
||||
reasoning: { enabled: false },
|
||||
});
|
||||
llmMocks.resolveProviderConfig.mockResolvedValue({
|
||||
knownModels: {
|
||||
"openai/gpt-5": {
|
||||
id: "openai/gpt-5",
|
||||
name: "GPT-5",
|
||||
capabilities: ["tools", "reasoning"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(mockState.runAgentCalls).toBe(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
modelId: "openai/gpt-5",
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers explicit --thinking over persisted reasoning effort", async () => {
|
||||
mockState.runAgentCalls = 0;
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
+54
-7
@@ -19,6 +19,10 @@ import {
|
||||
buildCliCompactionConfig,
|
||||
CLI_COMPACTION_MODE_EXPECTED_TEXT,
|
||||
} from "./utils/compaction-mode";
|
||||
import {
|
||||
getCliFeatureFlagsService,
|
||||
refreshCliFeatureFlagsInBackground,
|
||||
} from "./utils/feature-flags";
|
||||
import {
|
||||
configureSandboxEnvironment,
|
||||
normalizeAutoApproveArgs,
|
||||
@@ -57,6 +61,13 @@ export function stdinHasPipedInput(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function modelSupportsReasoning(
|
||||
knownModels: Config["knownModels"],
|
||||
modelId: string,
|
||||
): boolean {
|
||||
return knownModels?.[modelId]?.capabilities?.includes("reasoning") ?? false;
|
||||
}
|
||||
|
||||
async function createProviderSettingsManager() {
|
||||
const { ProviderSettingsManager } = await import("@cline/core");
|
||||
return new ProviderSettingsManager();
|
||||
@@ -152,6 +163,7 @@ export async function runCli(): Promise<void> {
|
||||
.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("--config <dir>", "configuration directory")
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option(
|
||||
@@ -165,6 +177,7 @@ export async function runCli(): Promise<void> {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
config?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
@@ -195,6 +208,7 @@ export async function runCli(): Promise<void> {
|
||||
apikey: opts.apikey,
|
||||
modelid: opts.modelid,
|
||||
baseurl: opts.baseurl,
|
||||
azureApiVersion: opts.azureApiVersion,
|
||||
io,
|
||||
});
|
||||
});
|
||||
@@ -660,6 +674,21 @@ 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,
|
||||
@@ -833,8 +862,12 @@ export async function runCli(): Promise<void> {
|
||||
};
|
||||
registerDisposable(stopUserInstructionService);
|
||||
try {
|
||||
refreshCliFeatureFlagsInBackground();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings();
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
|
||||
);
|
||||
@@ -901,8 +934,17 @@ export async function runCli(): Promise<void> {
|
||||
);
|
||||
}
|
||||
const knownModelIds = knownModels ? Object.keys(knownModels) : [];
|
||||
const selectedModelId =
|
||||
args.model ??
|
||||
selectedProviderSettings?.model ??
|
||||
knownModelIds[0] ??
|
||||
"anthropic/claude-sonnet-4.6";
|
||||
const persistedReasoning = selectedProviderSettings?.reasoning;
|
||||
const persistedReasoningEffort = persistedReasoning?.effort;
|
||||
const hasPersistedReasoning =
|
||||
persistedReasoning?.enabled !== undefined ||
|
||||
persistedReasoning?.effort !== undefined ||
|
||||
persistedReasoning?.budgetTokens !== undefined;
|
||||
const reasoningEffortFromSettings =
|
||||
persistedReasoning?.enabled === false
|
||||
? "none"
|
||||
@@ -911,9 +953,18 @@ export async function runCli(): Promise<void> {
|
||||
: persistedReasoning?.enabled === true
|
||||
? "medium"
|
||||
: "none";
|
||||
const reasoningEffortFromModel = modelSupportsReasoning(
|
||||
knownModels,
|
||||
selectedModelId,
|
||||
)
|
||||
? "medium"
|
||||
: "none";
|
||||
const effectiveReasoningEffort = args.thinkingExplicitlySet
|
||||
? (args.reasoningEffort ?? "none")
|
||||
: (args.reasoningEffort ?? reasoningEffortFromSettings);
|
||||
: (args.reasoningEffort ??
|
||||
(hasPersistedReasoning
|
||||
? reasoningEffortFromSettings
|
||||
: reasoningEffortFromModel));
|
||||
const { createCliLoggerAdapter } = await import("./logging/adapter");
|
||||
const loggerAdapter = createCliLoggerAdapter({
|
||||
runtime: "cli",
|
||||
@@ -927,11 +978,7 @@ export async function runCli(): Promise<void> {
|
||||
|
||||
const config: Config = {
|
||||
providerId: provider,
|
||||
modelId:
|
||||
args.model ??
|
||||
selectedProviderSettings?.model ??
|
||||
knownModelIds[0] ??
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
modelId: selectedModelId,
|
||||
apiKey: apiKey ?? "",
|
||||
knownModels,
|
||||
systemPrompt: await resolveSystemPrompt({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type ChatCommandState,
|
||||
chatCommandHost,
|
||||
createChatCommandHost,
|
||||
} from "../../utils/chat-commands";
|
||||
import type { Config } from "../../utils/types";
|
||||
import {
|
||||
@@ -162,4 +163,37 @@ 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 };
|
||||
| { handled: false; input: string; commandOutput?: string };
|
||||
|
||||
function commandTurnResult(commandOutput?: string): InteractiveTurnResult {
|
||||
return {
|
||||
@@ -46,6 +46,7 @@ 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);
|
||||
@@ -64,6 +65,7 @@ export async function runInteractiveChatCommand(input: {
|
||||
}
|
||||
|
||||
let commandOutput: string | undefined;
|
||||
let submitPrompt: string | undefined;
|
||||
const handled = await maybeHandleChatCommand(prompt, {
|
||||
enabled: input.enabled,
|
||||
host: input.host,
|
||||
@@ -80,6 +82,13 @@ 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();
|
||||
@@ -98,6 +107,13 @@ 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,4 +1,11 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
chmod,
|
||||
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";
|
||||
@@ -43,9 +50,17 @@ describe("interactive config data loader", () => {
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH =
|
||||
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
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;
|
||||
}
|
||||
await Promise.all(
|
||||
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
@@ -76,6 +91,28 @@ 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);
|
||||
@@ -311,6 +348,70 @@ 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);
|
||||
@@ -731,6 +832,142 @@ 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,7 +1,9 @@
|
||||
import {
|
||||
createCoreSettingsService,
|
||||
disablePluginMcpServersInSettings,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
syncPluginMcpServersToSettings,
|
||||
type UserInstructionConfigService,
|
||||
uninstallPlugin,
|
||||
} from "@cline/core";
|
||||
@@ -70,7 +72,32 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
}
|
||||
|
||||
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
|
||||
setDisabledPlugin(item.path, item.enabled);
|
||||
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);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { CliMigrationNotice } from "../kanban-migration/notice";
|
||||
import { logCliError } from "../logging/errors";
|
||||
import {
|
||||
loadClineAccountSnapshot,
|
||||
onProviderChange,
|
||||
switchClineAccount,
|
||||
} from "../tui/cline-account";
|
||||
import type {
|
||||
@@ -427,7 +428,8 @@ export async function runInteractive(
|
||||
uiEvents.off("pending-prompt-submitted", onPendingPromptSubmitted);
|
||||
};
|
||||
},
|
||||
onSubmit: async (input, mode, delivery, attachments) => {
|
||||
onSubmit: async (input, mode, delivery, attachments, onCommandOutput) => {
|
||||
let commandOutput: string | undefined;
|
||||
try {
|
||||
await sessionRuntime.ensureReady();
|
||||
await waitForSubmittedMode(mode);
|
||||
@@ -446,6 +448,7 @@ export async function runInteractive(
|
||||
setInteractiveAutoApprove,
|
||||
sessionRuntime,
|
||||
stop: () => tuiApp?.destroy(),
|
||||
onCommandOutput,
|
||||
});
|
||||
if (chatCommandResult.handled) {
|
||||
return chatCommandResult.turnResult;
|
||||
@@ -465,12 +468,14 @@ 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,
|
||||
@@ -507,6 +512,7 @@ export async function runInteractive(
|
||||
iterations: 0,
|
||||
finishReason: "queued",
|
||||
queued: delivery === "queue" || delivery === "steer",
|
||||
commandOutput,
|
||||
};
|
||||
}
|
||||
if (result.finishReason !== "completed") {
|
||||
@@ -519,6 +525,7 @@ export async function runInteractive(
|
||||
currentContextSize: getCurrentContextSize(result.messages),
|
||||
iterations: result.iterations,
|
||||
finishReason: "aborted",
|
||||
commandOutput,
|
||||
};
|
||||
}
|
||||
const errorText = result.text.trim();
|
||||
@@ -532,6 +539,7 @@ export async function runInteractive(
|
||||
currentContextSize: getCurrentContextSize(result.messages),
|
||||
iterations: result.iterations,
|
||||
finishReason: result.finishReason,
|
||||
commandOutput,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortInProgress()) {
|
||||
@@ -539,6 +547,7 @@ export async function runInteractive(
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
iterations: 0,
|
||||
finishReason: "aborted",
|
||||
commandOutput,
|
||||
};
|
||||
}
|
||||
logCliError(config.logger, "Interactive turn failed", {
|
||||
@@ -603,6 +612,10 @@ export async function runInteractive(
|
||||
},
|
||||
onModelChange: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
config,
|
||||
providerId: config.providerId,
|
||||
});
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
config.providerId,
|
||||
) ?? {
|
||||
@@ -623,6 +636,16 @@ 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) => {
|
||||
|
||||
@@ -12,6 +12,8 @@ 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 =
|
||||
@@ -49,6 +51,10 @@ 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(),
|
||||
@@ -68,6 +74,8 @@ 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(() => {
|
||||
@@ -108,6 +116,7 @@ describe("createCliCore", () => {
|
||||
backendMode: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(featureFlagsPoll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forces the local backend when requested by the caller", async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ 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";
|
||||
@@ -40,6 +41,11 @@ 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
|
||||
@@ -53,12 +59,18 @@ export async function createCliCore(options?: {
|
||||
}
|
||||
: {}),
|
||||
capabilities: options?.capabilities,
|
||||
telemetry: getCliTelemetryService(options?.logger),
|
||||
telemetry,
|
||||
featureFlags,
|
||||
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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
const coreMocks = vi.hoisted(() => {
|
||||
@@ -9,13 +9,20 @@ const coreMocks = vi.hoisted(() => {
|
||||
return {
|
||||
getProviderSettings: vi.fn(),
|
||||
saveProviderSettings: vi.fn(),
|
||||
getValidClineCredentials: vi.fn(),
|
||||
fetchMe: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchOrganizationBalance: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
const telemetryMocks = vi.hoisted(() => ({
|
||||
identifyTelemetryAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => {
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
ClineAccountService: class {
|
||||
constructor(options: {
|
||||
apiBaseUrl: string;
|
||||
@@ -23,6 +30,15 @@ vi.mock("@cline/core", () => {
|
||||
}) {
|
||||
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) {
|
||||
@@ -32,10 +48,13 @@ vi.mock("@cline/core", () => {
|
||||
coreMocks.saveProviderSettings(settings, options);
|
||||
}
|
||||
},
|
||||
getValidClineCredentials: coreMocks.getValidClineCredentials,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/telemetry", () => ({
|
||||
identifyTelemetryAccount: telemetryMocks.identifyTelemetryAccount,
|
||||
}));
|
||||
|
||||
function makeConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
providerId: "cline",
|
||||
@@ -59,15 +78,55 @@ function makeConfig(overrides: Partial<Config> = {}): Config {
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
function mockFetchJson(body: unknown, status = 200): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch,
|
||||
);
|
||||
}
|
||||
|
||||
describe("createClineAccountService", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.getValidClineCredentials.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("refreshes persisted Cline OAuth credentials before creating the account service", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: "new-access",
|
||||
refreshToken: "new-refresh",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: "2096-10-02T07:06:40.000Z",
|
||||
userInfo: {
|
||||
subject: "sub-new",
|
||||
email: "new@example.com",
|
||||
name: "New User",
|
||||
clineUserId: "acct-new",
|
||||
accounts: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
@@ -77,26 +136,12 @@ describe("createClineAccountService", () => {
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
coreMocks.getValidClineCredentials.mockResolvedValue({
|
||||
access: "new-access",
|
||||
refresh: "new-refresh",
|
||||
expires: 4_000_000_000_000,
|
||||
accountId: "acct-new",
|
||||
});
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
const service = await createClineAccountService({ config: makeConfig() });
|
||||
|
||||
expect(service).toBeDefined();
|
||||
expect(coreMocks.getValidClineCredentials).toHaveBeenCalledWith(
|
||||
{
|
||||
access: "old-access",
|
||||
refresh: "refresh-token",
|
||||
expires: 1,
|
||||
accountId: "acct-old",
|
||||
},
|
||||
{ apiBaseUrl: "https://api.cline.bot" },
|
||||
);
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
expect(coreMocks.saveProviderSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "cline",
|
||||
@@ -115,6 +160,14 @@ describe("createClineAccountService", () => {
|
||||
});
|
||||
|
||||
it("asks the user to re-authenticate when Cline OAuth credentials cannot refresh", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson(
|
||||
{
|
||||
error: "invalid_grant",
|
||||
error_description: "refresh expired",
|
||||
},
|
||||
401,
|
||||
);
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
@@ -123,7 +176,6 @@ describe("createClineAccountService", () => {
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
coreMocks.getValidClineCredentials.mockResolvedValue(null);
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
|
||||
@@ -134,3 +186,66 @@ 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,20 +4,25 @@ import {
|
||||
type ClineAccountOrganizationBalance,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
getValidClineCredentials,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
|
||||
import { toProviderApiKey } from "../utils/provider-auth";
|
||||
import { identifyTelemetryAccount } from "../utils/telemetry";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
const WORKOS_TOKEN_PREFIX = "workos:";
|
||||
export const CLINE_CREDITS_DASHBOARD_URL =
|
||||
"https://app.cline.bot/dashboard/account?tab=credits";
|
||||
|
||||
type ClineAccountConfig = Pick<Config, "apiKey" | "providerId">;
|
||||
type ClineAccountConfig = Pick<Config, "apiKey" | "logger" | "providerId">;
|
||||
|
||||
const CLINE_PASS_PROVIDER_ID = "cline-pass";
|
||||
|
||||
export interface ClineAccountSnapshot {
|
||||
user: ClineAccountUser;
|
||||
@@ -69,26 +74,13 @@ function resolveClineAccountAuthToken(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): string | undefined {
|
||||
const persistedAccessToken =
|
||||
input.clineProviderSettings?.auth?.accessToken?.trim() || "";
|
||||
const configApiKey =
|
||||
input.config.providerId === "cline" ? input.config.apiKey.trim() : "";
|
||||
const settingsApiKey =
|
||||
input.clineProviderSettings?.apiKey?.trim() ||
|
||||
input.clineProviderSettings?.auth?.apiKey?.trim() ||
|
||||
"";
|
||||
|
||||
let authToken = persistedAccessToken || configApiKey || settingsApiKey;
|
||||
if (authToken.toLowerCase().startsWith("workos:workos:")) {
|
||||
authToken = authToken.slice("workos:".length);
|
||||
}
|
||||
return authToken || undefined;
|
||||
}
|
||||
|
||||
function stripWorkosTokenPrefix(accessToken: string): string {
|
||||
return accessToken.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
|
||||
? accessToken.slice(WORKOS_TOKEN_PREFIX.length)
|
||||
: accessToken;
|
||||
return (
|
||||
getPersistedProviderApiKey("cline", input.clineProviderSettings) ||
|
||||
configApiKey ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveValidClineAccountAuthToken(input: {
|
||||
@@ -98,43 +90,26 @@ async function resolveValidClineAccountAuthToken(input: {
|
||||
apiBaseUrl: string;
|
||||
}): Promise<string | undefined> {
|
||||
const settings = input.clineProviderSettings;
|
||||
const auth = settings?.auth;
|
||||
const accessToken = auth?.accessToken?.trim();
|
||||
const refreshToken = auth?.refreshToken?.trim();
|
||||
if (settings && auth && accessToken && refreshToken) {
|
||||
const credentials = await getValidClineCredentials(
|
||||
{
|
||||
access: stripWorkosTokenPrefix(accessToken),
|
||||
refresh: refreshToken,
|
||||
expires: auth.expiresAt ?? Date.now() - 1,
|
||||
accountId: auth.accountId,
|
||||
},
|
||||
{ apiBaseUrl: input.apiBaseUrl },
|
||||
);
|
||||
if (!credentials) {
|
||||
const credentials = settings
|
||||
? getProviderOAuthCredentialsFromSettings("cline", settings)
|
||||
: null;
|
||||
if (settings && credentials) {
|
||||
const nextCredentials = await getValidClineCredentials(credentials, {
|
||||
apiBaseUrl: input.apiBaseUrl,
|
||||
});
|
||||
if (!nextCredentials) {
|
||||
throw new Error(
|
||||
"Cline account requires re-authentication. Run cline auth cline.",
|
||||
);
|
||||
}
|
||||
const nextAccessToken = toProviderApiKey("cline", credentials);
|
||||
if (
|
||||
nextAccessToken !== accessToken ||
|
||||
credentials.refresh !== refreshToken ||
|
||||
credentials.accountId !== auth.accountId ||
|
||||
credentials.expires !== auth.expiresAt
|
||||
) {
|
||||
input.manager.saveProviderSettings(
|
||||
{
|
||||
...settings,
|
||||
auth: {
|
||||
...(settings.auth ?? {}),
|
||||
accessToken: nextAccessToken,
|
||||
refreshToken: credentials.refresh,
|
||||
accountId: credentials.accountId,
|
||||
expiresAt: credentials.expires,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false, tokenSource: "oauth" },
|
||||
const nextAccessToken = formatProviderOAuthApiKey("cline", nextCredentials);
|
||||
if (nextCredentials !== credentials) {
|
||||
saveLocalProviderOAuthCredentials(
|
||||
input.manager,
|
||||
"cline",
|
||||
settings,
|
||||
nextCredentials,
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
}
|
||||
return nextAccessToken;
|
||||
@@ -195,6 +170,15 @@ 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,
|
||||
@@ -218,3 +202,27 @@ 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,7 +58,11 @@ describe("mcp manager dialog helpers", () => {
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_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;
|
||||
}
|
||||
await Promise.all(
|
||||
tempRoots.map((directory) =>
|
||||
rm(directory, { recursive: true, force: true }),
|
||||
@@ -107,6 +111,44 @@ 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,6 +13,7 @@ export interface McpEntry {
|
||||
enabled?: boolean;
|
||||
description?: string;
|
||||
lastError?: string;
|
||||
pluginName?: string;
|
||||
}
|
||||
|
||||
export type McpServerToggleResult =
|
||||
@@ -36,6 +37,12 @@ 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({
|
||||
@@ -71,6 +78,7 @@ 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") {
|
||||
@@ -150,6 +158,7 @@ export function McpManagerContent(
|
||||
{isSel ? "\u25b8 " : " "}
|
||||
{enabledIcon}
|
||||
{srv.name}
|
||||
{srv.pluginName ? " *" : ""}
|
||||
</text>
|
||||
{status && (
|
||||
<text fg={srv.lastError ? palette.error : "gray"}>
|
||||
@@ -184,6 +193,12 @@ 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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderConfigFields,
|
||||
listLocalProviders,
|
||||
isOAuthProvider,
|
||||
loginLocalProvider,
|
||||
type ProviderConfigFieldKey,
|
||||
type ProviderConfigFieldRequirement,
|
||||
@@ -21,12 +21,14 @@ import {
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import { isOAuthProvider } from "../../../utils/provider-auth";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
type ProviderConfigValues,
|
||||
resolveProviderConfigAwsRegion,
|
||||
resolveProviderConfigAzure,
|
||||
resolveProviderConfigGcp,
|
||||
resolveProviderConfigSap,
|
||||
updateProviderConfigValue,
|
||||
} from "../../utils/provider-config-values";
|
||||
@@ -315,8 +317,11 @@ export function UseExistingOrReconfigureContent(
|
||||
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
|
||||
apiKey: "API key",
|
||||
baseUrl: "Base URL",
|
||||
azureApiVersion: "Azure API Version",
|
||||
awsRegion: "AWS Region",
|
||||
awsProfile: "AWS Profile Name",
|
||||
gcpProjectId: "Google Cloud Project ID",
|
||||
gcpRegion: "Google Cloud Region",
|
||||
sapClientId: "Client ID",
|
||||
sapClientSecret: "Client Secret",
|
||||
sapTokenUrl: "Token URL",
|
||||
@@ -329,8 +334,11 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
|
||||
> = {
|
||||
apiKey: "sk-...",
|
||||
baseUrl: "",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
awsRegion: "us-east-1",
|
||||
awsProfile: "default",
|
||||
gcpProjectId: "my-gcp-project",
|
||||
gcpRegion: "us-central1",
|
||||
sapClientId: "sb-...|xsuaa_std!b...",
|
||||
sapClientSecret: "SAP AI Core client secret",
|
||||
sapTokenUrl: "https://<subdomain>.authentication.sap.hana.ondemand.com",
|
||||
@@ -341,7 +349,10 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
|
||||
/** Render order for cycling focus with Tab. */
|
||||
const FIELD_ORDER: ProviderConfigFieldKey[] = [
|
||||
"awsRegion",
|
||||
"gcpProjectId",
|
||||
"gcpRegion",
|
||||
"baseUrl",
|
||||
"azureApiVersion",
|
||||
"apiKey",
|
||||
"awsProfile",
|
||||
"sapClientId",
|
||||
@@ -398,11 +409,22 @@ export function ProviderConfigInputContent(
|
||||
config.fields.baseUrl?.defaultValue ??
|
||||
"";
|
||||
}
|
||||
if (config.fields.azureApiVersion) {
|
||||
initial.azureApiVersion =
|
||||
existingSettings?.azure?.apiVersion?.trim() ?? "";
|
||||
}
|
||||
if (config.fields.awsRegion) {
|
||||
const ep = existingSettings?.aws?.profile?.trim() ?? "";
|
||||
initial.awsRegion =
|
||||
existingSettings?.aws?.region?.trim() || getDefaultAwsRegion(ep);
|
||||
}
|
||||
if (config.fields.gcpProjectId)
|
||||
initial.gcpProjectId = existingSettings?.gcp?.projectId?.trim() ?? "";
|
||||
if (config.fields.gcpRegion)
|
||||
initial.gcpRegion =
|
||||
existingSettings?.gcp?.region?.trim() ??
|
||||
config.fields.gcpRegion.defaultValue ??
|
||||
"us-central1";
|
||||
if (config.fields.apiKey)
|
||||
initial.apiKey = existingSettings?.apiKey?.trim() ?? "";
|
||||
if (config.fields.awsProfile)
|
||||
@@ -430,7 +452,9 @@ export function ProviderConfigInputContent(
|
||||
const submit = () => {
|
||||
const apiKey = values.apiKey?.trim();
|
||||
const awsProfile = values.awsProfile?.trim();
|
||||
const hasAzureFields = config.fields.azureApiVersion;
|
||||
const hasAwsFields = config.fields.awsRegion || config.fields.awsProfile;
|
||||
const hasGcpFields = config.fields.gcpProjectId || config.fields.gcpRegion;
|
||||
const hasSapFields =
|
||||
config.fields.sapClientId ||
|
||||
config.fields.sapClientSecret ||
|
||||
@@ -441,6 +465,7 @@ export function ProviderConfigInputContent(
|
||||
providerId,
|
||||
apiKey: config.fields.apiKey ? apiKey : undefined,
|
||||
baseUrl: config.fields.baseUrl ? values.baseUrl?.trim() : undefined,
|
||||
azure: hasAzureFields ? resolveProviderConfigAzure(values) : undefined,
|
||||
aws: hasAwsFields
|
||||
? {
|
||||
region: resolveProviderConfigAwsRegion(values),
|
||||
@@ -448,6 +473,7 @@ export function ProviderConfigInputContent(
|
||||
profile: apiKey ? undefined : awsProfile || undefined,
|
||||
}
|
||||
: undefined,
|
||||
gcp: hasGcpFields ? resolveProviderConfigGcp(values) : undefined,
|
||||
sap: hasSapFields ? resolveProviderConfigSap(values) : undefined,
|
||||
});
|
||||
resolve(true);
|
||||
@@ -671,7 +697,7 @@ export function OAuthLoginContent(
|
||||
if (!isActiveAuthAttempt(attempt)) return;
|
||||
saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
@@ -705,30 +731,24 @@ export function OAuthLoginContent(
|
||||
const manager = new ProviderSettingsManager();
|
||||
const existing = manager.getProviderSettings(providerId);
|
||||
|
||||
loginLocalProvider(
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
existing,
|
||||
(url: string) => {
|
||||
setAuthUrl(url);
|
||||
setStatus("Waiting for authentication in browser...");
|
||||
try {
|
||||
void open(url, { wait: false }).catch(() => {
|
||||
setStatus(
|
||||
"Could not open browser automatically. Open the URL below.",
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
loginLocalProvider(providerId, existing, (url: string) => {
|
||||
setAuthUrl(url);
|
||||
setStatus("Waiting for authentication in browser...");
|
||||
try {
|
||||
void open(url, { wait: false }).catch(() => {
|
||||
setStatus(
|
||||
"Could not open browser automatically. Open the URL below.",
|
||||
);
|
||||
}
|
||||
},
|
||||
)
|
||||
});
|
||||
} catch {
|
||||
setStatus("Could not open browser automatically. Open the URL below.");
|
||||
}
|
||||
})
|
||||
.then((credentials) => {
|
||||
if (!isActiveAuthAttempt(attempt)) return;
|
||||
saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId as "cline" | "oca" | "openai-codex",
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
|
||||
@@ -17,7 +17,9 @@ export async function renderHistoryStandalone(input: {
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let result: number | string = 0;
|
||||
let resolved = false;
|
||||
let destroyStarted = false;
|
||||
let unmounted = false;
|
||||
const root = createRoot(renderer);
|
||||
|
||||
@@ -29,24 +31,29 @@ export async function renderHistoryStandalone(input: {
|
||||
root.unmount();
|
||||
};
|
||||
|
||||
const settle = (value: number | string) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
unmountRoot();
|
||||
renderer.destroy();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
// Resolve only once teardown has finished, so callers never run while
|
||||
// the renderer is still restoring the terminal.
|
||||
renderer.on("destroy", () => {
|
||||
unmountRoot();
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(0);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
|
||||
const settle = (value: number | string) => {
|
||||
if (destroyStarted) {
|
||||
return;
|
||||
}
|
||||
destroyStarted = true;
|
||||
result = value;
|
||||
unmountRoot();
|
||||
// Let OpenTUI finish parsing the current stdin batch before teardown.
|
||||
queueMicrotask(() => {
|
||||
renderer.destroy();
|
||||
});
|
||||
};
|
||||
|
||||
root.render(
|
||||
React.createElement(HistoryStandaloneContent, {
|
||||
rows: input.rows,
|
||||
|
||||
@@ -17,6 +17,7 @@ function toMcpEntries(items: InteractiveConfigItem[]): McpEntry[] {
|
||||
enabled: item.enabled,
|
||||
description: item.description,
|
||||
lastError: item.loadError,
|
||||
pluginName: item.pluginName,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -74,6 +74,21 @@ function clearReasoningConfig(config: Config): void {
|
||||
config.reasoningEffort = undefined;
|
||||
}
|
||||
|
||||
function resolveDefaultThinkingLevel(
|
||||
config: Pick<Config, "modelId" | "reasoningEffort" | "thinking">,
|
||||
selectedModelId: string,
|
||||
): ThinkingLevel {
|
||||
if (config.reasoningEffort) {
|
||||
return config.reasoningEffort as ThinkingLevel;
|
||||
}
|
||||
|
||||
if (selectedModelId === config.modelId && !config.thinking) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
return "medium";
|
||||
}
|
||||
|
||||
function usesModelIdInput(providerId: string): boolean {
|
||||
return providerId === "openai-compatible";
|
||||
}
|
||||
@@ -179,7 +194,6 @@ async function runProviderChange(
|
||||
|
||||
config.providerId = newProviderId;
|
||||
config.apiKey = newApiKey;
|
||||
|
||||
const resolved = await resolveProviderConfig(
|
||||
newProviderId,
|
||||
{
|
||||
@@ -332,23 +346,21 @@ export function useModelSelector(opts: {
|
||||
await changeProvider();
|
||||
continue;
|
||||
}
|
||||
config.modelId = browseResult;
|
||||
const browseModel = modelOptions.find(
|
||||
(m: ModelOption) => m.key === browseResult,
|
||||
);
|
||||
if (browseModel?.supportsReasoning) {
|
||||
const lvl: ThinkingLevel = config.reasoningEffort
|
||||
? (config.reasoningEffort as ThinkingLevel)
|
||||
: config.thinking
|
||||
? "medium"
|
||||
: "none";
|
||||
const currentLevel = resolveDefaultThinkingLevel(
|
||||
config,
|
||||
browseResult,
|
||||
);
|
||||
const pick = await dialog.choice<ThinkingLevel>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<ThinkingLevel>) => (
|
||||
<ThinkingLevelContent
|
||||
{...ctx}
|
||||
modelName={browseModel.name}
|
||||
currentLevel={lvl}
|
||||
currentLevel={currentLevel}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -362,6 +374,7 @@ export function useModelSelector(opts: {
|
||||
}
|
||||
}
|
||||
}
|
||||
config.modelId = browseResult;
|
||||
if (!browseModel?.supportsReasoning) {
|
||||
clearReasoningConfig(config);
|
||||
}
|
||||
@@ -369,16 +382,14 @@ export function useModelSelector(opts: {
|
||||
continue;
|
||||
}
|
||||
|
||||
config.modelId = clineResult;
|
||||
const selectedModel = modelOptions.find(
|
||||
(m: ModelOption) => m.key === clineResult,
|
||||
);
|
||||
if (selectedModel?.supportsReasoning) {
|
||||
const currentLevel: ThinkingLevel = config.reasoningEffort
|
||||
? (config.reasoningEffort as ThinkingLevel)
|
||||
: config.thinking
|
||||
? "medium"
|
||||
: "none";
|
||||
const currentLevel = resolveDefaultThinkingLevel(
|
||||
config,
|
||||
clineResult,
|
||||
);
|
||||
const thinkingLevel = await dialog.choice<ThinkingLevel>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<ThinkingLevel>) => (
|
||||
@@ -399,6 +410,7 @@ export function useModelSelector(opts: {
|
||||
}
|
||||
}
|
||||
}
|
||||
config.modelId = clineResult;
|
||||
if (!selectedModel?.supportsReasoning) {
|
||||
clearReasoningConfig(config);
|
||||
}
|
||||
@@ -427,23 +439,17 @@ export function useModelSelector(opts: {
|
||||
continue;
|
||||
}
|
||||
|
||||
config.modelId = selectedKey;
|
||||
|
||||
const selectedModel = modelOptions.find(
|
||||
(m: ModelOption) => m.key === selectedKey,
|
||||
);
|
||||
if (!selectedModel?.supportsReasoning) {
|
||||
config.modelId = selectedKey;
|
||||
clearReasoningConfig(config);
|
||||
pickingModel = false;
|
||||
break;
|
||||
}
|
||||
|
||||
const currentLevel: ThinkingLevel = config.reasoningEffort
|
||||
? (config.reasoningEffort as ThinkingLevel)
|
||||
: config.thinking
|
||||
? "medium"
|
||||
: "none";
|
||||
|
||||
const currentLevel = resolveDefaultThinkingLevel(config, selectedKey);
|
||||
const thinkingLevel = await dialog.choice<ThinkingLevel>({
|
||||
style: { maxHeight: termHeight - 2 },
|
||||
content: (ctx: ChoiceContext<ThinkingLevel>) => (
|
||||
@@ -466,6 +472,7 @@ export function useModelSelector(opts: {
|
||||
config.thinking = true;
|
||||
config.reasoningEffort = thinkingLevel;
|
||||
}
|
||||
config.modelId = selectedKey;
|
||||
pickingModel = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -327,6 +327,14 @@ 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,
|
||||
@@ -335,8 +343,9 @@ export function usePromptInputController(input: {
|
||||
activeUserImages.length > 0
|
||||
? { userImages: activeUserImages }
|
||||
: undefined,
|
||||
appendCommandOutput,
|
||||
);
|
||||
if (result.commandOutput) {
|
||||
if (result.commandOutput && !commandOutputAppended) {
|
||||
session.appendEntry({
|
||||
kind: "status",
|
||||
text: result.commandOutput,
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface InteractiveConfigData {
|
||||
mcp: InteractiveConfigItem[];
|
||||
tools: InteractiveConfigItem[];
|
||||
workflowSlashCommands: InteractiveSlashCommand[];
|
||||
pluginDiagnosticsLoaded?: boolean;
|
||||
}
|
||||
|
||||
export interface LoadInteractiveConfigDataOptions {
|
||||
@@ -93,12 +94,14 @@ export interface LoadInteractiveConfigDataOptions {
|
||||
}
|
||||
|
||||
export function isToggleableInteractiveConfigItem(
|
||||
item: Pick<InteractiveConfigItem, "kind" | "source">,
|
||||
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
|
||||
): 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"
|
||||
@@ -242,9 +245,10 @@ function readPackageName(packageJsonPath: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function getPluginDisplayName(filePath: string): string {
|
||||
function getPluginDisplayName(filePath: string, searchRoot: string): string {
|
||||
let current = dirname(filePath);
|
||||
for (let depth = 0; depth < 4; depth++) {
|
||||
const root = resolve(searchRoot);
|
||||
while (isPathWithin(root, current)) {
|
||||
const packageJsonPath = join(current, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageName = readPackageName(packageJsonPath);
|
||||
@@ -384,7 +388,7 @@ export async function loadInteractiveConfigData(input: {
|
||||
for (const filePath of discoverPluginModulePaths(directory)) {
|
||||
plugins.push({
|
||||
id: filePath,
|
||||
name: getPluginDisplayName(filePath),
|
||||
name: getPluginDisplayName(filePath, directory),
|
||||
path: filePath,
|
||||
enabled: !disabledPlugins.has(filePath),
|
||||
kind: "plugin",
|
||||
@@ -458,6 +462,16 @@ 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,
|
||||
@@ -467,6 +481,8 @@ export async function loadInteractiveConfigData(input: {
|
||||
source: detectSource(mcpSettingsPath, input.workspaceRoot),
|
||||
description: getMcpDescription(registration),
|
||||
loadError: registration.oauth?.lastError,
|
||||
pluginName,
|
||||
pluginPath,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -514,6 +530,7 @@ export async function loadInteractiveConfigData(input: {
|
||||
toolNames: [pluginTool.name],
|
||||
configKind: "tool",
|
||||
pluginName: pluginTool.pluginName,
|
||||
pluginPath: pluginTool.path,
|
||||
source: pluginTool.source,
|
||||
description: pluginTool.description,
|
||||
});
|
||||
@@ -533,5 +550,6 @@ export async function loadInteractiveConfigData(input: {
|
||||
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
|
||||
tools: toSorted(tools),
|
||||
workflowSlashCommands,
|
||||
pluginDiagnosticsLoaded: input.includePluginTools !== false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -921,6 +921,7 @@ function App(props: TuiProps) {
|
||||
if (result.reasoningEffort !== undefined) {
|
||||
props.config.reasoningEffort = result.reasoningEffort;
|
||||
}
|
||||
|
||||
handleModelChange().then(() => setAppView("home"));
|
||||
}}
|
||||
onExit={() => {
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface TuiProps {
|
||||
mode: AgentMode,
|
||||
delivery?: "queue" | "steer",
|
||||
attachments?: UserInputAttachments,
|
||||
onCommandOutput?: (text: string) => void,
|
||||
) => Promise<InteractiveTurnResult>;
|
||||
onUpdatePendingPrompt: (input: {
|
||||
promptId: string;
|
||||
|
||||
@@ -107,12 +107,13 @@ 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"] },
|
||||
{ stdio: ["pipe", "ignore", "ignore"], windowsHide: true },
|
||||
);
|
||||
expect(failed.getInput()).toBe("selected text");
|
||||
expect(succeeded.getInput()).toBe("selected text");
|
||||
@@ -134,6 +135,7 @@ describe("copyTextToSystemClipboard", () => {
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock).toHaveBeenCalledWith("wl-copy", [], {
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(wlcopy.getInput()).toBe("plain linux");
|
||||
});
|
||||
|
||||
@@ -142,6 +142,8 @@ 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;
|
||||
|
||||
|
||||
@@ -115,6 +115,8 @@ 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;
|
||||
|
||||
@@ -5,6 +5,8 @@ import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
resolveProviderConfigAwsRegion,
|
||||
resolveProviderConfigAzure,
|
||||
resolveProviderConfigGcp,
|
||||
resolveProviderConfigSap,
|
||||
updateProviderConfigValue,
|
||||
} from "./provider-config-values";
|
||||
@@ -66,6 +68,18 @@ describe("provider config values", () => {
|
||||
).toBe("us-west-2");
|
||||
});
|
||||
|
||||
it("resolves Vertex GCP field values into GCP settings", () => {
|
||||
expect(
|
||||
resolveProviderConfigGcp({ gcpRegion: "us-central1" }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveProviderConfigGcp({
|
||||
gcpProjectId: " project ",
|
||||
gcpRegion: " europe-west4 ",
|
||||
}),
|
||||
).toEqual({ projectId: "project", region: "europe-west4" });
|
||||
});
|
||||
|
||||
it("resolves SAP AI Core field values into SAP settings", () => {
|
||||
expect(
|
||||
resolveProviderConfigSap({
|
||||
@@ -83,4 +97,24 @@ describe("provider config values", () => {
|
||||
deploymentId: "deployment",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves Azure API version into Azure settings", () => {
|
||||
expect(
|
||||
resolveProviderConfigAzure({
|
||||
azureApiVersion: " 2025-01-01-preview ",
|
||||
}),
|
||||
).toEqual({
|
||||
apiVersion: "2025-01-01-preview",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps blank Azure API version so persisted settings can be cleared", () => {
|
||||
expect(
|
||||
resolveProviderConfigAzure({
|
||||
azureApiVersion: " ",
|
||||
}),
|
||||
).toEqual({
|
||||
apiVersion: "",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ProviderConfigValues = Partial<
|
||||
>;
|
||||
|
||||
const DEFAULT_AWS_REGION = "us-east-1";
|
||||
const DEFAULT_GCP_REGION = "us-central1";
|
||||
|
||||
export function getDefaultAwsRegion(profile?: string): string {
|
||||
return (
|
||||
@@ -20,6 +21,20 @@ export function resolveProviderConfigAwsRegion(
|
||||
return values.awsRegion?.trim() || getDefaultAwsRegion(values.awsProfile);
|
||||
}
|
||||
|
||||
export function resolveProviderConfigGcp(values: ProviderConfigValues):
|
||||
| {
|
||||
projectId?: string;
|
||||
region?: string;
|
||||
}
|
||||
| undefined {
|
||||
const projectId = values.gcpProjectId?.trim() || undefined;
|
||||
if (!projectId) return undefined;
|
||||
return {
|
||||
projectId,
|
||||
region: values.gcpRegion?.trim() || DEFAULT_GCP_REGION,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProviderConfigSap(values: ProviderConfigValues):
|
||||
| {
|
||||
clientId?: string;
|
||||
@@ -41,6 +56,12 @@ export function resolveProviderConfigSap(values: ProviderConfigValues):
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderConfigAzure(values: ProviderConfigValues): {
|
||||
apiVersion?: string;
|
||||
} {
|
||||
return { apiVersion: values.azureApiVersion?.trim() ?? "" };
|
||||
}
|
||||
|
||||
export function updateProviderConfigValue(
|
||||
previous: ProviderConfigValues,
|
||||
field: ProviderConfigFieldKey,
|
||||
|
||||
@@ -229,3 +229,15 @@ 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,6 +59,18 @@ 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",
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getConfigFooterText,
|
||||
getConfigItemDisplayName,
|
||||
getConfigTabs,
|
||||
getPluginDiagnosticsLoadingText,
|
||||
isInlineConfigAction,
|
||||
isToggleableConfigItem,
|
||||
resolveActiveConfigItems,
|
||||
@@ -198,6 +199,7 @@ function appendToolGroupRows(
|
||||
rightLabel: `${enabledCount}/${groupItems.length} tools enabled`,
|
||||
indent: 2,
|
||||
});
|
||||
|
||||
for (const item of sortBySourceThenName(groupItems)) {
|
||||
rows.push({
|
||||
kind: "ext",
|
||||
@@ -245,17 +247,24 @@ function appendToolRows(
|
||||
appendExtRows(rows, builtinTools);
|
||||
}
|
||||
|
||||
const pluginGroups = groupToolItems(items.filter((item) => item.pluginName));
|
||||
const pluginToolItems = items.filter((item) => item.pluginName);
|
||||
const pluginGroups = groupToolItems(pluginToolItems);
|
||||
if (pluginGroups.length > 0) {
|
||||
rows.push({ kind: "head", label: "Plugins" });
|
||||
appendToolGroupRows(
|
||||
rows,
|
||||
pluginGroups,
|
||||
getSharedToolNames(items.filter((item) => item.pluginName)),
|
||||
getSharedToolNames(pluginToolItems),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hasPluginDiagnostics(data: InteractiveConfigData): boolean {
|
||||
return (
|
||||
data.pluginDiagnosticsLoaded || data.tools.some((item) => item.pluginName)
|
||||
);
|
||||
}
|
||||
|
||||
function appendSkillRows(
|
||||
rows: ConfigRow[],
|
||||
items: InteractiveConfigItem[],
|
||||
@@ -305,11 +314,18 @@ function withOptimisticToggle(
|
||||
).filter(Boolean),
|
||||
);
|
||||
const updateItems = (items: InteractiveConfigItem[]) =>
|
||||
items.map((candidate) =>
|
||||
matchesItem(candidate)
|
||||
? { ...candidate, enabled: nextEnabled }
|
||||
: candidate,
|
||||
);
|
||||
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;
|
||||
});
|
||||
const updateTools = (items: InteractiveConfigItem[]) =>
|
||||
items.map((candidate) => {
|
||||
if (matchesItem(candidate)) {
|
||||
@@ -381,7 +397,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
);
|
||||
const [configData, setConfigData] = useState(props.configData);
|
||||
const [pluginToolsLoaded, setPluginToolsLoaded] = useState(
|
||||
props.configData.tools.some((item) => item.pluginName),
|
||||
hasPluginDiagnostics(props.configData),
|
||||
);
|
||||
const [pluginToolsLoading, setPluginToolsLoading] = useState(false);
|
||||
const [pluginToolsError, setPluginToolsError] = useState<
|
||||
@@ -465,10 +481,11 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
});
|
||||
} else if (activeTab === "tools") {
|
||||
appendToolRows(r, activeItems);
|
||||
if (pluginToolsLoading) {
|
||||
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
|
||||
if (pluginToolsLoading && loadingText) {
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: "Loading plugin tools...",
|
||||
text: loadingText,
|
||||
});
|
||||
}
|
||||
if (pluginToolsError) {
|
||||
@@ -499,9 +516,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsLoading) {
|
||||
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: "Loading plugin diagnostics...",
|
||||
text: loadingText ?? "Loading plugin diagnostics...",
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsError) {
|
||||
@@ -549,15 +567,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
});
|
||||
if (nextData) {
|
||||
setConfigData(nextData);
|
||||
setPluginToolsLoaded(nextData.tools.some((tool) => tool.pluginName));
|
||||
setPluginToolsLoaded(hasPluginDiagnostics(nextData));
|
||||
} else if (item.kind === "plugin" && loadConfigData) {
|
||||
const refreshedData = await loadConfigData({
|
||||
includePluginTools: true,
|
||||
});
|
||||
setConfigData(refreshedData);
|
||||
setPluginToolsLoaded(
|
||||
refreshedData.tools.some((tool) => tool.pluginName),
|
||||
);
|
||||
setPluginToolsLoaded(hasPluginDiagnostics(refreshedData));
|
||||
setPluginToolsError(undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -146,5 +146,50 @@ 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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
type ITelemetryService,
|
||||
isOAuthProvider,
|
||||
loginLocalProvider,
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
@@ -9,16 +10,12 @@ import {
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import open from "open";
|
||||
|
||||
export type OnboardingOAuthProviderId = "cline" | "oca" | "openai-codex";
|
||||
export type OnboardingOAuthProviderId = string;
|
||||
|
||||
export function isOnboardingOAuthProviderId(
|
||||
providerId: string,
|
||||
): providerId is OnboardingOAuthProviderId {
|
||||
return (
|
||||
providerId === "cline" ||
|
||||
providerId === "oca" ||
|
||||
providerId === "openai-codex"
|
||||
);
|
||||
return isOAuthProvider(providerId);
|
||||
}
|
||||
|
||||
export function runOAuthAuthFlow(input: {
|
||||
@@ -92,11 +89,18 @@ export function runDeviceCodeAuthFlow(input: {
|
||||
startClineDeviceAuth()
|
||||
.then((result) => {
|
||||
if (input.isAborted()) return;
|
||||
const verifyUrl =
|
||||
result.verificationUriComplete || result.verificationUri;
|
||||
input.setUserCode(result.userCode);
|
||||
input.setVerifyUrl(
|
||||
result.verificationUriComplete || result.verificationUri,
|
||||
);
|
||||
input.setVerifyUrl(verifyUrl);
|
||||
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,7 +2,6 @@ import {
|
||||
captureProviderConfigured,
|
||||
getLocalProviderModels,
|
||||
getProviderConfigFields,
|
||||
listLocalProviders,
|
||||
type ProviderConfigFieldKey,
|
||||
type ProviderConfigFields,
|
||||
ProviderSettingsManager,
|
||||
@@ -17,6 +16,7 @@ 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,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
getDefaultAwsRegion,
|
||||
type ProviderConfigValues,
|
||||
resolveProviderConfigAwsRegion,
|
||||
resolveProviderConfigAzure,
|
||||
resolveProviderConfigSap,
|
||||
updateProviderConfigValue,
|
||||
} from "../../utils/provider-config-values";
|
||||
@@ -382,6 +383,10 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
config.fields.baseUrl?.defaultValue ??
|
||||
"";
|
||||
}
|
||||
if (config.fields.azureApiVersion) {
|
||||
initialValues.azureApiVersion =
|
||||
existing?.azure?.apiVersion?.trim() ?? "";
|
||||
}
|
||||
if (config.fields.awsRegion) {
|
||||
const existingProfile = existing?.aws?.profile?.trim() ?? "";
|
||||
initialValues.awsRegion =
|
||||
@@ -444,6 +449,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
// surfaced when the model picker / first turn runs.
|
||||
const apiKey = byoValues.apiKey?.trim();
|
||||
const awsProfile = byoValues.awsProfile?.trim();
|
||||
const hasAzureFields = byoFields.azureApiVersion;
|
||||
const hasAwsFields = byoFields.awsRegion || byoFields.awsProfile;
|
||||
const hasSapFields =
|
||||
byoFields.sapClientId ||
|
||||
@@ -456,6 +462,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
providerId: activeProviderId,
|
||||
apiKey: byoFields.apiKey ? apiKey : undefined,
|
||||
baseUrl: byoFields.baseUrl ? byoValues.baseUrl?.trim() : undefined,
|
||||
azure: hasAzureFields ? resolveProviderConfigAzure(byoValues) : undefined,
|
||||
aws: hasAwsFields
|
||||
? {
|
||||
region: resolveProviderConfigAwsRegion(byoValues),
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { ProviderConfigFieldKey } from "@cline/core";
|
||||
export const FIELD_ORDER: ProviderConfigFieldKey[] = [
|
||||
"awsRegion",
|
||||
"baseUrl",
|
||||
"azureApiVersion",
|
||||
"apiKey",
|
||||
"awsProfile",
|
||||
"sapClientId",
|
||||
|
||||
@@ -222,6 +222,7 @@ import type {
|
||||
const DEFAULT_FIELD_LABELS: Partial<Record<ProviderConfigFieldKey, string>> = {
|
||||
apiKey: "API key",
|
||||
baseUrl: "Base URL",
|
||||
azureApiVersion: "Azure API Version",
|
||||
awsRegion: "AWS Region",
|
||||
awsProfile: "AWS Profile Name",
|
||||
sapClientId: "Client ID",
|
||||
@@ -236,6 +237,7 @@ const DEFAULT_FIELD_PLACEHOLDERS: Partial<
|
||||
> = {
|
||||
apiKey: "Paste your API key here...",
|
||||
baseUrl: "",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
awsRegion: "us-east-1",
|
||||
awsProfile: "default",
|
||||
sapClientId: "sb-...|xsuaa_std!b...",
|
||||
|
||||
@@ -28,6 +28,7 @@ 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;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
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,4 +65,55 @@ 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?.();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type AgentExtensionCommand,
|
||||
type AgentExtensionCommandResult,
|
||||
type BasicLogger,
|
||||
createContributionRegistry,
|
||||
resolveAndLoadAgentPlugins,
|
||||
@@ -45,13 +46,41 @@ function createPluginCommandDefinition(
|
||||
names: [normalizedName.toLowerCase()],
|
||||
run: async ({ args }, context) => {
|
||||
const result = await command.handler?.(args.join(" "));
|
||||
if (typeof result === "string" && result.trim()) {
|
||||
await context.reply(result);
|
||||
const { reply, submitPrompt } = normalizeCommandResult(result);
|
||||
if (reply) {
|
||||
await context.reply(reply);
|
||||
}
|
||||
if (submitPrompt) {
|
||||
await context.submitPrompt?.(submitPrompt);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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,14 +1,13 @@
|
||||
import { Llms, type ProviderSettings } from "@cline/core";
|
||||
import { isOAuthProviderId } from "@cline/shared";
|
||||
import {
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey as getCorePersistedProviderApiKey,
|
||||
isOAuthProvider,
|
||||
Llms,
|
||||
type ProviderOAuthCredentials,
|
||||
type ProviderSettings,
|
||||
} from "@cline/core";
|
||||
|
||||
export type OAuthCredentials = {
|
||||
access: string;
|
||||
refresh: string;
|
||||
expires: number;
|
||||
accountId?: string;
|
||||
email?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
};
|
||||
export type OAuthCredentials = ProviderOAuthCredentials;
|
||||
|
||||
export function normalizeProviderId(providerId: string): string {
|
||||
return Llms.normalizeProviderId(providerId.trim());
|
||||
@@ -22,42 +21,20 @@ export function normalizeAuthProviderId(providerId: string): string {
|
||||
return normalizeProviderId(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-exports `isOAuthProviderId` from `@cline/shared` so the CLI has a
|
||||
* single source of truth for the OAuth provider list. Existing call sites
|
||||
* keep their `isOAuthProvider` import name.
|
||||
*/
|
||||
export const isOAuthProvider = isOAuthProviderId;
|
||||
export { isOAuthProvider };
|
||||
|
||||
export function toProviderApiKey(
|
||||
providerId: string,
|
||||
credentials: Pick<OAuthCredentials, "access">,
|
||||
): string {
|
||||
if (providerId === "cline") {
|
||||
return credentials.access.startsWith("workos:")
|
||||
? credentials.access
|
||||
: `workos:${credentials.access}`;
|
||||
}
|
||||
return credentials.access;
|
||||
return formatProviderOAuthApiKey(providerId, credentials);
|
||||
}
|
||||
|
||||
export function getPersistedProviderApiKey(
|
||||
providerId: string,
|
||||
settings?: ProviderSettings,
|
||||
): string | undefined {
|
||||
const accessToken = settings?.auth?.accessToken?.trim();
|
||||
if (accessToken) {
|
||||
return toProviderApiKey(providerId, { access: accessToken });
|
||||
}
|
||||
const shorthandKey = settings?.apiKey?.trim();
|
||||
if (shorthandKey) {
|
||||
return shorthandKey;
|
||||
}
|
||||
const authKey = settings?.auth?.apiKey?.trim();
|
||||
if (authKey) {
|
||||
return authKey;
|
||||
}
|
||||
return undefined;
|
||||
return getCorePersistedProviderApiKey(providerId, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,7 +53,7 @@ export function isProviderConfigured(
|
||||
settings: ProviderSettings | undefined,
|
||||
): boolean {
|
||||
if (!settings) return false;
|
||||
if (isOAuthProviderId(providerId)) {
|
||||
if (isOAuthProvider(providerId)) {
|
||||
return Boolean(settings.auth?.accessToken?.trim());
|
||||
}
|
||||
if (getPersistedProviderApiKey(providerId, settings)) return true;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
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"),
|
||||
});
|
||||
}
|
||||
@@ -139,6 +139,12 @@ describe("provider readiness", () => {
|
||||
gcp: { projectId: "test-project" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isProviderSettingsUsable("vertex", {
|
||||
provider: "vertex",
|
||||
gcp: { projectId: "test-project", region: "us-central1" },
|
||||
} satisfies ProviderSettings),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isProviderSettingsUsable("sapaicore", {
|
||||
provider: "sapaicore",
|
||||
|
||||
@@ -33,6 +33,8 @@ function hasAwsRegion(settings: ProviderSettings): boolean {
|
||||
|
||||
function hasGcpCredentials(settings: ProviderSettings): boolean {
|
||||
const gcp = settings.gcp;
|
||||
// Vertex defaults to us-central1 at runtime when no region is stored, so keep
|
||||
// existing project-only configs usable while new CLI saves include a region.
|
||||
return hasText(gcp?.projectId);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,12 @@ 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,
|
||||
identifyCliTelemetryAccount,
|
||||
identifyTelemetryAccount,
|
||||
} from "./telemetry";
|
||||
import { resetCliExtensionActivationForTests } from "./telemetry.test-helpers";
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("captureCliExtensionActivated", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("identifyCliTelemetryAccount", () => {
|
||||
describe("identifyTelemetryAccount", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.identifyAccount.mockClear();
|
||||
hoisted.getCliTelemetryService.mockClear();
|
||||
@@ -107,7 +107,7 @@ describe("identifyCliTelemetryAccount", () => {
|
||||
memberId: "member-7",
|
||||
provider: "cline",
|
||||
};
|
||||
identifyCliTelemetryAccount(account);
|
||||
identifyTelemetryAccount(account);
|
||||
expect(hoisted.identifyAccount).toHaveBeenCalledWith(undefined, account);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TelemetryLoggerSink,
|
||||
} from "@cline/core";
|
||||
import { getCliBuildInfo } from "./common";
|
||||
import { identifyFeatureFlagsAccount } from "./feature-flags";
|
||||
import {
|
||||
markActivationCaptured,
|
||||
wasActivationCaptured,
|
||||
@@ -102,11 +103,12 @@ export interface CliTelemetryAccountContext {
|
||||
* Safe to call multiple times; the latest values win, mirroring the legacy
|
||||
* singleton-based behavior.
|
||||
*/
|
||||
export function identifyCliTelemetryAccount(
|
||||
export function identifyTelemetryAccount(
|
||||
account: CliTelemetryAccountContext,
|
||||
logger?: BasicLogger,
|
||||
): void {
|
||||
identifyAccount(getCliTelemetryService(logger), account);
|
||||
void identifyFeatureFlagsAccount(account, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,6 +136,7 @@ export function captureCliExtensionActivated(
|
||||
const telemetry = getCliTelemetryService(logger);
|
||||
if (account) {
|
||||
identifyAccount(telemetry, account);
|
||||
void identifyFeatureFlagsAccount(account, logger);
|
||||
}
|
||||
captureExtensionActivated(telemetry);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import { authorizeMcpServerOAuth } from "@cline/core";
|
||||
import open from "open";
|
||||
import { authorizeMcpServerOAuthWithBrowser as authorizeOAuth } from "./oauth";
|
||||
import {
|
||||
addServer,
|
||||
clearServerOAuth,
|
||||
@@ -17,16 +16,6 @@ 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}`;
|
||||
@@ -222,29 +211,6 @@ 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",
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,8 @@ 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 = "";
|
||||
|
||||
@@ -6,14 +6,13 @@ import {
|
||||
executeClineAccountAction,
|
||||
getLocalProviderModels,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
type ProviderProtocol,
|
||||
readGlobalSettings,
|
||||
resolveLocalClineAuthToken,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
@@ -117,17 +116,10 @@ export async function handleDesktopCommand(
|
||||
}
|
||||
if (command === "run_provider_oauth_login") {
|
||||
const providerId = normalizeOAuthProvider(String(args?.provider ?? ""));
|
||||
const existing = providerSettingsManager.getProviderSettings(providerId);
|
||||
const credentials = await loginLocalProvider(
|
||||
providerId,
|
||||
existing,
|
||||
openExternalUrl,
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
openExternalUrl,
|
||||
);
|
||||
return {
|
||||
provider: providerId,
|
||||
|
||||
@@ -4,9 +4,8 @@ import {
|
||||
getLocalProviderModels,
|
||||
Llms,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import type {
|
||||
@@ -134,17 +133,10 @@ export async function runProviderOAuthLogin(
|
||||
providerId: string,
|
||||
): Promise<void> {
|
||||
const normalized = normalizeOAuthProvider(providerId);
|
||||
const existing = providerSettingsManager.getProviderSettings(normalized);
|
||||
const credentials = await loginLocalProvider(
|
||||
normalized,
|
||||
existing,
|
||||
openExternalUrl,
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
normalized,
|
||||
existing,
|
||||
credentials,
|
||||
openExternalUrl,
|
||||
);
|
||||
ctx.send(peer, {
|
||||
type: "provider_oauth_login_done",
|
||||
|
||||
@@ -134,6 +134,12 @@ 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 });
|
||||
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,
|
||||
});
|
||||
child.unref();
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
listHookConfigFiles,
|
||||
listLocalProviders,
|
||||
listPluginTools,
|
||||
loginLocalProvider,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
ProviderSettingsManager,
|
||||
readGlobalSettings,
|
||||
@@ -40,7 +40,6 @@ import {
|
||||
resolveSessionBackend,
|
||||
resolveAgentConfigSearchPaths as resolveSharedAgentConfigSearchPaths,
|
||||
SqliteSessionStore,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
sendHubCommand,
|
||||
setDisabledPlugin,
|
||||
@@ -1012,10 +1011,9 @@ export async function handleCommand(
|
||||
if (command === "run_provider_oauth_login") {
|
||||
const providerId = normalizeOAuthProvider(String(args?.provider ?? ""));
|
||||
const manager = new ProviderSettingsManager();
|
||||
const existing = manager.getProviderSettings(providerId);
|
||||
const credentials = await loginLocalProvider(
|
||||
const saved = await loginAndSaveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId,
|
||||
existing,
|
||||
(url) => {
|
||||
const platform = process.platform;
|
||||
const spawned =
|
||||
@@ -1033,12 +1031,6 @@ export async function handleCommand(
|
||||
spawned.unref();
|
||||
},
|
||||
);
|
||||
const saved = saveLocalProviderOAuthCredentials(
|
||||
manager,
|
||||
providerId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
return {
|
||||
provider: providerId,
|
||||
accessToken: saved.auth?.accessToken ?? saved.apiKey ?? "",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
Llms,
|
||||
ProviderSettingsManager,
|
||||
stopLocalHubServerGracefully,
|
||||
toHubHealthUrl,
|
||||
toHubStatusUrl,
|
||||
} from "@cline/core";
|
||||
import type { HubUINotifyPayload, SessionRecord } from "@cline/shared";
|
||||
|
||||
@@ -460,7 +460,11 @@ async function main(): Promise<void> {
|
||||
|
||||
const syncHealthState = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await fetch(toHubHealthUrl(hubUrl));
|
||||
const response = await fetch(toHubStatusUrl(hubUrl), {
|
||||
headers: hubAuthToken
|
||||
? { authorization: `Bearer ${hubAuthToken}` }
|
||||
: undefined,
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -701,7 +701,9 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
|
||||
if (this.hubUrl) {
|
||||
const healthy = await probeHubServer(this.hubUrl);
|
||||
const healthy = await probeHubServer(this.hubUrl, {
|
||||
authToken: this.hubAuthToken,
|
||||
});
|
||||
if (healthy?.url) {
|
||||
return {
|
||||
url: rememberRecoverableLocalHubUrl(healthy.url, this.hubAuthToken),
|
||||
@@ -733,7 +735,9 @@ class CoreChatWebviewController implements vscode.Disposable {
|
||||
): Promise<HubResolution | undefined> {
|
||||
const discovery = await readHubDiscovery(discoveryPath);
|
||||
if (!discovery?.url) return undefined;
|
||||
const healthy = await probeHubServer(discovery.url);
|
||||
const healthy = await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
});
|
||||
return healthy?.url
|
||||
? {
|
||||
url: rememberRecoverableLocalHubUrl(healthy.url, discovery.authToken),
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
"useEnumInitializers": "off",
|
||||
"useSelfClosingElements": "info",
|
||||
"useSingleVarDeclarator": "off",
|
||||
"useNumberNamespace": "info",
|
||||
"noInferrableTypes": "info",
|
||||
"useNumberNamespace": "off",
|
||||
"noInferrableTypes": "off",
|
||||
"useTemplate": "info",
|
||||
"noUselessElse": "info"
|
||||
},
|
||||
|
||||
Generated
+16
-34
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.89.0",
|
||||
"version": "3.89.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.89.0",
|
||||
"version": "3.89.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.6.4",
|
||||
"@anthropic-ai/sdk": "^0.50.4",
|
||||
"@anthropic-ai/vertex-sdk": "^0.11.5",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
|
||||
"@aws-sdk/credential-providers": "^3.922.0",
|
||||
"@azure/identity": "^4.13.0",
|
||||
@@ -156,42 +156,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@anthropic-ai/sdk": {
|
||||
"version": "0.37.0",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz",
|
||||
"integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==",
|
||||
"version": "0.50.4",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.50.4.tgz",
|
||||
"integrity": "sha512-zZOWyIuznx2uqiRcCNuidkAsLC8IBHgS9lTwSVEB29sUCwEcqL95MWpWP1nccHSKfiZga+hbZaI0btR2zjuMmw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@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"
|
||||
"bin": {
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"version": "0.11.5",
|
||||
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.11.5.tgz",
|
||||
"integrity": "sha512-V7sB5nY80unEQu8lSQaEzh1WhYwpIdpC3iXNRHUskghkuQDhS6dQu2ASZBgA5MNuJ1Yv5PhY61NM15dLaQhPQw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": ">=0.35 <1",
|
||||
"@anthropic-ai/sdk": ">=0.50.3 <1",
|
||||
"google-auth-library": "^9.4.2"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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.0",
|
||||
"version": "3.89.2",
|
||||
"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/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched",
|
||||
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/generated webview-ui/src/services/grpc-client.ts --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.37.0",
|
||||
"@anthropic-ai/vertex-sdk": "^0.6.4",
|
||||
"@anthropic-ai/sdk": "^0.50.4",
|
||||
"@anthropic-ai/vertex-sdk": "^0.11.5",
|
||||
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
|
||||
"@aws-sdk/credential-providers": "^3.922.0",
|
||||
"@azure/identity": "^4.13.0",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -81,11 +81,12 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
const client = this.ensureClient()
|
||||
const model = this.getModel()
|
||||
|
||||
const isDeepSeekReasonerModel = model.id.includes("deepseek-reasoner")
|
||||
const isDeepSeekThinkingModel =
|
||||
model.id.includes("deepseek-reasoner") || model.id === "deepseek-v4-flash" || model.id === "deepseek-v4-pro"
|
||||
isDeepSeekReasonerModel || model.id === "deepseek-v4-flash" || model.id === "deepseek-v4-pro"
|
||||
|
||||
const convertedMessages = convertToOpenAiMessages(messages)
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepSeekThinkingModel
|
||||
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = isDeepSeekReasonerModel
|
||||
? [{ role: "system", content: systemPrompt }, ...addReasoningContent(convertedMessages, messages)]
|
||||
: [{ role: "system", content: systemPrompt }, ...convertedMessages]
|
||||
|
||||
@@ -104,6 +105,13 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices?.[0]?.delta
|
||||
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: (delta.reasoning_content as string | undefined) || "",
|
||||
}
|
||||
}
|
||||
|
||||
if (delta?.content) {
|
||||
yield {
|
||||
type: "text",
|
||||
@@ -115,13 +123,6 @@ export class DeepSeekHandler implements ApiHandler {
|
||||
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
|
||||
}
|
||||
|
||||
if (delta && "reasoning_content" in delta && delta.reasoning_content) {
|
||||
yield {
|
||||
type: "reasoning",
|
||||
reasoning: (delta.reasoning_content as string | undefined) || "",
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield* this.yieldUsage(model.info, chunk.usage)
|
||||
}
|
||||
|
||||
@@ -1,56 +1,66 @@
|
||||
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"
|
||||
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";
|
||||
|
||||
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: HuggingFaceModelId; info: ModelInfo } | undefined
|
||||
private options: HuggingFaceHandlerOptions;
|
||||
private client: OpenAI | undefined;
|
||||
private cachedModel: { id: string; 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,
|
||||
@@ -59,21 +69,25 @@ 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,
|
||||
@@ -83,66 +97,71 @@ 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: HuggingFaceModelId; info: ModelInfo } {
|
||||
getModel(): { id: string; info: ModelInfo } {
|
||||
// Return cached model if available
|
||||
if (this.cachedModel) {
|
||||
return this.cachedModel
|
||||
return this.cachedModel;
|
||||
}
|
||||
|
||||
const modelId = this.options.huggingFaceModelId
|
||||
|
||||
// List all available models for debugging
|
||||
const _availableModels = Object.keys(huggingFaceModels)
|
||||
let result: { id: HuggingFaceModelId; info: ModelInfo }
|
||||
const modelId = this.options.huggingFaceModelId;
|
||||
let result: { id: string; info: ModelInfo };
|
||||
|
||||
if (modelId && modelId in huggingFaceModels) {
|
||||
const id = modelId as HuggingFaceModelId
|
||||
const modelInfo = huggingFaceModels[id]
|
||||
result = { id, info: modelInfo }
|
||||
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,
|
||||
};
|
||||
} 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,5 +1,8 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { ClineStorageMessage, convertClineStorageToAnthropicMessage } from "@/shared/messages/content"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
import {
|
||||
type ClineStorageMessage,
|
||||
convertClineStorageToAnthropicMessage,
|
||||
} from "@/shared/messages/content";
|
||||
|
||||
/**
|
||||
* Converts Cline storage messages to Anthropic API format with optional cache control.
|
||||
@@ -12,7 +15,7 @@ import { ClineStorageMessage, convertClineStorageToAnthropicMessage } from "@/sh
|
||||
* @returns Array of Anthropic-compatible messages with cache control applied
|
||||
*/
|
||||
export function sanitizeAnthropicMessages(
|
||||
clineMessages: Array<ClineStorageMessage | Anthropic.MessageParam>,
|
||||
clineMessages: ClineStorageMessage[],
|
||||
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,
|
||||
@@ -21,32 +24,37 @@ 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.
|
||||
@@ -55,7 +63,9 @@ 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 {
|
||||
@@ -67,24 +77,24 @@ function addCacheControl(message: Anthropic.MessageParam): Anthropic.MessagePara
|
||||
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 { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { Content, GenerateContentResponse, Part } from "@google/genai"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
import type { Content, GenerateContentResponse, Part } from "@google/genai";
|
||||
import type { 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,27 +8,29 @@ import { 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: {
|
||||
@@ -37,7 +39,7 @@ export function convertAnthropicContentToGemini(content: string | ClineStorageMe
|
||||
},
|
||||
// Thought signature is required, so provide a dummy one if not present
|
||||
thoughtSignature: block.signature || GEMINI_DUMMY_THOUGHT_SIGNATURE,
|
||||
}
|
||||
};
|
||||
case "tool_result":
|
||||
return {
|
||||
functionResponse: {
|
||||
@@ -46,57 +48,66 @@ export function convertAnthropicContentToGemini(content: string | ClineStorageMe
|
||||
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: Anthropic.Messages.MessageParam): Content {
|
||||
export function convertAnthropicMessageToGemini(
|
||||
message: ClineStorageMessage,
|
||||
): 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +124,7 @@ export function convertGeminiResponseToAnthropic(response: GenerateContentRespon
|
||||
output_tokens: response.usageMetadata?.candidatesTokenCount ?? 0,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
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"
|
||||
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";
|
||||
|
||||
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({
|
||||
@@ -33,29 +36,31 @@ export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.M
|
||||
return {
|
||||
type: "image_url",
|
||||
imageUrl: {
|
||||
url: `data:${part.source.media_type};base64,${part.source.data}`,
|
||||
url: getImageDataUrl(part.source),
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
import type 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,106 +243,116 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,8 +376,10 @@ function validateToolInput(toolName: string, tool_input: Record<string, string>)
|
||||
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,
|
||||
@@ -384,14 +396,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)
|
||||
@@ -400,23 +412,26 @@ 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,64 +1,68 @@
|
||||
import { Message } from "ollama"
|
||||
import type { Message } from "ollama";
|
||||
import {
|
||||
ClineAssistantToolUseBlock,
|
||||
ClineImageContentBlock,
|
||||
ClineStorageMessage,
|
||||
ClineTextContentBlock,
|
||||
ClineUserToolResultContentBlock,
|
||||
} from "@/shared/messages/content"
|
||||
type ClineAssistantToolUseBlock,
|
||||
type ClineImageContentBlock,
|
||||
type ClineStorageMessage,
|
||||
type ClineTextContentBlock,
|
||||
type ClineUserToolResultContentBlock,
|
||||
getImageDataUrl,
|
||||
} 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(`data:${part.source.media_type};base64,${part.source.data}`)
|
||||
return "(see following user message for image)"
|
||||
toolResultImages.push(getImageDataUrl(part.source));
|
||||
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) {
|
||||
@@ -67,49 +71,50 @@ export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMess
|
||||
content: nonToolMessages
|
||||
.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return `data:${part.source.media_type};base64,${part.source.data}`
|
||||
return getImageDataUrl(part.source);
|
||||
}
|
||||
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 { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ApiProvider } from "@/shared/api"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
import type OpenAI from "openai";
|
||||
import type { ApiProvider } from "@/shared/api";
|
||||
import {
|
||||
ClineAssistantRedactedThinkingBlock,
|
||||
ClineAssistantThinkingBlock,
|
||||
ClineAssistantToolUseBlock,
|
||||
ClineImageContentBlock,
|
||||
ClineStorageMessage,
|
||||
ClineTextContentBlock,
|
||||
ClineUserToolResultContentBlock,
|
||||
} from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
type ClineAssistantRedactedThinkingBlock,
|
||||
type ClineAssistantThinkingBlock,
|
||||
type ClineAssistantToolUseBlock,
|
||||
type ClineImageContentBlock,
|
||||
type ClineTextContentBlock,
|
||||
type ClineUserToolResultContentBlock,
|
||||
getImageDataUrl,
|
||||
} 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,21 +37,24 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,17 +68,17 @@ function transformToolCallIdForNativeApi(toolId: string, provider?: ApiProvider)
|
||||
* @returns Array of OpenAI.Chat.ChatCompletionMessageParam objects
|
||||
*/
|
||||
export function convertToOpenAiMessages(
|
||||
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
|
||||
anthropicMessages: Anthropic.Messages.MessageParam[],
|
||||
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,
|
||||
@@ -86,52 +89,56 @@ 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.
|
||||
@@ -144,9 +151,9 @@ export function convertToOpenAiMessages(
|
||||
role: "user",
|
||||
content: toolResultImages.map((part) => ({
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
|
||||
image_url: { url: getImageDataUrl(part.source) },
|
||||
})),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Process non-tool messages
|
||||
@@ -158,106 +165,117 @@ export function convertToOpenAiMessages(
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${part.source.media_type};base64,${part.source.data}`,
|
||||
url: getImageDataUrl(part.source),
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
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",
|
||||
@@ -266,86 +284,91 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -358,12 +381,12 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso
|
||||
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 = {
|
||||
@@ -373,23 +396,25 @@ function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): Reaso
|
||||
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",
|
||||
@@ -405,14 +430,14 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
|
||||
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)
|
||||
@@ -421,37 +446,40 @@ export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.Ch
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -470,43 +498,47 @@ 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,5 +1,13 @@
|
||||
import { ResponseInput, ResponseInputMessageContentList, ResponseReasoningItem } from "openai/resources/responses/responses"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import type {
|
||||
ResponseInput,
|
||||
ResponseInputMessageContentList,
|
||||
ResponseReasoningItem,
|
||||
} from "openai/resources/responses/responses";
|
||||
import {
|
||||
type ClineStorageMessage,
|
||||
getBase64ImageSource,
|
||||
getImageDataUrl,
|
||||
} 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.
|
||||
@@ -75,56 +83,69 @@ 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 = [
|
||||
@@ -132,16 +153,17 @@ 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
|
||||
@@ -150,100 +172,115 @@ 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:${part.source.media_type}]` }],
|
||||
}
|
||||
content: [
|
||||
{
|
||||
type: "output_text",
|
||||
text: `[image:${getBase64ImageSource(part.source).mediaType}]`,
|
||||
},
|
||||
],
|
||||
};
|
||||
// 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: `data:${part.source.media_type};base64,${part.source.data}`,
|
||||
})
|
||||
break
|
||||
image_url: getImageDataUrl(part.source),
|
||||
});
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import OpenAI from "openai"
|
||||
import { ClineAssistantThinkingBlock, ClineStorageMessage } from "@/shared/messages/content"
|
||||
import type { Anthropic } from "@anthropic-ai/sdk";
|
||||
import type OpenAI from "openai";
|
||||
import {
|
||||
type ClineAssistantThinkingBlock,
|
||||
type ClineStorageMessage,
|
||||
getImageDataUrl,
|
||||
} 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.
|
||||
@@ -21,43 +25,45 @@ 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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,84 +74,104 @@ 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: `data:${part.source.media_type};base64,${part.source.data}` },
|
||||
})
|
||||
}
|
||||
})
|
||||
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")
|
||||
}
|
||||
} 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
|
||||
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 {
|
||||
const mergedContent = [...lastContent, ...newContent] as OpenAI.Chat.ChatCompletionUserMessageParam["content"]
|
||||
lastMessage.content = mergedContent
|
||||
messageContent = textParts.join("\n");
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
messageContent = message.content;
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}, [])
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
} 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 { Anthropic } from "@anthropic-ai/sdk"
|
||||
import * as vscode from "vscode"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { 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,27 +41,31 @@ 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 = [
|
||||
@@ -74,46 +78,55 @@ export function convertToVsCodeLmMessages(
|
||||
: (toolMessage.content?.map((part) => {
|
||||
if (part.type === "image") {
|
||||
return new vscode.LanguageModelTextPart(
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
|
||||
)
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && 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?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
|
||||
)
|
||||
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && 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 = [
|
||||
@@ -130,20 +143,24 @@ 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(
|
||||
@@ -151,18 +168,22 @@ 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 {
|
||||
@@ -177,7 +198,7 @@ export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelC
|
||||
type: "text",
|
||||
text: part.value,
|
||||
citations: null,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (part instanceof vscode.LanguageModelToolCallPart) {
|
||||
@@ -186,10 +207,10 @@ export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelC
|
||||
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,
|
||||
@@ -199,6 +220,7 @@ export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelC
|
||||
output_tokens: 0,
|
||||
cache_creation_input_tokens: null,
|
||||
cache_read_input_tokens: null,
|
||||
server_tool_use: null,
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,29 +46,34 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,9 +81,9 @@ export function extractTokenUsageFromMessage(message: ClineMessage | undefined):
|
||||
* Context files written for hook access
|
||||
*/
|
||||
export interface PreCompactContextFiles {
|
||||
contextJsonPath: string
|
||||
contextRawPath: string
|
||||
hookTimestamp: number
|
||||
contextJsonPath: string;
|
||||
contextRawPath: string;
|
||||
hookTimestamp: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,23 +96,32 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,53 +131,61 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,7 +193,7 @@ export interface PreCompactHookParams {
|
||||
*/
|
||||
export interface PreCompactHookResult {
|
||||
/** Context modification provided by the hook */
|
||||
contextModification?: string
|
||||
contextModification?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,37 +206,52 @@ 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)
|
||||
// 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.
|
||||
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
|
||||
@@ -245,53 +282,62 @@ export async function executePreCompactHookWithCleanup(params: PreCompactHookPar
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
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"
|
||||
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";
|
||||
|
||||
/**
|
||||
* Atomically write data to a file using temp file + rename pattern.
|
||||
@@ -28,16 +32,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +71,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") {
|
||||
@@ -76,33 +80,37 @@ 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");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,66 +123,68 @@ 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");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,41 +192,55 @@ 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");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -224,63 +248,93 @@ 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<Anthropic.MessageParam[]> {
|
||||
const filePath = path.join(await ensureTaskDirectoryExists(taskId), GlobalFileNames.apiConversationHistory)
|
||||
const fileExists = await fileExistsAtPath(filePath)
|
||||
export async function getSavedApiConversationHistory(
|
||||
taskId: string,
|
||||
): Promise<ClineStorageMessage[]> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,9 +343,11 @@ export async function saveClineMessages(taskId: string, uiMessages: ClineMessage
|
||||
* 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(),
|
||||
@@ -300,9 +356,9 @@ export async function collectEnvironmentMetadata(): Promise<Omit<EnvironmentMeta
|
||||
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(),
|
||||
@@ -311,191 +367,245 @@ export async function collectEnvironmentMetadata(): Promise<Omit<EnvironmentMeta
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,11 +614,11 @@ export async function deleteRemoteConfigFromCache(organizationId: string): Promi
|
||||
* 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.
|
||||
@@ -516,7 +626,7 @@ let runtimeHooksDir: string | undefined
|
||||
* when discovering hooks.
|
||||
*/
|
||||
export function setRuntimeHooksDir(dir: string | undefined): void {
|
||||
runtimeHooksDir = dir
|
||||
runtimeHooksDir = dir;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -531,24 +641,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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -560,17 +670,20 @@ 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));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -588,17 +701,20 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -608,14 +724,20 @@ 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,59 +756,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;
|
||||
}
|
||||
}
|
||||
|
||||
+1983
-1320
File diff suppressed because it is too large
Load Diff
@@ -1,33 +1,37 @@
|
||||
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?.media_type || "unknown"
|
||||
const sourceType = block.source?.type || "unknown";
|
||||
const mediaType =
|
||||
(block.source?.type === "base64" && 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,
|
||||
}
|
||||
})
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user