From e604fa5f47b51852aa5dc8295c15c3a17e881a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Mon, 18 May 2026 11:49:42 +0000 Subject: [PATCH] feat: add uwf setup command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Interactive mode: prompts for provider, API key, model (with /models discovery) - Non-interactive mode: --provider --base-url --api-key --model flags - Writes config.yaml (providers, models, agents, defaults) - Writes .env (API keys with auto-generated env var names) - Merges into existing config non-destructively - Includes 13 preset providers (international + China + local) 小橘 🍊(NEKO Team) --- packages/cli-uwf/src/cli.ts | 39 ++++ packages/cli-uwf/src/commands/setup.ts | 311 +++++++++++++++++++++++++ 2 files changed, 350 insertions(+) create mode 100644 packages/cli-uwf/src/commands/setup.ts diff --git a/packages/cli-uwf/src/cli.ts b/packages/cli-uwf/src/cli.ts index 159aecd..40ac774 100644 --- a/packages/cli-uwf/src/cli.ts +++ b/packages/cli-uwf/src/cli.ts @@ -10,6 +10,7 @@ import { cmdThreadStep, } from "./commands/thread.js"; import { cmdWorkflowList, cmdWorkflowPut, cmdWorkflowShow } from "./commands/workflow.js"; +import { cmdSetup, cmdSetupInteractive } from "./commands/setup.js"; import { resolveStorageRoot } from "./store.js"; function writeJson(data: unknown): void { @@ -130,6 +131,44 @@ thread }); }); +program + .command("setup") + .description("Configure provider, model, and agent") + .option("--provider ", "Provider name") + .option("--base-url ", "OpenAI-compatible API base URL") + .option("--api-key ", "API key") + .option("--model ", "Default model name") + .option("--agent ", "Default agent alias") + .action((opts: { + provider?: string; + baseUrl?: string; + apiKey?: string; + model?: string; + agent?: string; + }) => { + const storageRoot = resolveStorageRoot(); + runAction(async () => { + if (opts.provider && opts.baseUrl && opts.apiKey && opts.model) { + const result = await cmdSetup({ + provider: opts.provider, + baseUrl: opts.baseUrl, + apiKey: opts.apiKey, + model: opts.model, + agent: opts.agent, + storageRoot, + }); + writeJson(result); + } else if (!opts.provider && !opts.baseUrl && !opts.apiKey && !opts.model) { + const result = await cmdSetupInteractive(storageRoot); + writeJson(result); + } else { + throw new Error( + "Non-interactive setup requires all of: --provider, --base-url, --api-key, --model", + ); + } + }); + }); + program.parseAsync(process.argv).catch((e: unknown) => { const message = e instanceof Error ? e.message : String(e); process.stderr.write(`${message}\n`); diff --git a/packages/cli-uwf/src/commands/setup.ts b/packages/cli-uwf/src/commands/setup.ts new file mode 100644 index 0000000..2ce38de --- /dev/null +++ b/packages/cli-uwf/src/commands/setup.ts @@ -0,0 +1,311 @@ +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import { createInterface } from "node:readline/promises"; +import { stdin as input, stdout as output } from "node:process"; + +import { stringify, parse } from "yaml"; + +/** + * Preset provider list — embedded to avoid runtime YAML loading dependency. + * Keep in sync with providers.yaml in cli-workflow. + */ +const PRESET_PROVIDERS = [ + // International + { name: "openai", label: "OpenAI", baseUrl: "https://api.openai.com/v1" }, + { name: "xai", label: "xAI", baseUrl: "https://api.x.ai/v1" }, + { name: "openrouter", label: "OpenRouter", baseUrl: "https://openrouter.ai/api/v1" }, + { name: "venice", label: "Venice", baseUrl: "https://api.venice.ai/api/v1" }, + // China + { name: "dashscope", label: "DashScope (Alibaba)", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1" }, + { name: "deepseek", label: "DeepSeek", baseUrl: "https://api.deepseek.com/v1" }, + { name: "siliconflow", label: "SiliconFlow", baseUrl: "https://api.siliconflow.cn/v1" }, + { name: "volcengine", label: "Volcengine (ByteDance)", baseUrl: "https://ark.cn-beijing.volces.com/api/v3" }, + { name: "kimi", label: "Kimi (Moonshot)", baseUrl: "https://api.moonshot.cn/v1" }, + { name: "glm", label: "GLM (Zhipu AI)", baseUrl: "https://open.bigmodel.cn/api/paas/v4" }, + { name: "stepfun", label: "StepFun", baseUrl: "https://api.stepfun.com/v1" }, + { name: "minimax", label: "MiniMax", baseUrl: "https://api.minimax.io/v1" }, + // Local + { name: "ollama", label: "Ollama (local)", baseUrl: "http://localhost:11434/v1" }, +] as const; + +type SetupArgs = { + provider: string; + baseUrl: string; + apiKey: string; + model: string; + agent?: string; + storageRoot: string; +}; + +function getConfigPath(root: string): string { + return join(root, "config.yaml"); +} + +function getEnvPath(root: string): string { + return join(root, ".env"); +} + +/** + * Load existing config.yaml or return empty structure. + */ +function loadExistingConfig(configPath: string): Record { + try { + if (existsSync(configPath)) { + const raw = parse(readFileSync(configPath, "utf8")) as unknown; + if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) { + return raw as Record; + } + } + } catch { + // ignore parse errors, start fresh + } + return {}; +} + +/** + * Load existing .env as key=value map. + */ +function loadEnvFile(envPath: string): Record { + const env: Record = {}; + try { + if (existsSync(envPath)) { + for (const line of readFileSync(envPath, "utf8").split("\n")) { + const trimmed = line.trim(); + if (trimmed === "" || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq > 0) { + env[trimmed.slice(0, eq)] = trimmed.slice(eq + 1); + } + } + } + } catch { + // ignore + } + return env; +} + +function saveEnvFile(envPath: string, env: Record): void { + const lines = Object.entries(env).map(([k, v]) => `${k}=${v}`); + writeFileSync(envPath, `${lines.join("\n")}\n`, "utf8"); +} + +function apiKeyEnvName(providerName: string): string { + return `${providerName.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_API_KEY`; +} + +/** + * Merge setup args into config.yaml structure. Non-destructive — preserves existing entries. + */ +function mergeConfig(existing: Record, args: SetupArgs): Record { + const providers = (typeof existing.providers === "object" && existing.providers !== null + ? { ...(existing.providers as Record) } + : {}) as Record; + + const envName = apiKeyEnvName(args.provider); + providers[args.provider] = { baseUrl: args.baseUrl, apiKeyEnv: envName }; + + const models = (typeof existing.models === "object" && existing.models !== null + ? { ...(existing.models as Record) } + : {}) as Record; + models.default = { provider: args.provider, name: args.model }; + + const agents = (typeof existing.agents === "object" && existing.agents !== null + ? { ...(existing.agents as Record) } + : {}) as Record; + + const agentName = args.agent ?? "hermes"; + if (Object.keys(agents).length === 0) { + agents.hermes = { command: "uwf-hermes", args: [] }; + } + + return { + ...existing, + providers, + models, + agents, + defaultAgent: existing.defaultAgent ?? agentName, + defaultModel: existing.defaultModel ?? "default", + }; +} + +/** + * Non-interactive setup. All required args provided via CLI flags. + */ +export async function cmdSetup(args: SetupArgs): Promise> { + const { storageRoot } = args; + mkdirSync(storageRoot, { recursive: true }); + + const configPath = getConfigPath(storageRoot); + const envPath = getEnvPath(storageRoot); + + const existing = loadExistingConfig(configPath); + const merged = mergeConfig(existing, args); + + writeFileSync(configPath, stringify(merged, { indent: 2 }), "utf8"); + + // Write API key to .env + const envName = apiKeyEnvName(args.provider); + const envData = loadEnvFile(envPath); + envData[envName] = args.apiKey; + saveEnvFile(envPath, envData); + + return { + configPath, + envPath, + provider: args.provider, + model: args.model, + defaultAgent: merged.defaultAgent, + }; +} + +/** Read a line with terminal echo disabled (for secrets). */ +async function promptSecret(label: string): Promise { + process.stdout.write(label); + return new Promise((resolve) => { + const rawWasSet = process.stdin.isRaw; + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdin.setEncoding("utf8"); + + let buf = ""; + const 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("*"); + } + }; + process.stdin.on("data", onData); + }); +} + +/** Fetch available models from an OpenAI-compatible /models endpoint. */ +async function fetchModels(baseUrl: string, apiKey: string): Promise { + try { + const url = `${baseUrl.replace(/\/+$/, "")}/models`; + const res = await fetch(url, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) return []; + const body = (await res.json()) as { data?: { id: string }[] }; + if (!Array.isArray(body.data)) return []; + const NON_CHAT = /speech|embed|image|video|audio|ocr|rerank|tts|asr|paraformer|sambert|cosyvoice|wordart|wanx|wan2|flux|stable-diffusion|gui-/i; + return body.data.map((m) => m.id).filter((id) => !NON_CHAT.test(id)).sort(); + } catch { + return []; + } +} + +/** + * Interactive setup — prompts user for provider, API key, model. + */ +export async function cmdSetupInteractive(storageRoot: string): Promise> { + const rl = createInterface({ input, output }); + + try { + console.log("Configure LLM provider for uwf workflow agents.\n"); + + // 1. Provider selection + const numWidth = String(PRESET_PROVIDERS.length + 1).length; + console.log("Select a provider:\n"); + for (let i = 0; i < PRESET_PROVIDERS.length; i++) { + const p = PRESET_PROVIDERS[i]; + if (!p) continue; + const num = String(i + 1).padStart(numWidth); + console.log(` ${num}) ${p.label.padEnd(28)} ${p.baseUrl}`); + } + const customNum = String(PRESET_PROVIDERS.length + 1).padStart(numWidth); + console.log(` ${customNum}) Custom (enter name and URL manually)\n`); + + 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}`); + } + + 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"); + } + + // 2. API key + rl.close(); + const apiKey = await promptSecret("API key: "); + if (!apiKey) throw new Error("API key required"); + + // 3. Model selection + const rl2 = createInterface({ input, output }); + console.log("\nFetching available models..."); + const models = await fetchModels(baseUrl, apiKey); + + let model: string; + if (models.length > 0) { + console.log(`\nAvailable models (${models.length}):\n`); + const nw = String(models.length).length; + for (let i = 0; i < models.length; i++) { + const num = String(i + 1).padStart(nw); + console.log(` ${num}) ${models[i]}`); + } + 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"); + } + + rl2.close(); + + console.log(` → ${providerName}/${model}\n`); + + return await cmdSetup({ + provider: providerName, + baseUrl, + apiKey, + model, + storageRoot, + }); + } finally { + rl.close(); + } +}