mirror of
https://github.com/cline/cline.git
synced 2026-09-09 15:02:23 +08:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bb931b41c | ||
|
|
99fb5101e5 | ||
|
|
58db9887ee | ||
|
|
b16a28cd6f | ||
|
|
114aa3f161 | ||
|
|
64743d2911 | ||
|
|
d7292993e9 | ||
|
|
8110cc46d8 | ||
|
|
486091f7d6 | ||
|
|
a3cd39da14 | ||
|
|
a51b156383 |
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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/",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -109,6 +109,7 @@ export type WebviewSessionSummary = {
|
||||
providerId?: string;
|
||||
model?: string;
|
||||
workspaceRoot?: string;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 · {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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+60
@@ -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);
|
||||
});
|
||||
});
|
||||
+82
-39
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function isClineProvider(provider: string | undefined) {
|
||||
return provider === "cline" || provider === "cline-pass"
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user