Compare commits

..
Author SHA1 Message Date
Saoud Rizwan 3ef39b05ca test(sdk): derive the harness tail in the system prompt test
Replace the duplicated working-guidelines literal with a single HARNESS
constant sliced from the pinned default prompt, and assert persona swaps
keep that tail byte-for-byte. Stronger check, one source of truth.
2026-06-15 12:03:25 -07:00
Saoud Rizwan f059656ca5 fix(sdk): keep working guidelines in the harness for agent profiles
The persona/harness split let an agent profile body drop the working
guidelines along with the persona. Treat the guidelines as harness so a
profile swaps only the coding-agent identity + workflow persona; the rest
is always preserved. The "use tools or ask instead of fabricating" norm
moves into the Remember guidelines (first bullet) so profiles keep it too.
Guidelines are inlined into the harness template (no separate exported
constant). The default prompt's instruction set is unchanged; that one norm
is reordered into the Remember list.
2026-06-15 11:52:53 -07:00
Saoud Rizwan acf7fde625 fix(cli): tune agent status label 2026-06-10 17:30:11 -07:00
Saoud Rizwan c725f3da42 fix(cli): show agent profile in status bar 2026-06-10 17:29:47 -07:00
Saoud Rizwan 874b495abf Merge branch 'main' into saoudrizwan/agent-profiles 2026-06-10 17:11:46 -07:00
Saoud Rizwan ccc8e5759d fix(cli): unblock agent profile merge checks 2026-06-10 17:10:22 -07:00
BeeandSaoud Rizwan 6138bdfe40 feat: Enforce a production singleton Cline Hub (#11372)
* feat: Enforce a production singleton Cline Hub

This PR changes local Hub startup/discovery so production uses one stable daemon per user machine instead of silently creating additional hubs on random ports.

Replace resolveSharedHubOwnerContext with resolveProductionHubOwnerContext
across doctor and hub server lifecycle management to scope hub discovery
to the production owner.

Additionally:
- Preserve and propagate auth tokens when retiring incompatible hubs
- Throw a clear error when a compatible hub is already running but its
  discovery record is missing, guiding users to run 'cline doctor fix'
- Gate port fallback behind an explicit allowPortFallback override
- Update tests to mock the new production hub owner context

* patches

* fix

* hasExplicitPort

* Restored daemon cron startup, made discovery auth tokens required again, and fixed graceful hub stop/restart paths to use the selected production/shared owner context.

* clean up

* patches

* fix Polynomial regular expression

* test

* fix: require explicit hub port fallback in production

* fix(cli): stop pgrep from parsing the hub daemon marker as an option

pgrep treats the "--cline-hub-daemon" pattern as an unknown long option
and exits 2, so doctor never found stale daemons from compiled-binary
installs, which are exactly the processes 'cline doctor fix' is told to
clean up. Pass "--" before the pattern to end option parsing.

* fix(hub): retire legacy shared-owner hubs on production startup

Pre-singleton production builds tracked the local hub under the shared
owner discovery path and spawned daemons on random fallback ports. The
production owner context never reads that path, so upgrades would leave
those daemons running indefinitely with no way to reuse or stop them.
Retire the recorded legacy hub (its record carries the auth token and
pid needed for a graceful stop) and clear the legacy record before
resolving the production hub.

* refactor(hub): simplify stale discovery clearing, share capability list

shouldClearStaleHubDiscovery was only ever called with
discoveredVerified=false (the true assignment sits on a return path),
so the expected-hub probe and compatibility check had no effect and the
condition reduced to "a discovery record exists and was not reused".
Replace it with a plain conditional and drop the tests that exercised
unreachable states.

Also move the hub capability list into a typed HUB_CAPABILITIES
constant in @cline/shared next to HubCapabilityName so the server
cannot drift from the type.

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-10 16:59:13 -07:00
Saoud Rizwan 8b9590e1ea fix(cli): require path separator when classifying workspace agents
A bare startsWith(workspaceRoot) would label a profile under
/workspace-linked as a workspace agent when the root is /workspace.
Append the native separator so only real children match.
2026-06-10 15:51:02 -07:00
Saoud Rizwan 9b4aa6307b refactor(sdk,cli): trim comments and dead surface from agent profiles
Drop unused description/path fields from ActiveAgentProfile (captured and
threaded but never read), remove the unconsumed package index exports of
composeClineSystemPrompt/DEFAULT_CLINE_PERSONA/DEFAULT_CLINE_WORKING_GUIDELINES,
and cut inline comments that restated the code, keeping the ones that
explain non-obvious behavior (byte-identity, persona insertion order,
metadata dedup, mid-run guard).
2026-06-10 15:31:47 -07:00
Saoud Rizwan adb7f014cf fix(cli): refuse agent switching while a task is running
Applying a profile restarts the session in place, and the selector is
reachable mid-run via the status bar indicator and /agents autocomplete.
Guard the selector entry point with a status message instead of yanking
the live stream, mirroring how fork refuses when unavailable.
2026-06-10 15:09:08 -07:00
Saoud Rizwan ebefaa68c4 fix(sdk,cli): harden persona insertion and --system/--agent precedence
Insert the persona body after all placeholder replacements so template
tokens inside a profile body stay literal instead of hijacking the first
replacement. Resolve the guidelines slot before the persona inside
composeClineSystemPrompt for the same reason. Clarify in the docs that the
persona slot covers the default persona and its working guidelines.

When --system and --agent are both passed, ignore the agent profile
entirely instead of storing it: keeping it around meant a later plan/act
toggle silently switched the session to the profile prompt and the status
bar showed an agent that was never used.
2026-06-10 15:04:00 -07:00
Saoud Rizwan 0e240ed329 feat(cli): wire up agent profile selection for the main agent
Agent profiles in .cline/agents were discovered and listed in /settings
but never usable. This adds session-only selection of a profile for the
main Cline agent:

- /agents dialog selector (with hidden /agent alias) listing workspace
  and global profiles plus parse errors, following the provider picker
  pattern; also reachable from the command palette (Opt+T)
- status bar indicator showing the active profile next to the Plan/Act
  toggle, clickable to reopen the selector
- selecting a profile re-resolves the system prompt with the profile
  body in the persona slot and restarts the session in place keeping
  messages; plan/act toggles preserve the active profile
- --agent <name> flag for non-interactive runs (rejected in yolo mode,
  errors list available profiles); -s still wins over --agent
- interactive-config agent listing now reuses the SDK parser instead of
  a duplicated frontmatter regex, and broken profile files stay visible
  with their load error

Only name/description/body apply to the main agent; providerId/modelId/
tools in profile frontmatter remain subagent-spawn concerns.
2026-06-10 14:43:36 -07:00
Saoud Rizwan 2a4e33105c style(sdk): sort imports in agent-message-codec.test.ts 2026-06-10 14:43:06 -07:00
Saoud Rizwan 7e8a0df023 feat(sdk): apply persona slot to subagent system prompts
Subagent spawn prompts (configured agent profiles and ad-hoc spawn_agent)
now fill the persona slot instead of fully replacing the system prompt, so
subagents keep the env block and tool-call loop harness. The non-cline
provider early return is removed: the harness is provider-agnostic and the
Cline-specific workspace metadata is already gated on providerId inside
buildClineSystemPrompt. Teammate prompts are unchanged.
2026-06-10 14:42:06 -07:00
Saoud Rizwan df7124d561 feat(sdk): split Cline system prompt into persona slot and agent harness
Extract the coding-agent persona from DEFAULT_CLINE_SYSTEM_PROMPT into a
template slot so an agent profile body can replace just the persona while
keeping the harness (env block, tool-call loop contract, completion
instructions, rules/metadata placeholders). Adds composeClineSystemPrompt
plus a personaPrompt option on buildClineSystemPrompt; the default output
stays byte-identical, pinned by test. overridePrompt and the yolo template
are unchanged. Metadata injection is skipped when the persona already
embeds a workspace configuration block. Placeholder replacements now use
replacer functions so values containing $&-style patterns are inserted
literally.
2026-06-10 14:41:26 -07:00
Dominic Cooneyandgreptile-apps[bot] 7d119351b1 fix(cli): suppress flickering console windows on Windows (#11408)
* fix(cli): suppress flickering console windows on Windows by setting windowsHide on child processes

On Windows, child_process.spawn/execFile default to windowsHide: false,
so console-subsystem children (powershell, rg, git, node, npm) can
allocate a new visible console window - guaranteed when detached: true
is used. In the CLI this caused constant short-lived window flashes
from run_commands, the git status bar polling, ripgrep searches and
indexing, clipboard helpers, and hook/plugin node subprocesses.

Set windowsHide: true (CREATE_NO_WINDOW; a no-op on non-Windows) on all
remaining spawn/spawnSync/execFile call sites in the SDK core, CLI,
Cline Hub, and example plugins, matching the pattern already used by
the MCP client, checkpoint-hooks, and StandaloneTerminalProcess.

* Update apps/cli/src/commands/kanban.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-10 22:51:59 +09:00
Saoud Rizwan de987a5246 chore(cli): release v3.0.23 2026-06-09 17:43:24 -07:00
Saoud Rizwan 90050426df chore(sdk): release v0.0.46 2026-06-09 17:30:20 -07:00
Saoud Rizwan 205c5676ff fix(llms): fix disabled reasoning for Fable 5 error (#11397)
* fix(llms): avoid disabled reasoning for fable 5

* fix(llms): route fable reasoning by family

* Revert "fix(llms): route fable reasoning by family"

This reverts commit 6dd4e5dcf5.

* fix(llms): match claude fable reasoning workaround broadly
2026-06-09 17:24:34 -07:00
BeeandSaoud Rizwan 1c13edd395 fix(core): configured agent support as subagent tools (#11368)
* fix(core):  configured agent support as subagent tools

Introduce configured agent config parsing and tool creation for
subagents. Agent configs are defined via YAML frontmatter files
specifying name, description, tools, skills, model, and system prompt.

- Add `configured-agent-config` for loading and parsing agent
  definitions from search paths
- Add configured agent tool factory that wraps delegated agents as
  named subagent tools with policy and approval support

* patch

* patches

* fixes

* Infinite loop when YAML block is a non-object fix

* apply feedback

Forwarded host requestToolApproval into configured subagents.
Used the resolved workspace config root for configured-agent skills discovery.
Split configured-agent skill loading from root-session skills enablement.
Added host lifecycle/event plumbing for configured subagents via shared subagent callbacks.
Made UserInstructionConfigService.createSkillsExecutor optional and guarded its use.

* threaded

* test(core): cover configured subagent skill isolation (#11396)

---------

Co-authored-by: Saoud Rizwan <7799382+saoudrizwan@users.noreply.github.com>
2026-06-09 17:23:00 -07:00
Ara a2a1936709 Fix Azure Foundry API version for CLI (#11359)
* Fix Azure Foundry API version for CLI

* Fix Azure API version setup
2026-06-09 16:28:37 -07:00
MaxandMax Paulus 🥪 35ce6a3f26 fix(cli): configure Vertex GCP settings (#11390)
Co-authored-by: Max Paulus 🥪 <max@cline.bot>
2026-06-09 16:05:09 -07:00
Saoud Rizwan 2c4aeae4f3 fix(vscode): handle DeepSeek V4 reasoning format (#11392) 2026-06-09 15:10:38 -07:00
Tomás Barreiro 0c027d2731 Centralize OAuth management to the SDK (#11260)
* Centralize OAuth management to the SDK

* Update mock

* Cleanup TUI cline-account logic

* clean save credentails

* Remove unused code

* Reduce mocks

* use normalizeStoredAccessToken
2026-06-09 23:56:26 +02:00
Tomás Barreiro 6cc93c124e Format vscode using biome higher order rules (#11389) 2026-06-09 22:14:19 +02:00
Saoud Rizwan 7e5b8be28c chore(cli): release v3.0.22 2026-06-09 12:08:17 -07:00
151 changed files with 5674 additions and 1223 deletions
+3 -1
View File
@@ -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": {
+13
View File
@@ -1,5 +1,18 @@
# Cline CLI Changelog
## 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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.21",
"version": "3.0.23",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+24 -59
View File
@@ -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 };
}
+37 -1
View File
@@ -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));
+40 -124
View File
@@ -3,11 +3,13 @@ import {
BUILT_IN_PROVIDER,
createOAuthClientCallbacks,
ensureCustomProvidersLoaded,
getProviderAuthHandler,
listLocalProviders,
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";
@@ -37,40 +39,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 +49,7 @@ type AuthQuickSetupInput = {
apikey: string;
modelid: string;
baseurl?: string;
azureApiVersion?: string;
};
type AuthCommandInput = {
@@ -90,6 +59,7 @@ type AuthCommandInput = {
apikey?: string;
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
};
type ParsedAuthCommandArgs = {
@@ -97,30 +67,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 +87,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 +105,7 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
apikey?: string;
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
}>();
const positionalProvider = cmd.args[0];
return {
@@ -161,6 +113,7 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
apikey: opts.apikey,
modelid: opts.modelid,
baseurl: opts.baseurl,
azureApiVersion: opts.azureApiVersion,
};
}
@@ -200,6 +153,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 +168,7 @@ function saveQuickAuthProviderSettings(input: {
apikey: string;
modelid: string;
baseurl?: string;
azureApiVersion?: string;
}): void {
const existing = input.providerSettingsManager.getProviderSettings(
input.providerId,
@@ -224,6 +184,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 +238,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 +268,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 +285,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 +306,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 +391,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,13 +434,10 @@ export async function runAuthProviderCommand(
return 1;
}
try {
const existing = providerSettingsManager.getProviderSettings(providerId);
const credentials = await loginWithOAuthProvider(providerId, existing, io);
saveOAuthProviderSettings(
await loginAndSaveProviderOAuthCredentials(
providerSettingsManager,
providerId,
existing,
credentials,
{ callbacks: createOAuthCallbacks(io) },
);
io.writeln(
`${c.green}You are now logged in to ${c.cyan}${providerId}${c.reset}`,
+24 -2
View File
@@ -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,
+60 -9
View File
@@ -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",
+51 -1
View File
@@ -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 });
});
});
+14 -5
View File
@@ -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(
+6
View File
@@ -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;
+2
View File
@@ -506,6 +506,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) => {
+5
View File
@@ -51,6 +51,10 @@ export function addRootOptions(cmd: Command): Command {
"-s, --system <system-prompt>",
"Override the default system prompt",
)
.option(
"--agent <name>",
"Use an agent profile from .cline/agents for this session",
)
.option("-z, --zen", "Start a session that runs in the background hub")
.option(
"--retries [value]",
@@ -225,6 +229,7 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
if (opts.cwd !== undefined) result.cwd = opts.cwd;
if (opts.teamName !== undefined) result.teamName = opts.teamName;
if (opts.system !== undefined) result.systemPrompt = opts.system;
if (opts.agent !== undefined) result.agent = opts.agent;
if (opts.model !== undefined) result.model = opts.model;
if (opts.provider !== undefined) result.provider = opts.provider;
if (opts.key !== undefined) result.key = opts.key;
@@ -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 = {
+67
View File
@@ -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(
+23 -7
View File
@@ -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) {
+3
View File
@@ -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",
+158
View File
@@ -2,6 +2,23 @@ import { fstatSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CliMigrationNotice } from "./kanban-migration/notice";
interface MockConfiguredAgentConfig {
name: string;
description: string;
systemPrompt: string;
path?: string;
}
interface MockConfiguredAgentReadError {
path: string;
error: Error;
}
interface MockConfiguredAgentLoadResult {
configs: MockConfiguredAgentConfig[];
errors: MockConfiguredAgentReadError[];
}
/** Real `fstatSync`: used when tests stub only stdin (fd 0); throwing for every fd breaks imports and session I/O. */
const fsActual = vi.hoisted(() => ({
realFstatSync: null as null | typeof import("node:fs").fstatSync,
@@ -47,6 +64,14 @@ const sessionMocks = vi.hoisted(() => ({
const llmMocks = vi.hoisted(() => ({
resolveProviderConfig: vi.fn(async (): Promise<unknown> => undefined),
}));
const agentConfigMocks = vi.hoisted(() => ({
loadConfiguredAgentConfigs: vi.fn<
(input: { workspaceRoot?: string }) => MockConfiguredAgentLoadResult
>(() => ({
configs: [],
errors: [],
})),
}));
const promptMocks = vi.hoisted(() => ({
resolveSystemPrompt: vi.fn(async () => "system prompt"),
}));
@@ -142,6 +167,7 @@ vi.mock("./session/session", () => sessionMocks);
vi.mock("@cline/core", () => {
return {
resolveProviderConfig: llmMocks.resolveProviderConfig,
loadConfiguredAgentConfigs: agentConfigMocks.loadConfiguredAgentConfigs,
createTeamName: vi.fn(() => "team-test"),
createUserInstructionConfigService: vi.fn(() => ({
start: vi.fn(async () => {}),
@@ -215,6 +241,11 @@ describe("runCli lightweight command dispatch", () => {
});
llmMocks.resolveProviderConfig.mockReset();
llmMocks.resolveProviderConfig.mockResolvedValue(undefined);
agentConfigMocks.loadConfiguredAgentConfigs.mockReset();
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
configs: [],
errors: [],
});
authMocks.ensureOAuthProviderApiKey.mockReset();
authMocks.getPersistedProviderApiKey.mockReset();
authMocks.getPersistedProviderApiKey.mockReturnValue(undefined);
@@ -872,6 +903,133 @@ describe("runCli lightweight command dispatch", () => {
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
});
it("applies an agent profile to prompt runs", async () => {
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
configs: [
{
name: "Reviewer",
description: "Reviews code",
systemPrompt: "You are a reviewer.",
path: "/repo/.cline/agents/reviewer.yaml",
},
],
errors: [],
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--agent", "reviewer", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(agentConfigMocks.loadConfiguredAgentConfigs).toHaveBeenCalledWith({
workspaceRoot: "/workspace/cline",
});
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
expect.objectContaining({
agentPersona: "You are a reviewer.",
}),
);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
expect.objectContaining({
agentProfile: {
name: "Reviewer",
systemPrompt: "You are a reviewer.",
},
systemPrompt: "system prompt",
}),
expect.anything(),
);
});
it("lets --system override --agent without storing the profile", async () => {
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
configs: [
{
name: "Reviewer",
description: "Reviews code",
systemPrompt: "You are a reviewer.",
path: "/repo/.cline/agents/reviewer.yaml",
},
],
errors: [],
});
forcePromptModeInput();
process.argv = [
"bun",
"src/index.ts",
"--agent",
"reviewer",
"--system",
"Custom system.",
"hello",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(agentConfigMocks.loadConfiguredAgentConfigs).not.toHaveBeenCalled();
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
expect.objectContaining({
explicitSystemPrompt: "Custom system.",
}),
);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
expect.objectContaining({
agentProfile: undefined,
}),
expect.anything(),
);
});
it("rejects missing agent profiles before starting the runtime", async () => {
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
configs: [
{
name: "Planner",
description: "Plans work",
systemPrompt: "You are a planner.",
path: "/repo/.cline/agents/planner.yaml",
},
],
errors: [
{
path: "/repo/.cline/agents/broken.yaml",
error: new Error("Missing system prompt body"),
},
],
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--agent", "reviewer", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
});
it("rejects --agent in yolo mode before loading profiles", async () => {
forcePromptModeInput();
process.argv = [
"bun",
"src/index.ts",
"--yolo",
"--agent",
"reviewer",
"hello",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(agentConfigMocks.loadConfiguredAgentConfigs).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
});
it("rewrites /team prompts and enables teams in single-prompt mode", async () => {
runtimeMocks.runAgent.mockClear();
+49 -1
View File
@@ -42,7 +42,7 @@ import {
captureCliExtensionActivated,
getCliTelemetryService,
} from "./utils/telemetry";
import type { Config } from "./utils/types";
import type { ActiveAgentProfile, Config } from "./utils/types";
import { runConnectWizard } from "./wizards/connect";
import { runMcpWizard } from "./wizards/mcp";
import { runScheduleWizard } from "./wizards/schedule";
@@ -152,6 +152,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 +166,7 @@ export async function runCli(): Promise<void> {
apikey?: string;
modelid?: string;
baseurl?: string;
azureApiVersion?: string;
config?: string;
cwd?: string;
dataDir?: string;
@@ -195,6 +197,7 @@ export async function runCli(): Promise<void> {
apikey: opts.apikey,
modelid: opts.modelid,
baseurl: opts.baseurl,
azureApiVersion: opts.azureApiVersion,
io,
});
});
@@ -925,6 +928,49 @@ export async function runCli(): Promise<void> {
cwd,
});
let activeAgentProfile: ActiveAgentProfile | undefined;
const requestedAgentName = args.agent?.trim();
if (requestedAgentName) {
if (isYoloMode) {
writeErr("--agent is not supported in yolo mode");
process.exitCode = 1;
return;
}
if (args.systemPrompt) {
// Don't store an unused profile: it would resurface on plan/act toggles.
writeln(
`${c.dim}[warn] --system overrides --agent; ignoring agent profile "${requestedAgentName}"${c.reset}`,
);
} else {
const { loadConfiguredAgentConfigs } = await import("@cline/core");
const { configs, errors } = loadConfiguredAgentConfigs({
workspaceRoot,
});
const profile = configs.find(
(candidate) =>
candidate.name.trim().toLowerCase() ===
requestedAgentName.toLowerCase(),
);
if (!profile) {
const availableNames = configs.map((candidate) => candidate.name);
writeErr(
availableNames.length > 0
? `agent profile "${requestedAgentName}" not found (available: ${availableNames.join(", ")})`
: `agent profile "${requestedAgentName}" not found (no agent profiles in .cline/agents)`,
);
for (const error of errors) {
writeErr(`failed to load ${error.path}: ${error.error.message}`);
}
process.exitCode = 1;
return;
}
activeAgentProfile = {
name: profile.name,
systemPrompt: profile.systemPrompt,
};
}
}
const config: Config = {
providerId: provider,
modelId:
@@ -939,6 +985,7 @@ export async function runCli(): Promise<void> {
explicitSystemPrompt: args.systemPrompt,
providerId: provider,
mode: args.mode ?? "act",
agentPersona: activeAgentProfile?.systemPrompt,
}),
execution: {
maxConsecutiveMistakes: args.retries ?? 3,
@@ -961,6 +1008,7 @@ export async function runCli(): Promise<void> {
telemetry: getCliTelemetryService(loggerAdapter.core),
defaultToolAutoApprove,
toolPolicies,
agentProfile: activeAgentProfile,
enableSpawnAgent: !isYoloMode,
enableAgentTeams: !isYoloMode,
enableTools: true,
@@ -78,4 +78,36 @@ describe("applyInteractiveModeConfig", () => {
expect(config.extraTools).toEqual([]);
expect(config.systemPrompt).toBe("system prompt for act");
});
it("threads the active agent profile persona across mode switches", async () => {
const config = makeConfig();
config.agentProfile = {
name: "reviewer",
systemPrompt: "You are a reviewer.",
};
await applyInteractiveModeConfig({
config,
mode: "plan",
switchToActModeTool,
});
expect(resolveSystemPrompt).toHaveBeenLastCalledWith({
cwd: config.cwd,
providerId: config.providerId,
mode: "plan",
agentPersona: "You are a reviewer.",
});
await applyInteractiveModeConfig({
config,
mode: "act",
switchToActModeTool,
});
expect(resolveSystemPrompt).toHaveBeenLastCalledWith({
cwd: config.cwd,
providerId: config.providerId,
mode: "act",
agentPersona: "You are a reviewer.",
});
});
});
+1
View File
@@ -43,5 +43,6 @@ export async function applyInteractiveModeConfig(input: {
cwd: input.config.cwd,
providerId: input.config.providerId,
mode: input.mode,
agentPersona: input.config.agentProfile?.systemPrompt,
});
}
@@ -21,7 +21,7 @@ import type {
import { createRuntimeHooks } from "../../utils/hooks";
import { setActiveCliSession } from "../../utils/output";
import { loadInteractiveResumeMessages } from "../../utils/resume";
import type { Config } from "../../utils/types";
import type { ActiveAgentProfile, Config } from "../../utils/types";
import { markAbortInProgress } from "../active-runtime";
import type {
PendingPromptSnapshot,
@@ -359,6 +359,19 @@ export function createInteractiveSessionRuntime(input: {
await restartWithCurrentMessages();
};
const applyAgentProfile = async (
profile: ActiveAgentProfile | undefined,
): Promise<void> => {
input.config.agentProfile = profile;
// Re-apply the current mode so the system prompt picks up the persona.
await applyInteractiveModeConfig({
config: input.config,
mode: input.config.mode === "plan" ? "plan" : "act",
switchToActModeTool: input.switchToActModeTool,
});
await restartWithCurrentMessages();
};
const sendCurrentTurn = async (
turnInput: CurrentTurnInput,
): Promise<CurrentTurnResult> => {
@@ -639,6 +652,7 @@ export function createInteractiveSessionRuntime(input: {
getCheckpointData,
restoreCheckpoint,
applyMode,
applyAgentProfile,
resetAbortRequest,
abortAll,
cleanup,
+3
View File
@@ -28,6 +28,8 @@ export async function resolveSystemPrompt(input: {
providerId?: string;
rules?: string;
mode?: AgentMode;
/** Agent profile body that replaces the persona slot of the base prompt */
agentPersona?: string;
}): Promise<string> {
const metadata = await buildWorkspaceMetadata(input.cwd);
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
@@ -45,6 +47,7 @@ export async function resolveSystemPrompt(input: {
mode: input.mode,
providerId: input.providerId,
overridePrompt: input.explicitSystemPrompt,
personaPrompt: input.agentPersona,
platform:
(typeof process !== "undefined" && process?.platform) || "unknown",
});
+4
View File
@@ -598,6 +598,10 @@ export async function runInteractive(
}
await applyModeChange(mode);
},
onAgentProfileChange: async (profile) => {
await sessionRuntime.ensureReady();
await sessionRuntime.applyAgentProfile(profile ?? undefined);
},
onNewSession: async () => {
await sessionRuntime.resetForNewSession();
},
+50 -21
View File
@@ -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,14 @@ const coreMocks = vi.hoisted(() => {
return {
getProviderSettings: vi.fn(),
saveProviderSettings: vi.fn(),
getValidClineCredentials: vi.fn(),
serviceOptions,
};
});
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;
@@ -32,7 +33,6 @@ vi.mock("@cline/core", () => {
coreMocks.saveProviderSettings(settings, options);
}
},
getValidClineCredentials: coreMocks.getValidClineCredentials,
};
});
@@ -59,15 +59,51 @@ 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.serviceOptions.length = 0;
});
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 +113,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 +137,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 +153,6 @@ describe("createClineAccountService", () => {
expiresAt: 1,
},
});
coreMocks.getValidClineCredentials.mockResolvedValue(null);
const { createClineAccountService } = await import("./cline-account");
+25 -53
View File
@@ -4,16 +4,18 @@ 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 type { Config } from "../utils/types";
const WORKOS_TOKEN_PREFIX = "workos:";
export const CLINE_CREDITS_DASHBOARD_URL =
"https://app.cline.bot/dashboard/account?tab=credits";
@@ -69,26 +71,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 +87,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;
@@ -271,6 +271,35 @@ describe("slash command registry", () => {
).toContain("settings");
});
it("exposes agents as a local command with a hidden agent alias", () => {
const registry = buildSlashCommandRegistry({});
const commandNames = getVisibleSystemSlashCommands(registry).map(
(command) => command.name,
);
expect(resolveSlashCommand(registry, "agents")).toMatchObject({
source: "tui",
execution: "local",
description: "Switch agent profile",
visible: true,
selectable: true,
});
expect(resolveSlashCommand(registry, "agent")).toMatchObject({
source: "tui",
execution: "local",
visible: false,
selectable: false,
});
expect(commandNames).toContain("agents");
expect(commandNames).not.toContain("agent");
expect(commandNames.indexOf("agents")).toBeGreaterThan(
commandNames.indexOf("model"),
);
expect(commandNames.indexOf("agents")).toBeLessThan(
commandNames.indexOf("account"),
);
});
it("always exposes the account command", () => {
const registry = buildSlashCommandRegistry({});
@@ -17,6 +17,8 @@ export type LocalSlashCommandName =
| "plugins"
| "account"
| "model"
| "agents"
| "agent"
| "compact"
| "skills"
| "fork"
@@ -62,6 +64,15 @@ const TUI_LOCAL_COMMANDS: Array<{
name: "model",
description: "Switch model or provider",
},
{
name: "agents",
description: "Switch agent profile",
},
{
name: "agent",
description: "Switch agent profile",
visible: false,
},
{
name: "account",
description: "View Cline account",
@@ -112,6 +123,7 @@ const TUI_LOCAL_COMMANDS: Array<{
const SYSTEM_COMMAND_ORDER = [
"settings",
"model",
"agents",
"account",
"mcp",
"plugins",
@@ -0,0 +1,108 @@
import { basename } from "node:path";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useMemo } from "react";
import {
type SearchableItem,
SearchableList,
useSearchableList,
} from "../searchable-list";
/** Sentinel resolved when the user picks the default Cline agent. */
export const DEFAULT_AGENT_ACTION = "__default_agent__";
export interface AgentProfileOption {
name: string;
description?: string;
systemPrompt: string;
source: "workspace" | "global";
}
export interface AgentProfileLoadError {
path: string;
message: string;
}
export function AgentSelectorContent(
props: ChoiceContext<string> & {
currentAgentName: string | null;
agents: AgentProfileOption[];
loadErrors: AgentProfileLoadError[];
},
) {
const { resolve, dismiss, dialogId, currentAgentName, agents, loadErrors } =
props;
const items: SearchableItem[] = useMemo(() => {
const normalizedCurrent = currentAgentName?.trim().toLowerCase() ?? null;
const defaultItem: SearchableItem = {
key: DEFAULT_AGENT_ACTION,
label: "Cline (default)",
detail: "Standard Cline agent",
section: "Agents",
rightLabel: normalizedCurrent === null ? "(current)" : undefined,
};
const profileItems = agents.map((agent) => ({
key: agent.name.toLowerCase(),
label: agent.name,
detail: agent.description,
section:
agent.source === "workspace" ? "Workspace agents" : "Global agents",
rightLabel:
normalizedCurrent === agent.name.trim().toLowerCase()
? "(current)"
: undefined,
}));
return [defaultItem, ...profileItems];
}, [agents, currentAgentName]);
const list = useSearchableList(items);
useDialogKeyboard((key) => {
if (key.name === "escape") {
dismiss();
return;
}
if (key.name === "return") {
const item = list.selectedItem;
if (item) resolve(item.key);
return;
}
if (key.name === "up") {
list.moveUp();
return;
}
if (key.name === "down") {
list.moveDown();
}
}, dialogId);
return (
<box flexDirection="column" gap={1}>
<text>Select Agent</text>
<SearchableList
items={list.filtered}
selected={list.safeSelected}
placeholder="Search agents..."
onSearchChange={list.setSearch}
onItemSelect={(item) => resolve(item.key)}
emptyText="No agents match"
/>
{loadErrors.length > 0 && (
<box flexDirection="column">
{loadErrors.map((error) => (
<text key={error.path} fg="red">
{basename(error.path)}: {error.message}
</text>
))}
</box>
)}
<text fg="gray">
Type to search, / navigate, Enter to select, Esc to go back
</text>
</box>
);
}
@@ -2,6 +2,7 @@ export type CommandPaletteAction =
| "settings"
| "change-model"
| "change-provider"
| "agents"
| "account"
| "mcp"
| "plugins"
@@ -57,6 +58,13 @@ const ACTION_ITEMS: Array<{
description: "Switch provider and configure credentials",
keywords: ["provider", "api key", "account", "auth"],
},
{
action: "agents",
label: "Switch Agent",
shortcut: "Opt+T",
description: "Use an agent profile from .cline/agents",
keywords: ["agent", "agents", "profile", "persona", "subagent"],
},
{
action: "mcp",
label: "Manage MCP Servers",
@@ -120,6 +120,12 @@ const HELP_ROWS: HelpRow[] = [
key: "/model",
desc: "Switch model or provider",
},
{
kind: "entry",
id: "c-agents",
key: "/agents",
desc: "Switch agent profile",
},
{
kind: "entry",
id: "c-settings",
@@ -1,6 +1,7 @@
import {
completeClineDeviceAuth,
getProviderConfigFields,
isOAuthProvider,
listLocalProviders,
loginLocalProvider,
type ProviderConfigFieldKey,
@@ -21,12 +22,13 @@ import {
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
import { isOAuthProvider } from "../../../utils/provider-auth";
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,
);
@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import {
createContextBar,
formatStatusBarAgentLabel,
formatStatusBarAgentName,
formatStatusBarUsageText,
resolveContextBarFilledForeground,
} from "./status-bar";
@@ -49,6 +51,48 @@ describe("createContextBar", () => {
});
});
describe("formatStatusBarAgentName", () => {
it("keeps short names intact", () => {
expect(formatStatusBarAgentName("reviewer")).toBe("reviewer");
expect(formatStatusBarAgentName(" reviewer ")).toBe("reviewer");
});
it("truncates long names with an ellipsis", () => {
expect(formatStatusBarAgentName("documentation-specialist")).toBe(
"documentation...",
);
expect(formatStatusBarAgentName("documentation-specialist").length).toBe(
16,
);
});
it("handles very narrow limits without negative slicing", () => {
expect(formatStatusBarAgentName("reviewer", 3)).toBe("...");
expect(formatStatusBarAgentName("reviewer", 0)).toBe("");
});
});
describe("formatStatusBarAgentLabel", () => {
it("returns the trimmed active agent name", () => {
expect(formatStatusBarAgentLabel("reviewer")).toBe("reviewer");
expect(formatStatusBarAgentLabel(" reviewer ")).toBe("reviewer");
});
it("truncates to fit the label width", () => {
expect(formatStatusBarAgentLabel("documentation-specialist", 18)).toBe(
"documentation-s...",
);
expect(
formatStatusBarAgentLabel("documentation-specialist", 18)?.length,
).toBe(18);
});
it("hides blank or too-narrow labels", () => {
expect(formatStatusBarAgentLabel(" ")).toBeUndefined();
expect(formatStatusBarAgentLabel("reviewer", 0)).toBeUndefined();
});
});
describe("formatStatusBarUsageText", () => {
it("includes cost when usage cost is visible", () => {
expect(
+54 -6
View File
@@ -104,6 +104,23 @@ export function resolveModelMaxInputTokens(config: {
return undefined;
}
export function formatStatusBarAgentName(name: string, maxLen = 16): string {
const normalized = name.trim();
if (maxLen <= 0) return "";
if (normalized.length <= maxLen) return normalized;
if (maxLen <= 3) return ".".repeat(maxLen);
return `${normalized.slice(0, maxLen - 3)}...`;
}
export function formatStatusBarAgentLabel(
name: string,
maxLen = 18,
): string | undefined {
const normalized = name.trim();
if (!normalized || maxLen <= 0) return undefined;
return formatStatusBarAgentName(normalized, maxLen);
}
export interface StatusBarProps {
providerId: string;
modelId: string;
@@ -120,6 +137,9 @@ export interface StatusBarProps {
deletions: number;
} | null;
onToggleMode?: () => void;
/** Active agent profile name; the indicator is hidden when unset */
agentName?: string | null;
onOpenAgent?: () => void;
variant?: "home" | "chat";
}
@@ -135,6 +155,8 @@ export function StatusBar(props: StatusBarProps) {
gitBranch,
gitDiffStats,
onToggleMode,
agentName,
onOpenAgent,
} = props;
const { width } = useTerminalDimensions();
@@ -162,20 +184,26 @@ export function StatusBar(props: StatusBarProps) {
? Math.min(width, HOME_VIEW_MAX_WIDTH) - 2
: width - 2;
// Row 1 layout: [model + context info] .... [Plan/Act toggle]
// Row 1 layout: [model + context info] .... [agent label] [Plan/Act toggle]
// When the full row doesn't fit, context info drops to its own row 2.
// Model ID truncates with "..." before wrapping; toggle stays right-aligned.
const toggleWidth = 20;
const fullAgentLabel = agentName
? formatStatusBarAgentLabel(agentName)
: undefined;
const agentLabelWidth = fullAgentLabel ? fullAgentLabel.length + 3 : 0;
const usageText = formatStatusBarUsageText({
totalTokens,
totalCost,
showCost: showUsageCost,
});
const contextText = bar
? ` ${bar.filled}${bar.empty} ${usageText}`
: ` ${usageText}`;
const contextInlineText = bar
? `${bar.filled}${bar.empty} ${usageText}`
: usageText;
const contextText = ` ${contextInlineText}`;
const firstRowFits =
modelId.length + contextText.length + toggleWidth + 1 <= avail;
modelId.length + contextText.length + toggleWidth + agentLabelWidth + 1 <=
avail;
const renderContextText = (withLeadingSpace: boolean) => (
<>
{withLeadingSpace && " "}
@@ -191,7 +219,11 @@ export function StatusBar(props: StatusBarProps) {
const modelMaxLen = Math.max(
10,
avail - toggleWidth - (firstRowFits ? contextText.length : 0) - 1,
avail -
toggleWidth -
agentLabelWidth -
(firstRowFits ? contextText.length : 0) -
1,
);
const truncatedModel =
modelId.length > modelMaxLen
@@ -210,6 +242,14 @@ export function StatusBar(props: StatusBarProps) {
pathPart.length > pathMax
? `${pathPart.slice(0, pathMax - 3)}...`
: pathPart;
const firstRowAgentMaxLen = Math.min(
18,
Math.max(0, avail - toggleWidth - 3),
);
const agentLabel = agentName
? formatStatusBarAgentLabel(agentName, firstRowAgentMaxLen)
: undefined;
return (
<box flexDirection="column" paddingX={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -223,6 +263,14 @@ export function StatusBar(props: StatusBarProps) {
flexShrink={0}
onMouseDown={onToggleMode}
>
{agentLabel && (
<>
<box flexShrink={0} onMouseDown={onOpenAgent}>
<text fg={defaultFg}>{agentLabel}</text>
</box>
<text fg="gray">|</text>
</>
)}
<text fg={uiMode === "plan" ? planAccent : "gray"}>
{uiMode === "plan" ? "●" : "○"} Plan
</text>
@@ -21,6 +21,7 @@ interface SessionContextValue {
uiMode: AgentMode;
autoApproveAll: boolean;
compactionMode: CliCompactionMode;
activeAgentName: string | null;
lastTotalTokens: number;
lastTotalCost: number;
isExitRequested: boolean;
@@ -41,6 +42,7 @@ interface SessionContextValue {
setUiMode: (mode: AgentMode) => void;
toggleMode: () => void;
toggleAutoApprove: () => void;
setActiveAgentName: (name: string | null) => void;
setCompactionMode: (mode: CliCompactionMode) => void;
requestExit: () => void;
clearEntries: () => void;
@@ -112,6 +114,9 @@ export function SessionProvider(props: {
const [compactionMode, _setCompactionMode] = useState<CliCompactionMode>(() =>
getCliCompactionMode(config),
);
const [activeAgentName, setActiveAgentName] = useState<string | null>(
config.agentProfile?.name ?? null,
);
const [lastTotalTokens, setLastTotalTokens] = useState(
() => initialUsage?.totalTokens ?? 0,
);
@@ -250,6 +255,7 @@ export function SessionProvider(props: {
uiMode,
autoApproveAll,
compactionMode,
activeAgentName,
lastTotalTokens,
lastTotalCost,
isExitRequested,
@@ -268,6 +274,7 @@ export function SessionProvider(props: {
setUiMode,
toggleMode,
toggleAutoApprove,
setActiveAgentName,
setCompactionMode,
requestExit,
clearEntries,
@@ -7,6 +7,7 @@ export interface LocalSlashCommandActionInput {
openConfig: (options?: OpenConfigOptions) => void;
openMcpManager: () => Promise<boolean>;
openModelSelector: () => void;
openAgentSelector: () => void;
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
invocation?: LocalSlashCommandInvocation;
runCompact: () => void;
@@ -45,6 +46,10 @@ export function runLocalSlashCommandAction(
input.openModelSelector();
return true;
}
if (normalized === "agents" || normalized === "agent") {
input.openAgentSelector();
return true;
}
if (normalized === "compact") {
input.runCompact();
return true;
@@ -0,0 +1,119 @@
import { sep } from "node:path";
import { loadConfiguredAgentConfigs } from "@cline/core";
import type { ChoiceContext } from "@opentui-ui/dialog";
import type { DialogActions } from "@opentui-ui/dialog/react";
import { useCallback } from "react";
import type { Config } from "../../utils/types";
import {
type AgentProfileLoadError,
type AgentProfileOption,
AgentSelectorContent,
DEFAULT_AGENT_ACTION,
} from "../components/dialogs/agent-selector";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import { useSession } from "../contexts/session-context";
import type { TuiProps } from "../types";
function loadAgentProfileEntries(config: Config): {
agents: AgentProfileOption[];
loadErrors: AgentProfileLoadError[];
} {
const workspaceRoot = config.workspaceRoot?.trim() || config.cwd;
const { configs, errors } = loadConfiguredAgentConfigs({ workspaceRoot });
return {
agents: configs.map((profile) => ({
name: profile.name,
description: profile.description,
systemPrompt: profile.systemPrompt,
source:
workspaceRoot && profile.path?.startsWith(`${workspaceRoot}${sep}`)
? ("workspace" as const)
: ("global" as const),
})),
loadErrors: errors.map((error) => ({
path: error.path,
message: error.error.message,
})),
};
}
export function useAgentSelector(opts: {
dialog: DialogActions;
config: Config;
termHeight: number;
onAgentProfileChange: TuiProps["onAgentProfileChange"];
refocusTextarea: () => void;
}): () => Promise<void> {
const { dialog, config, termHeight, onAgentProfileChange, refocusTextarea } =
opts;
const session = useSession();
const openAgentSelector = useCallback(async () => {
// Applying a profile restarts the session in place, so refuse while a
// turn is running instead of yanking the live stream out from under it.
if (session.isRunning) {
session.appendEntry({
kind: "status",
text: "Finish or abort the current task before switching agents.",
});
refocusTextarea();
return;
}
const { agents, loadErrors } = loadAgentProfileEntries(config);
const currentAgentName = config.agentProfile?.name ?? null;
const selectedKey = await dialog.choice<string>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<string>) => (
<AgentSelectorContent
{...ctx}
currentAgentName={currentAgentName}
agents={agents}
loadErrors={loadErrors}
/>
),
});
if (!selectedKey) {
refocusTextarea();
return;
}
if (selectedKey === DEFAULT_AGENT_ACTION) {
if (config.agentProfile) {
await withLoadingDialog(dialog, "Applying agent...", async () => {
await onAgentProfileChange(null);
});
session.setActiveAgentName(null);
}
refocusTextarea();
return;
}
const profile = agents.find(
(agent) => agent.name.toLowerCase() === selectedKey,
);
if (!profile) {
refocusTextarea();
return;
}
if (profile.name !== currentAgentName) {
await withLoadingDialog(dialog, "Applying agent...", async () => {
await onAgentProfileChange({
name: profile.name,
systemPrompt: profile.systemPrompt,
});
});
session.setActiveAgentName(profile.name);
}
refocusTextarea();
}, [
dialog,
config,
termHeight,
onAgentProfileChange,
refocusTextarea,
session,
]);
return openAgentSelector;
}
@@ -13,6 +13,7 @@ function makeActions(
openConfig: vi.fn(),
openMcpManager: vi.fn(async () => false),
openModelSelector: vi.fn(),
openAgentSelector: vi.fn(),
openSkills: vi.fn(),
runCompact: vi.fn(),
runFork: vi.fn(),
@@ -45,6 +46,21 @@ describe("runLocalSlashCommandAction", () => {
expect(openSkills).toHaveBeenCalledWith(invocation);
});
it("opens the agent selector with agents and the agent alias", () => {
for (const name of ["agents", "agent"]) {
const openAgentSelector = vi.fn();
const actions = makeActions({ openAgentSelector });
const handled = runLocalSlashCommandAction({
name,
...actions,
});
expect(handled).toBe(true);
expect(openAgentSelector).toHaveBeenCalledOnce();
}
});
it("opens settings to the plugins tab with plugins", () => {
const openConfig = vi.fn();
const actions = makeActions({ openConfig });
@@ -23,6 +23,7 @@ export function useLocalCommandActions(input: {
openConfig: (options?: OpenConfigOptions) => void;
openMcpManager: () => Promise<boolean>;
openModelSelector: () => void;
openAgentSelector: () => void;
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
refocusTextarea: () => void;
setAppView: (view: AppView) => void;
@@ -43,6 +44,7 @@ export function useLocalCommandActions(input: {
openConfig,
openMcpManager,
openModelSelector,
openAgentSelector,
openSkills,
refocusTextarea,
setAppView,
@@ -185,6 +187,7 @@ export function useLocalCommandActions(input: {
openConfig,
openMcpManager,
openModelSelector,
openAgentSelector,
openSkills,
runCompact,
runFork,
@@ -205,6 +208,7 @@ export function useLocalCommandActions(input: {
openHelp,
openHistory,
openModelSelector,
openAgentSelector,
openSkills,
runCompact,
runFork,
+25 -53
View File
@@ -1,4 +1,4 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import {
basename,
dirname,
@@ -14,11 +14,11 @@ import {
hasMcpSettingsFile,
listHookConfigFiles,
listPluginToolsWithDiagnostics,
loadConfiguredAgentConfigs,
type McpServerRegistration,
type PluginInitializationFailure,
type RuleConfig,
readGlobalSettings,
resolveAgentConfigSearchPaths,
resolveDefaultMcpSettingsPath,
resolveMcpServerRegistrations,
resolvePluginConfigSearchPaths,
@@ -175,58 +175,30 @@ function getMcpDescription(registration: McpServerRegistration): string {
}
function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
const agentsById = new Map<string, InteractiveConfigItem>();
const directories = resolveAgentConfigSearchPaths(workspaceRoot).filter(
(directory) => existsSync(directory),
);
for (const directory of directories) {
try {
const entries = readdirSync(directory, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const extension = extname(entry.name).toLowerCase();
if (extension !== ".yml" && extension !== ".yaml") {
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
const descriptionMatch = frontmatter.match(
/^\s*description:\s*(.+?)\s*$/m,
);
const parsedName = nameMatch?.[1]?.replace(/^["']|["']$/g, "").trim();
const parsedDescription = descriptionMatch?.[1]
?.replace(/^["']|["']$/g, "")
.trim();
const name =
parsedName && parsedName.length > 0
? parsedName
: basename(entry.name, extension);
const id = name.toLowerCase();
if (agentsById.has(id)) {
continue;
}
agentsById.set(id, {
id,
name,
path: filePath,
enabled: true,
kind: "agent",
source: detectSource(filePath, workspaceRoot),
description: parsedDescription,
});
}
} catch {
// Best effort: keep listing other agent config roots.
}
const { configs, errors } = loadConfiguredAgentConfigs({ workspaceRoot });
const items: InteractiveConfigItem[] = configs.map((config) => ({
id: config.name.toLowerCase(),
name: config.name,
path: config.path ?? "",
enabled: true,
kind: "agent" as const,
source: detectSource(config.path ?? "", workspaceRoot),
description: config.description,
}));
// Keep broken profile files visible so users can spot and fix them.
for (const error of errors) {
items.push({
id: error.path,
name: basename(error.path, extname(error.path)),
path: error.path,
enabled: false,
kind: "agent" as const,
source: detectSource(error.path, workspaceRoot),
description: error.error.message,
loadError: error.error.message,
});
}
return [...agentsById.values()];
return items;
}
function readPackageName(packageJsonPath: string): string | undefined {
+13
View File
@@ -41,6 +41,7 @@ import { EventBridgeProvider } from "./contexts/event-bridge-context";
import { SessionProvider, useSession } from "./contexts/session-context";
import { useAccountDialog } from "./hooks/use-account-dialog";
import { useAgentEventHandlers } from "./hooks/use-agent-events";
import { useAgentSelector } from "./hooks/use-agent-selector";
import { useAutocomplete } from "./hooks/use-autocomplete";
import { useConfigPanel } from "./hooks/use-config-panel";
import { useLocalCommandActions } from "./hooks/use-local-command-actions";
@@ -187,6 +188,14 @@ function App(props: TuiProps) {
refocusTextarea: () => refocusTextareaRef.current(),
});
const openAgentSelector = useAgentSelector({
dialog,
config: props.config,
termHeight,
onAgentProfileChange: props.onAgentProfileChange,
refocusTextarea: () => refocusTextareaRef.current(),
});
const openMcpManager = useMcpManager({
dialog,
termHeight,
@@ -639,6 +648,7 @@ function App(props: TuiProps) {
openConfig,
openMcpManager,
openModelSelector,
openAgentSelector,
openSkills,
refocusTextarea: () => refocusTextareaRef.current(),
setAppView,
@@ -886,6 +896,9 @@ function App(props: TuiProps) {
void saveQueuedPromptEdit(id, prompt);
},
onToggleMode: toggleMode,
onOpenAgentSelector: () => {
void openAgentSelector();
},
runtimeInteraction,
onResolveToolApproval: runtimeBridge.resolveToolApproval,
onResolveAskQuestion: runtimeBridge.resolveAskQuestion,
+6 -1
View File
@@ -15,7 +15,11 @@ import type {
PendingPromptSubmittedEvent,
} from "../runtime/session-events";
import type { RepoStatus } from "../utils/repo-status";
import type { CliCompactionMode, Config } from "../utils/types";
import type {
ActiveAgentProfile,
CliCompactionMode,
Config,
} from "../utils/types";
import type { ClineAccountSnapshot } from "./cline-account";
import type {
InteractiveConfigData,
@@ -166,6 +170,7 @@ export interface TuiProps {
onCompactionModeChange: (mode: CliCompactionMode) => Promise<void>;
onModelChange: () => Promise<void>;
onModeChange: (mode: AgentMode) => Promise<void>;
onAgentProfileChange: (profile: ActiveAgentProfile | null) => Promise<void>;
onNewSession: () => Promise<void>;
onSessionRestart: () => Promise<void>;
onAccountChange: () => Promise<void>;
+3 -1
View File
@@ -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");
});
+2
View File
@@ -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;
+2
View File
@@ -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,
+3
View File
@@ -56,6 +56,7 @@ export function ChatView(props: {
editingQueuedPrompt?: QueuedPromptItem;
onQueuedPromptEditConfirm: (id: string, prompt: string) => void;
onToggleMode: () => void;
onOpenAgentSelector: () => void;
runtimeInteraction?: RuntimeToolInteraction | null;
onResolveToolApproval: (id: number, approved: boolean) => void;
onResolveAskQuestion: (id: number, answer: string | null) => void;
@@ -157,6 +158,8 @@ export function ChatView(props: {
gitBranch={repoStatus.branch}
gitDiffStats={repoStatus.diffStats}
onToggleMode={props.onToggleMode}
agentName={session.activeAgentName}
onOpenAgent={props.onOpenAgentSelector}
variant="chat"
/>
</box>
+3
View File
@@ -46,6 +46,7 @@ export function HomeView(props: {
textareaRef?: React.MutableRefObject<TextareaHandle | null>;
autocomplete?: AutocompleteDropdownProps;
onToggleMode: () => void;
onOpenAgentSelector: () => void;
}) {
const {
config,
@@ -156,6 +157,8 @@ export function HomeView(props: {
gitBranch={repoStatus.branch}
gitDiffStats={repoStatus.diffStats}
onToggleMode={props.onToggleMode}
agentName={session.activeAgentName}
onOpenAgent={props.onOpenAgentSelector}
variant="home"
/>
</box>
+3 -6
View File
@@ -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: {
@@ -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...",
+13 -36
View File
@@ -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;
@@ -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",
+2
View File
@@ -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);
}
+3
View File
@@ -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,
}),
]);
+13
View File
@@ -17,6 +17,16 @@ export type CliReasoningEffort = NonNullable<
>;
export type CliCompactionMode = "agentic" | "basic" | "off";
/**
* An agent profile from .cline/agents applied to the main Cline agent for
* the current session. Session-only: never persisted to settings.
*/
export interface ActiveAgentProfile {
name: string;
/** Profile body, captured at selection time (survives file deletion mid-session) */
systemPrompt: string;
}
export interface Config extends Omit<CoreSessionConfig, "apiKey" | "mode"> {
apiKey: string;
knownModels?: Record<string, Llms.ModelInfo>;
@@ -30,6 +40,7 @@ export interface Config extends Omit<CoreSessionConfig, "apiKey" | "mode"> {
mode: CliAgentMode;
defaultToolAutoApprove: boolean;
toolPolicies: Record<string, ToolPolicy>;
agentProfile?: ActiveAgentProfile;
}
export interface ActiveCliSession {
@@ -96,4 +107,6 @@ export interface ParsedArgs {
teamName?: string;
defaultToolAutoApprove: boolean;
autoApproveOverride?: boolean;
/** Agent profile name from .cline/agents to apply to the main agent */
agent?: string;
}
+2
View File
@@ -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 = "";
+3 -11
View File
@@ -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,
+3 -11
View File
@@ -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",
+7 -1
View File
@@ -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();
}
+3 -11
View File
@@ -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 ?? "",
+6 -2
View File
@@ -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;
}
+6 -2
View File
@@ -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),
+2 -2
View File
@@ -50,8 +50,8 @@
"useEnumInitializers": "off",
"useSelfClosingElements": "info",
"useSingleVarDeclarator": "off",
"useNumberNamespace": "info",
"noInferrableTypes": "info",
"useNumberNamespace": "off",
"noInferrableTypes": "off",
"useTemplate": "info",
"noUselessElse": "info"
},
+10 -9
View File
@@ -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)
}
+105 -1
View File
@@ -15,5 +15,109 @@
},
"linter": {
"enabled": true
}
},
"overrides": [
{
"includes": [
"apps/vscode/**"
],
"assist": {
"enabled": true,
"actions": {
"source": {
"organizeImports": "on",
"useSortedAttributes": "on"
}
}
},
"linter": {
"rules": {
"recommended": true,
"correctness": {
"useExhaustiveDependencies": "info",
"noUndeclaredVariables": "off",
"noEmptyPattern": "info",
"useJsxKeyInIterable": "off",
"noInnerDeclarations": "off",
"useHookAtTopLevel": "info",
"useYield": "info",
"noConstructorReturn": "off",
"noInvalidPositionAtImportRule": "off",
"noSwitchDeclarations": "off",
"noUnusedImports": "error"
},
"a11y": "info",
"style": {
"useNodejsImportProtocol": "off",
"useImportType": "off",
"useBlockStatements": "off",
"useNamingConvention": "off",
"useThrowOnlyError": "info",
"useConsistentArrayType": "off",
"noParameterAssign": "off",
"useAsConstAssertion": "off",
"useDefaultParameterLast": "off",
"noNonNullAssertion": "info",
"useEnumInitializers": "off",
"useSelfClosingElements": "info",
"useSingleVarDeclarator": "off",
"useNumberNamespace": "off",
"noInferrableTypes": "off",
"useTemplate": "info",
"noUselessElse": "info"
},
"suspicious": {
"noDoubleEquals": "warn",
"noImplicitAnyLet": "info",
"noThenProperty": "off",
"noAsyncPromiseExecutor": "info",
"noImportAssign": "off",
"noExplicitAny": "info",
"noControlCharactersInRegex": "warn",
"noShadowRestrictedNames": "off",
"noArrayIndexKey": "info",
"noAssignInExpressions": "info",
"useIterableCallbackReturn": "info"
},
"complexity": {
"noUselessConstructor": "info",
"useOptionalChain": "info",
"noBannedTypes": "warn",
"useLiteralKeys": "info",
"noUselessCatch": "info",
"noUselessSwitchCase": "info",
"noStaticOnlyClass": "info"
},
"security": {
"noDangerouslySetInnerHtml": "info"
}
}
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"indentWidth": 4,
"lineWidth": 130,
"lineEnding": "lf",
"formatWithErrors": true
},
"javascript": {
"formatter": {
"semicolons": "asNeeded",
"arrowParentheses": "always",
"bracketSameLine": true,
"bracketSpacing": true,
"jsxQuoteStyle": "double",
"quoteProperties": "asNeeded",
"trailingCommas": "all"
}
},
"json": {
"formatter": {
"trailingCommas": "none",
"expand": "always"
}
}
}
]
}
+27 -27
View File
@@ -19,7 +19,7 @@
},
"apps/cli": {
"name": "@cline/cli",
"version": "3.0.21",
"version": "3.0.22",
"bin": {
"cline": "src/index.ts",
},
@@ -371,7 +371,7 @@
},
"sdk/packages/agents": {
"name": "@cline/agents",
"version": "0.0.45",
"version": "0.0.46",
"dependencies": {
"@cline/llms": "workspace:*",
"@cline/shared": "workspace:*",
@@ -380,7 +380,7 @@
},
"sdk/packages/core": {
"name": "@cline/core",
"version": "0.0.45",
"version": "0.0.46",
"dependencies": {
"@cline/agents": "workspace:*",
"@cline/llms": "workspace:*",
@@ -411,7 +411,7 @@
},
"sdk/packages/llms": {
"name": "@cline/llms",
"version": "0.0.45",
"version": "0.0.46",
"dependencies": {
"@ai-sdk/amazon-bedrock": "^4.0.89",
"@ai-sdk/anthropic": "^3.0.68",
@@ -445,14 +445,14 @@
},
"sdk/packages/sdk": {
"name": "@cline/sdk",
"version": "0.0.45",
"version": "0.0.46",
"dependencies": {
"@cline/core": "workspace:*",
},
},
"sdk/packages/shared": {
"name": "@cline/shared",
"version": "0.0.45",
"version": "0.0.46",
"dependencies": {
"aws4fetch": "^1.0.20",
"jsonrepair": "^3.13.2",
@@ -468,11 +468,11 @@
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.81", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-B1JDd9Ugq9R5AgIaW3674lhGCMMYJcPUxnrZh8fzbGojgg4QvHFRv6eZahGQAUsmGHbcf74G9bdSBDLWQGY2GA=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.122", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-U1k2fk7cSH/tS5CZ3ujROiUCOLFwkzb792OqR/Org8Mfm27dKSIdRZG4ZuJUifT8alUWa61IoaRu4foXKlP5TQ=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.123", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rL+3Sp9crOlfE7MwguFPS30qVp6HFcr9na0KYMb4CcQdxAIjBJec3EEdCjc94UyRVZWLmgQ6Yr605FmPlFIi0w=="],
"@ai-sdk/google": ["@ai-sdk/google@3.0.80", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5ORbm/yFUPO0MEvZsxBMN0cdKw2+lwU/wVn5KN3KF8Dmk1LughuDuUohMh/7iU/XFTiyB0OvmTW/tdV/J7O9zg=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.140", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/google": "3.0.80", "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Fz3STER9hcrY0uJ6wMg8tSasS5+FbLnBU9N89cw9KBkSUtq+Hefeert7y/UA7RUgPS+d6Z+kzmkqbdAU5dn+oA=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.141", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.81", "@ai-sdk/google": "3.0.80", "@ai-sdk/openai-compatible": "2.0.48", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-UGXQeV+z30Gk1/mhKZoXKbYO7Q0U7pg0Nlz44hb2B1FpYRFBu8+ziaI60BdetncaoxPocdNKhP41AD15IA6nJg=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.37", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-KkdaMjs4C2y+vrZWJE990E3ZxBFiOTHQ94ZlquuIttpphcqJTMxNoIpnKT/4UzMVWXL0BUEE2vs+1UEVXkN8Kg=="],
@@ -484,7 +484,7 @@
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
"@ai-sdk/react": ["@ai-sdk/react@3.0.196", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.27", "ai": "6.0.194", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-e/XI6e5cY/FYAvd7ThQzA/zadmVLukwUPvLS9+i1ZoSjwcgTB5LyUnvljMY/+8w++aCp0EKEgrJ+jiL81UHpXQ=="],
"@ai-sdk/react": ["@ai-sdk/react@3.0.197", "", { "dependencies": { "@ai-sdk/provider-utils": "4.0.27", "ai": "6.0.195", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" } }, "sha512-sx5K0pvIWbgduMYGz++s28ldj+hu+GfDpXw9T2kL4n+bhRQpQvrH+jU0z3OqvjMrhD5oz9cGJ7bv/0JQEKXIbw=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -520,41 +520,41 @@
"@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
"@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1058.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-node": "^3.972.48", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-5D3cnn3h72xc7oXLfqMIP81Z7H2Y+FXUq+YGSHQRfGTEvbH+m+5IuA1SwmEKIktobXwjpSuQW34eUKSnTgK1ng=="],
"@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1059.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.16", "@aws-sdk/credential-provider-node": "^3.972.49", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-HW3Oq2rL65tbuqkQzoRrjfF3jauRfra056Xv0K2YtBWt+LaeLrr95D8SlRmgGzXZHwy38FO/w0N1EKjsTYmDKw=="],
"@aws-sdk/core": ["@aws-sdk/core@3.974.15", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@aws-sdk/xml-builder": "^3.972.26", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.5", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-UpA0rTGW/tHGITcCqHisbuuEPraYg9GG+mWmXjY5+RxZBMLGe6aL9oe0ix50LztwAcPIkGZLH0yWdMIkCM10hw=="],
"@aws-sdk/core": ["@aws-sdk/core@3.974.16", "", { "dependencies": { "@aws-sdk/types": "^3.973.10", "@aws-sdk/xml-builder": "^3.972.27", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/core": "^3.24.6", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-WXPvTfG7J2H4Ae6ewhd0285UC+8+9p/pKoibXXQlbXSqHexFLGM0oXHTwDfQEPmrNnvuWPpVjgoAUfW+cUFbXw=="],
"@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.38", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-OHkK6xOx/IHkSbQdDWxnVCLU+j28EFl8wyWgBILQDFAPY8n240C/O4gjmFx+zFU12lL8njgJQ5GWAIWq88CnSQ=="],
"@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.39", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-fp7hiew245BbBiaDGjLDaMXqxbOWnqzuhczWrJq/6/3gDNHtZvkorxHQXYpHYiddetR8sBWe3S49HfZ2BQbYSg=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-n1EbJ98yvPWWdHZZv8bRBMqqDQJrtgtxyJ4xLy2Uqrh25BCOZQ7nnS1CsFXvuH8r0b0KVHDZEGEH5FxmEMP8jg=="],
"@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-0+MCOYqeHyADFdeVr5/e1G8JJoWm7/szZAiKssqJS84E9+tO07509YNyQRJ5a7x5wO9YFAV324syrwm6yIcs5w=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.43", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-TT76RN1NkI9WoyZqCNxOw6/WBMF7pYOTJcXbMokNFU+euSG40Kaf/t/FhDACVZWP+43wEM6ZynIPIkzS1wR1iA=="],
"@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.44", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-auuhqlnv4PUdfqcdHLKGTdoCceXOuby6WNeCMoxtZmQGWNCibbz95/lSYzNWq9cExN17UlRFqo1nvTcC+zHfEg=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-hvcgcwOiS0nb2XFb5Op1Pz/vYaWz5K8kKullziGpdNRuG0NwzRXseuPt2CoBqknHGaSPVesu1aOn2OcctEYdCA=="],
"@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.47", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/credential-provider-env": "^3.972.42", "@aws-sdk/credential-provider-http": "^3.972.44", "@aws-sdk/credential-provider-login": "^3.972.46", "@aws-sdk/credential-provider-process": "^3.972.42", "@aws-sdk/credential-provider-sso": "^3.972.46", "@aws-sdk/credential-provider-web-identity": "^3.972.46", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-G+8JuG0CfLcC2IQXFVqMR1+KDF1rksebr+YL6+HHYbNjs/hiwX53ye45sU3pJKljpS2uIXqOrOsicHIv02vWrw=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-MZQv4SNjByk1iOKmrqmzcUF/uCB05wjvEHyXKxmGQTUANTIVayX6HPUF0bzkWLvtnkH7sAn9kUCfkXbSpj9sDA=="],
"@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-+H6h2/1Q+iIJ4w+FPCKM//xwy4C9yADknDTR68+K3PbOtB/uLup3zIH8PUKn6QwnttME4VM4ftSTnbvoDf5wXg=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.48", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-QIbtJP0olSLZ2ImEu636pP+7JJbPfaL3xSJIFXhu472CWuondCc4bGOa8OeyhOFet8z4H1D/ZFKXc39FboWwYA=="],
"@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.49", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.42", "@aws-sdk/credential-provider-http": "^3.972.44", "@aws-sdk/credential-provider-ini": "^3.972.47", "@aws-sdk/credential-provider-process": "^3.972.42", "@aws-sdk/credential-provider-sso": "^3.972.46", "@aws-sdk/credential-provider-web-identity": "^3.972.46", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-hlyoc+2352BhC+HF91t9avIDJu1+EvQGGltxFB8ADCpAHGYNNLEQAZJIPzw3O/bRiiDSE2B8pdj/fFCXlDTEDg=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.41", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-7I/n1zkysouLOWvkEhjNEP4vMnD2v4kzzr3/3QBdrripEpn7ap1/I5DF3Hou1SUqkKWo1f3oPGMyFAA1FAMvsQ=="],
"@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-r996IYVtQ7rWa5UfSs3fZLT/Dq/SSgH9wv9zahx9lcg6vvaPPQHFpTy45nZajZu/+OUdEQxEjTn9rOMons59mA=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/token-providers": "3.1056.0", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-oHgbz/eFD8IKiksqDsz9ZMU4A59BpQq4QwJedBnGD80ZqYcHPPHZBwjBnxLVkB7iRVVHWpDclR8yWdD2PkQIUA=="],
"@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/token-providers": "3.1059.0", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-vd+UqRbiYLvXXH3kTAuTxq+Vdb3rOgg29/FeB35ETsgdJTTB7x3YuqtpRckATsL5bDRXFVQo0uBj0/vxCJ8hHg=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.45", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-CDhzKdb2onv5bpnjn/acgdNmJOQthPDLsPizU7rZflsEcgMMp8Mlri+U5hdxf8ldvZJpvM3vLU6D56vfJm5AMQ=="],
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.46", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xuxBbMorygYsbDV6E/tUGQUgIDfhnzxz8uJYX8rByzehI6gLUu8RPsgOpLmh9YWerqeHZxJbqzgheBSB7tpooQ=="],
"@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1058.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1058.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/credential-provider-cognito-identity": "^3.972.38", "@aws-sdk/credential-provider-env": "^3.972.41", "@aws-sdk/credential-provider-http": "^3.972.43", "@aws-sdk/credential-provider-ini": "^3.972.46", "@aws-sdk/credential-provider-login": "^3.972.45", "@aws-sdk/credential-provider-node": "^3.972.48", "@aws-sdk/credential-provider-process": "^3.972.41", "@aws-sdk/credential-provider-sso": "^3.972.45", "@aws-sdk/credential-provider-web-identity": "^3.972.45", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/credential-provider-imds": "^4.3.6", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/5Yuf7w8GRz2lic2cg0gRvZzJA2OgiX8HYoMNWLbs3b1vJelz222w5VlKsxPP9D376Zv38E+VHJenvwFgKjK+Q=="],
"@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1059.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1059.0", "@aws-sdk/core": "^3.974.16", "@aws-sdk/credential-provider-cognito-identity": "^3.972.39", "@aws-sdk/credential-provider-env": "^3.972.42", "@aws-sdk/credential-provider-http": "^3.972.44", "@aws-sdk/credential-provider-ini": "^3.972.47", "@aws-sdk/credential-provider-login": "^3.972.46", "@aws-sdk/credential-provider-node": "^3.972.49", "@aws-sdk/credential-provider-process": "^3.972.42", "@aws-sdk/credential-provider-sso": "^3.972.46", "@aws-sdk/credential-provider-web-identity": "^3.972.46", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/credential-provider-imds": "^4.3.7", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-cSdDFb/O3cQHGg78VxPaNruyy9zGxcoeS7DlwYTdwxmYkE0GXmBRoej5N+q+Av9YWBayZ2n3QsSYhtnUXa/GXw=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.13", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.15", "@aws-sdk/signature-v4-multi-region": "^3.996.30", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/fetch-http-handler": "^5.4.5", "@smithy/node-http-handler": "^4.7.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-2pA6eyb5nSo/ZD2cayhOTEMoGQYgspq0RI05GDLkzQ3ajZ6isS6waV6E92Am/hz4LIlLUTrbwPLurJ/fuiHvkg=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.14", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.16", "@aws-sdk/signature-v4-multi-region": "^3.996.31", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/fetch-http-handler": "^5.4.6", "@smithy/node-http-handler": "^4.7.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-T5CS1r4P27FjkBYIWwVibWqEuq32BbCga2Z5m5OBSdSdi2wPfW2vl6zLWAB/5MeeyC4s2pY/MY3cj2Gd3rgSkg=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.30", "", { "dependencies": { "@aws-sdk/types": "^3.973.9", "@smithy/signature-v4": "^5.4.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-HULDLMVzkmTSEv6//7kx2kRevp/VYUpm8hJNNFbmhxDn0fUiGTxVcM9yg31TukvTq8nyOBDUN2gH0o5IRbKjdw=="],
"@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.31", "", { "dependencies": { "@aws-sdk/types": "^3.973.10", "@smithy/signature-v4": "^5.4.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-Kn2up9SlG1KC6wRtwf0d7waTGF6rvp9DxYqB54x6UCKdQ6kyaXCqHL4WGb5vUJga5kS8FxnjhY0LqM28aMvnNQ=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1056.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.15", "@aws-sdk/nested-clients": "^3.997.13", "@aws-sdk/types": "^3.973.9", "@smithy/core": "^3.24.5", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-81duvlltQlsfn5K+o8zILcystBRdbT1G2JJYVCML5NZHBz4CL/zf+sAemCtBh/uh6RQUMyInGeZLQ7/8igZhbA=="],
"@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1059.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.16", "@aws-sdk/nested-clients": "^3.997.14", "@aws-sdk/types": "^3.973.10", "@smithy/core": "^3.24.6", "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-xql+7YBAE7WYb9xJfY0vcAXM8rJXfClmB2wkt+g/EoLUMog0pOb7o741fE96wFqha0uQTEgTo/5lGGguzavxmw=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.9", "", { "dependencies": { "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg=="],
"@aws-sdk/types": ["@aws-sdk/types@3.973.10", "", { "dependencies": { "@smithy/types": "^4.14.3", "tslib": "^2.6.2" } }, "sha512-992QrTO7G9qCvKD0fx1rMlqcL14plUcRAbwmqqYVsuF3GrqcvlAL9qxR+baMafarEZ+l7DUQ5lCMmt5mbMhF7g=="],
"@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.26", "", { "dependencies": { "@smithy/types": "^4.14.2", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g=="],
"@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.27", "", { "dependencies": { "@smithy/types": "^4.14.3", "fast-xml-parser": "5.7.3", "tslib": "^2.6.2" } }, "sha512-hpsCXCOI436kxWpjtRuIHVvuPP81MOw8f18jzfZeg+UOiiOvlqWcmWChzEhJEu16cOC6+ku4ncBN+7rdt+DZ9g=="],
"@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.4", "", {}, "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ=="],
@@ -1662,7 +1662,7 @@
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ai": ["ai@6.0.194", "", { "dependencies": { "@ai-sdk/gateway": "3.0.122", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0MkYqrSZZuC1zTECppcaUT0i54aocXpYaUMVue3V8z/weBHCytfO5/CcwZCU80msZpfkbBUKYSSrkZFotEO5wQ=="],
"ai": ["ai@6.0.195", "", { "dependencies": { "@ai-sdk/gateway": "3.0.123", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@opentelemetry/api": "^1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-IYZpuVz0boWbpIQYyinfWFrvQ1N0dG+EVB63it45B2YAU/MxxCnwz4zBswjYnPtHnJBedpMPNrwVbeczbl2GKg=="],
"ai-sdk-provider-claude-code": ["ai-sdk-provider-claude-code@3.4.4", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.1", "@anthropic-ai/claude-agent-sdk": "^0.2.63" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-iHcup5SHh4Tul1RIi9J+bnpngen8WX66yC3lsz1YlbtwAmRhUEzZUuGKzmFGIN8Pmx9uQrerGfLJdbFxIxKkyw=="],
+8
View File
@@ -1,5 +1,13 @@
# Cline SDK Changelog
## 0.0.46
- Added support for configured agents as subagent tools
- Centralized OAuth management into the SDK
- Added Vertex GCP settings configuration
- Fixed the Azure Foundry API version for the CLI
- Fixed an error caused by disabled reasoning on Fable 5
## 0.0.45
- Added support for the Claude Fable 5 model
@@ -193,6 +193,8 @@ function startCommand(
detached: true,
stdio: ["ignore", "pipe", "pipe"],
env: process.env,
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
const record: JobRecord = {
@@ -192,7 +192,11 @@ async function checkIgnoredByWorkspaceGitignore(
const child = spawn(
"git",
["check-ignore", "--stdin", "-z", "-v", "-n", "--no-index"],
{ cwd: workspaceRoot, stdio: ["pipe", "pipe", "pipe"] },
{
cwd: workspaceRoot,
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
},
);
const stdout: Buffer[] = [];
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/agents",
"version": "0.0.45",
"version": "0.0.46",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
@@ -73,6 +73,7 @@ describe("AgentRuntime (provider-form config + Agent alias)", () => {
apiKey: "test-key",
baseUrl: undefined,
headers: undefined,
options: undefined,
},
],
});
@@ -82,6 +83,32 @@ describe("AgentRuntime (provider-form config + Agent alias)", () => {
});
});
it("passes provider options through to the llms gateway", () => {
const model = new ScriptedModel([]);
createAgentModel.mockReturnValue(model);
new Agent({
providerId: "openai-compatible",
modelId: "gpt-4.1",
apiKey: "test-key",
baseUrl: "https://example.openai.azure.com/openai/deployments/gpt-4.1",
options: { apiVersion: "2025-01-01-preview" },
});
expect(createGateway).toHaveBeenCalledWith({
providerConfigs: [
{
providerId: "openai-compatible",
apiKey: "test-key",
baseUrl:
"https://example.openai.azure.com/openai/deployments/gpt-4.1",
headers: undefined,
options: { apiVersion: "2025-01-01-preview" },
},
],
});
});
it("forwards abort() to the active AgentRuntime", async () => {
let abortReason: unknown;
const model = new ScriptedModel([
+6 -3
View File
@@ -1,4 +1,4 @@
import { createGateway } from "@cline/llms";
import { createGateway, type GatewayProviderSettings } from "@cline/llms";
import type {
AgentAfterToolResult,
AgentBeforeModelResult,
@@ -64,6 +64,8 @@ export interface AgentRuntimeConfigWithProvider
baseUrl?: string;
/** Additional headers for API requests */
headers?: Record<string, string>;
/** Provider-specific gateway options */
options?: GatewayProviderSettings["options"];
}
/**
@@ -88,9 +90,10 @@ function resolveRuntimeConfig(
if (hasPrebuiltModel(config)) {
return config;
}
const { providerId, modelId, apiKey, baseUrl, headers, ...rest } = config;
const { providerId, modelId, apiKey, baseUrl, headers, options, ...rest } =
config;
const gateway = createGateway({
providerConfigs: [{ providerId, apiKey, baseUrl, headers }],
providerConfigs: [{ providerId, apiKey, baseUrl, headers, options }],
telemetry: rest.telemetry,
});
const model = gateway.createAgentModel({ providerId, modelId });
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/core",
"description": "Cline Core SDK for Node Runtime",
"version": "0.0.45",
"version": "0.0.46",
"repository": {
"type": "git",
"url": "https://github.com/cline/cline",
-1
View File
@@ -22,7 +22,6 @@ const socketBindingSupported = await (async () => {
return false;
}
})();
const _socketIt = socketBindingSupported ? it : it.skip;
function createCredentials(
overrides: Partial<ClineOAuthCredentials> = {},
+1 -24
View File
@@ -10,11 +10,7 @@ import {
identifyAccount,
} from "../services/telemetry/core-events";
import { startLocalOAuthServer } from "./server";
import type {
OAuthCredentials,
OAuthLoginCallbacks,
OAuthProviderInterface,
} from "./types";
import type { OAuthCredentials, OAuthLoginCallbacks } from "./types";
import {
isCredentialLikelyExpired,
parseAuthorizationInput,
@@ -701,22 +697,3 @@ export async function getValidClineCredentials(
return null;
}
}
export function createClineOAuthProvider(
options: ClineOAuthProviderOptions,
): OAuthProviderInterface {
return {
id: "cline",
name: "Cline Account",
usesCallbackServer: !(options.useWorkOSDeviceAuth ?? true),
async login(callbacks) {
return loginClineOAuth({ ...options, callbacks });
},
async refreshToken(credentials) {
return refreshClineToken(credentials as ClineOAuthCredentials, options);
},
getApiKey(credentials) {
return `workos:${credentials.access}`;
},
};
}
-14
View File
@@ -1,7 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
getValidOpenAICodexCredentials,
normalizeOpenAICodexCredentials,
refreshOpenAICodexToken,
} from "./codex";
import type { OAuthCredentials } from "./types";
@@ -138,19 +137,6 @@ describe("auth/codex token lifecycle", () => {
nowSpy.mockRestore();
});
it("normalizes credentials by deriving accountId from access token", () => {
const accessToken = createJwt({
"https://api.openai.com/auth": { chatgpt_account_id: "acct-derived" },
});
const normalized = normalizeOpenAICodexCredentials({
access: accessToken,
refresh: "refresh",
expires: 1,
});
expect(normalized.accountId).toBe("acct-derived");
expect(normalized.metadata).toMatchObject({ provider: "openai-codex" });
});
it("refreshOpenAICodexToken throws when response is structurally invalid", async () => {
vi.stubGlobal(
"fetch",
+1 -53
View File
@@ -15,12 +15,7 @@ import {
identifyAccount,
} from "../services/telemetry/core-events";
import { startLocalOAuthServer } from "./server";
import type {
OAuthCredentials,
OAuthLoginCallbacks,
OAuthPrompt,
OAuthProviderInterface,
} from "./types";
import type { OAuthCredentials, OAuthPrompt } from "./types";
import {
decodeJwtPayload,
getProofKey,
@@ -442,50 +437,3 @@ export async function getValidOpenAICodexCredentials(
return null;
}
}
export function isOpenAICodexTokenExpired(
credentials: OAuthCredentials,
refreshBufferMs: number = OPENAI_CODEX_OAUTH_CONFIG.refreshBufferMs,
): boolean {
return isCredentialLikelyExpired(credentials, refreshBufferMs);
}
export function normalizeOpenAICodexCredentials(
credentials: OAuthCredentials,
): OAuthCredentials {
const accountId = credentials.accountId ?? getAccountId(credentials.access);
if (!accountId) {
throw new Error("Failed to extract accountId from token");
}
return {
...credentials,
accountId,
metadata: {
...(credentials.metadata ?? {}),
provider: "openai-codex",
},
};
}
export const openaiCodexOAuthProvider: OAuthProviderInterface = {
id: "openai-codex",
name: "ChatGPT Plus/Pro (ChatGPT Subscription)",
usesCallbackServer: true,
async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
return loginOpenAICodex({
onAuth: callbacks.onAuth,
onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress,
onManualCodeInput: callbacks.onManualCodeInput,
});
},
async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
return refreshOpenAICodexToken(credentials.refresh, credentials);
},
getApiKey(credentials: OAuthCredentials): string {
return credentials.access;
},
};
-63
View File
@@ -12,8 +12,6 @@ import { startLocalOAuthServer } from "./server";
import type {
OAuthCredentials,
OAuthLoginCallbacks,
OAuthProviderInterface,
OcaClientMetadata,
OcaMode,
OcaOAuthConfig,
OcaOAuthProviderOptions,
@@ -525,64 +523,3 @@ export async function getValidOcaCredentials(
return null;
}
}
export function createOcaOAuthProvider(
options: OcaOAuthProviderOptions = {},
): OAuthProviderInterface {
return {
id: "oca",
name: "Oracle Code Assist",
usesCallbackServer: true,
async login(callbacks) {
return loginOcaOAuth({ ...options, callbacks });
},
async refreshToken(credentials) {
return refreshOcaToken(credentials, options);
},
getApiKey(credentials) {
return credentials.access;
},
};
}
export async function generateOcaOpcRequestId(
taskId: string,
token: string,
): Promise<string> {
const encoder = new TextEncoder();
const hash8 = async (value: string): Promise<string> => {
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
return Array.from(new Uint8Array(digest).slice(0, 4), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
};
const [tokenHex, taskHex] = await Promise.all([hash8(token), hash8(taskId)]);
const timestampHex = Math.floor(Date.now() / 1000)
.toString(16)
.padStart(8, "0");
const randomPart = new Uint32Array(1);
crypto.getRandomValues(randomPart);
const randomHex = (randomPart[0] ?? 0).toString(16).padStart(8, "0");
return tokenHex + taskHex + timestampHex + randomHex;
}
export async function createOcaRequestHeaders(input: {
accessToken: string;
taskId: string;
metadata?: OcaClientMetadata;
}): Promise<Record<string, string>> {
const opcRequestId = await generateOcaOpcRequestId(
input.taskId,
input.accessToken,
);
return {
Authorization: `Bearer ${input.accessToken}`,
"Content-Type": "application/json",
client: input.metadata?.client ?? "Cline",
"client-version": input.metadata?.clientVersion ?? "unknown",
"client-ide": input.metadata?.clientIde ?? "unknown",
"client-ide-version": input.metadata?.clientIdeVersion ?? "unknown",
[OCI_HEADER_OPC_REQUEST_ID]: opcRequestId,
};
}
@@ -0,0 +1,107 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
formatProviderOAuthApiKey,
getPersistedProviderApiKey,
getProviderAuthHandler,
getProviderAuthStorageId,
isOAuthProvider,
loginAndSaveProviderOAuthCredentials,
} from "./provider-auth-registry";
const { loginClineOAuth } = vi.hoisted(() => ({
loginClineOAuth: vi.fn(),
}));
vi.mock("./cline", () => ({
getValidClineCredentials: vi.fn(),
loginClineOAuth,
}));
vi.mock("./oca", () => ({
getValidOcaCredentials: vi.fn(),
loginOcaOAuth: vi.fn(),
}));
vi.mock("./codex", () => ({
getValidOpenAICodexCredentials: vi.fn(),
loginOpenAICodex: vi.fn(),
}));
describe("provider auth registry", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns handlers for managed OAuth providers only", () => {
expect(getProviderAuthHandler("cline")?.providerId).toBe("cline");
expect(getProviderAuthHandler("oca")?.providerId).toBe("oca");
expect(getProviderAuthHandler("openai-codex")?.providerId).toBe(
"openai-codex",
);
expect(getProviderAuthHandler("openai-codex-cli")).toBeUndefined();
expect(isOAuthProvider("openai-codex-cli")).toBe(false);
});
it("returns storage provider IDs from handlers", () => {
expect(getProviderAuthStorageId("cline")).toBe("cline");
expect(getProviderAuthStorageId("oca")).toBe("oca");
expect(getProviderAuthStorageId("openai-codex")).toBe("openai-codex");
expect(getProviderAuthStorageId("openai-codex-cli")).toBeUndefined();
});
it("formats Cline WorkOS tokens without double-prefixing", () => {
expect(formatProviderOAuthApiKey("cline", { access: "abc" })).toBe(
"workos:abc",
);
expect(formatProviderOAuthApiKey("cline", { access: "workos:abc" })).toBe(
"workos:abc",
);
expect(
getPersistedProviderApiKey("cline", {
provider: "cline",
auth: { accessToken: "abc" },
}),
).toBe("workos:abc");
});
it("login/save stores credentials under handler storageProviderId", async () => {
loginClineOAuth.mockResolvedValueOnce({
access: "new-access",
refresh: "new-refresh",
expires: 4_000_000_000_000,
accountId: "acct-new",
});
const getProviderSettings = vi.fn().mockReturnValue({
provider: "cline",
apiKey: "manual-key",
});
const saveProviderSettings = vi.fn();
const manager = {
getProviderSettings,
saveProviderSettings,
} as never;
const saved = await loginAndSaveProviderOAuthCredentials(manager, "cline", {
callbacks: {
onAuth: vi.fn(),
onPrompt: vi.fn(async () => ""),
},
});
expect(getProviderSettings).toHaveBeenCalledWith("cline");
expect(saved).toMatchObject({
provider: "cline",
apiKey: "manual-key",
auth: {
accessToken: "workos:new-access",
refreshToken: "new-refresh",
accountId: "acct-new",
expiresAt: 4_000_000_000_000,
},
});
expect(saveProviderSettings).toHaveBeenCalledWith(
expect.objectContaining({ provider: "cline" }),
{ tokenSource: "oauth" },
);
});
});
@@ -0,0 +1,368 @@
import {
getClineEnvironmentConfig,
type ITelemetryService,
} from "@cline/shared";
import type { ProviderSettingsManager } from "../services/storage/provider-settings-manager";
import type { ProviderSettings } from "../types/provider-settings";
import {
type ClineOAuthCredentials,
getValidClineCredentials,
loginClineOAuth,
} from "./cline";
import { getValidOpenAICodexCredentials, loginOpenAICodex } from "./codex";
import { getValidOcaCredentials, loginOcaOAuth } from "./oca";
import type { OAuthCredentials, OAuthLoginCallbacks } from "./types";
import { decodeJwtPayload } from "./utils";
const WORKOS_TOKEN_PREFIX = "workos:";
export type ProviderOAuthCredentials = OAuthCredentials;
export interface ProviderAuthLoginInput {
settings?: ProviderSettings;
callbacks: OAuthLoginCallbacks;
telemetry?: ITelemetryService;
}
export interface ProviderAuthRefreshInput {
settings: ProviderSettings;
credentials: ProviderOAuthCredentials;
forceRefresh?: boolean;
telemetry?: ITelemetryService;
}
export interface ProviderAuthSaveCredentialsInput {
manager: ProviderSettingsManager;
settings?: ProviderSettings;
credentials: ProviderOAuthCredentials;
setLastUsed?: boolean;
save?: boolean;
}
export interface ProviderAuthHandler {
providerId: string;
storageProviderId: string;
getApiKey(settings: ProviderSettings | undefined): string | undefined;
login(input: ProviderAuthLoginInput): Promise<ProviderOAuthCredentials>;
refresh(
input: ProviderAuthRefreshInput,
): Promise<ProviderOAuthCredentials | null>;
saveCredentials(input: ProviderAuthSaveCredentialsInput): ProviderSettings;
isConfigured(settings: ProviderSettings | undefined): boolean;
normalizeStoredAccessToken?(accessToken: string): string;
}
function formatClineApiKey(accessToken: string): string {
const token = accessToken.trim();
return token.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
? token
: `${WORKOS_TOKEN_PREFIX}${token}`;
}
function stripClineApiKeyPrefix(accessToken: string): string {
const token = accessToken.trim();
return token.toLowerCase().startsWith(WORKOS_TOKEN_PREFIX)
? token.slice(WORKOS_TOKEN_PREFIX.length)
: token;
}
function readExpiryFromToken(accessToken: string): number | null {
const payload = decodeJwtPayload(accessToken);
const exp = payload?.exp;
if (typeof exp === "number" && exp > 0) {
return exp * 1000;
}
return null;
}
function deriveCredentialExpiry(
settings: ProviderSettings,
normalizedAccessToken: string,
): number {
const explicitExpiry = settings.auth?.expiresAt;
if (
typeof explicitExpiry === "number" &&
Number.isFinite(explicitExpiry) &&
explicitExpiry > 0
) {
return explicitExpiry;
}
const jwtExpiry = readExpiryFromToken(normalizedAccessToken);
if (jwtExpiry) {
return jwtExpiry;
}
// Unknown expiry should trigger refresh on next resolution.
return Date.now() - 1;
}
function createCredentialsFromSettings(
settings: ProviderSettings,
options?: { normalizeAccessToken?: (accessToken: string) => string },
): ProviderOAuthCredentials | null {
const rawAccess = settings.auth?.accessToken?.trim();
const refreshToken = settings.auth?.refreshToken?.trim();
if (!rawAccess || !refreshToken) {
return null;
}
const access = options?.normalizeAccessToken?.(rawAccess) ?? rawAccess;
if (!access) {
return null;
}
return {
access,
refresh: refreshToken,
expires: deriveCredentialExpiry(settings, access),
accountId: settings.auth?.accountId,
};
}
function saveOAuthCredentials(input: {
manager: ProviderSettingsManager;
storageProviderId: string;
settings?: ProviderSettings;
credentials: ProviderOAuthCredentials;
formatAccessToken?: (accessToken: string) => string;
setLastUsed?: boolean;
save?: boolean;
}): ProviderSettings {
const accessToken =
input.formatAccessToken?.(input.credentials.access) ??
input.credentials.access;
const auth = {
...(input.settings?.auth ?? {}),
accessToken,
refreshToken: input.credentials.refresh,
accountId: input.credentials.accountId,
expiresAt: input.credentials.expires,
};
const merged: ProviderSettings = {
...(input.settings ?? {
provider: input.storageProviderId as ProviderSettings["provider"],
}),
provider: input.storageProviderId as ProviderSettings["provider"],
auth,
};
if (input.save !== false) {
input.manager.saveProviderSettings(merged, {
...(input.setLastUsed === undefined
? {}
: { setLastUsed: input.setLastUsed }),
tokenSource: "oauth",
});
}
return merged;
}
function createOAuthHandler(input: {
providerId: string;
storageProviderId?: string;
formatAccessToken?: (accessToken: string) => string;
normalizeStoredAccessToken?: (accessToken: string) => string;
login: (input: ProviderAuthLoginInput) => Promise<ProviderOAuthCredentials>;
refresh: (
input: ProviderAuthRefreshInput,
) => Promise<ProviderOAuthCredentials | null>;
}): ProviderAuthHandler {
const storageProviderId = input.storageProviderId ?? input.providerId;
return {
providerId: input.providerId,
storageProviderId,
getApiKey(settings) {
const accessToken = settings?.auth?.accessToken?.trim();
if (accessToken) {
return input.formatAccessToken?.(accessToken) ?? accessToken;
}
return (
settings?.apiKey?.trim() || settings?.auth?.apiKey?.trim() || undefined
);
},
login: input.login,
refresh: input.refresh,
saveCredentials(saveInput) {
return saveOAuthCredentials({
...saveInput,
storageProviderId,
formatAccessToken: input.formatAccessToken,
});
},
isConfigured(settings) {
return !!settings?.auth?.accessToken;
},
normalizeStoredAccessToken: input.normalizeStoredAccessToken,
};
}
const providerAuthHandlers = [
createOAuthHandler({
providerId: "cline",
formatAccessToken: formatClineApiKey,
normalizeStoredAccessToken: stripClineApiKeyPrefix,
login: ({ settings, callbacks, telemetry }) =>
loginClineOAuth({
apiBaseUrl:
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
useWorkOSDeviceAuth: true,
callbacks,
telemetry,
}),
refresh: ({ settings, credentials, forceRefresh, telemetry }) =>
getValidClineCredentials(
credentials as ClineOAuthCredentials,
{
apiBaseUrl:
settings.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
telemetry,
},
{ forceRefresh },
),
}),
createOAuthHandler({
providerId: "oca",
login: ({ settings, callbacks, telemetry }) =>
loginOcaOAuth({ mode: settings?.oca?.mode, callbacks, telemetry }),
refresh: ({ settings, credentials, forceRefresh, telemetry }) =>
getValidOcaCredentials(
credentials,
{ forceRefresh, telemetry },
{ mode: settings.oca?.mode, telemetry },
),
}),
createOAuthHandler({
providerId: "openai-codex",
login: ({ callbacks, telemetry }) =>
loginOpenAICodex({
onAuth: callbacks.onAuth,
onPrompt: callbacks.onPrompt,
onProgress: callbacks.onProgress,
onManualCodeInput: callbacks.onManualCodeInput,
telemetry,
}),
refresh: ({ credentials, forceRefresh, telemetry }) =>
getValidOpenAICodexCredentials(credentials, { forceRefresh, telemetry }),
}),
] as const satisfies readonly ProviderAuthHandler[];
const providerAuthHandlerById = new Map<string, ProviderAuthHandler>(
providerAuthHandlers.map((handler) => [handler.providerId, handler]),
);
export function getProviderAuthHandler(
providerId: string,
): ProviderAuthHandler | undefined {
return providerAuthHandlerById.get(providerId.trim().toLowerCase());
}
export function isOAuthProvider(providerId: string): boolean {
return getProviderAuthHandler(providerId) !== undefined;
}
export function getProviderAuthStorageId(
providerId: string,
): string | undefined {
return getProviderAuthHandler(providerId)?.storageProviderId;
}
export function resolveProviderApiKeyFromSettings(
manager: ProviderSettingsManager,
providerId: string,
): string | undefined {
const handler = getProviderAuthHandler(providerId);
const storageProviderId = handler?.storageProviderId ?? providerId;
const settings = manager.getProviderSettings(storageProviderId);
return (
handler?.getApiKey(settings) ??
getPersistedProviderApiKey(providerId, settings)
);
}
export async function loginAndSaveProviderOAuthCredentials(
manager: ProviderSettingsManager,
providerId: string,
input: {
callbacks: OAuthLoginCallbacks;
telemetry?: ITelemetryService;
},
): Promise<ProviderSettings> {
const handler = getProviderAuthHandler(providerId);
if (!handler) {
throw new Error(`Provider "${providerId}" does not support OAuth login`);
}
const existing = manager.getProviderSettings(handler.storageProviderId);
const credentials = await handler.login({
settings: existing,
callbacks: input.callbacks,
telemetry: input.telemetry,
});
return handler.saveCredentials({ manager, settings: existing, credentials });
}
export function getProviderOAuthCredentialsFromSettings(
providerId: string,
settings: ProviderSettings,
): ProviderOAuthCredentials | null {
const handler = getProviderAuthHandler(providerId);
if (!handler) return null;
return createCredentialsFromSettings(settings, {
normalizeAccessToken: handler.normalizeStoredAccessToken,
});
}
export function saveProviderOAuthCredentials(input: {
manager: ProviderSettingsManager;
providerId: string;
settings?: ProviderSettings;
credentials: ProviderOAuthCredentials;
setLastUsed?: boolean;
save?: boolean;
}): ProviderSettings {
const handler = getProviderAuthHandler(input.providerId);
if (!handler) {
throw new Error(
`Provider "${input.providerId}" does not support OAuth credentials`,
);
}
return handler.saveCredentials({
manager: input.manager,
settings: input.settings,
credentials: input.credentials,
setLastUsed: input.setLastUsed,
save: input.save,
});
}
export function getPersistedProviderApiKey(
providerId: string,
settings?: ProviderSettings,
): string | undefined {
const handler = getProviderAuthHandler(providerId);
if (handler) {
return handler.getApiKey(settings);
}
return (
settings?.auth?.accessToken?.trim() ||
settings?.apiKey?.trim() ||
settings?.auth?.apiKey?.trim() ||
undefined
);
}
export function formatProviderOAuthApiKey(
providerId: string,
credentials: Pick<ProviderOAuthCredentials, "access">,
): string {
const handler = getProviderAuthHandler(providerId);
if (!handler) return credentials.access;
return (
handler.getApiKey({
provider: handler.storageProviderId,
auth: { accessToken: credentials.access },
}) ?? credentials.access
);
}
@@ -1,4 +1,5 @@
import type { AgentExtension } from "@cline/shared";
import type { SkillsExecutorWithMetadata } from "../tools";
import {
type AvailableRuntimeCommand,
listAvailableRuntimeCommandsFromWatcher,
@@ -14,6 +15,7 @@ import {
import {
type CreateUserInstructionPluginOptions,
createUserInstructionPlugin,
createUserInstructionSkillsExecutor,
getConfiguredSkillsFromWatcher,
} from "./user-instruction-plugin";
@@ -39,6 +41,9 @@ export interface UserInstructionConfigService {
listRuntimeCommands(): AvailableRuntimeCommand[];
resolveRuntimeSlashCommand(input: string): string;
hasConfiguredSkills(allowedSkillNames?: ReadonlyArray<string>): boolean;
createSkillsExecutor?(
allowedSkillNames?: ReadonlyArray<string>,
): SkillsExecutorWithMetadata;
createExtension(
options: Omit<
CreateUserInstructionPluginOptions,
@@ -107,6 +112,16 @@ class DefaultUserInstructionConfigService
);
}
createSkillsExecutor(
allowedSkillNames?: ReadonlyArray<string>,
): SkillsExecutorWithMetadata {
return createUserInstructionSkillsExecutor(
this.watcher,
(this.ready ?? Promise.resolve()).catch(() => {}),
allowedSkillNames,
);
}
createExtension(
options: Omit<
CreateUserInstructionPluginOptions,
@@ -69,6 +69,10 @@ function spawnAndCollect(
env: { ...process.env, ...config.env },
stdio: ["pipe", "pipe", "pipe"],
detached: !isWindows,
// Prevent a console window from flashing on Windows when the
// parent process has no console (or a different console).
// No-op on non-Windows platforms.
windowsHide: true,
});
const childPid = child.pid;
@@ -129,6 +129,8 @@ function checkRipgrepAvailable(): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn("rg", ["--version"], {
stdio: ["ignore", "pipe", "pipe"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
});
child.on("close", (code) => {
@@ -168,6 +170,8 @@ function searchWithRipgrep(
{
cwd,
stdio: ["ignore", "pipe", "pipe"],
// Prevent a console window from flashing on Windows.
windowsHide: true,
},
);
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { parseConfiguredAgentConfig } from "./configured-agent-config";
describe("configured agent config parser", () => {
it("parses YAML frontmatter and system prompt body", () => {
const config = parseConfiguredAgentConfig(`---
name: code-reviewer
description: Reviews code
tools: execute_command, read_file
skills:
- review-pr
modelId: anthropic/claude-sonnet-4.6
---
You are a code reviewer.`);
expect(config).toMatchObject({
name: "code-reviewer",
description: "Reviews code",
tools: ["execute_command", "read_file"],
skills: ["review-pr"],
modelId: "anthropic/claude-sonnet-4.6",
systemPrompt: "You are a code reviewer.",
});
});
it("does not treat delimiter lines in the body as frontmatter delimiters", () => {
const config = parseConfiguredAgentConfig(`---
name: code-reviewer
description: Reviews code
tools: read_file
---
Prompt body
---
More prompt`);
expect(config.systemPrompt).toBe("Prompt body\n---\nMore prompt");
});
it.each([
["empty", ""],
["comment-only", "# comment"],
["scalar", "code-reviewer"],
])("rejects %s frontmatter candidates without hanging", (_name, yaml) => {
expect(() =>
parseConfiguredAgentConfig(`---
${yaml}
---
You are a code reviewer.`),
).toThrow("Missing closing YAML frontmatter delimiter");
});
});
@@ -0,0 +1,198 @@
import { type Dirent, existsSync, readdirSync, readFileSync } from "node:fs";
import { basename, extname, join } from "node:path";
import { resolveAgentConfigSearchPaths } from "@cline/shared/storage";
import YAML from "yaml";
import { z } from "zod";
const ConfiguredAgentFrontmatterSchema = z.object({
name: z.string().trim().min(1),
description: z.string().trim().min(1),
tools: z.union([z.string(), z.array(z.string())]).optional(),
skills: z.union([z.string(), z.array(z.string())]).optional(),
providerId: z.string().trim().min(1).optional(),
modelId: z.string().trim().min(1).optional(),
maxIterations: z.number().int().positive().optional(),
});
export interface ConfiguredAgentConfig {
name: string;
description: string;
tools?: string[];
skills?: string[];
providerId?: string;
modelId?: string;
maxIterations?: number;
systemPrompt: string;
path?: string;
}
export interface ConfiguredAgentReadError {
path: string;
error: Error;
}
export interface ConfiguredAgentLoadResult {
configs: ConfiguredAgentConfig[];
errors: ConfiguredAgentReadError[];
}
function splitFrontmatter(content: string): {
frontmatter: string;
body: string;
} {
const firstLineMatch = content.match(/^(---)[^\S\r\n]*(?:\r?\n|$)/);
if (!firstLineMatch) {
throw new Error("Missing YAML frontmatter block in agent config file.");
}
const frontmatterStart = firstLineMatch[0].length;
const delimiterPattern = /^---[^\S\r\n]*(?:\r?\n|$)/gm;
delimiterPattern.lastIndex = frontmatterStart;
let lastValid:
| {
frontmatter: string;
body: string;
}
| undefined;
const candidates = Array.from(content.matchAll(delimiterPattern)).filter(
(candidate) => candidate.index >= frontmatterStart,
);
for (const candidate of candidates) {
const delimiterStart = candidate.index;
const frontmatter = content.slice(frontmatterStart, delimiterStart);
try {
const parsedYaml = YAML.parse(frontmatter);
if (
!parsedYaml ||
typeof parsedYaml !== "object" ||
Array.isArray(parsedYaml)
) {
continue;
}
ConfiguredAgentFrontmatterSchema.parse(parsedYaml);
const body = content.slice(delimiterStart + candidate[0].length);
lastValid = { frontmatter, body };
} catch {
// Keep scanning: this delimiter may be literal content inside YAML.
}
}
if (lastValid) {
return lastValid;
}
throw new Error(
"Missing closing YAML frontmatter delimiter in agent config file.",
);
}
function parseStringList(
value: string | string[] | undefined,
): string[] | undefined {
if (value === undefined) {
return undefined;
}
const raw = Array.isArray(value) ? value : value.split(",");
return Array.from(
new Set(
raw.map((entry) => entry.trim()).filter((entry) => entry.length > 0),
),
);
}
function normalizeAgentName(name: string): string {
return name.trim().toLowerCase();
}
function isYamlFile(fileName: string): boolean {
const extension = extname(fileName).toLowerCase();
return extension === ".yml" || extension === ".yaml";
}
export function parseConfiguredAgentConfig(
content: string,
options: { path?: string } = {},
): ConfiguredAgentConfig {
const { frontmatter, body } = splitFrontmatter(content);
const parsedYaml = YAML.parse(frontmatter);
if (
!parsedYaml ||
typeof parsedYaml !== "object" ||
Array.isArray(parsedYaml)
) {
throw new Error("Agent config frontmatter must be a YAML mapping.");
}
const parsed = ConfiguredAgentFrontmatterSchema.parse(parsedYaml);
const systemPrompt = body.trim();
if (!systemPrompt) {
throw new Error("Missing system prompt body in agent config file.");
}
return {
name: parsed.name,
description: parsed.description,
tools: parseStringList(parsed.tools),
skills: parseStringList(parsed.skills),
providerId: parsed.providerId,
modelId: parsed.modelId,
maxIterations: parsed.maxIterations,
systemPrompt,
path: options.path,
};
}
export function loadConfiguredAgentConfigs(input: {
workspaceRoot?: string;
searchPaths?: string[];
}): ConfiguredAgentLoadResult {
const searchPaths =
input.searchPaths ?? resolveAgentConfigSearchPaths(input.workspaceRoot);
const configsByName = new Map<string, ConfiguredAgentConfig>();
const errors: ConfiguredAgentReadError[] = [];
for (const directory of searchPaths.filter(Boolean)) {
if (!existsSync(directory)) {
continue;
}
let entries: Dirent[];
try {
entries = readdirSync(directory, { withFileTypes: true });
} catch (error) {
errors.push({
path: directory,
error: error instanceof Error ? error : new Error(String(error)),
});
continue;
}
for (const entry of entries) {
if (!entry.isFile() || !isYamlFile(entry.name)) {
continue;
}
const filePath = join(directory, entry.name);
try {
const raw = readFileSync(filePath, "utf8");
const config = parseConfiguredAgentConfig(raw, { path: filePath });
const normalizedName = normalizeAgentName(config.name);
if (!configsByName.has(normalizedName)) {
configsByName.set(normalizedName, config);
}
} catch (error) {
errors.push({
path: filePath,
error: error instanceof Error ? error : new Error(String(error)),
});
}
}
}
const configs = Array.from(configsByName.values()).sort((a, b) =>
(a.path ? basename(a.path) : a.name).localeCompare(
b.path ? basename(b.path) : b.name,
),
);
return { configs, errors };
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
buildConfiguredAgentToolName,
createConfiguredAgentTools,
} from "./configured-agent-tool";
describe("configured agent tools", () => {
it("builds stable subagent tool names", () => {
expect(buildConfiguredAgentToolName("Code Reviewer")).toBe(
"subagent_code_reviewer",
);
expect(buildConfiguredAgentToolName("___")).toBe("subagent_agent");
});
it("matches spawn_agent timeout and retry policy", () => {
const [tool] = createConfiguredAgentTools({
configProvider: {
getRuntimeConfig: () => ({
providerId: "anthropic",
modelId: "claude-sonnet-4-6",
apiKey: "key",
}),
getConnectionConfig: () => ({
providerId: "anthropic",
modelId: "claude-sonnet-4-6",
apiKey: "key",
}),
updateConnectionDefaults: () => {},
},
agents: [
{
name: "code-reviewer",
description: "Reviews code",
systemPrompt: "You are a code reviewer.",
},
],
});
expect(tool?.name).toBe("subagent_code_reviewer");
expect(tool?.timeoutMs).toBe(300000);
expect(tool?.retryable).toBe(false);
});
});
@@ -0,0 +1,253 @@
import {
type AgentEvent,
type AgentResult,
type AgentTool,
type AgentToolContext,
createTool,
type HookErrorMode,
type ToolApprovalRequest,
type ToolApprovalResult,
type ToolPolicy,
zodToJsonSchema,
} from "@cline/shared";
import { z } from "zod";
import type { ConfiguredAgentConfig } from "./configured-agent-config";
import {
createDelegatedAgent,
createDelegatedAgentConfigProvider,
type DelegatedAgentConfigProvider,
type DelegatedAgentRuntimeConfig,
} from "./delegated-agent";
import type {
SpawnAgentOutput,
SubAgentEndContext,
SubAgentStartContext,
} from "./spawn-agent-tool";
const CONFIGURED_AGENT_TOOL_NAME_PREFIX = "subagent_";
const CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH = 64;
const ConfiguredAgentInputSchema = z.object({
prompt: z.string().trim().min(1).describe("Task for the subagent to perform"),
});
export type ConfiguredAgentInput = z.infer<typeof ConfiguredAgentInputSchema>;
export interface ConfiguredAgentToolDescriptor {
toolName: string;
config: ConfiguredAgentConfig;
}
export interface ConfiguredAgentToolConfig {
configProvider: DelegatedAgentConfigProvider;
agents: ConfiguredAgentConfig[];
createSubAgentTools?: (
agent: ConfiguredAgentConfig,
input: ConfiguredAgentInput,
context: AgentToolContext,
) => AgentTool[] | Promise<AgentTool[]>;
onSubAgentEvent?: (event: AgentEvent) => void;
hookErrorMode?: HookErrorMode;
toolPolicies?: Record<string, ToolPolicy>;
requestToolApproval?: (
request: ToolApprovalRequest,
) => Promise<ToolApprovalResult> | ToolApprovalResult;
onSubAgentStart?: (context: SubAgentStartContext) => void | Promise<void>;
onSubAgentEnd?: (context: SubAgentEndContext) => void | Promise<void>;
}
function sanitizeAgentName(name: string): string {
let result = "";
let lastWasUnderscore = true;
for (const char of name.trim().toLowerCase()) {
const code = char.charCodeAt(0);
const isAllowed =
(code >= 97 && code <= 122) || (code >= 48 && code <= 57) || char === "_";
if (!isAllowed || char === "_") {
if (!lastWasUnderscore) {
result += "_";
lastWasUnderscore = true;
}
continue;
}
result += char;
lastWasUnderscore = false;
}
return lastWasUnderscore ? result.slice(0, -1) : result;
}
function hashString(value: string): string {
let hash = 2166136261;
for (let i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return (hash >>> 0).toString(36);
}
export function buildConfiguredAgentToolName(agentName: string): string {
const sanitized = sanitizeAgentName(agentName) || "agent";
const hashSuffix = hashString(agentName).slice(0, 6);
const base = `${CONFIGURED_AGENT_TOOL_NAME_PREFIX}${sanitized}`;
if (base.length <= CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH) {
return base;
}
const maxBodyLength =
CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH -
CONFIGURED_AGENT_TOOL_NAME_PREFIX.length -
hashSuffix.length -
1;
const body = sanitized.slice(0, Math.max(1, maxBodyLength));
return `${CONFIGURED_AGENT_TOOL_NAME_PREFIX}${body}_${hashSuffix}`.slice(
0,
CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH,
);
}
export function buildConfiguredAgentToolDescriptors(
agents: readonly ConfiguredAgentConfig[],
): ConfiguredAgentToolDescriptor[] {
const usedToolNames = new Set<string>();
const descriptors: ConfiguredAgentToolDescriptor[] = [];
for (const config of [...agents].sort((a, b) =>
a.name.localeCompare(b.name),
)) {
const baseName = buildConfiguredAgentToolName(config.name);
let candidate = baseName;
let suffix = 2;
while (usedToolNames.has(candidate)) {
const suffixText = `_${suffix++}`;
const maxBaseLength = Math.max(
1,
CONFIGURED_AGENT_TOOL_NAME_MAX_LENGTH - suffixText.length,
);
candidate = `${baseName.slice(0, maxBaseLength)}${suffixText}`;
}
usedToolNames.add(candidate);
descriptors.push({ toolName: candidate, config });
}
return descriptors;
}
function buildAgentRuntimeConfig(
base: DelegatedAgentRuntimeConfig,
agent: ConfiguredAgentConfig,
): DelegatedAgentRuntimeConfig {
return {
...base,
providerId: agent.providerId ?? base.providerId,
modelId: agent.modelId ?? base.modelId,
maxIterations: agent.maxIterations ?? base.maxIterations,
};
}
export function createConfiguredAgentTools(
options: ConfiguredAgentToolConfig,
): AgentTool[] {
return buildConfiguredAgentToolDescriptors(options.agents).map(
({ toolName, config }) => {
const tool = createTool<ConfiguredAgentInput, SpawnAgentOutput>({
name: toolName,
description: `Use the "${config.name}" subagent: ${config.description}`,
inputSchema: zodToJsonSchema(ConfiguredAgentInputSchema),
execute: async (input, context) => {
const baseRuntimeConfig = options.configProvider.getRuntimeConfig();
const configProvider = createDelegatedAgentConfigProvider(
buildAgentRuntimeConfig(baseRuntimeConfig, config),
);
const tools = options.createSubAgentTools
? await options.createSubAgentTools(config, input, context)
: [];
const subAgent = createDelegatedAgent({
kind: "subagent",
prompt: config.systemPrompt,
configProvider,
tools,
maxIterations: config.maxIterations,
parentAgentId: context.agentId,
abortSignal: context.signal,
onEvent: options.onSubAgentEvent,
hookErrorMode: options.hookErrorMode,
toolPolicies: options.toolPolicies,
requestToolApproval: options.requestToolApproval,
});
const subAgentId = subAgent.getAgentId();
const conversationId = subAgent.getConversationId();
const parentAgentId = context.agentId;
const spawnInput = {
systemPrompt: config.systemPrompt,
task: input.prompt,
};
if (options.onSubAgentStart) {
try {
await options.onSubAgentStart({
subAgentId,
conversationId,
parentAgentId,
input: spawnInput,
});
} catch {
// Best-effort observer callback.
}
}
try {
const result: AgentResult = await subAgent.run(input.prompt);
const output: SpawnAgentOutput = {
text: result.text,
iterations: result.iterations,
finishReason: result.finishReason,
usage: {
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
},
};
if (options.onSubAgentEnd) {
try {
await options.onSubAgentEnd({
subAgentId,
conversationId,
parentAgentId,
input: spawnInput,
result: output,
agentResult: result,
});
} catch {
// Best-effort observer callback.
}
}
return output;
} catch (error) {
if (options.onSubAgentEnd) {
try {
await options.onSubAgentEnd({
subAgentId,
conversationId,
parentAgentId,
input: spawnInput,
error:
error instanceof Error ? error : new Error(String(error)),
});
} catch {
// Best-effort observer callback.
}
}
throw error;
}
},
timeoutMs: 300000,
retryable: false,
});
return tool as unknown as AgentTool;
},
);
}
@@ -1,5 +1,24 @@
export {
type ConfiguredAgentConfig,
type ConfiguredAgentLoadResult,
type ConfiguredAgentReadError,
loadConfiguredAgentConfigs,
parseConfiguredAgentConfig,
} from "./configured-agent-config";
export {
buildConfiguredAgentToolDescriptors,
buildConfiguredAgentToolName,
type ConfiguredAgentInput,
type ConfiguredAgentToolConfig,
type ConfiguredAgentToolDescriptor,
createConfiguredAgentTools,
} from "./configured-agent-tool";
export {
buildTeamProgressSummary,
toTeamProgressLifecycleEvent,
} from "./projections";
export * from "./runtime";
export type {
SubAgentEndContext,
SubAgentStartContext,
} from "./spawn-agent-tool";
@@ -320,11 +320,17 @@ describe("createSpawnAgentTool", () => {
},
);
expect(agentConstructorSpy).toHaveBeenCalledWith(
expect.objectContaining({
systemPrompt: inputSystemPrompt,
}),
const constructedConfig = agentConstructorSpy.mock.calls[0]?.[0] as {
systemPrompt: string;
};
expect(constructedConfig.systemPrompt.startsWith(inputSystemPrompt)).toBe(
true,
);
// The embedded workspace configuration is not injected a second time.
const markerCount = constructedConfig.systemPrompt.split(
"# Workspace Configuration",
).length;
expect(markerCount - 1).toBe(1);
});
it("resolves connection settings lazily at execution time", async () => {
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import type { DelegatedAgentRuntimeConfig } from "./delegated-agent";
import {
buildSubAgentSystemPrompt,
buildTeammateSystemPrompt,
} from "./subagent-prompts";
const PROFILE_BODY = "You are a reviewer. Focus on correctness.";
function makeConfig(
overrides: Partial<DelegatedAgentRuntimeConfig> = {},
): DelegatedAgentRuntimeConfig {
return {
providerId: "cline",
modelId: "model",
cwd: "/repo",
apiKey: "key",
clineIdeName: "Terminal",
clinePlatform: "linux",
workspaceMetadata: '{"workspaces":{}}',
...overrides,
};
}
describe("buildSubAgentSystemPrompt", () => {
it("fills the persona slot and keeps the agent harness for cline", () => {
const prompt = buildSubAgentSystemPrompt(PROFILE_BODY, makeConfig());
expect(prompt.startsWith(PROFILE_BODY)).toBe(true);
expect(prompt).toContain("Environment you are running in:");
expect(prompt).toContain("4. Working Directory: /repo");
expect(prompt).toContain(
"IMPORTANT: Always includes tool calls in your response until the task is completed.",
);
expect(prompt).toContain("# Workspace Configuration");
expect(prompt).not.toContain("You are Cline, an AI coding agent.");
});
it("keeps the harness for non-cline providers without cline metadata", () => {
const prompt = buildSubAgentSystemPrompt(
PROFILE_BODY,
makeConfig({ providerId: "openai" }),
);
expect(prompt.startsWith(PROFILE_BODY)).toBe(true);
expect(prompt).toContain("Environment you are running in:");
expect(prompt).toContain(
"IMPORTANT: Always includes tool calls in your response until the task is completed.",
);
expect(prompt).not.toContain("# Workspace Configuration");
});
});
describe("buildTeammateSystemPrompt", () => {
it("injects the role prompt as rules under the default persona for cline", () => {
const prompt = buildTeammateSystemPrompt(PROFILE_BODY, makeConfig());
expect(prompt).toContain("You are Cline, an AI coding agent.");
expect(prompt).toContain(`# Team Teammate Role\n${PROFILE_BODY}`);
});
it("returns the raw prompt for non-cline providers", () => {
const prompt = buildTeammateSystemPrompt(
PROFILE_BODY,
makeConfig({ providerId: "openai" }),
);
expect(prompt).toBe(PROFILE_BODY);
});
});
@@ -26,15 +26,13 @@ export function buildSubAgentSystemPrompt(
config: DelegatedAgentRuntimeConfig,
): string {
const trimmedPrompt = prompt.trim();
if (config.providerId.toLowerCase() !== "cline") {
return trimmedPrompt;
}
// The spawn prompt fills the persona slot; the provider-agnostic harness
// (env block, tool-call loop contract) is kept for every provider.
return buildClineSystemPrompt({
ide: config.clineIdeName || "Terminal",
workspaceRoot: config.cwd?.trim() || "/",
providerId: config.providerId,
overridePrompt: trimmedPrompt,
personaPrompt: trimmedPrompt,
metadata: config.workspaceMetadata,
platform: config.clinePlatform,
});
@@ -335,6 +335,9 @@ async function runHookCommandOnce(
? ["pipe", "ignore", "ignore"]
: ["pipe", "pipe", "pipe"],
detached: options.detached,
// Prevent a console window from flashing on Windows (especially when
// detached, which would otherwise allocate a new console).
windowsHide: true,
});
const spawned = new Promise<void>((resolve) => {
child.once("spawn", () => resolve());
@@ -125,6 +125,9 @@ export async function runSubprocessEvent(
env: withResolvedClineBuildEnv(options.env),
stdio: detached ? ["pipe", "ignore", "ignore"] : ["pipe", "pipe", "pipe"],
detached,
// Prevent a console window from flashing on Windows (especially when
// detached, which would otherwise allocate a new console).
windowsHide: true,
});
const spawned = new Promise<void>((resolve) => {
child.once("spawn", () => {
@@ -46,6 +46,35 @@ describe("resolveHubUrl", () => {
await expect(resolveHubUrl()).resolves.toBe("ws://127.0.0.1:25463/hub");
});
it("uses the shared discovery owner in development builds", async () => {
delete process.env.CLINE_HUB_DISCOVERY_PATH;
process.env.CLINE_DATA_DIR = "/tmp/cline-connect-test-data";
process.env.CLINE_BUILD_ENV = "development";
const readHubDiscovery = vi
.spyOn(await import("../discovery"), "readHubDiscovery")
.mockResolvedValue({
hubId: "hub-test",
protocolVersion: "v1",
authToken: "test-token",
host: "127.0.0.1",
port: 25466,
url: "ws://127.0.0.1:25466/hub",
startedAt: new Date(0).toISOString(),
updatedAt: new Date(0).toISOString(),
});
await expect(resolveHubUrl()).resolves.toBe("ws://127.0.0.1:25466/hub");
const discoveryPath = readHubDiscovery.mock.calls[0]?.[0].replaceAll(
"\\",
"/",
);
expect(discoveryPath).toContain("/locks/hub/owners/");
expect(discoveryPath).not.toBe(
"/tmp/cline-connect-test-data/locks/hub/production.json",
);
});
it("falls back to the default endpoint when no discovery file exists", async () => {
process.env.CLINE_HUB_DISCOVERY_PATH = "/tmp/missing-hub-discovery.json";
vi.spyOn(
+13 -3
View File
@@ -3,12 +3,16 @@ import type {
HubReplyEnvelope,
HubTransportFrame,
} from "@cline/shared";
import { resolveClineBuildEnv } from "@cline/shared";
import { createHubServerUrl, readHubDiscovery } from "../discovery";
import {
type HubEndpointOverrides,
resolveHubEndpointOptions,
} from "../discovery/defaults";
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
import {
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "../discovery/workspace";
export interface HubConnection {
send(envelope: HubCommandEnvelope): Promise<HubReplyEnvelope>;
@@ -68,13 +72,19 @@ function sameHubEndpoint(left: string, right: string): boolean {
return leftUrl.toString() === rightUrl.toString();
}
function resolveDefaultHubOwnerContext() {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
async function resolveHubUrlAuthToken(url: URL): Promise<string | undefined> {
const queryToken = url.searchParams.get("authToken")?.trim();
url.searchParams.delete("authToken");
if (queryToken) {
return queryToken;
}
const owner = resolveSharedHubOwnerContext();
const owner = resolveDefaultHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (discovery?.url && sameHubEndpoint(url.toString(), discovery.url)) {
return discovery.authToken;
@@ -87,7 +97,7 @@ export async function resolveHubUrl(
): Promise<string> {
const endpoint = resolveHubEndpointOptions(overrides);
if (!hasExplicitEndpoint(overrides)) {
const owner = resolveSharedHubOwnerContext();
const owner = resolveDefaultHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (discovery?.url) {
return discovery.url;
+151 -5
View File
@@ -546,6 +546,10 @@ describe("NodeHubClient", () => {
spawnDetachedHubServerWithRetry: vi.fn(async () => undefined),
}));
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery-recovery.json",
@@ -697,6 +701,10 @@ describe("NodeHubClient", () => {
spawnDetachedHubServerWithRetry: vi.fn(async () => undefined),
}));
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery-explicit.json",
@@ -764,6 +772,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
it("does not clear discovery on transient probe failure", async () => {
const clearHubDiscoveryMock = vi.fn();
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
@@ -798,9 +810,13 @@ describe("resolveCompatibleLocalHubUrl", () => {
expect(clearHubDiscoveryMock).not.toHaveBeenCalled();
});
it("clears discovery on build mismatch", async () => {
it("keeps discovery on build mismatch when protocol is compatible", async () => {
const clearHubDiscoveryMock = vi.fn();
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
@@ -840,15 +856,19 @@ describe("resolveCompatibleLocalHubUrl", () => {
const { resolveCompatibleLocalHubUrl } = await import(".");
await expect(resolveCompatibleLocalHubUrl()).resolves.toBeUndefined();
expect(clearHubDiscoveryMock).toHaveBeenCalledWith(
"/tmp/hub-discovery.json",
await expect(resolveCompatibleLocalHubUrl()).resolves.toBe(
"ws://127.0.0.1:59999/hub",
);
expect(clearHubDiscoveryMock).not.toHaveBeenCalled();
});
it("clears discovery when a hub omits build metadata", async () => {
it("keeps discovery when a hub omits build metadata but has compatible protocol", async () => {
const clearHubDiscoveryMock = vi.fn();
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
@@ -886,6 +906,57 @@ describe("resolveCompatibleLocalHubUrl", () => {
const { resolveCompatibleLocalHubUrl } = await import(".");
await expect(resolveCompatibleLocalHubUrl()).resolves.toBe(
"ws://127.0.0.1:59999/hub",
);
expect(clearHubDiscoveryMock).not.toHaveBeenCalled();
});
it("clears discovery on protocol mismatch", async () => {
const clearHubDiscoveryMock = vi.fn();
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
}));
vi.doMock("../discovery", async () => {
const actual =
await vi.importActual<typeof import("../discovery")>("../discovery");
return {
...actual,
readHubDiscovery: vi.fn(async () => ({
hubId: "hub-test",
protocolVersion: "v0",
buildId: "old-build",
host: "127.0.0.1",
port: 59999,
url: "ws://127.0.0.1:59999/hub",
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})),
clearHubDiscovery: vi.fn(async (...args: unknown[]) => {
clearHubDiscoveryMock(...args);
}),
probeHubServer: vi.fn(async () => ({
hubId: "hub-test",
protocolVersion: "v0",
buildId: "old-build",
host: "127.0.0.1",
port: 59999,
url: "ws://127.0.0.1:59999/hub",
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
})),
};
});
const { resolveCompatibleLocalHubUrl } = await import(".");
await expect(resolveCompatibleLocalHubUrl()).resolves.toBeUndefined();
expect(clearHubDiscoveryMock).toHaveBeenCalledWith(
"/tmp/hub-discovery.json",
@@ -914,6 +985,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
spawnDetachedHubServerWithRetry: spawnDetachedHubServerWithRetryMock,
}));
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
@@ -950,6 +1025,73 @@ describe("resolveCompatibleLocalHubUrl", () => {
).toBeLessThan(readHubDiscoveryMock.mock.invocationCallOrder[1]);
});
it("waits on shared discovery after spawning in development builds", async () => {
vi.stubGlobal("WebSocket", MockWebSocket);
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
process.env.CLINE_BUILD_ENV = "development";
const spawnDetachedHubServerWithRetryMock = vi.fn(async () => undefined);
const record = {
hubId: "hub-test",
protocolVersion: "v1",
buildId: "test-build",
authToken: "token",
host: "127.0.0.1",
port: 25466,
url: "ws://127.0.0.1:25466/hub",
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
const readHubDiscoveryMock = vi.fn(async (path: string) =>
path === "/tmp/shared-hub-discovery.json" ? record : undefined,
);
vi.doMock("../daemon", () => ({
spawnDetachedHubServerWithRetry: spawnDetachedHubServerWithRetryMock,
}));
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "production",
discoveryPath: "/tmp/production-hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "shared",
discoveryPath: "/tmp/shared-hub-discovery.json",
}),
}));
vi.doMock("../discovery", async () => {
const actual =
await vi.importActual<typeof import("../discovery")>("../discovery");
return {
...actual,
readHubDiscovery: readHubDiscoveryMock,
probeHubServer: vi.fn(async () => record),
clearHubDiscovery: vi.fn(async () => undefined),
};
});
try {
const { ensureCompatibleLocalHubUrl } = await import(".");
await expect(
ensureCompatibleLocalHubUrl({
workspaceRoot: "/tmp/project",
cwd: "/tmp/project",
}),
).resolves.toBe("ws://127.0.0.1:25466/hub");
expect(readHubDiscoveryMock).toHaveBeenCalledWith(
"/tmp/shared-hub-discovery.json",
);
expect(readHubDiscoveryMock).not.toHaveBeenCalledWith(
"/tmp/production-hub-discovery.json",
);
} finally {
if (originalBuildEnv === undefined) {
delete process.env.CLINE_BUILD_ENV;
} else {
process.env.CLINE_BUILD_ENV = originalBuildEnv;
}
}
});
it("does not restart explicit local endpoints after startup timeout", async () => {
const readHubDiscoveryMock = vi.fn(async () => ({
hubId: "hub-test",
@@ -963,6 +1105,10 @@ describe("resolveCompatibleLocalHubUrl", () => {
updatedAt: new Date().toISOString(),
}));
vi.doMock("../discovery/workspace", () => ({
resolveProductionHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
}),
resolveSharedHubOwnerContext: () => ({
ownerId: "hub-test",
discoveryPath: "/tmp/hub-discovery.json",
+28 -15
View File
@@ -5,6 +5,8 @@ import {
type HubEventEnvelope,
type HubReplyEnvelope,
type HubTransportFrame,
isHubProtocolCompatible,
resolveClineBuildEnv,
resolveHubCommandTimeoutMs,
} from "@cline/shared";
import {
@@ -17,9 +19,11 @@ import {
type HubOwnerContext,
probeHubServer,
readHubDiscovery,
resolveHubBuildId,
} from "../discovery";
import { resolveSharedHubOwnerContext } from "../discovery/workspace";
import {
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "../discovery/workspace";
type PendingReply = {
resolve: (reply: HubReplyEnvelope) => void;
@@ -31,6 +35,12 @@ type SubscriptionEntry = {
sessionId?: string;
};
function resolveDefaultHubOwnerContext(): HubOwnerContext {
return resolveClineBuildEnv() === "production"
? resolveProductionHubOwnerContext()
: resolveSharedHubOwnerContext();
}
type WebSocketLike = {
readyState: number;
send(data: string): void;
@@ -821,7 +831,7 @@ type HubProbeResult =
url: string;
}
| {
status: "unreachable" | "build_mismatch";
status: "unreachable" | "protocol_mismatch";
url: string;
};
@@ -835,18 +845,18 @@ async function probeCompatibleHubUrl(
},
): Promise<HubProbeResult> {
const normalized = normalizeHubWebSocketUrl(url);
const record = await probeHubServer(normalized);
const record = await probeHubServer(normalized, {
authToken: options?.authToken,
});
if (!record) {
return {
status: "unreachable",
url: normalized,
};
}
const buildId = resolveHubBuildId();
const recordBuildId = record.buildId?.trim();
if (!recordBuildId || recordBuildId !== buildId) {
if (!isHubProtocolCompatible(record).compatible) {
return {
status: "build_mismatch",
status: "protocol_mismatch",
url: normalized,
};
}
@@ -973,16 +983,18 @@ export async function resolveCompatibleLocalHubUrl(
return compatible.status === "compatible" ? compatible.url : undefined;
}
const owner = resolveSharedHubOwnerContext();
const owner = resolveDefaultHubOwnerContext();
const record = await readHubDiscovery(owner.discoveryPath);
if (!record?.url) {
return undefined;
}
const compatible = await probeCompatibleHubUrl(record.url);
const compatible = await probeCompatibleHubUrl(record.url, {
authToken: record.authToken,
});
if (compatible.status === "compatible") {
return rememberRecoverableLocalHubUrl(compatible.url, record.authToken);
}
if (compatible.status === "build_mismatch") {
if (compatible.status === "protocol_mismatch") {
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
}
return undefined;
@@ -1004,7 +1016,7 @@ export async function ensureCompatibleLocalHubUrl(
if (options.endpoint?.trim()) {
return undefined;
}
const owner = resolveSharedHubOwnerContext();
const owner = resolveDefaultHubOwnerContext();
await spawnDetachedHubServerWithRetry(options.workspaceRoot ?? process.cwd());
return await waitForCompatibleHubUrl(owner);
}
@@ -1032,8 +1044,9 @@ export async function requestHubShutdown(
return response.ok;
}
export async function stopLocalHubServerGracefully(): Promise<boolean> {
const owner = resolveSharedHubOwnerContext();
export async function stopLocalHubServerGracefully(
owner: HubOwnerContext = resolveDefaultHubOwnerContext(),
): Promise<boolean> {
const discovery = await readHubDiscovery(owner.discoveryPath);
if (!discovery?.url) {
return false;
@@ -1060,7 +1073,7 @@ export async function restartLocalHubIfIdleAfterStartupTimeout(options: {
if (!isRecoverableLocalHubUrl(options.url)) {
return undefined;
}
const owner = resolveSharedHubOwnerContext();
const owner = resolveDefaultHubOwnerContext();
const discovery = await readHubDiscovery(owner.discoveryPath);
if (!discovery?.url || !sameNormalizedHubUrl(discovery.url, options.url)) {
return undefined;

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