diff --git a/packages/cli-workflow/src/__tests__/setup-complexity.test.ts b/packages/cli-workflow/src/__tests__/setup-complexity.test.ts new file mode 100644 index 0000000..06f111d --- /dev/null +++ b/packages/cli-workflow/src/__tests__/setup-complexity.test.ts @@ -0,0 +1,381 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { + _discoverAgents, + _isBackspace, + _isTerminator, + _parseWhichOutput, + _printModelMenu, + _printProviderMenu, + _printValidationResult, + _resolveModelChoice, + _resolveProviderChoice, + _searchPathDirs, +} from "../commands/setup.js"; + +// ────────────────────────────────────────────────────────────────────────────── +// 1a. _searchPathDirs +// ────────────────────────────────────────────────────────────────────────────── + +describe("_searchPathDirs", () => { + test("returns empty array for empty PATH", async () => { + const result = await _searchPathDirs(""); + expect(result).toEqual([]); + }); + + test("finds uwf-hermes in a single dir", async () => { + const dir = mkdirSync(join(tmpdir(), `uwf-test-${Date.now()}`), { recursive: true }) as + | string + | undefined; + const actualDir = dir ?? join(tmpdir(), `uwf-test-${Date.now()}`); + mkdirSync(actualDir, { recursive: true }); + const filePath = join(actualDir, "uwf-hermes"); + writeFileSync(filePath, "#!/bin/sh\n", { mode: 0o755 }); + const result = await _searchPathDirs(actualDir); + expect(result).toContain("uwf-hermes"); + }); + + test("skips non-uwf- prefixed binaries", async () => { + const dir = join(tmpdir(), `uwf-test-${Date.now()}-2`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "hermes"), "#!/bin/sh\n", { mode: 0o755 }); + writeFileSync(join(dir, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 }); + const result = await _searchPathDirs(dir); + expect(result).toEqual(["uwf-hermes"]); + }); + + test("skips entry named exactly 'uwf'", async () => { + const dir = join(tmpdir(), `uwf-test-${Date.now()}-3`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "uwf"), "#!/bin/sh\n", { mode: 0o755 }); + writeFileSync(join(dir, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 }); + const result = await _searchPathDirs(dir); + expect(result).toEqual(["uwf-hermes"]); + }); + + test("skips non-executable files", async () => { + const dir = join(tmpdir(), `uwf-test-${Date.now()}-4`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "uwf-foo"), "#!/bin/sh\n", { mode: 0o644 }); + const result = await _searchPathDirs(dir); + expect(result).toEqual([]); + }); + + test("deduplicates across PATH dirs", async () => { + const dir1 = join(tmpdir(), `uwf-test-${Date.now()}-5a`); + const dir2 = join(tmpdir(), `uwf-test-${Date.now()}-5b`); + mkdirSync(dir1, { recursive: true }); + mkdirSync(dir2, { recursive: true }); + writeFileSync(join(dir1, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 }); + writeFileSync(join(dir2, "uwf-hermes"), "#!/bin/sh\n", { mode: 0o755 }); + const result = await _searchPathDirs(`${dir1}:${dir2}`); + expect(result).toEqual(["uwf-hermes"]); + }); + + test("returns sorted array", async () => { + const dir = join(tmpdir(), `uwf-test-${Date.now()}-6`); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "uwf-zoo"), "#!/bin/sh\n", { mode: 0o755 }); + writeFileSync(join(dir, "uwf-alpha"), "#!/bin/sh\n", { mode: 0o755 }); + writeFileSync(join(dir, "uwf-mid"), "#!/bin/sh\n", { mode: 0o755 }); + const result = await _searchPathDirs(dir); + expect(result).toEqual(["uwf-alpha", "uwf-mid", "uwf-zoo"]); + }); + + test("skips inaccessible/nonexistent directories silently", async () => { + const result = await _searchPathDirs("/nonexistent-dir-xyz-abc-12345"); + expect(result).toEqual([]); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// 1b. _parseWhichOutput +// ────────────────────────────────────────────────────────────────────────────── + +describe("_parseWhichOutput", () => { + test("returns empty array for empty string", () => { + expect(_parseWhichOutput("")).toEqual([]); + }); + + test("parses single path", () => { + expect(_parseWhichOutput("/usr/local/bin/uwf-hermes")).toEqual(["uwf-hermes"]); + }); + + test("parses multiple paths", () => { + expect(_parseWhichOutput("/usr/local/bin/uwf-hermes\n/usr/bin/uwf-claude-code")).toEqual([ + "uwf-claude-code", + "uwf-hermes", + ]); + }); + + test("deduplicates identical basenames from different dirs", () => { + expect(_parseWhichOutput("/a/uwf-hermes\n/b/uwf-hermes")).toEqual(["uwf-hermes"]); + }); + + test("skips blank lines", () => { + expect(_parseWhichOutput("/a/uwf-hermes\n\n/b/uwf-cursor")).toEqual([ + "uwf-cursor", + "uwf-hermes", + ]); + }); + + test("skips entry named exactly 'uwf'", () => { + expect(_parseWhichOutput("/usr/bin/uwf")).toEqual([]); + }); + + test("skips basenames not starting with uwf-", () => { + expect(_parseWhichOutput("/usr/bin/node")).toEqual([]); + }); + + test("returns sorted array", () => { + expect(_parseWhichOutput("/a/uwf-zoo\n/a/uwf-alpha")).toEqual(["uwf-alpha", "uwf-zoo"]); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// 2a. _isTerminator +// ────────────────────────────────────────────────────────────────────────────── + +describe("_isTerminator", () => { + test("\\n is a terminator", () => { + expect(_isTerminator("\n")).toBe(true); + }); + test("\\r is a terminator", () => { + expect(_isTerminator("\r")).toBe(true); + }); + test("\\u0004 (EOT) is a terminator", () => { + expect(_isTerminator("")).toBe(true); + }); + test("regular char is not a terminator", () => { + expect(_isTerminator("a")).toBe(false); + }); + test("empty string is not a terminator", () => { + expect(_isTerminator("")).toBe(false); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// 2b. _isBackspace +// ────────────────────────────────────────────────────────────────────────────── + +describe("_isBackspace", () => { + test("\\u007F is a backspace", () => { + expect(_isBackspace("")).toBe(true); + }); + test("\\b is a backspace", () => { + expect(_isBackspace("\b")).toBe(true); + }); + test("regular char is not a backspace", () => { + expect(_isBackspace("x")).toBe(false); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// 3a. _printProviderMenu +// ────────────────────────────────────────────────────────────────────────────── + +describe("_printProviderMenu", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + const providers = [ + { name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" }, + { name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" }, + ] as const; + + test("prints correct number of lines (one per provider + custom)", () => { + const lines: string[] = []; + vi.spyOn(console, "log").mockImplementation((msg: string) => { + lines.push(msg); + }); + _printProviderMenu(providers); + // 2 providers + 1 custom = 3 lines + expect(lines.length).toBe(3); + }); + + test("custom option number = providers.length + 1", () => { + const lines: string[] = []; + vi.spyOn(console, "log").mockImplementation((msg: string) => { + lines.push(msg); + }); + _printProviderMenu(providers); + const lastLine = lines[lines.length - 1] ?? ""; + expect(lastLine).toMatch(/3\)/); + }); + + test("each provider line contains its label and baseUrl", () => { + const lines: string[] = []; + vi.spyOn(console, "log").mockImplementation((msg: string) => { + lines.push(msg); + }); + _printProviderMenu(providers); + expect(lines[0]).toContain("OpenAI"); + expect(lines[0]).toContain("https://api.openai.com/v1"); + expect(lines[1]).toContain("xAI"); + expect(lines[1]).toContain("https://api.x.ai/v1"); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// 3b. _resolveProviderChoice +// ────────────────────────────────────────────────────────────────────────────── + +describe("_resolveProviderChoice", () => { + const providers = [ + { name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" }, + { name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" }, + { name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" }, + ] as const; + + test("valid index 1 returns first provider", () => { + const result = _resolveProviderChoice("1", providers); + expect(result).toEqual({ providerName: "openai", baseUrl: "https://api.openai.com/v1" }); + }); + + test("valid index N (last preset) returns last provider", () => { + const result = _resolveProviderChoice("3", providers); + expect(result).toEqual({ providerName: "deepseek", baseUrl: "https://api.deepseek.com/v1" }); + }); + + test("index providers.length+1 (custom) returns null", () => { + const result = _resolveProviderChoice("4", providers); + expect(result).toBeNull(); + }); + + test("non-numeric string returns null", () => { + expect(_resolveProviderChoice("abc", providers)).toBeNull(); + }); + + test("0 returns null (out of range)", () => { + expect(_resolveProviderChoice("0", providers)).toBeNull(); + }); + + test("N+2 returns null (out of range)", () => { + expect(_resolveProviderChoice("5", providers)).toBeNull(); + }); + + test("negative number returns null", () => { + expect(_resolveProviderChoice("-1", providers)).toBeNull(); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// 3c. _resolveModelChoice +// ────────────────────────────────────────────────────────────────────────────── + +describe("_resolveModelChoice", () => { + test("numeric input within range returns model at that index", () => { + expect(_resolveModelChoice("2", ["a", "b", "c"])).toBe("b"); + }); + + test("numeric input out of range returns input as-is", () => { + expect(_resolveModelChoice("5", ["a"])).toBe("5"); + }); + + test("non-numeric input returns input as-is", () => { + expect(_resolveModelChoice("gpt-4o", ["a", "b"])).toBe("gpt-4o"); + }); + + test("numeric input 1 returns first model", () => { + expect(_resolveModelChoice("1", ["alpha", "beta"])).toBe("alpha"); + }); + + test("empty models list with numeric input returns input as-is", () => { + expect(_resolveModelChoice("1", [])).toBe("1"); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// 3d. _printModelMenu +// ────────────────────────────────────────────────────────────────────────────── + +describe("_printModelMenu", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("prints all models — each model name appears in output", () => { + const output: string[] = []; + vi.spyOn(console, "log").mockImplementation((msg: string) => { + output.push(msg); + }); + const models = ["model-a", "model-b", "model-c"]; + _printModelMenu(models, 100); + const combined = output.join("\n"); + for (const m of models) { + expect(combined).toContain(m); + } + }); + + test("single column when termCols is very small", () => { + const output: string[] = []; + vi.spyOn(console, "log").mockImplementation((msg: string) => { + output.push(msg); + }); + _printModelMenu(["a", "b", "c"], 1); + // Each model on its own row → 3 lines + expect(output.length).toBe(3); + }); + + test("wide terminal fits multiple columns", () => { + const output: string[] = []; + vi.spyOn(console, "log").mockImplementation((msg: string) => { + output.push(msg); + }); + const models = Array.from({ length: 6 }, (_, i) => `m${i}`); + _printModelMenu(models, 200); + // With wide terminal and short names, should fit in fewer than 6 rows + expect(output.length).toBeLessThan(6); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// 3e. _printValidationResult +// ────────────────────────────────────────────────────────────────────────────── + +describe("_printValidationResult", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("ok=true prints success message containing '✓'", () => { + const lines: string[] = []; + vi.spyOn(console, "log").mockImplementation((msg: string) => { + lines.push(msg); + }); + _printValidationResult({ ok: true, error: null }); + expect(lines.join("\n")).toContain("✓"); + }); + + test("ok=false prints warning message containing '⚠'", () => { + const lines: string[] = []; + vi.spyOn(console, "log").mockImplementation((msg: string) => { + lines.push(msg); + }); + _printValidationResult({ ok: false, error: "HTTP 401" }); + expect(lines.join("\n")).toContain("⚠"); + }); + + test("ok=false includes the error string in output", () => { + const lines: string[] = []; + vi.spyOn(console, "log").mockImplementation((msg: string) => { + lines.push(msg); + }); + _printValidationResult({ ok: false, error: "HTTP 401" }); + expect(lines.join("\n")).toContain("HTTP 401"); + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// 4. Regression +// ────────────────────────────────────────────────────────────────────────────── + +describe("_discoverAgents regression", () => { + test("returns an array (may be empty) — never throws", async () => { + const result = await _discoverAgents(); + expect(Array.isArray(result)).toBe(true); + }); +}); diff --git a/packages/cli-workflow/src/commands/setup.ts b/packages/cli-workflow/src/commands/setup.ts index 538c9ab..cd235c2 100644 --- a/packages/cli-workflow/src/commands/setup.ts +++ b/packages/cli-workflow/src/commands/setup.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { stdin as input, stdout as output } from "node:process"; import { createInterface } from "node:readline/promises"; @@ -137,75 +137,182 @@ function apiKeyEnvName(providerName: string): string { return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`; } +// ────────────────────────────────────────────────────────────────────────────── +// Extracted helpers — _discoverAgents +// ────────────────────────────────────────────────────────────────────────────── + +/** + * Scans directories from a PATH string for uwf-* executables. + */ +export async function _searchPathDirs(pathEnv: string): Promise { + if (!pathEnv) return []; + const dirs = pathEnv.split(":").filter((d) => d.length > 0); + const agents = new Set(); + for (const dir of dirs) { + _scanDirForAgents(dir, agents); + } + return Array.from(agents).sort(); +} + +function _scanDirForAgents(dir: string, agents: Set): void { + try { + if (!existsSync(dir)) return; + const entries = readdirSync(dir); + for (const entry of entries) { + if (!entry.startsWith("uwf-") || entry === "uwf") continue; + if (_isExecutableFile(join(dir, entry))) { + agents.add(entry); + } + } + } catch { + // Skip inaccessible directories + } +} + +function _isExecutableFile(fullPath: string): boolean { + try { + const s = statSync(fullPath); + return s.isFile() && (s.mode & 0o111) !== 0; + } catch { + return false; + } +} + +/** + * Parses the stdout of `which -a` into sorted unique basenames. + */ +export function _parseWhichOutput(text: string): string[] { + if (!text) return []; + const agents = new Set(); + for (const line of text.trim().split("\n")) { + if (!line) continue; + const basename = line.split("/").pop() ?? ""; + if (basename.startsWith("uwf-") && basename !== "uwf") { + agents.add(basename); + } + } + return Array.from(agents).sort(); +} + /** * Discover uwf-* agent binaries in PATH. * Returns sorted list of binary names (e.g., ["uwf-hermes", "uwf-claude-code"]). */ -async function _discoverAgents(): Promise { +export async function _discoverAgents(): Promise { + try { + const agents = await _tryWhichDiscovery(); + if (agents !== null) return agents; + return await _searchPathDirs(process.env.PATH ?? ""); + } catch { + return []; + } +} + +async function _tryWhichDiscovery(): Promise { try { - // Use which -a to find all uwf-* binaries in PATH const proc = Bun.spawn(["which", "-a", "uwf-hermes", "uwf-claude-code", "uwf-cursor"], { stdout: "pipe", stderr: "pipe", }); - const text = await new Response(proc.stdout).text(); await proc.exited; - - if (proc.exitCode !== 0) { - // Try alternative approach: search PATH directories manually - const pathEnv = process.env.PATH || ""; - const pathDirs = pathEnv.split(":").filter((d) => d.length > 0); - const agents = new Set(); - - for (const dir of pathDirs) { - try { - if (!existsSync(dir)) continue; - const { readdirSync, statSync } = await import("node:fs"); - const entries = readdirSync(dir); - - for (const entry of entries) { - if (!entry.startsWith("uwf-") || entry === "uwf") continue; - const fullPath = join(dir, entry); - try { - const stat = statSync(fullPath); - // Check if executable (owner, group, or other has execute bit) - if (stat.isFile() && (stat.mode & 0o111) !== 0) { - agents.add(entry); - } - } catch { - // Skip if can't stat - } - } - } catch { - // Skip inaccessible directories - } - } - - return Array.from(agents).sort(); - } - - // Parse which output - each line is a path to a binary - const paths = text - .trim() - .split("\n") - .filter((line) => line.length > 0); - const agents = new Set(); - - for (const path of paths) { - const basename = path.split("/").pop(); - if (basename?.startsWith("uwf-") && basename !== "uwf") { - agents.add(basename); - } - } - - return Array.from(agents).sort(); + if (proc.exitCode !== 0) return null; + return _parseWhichOutput(text); } catch { - // If all fails, return empty array - return []; + return null; } } +// ────────────────────────────────────────────────────────────────────────────── +// Extracted helpers — onData closure (promptSecret) +// ────────────────────────────────────────────────────────────────────────────── + +/** Returns true for newline, carriage return, or EOF (EOT). */ +export function _isTerminator(c: string): boolean { + return c === "\n" || c === "\r" || c === ""; +} + +/** Returns true for DEL or backspace. */ +export function _isBackspace(c: string): boolean { + return c === "" || c === "\b"; +} + +// ────────────────────────────────────────────────────────────────────────────── +// Extracted helpers — cmdSetupInteractive +// ────────────────────────────────────────────────────────────────────────────── + +type ProviderEntry = { name: string; label: string; baseUrl: string }; + +/** Prints the numbered provider list and custom option to stdout. */ +export function _printProviderMenu(providers: readonly ProviderEntry[]): void { + const numWidth = String(providers.length + 1).length; + for (let i = 0; i < providers.length; i++) { + const p = providers[i]; + if (!p) continue; + const num = String(i + 1).padStart(numWidth); + console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`); + } + const customNum = String(providers.length + 1).padStart(numWidth); + console.log(` ${customNum}) Custom (enter name and URL manually)\n`); +} + +/** Resolves a numeric choice string to a preset provider, or null for custom/invalid. */ +export function _resolveProviderChoice( + choice: string, + providers: readonly ProviderEntry[], +): { providerName: string; baseUrl: string } | null { + const n = Number.parseInt(choice, 10); + if (Number.isNaN(n) || n < 1 || n > providers.length) return null; + const p = providers[n - 1]; + if (!p) return null; + return { providerName: p.name, baseUrl: p.baseUrl }; +} + +/** Resolves numeric index or literal model name to a model string. */ +export function _resolveModelChoice(input: string, models: string[]): string { + const n = Number.parseInt(input, 10); + if (!Number.isNaN(n) && n >= 1 && n <= models.length) { + return models[n - 1] ?? input; + } + return input; +} + +/** Prints the multi-column model list to stdout. */ +export function _printModelMenu(models: string[], termCols: number): void { + const nw = String(models.length).length; + const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0); + const colWidth = nw + 2 + maxLen + 4; + const cols = Math.max(1, Math.floor(termCols / colWidth)); + const rows = Math.ceil(models.length / cols); + for (let r = 0; r < rows; r++) { + let line = ""; + for (let c = 0; c < cols; c++) { + const idx = c * rows + r; + if (idx >= models.length) break; + const num = String(idx + 1).padStart(nw); + const name = (models[idx] ?? "").padEnd(maxLen); + line += ` ${num}) ${name} `; + } + console.log(line.trimEnd()); + } +} + +type ValidationResult = { ok: boolean; error: string | null }; + +/** Prints the model validation result to stdout. */ +export function _printValidationResult(validation: ValidationResult): void { + if (validation.ok) { + console.log("✓ Model verified — connection successful.\n"); + } else { + console.log(`\n⚠ Warning: Could not reach model — ${validation.error}`); + console.log( + " Config saved, but you may want to try a different model or check your API key.\n", + ); + } +} + +// ────────────────────────────────────────────────────────────────────────────── + /** * Merge setup args into config.yaml structure. Non-destructive — preserves existing entries. */ @@ -281,6 +388,46 @@ export async function cmdSetup(args: SetupArgs): Promise }; } +type SecretState = { + buf: string; + rawWasSet: boolean; + resolve: (value: string) => void; + onData: (chunk: string) => void; +}; + +function _handleSecretTerminator(state: SecretState): void { + if (process.stdin.isTTY) process.stdin.setRawMode(state.rawWasSet); + process.stdin.pause(); + process.stdin.removeListener("data", state.onData); + process.stdout.write("\n"); + state.resolve(state.buf.trim()); +} + +function _handleSecretBackspace(state: SecretState): void { + if (state.buf.length > 0) { + state.buf = state.buf.slice(0, -1); + process.stdout.write("\b \b"); + } +} + +function _handleSecretChar(c: string, state: SecretState): boolean { + if (_isTerminator(c)) { + _handleSecretTerminator(state); + return true; + } + if (_isBackspace(c)) { + _handleSecretBackspace(state); + return false; + } + if (c === "") { + if (process.stdin.isTTY) process.stdin.setRawMode(state.rawWasSet); + process.exit(130); + } + state.buf += c; + process.stdout.write("*"); + return false; +} + /** Read a line with terminal echo disabled (for secrets). */ async function promptSecret(label: string): Promise { process.stdout.write(label); @@ -292,33 +439,13 @@ async function promptSecret(label: string): Promise { process.stdin.resume(); process.stdin.setEncoding("utf8"); - let buf = ""; - const onData = (chunk: string) => { + const state: SecretState = { buf: "", rawWasSet, resolve, onData: () => {} }; + state.onData = (chunk: string) => { for (const c of chunk.toString()) { - if (c === "\n" || c === "\r" || c === "\u0004") { - if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet); - process.stdin.pause(); - process.stdin.removeListener("data", onData); - process.stdout.write("\n"); - resolve(buf.trim()); - return; - } - if (c === "\u007F" || c === "\b") { - if (buf.length > 0) { - buf = buf.slice(0, -1); - process.stdout.write("\b \b"); - } - continue; - } - if (c === "\u0003") { - if (process.stdin.isTTY) process.stdin.setRawMode(rawWasSet); - process.exit(130); - } - buf += c; - process.stdout.write("*"); + if (_handleSecretChar(c, state)) return; } }; - process.stdin.on("data", onData); + process.stdin.on("data", state.onData); }); } @@ -344,6 +471,56 @@ async function fetchModels(baseUrl: string, apiKey: string): Promise { } } +async function _promptProviderSelection( + rl: ReturnType, +): Promise<{ providerName: string; baseUrl: string }> { + console.log("Select a provider:\n"); + _printProviderMenu(PRESET_PROVIDERS); + + const choice = (await rl.question(`Choose [1-${PRESET_PROVIDERS.length + 1}]: `)).trim(); + const choiceNum = Number.parseInt(choice, 10); + if (Number.isNaN(choiceNum) || choiceNum < 1 || choiceNum > PRESET_PROVIDERS.length + 1) { + throw new Error(`Invalid choice: ${choice}`); + } + + const preset = _resolveProviderChoice(choice, PRESET_PROVIDERS); + if (preset) { + const selected = PRESET_PROVIDERS[choiceNum - 1]; + if (selected) { + console.log(`\n → ${selected.label} (${selected.baseUrl})\n`); + } + return preset; + } + + const providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim(); + if (!providerName) throw new Error("Provider name required"); + const baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim(); + if (!baseUrl) throw new Error("Base URL required"); + return { providerName, baseUrl }; +} + +async function _promptModelSelection( + rl: ReturnType, + baseUrl: string, + apiKey: string, +): Promise { + console.log("\nFetching available models..."); + const models = await fetchModels(baseUrl, apiKey); + + if (models.length === 0) { + console.log("Could not fetch models. Enter model name manually."); + const model = (await rl.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim(); + if (!model) throw new Error("Model required"); + return model; + } + console.log(`\nAvailable models (${models.length}):\n`); + _printModelMenu(models, process.stdout.columns || 100); + console.log(`\nChoose a number, or type a model name directly.`); + const modelInput = (await rl.question(`Default model [1-${models.length}]: `)).trim(); + if (!modelInput) throw new Error("Model required"); + return _resolveModelChoice(modelInput, models); +} + /** * Interactive setup — prompts user for provider, API key, model. */ @@ -353,39 +530,7 @@ export async function cmdSetupInteractive(storageRoot: string): Promise PRESET_PROVIDERS.length + 1) { - throw new Error(`Invalid choice: ${choice}`); - } - - let providerName: string; - let baseUrl: string; - - if (choiceNum <= PRESET_PROVIDERS.length) { - const selected = PRESET_PROVIDERS[choiceNum - 1]; - if (!selected) throw new Error("Invalid selection"); - providerName = selected.name; - baseUrl = selected.baseUrl; - console.log(`\n → ${selected.label} (${selected.baseUrl})\n`); - } else { - providerName = (await rl.question("Provider name (e.g. my-proxy): ")).trim(); - if (!providerName) throw new Error("Provider name required"); - baseUrl = (await rl.question("OpenAI-compatible API base URL: ")).trim(); - if (!baseUrl) throw new Error("Base URL required"); - } + const { providerName, baseUrl } = await _promptProviderSelection(rl); // 2. API key rl.close(); @@ -394,47 +539,8 @@ export async function cmdSetupInteractive(storageRoot: string): Promise 0) { - console.log(`\nAvailable models (${models.length}):\n`); - const nw = String(models.length).length; - // Multi-column layout - const maxLen = models.reduce((m, s) => Math.max(m, s.length), 0); - const colWidth = nw + 2 + maxLen + 4; // " N) name " - const termCols = process.stdout.columns || 100; - const cols = Math.max(1, Math.floor(termCols / colWidth)); - const rows = Math.ceil(models.length / cols); - for (let r = 0; r < rows; r++) { - let line = ""; - for (let c = 0; c < cols; c++) { - const idx = c * rows + r; - if (idx >= models.length) break; - const num = String(idx + 1).padStart(nw); - const name = (models[idx] ?? "").padEnd(maxLen); - line += ` ${num}) ${name} `; - } - console.log(line.trimEnd()); - } - console.log(`\nChoose a number, or type a model name directly.`); - const modelInput = (await rl2.question(`Default model [1-${models.length}]: `)).trim(); - if (!modelInput) throw new Error("Model required"); - const modelNum = Number.parseInt(modelInput, 10); - if (!Number.isNaN(modelNum) && modelNum >= 1 && modelNum <= models.length) { - model = models[modelNum - 1] ?? modelInput; - } else { - model = modelInput; - } - } else { - console.log("Could not fetch models. Enter model name manually."); - model = (await rl2.question("Default model (e.g. qwen-plus, gpt-4o): ")).trim(); - if (!model) throw new Error("Model required"); - } - + const model = await _promptModelSelection(rl2, baseUrl, apiKey); rl2.close(); - console.log(` → ${providerName}/${model}\n`); const setupResult = await cmdSetup({ @@ -447,17 +553,8 @@ export async function cmdSetupInteractive(storageRoot: string): Promise Register a workflow"); console.log(' uwf thread start -p "..." Start a thread');