diff --git a/packages/cli-workflow/src/__tests__/setup-agent-discovery.test.ts b/packages/cli-workflow/src/__tests__/setup-agent-discovery.test.ts new file mode 100644 index 0000000..3b4c605 --- /dev/null +++ b/packages/cli-workflow/src/__tests__/setup-agent-discovery.test.ts @@ -0,0 +1,137 @@ +import { readFileSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { parse } from "yaml"; +import { _agentNameFromBinary, _printAgentMenu, cmdSetup } from "../commands/setup.js"; + +// ─── _agentNameFromBinary ──────────────────────────────────────────────────── + +describe("_agentNameFromBinary", () => { + test("strips uwf- prefix", () => { + expect(_agentNameFromBinary("uwf-hermes")).toBe("hermes"); + }); + + test("strips uwf- prefix for compound names", () => { + expect(_agentNameFromBinary("uwf-claude-code")).toBe("claude-code"); + }); + + test("returns as-is when no uwf- prefix", () => { + expect(_agentNameFromBinary("hermes")).toBe("hermes"); + }); + + test("handles uwf-builtin", () => { + expect(_agentNameFromBinary("uwf-builtin")).toBe("builtin"); + }); +}); + +// ─── _printAgentMenu ───────────────────────────────────────────────────────── + +describe("_printAgentMenu", () => { + test("prints known agents with labels", () => { + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + logs.push(args.join(" ")); + }); + + _printAgentMenu(["uwf-hermes", "uwf-claude-code"]); + + expect(logs.some((l) => l.includes("Hermes"))).toBe(true); + expect(logs.some((l) => l.includes("Claude Code"))).toBe(true); + + vi.restoreAllMocks(); + }); + + test("prints unknown agents with binary name as label", () => { + const logs: string[] = []; + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => { + logs.push(args.join(" ")); + }); + + _printAgentMenu(["uwf-custom-agent"]); + + expect(logs.some((l) => l.includes("uwf-custom-agent"))).toBe(true); + + vi.restoreAllMocks(); + }); +}); + +// ─── cmdSetup agent config ─────────────────────────────────────────────────── + +describe("cmdSetup agent configuration", () => { + let storageRoot: string; + + beforeEach(async () => { + storageRoot = await mkdtemp(join(tmpdir(), "uwf-setup-agent-")); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + await rm(storageRoot, { recursive: true, force: true }); + }); + + const baseArgs = () => ({ + provider: "testprovider", + baseUrl: "https://api.test.com/v1", + apiKey: "sk-test", + model: "test-model", + storageRoot, + }); + + test("defaults to hermes agent when no agent specified", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({}), { status: 200 }), + ); + + const result = await cmdSetup(baseArgs()); + + expect(result.defaultAgent).toBe("hermes"); + const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8")); + expect(config.agents.hermes).toEqual({ command: "uwf-hermes", args: [] }); + expect(config.defaultAgent).toBe("hermes"); + }); + + test("writes specified agent as default", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({}), { status: 200 }), + ); + + const result = await cmdSetup({ ...baseArgs(), agent: "claude-code" }); + + expect(result.defaultAgent).toBe("claude-code"); + const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8")); + expect(config.agents["claude-code"]).toEqual({ command: "uwf-claude-code", args: [] }); + expect(config.defaultAgent).toBe("claude-code"); + }); + + test("preserves existing agents when adding new one", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({}), { status: 200 }), + ); + + // First setup with hermes + await cmdSetup(baseArgs()); + // Second setup with claude-code + await cmdSetup({ ...baseArgs(), agent: "claude-code" }); + + const config = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8")); + expect(config.agents.hermes).toBeDefined(); + expect(config.agents["claude-code"]).toBeDefined(); + expect(config.defaultAgent).toBe("claude-code"); + }); + + test("updates defaultAgent on re-run with different agent", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({}), { status: 200 }), + ); + + await cmdSetup(baseArgs()); + const config1 = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8")); + expect(config1.defaultAgent).toBe("hermes"); + + await cmdSetup({ ...baseArgs(), agent: "builtin" }); + const config2 = parse(readFileSync(join(storageRoot, "config.yaml"), "utf8")); + expect(config2.defaultAgent).toBe("builtin"); + }); +}); diff --git a/packages/cli-workflow/src/commands/setup.ts b/packages/cli-workflow/src/commands/setup.ts index cd235c2..953a67e 100644 --- a/packages/cli-workflow/src/commands/setup.ts +++ b/packages/cli-workflow/src/commands/setup.ts @@ -297,6 +297,80 @@ export function _printModelMenu(models: string[], termCols: number): void { } } +// ────────────────────────────────────────────────────────────────────────────── +// Agent selection prompt +// ────────────────────────────────────────────────────────────────────────────── + +/** Known agent binary → display label mapping. */ +const KNOWN_AGENTS: Record = { + "uwf-hermes": "Hermes (hermes-agent)", + "uwf-claude-code": "Claude Code", + "uwf-cursor": "Cursor", + "uwf-builtin": "Built-in (lightweight, no external agent)", +}; + +/** Extract short agent name from binary name: uwf-claude-code → claude-code */ +export function _agentNameFromBinary(binary: string): string { + return binary.replace(/^uwf-/, ""); +} + +/** Prints numbered agent list to stdout. */ +export function _printAgentMenu(agents: string[]): void { + const numWidth = String(agents.length).length; + for (let i = 0; i < agents.length; i++) { + const bin = agents[i] ?? ""; + const label = KNOWN_AGENTS[bin] ?? bin; + const num = String(i + 1).padStart(numWidth); + console.log(` ${num}) ${label} (${bin})`); + } + console.log(""); +} + +/** + * Interactive agent selection. Discovers uwf-* binaries, lets user pick default. + * Returns short agent name (e.g. "hermes", "claude-code"). + */ +export async function _promptAgentSelection( + rl: ReturnType, +): Promise { + console.log("Discovering installed agents...\n"); + const agents = await _discoverAgents(); + + if (agents.length === 0) { + console.log(" No uwf-* agent binaries found in PATH.\n"); + console.log(" Install one first, for example:"); + console.log(" npm i -g @uncaged/workflow-agent-hermes"); + console.log(" npm i -g @uncaged/workflow-agent-claude-code\n"); + const manual = ( + await rl.question("Agent binary name (e.g. uwf-hermes), or press Enter to skip: ") + ).trim(); + if (!manual) return "hermes"; + return _agentNameFromBinary(manual.startsWith("uwf-") ? manual : `uwf-${manual}`); + } + + if (agents.length === 1) { + const name = _agentNameFromBinary(agents[0] ?? "uwf-hermes"); + const label = KNOWN_AGENTS[agents[0] ?? ""] ?? agents[0]; + console.log(` Found 1 agent: ${label} — auto-selected.\n`); + return name; + } + + console.log(` Found ${agents.length} agents:\n`); + _printAgentMenu(agents); + const choice = (await rl.question(`Choose default agent [1-${agents.length}]: `)).trim(); + const n = Number.parseInt(choice, 10); + if (!Number.isNaN(n) && n >= 1 && n <= agents.length) { + const selected = agents[n - 1] ?? "uwf-hermes"; + const name = _agentNameFromBinary(selected); + console.log(` → ${name}\n`); + return name; + } + // Treat as literal name + const name = _agentNameFromBinary(choice.startsWith("uwf-") ? choice : `uwf-${choice}`); + console.log(` → ${name}\n`); + return name; +} + type ValidationResult = { ok: boolean; error: string | null }; /** Prints the model validation result to stdout. */ @@ -340,8 +414,9 @@ function mergeConfig(existing: Record, args: SetupArgs): Record ) as Record; const agentName = args.agent ?? "hermes"; - if (Object.keys(agents).length === 0) { - agents.hermes = { command: "uwf-hermes", args: [] }; + // Ensure the selected agent has an entry + if (!agents[agentName]) { + agents[agentName] = { command: `uwf-${agentName}`, args: [] }; } return { @@ -349,7 +424,7 @@ function mergeConfig(existing: Record, args: SetupArgs): Record providers, models, agents, - defaultAgent: existing.defaultAgent ?? agentName, + defaultAgent: agentName, defaultModel: existing.defaultModel ?? "default", }; } @@ -543,11 +618,17 @@ export async function cmdSetupInteractive(storageRoot: string): Promise