mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
feat(cli): add catalog-driven provider setup
This commit is contained in:
@@ -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());
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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)");
|
||||
}
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user