Compare commits

...
Author SHA1 Message Date
Saoud Rizwan 9bb931b41c Merge branch 'main' into env-specific-providers-file 2026-06-18 13:23:16 -07:00
Tomás Barreiro 99fb5101e5 Improve ClinePass error handling (#11637)
* Make sections expandable and add ClinePass

* fix label

* Map clinePass models to clinePass and the rest to cline

* gate the models behind the feature flag

* Do not allow custom model on cline pass

* Do not show the count on the mode list

* fix clinepass model list

* fix focus when expanding a section

* update the model data when the provider doesn't match

* Add ClinePass to the onboarding screen

* Fix the auth flow not starting

* Hide option behind a feature flag

* Update icon

* Remove unrelated changes

* Hide the custom model id

* fix model names

* remove custom model id option

* fix tests

* Fix names

* Display the clinepass models in the onboarding

* Throw a specific error when the user isn't subscribed

* properly render the error message

* fix error detection

* refactor

* Update the ResponseErrorHandler type

* re-add trailing slash
2026-06-18 22:18:12 +02:00
Tomás Barreiro 58db9887ee Clear organization on ClinePass selection on the VSCode extension (#11648)
* Clear organization on ClinePass selection on the VSCode extension

* Disable remote config if ClinePass is selected

* Disable remote config if ClinePass is selected

* Revert formatting changes

* fix issue
2026-06-18 22:04:49 +02:00
Ahmad Shahzad b16a28cd6f feat(vscode): add Fireworks GLM 5.2 and Kimi K2.6 Fast, fix DeepSeek V4 Flash cache-read price (#11642)
Update the Fireworks model registry in apps/vscode/src/shared/api.ts:

- Add accounts/fireworks/models/glm-5p2: new general-purpose model with
  a 1,048,576-token context window (131,072 output).
- Add accounts/fireworks/routers/kimi-k2p6-fast: standardizes the
  Kimi K2.6 router on the `-fast` naming convention used for /routers.
  The existing kimi-k2p6-turbo entry is retained for now to avoid
  breaking user workflows; the turbo variant is expected to be removed
  in a future release once the fast variant is the only one served.
- Correct the cache-read price for accounts/fireworks/models/deepseek-v4-flash
  from 0.03 to 0.028 to match the published rate.

Pricing sourced from https://docs.fireworks.ai/serverless/pricing.
2026-06-18 21:18:01 +02:00
114aa3f161 Add friendly Cline Pass entitlement error UI (#11606)
* Add Cline Pass provider to VS Code extension

* Fix the model picker

* ClinePass specific options

* Add feature flag to the api options

* Extend flag usage

* fix type error

* Use the right model info

* Fix tests

* Hide price info for clinePass models

* Add friendly Cline Pass entitlement error UI

When a Cline Pass model returns a 403 ENTITLEMENT_ERROR (user not subscribed to the required model plan), the chat dumped the raw serialized error JSON and pointlessly auto-retried it.

- Add ClineErrorType.Entitlement, classified before the generic 403/auth path.
- Skip auto-retry (and the retries-exhausted message) for entitlement errors.
- Render a dedicated EntitlementError card: friendly headline, env-aware 'Get Cline Pass' subscribe link (clineUser.appBaseUrl, falling back to production), and a Retry button; backend detail is shown as muted support text.
- Add unit/component tests and a Storybook story.

* Scope entitlement to individual case; fix path-prefixed subscribe URL

- ClineError: only classify the individual 'not subscribed' ENTITLEMENT_ERROR as Entitlement (checks message and details.message). Org-account variant falls through to generic handling rather than showing a misleading 'Get Cline Pass' card.

- EntitlementError: build the subscribe URL with a relative path against a trailing-slash-normalized base so path-prefixed self-hosted/proxy app URLs (e.g. https://proxy.example.com/cline/app) are preserved instead of resetting to origin.

- Tests: add org-exclusion case, path-prefixed URL case, and assert the retry askResponse payload (yesButtonClicked).

* Trim redundant inline comments

* Add provider test: pre-stream 403 entitlement error classifies correctly

Documents and locks in that a 403 ENTITLEMENT_ERROR (which rejects completions.create before streaming, as an OpenAI SDK APIError) propagates through ClineHandler with the code intact so ClineError classifies it as Entitlement. Confirms the error does not rely on the mid-stream chunk.error path.

* Guard subscribe URL build against malformed appBaseUrl

Wrap new URL() in try/catch so an invalid appBaseUrl from the auth context omits the subscribe link instead of throwing TypeError and crashing the EntitlementError card mid-render. Addresses Greptile review feedback; adds a malformed-URL test.

* test: trim entitlement error UI coverage

* Use one-word 'ClinePass' in user-facing copy

Renames the display text in the entitlement card (headline, helper, button) and related tests/story/comments from 'Cline Pass' to 'ClinePass'. Also normalizes EntitlementError.test.tsx formatting to the webview Biome style.

* fix: skip subagent retries for ClinePass entitlement errors

---------

Co-authored-by: BarreiroT <tomasmbarreiroi@gmail.com>
Co-authored-by: Tomás Barreiro <52393857+BarreiroT@users.noreply.github.com>
2026-06-18 12:06:40 -07:00
Tomás Barreiro 64743d2911 Improve the ClinePass UX (#11644) 2026-06-18 20:39:52 +02:00
Tomás Barreiro d7292993e9 Add Cline Pass to the extension (#11556)
* Add Cline Pass provider to VS Code extension

* Fix the model picker

* ClinePass specific options

* Add feature flag to the api options

* Extend flag usage

* fix type error

* Use the right model info

* Fix tests

* Hide price info for clinePass models

* refactor

* remove redundancy

* fix syncModeConfigurations
2026-06-18 20:19:46 +02:00
Tomás Barreiro 8110cc46d8 Add the ClinePass onboarding flow (#11590)
* Make sections expandable and add ClinePass

* fix label

* Map clinePass models to clinePass and the rest to cline

* gate the models behind the feature flag

* Do not allow custom model on cline pass

* Do not show the count on the mode list

* fix clinepass model list

* fix focus when expanding a section

* update the model data when the provider doesn't match

* Add ClinePass to the onboarding screen

* Fix the auth flow not starting

* Hide option behind a feature flag

* Update icon

* Remove unrelated changes

* Hide the custom model id

* fix model names

* remove custom model id option

* fix tests

* Fix names

* Display the clinepass models in the onboarding
2026-06-18 19:00:18 +02:00
BarreiroT 486091f7d6 Add providers.json files for different envs 2026-06-18 02:33:10 -04:00
Saoud Rizwan a3cd39da14 feat(cli): update hub dashboard design with customizations break out (#11631)
* feat(cli): refresh hub dashboard navigation

* fix(cli): address hub dashboard review feedback

* feat(cli): polish hub dashboard mockup styling

* fix(cli): polish hub dashboard pages

* fix(cli): brand hub browser metadata

* fix(cli): simplify marketplace entry details

* fix(cli): consolidate customization catalog lists

* fix(cli): dedupe marketplace installed rows

* fix(cli): preserve marketplace controls in consolidated rows

* fix(cli): align marketplace plugin controls

* fix(cli): flatten marketplace plugin tool layout

* fix(cli): place marketplace path below tags

* fix(cli): show primitive icons in marketplace rows

* fix(cli): sort sessions by displayed created time

* fix(cli): restore hub account navigation

* fix(cli): refine hub account navigation

* fix(cli): reuse hub account credentials

* fix(cli): support uninstalling local primitives
2026-06-17 21:55:31 -07:00
Saoud Rizwan a51b156383 feat(cli): add hub primitive catalogs (#11624)
* feat(cli): add hub marketplace

* chore(cli): clean up marketplace install code

* fix(cli): harden marketplace review issues

* feat(cli): add marketplace uninstall actions

* fix(cli): keep marketplace dialog actions in sync

* fix(cli): uninstall marketplace cards directly

* fix(cli): hide stale marketplace installed notice

* feat(cli): split hub marketplace pages

* fix(cli): show full marketplace descriptions

* fix(cli): avoid duplicate marketplace descriptions

* fix(cli): support marketplace deep refreshes

* fix(cli): hide marketplace placeholder icons

* fix(cli): remove marketplace card icons

* fix(cli): ignore featured marketplace entries

* chore(cli): clean marketplace follow-up code

* fix(cli): address marketplace review findings
2026-06-17 17:17:38 -07:00
124 changed files with 9956 additions and 3144 deletions
+24
View File
@@ -6,6 +6,15 @@ import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
const ENV_KEYS = [
"WORKSPACE_ROOT",
"CLINE_DIR",
"CLINE_SANDBOX",
"CLINE_SANDBOX_DATA_DIR",
"CLINE_DATA_DIR",
"CLINE_DB_DATA_DIR",
"CLINE_SESSION_DATA_DIR",
"CLINE_TEAM_DATA_DIR",
"CLINE_PROVIDER_SETTINGS_PATH",
"CLINE_HOOKS_LOG_PATH",
"HOST",
"CLINE_HUB_DASHBOARD_PORT",
"PUBLIC_URL",
@@ -38,6 +47,9 @@ describe("runDashboardCommand", () => {
let observedEnv:
| {
workspaceRoot: string | undefined;
clineDir: string | undefined;
clineDataDir: string | undefined;
providerSettingsPath: string | undefined;
host: string | undefined;
port: string | undefined;
publicUrl: string | undefined;
@@ -50,7 +62,9 @@ describe("runDashboardCommand", () => {
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
const exitCode = await runDashboardCommand({
configDir: "/tmp/cline-config",
cwd: "sdk",
dataDir: ".cline-dashboard-data",
host: "127.0.0.1",
port: "9090",
publicUrl: "http://127.0.0.1:9090",
@@ -62,6 +76,9 @@ describe("runDashboardCommand", () => {
startServer: async () => {
observedEnv = {
workspaceRoot: process.env.WORKSPACE_ROOT,
clineDir: process.env.CLINE_DIR,
clineDataDir: process.env.CLINE_DATA_DIR,
providerSettingsPath: process.env.CLINE_PROVIDER_SETTINGS_PATH,
host: process.env.HOST,
port: process.env.CLINE_HUB_DASHBOARD_PORT,
publicUrl: process.env.PUBLIC_URL,
@@ -87,6 +104,13 @@ describe("runDashboardCommand", () => {
expect(exitCode).toBe(0);
expect(observedEnv).toEqual({
workspaceRoot: resolve("sdk"),
clineDir: "/tmp/cline-config",
clineDataDir: resolve("sdk", ".cline-dashboard-data"),
providerSettingsPath: join(
resolve("sdk", ".cline-dashboard-data"),
"settings",
"providers.json",
),
host: "127.0.0.1",
port: "9090",
publicUrl: "http://127.0.0.1:9090",
+27 -7
View File
@@ -3,6 +3,7 @@ import { arch, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import open from "open";
import { configureSandboxEnvironment } from "../utils/helpers";
import { c } from "../utils/output";
export interface DashboardServerHandle {
@@ -19,7 +20,9 @@ interface DashboardCommandIo {
}
export interface RunDashboardCommandOptions {
configDir?: string;
cwd?: string;
dataDir?: string;
host?: string;
port?: string;
publicUrl?: string;
@@ -36,10 +39,9 @@ const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
function setEnvValue(name: string, value: string | undefined): () => void {
const previous = process.env[name];
if (value === undefined) {
return () => {};
if (value !== undefined) {
process.env[name] = value;
}
process.env[name] = value;
return () => {
if (previous === undefined) {
delete process.env[name];
@@ -49,21 +51,39 @@ function setEnvValue(name: string, value: string | undefined): () => void {
};
}
const SANDBOX_ENV_KEYS = [
"CLINE_SANDBOX",
"CLINE_SANDBOX_DATA_DIR",
"CLINE_DATA_DIR",
"CLINE_DB_DATA_DIR",
"CLINE_SESSION_DATA_DIR",
"CLINE_TEAM_DATA_DIR",
"CLINE_PROVIDER_SETTINGS_PATH",
"CLINE_HOOKS_LOG_PATH",
] as const;
async function withDashboardEnvironment<T>(
options: RunDashboardCommandOptions,
fn: () => Promise<T>,
): Promise<T> {
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
const restore = [
setEnvValue(
"WORKSPACE_ROOT",
options.cwd ? resolve(options.cwd) : undefined,
),
setEnvValue("WORKSPACE_ROOT", options.cwd ? cwd : undefined),
setEnvValue("CLINE_DIR", options.configDir?.trim() || undefined),
setEnvValue("HOST", options.host),
setEnvValue(DASHBOARD_PORT_ENV, options.port),
setEnvValue("PUBLIC_URL", options.publicUrl),
setEnvValue("ROOM_SECRET", options.roomSecret),
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
...SANDBOX_ENV_KEYS.map((key) => setEnvValue(key, undefined)),
];
if (options.dataDir || process.env.CLINE_SANDBOX?.trim() === "1") {
configureSandboxEnvironment({
enabled: true,
cwd,
explicitDir: options.dataDir,
});
}
try {
return await fn();
} finally {
+40 -2
View File
@@ -10,7 +10,7 @@ describe("buildSkillsArgs", () => {
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
"-y",
"skills@latest",
"install",
"add",
"owner/repo",
"--agent",
"cline",
@@ -20,6 +20,17 @@ describe("buildSkillsArgs", () => {
expect(buildSkillsArgs(["update", "owner/repo"])).toContain("cline");
});
it("aliases uninstall to the skills remove subcommand", () => {
expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([
"-y",
"skills@latest",
"remove",
"my-skill",
"--agent",
"cline",
]);
});
it("does not inject when the user already targeted an agent", () => {
expect(
buildSkillsArgs(["install", "owner/repo", "--agent", "cursor"]),
@@ -32,12 +43,39 @@ describe("buildSkillsArgs", () => {
).not.toContain("cline");
});
it("aliases install and uninstall when agent options come before the subcommand", () => {
expect(
buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]),
).toEqual([
"-y",
"skills@latest",
"--agent",
"cursor",
"add",
"owner/repo",
]);
expect(
buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]),
).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]);
});
it("does not scope non-install subcommands to cline", () => {
expect(buildSkillsArgs(["use", "owner/repo"])).not.toContain("--agent");
expect(buildSkillsArgs(["remove"])).not.toContain("--agent");
expect(buildSkillsArgs(["list"])).not.toContain("--agent");
});
it("scopes remove-style subcommands to cline", () => {
expect(buildSkillsArgs(["remove"])).toEqual([
"-y",
"skills@latest",
"remove",
"--agent",
"cline",
]);
expect(buildSkillsArgs(["rm", "my-skill"])).toContain("cline");
expect(buildSkillsArgs(["r", "my-skill"])).toContain("cline");
});
it("ignores leading flags when detecting the subcommand", () => {
expect(buildSkillsArgs(["--global", "install", "owner/repo"])).toContain(
"cline",
+45 -2
View File
@@ -16,7 +16,21 @@ const SKILLS_PACKAGE = "skills@latest";
// own agent. `use` is intentionally excluded: without --agent it prints the
// generated prompt to stdout, whereas adding --agent would launch that agent
// interactively instead — not what someone scoping to Cline would expect.
const CLINE_SCOPED_SUBCOMMANDS = new Set(["add", "install", "i", "update"]);
const CLINE_SCOPED_SUBCOMMANDS = new Set([
"add",
"install",
"i",
"update",
"remove",
"rm",
"r",
"uninstall",
]);
const SKILLS_SUBCOMMAND_ALIASES = new Map([
["install", "add"],
["uninstall", "remove"],
]);
function hasAgentFlag(args: readonly string[]): boolean {
return args.some(
@@ -24,8 +38,36 @@ function hasAgentFlag(args: readonly string[]): boolean {
);
}
function optionConsumesNextValue(arg: string): boolean {
return arg === "-a" || arg === "--agent";
}
function findSubcommandIndex(args: readonly string[]): number {
for (let index = 0; index < args.length; index++) {
const arg = args[index];
if (arg.startsWith("-")) {
if (optionConsumesNextValue(arg)) {
index++;
}
continue;
}
return index;
}
return -1;
}
function findSubcommand(args: readonly string[]): string | undefined {
return args.find((arg) => !arg.startsWith("-"));
const index = findSubcommandIndex(args);
return index >= 0 ? args[index] : undefined;
}
function normalizeSkillsSubcommandAliases(args: string[]): void {
const index = findSubcommandIndex(args);
if (index < 0) return;
const alias = SKILLS_SUBCOMMAND_ALIASES.get(args[index]);
if (alias) {
args[index] = alias;
}
}
/**
@@ -35,6 +77,7 @@ function findSubcommand(args: readonly string[]): string | undefined {
export function buildSkillsArgs(userArgs: readonly string[]): string[] {
const args = [...userArgs];
const subcommand = findSubcommand(args);
normalizeSkillsSubcommandAliases(args);
if (
subcommand &&
CLINE_SCOPED_SUBCOMMANDS.has(subcommand) &&
+6
View File
@@ -929,6 +929,10 @@ describe("runCli lightweight command dispatch", () => {
"bun",
"src/index.ts",
"dashboard",
"--config",
"/tmp/cline-config",
"--data-dir",
".cline-dashboard-data",
"--port",
"9090",
"--no-open",
@@ -939,6 +943,8 @@ describe("runCli lightweight command dispatch", () => {
await expect(runCli()).resolves.toBeUndefined();
expect(dashboardMocks.runDashboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
configDir: "/tmp/cline-config",
dataDir: ".cline-dashboard-data",
port: "9090",
openBrowser: false,
io: expect.any(Object),
+13 -2
View File
@@ -324,10 +324,12 @@ export async function runCli(): Promise<void> {
.addHelpText(
"after",
"\nForwards to the open skills CLI via npx. Examples:\n" +
" cline skill install <owner/repo> Install a skill into Cline\n" +
" cline skill add <owner/repo> Add a skill into Cline\n" +
" cline skill install <owner/repo> Alias for add\n" +
" cline skill list List installed skills\n" +
" cline skill remove Remove installed skills\n" +
"\ninstall/add default to '--agent cline' unless you pass your own --agent.\n" +
" cline skill uninstall Alias for remove\n" +
"\nadd/install and remove/uninstall default to '--agent cline' unless you pass your own --agent.\n" +
"Run 'npx skills --help' for the full command reference.",
)
.action(async () => {
@@ -593,7 +595,12 @@ export async function runCli(): Promise<void> {
const dashboardCmd = program
.command("dashboard")
.description("Start the Cline Hub dashboard and open it in a browser")
.option("--config <dir>", "configuration directory")
.option("-c, --cwd <path>", "Workspace root", process.cwd())
.option(
"--data-dir <dir>",
"Use isolated local state at <dir> instead of ~/.cline (enables sandbox mode)",
)
.option("--host <host>", "Dashboard bind host")
.option("--port <port>", "Dashboard HTTP/WebSocket port")
.option("--public-url <url>", "Public dashboard URL")
@@ -601,7 +608,9 @@ export async function runCli(): Promise<void> {
.option("--no-open", "Start the dashboard without opening a browser")
.action(async () => {
const opts = dashboardCmd.opts<{
config?: string;
cwd?: string;
dataDir?: string;
host?: string;
port?: string;
publicUrl?: string;
@@ -610,7 +619,9 @@ export async function runCli(): Promise<void> {
}>();
const { runDashboardCommand } = await import("./commands/dashboard");
ctx.exitCode = await runDashboardCommand({
configDir: opts.config,
cwd: opts.cwd,
dataDir: opts.dataDir,
host: opts.host,
port: opts.port,
publicUrl: opts.publicUrl,
+101
View File
@@ -27,7 +27,18 @@ const outputMocks = vi.hoisted(() => ({
c: { dim: "", reset: "" },
}));
const CLINE_PASS_SUBSCRIPTION_URL =
"https://app.cline.bot/dashboard/subscription/";
const CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
vi.mock("@cline/core", () => ({
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
isClineNotSubscribedError: (error: unknown) =>
error instanceof Error && error.name === "ClineNotSubscribedError",
isClineNotSubscribedMessage: (text: string) =>
text
.toLowerCase()
.includes("the user is not subscribed to required model plan"),
prewarmFileIndex: vi.fn(async () => undefined),
SessionSource: {
CLI: "cli",
@@ -511,6 +522,39 @@ describe("runAgent", () => {
expect(outputMocks.writeErr).toHaveBeenCalledWith("Missing API key");
});
it("renders ClinePass subscription errors with friendly copy when startup throws", async () => {
const error = new Error(CLINE_PASS_SUBSCRIPTION_MESSAGE);
error.name = "ClineNotSubscribedError";
sessionManagerMocks.start.mockRejectedValue(error);
const { runAgent } = await import("./run-agent");
await expect(
runAgent("test prompt", {
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: { maxConsecutiveMistakes: 3 },
logger: undefined,
mode: "yolo",
modelId: "premium-model",
outputMode: "text",
providerId: "cline-pass",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).toHaveBeenCalledWith(
CLINE_PASS_SUBSCRIPTION_MESSAGE,
);
});
it("emits JSON error lines for non-completed results", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
@@ -576,6 +620,63 @@ describe("runAgent", () => {
);
});
it("renders ClinePass subscription errors with friendly copy for failed results", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
sessionManagerMocks.start.mockResolvedValue({
sessionId: "session-1",
manifestPath: "/tmp/manifest.json",
messagesPath: "/tmp/messages.json",
manifest: { session_id: "session-1" },
result: {
text: CLINE_PASS_SUBSCRIPTION_MESSAGE,
usage: {
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheWriteTokens: 0,
totalCost: 0,
},
messages: [],
toolCalls: [],
iterations: 1,
finishReason: "error",
model: { id: "premium-model", provider: "cline-pass", info: {} },
startedAt,
endedAt,
durationMs: 1000,
},
});
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
const { runAgent } = await import("./run-agent");
await expect(
runAgent("test prompt", {
cwd: process.cwd(),
enableAgentTeams: false,
enableSpawnAgent: false,
enableTools: [],
execution: { maxConsecutiveMistakes: 3 },
logger: undefined,
mode: "yolo",
modelId: "premium-model",
outputMode: "text",
providerId: "cline-pass",
systemPrompt: "system",
thinking: false,
toolPolicies: { "*": { autoApprove: true } },
verbose: false,
workspaceRoot: process.cwd(),
} as never),
).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(outputMocks.writeErr).toHaveBeenCalledWith(
CLINE_PASS_SUBSCRIPTION_MESSAGE,
);
});
it("surfaces post-run bookkeeping failures after a completed result", async () => {
const startedAt = new Date("2026-03-22T00:00:00.000Z");
const endedAt = new Date("2026-03-22T00:00:01.000Z");
+3 -2
View File
@@ -16,6 +16,7 @@ import {
requestToolApproval,
submitAndExitInTerminal,
} from "../utils/approval";
import { formatCliErrorMessage } from "../utils/cline-pass-errors";
import { handleEvent, handleTeamEvent } from "../utils/events";
import { createRuntimeHooks } from "../utils/hooks";
import {
@@ -374,7 +375,7 @@ export async function runAgent(
}
if (result.finishReason !== "completed") {
const errorText = result.text.trim();
const errorText = formatCliErrorMessage(result.text).trim();
if (
errorText &&
(config.outputMode === "json" || !displayedErrorMessages.has(errorText))
@@ -395,7 +396,7 @@ export async function runAgent(
);
process.exitCode = 0;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const message = formatCliErrorMessage(err);
logCliError(config.logger, "CLI task run failed", { error: err });
writeErr(message);
process.exitCode = 1;
@@ -2,6 +2,10 @@ import { useTerminalDimensions } from "@opentui/react";
import type React from "react";
import { useState } from "react";
import "opentui-spinner/react";
import {
getClinePassSubscriptionUrl,
isClinePassSubscriptionError,
} from "../../utils/cline-pass-errors";
import {
CLINE_CREDITS_DASHBOARD_URL,
isClineAccountCreditsErrorMessage,
@@ -290,6 +294,41 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
);
}
function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
const subscriptionUrl = getClinePassSubscriptionUrl();
return (
<box flexDirection="row">
<text fg="yellow" content="* " />
<box
flexDirection="column"
border
borderStyle="rounded"
borderColor="yellow"
paddingX={1}
>
<text fg="yellow">ClinePass subscription required</text>
<text
fg={props.defaultFg}
selectable
content="No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan."
/>
<box flexDirection="row">
<text fg="gray">Subscribe: </text>
<text fg="cyan" selectable>
<a href={subscriptionUrl}>Open subscription page</a>
</text>
</box>
<box flexDirection="row">
<text fg="gray">URL: </text>
<text fg="cyan" selectable>
<a href={subscriptionUrl}>{subscriptionUrl}</a>
</text>
</box>
</box>
</box>
);
}
export function ChatEntryView(props: {
entry: ChatEntry;
accent?: string;
@@ -388,6 +427,9 @@ export function ChatEntryView(props: {
if (isClineAccountCreditsErrorMessage(entry.text)) {
return <ClineCreditsErrorView defaultFg={defaultFg} />;
}
if (isClinePassSubscriptionError(entry.text)) {
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
}
return (
<box flexDirection="row">
<text fg="red" content="* " />
@@ -134,6 +134,7 @@ export function ModelSelectorContent(
currentModel: string;
currentProviderName: string;
models: ModelOption[];
showCustomModelId?: boolean;
},
) {
const {
@@ -143,6 +144,7 @@ export function ModelSelectorContent(
currentModel,
currentProviderName,
models,
showCustomModelId = true,
} = props;
const [search, setSearch] = useState("");
const [selected, setSelected] = useState(() => {
@@ -164,7 +166,7 @@ export function ModelSelectorContent(
return scored.map((r) => r.model);
}, [models, search]);
const optionCount = filtered.length + 1;
const optionCount = filtered.length + (showCustomModelId ? 1 : 0);
const safeSelected = Math.min(selected, Math.max(0, optionCount - 1));
useDialogKeyboard((key) => {
@@ -188,7 +190,7 @@ export function ModelSelectorContent(
resolve(model.key);
return;
}
if (safeSelected === filtered.length) {
if (showCustomModelId && safeSelected === filtered.length) {
setIsCreatingCustomModel(true);
setCustomModelId("");
setCustomModelError("");
@@ -290,6 +292,7 @@ export function ModelSelectorContent(
dimmed={onProvider}
currentModel={currentModel}
onSelect={resolve}
showCustomModelId={showCustomModelId}
onCreateCustomModel={() => {
setIsCreatingCustomModel(true);
setCustomModelId("");
@@ -408,6 +411,7 @@ function ModelList(props: {
dimmed?: boolean;
currentModel: string;
onSelect: (key: string) => void;
showCustomModelId: boolean;
onCreateCustomModel: () => void;
}) {
const {
@@ -416,11 +420,12 @@ function ModelList(props: {
dimmed,
currentModel,
onSelect,
showCustomModelId,
onCreateCustomModel,
} = props;
const rows: ({ type: "model"; model: ModelOption } | { type: "custom" })[] = [
...items.map((model) => ({ type: "model" as const, model })),
{ type: "custom" as const },
...(showCustomModelId ? ([{ type: "custom" as const }] as const) : []),
];
if (rows.length <= MAX_VISIBLE) {
+5 -1
View File
@@ -4,6 +4,7 @@ import type {
PendingPromptSnapshot,
PendingPromptSubmittedEvent,
} from "../../runtime/session-events";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { resolveStatusNoticeLabel } from "../../utils/events";
import {
formatToolInput,
@@ -171,7 +172,10 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
turnErrorReportedRef.current = true;
onTurnErrorReported(true);
if (!event.recoverable || verbose) {
appendEntry({ kind: "error", text: event.error.message });
appendEntry({
kind: "error",
text: formatCliErrorMessage(event.error),
});
}
break;
case "notice":
@@ -323,6 +323,7 @@ export function useModelSelector(opts: {
currentModel={config.modelId}
currentProviderName={providerDisplayName}
models={modelOptions}
showCustomModelId={config.providerId !== "cline-pass"}
/>
),
});
@@ -413,6 +414,7 @@ export function useModelSelector(opts: {
currentModel={config.modelId}
currentProviderName={providerDisplayName}
models={modelOptions}
showCustomModelId={config.providerId !== "cline-pass"}
/>
),
});
@@ -1,4 +1,5 @@
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
import type { SlashCommandRegistry } from "../commands/slash-command-registry";
import {
@@ -376,7 +377,7 @@ export function usePromptInputController(input: {
if (!turnErrorReportedRef.current) {
session.appendEntry({
kind: "error",
text: error instanceof Error ? error.message : String(error),
text: formatCliErrorMessage(error),
});
}
} finally {
@@ -9,12 +9,14 @@ import {
resolveProviderConfig,
saveLocalProviderSettings,
} from "@cline/core";
import { isClineProvider } from "@cline/shared";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
type CodexCliStatus,
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
import { getCliFeatureFlagsService } from "../../../utils/feature-flags";
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { getCliTelemetryService } from "../../../utils/telemetry";
@@ -46,11 +48,13 @@ import {
import { FIELD_ORDER } from "./fields";
import { useOnboardingKeyboard } from "./keyboard";
import {
getMainMenuOptions,
type ModelEntry,
type OnboardingResult,
type OnboardingStep,
type ProviderEntry,
type ReasoningEffort,
shouldUseFeaturedClineModelPicker,
type ThinkingLevel,
toModelEntriesFromKnownModels,
toModelEntry,
@@ -71,6 +75,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
() => props.providerSettingsManager ?? new ProviderSettingsManager(),
[props.providerSettingsManager],
);
const menuOptions = useMemo(
() =>
getMainMenuOptions({
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
}),
[],
);
const [step, setStep] = useState<OnboardingStep>("menu");
const [menuSelected, setMenuSelected] = useState(0);
const [oauthProvider, setOauthProvider] = useState("");
@@ -153,6 +165,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const createCustomModelItem = useCallback(
(_search: string, filteredItems: SearchableItem[]) => {
if (activeProviderId === "cline-pass") {
return undefined;
}
if (filteredItems.some((item) => item.key === CUSTOM_MODEL_ID_ACTION)) {
return undefined;
}
@@ -163,7 +178,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
searchText: "create custom model id manual entry",
} satisfies SearchableItem;
},
[],
[activeProviderId],
);
const modelList = useSearchableList(modelItems, createCustomModelItem);
@@ -256,7 +271,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const provider = providers.find((p) => p.id === providerId);
setActiveProviderName(provider?.name ?? providerId);
setModelsDefaultId(provider?.defaultModelId ?? "");
if (providerId === "cline") {
if (shouldUseFeaturedClineModelPicker(providerId)) {
setClineModelSelected(0);
setStep("cline_model");
} else if (providerId === "openai-compatible") {
@@ -307,7 +322,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
const startOAuthFlow = useCallback(
(providerId: OnboardingOAuthProviderId) => {
if (providerId === "cline") {
if (isClineProvider(providerId)) {
startDeviceCodeFlow(providerId);
return;
}
@@ -611,6 +626,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
onExit: props.onExit,
oauthProvider,
activeProviderId,
menuOptions,
menuSelected,
providerList,
modelList,
@@ -685,6 +701,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
},
handleModelItemSelect: selectModelItem,
menuSelected,
menuOptions,
modelItems,
modelList,
modelsLoading,
+14 -6
View File
@@ -3,10 +3,13 @@ import { useKeyboard } from "@opentui/react";
import type { Dispatch, SetStateAction } from "react";
import type { ClineModelPickerEntry } from "../../components/model-selector/cline-model-picker";
import type { SearchableListState } from "../../components/searchable-list";
import type { OnboardingOAuthProviderId } from "./auth";
import {
isOnboardingOAuthProviderId,
type OnboardingOAuthProviderId,
} from "./auth";
import { FIELD_ORDER } from "./fields";
import {
MAIN_MENU,
type MenuOption,
type OnboardingStep,
THINKING_LEVELS,
type ThinkingLevel,
@@ -17,6 +20,7 @@ export function useOnboardingKeyboard(input: {
onExit: () => void;
oauthProvider: string;
activeProviderId: string;
menuOptions: MenuOption[];
menuSelected: number;
providerList: SearchableListState;
modelList: SearchableListState;
@@ -133,17 +137,21 @@ export function useOnboardingKeyboard(input: {
if (input.step === "menu") {
if (key.name === "up") {
input.setMenuSelected((s) => (s <= 0 ? MAIN_MENU.length - 1 : s - 1));
input.setMenuSelected((s) =>
s <= 0 ? input.menuOptions.length - 1 : s - 1,
);
return;
}
if (key.name === "down") {
input.setMenuSelected((s) => (s >= MAIN_MENU.length - 1 ? 0 : s + 1));
input.setMenuSelected((s) =>
s >= input.menuOptions.length - 1 ? 0 : s + 1,
);
return;
}
if (key.name === "return") {
const option = MAIN_MENU[input.menuSelected];
const option = input.menuOptions[input.menuSelected];
if (!option) return;
if (option.value === "cline" || option.value === "openai-codex") {
if (isOnboardingOAuthProviderId(option.value)) {
input.startOAuthFlow(option.value);
} else {
input.setStep("byo_provider");
@@ -1,12 +1,30 @@
import { describe, expect, it } from "vitest";
import {
getMainMenuOptions,
getOAuthProviderLabel,
shouldUseFeaturedClineModelPicker,
toModelEntriesFromKnownModels,
toModelEntry,
toProviderEntry,
} from "./model";
describe("onboarding model helpers", () => {
it("hides ClinePass from the main menu unless its feature flag is enabled", () => {
expect(
getMainMenuOptions().some((option) => option.value === "cline-pass"),
).toBe(false);
expect(
getMainMenuOptions({ isClinePassEnabled: false }).some(
(option) => option.value === "cline-pass",
),
).toBe(false);
expect(
getMainMenuOptions({ isClinePassEnabled: true }).some(
(option) => option.value === "cline-pass",
),
).toBe(true);
});
it("maps provider catalog entries into onboarding provider entries", () => {
expect(
toProviderEntry({
@@ -112,7 +130,14 @@ describe("onboarding model helpers", () => {
it("formats OAuth provider labels for onboarding status views", () => {
expect(getOAuthProviderLabel("cline")).toBe("Cline");
expect(getOAuthProviderLabel("cline-pass")).toBe("ClinePass");
expect(getOAuthProviderLabel("openai-codex")).toBe("ChatGPT");
expect(getOAuthProviderLabel("oca")).toBe("oca");
});
it("uses the featured Cline model picker only for the Cline provider", () => {
expect(shouldUseFeaturedClineModelPicker("cline")).toBe(true);
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(false);
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
});
});
@@ -43,6 +43,12 @@ export const MAIN_MENU: MenuOption[] = [
detail: "Latest models with regular free promos",
icon: "\u263a",
},
{
label: "Sign in with ClinePass",
value: "cline-pass",
detail: "Low cost subscription for everyone",
icon: "\u2726",
},
{
label: "Sign in with ChatGPT",
value: "openai-codex",
@@ -57,6 +63,14 @@ export const MAIN_MENU: MenuOption[] = [
},
];
export function getMainMenuOptions(options?: {
isClinePassEnabled?: boolean;
}): MenuOption[] {
return MAIN_MENU.filter(
(option) => option.value !== "cline-pass" || options?.isClinePassEnabled,
);
}
export interface OnboardingResult {
providerId: string;
modelId: string;
@@ -139,6 +153,9 @@ export function toModelEntriesFromKnownModels(
}
export function getOAuthProviderLabel(providerId: string): string {
if (providerId === "cline-pass") {
return "ClinePass";
}
if (providerId === "cline") {
return "Cline";
}
@@ -147,3 +164,7 @@ export function getOAuthProviderLabel(providerId: string): string {
}
return providerId;
}
export function shouldUseFeaturedClineModelPicker(providerId: string): boolean {
return providerId === "cline";
}
@@ -20,7 +20,7 @@ import {
import { useTerminalBackground } from "../../hooks/use-terminal-background";
import { getDefaultForeground, palette } from "../../palette";
import { FIELD_ORDER } from "./fields";
import { MAIN_MENU, THINKING_LEVELS } from "./model";
import { type MenuOption, THINKING_LEVELS } from "./model";
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
@@ -633,6 +633,7 @@ export function OnboardingThinkingLevelScreen(props: {
export function OnboardingMainMenuScreen(props: {
contentWidth: number;
menuOptions: MenuOption[];
menuSelected: number;
mouse: MouseTrackerState;
}) {
@@ -671,7 +672,7 @@ export function OnboardingMainMenuScreen(props: {
marginTop={1}
gap={0}
>
{MAIN_MENU.map((option, i) => {
{props.menuOptions.map((option, i) => {
const isSel = i === props.menuSelected;
return (
<box
@@ -166,6 +166,7 @@ export function OnboardingView(props: OnboardingViewProps) {
return (
<OnboardingMainMenuScreen
contentWidth={contentWidth}
menuOptions={state.menuOptions}
menuSelected={state.menuSelected}
mouse={mouse}
/>
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
formatCliErrorMessage,
getClinePassSubscriptionUrl,
isClinePassSubscriptionError,
} from "./cline-pass-errors";
describe("cline-pass-errors", () => {
it("recognizes both raw and formatted ClinePass subscription messages", () => {
expect(
isClinePassSubscriptionError(
"the user is not subscribed to required model plan",
),
).toBe(true);
const formatted = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getClinePassSubscriptionUrl()}`;
expect(isClinePassSubscriptionError(formatted)).toBe(true);
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
});
it("formats the ClinePass subscription URL", () => {
expect(getClinePassSubscriptionUrl()).toBe(
"https://app.cline.bot/dashboard/subscription/",
);
});
});
+40
View File
@@ -0,0 +1,40 @@
import {
getClinePassSubscriptionUrl,
isClineNotSubscribedError,
isClineNotSubscribedMessage,
} from "@cline/core";
export { getClinePassSubscriptionUrl };
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
const normalized = message.trim().toLowerCase();
return (
normalized.includes("no access to clinepass subscription models yet") &&
normalized.includes("subscribe to clinepass")
);
}
export function isClinePassSubscriptionError(error: unknown): boolean {
if (isClineNotSubscribedError(error)) {
return true;
}
if (error instanceof Error) {
return (
error.name === "ClineNotSubscribedError" ||
isClineNotSubscribedMessage(error.message) ||
isFormattedClinePassSubscriptionMessage(error.message)
);
}
return (
typeof error === "string" &&
(isClineNotSubscribedMessage(error) ||
isFormattedClinePassSubscriptionMessage(error))
);
}
export function formatCliErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
+1
View File
@@ -12,6 +12,7 @@
"dev": "bun run src/dev.ts",
"start": "bun run src/server.ts",
"smoke:options": "bun run src/validate-options.ts",
"test": "bunx vitest run --config vitest.config.ts",
"typecheck": "bun tsc -p tsconfig.json --noEmit"
},
"dependencies": {
+16
View File
@@ -22,6 +22,7 @@ import {
syncHubClientsAndSessions,
syncHubHealth,
} from "./server/hub";
import { fetchMarketplaceCatalog } from "./server/marketplace";
import {
loadModels,
runProviderOAuthLogin,
@@ -99,6 +100,21 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
if (url.pathname === "/config.json") {
return createJsonResponse(browserConfig);
}
if (url.pathname === "/api/marketplace/catalog") {
try {
return createJsonResponse(await fetchMarketplaceCatalog());
} catch (error) {
return createJsonResponse(
{
error:
error instanceof Error
? error.message
: "Failed to fetch marketplace catalog",
},
502,
);
}
}
return assets.serve(url.pathname);
},
websocket: {
+77 -4
View File
@@ -4,15 +4,20 @@ import {
ClineAccountService,
ensureCustomProvidersLoaded,
executeClineAccountAction,
formatProviderOAuthApiKey,
getLocalProviderModels,
getPersistedProviderApiKey,
getProviderOAuthCredentialsFromSettings,
getValidClineCredentials,
listLocalProviders,
loginAndSaveLocalProviderOAuthCredentials,
normalizeOAuthProvider,
type ProviderCapability,
type ProviderClient,
type ProviderProtocol,
type ProviderSettings,
readGlobalSettings,
resolveLocalClineAuthToken,
saveLocalProviderOAuthCredentials,
saveLocalProviderSettings,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
@@ -27,6 +32,12 @@ import {
stopConnectorChannel,
} from "./connectors";
import { providerSettingsManager, workspaceRoot } from "./deps";
import {
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
uninstallLocalPrimitive,
uninstallMarketplaceEntryForDesktopCommand,
} from "./marketplace";
import {
deleteMcpServer,
ensureMcpSettingsFile,
@@ -52,6 +63,39 @@ const ROUTINE_SCHEDULE_COMMANDS = new Set([
"delete_routine_schedule",
]);
async function resolveHubClineAccountAuthToken(input: {
settings?: ProviderSettings;
apiBaseUrl: string;
}): Promise<string | undefined> {
const credentials = input.settings
? getProviderOAuthCredentialsFromSettings("cline", input.settings)
: null;
if (!credentials || !input.settings) {
return getPersistedProviderApiKey("cline", input.settings);
}
const nextCredentials = await getValidClineCredentials(credentials, {
apiBaseUrl: input.apiBaseUrl,
});
if (!nextCredentials) {
throw new Error(
"Cline account requires re-authentication. Run cline auth cline.",
);
}
if (nextCredentials !== credentials) {
saveLocalProviderOAuthCredentials(
providerSettingsManager,
"cline",
input.settings,
nextCredentials,
{ setLastUsed: false },
);
}
return formatProviderOAuthApiKey("cline", nextCredentials);
}
export async function handleDesktopCommand(
ctx: HubContext,
command: string,
@@ -128,10 +172,18 @@ export async function handleDesktopCommand(
}
if (command === "cline_account") {
const settings = providerSettingsManager.getProviderSettings("cline");
const apiBaseUrl =
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
const authToken = await resolveHubClineAccountAuthToken({
settings,
apiBaseUrl,
});
if (!authToken) {
throw new Error("No Cline account auth token found");
}
const accountService = new ClineAccountService({
apiBaseUrl:
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
getAuthToken: async () => resolveLocalClineAuthToken(settings),
apiBaseUrl,
getAuthToken: async () => authToken,
});
return await executeClineAccountAction(
args as ClineAccountActionRequest,
@@ -213,6 +265,27 @@ export async function handleDesktopCommand(
if (command === "list_user_instruction_configs") {
return await listUserInstructionConfigs(workspaceRoot);
}
if (command === "list_marketplace_installed_entries") {
return listMarketplaceInstalledEntries(
args,
await listUserInstructionConfigs(workspaceRoot),
);
}
if (command === "install_marketplace_entry") {
const result = await installMarketplaceEntryForDesktopCommand(args);
broadcastHubState(ctx);
return result;
}
if (command === "uninstall_marketplace_entry") {
const result = await uninstallMarketplaceEntryForDesktopCommand(args);
broadcastHubState(ctx);
return result;
}
if (command === "uninstall_local_primitive") {
const result = await uninstallLocalPrimitive(args, { workspaceRoot });
broadcastHubState(ctx);
return result;
}
if (command === "toggle_disabled_plugin_tool") {
const toolName = String(args?.name ?? "").trim();
if (!toolName) throw new Error("tool name is required");
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from "vitest";
import { isWebviewRoute, normalizeWebviewIndexHtml } from "./http";
describe("isWebviewRoute", () => {
it.each([
"/",
"/chat",
"/sessions",
"/models",
"/customizations",
"/rules",
"/hooks",
"/mcp",
"/plugins",
"/skills",
"/agents",
"/tools",
"/marketplace",
"/marketplace/mcp",
"/marketplace/skills",
"/marketplace/plugins",
"/channels",
"/schedules",
"/settings",
"/settings/providers",
])("matches dashboard SPA route %s", (pathname) => {
expect(isWebviewRoute(pathname)).toBe(true);
});
it("does not treat nested marketplace asset requests as SPA routes", () => {
expect(isWebviewRoute("/marketplace/assets/index.js")).toBe(false);
});
});
describe("normalizeWebviewIndexHtml", () => {
it("rewrites relative built asset URLs so deep links can refresh", () => {
expect(
normalizeWebviewIndexHtml(
'<script type="module" src="./assets/index.js"></script><link href="./assets/index.css">',
),
).toBe(
'<script type="module" src="/assets/index.js"></script><link href="/assets/index.css">',
);
});
it("injects the persisted theme bootstrap once", () => {
const normalized = normalizeWebviewIndexHtml(
"<html><head></head><body></body></html>",
);
expect(normalized).toContain('id="cline-hub-theme-bootstrap"');
expect(normalizeWebviewIndexHtml(normalized)).toBe(normalized);
});
});
+92 -7
View File
@@ -15,6 +15,25 @@ export function createTextResponse(text: string, status = 200): Response {
});
}
const NO_STORE_HEADERS = {
"cache-control": "no-store, no-cache, must-revalidate, proxy-revalidate",
pragma: "no-cache",
expires: "0",
};
const IMMUTABLE_ASSET_CACHE = "public, max-age=31536000, immutable";
const THEME_BOOTSTRAP_SCRIPT = `<script id="cline-hub-theme-bootstrap">
(() => {
try {
const theme = window.localStorage.getItem("cline-hub-theme");
if (theme === "dark" || theme === "light") {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.dataset.clineHubTheme = theme;
}
} catch {}
})();
</script>`;
function contentTypeFor(path: string): string {
switch (extname(path)) {
case ".html":
@@ -36,20 +55,47 @@ function contentTypeFor(path: string): string {
}
}
function isWebviewRoute(pathname: string): boolean {
export function isWebviewRoute(pathname: string): boolean {
return (
pathname === "/" ||
pathname === "/index.html" ||
pathname === "/chat" ||
pathname === "/sessions" ||
pathname === "/models" ||
pathname === "/customizations" ||
pathname === "/rules" ||
pathname === "/hooks" ||
pathname === "/mcp" ||
pathname === "/plugins" ||
pathname === "/skills" ||
pathname === "/agents" ||
pathname === "/tools" ||
pathname === "/marketplace" ||
pathname === "/marketplace/mcp" ||
pathname === "/marketplace/skills" ||
pathname === "/marketplace/plugins" ||
pathname === "/channels" ||
pathname === "/schedules" ||
pathname === "/settings" ||
pathname.startsWith("/settings/")
);
}
export function normalizeWebviewIndexHtml(html: string): string {
const normalized = html
.replaceAll('src="./', 'src="/')
.replaceAll('href="./', 'href="/');
if (normalized.includes('id="cline-hub-theme-bootstrap"')) {
return normalized;
}
return normalized.replace("<head>", `<head>\n${THEME_BOOTSTRAP_SCRIPT}`);
}
function renderDevIndexHtml(devServerUrl: string): string {
return `<!doctype html>
<html lang="en">
<head>
${THEME_BOOTSTRAP_SCRIPT}
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script type="module">
@@ -60,7 +106,7 @@ function renderDevIndexHtml(devServerUrl: string): string {
window.__vite_plugin_react_preamble_installed__ = true;
</script>
<script type="module" src="${devServerUrl}/@vite/client"></script>
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/cline-logo-filled.svg" />
<title>Cline Hub</title>
</head>
<body>
@@ -74,6 +120,14 @@ function renderDevIndexHtml(devServerUrl: string): string {
export class WebviewAssets {
constructor(private readonly webviewDistDir: string) {}
private async resolveCurrentMainAssetPath(): Promise<string | undefined> {
const indexFile = Bun.file(join(this.webviewDistDir, "index.html"));
if (!(await indexFile.exists())) return undefined;
const html = await indexFile.text();
const match = html.match(/src="\.\/(assets\/index-[^"]+\.js)"/);
return match?.[1] ? join(this.webviewDistDir, match[1]) : undefined;
}
private resolveStaticPath(pathname: string): string | undefined {
const decoded = decodeURIComponent(pathname);
const requested = decoded === "/" ? "/index.html" : decoded;
@@ -89,8 +143,11 @@ export class WebviewAssets {
private async serveIndex(): Promise<Response> {
const indexFile = Bun.file(join(this.webviewDistDir, "index.html"));
if (await indexFile.exists()) {
return new Response(indexFile, {
headers: { "content-type": "text/html; charset=utf-8" },
return new Response(normalizeWebviewIndexHtml(await indexFile.text()), {
headers: {
"content-type": "text/html; charset=utf-8",
...NO_STORE_HEADERS,
},
});
}
return createTextResponse(
@@ -103,7 +160,10 @@ export class WebviewAssets {
const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
if (devServerUrl && isWebviewRoute(pathname)) {
return new Response(renderDevIndexHtml(devServerUrl), {
headers: { "content-type": "text/html; charset=utf-8" },
headers: {
"content-type": "text/html; charset=utf-8",
...NO_STORE_HEADERS,
},
});
}
if (isWebviewRoute(pathname)) {
@@ -112,12 +172,37 @@ export class WebviewAssets {
const filePath = this.resolveStaticPath(pathname);
if (!filePath) return createTextResponse("not found", 404);
const file = Bun.file(filePath);
let responsePath = filePath;
let file = Bun.file(responsePath);
if (
!(await file.exists()) &&
/^\/assets\/index-[A-Za-z0-9_-]+\.js$/.test(pathname)
) {
const currentMainAssetPath = await this.resolveCurrentMainAssetPath();
if (currentMainAssetPath) {
responsePath = currentMainAssetPath;
file = Bun.file(responsePath);
}
}
if (!(await file.exists())) {
return createTextResponse("not found", 404);
}
const isHashedAsset = /^\/assets\/.+-[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/.test(
pathname,
);
return new Response(file, {
headers: { "content-type": contentTypeFor(filePath) },
headers: {
"content-type": contentTypeFor(responsePath),
"cache-control": isHashedAsset
? IMMUTABLE_ASSET_CACHE
: NO_STORE_HEADERS["cache-control"],
...(isHashedAsset
? {}
: {
pragma: NO_STORE_HEADERS.pragma,
expires: NO_STORE_HEADERS.expires,
}),
},
});
}
}
@@ -0,0 +1,870 @@
import { createHash } from "node:crypto";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildMarketplaceMcpInput,
fetchMarketplaceCatalog,
installMarketplaceEntry,
installMarketplaceEntryForDesktopCommand,
listMarketplaceInstalledEntries,
uninstallLocalPrimitive,
uninstallMarketplaceEntry,
uninstallMarketplaceEntryForDesktopCommand,
} from "./marketplace";
describe("marketplace installer", () => {
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
const originalClineDir = process.env.CLINE_DIR;
const originalHome = process.env.HOME;
const originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
afterEach(() => {
if (originalWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
}
if (originalClineDir === undefined) {
delete process.env.CLINE_DIR;
} else {
process.env.CLINE_DIR = originalClineDir;
}
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalMcpSettingsPath === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
}
vi.restoreAllMocks();
});
it("maps remote MCP catalog args to the hub MCP upsert shape", () => {
expect(
buildMarketplaceMcpInput([
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
]),
).toEqual({
name: "context7",
transportType: "streamableHttp",
url: "https://mcp.context7.com/mcp",
disabled: false,
});
});
it("maps stdio MCP catalog args to command and args", () => {
expect(
buildMarketplaceMcpInput(["filesystem", "npx", "-y", "server", "/tmp"]),
).toEqual({
name: "filesystem",
transportType: "stdio",
command: "npx",
args: ["-y", "server", "/tmp"],
disabled: false,
});
});
it("preserves server flags after stdio MCP command args begin", () => {
expect(
buildMarketplaceMcpInput([
"search",
"npx",
"-y",
"server",
"--transport",
"stdio",
]),
).toEqual({
name: "search",
transportType: "stdio",
command: "npx",
args: ["-y", "server", "--transport", "stdio"],
disabled: false,
});
});
it("runs skills globally for Cline without prompts", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => {
mkdirSync(join(homeDir, ".agents", "skills", "web-design-guidelines"), {
recursive: true,
});
writeFileSync(
join(homeDir, ".agents", "skills", "web-design-guidelines", "SKILL.md"),
"---\nname: web-design-guidelines\n---\n",
);
return {
exitCode: 0,
stdout: "installed",
stderr: "",
};
});
await installMarketplaceEntry(
{
entry: {
id: "web-design-guidelines",
type: "skill",
name: "Web Design Guidelines",
install: {
args: [
"vercel-labs/agent-skills",
"--skill",
"web-design-guidelines",
],
},
},
},
{ spawnCommand },
);
expect(spawnCommand).toHaveBeenCalledWith("npx", [
"-y",
"skills@latest",
"add",
"vercel-labs/agent-skills",
"--skill",
"web-design-guidelines",
"-g",
"-a",
"cline",
"-y",
]);
});
it("skips skill install commands when the global skill already exists", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
mkdirSync(join(homeDir, ".agents", "skills", "cline-sdk"), {
recursive: true,
});
writeFileSync(
join(homeDir, ".agents", "skills", "cline-sdk", "SKILL.md"),
"---\nname: cline-sdk\n---\n",
);
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "installed",
message: "Cline SDK is already installed.",
});
expect(spawnCommand).not.toHaveBeenCalled();
});
it("reports Cline global skills as marketplace-installed", () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-cline-"));
process.env.CLINE_DIR = clineDir;
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
recursive: true,
});
writeFileSync(
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
"---\nname: cline-sdk\n---\n",
);
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
],
}),
).toEqual({ installedKeys: ["skill:cline-sdk"] });
});
it("accepts skill installs that create Cline global skills", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
const clineDir = join(homeDir, ".cline");
process.env.HOME = homeDir;
process.env.CLINE_DIR = clineDir;
const spawnCommand = vi.fn(async () => {
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
recursive: true,
});
writeFileSync(
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
"---\nname: cline-sdk\n---\n",
);
return {
exitCode: 0,
stdout: "installed",
stderr: "",
};
});
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "installed",
message: "Installed Cline SDK globally for Cline.",
});
});
it("removes Cline global marketplace skills without prompts", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const skillDir = join(homeDir, ".agents", "skills", "cline-sdk");
mkdirSync(skillDir, { recursive: true });
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: cline-sdk\n---\n");
const spawnCommand = vi.fn(async () => {
rmSync(skillDir, { recursive: true, force: true });
return {
exitCode: 0,
stdout: "removed",
stderr: "",
};
});
await expect(
uninstallMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Cline SDK.",
});
expect(spawnCommand).toHaveBeenCalledWith("npx", [
"-y",
"skills@latest",
"remove",
"cline-sdk",
"-g",
"-a",
"cline",
"-y",
]);
});
it("does not report project-local skills as marketplace-installed globals", () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
expect(
listMarketplaceInstalledEntries(
{
entries: [
{
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
],
},
{
skills: [
{
id: "cline-sdk",
name: "cline-sdk",
path: "/workspace/project/.agents/skills/cline-sdk/SKILL.md",
},
],
},
),
).toEqual({ installedKeys: [] });
});
it("rejects skill installs that exit zero but report failure", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "Failed to install 1",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).rejects.toThrow("Skill install failed");
});
it("redacts common secret formats from failed install output", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => ({
exitCode: 1,
stdout:
"Authorization: Bearer stdout-token\napi key stdout-key\nOPENAI_API_KEY=compound-key",
stderr:
"TOKEN=stderr-token\npassword is stderr-password\nANTHROPIC_SECRET_KEY=anthropic-secret",
}));
let message = "";
try {
await installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
);
} catch (error) {
message = error instanceof Error ? error.message : String(error);
}
expect(message).toContain("Authorization: [redacted]");
expect(message).toContain("api key [redacted]");
expect(message).toContain("OPENAI_API_KEY=[redacted]");
expect(message).toContain("TOKEN=[redacted]");
expect(message).toContain("password is [redacted]");
expect(message).toContain("ANTHROPIC_SECRET_KEY=[redacted]");
expect(message).not.toContain("stdout-token");
expect(message).not.toContain("stdout-key");
expect(message).not.toContain("compound-key");
expect(message).not.toContain("stderr-token");
expect(message).not.toContain("stderr-password");
expect(message).not.toContain("anthropic-secret");
});
it("rejects skill installs before spawning when the global skill directory is not writable", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
mkdirSync(join(homeDir, ".agents"), { recursive: true });
writeFileSync(join(homeDir, ".agents", "skills"), "");
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).rejects.toThrow(
"Cannot install skill globally because ~/.agents/skills is not writable",
);
expect(spawnCommand).not.toHaveBeenCalled();
});
it("rejects skill installs that do not create a global skill", async () => {
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
process.env.HOME = homeDir;
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "Installation complete",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "cline-sdk",
type: "skill",
name: "Cline SDK",
install: { args: ["cline/sdk-skill"] },
},
},
{ spawnCommand },
),
).rejects.toThrow("was not found in Cline's global skills directories");
});
it("runs official plugin installs through the current Cline CLI", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
stderr: "",
}));
await installMarketplaceEntry(
{
entry: {
id: "marketplace-test-plugin",
type: "plugin",
name: "Marketplace Test Plugin",
install: { args: ["marketplace-test-plugin"] },
},
},
{ spawnCommand },
);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"install",
"marketplace-test-plugin",
"--json",
]);
});
it("runs official plugin uninstalls through the current Cline CLI", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stderr: "",
}));
await expect(
uninstallMarketplaceEntry(
{
entry: {
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
},
{ spawnCommand },
),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Goal.",
});
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
});
it("resolves desktop installs from the server catalog instead of browser-sent args", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
stderr: "",
}));
await installMarketplaceEntryForDesktopCommand(
{
entry: {
id: "marketplace-test-plugin",
type: "plugin",
name: "Tampered",
install: { args: ["malicious-source"] },
},
},
{
spawnCommand,
loadCatalog: async () => ({
entries: [
{
id: "marketplace-test-plugin",
type: "plugin",
name: "Marketplace Test Plugin",
install: { args: ["marketplace-test-plugin"] },
},
],
}),
},
);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"install",
"marketplace-test-plugin",
"--json",
]);
});
it("resolves desktop uninstalls from the server catalog instead of browser-sent args", async () => {
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: JSON.stringify({
name: "goal",
installPath: "/tmp/plugin",
removedPaths: ["/tmp/plugin"],
entryPaths: [],
}),
stderr: "",
}));
await uninstallMarketplaceEntryForDesktopCommand(
{
entry: {
id: "goal",
type: "plugin",
name: "Tampered",
install: { args: ["malicious-source"] },
},
},
{
spawnCommand,
loadCatalog: async () => ({
entries: [
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
}),
},
);
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
"plugin",
"uninstall",
"goal",
"--json",
]);
});
it("uninstalls MCP marketplace entries from Cline MCP settings", async () => {
const settingsPath = join(
mkdtempSync(join(tmpdir(), "cline-marketplace-mcp-")),
"cline_mcp_settings.json",
);
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
context7: {
transport: {
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
},
},
},
},
null,
2,
),
);
await expect(
uninstallMarketplaceEntry({
entry: {
id: "context7",
type: "mcp",
name: "Context7",
install: {
args: [
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
],
},
},
}),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Context7.",
});
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "context7",
type: "mcp",
name: "Context7",
install: {
args: [
"context7",
"--transport",
"http",
"https://mcp.context7.com/mcp",
],
},
},
],
}),
).toEqual({ installedKeys: [] });
});
it("uninstalls local MCP servers by name", async () => {
const settingsPath = join(
mkdtempSync(join(tmpdir(), "cline-local-mcp-")),
"cline_mcp_settings.json",
);
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
writeFileSync(
settingsPath,
JSON.stringify(
{
mcpServers: {
context7: {
transport: {
type: "streamableHttp",
url: "https://mcp.context7.com/mcp",
},
},
},
},
null,
2,
),
);
await expect(
uninstallLocalPrimitive({
type: "mcp",
id: "context7",
name: "context7",
}),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled context7.",
});
expect(readFileSync(settingsPath, "utf8")).not.toContain("context7");
});
it("uninstalls local skills by removing their configured skill directory", async () => {
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-local-skill-"));
const skillDir = join(workspaceRoot, ".cline", "skills", "review");
mkdirSync(skillDir, { recursive: true });
const skillPath = join(skillDir, "SKILL.md");
writeFileSync(skillPath, "---\nname: review\n---\nReview changes.");
await expect(
uninstallLocalPrimitive(
{
type: "skill",
id: "review",
name: "Review",
path: skillPath,
},
{ workspaceRoot },
),
).resolves.toMatchObject({
status: "uninstalled",
message: "Uninstalled Review.",
});
expect(existsSync(skillDir)).toBe(false);
});
it("reports official plugin marketplace entries installed from Cline home", () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
process.env.CLINE_DIR = clineDir;
const sourceKey =
"official:https://github.com/cline/plugins.git#plugins/goal";
const hash = createHash("sha256")
.update(sourceKey)
.digest("hex")
.slice(0, 12);
mkdirSync(
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
{
recursive: true,
},
);
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
}),
).toEqual({ installedKeys: ["plugin:goal"] });
});
it("does not report plugin inventory substring matches as installed", () => {
process.env.CLINE_DIR = mkdtempSync(
join(tmpdir(), "cline-marketplace-test-"),
);
expect(
listMarketplaceInstalledEntries(
{
entries: [
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
},
{
plugins: [
{
name: "goal-helper",
path: "/workspace/.cline/plugins/goal-helper/index.ts",
},
],
},
),
).toEqual({ installedKeys: [] });
});
it("skips invalid marketplace entries during installed-status checks", () => {
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
process.env.CLINE_DIR = clineDir;
const sourceKey =
"official:https://github.com/cline/plugins.git#plugins/goal";
const hash = createHash("sha256")
.update(sourceKey)
.digest("hex")
.slice(0, 12);
mkdirSync(
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
{
recursive: true,
},
);
expect(
listMarketplaceInstalledEntries({
entries: [
{
id: "broken-mcp",
type: "mcp",
name: "Broken MCP",
install: {
args: [
"broken-mcp",
"--transport",
"ws",
"https://example.com/mcp",
],
},
},
{
id: "goal",
type: "plugin",
name: "Goal",
install: { args: ["goal"] },
},
],
}),
).toEqual({ installedKeys: ["plugin:goal"] });
});
it("rejects invalid marketplace entries before spawning commands", async () => {
const spawnCommand = vi.fn(async () => ({
exitCode: 0,
stdout: "",
stderr: "",
}));
await expect(
installMarketplaceEntry(
{
entry: {
id: "bad",
type: "skill",
install: { args: [] },
},
},
{ spawnCommand },
),
).rejects.toThrow("marketplace install args are required");
expect(spawnCommand).not.toHaveBeenCalled();
});
it("fetches the marketplace catalog through the server helper", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(JSON.stringify({ version: 1, entries: [] }), {
headers: { "content-type": "application/json" },
});
});
await expect(fetchMarketplaceCatalog(fetchImpl)).resolves.toEqual({
version: 1,
entries: [],
});
expect(fetchImpl).toHaveBeenCalledWith(
"https://cline.github.io/marketplace/catalog.json",
{ headers: { Accept: "application/json" } },
);
});
it("surfaces marketplace catalog upstream failures", async () => {
const fetchImpl = vi.fn(async () => {
return new Response("nope", {
status: 503,
statusText: "Service Unavailable",
});
});
await expect(fetchMarketplaceCatalog(fetchImpl)).rejects.toThrow(
"Failed to fetch marketplace catalog: 503 Service Unavailable",
);
});
});
File diff suppressed because it is too large Load Diff
@@ -235,6 +235,7 @@ export function toWebviewSessionSummary(
providerId: session.provider,
model: session.model,
workspaceRoot: session.workspaceRoot,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
@@ -0,0 +1,70 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { listUserInstructionConfigs } from "./user-instructions";
describe("listUserInstructionConfigs", () => {
const tempRoots: string[] = [];
const envSnapshot = {
CLINE_GLOBAL_SETTINGS_PATH: process.env.CLINE_GLOBAL_SETTINGS_PATH,
CLINE_MCP_SETTINGS_PATH: process.env.CLINE_MCP_SETTINGS_PATH,
};
afterEach(async () => {
if (envSnapshot.CLINE_GLOBAL_SETTINGS_PATH === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
}
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
}
await Promise.all(
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
);
tempRoots.length = 0;
});
it("uses the package name for package-backed plugin entries", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cline-hub-config-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(tempRoot, "settings.json");
process.env.CLINE_MCP_SETTINGS_PATH = join(tempRoot, "mcp.json");
const packageDir = join(
tempRoot,
".cline",
"plugins",
"_installed",
"git",
"github.com",
"demo",
"package",
);
await mkdir(packageDir, { recursive: true });
const pluginPath = join(packageDir, "index.ts");
await writeFile(
join(packageDir, "package.json"),
JSON.stringify(
{
name: "cline-sdk-portable-agents",
cline: {
plugins: [{ paths: ["./index.ts"] }],
},
},
null,
2,
),
);
await writeFile(pluginPath, "export default {};\n");
const data = await listUserInstructionConfigs(tempRoot);
const plugins = data.plugins as Array<{ name: string; path: string }>;
const plugin = plugins.find((item) => item.path === pluginPath);
expect(plugin?.name).toBe("cline-sdk-portable-agents");
});
});
+52 -2
View File
@@ -1,5 +1,13 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { extname, join, basename as pathBasename } from "node:path";
import {
dirname,
extname,
isAbsolute,
join,
basename as pathBasename,
relative,
resolve,
} from "node:path";
import {
createUserInstructionConfigService,
discoverPluginModulePaths,
@@ -17,6 +25,48 @@ function resolveAgentConfigSearchPaths(workspaceRoot?: string): string[] {
return resolveSharedAgentConfigSearchPaths(workspaceRoot);
}
function readPackageName(packageJsonPath: string): string | undefined {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
name?: unknown;
};
return typeof packageJson.name === "string" && packageJson.name.trim()
? packageJson.name.trim()
: undefined;
} catch {
return undefined;
}
}
function isPathWithin(parentPath: string, childPath: string): boolean {
const relativePath = relative(resolve(parentPath), resolve(childPath));
return (
relativePath === "" ||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
);
}
function getPluginDisplayName(filePath: string, searchRoot: string): string {
let current = dirname(filePath);
const root = resolve(searchRoot);
while (isPathWithin(root, current)) {
const packageJsonPath = join(current, "package.json");
if (existsSync(packageJsonPath)) {
const packageName = readPackageName(packageJsonPath);
if (packageName) {
return packageName;
}
break;
}
const parent = resolve(current, "..");
if (parent === current) {
break;
}
current = parent;
}
return pathBasename(filePath, extname(filePath));
}
export async function listUserInstructionConfigs(
targetWorkspaceRoot: string,
): Promise<JsonRecord> {
@@ -115,7 +165,7 @@ export async function listUserInstructionConfigs(
for (const filePath of discoverPluginModulePaths(directory)) {
if (pluginsByPath.has(filePath)) continue;
pluginsByPath.set(filePath, {
name: pathBasename(filePath, extname(filePath)),
name: getPluginDisplayName(filePath, directory),
path: filePath,
enabled: !disabledPlugins.has(filePath),
});
+1
View File
@@ -109,6 +109,7 @@ export type WebviewSessionSummary = {
providerId?: string;
model?: string;
workspaceRoot?: string;
createdAt?: number;
updatedAt?: number;
inputTokens?: number;
outputTokens?: number;
+2 -2
View File
@@ -2,9 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/cline-logo-filled.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webview</title>
<title>Cline Hub</title>
</head>
<body>
<div id="root"></div>
+1 -1
View File
@@ -10,7 +10,7 @@
},
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/schibsted-grotesk": "^5.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@rive-app/react-webgl2": "^4.27.2",
"@streamdown/cjk": "^1.0.3",
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 487.04">
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
<path d="M463.6,275.08l-29.26-58.75v-33.83c0-56.08-45.01-101.5-100.53-101.5h-50.01c3.62-7.43,5.61-15.79,5.61-24.61,0-31.17-25.08-56.39-56.07-56.39s-56.07,25.22-56.07,56.39c0,8.82,1.99,17.17,5.61,24.61h-50.01c-55.51,0-100.52,45.42-100.52,101.5v33.83l-29.87,58.59c-3.01,5.9-3.01,12.92,0,18.81l29.87,57.93v33.83c0,56.08,45.01,101.5,100.52,101.5h200.95c55.51,0,100.53-45.42,100.53-101.5v-33.83l29.21-58.13c2.9-5.79,2.9-12.61.05-18.46ZM202.75,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02ZM350.58,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02Z"/>
</svg>

After

Width:  |  Height:  |  Size: 957 B

File diff suppressed because it is too large Load Diff
@@ -32,7 +32,7 @@ function AccordionTrigger({
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
"group/accordion-trigger relative flex flex-1 cursor-pointer items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
className,
)}
{...props}
@@ -9,7 +9,7 @@ function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-lg border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
"peer relative flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className,
)}
{...props}
@@ -24,7 +24,10 @@ function ComboboxTrigger({
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("[&_svg:not([class*='size-'])]:size-4", className)}
className={cn(
"cursor-pointer data-disabled:cursor-not-allowed [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{children}
@@ -140,7 +143,7 @@ function ComboboxItem({
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex w-full cursor-pointer items-center gap-2 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground not-data-[variant=destructive]:data-highlighted:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -250,7 +253,7 @@ function ComboboxChip({
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" />}
className="-ml-1 opacity-50 hover:opacity-100"
className="-ml-1 cursor-pointer opacity-50 hover:opacity-100"
data-slot="combobox-chip-remove"
>
<XIcon className="pointer-events-none" />
@@ -152,7 +152,7 @@ function CommandItem({
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
"group/command-item relative flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none in-data-[slot=dialog-content]:rounded-lg! data-[disabled=true]:pointer-events-none data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className,
)}
{...props}
@@ -90,7 +90,7 @@ function DropdownMenuItem({
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
"group/dropdown-menu-item relative flex cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className,
)}
{...props}
@@ -115,7 +115,7 @@ function DropdownMenuSubTrigger({
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"flex cursor-pointer items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground data-disabled:cursor-not-allowed [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -164,7 +164,7 @@ function DropdownMenuCheckboxItem({
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
checked={checked}
@@ -205,7 +205,7 @@ function DropdownMenuRadioItem({
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -56,7 +56,7 @@ function MenubarTrigger({
<DropdownMenuTrigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-0.5 text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
"flex cursor-pointer items-center rounded-sm px-1.5 py-0.5 text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
className,
)}
{...props}
@@ -120,7 +120,7 @@ function MenubarCheckboxItem({
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className,
)}
checked={checked}
@@ -155,7 +155,7 @@ function MenubarRadioItem({
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative flex cursor-pointer items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -23,7 +23,7 @@ function NativeSelect({
<select
data-slot="native-select"
data-size={size}
className="h-full w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
className="h-full w-full min-w-0 cursor-pointer appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
{...props}
/>
<ChevronDownIcon
@@ -18,7 +18,7 @@ function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
<RadioPrimitive.Root
data-slot="radio-group-item"
className={cn(
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 cursor-pointer rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className,
)}
{...props}
@@ -38,7 +38,7 @@ function SelectTrigger({
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"flex w-fit cursor-pointer items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -117,7 +117,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"relative flex w-full cursor-pointer items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className,
)}
{...props}
@@ -157,7 +157,7 @@ function SelectScrollUpButton({
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
"top-0 z-10 flex w-full cursor-pointer items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -175,7 +175,7 @@ function SelectScrollDownButton({
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
"bottom-0 z-10 flex w-full cursor-pointer items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
@@ -16,7 +16,7 @@ function Switch({
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
"peer group/switch relative inline-flex cursor-pointer shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-8 data-[size=sm]:h-3.5 data-[size=sm]:w-6 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className,
)}
{...props}
@@ -58,7 +58,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative inline-flex h-[calc(100%-1px)] flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:cursor-not-allowed aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
@@ -0,0 +1,941 @@
import {
ExternalLink,
Puzzle,
Search,
Server,
Trash2,
Zap,
} from "lucide-react";
import {
type CSSProperties,
type MouseEvent,
type ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Spinner } from "@/components/ui/spinner";
import { desktopClient } from "@/lib/desktop-client";
import {
fetchMarketplaceCatalog,
type MarketplaceCatalog,
type MarketplaceEntry,
type MarketplacePrimitiveType,
type MarketplaceTag,
} from "@/lib/marketplace";
import { CommandBadge, PageFrame, PageHeader } from "./page-layout";
type EntryActionState =
| { status: "idle" }
| { status: "installing" }
| { status: "uninstalling" }
| {
status: "installed";
message: string;
}
| {
status: "uninstalled";
message: string;
}
| { status: "failed"; message: string };
type MarketplaceInstallResult = {
status: "installed" | "uninstalled";
message: string;
output?: string;
};
type MarketplaceInstallStatusResult = {
installedKeys: string[];
};
type InstalledStatusState = "loading" | "ready";
const INSTALL_TIMEOUT_MS = 300_000;
const CODE_FONT_STYLE: CSSProperties = {
fontFamily:
'ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace',
};
const primitivePageDetails = {
mcp: {
title: "MCP Servers",
description:
"Install Model Context Protocol servers into this CLI environment.",
emptyInstalled: "No MCP servers installed.",
emptyCatalog: "No MCP servers match the current filters.",
icon: Server,
},
skill: {
title: "Skills",
description: "Install skills globally for Cline.",
emptyInstalled: "No skills installed.",
emptyCatalog: "No skills match the current filters.",
icon: Zap,
},
plugin: {
title: "Plugins",
description: "Install plugins into this CLI environment.",
emptyInstalled: "No plugins installed.",
emptyCatalog: "No plugins match the current filters.",
icon: Puzzle,
},
} satisfies Record<
MarketplacePrimitiveType,
{
title: string;
description: string;
emptyInstalled: string;
emptyCatalog: string;
icon: typeof Server;
}
>;
const primitiveCommands = {
mcp: "cline mcp install",
plugin: "cline plugin install",
skill: "cline skill add",
} satisfies Record<MarketplacePrimitiveType, string>;
export type MarketplaceLocalInstalledItem = {
key: string;
matchValues: string[];
render: () => ReactNode;
renderMatchedBadges?: () => ReactNode;
renderMatchedControls?: () => ReactNode;
renderMatchedDetails?: () => ReactNode;
renderMatchedMeta?: () => ReactNode;
};
function entryKey(entry: Pick<MarketplaceEntry, "id" | "type">): string {
return `${entry.type}:${entry.id}`;
}
function normalizeMatchValue(value: string): string {
return value.trim().toLowerCase();
}
function entryMatchValues(entry: MarketplaceEntry): Set<string> {
return new Set(
[entry.id, entry.name, ...entry.install.args]
.map(normalizeMatchValue)
.filter(Boolean),
);
}
function entryMatchesLocalItem(
entry: MarketplaceEntry,
item: MarketplaceLocalInstalledItem,
): boolean {
const entryValues = entryMatchValues(entry);
return item.matchValues
.map(normalizeMatchValue)
.filter(Boolean)
.some((value) => entryValues.has(value));
}
function entrySearchText(
entry: MarketplaceEntry,
tagLabels: Map<string, string>,
): string {
return [
entry.name,
entry.tagline,
entry.description,
entry.type,
...entry.tags.map((tag) => tagLabels.get(tag) ?? tag),
]
.join(" ")
.toLowerCase();
}
function actionMessage(
state: EntryActionState | undefined,
): string | undefined {
if (
state?.status === "installed" ||
state?.status === "uninstalled" ||
state?.status === "failed"
) {
return state.message;
}
return undefined;
}
function EntryDetails({
actionState,
entry,
}: {
actionState: EntryActionState | undefined;
entry: MarketplaceEntry;
}) {
const requiredEnv =
entry.install.env?.filter((env) => env.required !== false) ?? [];
const optionalEnv =
entry.install.env?.filter((env) => env.required === false) ?? [];
const hasSetupDetails =
requiredEnv.length > 0 ||
optionalEnv.length > 0 ||
Boolean(entry.install.notes) ||
actionState?.status === "failed";
if (!hasSetupDetails) {
return null;
}
return (
<div className="grid gap-3 border-t pt-3" data-marketplace-entry-details>
{requiredEnv.length > 0 || optionalEnv.length > 0 ? (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-3">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
Environment setup needed
</p>
<p className="mt-1 text-xs leading-5 text-amber-800/80 dark:text-amber-100/80">
Add these values to your Cline/plugin environment after install.
</p>
<div className="mt-3 grid gap-2">
{[...requiredEnv, ...optionalEnv].map((env) => (
<div
key={env.name}
className="rounded-md border border-amber-500/20 bg-background/60 p-2"
>
<div className="flex items-center justify-between gap-2">
<code className="font-mono text-xs font-semibold">
<span style={CODE_FONT_STYLE}>{env.name}</span>
</code>
<Badge variant="outline">
{env.required === false ? "Optional" : "Required"}
</Badge>
</div>
{env.description ? (
<p className="mt-1 text-xs text-muted-foreground">
{env.description}
</p>
) : null}
{env.url ? (
<a
className="mt-1 inline-flex items-center gap-1 text-xs text-primary hover:underline"
href={env.url}
rel="noreferrer"
target="_blank"
>
Get value
<ExternalLink className="size-3" />
</a>
) : null}
</div>
))}
</div>
</div>
) : null}
{entry.install.notes ? (
<p className="rounded-lg border bg-muted/30 p-3 text-xs leading-5 text-muted-foreground">
{entry.install.notes}
</p>
) : null}
{actionState?.status === "failed" ? (
<div className="max-h-44 overflow-auto rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
{actionState.message}
</div>
) : null}
</div>
);
}
function MarketplaceEntryCard({
actionState,
entry,
expanded,
installed,
installedStatusReady,
onInstall,
onToggleExpanded,
onUninstall,
matchedLocalItems = [],
sourceLabel,
tagLabels,
}: {
actionState: EntryActionState | undefined;
entry: MarketplaceEntry;
expanded: boolean;
installed: boolean;
installedStatusReady: boolean;
onInstall: (entry: MarketplaceEntry) => void;
onToggleExpanded: (entry: MarketplaceEntry) => void;
onUninstall: (entry: MarketplaceEntry) => void;
matchedLocalItems?: MarketplaceLocalInstalledItem[];
sourceLabel?: string;
tagLabels: Map<string, string>;
}) {
const EntryIcon = primitivePageDetails[entry.type].icon;
const busy =
actionState?.status === "installing" ||
actionState?.status === "uninstalling";
const setupNeeded = Boolean(entry.install.env?.length);
const hasExpandableDetails =
setupNeeded ||
Boolean(entry.install.notes) ||
actionState?.status === "failed";
const inlineMessage = actionMessage(actionState);
const handleActionClick = (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation();
if (installed) {
onUninstall(entry);
return;
}
onInstall(entry);
};
const actionLabel = !installedStatusReady
? "Checking..."
: actionState?.status === "installing"
? "Installing..."
: actionState?.status === "uninstalling"
? "Uninstalling..."
: installed
? "Uninstall"
: "Install";
const content = (
<>
<div className="min-w-0">
<div className="flex min-w-0 items-start justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center gap-3">
<EntryIcon className="h-4 w-4 shrink-0 text-primary" />
<h2 className="min-w-0 flex-1 truncate text-sm font-semibold text-foreground">
{entry.name}
</h2>
</div>
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{sourceLabel ? (
<Badge
variant="outline"
className="shrink-0 text-muted-foreground"
>
{sourceLabel}
</Badge>
) : null}
{matchedLocalItems.map((item) =>
item.renderMatchedBadges ? (
<span className="contents" key={`${item.key}:badges`}>
{item.renderMatchedBadges()}
</span>
) : null,
)}
{matchedLocalItems.map((item) =>
item.renderMatchedControls ? (
<span
className="contents"
data-marketplace-entry-interactive
key={`${item.key}:controls`}
>
{item.renderMatchedControls()}
</span>
) : null,
)}
</div>
</div>
{matchedLocalItems.some((item) => item.renderMatchedMeta) ? (
<div className="mt-1 grid gap-1">
{matchedLocalItems.map((item) =>
item.renderMatchedMeta ? (
<div key={`${item.key}:meta`}>{item.renderMatchedMeta()}</div>
) : null,
)}
</div>
) : null}
</div>
<p className="line-clamp-2 text-xs leading-5 text-muted-foreground">
{entry.description}
</p>
{entry.tags.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{entry.tags.slice(0, 5).map((tag) => (
<Badge
key={tag}
variant="outline"
className="max-w-full text-muted-foreground"
>
<span className="truncate">{tagLabels.get(tag) ?? tag}</span>
</Badge>
))}
</div>
) : null}
{matchedLocalItems.some((item) => item.renderMatchedDetails) ? (
<div className="grid gap-2" data-marketplace-entry-details>
{matchedLocalItems.map((item) =>
item.renderMatchedDetails ? (
<div key={item.key}>{item.renderMatchedDetails()}</div>
) : null,
)}
</div>
) : null}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-h-5 text-xs text-muted-foreground">
{inlineMessage ? (
<output
className={
actionState?.status === "failed"
? "text-destructive"
: "text-muted-foreground"
}
>
{inlineMessage}
</output>
) : setupNeeded ? (
<span className="text-amber-700 dark:text-amber-300">
Requires setup after install
</span>
) : null}
</div>
<Button
disabled={!installedStatusReady || busy}
onClick={handleActionClick}
type="button"
variant={installed ? "destructive" : "default"}
>
{busy || !installedStatusReady ? <Spinner /> : null}
{installed && !busy ? <Trash2 className="size-4" /> : null}
{actionLabel}
</Button>
</div>
{expanded && hasExpandableDetails ? (
<EntryDetails actionState={actionState} entry={entry} />
) : null}
</>
);
if (!hasExpandableDetails) {
return (
<div className="grid gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20">
{content}
</div>
);
}
return (
// biome-ignore lint/a11y/useSemanticElements: The card contains a nested action button, so the wrapper cannot be a native button.
<div
aria-expanded={expanded}
aria-label={`${expanded ? "Collapse" : "Expand"} ${entry.name}`}
className="grid cursor-pointer gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:bg-accent/20 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
onClick={(event) => {
if (
event.target instanceof HTMLElement &&
event.target.closest(
"[data-marketplace-entry-details], [data-marketplace-entry-interactive]",
)
) {
return;
}
onToggleExpanded(entry);
}}
onKeyDown={(event) => {
if (event.target !== event.currentTarget) {
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onToggleExpanded(entry);
}
}}
role="button"
tabIndex={0}
>
{content}
</div>
);
}
function TagButton({
active,
count,
onClick,
tag,
}: {
active: boolean;
count: number;
onClick: () => void;
tag: MarketplaceTag;
}) {
return (
<Button
aria-pressed={active}
onClick={onClick}
size="sm"
type="button"
variant={active ? "default" : "outline"}
>
<span className="truncate">{tag.label}</span>
<span className="rounded bg-background/30 px-1.5 py-0.5 text-xs">
{count}
</span>
</Button>
);
}
function MarketplaceSection({
actionStates,
emptyMessage,
entries,
expandedEntryKey,
installedEntryKeys,
installedStatusReady,
localOnlyInstalledItems = [],
matchedLocalItemsByEntryKey,
onInstall,
onToggleExpanded,
onUninstall,
sourceLabel,
tagLabels,
title,
}: {
actionStates: Map<string, EntryActionState>;
emptyMessage: string;
entries: MarketplaceEntry[];
expandedEntryKey: string | null;
installedEntryKeys: Set<string>;
installedStatusReady: boolean;
localOnlyInstalledItems?: MarketplaceLocalInstalledItem[];
matchedLocalItemsByEntryKey?: Map<string, MarketplaceLocalInstalledItem[]>;
onInstall: (entry: MarketplaceEntry) => void;
onToggleExpanded: (entry: MarketplaceEntry) => void;
onUninstall: (entry: MarketplaceEntry) => void;
sourceLabel?: string;
tagLabels: Map<string, string>;
title: string;
}) {
const totalCount = entries.length + localOnlyInstalledItems.length;
return (
<section className="grid gap-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-foreground">{title}</h2>
<span className="text-sm text-muted-foreground">{totalCount}</span>
</div>
{totalCount > 0 ? (
<div className="grid gap-3">
{localOnlyInstalledItems.map((item) => item.render())}
{entries.map((entry) => {
const key = entryKey(entry);
return (
<MarketplaceEntryCard
actionState={actionStates.get(key)}
entry={entry}
expanded={expandedEntryKey === key}
installed={installedEntryKeys.has(key)}
installedStatusReady={installedStatusReady}
key={key}
onInstall={onInstall}
onToggleExpanded={onToggleExpanded}
onUninstall={onUninstall}
matchedLocalItems={matchedLocalItemsByEntryKey?.get(key) ?? []}
sourceLabel={sourceLabel}
tagLabels={tagLabels}
/>
);
})}
</div>
) : (
<div className="rounded-lg border border-dashed bg-card p-6 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
)}
</section>
);
}
export function MarketplaceView({
chrome = "page",
installedItems,
onInstalledItemsChanged,
primitive,
}: {
chrome?: "page" | "embedded";
installedItems?: MarketplaceLocalInstalledItem[];
onInstalledItemsChanged?: () => void | Promise<void>;
primitive: MarketplacePrimitiveType;
}) {
const [catalog, setCatalog] = useState<MarketplaceCatalog | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [query, setQuery] = useState("");
const [selectedTag, setSelectedTag] = useState<string | null>(null);
const [expandedEntryKey, setExpandedEntryKey] = useState<string | null>(null);
const [installedEntryKeys, setInstalledEntryKeys] = useState<Set<string>>(
() => new Set(),
);
const [actionStates, setActionStates] = useState<
Map<string, EntryActionState>
>(() => new Map());
const [installedStatusState, setInstalledStatusState] =
useState<InstalledStatusState>("loading");
useEffect(() => {
let cancelled = false;
void (async () => {
try {
if (!cancelled) {
setInstalledStatusState("loading");
}
const nextCatalog = await fetchMarketplaceCatalog();
if (!cancelled) {
setCatalog(nextCatalog);
setErrorMessage(null);
}
try {
const response =
await desktopClient.invoke<MarketplaceInstallStatusResult>(
"list_marketplace_installed_entries",
{ entries: nextCatalog.entries },
);
if (!cancelled) {
setInstalledEntryKeys(new Set(response.installedKeys));
setInstalledStatusState("ready");
}
} catch {
if (!cancelled) {
setInstalledStatusState("ready");
}
}
} catch (error) {
if (!cancelled) {
setErrorMessage(
error instanceof Error ? error.message : String(error),
);
setInstalledStatusState("ready");
}
}
})();
return () => {
cancelled = true;
};
}, []);
const pageDetails = primitivePageDetails[primitive];
const PageIcon = pageDetails.icon;
const tagLabels = useMemo(
() => new Map(catalog?.tags.map((tag) => [tag.id, tag.label]) ?? []),
[catalog?.tags],
);
const primitiveEntries = useMemo(
() => catalog?.entries.filter((entry) => entry.type === primitive) ?? [],
[catalog?.entries, primitive],
);
const tagCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const entry of primitiveEntries) {
for (const tag of entry.tags) {
counts.set(tag, (counts.get(tag) ?? 0) + 1);
}
}
return counts;
}, [primitiveEntries]);
const primitiveTags = useMemo(
() =>
(catalog?.tags ?? []).filter((tag) => (tagCounts.get(tag.id) ?? 0) > 0),
[catalog?.tags, tagCounts],
);
const filteredEntries = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return primitiveEntries.filter((entry) => {
const matchesTag = !selectedTag || entry.tags.includes(selectedTag);
const matchesQuery =
normalizedQuery.length === 0 ||
entrySearchText(entry, tagLabels).includes(normalizedQuery);
return matchesTag && matchesQuery;
});
}, [primitiveEntries, query, selectedTag, tagLabels]);
const matchedLocalItemsByEntryKey = useMemo(() => {
const matched = new Map<string, MarketplaceLocalInstalledItem[]>();
for (const item of installedItems ?? []) {
for (const entry of filteredEntries) {
const key = entryKey(entry);
if (
!installedEntryKeys.has(key) ||
!entryMatchesLocalItem(entry, item)
) {
continue;
}
const items = matched.get(key) ?? [];
items.push(item);
matched.set(key, items);
}
}
return matched;
}, [filteredEntries, installedEntryKeys, installedItems]);
const matchedLocalItemKeys = useMemo(
() =>
new Set(
[...matchedLocalItemsByEntryKey.values()].flatMap((items) =>
items.map((item) => item.key),
),
),
[matchedLocalItemsByEntryKey],
);
const localOnlyInstalledItems = useMemo(
() =>
(installedItems ?? []).filter(
(item) => !matchedLocalItemKeys.has(item.key),
),
[installedItems, matchedLocalItemKeys],
);
const installedEntries = useMemo(
() =>
filteredEntries.filter((entry) =>
installedEntryKeys.has(entryKey(entry)),
),
[filteredEntries, installedEntryKeys],
);
const catalogEntries = useMemo(
() =>
filteredEntries.filter(
(entry) => !installedEntryKeys.has(entryKey(entry)),
),
[filteredEntries, installedEntryKeys],
);
const activeFilters = query.trim().length > 0 || selectedTag !== null;
const installedStatusReady = installedStatusState === "ready";
const clearFilters = () => {
setQuery("");
setSelectedTag(null);
};
const setEntryState = (entry: MarketplaceEntry, state: EntryActionState) => {
const key = entryKey(entry);
setActionStates((current) => {
const next = new Map(current);
next.set(key, state);
return next;
});
};
const markEntryInstalled = (entry: MarketplaceEntry) => {
setInstalledEntryKeys((current) => new Set(current).add(entryKey(entry)));
};
const markEntryUninstalled = (entry: MarketplaceEntry) => {
setInstalledEntryKeys((current) => {
const next = new Set(current);
next.delete(entryKey(entry));
return next;
});
};
const toggleExpanded = (entry: MarketplaceEntry) => {
const key = entryKey(entry);
setExpandedEntryKey((current) => (current === key ? null : key));
};
const installEntry = async (entry: MarketplaceEntry) => {
const key = entryKey(entry);
const currentState = actionStates.get(key);
if (
currentState?.status === "installing" ||
currentState?.status === "uninstalling"
) {
return;
}
setExpandedEntryKey(key);
setEntryState(entry, { status: "installing" });
try {
const result = await desktopClient.invoke<MarketplaceInstallResult>(
"install_marketplace_entry",
{ entry },
{ timeoutMs: INSTALL_TIMEOUT_MS },
);
setEntryState(entry, {
status: "installed",
message: result.message,
});
markEntryInstalled(entry);
await onInstalledItemsChanged?.();
} catch (error) {
setEntryState(entry, {
status: "failed",
message: error instanceof Error ? error.message : String(error),
});
}
};
const uninstallEntry = async (entry: MarketplaceEntry) => {
const key = entryKey(entry);
const currentState = actionStates.get(key);
if (
currentState?.status === "installing" ||
currentState?.status === "uninstalling"
) {
return;
}
setExpandedEntryKey(key);
setEntryState(entry, { status: "uninstalling" });
try {
const result = await desktopClient.invoke<MarketplaceInstallResult>(
"uninstall_marketplace_entry",
{ entry },
{ timeoutMs: INSTALL_TIMEOUT_MS },
);
setEntryState(entry, {
status: "uninstalled",
message: result.message,
});
markEntryUninstalled(entry);
await onInstalledItemsChanged?.();
} catch (error) {
setEntryState(entry, {
status: "failed",
message: error instanceof Error ? error.message : String(error),
});
}
};
const content = (
<div className="grid gap-6">
{chrome === "page" ? (
<PageHeader
description={pageDetails.description}
icon={PageIcon}
title={pageDetails.title}
meta={<CommandBadge>{primitiveCommands[primitive]}</CommandBadge>}
actions={
catalog?.generatedAt ? (
<p className="text-xs text-muted-foreground">
Updated{" "}
{new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
year: "numeric",
}).format(new Date(catalog.generatedAt))}
</p>
) : null
}
/>
) : null}
{!catalog && !errorMessage ? (
<div className="flex min-h-80 items-center justify-center rounded-lg border bg-card text-sm text-muted-foreground">
<Spinner className="mr-2" />
Loading marketplace...
</div>
) : null}
{catalog && !installedStatusReady ? (
<div className="flex min-h-80 items-center justify-center rounded-lg border bg-card text-sm text-muted-foreground">
<Spinner className="mr-2" />
Checking installed status...
</div>
) : null}
{errorMessage ? (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive">
{errorMessage}
</div>
) : null}
{catalog && installedStatusReady ? (
<div className="grid gap-6">
<div className="grid gap-3">
<div className="flex flex-col gap-3 md:flex-row md:items-center">
<div className="relative block flex-1">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label={`Search ${pageDetails.title}`}
className="h-10 pl-8"
onChange={(event) => setQuery(event.target.value)}
placeholder={`Search ${pageDetails.title.toLowerCase()}`}
value={query}
/>
</div>
<div className="flex min-h-8 items-center gap-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{filteredEntries.length}
</span>
<span>
{filteredEntries.length === 1 ? "result" : "results"}
</span>
{activeFilters ? (
<Button
onClick={clearFilters}
size="sm"
type="button"
variant="ghost"
>
Clear filters
</Button>
) : null}
</div>
</div>
{primitiveTags.length > 0 ? (
<div className="flex gap-2 overflow-x-auto pb-1">
{primitiveTags.map((tag) => (
<TagButton
active={selectedTag === tag.id}
count={tagCounts.get(tag.id) ?? 0}
key={tag.id}
onClick={() =>
setSelectedTag((current) =>
current === tag.id ? null : tag.id,
)
}
tag={tag}
/>
))}
</div>
) : null}
</div>
<MarketplaceSection
actionStates={actionStates}
emptyMessage={pageDetails.emptyInstalled}
entries={installedEntries}
expandedEntryKey={expandedEntryKey}
installedEntryKeys={installedEntryKeys}
installedStatusReady={installedStatusReady}
localOnlyInstalledItems={localOnlyInstalledItems}
matchedLocalItemsByEntryKey={matchedLocalItemsByEntryKey}
onInstall={installEntry}
onToggleExpanded={toggleExpanded}
onUninstall={uninstallEntry}
sourceLabel="Marketplace"
tagLabels={tagLabels}
title="Installed"
/>
<MarketplaceSection
actionStates={actionStates}
emptyMessage={pageDetails.emptyCatalog}
entries={catalogEntries}
expandedEntryKey={expandedEntryKey}
installedEntryKeys={installedEntryKeys}
installedStatusReady={installedStatusReady}
onInstall={installEntry}
onToggleExpanded={toggleExpanded}
onUninstall={uninstallEntry}
tagLabels={tagLabels}
title="Marketplace"
/>
</div>
) : null}
</div>
);
return chrome === "embedded" ? content : <PageFrame>{content}</PageFrame>;
}
@@ -0,0 +1,111 @@
import type { ComponentType, ReactNode } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
type PageFrameProps = {
children: ReactNode;
className?: string;
contentClassName?: string;
};
export function PageFrame({
children,
className,
contentClassName,
}: PageFrameProps) {
return (
<ScrollArea className="h-full">
<div
className={cn(
"px-18 py-10 max-[1200px]:px-8 max-[720px]:px-4 max-[720px]:py-5",
className,
)}
>
<div className={cn("max-w-[86rem]", contentClassName)}>{children}</div>
</div>
</ScrollArea>
);
}
type PageHeaderProps = {
actions?: ReactNode;
className?: string;
description?: ReactNode;
icon?: ComponentType<{ className?: string }>;
meta?: ReactNode;
title: ReactNode;
};
export function PageHeader({
actions,
className,
description,
icon: Icon,
meta,
title,
}: PageHeaderProps) {
return (
<section
className={cn(
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
className,
)}
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-3">
{Icon ? <Icon className="size-8 shrink-0 text-primary" /> : null}
<h1 className="truncate text-[32px] font-semibold leading-[1.15] tracking-normal text-foreground">
{title}
</h1>
{meta}
</div>
{description ? (
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-muted-foreground">
{description}
</p>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 max-[860px]:justify-start">
{actions}
</div>
) : null}
</section>
);
}
type PageEmptyStateProps = {
children: ReactNode;
className?: string;
};
export function PageEmptyState({ children, className }: PageEmptyStateProps) {
return (
<div
className={cn(
"rounded-lg border border-dashed border-border bg-card px-5 py-4 text-sm leading-6 text-muted-foreground",
className,
)}
>
{children}
</div>
);
}
type CommandBadgeProps = {
children: ReactNode;
className?: string;
};
export function CommandBadge({ children, className }: CommandBadgeProps) {
return (
<span
className={cn(
"rounded-md border border-border bg-background px-2 py-0.5 font-mono text-xs text-muted-foreground",
className,
)}
>
{children}
</span>
);
}
@@ -15,15 +15,18 @@ import {
CreditCard,
ExternalLink,
Loader2,
LogIn,
LogOut,
Plus,
Receipt,
RefreshCw,
UserCircleIcon,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Button } from "@/components/ui/button";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
function normalizeAccountViewError(error: unknown): Error {
const message = error instanceof Error ? error.message : String(error);
@@ -35,6 +38,16 @@ function normalizeAccountViewError(error: unknown): Error {
return error instanceof Error ? error : new Error(message);
}
function isAccountAuthError(message: string): boolean {
const normalized = message.toLowerCase();
return (
normalized.includes("no cline account auth token found") ||
normalized.includes("requires re-authentication") ||
normalized.includes("auth token") ||
normalized.includes("unauthorized")
);
}
// ---------------------------------------------------------------------------
// Data fetching helpers via sidecar command
// ---------------------------------------------------------------------------
@@ -125,6 +138,9 @@ export function AccountView() {
const [activeTab, setActiveTab] = useState<"overview" | "usage" | "billing">(
"overview",
);
const [accountActionPending, setAccountActionPending] = useState<
"sign-in" | "sign-out" | null
>(null);
// Overview data
const [user, setUser] = useState<ClineAccountUser | null>(null);
@@ -155,6 +171,19 @@ export function AccountView() {
const [billingLoaded, setBillingLoaded] = useState(false);
const activeOrganization = organizations.find((org) => org.active) ?? null;
const resetAccountData = useCallback(() => {
setUser(null);
setBalance(null);
setOrganizationBalance(null);
setOrganizations([]);
setUsageTransactions([]);
setUsageLoaded(false);
setUsageError(null);
setPaymentTransactions([]);
setBillingLoaded(false);
setBillingError(null);
}, []);
// -- Overview fetch --
const loadOverview = useCallback(async () => {
setOverviewLoading(true);
@@ -175,17 +204,61 @@ export function AccountView() {
setOrganizationBalance(organizationBalanceData);
setOrganizations(orgsData);
} catch (err) {
resetAccountData();
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
} finally {
setOverviewLoading(false);
}
}, []);
}, [resetAccountData]);
useEffect(() => {
void loadOverview();
}, [loadOverview]);
const signIn = async () => {
setAccountActionPending("sign-in");
setOverviewError(null);
try {
await desktopClient.invoke("run_provider_oauth_login", {
provider: "cline",
});
await loadOverview();
setActiveTab("overview");
} catch (err) {
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
resetAccountData();
} finally {
setAccountActionPending(null);
}
};
const signOut = async () => {
setAccountActionPending("sign-out");
try {
await desktopClient.invoke("save_provider_settings", {
provider: "cline",
api_key: "",
settings: {
auth: {
accessToken: "",
refreshToken: "",
accountId: "",
},
},
});
resetAccountData();
setActiveTab("overview");
setOverviewError("No Cline account auth token found");
} catch (err) {
const message = normalizeAccountViewError(err).message;
setOverviewError(message);
} finally {
setAccountActionPending(null);
}
};
// -- Usage fetch (lazy on tab switch) --
const loadUsage = useCallback(async () => {
const generation = usageGenerationRef.current;
@@ -295,6 +368,48 @@ export function AccountView() {
</div>
);
const renderSignedOut = () => (
<div className="rounded-lg border border-border bg-card p-6">
<div className="mx-auto flex max-w-xl flex-col items-center gap-4 py-8 text-center">
<div className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
<UserCircleIcon className="h-6 w-6" />
</div>
<div>
<h3 className="text-lg font-semibold text-foreground">
Sign in to Cline
</h3>
<p className="mt-2 text-sm text-muted-foreground">
Connect your Cline account to review credits, usage, billing, and
organization details from Cline Hub.
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-2">
<Button
disabled={accountActionPending !== null}
onClick={() => void signIn()}
type="button"
>
{accountActionPending === "sign-in" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogIn className="h-4 w-4" />
)}
{accountActionPending === "sign-in" ? "Signing in" : "Sign in"}
</Button>
<a
className="inline-flex h-9 items-center gap-2 rounded-lg border border-border px-3.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
href="https://app.cline.bot"
rel="noopener noreferrer"
target="_blank"
>
Create account
<ExternalLink className="h-4 w-4" />
</a>
</div>
</div>
</div>
);
const renderLoading = () => (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
@@ -302,24 +417,19 @@ export function AccountView() {
);
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
{/* Header */}
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">Account</h2>
<button
type="button"
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<LogOut className="h-4 w-4" />
Sign Out
</button>
</div>
<PageFrame>
<PageHeader
description="Review account, usage, billing, and organization details."
title="Account"
/>
{/* Tabs */}
<div className="mb-6 flex items-center gap-0 border-b border-border">
{tabs.map((tab) => (
{/* Tabs */}
<div className="mb-6 flex items-center gap-0 border-b border-border">
{tabs.map((tab) => {
const disabled = !user && tab !== "overview";
return (
<button
disabled={disabled}
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
@@ -328,6 +438,8 @@ export function AccountView() {
activeTab === tab
? "text-foreground"
: "text-muted-foreground hover:text-foreground",
disabled &&
"cursor-not-allowed opacity-45 hover:text-muted-foreground",
)}
>
{tab}
@@ -335,243 +447,265 @@ export function AccountView() {
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
)}
</button>
))}
</div>
);
})}
</div>
{/* Overview Tab */}
{activeTab === "overview" && (
<div className="flex flex-col gap-6">
{overviewLoading && renderLoading()}
{overviewError && renderError(overviewError, loadOverview)}
{!overviewLoading && !overviewError && user && (
<>
{/* User Profile Card */}
<div className="rounded-lg border border-border p-5">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary/20 text-2xl font-bold text-primary">
{user.displayName?.charAt(0) ??
user.email?.charAt(0) ??
"?"}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-foreground">
{user.displayName || user.email}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{user.email}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Member since {formatDate(user.createdAt)}
</p>
</div>
{/* Overview Tab */}
{activeTab === "overview" && (
<div className="flex flex-col gap-6">
{overviewLoading && renderLoading()}
{overviewError &&
(isAccountAuthError(overviewError)
? renderSignedOut()
: renderError(overviewError, loadOverview))}
{!overviewLoading && !overviewError && user && (
<>
{/* User Profile Card */}
<div className="rounded-lg border border-border p-5">
<div className="flex items-start gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary/20 text-2xl font-bold text-primary">
{user.displayName?.charAt(0) ??
user.email?.charAt(0) ??
"?"}
</div>
<div className="min-w-0 flex-1">
<h3 className="text-base font-semibold text-foreground">
{user.displayName || user.email}
</h3>
<p className="mt-0.5 text-sm text-muted-foreground">
{user.email}
</p>
<p className="mt-2 text-xs text-muted-foreground">
Member since {formatDate(user.createdAt)}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<a
href="https://app.cline.bot/dashboard"
target="_blank"
rel="noopener noreferrer"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ExternalLink className="h-4 w-4" />
Open dashboard
<ExternalLink className="h-3.5 w-3.5" />
</a>
<Button
className="h-8 rounded-md px-2.5 text-xs"
disabled={accountActionPending !== null}
onClick={() => void signOut()}
type="button"
variant="outline"
>
{accountActionPending === "sign-out" ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<LogOut className="h-3.5 w-3.5" />
)}
{accountActionPending === "sign-out"
? "Signing out"
: "Sign out"}
</Button>
</div>
</div>
</div>
{/* Balance Card */}
{displayedBalance !== null && (
<div className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<CreditCard className="h-5 w-5 text-primary" />
<h3 className="text-sm font-semibold text-foreground">
{activeOrganization
? `${activeOrganization.name} Balance`
: "Credits Balance"}
</h3>
</div>
<a
href="https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Credit
</a>
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">
${formatCreditBalance(displayedBalance)}
</span>
</div>
{activeOrganization && balance && (
<p className="mt-2 text-xs text-muted-foreground">
Personal account: {formatCreditBalance(balance.balance)}{" "}
credits
</p>
)}
</div>
)}
{/* Organizations */}
{/* Balance Card */}
{displayedBalance !== null && (
<div className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Building className="h-5 w-5 text-muted-foreground" />
<CreditCard className="h-5 w-5 text-primary" />
<h3 className="text-sm font-semibold text-foreground">
Organizations
{activeOrganization
? `${activeOrganization.name} Balance`
: "Credits Balance"}
</h3>
</div>
<a
href="https://app.cline.bot/onboarding?step=1"
href="https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Create
Credit
</a>
</div>
{organizations.length === 0 ? (
<p className="text-sm text-muted-foreground">
No organizations yet.
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold text-foreground">
${formatCreditBalance(displayedBalance)}
</span>
</div>
{activeOrganization && balance && (
<p className="mt-2 text-xs text-muted-foreground">
Personal account: {formatCreditBalance(balance.balance)}{" "}
credits
</p>
) : (
<div className="flex flex-col gap-2">
{organizations.map((org) => (
<div
key={org.organizationId}
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 transition-colors hover:bg-accent/20"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-foreground">
{org.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
{org.name}
</p>
<p className="text-xs text-muted-foreground capitalize">
{org.roles.join(", ")}
</p>
</div>
{org.active && (
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
Active
</span>
)}
</div>
))}
</div>
)}
</div>
</>
)}
</div>
)}
)}
{/* Usage Tab */}
{activeTab === "usage" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
{activeOrganization
? `Recent API usage and token consumption for ${activeOrganization.name}.`
: "Recent API usage and token consumption across all providers."}
</p>
{usageLoading && renderLoading()}
{usageError && renderError(usageError, loadUsage)}
{!usageLoading &&
!usageError &&
usageLoaded &&
(usageTransactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No usage transactions yet.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Model</span>
<span className="text-right">Tokens</span>
<span className="text-right">Credits</span>
<span className="text-right">Time</span>
</div>
<div className="divide-y divide-border">
{usageTransactions.map((tx) => (
<div
key={tx.id}
className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{tx.aiModelName}
</p>
<p className="text-xs text-muted-foreground">
{tx.aiInferenceProviderName}
</p>
</div>
<div className="text-right text-muted-foreground">
{tx.totalTokens.toLocaleString()}
</div>
<div className="text-right text-foreground font-medium">
{formatCreditBalance(tx.creditsUsed)}
</div>
<div className="text-right text-xs text-muted-foreground">
<p>{formatDate(tx.createdAt)}</p>
<p>{formatTime(tx.createdAt)}</p>
</div>
</div>
))}
{/* Organizations */}
<div className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<Building className="h-5 w-5 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">
Organizations
</h3>
</div>
<a
href="https://app.cline.bot/onboarding?step=1"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
<Plus className="h-3.5 w-3.5" />
Create
</a>
</div>
))}
</div>
)}
{/* Billing Tab */}
{activeTab === "billing" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
Payment history and credit purchases.
</p>
{billingLoading && renderLoading()}
{billingError && renderError(billingError, loadBilling)}
{!billingLoading &&
!billingError &&
billingLoaded &&
(paymentTransactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No payment transactions yet.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Date</span>
<span className="text-right">Amount</span>
<span className="text-right">Credits</span>
</div>
<div className="divide-y divide-border">
{paymentTransactions.map((tx) => (
{organizations.length === 0 ? (
<p className="text-sm text-muted-foreground">
No organizations yet.
</p>
) : (
<div className="flex flex-col gap-2">
{organizations.map((org) => (
<div
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
key={org.organizationId}
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-muted-foreground" />
<span className="text-foreground">
{formatDate(tx.paidAt)}
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-foreground">
{org.name.charAt(0)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
{org.name}
</p>
<p className="text-xs text-muted-foreground capitalize">
{org.roles.join(", ")}
</p>
</div>
{org.active && (
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
Active
</span>
</div>
<div className="text-right text-foreground font-medium">
${(tx.amountCents / 100).toFixed(2)}
</div>
<div className="text-right text-primary font-medium">
+{formatCreditBalance(tx.credits)}
</div>
)}
</div>
))}
</div>
)}
</div>
</>
)}
</div>
)}
{/* Usage Tab */}
{activeTab === "usage" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
{activeOrganization
? `Recent API usage and token consumption for ${activeOrganization.name}.`
: "Recent API usage and token consumption across all providers."}
</p>
{usageLoading && renderLoading()}
{usageError && renderError(usageError, loadUsage)}
{!usageLoading &&
!usageError &&
usageLoaded &&
(usageTransactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No usage transactions yet.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Model</span>
<span className="text-right">Tokens</span>
<span className="text-right">Credits</span>
<span className="text-right">Time</span>
</div>
))}
</div>
)}
</div>
</ScrollArea>
<div className="divide-y divide-border">
{usageTransactions.map((tx) => (
<div
key={tx.id}
className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
>
<div className="min-w-0">
<p className="font-medium text-foreground truncate">
{tx.aiModelName}
</p>
<p className="text-xs text-muted-foreground">
{tx.aiInferenceProviderName}
</p>
</div>
<div className="text-right text-muted-foreground">
{tx.totalTokens.toLocaleString()}
</div>
<div className="text-right text-foreground font-medium">
{formatCreditBalance(tx.creditsUsed)}
</div>
<div className="text-right text-xs text-muted-foreground">
<p>{formatDate(tx.createdAt)}</p>
<p>{formatTime(tx.createdAt)}</p>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
{/* Billing Tab */}
{activeTab === "billing" && (
<div>
<p className="mb-6 text-sm text-muted-foreground">
Payment history and credit purchases.
</p>
{billingLoading && renderLoading()}
{billingError && renderError(billingError, loadBilling)}
{!billingLoading &&
!billingError &&
billingLoaded &&
(paymentTransactions.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
No payment transactions yet.
</p>
) : (
<div className="rounded-lg border border-border overflow-hidden">
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
<span>Date</span>
<span className="text-right">Amount</span>
<span className="text-right">Credits</span>
</div>
<div className="divide-y divide-border">
{paymentTransactions.map((tx) => (
<div
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Receipt className="h-4 w-4 text-muted-foreground" />
<span className="text-foreground">
{formatDate(tx.paidAt)}
</span>
</div>
<div className="text-right text-foreground font-medium">
${(tx.amountCents / 100).toFixed(2)}
</div>
<div className="text-right text-primary font-medium">
+{formatCreditBalance(tx.credits)}
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</PageFrame>
);
}
@@ -13,8 +13,8 @@ import {
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
const CAPABILITY_OPTIONS = [
"streaming",
@@ -204,9 +204,11 @@ export function AddProviderContent({
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center gap-3">
<PageFrame contentClassName="max-w-4xl">
<PageHeader
description="Add an OpenAI-compatible provider and choose its available models."
title="Add Provider"
actions={
<Button
onClick={onBack}
variant="secondary"
@@ -214,320 +216,314 @@ export function AddProviderContent({
aria-label="Back to providers"
>
<ArrowLeft className="h-4 w-4" />
Providers
</Button>
<h2 className="text-lg font-semibold text-foreground">
Add Provider
</h2>
</div>
}
/>
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-border p-5">
<h3 className="mb-4 text-sm font-semibold text-foreground">
OpenAI-Compatible Provider
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Provider ID
</Label>
<input
type="text"
value={form.providerId}
onChange={(e) =>
setForm((prev) => ({ ...prev, providerId: e.target.value }))
}
placeholder="my-provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Lowercase ID used in provider registry.
</p>
{duplicateProviderId ? (
<p className="mt-1 text-xs text-destructive">
This provider ID already exists.
</p>
) : null}
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Provider Name
</Label>
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="My Provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
</div>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Base URL
</Label>
<input
type="url"
value={form.baseUrl}
onChange={(e) =>
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
}
placeholder="https://api.example.com/v1"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Model Source URL (Optional)
</Label>
<input
type="url"
value={form.modelsSourceUrl}
onChange={(e) =>
setForm((prev) => ({
...prev,
modelsSourceUrl: e.target.value,
}))
}
placeholder="https://api.example.com/v1/models"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Supported JSON: OpenAI `/models` shape with a `data` array, or a
direct model array.
</p>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Models
</Label>
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
{form.models.map((model) => (
<span
key={model}
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
>
<span className="font-mono">{model}</span>
<Button
onClick={() => removeModel(model)}
className="text-primary/60 hover:text-primary transition-colors"
aria-label={`Remove ${model}`}
>
<X className="h-3 w-3" />
</Button>
</span>
))}
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-border p-5">
<h3 className="mb-4 text-sm font-semibold text-foreground">
OpenAI-Compatible Provider
</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Provider ID
</Label>
<input
type="text"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={handleAddModel}
placeholder={
form.models.length === 0
? "Type model ID and press Enter"
: ""
value={form.providerId}
onChange={(e) =>
setForm((prev) => ({ ...prev, providerId: e.target.value }))
}
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
placeholder="my-provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Lowercase ID used in provider registry.
</p>
{duplicateProviderId ? (
<p className="mt-1 text-xs text-destructive">
This provider ID already exists.
</p>
) : null}
</div>
<p className="mt-1.5 text-xs text-muted-foreground">
Add at least one model or set a Model Source URL.
</p>
</div>
{form.models.length > 1 ? (
<div className="rounded-lg border border-border p-5">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Default Model
Provider Name
</Label>
<select
value={form.defaultModel}
onChange={(e) =>
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
>
{form.models.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
) : null}
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
API Key (Optional)
</Label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={form.apiKey}
type="text"
value={form.name}
onChange={(e) =>
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
setForm((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="sk-..."
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
placeholder="My Provider"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<Button
onClick={() => setShowApiKey(!showApiKey)}
variant="ghost"
className="rounded-md p-1 transition-colors"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
onClick={() => navigator.clipboard.writeText(form.apiKey)}
variant="ghost"
className="rounded-md p-1 transition-colors"
aria-label="Copy API key"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
Capabilities
</Label>
<div className="flex flex-wrap gap-2">
{CAPABILITY_OPTIONS.map((cap) => (
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Base URL
</Label>
<input
type="url"
value={form.baseUrl}
onChange={(e) =>
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
}
placeholder="https://api.example.com/v1"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Model Source URL (Optional)
</Label>
<input
type="url"
value={form.modelsSourceUrl}
onChange={(e) =>
setForm((prev) => ({
...prev,
modelsSourceUrl: e.target.value,
}))
}
placeholder="https://api.example.com/v1/models"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Supported JSON: OpenAI `/models` shape with a `data` array, or a
direct model array.
</p>
</div>
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Models
</Label>
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
{form.models.map((model) => (
<span
key={model}
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
>
<span className="font-mono">{model}</span>
<Button
key={cap}
onClick={() => toggleCapability(cap)}
className={cn(
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
form.capabilities.includes(cap)
? "border-primary/40 bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
)}
onClick={() => removeModel(model)}
className="text-primary/60 hover:text-primary transition-colors"
aria-label={`Remove ${model}`}
>
{cap.replace(/-/g, " ")}
<X className="h-3 w-3" />
</Button>
</span>
))}
<input
type="text"
value={modelInput}
onChange={(e) => setModelInput(e.target.value)}
onKeyDown={handleAddModel}
placeholder={
form.models.length === 0 ? "Type model ID and press Enter" : ""
}
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
/>
</div>
<p className="mt-1.5 text-xs text-muted-foreground">
Add at least one model or set a Model Source URL.
</p>
</div>
{form.models.length > 1 ? (
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Default Model
</Label>
<select
value={form.defaultModel}
onChange={(e) =>
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
}
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
>
{form.models.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</select>
</div>
) : null}
<div className="rounded-lg border border-border p-5">
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
API Key (Optional)
</Label>
<div className="relative">
<input
type={showApiKey ? "text" : "password"}
value={form.apiKey}
onChange={(e) =>
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
}
placeholder="sk-..."
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
<Button
onClick={() => setShowApiKey(!showApiKey)}
variant="ghost"
className="rounded-md p-1 transition-colors"
aria-label={showApiKey ? "Hide API key" : "Show API key"}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
onClick={() => navigator.clipboard.writeText(form.apiKey)}
variant="ghost"
className="rounded-md p-1 transition-colors"
aria-label="Copy API key"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<div className="rounded-lg border border-border overflow-hidden">
<Button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
variant="ghost"
>
Advanced Settings
<ChevronDown
<div className="rounded-lg border border-border p-5">
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
Capabilities
</Label>
<div className="flex flex-wrap gap-2">
{CAPABILITY_OPTIONS.map((cap) => (
<Button
key={cap}
onClick={() => toggleCapability(cap)}
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
showAdvanced && "rotate-180",
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
form.capabilities.includes(cap)
? "border-primary/40 bg-primary/10 text-primary"
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
)}
/>
</Button>
>
{cap.replace(/-/g, " ")}
</Button>
))}
</div>
</div>
<div className="rounded-lg border border-border overflow-hidden">
<Button
onClick={() => setShowAdvanced(!showAdvanced)}
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
variant="ghost"
>
Advanced Settings
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform",
showAdvanced && "rotate-180",
)}
/>
</Button>
{showAdvanced ? (
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Timeout (ms)
</Label>
<input
type="number"
value={form.timeoutMs}
onChange={(e) =>
setForm((prev) => ({
...prev,
timeoutMs: e.target.value,
}))
}
placeholder="30000"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
{showAdvanced ? (
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Timeout (ms)
</Label>
<input
type="number"
value={form.timeoutMs}
onChange={(e) =>
setForm((prev) => ({
...prev,
timeoutMs: e.target.value,
}))
}
placeholder="30000"
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Custom Headers
</Label>
<div className="flex flex-col gap-2">
{Object.entries(form.headers).map(([key, value], idx) => (
<div key={key} className="flex items-center gap-2">
<input
type="text"
value={key}
onChange={(e) =>
updateHeaderKey(key, e.target.value, idx)
}
placeholder="Header name"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<input
type="text"
value={value}
onChange={(e) =>
updateHeaderValue(key, e.target.value)
}
placeholder="Value"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<Button
onClick={() => removeHeader(key)}
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Remove header"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
onClick={addHeader}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<Plus className="h-3 w-3" />
Add Header
</Button>
</div>
<div>
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
Custom Headers
</Label>
<div className="flex flex-col gap-2">
{Object.entries(form.headers).map(([key, value], idx) => (
<div key={key} className="flex items-center gap-2">
<input
type="text"
value={key}
onChange={(e) =>
updateHeaderKey(key, e.target.value, idx)
}
placeholder="Header name"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<input
type="text"
value={value}
onChange={(e) => updateHeaderValue(key, e.target.value)}
placeholder="Value"
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
/>
<Button
onClick={() => removeHeader(key)}
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
aria-label="Remove header"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
<Button
onClick={addHeader}
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
>
<Plus className="h-3 w-3" />
Add Header
</Button>
</div>
</div>
) : null}
</div>
</div>
) : null}
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{error ? <p className="text-sm text-destructive">{error}</p> : null}
<div className="flex items-center justify-end gap-3 pt-2">
<Button
onClick={onBack}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
disabled={!canSave || saving}
className={cn(
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
canSave && !saving
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
{saving ? "Saving..." : "Add Provider"}
</Button>
</div>
<div className="flex items-center justify-end gap-3 pt-2">
<Button
onClick={onBack}
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
>
Cancel
</Button>
<Button
onClick={() => void handleSave()}
disabled={!canSave || saving}
className={cn(
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
canSave && !saving
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "bg-muted text-muted-foreground cursor-not-allowed",
)}
>
{saving ? "Saving..." : "Add Provider"}
</Button>
</div>
</div>
</ScrollArea>
</PageFrame>
);
}
@@ -23,7 +23,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -35,6 +34,12 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import {
CommandBadge,
PageEmptyState,
PageFrame,
PageHeader,
} from "../page-layout";
type ConnectorField = {
flag: string;
@@ -341,16 +346,13 @@ export function ChannelsContent() {
};
return (
<ScrollArea className="h-full">
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-lg font-semibold">Channels</h2>
<p className="text-sm text-muted-foreground">
{activeConnectors.length} connected
</p>
</div>
<div className="flex items-center gap-2">
<PageFrame>
<PageHeader
description={`${activeConnectors.length} connected. Start and manage connector channels for Cline Hub.`}
title="Channels"
meta={<CommandBadge>cline connect</CommandBadge>}
actions={
<>
<Button
disabled={isLoading}
onClick={() => void refreshChannels()}
@@ -371,85 +373,81 @@ export function ChannelsContent() {
<Plus className="size-4" />
Add Channel
</Button>
</div>
</>
}
/>
{errorMessage ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
{errorMessage ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
) : null}
{isLoading ? (
<PageEmptyState>Loading channels...</PageEmptyState>
) : activeConnectors.length === 0 ? (
<PageEmptyState>No channels connected.</PageEmptyState>
) : (
<section className="overflow-hidden rounded-lg border bg-card">
<div className="grid gap-2 p-2.5">
{isLoading ? (
<p className="px-1 py-4 text-[13px] text-muted-foreground">
Loading channels...
</p>
) : activeConnectors.length === 0 ? (
<p className="px-1 py-4 text-[13px] text-muted-foreground">
No channels connected.
</p>
) : (
activeConnectors.map((connector) => (
<div
className="grid gap-3 border bg-[color-mix(in_oklch,var(--background)_70%,var(--card))] p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
key={connector.id}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Circle className="size-2 fill-emerald-300 text-emerald-300" />
<p className="truncate text-[13px] font-semibold leading-tight">
{connectorName(connector, channels)}
</p>
<span className="rounded-md border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">
{connectorIdentity(connector)}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
<span className="rounded-md border bg-background px-1.5 py-0.5">
pid={connector.pid}
</span>
{activeConnectors.map((connector) => (
<div
className="grid gap-3 border bg-[color-mix(in_oklch,var(--background)_70%,var(--card))] p-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-center"
key={connector.id}
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<Circle className="size-2 fill-emerald-300 text-emerald-300" />
<p className="truncate text-[13px] font-semibold leading-tight">
{connectorName(connector, channels)}
</p>
<span className="rounded-md border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">
{connectorIdentity(connector)}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-1.5 text-[11px] text-muted-foreground">
<span className="rounded-md border bg-background px-1.5 py-0.5">
pid={connector.pid}
</span>
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.hubUrl}
>
{connector.hubUrl}
</span>
{connector.baseUrl ? (
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.hubUrl}
title={connector.baseUrl}
>
{connector.hubUrl}
{connector.baseUrl}
</span>
{connector.baseUrl ? (
<span
className="max-w-full break-all rounded-md border bg-background px-1.5 py-0.5"
title={connector.baseUrl}
>
{connector.baseUrl}
</span>
) : null}
) : null}
<span className="rounded-md border bg-background px-1.5 py-0.5">
{formatDateTime(connector.startedAt)}
</span>
{connector.connectionMode ? (
<span className="rounded-md border bg-background px-1.5 py-0.5">
{formatDateTime(connector.startedAt)}
{connector.connectionMode}
</span>
{connector.connectionMode ? (
<span className="rounded-md border bg-background px-1.5 py-0.5">
{connector.connectionMode}
</span>
) : null}
</div>
) : null}
</div>
<Button
disabled={busyChannel === connector.type}
onClick={() => setRemoveTarget(connector)}
size="sm"
type="button"
variant="outline"
>
<Trash2 className="size-4" />
Remove...
</Button>
</div>
))
)}
<Button
disabled={busyChannel === connector.type}
onClick={() => setRemoveTarget(connector)}
size="sm"
type="button"
variant="outline"
>
<Trash2 className="size-4" />
Remove...
</Button>
</div>
))}
</div>
</section>
</div>
)}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-h-[86vh] overflow-y-auto sm:max-w-xl">
@@ -639,6 +637,6 @@ export function ChannelsContent() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ScrollArea>
</PageFrame>
);
}
File diff suppressed because it is too large Load Diff
@@ -23,7 +23,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -35,6 +34,7 @@ import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { desktopClient } from "@/lib/desktop-client";
import { cn } from "@/lib/utils";
import { CommandBadge, PageFrame, PageHeader } from "../page-layout";
type McpTransportType = "stdio" | "sse" | "streamableHttp";
@@ -405,18 +405,24 @@ export function McpServersContent() {
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<h2 className="truncate text-lg font-semibold text-foreground">
MCP Servers
</h2>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
<PageFrame>
<PageHeader
description={
hasSettingsFile
? "Editing this list updates cline_mcp_settings.json."
: "No MCP settings file found yet. Add a server to create it."
}
title="MCP Servers"
meta={
<>
<CommandBadge>cline config mcp</CommandBadge>
<span className="rounded-md border border-border bg-background px-2 py-0.5 text-xs text-muted-foreground">
From settings file
</span>
</div>
<div className="flex items-center gap-2">
</>
}
actions={
<>
<Button
variant="outline"
size="sm"
@@ -431,145 +437,133 @@ export function McpServersContent() {
<Plus className="h-4 w-4" />
Add MCP Server
</Button>
</div>
</>
}
/>
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>MCP settings path:</span>
<Button
variant="link"
className="h-auto p-0 font-mono text-xs"
onClick={() => void openSettingsFile()}
disabled={isOpeningSettingsFile}
>
{settingsPath || "Open settings file"}
</Button>
</div>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
<div className="mb-1 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>MCP settings path:</span>
<Button
variant="link"
className="h-auto p-0 font-mono text-xs"
onClick={() => void openSettingsFile()}
disabled={isOpeningSettingsFile}
>
{settingsPath || "Open settings file"}
</Button>
{isLoading ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
Loading MCP servers...
</div>
<p className="mb-6 text-xs text-muted-foreground">
{hasSettingsFile
? "Editing this list updates cline_mcp_settings.json."
: "No MCP settings file found yet. Add a server to create it."}
</p>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
{isLoading ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
Loading MCP servers...
</div>
) : sortedServers.length === 0 ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
No MCP servers configured.
</div>
) : (
<div className="flex flex-col gap-3">
{sortedServers.map((server) => {
const isBusy = busyServerName === server.name;
return (
<div
key={server.name}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
server.disabled
? "fill-muted-foreground/40 text-muted-foreground/40"
: "fill-primary text-primary",
)}
) : sortedServers.length === 0 ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
No MCP servers configured.
</div>
) : (
<div className="flex flex-col gap-3">
{sortedServers.map((server) => {
const isBusy = busyServerName === server.name;
return (
<div
key={server.name}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
server.disabled
? "fill-muted-foreground/40 text-muted-foreground/40"
: "fill-primary text-primary",
)}
/>
<h3 className="text-sm font-semibold text-foreground">
{server.name}
</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{server.transportType}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${server.name}`}
onClick={() => openEditDialog(server)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${server.name}`}
onClick={() => setDeleteTarget(server)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={!server.disabled}
onCheckedChange={(enabled) =>
toggleServer(server, !enabled)
}
disabled={isBusy}
aria-label={`Enable ${server.name}`}
/>
<h3 className="text-sm font-semibold text-foreground">
{server.name}
</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{server.transportType}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${server.name}`}
onClick={() => openEditDialog(server)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${server.name}`}
onClick={() => setDeleteTarget(server)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={!server.disabled}
onCheckedChange={(enabled) =>
toggleServer(server, !enabled)
}
disabled={isBusy}
aria-label={`Enable ${server.name}`}
/>
</div>
</div>
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
{server.command && (
<p>
<span className="text-muted-foreground/70">
Command:
</span>{" "}
{server.command}
</p>
)}
{server.args && server.args.length > 0 && (
<p>
<span className="text-muted-foreground/70">Args:</span>{" "}
{server.args.join(", ")}
</p>
)}
{server.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span>{" "}
{server.cwd}
</p>
)}
{server.url && (
<p>
<span className="text-muted-foreground/70">URL:</span>{" "}
{server.url}
</p>
)}
{server.env && Object.keys(server.env).length > 0 && (
<p>
<span className="text-muted-foreground/70">Env:</span>{" "}
{stringifyRedactedKeyValuePairs(server.env)}
</p>
)}
{server.headers &&
Object.keys(server.headers).length > 0 && (
<p>
<span className="text-muted-foreground/70">
Headers:
</span>{" "}
{stringifyKeyValuePairs(server.headers)}
</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
{server.command && (
<p>
<span className="text-muted-foreground/70">Command:</span>{" "}
{server.command}
</p>
)}
{server.args && server.args.length > 0 && (
<p>
<span className="text-muted-foreground/70">Args:</span>{" "}
{server.args.join(", ")}
</p>
)}
{server.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span>{" "}
{server.cwd}
</p>
)}
{server.url && (
<p>
<span className="text-muted-foreground/70">URL:</span>{" "}
{server.url}
</p>
)}
{server.env && Object.keys(server.env).length > 0 && (
<p>
<span className="text-muted-foreground/70">Env:</span>{" "}
{stringifyRedactedKeyValuePairs(server.env)}
</p>
)}
{server.headers && Object.keys(server.headers).length > 0 && (
<p>
<span className="text-muted-foreground/70">Headers:</span>{" "}
{stringifyKeyValuePairs(server.headers)}
</p>
)}
</div>
</div>
);
})}
</div>
)}
<Dialog
open={editorOpen}
onOpenChange={(open) => {
@@ -854,6 +848,6 @@ export function McpServersContent() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</ScrollArea>
</PageFrame>
);
}
@@ -2,6 +2,7 @@
import {
ArrowLeft,
ChevronRight,
Copy,
Eye,
EyeOff,
@@ -12,8 +13,8 @@ import {
PlusCircle,
RefreshCw,
Search,
Settings2,
Star,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
@@ -89,58 +90,142 @@ export function ProviderListContent({
onToggle,
onConfigure,
onAddProvider,
selectedProviderId,
variant = "page",
}: {
providers: Provider[];
onToggle: (id: string) => void;
onConfigure: (id: string) => void;
onAddProvider: () => void;
selectedProviderId?: string | null;
variant?: "page" | "panel";
}) {
const [providerSearchOpen, setProviderSearchOpen] = useState(false);
const [providerSearch, setProviderSearch] = useState("");
const enabledProviderCount = providers.filter(
(provider) => provider.enabled,
).length;
const providerSearchQuery = providerSearch.trim().toLowerCase();
const filteredProviders = providerSearchQuery
? providers.filter((provider) =>
provider.name.toLowerCase().includes(providerSearchQuery),
)
: providers;
const isPanel = variant === "panel";
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
Model Providers
</h2>
<Button
className="flex items-center gap-2 rounded-lg border border-border bg-accent px-3.5 py-2 text-sm font-medium text-foreground hover:bg-accent/80 transition-colors"
onClick={onAddProvider}
variant="ghost"
>
<PlusCircle className="h-4 w-4" />
Add Provider
</Button>
<div
className={cn(
"py-10 max-[720px]:px-4 max-[720px]:py-5",
isPanel ? "px-8" : "px-18 max-[1200px]:px-8",
)}
>
<div
className={cn(
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
isPanel ? "max-w-none" : "max-w-[42rem]",
)}
>
<div className="min-w-0">
<h1
className={cn(
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
isPanel ? "text-[24px]" : "text-[32px]",
)}
>
Models
</h1>
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
Configure model providers and choose which ones are available.{" "}
{providers.length} available &middot; {enabledProviderCount}{" "}
enabled
</p>
</div>
<div className="flex shrink-0 items-center gap-2 max-[860px]:justify-start">
<Button
aria-label="Search providers"
className="size-8 rounded-md"
onClick={() => setProviderSearchOpen((open) => !open)}
size="icon-sm"
type="button"
variant={providerSearchOpen ? "default" : "secondary"}
>
<Search className="size-4" />
</Button>
<Button
className="h-8 rounded-md bg-foreground px-3 text-sm text-background hover:bg-foreground/90"
onClick={onAddProvider}
type="button"
>
<PlusCircle className="size-4" />
Add provider
</Button>
</div>
</div>
<div className="flex flex-col divide-y divide-border rounded-lg border border-border overflow-hidden">
{providers.map((prov) => (
{providerSearchOpen ? (
<div className={cn("mb-4", isPanel ? "max-w-none" : "max-w-[42rem]")}>
<div className="flex h-9 items-center gap-2 rounded border bg-background px-3">
<Search className="size-4 shrink-0 text-muted-foreground" />
<Input
aria-label="Search model providers"
autoFocus
className="h-7 border-0 bg-transparent px-0 text-sm"
onChange={(event) => setProviderSearch(event.target.value)}
placeholder="Search providers"
value={providerSearch}
/>
</div>
</div>
) : null}
<div
className={cn(
"overflow-hidden",
isPanel ? "max-w-none" : "max-w-[42rem]",
)}
>
{filteredProviders.length === 0 ? (
<div className="border-b px-2 py-6 text-[15px] text-muted-foreground">
No providers match "{providerSearch.trim()}".
</div>
) : null}
{filteredProviders.map((prov) => (
<div
className="flex items-center gap-4 px-5 py-4 transition-colors hover:bg-accent/30"
className={cn(
"flex min-h-11 items-center gap-4 border-b px-2 py-2 transition-colors hover:bg-accent/30",
selectedProviderId === prov.id && "bg-accent/45",
)}
key={prov.id}
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground">
<button
className="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onConfigure(prov.id)}
type="button"
>
<p className="min-w-0 flex-1 truncate text-[17px] font-semibold text-foreground">
{prov.name}
</p>
<p className="text-xs text-muted-foreground">
<p className="shrink-0 text-[15px] text-muted-foreground">
{prov.models === null
? "Models load on demand"
: `${prov.models} Model${prov.models !== 1 ? "s" : ""}`}
: `${prov.models} model${prov.models !== 1 ? "s" : ""}`}
</p>
</div>
<Button
aria-label={`Configure ${prov.name}`}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={() => onConfigure(prov.id)}
variant="ghost"
>
<Settings2 className="h-4 w-4" />
</Button>
</button>
<Switch
aria-label={`Toggle ${prov.name}`}
checked={prov.enabled}
onCheckedChange={() => onToggle(prov.id)}
/>
<button
aria-label={`Configure ${prov.name}`}
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onConfigure(prov.id)}
type="button"
>
<ChevronRight className="size-4" />
</button>
</div>
))}
</div>
@@ -158,6 +243,7 @@ export function ProviderDetailContent({
modelsError,
onOAuthLogin,
oauthLoginPending = false,
variant = "page",
}: {
provider: Provider;
onBack: () => void;
@@ -167,6 +253,7 @@ export function ProviderDetailContent({
modelsError?: string | null;
onOAuthLogin?: () => void;
oauthLoginPending?: boolean;
variant?: "page" | "panel";
}) {
const [shownSecrets, setShownSecrets] = useState<Record<string, boolean>>({});
const [localConfigValues, setLocalConfigValues] = useState<
@@ -199,6 +286,7 @@ export function ProviderDetailContent({
model.id.toLowerCase().includes(modelSearchQuery),
)
: modelList;
const isPanel = variant === "panel";
useEffect(
() => () => {
@@ -250,44 +338,65 @@ export function ProviderDetailContent({
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div
className={cn(
"py-10 max-[720px]:px-4 max-[720px]:py-5",
isPanel ? "px-6" : "px-18 max-[1200px]:px-8",
)}
>
{/* Back + title */}
<div className="mb-8 flex items-center gap-3">
<Button
aria-label="Back to providers"
aria-label={
isPanel ? "Close provider details" : "Back to providers"
}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
onClick={onBack}
variant="ghost"
>
<ArrowLeft className="h-4 w-4" />
{isPanel ? (
<X className="h-4 w-4" />
) : (
<ArrowLeft className="h-4 w-4" />
)}
</Button>
<h2 className="text-lg font-semibold text-foreground">
<h1
className={cn(
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
isPanel ? "text-[24px]" : "text-[32px]",
)}
>
{provider.name}
</h2>
</h1>
</div>
{configFields.length > 0 ? (
<section className="mb-8">
<div className="flex flex-col gap-5">
<section
className={cn("mb-8", isPanel ? "max-w-none" : "max-w-[86rem]")}
>
<div className="flex flex-col">
{configFields.map((field) => {
const value = localConfigValues[field.path];
const valueText = fieldValueToString(value);
const isSecret = field.type === "password" || field.secret;
const isShown = shownSecrets[field.path] ?? false;
return (
<div key={field.path}>
<header className="mb-2">
<h3 className="text-sm font-semibold text-foreground">
<div
className="grid min-h-18 grid-cols-[minmax(12rem,0.55fr)_minmax(16rem,0.45fr)] items-center gap-6 border-b py-4 max-[900px]:grid-cols-1 max-[900px]:gap-3"
key={field.path}
>
<header>
<h3 className="text-[17px] font-semibold text-foreground">
{field.label}
</h3>
{field.description ? (
<p className="mt-1 text-sm leading-relaxed text-muted-foreground">
<p className="mt-1 text-[15px] leading-relaxed text-muted-foreground">
{field.description}
</p>
) : null}
</header>
{field.type === "boolean" ? (
<div className="flex items-center justify-between rounded-lg border border-border px-4 py-3">
<div className="flex items-center justify-end">
<span className="text-sm text-muted-foreground">
{field.label}
</span>
@@ -300,7 +409,7 @@ export function ProviderDetailContent({
</div>
) : field.type === "select" ? (
<select
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
onChange={(event) =>
commitField(field, event.target.value)
}
@@ -317,12 +426,12 @@ export function ProviderDetailContent({
))}
</select>
) : (
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-4 py-3">
<div className="flex h-9 items-center gap-2 rounded border border-border bg-background px-3">
{field.type === "url" ? (
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
) : null}
<Input
className="flex-1 text-sm text-foreground placeholder:text-muted-foreground outline-none border-0"
className="h-7 flex-1 border-0 bg-transparent px-0 text-sm text-foreground outline-none placeholder:text-muted-foreground"
onBlur={() => commitField(field, valueText)}
onChange={(event) =>
setLocalConfigValues((current) => ({
@@ -408,10 +517,18 @@ export function ProviderDetailContent({
) : null}
{/* Models section */}
<section>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-sm font-semibold text-foreground">Models</h3>
<section
className={cn(
"overflow-hidden rounded-lg border",
isPanel ? "max-w-none" : "max-w-[46rem]",
)}
>
<div className="flex h-12 items-center justify-between bg-muted/40 px-4">
<h2 className="text-[17px] font-medium text-muted-foreground">
Models
</h2>
<div className="flex items-center gap-1">
<Search className="size-4 text-muted-foreground" />
<Button
aria-label="Refresh models"
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
@@ -432,7 +549,7 @@ export function ProviderDetailContent({
</div>
) : modelList.length > 0 ? (
<div className="space-y-3">
<div className="flex items-center gap-2 rounded-lg border border-border bg-input px-3 py-2">
<div className="mx-4 mt-4 flex items-center gap-2 rounded border border-border bg-background px-3 py-2">
<Search className="size-4 shrink-0 text-muted-foreground" />
<Input
aria-label="Search models"
@@ -449,10 +566,10 @@ export function ProviderDetailContent({
/>
</div>
{filteredModelList.length > 0 ? (
<div className="flex flex-col divide-y divide-border rounded-lg border border-border max-h-125 overflow-y-scroll">
<div className="max-h-125 overflow-y-scroll border-t">
{filteredModelList.map((model) => (
<div
className="group flex items-center gap-3 px-4 py-3 transition-colors hover:bg-accent/30"
className="group flex min-h-16 items-center gap-3 border-b px-4 py-3 transition-colors hover:bg-accent/30"
key={model.id}
>
<div className="min-w-0 flex-1 font-mono">
@@ -42,7 +42,6 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
@@ -60,6 +59,12 @@ import {
loadProviderModels,
} from "@/lib/provider-model-catalog";
import { cn } from "@/lib/utils";
import {
CommandBadge,
PageEmptyState,
PageFrame,
PageHeader,
} from "../page-layout";
type DateTimeValue = number | string;
@@ -871,18 +876,13 @@ export function RoutineSchedulesContent() {
);
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<h2 className="truncate text-lg font-semibold text-foreground">
Schedules
</h2>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
cline schedule
</span>
</div>
<div className="flex items-center gap-2">
<PageFrame>
<PageHeader
description="Scheduled jobs are run through the hub."
title="Schedules"
meta={<CommandBadge>cline schedule</CommandBadge>}
actions={
<>
<Button
variant="outline"
size="sm"
@@ -897,223 +897,204 @@ export function RoutineSchedulesContent() {
<Plus className="h-4 w-4" />
New Schedule
</Button>
</div>
</>
}
/>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
<p className="mb-6 text-xs text-muted-foreground">
Scheduled jobs are run through the hub.
</p>
{errorMessage && (
<div className="mb-4 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMessage}
</div>
)}
{isLoading ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
Loading routines...
</div>
) : sortedSchedules.length === 0 ? (
<div className="rounded-lg border border-border px-5 py-4 text-sm text-muted-foreground">
No schedules found. Create a schedule to run routines on a recurring
basis.
</div>
) : (
<div className="flex flex-col gap-3">
{sortedSchedules.map((schedule) => {
const isBusy = busyScheduleId === schedule.scheduleId;
const activeExecution = executionBySchedule.get(
schedule.scheduleId,
);
const lastExecution = lastExecutionBySchedule.get(
schedule.scheduleId,
);
const upcoming = upcomingRuns.find(
(item) => item.scheduleId === schedule.scheduleId,
);
return (
<div
key={schedule.scheduleId}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
{isLoading ? (
<PageEmptyState>Loading schedules...</PageEmptyState>
) : sortedSchedules.length === 0 ? (
<PageEmptyState>
No schedules found. Create a schedule to run routines on a recurring
basis.
</PageEmptyState>
) : (
<div className="flex flex-col gap-3">
{sortedSchedules.map((schedule) => {
const isBusy = busyScheduleId === schedule.scheduleId;
const activeExecution = executionBySchedule.get(
schedule.scheduleId,
);
const lastExecution = lastExecutionBySchedule.get(
schedule.scheduleId,
);
const upcoming = upcomingRuns.find(
(item) => item.scheduleId === schedule.scheduleId,
);
return (
<div
key={schedule.scheduleId}
className="rounded-lg border border-border px-5 py-4 transition-colors hover:bg-accent/20"
>
<div className="flex items-center gap-3">
<Circle
className={cn(
"h-2.5 w-2.5 shrink-0",
schedule.enabled
? "fill-primary text-primary"
: "fill-muted-foreground/40 text-muted-foreground/40",
)}
/>
<h3 className="text-sm font-semibold text-foreground">
{schedule.name}
</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{schedule.mode}
</span>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{schedule.cronPattern}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`View ${schedule.name}`}
onClick={() => {
window.alert(JSON.stringify(schedule, null, 2));
}}
>
<Eye className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${schedule.name}`}
onClick={() => openEditDialog(schedule)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Run ${schedule.name} now`}
onClick={() => void triggerSchedule(schedule.scheduleId)}
disabled={isBusy}
>
<Zap className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={
schedule.enabled
? "fill-primary text-primary"
: "fill-muted-foreground/40 text-muted-foreground/40",
? `Pause ${schedule.name}`
: `Resume ${schedule.name}`
}
onClick={() =>
void upsertScheduleEnabled(schedule, !schedule.enabled)
}
disabled={isBusy}
>
{schedule.enabled ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${schedule.name}`}
onClick={() => setSchedulePendingDelete(schedule)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={schedule.enabled}
onCheckedChange={(checked) =>
void upsertScheduleEnabled(schedule, checked)
}
disabled={isBusy}
aria-label={`Enable ${schedule.name}`}
/>
<h3 className="text-sm font-semibold text-foreground">
{schedule.name}
</h3>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{schedule.mode}
</span>
<span className="rounded-md border border-border px-2 py-0.5 text-xs text-muted-foreground">
{schedule.cronPattern}
</span>
<div className="flex-1" />
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label={`View ${schedule.name}`}
onClick={() => {
window.alert(JSON.stringify(schedule, null, 2));
}}
>
<Eye className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Edit ${schedule.name}`}
onClick={() => openEditDialog(schedule)}
disabled={isBusy}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Run ${schedule.name} now`}
onClick={() =>
void triggerSchedule(schedule.scheduleId)
}
disabled={isBusy}
>
<Zap className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={
schedule.enabled
? `Pause ${schedule.name}`
: `Resume ${schedule.name}`
}
onClick={() =>
void upsertScheduleEnabled(
schedule,
!schedule.enabled,
)
}
disabled={isBusy}
>
{schedule.enabled ? (
<Pause className="h-3.5 w-3.5" />
) : (
<Play className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Delete ${schedule.name}`}
onClick={() => setSchedulePendingDelete(schedule)}
disabled={isBusy}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<Switch
checked={schedule.enabled}
onCheckedChange={(checked) =>
void upsertScheduleEnabled(schedule, checked)
}
disabled={isBusy}
aria-label={`Enable ${schedule.name}`}
/>
</div>
</div>
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
<p>
<span className="text-muted-foreground/70">ID:</span>{" "}
{schedule.scheduleId}
</p>
<p>
<span className="text-muted-foreground/70">Prompt:</span>{" "}
{schedule.prompt}
</p>
<p>
<span className="text-muted-foreground/70">Model:</span>{" "}
{formatScheduleModel(schedule)}
</p>
{schedule.workspaceRoot && (
<p>
<span className="text-muted-foreground/70">
Workspace:
</span>{" "}
{schedule.workspaceRoot}
</p>
)}
{schedule.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span>{" "}
{schedule.cwd}
</p>
)}
<p>
<span className="text-muted-foreground/70">
Last run:
</span>{" "}
{formatDateTime(schedule.lastRunAt)}
</p>
<p>
<span className="text-muted-foreground/70">
Last result:
</span>{" "}
{formatExecutionResult(lastExecution)}
</p>
{lastExecution?.sessionId && (
<p>
<span className="text-muted-foreground/70">
Last session:
</span>{" "}
{lastExecution.sessionId}
</p>
)}
{lastExecution?.errorMessage && (
<p className="text-destructive">
<span className="text-muted-foreground/70">
Last error:
</span>{" "}
{lastExecution.errorMessage}
</p>
)}
<p>
<span className="text-muted-foreground/70">
Next run:
</span>{" "}
{formatDateTime(
schedule.nextRunAt || upcoming?.nextRunAt,
)}
</p>
{activeExecution && (
<p>
<span className="text-muted-foreground/70">
Active:
</span>{" "}
{activeExecution.executionId} since{" "}
{formatDateTime(activeExecution.startedAt)}
</p>
)}
{schedule.tags && schedule.tags.length > 0 && (
<p>
<span className="text-muted-foreground/70">Tags:</span>{" "}
{schedule.tags.join(", ")}
</p>
)}
</div>
</div>
);
})}
</div>
)}
</div>
<div className="mt-2.5 ml-5.5 flex flex-col gap-1 text-xs text-muted-foreground">
<p>
<span className="text-muted-foreground/70">ID:</span>{" "}
{schedule.scheduleId}
</p>
<p>
<span className="text-muted-foreground/70">Prompt:</span>{" "}
{schedule.prompt}
</p>
<p>
<span className="text-muted-foreground/70">Model:</span>{" "}
{formatScheduleModel(schedule)}
</p>
{schedule.workspaceRoot && (
<p>
<span className="text-muted-foreground/70">
Workspace:
</span>{" "}
{schedule.workspaceRoot}
</p>
)}
{schedule.cwd && (
<p>
<span className="text-muted-foreground/70">CWD:</span>{" "}
{schedule.cwd}
</p>
)}
<p>
<span className="text-muted-foreground/70">Last run:</span>{" "}
{formatDateTime(schedule.lastRunAt)}
</p>
<p>
<span className="text-muted-foreground/70">
Last result:
</span>{" "}
{formatExecutionResult(lastExecution)}
</p>
{lastExecution?.sessionId && (
<p>
<span className="text-muted-foreground/70">
Last session:
</span>{" "}
{lastExecution.sessionId}
</p>
)}
{lastExecution?.errorMessage && (
<p className="text-destructive">
<span className="text-muted-foreground/70">
Last error:
</span>{" "}
{lastExecution.errorMessage}
</p>
)}
<p>
<span className="text-muted-foreground/70">Next run:</span>{" "}
{formatDateTime(schedule.nextRunAt || upcoming?.nextRunAt)}
</p>
{activeExecution && (
<p>
<span className="text-muted-foreground/70">Active:</span>{" "}
{activeExecution.executionId} since{" "}
{formatDateTime(activeExecution.startedAt)}
</p>
)}
{schedule.tags && schedule.tags.length > 0 && (
<p>
<span className="text-muted-foreground/70">Tags:</span>{" "}
{schedule.tags.join(", ")}
</p>
)}
</div>
</div>
);
})}
</div>
)}
<AlertDialog
open={Boolean(schedulePendingDelete)}
onOpenChange={(open) => {
@@ -1554,6 +1535,6 @@ export function RoutineSchedulesContent() {
</DialogFooter>
</DialogContent>
</Dialog>
</ScrollArea>
</PageFrame>
);
}
@@ -1,6 +1,4 @@
"use client";
import { ChevronDown, ChevronRight, Moon, Sun, X } from "lucide-react";
import { ChevronDown, ChevronRight, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
@@ -12,11 +10,17 @@ import type {
ProviderModelsResponse,
ProviderSettingsUpdate,
} from "@/lib/provider-schema";
import {
type HubTheme,
readStoredHubTheme,
readSystemHubTheme,
setStoredHubTheme,
} from "@/lib/theme";
import { cn } from "@/lib/utils";
import { PageFrame, PageHeader } from "../page-layout";
import { AccountView } from "./account-view";
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
import { ChannelsContent } from "./channels-view";
import { RulesView } from "./extensions-view";
import { McpServersContent } from "./mcp-view";
import {
ProviderDetailContent,
@@ -32,7 +36,6 @@ import { toSettingsPatch } from "./settings-patch";
const navCategories = [
"General",
"Providers",
"Customizations",
"MCP",
"Channels",
"Schedules",
@@ -40,7 +43,6 @@ const navCategories = [
] as const;
export type SettingsSection = (typeof navCategories)[number];
type Theme = "dark" | "light";
type GlobalSettingsResponse = {
telemetryOptOut: boolean;
autoUpdateEnabled: boolean;
@@ -58,17 +60,15 @@ let providerCatalogCache: {
// -----------------------------------------------------------
export function SettingsView({
chrome = "full",
initialSection = "General",
onClose,
onNavigateSection,
onThemeChange,
theme,
}: {
chrome?: "full" | "content";
initialSection?: SettingsSection;
onClose: () => void;
onNavigateSection?: (section: SettingsSection) => void;
onThemeChange: (theme: Theme) => void;
theme: Theme;
}) {
const [activeNav, setActiveNav] = useState<SettingsSection>(initialSection);
const [providersExpanded, setProvidersExpanded] = useState(true);
@@ -141,11 +141,14 @@ export function SettingsView({
}, [setProvidersWithCache]);
useEffect(() => {
if (activeNav !== "Providers") {
return;
}
const timeoutId = window.setTimeout(() => {
void loadProviderCatalog();
}, 0);
return () => window.clearTimeout(timeoutId);
}, [loadProviderCatalog]);
}, [activeNav, loadProviderCatalog]);
const persistProviderSettings = useCallback(
async (
@@ -349,6 +352,86 @@ export function SettingsView({
setAddingProvider(false);
};
const providerContent = addingProvider ? (
<AddProviderContent
existingProviderIds={providers.map((provider) => provider.id)}
onBack={backToProviderList}
onSave={saveNewProvider}
/>
) : providersLoading ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">Loading providers...</p>
</div>
) : providerCatalogError ? (
<div className="flex h-full items-center justify-center">
<p className="max-w-xl px-4 text-center text-sm text-destructive">
Failed to load providers: {providerCatalogError}
</p>
</div>
) : selectedProvider ? (
<div className="grid h-full grid-cols-[minmax(24rem,0.95fr)_minmax(28rem,1.05fr)] overflow-hidden max-[1100px]:grid-cols-1 max-[1100px]:grid-rows-[minmax(24rem,0.9fr)_minmax(26rem,1fr)]">
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
selectedProviderId={selectedProvider.id}
variant="panel"
/>
<aside className="min-h-0 overflow-hidden border-l bg-background max-[1100px]:border-l-0 max-[1100px]:border-t">
<ProviderDetailContent
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
modelsLoading={modelsLoadingByProvider[selectedProvider.id] ?? false}
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
isOAuthProvider(selectedProvider.id)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
onUpdate={(updates) => updateProvider(selectedProvider.id, updates)}
provider={selectedProvider}
variant="panel"
/>
</aside>
</div>
) : (
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
/>
);
const content =
activeNav === "Providers" ? (
providerContent
) : activeNav === "MCP" ? (
<McpServersContent />
) : activeNav === "Channels" ? (
<ChannelsContent />
) : activeNav === "Schedules" ? (
<RoutineSchedulesContent />
) : activeNav === "Account" ? (
<AccountView />
) : activeNav === "General" ? (
<GeneralSettingsContent />
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{activeNav} settings coming soon.
</p>
</div>
);
if (chrome === "content") {
return (
<div className="h-full overflow-hidden bg-background">{content}</div>
);
}
return (
<div className="flex h-full flex-col overflow-hidden bg-background">
{/* Header bar */}
@@ -440,88 +523,17 @@ export function SettingsView({
</nav>
{/* Content area */}
<div className="flex-1 overflow-hidden">
{activeNav === "Providers" && selectedProvider ? (
<ProviderDetailContent
modelsError={modelsErrorByProvider[selectedProvider.id] ?? null}
modelsLoading={
modelsLoadingByProvider[selectedProvider.id] ?? false
}
oauthLoginPending={oauthSigningProviderId === selectedProvider.id}
onBack={backToProviderList}
onLoadModels={() => void loadProviderModels(selectedProvider.id)}
onOAuthLogin={
isOAuthProvider(selectedProvider.id)
? () => void runOAuthProviderLogin(selectedProvider.id)
: undefined
}
onUpdate={(updates) =>
updateProvider(selectedProvider.id, updates)
}
provider={selectedProvider}
/>
) : activeNav === "Providers" ? (
addingProvider ? (
<AddProviderContent
existingProviderIds={providers.map((provider) => provider.id)}
onBack={backToProviderList}
onSave={saveNewProvider}
/>
) : providersLoading ? (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
Loading providers...
</p>
</div>
) : providerCatalogError ? (
<div className="flex h-full items-center justify-center">
<p className="max-w-xl px-4 text-center text-sm text-destructive">
Failed to load providers: {providerCatalogError}
</p>
</div>
) : (
<ProviderListContent
onAddProvider={openAddProvider}
onConfigure={openProviderDetail}
onToggle={toggleProvider}
providers={providers}
/>
)
) : activeNav === "MCP" ? (
<McpServersContent />
) : activeNav === "Channels" ? (
<ChannelsContent />
) : activeNav === "Schedules" ? (
<RoutineSchedulesContent />
) : activeNav === "Customizations" ? (
<RulesView />
) : activeNav === "Account" ? (
<AccountView />
) : activeNav === "General" ? (
<GeneralSettingsContent
onThemeChange={onThemeChange}
theme={theme}
/>
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
{activeNav} settings coming soon.
</p>
</div>
)}
</div>
<div className="flex-1 overflow-hidden">{content}</div>
</div>
</div>
);
}
function GeneralSettingsContent({
onThemeChange,
theme,
}: {
onThemeChange: (theme: Theme) => void;
theme: Theme;
}) {
function GeneralSettingsContent() {
const [theme, setTheme] = useState<HubTheme>(() => {
if (typeof window === "undefined") return "light";
return readStoredHubTheme() ?? readSystemHubTheme();
});
const [telemetryOptOut, setTelemetryOptOut] = useState(false);
const [telemetryLoading, setTelemetryLoading] = useState(true);
const [telemetrySaving, setTelemetrySaving] = useState(false);
@@ -603,85 +615,77 @@ function GeneralSettingsContent({
}
};
const updateTheme = (darkModeEnabled: boolean) => {
const nextTheme = darkModeEnabled ? "dark" : "light";
setTheme(setStoredHubTheme(nextTheme));
window.dispatchEvent(new Event("cline-hub-theme-change"));
};
return (
<ScrollArea className="h-full">
<div className="mx-auto max-w-3xl px-8 py-6">
<div className="mb-6">
<h2 className="text-lg font-semibold text-foreground">General</h2>
<PageFrame>
<PageHeader
description="Manage Hub preferences for this browser and CLI environment."
title="Settings"
/>
<section className="max-w-[86rem]">
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Dark mode
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Keep the Hub interface in dark mode on this browser.
</p>
</div>
<Switch
aria-label="Dark mode"
checked={theme === "dark"}
onCheckedChange={updateTheme}
/>
</div>
<section className="rounded-lg border border-border p-5">
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
<div>
<p className="text-sm font-medium text-foreground">Theme</p>
<p className="mt-1 text-xs text-muted-foreground">
Use the light or dark Cline Hub interface.
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Auto update
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Automatically install CLI updates on startup.
</p>
{autoUpdateError ? (
<p className="mt-2 text-xs text-destructive">
Failed to update auto update setting: {autoUpdateError}
</p>
</div>
<div className="flex items-center gap-2 max-[720px]:justify-start">
<Button
onClick={() => onThemeChange("dark")}
type="button"
variant={theme === "dark" ? "default" : "outline"}
>
<Moon className="size-4" />
Dark
</Button>
<Button
onClick={() => onThemeChange("light")}
type="button"
variant={theme === "light" ? "default" : "outline"}
>
<Sun className="size-4" />
Light
</Button>
</div>
) : null}
</div>
</section>
<section className="mt-4 rounded-lg border border-border p-5">
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
<div>
<p className="text-sm font-medium text-foreground">Auto update</p>
<p className="mt-1 text-xs text-muted-foreground">
Automatically install CLI updates on startup.
<Switch
aria-label="Auto update"
checked={autoUpdateEnabled}
disabled={autoUpdateLoading || autoUpdateSaving}
onCheckedChange={(checked) => void updateAutoUpdateEnabled(checked)}
/>
</div>
<div className="flex min-h-20 items-center justify-between gap-5 border-b max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div>
<p className="text-[17px] font-semibold text-foreground">
Telemetry
</p>
<p className="mt-1 text-[15px] text-muted-foreground">
Enable error and usage report to help us improve Cline.
</p>
{telemetryError ? (
<p className="mt-2 text-xs text-destructive">
Failed to update telemetry setting: {telemetryError}
</p>
{autoUpdateError ? (
<p className="mt-2 text-xs text-destructive">
Failed to update auto update setting: {autoUpdateError}
</p>
) : null}
</div>
<Switch
aria-label="Auto update"
checked={autoUpdateEnabled}
disabled={autoUpdateLoading || autoUpdateSaving}
onCheckedChange={(checked) =>
void updateAutoUpdateEnabled(checked)
}
/>
) : null}
</div>
</section>
<section className="mt-4 rounded-lg border border-border p-5">
<div className="flex items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch">
<div>
<p className="text-sm font-medium text-foreground">Telemetry</p>
<p className="mt-1 text-xs text-muted-foreground">
Enable error and usage report to help us improve Cline.
</p>
{telemetryError ? (
<p className="mt-2 text-xs text-destructive">
Failed to update telemetry setting: {telemetryError}
</p>
) : null}
</div>
<Switch
aria-label="Telemetry opt-out"
checked={!telemetryOptOut} // If opt-out is true, the switch should be off (unchecked)
disabled={telemetryLoading || telemetrySaving}
onCheckedChange={(checked) => void updateTelemetryOptOut(checked)}
/>
</div>
</section>
</div>
</ScrollArea>
<Switch
aria-label="Telemetry"
checked={!telemetryOptOut} // If opt-out is true, the switch should be off (unchecked)
disabled={telemetryLoading || telemetrySaving}
onCheckedChange={(checked) => void updateTelemetryOptOut(!checked)}
/>
</div>
</section>
</PageFrame>
);
}
+94 -19
View File
@@ -1,29 +1,29 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@import "@fontsource-variable/schibsted-grotesk";
@custom-variant dark (&:is(.dark *));
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--foreground: oklch(0.18 0.008 255);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--card-foreground: oklch(0.18 0.008 255);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--muted: oklch(0.974 0.003 270);
--muted-foreground: oklch(0.52 0.014 270);
--accent: oklch(0.94 0.004 270);
--accent-foreground: oklch(0.18 0.008 255);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.93 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--border: oklch(0.92 0.005 270);
--input: oklch(0.9 0.006 270);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
@@ -31,19 +31,21 @@
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar: oklch(0.977 0.004 270);
--sidebar-foreground: oklch(0.18 0.008 255);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-accent: oklch(0.928 0.006 270);
--sidebar-accent-foreground: oklch(0.18 0.008 255);
--sidebar-border: oklch(0.92 0.005 270);
--sidebar-ring: oklch(0.708 0 0);
}
@theme inline {
--font-sans: "Geist Variable", sans-serif;
--font-mono: "Geist Mono", "Geist Mono Fallback";
--font-sans: "Schibsted Grotesk Variable", sans-serif;
--font-mono:
ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono",
monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
@@ -91,13 +93,86 @@
}
body {
@apply bg-background text-foreground;
font-optical-sizing: auto;
}
html {
@apply font-sans;
}
a[href],
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]),
summary,
input[type="button"]:not(:disabled),
input[type="checkbox"]:not(:disabled),
input[type="radio"]:not(:disabled),
input[type="reset"]:not(:disabled),
input[type="submit"]:not(:disabled),
label[for],
select:not(:disabled) {
cursor: pointer;
}
button:disabled,
[aria-disabled="true"],
input:disabled,
select:disabled,
textarea:disabled {
cursor: not-allowed;
}
}
:is(
[role="menuitem"],
[role="menuitemcheckbox"],
[role="menuitemradio"],
[role="option"],
[role="tab"],
[role="switch"],
[role="checkbox"],
[role="radio"],
[data-slot="switch"],
[data-slot="checkbox"],
[data-slot="radio-group-item"],
[data-slot="tabs-trigger"],
[data-slot="dropdown-menu-trigger"],
[data-slot="dropdown-menu-item"],
[data-slot="dropdown-menu-checkbox-item"],
[data-slot="dropdown-menu-radio-item"],
[data-slot="dropdown-menu-sub-trigger"],
[data-slot="menubar-trigger"],
[data-slot="menubar-item"],
[data-slot="menubar-checkbox-item"],
[data-slot="menubar-radio-item"],
[data-slot="menubar-sub-trigger"],
[data-slot="select-trigger"],
[data-slot="select-item"],
[data-slot="select-scroll-up-button"],
[data-slot="select-scroll-down-button"],
[data-slot="combobox-trigger"],
[data-slot="combobox-clear"],
[data-slot="combobox-item"],
[data-slot="combobox-chip-remove"],
[data-slot="command-item"],
[data-slot="accordion-trigger"],
[data-slot="collapsible-trigger"],
[data-slot="dialog-trigger"],
[data-slot="dialog-close"],
[data-slot="alert-dialog-trigger"],
[data-slot="alert-dialog-action"],
[data-slot="alert-dialog-cancel"],
[data-slot="popover-trigger"],
[data-slot="hover-card-trigger"],
[data-slot="tooltip-trigger"]
):not(:disabled):not([aria-disabled="true"]):not([data-disabled]):not(
[data-disabled="true"]
) {
cursor: pointer;
}
[data-disabled],
[data-disabled="true"] {
cursor: not-allowed;
}
/* Custom scrollbar for dark theme */
::-webkit-scrollbar {
width: 6px;
height: 6px;
@@ -108,12 +183,12 @@
}
::-webkit-scrollbar-thumb {
background: oklch(0.3 0.005 260);
background: oklch(0.82 0.006 270);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: oklch(0.4 0.005 260);
background: oklch(0.72 0.01 270);
}
/* Selection color */
@@ -0,0 +1,71 @@
import { describe, expect, it, vi } from "vitest";
import type { WebviewInboundMessage } from "../../../webview-protocol";
import { HubDesktopClient, isBrowserTransportFailure } from "./desktop-client";
function createClient() {
const postToHost = vi.fn<(message: WebviewInboundMessage) => void>();
const client = new HubDesktopClient({ postToHost, listen: false });
return { client, postToHost };
}
function lastDesktopCommand(postToHost: ReturnType<typeof vi.fn>) {
const message = postToHost.mock.lastCall?.[0] as
| Extract<WebviewInboundMessage, { type: "desktopCommand" }>
| undefined;
if (message?.type !== "desktopCommand") {
throw new Error("Expected a desktop command to be posted");
}
return message;
}
describe("HubDesktopClient", () => {
it("does not reject pending desktop commands for unrelated hub errors", async () => {
const { client, postToHost } = createClient();
const pending = client.invoke<{ installedKeys: string[] }>(
"list_marketplace_installed_entries",
);
const command = lastDesktopCommand(postToHost);
client.handleMessage({
data: { type: "error", text: "Failed to restore previous session." },
});
client.handleMessage({
data: {
type: "desktopCommandResult",
id: command.id,
ok: true,
result: { installedKeys: ["plugin:goal"] },
},
});
await expect(pending).resolves.toEqual({ installedKeys: ["plugin:goal"] });
});
it("rejects pending desktop commands for browser transport failures", async () => {
const { client } = createClient();
const pending = client.invoke("list_marketplace_installed_entries");
client.handleMessage({
data: { type: "status", text: "Disconnected from the Cline Hub server." },
});
await expect(pending).rejects.toThrow(
"Disconnected from the Cline Hub server.",
);
});
it("only treats exact browser lifecycle messages as transport failures", () => {
expect(
isBrowserTransportFailure({
type: "error",
text: "Failed to connect to the Cline Hub server.",
}),
).toBe(true);
expect(
isBrowserTransportFailure({
type: "error",
text: "Failed to restore previous session.",
}),
).toBe(false);
});
});
@@ -3,28 +3,62 @@
import type { WebviewOutboundMessage } from "../../../webview-protocol";
import { postToHost } from "../vscode";
type PostToHost = typeof postToHost;
type PendingRequest = {
command: string;
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout>;
};
const REQUEST_TIMEOUT_MS = 120_000;
const BROWSER_TRANSPORT_FAILURE_MESSAGES = new Set([
"Disconnected from the Cline Hub server.",
"Failed to connect to the Cline Hub server.",
"Received an invalid message from the Cline Hub server.",
]);
class HubDesktopClient {
export function isBrowserTransportFailure(
message: WebviewOutboundMessage,
): boolean {
if (message.type !== "status" && message.type !== "error") {
return false;
}
return BROWSER_TRANSPORT_FAILURE_MESSAGES.has(message.text);
}
export class HubDesktopClient {
private requestCounter = 0;
private readonly pending = new Map<string, PendingRequest>();
private readonly postToHost: PostToHost;
constructor() {
if (typeof window !== "undefined") {
constructor(options: { postToHost?: PostToHost; listen?: boolean } = {}) {
this.postToHost = options.postToHost ?? postToHost;
if ((options.listen ?? true) && typeof window !== "undefined") {
window.addEventListener("message", (event) => {
this.handleMessage(event as MessageEvent<WebviewOutboundMessage>);
});
}
}
private handleMessage(event: MessageEvent<WebviewOutboundMessage>) {
handleMessage(event: Pick<MessageEvent<WebviewOutboundMessage>, "data">) {
const message = event.data;
if (
message &&
typeof message === "object" &&
(message.type === "status" || message.type === "error")
) {
if (isBrowserTransportFailure(message) && this.pending.size > 0) {
const error = new Error(message.text);
for (const pending of this.pending.values()) {
clearTimeout(pending.timeoutId);
pending.reject(error);
}
this.pending.clear();
}
return;
}
if (
!message ||
typeof message !== "object" ||
@@ -46,19 +80,24 @@ class HubDesktopClient {
pending.reject(new Error(message.error));
}
async invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
async invoke<T>(
command: string,
args?: Record<string, unknown>,
options?: { timeoutMs?: number },
): Promise<T> {
const id = `desktop_${Date.now()}_${this.requestCounter++}`;
return await new Promise<T>((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Timed out waiting for desktop command: ${command}`));
}, REQUEST_TIMEOUT_MS);
}, options?.timeoutMs ?? REQUEST_TIMEOUT_MS);
this.pending.set(id, {
command,
resolve: (value) => resolve(value as T),
reject,
timeoutId,
});
postToHost({ type: "desktopCommand", id, command, args });
this.postToHost({ type: "desktopCommand", id, command, args });
});
}
}
@@ -0,0 +1,189 @@
export type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
export type MarketplaceTag = {
id: string;
label: string;
count: number;
};
export type MarketplaceEnvVar = {
name: string;
required?: boolean;
description?: string;
url?: string;
};
export type MarketplaceEntry = {
id: string;
type: MarketplacePrimitiveType;
name: string;
tagline: string;
description: string;
tags: string[];
install: {
args: string[];
env?: MarketplaceEnvVar[];
notes?: string;
command: string;
};
};
export type MarketplaceCatalog = {
version: number;
generatedAt?: string;
baseUrl?: string;
counts: {
total: number;
plugins: number;
skills: number;
mcps: number;
};
tags: MarketplaceTag[];
entries: MarketplaceEntry[];
};
const MARKETPLACE_CATALOG_URL = "/api/marketplace/catalog";
const EMPTY_CATALOG: MarketplaceCatalog = {
version: 1,
counts: {
total: 0,
plugins: 0,
skills: 0,
mcps: 0,
},
tags: [],
entries: [],
};
function isPrimitiveType(value: unknown): value is MarketplacePrimitiveType {
return value === "mcp" || value === "skill" || value === "plugin";
}
function toStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === "string")
: [];
}
function parseCount(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
function parseEnv(value: unknown): MarketplaceEnvVar[] | undefined {
if (!Array.isArray(value)) return undefined;
const env = value
.map((item): MarketplaceEnvVar | null => {
if (!item || typeof item !== "object") return null;
const candidate = item as Record<string, unknown>;
if (typeof candidate.name !== "string") return null;
const parsed: MarketplaceEnvVar = {
name: candidate.name,
};
if (typeof candidate.required === "boolean") {
parsed.required = candidate.required;
}
if (typeof candidate.description === "string") {
parsed.description = candidate.description;
}
if (typeof candidate.url === "string") {
parsed.url = candidate.url;
}
return parsed;
})
.filter((item): item is MarketplaceEnvVar => item !== null);
return env.length > 0 ? env : undefined;
}
export async function fetchMarketplaceCatalog(): Promise<MarketplaceCatalog> {
const response = await fetch(MARKETPLACE_CATALOG_URL, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`Failed to fetch marketplace catalog: ${response.status}`);
}
const data = await response.json();
const baseUrl = typeof data?.baseUrl === "string" ? data.baseUrl : undefined;
const rawCounts =
typeof data?.counts === "object" && data.counts !== null ? data.counts : {};
const tags: MarketplaceTag[] = Array.isArray(data?.tags)
? data.tags
.map((tag: unknown) => {
if (!tag || typeof tag !== "object") return null;
const candidate = tag as Record<string, unknown>;
if (
typeof candidate.id !== "string" ||
typeof candidate.label !== "string"
) {
return null;
}
return {
id: candidate.id,
label: candidate.label,
count: parseCount(candidate.count),
};
})
.filter(
(tag: MarketplaceTag | null): tag is MarketplaceTag => tag !== null,
)
: [];
const entries: MarketplaceEntry[] = Array.isArray(data?.entries)
? data.entries
.map((entry: unknown) => {
if (!entry || typeof entry !== "object") return null;
const candidate = entry as Record<string, unknown>;
const install =
typeof candidate.install === "object" && candidate.install !== null
? (candidate.install as Record<string, unknown>)
: {};
if (
typeof candidate.id !== "string" ||
!isPrimitiveType(candidate.type) ||
typeof candidate.name !== "string" ||
typeof candidate.tagline !== "string" ||
typeof candidate.description !== "string" ||
typeof install.command !== "string"
) {
return null;
}
return {
id: candidate.id,
type: candidate.type,
name: candidate.name,
tagline: candidate.tagline,
description: candidate.description,
tags: toStringArray(candidate.tags),
install: {
args: toStringArray(install.args),
command: install.command,
env: parseEnv(install.env),
notes:
typeof install.notes === "string" ? install.notes : undefined,
},
};
})
.filter(
(entry: MarketplaceEntry | null): entry is MarketplaceEntry =>
entry !== null && entry.install.args.length > 0,
)
: [];
return {
version: parseCount(data?.version) || EMPTY_CATALOG.version,
generatedAt:
typeof data?.generatedAt === "string" ? data.generatedAt : undefined,
baseUrl,
counts: {
total: parseCount(rawCounts.total) || entries.length,
plugins: parseCount(rawCounts.plugins),
skills: parseCount(rawCounts.skills),
mcps: parseCount(rawCounts.mcps),
},
tags,
entries,
};
}
export { EMPTY_CATALOG, MARKETPLACE_CATALOG_URL };
@@ -0,0 +1,30 @@
export const HUB_THEME_STORAGE_KEY = "cline-hub-theme";
export type HubTheme = "light" | "dark";
export function readStoredHubTheme(): HubTheme | null {
const stored = window.localStorage.getItem(HUB_THEME_STORAGE_KEY);
return stored === "light" || stored === "dark" ? stored : null;
}
export function readSystemHubTheme(): HubTheme {
const kind = document.body.dataset.vscodeThemeKind;
return kind === "vscode-dark" || kind === "vscode-high-contrast"
? "dark"
: "light";
}
export function applyHubTheme(theme: HubTheme): HubTheme {
document.documentElement.classList.toggle("dark", theme === "dark");
document.documentElement.dataset.clineHubTheme = theme;
return theme;
}
export function syncHubTheme(): HubTheme {
return applyHubTheme(readStoredHubTheme() ?? readSystemHubTheme());
}
export function setStoredHubTheme(theme: HubTheme): HubTheme {
window.localStorage.setItem(HUB_THEME_STORAGE_KEY, theme);
return applyHubTheme(theme);
}
+4 -12
View File
@@ -3,23 +3,15 @@ import { createRoot } from "react-dom/client";
import "./index.css";
import { TooltipProvider } from "@/components/ui/tooltip";
import App from "./App.tsx";
import { syncHubTheme } from "./lib/theme";
/**
* Sync the `dark` class on <html> with the VS Code theme.
* VS Code sets `data-vscode-theme-kind` on <body> to one of:
* "vscode-light" | "vscode-dark" | "vscode-high-contrast" | "vscode-high-contrast-light"
*/
function syncTheme() {
const kind = document.body.dataset.vscodeThemeKind;
const isDark = kind === "vscode-dark" || kind === "vscode-high-contrast";
document.documentElement.classList.toggle("dark", isDark);
}
window.addEventListener("cline-hub-theme-change", syncHubTheme);
// Apply immediately
syncTheme();
syncHubTheme();
// Re-apply whenever VS Code changes the theme attribute
const observer = new MutationObserver(syncTheme);
const observer = new MutationObserver(syncHubTheme);
observer.observe(document.body, {
attributes: true,
attributeFilter: ["data-vscode-theme-kind"],
+15 -9
View File
@@ -24,6 +24,13 @@ function dispatchHostMessage(message: WebviewOutboundMessage): void {
window.dispatchEvent(new MessageEvent("message", { data: message }));
}
function readBrowserRoomSecret(): string | undefined {
const roomSecret = new URLSearchParams(window.location.search)
.get("roomSecret")
?.trim();
return roomSecret || undefined;
}
function createBrowserSocket(): WebSocket {
if (
browserSocket &&
@@ -35,16 +42,13 @@ function createBrowserSocket(): WebSocket {
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const params = new URLSearchParams();
const roomSecret = new URLSearchParams(window.location.search)
.get("roomSecret")
?.trim();
const roomSecret = readBrowserRoomSecret();
if (roomSecret) {
params.set("roomSecret", roomSecret);
}
const query = params.toString();
browserSocket = new WebSocket(
`${protocol}//${window.location.host}/browser${query ? `?${query}` : ""}`,
);
const socketUrl = `${protocol}//${window.location.host}/browser${query ? `?${query}` : ""}`;
browserSocket = new WebSocket(socketUrl);
browserSocket.addEventListener("open", () => {
for (const message of pendingMessages.splice(0)) {
browserSocket?.send(JSON.stringify(message));
@@ -52,10 +56,10 @@ function createBrowserSocket(): WebSocket {
});
browserSocket.addEventListener("message", (event) => {
try {
dispatchHostMessage(
JSON.parse(String(event.data)) as WebviewOutboundMessage,
);
const message = JSON.parse(String(event.data)) as WebviewOutboundMessage;
dispatchHostMessage(message);
} catch {
pendingMessages.splice(0);
dispatchHostMessage({
type: "error",
text: "Received an invalid message from the Cline Hub server.",
@@ -63,12 +67,14 @@ function createBrowserSocket(): WebSocket {
}
});
browserSocket.addEventListener("close", () => {
pendingMessages.splice(0);
dispatchHostMessage({
type: "status",
text: "Disconnected from the Cline Hub server.",
});
});
browserSocket.addEventListener("error", () => {
pendingMessages.splice(0);
dispatchHostMessage({
type: "error",
text: "Failed to connect to the Cline Hub server.",
+33
View File
@@ -0,0 +1,33 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const rootDir = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
root: rootDir,
resolve: {
alias: [
{
find: /^@cline\/core$/,
replacement: resolve(rootDir, "../../sdk/packages/core/src/index.ts"),
},
{
find: /^@cline\/core\/(.+)$/,
replacement: resolve(rootDir, "../../sdk/packages/core/src/$1"),
},
{
find: /^@cline\/shared$/,
replacement: resolve(rootDir, "../../sdk/packages/shared/src/index.ts"),
},
{
find: /^@cline\/shared\/(.+)$/,
replacement: resolve(rootDir, "../../sdk/packages/shared/src/$1"),
},
],
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});
+10
View File
@@ -127,6 +127,7 @@ message ClineRecommendedModel {
message ClineRecommendedModelsResponse {
repeated ClineRecommendedModel recommended = 1;
repeated ClineRecommendedModel free = 2;
repeated ClineRecommendedModel cline_pass = 3;
}
// Request for fetching OpenAI models
@@ -287,6 +288,8 @@ message ModelsApiOptions {
optional OpenAiCompatibleModelInfo plan_mode_aihubmix_model_info = 134;
optional string plan_mode_cline_model_id = 135;
optional OpenRouterModelInfo plan_mode_cline_model_info = 136;
optional string plan_mode_cline_pass_model_id = 137;
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 138;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
@@ -326,6 +329,8 @@ message ModelsApiOptions {
optional OpenAiCompatibleModelInfo act_mode_aihubmix_model_info = 234;
optional string act_mode_cline_model_id = 235;
optional OpenRouterModelInfo act_mode_cline_model_info = 236;
optional string act_mode_cline_pass_model_id = 237;
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 238;
}
// Request for updating API configuration (legacy - uses combined configuration)
@@ -461,6 +466,7 @@ enum ApiProvider {
NOUSRESEARCH = 39;
OPENAI_CODEX = 40;
WANDB = 41;
CLINE_PASS = 42;
}
enum ApiFormat {
@@ -644,6 +650,8 @@ message ModelsApiConfiguration {
optional string gemini_plan_mode_thinking_level = 139;
optional string plan_mode_cline_model_id = 140;
optional OpenRouterModelInfo plan_mode_cline_model_info = 141;
optional string plan_mode_cline_pass_model_id = 142;
optional OpenRouterModelInfo plan_mode_cline_pass_model_info = 143;
// Act mode configurations
optional ApiProvider act_mode_api_provider = 200;
@@ -688,4 +696,6 @@ message ModelsApiConfiguration {
optional string gemini_act_mode_thinking_level = 239;
optional string act_mode_cline_model_id = 240;
optional OpenRouterModelInfo act_mode_cline_model_info = 241;
optional string act_mode_cline_pass_model_id = 242;
optional OpenRouterModelInfo act_mode_cline_pass_model_info = 243;
}
+34 -5
View File
@@ -1,6 +1,8 @@
import { ApiConfiguration, ModelInfo, QwenApiRegions } from "@shared/api"
import { ApiConfiguration, clinePassDefaultModelId, ModelInfo, QwenApiRegions, resolveClinePassModelInfo } from "@shared/api"
import { Mode } from "@shared/storage/types"
import { featureFlagsService } from "@/services/feature-flags"
import { ClineStorageMessage } from "@/shared/messages/content"
import { FeatureFlag } from "@/shared/services/feature-flags/feature-flags"
import { Logger } from "@/shared/services/Logger"
import { ClineTool } from "@/shared/tools"
import { AIhubmixHandler } from "./providers/aihubmix"
@@ -78,7 +80,10 @@ function createHandlerForProvider(
options: Omit<ApiConfiguration, "apiProvider">,
mode: Mode,
): ApiHandler {
switch (apiProvider) {
const effectiveApiProvider =
apiProvider === "cline-pass" && !featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLINE_PASS) ? "cline" : apiProvider
switch (effectiveApiProvider) {
case "anthropic":
return new AnthropicHandler({
onRetryAttempt: options.onRetryAttempt,
@@ -258,11 +263,12 @@ function createHandlerForProvider(
mode === "plan" ? options.planModeVsCodeLmModelSelector : options.actModeVsCodeLmModelSelector,
})
case "cline": {
const configuredClineModelId = mode === "plan" ? options.planModeClineModelId : options.actModeClineModelId
const configuredClineModelInfo = mode === "plan" ? options.planModeClineModelInfo : options.actModeClineModelInfo
const clineModelId =
(mode === "plan" ? options.planModeClineModelId : options.actModeClineModelId) ||
(mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId)
configuredClineModelId || (mode === "plan" ? options.planModeOpenRouterModelId : options.actModeOpenRouterModelId)
const clineModelInfo =
(mode === "plan" ? options.planModeClineModelInfo : options.actModeClineModelInfo) ||
configuredClineModelInfo ||
(mode === "plan" ? options.planModeOpenRouterModelInfo : options.actModeOpenRouterModelInfo)
return new ClineHandler({
onRetryAttempt: options.onRetryAttempt,
@@ -278,6 +284,29 @@ function createHandlerForProvider(
enableParallelToolCalling: options.enableParallelToolCalling,
})
}
case "cline-pass": {
const configuredClinePassModelId =
mode === "plan" ? options.planModeClinePassModelId : options.actModeClinePassModelId
const configuredClinePassModelInfo =
mode === "plan" ? options.planModeClinePassModelInfo : options.actModeClinePassModelInfo
const clineModelId = configuredClinePassModelId?.startsWith("cline-pass/")
? configuredClinePassModelId
: clinePassDefaultModelId
const clineModelInfo = configuredClinePassModelInfo || resolveClinePassModelInfo(clineModelId)
return new ClineHandler({
onRetryAttempt: options.onRetryAttempt,
clineAccountId: options.clineAccountId,
clineApiKey: options.clineApiKey,
ulid: options.ulid,
reasoningEffort: mode === "plan" ? options.planModeReasoningEffort : options.actModeReasoningEffort,
thinkingBudgetTokens:
mode === "plan" ? options.planModeThinkingBudgetTokens : options.actModeThinkingBudgetTokens,
openRouterProviderSorting: options.openRouterProviderSorting,
openRouterModelId: clineModelId,
openRouterModelInfo: clineModelInfo,
enableParallelToolCalling: options.enableParallelToolCalling,
})
}
case "litellm":
return new LiteLlmHandler({
onRetryAttempt: options.onRetryAttempt,
@@ -3,6 +3,7 @@ import { openRouterDefaultModelInfo } from "@shared/api"
import sinon from "sinon"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import { ClineError, ClineErrorType } from "@/services/error/ClineError"
import { ClineHandler } from "../cline"
describe("ClineHandler", () => {
@@ -163,4 +164,34 @@ describe("ClineHandler", () => {
payload.messages[0].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
payload.messages[1].content[0].cache_control.should.deepEqual({ type: "ephemeral" })
})
it("propagates a pre-stream 403 entitlement error so it classifies as Entitlement", async () => {
const handler = createHandler({})
// A 403 from ValidateModelEntitlement rejects completions.create() before streaming,
// matching the OpenAI SDK APIError shape (status + code + error body).
const apiError = Object.assign(new Error("403 the user is not subscribed to required model plan"), {
status: 403,
code: "ENTITLEMENT_ERROR",
error: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
})
const fakeClient = { chat: { completions: { create: sinon.stub().rejects(apiError) } } }
sinon.stub(handler as any, "ensureClient").resolves(fakeClient as any)
sinon.stub(handler as any, "getFreeModelIdSet").resolves(new Set())
sinon.stub(handler, "getModel").returns({ id: "cline-pass/glm-5.1", info: openRouterDefaultModelInfo })
let thrown: unknown
try {
for await (const _chunk of handler.createMessage("system", [{ role: "user", content: "hi" }])) {
// drain
}
} catch (e) {
thrown = e
}
const clineError = ClineError.transform(thrown, "cline-pass/glm-5.1", "cline-pass")
clineError.isErrorType(ClineErrorType.Entitlement).should.be.true()
})
})
+8 -3
View File
@@ -1,4 +1,4 @@
import { type ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import { clinePassModels, type ModelInfo, openRouterDefaultModelId, openRouterDefaultModelInfo } from "@shared/api"
import { shouldSkipReasoningForModel } from "@utils/model-utils"
import axios from "axios"
import OpenAI from "openai"
@@ -37,7 +37,10 @@ function normalizeModelId(modelId: string): string {
return modelId.trim().toLowerCase()
}
const CLINE_FREE_MODEL_IDS = new Set(CLINE_RECOMMENDED_MODELS_FALLBACK.free.map((model) => normalizeModelId(model.id)))
const CLINE_FREE_MODEL_IDS = new Set([
...CLINE_RECOMMENDED_MODELS_FALLBACK.free.map((model) => normalizeModelId(model.id)),
...Object.keys(clinePassModels).map((modelId) => normalizeModelId(modelId)),
])
function getCacheReadTokens(usage: any): number {
return usage?.prompt_tokens_details?.cached_tokens || usage?.cache_read_input_tokens || 0
@@ -67,7 +70,9 @@ export class ClineHandler implements ApiHandler {
private async getFreeModelIdSet(): Promise<Set<string>> {
try {
const models = await refreshClineRecommendedModels()
const freeModelIds = models.free.map((model) => normalizeModelId(model.id)).filter((modelId) => modelId.length > 0)
const freeModelIds = [...models.free, ...models.clinePass]
.map((model) => normalizeModelId(model.id))
.filter((modelId) => modelId.length > 0)
if (freeModelIds.length > 0) {
return new Set(freeModelIds)
}
@@ -0,0 +1,60 @@
import * as assert from "assert";
import { afterEach, beforeEach, describe, it } from "mocha";
import sinon from "sinon";
import { Logger } from "@/shared/services/Logger";
import type { Controller } from "../../index";
import { clearOrganizationForClinePassProviderSelection } from "../handleClinePassProviderSelection";
describe("clearOrganizationForClinePassProviderSelection", () => {
let sandbox: sinon.SinonSandbox;
let switchAccount: sinon.SinonStub;
beforeEach(() => {
sandbox = sinon.createSandbox();
switchAccount = sandbox.stub().resolves();
sandbox.stub(Logger, "debug");
});
afterEach(() => {
sandbox.restore();
});
function createController(): Controller {
return {
accountService: { switchAccount },
} as unknown as Controller;
}
it("does nothing when Cline Pass is not selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline",
actModeApiProvider: "openrouter",
});
assert.strictEqual(switchAccount.callCount, 0);
});
it("switches to the personal account when Cline Pass is selected", async () => {
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline-pass",
actModeApiProvider: "openrouter",
});
assert.strictEqual(switchAccount.callCount, 1);
assert.strictEqual(switchAccount.firstCall.args[0], null);
});
it("logs and swallows account switch failures", async () => {
const error = new Error("not signed in");
switchAccount.rejects(error);
await clearOrganizationForClinePassProviderSelection(createController(), {
planModeApiProvider: "cline",
actModeApiProvider: "cline-pass",
});
assert.strictEqual(switchAccount.callCount, 1);
assert.strictEqual(switchAccount.firstCall.args[0], null);
assert.ok((Logger.debug as sinon.SinonStub).calledOnce);
});
});
@@ -1,27 +1,30 @@
import * as disk from "@core/storage/disk"
import axios from "axios"
import { expect } from "chai"
import fs from "fs/promises"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { ClineEnv, Environment } from "@/config"
import { Logger } from "@/shared/services/Logger"
import { refreshClineRecommendedModels, resetClineRecommendedModelsCacheForTests } from "../refreshClineRecommendedModels"
import * as disk from "@core/storage/disk";
import axios from "axios";
import { expect } from "chai";
import fs from "fs/promises";
import { afterEach, beforeEach, describe, it } from "mocha";
import sinon from "sinon";
import { ClineEnv, Environment } from "@/config";
import { Logger } from "@/shared/services/Logger";
import {
refreshClineRecommendedModels,
resetClineRecommendedModelsCacheForTests,
} from "../refreshClineRecommendedModels";
describe("refreshClineRecommendedModels", () => {
let sandbox: sinon.SinonSandbox
let sandbox: sinon.SinonSandbox;
beforeEach(() => {
sandbox = sinon.createSandbox()
resetClineRecommendedModelsCacheForTests()
sandbox.stub(Logger, "log")
sandbox.stub(Logger, "error")
})
sandbox = sinon.createSandbox();
resetClineRecommendedModelsCacheForTests();
sandbox.stub(Logger, "log");
sandbox.stub(Logger, "error");
});
afterEach(() => {
resetClineRecommendedModelsCacheForTests()
sandbox.restore()
})
resetClineRecommendedModelsCacheForTests();
sandbox.restore();
});
it("fetches from upstream", async () => {
sandbox.stub(ClineEnv, "config").returns({
@@ -29,19 +32,32 @@ describe("refreshClineRecommendedModels", () => {
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
})
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
sandbox.stub(fs, "writeFile").resolves()
});
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp");
sandbox.stub(fs, "writeFile").resolves();
const axiosGetStub = sandbox.stub(axios, "get").resolves({
data: {
recommended: [{ id: "anthropic/claude-sonnet-4.6", description: "Remote recommended", tags: ["NEW"] }],
recommended: [
{
id: "anthropic/claude-sonnet-4.6",
description: "Remote recommended",
tags: ["NEW"],
},
],
free: [{ id: "z-ai/glm-5", description: "Remote free" }],
clinePass: [
{
id: "cline-pass/glm-5",
description: "Remote Cline Pass",
tags: ["CLINE_PASS"],
},
],
},
})
});
const result = await refreshClineRecommendedModels()
const result = await refreshClineRecommendedModels();
expect(axiosGetStub.calledOnce).to.equal(true)
expect(axiosGetStub.calledOnce).to.equal(true);
expect(result).to.deep.equal({
recommended: [
{
@@ -59,8 +75,16 @@ describe("refreshClineRecommendedModels", () => {
tags: [],
},
],
})
})
clinePass: [
{
id: "cline-pass/glm-5",
name: "cline-pass/glm-5",
description: "Remote Cline Pass",
tags: ["CLINE_PASS"],
},
],
});
});
it("uses the in-memory cache after upstream cache is populated", async () => {
sandbox.stub(ClineEnv, "config").returns({
@@ -68,20 +92,39 @@ describe("refreshClineRecommendedModels", () => {
appBaseUrl: "https://app.cline-mock.bot",
apiBaseUrl: "https://api.cline-mock.bot",
mcpBaseUrl: "https://api.cline-mock.bot/v1/mcp",
})
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp")
sandbox.stub(fs, "writeFile").resolves()
});
sandbox.stub(disk, "ensureCacheDirectoryExists").resolves("/tmp");
sandbox.stub(fs, "writeFile").resolves();
const axiosGetStub = sandbox.stub(axios, "get").resolves({
data: {
recommended: [{ id: "google/gemini-3.1-pro-preview", description: "Remote recommended", tags: ["NEW"] }],
free: [{ id: "minimax/minimax-m2.5", description: "Remote free", tags: ["FREE"] }],
recommended: [
{
id: "google/gemini-3.1-pro-preview",
description: "Remote recommended",
tags: ["NEW"],
},
],
free: [
{
id: "minimax/minimax-m2.5",
description: "Remote free",
tags: ["FREE"],
},
],
clinePass: [
{
id: "cline-pass/glm-5",
description: "Remote Cline Pass",
tags: ["CLINE_PASS"],
},
],
},
})
});
const firstResult = await refreshClineRecommendedModels()
const secondResult = await refreshClineRecommendedModels()
const firstResult = await refreshClineRecommendedModels();
const secondResult = await refreshClineRecommendedModels();
expect(axiosGetStub.calledOnce).to.equal(true)
expect(secondResult).to.deep.equal(firstResult)
})
})
expect(axiosGetStub.calledOnce).to.equal(true);
expect(secondResult).to.deep.equal(firstResult);
});
});
@@ -0,0 +1,29 @@
import type { ApiConfiguration } from "@shared/api"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
export const CLINE_PASS_PROVIDER_ID = "cline-pass"
/**
* Cline Pass always uses the user's personal Cline account balance.
*
* This is intentionally best-effort: selecting the provider should still be
* saved even if the account switch fails.
*/
export async function clearOrganizationForClinePassProviderSelection(
controller: Controller,
apiConfiguration: Pick<ApiConfiguration, "planModeApiProvider" | "actModeApiProvider">,
): Promise<void> {
if (
apiConfiguration.planModeApiProvider !== CLINE_PASS_PROVIDER_ID &&
apiConfiguration.actModeApiProvider !== CLINE_PASS_PROVIDER_ID
) {
return
}
try {
await controller.accountService.switchAccount(null)
} catch (error) {
Logger.debug("Failed to switch Cline Pass to personal account", { error })
}
}
@@ -1,137 +1,192 @@
import { ensureCacheDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import axios from "axios"
import fs from "fs/promises"
import path from "path"
import { ClineEnv } from "@/config"
import { getAxiosSettings } from "@/shared/net"
import { Logger } from "@/shared/services/Logger"
import {
ensureCacheDirectoryExists,
GlobalFileNames,
} from "@core/storage/disk";
import axios from "axios";
import fs from "fs/promises";
import path from "path";
import { ClineEnv } from "@/config";
import { getAxiosSettings } from "@/shared/net";
import { Logger } from "@/shared/services/Logger";
export interface ClineRecommendedModelData {
id: string
name: string
description: string
tags: string[]
id: string;
name: string;
description: string;
tags: string[];
}
export interface ClineRecommendedModelsData {
recommended: ClineRecommendedModelData[]
free: ClineRecommendedModelData[]
recommended: ClineRecommendedModelData[];
free: ClineRecommendedModelData[];
clinePass: ClineRecommendedModelData[];
}
const RECOMMENDED_MODELS_CACHE_TTL_MS = 60 * 60 * 1000
const RECOMMENDED_MODELS_CACHE_TTL_MS = 60 * 60 * 1000;
let pendingRefresh: Promise<ClineRecommendedModelsData> | null = null
let inMemoryCache: { data: ClineRecommendedModelsData; timestamp: number } | null = null
let pendingRefresh: Promise<ClineRecommendedModelsData> | null = null;
let inMemoryCache: {
data: ClineRecommendedModelsData;
timestamp: number;
} | null = null;
function normalizeRecommendedModel(raw: unknown): ClineRecommendedModelData | null {
function normalizeRecommendedModel(
raw: unknown,
): ClineRecommendedModelData | null {
if (!raw || typeof raw !== "object") {
return null
return null;
}
const data = raw as Record<string, unknown>
const data = raw as Record<string, unknown>;
if (typeof data.id !== "string" || data.id.length === 0) {
return null
return null;
}
return {
id: data.id,
name: typeof data.name === "string" && data.name.length > 0 ? data.name : data.id,
name:
typeof data.name === "string" && data.name.length > 0
? data.name
: data.id,
description: typeof data.description === "string" ? data.description : "",
tags: Array.isArray(data.tags) ? data.tags.filter((tag): tag is string => typeof tag === "string") : [],
}
tags: Array.isArray(data.tags)
? data.tags.filter((tag): tag is string => typeof tag === "string")
: [],
};
}
function normalizeRecommendedModelsResponse(raw: unknown): ClineRecommendedModelsData | null {
function normalizeRecommendedModelsResponse(
raw: unknown,
): ClineRecommendedModelsData | null {
if (!raw || typeof raw !== "object") {
return null
return null;
}
const data = raw as Record<string, unknown>
const data = raw as Record<string, unknown>;
if (
(data.recommended !== undefined && !Array.isArray(data.recommended)) ||
(data.free !== undefined && !Array.isArray(data.free))
(data.free !== undefined && !Array.isArray(data.free)) ||
(data.clinePass !== undefined && !Array.isArray(data.clinePass))
) {
return null
return null;
}
const recommendedRaw = Array.isArray(data.recommended) ? data.recommended : []
const freeRaw = Array.isArray(data.free) ? data.free : []
const recommendedRaw = Array.isArray(data.recommended)
? data.recommended
: [];
const freeRaw = Array.isArray(data.free) ? data.free : [];
const clinePassRaw = Array.isArray(data.clinePass) ? data.clinePass : [];
const recommended = recommendedRaw
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null)
.filter((model): model is ClineRecommendedModelData => model !== null);
const free = freeRaw
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null)
.filter((model): model is ClineRecommendedModelData => model !== null);
return { recommended, free }
const clinePass = clinePassRaw
.map((model) => normalizeRecommendedModel(model))
.filter((model): model is ClineRecommendedModelData => model !== null);
return { recommended, free, clinePass };
}
export async function refreshClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
if (inMemoryCache && Date.now() - inMemoryCache.timestamp <= RECOMMENDED_MODELS_CACHE_TTL_MS) {
return inMemoryCache.data
if (
inMemoryCache &&
Date.now() - inMemoryCache.timestamp <= RECOMMENDED_MODELS_CACHE_TTL_MS
) {
return inMemoryCache.data;
}
if (pendingRefresh) {
return pendingRefresh
return pendingRefresh;
}
pendingRefresh = (async () => {
try {
return await fetchAndCacheClineRecommendedModels()
return await fetchAndCacheClineRecommendedModels();
} finally {
pendingRefresh = null
pendingRefresh = null;
}
})()
})();
return pendingRefresh
return pendingRefresh;
}
export function resetClineRecommendedModelsCacheForTests(): void {
pendingRefresh = null
inMemoryCache = null
pendingRefresh = null;
inMemoryCache = null;
}
async function fetchAndCacheClineRecommendedModels(): Promise<ClineRecommendedModelsData> {
const clineRecommendedModelsFilePath = path.join(await ensureCacheDirectoryExists(), GlobalFileNames.clineRecommendedModels)
let result: ClineRecommendedModelsData = { recommended: [], free: [] }
const clineRecommendedModelsFilePath = path.join(
await ensureCacheDirectoryExists(),
GlobalFileNames.clineRecommendedModels,
);
let result: ClineRecommendedModelsData = {
recommended: [],
free: [],
clinePass: [],
};
try {
const apiBaseUrl = ClineEnv.config().apiBaseUrl
const response = await axios.get(`${apiBaseUrl}/api/v1/ai/cline/recommended-models`, getAxiosSettings())
const normalized = normalizeRecommendedModelsResponse(response.data)
const apiBaseUrl = ClineEnv.config().apiBaseUrl;
const response = await axios.get(
`${apiBaseUrl}/api/v1/ai/cline/recommended-models`,
getAxiosSettings(),
);
const normalized = normalizeRecommendedModelsResponse(response.data);
if (!normalized) {
throw new Error("Invalid response data when fetching Cline recommended models")
throw new Error(
"Invalid response data when fetching Cline recommended models",
);
}
result = normalized
await fs.writeFile(clineRecommendedModelsFilePath, JSON.stringify(result))
Logger.log("Cline recommended models fetched and saved")
result = normalized;
await fs.writeFile(clineRecommendedModelsFilePath, JSON.stringify(result));
Logger.log("Cline recommended models fetched and saved");
} catch (error) {
Logger.error("Error fetching Cline recommended models:", error)
Logger.error("Error fetching Cline recommended models:", error);
try {
const fileExists = await fs
.access(clineRecommendedModelsFilePath)
.then(() => true)
.catch(() => false)
.catch(() => false);
if (fileExists) {
const fileContents = await fs.readFile(clineRecommendedModelsFilePath, "utf8")
const parsed = JSON.parse(fileContents)
const fileContents = await fs.readFile(
clineRecommendedModelsFilePath,
"utf8",
);
const parsed = JSON.parse(fileContents);
if (parsed) {
result = parsed
Logger.log("Loaded Cline recommended models from cache")
result = {
recommended: Array.isArray(parsed.recommended)
? parsed.recommended
: [],
free: Array.isArray(parsed.free) ? parsed.free : [],
clinePass: Array.isArray(parsed.clinePass) ? parsed.clinePass : [],
};
Logger.log("Loaded Cline recommended models from cache");
}
}
} catch (cacheError) {
Logger.error("Error reading Cline recommended models from cache:", cacheError)
Logger.error(
"Error reading Cline recommended models from cache:",
cacheError,
);
}
}
// Avoid pinning empty results in memory for the full TTL after a transient API/cache miss.
if (result.recommended.length > 0 || result.free.length > 0) {
inMemoryCache = { data: result, timestamp: Date.now() }
if (
result.recommended.length > 0 ||
result.free.length > 0 ||
result.clinePass.length > 0
) {
inMemoryCache = { data: result, timestamp: Date.now() };
}
return result
return result;
}
@@ -25,5 +25,13 @@ export async function refreshClineRecommendedModelsRpc(
tags: model.tags,
}),
),
clinePass: models.clinePass.map((model) =>
ClineRecommendedModel.create({
id: model.id,
name: model.name,
description: model.description,
tags: model.tags,
}),
),
})
}
@@ -6,6 +6,7 @@ import { UpdateApiConfigurationRequestNew } from "@/shared/proto/index.cline"
import { Logger } from "@/shared/services/Logger"
import { Secrets } from "@/shared/storage/state-keys"
import type { Controller } from "../index"
import { clearOrganizationForClinePassProviderSelection } from "./handleClinePassProviderSelection"
/**
* Parses field mask paths into separate sets for options and secrets
@@ -41,7 +42,8 @@ function parseFieldMask(updateMask: string[]): {
function getAlternateModeField(fieldName: string): string | null {
if (fieldName.startsWith("planMode")) {
return fieldName.replace("planMode", "actMode")
} else if (fieldName.startsWith("actMode")) {
}
if (fieldName.startsWith("actMode")) {
return fieldName.replace("actMode", "planMode")
}
return null
@@ -136,6 +138,7 @@ export async function updateApiConfiguration(controller: Controller, request: Up
}
if (Object.keys(options).length > 0) {
controller.stateManager.setGlobalStateBatch(options)
await clearOrganizationForClinePassProviderSelection(controller, controller.stateManager.getApiConfiguration())
}
// Update the task's API handler if there's an active task
@@ -4,6 +4,7 @@ import { UpdateApiConfigurationPartialRequest } from "@shared/proto/cline/models
import { convertProtoToApiConfiguration } from "@shared/proto-conversions/models/api-configuration-conversion"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
import { clearOrganizationForClinePassProviderSelection } from "./handleClinePassProviderSelection"
/**
* Updates API configuration with partial values using FieldMask
@@ -42,6 +43,7 @@ export async function updateApiConfigurationPartial(
// Update storage and task API handler
controller.stateManager.setApiConfiguration(updatedConfig)
await clearOrganizationForClinePassProviderSelection(controller, updatedConfig)
if (controller.task) {
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
controller.task.api = buildApiHandler({ ...updatedConfig, ulid: controller.task.ulid }, currentMode)
@@ -1,16 +1,17 @@
import { Empty } from "@shared/proto/cline/common"
import { UpdateApiConfigurationRequest } from "@shared/proto/cline/models"
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion"
import { Empty } from "@shared/proto/cline/common";
import type { UpdateApiConfigurationRequest } from "@shared/proto/cline/models";
import { convertProtoToApiProvider } from "@shared/proto-conversions/models/api-configuration-conversion";
import {
fromProtobufLiteLLMModelInfo,
fromProtobufModelInfo,
fromProtobufOcaModelInfo,
fromProtobufOpenAiCompatibleModelInfo,
} from "@shared/proto-conversions/models/typeConversion"
import { OpenaiReasoningEffort } from "@shared/storage/types"
import { buildApiHandler } from "@/core/api"
import { Logger } from "@/shared/services/Logger"
import type { Controller } from "../index"
} from "@shared/proto-conversions/models/typeConversion";
import type { OpenaiReasoningEffort } from "@shared/storage/types";
import { buildApiHandler } from "@/core/api";
import { Logger } from "@/shared/services/Logger";
import type { Controller } from "../index";
import { clearOrganizationForClinePassProviderSelection } from "./handleClinePassProviderSelection";
/**
* Updates API configuration
@@ -24,18 +25,22 @@ export async function updateApiConfigurationProto(
): Promise<Empty> {
try {
if (!request.apiConfiguration) {
Logger.log("[APICONFIG: updateApiConfigurationProto] API configuration is required")
throw new Error("API configuration is required")
Logger.log(
"[APICONFIG: updateApiConfigurationProto] API configuration is required",
);
throw new Error("API configuration is required");
}
const protoApiConfiguration = request.apiConfiguration
const protoApiConfiguration = request.apiConfiguration;
const convertedApiConfigurationFromProto = {
...protoApiConfiguration,
// Convert proto ApiProvider enums to native string types
planModeApiProvider:
protoApiConfiguration.planModeApiProvider !== undefined
? convertProtoToApiProvider(protoApiConfiguration.planModeApiProvider!)
? convertProtoToApiProvider(
protoApiConfiguration.planModeApiProvider!,
)
: undefined,
actModeApiProvider:
protoApiConfiguration.actModeApiProvider !== undefined
@@ -44,20 +49,36 @@ export async function updateApiConfigurationProto(
// Convert ModelInfo objects (empty arrays → undefined)
// Plan Mode
planModeOpenRouterModelInfo: protoApiConfiguration.planModeOpenRouterModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeOpenRouterModelInfo)
: undefined,
planModeOpenRouterModelInfo:
protoApiConfiguration.planModeOpenRouterModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeOpenRouterModelInfo,
)
: undefined,
planModeClineModelInfo: protoApiConfiguration.planModeClineModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeClineModelInfo)
: undefined,
planModeClinePassModelInfo:
protoApiConfiguration.planModeClinePassModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeClinePassModelInfo,
)
: undefined,
planModeOpenAiModelInfo: protoApiConfiguration.planModeOpenAiModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.planModeOpenAiModelInfo)
: undefined,
planModeHuggingFaceModelInfo: protoApiConfiguration.planModeHuggingFaceModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeHuggingFaceModelInfo)
? fromProtobufOpenAiCompatibleModelInfo(
protoApiConfiguration.planModeOpenAiModelInfo,
)
: undefined,
planModeHuggingFaceModelInfo:
protoApiConfiguration.planModeHuggingFaceModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeHuggingFaceModelInfo,
)
: undefined,
planModeLiteLlmModelInfo: protoApiConfiguration.planModeLiteLlmModelInfo
? fromProtobufLiteLLMModelInfo(protoApiConfiguration.planModeLiteLlmModelInfo)
? fromProtobufLiteLLMModelInfo(
protoApiConfiguration.planModeLiteLlmModelInfo,
)
: undefined,
planModeRequestyModelInfo: protoApiConfiguration.planModeRequestyModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeRequestyModelInfo)
@@ -65,34 +86,52 @@ export async function updateApiConfigurationProto(
planModeGroqModelInfo: protoApiConfiguration.planModeGroqModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeGroqModelInfo)
: undefined,
planModeHuaweiCloudMaasModelInfo: protoApiConfiguration.planModeHuaweiCloudMaasModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeHuaweiCloudMaasModelInfo)
: undefined,
planModeHuaweiCloudMaasModelInfo:
protoApiConfiguration.planModeHuaweiCloudMaasModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeHuaweiCloudMaasModelInfo,
)
: undefined,
planModeBasetenModelInfo: protoApiConfiguration.planModeBasetenModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeBasetenModelInfo)
: undefined,
planModeVercelAiGatewayModelInfo: protoApiConfiguration.planModeVercelAiGatewayModelInfo
? fromProtobufModelInfo(protoApiConfiguration.planModeVercelAiGatewayModelInfo)
: undefined,
planModeVercelAiGatewayModelInfo:
protoApiConfiguration.planModeVercelAiGatewayModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.planModeVercelAiGatewayModelInfo,
)
: undefined,
planModeOcaModelInfo: protoApiConfiguration.planModeOcaModelInfo
? fromProtobufOcaModelInfo(protoApiConfiguration.planModeOcaModelInfo)
: undefined,
planModeAihubmixModelInfo: protoApiConfiguration.planModeAihubmixModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.planModeAihubmixModelInfo)
? fromProtobufOpenAiCompatibleModelInfo(
protoApiConfiguration.planModeAihubmixModelInfo,
)
: undefined,
// Act Mode
actModeOpenRouterModelInfo: protoApiConfiguration.actModeOpenRouterModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeOpenRouterModelInfo)
: undefined,
actModeOpenRouterModelInfo:
protoApiConfiguration.actModeOpenRouterModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.actModeOpenRouterModelInfo,
)
: undefined,
actModeClineModelInfo: protoApiConfiguration.actModeClineModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeClineModelInfo)
: undefined,
actModeClinePassModelInfo: protoApiConfiguration.actModeClinePassModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeClinePassModelInfo)
: undefined,
actModeOpenAiModelInfo: protoApiConfiguration.actModeOpenAiModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.actModeOpenAiModelInfo)
? fromProtobufOpenAiCompatibleModelInfo(
protoApiConfiguration.actModeOpenAiModelInfo,
)
: undefined,
actModeLiteLlmModelInfo: protoApiConfiguration.actModeLiteLlmModelInfo
? fromProtobufLiteLLMModelInfo(protoApiConfiguration.actModeLiteLlmModelInfo)
? fromProtobufLiteLLMModelInfo(
protoApiConfiguration.actModeLiteLlmModelInfo,
)
: undefined,
actModeRequestyModelInfo: protoApiConfiguration.actModeRequestyModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeRequestyModelInfo)
@@ -100,48 +139,71 @@ export async function updateApiConfigurationProto(
actModeGroqModelInfo: protoApiConfiguration.actModeGroqModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeGroqModelInfo)
: undefined,
actModeHuggingFaceModelInfo: protoApiConfiguration.actModeHuggingFaceModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeHuggingFaceModelInfo)
: undefined,
actModeHuaweiCloudMaasModelInfo: protoApiConfiguration.actModeHuaweiCloudMaasModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeHuaweiCloudMaasModelInfo)
: undefined,
actModeHuggingFaceModelInfo:
protoApiConfiguration.actModeHuggingFaceModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.actModeHuggingFaceModelInfo,
)
: undefined,
actModeHuaweiCloudMaasModelInfo:
protoApiConfiguration.actModeHuaweiCloudMaasModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.actModeHuaweiCloudMaasModelInfo,
)
: undefined,
actModeBasetenModelInfo: protoApiConfiguration.actModeBasetenModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeBasetenModelInfo)
: undefined,
actModeVercelAiGatewayModelInfo: protoApiConfiguration.actModeVercelAiGatewayModelInfo
? fromProtobufModelInfo(protoApiConfiguration.actModeVercelAiGatewayModelInfo)
: undefined,
actModeVercelAiGatewayModelInfo:
protoApiConfiguration.actModeVercelAiGatewayModelInfo
? fromProtobufModelInfo(
protoApiConfiguration.actModeVercelAiGatewayModelInfo,
)
: undefined,
actModeOcaModelInfo: protoApiConfiguration.actModeOcaModelInfo
? fromProtobufOcaModelInfo(protoApiConfiguration.actModeOcaModelInfo)
: undefined,
actModeAihubmixModelInfo: protoApiConfiguration.actModeAihubmixModelInfo
? fromProtobufOpenAiCompatibleModelInfo(protoApiConfiguration.actModeAihubmixModelInfo)
? fromProtobufOpenAiCompatibleModelInfo(
protoApiConfiguration.actModeAihubmixModelInfo,
)
: undefined,
geminiPlanModeThinkingLevel: protoApiConfiguration.geminiPlanModeThinkingLevel,
geminiActModeThinkingLevel: protoApiConfiguration.geminiActModeThinkingLevel,
planModeReasoningEffort: protoApiConfiguration.planModeReasoningEffort as OpenaiReasoningEffort | undefined,
actModeReasoningEffort: protoApiConfiguration.actModeReasoningEffort as OpenaiReasoningEffort | undefined,
}
geminiPlanModeThinkingLevel:
protoApiConfiguration.geminiPlanModeThinkingLevel,
geminiActModeThinkingLevel:
protoApiConfiguration.geminiActModeThinkingLevel,
planModeReasoningEffort: protoApiConfiguration.planModeReasoningEffort as
| OpenaiReasoningEffort
| undefined,
actModeReasoningEffort: protoApiConfiguration.actModeReasoningEffort as
| OpenaiReasoningEffort
| undefined,
};
// Update the API configuration in storage
controller.stateManager.setApiConfiguration(convertedApiConfigurationFromProto)
controller.stateManager.setApiConfiguration(
convertedApiConfigurationFromProto,
);
await clearOrganizationForClinePassProviderSelection(
controller,
convertedApiConfigurationFromProto,
);
// Update the task's API handler if there's an active task
if (controller.task) {
const currentMode = controller.stateManager.getGlobalSettingsKey("mode")
const currentMode = controller.stateManager.getGlobalSettingsKey("mode");
controller.task.api = buildApiHandler(
{ ...convertedApiConfigurationFromProto, ulid: controller.task.ulid },
currentMode,
)
);
}
// Post updated state to webview
await controller.postStateToWebview()
await controller.postStateToWebview();
return Empty.create()
return Empty.create();
} catch (error) {
Logger.error(`Failed to update API configuration: ${error}`)
throw error
Logger.error(`Failed to update API configuration: ${error}`);
throw error;
}
}
@@ -1,176 +1,246 @@
import * as diskStorage from "@core/storage/disk"
import * as remoteConfigFetch from "@core/storage/remote-config/fetch"
import * as remoteConfigUtils from "@core/storage/remote-config/utils"
import * as assert from "assert"
import { afterEach, beforeEach, describe, it } from "mocha"
import sinon from "sinon"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { AuthService } from "@/services/auth/AuthService"
import * as diskStorage from "@core/storage/disk";
import * as remoteConfigFetch from "@core/storage/remote-config/fetch";
import * as remoteConfigUtils from "@core/storage/remote-config/utils";
import * as assert from "assert";
import { afterEach, beforeEach, describe, it } from "mocha";
import sinon from "sinon";
import type { Controller } from "@/core/controller";
import { ClineAccountService } from "@/services/account/ClineAccountService";
import { AuthService } from "@/services/auth/AuthService";
describe("fetchRemoteConfig", () => {
let sandbox: sinon.SinonSandbox
let accountService: ClineAccountService
let authServiceStub: Partial<AuthService>
let fetchUserRemoteConfigStub: sinon.SinonStub
let isRemoteConfigEnabledStub: sinon.SinonStub
let sandbox: sinon.SinonSandbox;
let accountService: ClineAccountService;
let authServiceStub: Partial<AuthService>;
let fetchUserRemoteConfigStub: sinon.SinonStub;
let isRemoteConfigEnabledStub: sinon.SinonStub;
function createController(overrides: Record<string, unknown> = {}) {
return {
accountService: { switchAccount: sandbox.stub().resolves() },
stateManager: {
getApiConfiguration: sandbox.stub().returns({
planModeApiProvider: "cline",
actModeApiProvider: "cline",
}),
getGlobalSettingsKey: sandbox.stub().withArgs("mode").returns("act"),
setSecret: sandbox.stub(),
},
mcpHub: {},
postStateToWebview: sandbox.stub(),
...overrides,
};
}
beforeEach(() => {
sandbox = sinon.createSandbox()
authServiceStub = {}
sandbox.stub(AuthService, "getInstance").returns(authServiceStub as AuthService)
accountService = new ClineAccountService()
sandbox.stub(ClineAccountService, "getInstance").returns(accountService)
fetchUserRemoteConfigStub = sandbox.stub(accountService, "fetchUserRemoteConfig")
isRemoteConfigEnabledStub = sandbox.stub(remoteConfigUtils, "isRemoteConfigEnabled").returns(true)
sandbox.stub(remoteConfigUtils, "applyRemoteConfig").resolves()
sandbox.stub(remoteConfigUtils, "clearRemoteConfig")
sandbox.stub(diskStorage, "writeRemoteConfigToCache").resolves()
sandbox.stub(diskStorage, "readRemoteConfigFromCache").resolves({ version: "v1" })
sandbox.stub(diskStorage, "deleteRemoteConfigFromCache").resolves()
})
sandbox = sinon.createSandbox();
authServiceStub = {};
sandbox
.stub(AuthService, "getInstance")
.returns(authServiceStub as AuthService);
accountService = new ClineAccountService();
sandbox.stub(ClineAccountService, "getInstance").returns(accountService);
fetchUserRemoteConfigStub = sandbox.stub(
accountService,
"fetchUserRemoteConfig",
);
isRemoteConfigEnabledStub = sandbox
.stub(remoteConfigUtils, "isRemoteConfigEnabled")
.returns(true);
sandbox.stub(remoteConfigUtils, "applyRemoteConfig").resolves();
sandbox.stub(remoteConfigUtils, "clearRemoteConfig");
sandbox.stub(diskStorage, "writeRemoteConfigToCache").resolves();
sandbox
.stub(diskStorage, "readRemoteConfigFromCache")
.resolves({ version: "v1" });
sandbox.stub(diskStorage, "deleteRemoteConfigFromCache").resolves();
});
afterEach(() => {
sandbox.restore()
})
sandbox.restore();
});
it("clears remote config and skips discovery when Cline Pass is selected", async () => {
const controller = createController({
stateManager: {
getApiConfiguration: sandbox.stub().returns({
planModeApiProvider: "cline",
actModeApiProvider: "cline-pass",
}),
getGlobalSettingsKey: sandbox.stub().withArgs("mode").returns("act"),
setSecret: sandbox.stub(),
},
});
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.ok(
(remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).calledOnce,
);
assert.strictEqual(fetchUserRemoteConfigStub.callCount, 0);
assert.strictEqual(controller.accountService.switchAccount.callCount, 0);
assert.strictEqual(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount,
0,
);
assert.strictEqual(controller.postStateToWebview.callCount, 1);
});
it("switches org when not in the chosen org", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-current",
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-target",
value: '{"version":"v1"}',
organizations: [{ organizationId: "org-target", name: "Target Org" }],
})
});
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub().resolves() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.strictEqual(controller.accountService.switchAccount.callCount, 1)
assert.strictEqual(controller.accountService.switchAccount.firstCall.args[0], "org-target")
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
})
assert.strictEqual(controller.accountService.switchAccount.callCount, 1);
assert.strictEqual(
controller.accountService.switchAccount.firstCall.args[0],
"org-target",
);
assert.ok(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce,
);
});
it("skips switchAccount when already in the chosen org", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-target",
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-target",
value: '{"version":"v1"}',
organizations: [{ organizationId: "org-target", name: "Target Org" }],
})
});
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.strictEqual(controller.accountService.switchAccount.callCount, 0)
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
})
assert.strictEqual(controller.accountService.switchAccount.callCount, 0);
assert.ok(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce,
);
});
it("uses discoveredValue inline and skips org-level config fetch", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-target",
getAuthToken: () => Promise.resolve("token"),
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-target",
value: '{"version":"v1"}',
organizations: [{ organizationId: "org-target", name: "Target Org" }],
})
});
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
assert.ok(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce,
);
// writeRemoteConfigToCache is called with the parsed config, proving inline parse succeeded.
// If it had fallen through to fetchRemoteConfigForOrganization, it would need getAuthToken
// and make an HTTP call — but no axios stub is set up, so the test would fail.
assert.ok((diskStorage.writeRemoteConfigToCache as sinon.SinonStub).calledOnce)
})
assert.ok(
(diskStorage.writeRemoteConfigToCache as sinon.SinonStub).calledOnce,
);
});
it("falls back to org-level fetch when discoveredValue fails to parse", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-target",
getAuthToken: () => Promise.resolve(null),
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-target",
value: "not valid json{{{",
organizations: [{ organizationId: "org-target", name: "Target Org" }],
})
});
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
// Parse failed → fetchRemoteConfigForOrganization → no auth → cache fallback
assert.ok((diskStorage.readRemoteConfigFromCache as sinon.SinonStub).called)
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
})
assert.ok(
(diskStorage.readRemoteConfigFromCache as sinon.SinonStub).called,
);
assert.ok(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce,
);
});
it("does not switch org when resolve fails", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-current",
getAuthToken: () => Promise.resolve(null),
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-target",
value: "not valid json{{{",
organizations: [{ organizationId: "org-target", name: "Target Org" }],
})
});
// Both inline parse and org-level fetch fail (no auth → no fetch), cache is empty
;(diskStorage.readRemoteConfigFromCache as sinon.SinonStub).resolves(undefined)
(diskStorage.readRemoteConfigFromCache as sinon.SinonStub).resolves(
undefined,
);
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
// Config resolution failed — user should stay in their current org
assert.strictEqual(controller.accountService.switchAccount.callCount, 0)
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
})
assert.strictEqual(controller.accountService.switchAccount.callCount, 0);
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called);
assert.strictEqual(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount,
0,
);
});
it("falls back to next locally-allowed org when backend org is opted-out", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-3",
getAuthToken: () => Promise.resolve("token"),
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-1",
@@ -180,26 +250,29 @@ describe("fetchRemoteConfig", () => {
{ organizationId: "org-2", name: "Org 2" },
{ organizationId: "org-3", name: "Org 3" },
],
})
isRemoteConfigEnabledStub.reset()
isRemoteConfigEnabledStub.withArgs("org-1").returns(false)
isRemoteConfigEnabledStub.withArgs("org-2").returns(false)
isRemoteConfigEnabledStub.withArgs("org-3").returns(true)
});
isRemoteConfigEnabledStub.reset();
isRemoteConfigEnabledStub.withArgs("org-1").returns(false);
isRemoteConfigEnabledStub.withArgs("org-2").returns(false);
isRemoteConfigEnabledStub.withArgs("org-3").returns(true);
// Fallback org has no discoveredValue, so it will go through fetchRemoteConfigForOrganization
// which needs auth → will fall back to cache
;(diskStorage.readRemoteConfigFromCache as sinon.SinonStub).resolves({ version: "v1" })
(diskStorage.readRemoteConfigFromCache as sinon.SinonStub).resolves({
version: "v1",
});
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub().resolves() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.ok((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce)
})
assert.ok(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).calledOnce,
);
});
it("clears remote config when all orgs are locally opted-out", async () => {
fetchUserRemoteConfigStub.resolves({
@@ -209,109 +282,126 @@ describe("fetchRemoteConfig", () => {
{ organizationId: "org-1", name: "Org 1" },
{ organizationId: "org-2", name: "Org 2" },
],
})
isRemoteConfigEnabledStub.reset()
isRemoteConfigEnabledStub.returns(false)
});
isRemoteConfigEnabledStub.reset();
isRemoteConfigEnabledStub.returns(false);
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called)
assert.strictEqual(controller.accountService.switchAccount.callCount, 0)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
})
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called);
assert.strictEqual(controller.accountService.switchAccount.callCount, 0);
assert.strictEqual(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount,
0,
);
});
it("calls clearRemoteConfig when discovery returns no qualifying org", async () => {
fetchUserRemoteConfigStub.resolves(undefined)
fetchUserRemoteConfigStub.resolves(undefined);
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called)
assert.strictEqual(controller.accountService.switchAccount.callCount, 0)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
})
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called);
assert.strictEqual(controller.accountService.switchAccount.callCount, 0);
assert.strictEqual(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount,
0,
);
});
it("clears remote config when isRemoteConfigEnabled toggled off mid-flight", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-target",
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-target",
value: '{"version":"v1"}',
organizations: [{ organizationId: "org-target", name: "Target Org" }],
})
});
isRemoteConfigEnabledStub.reset()
isRemoteConfigEnabledStub.onFirstCall().returns(true)
isRemoteConfigEnabledStub.onSecondCall().returns(false)
isRemoteConfigEnabledStub.reset();
isRemoteConfigEnabledStub.onFirstCall().returns(true);
isRemoteConfigEnabledStub.onSecondCall().returns(false);
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
assert.ok((diskStorage.writeRemoteConfigToCache as sinon.SinonStub).calledOnce)
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
})
assert.ok(
(diskStorage.writeRemoteConfigToCache as sinon.SinonStub).calledOnce,
);
assert.ok((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).called);
assert.strictEqual(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount,
0,
);
});
it("preserves existing config on unexpected network error", async () => {
fetchUserRemoteConfigStub.rejects(new Error("network failure"))
fetchUserRemoteConfigStub.rejects(new Error("network failure"));
const controller = {
const controller = createController({
accountService: { switchAccount: sandbox.stub() },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
// Transient errors should NOT clear existing remote config
assert.strictEqual((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).callCount, 0)
assert.strictEqual(controller.postStateToWebview.callCount, 0)
})
assert.strictEqual(
(remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).callCount,
0,
);
assert.strictEqual(controller.postStateToWebview.callCount, 0);
});
it("preserves existing config when switchAccount rejects", async () => {
Object.assign(authServiceStub, {
getActiveOrganizationId: () => "org-current",
})
});
fetchUserRemoteConfigStub.resolves({
organizationId: "org-target",
value: '{"version":"v1"}',
organizations: [{ organizationId: "org-target", name: "Target Org" }],
})
});
const controller = {
accountService: { switchAccount: sandbox.stub().rejects(new Error("switch failed")) },
stateManager: { setSecret: sandbox.stub() },
mcpHub: {},
postStateToWebview: sandbox.stub(),
}
const controller = createController({
accountService: {
switchAccount: sandbox.stub().rejects(new Error("switch failed")),
},
});
await remoteConfigFetch.fetchRemoteConfig(controller as any)
await remoteConfigFetch.fetchRemoteConfig(
controller as unknown as Controller,
);
// switchAccount failure should NOT clear existing remote config
assert.strictEqual((remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).callCount, 0)
assert.strictEqual((remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount, 0)
})
})
assert.strictEqual(
(remoteConfigUtils.clearRemoteConfig as sinon.SinonStub).callCount,
0,
);
assert.strictEqual(
(remoteConfigUtils.applyRemoteConfig as sinon.SinonStub).callCount,
0,
);
});
});
@@ -1,5 +1,6 @@
import axios, { AxiosRequestConfig, AxiosResponse } from "axios"
import { Controller } from "@/core/controller"
import { CLINE_PASS_PROVIDER_ID } from "@/core/controller/models/handleClinePassProviderSelection"
import { ClineAccountService } from "@/services/account/ClineAccountService"
import { buildBasicClineHeaders } from "@/services/EnvUtils"
import { getAxiosSettings } from "@/shared/net"
@@ -157,9 +158,7 @@ async function fetchApiKeysForOrganization(organizationId: string): Promise<APIK
}
}
async function discoverRemoteConfigOrg(): Promise<
{ organizationId: string; discoveredValue?: string } | undefined
> {
async function discoverRemoteConfigOrg(): Promise<{ organizationId: string; discoveredValue?: string } | undefined> {
const accountService = ClineAccountService.getInstance()
const discovery = await accountService.fetchUserRemoteConfig()
@@ -194,10 +193,7 @@ function parseDiscoveredConfig(value: string, organizationId: string): RemoteCon
}
}
async function resolveRemoteConfig(
organizationId: string,
discoveredValue?: string,
): Promise<RemoteConfig | undefined> {
async function resolveRemoteConfig(organizationId: string, discoveredValue?: string): Promise<RemoteConfig | undefined> {
if (discoveredValue) {
const config = parseDiscoveredConfig(discoveredValue, organizationId)
if (config) {
@@ -207,6 +203,15 @@ async function resolveRemoteConfig(
return fetchRemoteConfigForOrganization(organizationId)
}
function isClinePassSelected(controller: Controller): boolean {
const apiConfiguration = controller.stateManager.getApiConfiguration()
return (
apiConfiguration.planModeApiProvider === CLINE_PASS_PROVIDER_ID ||
apiConfiguration.actModeApiProvider === CLINE_PASS_PROVIDER_ID
)
}
/**
* Discovers the target org, resolves its remote config, switches org if needed,
* fetches API keys, and applies the config. Clears remote config when no
@@ -217,6 +222,13 @@ async function resolveRemoteConfig(
*/
async function ensureUserInOrgWithRemoteConfig(controller: Controller): Promise<RemoteConfig | undefined> {
const authService = AuthService.getInstance()
if (isClinePassSelected(controller)) {
clearRemoteConfig()
controller.postStateToWebview()
return undefined
}
const discovered = await discoverRemoteConfigOrg()
if (!discovered) {
+13 -2
View File
@@ -87,6 +87,7 @@ import {
} from "@shared/Languages";
import { USER_CONTENT_TAGS } from "@shared/messages/constants";
import { convertClineMessageToProto } from "@shared/proto-conversions/cline-message";
import { FeatureFlag } from "@shared/services/feature-flags/feature-flags";
import { type ClineDefaultTool, READ_ONLY_TOOLS } from "@shared/tools";
import type { ClineAskResponse } from "@shared/WebviewMessage";
import {
@@ -2033,11 +2034,16 @@ export class Task {
const model = this.api.getModel();
const apiConfig = this.stateManager.getApiConfiguration();
const mode = this.stateManager.getGlobalSettingsKey("mode");
const providerId = (
const configuredProviderId = (
mode === "plan"
? apiConfig.planModeApiProvider
: apiConfig.actModeApiProvider
) as string;
const providerId =
configuredProviderId === "cline-pass" &&
!featureFlagsService.getBooleanFlagEnabled(FeatureFlag.CLINE_PASS)
? "cline"
: configuredProviderId;
const customPrompt = this.stateManager.getGlobalSettingsKey("customPrompt");
return { model, providerId, customPrompt, mode };
}
@@ -2460,6 +2466,9 @@ export class Task {
const quotaExceeded = clineError.isErrorType(
ClineErrorType.QuotaExceeded,
);
const isEntitlementError = clineError.isErrorType(
ClineErrorType.Entitlement,
);
// Check if this is a Cline provider insufficient credits error - don't auto-retry these
const isClineProviderInsufficientCredits = (() => {
@@ -2485,6 +2494,7 @@ export class Task {
!isAuthError &&
!isSpendLimitError &&
!quotaExceeded &&
!isEntitlementError &&
this.taskState.autoRetryAttempts < 3;
if (shouldRetry) {
// Auto-retry enabled with max 3 attempts: automatically approve the retry
@@ -2546,7 +2556,8 @@ export class Task {
!isClineProviderInsufficientCredits &&
!isAuthError &&
!isSpendLimitError &&
!quotaExceeded;
!quotaExceeded &&
!isEntitlementError;
if (showRetry) {
await this.say(
"error_retry",
@@ -733,8 +733,9 @@ export class SubagentRunner {
const parsedError = ClineError.transform(error, modelId, providerId)
const isAuthError = parsedError.isErrorType(ClineErrorType.Auth)
const isBalanceError = parsedError.isErrorType(ClineErrorType.Balance)
const isEntitlementError = parsedError.isErrorType(ClineErrorType.Entitlement)
if (isAuthError || isBalanceError) {
if (isAuthError || isBalanceError || isEntitlementError) {
return false
}
@@ -462,6 +462,38 @@ describe("SubagentRunner", () => {
assert.match(result.error || "", /stream_initialization_failed/i)
})
it("does not retry initial ClinePass entitlement errors", async () => {
const createMessage = sinon.stub()
createMessage.onFirstCall().callsFake(async function* () {
yield* []
throw Object.assign(new Error("403 the user is not subscribed to required model plan"), {
status: 403,
code: "ENTITLEMENT_ERROR",
error: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
})
})
const promptRegistry = PromptRegistry.getInstance()
sinon.stub(promptRegistry, "get").callsFake(async () => {
promptRegistry.nativeTools = undefined
return "system prompt"
})
sinon.stub(skills, "discoverSkills").resolves([])
sinon.stub(skills, "getAvailableSkills").returns([])
stubApiHandler(createMessage)
initializeHostProvider()
const runner = new SubagentRunner(createTaskConfig(false))
const result = await runner.run("List files", () => {})
assert.equal(result.status, "failed")
assert.equal(createMessage.callCount, 1)
assert.match(result.error || "", /not subscribed to required model plan/i)
})
it("fails context window errors", async () => {
const createMessage = sinon.stub()
createMessage.onFirstCall().callsFake(async function* () {
@@ -285,7 +285,7 @@ export class ClineAccountService {
* @returns {Promise<void>} A promise that resolves when the account switch is complete.
* @throws {Error} If the account switch fails, an error will be thrown.
*/
async switchAccount(organizationId?: string): Promise<void> {
async switchAccount(organizationId?: string | null): Promise<void> {
// Call API to switch account
try {
// make XHR request to switch account
@@ -8,6 +8,7 @@ export enum ClineErrorType {
Balance = "balance",
SpendLimit = "spendLimit",
QuotaExceeded = "quotaExceeded",
Entitlement = "entitlement",
}
interface ErrorDetails {
@@ -152,6 +153,14 @@ export class ClineError extends Error {
return ClineErrorType.SpendLimit
}
// Scoped to the individual "not subscribed" case; other ENTITLEMENT_ERROR variants (e.g. org
// accounts) fall through. Checked before the generic auth check since these are returned as 403.
const isEntitlementCode = code === "ENTITLEMENT_ERROR" || details?.code === "ENTITLEMENT_ERROR"
const entitlementText = `${message ?? ""} ${details?.message ?? ""}`.toLowerCase()
if (isEntitlementCode && entitlementText.includes("not subscribed to required model plan")) {
return ClineErrorType.Entitlement
}
// Check auth errors
const isAuthStatus = status !== undefined && status > 400 && status < 429
if (code === "ERR_BAD_REQUEST" || err instanceof AuthInvalidTokenError || isAuthStatus) {
@@ -8,5 +8,62 @@ describe("ClineError", () => {
const err = new ClineError({ message: "Inference cap reached", code: "INFERENCE_CAP_ERROR" })
ClineError.getErrorType(err)!.should.equal(ClineErrorType.QuotaExceeded)
})
it("should return Entitlement when code is ENTITLEMENT_ERROR", () => {
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
code: "ENTITLEMENT_ERROR",
status: 403,
})
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should return Entitlement when details.code is ENTITLEMENT_ERROR", () => {
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
status: 403,
details: { code: "ENTITLEMENT_ERROR", message: "Error 403: the user is not subscribed to required model plan" },
})
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should prefer Entitlement over Auth for 403 ENTITLEMENT_ERROR", () => {
// status 403 would otherwise be classified as Auth; the entitlement code must win.
const err = new ClineError({
message: "403 Error 403: the user is not subscribed to required model plan",
code: "ENTITLEMENT_ERROR",
status: 403,
})
ClineError.getErrorType(err)!.should.not.equal(ClineErrorType.Auth)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should return Entitlement for the real Cline 403 provider error shape (nested error object)", () => {
// ClineError maps `error.error` into `details`, so `details.code` drives classification.
const err = new ClineError(
{
status: 403,
error: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
},
"cline-pass/glm-5.1",
"cline-pass",
)
ClineError.getErrorType(err)!.should.equal(ClineErrorType.Entitlement)
})
it("should NOT classify the organization ENTITLEMENT_ERROR variant as Entitlement", () => {
// Org accounts can't use individual subs; this case is intentionally out of scope and
// falls through to generic handling rather than showing the ClinePass card.
const err = new ClineError({
message: "403 Error 403: organization accounts cannot use individual model inference subscriptions",
code: "ENTITLEMENT_ERROR",
status: 403,
})
const result = ClineError.getErrorType(err)
;(result !== ClineErrorType.Entitlement).should.be.true()
})
})
})
+77 -1
View File
@@ -22,6 +22,7 @@ export type ApiProvider =
| "mistral"
| "vscode-lm"
| "cline"
| "cline-pass"
| "litellm"
| "moonshot"
| "nebius"
@@ -1023,6 +1024,58 @@ export const clineDevstralModelInfo: ModelInfo = {
description: "A stealth model for agentic coding tasks",
}
export type ClinePassModelId = keyof typeof clinePassModels
export const clinePassDefaultModelId = "cline-pass/glm-5.1"
export const clinePassModelInfoSaneDefaults: ModelInfo = {
maxTokens: 8_192,
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
supportsReasoning: true,
inputPrice: 0,
outputPrice: 0,
cacheReadsPrice: 0,
cacheWritesPrice: 0,
description: "",
}
export const clinePassModels = {
"cline-pass/glm-5.1": {
name: "cline-pass/glm-5.1",
maxTokens: 131_072,
contextWindow: 202_752,
supportsImages: false,
supportsPromptCache: true,
supportsReasoning: true,
inputPrice: 0.98,
outputPrice: 3.08,
cacheReadsPrice: 0.182,
cacheWritesPrice: 0,
description: "",
},
} as const satisfies Record<string, ModelInfo>
export function getModelSlug(modelId: string): string {
return modelId.split("/").at(-1) ?? modelId
}
export function buildModelInfoNameMap(models: Record<string, ModelInfo>): Record<string, ModelInfo> {
const nameMap: Record<string, ModelInfo> = {}
for (const [id, info] of Object.entries(models)) {
nameMap[getModelSlug(id)] = info
}
return nameMap
}
export function resolveClinePassModelInfo(modelId: string, modelInfoByName?: Record<string, ModelInfo>): ModelInfo {
return (
clinePassModels[modelId as keyof typeof clinePassModels] ??
modelInfoByName?.[getModelSlug(modelId)] ??
clinePassModelInfoSaneDefaults
)
}
export const OPENROUTER_PROVIDER_PREFERENCES: Record<string, { order: string[]; allow_fallbacks: boolean }> = {
// Exacto Providers
"moonshotai/kimi-k2:exacto": {
@@ -5143,6 +5196,18 @@ export const fireworksModels = {
description:
"Kimi K2.6 Turbo router for high-performance agentic workloads with vision and text reasoning.",
},
"accounts/fireworks/routers/kimi-k2p6-fast": {
maxTokens: 262000,
contextWindow: 262000,
supportsImages: true,
supportsPromptCache: true,
inputPrice: 2,
outputPrice: 8,
cacheWritesPrice: 0,
cacheReadsPrice: 0.3,
description:
"Kimi K2.6 Fast router for high-performance agentic workloads with vision and text reasoning.",
},
"accounts/fireworks/routers/kimi-k2p7-code-fast": {
maxTokens: 262000,
contextWindow: 262000,
@@ -5163,7 +5228,7 @@ export const fireworksModels = {
inputPrice: 0.14,
outputPrice: 0.28,
cacheWritesPrice: 0,
cacheReadsPrice: 0.03,
cacheReadsPrice: 0.028,
description:
"DeepSeek V4 Flash is a fast, cost-efficient reasoning model with a 1M context window and strong tool-use capabilities.",
},
@@ -5179,6 +5244,17 @@ export const fireworksModels = {
description:
"DeepSeek V4 Pro is a flagship reasoning model with a 1M context window, advanced structured output, and agentic performance.",
},
"accounts/fireworks/models/glm-5p2": {
maxTokens: 131072,
contextWindow: 1048576,
supportsImages: false,
supportsPromptCache: true,
inputPrice: 1.4,
outputPrice: 4.4,
cacheWritesPrice: 0,
cacheReadsPrice: 0.26,
description: "GLM 5.2 is a next-generation general-purpose model optimized for coding, reasoning, and agentic workflows with a 1M context window.",
},
"accounts/fireworks/models/glm-5p1": {
maxTokens: 131072,
contextWindow: 202800,
@@ -280,6 +280,8 @@ function convertApiProviderToProto(provider: string | undefined): ProtoApiProvid
return ProtoApiProvider.VSCODE_LM
case "cline":
return ProtoApiProvider.CLINE
case "cline-pass":
return ProtoApiProvider.CLINE_PASS
case "litellm":
return ProtoApiProvider.LITELLM
case "moonshot":
@@ -372,6 +374,8 @@ export function convertProtoToApiProvider(provider: ProtoApiProvider): ApiProvid
return "vscode-lm"
case ProtoApiProvider.CLINE:
return "cline"
case ProtoApiProvider.CLINE_PASS:
return "cline-pass"
case ProtoApiProvider.LITELLM:
return "litellm"
case ProtoApiProvider.MOONSHOT:
@@ -528,6 +532,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
planModeOpenRouterModelInfo: convertModelInfoToProtoOpenRouter(config.planModeOpenRouterModelInfo),
planModeClineModelId: config.planModeClineModelId,
planModeClineModelInfo: convertModelInfoToProtoOpenRouter(config.planModeClineModelInfo),
planModeClinePassModelId: config.planModeClinePassModelId,
planModeClinePassModelInfo: convertModelInfoToProtoOpenRouter(config.planModeClinePassModelInfo),
planModeOpenAiModelId: config.planModeOpenAiModelId,
planModeOpenAiModelInfo: convertOpenAiCompatibleModelInfoToProto(config.planModeOpenAiModelInfo),
planModeOllamaModelId: config.planModeOllamaModelId,
@@ -572,6 +578,8 @@ export function convertApiConfigurationToProto(config: ApiConfiguration): ProtoA
actModeOpenRouterModelInfo: convertModelInfoToProtoOpenRouter(config.actModeOpenRouterModelInfo),
actModeClineModelId: config.actModeClineModelId,
actModeClineModelInfo: convertModelInfoToProtoOpenRouter(config.actModeClineModelInfo),
actModeClinePassModelId: config.actModeClinePassModelId,
actModeClinePassModelInfo: convertModelInfoToProtoOpenRouter(config.actModeClinePassModelInfo),
actModeOpenAiModelId: config.actModeOpenAiModelId,
actModeOpenAiModelInfo: convertOpenAiCompatibleModelInfoToProto(config.actModeOpenAiModelInfo),
actModeOllamaModelId: config.actModeOllamaModelId,
@@ -711,6 +719,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
planModeOpenRouterModelInfo: convertProtoToModelInfo(protoConfig.planModeOpenRouterModelInfo),
planModeClineModelId: protoConfig.planModeClineModelId,
planModeClineModelInfo: convertProtoToModelInfo(protoConfig.planModeClineModelInfo),
planModeClinePassModelId: protoConfig.planModeClinePassModelId,
planModeClinePassModelInfo: convertProtoToModelInfo(protoConfig.planModeClinePassModelInfo),
planModeOpenAiModelId: protoConfig.planModeOpenAiModelId,
planModeOpenAiModelInfo: convertProtoToOpenAiCompatibleModelInfo(protoConfig.planModeOpenAiModelInfo),
planModeOllamaModelId: protoConfig.planModeOllamaModelId,
@@ -756,6 +766,8 @@ export function convertProtoToApiConfiguration(protoConfig: ProtoApiConfiguratio
actModeOpenRouterModelInfo: convertProtoToModelInfo(protoConfig.actModeOpenRouterModelInfo),
actModeClineModelId: protoConfig.actModeClineModelId,
actModeClineModelInfo: convertProtoToModelInfo(protoConfig.actModeClineModelInfo),
actModeClinePassModelId: protoConfig.actModeClinePassModelId,
actModeClinePassModelInfo: convertProtoToModelInfo(protoConfig.actModeClinePassModelInfo),
actModeOpenAiModelId: protoConfig.actModeOpenAiModelId,
actModeOpenAiModelInfo: convertProtoToOpenAiCompatibleModelInfo(protoConfig.actModeOpenAiModelInfo),
actModeOllamaModelId: protoConfig.actModeOllamaModelId,
@@ -4,6 +4,10 @@
"value": "cline",
"label": "Cline"
},
{
"value": "cline-pass",
"label": "Cline Pass"
},
{
"value": "openai-codex",
"label": "ChatGPT Subscription"
@@ -15,6 +15,8 @@ export enum FeatureFlag {
// Rollout flag for Cline provider model sourcing:
// off => OpenRouter model list, on => Cline endpoint model list.
EXTENSION_CLINE_MODELS_ENDPOINT = "extension_cline_models_endpoint",
// Enables Cline Pass provider/model list exposure.
CLINE_PASS = "ext-cline-pass",
// Use the websocket mode for OpenAI native Responses API format
OPENAI_RESPONSES_WEBSOCKET_MODE = "openai-responses-websocket-mode",
}
@@ -27,6 +29,7 @@ export const FeatureFlagDefaultValue: Partial<Record<FeatureFlag, FeatureFlagPay
[FeatureFlag.EXTENSION_REMOTE_BANNERS_TTL]: 24 * 60 * 60 * 1000,
[FeatureFlag.REMOTE_WELCOME_BANNERS]: process.env.E2E_TEST === "true" || process.env.IS_DEV === "true",
[FeatureFlag.EXTENSION_CLINE_MODELS_ENDPOINT]: false,
[FeatureFlag.CLINE_PASS]: false,
[FeatureFlag.OPENAI_RESPONSES_WEBSOCKET_MODE]: false,
}
@@ -22,4 +22,9 @@ describe("Provider key mapping", () => {
expect(getProviderModelIdKey("cline", "act")).to.equal("actModeClineModelId")
expect(getProviderModelIdKey("cline", "plan")).to.equal("planModeClineModelId")
})
it("uses separate model keys for Cline Pass", () => {
expect(getProviderModelIdKey("cline-pass", "act")).to.equal("actModeClinePassModelId")
expect(getProviderModelIdKey("cline-pass", "plan")).to.equal("planModeClinePassModelId")
})
})
@@ -6,6 +6,7 @@ import {
anthropicDefaultModelId,
basetenDefaultModelId,
bedrockDefaultModelId,
clinePassDefaultModelId,
deepSeekDefaultModelId,
fireworksDefaultModelId,
geminiDefaultModelId,
@@ -28,6 +29,7 @@ import {
const ProviderKeyMap: Partial<Record<ApiProvider, string>> = {
openrouter: "OpenRouterModelId",
cline: "ClineModelId",
"cline-pass": "ClinePassModelId",
openai: "OpenAiModelId",
ollama: "OllamaModelId",
lmstudio: "LmStudioModelId",
@@ -49,6 +51,7 @@ const ProviderKeyMap: Partial<Record<ApiProvider, string>> = {
export const ProviderToApiKeyMap: Partial<Record<ApiProvider, keyof Secrets | (keyof Secrets)[]>> = {
cline: ["clineApiKey", "clineAccountId"],
"cline-pass": ["clineApiKey", "clineAccountId"],
anthropic: "apiKey",
openrouter: "openRouterApiKey",
bedrock: ["awsAccessKey", "awsBedrockApiKey"],
@@ -91,6 +94,7 @@ const ProviderDefaultModelMap: Partial<Record<ApiProvider, string>> = {
anthropic: anthropicDefaultModelId,
openrouter: openRouterDefaultModelId,
cline: openRouterDefaultModelId,
"cline-pass": clinePassDefaultModelId,
openai: openAiNativeDefaultModelId,
ollama: "",
lmstudio: "",
@@ -156,6 +156,8 @@ const API_HANDLER_SETTINGS_FIELDS = {
planModeOpenRouterModelInfo: { default: undefined as ModelInfo | undefined },
planModeClineModelId: { default: undefined as string | undefined },
planModeClineModelInfo: { default: undefined as ModelInfo | undefined },
planModeClinePassModelId: { default: undefined as string | undefined },
planModeClinePassModelInfo: { default: undefined as ModelInfo | undefined },
planModeOpenAiModelId: { default: undefined as string | undefined },
planModeOpenAiModelInfo: { default: undefined as OpenAiCompatibleModelInfo | undefined },
planModeOllamaModelId: { default: undefined as string | undefined },
@@ -200,6 +202,8 @@ const API_HANDLER_SETTINGS_FIELDS = {
actModeOpenRouterModelInfo: { default: undefined as ModelInfo | undefined },
actModeClineModelId: { default: undefined as string | undefined },
actModeClineModelInfo: { default: undefined as ModelInfo | undefined },
actModeClinePassModelId: { default: undefined as string | undefined },
actModeClinePassModelInfo: { default: undefined as ModelInfo | undefined },
actModeOpenAiModelId: { default: undefined as string | undefined },
actModeOpenAiModelInfo: { default: undefined as OpenAiCompatibleModelInfo | undefined },
actModeOllamaModelId: { default: undefined as string | undefined },
+3
View File
@@ -0,0 +1,3 @@
export function isClineProvider(provider: string | undefined) {
return provider === "cline" || provider === "cline-pass"
}
+1
View File
@@ -10,6 +10,7 @@ export function isNextGenModelProvider(providerInfo: ApiProviderInfo): boolean {
const providerId = normalize(providerInfo.providerId)
return [
"cline",
"cline-pass",
"anthropic",
"bedrock",
"gemini",
@@ -0,0 +1,70 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { beforeEach, describe, expect, it, vi } from "vitest"
import EntitlementError from "./EntitlementError"
const mockAuth: { clineUser: { appBaseUrl?: string } | null } = {
clineUser: null,
}
vi.mock("@/context/ClineAuthContext", () => ({
useClineAuth: () => mockAuth,
}))
const askResponseMock = vi.fn()
vi.mock("@/services/grpc-client", () => ({
TaskServiceClient: {
askResponse: (...args: unknown[]) => askResponseMock(...args),
},
}))
const getSubscribeHref = () => screen.getByRole("link", { name: /get clinepass/i }).getAttribute("href")
const querySubscribeLink = () => screen.queryByRole("link", { name: /get clinepass/i })
describe("EntitlementError", () => {
beforeEach(() => {
vi.clearAllMocks()
mockAuth.clineUser = null
})
it("shows friendly copy with the backend detail as muted support text", () => {
render(<EntitlementError message="Error 403: the user is not subscribed to required model plan" />)
expect(screen.getByText("This model requires a ClinePass subscription.")).toBeInTheDocument()
expect(screen.getByText("Error 403: the user is not subscribed to required model plan")).toBeInTheDocument()
})
it("omits the subscribe link when no usable app base URL is available", () => {
render(<EntitlementError />)
expect(querySubscribeLink()).toBeNull()
mockAuth.clineUser = {}
render(<EntitlementError />)
expect(querySubscribeLink()).toBeNull()
mockAuth.clineUser = { appBaseUrl: "not a valid url" }
render(<EntitlementError />)
expect(querySubscribeLink()).toBeNull()
})
it("builds the subscribe link from the authenticated user's app base URL", () => {
mockAuth.clineUser = { appBaseUrl: "https://staging-app.cline.bot" }
const { unmount } = render(<EntitlementError />)
expect(getSubscribeHref()).toBe("https://staging-app.cline.bot/dashboard/subscription")
unmount()
mockAuth.clineUser = {
appBaseUrl: "https://proxy.enterprise.com/cline/app",
}
render(<EntitlementError />)
expect(getSubscribeHref()).toBe("https://proxy.enterprise.com/cline/app/dashboard/subscription")
})
it("sends a yesButtonClicked askResponse when Retry Request is clicked", () => {
render(<EntitlementError />)
// VSCodeButton has no ARIA role in jsdom; click by label text instead.
fireEvent.click(screen.getByText("Retry Request"))
expect(askResponseMock).toHaveBeenCalledTimes(1)
expect(askResponseMock.mock.calls[0][0]).toMatchObject({
responseType: "yesButtonClicked",
})
})
})
@@ -0,0 +1,77 @@
import { AskResponseRequest } from "@shared/proto/cline/task"
import { VSCodeButton } from "@vscode/webview-ui-toolkit/react"
import React from "react"
import VSCodeButtonLink from "@/components/common/VSCodeButtonLink"
import { useClineAuth } from "@/context/ClineAuthContext"
import { TaskServiceClient } from "@/services/grpc-client"
interface EntitlementErrorProps {
message?: string
}
// Relative (no leading slash) so it appends to path-prefixed app URLs (e.g. self-hosted/proxy) instead of resetting to origin.
const CLINE_PASS_SUBSCRIBE_PATH = "dashboard/subscription"
const HEADLINE = "This model requires a ClinePass subscription."
function buildSubscribeUrl(appBaseUrl?: string): string | undefined {
if (!appBaseUrl) {
return undefined
}
try {
const base = appBaseUrl.endsWith("/") ? appBaseUrl : `${appBaseUrl}/`
return new URL(CLINE_PASS_SUBSCRIBE_PATH, base).toString()
} catch {
// Malformed appBaseUrl: omit the link rather than crashing the error card.
return undefined
}
}
const EntitlementError: React.FC<EntitlementErrorProps> = ({ message }) => {
const { clineUser } = useClineAuth()
const subscribeUrl = buildSubscribeUrl(clineUser?.appBaseUrl)
const backendDetail = message && message !== HEADLINE ? message : undefined
return (
<div className="p-2 border-none rounded-md mb-2 bg-(--vscode-textBlockQuote-background)">
<div className="mb-3">
<div className="text-error mb-2">{HEADLINE}</div>
<div className="text-(--vscode-descriptionForeground) text-xs">
Subscribe to ClinePass to use this model, then retry your request.
</div>
{backendDetail && (
<div className="text-(--vscode-descriptionForeground) text-xs mt-1 opacity-80 wrap-anywhere">
{backendDetail}
</div>
)}
</div>
{subscribeUrl && (
<VSCodeButtonLink className="w-full mb-2" href={subscribeUrl}>
<span className="codicon codicon-rocket mr-[6px] text-[14px]" />
Get ClinePass
</VSCodeButtonLink>
)}
<VSCodeButton
appearance="secondary"
className="w-full"
onClick={async () => {
try {
await TaskServiceClient.askResponse(
AskResponseRequest.create({
responseType: "yesButtonClicked",
}),
)
} catch (error) {
console.error("Error invoking action:", error)
}
}}>
<span className="codicon codicon-refresh mr-1.5" />
Retry Request
</VSCodeButton>
</div>
)
}
export default EntitlementError
@@ -214,6 +214,32 @@ export const ClineSpendLimitMinimal: Story = {
},
}
// ClinePass entitlement error (user not subscribed to a required model plan)
export const ClinePassEntitlementError: Story = {
args: {
message: createMockMessage(),
errorType: "error",
apiRequestFailedMessage: JSON.stringify({
message: "403 Error 403: the user is not subscribed to required model plan",
status: 403,
code: "ENTITLEMENT_ERROR",
modelId: "cline-pass/glm-5.1",
providerId: "cline-pass",
details: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
}),
},
parameters: {
docs: {
description: {
story: "ClinePass model returns a 403 ENTITLEMENT_ERROR when the user is not subscribed. Instead of dumping the raw JSON blob, a human-readable message with a 'Get ClinePass' subscribe link and a retry button is shown.",
},
},
},
}
// Authentication-related errors with configurable scenarios
export const AuthenticationErrors: Story = {
args: {
@@ -19,6 +19,11 @@ vi.mock("@/components/chat/CreditLimitError", () => ({
default: ({ message }: { message: string }) => <div data-testid="credit-limit-error">{message}</div>,
}))
// Mock EntitlementError component
vi.mock("@/components/chat/EntitlementError", () => ({
default: ({ message }: { message: string }) => <div data-testid="entitlement-error">{message}</div>,
}))
// Mock ClineError
vi.mock("../../../../src/services/error/ClineError", () => ({
ClineError: {
@@ -28,6 +33,7 @@ vi.mock("../../../../src/services/error/ClineError", () => ({
Balance: "balance",
RateLimit: "rateLimit",
Auth: "auth",
Entitlement: "entitlement",
},
}))
@@ -128,6 +134,38 @@ describe("ErrorRow", () => {
expect(screen.getByText("Inference cap reached")).toBeInTheDocument()
})
it("renders entitlement error with the detail message instead of a raw JSON blob", async () => {
const mockClineError = {
message: "403 Error 403: the user is not subscribed to required model plan",
isErrorType: vi.fn((type) => type === "entitlement"),
providerId: "cline-pass",
_error: {
code: "ENTITLEMENT_ERROR",
details: {
code: "ENTITLEMENT_ERROR",
message: "Error 403: the user is not subscribed to required model plan",
},
},
}
const { ClineError } = await import("../../../../src/services/error/ClineError")
vi.mocked(ClineError.parse).mockReturnValue(mockClineError as any)
render(
<ErrorRow
apiRequestFailedMessage='{"message":"403 Error 403...","code":"ENTITLEMENT_ERROR"}'
errorType="error"
message={mockMessage}
/>,
)
// Renders the friendly EntitlementError component with the human-readable detail message...
expect(screen.getByTestId("entitlement-error")).toBeInTheDocument()
expect(screen.getByText("Error 403: the user is not subscribed to required model plan")).toBeInTheDocument()
// ...and does not dump the raw JSON blob or the [CLINE-PASS] ENTITLEMENT_ERROR header.
expect(screen.queryByText(/ENTITLEMENT_ERROR/)).not.toBeInTheDocument()
})
it("renders friendly logged-out message and sign in button when user is not signed in", async () => {
const mockClineError = {
message: "Authentication failed",
@@ -1,6 +1,8 @@
import { ClineMessage } from "@shared/ExtensionMessage"
import { isClineProvider } from "@shared/utils/cline"
import { memo } from "react"
import CreditLimitError from "@/components/chat/CreditLimitError"
import EntitlementError from "@/components/chat/EntitlementError"
import SpendLimitError from "@/components/chat/SpendLimitError"
import { Button } from "@/components/ui/button"
import { useClineAuth, useClineSignIn } from "@/context/ClineAuthContext"
@@ -32,7 +34,6 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
const errorMessage = clineError?._error?.message || clineError?.message || rawApiError
const requestId = clineError?._error?.request_id
const providerId = clineError?.providerId || clineError?._error?.providerId
const isClineProvider = providerId === "cline"
const errorCode = clineError?._error?.code
if (clineError?.isErrorType(ClineErrorType.Balance)) {
@@ -61,6 +62,11 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
)
}
if (clineError?.isErrorType(ClineErrorType.Entitlement)) {
const detailMessage = clineError?._error?.details?.message || errorMessage
return <EntitlementError message={detailMessage} />
}
if (clineError?.isErrorType(ClineErrorType.RateLimit)) {
return (
<p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">
@@ -75,7 +81,7 @@ const ErrorRow = memo(({ message, errorType, apiRequestFailedMessage, apiReqStre
return <p className="m-0 whitespace-pre-wrap text-error wrap-anywhere">{detailMessage}</p>
}
if (clineError?.isErrorType(ClineErrorType.Auth) && isClineProvider) {
if (clineError?.isErrorType(ClineErrorType.Auth) && isClineProvider(providerId)) {
return !clineUser ? (
// User is using Cline provider and is not logged in
<div className="flex flex-col gap-3">
@@ -10,6 +10,7 @@ import { normalizeApiConfiguration } from "@/components/settings/utils/providerU
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
import { PLATFORM_CONFIG, PlatformType } from "@/config/platform.config"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { useHasFeatureFlag } from "@/hooks/useFeatureFlag"
import { ModelsServiceClient } from "@/services/grpc-client"
import { OPENROUTER_MODEL_PICKER_Z_INDEX } from "./OpenRouterModelPicker"
import { AIhubmixProvider } from "./providers/AihubmixProvider"
@@ -19,6 +20,7 @@ import { BasetenProvider } from "./providers/BasetenProvider"
import { BedrockProvider } from "./providers/BedrockProvider"
import { CerebrasProvider } from "./providers/CerebrasProvider"
import { ClaudeCodeProvider } from "./providers/ClaudeCodeProvider"
import { ClinePassProvider } from "./providers/ClinePassProvider"
import { ClineProvider } from "./providers/ClineProvider"
import { DeepSeekProvider } from "./providers/DeepSeekProvider"
import { DifyProvider } from "./providers/DifyProvider"
@@ -56,6 +58,8 @@ import { XaiProvider } from "./providers/XaiProvider"
import { ZAiProvider } from "./providers/ZAiProvider"
import { useApiConfigurationHandlers } from "./utils/useApiConfigurationHandlers"
const CLINE_PASS_FEATURE_FLAG = "ext-cline-pass"
interface ApiOptionsProps {
showModelOptions: boolean
apiErrorMessage?: string
@@ -99,8 +103,9 @@ const ApiOptions = ({
}: ApiOptionsProps) => {
// Use full context state for immediate save payload
const { apiConfiguration, remoteConfigSettings } = useExtensionState()
const isClinePassEnabled = useHasFeatureFlag(CLINE_PASS_FEATURE_FLAG)
const { selectedProvider } = normalizeApiConfiguration(apiConfiguration, currentMode)
const { selectedProvider } = normalizeApiConfiguration(apiConfiguration, currentMode, { isClinePassEnabled })
const { handleModeFieldChange } = useApiConfigurationHandlers()
@@ -141,6 +146,9 @@ const ApiOptions = ({
const providerOptions = useMemo(() => {
let providers = PROVIDERS.list
if (!isClinePassEnabled) {
providers = providers.filter((option) => option.value !== "cline-pass")
}
// Filter by platform
if (PLATFORM_CONFIG.type !== PlatformType.VSCODE) {
// Don't include VS Code LM API for non-VSCode platforms
@@ -154,7 +162,7 @@ const ApiOptions = ({
}
return providers
}, [remoteConfigSettings])
}, [isClinePassEnabled, remoteConfigSettings])
const currentProviderLabel = useMemo(() => {
return providerOptions.find((option) => option.value === selectedProvider)?.label || selectedProvider
@@ -363,11 +371,16 @@ const ApiOptions = ({
<ClineProvider
currentMode={currentMode}
initialModelTab={initialModelTab}
isClinePassEnabled={isClinePassEnabled}
isPopup={isPopup}
showModelOptions={showModelOptions}
/>
)}
{apiConfiguration && isClinePassEnabled && selectedProvider === "cline-pass" && (
<ClinePassProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
)}
{apiConfiguration && selectedProvider === "asksage" && (
<AskSageProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
)}

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