diff --git a/docs/agents/config-profile-change.md b/docs/agents/config-profile-change.md index 4e79e7d1..b9b00dbd 100644 --- a/docs/agents/config-profile-change.md +++ b/docs/agents/config-profile-change.md @@ -46,7 +46,9 @@ - `config list` 标识所有 Profile 与当前激活项。 - `config show`、`auth status` 只输出本次最终选择的 `config` 和 `config_file`,不重复携带激活状态。 - `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`。 -- `config ui` 保存时只替换 UI 管理的字段;Profile 中未展示但仍属于 `ConfigFile` 的合法字段必须保留,不能因打开并保存 UI 而丢失。 +- `config ui` 展示并可编辑完整 `ConfigFile`(含 `console_*`、`telemetry`),保存时按类型(数字/布尔/枚举)归一化写回;`config set` 仍只暴露较窄的 `VALID_KEYS`。UI 未管理的顶层元数据(如 `active_config`)不进入 Profile block,仍由写盘逻辑单独保留。 +- `config ui` 只读展示本地 agent 生态:Skills 跨全部 agent skill 目录(`~/.agents/skills` 及各 agent 的 `skills/`,含软链接)按 id 聚合并标注安装来源;MCP、Agents 从各 agent 本地配置读取。 +- `config ui` 提供 Assets 资产管理:扫描 `output_dir`(默认 `~/bailian-output`)下的 `images/videos/speech/omni` 分类及根目录散落文件,按分类与生成时间(mtime)标记,支持按分类筛选、内联预览(图/视频/音频)与删除单个文件;文件读取与删除均通过限定在输出目录内的路径校验(防目录穿越)。 - 同步 E2E topic routes、Skill setup 和自动生成 reference。 ## 6. 最小测试矩阵 @@ -62,7 +64,8 @@ `--config default` 成功后切回 `default`。 - Console token 自动刷新不从其他 Profile 借用 AK/SK,也不把新 token 写入其他 Profile。 - `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。 -- `config ui` 覆盖保存时保留未管理字段,并继续允许空值清除 UI 管理字段。 +- `config ui` 覆盖保存时保留顶层元数据(如 `active_config`),继续允许空值清除字段,并覆盖 `console_*`/`telemetry` 的类型归一化与枚举校验。 +- Assets:`listAssets` 覆盖分类归类、时间倒序、目录缺失返回空;`resolveAssetPath` 覆盖目录穿越拦截;`contentType` 覆盖常见扩展名映射。 ## 7. 完成检查 diff --git a/packages/commands/src/commands/auth/console-ui.ts b/packages/commands/src/commands/auth/console-ui.ts new file mode 100644 index 00000000..dd5a498c --- /dev/null +++ b/packages/commands/src/commands/auth/console-ui.ts @@ -0,0 +1,79 @@ +import { maskToken, type AuthStore, type Identity, type Settings } from "bailian-cli-core"; +import { runConsoleLogin, resolveConsoleOrigin } from "./login-console.ts"; + +/** Read-only auth snapshot the config UI account widget renders. bl stores no + * user profile (name/avatar), so this exposes only which credential domains + * resolve, the console region/site, and a masked token. */ +export interface AuthUiStatus { + authenticated: boolean; + methods: { apiKey: boolean; console: boolean; openapi: boolean }; + primary: "console" | "apiKey" | "openapi" | null; + region?: string; + site?: "domestic" | "international"; + masked?: string; +} + +/** + * The auth capability surface the config UI is allowed to use. All `authStore` + * access is kept inside this module (commands/auth/**), which the lint boundary + * permits; commands/config/** consumes only this opaque bridge and never + * touches `authStore` directly. + */ +export interface AuthUiBridge { + status(): AuthUiStatus; + /** Start browser-based console login (fire-and-forget; UI polls status). */ + startConsoleLogin(): void; + /** Clear all stored credentials. Returns whether anything changed. */ + logout(): Promise; +} + +/** Build the bridge from a command context (identity/settings/authStore). */ +export function makeAuthUiBridge(ctx: { + identity: Identity; + settings: Settings; + authStore: AuthStore; +}): AuthUiBridge { + const { identity, settings, authStore } = ctx; + return { + status() { + const a = authStore.describe(); + const methods = { apiKey: !!a.apiKey, console: !!a.console, openapi: !!a.openapi }; + let masked: string | undefined; + if (a.console) masked = maskToken(a.console.token); + else if (a.apiKey) masked = maskToken(a.apiKey.token); + else if (a.openapi) masked = maskToken(a.openapi.accessKeyId); + const primary = a.console ? "console" : a.apiKey ? "apiKey" : a.openapi ? "openapi" : null; + return { + authenticated: methods.apiKey || methods.console || methods.openapi, + methods, + primary, + region: a.console?.region, + site: a.console?.site, + masked, + }; + }, + startConsoleLogin() { + const origin = resolveConsoleOrigin(authStore.describe().console?.site); + // Mirror the CLI (`bl auth login --console`): request an api_key from the + // console only when one isn't already stored, so a first console login in + // the config UI also provisions the model api_key (not just access_token). + const hasApiKey = !!authStore.stored().apiKey; + // runConsoleLogin opens the browser and runs its own callback server + // (up to 15 min). We don't await it — the config UI polls the status + // endpoint to detect completion. Errors are logged, not surfaced. + void runConsoleLogin( + origin, + { identity, settings, authStore }, + { + needApiKey: !hasApiKey, + }, + ).catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err); + process.stderr.write(`console login failed: ${msg}\n`); + }); + }, + logout() { + return authStore.logout("all"); + }, + }; +} diff --git a/packages/commands/src/commands/config/agent-launch.ts b/packages/commands/src/commands/config/agent-launch.ts new file mode 100644 index 00000000..ad380400 --- /dev/null +++ b/packages/commands/src/commands/config/agent-launch.ts @@ -0,0 +1,131 @@ +/** + * Best-effort local launcher for coding-agent CLIs surfaced in the config UI. + * + * The command for each agent is taken from a fixed allowlist keyed by the + * agent id, so no user-controlled string is ever executed. Every child process + * is spawned via `execFile` (array args, no shell) to avoid injection. + */ +import { execFile } from "node:child_process"; + +/** Fixed allowlist: agent id -> launch binary. Keys match `AGENT_PROBES` ids. */ +export const AGENT_COMMANDS: Record = { + "claude-code": "claude", + "qwen-code": "qwen", + opencode: "opencode", + openclaw: "openclaw", + hermes: "hermes", + codex: "codex", +}; + +/** The launch binary for a known agent id, or undefined when unknown. */ +export function agentCommand(id: string): string | undefined { + return Object.prototype.hasOwnProperty.call(AGENT_COMMANDS, id) ? AGENT_COMMANDS[id] : undefined; +} + +/** + * Per-agent argv that passes an initial task prompt while keeping the agent + * interactive in the terminal. Only verified contracts are listed; an agent + * absent here cannot be dispatched a prompt (its bare launch still works). + * - qwen-code: `qwen -i ""` (execute prompt, stay interactive) + * - claude-code: `claude ""` (positional initial prompt) + * - codex: `codex ""` (positional initial prompt) + */ +const AGENT_PROMPT_ARGV: Record string[]> = { + "qwen-code": (p) => ["-i", p], + "claude-code": (p) => [p], + codex: (p) => [p], +}; + +/** Whether a known agent supports being dispatched an initial task prompt. */ +export function agentSupportsPrompt(id: string): boolean { + return Object.prototype.hasOwnProperty.call(AGENT_PROMPT_ARGV, id); +} + +/** Resolve whether a binary is reachable on PATH (via `which`/`where`). */ +function onPath(bin: string): Promise { + const cmd = process.platform === "win32" ? "where" : "which"; + return new Promise((resolve) => { + execFile(cmd, [bin], { windowsHide: true }, (err) => resolve(!err)); + }); +} + +/** + * Whether a known agent can actually be quick-launched right now: its id maps to + * a launch binary and that binary is reachable on PATH. Unknown ids resolve to + * false. Used to gate the UI's Quick launch button so "Connected" agents whose + * CLI is not installed do not offer a launch that would immediately fail. + */ +export function agentLaunchable(id: string): Promise { + const command = agentCommand(id); + if (!command) return Promise.resolve(false); + return onPath(command); +} + +/** Single-quote a path for a POSIX shell command line. */ +function shQuote(p: string): string { + return `'${p.replace(/'/g, "'\\''")}'`; +} + +/** Open a new OS terminal window that cd's into `cwd` and runs `command`. */ +function spawnTerminal(command: string, cwd: string): Promise { + const platform = process.platform; + return new Promise((resolve, reject) => { + if (platform === "darwin") { + const inner = `cd ${shQuote(cwd)} && ${command}`; + const escaped = inner.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const args = [ + "-e", + `tell application "Terminal" to do script "${escaped}"`, + "-e", + 'tell application "Terminal" to activate', + ]; + execFile("osascript", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve())); + return; + } + if (platform === "win32") { + const args = ["/c", "start", "", "cmd", "/k", `cd /d ${cwd} && ${command}`]; + execFile("cmd", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve())); + return; + } + // Linux / other: best-effort via the distro's default terminal emulator. + const inner = `cd ${shQuote(cwd)} && ${command}; exec $SHELL`; + execFile("x-terminal-emulator", ["-e", "bash", "-lc", inner], { windowsHide: true }, (err) => + err ? reject(new Error("No supported terminal emulator was found")) : resolve(), + ); + }); +} + +export interface LaunchResult { + launched: boolean; + command: string; +} + +/** + * Launch a known coding agent's local CLI in a new terminal window. When + * `prompt` is provided, it is passed as a single quoted argument using the + * agent's verified prompt contract so the agent starts with that task. + * Rejects when the id is unknown, the binary is missing from PATH, the agent + * does not support prompt dispatch, or the platform terminal could not open. + */ +export async function launchAgent( + id: string, + cwd: string = process.cwd(), + prompt?: string, +): Promise { + const command = agentCommand(id); + if (!command) throw new Error(`Unknown agent: ${id}`); + if (!(await onPath(command))) { + throw new Error(`\`${command}\` was not found on your PATH — install ${id} first.`); + } + let fullCommand = command; + const task = (prompt ?? "").trim(); + if (task) { + const build = AGENT_PROMPT_ARGV[id]; + if (!build) throw new Error(`${id} does not support dispatching a task prompt.`); + // shQuote keeps the whole prompt as one shell argument (no injection); the + // platform terminal layer escapes the resulting command line separately. + fullCommand = [command, ...build(task).map(shQuote)].join(" "); + } + await spawnTerminal(fullCommand, cwd); + return { launched: true, command: fullCommand }; +} diff --git a/packages/commands/src/commands/config/assets.ts b/packages/commands/src/commands/config/assets.ts new file mode 100644 index 00000000..17c29385 --- /dev/null +++ b/packages/commands/src/commands/config/assets.ts @@ -0,0 +1,160 @@ +// Read/manage the local assets that `bl` writes into the output directory +// (default ~/bailian-output, overridable via the `output_dir` config key). +// Generated media may live directly under the base or in any subfolder (bl's +// own images/, videos/, speech/, omni/, or user-created folders). This module +// recursively discovers every file under the base, classifies each by type, +// derives its category from the top-level folder, and provides safe path +// resolution for serving/deleting individual assets. +import { readdirSync, statSync, existsSync, type Dirent } from "node:fs"; +import { homedir } from "node:os"; +import { join, extname, relative, resolve, sep } from "node:path"; + +export type AssetKind = "image" | "video" | "audio" | "other"; + +/** One generated file discovered under the output directory. */ +export interface AssetInfo { + name: string; + /** Category folder the file lives in: images | videos | speech | omni | other. */ + category: string; + kind: AssetKind; + /** Path relative to the output base (used as the API handle). */ + relPath: string; + size: number; + /** Modification time in epoch milliseconds ~= generation time. */ + mtime: number; + ext: string; +} + +/** Max directory depth to descend from the output base when scanning. */ +const MAX_SCAN_DEPTH = 8; + +const KIND_BY_EXT: Record = { + ".png": "image", + ".jpg": "image", + ".jpeg": "image", + ".webp": "image", + ".gif": "image", + ".bmp": "image", + ".svg": "image", + ".mp4": "video", + ".mov": "video", + ".webm": "video", + ".mkv": "video", + ".avi": "video", + ".mp3": "audio", + ".wav": "audio", + ".m4a": "audio", + ".aac": "audio", + ".flac": "audio", + ".ogg": "audio", +}; + +const CONTENT_TYPE: Record = { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".bmp": "image/bmp", + ".svg": "image/svg+xml", + ".mp4": "video/mp4", + ".mov": "video/quicktime", + ".webm": "video/webm", + ".mkv": "video/x-matroska", + ".avi": "video/x-msvideo", + ".mp3": "audio/mpeg", + ".wav": "audio/wav", + ".m4a": "audio/mp4", + ".aac": "audio/aac", + ".flac": "audio/flac", + ".ogg": "audio/ogg", +}; + +/** The default output base when `output_dir` is not configured. */ +export function defaultOutputBase(home: string = homedir()): string { + return join(home, "bailian-output"); +} + +function kindOf(ext: string): AssetKind { + return KIND_BY_EXT[ext.toLowerCase()] ?? "other"; +} + +/** MIME type for serving an asset; falls back to a safe binary type. */ +export function contentType(ext: string): string { + return CONTENT_TYPE[ext.toLowerCase()] ?? "application/octet-stream"; +} + +/** Recursively collect regular files under `dir`, descending at most `depth` levels. */ +function walk(dir: string, depth: number, out: string[]): void { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return; + } + for (const e of entries) { + const full = join(dir, e.name); + if (e.isDirectory()) { + if (depth > 0) walk(full, depth - 1, out); + } else if (e.isFile() || e.isSymbolicLink()) { + out.push(full); + } + } +} + +/** + * List generated assets under `base`, newest first. Recursively scans every + * subfolder under the base (plus loose files at the root), so assets in bl's + * own category dirs and any user-created folders are all discovered. Each + * file's `category` is its top-level folder name, or "other" for root files. + * Returns the resolved base so callers can surface it in the UI. + */ +export function listAssets(base: string = defaultOutputBase()): { + base: string; + assets: AssetInfo[]; +} { + const assets: AssetInfo[] = []; + if (!existsSync(base)) return { base, assets }; + + const files: string[] = []; + walk(base, MAX_SCAN_DEPTH, files); + + for (const full of files) { + let st; + try { + st = statSync(full); + } catch { + continue; + } + if (!st.isFile()) continue; + const rel = relative(base, full); + const segments = rel.split(sep); + const category = segments.length > 1 ? segments[0]! : "other"; + const ext = extname(full); + assets.push({ + name: full.split(sep).pop() ?? full, + category, + kind: kindOf(ext), + relPath: rel, + size: st.size, + mtime: st.mtimeMs, + ext: ext.replace(/^\./, "").toLowerCase(), + }); + } + + assets.sort((a, b) => b.mtime - a.mtime); + return { base, assets }; +} + +/** + * Resolve a client-supplied relative path to an absolute path strictly inside + * `base`. Returns null for empty input or any path that would escape the base + * (path traversal guard). + */ +export function resolveAssetPath(base: string, relPath: string): string | null { + if (typeof relPath !== "string" || relPath.length === 0) return null; + const root = resolve(base); + const abs = resolve(root, relPath); + if (abs !== root && !abs.startsWith(root + sep)) return null; + return abs; +} diff --git a/packages/commands/src/commands/config/inventory.ts b/packages/commands/src/commands/config/inventory.ts new file mode 100644 index 00000000..a50b08b2 --- /dev/null +++ b/packages/commands/src/commands/config/inventory.ts @@ -0,0 +1,955 @@ +// Read-only discovery of locally installed AI tooling, surfaced by `config ui`: +// - Agent skills installed under ~/.agents/skills (via `npx skills add`). +// - MCP servers declared in each coding agent's local config file. +// - Coding agent frameworks and whether the bailian-cli provider is wired in. +// +// Everything here only reads the filesystem; nothing is written. Missing files, +// unreadable dirs and malformed configs degrade to empty results rather than +// throwing, so a broken third-party config never takes down the UI. +import { homedir } from "node:os"; +import { dirname, join, resolve, sep } from "node:path"; +import { + existsSync, + mkdirSync, + readFileSync, + readdirSync, + writeFileSync, + type Dirent, +} from "node:fs"; +import { inflateRawSync } from "node:zlib"; +import yaml from "yaml"; +import { parse as parseToml } from "smol-toml"; + +/** + * Where an item comes from. Everything discovered on disk today is `local`; + * `remote` is reserved for entries later loaded from an online URL. + */ +export type ItemOrigin = "local" | "remote"; + +/** A skill discovered in one or more agent skill directories. */ +export interface SkillInfo { + id: string; + name: string; + description: string; + version?: string; + fileCount: number; + path: string; + /** Agent modules this skill is installed in (e.g. global, claude-code, qwen-code). */ + sources: string[]; + /** local (on disk) or remote (loaded from a URL). */ + origin: ItemOrigin; +} + +/** One MCP server entry pulled from an agent's local config. */ +export interface McpServerInfo { + name: string; + source: string; + transport: "stdio" | "http" | "sse" | "unknown"; + detail: string; + scope: string; + /** local (on disk) or remote (loaded from a URL). */ + origin: ItemOrigin; + /** Raw config entry for this server, with secret-looking values masked. */ + config?: Record; + /** Whether this entry lives in a JSON config we can edit/write back here. */ + editable: boolean; +} + +/** A coding agent framework and its local configuration state. */ +export interface AgentInfo { + id: string; + label: string; + installed: boolean; + configured: boolean; + model?: string; + paths: string[]; + origin: ItemOrigin; +} + +function readText(path: string): string | undefined { + try { + return readFileSync(path, "utf-8"); + } catch { + return undefined; + } +} + +function readJsonSafe(path: string): Record | undefined { + const text = readText(path); + if (text === undefined) return undefined; + try { + const parsed = JSON.parse(text) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : undefined; + } catch { + return undefined; + } +} + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +// ---- Skills ---- + +/** Extract the leading `--- ... ---` YAML frontmatter block from a SKILL.md. */ +function parseFrontmatter(md: string): Record { + const match = /^---\s*\n([\s\S]*?)\n---/.exec(md); + if (!match) return {}; + try { + return asRecord(yaml.parse(match[1])) ?? {}; + } catch { + return {}; + } +} + +function countFiles(dir: string, budget = 500): number { + let total = 0; + const walk = (current: string): void => { + if (total >= budget) return; + let entries: Dirent[]; + try { + entries = readdirSync(current, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (total >= budget) return; + const full = join(current, entry.name); + if (entry.isDirectory()) walk(full); + else total += 1; + } + }; + walk(dir); + return total; +} + +/** + * Skill directories to scan, keyed by the module that owns them. `npx skills + * add --all` fans skills out into each installed agent, so the same skill can + * live in several of these roots at once. + */ +function skillRoots(home: string): Array<{ source: string; dir: string }> { + return [ + { source: "global", dir: join(home, ".agents", "skills") }, + { source: "claude-code", dir: join(home, ".claude", "skills") }, + { source: "cursor", dir: join(home, ".cursor", "skills") }, + { source: "qwen-code", dir: join(home, ".qwen", "skills") }, + { source: "codex", dir: join(home, ".codex", "skills") }, + { source: "opencode", dir: join(home, ".config", "opencode", "skills") }, + { source: "openclaw", dir: join(home, ".openclaw", "skills") }, + { source: "openclaw", dir: join(home, ".openclaw", "workspace", "skills") }, + { source: "hermes", dir: join(home, ".hermes", "skills") }, + { source: "gemini", dir: join(home, ".gemini", "skills") }, + { source: "antigravity", dir: join(home, ".gemini", "antigravity", "skills") }, + { source: "windsurf", dir: join(home, ".windsurf", "skills") }, + { source: "windsurf", dir: join(home, ".codeium", "windsurf", "skills") }, + { source: "qoderwork", dir: join(home, ".qoderwork", "skills") }, + { source: "workbuddy", dir: join(home, ".workbuddy", "skills") }, + ]; +} + +/** + * List skills installed across every known agent skill directory, aggregated + * by skill id. Each skill records the modules (`sources`) it is installed in; + * metadata is taken from the first module found (roots are ordered global-first). + */ +export function listSkills(home: string = homedir()): SkillInfo[] { + const byId = new Map(); + + for (const { source, dir: root } of skillRoots(home)) { + let entries: Dirent[]; + try { + entries = readdirSync(root, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + // Accept real dirs and symlinks: `skills add` fans skills into each agent + // as symlinks back to ~/.agents/skills, and isDirectory() is false for those. + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const dir = join(root, entry.name); + const skillMd = join(dir, "SKILL.md"); + if (!existsSync(skillMd)) continue; + + const existing = byId.get(entry.name); + if (existing) { + if (!existing.sources.includes(source)) existing.sources.push(source); + continue; + } + + const fm = parseFrontmatter(readText(skillMd) ?? ""); + const meta = asRecord(fm.metadata); + const description = typeof fm.description === "string" ? fm.description.trim() : ""; + const version = + meta && typeof meta.version === "string" + ? meta.version + : typeof fm.version === "string" + ? fm.version + : undefined; + + byId.set(entry.name, { + id: entry.name, + name: typeof fm.name === "string" && fm.name ? fm.name : entry.name, + description, + version, + fileCount: countFiles(dir), + path: dir, + sources: [source], + origin: "local", + }); + } + } + + return [...byId.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** A skill plus the raw text of its SKILL.md, for the detail drawer. */ +export interface SkillDetail extends SkillInfo { + content: string; +} + +/** + * Return full detail for one discovered skill (by id), including the raw + * SKILL.md content. The id must match a skill found by `listSkills`, so the + * read is confined to a known skill directory. Returns null when not found. + */ +export function getSkillDetail(id: string, home: string = homedir()): SkillDetail | null { + const skill = listSkills(home).find((s) => s.id === id); + if (!skill) return null; + const content = readText(join(skill.path, "SKILL.md")) ?? ""; + return { ...skill, content }; +} + +// ---- Skill install (upload a .zip and unpack it into a skill root) ---- + +/** Allow-listed skill install targets: source id -> label + path segments. */ +const SKILL_INSTALL_TARGETS: Array<{ source: string; label: string; sub: string[] }> = [ + { source: "global", label: "All agents (~/.agents/skills)", sub: [".agents", "skills"] }, + { source: "claude-code", label: "Claude Code", sub: [".claude", "skills"] }, + { source: "qwen-code", label: "Qwen Code", sub: [".qwen", "skills"] }, + { source: "codex", label: "Codex", sub: [".codex", "skills"] }, + { source: "opencode", label: "OpenCode", sub: [".config", "opencode", "skills"] }, + { source: "openclaw", label: "OpenClaw", sub: [".openclaw", "skills"] }, + { source: "qoderwork", label: "QoderWork", sub: [".qoderwork", "skills"] }, + { source: "windsurf", label: "Windsurf", sub: [".codeium", "windsurf", "skills"] }, + { source: "gemini", label: "Gemini", sub: [".gemini", "skills"] }, +]; + +/** The list of install targets exposed to the UI (source + human label). */ +export function skillInstallTargets(): Array<{ source: string; label: string }> { + return SKILL_INSTALL_TARGETS.map((t) => ({ source: t.source, label: t.label })); +} + +function skillInstallRoot(source: string, home: string): string | null { + const t = SKILL_INSTALL_TARGETS.find((x) => x.source === source); + return t ? join(home, ...t.sub) : null; +} + +interface ZipEntry { + name: string; + data: Buffer; +} + +/** + * Minimal ZIP reader (no external deps). Walks the central directory for + * correct sizes/offsets, then inflates each entry (stored or deflate). Zip64 + * and encryption are unsupported and will throw. Sufficient for skill packages. + */ +function parseZip(buf: Buffer): ZipEntry[] { + const EOCD_SIG = 0x06054b50; + let eocd = -1; + const minStart = Math.max(0, buf.length - 22 - 0xffff); + for (let i = buf.length - 22; i >= minStart; i--) { + if (buf.readUInt32LE(i) === EOCD_SIG) { + eocd = i; + break; + } + } + if (eocd < 0) throw new Error("Not a valid .zip archive."); + const count = buf.readUInt16LE(eocd + 10); + let off = buf.readUInt32LE(eocd + 16); + const entries: ZipEntry[] = []; + for (let e = 0; e < count; e++) { + if (off + 46 > buf.length || buf.readUInt32LE(off) !== 0x02014b50) break; + const method = buf.readUInt16LE(off + 10); + const compSize = buf.readUInt32LE(off + 20); + const nameLen = buf.readUInt16LE(off + 28); + const extraLen = buf.readUInt16LE(off + 30); + const commentLen = buf.readUInt16LE(off + 32); + const localOff = buf.readUInt32LE(off + 42); + const name = buf.toString("utf8", off + 46, off + 46 + nameLen); + off += 46 + nameLen + extraLen + commentLen; + if (name.endsWith("/")) continue; + if (localOff + 30 > buf.length || buf.readUInt32LE(localOff) !== 0x04034b50) continue; + const lNameLen = buf.readUInt16LE(localOff + 26); + const lExtraLen = buf.readUInt16LE(localOff + 28); + const dataStart = localOff + 30 + lNameLen + lExtraLen; + const raw = buf.subarray(dataStart, dataStart + compSize); + let data: Buffer; + if (method === 0) data = Buffer.from(raw); + else if (method === 8) data = inflateRawSync(raw); + else throw new Error("Unsupported compression in archive (entry: " + name + ")."); + entries.push({ name, data }); + } + return entries; +} + +/** + * Install a skill from an uploaded .zip into the chosen agent's skills root. + * The archive must contain a SKILL.md at its root or inside one top-level + * folder. Entry paths are sanitized (no absolute paths, no `..`) and every + * write is confined to the target skill directory (zip-slip safe). + */ +export function installSkillZip( + source: string, + zip: Buffer, + nameHint: string, + home: string = homedir(), +): { installed: string; files: number; dir: string } { + const root = skillInstallRoot(source, home); + if (!root) throw new Error("Unknown skill install target."); + + const norm = parseZip(zip) + .map((e) => ({ + name: e.name.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, ""), + data: e.data, + })) + .filter((e) => e.name && !e.name.endsWith("/") && !e.name.split("/").includes("..")); + if (!norm.length) throw new Error("The archive contains no usable files."); + + const skillMd = norm.find((e) => e.name === "SKILL.md" || e.name.endsWith("/SKILL.md")); + if (!skillMd) throw new Error("No SKILL.md found in the archive (root or a top-level folder)."); + + const slash = skillMd.name.lastIndexOf("/"); + let skillName: string; + let prefix: string; + if (slash < 0) { + skillName = (nameHint || "").trim(); + if (!skillName) + throw new Error("SKILL.md is at the archive root \u2014 please provide a skill name."); + prefix = ""; + } else { + prefix = skillMd.name.slice(0, slash + 1); + skillName = (nameHint || "").trim() || prefix.split("/")[0]; + } + if (!/^[A-Za-z0-9._-]+$/.test(skillName)) throw new Error("Invalid skill name: " + skillName); + + const targetDir = join(root, skillName); + const base = resolve(targetDir); + let files = 0; + for (const e of norm) { + let rel = e.name; + if (prefix) { + if (!e.name.startsWith(prefix)) continue; + rel = e.name.slice(prefix.length); + } + if (!rel) continue; + const abs = resolve(join(targetDir, rel)); + if (abs !== base && !abs.startsWith(base + sep)) continue; // zip-slip guard + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, e.data); + files++; + } + if (files === 0) throw new Error("Nothing was extracted from the archive."); + return { installed: skillName, files, dir: targetDir }; +} + +// ---- MCP servers ---- + +function transportOf(entry: Record): { + transport: McpServerInfo["transport"]; + detail: string; +} { + if (typeof entry.command === "string") { + const args = Array.isArray(entry.args) ? entry.args.join(" ") : ""; + return { transport: "stdio", detail: `${entry.command} ${args}`.trim() }; + } + const url = typeof entry.url === "string" ? entry.url : undefined; + if (url) { + const type = typeof entry.type === "string" ? entry.type.toLowerCase() : ""; + return { transport: type === "sse" ? "sse" : "http", detail: url }; + } + return { transport: "unknown", detail: "" }; +} + +function collectMcpMap( + raw: unknown, + source: string, + scope: string, + out: McpServerInfo[], + editable: boolean, +): void { + const map = asRecord(raw); + if (!map) return; + for (const [name, value] of Object.entries(map)) { + const entry = asRecord(value) ?? {}; + const { transport, detail } = transportOf(entry); + out.push({ + name, + source, + transport, + detail, + scope, + origin: "local", + config: maskConfigSecrets(entry) as Record, + editable, + }); + } +} + +/** Discover MCP servers declared across local agent config files. */ +export function listMcpServers(home: string = homedir()): McpServerInfo[] { + const out: McpServerInfo[] = []; + + // Claude Code: global mcpServers + per-project mcpServers in ~/.claude.json. + const claude = readJsonSafe(join(home, ".claude.json")); + if (claude) { + collectMcpMap(claude.mcpServers, "claude-code", "global", out, true); + const projects = asRecord(claude.projects); + if (projects) { + for (const [projectPath, projectValue] of Object.entries(projects)) { + const project = asRecord(projectValue); + if (project?.mcpServers) + collectMcpMap(project.mcpServers, "claude-code", projectPath, out, true); + } + } + } + + // Qwen Code. + const qwen = readJsonSafe(join(home, ".qwen", "settings.json")); + if (qwen) collectMcpMap(qwen.mcpServers, "qwen-code", "global", out, true); + + // OpenCode uses `mcp` rather than `mcpServers`. + const opencode = readJsonSafe(join(home, ".config", "opencode", "opencode.json")); + if (opencode) collectMcpMap(opencode.mcp, "opencode", "global", out, true); + + // Cursor, Windsurf, Gemini, QoderWork, OpenClaw, Claude Desktop: all JSON with + // a top-level `mcpServers` map (Claude/Cursor convention). + const cursor = readJsonSafe(join(home, ".cursor", "mcp.json")); + if (cursor) collectMcpMap(cursor.mcpServers, "cursor", "global", out, true); + + const windsurf = readJsonSafe(join(home, ".codeium", "windsurf", "mcp_config.json")); + if (windsurf) collectMcpMap(windsurf.mcpServers, "windsurf", "global", out, true); + + const gemini = readJsonSafe(join(home, ".gemini", "settings.json")); + if (gemini) collectMcpMap(gemini.mcpServers, "gemini", "global", out, true); + + const qoder = readJsonSafe(join(home, ".qoderwork", "mcp.json")); + if (qoder) collectMcpMap(qoder.mcpServers, "qoderwork", "global", out, true); + + const openclaw = readJsonSafe(join(home, ".openclaw", "openclaw.json")); + if (openclaw) collectMcpMap(openclaw.mcpServers, "openclaw", "global", out, true); + + const claudeDesktop = readJsonSafe(claudeDesktopConfigPath(home)); + if (claudeDesktop) collectMcpMap(claudeDesktop.mcpServers, "claude-desktop", "global", out, true); + + // Codex declares servers as [mcp_servers.] TOML tables. + const codexToml = readText(join(home, ".codex", "config.toml")); + if (codexToml) { + try { + const parsed = parseToml(codexToml) as Record; + collectMcpMap(parsed.mcp_servers, "codex", "global", out, false); + } catch { + /* ignore malformed toml */ + } + } + + return out.sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)); +} + +// ---- MCP write-back (edit / add / delete) ---- + +const MCP_MASK_CHAR = "\u2022"; + +/** Platform-specific Claude Desktop config path. */ +function claudeDesktopConfigPath(home: string): string { + if (process.platform === "darwin") + return join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json"); + if (process.platform === "win32") + return join( + process.env.APPDATA ?? join(home, "AppData", "Roaming"), + "Claude", + "claude_desktop_config.json", + ); + return join(home, ".config", "Claude", "claude_desktop_config.json"); +} + +interface McpWriteTarget { + file: string; + mapKey: string; + /** Claude stores project-scoped servers under projects[scope][mapKey]. */ + projectScoped: boolean; +} + +/** + * Map a (source, scope) pair to the JSON config file and map key to edit. Only + * JSON-backed sources are writable; Codex (TOML) and unknown sources return + * null so the UI keeps them read-only. + */ +function mcpWriteTarget(source: string, scope: string, home: string): McpWriteTarget | null { + if (source === "claude-code") + return { + file: join(home, ".claude.json"), + mapKey: "mcpServers", + projectScoped: scope !== "global", + }; + if (source === "qwen-code") + return { + file: join(home, ".qwen", "settings.json"), + mapKey: "mcpServers", + projectScoped: false, + }; + if (source === "opencode") + return { + file: join(home, ".config", "opencode", "opencode.json"), + mapKey: "mcp", + projectScoped: false, + }; + if (source === "cursor") + return { file: join(home, ".cursor", "mcp.json"), mapKey: "mcpServers", projectScoped: false }; + if (source === "windsurf") + return { + file: join(home, ".codeium", "windsurf", "mcp_config.json"), + mapKey: "mcpServers", + projectScoped: false, + }; + if (source === "gemini") + return { + file: join(home, ".gemini", "settings.json"), + mapKey: "mcpServers", + projectScoped: false, + }; + if (source === "qoderwork") + return { + file: join(home, ".qoderwork", "mcp.json"), + mapKey: "mcpServers", + projectScoped: false, + }; + if (source === "openclaw") + return { + file: join(home, ".openclaw", "openclaw.json"), + mapKey: "mcpServers", + projectScoped: false, + }; + if (source === "claude-desktop") + return { file: claudeDesktopConfigPath(home), mapKey: "mcpServers", projectScoped: false }; + return null; +} + +/** + * Merge a submitted config with the stored one: any string still carrying the + * mask char is treated as unchanged and restored from `stored`. Throws if a + * masked value has no stored counterpart, so bullet placeholders are never + * persisted as a real secret. + */ +function unmaskMcpConfig(submitted: unknown, stored: unknown): unknown { + if (typeof submitted === "string") { + if (submitted.includes(MCP_MASK_CHAR)) { + if (typeof stored === "string") return stored; + throw new Error( + "Replace masked values (\u2022\u2022\u2022) with the real value before saving.", + ); + } + return submitted; + } + if (Array.isArray(submitted)) { + const arr = Array.isArray(stored) ? stored : []; + return submitted.map((v, i) => unmaskMcpConfig(v, arr[i])); + } + const rec = asRecord(submitted); + if (rec) { + const storedRec = asRecord(stored) ?? {}; + const out: Record = {}; + for (const [k, v] of Object.entries(rec)) out[k] = unmaskMcpConfig(v, storedRec[k]); + return out; + } + return submitted; +} + +/** Create or update one MCP server entry, writing back to its source file. */ +export function writeMcpServer( + source: string, + scope: string, + name: string, + config: unknown, + home: string = homedir(), +): void { + const target = mcpWriteTarget(source, scope, home); + if (!target) throw new Error("This MCP source is read-only and cannot be edited here."); + const trimmed = name.trim(); + if (!trimmed) throw new Error("Server name is required."); + const cfg = asRecord(config); + if (!cfg) throw new Error("Config must be a JSON object."); + + const root = readJsonSafe(target.file) ?? {}; + let container: Record = root; + if (target.projectScoped) { + const projects = asRecord(root.projects) ?? {}; + root.projects = projects; + const proj = asRecord(projects[scope]) ?? {}; + projects[scope] = proj; + container = proj; + } + const map = asRecord(container[target.mapKey]) ?? {}; + container[target.mapKey] = map; + + map[trimmed] = unmaskMcpConfig(cfg, asRecord(map[trimmed])); + + mkdirSync(dirname(target.file), { recursive: true }); + writeFileSync(target.file, JSON.stringify(root, null, 2) + "\n", "utf-8"); +} + +/** Remove one MCP server entry from its source file. */ +export function deleteMcpServer( + source: string, + scope: string, + name: string, + home: string = homedir(), +): void { + const target = mcpWriteTarget(source, scope, home); + if (!target) throw new Error("This MCP source is read-only and cannot be edited here."); + const root = readJsonSafe(target.file); + if (!root) throw new Error("Config file not found."); + let container: Record | undefined = root; + if (target.projectScoped) { + const projects = asRecord(root.projects); + container = projects ? asRecord(projects[scope]) : undefined; + } + const map = container ? asRecord(container[target.mapKey]) : undefined; + if (!map || !(name in map)) throw new Error("Server not found: " + name); + delete map[name]; + // Don't leave an empty map behind if this was the last server. + if (container && Object.keys(map).length === 0) delete container[target.mapKey]; + writeFileSync(target.file, JSON.stringify(root, null, 2) + "\n", "utf-8"); +} + +// ---- Agent frameworks ---- + +interface AgentProbe { + id: string; + label: string; + /** Config paths that mark the agent as installed (any existing → installed). */ + paths: (home: string) => string[]; + /** Inspect config to decide whether the bailian-cli provider is wired in. */ + detect: (home: string) => { configured: boolean; model?: string }; +} + +const AGENT_PROBES: AgentProbe[] = [ + { + id: "claude-code", + label: "Claude Code", + paths: (h) => [join(h, ".claude", "settings.json"), join(h, ".claude.json")], + detect: (h) => { + const env = asRecord(readJsonSafe(join(h, ".claude", "settings.json"))?.env); + const baseUrl = + env && typeof env.ANTHROPIC_BASE_URL === "string" ? env.ANTHROPIC_BASE_URL : undefined; + const model = + env && typeof env.ANTHROPIC_MODEL === "string" ? env.ANTHROPIC_MODEL : undefined; + return { configured: Boolean(baseUrl), model }; + }, + }, + { + id: "qwen-code", + label: "Qwen Code", + paths: (h) => [join(h, ".qwen", "settings.json")], + detect: (h) => { + const settings = readJsonSafe(join(h, ".qwen", "settings.json")); + const providers = asRecord(settings?.modelProviders); + const hasBailian = providers + ? Object.values(providers).some( + (list) => Array.isArray(list) && list.some((e) => asRecord(e)?.name === "bailian-cli"), + ) + : false; + const model = asRecord(settings?.model); + return { + configured: hasBailian, + model: model && typeof model.name === "string" ? model.name : undefined, + }; + }, + }, + { + id: "opencode", + label: "OpenCode", + paths: (h) => [join(h, ".config", "opencode", "opencode.json")], + detect: (h) => { + const provider = asRecord( + readJsonSafe(join(h, ".config", "opencode", "opencode.json"))?.provider, + ); + const bailian = asRecord(provider?.["bailian-cli"]); + const models = asRecord(bailian?.models); + return { + configured: Boolean(bailian), + model: models ? Object.keys(models)[0] : undefined, + }; + }, + }, + { + id: "openclaw", + label: "OpenClaw", + paths: (h) => [join(h, ".openclaw", "openclaw.json")], + detect: (h) => { + const models = asRecord(readJsonSafe(join(h, ".openclaw", "openclaw.json"))?.models); + const providers = asRecord(models?.providers); + const bailian = asRecord(providers?.["bailian-cli"]); + const list = Array.isArray(bailian?.models) ? bailian.models : []; + const first = asRecord(list[0]); + return { + configured: Boolean(bailian), + model: first && typeof first.id === "string" ? first.id : undefined, + }; + }, + }, + { + id: "hermes", + label: "Hermes Agent", + paths: (h) => [join(h, ".hermes", "config.yaml")], + detect: (h) => { + const text = readText(join(h, ".hermes", "config.yaml")); + if (!text) return { configured: false }; + let config: Record | undefined; + try { + config = asRecord(yaml.parse(text)); + } catch { + return { configured: false }; + } + const providers = Array.isArray(config?.custom_providers) ? config.custom_providers : []; + const configured = providers.some((p) => asRecord(p)?.name === "bailian-cli"); + const model = asRecord(config?.model); + return { + configured, + model: model && typeof model.default === "string" ? model.default : undefined, + }; + }, + }, + { + id: "codex", + label: "Codex", + paths: (h) => [join(h, ".codex", "config.toml"), join(h, ".codex", "auth.json")], + detect: (h) => { + const text = readText(join(h, ".codex", "config.toml")); + if (!text) return { configured: false }; + let config: Record = {}; + try { + config = parseToml(text) as Record; + } catch { + return { configured: false }; + } + const providers = asRecord(config.model_providers); + const configured = Boolean(providers?.["bailian-cli"]); + return { + configured, + model: typeof config.model === "string" ? config.model : undefined, + }; + }, + }, +]; + +/** Report each known coding agent framework and its local config state. */ +export function listAgents(home: string = homedir()): AgentInfo[] { + return AGENT_PROBES.map((probe) => { + const paths = probe.paths(home); + const installed = paths.some((p) => existsSync(p)); + const { configured, model } = installed + ? probe.detect(home) + : { configured: false, model: undefined }; + return { + id: probe.id, + label: probe.label, + installed, + configured, + model, + paths, + origin: "local", + }; + }); +} + +/** A single displayed config field for an agent's detail view. */ +export interface AgentField { + label: string; + value: string; + /** Sensitive value: the server masks it before returning. */ + secret?: boolean; + /** Unmasked value for secrets, revealed on demand (localhost only). */ + raw?: string; +} + +/** Raw content of one of the agent's config files. */ +export interface AgentSettingsFile { + path: string; + lang: string; + text: string; +} + +/** Full detail for one agent: extracted config fields plus its config files. */ +export interface AgentDetail extends AgentInfo { + fields: AgentField[]; + files: Array<{ path: string; exists: boolean }>; + settings: AgentSettingsFile[]; +} + +function pushField(out: AgentField[], label: string, value: unknown, secret = false): void { + if (typeof value === "string" && value.trim()) out.push({ label, value: value.trim(), secret }); +} + +/** + * Extract the bailian-cli connection fields (model, base URL, credential, ...) + * an agent has written into its local config. Values are raw here; secrets are + * masked by `getAgentDetail` before leaving the server. Read-only. + */ +function agentDetailFields(id: string, home: string): AgentField[] { + const out: AgentField[] = []; + if (id === "claude-code") { + const env = asRecord(readJsonSafe(join(home, ".claude", "settings.json"))?.env); + pushField(out, "Model", env?.ANTHROPIC_MODEL); + pushField(out, "Base URL", env?.ANTHROPIC_BASE_URL); + pushField(out, "Auth Token", env?.ANTHROPIC_AUTH_TOKEN, true); + } else if (id === "qwen-code") { + const s = readJsonSafe(join(home, ".qwen", "settings.json")); + const model = asRecord(s?.model); + const auth = asRecord(asRecord(s?.security)?.auth); + const env = asRecord(s?.env); + pushField(out, "Model", model?.name); + pushField(out, "Base URL", auth?.baseUrl ?? model?.baseUrl); + pushField(out, "Protocol", auth?.selectedType); + pushField(out, "API Key", auth?.apiKey ?? env?.BAILIAN_CLI_API_KEY, true); + } else if (id === "opencode") { + const provider = asRecord( + readJsonSafe(join(home, ".config", "opencode", "opencode.json"))?.provider, + ); + const bailian = asRecord(provider?.["bailian-cli"]); + const options = asRecord(bailian?.options); + const models = asRecord(bailian?.models); + pushField(out, "Model", models ? Object.keys(models)[0] : undefined); + pushField(out, "Base URL", options?.baseURL); + pushField(out, "Provider", bailian?.npm); + pushField(out, "API Key", options?.apiKey, true); + } else if (id === "openclaw") { + const models = asRecord(readJsonSafe(join(home, ".openclaw", "openclaw.json"))?.models); + const bailian = asRecord(asRecord(models?.providers)?.["bailian-cli"]); + const list = Array.isArray(bailian?.models) ? bailian.models : []; + const first = asRecord(list[0]); + pushField(out, "Model", first?.id); + pushField(out, "Base URL", bailian?.baseUrl); + pushField(out, "API", bailian?.api); + pushField(out, "API Key", bailian?.apiKey, true); + } else if (id === "hermes") { + const text = readText(join(home, ".hermes", "config.yaml")); + let config: Record | undefined; + if (text) { + try { + config = asRecord(yaml.parse(text)); + } catch { + config = undefined; + } + } + const providers = Array.isArray(config?.custom_providers) ? config.custom_providers : []; + const pr = asRecord(providers.find((e: unknown) => asRecord(e)?.name === "bailian-cli")); + const list = Array.isArray(pr?.models) ? pr.models : []; + const first = asRecord(list[0]); + pushField(out, "Model", first?.id ?? asRecord(config?.model)?.default); + pushField(out, "Base URL", pr?.base_url); + pushField(out, "API Mode", pr?.api_mode); + pushField(out, "API Key", pr?.api_key, true); + } else if (id === "codex") { + const text = readText(join(home, ".codex", "config.toml")); + let config: Record = {}; + if (text) { + try { + config = parseToml(text) as Record; + } catch { + config = {}; + } + } + const bailian = asRecord(asRecord(config.model_providers)?.["bailian-cli"]); + const auth = readJsonSafe(join(home, ".codex", "auth.json")); + pushField(out, "Model", config.model); + pushField(out, "Base URL", bailian?.base_url); + pushField(out, "Wire API", bailian?.wire_api); + pushField(out, "API Key", auth?.OPENAI_API_KEY, true); + } + return out; +} + +/** Mask a secret so its presence is visible but the value is not disclosed. */ +function maskSecret(value: string): string { + const v = value.trim(); + if (v.length <= 8) return "\u2022".repeat(Math.max(v.length, 4)); + return v.slice(0, 3) + "\u2022".repeat(Math.min(v.length - 3, 16)); +} + +/** Config keys whose string value is treated as a secret and masked. */ +const SECRET_KEY_RE = /key|token|secret|password|passwd|auth|credential/i; + +/** + * Deep-clone a config entry, masking any string value whose key name looks + * like a secret (API keys, tokens, Authorization headers, ...). Empty strings + * are left untouched so placeholders like "AMAP_MAPS_API_KEY": "" stay visible. + */ +function maskConfigSecrets(value: unknown, keyName = ""): unknown { + if (typeof value === "string") { + return keyName && SECRET_KEY_RE.test(keyName) && value.trim() ? maskSecret(value) : value; + } + if (Array.isArray(value)) return value.map((v) => maskConfigSecrets(v)); + const rec = asRecord(value); + if (rec) { + const out: Record = {}; + for (const [k, v] of Object.entries(rec)) out[k] = maskConfigSecrets(v, k); + return out; + } + return value; +} + +/** Guess a syntax-highlight language for a config file from its extension. */ +function langForPath(p: string): string { + if (p.endsWith(".json")) return "json"; + if (p.endsWith(".yaml") || p.endsWith(".yml")) return "yaml"; + if (p.endsWith(".toml")) return "toml"; + return "text"; +} + +/** + * Full detail for one coding agent by id: normalized config fields plus its + * config file list and raw file contents. Secret values (API keys/tokens) are + * masked in `fields` but their unmasked form is included as `raw` for on-demand + * reveal; raw file contents are returned verbatim. Localhost-only. Returns null + * for an unknown id. + */ +export function getAgentDetail(id: string, home: string = homedir()): AgentDetail | null { + const probe = AGENT_PROBES.find((p) => p.id === id); + if (!probe) return null; + const paths = probe.paths(home); + const installed = paths.some((p) => existsSync(p)); + const { configured, model } = installed + ? probe.detect(home) + : { configured: false, model: undefined }; + const fields = installed + ? agentDetailFields(id, home).map((f) => + f.secret ? { ...f, raw: f.value, value: maskSecret(f.value) } : f, + ) + : []; + const files = paths.map((p) => ({ path: p, exists: existsSync(p) })); + const settings: AgentSettingsFile[] = installed + ? paths + .filter((p) => existsSync(p)) + .map((p) => ({ path: p, lang: langForPath(p), text: readText(p) ?? "" })) + .filter((s) => s.text.trim()) + : []; + return { + id: probe.id, + label: probe.label, + installed, + configured, + model, + paths, + origin: "local", + fields, + files, + settings, + }; +} diff --git a/packages/commands/src/commands/config/qr.ts b/packages/commands/src/commands/config/qr.ts new file mode 100644 index 00000000..10fe90f3 --- /dev/null +++ b/packages/commands/src/commands/config/qr.ts @@ -0,0 +1,353 @@ +/** + * Minimal, dependency-free QR Code encoder used by the config UI to show a + * scannable code for the current session URL. + * + * Scope is deliberately narrow: byte mode, error-correction level L, versions + * 1–5 (21x21 … 37x37). Restricting to level L keeps every supported version a + * single Reed–Solomon block, so no codeword interleaving is required. Version 5 + * (level L) holds up to 108 data bytes, comfortably more than a + * `http://127.0.0.1:/?token=` URL. + * + * The output is an SVG string with a 4-module quiet zone and a `viewBox` only + * (no fixed width/height), so the caller sizes it via CSS. + */ + +// --- GF(256) arithmetic (primitive polynomial 0x11D) --- + +const EXP = new Uint8Array(512); +const LOG = new Uint8Array(256); +(() => { + let x = 1; + for (let i = 0; i < 255; i++) { + EXP[i] = x; + LOG[x] = i; + x <<= 1; + if (x & 0x100) x ^= 0x11d; + } + for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255]; +})(); + +function gmul(a: number, b: number): number { + if (a === 0 || b === 0) return 0; + return EXP[LOG[a] + LOG[b]]; +} + +/** Reed–Solomon generator polynomial for `degree` EC codewords (alpha exponents). */ +export function rsGeneratorExp(degree: number): number[] { + let poly = [1]; + for (let i = 0; i < degree; i++) { + const next: number[] = Array.from({ length: poly.length + 1 }, () => 0); + for (let j = 0; j < poly.length; j++) { + next[j] ^= poly[j]; + next[j + 1] ^= gmul(poly[j], EXP[i]); + } + poly = next; + } + return poly.map((v) => LOG[v]); +} + +/** Compute `ecLen` Reed–Solomon error-correction codewords for `data`. */ +export function rsEncode(data: number[], ecLen: number): number[] { + const gen = rsGeneratorExp(ecLen); + const res = new Uint8Array(data.length + ecLen); + res.set(data, 0); + for (let i = 0; i < data.length; i++) { + const coef = res[i]; + if (coef !== 0) { + const lead = LOG[coef]; + for (let j = 0; j < gen.length; j++) res[i + j] ^= EXP[(gen[j] + lead) % 255]; + } + } + return Array.from(res.slice(data.length)); +} + +// --- Capacity table: [data codewords, EC codewords] per version at level L --- + +const CAP_L: Array<[number, number]> = [ + [19, 7], // V1 (21x21) + [34, 10], // V2 (25x25) + [55, 15], // V3 (29x29) + [80, 20], // V4 (33x33) + [108, 26], // V5 (37x37) +]; + +const EC_BITS_L = 0b01; // format-info error-correction level bits for L + +function pickVersion(byteLen: number): number { + const bits = 4 + 8 + byteLen * 8; // mode + 8-bit count (V1–9) + payload + for (let v = 0; v < CAP_L.length; v++) { + if (CAP_L[v][0] * 8 >= bits) return v + 1; + } + throw new Error("qr: data too large for supported versions (max 108 bytes)"); +} + +// --- Bit/codeword assembly --- + +function toCodewords(bytes: Uint8Array, version: number): number[] { + const [dataCw] = CAP_L[version - 1]; + const bits: number[] = []; + const put = (val: number, len: number) => { + for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1); + }; + put(0b0100, 4); // byte mode + put(bytes.length, 8); // character count (versions 1–9) + for (const b of bytes) put(b, 8); + + const capBits = dataCw * 8; + put(0, Math.min(4, capBits - bits.length)); // terminator + while (bits.length % 8 !== 0) bits.push(0); // pad to byte + + const data: number[] = []; + for (let i = 0; i < bits.length; i += 8) { + let v = 0; + for (let j = 0; j < 8; j++) v = (v << 1) | bits[i + j]; + data.push(v); + } + const pads = [0xec, 0x11]; + for (let p = 0; data.length < dataCw; p++) data.push(pads[p % 2]); + + return data.concat(rsEncode(data, CAP_L[version - 1][1])); +} + +// --- Matrix construction --- + +interface Grid { + size: number; + mod: Uint8Array; // 0/1 + fn: Uint8Array; // 1 = function/reserved module (skip during data placement) +} + +function newGrid(size: number): Grid { + return { size, mod: new Uint8Array(size * size), fn: new Uint8Array(size * size) }; +} + +function setFn(g: Grid, r: number, c: number, dark: number): void { + g.mod[r * g.size + c] = dark; + g.fn[r * g.size + c] = 1; +} + +function drawFinder(g: Grid, r: number, c: number): void { + for (let dr = -1; dr <= 7; dr++) { + for (let dc = -1; dc <= 7; dc++) { + const rr = r + dr; + const cc = c + dc; + if (rr < 0 || rr >= g.size || cc < 0 || cc >= g.size) continue; + const inRing = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6; + const isDark = + inRing && + (dr === 0 || + dr === 6 || + dc === 0 || + dc === 6 || + (dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4)); + setFn(g, rr, cc, isDark ? 1 : 0); + } + } +} + +function drawAlignment(g: Grid, cr: number, cc: number): void { + for (let dr = -2; dr <= 2; dr++) { + for (let dc = -2; dc <= 2; dc++) { + const ring = Math.max(Math.abs(dr), Math.abs(dc)); + setFn(g, cr + dr, cc + dc, ring === 1 ? 0 : 1); + } + } +} + +function drawFunctionPatterns(g: Grid, version: number): void { + const size = g.size; + // Timing patterns. + for (let i = 0; i < size; i++) { + setFn(g, 6, i, i % 2 === 0 ? 1 : 0); + setFn(g, i, 6, i % 2 === 0 ? 1 : 0); + } + // Finder patterns + separators (drawn as the -1 border above). + drawFinder(g, 0, 0); + drawFinder(g, 0, size - 7); + drawFinder(g, size - 7, 0); + // Alignment pattern (single, centered) for versions 2–5. + if (version >= 2) { + const pos = size - 7; // e.g. 18 (V2), 22 (V3), 26 (V4), 30 (V5) + drawAlignment(g, pos, pos); + } + // Reserve format-info areas (values written later). + for (let i = 0; i < 9; i++) { + if (!(i === 6)) g.fn[8 * size + i] = 1; + if (!(i === 6)) g.fn[i * size + 8] = 1; + } + g.fn[8 * size + 6] = 1; + g.fn[6 * size + 8] = 1; + for (let i = 0; i < 8; i++) g.fn[(size - 1 - i) * size + 8] = 1; + for (let i = 0; i < 8; i++) g.fn[8 * size + (size - 1 - i)] = 1; + // Dark module. + setFn(g, size - 8, 8, 1); +} + +function placeData(g: Grid, codewords: number[]): void { + const size = g.size; + const stream: number[] = []; + for (const cw of codewords) for (let i = 7; i >= 0; i--) stream.push((cw >> i) & 1); + let idx = 0; + let upward = true; + for (let col = size - 1; col >= 1; col -= 2) { + if (col === 6) col = 5; // skip the vertical timing column + for (let i = 0; i < size; i++) { + const row = upward ? size - 1 - i : i; + for (const off of [0, 1]) { + const cc = col - off; + if (g.fn[row * size + cc]) continue; + g.mod[row * size + cc] = idx < stream.length ? stream[idx++] : 0; + } + } + upward = !upward; + } +} + +const MASKS: Array<(r: number, c: number) => boolean> = [ + (r, c) => (r + c) % 2 === 0, + (r) => r % 2 === 0, + (_r, c) => c % 3 === 0, + (r, c) => (r + c) % 3 === 0, + (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0, + (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0, + (r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0, + (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0, +]; + +function applyMask(g: Grid, mask: number): void { + const cond = MASKS[mask]; + for (let r = 0; r < g.size; r++) { + for (let c = 0; c < g.size; c++) { + if (!g.fn[r * g.size + c] && cond(r, c)) g.mod[r * g.size + c] ^= 1; + } + } +} + +function penalty(g: Grid): number { + const size = g.size; + const at = (r: number, c: number) => g.mod[r * size + c]; + let score = 0; + // Rule 1: runs of >=5 same-color modules in rows and columns. + for (let r = 0; r < size; r++) { + let runC = 1; + let runR = 1; + for (let c = 1; c < size; c++) { + if (at(r, c) === at(r, c - 1)) runC++; + else { + if (runC >= 5) score += runC - 2; + runC = 1; + } + if (at(c, r) === at(c - 1, r)) runR++; + else { + if (runR >= 5) score += runR - 2; + runR = 1; + } + } + if (runC >= 5) score += runC - 2; + if (runR >= 5) score += runR - 2; + } + // Rule 2: 2x2 blocks of the same color. + for (let r = 0; r < size - 1; r++) { + for (let c = 0; c < size - 1; c++) { + const v = at(r, c); + if (v === at(r, c + 1) && v === at(r + 1, c) && v === at(r + 1, c + 1)) score += 3; + } + } + // Rule 3: finder-like 1:1:3:1:1 patterns. + const pat1 = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0]; + const pat2 = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1]; + const match = (get: (k: number) => number, start: number, pat: number[]) => { + for (let k = 0; k < pat.length; k++) if (get(start + k) !== pat[k]) return false; + return true; + }; + for (let r = 0; r < size; r++) { + for (let c = 0; c <= size - 11; c++) { + if (match((k) => at(r, k), c, pat1) || match((k) => at(r, k), c, pat2)) score += 40; + if (match((k) => at(k, r), c, pat1) || match((k) => at(k, r), c, pat2)) score += 40; + } + } + // Rule 4: proportion of dark modules. + let dark = 0; + for (let i = 0; i < size * size; i++) dark += g.mod[i]; + const percent = (dark * 100) / (size * size); + const k = Math.floor(Math.abs(percent - 50) / 5); + score += k * 10; + return score; +} + +function formatBits(mask: number): number { + const data = (EC_BITS_L << 3) | mask; // 5 bits + let rem = data << 10; + for (let i = 14; i >= 10; i--) if ((rem >> i) & 1) rem ^= 0x537 << (i - 10); + return ((data << 10) | rem) ^ 0x5412; +} + +function drawFormat(g: Grid, mask: number): void { + const size = g.size; + const fmt = formatBits(mask); + const bit = (i: number) => (fmt >> i) & 1; + // First copy: around the top-left finder. Bits 0–5 run down column 8 + // (rows 0–5); bits 9–14 run left along row 8 (cols 5–0). + for (let i = 0; i <= 5; i++) g.mod[i * size + 8] = bit(i); + g.mod[7 * size + 8] = bit(6); + g.mod[8 * size + 8] = bit(7); + g.mod[8 * size + 7] = bit(8); + for (let i = 9; i < 15; i++) g.mod[8 * size + (14 - i)] = bit(i); + // Second copy: split across top-right and bottom-left. + for (let i = 0; i < 8; i++) g.mod[(size - 1 - i) * size + 8] = bit(i); + for (let i = 8; i < 15; i++) g.mod[8 * size + (size - 15 + i)] = bit(i); + g.mod[(size - 8) * size + 8] = 1; // dark module stays set +} + +/** Build the final QR module matrix (true = dark) for `text`. */ +export function qrMatrix(text: string): boolean[][] { + const bytes = new TextEncoder().encode(text); + const version = pickVersion(bytes.length); + const codewords = toCodewords(bytes, version); + const g = newGrid(17 + 4 * version); + drawFunctionPatterns(g, version); + placeData(g, codewords); + + let best = 0; + let bestScore = Infinity; + for (let m = 0; m < 8; m++) { + applyMask(g, m); + drawFormat(g, m); + const s = penalty(g); + if (s < bestScore) { + bestScore = s; + best = m; + } + applyMask(g, m); // undo (XOR is its own inverse) + } + applyMask(g, best); + drawFormat(g, best); + + const out: boolean[][] = []; + for (let r = 0; r < g.size; r++) { + const row: boolean[] = []; + for (let c = 0; c < g.size; c++) row.push(g.mod[r * g.size + c] === 1); + out.push(row); + } + return out; +} + +/** Render `text` as an SVG QR code string (4-module quiet zone, viewBox only). */ +export function qrSvg(text: string): string { + const m = qrMatrix(text); + const size = m.length; + const quiet = 4; + const dim = size + quiet * 2; + let rects = ""; + for (let r = 0; r < size; r++) { + for (let c = 0; c < size; c++) { + if (m[r][c]) rects += ``; + } + } + return ( + `` + + `` + + `${rects}` + ); +} diff --git a/packages/commands/src/commands/config/scenarios.ts b/packages/commands/src/commands/config/scenarios.ts new file mode 100644 index 00000000..c4586903 --- /dev/null +++ b/packages/commands/src/commands/config/scenarios.ts @@ -0,0 +1,166 @@ +/** + * Curated "Playground" scenarios surfaced in the config UI. + * + * Each scenario is a fixed, reviewable prompt template that the UI can dispatch + * to a connected local coding agent (e.g. qwen-code), which then runs it in a + * new terminal. Optional `{{inputs}}` are filled by the user before dispatch. + * + * Prompts are defined here and never accepted as free-form text from the web, + * so the instruction handed to a local agent is always known and auditable. + */ +export interface ScenarioInput { + key: string; + label: string; + placeholder?: string; +} + +export interface Scenario { + id: string; + title: string; + description: string; + category: string; + prompt: string; + inputs?: ScenarioInput[]; +} + +export const SCENARIOS: Scenario[] = [ + // ---- 图像 ---- + { + id: "image-generate", + title: "文生图", + description: "一键生成一张示例图片并保存到输出目录。", + category: "图像", + prompt: + "请使用 bl 的图像生成能力(如 `bl image generate` 命令)生成一张示例图片:一只在雨中撑伞的柯基,水彩风格,光线柔和。保存到输出目录后告诉我文件路径。", + }, + { + id: "image-describe", + title: "图片理解", + description: "从输出目录任选一张图片,详细描述内容与风格。", + category: "图像", + prompt: + "请在输出目录(默认 output/images)中任选一张图片,用中文详细描述它的内容、主体、构图、色彩与风格,并推测它适合的使用场景。若目录为空请说明。", + }, + { + id: "image-alt-batch", + title: "批量 Alt 文本", + description: "为输出目录下的图片批量生成无障碍 alt 文本。", + category: "图像", + prompt: + "请扫描输出目录(默认 output/images)下的所有图片,逐张生成简洁、准确的 alt 无障碍描述,最后以「文件名 → alt 文本」的表格汇总。若目录为空请说明。", + }, + { + id: "image-to-code", + title: "截图转代码", + description: "把输出目录里的界面截图还原成 HTML+CSS。", + category: "图像", + prompt: + "请在输出目录(默认 output/images)中查找一张界面截图,用 HTML + CSS 尽可能还原它的布局、间距与配色,输出为一个可直接在浏览器打开的单文件,并简述还原思路。若没有找到截图请说明。", + }, + // ---- 音频 ---- + { + id: "speech-generate", + title: "文字转语音", + description: "把一句示例文字合成为自然语音。", + category: "音频", + prompt: + "请使用 bl 的语音合成能力(如 `bl speech` 相关命令)把下面这句话合成为自然语音,保存到输出目录,并告诉我音频文件路径:欢迎使用阿里云百炼命令行工具,让多模态创作更简单。", + }, + { + id: "audio-summarize", + title: "音频转写总结", + description: "转写输出目录里的音频并提炼要点。", + category: "音频", + prompt: + "请在输出目录(默认 output/speech)中找到一个音频文件,转写其内容,先给出完整文字,再用要点列表总结关键信息。若目录为空或缺少转写能力,请说明并尝试用可用的能力完成。", + }, + // ---- 视频 ---- + { + id: "video-generate", + title: "文生视频", + description: "一键生成一段示例短视频。", + category: "视频", + prompt: + "请使用 bl 的视频生成能力(如 `bl video generate` 命令)生成一段示例短视频:日落时分海边奔跑的少年,电影质感,慢动作。保存到输出目录后告诉我视频文件路径。", + }, + { + id: "video-storyboard", + title: "视频分镜脚本", + description: "围绕示例主题产出可用于文生视频的分镜。", + category: "视频", + prompt: + "围绕主题「城市清晨的第一杯咖啡」,为一支 15-30 秒的短视频撰写分镜脚本:逐镜头给出画面描述、时长、字幕或旁白,并为每个镜头附上可直接用于文生视频的英文 prompt。", + }, + // ---- 多模态 ---- + { + id: "media-prompt-craft", + title: "多模态提示词", + description: "把一个示例创意扩展成图/视频/语音提示词。", + category: "多模态", + prompt: + "把创意「未来赛博城市的夜市」扩展成三组高质量生成提示词:1) 文生图;2) 文生视频;3) 语音风格描述。每组给出中英对照,并简要说明关键参数建议。", + }, + { + id: "image-story-narration", + title: "图片配音文案", + description: "为输出目录里的图片写解说词并给出可合成文本。", + category: "多模态", + prompt: + "请在输出目录(默认 output/images)中任选一张图片,为它撰写一段 60 秒左右的中文解说词(适合配音),语气生动。随后给出可直接用于语音合成的纯文本版本。若目录为空请说明。", + }, + // ---- 代码 ---- + { + id: "summarize-project", + title: "总结当前项目", + description: "让 agent 阅读当前目录,总结架构、技术栈与主要模块。", + category: "代码", + prompt: + "请阅读当前工作目录的项目结构和关键源码,用简洁的中文总结:1) 它是做什么的;2) 技术栈;3) 主要模块及其职责;4) 值得注意的设计。先浏览再下结论,不要臆测。", + }, + { + id: "write-tests", + title: "为核心模块写单测", + description: "自动挑选缺测试的核心模块并补全单元测试。", + category: "代码", + prompt: + "请在当前项目中挑选一个核心且缺少测试(或测试薄弱)的模块,为它编写全面的单元测试,覆盖主要逻辑分支和边界情况,并遵循本项目现有的测试框架与风格。先阅读相关文件及其依赖,再编写测试。", + }, + { + id: "code-review", + title: "代码审查", + description: "审查当前项目核心代码,指出问题与改进建议。", + category: "代码", + prompt: + "请审查当前项目的核心源码,指出潜在的 bug、安全隐患、性能与可维护性问题,并给出具体、可操作的改进建议,按严重程度排序。先浏览项目结构,选取关键文件再审查。", + }, + { + id: "explain-code", + title: "解释核心代码", + description: "挑选入口或核心模块,解释其实现与依赖。", + category: "代码", + prompt: + "请挑选当前项目的入口文件或核心模块,解释它的实现:职责是什么、关键流程如何运转、依赖了哪些模块。用清晰的中文说明,必要时给出调用关系。", + }, + // ---- 文档 ---- + { + id: "generate-readme", + title: "生成 README", + description: "阅读代码后生成结构清晰、与实现一致的 README.md。", + category: "文档", + prompt: + "为当前工作目录的项目生成一个结构清晰的 README.md,包含:项目简介、安装步骤、使用示例、目录结构说明。请先阅读现有代码与配置再撰写,内容必须与实际实现一致。", + }, +]; + +/** Look up a scenario by id, or undefined when unknown. */ +export function getScenario(id: string): Scenario | undefined { + return SCENARIOS.find((s) => s.id === id); +} + +/** Fill a scenario's `{{placeholder}}` tokens from user-provided values. */ +export function renderScenarioPrompt(scenario: Scenario, values: Record): string { + return scenario.prompt.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => { + const v = values[key]; + return typeof v === "string" ? v.trim() : ""; + }); +} diff --git a/packages/commands/src/commands/config/shared.ts b/packages/commands/src/commands/config/shared.ts index 7ff612d2..96651d0d 100644 --- a/packages/commands/src/commands/config/shared.ts +++ b/packages/commands/src/commands/config/shared.ts @@ -30,6 +30,79 @@ export const SECRET_KEYS = new Set([ "security_token", ]); +// The web UI edits the full ConfigFile, so it exposes these extra keys on top +// of VALID_KEYS (which `config set` keeps as its narrower, documented surface). +// This lets `config ui` surface and edit every field that lives in config.json +// rather than silently hiding console/telemetry settings. +export const UI_EXTRA_KEYS = [ + "console_site", + "console_region", + "console_switch_agent", + "telemetry", +] as const; + +export const UI_VALID_KEYS = [...VALID_KEYS, ...UI_EXTRA_KEYS] as const; + +// Keys the UI renders as a fixed-choice dropdown instead of a free-text input. +export const UI_ENUM_KEYS: Record = { + output: ["text", "json"], + console_site: ["domestic", "international"], +}; + +// Keys the UI renders as a true/false dropdown and stores as a boolean. +export const UI_BOOLEAN_KEYS = new Set(["telemetry"]); + +// Default model each `default_*_model` key falls back to when left unset. These +// mirror the inline `|| ""` fallbacks in the generation commands +// (text/chat, image/generate, video/generate, speech/synthesize, omni/chat) and +// are surfaced as input placeholders so users can see the effective default +// without persisting a value that would pin the model. +export const UI_MODEL_DEFAULTS: Record = { + default_text_model: "qwen3.7-max", + default_image_model: "qwen-image-2.0", + default_video_model: "happyhorse-1.1-t2v", + default_speech_model: "cosyvoice-v3-flash", + default_omni_model: "qwen3.5-omni-plus", +}; + +/** One selectable model plus a short note on where the CLI uses it. */ +export interface ModelOption { + id: string; + role: string; +} + +// A per-category catalog of the model names the `bl` pipeline actually +// references (packages/runtime/src/pipeline/steps/bl-api.ts, plus the advisor +// and agent-writer helpers). The UI groups these under each `default_*_model` +// field as click-to-fill suggestions; the first entry is the fallback default. +// Only names present in the codebase are listed here — no invented models. +export const UI_MODEL_CATALOG: Record = { + default_text_model: [ + { id: "qwen3.7-max", role: "text/chat default" }, + { id: "qwen3-coder-plus", role: "coding-oriented (agent config)" }, + { id: "qwen-flash", role: "fast · advisor ranking" }, + { id: "qwen3.6-flash", role: "fast · advisor intent" }, + ], + default_image_model: [ + { id: "qwen-image-2.0", role: "image/generate default · sync" }, + { id: "qwen-image-max", role: "image/generate · sync" }, + { id: "qwen-image-edit-2.0", role: "image/edit · sync" }, + { id: "wanx2.x", role: "image/generate · async series" }, + ], + default_video_model: [ + { id: "happyhorse-1.1-t2v", role: "video/generate default · text-to-video" }, + { id: "happyhorse-1.1-i2v", role: "video/generate · image-to-video" }, + ], + default_speech_model: [ + { id: "cosyvoice-v3-flash", role: "speech/synthesize (TTS) default" }, + { id: "fun-asr", role: "speech/recognize (ASR)" }, + ], + default_omni_model: [ + { id: "qwen3.5-omni-plus", role: "omni/chat default" }, + { id: "qwen3-vl-plus", role: "vision/describe · multimodal input" }, + ], +}; + // Allow hyphen-style keys (e.g. default-text-model → default_text_model). export const KEY_ALIASES: Record = { "base-url": "base_url", @@ -88,3 +161,55 @@ export function validateAndCoerce(key: string, value: string): string | number { return value; } + +/** + * Validate/coerce a value for the wider set of keys the web UI can edit + * (UI_VALID_KEYS). Standard keys delegate to `validateAndCoerce`; the UI-only + * extras (console_*, telemetry) are validated here. Booleans are returned as + * real booleans so they persist correctly in config.json. + */ +export function validateAndCoerceUi(key: string, value: string): string | number | boolean { + const resolvedKey = resolveKey(key); + + if ((VALID_KEYS as readonly string[]).includes(resolvedKey)) { + return validateAndCoerce(key, value); + } + + if (resolvedKey === "console_site") { + if (!["domestic", "international"].includes(value)) { + throw new BailianError( + `Invalid console_site "${value}". Valid values: domestic, international`, + ExitCode.USAGE, + ); + } + return value; + } + + if (resolvedKey === "console_region") return value; + + if (resolvedKey === "console_switch_agent") { + const num = Number(value); + if (!Number.isFinite(num) || num <= 0) { + throw new BailianError( + `Invalid console_switch_agent "${value}". Must be a positive number.`, + ExitCode.USAGE, + ); + } + return num; + } + + if (resolvedKey === "telemetry") { + if (value !== "true" && value !== "false") { + throw new BailianError( + `Invalid telemetry "${value}". Valid values: true, false`, + ExitCode.USAGE, + ); + } + return value === "true"; + } + + throw new BailianError( + `Invalid config key "${key}". Valid keys: ${UI_VALID_KEYS.join(", ")}`, + ExitCode.USAGE, + ); +} diff --git a/packages/commands/src/commands/config/ui-html.ts b/packages/commands/src/commands/config/ui-html.ts index 1bcbf8c8..6f9a04a2 100644 --- a/packages/commands/src/commands/config/ui-html.ts +++ b/packages/commands/src/commands/config/ui-html.ts @@ -1,107 +1,1136 @@ -// Self-contained single-page web UI for managing config profiles. Served as a -// string by `config ui`; no build step, no client dependencies. All fetches -// carry the session token from the page URL. +// Self-contained single-page web UI for managing config profiles and viewing +// locally installed agent tooling (skills, MCP servers, coding agents). Served +// as a string by `config ui`; no build step, no client dependencies. All +// fetches carry the session token from the page URL. Visual language mirrors +// the bailian landing design system (Inter / Geist Mono, gradient accents, +// lift-on-hover cards). export const PAGE_HTML = ` -bailian-cli config +Config - Alibaba Cloud Model Studio CLI + + +
+ -
-
-

