Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions docs/agents/config-profile-change.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@
- `config list` 标识所有 Profile 与当前激活项。
- `config show`、`auth status` 只输出本次最终选择的 `config` 和 `config_file`,不重复携带激活状态。
- `config ui` 从持久化元数据读取激活项,提供显式激活操作,并在删除激活项后刷新为 `default`。
- `config ui` 保存时只替换 UI 管理的字段;Profile 中未展示但仍属于 `ConfigFile` 的合法字段必须保留,不能因打开并保存 UI 而丢失。
- `config ui` 展示并可编辑完整 `ConfigFile`(含 `console_*`、`telemetry`),保存时按类型(数字/布尔/枚举)归一化写回;`config set` 仍只暴露较窄的 `VALID_KEYS`。UI 未管理的顶层元数据(如 `active_config`)不进入 Profile block,仍由写盘逻辑单独保留。
- `config ui` 只读展示本地 agent 生态:Skills 跨全部 agent skill 目录(`~/.agents/skills` 及各 agent 的 `skills/`,含软链接)按 id 聚合并标注安装来源;MCP、Agents 从各 agent 本地配置读取。
- `config ui` 提供 Assets 资产管理:扫描 `output_dir`(默认 `~/bailian-output`)下的 `images/videos/speech/omni` 分类及根目录散落文件,按分类与生成时间(mtime)标记,支持按分类筛选、内联预览(图/视频/音频)与删除单个文件;文件读取与删除均通过限定在输出目录内的路径校验(防目录穿越)。
- 同步 E2E topic routes、Skill setup 和自动生成 reference。

## 6. 最小测试矩阵
Expand All @@ -62,7 +64,8 @@
`--config default` 成功后切回 `default`。
- Console token 自动刷新不从其他 Profile 借用 AK/SK,也不把新 token 写入其他 Profile。
- `config list/show/use/ui`、`auth status` 和依赖默认模型的消费命令覆盖对应 E2E。
- `config ui` 覆盖保存时保留未管理字段,并继续允许空值清除 UI 管理字段。
- `config ui` 覆盖保存时保留顶层元数据(如 `active_config`),继续允许空值清除字段,并覆盖 `console_*`/`telemetry` 的类型归一化与枚举校验。
- Assets:`listAssets` 覆盖分类归类、时间倒序、目录缺失返回空;`resolveAssetPath` 覆盖目录穿越拦截;`contentType` 覆盖常见扩展名映射。

## 7. 完成检查

Expand Down
79 changes: 79 additions & 0 deletions packages/commands/src/commands/auth/console-ui.ts
Original file line number Diff line number Diff line change
@@ -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");
},
};
}
131 changes: 131 additions & 0 deletions packages/commands/src/commands/config/agent-launch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
/**
* Best-effort local launcher for coding-agent CLIs surfaced in the config UI.
*
* The command for each agent is taken from a fixed allowlist keyed by the
* agent id, so no user-controlled string is ever executed. Every child process
* is spawned via `execFile` (array args, no shell) to avoid injection.
*/
import { execFile } from "node:child_process";

/** Fixed allowlist: agent id -> launch binary. Keys match `AGENT_PROBES` ids. */
export const AGENT_COMMANDS: Record<string, string> = {
"claude-code": "claude",
"qwen-code": "qwen",
opencode: "opencode",
openclaw: "openclaw",
hermes: "hermes",
codex: "codex",
};

/** The launch binary for a known agent id, or undefined when unknown. */
export function agentCommand(id: string): string | undefined {
return Object.prototype.hasOwnProperty.call(AGENT_COMMANDS, id) ? AGENT_COMMANDS[id] : undefined;
}

/**
* 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";
return new Promise((resolve) => {
execFile(cmd, [bin], { windowsHide: true }, (err) => resolve(!err));
});
}

/**
* Whether a known agent can actually be quick-launched right now: its id maps to
* a launch binary and that binary is reachable on PATH. Unknown ids resolve to
* false. Used to gate the UI's Quick launch button so "Connected" agents whose
* CLI is not installed do not offer a launch that would immediately fail.
*/
export function agentLaunchable(id: string): Promise<boolean> {
const command = agentCommand(id);
if (!command) return Promise.resolve(false);
return onPath(command);
}

/** Single-quote a path for a POSIX shell command line. */
function shQuote(p: string): string {
return `'${p.replace(/'/g, "'\\''")}'`;
}

/** Open a new OS terminal window that cd's into `cwd` and runs `command`. */
function spawnTerminal(command: string, cwd: string): Promise<void> {
const platform = process.platform;
return new Promise((resolve, reject) => {
if (platform === "darwin") {
const inner = `cd ${shQuote(cwd)} && ${command}`;
const escaped = inner.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const args = [
"-e",
`tell application "Terminal" to do script "${escaped}"`,
"-e",
'tell application "Terminal" to activate',
];
execFile("osascript", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve()));
return;
}
if (platform === "win32") {
const args = ["/c", "start", "", "cmd", "/k", `cd /d ${cwd} && ${command}`];
execFile("cmd", args, { windowsHide: true }, (err) => (err ? reject(err) : resolve()));
return;
}
// Linux / other: best-effort via the distro's default terminal emulator.
const inner = `cd ${shQuote(cwd)} && ${command}; exec $SHELL`;
execFile("x-terminal-emulator", ["-e", "bash", "-lc", inner], { windowsHide: true }, (err) =>
err ? reject(new Error("No supported terminal emulator was found")) : resolve(),
);
});
}

