From 9ab5de8c2e5b8280a5a14ade63f0d7000f3b8c4a Mon Sep 17 00:00:00 2001 From: inhai Date: Tue, 21 Jul 2026 21:54:27 +0800 Subject: [PATCH] feat(config-ui): enrich config UI with skills, MCP, agents, assets and model catalog - Add Skills / MCP / Agents / Assets inventory views with click-to-open right-side detail drawers (reusable infoDrawer) - Render SKILL.md as Markdown via a self-contained, XSS-safe inline renderer (HTML-escape first, strip YAML frontmatter, no external deps) - Add local vs remote origin badges to Skills and MCP items - Add quick-launch for coding agents (allowlisted id->binary, execFile, no shell); gate the button on Connected AND the CLI binary being on PATH - Add per-category model catalog surfaced as click-to-fill suggestion chips under each default_*_model field, sourced from real bl pipeline model names - Add assets browser (categorized, time-sorted) with preview, open-locally and delete, backed by path-traversal-guarded file serving - Convert Profiles to a tile grid with an add-tile and design-consistent new-profile modal; make view headers sticky and use drawers for editing - Tests for inventory, agent-launch, assets and config-ui endpoints --- docs/agents/config-profile-change.md | 7 +- .../src/commands/config/agent-launch.ts | 97 ++ .../commands/src/commands/config/assets.ts | 170 +++ .../commands/src/commands/config/inventory.ts | 415 ++++++ .../commands/src/commands/config/shared.ts | 125 ++ .../commands/src/commands/config/ui-html.ts | 1156 ++++++++++++++--- packages/commands/src/commands/config/ui.ts | 159 ++- .../src/commands/shared/local-server.ts | 15 +- packages/commands/tests/agent-launch.test.ts | 31 + packages/commands/tests/assets.test.ts | 74 ++ packages/commands/tests/config-ui.test.ts | 62 +- packages/commands/tests/inventory.test.ts | 160 +++ 12 files changed, 2265 insertions(+), 206 deletions(-) create mode 100644 packages/commands/src/commands/config/agent-launch.ts create mode 100644 packages/commands/src/commands/config/assets.ts create mode 100644 packages/commands/src/commands/config/inventory.ts create mode 100644 packages/commands/tests/agent-launch.test.ts create mode 100644 packages/commands/tests/assets.test.ts create mode 100644 packages/commands/tests/inventory.test.ts diff --git a/docs/agents/config-profile-change.md b/docs/agents/config-profile-change.md index 4e79e7d..b9b00db 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/config/agent-launch.ts b/packages/commands/src/commands/config/agent-launch.ts new file mode 100644 index 0000000..2597d19 --- /dev/null +++ b/packages/commands/src/commands/config/agent-launch.ts @@ -0,0 +1,97 @@ +/** + * 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; +} + +/** 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. + * Rejects when the id is unknown, the binary is missing from PATH, or the + * platform terminal could not be opened. + */ +export async function launchAgent(id: string, cwd: string = process.cwd()): 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.`); + } + await spawnTerminal(command, cwd); + return { launched: true, command }; +} diff --git a/packages/commands/src/commands/config/assets.ts b/packages/commands/src/commands/config/assets.ts new file mode 100644 index 0000000..841e6d8 --- /dev/null +++ b/packages/commands/src/commands/config/assets.ts @@ -0,0 +1,170 @@ +// Read/manage the local assets that `bl` writes into the output directory +// (default ~/bailian-output, overridable via the `output_dir` config key). +// Generated media is organized into per-type subdirectories: images/, videos/, +// speech/, omni/. This module discovers those files, classifies them, 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; +} + +/** Category subdirectories that `bl` writes generated media into. */ +const CATEGORY_DIRS = ["images", "videos", "speech", "omni"] as const; + +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. Scans each known category + * subdirectory plus any loose files directly under the base (grouped as + * "other"). 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 seen = new Set(); + const addFile = (full: string, category: string): void => { + if (seen.has(full)) return; + seen.add(full); + let st; + try { + st = statSync(full); + } catch { + return; + } + if (!st.isFile()) return; + const ext = extname(full); + assets.push({ + name: full.split(sep).pop() ?? full, + category, + kind: kindOf(ext), + relPath: relative(base, full), + size: st.size, + mtime: st.mtimeMs, + ext: ext.replace(/^\./, "").toLowerCase(), + }); + }; + + for (const cat of CATEGORY_DIRS) { + const files: string[] = []; + walk(join(base, cat), 4, files); + for (const f of files) addFile(f, cat); + } + + // Loose files placed directly under the base directory. + let rootEntries: Dirent[] = []; + try { + rootEntries = readdirSync(base, { withFileTypes: true }); + } catch { + rootEntries = []; + } + for (const e of rootEntries) { + if (e.isFile() || e.isSymbolicLink()) addFile(join(base, e.name), "other"); + } + + 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 0000000..8bd0899 --- /dev/null +++ b/packages/commands/src/commands/config/inventory.ts @@ -0,0 +1,415 @@ +// 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 { join } from "node:path"; +import { existsSync, readFileSync, readdirSync, type Dirent } from "node:fs"; +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; +} + +/** A coding agent framework and its local configuration state. */ +export interface AgentInfo { + id: string; + label: string; + installed: boolean; + configured: boolean; + model?: string; + paths: string[]; +} + +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: "hermes", dir: join(home, ".hermes", "skills") }, + { source: "gemini", dir: join(home, ".gemini", "skills") }, + { source: "windsurf", dir: join(home, ".windsurf", "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 }; +} + +// ---- 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[]): 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" }); + } +} + +/** 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); + 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); + } + } + } + + // Qwen Code. + const qwen = readJsonSafe(join(home, ".qwen", "settings.json")); + if (qwen) collectMcpMap(qwen.mcpServers, "qwen-code", "global", out); + + // OpenCode uses `mcp` rather than `mcpServers`. + const opencode = readJsonSafe(join(home, ".config", "opencode", "opencode.json")); + if (opencode) collectMcpMap(opencode.mcp, "opencode", "global", out); + + // 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); + } catch { + /* ignore malformed toml */ + } + } + + return out.sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)); +} + +// ---- 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, + }; + }); +} diff --git a/packages/commands/src/commands/config/shared.ts b/packages/commands/src/commands/config/shared.ts index 7ff612d..96651d0 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 1bcbf8c..bdd5a84 100644 --- a/packages/commands/src/commands/config/ui-html.ts +++ b/packages/commands/src/commands/config/ui-html.ts @@ -1,105 +1,597 @@ -// 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 - 阿里云百炼CLI + + +
-
-
-

- -
-
-
- - - -
+
+
+
+

Config Profiles

+

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…
+
+ +
+
+

MCP Servers

+

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 (images, videos, speech, omni), tagged by category and generation time.

+
+
+
Loading…
+
+ + +
diff --git a/packages/commands/src/commands/config/ui.ts b/packages/commands/src/commands/config/ui.ts index 2c10c68..e3e9666 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 { createReadStream, existsSync, statSync, unlinkSync } from "node:fs"; +import { extname } from "node:path"; import { defineCommand, @@ -14,9 +16,21 @@ import { 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 } from "./inventory.ts"; +import { launchAgent, agentLaunchable } from "./agent-launch.ts"; +import { listAssets, resolveAssetPath, defaultOutputBase, contentType } from "./assets.ts"; const FLAGS = { port: { @@ -60,15 +74,17 @@ function readBody(req: http.IncomingMessage): Promise { } /** 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 +92,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 +107,11 @@ 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(), +): http.Server { return http.createServer(async (req, res) => { try { const host = (req.headers.host || "").split(":")[0]; @@ -121,8 +141,16 @@ export function createConfigUiServer(token: string, configStore: ConfigStore): h 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, + output_dir: defaultOutputBase(), + timeout: "300", + }, + modelCatalog: UI_MODEL_CATALOG, activeProfile: profiles.active, default: profiles.default, named: profiles.named, @@ -130,6 +158,105 @@ 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/mcp" && method === "GET") { + sendJson(res, 200, { servers: listMcpServers() }); + 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. + 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] })), + }); + 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") ?? ""); + if (!abs || !existsSync(abs) || !statSync(abs).isFile()) { + sendJson(res, 404, { error: "not found" }); + return; + } + res.writeHead(200, { + "Content-Type": contentType(extname(abs)), + "Content-Length": statSync(abs).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/active" && method === "POST") { const raw = await readBody(req); let parsed: unknown; @@ -164,7 +291,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); @@ -217,9 +344,18 @@ 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", + "GET /api/mcp -> list local MCP servers", + "GET /api/agents -> list coding agent frameworks", + "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", "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 +364,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); 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 ffbf5c1..102247d 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 0000000..0a91fe5 --- /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 0000000..19f9709 --- /dev/null +++ b/packages/commands/tests/assets.test.ts @@ -0,0 +1,74 @@ +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) => { + 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/config-ui.test.ts b/packages/commands/tests/config-ui.test.ts index 5a91559..b3eeb6d 100644 --- a/packages/commands/tests/config-ui.test.ts +++ b/packages/commands/tests/config-ui.test.ts @@ -81,6 +81,24 @@ 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"); + // 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", + ); }); }); @@ -128,44 +146,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 0000000..24a575e --- /dev/null +++ b/packages/commands/tests/inventory.test.ts @@ -0,0 +1,160 @@ +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", + }); + 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); + }); +});