feat(cli): add catalog-driven provider setup

This commit is contained in:
Mohamed Boudra
2026-07-02 11:26:56 +02:00
parent 4e5737169f
commit 27e713671d
14 changed files with 1493 additions and 895 deletions
-4
View File
@@ -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());
+4 -5
View File
@@ -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,
@@ -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");
});
});
-221
View File
@@ -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<DaemonClient, "getLastServerInfoMessage" | "storePaseoAgentOAuthCredential" | "close">
>;
openBrowser: (url: string) => boolean;
promptForCode: (message: string) => Promise<string>;
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<LoginResult> = {
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<DaemonClient, "getLastServerInfoMessage">) {
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<string> {
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<SingleResult<LoginResult>> {
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<LoginCommandDependencies> = {}): 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 <path>", "Paseo home directory for local --device-code only"),
).action(withOutput<LoginResult, []>((options, _command) => runChatgptLogin(options, deps)));
return login;
}
+1 -4
View File
@@ -50,10 +50,7 @@ export function addLoopRunOptions(command: Command): Command {
.argument("<prompt>", "Prompt for each fresh worker iteration")
.option("--provider <provider>", "Default provider for worker and verifier agents")
.option("--model <model>", "Default model for worker and verifier agents")
.option(
"--mode <mode>",
"Provider-specific mode for the worker agent (e.g. claude bypassPermissions, opencode build)",
)
.option("--mode <mode>", "Provider-specific mode for the worker agent")
.option("--verify-provider <provider>", "Provider for the verifier agent")
.option("--verify-model <model>", "Model for the verifier agent")
.option("--verify-mode <mode>", "Provider-specific mode for the verifier agent")
@@ -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<string, unknown>;
models: Array<{ id: string; label?: string; reasoning?: boolean }>;
}
interface RecordingClientInput {
catalog: CatalogEntry[];
features?: Record<string, unknown>;
setProvider?: (input: {
name: string;
providerType: string;
options: { apiKey?: string; models: Array<{ id: string }> };
}) => Promise<unknown>;
startOAuth?: (name: string) => Promise<unknown>;
completeOAuth?: (name: string) => Promise<unknown>;
storeCredential?: (input: { name: string; credential: unknown }) => Promise<unknown>;
}
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> = {}): 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> = {}): 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" }),
]);
});
});
+540
View File
@@ -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<ProviderAddClient>;
readStdin: () => Promise<string>;
promptText: (message: string) => Promise<string>;
promptSecret: (message: string) => Promise<string>;
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<ProviderConfiguredItem> = {
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<string> {
process.stdin.setEncoding("utf8");
let value = "";
for await (const chunk of process.stdin) {
value += chunk;
}
return value;
}
async function promptText(message: string): Promise<string> {
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<string> {
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<DaemonClient, "getLastServerInfoMessage">,
): 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<string, unknown>, 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 <model-id>. Repeat --model to configure more than one; comma-separated values are also accepted.",
} satisfies CommandError;
}
async function selectCatalogEntry(
catalog: PaseoAgentCatalogEntry[],
dependencies: ProviderAddDependencies,
): Promise<PaseoAgentCatalogEntry> {
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<PaseoAgentCatalogEntry> {
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<string> {
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<ReturnType<ProviderAddClient["startPaseoAgentOAuth"]>>["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<PaseoAgentProviderAuthState | undefined> {
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<PaseoAgentProviderAuthState | undefined> {
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<RedactedPaseoAgentProviderConfig> {
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<PaseoAgentProviderAuthState | undefined> {
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<ProviderAddDependencies> = {},
): Promise<SingleResult<ProviderConfiguredItem>> {
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 <instanceName>", "Provider instance name (default: provider id)")
.option(
"--model <id>",
"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");
}
+28 -10
View File
@@ -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<typeof runAddOpenRouterCommand>[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<ProviderListItem, []>((options, command) =>
runLsCommand(options, command, dependencies),
),
);
addJsonAndDaemonHostOptions(
provider
.command("models")
.description("List models for a provider")
.argument("<provider>", "Provider name (claude, codex, opencode)")
.argument("<provider>", "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<Awaited<ReturnType<typeof runAddOpenRouterCommand>>["data"], [string]>(
(name, options, command) => runAddOpenRouterCommand(name, options, command, dependencies),
addJsonAndDaemonHostOptions(addProviderAddOptions(provider.command("add"))).action(
withOutput<Awaited<ReturnType<typeof runAddCommand>>["data"], [string | undefined]>(
(id, options, command) => runAddCommand(id, options, command, dependencies),
),
);
addJsonAndDaemonHostOptions(
provider
.command("rm")
.description("Remove a Paseo Agent model provider")
.argument("<name>", "Provider instance name"),
).action(
withOutput<Awaited<ReturnType<typeof runRmCommand>>["data"], [string]>(
(name, options, command) => runRmCommand(name, options, command, dependencies),
),
);
@@ -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" });
});
});
+77 -59
View File
@@ -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<ProviderLsClient>;
}
/** Schema for provider ls output */
const defaultDependencies: ProviderLsDependencies = {
connectDaemon: connectToDaemon,
};
export const providerLsSchema: OutputSchema<ProviderListItem> = {
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<DaemonClient, "getLastServerInfoMessage">,
): 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<ProviderLsDependencies> = {},
): Promise<ProviderLsResult> {
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(() => {});
}
@@ -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([]);
});
});
@@ -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<Pick<DaemonClient, "getLastServerInfoMessage" | "setPaseoAgentProvider" | "close">>;
env: NodeJS.ProcessEnv;
readStdin: () => Promise<string>;
}
const DEFAULT_API_KEY_ENV = "OPENROUTER_API_KEY";
const defaultDependencies: OpenRouterDependencies = {
connectDaemon: connectToDaemon,
env: process.env,
readStdin,
};
export const openRouterConfiguredSchema: OutputSchema<OpenRouterConfiguredItem> = {
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<string> {
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<string> {
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 <name>, 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<DaemonClient, "getLastServerInfoMessage">) {
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<OpenRouterDependencies> = {},
): Promise<SingleResult<OpenRouterConfiguredItem>> {
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 <provider/model-id>. 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("<name>", "Provider instance name")
.option(
"--model <id>",
"OpenRouter model ID to expose (repeatable, comma-separated also accepted)",
collectMultiple,
[],
)
.option(
"--api-key-env <name>",
`Environment variable containing the API key`,
DEFAULT_API_KEY_ENV,
)
.option("--api-key-stdin", "Read the API key from stdin")
.option("--api-key <key>", "OpenRouter API key (prefer env or stdin to avoid shell history)");
}
+82
View File
@@ -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<ProviderRmClient>;
}
const defaultDependencies: ProviderRmDependencies = {
connectDaemon: connectToDaemon,
};
export const providerRemoveSchema: OutputSchema<ProviderRemoveItem> = {
idField: "name",
columns: [
{ header: "NAME", field: "name", width: 20 },
{ header: "REMOVED", field: "removed", width: 10 },
],
};
function requirePaseoAgentCatalogFeature(
client: Pick<DaemonClient, "getLastServerInfoMessage">,
): 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<ProviderRmDependencies> = {},
): Promise<SingleResult<ProviderRemoveItem>> {
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(() => {});
}
}
+1 -4
View File
@@ -28,10 +28,7 @@ export function createScheduleCommand(): Command {
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
)
.option(
"--mode <mode>",
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
)
.option("--mode <mode>", "Provider-specific mode")
.option("--cwd <path>", "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)")