feat(config-ui): MCP management, skill zip install, and UI polish

- MCP: editable JSON config in the detail drawer with secret masking and
  mask-preserving writes; create/update/delete across claude-code, qwen-code,
  opencode, cursor, windsurf, gemini, qoderwork, openclaw and Claude Desktop
- Skills: upload a .zip and install into any agent's skills root (self-contained
  ZIP reader, zip-slip safe); scan more roots (openclaw workspace, qoderwork,
  windsurf/codeium, gemini antigravity, workbuddy)
- Markdown: GFM table rendering in the skill detail drawer
- Layout: collapsible grouped sidebar with icons + persistent state, responsive
  breakpoint, wider main, single-line tile titles, 2-line description clamp,
  round icon run buttons, custom file picker, modal spacing
- Server: /api/mcp POST/DELETE, /api/skill/install, binary upload reader,
  constant-time token compare, CSP/no-store headers, error logging
This commit is contained in:
inhai
2026-07-23 10:52:26 +08:00
parent 9ab5de8c2e
commit e1caee99f2
14 changed files with 3524 additions and 240 deletions
@@ -0,0 +1,79 @@
import { maskToken, type AuthStore, type Identity, type Settings } from "bailian-cli-core";
import { runConsoleLogin, resolveConsoleOrigin } from "./login-console.ts";
/** Read-only auth snapshot the config UI account widget renders. bl stores no
* user profile (name/avatar), so this exposes only which credential domains
* resolve, the console region/site, and a masked token. */
export interface AuthUiStatus {
authenticated: boolean;
methods: { apiKey: boolean; console: boolean; openapi: boolean };
primary: "console" | "apiKey" | "openapi" | null;
region?: string;
site?: "domestic" | "international";
masked?: string;
}
/**
* The auth capability surface the config UI is allowed to use. All `authStore`
* access is kept inside this module (commands/auth/**), which the lint boundary
* permits; commands/config/** consumes only this opaque bridge and never
* touches `authStore` directly.
*/
export interface AuthUiBridge {
status(): AuthUiStatus;
/** Start browser-based console login (fire-and-forget; UI polls status). */
startConsoleLogin(): void;
/** Clear all stored credentials. Returns whether anything changed. */
logout(): Promise<boolean>;
}
/** Build the bridge from a command context (identity/settings/authStore). */
export function makeAuthUiBridge(ctx: {
identity: Identity;
settings: Settings;
authStore: AuthStore;
}): AuthUiBridge {
const { identity, settings, authStore } = ctx;
return {
status() {
const a = authStore.describe();
const methods = { apiKey: !!a.apiKey, console: !!a.console, openapi: !!a.openapi };
let masked: string | undefined;
if (a.console) masked = maskToken(a.console.token);
else if (a.apiKey) masked = maskToken(a.apiKey.token);
else if (a.openapi) masked = maskToken(a.openapi.accessKeyId);
const primary = a.console ? "console" : a.apiKey ? "apiKey" : a.openapi ? "openapi" : null;
return {
authenticated: methods.apiKey || methods.console || methods.openapi,
methods,
primary,
region: a.console?.region,
site: a.console?.site,
masked,
};
},
startConsoleLogin() {
const origin = resolveConsoleOrigin(authStore.describe().console?.site);
// Mirror the CLI (`bl auth login --console`): request an api_key from the
// console only when one isn't already stored, so a first console login in
// the config UI also provisions the model api_key (not just access_token).
const hasApiKey = !!authStore.stored().apiKey;
// runConsoleLogin opens the browser and runs its own callback server
// (up to 15 min). We don't await it — the config UI polls the status
// endpoint to detect completion. Errors are logged, not surfaced.
void runConsoleLogin(
origin,
{ identity, settings, authStore },
{
needApiKey: !hasApiKey,
},
).catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
process.stderr.write(`console login failed: ${msg}\n`);
});
},
logout() {
return authStore.logout("all");
},
};
}
@@ -22,6 +22,25 @@ export function agentCommand(id: string): string | undefined {
return Object.prototype.hasOwnProperty.call(AGENT_COMMANDS, id) ? AGENT_COMMANDS[id] : undefined;
}
/**
* Per-agent argv that passes an initial task prompt while keeping the agent
* interactive in the terminal. Only verified contracts are listed; an agent
* absent here cannot be dispatched a prompt (its bare launch still works).
* - qwen-code: `qwen -i "<prompt>"` (execute prompt, stay interactive)
* - claude-code: `claude "<prompt>"` (positional initial prompt)
* - codex: `codex "<prompt>"` (positional initial prompt)
*/
const AGENT_PROMPT_ARGV: Record<string, (prompt: string) => string[]> = {
"qwen-code": (p) => ["-i", p],
"claude-code": (p) => [p],
codex: (p) => [p],
};
/** Whether a known agent supports being dispatched an initial task prompt. */
export function agentSupportsPrompt(id: string): boolean {
return Object.prototype.hasOwnProperty.call(AGENT_PROMPT_ARGV, id);
}
/** Resolve whether a binary is reachable on PATH (via `which`/`where`). */
function onPath(bin: string): Promise<boolean> {
const cmd = process.platform === "win32" ? "where" : "which";
@@ -82,16 +101,31 @@ export interface LaunchResult {
}
/**
* 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.
* Launch a known coding agent's local CLI in a new terminal window. When
* `prompt` is provided, it is passed as a single quoted argument using the
* agent's verified prompt contract so the agent starts with that task.
* Rejects when the id is unknown, the binary is missing from PATH, the agent
* does not support prompt dispatch, or the platform terminal could not open.
*/
export async function launchAgent(id: string, cwd: string = process.cwd()): Promise<LaunchResult> {
export async function launchAgent(
id: string,
cwd: string = process.cwd(),
prompt?: string,
): 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 };
let fullCommand = command;
const task = (prompt ?? "").trim();
if (task) {
const build = AGENT_PROMPT_ARGV[id];
if (!build) throw new Error(`${id} does not support dispatching a task prompt.`);
// shQuote keeps the whole prompt as one shell argument (no injection); the
// platform terminal layer escapes the resulting command line separately.
fullCommand = [command, ...build(task).map(shQuote)].join(" ");
}
await spawnTerminal(fullCommand, cwd);
return { launched: true, command: fullCommand };
}
+22 -32
View File
@@ -1,8 +1,10 @@
// 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.
// Generated media may live directly under the base or in any subfolder (bl's
// own images/, videos/, speech/, omni/, or user-created folders). This module
// recursively discovers every file under the base, classifies each by type,
// derives its category from the top-level folder, and provides safe path
// resolution for serving/deleting individual assets.
import { readdirSync, statSync, existsSync, type Dirent } from "node:fs";
import { homedir } from "node:os";
import { join, extname, relative, resolve, sep } from "node:path";
@@ -23,8 +25,8 @@ export interface AssetInfo {
ext: string;
}
/** Category subdirectories that `bl` writes generated media into. */
const CATEGORY_DIRS = ["images", "videos", "speech", "omni"] as const;
/** Max directory depth to descend from the output base when scanning. */
const MAX_SCAN_DEPTH = 8;
const KIND_BY_EXT: Record<string, AssetKind> = {
".png": "image",
@@ -101,9 +103,11 @@ function walk(dir: string, depth: number, out: string[]): void {
}
/**
* 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.
* List generated assets under `base`, newest first. Recursively scans every
* subfolder under the base (plus loose files at the root), so assets in bl's
* own category dirs and any user-created folders are all discovered. Each
* file's `category` is its top-level folder name, or "other" for root files.
* Returns the resolved base so callers can surface it in the UI.
*/
export function listAssets(base: string = defaultOutputBase()): {
base: string;
@@ -112,44 +116,30 @@ export function listAssets(base: string = defaultOutputBase()): {
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);
const files: string[] = [];
walk(base, MAX_SCAN_DEPTH, files);
for (const full of files) {
let st;
try {
st = statSync(full);
} catch {
return;
continue;
}
if (!st.isFile()) return;
if (!st.isFile()) continue;
const rel = relative(base, full);
const segments = rel.split(sep);
const category = segments.length > 1 ? segments[0]! : "other";
const ext = extname(full);
assets.push({
name: full.split(sep).pop() ?? full,
category,
kind: kindOf(ext),
relPath: relative(base, full),
relPath: rel,
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);
@@ -7,8 +7,16 @@
// 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 { dirname, join, resolve, sep } from "node:path";
import {
existsSync,
mkdirSync,
readFileSync,
readdirSync,
writeFileSync,
type Dirent,
} from "node:fs";
import { inflateRawSync } from "node:zlib";
import yaml from "yaml";
import { parse as parseToml } from "smol-toml";
@@ -41,6 +49,10 @@ export interface McpServerInfo {
scope: string;
/** local (on disk) or remote (loaded from a URL). */
origin: ItemOrigin;
/** Raw config entry for this server, with secret-looking values masked. */
config?: Record<string, unknown>;
/** Whether this entry lives in a JSON config we can edit/write back here. */
editable: boolean;
}
/** A coding agent framework and its local configuration state. */
@@ -51,6 +63,7 @@ export interface AgentInfo {
configured: boolean;
model?: string;
paths: string[];
origin: ItemOrigin;
}
function readText(path: string): string | undefined {
@@ -128,9 +141,14 @@ function skillRoots(home: string): Array<{ source: string; dir: string }> {
{ source: "codex", dir: join(home, ".codex", "skills") },
{ source: "opencode", dir: join(home, ".config", "opencode", "skills") },
{ source: "openclaw", dir: join(home, ".openclaw", "skills") },
{ source: "openclaw", dir: join(home, ".openclaw", "workspace", "skills") },
{ source: "hermes", dir: join(home, ".hermes", "skills") },
{ source: "gemini", dir: join(home, ".gemini", "skills") },
{ source: "antigravity", dir: join(home, ".gemini", "antigravity", "skills") },
{ source: "windsurf", dir: join(home, ".windsurf", "skills") },
{ source: "windsurf", dir: join(home, ".codeium", "windsurf", "skills") },
{ source: "qoderwork", dir: join(home, ".qoderwork", "skills") },
{ source: "workbuddy", dir: join(home, ".workbuddy", "skills") },
];
}
@@ -207,6 +225,140 @@ export function getSkillDetail(id: string, home: string = homedir()): SkillDetai
return { ...skill, content };
}
// ---- Skill install (upload a .zip and unpack it into a skill root) ----
/** Allow-listed skill install targets: source id -> label + path segments. */
const SKILL_INSTALL_TARGETS: Array<{ source: string; label: string; sub: string[] }> = [
{ source: "global", label: "All agents (~/.agents/skills)", sub: [".agents", "skills"] },
{ source: "claude-code", label: "Claude Code", sub: [".claude", "skills"] },
{ source: "qwen-code", label: "Qwen Code", sub: [".qwen", "skills"] },
{ source: "codex", label: "Codex", sub: [".codex", "skills"] },
{ source: "opencode", label: "OpenCode", sub: [".config", "opencode", "skills"] },
{ source: "openclaw", label: "OpenClaw", sub: [".openclaw", "skills"] },
{ source: "qoderwork", label: "QoderWork", sub: [".qoderwork", "skills"] },
{ source: "windsurf", label: "Windsurf", sub: [".codeium", "windsurf", "skills"] },
{ source: "gemini", label: "Gemini", sub: [".gemini", "skills"] },
];
/** The list of install targets exposed to the UI (source + human label). */
export function skillInstallTargets(): Array<{ source: string; label: string }> {
return SKILL_INSTALL_TARGETS.map((t) => ({ source: t.source, label: t.label }));
}
function skillInstallRoot(source: string, home: string): string | null {
const t = SKILL_INSTALL_TARGETS.find((x) => x.source === source);
return t ? join(home, ...t.sub) : null;
}
interface ZipEntry {
name: string;
data: Buffer;
}
/**
* Minimal ZIP reader (no external deps). Walks the central directory for
* correct sizes/offsets, then inflates each entry (stored or deflate). Zip64
* and encryption are unsupported and will throw. Sufficient for skill packages.
*/
function parseZip(buf: Buffer): ZipEntry[] {
const EOCD_SIG = 0x06054b50;
let eocd = -1;
const minStart = Math.max(0, buf.length - 22 - 0xffff);
for (let i = buf.length - 22; i >= minStart; i--) {
if (buf.readUInt32LE(i) === EOCD_SIG) {
eocd = i;
break;
}
}
if (eocd < 0) throw new Error("Not a valid .zip archive.");
const count = buf.readUInt16LE(eocd + 10);
let off = buf.readUInt32LE(eocd + 16);
const entries: ZipEntry[] = [];
for (let e = 0; e < count; e++) {
if (off + 46 > buf.length || buf.readUInt32LE(off) !== 0x02014b50) break;
const method = buf.readUInt16LE(off + 10);
const compSize = buf.readUInt32LE(off + 20);
const nameLen = buf.readUInt16LE(off + 28);
const extraLen = buf.readUInt16LE(off + 30);
const commentLen = buf.readUInt16LE(off + 32);
const localOff = buf.readUInt32LE(off + 42);
const name = buf.toString("utf8", off + 46, off + 46 + nameLen);
off += 46 + nameLen + extraLen + commentLen;
if (name.endsWith("/")) continue;
if (localOff + 30 > buf.length || buf.readUInt32LE(localOff) !== 0x04034b50) continue;
const lNameLen = buf.readUInt16LE(localOff + 26);
const lExtraLen = buf.readUInt16LE(localOff + 28);
const dataStart = localOff + 30 + lNameLen + lExtraLen;
const raw = buf.subarray(dataStart, dataStart + compSize);
let data: Buffer;
if (method === 0) data = Buffer.from(raw);
else if (method === 8) data = inflateRawSync(raw);
else throw new Error("Unsupported compression in archive (entry: " + name + ").");
entries.push({ name, data });
}
return entries;
}
/**
* Install a skill from an uploaded .zip into the chosen agent's skills root.
* The archive must contain a SKILL.md at its root or inside one top-level
* folder. Entry paths are sanitized (no absolute paths, no `..`) and every
* write is confined to the target skill directory (zip-slip safe).
*/
export function installSkillZip(
source: string,
zip: Buffer,
nameHint: string,
home: string = homedir(),
): { installed: string; files: number; dir: string } {
const root = skillInstallRoot(source, home);
if (!root) throw new Error("Unknown skill install target.");
const norm = parseZip(zip)
.map((e) => ({
name: e.name.replace(/\\/g, "/").replace(/^\.\//, "").replace(/^\/+/, ""),
data: e.data,
}))
.filter((e) => e.name && !e.name.endsWith("/") && !e.name.split("/").includes(".."));
if (!norm.length) throw new Error("The archive contains no usable files.");
const skillMd = norm.find((e) => e.name === "SKILL.md" || e.name.endsWith("/SKILL.md"));
if (!skillMd) throw new Error("No SKILL.md found in the archive (root or a top-level folder).");
const slash = skillMd.name.lastIndexOf("/");
let skillName: string;
let prefix: string;
if (slash < 0) {
skillName = (nameHint || "").trim();
if (!skillName)
throw new Error("SKILL.md is at the archive root \u2014 please provide a skill name.");
prefix = "";
} else {
prefix = skillMd.name.slice(0, slash + 1);
skillName = (nameHint || "").trim() || prefix.split("/")[0];
}
if (!/^[A-Za-z0-9._-]+$/.test(skillName)) throw new Error("Invalid skill name: " + skillName);
const targetDir = join(root, skillName);
const base = resolve(targetDir);
let files = 0;
for (const e of norm) {
let rel = e.name;
if (prefix) {
if (!e.name.startsWith(prefix)) continue;
rel = e.name.slice(prefix.length);
}
if (!rel) continue;
const abs = resolve(join(targetDir, rel));
if (abs !== base && !abs.startsWith(base + sep)) continue; // zip-slip guard
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, e.data);
files++;
}
if (files === 0) throw new Error("Nothing was extracted from the archive.");
return { installed: skillName, files, dir: targetDir };
}
// ---- MCP servers ----
function transportOf(entry: Record<string, unknown>): {
@@ -225,13 +377,28 @@ function transportOf(entry: Record<string, unknown>): {
return { transport: "unknown", detail: "" };
}
function collectMcpMap(raw: unknown, source: string, scope: string, out: McpServerInfo[]): void {
function collectMcpMap(
raw: unknown,
source: string,
scope: string,
out: McpServerInfo[],
editable: boolean,
): void {
const map = asRecord(raw);
if (!map) return;
for (const [name, value] of Object.entries(map)) {
const entry = asRecord(value) ?? {};
const { transport, detail } = transportOf(entry);
out.push({ name, source, transport, detail, scope, origin: "local" });
out.push({
name,
source,
transport,
detail,
scope,
origin: "local",
config: maskConfigSecrets(entry) as Record<string, unknown>,
editable,
});
}
}
@@ -242,30 +409,51 @@ export function listMcpServers(home: string = homedir()): 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);
collectMcpMap(claude.mcpServers, "claude-code", "global", out, true);
const projects = asRecord(claude.projects);
if (projects) {
for (const [projectPath, projectValue] of Object.entries(projects)) {
const project = asRecord(projectValue);
if (project?.mcpServers) collectMcpMap(project.mcpServers, "claude-code", projectPath, out);
if (project?.mcpServers)
collectMcpMap(project.mcpServers, "claude-code", projectPath, out, true);
}
}
}
// Qwen Code.
const qwen = readJsonSafe(join(home, ".qwen", "settings.json"));
if (qwen) collectMcpMap(qwen.mcpServers, "qwen-code", "global", out);
if (qwen) collectMcpMap(qwen.mcpServers, "qwen-code", "global", out, true);
// OpenCode uses `mcp` rather than `mcpServers`.
const opencode = readJsonSafe(join(home, ".config", "opencode", "opencode.json"));
if (opencode) collectMcpMap(opencode.mcp, "opencode", "global", out);
if (opencode) collectMcpMap(opencode.mcp, "opencode", "global", out, true);
// Cursor, Windsurf, Gemini, QoderWork, OpenClaw, Claude Desktop: all JSON with
// a top-level `mcpServers` map (Claude/Cursor convention).
const cursor = readJsonSafe(join(home, ".cursor", "mcp.json"));
if (cursor) collectMcpMap(cursor.mcpServers, "cursor", "global", out, true);
const windsurf = readJsonSafe(join(home, ".codeium", "windsurf", "mcp_config.json"));
if (windsurf) collectMcpMap(windsurf.mcpServers, "windsurf", "global", out, true);
const gemini = readJsonSafe(join(home, ".gemini", "settings.json"));
if (gemini) collectMcpMap(gemini.mcpServers, "gemini", "global", out, true);
const qoder = readJsonSafe(join(home, ".qoderwork", "mcp.json"));
if (qoder) collectMcpMap(qoder.mcpServers, "qoderwork", "global", out, true);
const openclaw = readJsonSafe(join(home, ".openclaw", "openclaw.json"));
if (openclaw) collectMcpMap(openclaw.mcpServers, "openclaw", "global", out, true);
const claudeDesktop = readJsonSafe(claudeDesktopConfigPath(home));
if (claudeDesktop) collectMcpMap(claudeDesktop.mcpServers, "claude-desktop", "global", out, true);
// Codex declares servers as [mcp_servers.<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);
collectMcpMap(parsed.mcp_servers, "codex", "global", out, false);
} catch {
/* ignore malformed toml */
}
@@ -274,6 +462,172 @@ export function listMcpServers(home: string = homedir()): McpServerInfo[] {
return out.sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source));
}
// ---- MCP write-back (edit / add / delete) ----
const MCP_MASK_CHAR = "\u2022";
/** Platform-specific Claude Desktop config path. */
function claudeDesktopConfigPath(home: string): string {
if (process.platform === "darwin")
return join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
if (process.platform === "win32")
return join(
process.env.APPDATA ?? join(home, "AppData", "Roaming"),
"Claude",
"claude_desktop_config.json",
);
return join(home, ".config", "Claude", "claude_desktop_config.json");
}
interface McpWriteTarget {
file: string;
mapKey: string;
/** Claude stores project-scoped servers under projects[scope][mapKey]. */
projectScoped: boolean;
}
/**
* Map a (source, scope) pair to the JSON config file and map key to edit. Only
* JSON-backed sources are writable; Codex (TOML) and unknown sources return
* null so the UI keeps them read-only.
*/
function mcpWriteTarget(source: string, scope: string, home: string): McpWriteTarget | null {
if (source === "claude-code")
return {
file: join(home, ".claude.json"),
mapKey: "mcpServers",
projectScoped: scope !== "global",
};
if (source === "qwen-code")
return {
file: join(home, ".qwen", "settings.json"),
mapKey: "mcpServers",
projectScoped: false,
};
if (source === "opencode")
return {
file: join(home, ".config", "opencode", "opencode.json"),
mapKey: "mcp",
projectScoped: false,
};
if (source === "cursor")
return { file: join(home, ".cursor", "mcp.json"), mapKey: "mcpServers", projectScoped: false };
if (source === "windsurf")
return {
file: join(home, ".codeium", "windsurf", "mcp_config.json"),
mapKey: "mcpServers",
projectScoped: false,
};
if (source === "gemini")
return {
file: join(home, ".gemini", "settings.json"),
mapKey: "mcpServers",
projectScoped: false,
};
if (source === "qoderwork")
return {
file: join(home, ".qoderwork", "mcp.json"),
mapKey: "mcpServers",
projectScoped: false,
};
if (source === "openclaw")
return {
file: join(home, ".openclaw", "openclaw.json"),
mapKey: "mcpServers",
projectScoped: false,
};
if (source === "claude-desktop")
return { file: claudeDesktopConfigPath(home), mapKey: "mcpServers", projectScoped: false };
return null;
}
/**
* Merge a submitted config with the stored one: any string still carrying the
* mask char is treated as unchanged and restored from `stored`. Throws if a
* masked value has no stored counterpart, so bullet placeholders are never
* persisted as a real secret.
*/
function unmaskMcpConfig(submitted: unknown, stored: unknown): unknown {
if (typeof submitted === "string") {
if (submitted.includes(MCP_MASK_CHAR)) {
if (typeof stored === "string") return stored;
throw new Error(
"Replace masked values (\u2022\u2022\u2022) with the real value before saving.",
);
}
return submitted;
}
if (Array.isArray(submitted)) {
const arr = Array.isArray(stored) ? stored : [];
return submitted.map((v, i) => unmaskMcpConfig(v, arr[i]));
}
const rec = asRecord(submitted);
if (rec) {
const storedRec = asRecord(stored) ?? {};
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(rec)) out[k] = unmaskMcpConfig(v, storedRec[k]);
return out;
}
return submitted;
}
/** Create or update one MCP server entry, writing back to its source file. */
export function writeMcpServer(
source: string,
scope: string,
name: string,
config: unknown,
home: string = homedir(),
): void {
const target = mcpWriteTarget(source, scope, home);
if (!target) throw new Error("This MCP source is read-only and cannot be edited here.");
const trimmed = name.trim();
if (!trimmed) throw new Error("Server name is required.");
const cfg = asRecord(config);
if (!cfg) throw new Error("Config must be a JSON object.");
const root = readJsonSafe(target.file) ?? {};
let container: Record<string, unknown> = root;
if (target.projectScoped) {
const projects = asRecord(root.projects) ?? {};
root.projects = projects;
const proj = asRecord(projects[scope]) ?? {};
projects[scope] = proj;
container = proj;
}
const map = asRecord(container[target.mapKey]) ?? {};
container[target.mapKey] = map;
map[trimmed] = unmaskMcpConfig(cfg, asRecord(map[trimmed]));
mkdirSync(dirname(target.file), { recursive: true });
writeFileSync(target.file, JSON.stringify(root, null, 2) + "\n", "utf-8");
}
/** Remove one MCP server entry from its source file. */
export function deleteMcpServer(
source: string,
scope: string,
name: string,
home: string = homedir(),
): void {
const target = mcpWriteTarget(source, scope, home);
if (!target) throw new Error("This MCP source is read-only and cannot be edited here.");
const root = readJsonSafe(target.file);
if (!root) throw new Error("Config file not found.");
let container: Record<string, unknown> | undefined = root;
if (target.projectScoped) {
const projects = asRecord(root.projects);
container = projects ? asRecord(projects[scope]) : undefined;
}
const map = container ? asRecord(container[target.mapKey]) : undefined;
if (!map || !(name in map)) throw new Error("Server not found: " + name);
delete map[name];
// Don't leave an empty map behind if this was the last server.
if (container && Object.keys(map).length === 0) delete container[target.mapKey];
writeFileSync(target.file, JSON.stringify(root, null, 2) + "\n", "utf-8");
}
// ---- Agent frameworks ----
interface AgentProbe {
@@ -410,6 +764,192 @@ export function listAgents(home: string = homedir()): AgentInfo[] {
configured,
model,
paths,
origin: "local",
};
});
}
/** A single displayed config field for an agent's detail view. */
export interface AgentField {
label: string;
value: string;
/** Sensitive value: the server masks it before returning. */
secret?: boolean;
/** Unmasked value for secrets, revealed on demand (localhost only). */
raw?: string;
}
/** Raw content of one of the agent's config files. */
export interface AgentSettingsFile {
path: string;
lang: string;
text: string;
}
/** Full detail for one agent: extracted config fields plus its config files. */
export interface AgentDetail extends AgentInfo {
fields: AgentField[];
files: Array<{ path: string; exists: boolean }>;
settings: AgentSettingsFile[];
}
function pushField(out: AgentField[], label: string, value: unknown, secret = false): void {
if (typeof value === "string" && value.trim()) out.push({ label, value: value.trim(), secret });
}
/**
* Extract the bailian-cli connection fields (model, base URL, credential, ...)
* an agent has written into its local config. Values are raw here; secrets are
* masked by `getAgentDetail` before leaving the server. Read-only.
*/
function agentDetailFields(id: string, home: string): AgentField[] {
const out: AgentField[] = [];
if (id === "claude-code") {
const env = asRecord(readJsonSafe(join(home, ".claude", "settings.json"))?.env);
pushField(out, "Model", env?.ANTHROPIC_MODEL);
pushField(out, "Base URL", env?.ANTHROPIC_BASE_URL);
pushField(out, "Auth Token", env?.ANTHROPIC_AUTH_TOKEN, true);
} else if (id === "qwen-code") {
const s = readJsonSafe(join(home, ".qwen", "settings.json"));
const model = asRecord(s?.model);
const auth = asRecord(asRecord(s?.security)?.auth);
const env = asRecord(s?.env);
pushField(out, "Model", model?.name);
pushField(out, "Base URL", auth?.baseUrl ?? model?.baseUrl);
pushField(out, "Protocol", auth?.selectedType);
pushField(out, "API Key", auth?.apiKey ?? env?.BAILIAN_CLI_API_KEY, true);
} else if (id === "opencode") {
const provider = asRecord(
readJsonSafe(join(home, ".config", "opencode", "opencode.json"))?.provider,
);
const bailian = asRecord(provider?.["bailian-cli"]);
const options = asRecord(bailian?.options);
const models = asRecord(bailian?.models);
pushField(out, "Model", models ? Object.keys(models)[0] : undefined);
pushField(out, "Base URL", options?.baseURL);
pushField(out, "Provider", bailian?.npm);
pushField(out, "API Key", options?.apiKey, true);
} else if (id === "openclaw") {
const models = asRecord(readJsonSafe(join(home, ".openclaw", "openclaw.json"))?.models);
const bailian = asRecord(asRecord(models?.providers)?.["bailian-cli"]);
const list = Array.isArray(bailian?.models) ? bailian.models : [];
const first = asRecord(list[0]);
pushField(out, "Model", first?.id);
pushField(out, "Base URL", bailian?.baseUrl);
pushField(out, "API", bailian?.api);
pushField(out, "API Key", bailian?.apiKey, true);
} else if (id === "hermes") {
const text = readText(join(home, ".hermes", "config.yaml"));
let config: Record<string, unknown> | undefined;
if (text) {
try {
config = asRecord(yaml.parse(text));
} catch {
config = undefined;
}
}
const providers = Array.isArray(config?.custom_providers) ? config.custom_providers : [];
const pr = asRecord(providers.find((e: unknown) => asRecord(e)?.name === "bailian-cli"));
const list = Array.isArray(pr?.models) ? pr.models : [];
const first = asRecord(list[0]);
pushField(out, "Model", first?.id ?? asRecord(config?.model)?.default);
pushField(out, "Base URL", pr?.base_url);
pushField(out, "API Mode", pr?.api_mode);
pushField(out, "API Key", pr?.api_key, true);
} else if (id === "codex") {
const text = readText(join(home, ".codex", "config.toml"));
let config: Record<string, unknown> = {};
if (text) {
try {
config = parseToml(text) as Record<string, unknown>;
} catch {
config = {};
}
}
const bailian = asRecord(asRecord(config.model_providers)?.["bailian-cli"]);
const auth = readJsonSafe(join(home, ".codex", "auth.json"));
pushField(out, "Model", config.model);
pushField(out, "Base URL", bailian?.base_url);
pushField(out, "Wire API", bailian?.wire_api);
pushField(out, "API Key", auth?.OPENAI_API_KEY, true);
}
return out;
}
/** Mask a secret so its presence is visible but the value is not disclosed. */
function maskSecret(value: string): string {
const v = value.trim();
if (v.length <= 8) return "\u2022".repeat(Math.max(v.length, 4));
return v.slice(0, 3) + "\u2022".repeat(Math.min(v.length - 3, 16));
}
/** Config keys whose string value is treated as a secret and masked. */
const SECRET_KEY_RE = /key|token|secret|password|passwd|auth|credential/i;
/**
* Deep-clone a config entry, masking any string value whose key name looks
* like a secret (API keys, tokens, Authorization headers, ...). Empty strings
* are left untouched so placeholders like "AMAP_MAPS_API_KEY": "" stay visible.
*/
function maskConfigSecrets(value: unknown, keyName = ""): unknown {
if (typeof value === "string") {
return keyName && SECRET_KEY_RE.test(keyName) && value.trim() ? maskSecret(value) : value;
}
if (Array.isArray(value)) return value.map((v) => maskConfigSecrets(v));
const rec = asRecord(value);
if (rec) {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(rec)) out[k] = maskConfigSecrets(v, k);
return out;
}
return value;
}
/** Guess a syntax-highlight language for a config file from its extension. */
function langForPath(p: string): string {
if (p.endsWith(".json")) return "json";
if (p.endsWith(".yaml") || p.endsWith(".yml")) return "yaml";
if (p.endsWith(".toml")) return "toml";
return "text";
}
/**
* Full detail for one coding agent by id: normalized config fields plus its
* config file list and raw file contents. Secret values (API keys/tokens) are
* masked in `fields` but their unmasked form is included as `raw` for on-demand
* reveal; raw file contents are returned verbatim. Localhost-only. Returns null
* for an unknown id.
*/
export function getAgentDetail(id: string, home: string = homedir()): AgentDetail | null {
const probe = AGENT_PROBES.find((p) => p.id === id);
if (!probe) return null;
const paths = probe.paths(home);
const installed = paths.some((p) => existsSync(p));
const { configured, model } = installed
? probe.detect(home)
: { configured: false, model: undefined };
const fields = installed
? agentDetailFields(id, home).map((f) =>
f.secret ? { ...f, raw: f.value, value: maskSecret(f.value) } : f,
)
: [];
const files = paths.map((p) => ({ path: p, exists: existsSync(p) }));
const settings: AgentSettingsFile[] = installed
? paths
.filter((p) => existsSync(p))
.map((p) => ({ path: p, lang: langForPath(p), text: readText(p) ?? "" }))
.filter((s) => s.text.trim())
: [];
return {
id: probe.id,
label: probe.label,
installed,
configured,
model,
paths,
origin: "local",
fields,
files,
settings,
};
}
+353
View File
@@ -0,0 +1,353 @@
/**
* Minimal, dependency-free QR Code encoder used by the config UI to show a
* scannable code for the current session URL.
*
* Scope is deliberately narrow: byte mode, error-correction level L, versions
* 1–5 (21x21 … 37x37). Restricting to level L keeps every supported version a
* single Reed–Solomon block, so no codeword interleaving is required. Version 5
* (level L) holds up to 108 data bytes, comfortably more than a
* `http://127.0.0.1:<port>/?token=<hex>` URL.
*
* The output is an SVG string with a 4-module quiet zone and a `viewBox` only
* (no fixed width/height), so the caller sizes it via CSS.
*/
// --- GF(256) arithmetic (primitive polynomial 0x11D) ---
const EXP = new Uint8Array(512);
const LOG = new Uint8Array(256);
(() => {
let x = 1;
for (let i = 0; i < 255; i++) {
EXP[i] = x;
LOG[x] = i;
x <<= 1;
if (x & 0x100) x ^= 0x11d;
}
for (let i = 255; i < 512; i++) EXP[i] = EXP[i - 255];
})();
function gmul(a: number, b: number): number {
if (a === 0 || b === 0) return 0;
return EXP[LOG[a] + LOG[b]];
}
/** Reed–Solomon generator polynomial for `degree` EC codewords (alpha exponents). */
export function rsGeneratorExp(degree: number): number[] {
let poly = [1];
for (let i = 0; i < degree; i++) {
const next: number[] = Array.from({ length: poly.length + 1 }, () => 0);
for (let j = 0; j < poly.length; j++) {
next[j] ^= poly[j];
next[j + 1] ^= gmul(poly[j], EXP[i]);
}
poly = next;
}
return poly.map((v) => LOG[v]);
}
/** Compute `ecLen` Reed–Solomon error-correction codewords for `data`. */
export function rsEncode(data: number[], ecLen: number): number[] {
const gen = rsGeneratorExp(ecLen);
const res = new Uint8Array(data.length + ecLen);
res.set(data, 0);
for (let i = 0; i < data.length; i++) {
const coef = res[i];
if (coef !== 0) {
const lead = LOG[coef];
for (let j = 0; j < gen.length; j++) res[i + j] ^= EXP[(gen[j] + lead) % 255];
}
}
return Array.from(res.slice(data.length));
}
// --- Capacity table: [data codewords, EC codewords] per version at level L ---
const CAP_L: Array<[number, number]> = [
[19, 7], // V1 (21x21)
[34, 10], // V2 (25x25)
[55, 15], // V3 (29x29)
[80, 20], // V4 (33x33)
[108, 26], // V5 (37x37)
];
const EC_BITS_L = 0b01; // format-info error-correction level bits for L
function pickVersion(byteLen: number): number {
const bits = 4 + 8 + byteLen * 8; // mode + 8-bit count (V1–9) + payload
for (let v = 0; v < CAP_L.length; v++) {
if (CAP_L[v][0] * 8 >= bits) return v + 1;
}
throw new Error("qr: data too large for supported versions (max 108 bytes)");
}
// --- Bit/codeword assembly ---
function toCodewords(bytes: Uint8Array, version: number): number[] {
const [dataCw] = CAP_L[version - 1];
const bits: number[] = [];
const put = (val: number, len: number) => {
for (let i = len - 1; i >= 0; i--) bits.push((val >> i) & 1);
};
put(0b0100, 4); // byte mode
put(bytes.length, 8); // character count (versions 1–9)
for (const b of bytes) put(b, 8);
const capBits = dataCw * 8;
put(0, Math.min(4, capBits - bits.length)); // terminator
while (bits.length % 8 !== 0) bits.push(0); // pad to byte
const data: number[] = [];
for (let i = 0; i < bits.length; i += 8) {
let v = 0;
for (let j = 0; j < 8; j++) v = (v << 1) | bits[i + j];
data.push(v);
}
const pads = [0xec, 0x11];
for (let p = 0; data.length < dataCw; p++) data.push(pads[p % 2]);
return data.concat(rsEncode(data, CAP_L[version - 1][1]));
}
// --- Matrix construction ---
interface Grid {
size: number;
mod: Uint8Array; // 0/1
fn: Uint8Array; // 1 = function/reserved module (skip during data placement)
}
function newGrid(size: number): Grid {
return { size, mod: new Uint8Array(size * size), fn: new Uint8Array(size * size) };
}
function setFn(g: Grid, r: number, c: number, dark: number): void {
g.mod[r * g.size + c] = dark;
g.fn[r * g.size + c] = 1;
}
function drawFinder(g: Grid, r: number, c: number): void {
for (let dr = -1; dr <= 7; dr++) {
for (let dc = -1; dc <= 7; dc++) {
const rr = r + dr;
const cc = c + dc;
if (rr < 0 || rr >= g.size || cc < 0 || cc >= g.size) continue;
const inRing = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6;
const isDark =
inRing &&
(dr === 0 ||
dr === 6 ||
dc === 0 ||
dc === 6 ||
(dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4));
setFn(g, rr, cc, isDark ? 1 : 0);
}
}
}
function drawAlignment(g: Grid, cr: number, cc: number): void {
for (let dr = -2; dr <= 2; dr++) {
for (let dc = -2; dc <= 2; dc++) {
const ring = Math.max(Math.abs(dr), Math.abs(dc));
setFn(g, cr + dr, cc + dc, ring === 1 ? 0 : 1);
}
}
}
function drawFunctionPatterns(g: Grid, version: number): void {
const size = g.size;
// Timing patterns.
for (let i = 0; i < size; i++) {
setFn(g, 6, i, i % 2 === 0 ? 1 : 0);
setFn(g, i, 6, i % 2 === 0 ? 1 : 0);
}
// Finder patterns + separators (drawn as the -1 border above).
drawFinder(g, 0, 0);
drawFinder(g, 0, size - 7);
drawFinder(g, size - 7, 0);
// Alignment pattern (single, centered) for versions 2–5.
if (version >= 2) {
const pos = size - 7; // e.g. 18 (V2), 22 (V3), 26 (V4), 30 (V5)
drawAlignment(g, pos, pos);
}
// Reserve format-info areas (values written later).
for (let i = 0; i < 9; i++) {
if (!(i === 6)) g.fn[8 * size + i] = 1;
if (!(i === 6)) g.fn[i * size + 8] = 1;
}
g.fn[8 * size + 6] = 1;
g.fn[6 * size + 8] = 1;
for (let i = 0; i < 8; i++) g.fn[(size - 1 - i) * size + 8] = 1;
for (let i = 0; i < 8; i++) g.fn[8 * size + (size - 1 - i)] = 1;
// Dark module.
setFn(g, size - 8, 8, 1);
}
function placeData(g: Grid, codewords: number[]): void {
const size = g.size;
const stream: number[] = [];
for (const cw of codewords) for (let i = 7; i >= 0; i--) stream.push((cw >> i) & 1);
let idx = 0;
let upward = true;
for (let col = size - 1; col >= 1; col -= 2) {
if (col === 6) col = 5; // skip the vertical timing column
for (let i = 0; i < size; i++) {
const row = upward ? size - 1 - i : i;
for (const off of [0, 1]) {
const cc = col - off;
if (g.fn[row * size + cc]) continue;
g.mod[row * size + cc] = idx < stream.length ? stream[idx++] : 0;
}
}
upward = !upward;
}
}
const MASKS: Array<(r: number, c: number) => boolean> = [
(r, c) => (r + c) % 2 === 0,
(r) => r % 2 === 0,
(_r, c) => c % 3 === 0,
(r, c) => (r + c) % 3 === 0,
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
];
function applyMask(g: Grid, mask: number): void {
const cond = MASKS[mask];
for (let r = 0; r < g.size; r++) {
for (let c = 0; c < g.size; c++) {
if (!g.fn[r * g.size + c] && cond(r, c)) g.mod[r * g.size + c] ^= 1;
}
}
}
function penalty(g: Grid): number {
const size = g.size;
const at = (r: number, c: number) => g.mod[r * size + c];
let score = 0;
// Rule 1: runs of >=5 same-color modules in rows and columns.
for (let r = 0; r < size; r++) {
let runC = 1;
let runR = 1;
for (let c = 1; c < size; c++) {
if (at(r, c) === at(r, c - 1)) runC++;
else {
if (runC >= 5) score += runC - 2;
runC = 1;
}
if (at(c, r) === at(c - 1, r)) runR++;
else {
if (runR >= 5) score += runR - 2;
runR = 1;
}
}
if (runC >= 5) score += runC - 2;
if (runR >= 5) score += runR - 2;
}
// Rule 2: 2x2 blocks of the same color.
for (let r = 0; r < size - 1; r++) {
for (let c = 0; c < size - 1; c++) {
const v = at(r, c);
if (v === at(r, c + 1) && v === at(r + 1, c) && v === at(r + 1, c + 1)) score += 3;
}
}
// Rule 3: finder-like 1:1:3:1:1 patterns.
const pat1 = [1, 0, 1, 1, 1, 0, 1, 0, 0, 0, 0];
const pat2 = [0, 0, 0, 0, 1, 0, 1, 1, 1, 0, 1];
const match = (get: (k: number) => number, start: number, pat: number[]) => {
for (let k = 0; k < pat.length; k++) if (get(start + k) !== pat[k]) return false;
return true;
};
for (let r = 0; r < size; r++) {
for (let c = 0; c <= size - 11; c++) {
if (match((k) => at(r, k), c, pat1) || match((k) => at(r, k), c, pat2)) score += 40;
if (match((k) => at(k, r), c, pat1) || match((k) => at(k, r), c, pat2)) score += 40;
}
}
// Rule 4: proportion of dark modules.
let dark = 0;
for (let i = 0; i < size * size; i++) dark += g.mod[i];
const percent = (dark * 100) / (size * size);
const k = Math.floor(Math.abs(percent - 50) / 5);
score += k * 10;
return score;
}
function formatBits(mask: number): number {
const data = (EC_BITS_L << 3) | mask; // 5 bits
let rem = data << 10;
for (let i = 14; i >= 10; i--) if ((rem >> i) & 1) rem ^= 0x537 << (i - 10);
return ((data << 10) | rem) ^ 0x5412;
}
function drawFormat(g: Grid, mask: number): void {
const size = g.size;
const fmt = formatBits(mask);
const bit = (i: number) => (fmt >> i) & 1;
// First copy: around the top-left finder. Bits 0–5 run down column 8
// (rows 0–5); bits 9–14 run left along row 8 (cols 5–0).
for (let i = 0; i <= 5; i++) g.mod[i * size + 8] = bit(i);
g.mod[7 * size + 8] = bit(6);
g.mod[8 * size + 8] = bit(7);
g.mod[8 * size + 7] = bit(8);
for (let i = 9; i < 15; i++) g.mod[8 * size + (14 - i)] = bit(i);
// Second copy: split across top-right and bottom-left.
for (let i = 0; i < 8; i++) g.mod[(size - 1 - i) * size + 8] = bit(i);
for (let i = 8; i < 15; i++) g.mod[8 * size + (size - 15 + i)] = bit(i);
g.mod[(size - 8) * size + 8] = 1; // dark module stays set
}
/** Build the final QR module matrix (true = dark) for `text`. */
export function qrMatrix(text: string): boolean[][] {
const bytes = new TextEncoder().encode(text);
const version = pickVersion(bytes.length);
const codewords = toCodewords(bytes, version);
const g = newGrid(17 + 4 * version);
drawFunctionPatterns(g, version);
placeData(g, codewords);
let best = 0;
let bestScore = Infinity;
for (let m = 0; m < 8; m++) {
applyMask(g, m);
drawFormat(g, m);
const s = penalty(g);
if (s < bestScore) {
bestScore = s;
best = m;
}
applyMask(g, m); // undo (XOR is its own inverse)
}
applyMask(g, best);
drawFormat(g, best);
const out: boolean[][] = [];
for (let r = 0; r < g.size; r++) {
const row: boolean[] = [];
for (let c = 0; c < g.size; c++) row.push(g.mod[r * g.size + c] === 1);
out.push(row);
}
return out;
}
/** Render `text` as an SVG QR code string (4-module quiet zone, viewBox only). */
export function qrSvg(text: string): string {
const m = qrMatrix(text);
const size = m.length;
const quiet = 4;
const dim = size + quiet * 2;
let rects = "";
for (let r = 0; r < size; r++) {
for (let c = 0; c < size; c++) {
if (m[r][c]) rects += `<rect x="${c + quiet}" y="${r + quiet}" width="1" height="1"/>`;
}
}
return (
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${dim} ${dim}" ` +
`shape-rendering="crispEdges" role="img" aria-label="QR code">` +
`<rect width="${dim}" height="${dim}" fill="#ffffff"/>` +
`<g fill="#000000">${rects}</g></svg>`
);
}
@@ -0,0 +1,166 @@
/**
* Curated "Playground" scenarios surfaced in the config UI.
*
* Each scenario is a fixed, reviewable prompt template that the UI can dispatch
* to a connected local coding agent (e.g. qwen-code), which then runs it in a
* new terminal. Optional `{{inputs}}` are filled by the user before dispatch.
*
* Prompts are defined here and never accepted as free-form text from the web,
* so the instruction handed to a local agent is always known and auditable.
*/
export interface ScenarioInput {
key: string;
label: string;
placeholder?: string;
}
export interface Scenario {
id: string;
title: string;
description: string;
category: string;
prompt: string;
inputs?: ScenarioInput[];
}
export const SCENARIOS: Scenario[] = [
// ---- 图像 ----
{
id: "image-generate",
title: "文生图",
description: "一键生成一张示例图片并保存到输出目录。",
category: "图像",
prompt:
"请使用 bl 的图像生成能力(如 `bl image generate` 命令)生成一张示例图片:一只在雨中撑伞的柯基,水彩风格,光线柔和。保存到输出目录后告诉我文件路径。",
},
{
id: "image-describe",
title: "图片理解",
description: "从输出目录任选一张图片,详细描述内容与风格。",
category: "图像",
prompt:
"请在输出目录(默认 output/images)中任选一张图片,用中文详细描述它的内容、主体、构图、色彩与风格,并推测它适合的使用场景。若目录为空请说明。",
},
{
id: "image-alt-batch",
title: "批量 Alt 文本",
description: "为输出目录下的图片批量生成无障碍 alt 文本。",
category: "图像",
prompt:
"请扫描输出目录(默认 output/images)下的所有图片,逐张生成简洁、准确的 alt 无障碍描述,最后以「文件名 → alt 文本」的表格汇总。若目录为空请说明。",
},
{
id: "image-to-code",
title: "截图转代码",
description: "把输出目录里的界面截图还原成 HTML+CSS。",
category: "图像",
prompt:
"请在输出目录(默认 output/images)中查找一张界面截图,用 HTML + CSS 尽可能还原它的布局、间距与配色,输出为一个可直接在浏览器打开的单文件,并简述还原思路。若没有找到截图请说明。",
},
// ---- 音频 ----
{
id: "speech-generate",
title: "文字转语音",
description: "把一句示例文字合成为自然语音。",
category: "音频",
prompt:
"请使用 bl 的语音合成能力(如 `bl speech` 相关命令)把下面这句话合成为自然语音,保存到输出目录,并告诉我音频文件路径:欢迎使用阿里云百炼命令行工具,让多模态创作更简单。",
},
{
id: "audio-summarize",
title: "音频转写总结",
description: "转写输出目录里的音频并提炼要点。",
category: "音频",
prompt:
"请在输出目录(默认 output/speech)中找到一个音频文件,转写其内容,先给出完整文字,再用要点列表总结关键信息。若目录为空或缺少转写能力,请说明并尝试用可用的能力完成。",
},
// ---- 视频 ----
{
id: "video-generate",
title: "文生视频",
description: "一键生成一段示例短视频。",
category: "视频",
prompt:
"请使用 bl 的视频生成能力(如 `bl video generate` 命令)生成一段示例短视频:日落时分海边奔跑的少年,电影质感,慢动作。保存到输出目录后告诉我视频文件路径。",
},
{
id: "video-storyboard",
title: "视频分镜脚本",
description: "围绕示例主题产出可用于文生视频的分镜。",
category: "视频",
prompt:
"围绕主题「城市清晨的第一杯咖啡」,为一支 15-30 秒的短视频撰写分镜脚本:逐镜头给出画面描述、时长、字幕或旁白,并为每个镜头附上可直接用于文生视频的英文 prompt。",
},
// ---- 多模态 ----
{
id: "media-prompt-craft",
title: "多模态提示词",
description: "把一个示例创意扩展成图/视频/语音提示词。",
category: "多模态",
prompt:
"把创意「未来赛博城市的夜市」扩展成三组高质量生成提示词:1) 文生图;2) 文生视频;3) 语音风格描述。每组给出中英对照,并简要说明关键参数建议。",
},
{
id: "image-story-narration",
title: "图片配音文案",
description: "为输出目录里的图片写解说词并给出可合成文本。",
category: "多模态",
prompt:
"请在输出目录(默认 output/images)中任选一张图片,为它撰写一段 60 秒左右的中文解说词(适合配音),语气生动。随后给出可直接用于语音合成的纯文本版本。若目录为空请说明。",
},
// ---- 代码 ----
{
id: "summarize-project",
title: "总结当前项目",
description: "让 agent 阅读当前目录,总结架构、技术栈与主要模块。",
category: "代码",
prompt:
"请阅读当前工作目录的项目结构和关键源码,用简洁的中文总结:1) 它是做什么的;2) 技术栈;3) 主要模块及其职责;4) 值得注意的设计。先浏览再下结论,不要臆测。",
},
{
id: "write-tests",
title: "为核心模块写单测",
description: "自动挑选缺测试的核心模块并补全单元测试。",
category: "代码",
prompt:
"请在当前项目中挑选一个核心且缺少测试(或测试薄弱)的模块,为它编写全面的单元测试,覆盖主要逻辑分支和边界情况,并遵循本项目现有的测试框架与风格。先阅读相关文件及其依赖,再编写测试。",
},
{
id: "code-review",
title: "代码审查",
description: "审查当前项目核心代码,指出问题与改进建议。",
category: "代码",
prompt:
"请审查当前项目的核心源码,指出潜在的 bug、安全隐患、性能与可维护性问题,并给出具体、可操作的改进建议,按严重程度排序。先浏览项目结构,选取关键文件再审查。",
},
{
id: "explain-code",
title: "解释核心代码",
description: "挑选入口或核心模块,解释其实现与依赖。",
category: "代码",
prompt:
"请挑选当前项目的入口文件或核心模块,解释它的实现:职责是什么、关键流程如何运转、依赖了哪些模块。用清晰的中文说明,必要时给出调用关系。",
},
// ---- 文档 ----
{
id: "generate-readme",
title: "生成 README",
description: "阅读代码后生成结构清晰、与实现一致的 README.md。",
category: "文档",
prompt:
"为当前工作目录的项目生成一个结构清晰的 README.md,包含:项目简介、安装步骤、使用示例、目录结构说明。请先阅读现有代码与配置再撰写,内容必须与实际实现一致。",
},
];
/** Look up a scenario by id, or undefined when unknown. */
export function getScenario(id: string): Scenario | undefined {
return SCENARIOS.find((s) => s.id === id);
}
/** Fill a scenario's `{{placeholder}}` tokens from user-provided values. */
export function renderScenarioPrompt(scenario: Scenario, values: Record<string, string>): string {
return scenario.prompt.replace(/\{\{(\w+)\}\}/g, (_match, key: string) => {
const v = values[key];
return typeof v === "string" ? v.trim() : "";
});
}
File diff suppressed because one or more lines are too long
+339 -12
View File
@@ -1,5 +1,5 @@
import http from "node:http";
import { randomBytes } from "node:crypto";
import { randomBytes, timingSafeEqual } from "node:crypto";
import { createReadStream, existsSync, statSync, unlinkSync } from "node:fs";
import { extname } from "node:path";
@@ -12,6 +12,7 @@ import {
readConfigFile,
writeConfigFile,
deleteConfigProfile,
REGIONS,
type ConfigStore,
type FlagsDef,
} from "bailian-cli-core";
@@ -28,8 +29,20 @@ import {
resolveKey,
validateAndCoerceUi,
} from "./shared.ts";
import { listSkills, listMcpServers, listAgents, getSkillDetail } from "./inventory.ts";
import { launchAgent, agentLaunchable } from "./agent-launch.ts";
import {
listSkills,
listMcpServers,
listAgents,
getSkillDetail,
getAgentDetail,
writeMcpServer,
deleteMcpServer,
installSkillZip,
} from "./inventory.ts";
import { launchAgent, agentLaunchable, agentSupportsPrompt } from "./agent-launch.ts";
import { SCENARIOS, getScenario, renderScenarioPrompt, type Scenario } from "./scenarios.ts";
import { qrSvg } from "./qr.ts";
import { makeAuthUiBridge, type AuthUiBridge } from "../auth/console-ui.ts";
import { listAssets, resolveAssetPath, defaultOutputBase, contentType } from "./assets.ts";
const FLAGS = {
@@ -64,6 +77,7 @@ function readBody(req: http.IncomingMessage): Promise<string> {
size += chunk.length;
if (size > MAX_BODY) {
reject(new Error("payload too large"));
req.destroy();
return;
}
chunks.push(chunk);
@@ -73,6 +87,35 @@ function readBody(req: http.IncomingMessage): Promise<string> {
});
}
/** Max size for binary uploads (skill .zip packages). */
const MAX_UPLOAD = 24 * (1 << 20); // 24 MiB
function readBodyBuffer(req: http.IncomingMessage, max: number): Promise<Buffer> {
return new Promise((resolve, reject) => {
let size = 0;
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => {
size += chunk.length;
if (size > max) {
reject(new Error("payload too large"));
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => resolve(Buffer.concat(chunks)));
req.on("error", reject);
});
}
/** Constant-time token comparison (avoids timing side channels). */
function tokenMatches(provided: string | null, expected: string): boolean {
if (!provided) return false;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
/** Build the request cleaned/validated config block from a posted `data` map. */
function buildProfilePatch(
data: Record<string, unknown>,
@@ -111,6 +154,7 @@ export function createConfigUiServer(
token: string,
configStore: ConfigStore,
outputBase: string = defaultOutputBase(),
authBridge?: AuthUiBridge,
): http.Server {
return http.createServer(async (req, res) => {
try {
@@ -122,7 +166,7 @@ export function createConfigUiServer(
}
const u = new URL(req.url ?? "/", "http://127.0.0.1");
if (u.searchParams.get("token") !== token) {
if (!tokenMatches(u.searchParams.get("token"), token)) {
res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
res.end("unauthorized\n");
return;
@@ -132,11 +176,39 @@ export function createConfigUiServer(
const path = u.pathname;
if (path === "/" && method === "GET") {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
// The page URL carries the session token, so never cache it.
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
"Content-Security-Policy":
"default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " +
"img-src 'self' data: https://img.alicdn.com https://oss.aliyuncs.com; " +
"media-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
});
res.end(PAGE_HTML);
return;
}
if (path === "/api/qr" && method === "GET") {
const data = (u.searchParams.get("data") ?? "").slice(0, 512);
if (!data) {
sendJson(res, 400, { error: "missing data" });
return;
}
try {
const svg = qrSvg(data);
res.writeHead(200, {
"Content-Type": "image/svg+xml; charset=utf-8",
"Cache-Control": "no-store",
});
res.end(svg);
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/config" && method === "GET") {
const profiles = configStore.profiles();
sendJson(res, 200, {
@@ -147,6 +219,7 @@ export function createConfigUiServer(
booleanKeys: [...UI_BOOLEAN_KEYS],
fieldDefaults: {
...UI_MODEL_DEFAULTS,
base_url: REGIONS.cn,
output_dir: defaultOutputBase(),
timeout: "300",
},
@@ -173,23 +246,174 @@ export function createConfigUiServer(
return;
}
if (path === "/api/skill/install" && method === "POST") {
const source = u.searchParams.get("source") ?? "";
const name = u.searchParams.get("name") ?? "";
try {
const buf = await readBodyBuffer(req, MAX_UPLOAD);
const result = installSkillZip(source, buf, name);
sendJson(res, 200, result);
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/mcp" && method === "GET") {
sendJson(res, 200, { servers: listMcpServers() });
return;
}
if (path === "/api/mcp" && method === "POST") {
const raw = await readBody(req);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
sendJson(res, 400, { error: "invalid JSON body" });
return;
}
const body = parsed as {
source?: unknown;
scope?: unknown;
name?: unknown;
config?: unknown;
};
const source = typeof body.source === "string" ? body.source : "";
const scope = typeof body.scope === "string" && body.scope ? body.scope : "global";
const name = typeof body.name === "string" ? body.name : "";
try {
writeMcpServer(source, scope, name, body.config);
sendJson(res, 200, { saved: name.trim() });
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/mcp" && method === "DELETE") {
const source = u.searchParams.get("source") ?? "";
const scope = u.searchParams.get("scope") || "global";
const name = u.searchParams.get("name") ?? "";
try {
deleteMcpServer(source, scope, name);
sendJson(res, 200, { deleted: name });
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/health" && method === "GET") {
const major = Number(process.versions.node.split(".")[0]);
sendJson(res, 200, {
node: process.version,
nodeOk: Number.isFinite(major) && major >= 18,
platform: process.platform,
cwd: process.cwd(),
});
return;
}
if (path === "/api/agents" && method === "GET") {
// Augment each agent with `launchable`: whether its CLI binary is on
// PATH. "Connected" only means bl is wired into the agent's config, so
// the UI uses this to avoid offering a launch that would instantly fail.
// `dispatchable` additionally requires a verified prompt contract.
const agents = listAgents();
const launchable = await Promise.all(agents.map((a) => agentLaunchable(a.id)));
sendJson(res, 200, {
agents: agents.map((a, i) => ({ ...a, launchable: launchable[i] })),
agents: agents.map((a, i) => ({
...a,
launchable: launchable[i],
dispatchable: launchable[i] && agentSupportsPrompt(a.id),
})),
});
return;
}
if (path === "/api/agent" && method === "GET") {
const detail = getAgentDetail(u.searchParams.get("id") ?? "");
if (!detail) {
sendJson(res, 404, { error: "not found" });
return;
}
sendJson(res, 200, detail);
return;
}
if (path === "/api/agent/open" && method === "POST") {
const detail = getAgentDetail(u.searchParams.get("id") ?? "");
const target = u.searchParams.get("path") ?? "";
const allowed = detail?.settings.some((s) => s.path === target) ?? false;
if (!detail || !allowed || !existsSync(target)) {
sendJson(res, 404, { error: "not found" });
return;
}
try {
await openPath(target);
sendJson(res, 200, { opened: target });
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/scenarios" && method === "GET") {
// Curated Playground scenarios plus the connected agents that can be
// dispatched a prompt right now (on PATH + verified prompt contract).
const agents = listAgents();
const launchable = await Promise.all(agents.map((a) => agentLaunchable(a.id)));
const targets = agents
.map((a, i) => ({
id: a.id,
label: a.label,
dispatchable: launchable[i] && agentSupportsPrompt(a.id),
}))
.filter((a) => a.dispatchable);
sendJson(res, 200, { scenarios: SCENARIOS, agents: targets });
return;
}
if (path === "/api/auth/status" && method === "GET") {
sendJson(
res,
200,
authBridge
? authBridge.status()
: {
authenticated: false,
methods: { apiKey: false, console: false, openapi: false },
primary: null,
},
);
return;
}
if (path === "/api/auth/login" && method === "POST") {
if (!authBridge) {
sendJson(res, 400, { error: "login unavailable" });
return;
}
authBridge.startConsoleLogin();
sendJson(res, 200, { started: true });
return;
}
if (path === "/api/auth/logout" && method === "POST") {
if (!authBridge) {
sendJson(res, 400, { error: "logout unavailable" });
return;
}
try {
const loggedOut = await authBridge.logout();
sendJson(res, 200, { loggedOut });
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/assets" && method === "GET") {
sendJson(res, 200, listAssets(outputBase));
return;
@@ -197,13 +421,14 @@ export function createConfigUiServer(
if (path === "/api/asset/file" && method === "GET") {
const abs = resolveAssetPath(outputBase, u.searchParams.get("path") ?? "");
if (!abs || !existsSync(abs) || !statSync(abs).isFile()) {
const st = abs && existsSync(abs) ? statSync(abs) : null;
if (!abs || !st || !st.isFile()) {
sendJson(res, 404, { error: "not found" });
return;
}
res.writeHead(200, {
"Content-Type": contentType(extname(abs)),
"Content-Length": statSync(abs).size,
"Content-Length": st.size,
"Cache-Control": "no-store",
});
const stream = createReadStream(abs);
@@ -257,6 +482,91 @@ export function createConfigUiServer(
return;
}
if (path === "/api/agent/dispatch" && method === "POST") {
const raw = await readBody(req);
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
sendJson(res, 400, { error: "invalid JSON body" });
return;
}
const body = parsed as {
scenario?: unknown;
agent?: unknown;
values?: unknown;
custom?: unknown;
};
const agentId = typeof body.agent === "string" ? body.agent : "";
if (!agentSupportsPrompt(agentId)) {
sendJson(res, 400, { error: "agent cannot be dispatched a prompt" });
return;
}
let scenario: Scenario | undefined;
const custom = body.custom;
if (custom && typeof custom === "object" && !Array.isArray(custom)) {
const c = custom as { title?: unknown; prompt?: unknown; inputs?: unknown };
const promptTpl = typeof c.prompt === "string" ? c.prompt.trim() : "";
if (!promptTpl) {
sendJson(res, 400, { error: "custom scenario needs a prompt" });
return;
}
const inputs: { key: string; label: string }[] = [];
if (Array.isArray(c.inputs)) {
for (const it of c.inputs as unknown[]) {
if (it && typeof it === "object") {
const o = it as { key?: unknown; label?: unknown };
const key = typeof o.key === "string" ? o.key.trim() : "";
if (key) {
const label =
typeof o.label === "string" && o.label.trim() ? o.label.trim() : key;
inputs.push({ key, label });
}
}
}
}
scenario = {
id: "custom",
title: typeof c.title === "string" && c.title.trim() ? c.title.trim() : "Custom",
description: "",
category: "\u81ea\u5b9a\u4e49",
prompt: promptTpl,
inputs,
};
} else {
scenario = typeof body.scenario === "string" ? getScenario(body.scenario) : undefined;
}
if (!scenario) {
sendJson(res, 400, { error: "unknown scenario" });
return;
}
const values: Record<string, string> = {};
if (body.values && typeof body.values === "object" && !Array.isArray(body.values)) {
for (const [k, v] of Object.entries(body.values as Record<string, unknown>)) {
if (typeof v === "string") values[k] = v;
}
}
for (const inp of scenario.inputs ?? []) {
if (!values[inp.key] || !values[inp.key]!.trim()) {
sendJson(res, 400, { error: `Missing input: ${inp.label}` });
return;
}
}
const prompt = renderScenarioPrompt(scenario, values);
try {
const result = await launchAgent(agentId, process.cwd(), prompt);
sendJson(res, 200, {
launched: true,
agent: agentId,
scenario: scenario.id,
command: result.command,
});
} catch (err) {
sendJson(res, 400, { error: errMessage(err) });
}
return;
}
if (path === "/api/active" && method === "POST") {
const raw = await readBody(req);
let parsed: unknown;
@@ -318,9 +628,15 @@ export function createConfigUiServer(
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("not found\n");
} catch {
if (!res.headersSent) res.writeHead(500);
res.end();
} catch (err) {
// Log server-side so failures are diagnosable, and return a JSON error
// instead of an empty 500 body.
console.error("[config ui] request failed:", err);
if (res.headersSent) {
res.end();
return;
}
sendJson(res, 500, { error: errMessage(err) });
}
});
}
@@ -346,12 +662,23 @@ export default defineCommand({
"GET /api/config -> read all profiles",
"GET /api/skills -> list installed agent skills",
"GET /api/skill -> read one skill's SKILL.md detail",
"POST /api/skill/install -> install a skill from an uploaded .zip into a skills root",
"GET /api/mcp -> list local MCP servers",
"POST /api/mcp -> create or update one MCP server (writes its source config)",
"DELETE /api/mcp -> remove one MCP server from its source config",
"GET /api/health -> runtime environment info (node, platform, cwd)",
"GET /api/agents -> list coding agent frameworks",
"GET /api/agent -> one agent's config detail (secrets masked)",
"POST /api/agent/open -> open one agent's config file with the OS default app",
"GET /api/auth/status -> current auth state",
"POST /api/auth/login -> start console login (opens browser)",
"POST /api/auth/logout -> clear all stored credentials",
"GET /api/assets -> list generated assets",
"GET /api/asset/file -> stream one asset file",
"POST /api/asset/open -> open one asset with the OS default app",
"POST /api/agent/launch -> launch a coding agent CLI in a new terminal",
"GET /api/scenarios -> list Playground scenarios and dispatchable agents",
"POST /api/agent/dispatch -> dispatch a scenario prompt to a connected agent",
"POST /api/profile -> save a profile",
"POST /api/active -> activate a profile",
"DELETE /api/profile -> delete a named profile",
@@ -365,7 +692,7 @@ export default defineCommand({
const token = randomBytes(16).toString("hex");
const outputBase = settings.outputDir || defaultOutputBase();
const server = createConfigUiServer(token, ctx.configStore, outputBase);
const server = createConfigUiServer(token, ctx.configStore, outputBase, makeAuthUiBridge(ctx));
let port: number;
try {
+22
View File
@@ -38,6 +38,28 @@ test("listAssets 按分类归类并识别类型", () => {
});
});
test("listAssets 递归扫描任意子文件夹(非预设分类目录)", () => {
withBase((base) => {
put(base, "news-articles/report.md");
put(base, "custom/deep/nested/pic.png");
put(base, "images/a.png");
const { assets } = listAssets(base);
const byName = Object.fromEntries(assets.map((a) => [a.name, a]));
// Arbitrary top-level folder becomes the category.
expect(byName["report.md"]).toMatchObject({
category: "news-articles",
kind: "other",
ext: "md",
});
// Deeply nested file is discovered; category is its top-level folder.
expect(byName["pic.png"]).toMatchObject({ category: "custom", kind: "image" });
expect(byName["pic.png"]!.relPath).toBe(join("custom", "deep", "nested", "pic.png"));
// Known category dirs still work.
expect(byName["a.png"]).toMatchObject({ category: "images", kind: "image" });
});
});
test("listAssets 按生成时间倒序排列", () => {
withBase((base) => {
const older = put(base, "images/old.png");
@@ -0,0 +1,71 @@
import { expect, test } from "vite-plus/test";
import { makeAuthUiBridge } from "../src/commands/auth/console-ui.ts";
import type { AuthState, AuthStore, Identity, Settings } from "bailian-cli-core";
/** Build a bridge over a fake AuthStore. Only describe()/logout() are used by
* the surface under test; startConsoleLogin() is intentionally not exercised
* (it opens a browser and starts a real callback server). */
function bridgeWith(state: AuthState, onLogout?: (scope: string) => Promise<boolean>) {
const authStore = {
describe: () => state,
stored: () => ({ apiKey: false, console: false, openapi: false }),
resolveBaseUrl: () => "https://dashscope.aliyuncs.com",
login: async () => {},
logout: onLogout ?? (async () => false),
path: "/tmp/config.json",
} as unknown as AuthStore;
return makeAuthUiBridge({
identity: {} as unknown as Identity,
settings: {} as unknown as Settings,
authStore,
});
}
test("status: console 凭证 -> primary=console,带 region/site 与掩码 token", () => {
const st = bridgeWith({
console: {
token: "abcd1234efgh5678",
region: "cn-beijing",
site: "domestic",
source: "config",
},
}).status();
expect(st.authenticated).toBe(true);
expect(st.primary).toBe("console");
expect(st.methods).toEqual({ apiKey: false, console: true, openapi: false });
expect(st.region).toBe("cn-beijing");
expect(st.site).toBe("domestic");
expect(st.masked).toContain("...");
// Masked, never the raw token.
expect(st.masked).not.toBe("abcd1234efgh5678");
});
test("status: 无任何凭证 -> 未认证,无 masked", () => {
const st = bridgeWith({}).status();
expect(st.authenticated).toBe(false);
expect(st.primary).toBe(null);
expect(st.masked).toBeUndefined();
expect(st.methods).toEqual({ apiKey: false, console: false, openapi: false });
});
test("status: 仅 apiKey -> primary=apiKey", () => {
const st = bridgeWith({
apiKey: { token: "sk-1234567890", baseUrl: "https://dashscope.aliyuncs.com", source: "env" },
}).status();
expect(st.primary).toBe("apiKey");
expect(st.methods.apiKey).toBe(true);
expect(st.region).toBeUndefined();
});
test("logout 委托给 authStore.logout('all')", async () => {
let scope = "";
const bridge = bridgeWith(
{ apiKey: { token: "sk-x", baseUrl: "https://x", source: "config" } },
async (s) => {
scope = s;
return true;
},
);
expect(await bridge.logout()).toBe(true);
expect(scope).toBe("all");
});
+19
View File
@@ -91,6 +91,7 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项",
expect(res.json.fieldDefaults.default_text_model).toBe("qwen3.7-max");
expect(res.json.fieldDefaults.output_dir).toContain("bailian-output");
expect(res.json.fieldDefaults.timeout).toBe("300");
expect(res.json.fieldDefaults.base_url).toBe("https://dashscope.aliyuncs.com");
// Per-category model catalog (click-to-fill suggestions) is exposed too.
expect(res.json.modelCatalog.default_image_model[0]).toMatchObject({ id: "qwen-image-2.0" });
expect(res.json.modelCatalog.default_video_model.map((m: { id: string }) => m.id)).toContain(
@@ -102,6 +103,24 @@ test("GET /api/config 返回全部 profile、明文密钥与持久化激活项",
});
});
test("GET /api/auth/status 无 bridge 时返回未认证;login/logout 返回 400", async () => {
await withServer(async (port) => {
// The test harness builds the server without an auth bridge, so the auth
// endpoints degrade safely instead of throwing.
const status = await httpJson(port, "GET", `/api/auth/status?token=${TOKEN}`);
expect(status.status).toBe(200);
expect(status.json.authenticated).toBe(false);
expect(status.json.methods).toEqual({ apiKey: false, console: false, openapi: false });
expect(status.json.primary).toBe(null);
const login = await httpJson(port, "POST", `/api/auth/login?token=${TOKEN}`);
expect(login.status).toBe(400);
const logout = await httpJson(port, "POST", `/api/auth/logout?token=${TOKEN}`);
expect(logout.status).toBe(400);
});
});
test("鉴权:错误 token 401、非 loopback Host 403", async () => {
await withServer(async (port) => {
const bad = await httpJson(port, "GET", `/api/config?token=wrong`);
@@ -151,6 +151,7 @@ test("listAgents 报告安装与已连接 bailian-cli 的状态", () => {
installed: true,
configured: true,
model: "qwen3-max",
origin: "local",
});
expect(byId.codex).toMatchObject({ installed: true, configured: false, model: "gpt-5" });
expect(byId.opencode).toMatchObject({ installed: false, configured: false });
+157
View File
@@ -0,0 +1,157 @@
import { expect, test } from "vite-plus/test";
import { rsGeneratorExp, rsEncode, qrMatrix, qrSvg } from "../src/commands/config/qr.ts";
test("rsGeneratorExp 匹配已知 QR 生成多项式(alpha 指数)", () => {
// Well-documented QR generator polynomials — a strong correctness anchor for
// the GF(256) arithmetic and polynomial construction.
expect(rsGeneratorExp(7)).toEqual([0, 87, 229, 146, 149, 238, 102, 21]);
expect(rsGeneratorExp(10)).toEqual([0, 251, 67, 46, 61, 118, 70, 64, 94, 32, 45]);
expect(rsGeneratorExp(15)).toEqual([
0, 8, 183, 61, 91, 202, 37, 51, 58, 58, 237, 140, 124, 5, 99, 105,
]);
});
test("rsEncode 产出请求数量的纠错码字", () => {
const ec = rsEncode([0x40, 0xd2, 0x75, 0x47, 0x76, 0x17, 0x32, 0x06, 0x27, 0x26], 10);
expect(ec).toHaveLength(10);
expect(ec.every((b) => b >= 0 && b <= 255)).toBe(true);
});
test("qrMatrix 尺寸随版本增长且含三个定位图案", () => {
const m = qrMatrix("http://127.0.0.1:8787/?token=" + "a".repeat(32));
// ~61 byte URL -> version 4 (33x33).
expect(m.length).toBe(33);
expect(m[0]).toHaveLength(33);
const size = m.length;
// Finder pattern centers are dark (3x3 core) at the three corners.
expect(m[3][3]).toBe(true);
expect(m[3][size - 4]).toBe(true);
expect(m[size - 4][3]).toBe(true);
// Timing pattern alternates on row/col 6.
expect(m[6][8]).toBe(true);
expect(m[6][9]).toBe(false);
});
test("qrMatrix 短文本用最小版本(V1=21x21)", () => {
expect(qrMatrix("hi").length).toBe(21);
});
test("qrSvg 返回带 viewBox 的 SVG 且含模块矩形", () => {
const svg = qrSvg("http://127.0.0.1:8787/");
expect(svg.startsWith("<svg")).toBe(true);
expect(svg).toContain("viewBox=");
expect(svg).toContain("<rect");
});
test("qrMatrix 超长数据抛出清晰错误", () => {
expect(() => qrMatrix("x".repeat(200))).toThrow(/too large/);
});
// --- End-to-end: decode the produced matrix with the STANDARD reading order
// and confirm it round-trips back to the original text. This is the real proof
// that the code is scannable (format-info placement + mask + data placement),
// which cannot be checked by structure alone. The format info is read from the
// FIRST copy (top-left) using the canonical mapping, independent of the encoder.
const MASK_FNS: Array<(r: number, c: number) => boolean> = [
(r, c) => (r + c) % 2 === 0,
(r) => r % 2 === 0,
(_r, c) => c % 3 === 0,
(r, c) => (r + c) % 3 === 0,
(r, c) => (Math.floor(r / 2) + Math.floor(c / 3)) % 2 === 0,
(r, c) => ((r * c) % 2) + ((r * c) % 3) === 0,
(r, c) => (((r * c) % 2) + ((r * c) % 3)) % 2 === 0,
(r, c) => (((r + c) % 2) + ((r * c) % 3)) % 2 === 0,
];
function qrDecode(m: boolean[][]): { text: string; mask: number; ecLevel: number } {
const size = m.length;
const version = (size - 17) / 4;
const bAt = (r: number, c: number) => (m[r][c] ? 1 : 0);
// Read the 15-bit format info from the first copy (canonical cell mapping).
const cells: Array<[number, number]> = [
[0, 8],
[1, 8],
[2, 8],
[3, 8],
[4, 8],
[5, 8], // bits 0–5
[7, 8], // bit 6
[8, 8], // bit 7
[8, 7], // bit 8
[8, 5],
[8, 4],
[8, 3],
[8, 2],
[8, 1],
[8, 0], // bits 9–14
];
let fmt = 0;
for (let i = 0; i < 15; i++) fmt |= bAt(cells[i][0], cells[i][1]) << i;
fmt ^= 0x5412;
const mask = (fmt >> 10) & 7;
const ecLevel = (fmt >> 13) & 3;
const cond = MASK_FNS[mask];
// Independent function/reserved-module map.
const isFn = (r: number, c: number): boolean => {
if (r <= 7 && c <= 7) return true; // top-left finder + separator
if (r <= 7 && c >= size - 8) return true; // top-right finder
if (r >= size - 8 && c <= 7) return true; // bottom-left finder
if (r === 6 || c === 6) return true; // timing
if (r === 8 && (c <= 8 || c >= size - 8)) return true; // format (horizontal)
if (c === 8 && (r <= 8 || r >= size - 8)) return true; // format (vertical) + dark
if (version >= 2) {
const ac = size - 7;
if (Math.abs(r - ac) <= 2 && Math.abs(c - ac) <= 2) return true; // alignment
}
return false;
};
// Read data modules in the standard right-to-left zigzag, un-masking as we go.
const bits: number[] = [];
let upward = true;
for (let col = size - 1; col >= 1; col -= 2) {
if (col === 6) col = 5;
for (let i = 0; i < size; i++) {
const row = upward ? size - 1 - i : i;
for (const off of [0, 1]) {
const cc = col - off;
if (isFn(row, cc)) continue;
let b = bAt(row, cc);
if (cond(row, cc)) b ^= 1;
bits.push(b);
}
}
upward = !upward;
}
let p = 0;
const read = (n: number) => {
let v = 0;
for (let k = 0; k < n; k++) v = (v << 1) | (bits[p++] ?? 0);
return v;
};
const mode = read(4);
if (mode !== 0b0100) throw new Error("decode: not byte mode, got " + mode);
const len = read(8);
const out: number[] = [];
for (let i = 0; i < len; i++) out.push(read(8));
return { text: new TextDecoder().decode(new Uint8Array(out)), mask, ecLevel };
}
test("qrMatrix → 标准解码能无损还原原文(失败则说明无法扫描)", () => {
const samples = [
"hi",
"http://127.0.0.1:8787/",
"http://127.0.0.1:8787/?token=" + "a".repeat(32),
"http://127.0.0.1:65535/?token=0123456789abcdef0123456789abcdef",
];
for (const text of samples) {
const decoded = qrDecode(qrMatrix(text));
expect(decoded.text).toBe(text);
expect(decoded.ecLevel).toBe(0b01); // error-correction level L
expect(decoded.mask).toBeGreaterThanOrEqual(0);
expect(decoded.mask).toBeLessThanOrEqual(7);
}
});
+5 -1
View File
@@ -59,7 +59,11 @@ export function makeAuthStore(sources: ResolutionSources): AuthStore {
const configName = sources.configName;
const activateAfterLogin = sources.flags.config !== undefined;
return {
describe: () => describeAuthState(sources),
// Read the config block live (not the startup `sources.file` snapshot) so
// long-lived processes like `config ui` reflect a fresh login without a
// restart. For one-shot commands this reads the same content the snapshot
// held, so behavior is unchanged.
describe: () => describeAuthState({ ...sources, file: readConfigFile(configName) }),
stored() {
const file = readConfigFile(configName);
return {