export interface LaunchResult {
launched: boolean;
command: string;
}

/**
* Launch a known coding agent's local CLI in a new terminal window. When
* `prompt` is provided, it is passed as a single quoted argument using the
* agent's verified prompt contract so the agent starts with that task.
* Rejects when the id is unknown, the binary is missing from PATH, the agent
* does not support prompt dispatch, or the platform terminal could not open.
*/
export async function launchAgent(
id: string,
cwd: string = process.cwd(),
prompt?: string,
): Promise<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.`);
}
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 };
}
160 changes: 160 additions & 0 deletions packages/commands/src/commands/config/assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Read/manage the local assets that `bl` writes into the output directory
// (default ~/bailian-output, overridable via the `output_dir` config key).
// Generated media may live directly under the base or in any subfolder (bl's
// own images/, videos/, speech/, omni/, or user-created folders). This module
// recursively discovers every file under the base, classifies each by type,
// derives its category from the top-level folder, and provides safe path
// resolution for serving/deleting individual assets.
import { readdirSync, statSync, existsSync, type Dirent } from "node:fs";
import { homedir } from "node:os";
import { join, extname, relative, resolve, sep } from "node:path";

export type AssetKind = "image" | "video" | "audio" | "other";

/** One generated file discovered under the output directory. */
export interface AssetInfo {
name: string;
/** Category folder the file lives in: images | videos | speech | omni | other. */
category: string;
kind: AssetKind;
/** Path relative to the output base (used as the API handle). */
relPath: string;
size: number;
/** Modification time in epoch milliseconds ~= generation time. */
mtime: number;
ext: string;
}

/** Max directory depth to descend from the output base when scanning. */
const MAX_SCAN_DEPTH = 8;

const KIND_BY_EXT: Record<string, AssetKind> = {
".png": "image",
".jpg": "image",
".jpeg": "image",
".webp": "image",
".gif": "image",
".bmp": "image",
".svg": "image",
".mp4": "video",
".mov": "video",
".webm": "video",
".mkv": "video",
".avi": "video",
".mp3": "audio",
".wav": "audio",
".m4a": "audio",
".aac": "audio",
".flac": "audio",
".ogg": "audio",
};

const CONTENT_TYPE: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".svg": "image/svg+xml",
".mp4": "video/mp4",
".mov": "video/quicktime",
".webm": "video/webm",
".mkv": "video/x-matroska",
".avi": "video/x-msvideo",
".mp3": "audio/mpeg",
".wav": "audio/wav",
".m4a": "audio/mp4",
".aac": "audio/aac",
".flac": "audio/flac",
".ogg": "audio/ogg",
};

/** The default output base when `output_dir` is not configured. */
export function defaultOutputBase(home: string = homedir()): string {
return join(home, "bailian-output");
}

function kindOf(ext: string): AssetKind {
return KIND_BY_EXT[ext.toLowerCase()] ?? "other";
}

/** MIME type for serving an asset; falls back to a safe binary type. */
export function contentType(ext: string): string {
return CONTENT_TYPE[ext.toLowerCase()] ?? "application/octet-stream";
}

/** Recursively collect regular files under `dir`, descending at most `depth` levels. */
function walk(dir: string, depth: number, out: string[]): void {
let entries: Dirent[];
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return;
}
for (const e of entries) {
const full = join(dir, e.name);
if (e.isDirectory()) {
if (depth > 0) walk(full, depth - 1, out);
} else if (e.isFile() || e.isSymbolicLink()) {
out.push(full);
}
}
}

/**
* List generated assets under `base`, newest first. Recursively scans every
* subfolder under the base (plus loose files at the root), so assets in bl's
* own category dirs and any user-created folders are all discovered. Each
* file's `category` is its top-level folder name, or "other" for root files.
* Returns the resolved base so callers can surface it in the UI.
*/
export function listAssets(base: string = defaultOutputBase()): {
base: string;
assets: AssetInfo[];
} {
const assets: AssetInfo[] = [];
if (!existsSync(base)) return { base, assets };

const files: string[] = [];
walk(base, MAX_SCAN_DEPTH, files);

for (const full of files) {
let st;
try {
st = statSync(full);
} catch {
continue;
}
if (!st.isFile()) continue;
const rel = relative(base, full);
const segments = rel.split(sep);
const category = segments.length > 1 ? segments[0]! : "other";
const ext = extname(full);
assets.push({
name: full.split(sep).pop() ?? full,
category,
kind: kindOf(ext),
relPath: rel,
size: st.size,
mtime: st.mtimeMs,
ext: ext.replace(/^\./, "").toLowerCase(),
});
}

assets.sort((a, b) => b.mtime - a.mtime);
return { base, assets };
}

/**
* Resolve a client-supplied relative path to an absolute path strictly inside
* `base`. Returns null for empty input or any path that would escape the base
* (path traversal guard).
*/
export function resolveAssetPath(base: string, relPath: string): string | null {
if (typeof relPath !== "string" || relPath.length === 0) return null;
const root = resolve(base);
const abs = resolve(root, relPath);
if (abs !== root && !abs.startsWith(root + sep)) return null;
return abs;
}
Loading