mirror of
https://github.com/modelstudioai/cli.git
synced 2026-09-14 19:49:23 +08:00
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:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user