From 1e936cf04a1c72c364ae52146d3d7abc2cf38763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=9F=E6=9C=88?= Date: Tue, 12 May 2026 21:44:21 +0800 Subject: [PATCH] fix: improve setup interactive UX 1. Mask API key input with * characters (raw mode) 2. Fetch and list available models from provider /models endpoint 3. Workspace prompt: fill path directly (default skip), not y/n 4. Add .gitkeep to workflows/ in init workspace scaffold --- .../src/commands/init/workspace.ts | 1 + .../src/commands/setup/dispatch.ts | 132 +++++++++++++++--- 2 files changed, 111 insertions(+), 22 deletions(-) diff --git a/packages/cli-workflow/src/commands/init/workspace.ts b/packages/cli-workflow/src/commands/init/workspace.ts index 9bd1a8a..8b3bea9 100644 --- a/packages/cli-workflow/src/commands/init/workspace.ts +++ b/packages/cli-workflow/src/commands/init/workspace.ts @@ -320,6 +320,7 @@ export async function cmdInitWorkspace( writeFile(join(rootPath, "README.md"), readmeMd(workspaceName), "utf8"), writeFile(join(rootPath, "templates", ".gitkeep"), "", "utf8"), writeFile(join(rootPath, "workflows", "package.json"), workflowsPackageJson(), "utf8"), + writeFile(join(rootPath, "workflows", ".gitkeep"), "", "utf8"), writeFile(join(rootPath, "bunfig.toml"), bunfigToml(), "utf8"), writeFile(join(rootPath, "scripts", "bundle.ts"), bundleTs(), "utf8"), ]); diff --git a/packages/cli-workflow/src/commands/setup/dispatch.ts b/packages/cli-workflow/src/commands/setup/dispatch.ts index c9cf26c..f0aa85f 100644 --- a/packages/cli-workflow/src/commands/setup/dispatch.ts +++ b/packages/cli-workflow/src/commands/setup/dispatch.ts @@ -3,10 +3,18 @@ import { createInterface } from "node:readline/promises"; import { err, ok, type Result } from "@uncaged/workflow-protocol"; -import { printCliError, printCliLine } from "../../cli-output.js"; +import { printCliError, printCliLine, printCliWarn } from "../../cli-output.js"; import { cmdSetup, printSetupSummary } from "./setup.js"; import type { SetupCliArgs } from "./types.js"; +type OpenAiModelEntry = { + id: string; +}; + +type OpenAiModelsResponse = { + data: OpenAiModelEntry[]; +}; + function usageSetup(): string { return [ "uncaged-workflow setup — configure workflow.yaml providers and default model", @@ -139,6 +147,69 @@ async function promptLine( return raw.trim(); } +/** Read a line with terminal echo disabled (for secrets). */ +async function promptSecret(label: string): Promise { + process.stdout.write(label); + return new Promise((resolve) => { + let buf = ""; + const rawWasSet = process.stdin.isRaw; + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + + const onData = (ch: string) => { + const c = ch.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") { + buf = buf.slice(0, -1); + return; + } + if (c === "\u0003") { + process.exit(130); + } + buf += c; + process.stdout.write("*"); + }; + + process.stdin.on("data", onData); + }); +} + +/** Fetch available models from an OpenAI-compatible /models endpoint. */ +async function fetchAvailableModels( + baseUrl: string, + apiKey: string, +): Promise { + const url = baseUrl.replace(/\/+$/, "") + "/models"; + try { + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) { + return []; + } + const body = (await res.json()) as OpenAiModelsResponse; + if (!Array.isArray(body.data)) { + return []; + } + return body.data.map((m) => m.id).sort(); + } catch { + return []; + } +} + async function collectInteractiveSetup(): Promise> { const rl = createInterface({ input, output }); try { @@ -158,34 +229,51 @@ async function collectInteractiveSetup(): Promise> if (baseUrl === "") { return err("base URL must not be empty"); } - // Note: readline does not support masked input; API key is visible during entry. - // Acceptable for a local dev CLI — not a production-facing prompt. - const apiKey = await promptLine(rl, "API key for this provider: "); + + // Close readline before raw-mode secret prompt, reopen after. + rl.close(); + const apiKey = await promptSecret("API key for this provider: "); if (apiKey === "") { return err("API key must not be empty"); } - const defaultModel = await promptLine( - rl, - `Default model — format: ${provider}/\n (e.g. ${provider}/gpt-4o, ${provider}/qwen-plus): `, - ); + const rl2 = createInterface({ input, output }); + + // Try to list available models from the provider. + printCliLine("\nFetching available models..."); + const models = await fetchAvailableModels(baseUrl, apiKey); + let modelPrompt: string; + if (models.length > 0) { + const display = models.slice(0, 20); + printCliLine(`Available models (${models.length} total):`); + for (const m of display) { + printCliLine(` ${m}`); + } + if (models.length > 20) { + printCliLine(` ... and ${models.length - 20} more`); + } + modelPrompt = `\nDefault model — format: ${provider}/: `; + } else { + printCliWarn("Could not fetch models (API may not support /models endpoint)."); + modelPrompt = `Default model — format: ${provider}/\n (e.g. ${provider}/gpt-4o, ${provider}/qwen-plus): `; + } + + const defaultModel = await promptLine(rl2, modelPrompt); if (defaultModel === "") { + rl2.close(); return err("default model must not be empty"); } - const yn = await promptLine( - rl, - "\nCreate a workflow workspace in the current directory? (y/n): ", + + const wsPath = await promptLine( + rl2, + "\nWorkflow workspace path (default: ./workflows, leave empty to skip): ", ); - const lower = yn.toLowerCase(); + rl2.close(); + let initWorkspaceName: string | null = null; - if (lower === "y" || lower === "yes") { - const name = await promptLine(rl, "Workspace directory name: "); - if (name === "") { - return err("workspace name must not be empty"); - } - initWorkspaceName = name; - } else if (lower !== "n" && lower !== "no" && lower !== "") { - return err('expected "y" or "n" for workspace init prompt'); + if (wsPath !== "") { + initWorkspaceName = wsPath; } + return ok({ provider, baseUrl, @@ -193,8 +281,8 @@ async function collectInteractiveSetup(): Promise> defaultModel, initWorkspaceName, }); - } finally { - rl.close(); + } catch (e) { + return err(e instanceof Error ? e.message : String(e)); } }