mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
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
This commit is contained in:
@@ -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. 完成检查
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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<boolean> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<LaunchResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, AssetKind> = {
|
||||
".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<string, string> = {
|
||||
".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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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<string, unknown> | 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<string, unknown>)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// ---- Skills ----
|
||||
|
||||
/** Extract the leading `--- ... ---` YAML frontmatter block from a SKILL.md. */
|
||||
function parseFrontmatter(md: string): Record<string, unknown> {
|
||||
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<string, SkillInfo>();
|
||||
|
||||
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<string, unknown>): {
|
||||
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.<name>] TOML tables.
|
||||
const codexToml = readText(join(home, ".codex", "config.toml"));
|
||||
if (codexToml) {
|
||||
try {
|
||||
const parsed = parseToml(codexToml) as Record<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown> = {};
|
||||
try {
|
||||
config = parseToml(text) as Record<string, unknown>;
|
||||
} 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,79 @@ export const SECRET_KEYS = new Set<string>([
|
||||
"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<string, string[]> = {
|
||||
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<string>(["telemetry"]);
|
||||
|
||||
// Default model each `default_*_model` key falls back to when left unset. These
|
||||
// mirror the inline `|| "<model>"` 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<string, string> = {
|
||||
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<string, ModelOption[]> = {
|
||||
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<string, string> = {
|
||||
"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,
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string> {
|
||||
}
|
||||
|
||||
/** Build the request cleaned/validated config block from a posted `data` map. */
|
||||
function buildProfilePatch(data: Record<string, unknown>): Record<string, string | number> {
|
||||
const cleaned: Record<string, string | number> = {};
|
||||
function buildProfilePatch(
|
||||
data: Record<string, unknown>,
|
||||
): Record<string, string | number | boolean> {
|
||||
const cleaned: Record<string, string | number | boolean> = {};
|
||||
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<string, unknown>): Record<string, string
|
||||
/** Preserve valid Config fields that the UI does not expose or manage. */
|
||||
function mergeUnmanagedProfileFields(
|
||||
existing: Record<string, unknown>,
|
||||
managedPatch: Record<string, string | number>,
|
||||
managedPatch: Record<string, string | number | boolean>,
|
||||
): Record<string, unknown> {
|
||||
const managedKeys = new Set<string>(VALID_KEYS);
|
||||
const managedKeys = new Set<string>(UI_VALID_KEYS);
|
||||
const merged: Record<string, unknown> = {};
|
||||
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<string, string | number>;
|
||||
let cleaned: Record<string, string | number | boolean>;
|
||||
try {
|
||||
normalized = normalizeConfigName(body.name);
|
||||
cleaned = buildProfilePatch(body.data as Record<string, unknown>);
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,11 +22,15 @@ export function listenLocalServer(server: http.Server, port = 0): Promise<number
|
||||
});
|
||||
}
|
||||
|
||||
/** Open a URL in the user's default browser (best-effort, cross-platform). */
|
||||
export function openInBrowser(url: string): Promise<void> {
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Open a URL in the user's default browser (best-effort, cross-platform). */
|
||||
export function openInBrowser(url: string): Promise<void> {
|
||||
return openPath(url);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user