- +
+
+
+

Quick Start

+

Complete a few steps to check your local environment and finish first-time setup: sign in to Bailian, connect a local coding agent, and run your first task. Each circle turns from gray to green as you go.

+
+
Loading…
+
+
+
+

Playground

+

Pick a scenario to dispatch a preset task to a connected local coding agent, running in a new terminal. Tasks run in the directory where bl config ui was started.

+
+
+
+
Loading…
+
+
+ +
+
+

Config

+

Credentials and default models. The active profile (marked with a star) is used by every bl command. Click a profile to edit its settings.

+
+
+
+ +
+
+

Installed Skills

+

Agent skills discovered across every local agent module (~/.agents/skills plus each agent's skills folder). Installed via npx skills add.

+
+
+
Loading…
+
+
+ +
+
+

MCPs

+

Model Context Protocol servers declared in your local coding-agent configs.

+
+
+
Loading…
+
+
+ +
+
+

Coding Agents

+

Frameworks bl can configure. "Connected" means the bailian-cli provider is wired into that agent.

+
+
+
+
Loading…
+
+
+ +
+
+

Generated Assets

+

Media that bl writes into the output directory, grouped by type (images, videos, audio, files) and generation time.

+
+
+
+
Loading…
+
+
+
+ + + +
+
+ diff --git a/packages/commands/src/commands/config/ui.ts b/packages/commands/src/commands/config/ui.ts index 2c10c68e..5a12de0d 100644 --- a/packages/commands/src/commands/config/ui.ts +++ b/packages/commands/src/commands/config/ui.ts @@ -1,5 +1,7 @@ import http from "node:http"; -import { randomBytes } from "node:crypto"; +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createReadStream, existsSync, statSync, unlinkSync } from "node:fs"; +import { extname } from "node:path"; import { defineCommand, @@ -10,13 +12,38 @@ import { readConfigFile, writeConfigFile, deleteConfigProfile, + REGIONS, type ConfigStore, type FlagsDef, } from "bailian-cli-core"; import { emitResult, emitBare } from "bailian-cli-runtime"; -import { listenLocalServer, openInBrowser } from "../shared/local-server.ts"; +import { listenLocalServer, openInBrowser, openPath } from "../shared/local-server.ts"; import { PAGE_HTML } from "./ui-html.ts"; -import { VALID_KEYS, SECRET_KEYS, resolveKey, validateAndCoerce } from "./shared.ts"; +import { + UI_VALID_KEYS, + UI_ENUM_KEYS, + UI_BOOLEAN_KEYS, + UI_MODEL_DEFAULTS, + UI_MODEL_CATALOG, + SECRET_KEYS, + resolveKey, + validateAndCoerceUi, +} from "./shared.ts"; +import { + listSkills, + listMcpServers, + listAgents, + getSkillDetail, + getAgentDetail, + writeMcpServer, + deleteMcpServer, + installSkillZip, +} from "./inventory.ts"; +import { launchAgent, agentLaunchable, agentSupportsPrompt } from "./agent-launch.ts"; +import { SCENARIOS, getScenario, renderScenarioPrompt, type Scenario } from "./scenarios.ts"; +import { qrSvg } from "./qr.ts"; +import { makeAuthUiBridge, type AuthUiBridge } from "../auth/console-ui.ts"; +import { listAssets, resolveAssetPath, defaultOutputBase, contentType } from "./assets.ts"; const FLAGS = { port: { @@ -50,6 +77,7 @@ function readBody(req: http.IncomingMessage): Promise { size += chunk.length; if (size > MAX_BODY) { reject(new Error("payload too large")); + req.destroy(); return; } chunks.push(chunk); @@ -59,16 +87,47 @@ function readBody(req: http.IncomingMessage): Promise { }); } +/** Max size for binary uploads (skill .zip packages). */ +const MAX_UPLOAD = 24 * (1 << 20); // 24 MiB + +function readBodyBuffer(req: http.IncomingMessage, max: number): Promise { + return new Promise((resolve, reject) => { + let size = 0; + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > max) { + reject(new Error("payload too large")); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +/** Constant-time token comparison (avoids timing side channels). */ +function tokenMatches(provided: string | null, expected: string): boolean { + if (!provided) return false; + const a = Buffer.from(provided); + const b = Buffer.from(expected); + return a.length === b.length && timingSafeEqual(a, b); +} + /** Build the request cleaned/validated config block from a posted `data` map. */ -function buildProfilePatch(data: Record): Record { - const cleaned: Record = {}; +function buildProfilePatch( + data: Record, +): Record { + const cleaned: Record = {}; for (const [k, v] of Object.entries(data)) { let value = ""; if (typeof v === "string") value = v; else if (typeof v === "number" || typeof v === "boolean") value = String(v); // null/undefined/objects fall through as "" and clear the key if (value === "") continue; - cleaned[resolveKey(k)] = validateAndCoerce(k, value); + cleaned[resolveKey(k)] = validateAndCoerceUi(k, value); } return cleaned; } @@ -76,9 +135,9 @@ function buildProfilePatch(data: Record): Record, - managedPatch: Record, + managedPatch: Record, ): Record { - const managedKeys = new Set(VALID_KEYS); + const managedKeys = new Set(UI_VALID_KEYS); const merged: Record = {}; for (const [key, value] of Object.entries(existing)) { if (!managedKeys.has(key)) merged[key] = value; @@ -91,7 +150,12 @@ function mergeUnmanagedProfileFields( * - Host header must be a loopback name (anti DNS-rebinding). * - every request must carry `?token=` matching the session token. */ -export function createConfigUiServer(token: string, configStore: ConfigStore): http.Server { +export function createConfigUiServer( + token: string, + configStore: ConfigStore, + outputBase: string = defaultOutputBase(), + authBridge?: AuthUiBridge, +): http.Server { return http.createServer(async (req, res) => { try { const host = (req.headers.host || "").split(":")[0]; @@ -102,7 +166,7 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h } const u = new URL(req.url ?? "/", "http://127.0.0.1"); - if (u.searchParams.get("token") !== token) { + if (!tokenMatches(u.searchParams.get("token"), token)) { res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" }); res.end("unauthorized\n"); return; @@ -112,17 +176,54 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h const path = u.pathname; if (path === "/" && method === "GET") { - res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + // The page URL carries the session token, so never cache it. + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Content-Security-Policy": + "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + + "img-src 'self' data: https://img.alicdn.com https://oss.aliyuncs.com; " + + "media-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'", + }); res.end(PAGE_HTML); return; } + if (path === "/api/qr" && method === "GET") { + const data = (u.searchParams.get("data") ?? "").slice(0, 512); + if (!data) { + sendJson(res, 400, { error: "missing data" }); + return; + } + try { + const svg = qrSvg(data); + res.writeHead(200, { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(svg); + } catch (err) { + sendJson(res, 400, { error: errMessage(err) }); + } + return; + } + if (path === "/api/config" && method === "GET") { const profiles = configStore.profiles(); sendJson(res, 200, { configFile: configStore.path, - keys: VALID_KEYS, + keys: UI_VALID_KEYS, secretKeys: [...SECRET_KEYS], + enums: UI_ENUM_KEYS, + booleanKeys: [...UI_BOOLEAN_KEYS], + fieldDefaults: { + ...UI_MODEL_DEFAULTS, + base_url: REGIONS.cn, + output_dir: defaultOutputBase(), + timeout: "300", + }, + modelCatalog: UI_MODEL_CATALOG, activeProfile: profiles.active, default: profiles.default, named: profiles.named, @@ -130,6 +231,342 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h return; } + if (path === "/api/skills" && method === "GET") { + sendJson(res, 200, { skills: listSkills() }); + return; + } + + if (path === "/api/skill" && method === "GET") { + const detail = getSkillDetail(u.searchParams.get("id") ?? ""); + if (!detail) { + sendJson(res, 404, { error: "not found" }); + return; + } + sendJson(res, 200, detail); + return; + } + + if (path === "/api/skill/install" && method === "POST") { + const source = u.searchParams.get("source") ?? ""; + const name = u.searchParams.get("name") ?? ""; + try { + const buf = await readBodyBuffer(req, MAX_UPLOAD); + const result = installSkillZip(source, buf, name); + sendJson(res, 200, result); + } catch (err) { + sendJson(res, 400, { error: errMessage(err) }); + } + return; + } + + if (path === "/api/mcp" && method === "GET") { + sendJson(res, 200, { servers: listMcpServers() }); + return; + } + + if (path === "/api/mcp" && method === "POST") { + const raw = await readBody(req); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + sendJson(res, 400, { error: "invalid JSON body" }); + return; + } + const body = parsed as { + source?: unknown; + scope?: unknown; + name?: unknown; + config?: unknown; + }; + const source = typeof body.source === "string" ? body.source : ""; + const scope = typeof body.scope === "string" && body.scope ? body.scope : "global"; + const name = typeof body.name === "string" ? body.name : ""; + try { + writeMcpServer(source, scope, name, body.config); + sendJson(res, 200, { saved: name.trim() }); + } catch (err) { + sendJson(res, 400, { error: errMessage(err) }); + } + return; + } + + if (path === "/api/mcp" && method === "DELETE") { + const source = u.searchParams.get("source") ?? ""; + const scope = u.searchParams.get("scope") || "global"; + const name = u.searchParams.get("name") ?? ""; + try { + deleteMcpServer(source, scope, name); + sendJson(res, 200, { deleted: name }); + } catch (err) { + sendJson(res, 400, { error: errMessage(err) }); + } + return; + } + + if (path === "/api/health" && method === "GET") { + const major = Number(process.versions.node.split(".")[0]); + sendJson(res, 200, { + node: process.version, + nodeOk: Number.isFinite(major) && major >= 18, + platform: process.platform, + cwd: process.cwd(), + }); + return; + } + + if (path === "/api/agents" && method === "GET") { + // Augment each agent with `launchable`: whether its CLI binary is on + // PATH. "Connected" only means bl is wired into the agent's config, so + // the UI uses this to avoid offering a launch that would instantly fail. + // `dispatchable` additionally requires a verified prompt contract. + const agents = listAgents(); + const launchable = await Promise.all(agents.map((a) => agentLaunchable(a.id))); + sendJson(res, 200, { + agents: agents.map((a, i) => ({ + ...a, + launchable: launchable[i], + dispatchable: launchable[i] && agentSupportsPrompt(a.id), + })), + }); + return; + } + + if (path === "/api/agent" && method === "GET") { + const detail = getAgentDetail(u.searchParams.get("id") ?? ""); + if (!detail) { + sendJson(res, 404, { error: "not found" }); + return; + } + sendJson(res, 200, detail); + return; + } + + if (path === "/api/agent/open" && method === "POST") { + const detail = getAgentDetail(u.searchParams.get("id") ?? ""); + const target = u.searchParams.get("path") ?? ""; + const allowed = detail?.settings.some((s) => s.path === target) ?? false; + if (!detail || !allowed || !existsSync(target)) { + sendJson(res, 404, { error: "not found" }); + return; + } + try { + await openPath(target); + sendJson(res, 200, { opened: target }); + } catch (err) { + sendJson(res, 400, { error: errMessage(err) }); + } + return; + } + + if (path === "/api/scenarios" && method === "GET") { + // Curated Playground scenarios plus the connected agents that can be + // dispatched a prompt right now (on PATH + verified prompt contract). + const agents = listAgents(); + const launchable = await Promise.all(agents.map((a) => agentLaunchable(a.id))); + const targets = agents + .map((a, i) => ({ + id: a.id, + label: a.label, + dispatchable: launchable[i] && agentSupportsPrompt(a.id), + })) + .filter((a) => a.dispatchable); + sendJson(res, 200, { scenarios: SCENARIOS, agents: targets }); + return; + } + + if (path === "/api/auth/status" && method === "GET") { + sendJson( + res, + 200, + authBridge + ? authBridge.status() + : { + authenticated: false, + methods: { apiKey: false, console: false, openapi: false }, + primary: null, + }, + ); + return; + } + + if (path === "/api/auth/login" && method === "POST") { + if (!authBridge) { + sendJson(res, 400, { error: "login unavailable" }); + return; + } + authBridge.startConsoleLogin(); + sendJson(res, 200, { started: true }); + return; + } + + if (path === "/api/auth/logout" && method === "POST") { + if (!authBridge) { + sendJson(res, 400, { error: "logout unavailable" }); + return; + } + try { + const loggedOut = await authBridge.logout(); + sendJson(res, 200, { loggedOut }); + } catch (err) { + sendJson(res, 400, { error: errMessage(err) }); + } + return; + } + + if (path === "/api/assets" && method === "GET") { + sendJson(res, 200, listAssets(outputBase)); + return; + } + + if (path === "/api/asset/file" && method === "GET") { + const abs = resolveAssetPath(outputBase, u.searchParams.get("path") ?? ""); + const st = abs && existsSync(abs) ? statSync(abs) : null; + if (!abs || !st || !st.isFile()) { + sendJson(res, 404, { error: "not found" }); + return; + } + res.writeHead(200, { + "Content-Type": contentType(extname(abs)), + "Content-Length": st.size, + "Cache-Control": "no-store", + }); + const stream = createReadStream(abs); + stream.on("error", () => { + if (!res.headersSent) res.writeHead(500); + res.end(); + }); + stream.pipe(res); + return; + } + + if (path === "/api/asset" && method === "DELETE") { + const rel = u.searchParams.get("path") ?? ""; + const abs = resolveAssetPath(outputBase, rel); + if (!abs || !existsSync(abs) || !statSync(abs).isFile()) { + sendJson(res, 404, { error: "not found" }); + return; + } + try { + unlinkSync(abs); + sendJson(res, 200, { deleted: rel }); + } catch (err) { + sendJson(res, 400, { error: errMessage(err) }); + } + return; + } + + if (path === "/api/asset/open" && method === "POST") { + const rel = u.searchParams.get("path") ?? ""; + const abs = resolveAssetPath(outputBase, rel); + if (!abs || !existsSync(abs) || !statSync(abs).isFile()) { + sendJson(res, 404, { error: "not found" }); + return; + } + try { + await openPath(abs); + sendJson(res, 200, { opened: rel }); + } catch (err) { + sendJson(res, 400, { error: errMessage(err) }); + } + return; + } + + if (path === "/api/agent/launch" && method === "POST") { + try { + const result = await launchAgent(u.searchParams.get("id") ?? ""); + sendJson(res, 200, result); + } catch (err) { + sendJson(res, 400, { error: errMessage(err) }); + } + return; + } + + if (path === "/api/agent/dispatch" && method === "POST") { + const raw = await readBody(req); + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + sendJson(res, 400, { error: "invalid JSON body" }); + return; + } + const body = parsed as { + scenario?: unknown; + agent?: unknown; + values?: unknown; + custom?: unknown; + }; + const agentId = typeof body.agent === "string" ? body.agent : ""; + if (!agentSupportsPrompt(agentId)) { + sendJson(res, 400, { error: "agent cannot be dispatched a prompt" }); + return; + } + let scenario: Scenario | undefined; + const custom = body.custom; + if (custom && typeof custom === "object" && !Array.isArray(custom)) { + const c = custom as { title?: unknown; prompt?: unknown; inputs?: unknown }; + const promptTpl = typeof c.prompt === "string" ? c.prompt.trim() : ""; + if (!promptTpl) { + sendJson(res, 400, { error: "custom scenario needs a prompt" }); + return; + } + const inputs: { key: string; label: string }[] = []; + if (Array.isArray(c.inputs)) { + for (const it of c.inputs as unknown[]) { + if (it && typeof it === "object") { + const o = it as { key?: unknown; label?: unknown }; + const key = typeof o.key === "string" ? o.key.trim() : ""; + if (key) { + const label = + typeof o.label === "string" && o.label.trim() ? o.label.trim() : key; + inputs.push({ key, label }); + } + } + } + } + scenario = { + id: "custom", + title: typeof c.title === "string" && c.title.trim() ? c.title.trim() : "Custom", + description: "", + category: "\u81ea\u5b9a\u4e49", + prompt: promptTpl, + inputs, + }; + } else { + scenario = typeof body.scenario === "string" ? getScenario(body.scenario) : undefined; + } + if (!scenario) { + sendJson(res, 400, { error: "unknown scenario" }); + return; + } + const values: Record = {}; + if (body.values && typeof body.values === "object" && !Array.isArray(body.values)) { + for (const [k, v] of Object.entries(body.values as Record)) { + if (typeof v === "string") values[k] = v; + } + } + for (const inp of scenario.inputs ?? []) { + if (!values[inp.key] || !values[inp.key]!.trim()) { + sendJson(res, 400, { error: `Missing input: ${inp.label}` }); + return; + } + } + const prompt = renderScenarioPrompt(scenario, values); + try { + const result = await launchAgent(agentId, process.cwd(), prompt); + sendJson(res, 200, { + launched: true, + agent: agentId, + scenario: scenario.id, + command: result.command, + }); + } catch (err) { + sendJson(res, 400, { error: errMessage(err) }); + } + return; + } + if (path === "/api/active" && method === "POST") { const raw = await readBody(req); let parsed: unknown; @@ -164,7 +601,7 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h return; } let normalized: string | undefined; - let cleaned: Record; + let cleaned: Record; try { normalized = normalizeConfigName(body.name); cleaned = buildProfilePatch(body.data as Record); @@ -191,9 +628,15 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("not found\n"); - } catch { - if (!res.headersSent) res.writeHead(500); - res.end(); + } catch (err) { + // Log server-side so failures are diagnosable, and return a JSON error + // instead of an empty 500 body. + console.error("[config ui] request failed:", err); + if (res.headersSent) { + res.end(); + return; + } + sendJson(res, 500, { error: errMessage(err) }); } }); } @@ -217,9 +660,29 @@ export default defineCommand({ routes: [ "GET / -> web UI", "GET /api/config -> read all profiles", + "GET /api/skills -> list installed agent skills", + "GET /api/skill -> read one skill's SKILL.md detail", + "POST /api/skill/install -> install a skill from an uploaded .zip into a skills root", + "GET /api/mcp -> list local MCP servers", + "POST /api/mcp -> create or update one MCP server (writes its source config)", + "DELETE /api/mcp -> remove one MCP server from its source config", + "GET /api/health -> runtime environment info (node, platform, cwd)", + "GET /api/agents -> list coding agent frameworks", + "GET /api/agent -> one agent's config detail (secrets masked)", + "POST /api/agent/open -> open one agent's config file with the OS default app", + "GET /api/auth/status -> current auth state", + "POST /api/auth/login -> start console login (opens browser)", + "POST /api/auth/logout -> clear all stored credentials", + "GET /api/assets -> list generated assets", + "GET /api/asset/file -> stream one asset file", + "POST /api/asset/open -> open one asset with the OS default app", + "POST /api/agent/launch -> launch a coding agent CLI in a new terminal", + "GET /api/scenarios -> list Playground scenarios and dispatchable agents", + "POST /api/agent/dispatch -> dispatch a scenario prompt to a connected agent", "POST /api/profile -> save a profile", "POST /api/active -> activate a profile", "DELETE /api/profile -> delete a named profile", + "DELETE /api/asset -> delete one asset file", ], }, format, @@ -228,7 +691,8 @@ export default defineCommand({ } const token = randomBytes(16).toString("hex"); - const server = createConfigUiServer(token, ctx.configStore); + const outputBase = settings.outputDir || defaultOutputBase(); + const server = createConfigUiServer(token, ctx.configStore, outputBase, makeAuthUiBridge(ctx)); let port: number; try { diff --git a/packages/commands/src/commands/shared/local-server.ts b/packages/commands/src/commands/shared/local-server.ts index ffbf5c1a..102247d5 100644 --- a/packages/commands/src/commands/shared/local-server.ts +++ b/packages/commands/src/commands/shared/local-server.ts @@ -22,11 +22,15 @@ export function listenLocalServer(server: http.Server, port = 0): Promise { +/** + * Open a local file, directory, or URL with the OS default handler + * (best-effort, cross-platform). Arguments are passed to `execFile` as an array + * so the target is never interpreted by a shell. + */ +export function openPath(target: string): Promise { const platform = process.platform; const cmd = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; - const args = platform === "win32" ? ["/c", "start", "", url] : [url]; + const args = platform === "win32" ? ["/c", "start", "", target] : [target]; return new Promise((resolve, reject) => { execFile(cmd, args, { windowsHide: true }, (err) => { @@ -35,3 +39,8 @@ export function openInBrowser(url: string): Promise { }); }); } + +/** Open a URL in the user's default browser (best-effort, cross-platform). */ +export function openInBrowser(url: string): Promise { + return openPath(url); +} diff --git a/packages/commands/tests/agent-launch.test.ts b/packages/commands/tests/agent-launch.test.ts new file mode 100644 index 00000000..0a91fe5d --- /dev/null +++ b/packages/commands/tests/agent-launch.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from "vite-plus/test"; +import { + AGENT_COMMANDS, + agentCommand, + agentLaunchable, + launchAgent, +} from "../src/commands/config/agent-launch.ts"; + +test("agentCommand 返回已知 agent 的可执行命令,未知返回 undefined", () => { + expect(agentCommand("qwen-code")).toBe("qwen"); + expect(agentCommand("codex")).toBe("codex"); + expect(agentCommand("nope")).toBeUndefined(); + // Guards against prototype keys leaking through the allowlist lookup. + expect(agentCommand("toString")).toBeUndefined(); +}); + +test("AGENT_COMMANDS 覆盖所有已知 agent id", () => { + expect(Object.keys(AGENT_COMMANDS).sort()).toEqual( + ["claude-code", "codex", "hermes", "opencode", "openclaw", "qwen-code"].sort(), + ); +}); + +test("launchAgent 对未知 id 抛错且不启动任何进程", async () => { + await expect(launchAgent("definitely-not-an-agent")).rejects.toThrow(/Unknown agent/); +}); + +test("agentLaunchable 对未知 id 返回 false,不探测 PATH", async () => { + expect(await agentLaunchable("definitely-not-an-agent")).toBe(false); + // Prototype keys must not resolve to a launchable command either. + expect(await agentLaunchable("toString")).toBe(false); +}); diff --git a/packages/commands/tests/assets.test.ts b/packages/commands/tests/assets.test.ts new file mode 100644 index 00000000..63f02e93 --- /dev/null +++ b/packages/commands/tests/assets.test.ts @@ -0,0 +1,96 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, sep } from "node:path"; +import { expect, test } from "vite-plus/test"; +import { listAssets, resolveAssetPath, contentType } from "../src/commands/config/assets.ts"; + +/** Build an isolated temp output base and clean it up afterwards. */ +function withBase(fn: (base: string) => void): void { + const base = mkdtempSync(join(tmpdir(), "bl-assets-")); + try { + fn(base); + } finally { + rmSync(base, { recursive: true, force: true }); + } +} + +function put(base: string, rel: string, content = "x"): string { + const path = join(base, rel); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, content); + return path; +} + +test("listAssets 按分类归类并识别类型", () => { + withBase((base) => { + put(base, "images/a.png"); + put(base, "videos/clip.mp4"); + put(base, "speech/voice.mp3"); + put(base, "notes.txt"); // loose file -> other + + const { base: reported, assets } = listAssets(base); + expect(reported).toBe(base); + const byName = Object.fromEntries(assets.map((a) => [a.name, a])); + expect(byName["a.png"]).toMatchObject({ category: "images", kind: "image", ext: "png" }); + expect(byName["clip.mp4"]).toMatchObject({ category: "videos", kind: "video", ext: "mp4" }); + expect(byName["voice.mp3"]).toMatchObject({ category: "speech", kind: "audio", ext: "mp3" }); + expect(byName["notes.txt"]).toMatchObject({ category: "other", kind: "other" }); + }); +}); + +test("listAssets 递归扫描任意子文件夹(非预设分类目录)", () => { + withBase((base) => { + put(base, "news-articles/report.md"); + put(base, "custom/deep/nested/pic.png"); + put(base, "images/a.png"); + + const { assets } = listAssets(base); + const byName = Object.fromEntries(assets.map((a) => [a.name, a])); + // Arbitrary top-level folder becomes the category. + expect(byName["report.md"]).toMatchObject({ + category: "news-articles", + kind: "other", + ext: "md", + }); + // Deeply nested file is discovered; category is its top-level folder. + expect(byName["pic.png"]).toMatchObject({ category: "custom", kind: "image" }); + expect(byName["pic.png"]!.relPath).toBe(join("custom", "deep", "nested", "pic.png")); + // Known category dirs still work. + expect(byName["a.png"]).toMatchObject({ category: "images", kind: "image" }); + }); +}); + +test("listAssets 按生成时间倒序排列", () => { + withBase((base) => { + const older = put(base, "images/old.png"); + const newer = put(base, "images/new.png"); + // Force a stable ordering by stamping mtimes. + utimesSync(older, new Date(1000), new Date(1000)); + utimesSync(newer, new Date(2000), new Date(2000)); + + const { assets } = listAssets(base); + expect(assets.map((a) => a.name)).toEqual(["new.png", "old.png"]); + }); +}); + +test("listAssets 目录不存在时返回空", () => { + const { assets } = listAssets(join(tmpdir(), "bl-assets-does-not-exist-xyz")); + expect(assets).toEqual([]); +}); + +test("resolveAssetPath 阻止目录穿越", () => { + withBase((base) => { + put(base, "images/a.png"); + expect(resolveAssetPath(base, "images/a.png")).toBe(join(base, "images/a.png")); + expect(resolveAssetPath(base, "../../etc/passwd")).toBeNull(); + expect(resolveAssetPath(base, "")).toBeNull(); + expect(resolveAssetPath(base, "images" + sep + ".." + sep + ".." + sep + "outside")).toBeNull(); + }); +}); + +test("contentType 映射常见扩展名", () => { + expect(contentType(".png")).toBe("image/png"); + expect(contentType(".MP4")).toBe("video/mp4"); + expect(contentType(".mp3")).toBe("audio/mpeg"); + expect(contentType(".xyz")).toBe("application/octet-stream"); +}); diff --git a/packages/commands/tests/auth-console-ui.test.ts b/packages/commands/tests/auth-console-ui.test.ts new file mode 100644 index 00000000..3f743789 --- /dev/null +++ b/packages/commands/tests/auth-console-ui.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from "vite-plus/test"; +import { makeAuthUiBridge } from "../src/commands/auth/console-ui.ts"; +import type { AuthState, AuthStore, Identity, Settings } from "bailian-cli-core"; + +/** Build a bridge over a fake AuthStore. Only describe()/logout() are used by + * the surface under test; startConsoleLogin() is intentionally not exercised + * (it opens a browser and starts a real callback server). */ +function bridgeWith(state: AuthState, onLogout?: (scope: string) => Promise) { + const authStore = { + describe: () => state, + stored: () => ({ apiKey: false, console: false, openapi: false }), + resolveBaseUrl: () => "https://dashscope.aliyuncs.com", + login: async () => {}, + logout: onLogout ?? (async () => false), + path: "/tmp/config.json", + } as unknown as AuthStore; + return makeAuthUiBridge({ + identity: {} as unknown as Identity, + settings: {} as unknown as Settings, + authStore, + }); +} + +test("status: console 凭证 -> primary=console,带 region/site 与掩码 token", () => { + const st = bridgeWith({ + console: { + token: "abcd1234efgh5678", + region: "cn-beijing", + site: "domestic", + source: "config", + }, + }).status(); + expect(st.authenticated).toBe(true); + expect(st.primary).toBe("console"); + expect(st.methods).toEqual({ apiKey: false, console: true, openapi: false }); + expect(st.region).toBe("cn-beijing"); + expect(st.site).toBe("domestic"); + expect(st.masked).toContain("..."); + // Masked, never the raw token. + expect(st.masked).not.toBe("abcd1234efgh5678"); +}); + +test("status: 无任何凭证 -> 未认证,无 masked", () => { + const st = bridgeWith({}).status(); + expect(st.authenticated).toBe(false); + expect(st.primary).toBe(null); + expect(st.masked).toBeUndefined(); + expect(st.methods).toEqual({ apiKey: false, console: false, openapi: false }); +}); + +test("status: 仅 apiKey -> primary=apiKey", () => { + const st = bridgeWith({ + apiKey: { token: "sk-1234567890", baseUrl: "https://dashscope.aliyuncs.com", source: "env" }, + }).status(); + expect(st.primary).toBe("apiKey"); + expect(st.methods.apiKey).toBe(true); + expect(st.region).toBeUndefined(); +}); + +test("logout 委托给 authStore.logout('all')", async () => { + let scope = ""; + const bridge = bridgeWith( + { apiKey: { token: "sk-x", baseUrl: "https://x", source: "config" } }, + async (s) => { + scope = s; + return true; + }, + ); + expect(await bridge.logout()).toBe(true); + expect(scope).toBe("all"); +}); diff --git a/packages/commands/tests/config-ui.test.ts b/packages/commands/tests/config-ui.test.ts index 5a915591..dda40cc5 100644 --- a/packages/commands/tests/config-ui.test.ts +++ b/packages/commands/tests/config-ui.test.ts @@ -81,6 +81,43 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项", expect(res.json.default).toMatchObject({ api_key: "sk-default", output: "json" }); expect(res.json.named.dev).toMatchObject({ api_key: "sk-dev", access_token: "tok-dev" }); expect(res.json.secretKeys).toContain("api_key"); + // Console/telemetry fields are editable via the UI (full ConfigFile surface). + expect(res.json.keys).toContain("console_site"); + expect(res.json.keys).toContain("telemetry"); + expect(res.json.enums.console_site).toEqual(["domestic", "international"]); + expect(res.json.booleanKeys).toContain("telemetry"); + // Default field hints are surfaced as prefilled values in the UI. + expect(res.json.fieldDefaults.default_image_model).toBe("qwen-image-2.0"); + expect(res.json.fieldDefaults.default_text_model).toBe("qwen3.7-max"); + expect(res.json.fieldDefaults.output_dir).toContain("bailian-output"); + expect(res.json.fieldDefaults.timeout).toBe("300"); + expect(res.json.fieldDefaults.base_url).toBe("https://dashscope.aliyuncs.com"); + // Per-category model catalog (click-to-fill suggestions) is exposed too. + expect(res.json.modelCatalog.default_image_model[0]).toMatchObject({ id: "qwen-image-2.0" }); + expect(res.json.modelCatalog.default_video_model.map((m: { id: string }) => m.id)).toContain( + "happyhorse-1.1-i2v", + ); + expect(res.json.modelCatalog.default_speech_model.map((m: { id: string }) => m.id)).toContain( + "fun-asr", + ); + }); +}); + +test("GET /api/auth/status 无 bridge 时返回未认证;login/logout 返回 400", async () => { + await withServer(async (port) => { + // The test harness builds the server without an auth bridge, so the auth + // endpoints degrade safely instead of throwing. + const status = await httpJson(port, "GET", `/api/auth/status?token=${TOKEN}`); + expect(status.status).toBe(200); + expect(status.json.authenticated).toBe(false); + expect(status.json.methods).toEqual({ apiKey: false, console: false, openapi: false }); + expect(status.json.primary).toBe(null); + + const login = await httpJson(port, "POST", `/api/auth/login?token=${TOKEN}`); + expect(login.status).toBe(400); + + const logout = await httpJson(port, "POST", `/api/auth/logout?token=${TOKEN}`); + expect(logout.status).toBe(400); }); }); @@ -128,44 +165,42 @@ test("POST /api/profile 写命名 profile(timeout 强制为 number),空串 }); }); -test("POST /api/profile 保留 UI 未管理字段,同时替换 UI 管理字段", async () => { +test("POST /api/profile 可编辑 console/telemetry 字段并按类型持久化", async () => { await withServer(async (port) => { - await writeConfigFile( - { - api_key: "sk-old", - output: "json", - console_site: "international", - console_region: "ap-southeast-1", - console_switch_agent: 42, - telemetry: false, - }, - "stage", - ); - const save = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { - body: { name: "stage", data: { api_key: "sk-new" } }, + body: { + name: "stage", + data: { + api_key: "sk-stage", + console_site: "international", + console_region: "ap-southeast-1", + console_switch_agent: "42", + telemetry: "false", + }, + }, }); expect(save.status).toBe(200); const profile = readConfigFile("stage"); expect(profile).toMatchObject({ - api_key: "sk-new", + api_key: "sk-stage", console_site: "international", console_region: "ap-southeast-1", console_switch_agent: 42, telemetry: false, }); - expect(profile.output).toBeUndefined(); const rawConfig = JSON.parse(readFileSync(getConfigPath(), "utf8")); - expect(rawConfig.stage).toMatchObject({ - api_key: "sk-new", - console_site: "international", - console_region: "ap-southeast-1", - console_switch_agent: 42, - telemetry: false, + // Coerced to the right JSON types, not left as strings. + expect(rawConfig.stage.console_switch_agent).toBe(42); + expect(rawConfig.stage.telemetry).toBe(false); + + // Invalid enum value is rejected. + const bad = await httpJson(port, "POST", `/api/profile?token=${TOKEN}`, { + body: { name: "stage", data: { console_site: "mars" } }, }); - expect(rawConfig.stage.output).toBeUndefined(); + expect(bad.status).toBe(400); + expect(String(bad.json.error)).toMatch(/console_site/); }); }); diff --git a/packages/commands/tests/inventory.test.ts b/packages/commands/tests/inventory.test.ts new file mode 100644 index 00000000..b4a0608e --- /dev/null +++ b/packages/commands/tests/inventory.test.ts @@ -0,0 +1,161 @@ +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vite-plus/test"; +import { + listSkills, + listMcpServers, + listAgents, + getSkillDetail, +} from "../src/commands/config/inventory.ts"; + +/** Build an isolated fake $HOME and clean it up afterwards. */ +function withHome(fn: (home: string) => void): void { + const home = mkdtempSync(join(tmpdir(), "bl-inv-")); + try { + fn(home); + } finally { + rmSync(home, { recursive: true, force: true }); + } +} + +function write(home: string, rel: string, content: string): void { + const path = join(home, rel); + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, content); +} + +test("getSkillDetail 返回 SKILL.md 原文,未知 id 返回 null", () => { + withHome((home) => { + write( + home, + ".agents/skills/demo/SKILL.md", + "---\nname: demo-skill\ndescription: A demo skill.\n---\n# Body\nhello world\n", + ); + + const detail = getSkillDetail("demo", home); + expect(detail).not.toBeNull(); + expect(detail?.name).toBe("demo-skill"); + expect(detail?.content).toContain("hello world"); + expect(getSkillDetail("nope", home)).toBeNull(); + }); +}); + +test("listSkills 解析 SKILL.md frontmatter 与文件数", () => { + withHome((home) => { + write( + home, + ".agents/skills/demo/SKILL.md", + '---\nname: demo-skill\nmetadata:\n version: "2.1.0"\ndescription: A demo skill.\n---\n# Body\n', + ); + write(home, ".agents/skills/demo/assets/a.md", "x"); + // Directory without SKILL.md is ignored. + mkdirSync(join(home, ".agents/skills/not-a-skill"), { recursive: true }); + + const skills = listSkills(home); + expect(skills).toHaveLength(1); + expect(skills[0]).toMatchObject({ + id: "demo", + name: "demo-skill", + version: "2.1.0", + description: "A demo skill.", + sources: ["global"], + origin: "local", + }); + expect(skills[0].fileCount).toBe(2); + }); +}); + +test("listSkills 将 agent 目录下的软链接视为安装来源", () => { + withHome((home) => { + write(home, ".agents/skills/bailian-cli/SKILL.md", "---\nname: bailian-cli\n---\n"); + // Mimic `skills add`: the agent copy is a symlink back to the global dir. + mkdirSync(join(home, ".claude/skills"), { recursive: true }); + symlinkSync(join(home, ".agents/skills/bailian-cli"), join(home, ".claude/skills/bailian-cli")); + + const skills = listSkills(home); + expect(skills).toHaveLength(1); + expect(skills[0].sources).toEqual(["global", "claude-code"]); + }); +}); + +test("listSkills 跨 agent 模块聚合并记录来源", () => { + withHome((home) => { + // Same skill installed in the global dir and two agent modules. + const skillMd = "---\nname: bailian-cli\n---\n# B\n"; + write(home, ".agents/skills/bailian-cli/SKILL.md", skillMd); + write(home, ".claude/skills/bailian-cli/SKILL.md", skillMd); + write(home, ".qwen/skills/bailian-cli/SKILL.md", skillMd); + // A skill only present in qwen. + write(home, ".qwen/skills/spark-video/SKILL.md", "---\nname: spark-video\n---\n"); + + const skills = listSkills(home); + const byId = Object.fromEntries(skills.map((s) => [s.id, s])); + expect(byId["bailian-cli"].sources).toEqual(["global", "claude-code", "qwen-code"]); + expect(byId["spark-video"].sources).toEqual(["qwen-code"]); + }); +}); + +test("listSkills 目录缺失时返回空数组", () => { + withHome((home) => { + expect(listSkills(home)).toEqual([]); + }); +}); + +test("listMcpServers 汇总 codex(toml) 与 claude(json) 的 MCP 定义", () => { + withHome((home) => { + write(home, ".codex/config.toml", '[mcp_servers.repl]\ncommand = "node"\nargs = ["repl.js"]\n'); + write( + home, + ".claude.json", + JSON.stringify({ + mcpServers: { web: { url: "https://example.com/mcp", type: "sse" } }, + projects: { "/proj": { mcpServers: { local: { command: "python", args: ["s.py"] } } } }, + }), + ); + + const servers = listMcpServers(home); + const byName = Object.fromEntries(servers.map((s) => [s.name, s])); + expect(byName.repl).toMatchObject({ source: "codex", transport: "stdio", origin: "local" }); + expect(byName.repl.detail).toContain("node repl.js"); + expect(byName.web).toMatchObject({ source: "claude-code", transport: "sse", scope: "global" }); + expect(byName.local).toMatchObject({ + source: "claude-code", + transport: "stdio", + scope: "/proj", + }); + }); +}); + +test("listMcpServers 无配置时返回空数组", () => { + withHome((home) => { + expect(listMcpServers(home)).toEqual([]); + }); +}); + +test("listAgents 报告安装与已连接 bailian-cli 的状态", () => { + withHome((home) => { + // Claude Code: installed + configured (base url present). + write( + home, + ".claude/settings.json", + JSON.stringify({ env: { ANTHROPIC_BASE_URL: "https://x", ANTHROPIC_MODEL: "qwen3-max" } }), + ); + // Codex: installed but NOT configured (no bailian-cli provider). + write(home, ".codex/config.toml", 'model = "gpt-5"\n'); + + const agents = listAgents(home); + const byId = Object.fromEntries(agents.map((a) => [a.id, a])); + + expect(byId["claude-code"]).toMatchObject({ + installed: true, + configured: true, + model: "qwen3-max", + origin: "local", + }); + expect(byId.codex).toMatchObject({ installed: true, configured: false, model: "gpt-5" }); + expect(byId.opencode).toMatchObject({ installed: false, configured: false }); + // Always reports all six known frameworks. + expect(agents).toHaveLength(6); + }); +}); diff --git a/packages/commands/tests/qr.test.ts b/packages/commands/tests/qr.test.ts new file mode 100644 index 00000000..27b5fab3 --- /dev/null +++ b/packages/commands/tests/qr.test.ts @@ -0,0 +1,157 @@ +import { expect, test } from "vite-plus/test"; +import { rsGeneratorExp, rsEncode, qrMatrix, qrSvg } from "../src/commands/config/qr.ts"; + +test("rsGeneratorExp 匹配已知 QR 生成多项式(alpha 指数)", () => { + // Well-documented QR generator polynomials — a strong correctness anchor for + // the GF(256) arithmetic and polynomial construction. + expect(rsGeneratorExp(7)).toEqual([0, 87, 229, 146, 149, 238, 102, 21]); + expect(rsGeneratorExp(10)).toEqual([0, 251, 67, 46, 61, 118, 70, 64, 94, 32, 45]); + expect(rsGeneratorExp(15)).toEqual([ + 0, 8, 183, 61, 91, 202, 37, 51, 58, 58, 237, 140, 124, 5, 99, 105, + ]); +}); + +test("rsEncode 产出请求数量的纠错码字", () => { + const ec = rsEncode([0x40, 0xd2, 0x75, 0x47, 0x76, 0x17, 0x32, 0x06, 0x27, 0x26], 10); + expect(ec).toHaveLength(10); + expect(ec.every((b) => b >= 0 && b <= 255)).toBe(true); +}); + +test("qrMatrix 尺寸随版本增长且含三个定位图案", () => { + const m = qrMatrix("http://127.0.0.1:8787/?token=" + "a".repeat(32)); + // ~61 byte URL -> version 4 (33x33). + expect(m.length).toBe(33); + expect(m[0]).toHaveLength(33); + const size = m.length; + // Finder pattern centers are dark (3x3 core) at the three corners. + expect(m[3][3]).toBe(true); + expect(m[3][size - 4]).toBe(true); + expect(m[size - 4][3]).toBe(true); + // Timing pattern alternates on row/col 6. + expect(m[6][8]).toBe(true); + expect(m[6][9]).toBe(false); +}); + +test("qrMatrix 短文本用最小版本(V1=21x21)", () => { + expect(qrMatrix("hi").length).toBe(21); +}); + +test("qrSvg 返回带 viewBox 的 SVG 且含模块矩形", () => { + const svg = qrSvg("http://127.0.0.1:8787/"); + expect(svg.startsWith(" { + expect(() => qrMatrix("x".repeat(200))).toThrow(/too large/); +}); + +// --- End-to-end: decode the produced matrix with the STANDARD reading order +// and confirm it round-trips back to the original text. This is the real proof +// that the code is scannable (format-info placement + mask + data placement), +// which cannot be checked by structure alone. The format info is read from the +// FIRST copy (top-left) using the canonical mapping, independent of the encoder. +const MASK_FNS: Array<(r: number, c: number) => boolean> = [ + (r, c) => (r + c) % 2 === 0, + (r) => r % 2 === 0, + (_r, c) => c % 3 === 0, + (r, c) => (r + c) % 3 === 0, + (r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0, + (r, c) => ((r * c) % 2) + ((r * c) % 3) === 0, + (r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0, + (r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0, +]; + +function qrDecode(m: boolean[][]): { text: string; mask: number; ecLevel: number } { + const size = m.length; + const version = (size - 17) / 4; + const bAt = (r: number, c: number) => (m[r][c] ? 1 : 0); + + // Read the 15-bit format info from the first copy (canonical cell mapping). + const cells: Array<[number, number]> = [ + [0, 8], + [1, 8], + [2, 8], + [3, 8], + [4, 8], + [5, 8], // bits 0–5 + [7, 8], // bit 6 + [8, 8], // bit 7 + [8, 7], // bit 8 + [8, 5], + [8, 4], + [8, 3], + [8, 2], + [8, 1], + [8, 0], // bits 9–14 + ]; + let fmt = 0; + for (let i = 0; i < 15; i++) fmt |= bAt(cells[i][0], cells[i][1]) << i; + fmt ^= 0x5412; + const mask = (fmt >> 10) & 7; + const ecLevel = (fmt >> 13) & 3; + const cond = MASK_FNS[mask]; + + // Independent function/reserved-module map. + const isFn = (r: number, c: number): boolean => { + if (r <= 7 && c <= 7) return true; // top-left finder + separator + if (r <= 7 && c >= size - 8) return true; // top-right finder + if (r >= size - 8 && c <= 7) return true; // bottom-left finder + if (r === 6 || c === 6) return true; // timing + if (r === 8 && (c <= 8 || c >= size - 8)) return true; // format (horizontal) + if (c === 8 && (r <= 8 || r >= size - 8)) return true; // format (vertical) + dark + if (version >= 2) { + const ac = size - 7; + if (Math.abs(r - ac) <= 2 && Math.abs(c - ac) <= 2) return true; // alignment + } + return false; + }; + + // Read data modules in the standard right-to-left zigzag, un-masking as we go. + const bits: number[] = []; + let upward = true; + for (let col = size - 1; col >= 1; col -= 2) { + if (col === 6) col = 5; + for (let i = 0; i < size; i++) { + const row = upward ? size - 1 - i : i; + for (const off of [0, 1]) { + const cc = col - off; + if (isFn(row, cc)) continue; + let b = bAt(row, cc); + if (cond(row, cc)) b ^= 1; + bits.push(b); + } + } + upward = !upward; + } + + let p = 0; + const read = (n: number) => { + let v = 0; + for (let k = 0; k < n; k++) v = (v << 1) | (bits[p++] ?? 0); + return v; + }; + const mode = read(4); + if (mode !== 0b0100) throw new Error("decode: not byte mode, got " + mode); + const len = read(8); + const out: number[] = []; + for (let i = 0; i < len; i++) out.push(read(8)); + return { text: new TextDecoder().decode(new Uint8Array(out)), mask, ecLevel }; +} + +test("qrMatrix → 标准解码能无损还原原文(失败则说明无法扫描)", () => { + const samples = [ + "hi", + "http://127.0.0.1:8787/", + "http://127.0.0.1:8787/?token=" + "a".repeat(32), + "http://127.0.0.1:65535/?token=0123456789abcdef0123456789abcdef", + ]; + for (const text of samples) { + const decoded = qrDecode(qrMatrix(text)); + expect(decoded.text).toBe(text); + expect(decoded.ecLevel).toBe(0b01); // error-correction level L + expect(decoded.mask).toBeGreaterThanOrEqual(0); + expect(decoded.mask).toBeLessThanOrEqual(7); + } +}); diff --git a/packages/core/src/auth/store.ts b/packages/core/src/auth/store.ts index 33572107..04bae18b 100644 --- a/packages/core/src/auth/store.ts +++ b/packages/core/src/auth/store.ts @@ -59,7 +59,11 @@ export function makeAuthStore(sources: ResolutionSources): AuthStore { const configName = sources.configName; const activateAfterLogin = sources.flags.config !== undefined; return { - describe: () => describeAuthState(sources), + // Read the config block live (not the startup `sources.file` snapshot) so + // long-lived processes like `config ui` reflect a fresh login without a + // restart. For one-shot commands this reads the same content the snapshot + // held, so behavior is unchanged. + describe: () => describeAuthState({ ...sources, file: readConfigFile(configName) }), stored() { const file = readConfigFile(configName); return {