From 27e713671dec32b636a281ba61a6554ceffb9066 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 2 Jul 2026 11:26:56 +0200 Subject: [PATCH] feat(cli): add catalog-driven provider setup --- packages/cli/src/cli.ts | 4 - packages/cli/src/commands/daemon/status.ts | 9 +- packages/cli/src/commands/login/index.test.ts | 287 -------- packages/cli/src/commands/login/index.ts | 221 ------- packages/cli/src/commands/loop/run.ts | 5 +- .../cli/src/commands/provider/add.test.ts | 621 ++++++++++++++++++ packages/cli/src/commands/provider/add.ts | 540 +++++++++++++++ packages/cli/src/commands/provider/index.ts | 38 +- .../cli/src/commands/provider/ls-rm.test.ts | 139 ++++ packages/cli/src/commands/provider/ls.ts | 136 ++-- .../src/commands/provider/openrouter.test.ts | 119 ---- .../cli/src/commands/provider/openrouter.ts | 182 ----- packages/cli/src/commands/provider/rm.ts | 82 +++ packages/cli/src/commands/schedule/index.ts | 5 +- 14 files changed, 1493 insertions(+), 895 deletions(-) delete mode 100644 packages/cli/src/commands/login/index.test.ts delete mode 100644 packages/cli/src/commands/login/index.ts create mode 100644 packages/cli/src/commands/provider/add.test.ts create mode 100644 packages/cli/src/commands/provider/add.ts create mode 100644 packages/cli/src/commands/provider/ls-rm.test.ts delete mode 100644 packages/cli/src/commands/provider/openrouter.test.ts delete mode 100644 packages/cli/src/commands/provider/openrouter.ts create mode 100644 packages/cli/src/commands/provider/rm.ts diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 32f868e03d..f89004dd51 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,7 +2,6 @@ import { Command, Option } from "commander"; import { createAgentCommand } from "./commands/agent/index.js"; import { createDaemonCommand } from "./commands/daemon/index.js"; import { createChatCommand } from "./commands/chat/index.js"; -import { createLoginCommand } from "./commands/login/index.js"; import { createLoopCommand } from "./commands/loop/index.js"; import { createPermitCommand } from "./commands/permit/index.js"; import { createProviderCommand } from "./commands/provider/index.js"; @@ -162,9 +161,6 @@ export function createCli(): Command { // Permission commands program.addCommand(createPermitCommand()); - // Auth / login commands - program.addCommand(createLoginCommand()); - // Provider commands program.addCommand(createProviderCommand()); diff --git a/packages/cli/src/commands/daemon/status.ts b/packages/cli/src/commands/daemon/status.ts index 392279f4ad..a1b68792d1 100644 --- a/packages/cli/src/commands/daemon/status.ts +++ b/packages/cli/src/commands/daemon/status.ts @@ -1,6 +1,7 @@ import type { Command } from "commander"; import { createRequire } from "node:module"; import { getOrCreateServerId, findExecutable, execCommand } from "@getpaseo/server"; +import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest"; import { connectToDaemon } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; import { resolveLocalDaemonState, resolveTcpHostFromListen } from "./local-daemon.js"; @@ -156,11 +157,9 @@ function toStatusRows(status: DaemonStatus): StatusRow[] { return rows; } -const PROVIDER_BINARIES: { label: string; binary: string }[] = [ - { label: "Claude", binary: "claude" }, - { label: "Codex", binary: "codex" }, - { label: "OpenCode", binary: "opencode" }, -]; +const PROVIDER_BINARIES: { label: string; binary: string }[] = AGENT_PROVIDER_DEFINITIONS.filter( + (provider) => provider.voice?.enabled === true && provider.defaultModeId !== null, +).map((provider) => ({ label: provider.label, binary: provider.id })); async function checkProviderBinary( binary: string, diff --git a/packages/cli/src/commands/login/index.test.ts b/packages/cli/src/commands/login/index.test.ts deleted file mode 100644 index cdc8444626..0000000000 --- a/packages/cli/src/commands/login/index.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import { createCli } from "../../cli.js"; -import { createLoginCommand } from "./index.js"; - -interface RecordedLogin { - mode: "browser" | "device"; - providerInstance?: string; - baseUrl?: string; - envHome?: string | undefined; -} - -describe("paseo login command", () => { - it("is registered on the top-level CLI", () => { - const program = createCli(); - const login = program.commands.find((command) => command.name() === "login"); - expect(login).toBeDefined(); - }); - - it("exposes a `chatgpt` subcommand with browser default + headless flag", () => { - const login = createLoginCommand(); - const chatgpt = login.commands.find((command) => command.name() === "chatgpt"); - expect(chatgpt).toBeDefined(); - - const flags = chatgpt?.options.map((option) => option.long); - // Default flow is browser; device-code is an opt-in fallback. - expect(flags).toContain("--device-code"); - expect(flags).toContain("--host"); - expect(flags).toContain("--home"); - // It must not require a copy/paste device flow by default. - expect(chatgpt?.description().toLowerCase()).toContain("chatgpt"); - }); - - it("runs browser login by default and stores the credential through the daemon", async () => { - const recorded: RecordedLogin[] = []; - const stored: unknown[] = []; - const openedUrls: string[] = []; - const output: string[] = []; - - const login = createLoginCommand({ - write: (message) => output.push(message), - writeError: (message) => output.push(message), - openBrowser: (url) => { - openedUrls.push(url); - return true; - }, - promptForCode: async () => { - throw new Error("manual code prompt should not be used for successful browser login"); - }, - loginBrowserCredential: async (options) => { - recorded.push({ mode: "browser" }); - options.onAuthUrl("https://auth.openai.com/oauth/authorize?client_id=paseo"); - options.onProgress?.("callback complete"); - return { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 123 }; - }, - connectDaemon: async (options) => { - expect(options.host).toBe("localhost:7777"); - return { - getLastServerInfoMessage: () => ({ - status: "server_info", - serverId: "test-daemon", - features: { paseoAgentConfig: true }, - }), - storePaseoAgentOAuthCredential: async (input) => { - stored.push(input); - return { - requestId: "request-1", - success: true, - name: input.name, - auth: { kind: "oauth", configured: true, source: "stored" }, - error: null, - }; - }, - close: async () => {}, - }; - }, - loginDeviceCode: async () => { - throw new Error("device-code login should not be used by default"); - }, - }); - - await login.parseAsync(["node", "login", "chatgpt", "--host", "localhost:7777"]); - - expect(recorded).toEqual([{ mode: "browser" }]); - expect(stored).toEqual([ - { - name: "chatgpt", - credential: { - type: "oauth", - access: "access-token", - refresh: "refresh-token", - expires: 123, - }, - }, - ]); - expect(openedUrls).toEqual(["https://auth.openai.com/oauth/authorize?client_id=paseo"]); - expect(output.join("\n")).toContain("browser flow"); - expect(output.join("\n")).toContain("selected daemon (localhost:7777)"); - expect(output.join("\n")).not.toContain("access-token"); - expect(output.join("\n")).not.toContain("refresh-token"); - }); - - it("uses device-code login only when explicitly requested", async () => { - const recorded: RecordedLogin[] = []; - const output: string[] = []; - - const login = createLoginCommand({ - write: (message) => output.push(message), - writeError: (message) => output.push(message), - openBrowser: () => { - throw new Error("browser opener should not run for --device-code"); - }, - promptForCode: async () => { - throw new Error("manual browser prompt should not run for --device-code"); - }, - loginBrowserCredential: async () => { - throw new Error("browser login should not run for --device-code"); - }, - connectDaemon: async () => { - throw new Error("daemon client should not be used for local --device-code"); - }, - loginDeviceCode: async (options) => { - recorded.push({ - providerInstance: options.providerInstance, - baseUrl: options.baseUrl, - envHome: options.env?.PASEO_HOME, - mode: "device", - }); - options.onDeviceCode({ - userCode: "ABCD-EFGH", - verificationUri: "https://auth.openai.com/codex/device", - intervalSeconds: 5, - expiresInSeconds: 900, - }); - return { path: "/tmp/paseo-home/paseo-agent/auth.json" }; - }, - }); - - await login.parseAsync([ - "node", - "login", - "chatgpt", - "--device-code", - "--home", - "/tmp/paseo-home", - ]); - - expect(recorded).toEqual([ - { - providerInstance: "chatgpt", - baseUrl: "https://chatgpt.com/backend-api", - envHome: "/tmp/paseo-home", - mode: "device", - }, - ]); - expect(output.join("\n")).toContain("headless device-code flow"); - expect(output.join("\n")).toContain("ABCD-EFGH"); - }); - - it("rejects --device-code with --host instead of writing local auth for a remote host", async () => { - const output: string[] = []; - const stderr: string[] = []; - const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { - stderr.push(String(chunk)); - return true; - }); - const login = createLoginCommand({ - write: (message) => output.push(message), - writeError: (message) => output.push(message), - openBrowser: () => { - throw new Error("browser opener should not run"); - }, - promptForCode: async () => { - throw new Error("prompt should not run"); - }, - loginBrowserCredential: async () => { - throw new Error("browser login should not run"); - }, - loginDeviceCode: async () => { - throw new Error("device-code login should not run with --host"); - }, - connectDaemon: async () => { - throw new Error("daemon client should not be used"); - }, - }); - - await expect( - login.parseAsync(["node", "login", "chatgpt", "--device-code", "--host", "remote:7777"]), - ).rejects.toThrow("process.exit unexpectedly called"); - stderrSpy.mockRestore(); - - expect(stderr.join("\n")).toContain("--device-code cannot be combined with --host"); - }); - - it("asks for a host update instead of sending credentials to an old daemon", async () => { - const stored: unknown[] = []; - const output: string[] = []; - const stderr: string[] = []; - const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk) => { - stderr.push(String(chunk)); - return true; - }); - const login = createLoginCommand({ - write: (message) => output.push(message), - writeError: (message) => output.push(message), - openBrowser: () => { - throw new Error("browser opener should not run without the capability flag"); - }, - promptForCode: async () => { - throw new Error("manual code prompt should not be used"); - }, - loginBrowserCredential: async () => { - throw new Error("browser login should not run without the capability flag"); - }, - connectDaemon: async () => ({ - getLastServerInfoMessage: () => ({ - status: "server_info", - serverId: "test-daemon", - features: {}, - }), - storePaseoAgentOAuthCredential: async (input) => { - stored.push(input); - throw new Error("store RPC should not be called without the capability flag"); - }, - close: async () => {}, - }), - loginDeviceCode: async () => { - throw new Error("device-code login should not run"); - }, - }); - - await expect( - login.parseAsync(["node", "login", "chatgpt", "--host", "remote:7777"]), - ).rejects.toThrow("process.exit unexpectedly called"); - stderrSpy.mockRestore(); - - expect(stored).toEqual([]); - expect(stderr.join("\n")).toContain("Update the host to configure Paseo Agent providers."); - }); - - it("does not echo password-bearing host URIs after remote login", async () => { - const output: string[] = []; - const login = createLoginCommand({ - write: (message) => output.push(message), - writeError: (message) => output.push(message), - openBrowser: () => true, - promptForCode: async () => { - throw new Error("manual code prompt should not be used"); - }, - loginBrowserCredential: async () => ({ - type: "oauth", - access: "access-token", - refresh: "refresh-token", - expires: 123, - }), - connectDaemon: async () => ({ - getLastServerInfoMessage: () => ({ - status: "server_info", - serverId: "test-daemon", - features: { paseoAgentConfig: true }, - }), - storePaseoAgentOAuthCredential: async (input) => ({ - requestId: "request-1", - success: true, - name: input.name, - auth: { kind: "oauth", configured: true, source: "stored" }, - error: null, - }), - close: async () => {}, - }), - loginDeviceCode: async () => { - throw new Error("device-code login should not run"); - }, - }); - - await login.parseAsync([ - "node", - "login", - "chatgpt", - "--host", - "tcp://remote:7777?ssl=true&password=super-secret", - ]); - - expect(output.join("\n")).toContain("tcp://remote:7777?ssl=true"); - expect(output.join("\n")).not.toContain("super-secret"); - }); -}); diff --git a/packages/cli/src/commands/login/index.ts b/packages/cli/src/commands/login/index.ts deleted file mode 100644 index 5606a6d29a..0000000000 --- a/packages/cli/src/commands/login/index.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { createInterface } from "node:readline/promises"; -import { Command } from "commander"; -import { loginOAuthBrowser, loginAndStoreOAuth, type OAuthDeviceCodeInfo } from "@getpaseo/server"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { PaseoAgentOAuthCredential } from "@getpaseo/protocol/messages"; - -import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; -import { connectToDaemon } from "../../utils/client.js"; -import { openBrowserUrl } from "../../utils/open-browser.js"; -import { - type CommandError, - type CommandOptions, - type OutputSchema, - type SingleResult, - withOutput, -} from "../../output/index.js"; - -// First-class auth UX: `paseo login chatgpt`. -// Default flow is browser OAuth (PKCE + local callback on 127.0.0.1:1455) via Pi's -// helper; credentials are then sent to the selected daemon for storage. `--device-code` -// remains a local-only fallback until a daemon-run device-code RPC exists. - -const PROVIDER_INSTANCE = "chatgpt"; -const OAUTH_FLOW = "openai-codex"; -const OAUTH_BASE_URL = "https://chatgpt.com/backend-api"; - -interface LoginChatgptOptions extends CommandOptions { - deviceCode?: boolean; - home?: string; -} - -interface LoginResult { - provider: string; - mode: "browser" | "device-code"; - target: string; - path: string; -} - -interface LoginCommandDependencies { - loginDeviceCode: typeof loginAndStoreOAuth; - loginBrowserCredential: typeof loginOAuthBrowser; - connectDaemon: (options: { - host?: string; - }) => Promise< - Pick - >; - openBrowser: (url: string) => boolean; - promptForCode: (message: string) => Promise; - write: (message: string) => void; - writeError: (message: string) => void; -} - -const defaultDependencies: LoginCommandDependencies = { - loginDeviceCode: loginAndStoreOAuth, - loginBrowserCredential: loginOAuthBrowser, - connectDaemon: connectToDaemon, - openBrowser: openBrowserUrl, - promptForCode, - write: (message) => console.error(message), - writeError: (message) => console.error(message), -}; - -const loginResultSchema: OutputSchema = { - idField: "provider", - columns: [ - { header: "PROVIDER", field: "provider", width: 12 }, - { header: "MODE", field: "mode", width: 12 }, - { header: "TARGET", field: "target", width: 44 }, - ], - renderHuman: (result) => { - const data = result.data as LoginResult; - return `Logged in. Credential stored ${data.mode === "device-code" ? `at ${data.path}` : `on ${data.target}`} in Paseo-owned auth storage.`; - }, - serialize: (data) => data, -}; - -function resolveEnv(home: string | undefined): NodeJS.ProcessEnv { - return home ? { ...process.env, PASEO_HOME: home } : process.env; -} - -function requirePaseoAgentConfigFeature(client: Pick) { - if (client.getLastServerInfoMessage()?.features?.paseoAgentConfig === true) { - return; - } - throw { - code: "HOST_UPDATE_REQUIRED", - message: "Update the host to configure Paseo Agent providers.", - } satisfies CommandError; -} - -function formatDaemonTarget(host: string | undefined): string { - if (!host) { - return "local daemon"; - } - try { - if (host.startsWith("tcp://")) { - const url = new URL(host); - url.searchParams.delete("password"); - return `selected daemon (${url.toString()})`; - } - } catch { - // Invalid hosts fail during connection; this path only formats the success message. - } - return `selected daemon (${host})`; -} - -async function promptForCode(message: string): Promise { - const rl = createInterface({ input: process.stdin, output: process.stdout }); - try { - return (await rl.question(`${message} `)).trim(); - } finally { - rl.close(); - } -} - -function printDeviceCode(write: (message: string) => void, info: OAuthDeviceCodeInfo): void { - const expiresInSeconds = info.expiresInSeconds ?? 900; - write("To authorize Paseo:"); - write(` 1. Open: ${info.verificationUri}`); - write(` 2. Enter code: ${info.userCode}`); - write(` (expires in ~${Math.round(expiresInSeconds / 60)} min — waiting...)\n`); -} - -async function runChatgptLogin( - options: LoginChatgptOptions, - dependencies: LoginCommandDependencies, -): Promise> { - const env = resolveEnv(options.home); - const { write } = dependencies; - - if (options.deviceCode && options.host) { - throw { - code: "INVALID_LOGIN_OPTIONS", - message: - "--device-code cannot be combined with --host yet. Use browser login for remote hosts.", - } satisfies CommandError; - } - - if (options.deviceCode) { - write("Paseo login — ChatGPT/Codex subscription (headless device-code flow)\n"); - const { path } = await dependencies.loginDeviceCode({ - flow: OAUTH_FLOW, - baseUrl: OAUTH_BASE_URL, - providerInstance: PROVIDER_INSTANCE, - env, - onDeviceCode: (info) => printDeviceCode(write, info), - }); - return { - type: "single", - data: { - provider: "chatgpt", - mode: "device-code", - target: path, - path, - }, - schema: loginResultSchema, - }; - } - - const target = formatDaemonTarget(options.host); - const client = await dependencies.connectDaemon({ host: options.host }); - try { - requirePaseoAgentConfigFeature(client); - write("Paseo login — ChatGPT/Codex subscription (browser flow)\n"); - const credential: PaseoAgentOAuthCredential = await dependencies.loginBrowserCredential({ - flow: OAUTH_FLOW, - onAuthUrl: (url) => { - const opened = dependencies.openBrowser(url); - write( - opened ? "Opening your browser to authorize Paseo…" : "Open this URL to authorize Paseo:", - ); - write(` ${url}\n`); - write("Waiting for you to approve in the browser…"); - write( - "(If the browser didn't open, copy the URL above. You can also paste the code here.)", - ); - }, - onProgress: (message) => write(message), - promptForCode: dependencies.promptForCode, - }); - const result = await client.storePaseoAgentOAuthCredential({ - name: PROVIDER_INSTANCE, - credential, - }); - if (!result.success || result.error) { - throw { - code: "LOGIN_REJECTED", - message: result.error ?? "Daemon rejected the ChatGPT credential", - } satisfies CommandError; - } - write(`Credential accepted by ${target}.`); - } finally { - await client.close().catch(() => {}); - } - - return { - type: "single", - data: { - provider: "chatgpt", - mode: "browser", - target, - path: target, - }, - schema: loginResultSchema, - }; -} - -export function createLoginCommand(dependencies: Partial = {}): Command { - const deps = { ...defaultDependencies, ...dependencies }; - const login = new Command("login").description("Authenticate Paseo providers"); - - addJsonAndDaemonHostOptions( - login - .command("chatgpt") - .description("Log in to ChatGPT/OpenAI (Codex subscription) for the Paseo Agent provider") - .option("--device-code", "Use the headless device-code flow instead of the browser flow") - .option("--home ", "Paseo home directory for local --device-code only"), - ).action(withOutput((options, _command) => runChatgptLogin(options, deps))); - - return login; -} diff --git a/packages/cli/src/commands/loop/run.ts b/packages/cli/src/commands/loop/run.ts index fc8c25c54b..2f3beeb2ae 100644 --- a/packages/cli/src/commands/loop/run.ts +++ b/packages/cli/src/commands/loop/run.ts @@ -50,10 +50,7 @@ export function addLoopRunOptions(command: Command): Command { .argument("", "Prompt for each fresh worker iteration") .option("--provider ", "Default provider for worker and verifier agents") .option("--model ", "Default model for worker and verifier agents") - .option( - "--mode ", - "Provider-specific mode for the worker agent (e.g. claude bypassPermissions, opencode build)", - ) + .option("--mode ", "Provider-specific mode for the worker agent") .option("--verify-provider ", "Provider for the verifier agent") .option("--verify-model ", "Model for the verifier agent") .option("--verify-mode ", "Provider-specific mode for the verifier agent") diff --git a/packages/cli/src/commands/provider/add.test.ts b/packages/cli/src/commands/provider/add.test.ts new file mode 100644 index 0000000000..82b30ad6a1 --- /dev/null +++ b/packages/cli/src/commands/provider/add.test.ts @@ -0,0 +1,621 @@ +import { describe, expect, it } from "vitest"; + +import { render } from "../../output/index.js"; +import { runAddCommand } from "./add.js"; + +interface CatalogEntry { + id: string; + label: string; + api: string; + baseUrl: string; + auth: Record; + models: Array<{ id: string; label?: string; reasoning?: boolean }>; +} + +interface RecordingClientInput { + catalog: CatalogEntry[]; + features?: Record; + setProvider?: (input: { + name: string; + providerType: string; + options: { apiKey?: string; models: Array<{ id: string }> }; + }) => Promise; + startOAuth?: (name: string) => Promise; + completeOAuth?: (name: string) => Promise; + storeCredential?: (input: { name: string; credential: unknown }) => Promise; +} + +function createClient(input: RecordingClientInput) { + return { + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "test-daemon", + features: input.features ?? { paseoAgentCatalog: true }, + }), + getPaseoAgentCatalog: async () => ({ + requestId: "catalog-1", + catalog: input.catalog, + error: null, + }), + setPaseoAgentProvider: async (providerInput: { + name: string; + providerType: string; + options: { apiKey?: string; models: Array<{ id: string }> }; + }) => { + if (input.setProvider) { + return input.setProvider(providerInput); + } + return { + requestId: "set-1", + success: true, + provider: { + name: providerInput.name, + providerType: providerInput.providerType, + models: providerInput.options.models, + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + error: null, + }, + error: null, + }; + }, + startPaseoAgentOAuth: async (name: string) => + input.startOAuth?.(name) ?? { + requestId: "oauth-start-1", + success: true, + name, + authorization: { + kind: "device_code", + userCode: "ABCD-EFGH", + verificationUri: "https://auth.example.test/device", + intervalSeconds: 5, + expiresInSeconds: 900, + }, + error: null, + }, + completePaseoAgentOAuth: async (name: string) => + input.completeOAuth?.(name) ?? { + requestId: "oauth-complete-1", + success: true, + name, + auth: { kind: "oauth", configured: true, source: "stored" }, + error: null, + }, + storePaseoAgentOAuthCredential: async (credentialInput: { + name: string; + credential: unknown; + }) => + input.storeCredential?.(credentialInput) ?? { + requestId: "oauth-store-1", + success: true, + name: credentialInput.name, + auth: { kind: "oauth", configured: true, source: "stored" }, + error: null, + }, + close: async () => {}, + }; +} + +function apiKeyEntry(overrides: Partial = {}): CatalogEntry { + return { + id: "alpha-key", + label: "Alpha Key", + api: "test-api", + baseUrl: "https://alpha.example.test", + auth: { + kind: "api_key", + envVar: "ALPHA_API_KEY", + hint: "Create an Alpha key before continuing.", + keyUrl: "https://alpha.example.test/keys", + placeholder: "Alpha API key", + }, + models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }], + ...overrides, + }; +} + +function oauthEntry(overrides: Partial = {}): CatalogEntry { + return { + id: "beta-oauth", + label: "Beta OAuth", + api: "test-oauth-api", + baseUrl: "https://beta.example.test", + auth: { kind: "oauth", flow: "beta-flow" }, + models: [{ id: "beta-model" }], + ...overrides, + }; +} + +describe("provider add", () => { + it("configures an API-key provider from a hidden prompt without echoing the key", async () => { + const setCalls: unknown[] = []; + const prompts: string[] = []; + const output: string[] = []; + const result = await runAddCommand( + "alpha-key", + { host: "localhost:7777", name: "alpha-main" }, + {} as never, + { + write: (message) => output.push(message), + promptSecret: async (message) => { + prompts.push(message); + return "redaction-sentinel"; + }, + promptText: async () => { + throw new Error("text prompt should not be used"); + }, + readStdin: async () => { + throw new Error("stdin should not be read"); + }, + connectDaemon: async (options) => { + expect(options.host).toBe("localhost:7777"); + return createClient({ + catalog: [apiKeyEntry()], + setProvider: async (input) => { + setCalls.push(input); + return { + requestId: "set-1", + success: true, + provider: { + name: input.name, + providerType: input.providerType, + models: input.options.models, + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + error: null, + }, + error: null, + }; + }, + }); + }, + }, + ); + + expect(setCalls).toEqual([ + { + name: "alpha-main", + providerType: "alpha-key", + options: { + apiKey: "redaction-sentinel", + models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }], + }, + }, + ]); + expect(prompts).toEqual(["Enter Alpha API key (leave empty to use $ALPHA_API_KEY):"]); + expect(output.join("\n")).toContain("Create an Alpha key"); + expect(output.join("\n")).toContain("https://alpha.example.test/keys"); + expect(render(result, { format: "json" })).not.toContain("redaction-sentinel"); + expect(render(result, { format: "table", noColor: true })).toContain("alpha-main"); + }); + + it("stores an environment reference when the API-key prompt is empty", async () => { + const setCalls: unknown[] = []; + const result = await runAddCommand("alpha-key", {}, {} as never, { + promptSecret: async () => "", + promptText: async () => { + throw new Error("text prompt should not be used"); + }, + readStdin: async () => { + throw new Error("stdin should not be read"); + }, + write: () => {}, + connectDaemon: async () => + createClient({ + catalog: [apiKeyEntry()], + setProvider: async (input) => { + setCalls.push(input); + return { + requestId: "set-1", + success: true, + provider: { + name: input.name, + providerType: input.providerType, + models: input.options.models, + auth: { kind: "api_key", configured: false, source: "env" }, + available: false, + error: null, + }, + error: null, + }; + }, + }), + }); + + expect(setCalls).toEqual([ + { + name: "alpha-key", + providerType: "alpha-key", + options: { + apiKey: "$ALPHA_API_KEY", + models: [{ id: "alpha-model", label: "Alpha Model", reasoning: true }], + }, + }, + ]); + expect(result.data.auth).toBe("Needs attention"); + }); + + it("reads an API key from stdin for scripts", async () => { + const setCalls: unknown[] = []; + + await runAddCommand("alpha-key", { apiKeyStdin: true, model: ["one,two"] }, {} as never, { + readStdin: async () => "stdin-secret\n", + promptSecret: async () => { + throw new Error("prompt should not be used with --api-key-stdin"); + }, + promptText: async () => { + throw new Error("text prompt should not be used"); + }, + write: () => {}, + connectDaemon: async () => + createClient({ + catalog: [apiKeyEntry({ models: [] })], + setProvider: async (input) => { + setCalls.push(input); + return { + requestId: "set-1", + success: true, + provider: { + name: input.name, + providerType: input.providerType, + models: input.options.models, + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + error: null, + }, + error: null, + }; + }, + }), + }); + + expect(setCalls).toEqual([ + { + name: "alpha-key", + providerType: "alpha-key", + options: { + apiKey: "stdin-secret", + models: [{ id: "one" }, { id: "two" }], + }, + }, + ]); + }); + + it("runs browser OAuth locally and pushes the credential to the selected daemon", async () => { + const order: string[] = []; + const stored: unknown[] = []; + const openedUrls: string[] = []; + + const result = await runAddCommand( + "beta-oauth", + { host: "tcp://remote:7777?ssl=true&password=secret", name: "beta-main" }, + {} as never, + { + write: (message) => order.push(`write:${message}`), + openBrowser: (url) => { + openedUrls.push(url); + return true; + }, + loginBrowserCredential: async (options) => { + expect(options.flow).toBe("beta-flow"); + order.push("browser-login"); + options.onAuthUrl("https://auth.example.test/browser", "Authorize in the browser."); + return { type: "oauth", access: "access-token", refresh: "refresh-token", expires: 123 }; + }, + promptText: async () => { + throw new Error("manual prompt should not be used"); + }, + promptSecret: async () => { + throw new Error("secret prompt should not be used"); + }, + readStdin: async () => { + throw new Error("stdin should not be read"); + }, + connectDaemon: async () => + createClient({ + catalog: [oauthEntry()], + setProvider: async (input) => { + order.push("set-provider"); + return { + requestId: "set-1", + success: true, + provider: { + name: input.name, + providerType: input.providerType, + models: input.options.models, + auth: { kind: "oauth", configured: false }, + available: false, + error: null, + }, + error: null, + }; + }, + storeCredential: async (input) => { + stored.push(input); + return { + requestId: "store-1", + success: true, + name: input.name, + auth: { kind: "oauth", configured: true, source: "stored" }, + error: null, + }; + }, + }), + }, + ); + + expect(order).toEqual([ + "set-provider", + "browser-login", + "write:Authorize in the browser.", + "write: https://auth.example.test/browser", + "write:Waiting for you to approve in the browser...", + "write:Credential accepted by selected daemon (tcp://remote:7777?ssl=true).", + ]); + expect(stored).toEqual([ + { + name: "beta-main", + credential: { + type: "oauth", + access: "access-token", + refresh: "refresh-token", + expires: 123, + }, + }, + ]); + expect(openedUrls).toEqual(["https://auth.example.test/browser"]); + expect(result.data.auth).toBe("Connected"); + expect(order.join("\n")).not.toContain("secret"); + }); + + it("uses daemon-run OAuth when --device-code is passed", async () => { + const output: string[] = []; + const oauthCalls: string[] = []; + + const result = await runAddCommand("beta-oauth", { deviceCode: true }, {} as never, { + write: (message) => output.push(message), + openBrowser: () => { + throw new Error("browser should not be opened for --device-code"); + }, + loginBrowserCredential: async () => { + throw new Error("browser login should not run for --device-code"); + }, + promptText: async () => { + throw new Error("prompt should not be used"); + }, + promptSecret: async () => { + throw new Error("secret prompt should not be used"); + }, + readStdin: async () => { + throw new Error("stdin should not be read"); + }, + connectDaemon: async () => + createClient({ + catalog: [oauthEntry()], + startOAuth: async (name) => { + oauthCalls.push(`start:${name}`); + return { + requestId: "start-1", + success: true, + name, + authorization: { + kind: "device_code", + userCode: "ABCD-EFGH", + verificationUri: "https://auth.example.test/device", + expiresInSeconds: 900, + }, + error: null, + }; + }, + completeOAuth: async (name) => { + oauthCalls.push(`complete:${name}`); + return { + requestId: "complete-1", + success: true, + name, + auth: { kind: "oauth", configured: true, source: "stored" }, + error: null, + }; + }, + }), + }); + + expect(oauthCalls).toEqual(["start:beta-oauth", "complete:beta-oauth"]); + expect(output.join("\n")).toContain("ABCD-EFGH"); + expect(output.join("\n")).toContain("https://auth.example.test/device"); + expect(result.data.auth).toBe("Connected"); + }); + + it("falls back to daemon-run OAuth when the browser cannot open", async () => { + const output: string[] = []; + const oauthCalls: string[] = []; + + await runAddCommand("beta-oauth", {}, {} as never, { + write: (message) => output.push(message), + openBrowser: () => false, + loginBrowserCredential: async (options) => { + options.onAuthUrl("https://auth.example.test/browser"); + throw new Error("onAuthUrl should have switched to device-code"); + }, + promptText: async () => { + throw new Error("prompt should not be used"); + }, + promptSecret: async () => { + throw new Error("secret prompt should not be used"); + }, + readStdin: async () => { + throw new Error("stdin should not be read"); + }, + connectDaemon: async () => + createClient({ + catalog: [oauthEntry()], + startOAuth: async (name) => { + oauthCalls.push(`start:${name}`); + return { + requestId: "start-1", + success: true, + name, + authorization: { + kind: "device_code", + userCode: "WXYZ-1234", + verificationUri: "https://auth.example.test/device", + }, + error: null, + }; + }, + completeOAuth: async (name) => { + oauthCalls.push(`complete:${name}`); + return { + requestId: "complete-1", + success: true, + name, + auth: { kind: "oauth", configured: true, source: "stored" }, + error: null, + }; + }, + }), + }); + + expect(oauthCalls).toEqual(["start:beta-oauth", "complete:beta-oauth"]); + expect(output.join("\n")).toContain("Browser could not be opened"); + expect(output.join("\n")).toContain("WXYZ-1234"); + }); + + it("updates the same instance on repeated add calls", async () => { + const setCalls: unknown[] = []; + const client = createClient({ + catalog: [apiKeyEntry()], + setProvider: async (input) => { + setCalls.push(input); + return { + requestId: "set-1", + success: true, + provider: { + name: input.name, + providerType: input.providerType, + models: input.options.models, + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + error: null, + }, + error: null, + }; + }, + }); + const dependencies = { + promptSecret: async () => "secret", + promptText: async () => { + throw new Error("text prompt should not be used"); + }, + readStdin: async () => { + throw new Error("stdin should not be read"); + }, + write: () => {}, + connectDaemon: async () => client, + }; + + await runAddCommand("alpha-key", { name: "same-name" }, {} as never, dependencies); + await runAddCommand("alpha-key", { name: "same-name" }, {} as never, dependencies); + + expect(setCalls).toHaveLength(2); + expect(setCalls).toEqual([ + expect.objectContaining({ name: "same-name", providerType: "alpha-key" }), + expect.objectContaining({ name: "same-name", providerType: "alpha-key" }), + ]); + }); + + it("requires the catalog feature flag before reading the catalog", async () => { + const calls: string[] = []; + + await expect( + runAddCommand("alpha-key", {}, {} as never, { + promptSecret: async () => { + throw new Error("prompt should not run"); + }, + promptText: async () => { + throw new Error("text prompt should not run"); + }, + readStdin: async () => { + throw new Error("stdin should not run"); + }, + write: () => {}, + connectDaemon: async () => ({ + getLastServerInfoMessage: () => ({ + status: "server_info", + serverId: "test-daemon", + features: {}, + }), + getPaseoAgentCatalog: async () => { + calls.push("catalog"); + throw new Error("catalog should not be read"); + }, + setPaseoAgentProvider: async () => { + calls.push("set"); + throw new Error("set should not run"); + }, + startPaseoAgentOAuth: async () => { + throw new Error("oauth should not run"); + }, + completePaseoAgentOAuth: async () => { + throw new Error("oauth should not run"); + }, + storePaseoAgentOAuthCredential: async () => { + throw new Error("oauth should not run"); + }, + close: async () => {}, + }), + }), + ).rejects.toMatchObject({ + code: "HOST_UPDATE_REQUIRED", + message: "Update the Paseo daemon to use this command.", + }); + + expect(calls).toEqual([]); + }); + + it("lets the user choose a provider when no id is passed", async () => { + const setCalls: unknown[] = []; + const output: string[] = []; + + await runAddCommand(undefined, {}, {} as never, { + write: (message) => output.push(message), + promptText: async (message) => { + expect(message).toBe("Select provider:"); + return "2"; + }, + promptSecret: async () => "chosen-secret", + readStdin: async () => { + throw new Error("stdin should not be read"); + }, + connectDaemon: async () => + createClient({ + catalog: [ + apiKeyEntry({ id: "first-key", label: "First Key" }), + apiKeyEntry({ id: "second-key", label: "Second Key" }), + ], + setProvider: async (input) => { + setCalls.push(input); + return { + requestId: "set-1", + success: true, + provider: { + name: input.name, + providerType: input.providerType, + models: input.options.models, + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + error: null, + }, + error: null, + }; + }, + }), + }); + + expect(output.join("\n")).toContain("First Key (first-key)"); + expect(output.join("\n")).toContain("Second Key (second-key)"); + expect(setCalls).toEqual([ + expect.objectContaining({ name: "second-key", providerType: "second-key" }), + ]); + }); +}); diff --git a/packages/cli/src/commands/provider/add.ts b/packages/cli/src/commands/provider/add.ts new file mode 100644 index 0000000000..032bf88d9c --- /dev/null +++ b/packages/cli/src/commands/provider/add.ts @@ -0,0 +1,540 @@ +import { createInterface } from "node:readline/promises"; +import { Writable } from "node:stream"; +import type { Command } from "commander"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { + PaseoAgentCatalogEntry, + PaseoAgentOAuthCredential, + PaseoAgentProviderAuthState, + RedactedPaseoAgentProviderConfig, +} from "@getpaseo/protocol/messages"; +import { loginOAuthBrowser } from "@getpaseo/server"; + +import { connectToDaemon } from "../../utils/client.js"; +import { collectMultiple } from "../../utils/command-options.js"; +import { openBrowserUrl } from "../../utils/open-browser.js"; +import type { + CommandError, + CommandOptions, + OutputSchema, + SingleResult, +} from "../../output/index.js"; + +interface ProviderAddOptions extends CommandOptions { + name?: string; + apiKeyStdin?: boolean; + deviceCode?: boolean; + model?: string[]; +} + +interface ProviderModelInput { + id: string; + label?: string; + api?: string; + reasoning?: boolean; + contextWindow?: number; + maxTokens?: number; +} + +interface ProviderConfiguredItem { + name: string; + providerType: string; + label: string; + auth: string; + available: string; + models: string; +} + +interface ProviderAddClient extends Pick< + DaemonClient, + | "getLastServerInfoMessage" + | "getPaseoAgentCatalog" + | "setPaseoAgentProvider" + | "startPaseoAgentOAuth" + | "completePaseoAgentOAuth" + | "storePaseoAgentOAuthCredential" + | "close" +> {} + +export interface ProviderAddDependencies { + connectDaemon: (options: { host?: string }) => Promise; + readStdin: () => Promise; + promptText: (message: string) => Promise; + promptSecret: (message: string) => Promise; + loginBrowserCredential: typeof loginOAuthBrowser; + openBrowser: (url: string) => boolean; + write: (message: string) => void; +} + +const defaultDependencies: ProviderAddDependencies = { + connectDaemon: connectToDaemon, + readStdin, + promptText, + promptSecret, + loginBrowserCredential: loginOAuthBrowser, + openBrowser: openBrowserUrl, + write: (message) => console.error(message), +}; + +export const providerConfiguredSchema: OutputSchema = { + idField: "name", + columns: [ + { header: "NAME", field: "name", width: 20 }, + { header: "TYPE", field: "providerType", width: 16 }, + { header: "LABEL", field: "label", width: 22 }, + { header: "AUTH", field: "auth", width: 16 }, + { header: "AVAILABLE", field: "available", width: 10 }, + { header: "MODELS", field: "models", width: 50 }, + ], +}; + +async function readStdin(): Promise { + process.stdin.setEncoding("utf8"); + let value = ""; + for await (const chunk of process.stdin) { + value += chunk; + } + return value; +} + +async function promptText(message: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + return (await rl.question(`${message} `)).trim(); + } finally { + rl.close(); + } +} + +async function promptSecret(message: string): Promise { + const output = new Writable({ + write(chunk, _encoding, callback) { + const text = String(chunk); + if (text.includes(message)) { + process.stdout.write(text); + } + callback(); + }, + }) as Writable & { isTTY?: boolean }; + output.isTTY = true; + + const rl = createInterface({ input: process.stdin, output, terminal: true }); + try { + return (await rl.question(`${message} `)).trim(); + } finally { + process.stdout.write("\n"); + rl.close(); + } +} + +function requirePaseoAgentCatalogFeature( + client: Pick, +): void { + if (client.getLastServerInfoMessage()?.features?.paseoAgentCatalog === true) { + return; + } + throw { + code: "HOST_UPDATE_REQUIRED", + message: "Update the Paseo daemon to use this command.", + } satisfies CommandError; +} + +function authField(auth: Record, field: string): string | undefined { + const value = auth[field]; + return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined; +} + +function apiKeyEnvVar(entry: PaseoAgentCatalogEntry): string { + const envVar = authField(entry.auth, "envVar"); + if (envVar) { + return envVar; + } + throw { + code: "UNSUPPORTED_PROVIDER_AUTH", + message: `Provider ${entry.id} is missing an API key environment variable. Update the Paseo daemon to use this command.`, + } satisfies CommandError; +} + +function oauthFlow(entry: PaseoAgentCatalogEntry): string { + const flow = authField(entry.auth, "flow"); + if (flow) { + return flow; + } + throw { + code: "UNSUPPORTED_PROVIDER_AUTH", + message: `Provider ${entry.id} is missing an OAuth flow. Update the Paseo daemon to use this command.`, + } satisfies CommandError; +} + +function normalizeModels(rawModels: string[] | undefined): string[] { + return (rawModels ?? []) + .flatMap((value) => value.split(",")) + .map((value) => value.trim()) + .filter(Boolean); +} + +function catalogModels(entry: PaseoAgentCatalogEntry): ProviderModelInput[] { + return entry.models.map((model) => ({ + id: model.id, + ...(model.label ? { label: model.label } : {}), + ...(model.api ? { api: model.api } : {}), + ...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {}), + ...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}), + ...(model.maxTokens !== undefined ? { maxTokens: model.maxTokens } : {}), + })); +} + +function selectedModels( + entry: PaseoAgentCatalogEntry, + options: ProviderAddOptions, +): ProviderModelInput[] { + const modelIds = normalizeModels(options.model); + if (modelIds.length > 0) { + return modelIds.map((id) => ({ id })); + } + return catalogModels(entry); +} + +function requireModels( + entry: PaseoAgentCatalogEntry, + options: ProviderAddOptions, +): ProviderModelInput[] { + const models = selectedModels(entry, options); + if (models.length > 0) { + return models; + } + throw { + code: "MISSING_MODELS", + message: `At least one model is required for ${entry.label}.`, + details: + "Pass --model . Repeat --model to configure more than one; comma-separated values are also accepted.", + } satisfies CommandError; +} + +async function selectCatalogEntry( + catalog: PaseoAgentCatalogEntry[], + dependencies: ProviderAddDependencies, +): Promise { + if (catalog.length === 0) { + throw { + code: "EMPTY_PROVIDER_CATALOG", + message: "The Paseo daemon returned an empty provider catalog.", + } satisfies CommandError; + } + + dependencies.write("Available model providers:"); + catalog.forEach((entry, index) => { + dependencies.write(` ${index + 1}. ${entry.label} (${entry.id})`); + }); + const answer = await dependencies.promptText("Select provider:"); + const selectedIndex = Number(answer); + const byIndex = Number.isInteger(selectedIndex) ? catalog[selectedIndex - 1] : undefined; + const byId = catalog.find((entry) => entry.id === answer); + const selected = byIndex ?? byId; + if (selected) { + return selected; + } + throw { + code: "INVALID_PROVIDER_SELECTION", + message: `Invalid provider selection: ${answer}`, + } satisfies CommandError; +} + +async function resolveEntry( + id: string | undefined, + catalog: PaseoAgentCatalogEntry[], + client: ProviderAddClient, + dependencies: ProviderAddDependencies, +): Promise { + if (!id) { + return selectCatalogEntry(catalog, dependencies); + } + + const entry = catalog.find((candidate) => candidate.id === id); + if (entry) { + return entry; + } + + const result = await client.setPaseoAgentProvider({ + name: id, + providerType: id, + options: { models: [{ id: "placeholder" }] }, + }); + throw { + code: "UNKNOWN_PROVIDER", + message: result.error ?? `Unknown model provider type "${id}".`, + } satisfies CommandError; +} + +function formatAuthState(provider: RedactedPaseoAgentProviderConfig): string { + if (!provider.auth) { + return "not configured"; + } + return provider.auth.configured ? "Connected" : "Needs attention"; +} + +function toConfiguredItem( + provider: RedactedPaseoAgentProviderConfig, + entry: PaseoAgentCatalogEntry, +): ProviderConfiguredItem { + return { + name: provider.name, + providerType: provider.providerType, + label: entry.label, + auth: formatAuthState(provider), + available: provider.available ? "yes" : "no", + models: provider.models.map((model) => model.id).join(", ") || "-", + }; +} + +function formatDaemonTarget(host: string | undefined): string { + if (!host) { + return "local daemon"; + } + try { + if (host.startsWith("tcp://")) { + const url = new URL(host); + url.searchParams.delete("password"); + return `selected daemon (${url.toString()})`; + } + } catch { + return `selected daemon (${host})`; + } + return `selected daemon (${host})`; +} + +async function resolveApiKey( + entry: PaseoAgentCatalogEntry, + options: ProviderAddOptions, + dependencies: ProviderAddDependencies, +): Promise { + const envVar = apiKeyEnvVar(entry); + if (options.apiKeyStdin) { + const value = (await dependencies.readStdin()).trim(); + if (value) { + return value; + } + throw { + code: "MISSING_API_KEY", + message: "No API key was read from stdin.", + } satisfies CommandError; + } + + const hint = authField(entry.auth, "hint"); + const keyUrl = authField(entry.auth, "keyUrl"); + if (hint) { + dependencies.write(hint); + } + if (keyUrl) { + dependencies.write(`API key URL: ${keyUrl}`); + } + const placeholder = authField(entry.auth, "placeholder") ?? "API key"; + const value = await dependencies.promptSecret( + `Enter ${placeholder} (leave empty to use $${envVar}):`, + ); + return value || `$${envVar}`; +} + +function browserOpenError(): CommandError { + return { + code: "BROWSER_OPEN_FAILED", + message: "Browser could not be opened.", + }; +} + +function isBrowserOpenError(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + (error as CommandError).code === "BROWSER_OPEN_FAILED" + ); +} + +function printOAuthAuthorization( + authorization: Awaited>["authorization"], + dependencies: ProviderAddDependencies, +): void { + if (!authorization) { + dependencies.write("Authorization completed; waiting for the daemon to store credentials..."); + return; + } + if (authorization.instructions) { + dependencies.write(authorization.instructions); + } + if (authorization.verificationUri) { + dependencies.write(`Open: ${authorization.verificationUri}`); + } + if (authorization.userCode) { + dependencies.write(`Code: ${authorization.userCode}`); + } + if (authorization.url) { + dependencies.write(`Open: ${authorization.url}`); + } + if (authorization.expiresInSeconds) { + dependencies.write( + `Expires in about ${Math.round(authorization.expiresInSeconds / 60)} minutes.`, + ); + } + dependencies.write("Waiting for authorization..."); +} + +async function runDaemonOAuth( + client: ProviderAddClient, + name: string, + dependencies: ProviderAddDependencies, +): Promise { + const started = await client.startPaseoAgentOAuth(name); + if (!started.success) { + throw { + code: "OAUTH_START_FAILED", + message: started.error ?? "Daemon rejected the OAuth start request.", + } satisfies CommandError; + } + printOAuthAuthorization(started.authorization, dependencies); + const completed = await client.completePaseoAgentOAuth(name); + if (!completed.success) { + throw { + code: "OAUTH_COMPLETE_FAILED", + message: completed.error ?? "Daemon did not complete OAuth.", + } satisfies CommandError; + } + return completed.auth; +} + +async function runBrowserOAuth( + client: ProviderAddClient, + entry: PaseoAgentCatalogEntry, + name: string, + options: ProviderAddOptions, + dependencies: ProviderAddDependencies, +): Promise { + const target = formatDaemonTarget(options.host); + const credential: PaseoAgentOAuthCredential = await dependencies.loginBrowserCredential({ + flow: oauthFlow(entry), + onAuthUrl: (url, instructions) => { + const opened = dependencies.openBrowser(url); + if (!opened) { + throw browserOpenError(); + } + dependencies.write(instructions ?? "Opening your browser to authorize Paseo."); + dependencies.write(` ${url}`); + dependencies.write("Waiting for you to approve in the browser..."); + }, + onProgress: (message) => dependencies.write(message), + promptForCode: dependencies.promptText, + }); + const result = await client.storePaseoAgentOAuthCredential({ name, credential }); + if (!result.success) { + throw { + code: "OAUTH_STORE_FAILED", + message: result.error ?? "Daemon rejected the OAuth credential.", + } satisfies CommandError; + } + dependencies.write(`Credential accepted by ${target}.`); + return result.auth; +} + +async function configureProvider( + client: ProviderAddClient, + entry: PaseoAgentCatalogEntry, + name: string, + options: ProviderAddOptions, + dependencies: ProviderAddDependencies, +): Promise { + const models = requireModels(entry, options); + const apiKey = + entry.auth.kind === "api_key" ? await resolveApiKey(entry, options, dependencies) : undefined; + const result = await client.setPaseoAgentProvider({ + name, + providerType: entry.id, + options: { + ...(apiKey ? { apiKey } : {}), + models, + }, + }); + if (!result.success || !result.provider) { + throw { + code: "PROVIDER_CONFIG_FAILED", + message: result.error ?? "Daemon rejected the provider config.", + } satisfies CommandError; + } + return result.provider; +} + +async function authenticateOAuthProvider( + client: ProviderAddClient, + entry: PaseoAgentCatalogEntry, + name: string, + options: ProviderAddOptions, + dependencies: ProviderAddDependencies, +): Promise { + if (options.deviceCode) { + return runDaemonOAuth(client, name, dependencies); + } + try { + return await runBrowserOAuth(client, entry, name, options, dependencies); + } catch (error) { + if (!isBrowserOpenError(error)) { + throw error; + } + dependencies.write("Browser could not be opened; using device-code authorization."); + return runDaemonOAuth(client, name, dependencies); + } +} + +export async function runAddCommand( + id: string | undefined, + options: ProviderAddOptions, + _command: Command, + dependencies: Partial = {}, +): Promise> { + const deps = { ...defaultDependencies, ...dependencies }; + const client = await deps.connectDaemon({ host: options.host }); + try { + requirePaseoAgentCatalogFeature(client); + const catalogResult = await client.getPaseoAgentCatalog(); + if (catalogResult.error) { + throw { + code: "PROVIDER_CATALOG_FAILED", + message: catalogResult.error, + } satisfies CommandError; + } + const entry = await resolveEntry(id, catalogResult.catalog, client, deps); + const name = options.name?.trim() || entry.id; + const provider = await configureProvider(client, entry, name, options, deps); + let outputProvider = provider; + if (entry.auth.kind === "oauth") { + const auth = await authenticateOAuthProvider(client, entry, name, options, deps); + outputProvider = auth ? { ...provider, auth } : provider; + } else if (entry.auth.kind !== "api_key") { + throw { + code: "UNSUPPORTED_PROVIDER_AUTH", + message: `Provider ${entry.label} uses an auth type this CLI does not understand. Update the Paseo daemon to use this command.`, + } satisfies CommandError; + } + + return { + type: "single", + data: toConfiguredItem(outputProvider, entry), + schema: providerConfiguredSchema, + }; + } finally { + await client.close().catch(() => {}); + } +} + +export function addProviderAddOptions(command: Command): Command { + return command + .description("Configure a Paseo Agent model provider") + .argument("[id]", "Catalog provider id; omit to choose interactively") + .option("--name ", "Provider instance name (default: provider id)") + .option( + "--model ", + "Model ID to expose (repeatable, comma-separated; defaults to catalog models)", + collectMultiple, + [], + ) + .option("--api-key-stdin", "Read API key from stdin") + .option("--device-code", "Use daemon-run device-code OAuth instead of browser OAuth"); +} diff --git a/packages/cli/src/commands/provider/index.ts b/packages/cli/src/commands/provider/index.ts index 43f5660849..ae9a3f2cda 100644 --- a/packages/cli/src/commands/provider/index.ts +++ b/packages/cli/src/commands/provider/index.ts @@ -1,33 +1,51 @@ import { Command } from "commander"; import { runLsCommand } from "./ls.js"; import { runModelsCommand } from "./models.js"; -import { addOpenRouterOptions, runAddOpenRouterCommand } from "./openrouter.js"; +import { addProviderAddOptions, runAddCommand, type ProviderAddDependencies } from "./add.js"; +import { runRmCommand, type ProviderRmDependencies } from "./rm.js"; +import type { ProviderListItem, ProviderLsDependencies } from "./ls.js"; import { withOutput } from "../../output/index.js"; import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js"; export function createProviderCommand( - dependencies: Parameters[3] = {}, + dependencies: Partial< + ProviderAddDependencies & ProviderLsDependencies & ProviderRmDependencies + > = {}, ): Command { const provider = new Command("provider").description( - "Manage agent providers (ls, models) and Paseo Agent model providers (add)", + "Manage Paseo Agent model providers and agent provider models", ); addJsonAndDaemonHostOptions( - provider.command("ls").description("List available providers and status"), - ).action(withOutput(runLsCommand)); + provider.command("ls").description("List configured Paseo Agent model providers"), + ).action( + withOutput((options, command) => + runLsCommand(options, command, dependencies), + ), + ); addJsonAndDaemonHostOptions( provider .command("models") .description("List models for a provider") - .argument("", "Provider name (claude, codex, opencode)") + .argument("", "Provider name") .option("--thinking", "Include thinking option IDs for each model"), ).action(withOutput(runModelsCommand)); - const add = provider.command("add").description("Configure a Paseo Agent model provider"); - addJsonAndDaemonHostOptions(addOpenRouterOptions(add.command("openrouter"))).action( - withOutput>["data"], [string]>( - (name, options, command) => runAddOpenRouterCommand(name, options, command, dependencies), + addJsonAndDaemonHostOptions(addProviderAddOptions(provider.command("add"))).action( + withOutput>["data"], [string | undefined]>( + (id, options, command) => runAddCommand(id, options, command, dependencies), + ), + ); + + addJsonAndDaemonHostOptions( + provider + .command("rm") + .description("Remove a Paseo Agent model provider") + .argument("", "Provider instance name"), + ).action( + withOutput>["data"], [string]>( + (name, options, command) => runRmCommand(name, options, command, dependencies), ), ); diff --git a/packages/cli/src/commands/provider/ls-rm.test.ts b/packages/cli/src/commands/provider/ls-rm.test.ts new file mode 100644 index 0000000000..0d4c513527 --- /dev/null +++ b/packages/cli/src/commands/provider/ls-rm.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; + +import { render } from "../../output/index.js"; +import { runLsCommand } from "./ls.js"; +import { runRmCommand } from "./rm.js"; + +function createServerInfo() { + return { + status: "server_info", + serverId: "test-daemon", + features: { paseoAgentCatalog: true }, + }; +} + +describe("provider ls", () => { + it("lists configured model providers with catalog labels and auth states", async () => { + const result = await runLsCommand({ host: "localhost:7777" }, {} as never, { + connectDaemon: async (options) => { + expect(options.host).toBe("localhost:7777"); + return { + getLastServerInfoMessage: createServerInfo, + getPaseoAgentCatalog: async () => ({ + requestId: "catalog-1", + catalog: [ + { + id: "alpha-key", + label: "Alpha Key", + api: "test-api", + baseUrl: "https://alpha.example.test", + auth: { kind: "api_key", envVar: "ALPHA_API_KEY" }, + models: [{ id: "alpha-model" }], + }, + { + id: "beta-oauth", + label: "Beta OAuth", + api: "test-oauth-api", + baseUrl: "https://beta.example.test", + auth: { kind: "oauth", flow: "beta-flow" }, + models: [{ id: "beta-model" }], + }, + ], + error: null, + }), + getPaseoAgentProviders: async () => ({ + requestId: "providers-1", + defaultModel: null, + providers: [ + { + name: "alpha-main", + providerType: "alpha-key", + models: [{ id: "alpha-model" }], + auth: { kind: "api_key", configured: true, source: "literal" }, + available: true, + error: null, + }, + { + name: "beta-main", + providerType: "beta-oauth", + models: [{ id: "beta-model" }], + auth: { kind: "oauth", configured: false, hint: "sign in again" }, + available: false, + error: "auth missing", + }, + { + name: "manual-main", + providerType: "manual-type", + models: [{ id: "manual-model" }], + available: true, + error: null, + }, + ], + error: null, + }), + close: async () => {}, + }; + }, + }); + + expect(result.data).toEqual([ + { + name: "alpha-main", + providerType: "alpha-key", + label: "Alpha Key", + auth: "Connected", + available: "yes", + models: "alpha-model", + }, + { + name: "beta-main", + providerType: "beta-oauth", + label: "Beta OAuth", + auth: "Needs attention", + available: "no", + models: "beta-model", + }, + { + name: "manual-main", + providerType: "manual-type", + label: "manual-type", + auth: "not configured", + available: "yes", + models: "manual-model", + }, + ]); + + const table = render(result, { format: "table", noColor: true }); + expect(table).toContain("Connected"); + expect(table).toContain("Needs attention"); + expect(table).toContain("not configured"); + }); +}); + +describe("provider rm", () => { + it("removes a configured model provider", async () => { + const removedNames: string[] = []; + + const result = await runRmCommand("alpha-main", { host: "localhost:7777" }, {} as never, { + connectDaemon: async (options) => { + expect(options.host).toBe("localhost:7777"); + return { + getLastServerInfoMessage: createServerInfo, + removePaseoAgentProvider: async (name: string) => { + removedNames.push(name); + return { + requestId: "remove-1", + success: true, + removed: true, + error: null, + }; + }, + close: async () => {}, + }; + }, + }); + + expect(removedNames).toEqual(["alpha-main"]); + expect(result.data).toEqual({ name: "alpha-main", removed: "yes" }); + }); +}); diff --git a/packages/cli/src/commands/provider/ls.ts b/packages/cli/src/commands/provider/ls.ts index 6b902383db..35532d894d 100644 --- a/packages/cli/src/commands/provider/ls.ts +++ b/packages/cli/src/commands/provider/ls.ts @@ -1,51 +1,43 @@ import type { Command } from "commander"; import type { CommandOptions, ListResult, OutputSchema } from "../../output/index.js"; -import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; -import { AGENT_PROVIDER_DEFINITIONS } from "@getpaseo/protocol/provider-manifest"; -import { tryConnectToDaemon } from "../../utils/client.js"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import type { + PaseoAgentCatalogEntry, + RedactedPaseoAgentProviderConfig, +} from "@getpaseo/protocol/messages"; +import { connectToDaemon } from "../../utils/client.js"; export interface ProviderListItem { - provider: ProviderSnapshotEntry["provider"]; + name: string; + providerType: string; label: string; - status: string; - enabled: "Enabled" | "Disabled"; - defaultMode: string; - modes: string; + auth: string; + available: string; + models: string; } -/** Derive provider list from the manifest — single source of truth */ -const PROVIDERS: ProviderListItem[] = AGENT_PROVIDER_DEFINITIONS.map((def) => ({ - provider: def.id, - label: def.label, - status: "available", - enabled: def.enabledByDefault === false ? "Disabled" : "Enabled", - defaultMode: def.defaultModeId ?? "-", - modes: def.modes.length > 0 ? def.modes.map((m) => m.label).join(", ") : "-", -})); +interface ProviderLsClient extends Pick< + DaemonClient, + "getLastServerInfoMessage" | "getPaseoAgentCatalog" | "getPaseoAgentProviders" | "close" +> {} -function getStaticProviders(): ProviderListItem[] { - return PROVIDERS; +export interface ProviderLsDependencies { + connectDaemon: (options: { host?: string }) => Promise; } -/** Schema for provider ls output */ +const defaultDependencies: ProviderLsDependencies = { + connectDaemon: connectToDaemon, +}; + export const providerLsSchema: OutputSchema = { - idField: "provider", + idField: "name", columns: [ - { header: "PROVIDER", field: "provider", width: 12 }, - { header: "LABEL", field: "label", width: 16 }, - { - header: "STATUS", - field: "status", - width: 12, - color: (value) => { - if (value === "available") return "green"; - if (value === "unavailable") return "red"; - return undefined; - }, - }, - { header: "ENABLED", field: "enabled", width: 10 }, - { header: "DEFAULT MODE", field: "defaultMode", width: 14 }, - { header: "MODES", field: "modes", width: 30 }, + { header: "NAME", field: "name", width: 20 }, + { header: "TYPE", field: "providerType", width: 16 }, + { header: "LABEL", field: "label", width: 22 }, + { header: "AUTH", field: "auth", width: 16 }, + { header: "AVAILABLE", field: "available", width: 10 }, + { header: "MODELS", field: "models", width: 30 }, ], }; @@ -55,40 +47,66 @@ export interface ProviderLsOptions extends CommandOptions { host?: string; } +function requirePaseoAgentCatalogFeature( + client: Pick, +): void { + if (client.getLastServerInfoMessage()?.features?.paseoAgentCatalog === true) { + return; + } + throw { + code: "HOST_UPDATE_REQUIRED", + message: "Update the Paseo daemon to use this command.", + }; +} + +function authState(provider: RedactedPaseoAgentProviderConfig): string { + if (!provider.auth) { + return "not configured"; + } + return provider.auth.configured ? "Connected" : "Needs attention"; +} + +function catalogLabel(catalog: PaseoAgentCatalogEntry[], providerType: string): string { + return catalog.find((entry) => entry.id === providerType)?.label ?? providerType; +} + export async function runLsCommand( options: ProviderLsOptions, _command: Command, + dependencies: Partial = {}, ): Promise { - const client = await tryConnectToDaemon({ host: options.host }); - - if (!client) { - return { - type: "list", - data: getStaticProviders(), - schema: providerLsSchema, - }; - } + const deps = { ...defaultDependencies, ...dependencies }; + const client = await deps.connectDaemon({ host: options.host }); try { - const snapshot = await client.getProvidersSnapshot(); + requirePaseoAgentCatalogFeature(client); + const catalogResult = await client.getPaseoAgentCatalog(); + if (catalogResult.error) { + throw { + code: "PROVIDER_CATALOG_FAILED", + message: catalogResult.error, + }; + } + const providersResult = await client.getPaseoAgentProviders(); + if (providersResult.error) { + throw { + code: "PROVIDER_LIST_FAILED", + message: providersResult.error, + }; + } + return { type: "list", - data: snapshot.entries.map((entry) => ({ - provider: entry.provider, - label: entry.label ?? entry.provider, - status: entry.status === "ready" ? "available" : entry.status, - enabled: !entry.enabled ? "Disabled" : "Enabled", - defaultMode: entry.defaultModeId ?? "default", - modes: (entry.modes ?? []).map((mode) => mode.label).join(", "), + data: providersResult.providers.map((provider) => ({ + name: provider.name, + providerType: provider.providerType, + label: catalogLabel(catalogResult.catalog, provider.providerType), + auth: authState(provider), + available: provider.available ? "yes" : "no", + models: provider.models.map((model) => model.id).join(", ") || "-", })), schema: providerLsSchema, }; - } catch { - return { - type: "list", - data: getStaticProviders(), - schema: providerLsSchema, - }; } finally { await client.close().catch(() => {}); } diff --git a/packages/cli/src/commands/provider/openrouter.test.ts b/packages/cli/src/commands/provider/openrouter.test.ts deleted file mode 100644 index 805c4eb47a..0000000000 --- a/packages/cli/src/commands/provider/openrouter.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { render } from "../../output/index.js"; -import { runAddOpenRouterCommand } from "./openrouter.js"; - -describe("provider add openrouter", () => { - it("sends OpenRouter config to the selected daemon and redacts output", async () => { - const calls: unknown[] = []; - const result = await runAddOpenRouterCommand( - "openrouter-main", - { - host: "localhost:7777", - apiKeyStdin: true, - model: ["anthropic/claude-3.7-sonnet", "openai/gpt-4o"], - }, - {} as never, - { - readStdin: async () => "redaction-sentinel\n", - env: {}, - connectDaemon: async (options) => { - expect(options.host).toBe("localhost:7777"); - return { - getLastServerInfoMessage: () => ({ - status: "server_info", - serverId: "test-daemon", - features: { paseoAgentConfig: true }, - }), - setPaseoAgentProvider: async (input) => { - calls.push(input); - return { - requestId: "request-1", - success: true, - provider: { - name: input.name, - providerType: "openrouter", - models: input.options.models, - auth: { kind: "api_key", configured: true, source: "literal" }, - available: true, - error: null, - }, - error: null, - }; - }, - close: async () => {}, - }; - }, - }, - ); - - expect(calls).toEqual([ - { - name: "openrouter-main", - providerType: "openrouter", - options: { - apiKey: "redaction-sentinel", - models: [{ id: "anthropic/claude-3.7-sonnet" }, { id: "openai/gpt-4o" }], - }, - }, - ]); - - const json = render(result, { format: "json" }); - const table = render(result, { format: "table", noColor: true }); - expect(json).not.toContain("redaction-sentinel"); - expect(table).not.toContain("redaction-sentinel"); - expect(table).toContain("openrouter-main"); - expect(table).toContain("anthropic/claude-3.7-sonnet"); - }); - - it("uses OPENROUTER_API_KEY by default and requires explicit models", async () => { - await expect( - runAddOpenRouterCommand("openrouter-main", { model: [] }, {} as never, { - env: { OPENROUTER_API_KEY: "redaction-sentinel" }, - readStdin: async () => { - throw new Error("stdin should not be read"); - }, - connectDaemon: async () => { - throw new Error("daemon should not be called without models"); - }, - }), - ).rejects.toMatchObject({ code: "MISSING_MODELS" }); - }); - - it("asks for a host update instead of sending provider config to an old daemon", async () => { - const calls: unknown[] = []; - - await expect( - runAddOpenRouterCommand( - "openrouter-main", - { - host: "localhost:7777", - apiKeyStdin: true, - model: ["anthropic/claude-3.7-sonnet"], - }, - {} as never, - { - readStdin: async () => "redaction-sentinel\n", - env: {}, - connectDaemon: async () => ({ - getLastServerInfoMessage: () => ({ - status: "server_info", - serverId: "test-daemon", - features: {}, - }), - setPaseoAgentProvider: async (input) => { - calls.push(input); - throw new Error("set provider RPC should not run without the capability flag"); - }, - close: async () => {}, - }), - }, - ), - ).rejects.toMatchObject({ - code: "HOST_UPDATE_REQUIRED", - message: "Update the host to configure Paseo Agent providers.", - }); - - expect(calls).toEqual([]); - }); -}); diff --git a/packages/cli/src/commands/provider/openrouter.ts b/packages/cli/src/commands/provider/openrouter.ts deleted file mode 100644 index 08ccd6bcfd..0000000000 --- a/packages/cli/src/commands/provider/openrouter.ts +++ /dev/null @@ -1,182 +0,0 @@ -import type { Command } from "commander"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { RedactedPaseoAgentProviderConfig } from "@getpaseo/protocol/messages"; - -import { connectToDaemon } from "../../utils/client.js"; -import { collectMultiple } from "../../utils/command-options.js"; -import type { CommandOptions, OutputSchema, SingleResult } from "../../output/index.js"; - -interface OpenRouterAddOptions extends CommandOptions { - apiKey?: string; - apiKeyEnv?: string; - apiKeyStdin?: boolean; - model?: string[]; -} - -interface OpenRouterConfiguredItem { - name: string; - providerType: string; - auth: string; - available: string; - models: string; -} - -interface OpenRouterDependencies { - connectDaemon: (options: { - host?: string; - }) => Promise>; - env: NodeJS.ProcessEnv; - readStdin: () => Promise; -} - -const DEFAULT_API_KEY_ENV = "OPENROUTER_API_KEY"; - -const defaultDependencies: OpenRouterDependencies = { - connectDaemon: connectToDaemon, - env: process.env, - readStdin, -}; - -export const openRouterConfiguredSchema: OutputSchema = { - idField: "name", - columns: [ - { header: "NAME", field: "name", width: 20 }, - { header: "TYPE", field: "providerType", width: 12 }, - { header: "AUTH", field: "auth", width: 16 }, - { header: "AVAILABLE", field: "available", width: 10 }, - { header: "MODELS", field: "models", width: 50 }, - ], -}; - -async function readStdin(): Promise { - process.stdin.setEncoding("utf8"); - let value = ""; - for await (const chunk of process.stdin) { - value += chunk; - } - return value; -} - -function normalizeModels(rawModels: string[] | undefined): string[] { - return (rawModels ?? []) - .flatMap((value) => value.split(",")) - .map((value) => value.trim()) - .filter(Boolean); -} - -async function resolveApiKey( - options: OpenRouterAddOptions, - dependencies: OpenRouterDependencies, -): Promise { - if (options.apiKey) { - return options.apiKey; - } - - if (options.apiKeyStdin) { - const value = (await dependencies.readStdin()).trim(); - if (value) { - return value; - } - throw { - code: "MISSING_API_KEY", - message: "No OpenRouter API key was read from stdin", - }; - } - - const envName = options.apiKeyEnv ?? DEFAULT_API_KEY_ENV; - const value = dependencies.env[envName]?.trim(); - if (value) { - return value; - } - - throw { - code: "MISSING_API_KEY", - message: `OpenRouter API key not found in $${envName}`, - details: - "Set OPENROUTER_API_KEY, pass --api-key-env , or pipe the key with --api-key-stdin.", - }; -} - -function toConfiguredItem(provider: RedactedPaseoAgentProviderConfig): OpenRouterConfiguredItem { - return { - name: provider.name, - providerType: provider.providerType, - auth: provider.auth?.configured ? (provider.auth.source ?? "configured") : "not configured", - available: provider.available ? "yes" : "no", - models: provider.models.map((model) => model.id).join(", "), - }; -} - -function requirePaseoAgentConfigFeature(client: Pick) { - if (client.getLastServerInfoMessage()?.features?.paseoAgentConfig === true) { - return; - } - throw { - code: "HOST_UPDATE_REQUIRED", - message: "Update the host to configure Paseo Agent providers.", - }; -} - -export async function runAddOpenRouterCommand( - name: string, - options: OpenRouterAddOptions, - _command: Command, - dependencies: Partial = {}, -): Promise> { - const deps = { ...defaultDependencies, ...dependencies }; - const models = normalizeModels(options.model); - if (models.length === 0) { - throw { - code: "MISSING_MODELS", - message: "At least one OpenRouter model is required", - details: "Pass --model . Repeat --model to configure more than one.", - }; - } - - const apiKey = await resolveApiKey(options, deps); - const client = await deps.connectDaemon({ host: options.host }); - try { - requirePaseoAgentConfigFeature(client); - const result = await client.setPaseoAgentProvider({ - name, - providerType: "openrouter", - options: { - apiKey, - models: models.map((id) => ({ id })), - }, - }); - if (!result.success || !result.provider) { - throw { - code: "PROVIDER_CONFIG_FAILED", - message: result.error ?? "Daemon rejected the OpenRouter provider config", - }; - } - - return { - type: "single", - data: toConfiguredItem(result.provider), - schema: openRouterConfiguredSchema, - }; - } finally { - await client.close().catch(() => {}); - } -} - -export function addOpenRouterOptions(command: Command): Command { - return command - .description("Configure an OpenRouter inference provider for Paseo Agent") - .argument("", "Provider instance name") - .option( - "--model ", - "OpenRouter model ID to expose (repeatable, comma-separated also accepted)", - collectMultiple, - [], - ) - .option( - "--api-key-env ", - `Environment variable containing the API key`, - DEFAULT_API_KEY_ENV, - ) - .option("--api-key-stdin", "Read the API key from stdin") - .option("--api-key ", "OpenRouter API key (prefer env or stdin to avoid shell history)"); -} diff --git a/packages/cli/src/commands/provider/rm.ts b/packages/cli/src/commands/provider/rm.ts new file mode 100644 index 0000000000..e47af4e2dd --- /dev/null +++ b/packages/cli/src/commands/provider/rm.ts @@ -0,0 +1,82 @@ +import type { Command } from "commander"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; + +import { connectToDaemon } from "../../utils/client.js"; +import type { + CommandError, + CommandOptions, + OutputSchema, + SingleResult, +} from "../../output/index.js"; + +interface ProviderRmOptions extends CommandOptions { + host?: string; +} + +interface ProviderRemoveItem { + name: string; + removed: string; +} + +interface ProviderRmClient extends Pick< + DaemonClient, + "getLastServerInfoMessage" | "removePaseoAgentProvider" | "close" +> {} + +export interface ProviderRmDependencies { + connectDaemon: (options: { host?: string }) => Promise; +} + +const defaultDependencies: ProviderRmDependencies = { + connectDaemon: connectToDaemon, +}; + +export const providerRemoveSchema: OutputSchema = { + idField: "name", + columns: [ + { header: "NAME", field: "name", width: 20 }, + { header: "REMOVED", field: "removed", width: 10 }, + ], +}; + +function requirePaseoAgentCatalogFeature( + client: Pick, +): void { + if (client.getLastServerInfoMessage()?.features?.paseoAgentCatalog === true) { + return; + } + throw { + code: "HOST_UPDATE_REQUIRED", + message: "Update the Paseo daemon to use this command.", + } satisfies CommandError; +} + +export async function runRmCommand( + name: string, + options: ProviderRmOptions, + _command: Command, + dependencies: Partial = {}, +): Promise> { + const deps = { ...defaultDependencies, ...dependencies }; + const client = await deps.connectDaemon({ host: options.host }); + try { + requirePaseoAgentCatalogFeature(client); + const result = await client.removePaseoAgentProvider(name); + if (!result.success) { + throw { + code: "PROVIDER_REMOVE_FAILED", + message: result.error ?? "Daemon rejected the provider removal request.", + } satisfies CommandError; + } + return { + type: "single", + data: { + name, + removed: result.removed ? "yes" : "no", + }, + schema: providerRemoveSchema, + }; + } finally { + await client.close().catch(() => {}); + } +} diff --git a/packages/cli/src/commands/schedule/index.ts b/packages/cli/src/commands/schedule/index.ts index 5087db18a7..b97cd537ca 100644 --- a/packages/cli/src/commands/schedule/index.ts +++ b/packages/cli/src/commands/schedule/index.ts @@ -28,10 +28,7 @@ export function createScheduleCommand(): Command { "--provider ", "Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)", ) - .option( - "--mode ", - "Provider-specific mode (e.g. claude bypassPermissions, opencode build)", - ) + .option("--mode ", "Provider-specific mode") .option("--cwd ", "Working directory (default: current; required with --host)") .option("--run-now", "Fire one immediate run on creation (only with --cron)") .option("--no-run-now", "Wait the full interval before the first run (only with --every)")