Skip to content

Commit e1caee9

Browse files
inhaiinhai
authored andcommitted
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
1 parent 9ab5de8 commit e1caee9

14 files changed

Lines changed: 3522 additions & 238 deletions

File tree

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { maskToken, type AuthStore, type Identity, type Settings } from "bailian-cli-core";
2+
import { runConsoleLogin, resolveConsoleOrigin } from "./login-console.ts";
3+
4+
/** Read-only auth snapshot the config UI account widget renders. bl stores no
5+
* user profile (name/avatar), so this exposes only which credential domains
6+
* resolve, the console region/site, and a masked token. */
7+
export interface AuthUiStatus {
8+
authenticated: boolean;
9+
methods: { apiKey: boolean; console: boolean; openapi: boolean };
10+
primary: "console" | "apiKey" | "openapi" | null;
11+
region?: string;
12+
site?: "domestic" | "international";
13+
masked?: string;
14+
}
15+
16+
/**
17+
* The auth capability surface the config UI is allowed to use. All `authStore`
18+
* access is kept inside this module (commands/auth/**), which the lint boundary
19+
* permits; commands/config/** consumes only this opaque bridge and never
20+
* touches `authStore` directly.
21+
*/
22+
export interface AuthUiBridge {
23+
status(): AuthUiStatus;
24+
/** Start browser-based console login (fire-and-forget; UI polls status). */
25+
startConsoleLogin(): void;
26+
/** Clear all stored credentials. Returns whether anything changed. */
27+
logout(): Promise<boolean>;
28+
}
29+
30+
/** Build the bridge from a command context (identity/settings/authStore). */
31+
export function makeAuthUiBridge(ctx: {
32+
identity: Identity;
33+
settings: Settings;
34+
authStore: AuthStore;
35+
}): AuthUiBridge {
36+
const { identity, settings, authStore } = ctx;
37+
return {
38+
status() {
39+
const a = authStore.describe();
40+
const methods = { apiKey: !!a.apiKey, console: !!a.console, openapi: !!a.openapi };
41+
let masked: string | undefined;
42+
if (a.console) masked = maskToken(a.console.token);
43+
else if (a.apiKey) masked = maskToken(a.apiKey.token);
44+
else if (a.openapi) masked = maskToken(a.openapi.accessKeyId);
45+
const primary = a.console ? "console" : a.apiKey ? "apiKey" : a.openapi ? "openapi" : null;
46+
return {
47+
authenticated: methods.apiKey || methods.console || methods.openapi,
48+
methods,
49+
primary,
50+
region: a.console?.region,
51+
site: a.console?.site,
52+
masked,
53+
};
54+
},
55+
startConsoleLogin() {
56+
const origin = resolveConsoleOrigin(authStore.describe().console?.site);
57+
// Mirror the CLI (`bl auth login --console`): request an api_key from the
58+
// console only when one isn't already stored, so a first console login in
59+
// the config UI also provisions the model api_key (not just access_token).
60+
const hasApiKey = !!authStore.stored().apiKey;
61+
// runConsoleLogin opens the browser and runs its own callback server
62+
// (up to 15 min). We don't await it — the config UI polls the status
63+
// endpoint to detect completion. Errors are logged, not surfaced.
64+
void runConsoleLogin(
65+
origin,
66+
{ identity, settings, authStore },
67+
{
68+
needApiKey: !hasApiKey,
69+
},
70+
).catch((err: unknown) => {
71+
const msg = err instanceof Error ? err.message : String(err);
72+
process.stderr.write(`console login failed: ${msg}\n`);
73+
});
74+
},
75+
logout() {
76+
return authStore.logout("all");
77+
},
78+
};
79+
}

packages/commands/src/commands/config/agent-launch.ts

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,25 @@ export function agentCommand(id: string): string | undefined {
2222
return Object.prototype.hasOwnProperty.call(AGENT_COMMANDS, id) ? AGENT_COMMANDS[id] : undefined;
2323
}
2424

25+
/**
26+
* Per-agent argv that passes an initial task prompt while keeping the agent
27+
* interactive in the terminal. Only verified contracts are listed; an agent
28+
* absent here cannot be dispatched a prompt (its bare launch still works).
29+
* - qwen-code: `qwen -i "<prompt>"` (execute prompt, stay interactive)
30+
* - claude-code: `claude "<prompt>"` (positional initial prompt)
31+
* - codex: `codex "<prompt>"` (positional initial prompt)
32+
*/
33+
const AGENT_PROMPT_ARGV: Record<string, (prompt: string) => string[]> = {
34+
"qwen-code": (p) => ["-i", p],
35+
"claude-code": (p) => [p],
36+
codex: (p) => [p],
37+
};
38+
39+
/** Whether a known agent supports being dispatched an initial task prompt. */
40+
export function agentSupportsPrompt(id: string): boolean {
41+
return Object.prototype.hasOwnProperty.call(AGENT_PROMPT_ARGV, id);
42+
}
43+
2544
/** Resolve whether a binary is reachable on PATH (via `which`/`where`). */
2645
function onPath(bin: string): Promise<boolean> {
2746
const cmd = process.platform === "win32" ? "where" : "which";
@@ -82,16 +101,31 @@ export interface LaunchResult {
82101
}
83102

84103
/**
85-
* Launch a known coding agent's local CLI in a new terminal window.
86-
* Rejects when the id is unknown, the binary is missing from PATH, or the
87-
* platform terminal could not be opened.
104+
* Launch a known coding agent's local CLI in a new terminal window. When
105+
* `prompt` is provided, it is passed as a single quoted argument using the
106+
* agent's verified prompt contract so the agent starts with that task.
107+
* Rejects when the id is unknown, the binary is missing from PATH, the agent
108+
* does not support prompt dispatch, or the platform terminal could not open.
88109
*/
89-
export async function launchAgent(id: string, cwd: string = process.cwd()): Promise<LaunchResult> {
110+
export async function launchAgent(
111+
id: string,
112+
cwd: string = process.cwd(),
113+
prompt?: string,
114+
): Promise<LaunchResult> {
90115
const command = agentCommand(id);
91116
if (!command) throw new Error(`Unknown agent: ${id}`);
92117
if (!(await onPath(command))) {
93118
throw new Error(`\`${command}\` was not found on your PATH — install ${id} first.`);
94119
}
95-
await spawnTerminal(command, cwd);
96-
return { launched: true, command };
120+
let fullCommand = command;
121+
const task = (prompt ?? "").trim();
122+
if (task) {
123+
const build = AGENT_PROMPT_ARGV[id];
124+
if (!build) throw new Error(`${id} does not support dispatching a task prompt.`);
125+
// shQuote keeps the whole prompt as one shell argument (no injection); the
126+
// platform terminal layer escapes the resulting command line separately.
127+
fullCommand = [command, ...build(task).map(shQuote)].join(" ");
128+
}
129+
await spawnTerminal(fullCommand, cwd);
130+
return { launched: true, command: fullCommand };
97131
}

packages/commands/src/commands/config/assets.ts

Lines changed: 22 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
// Read/manage the local assets that `bl` writes into the output directory
22
// (default ~/bailian-output, overridable via the `output_dir` config key).
3-
// Generated media is organized into per-type subdirectories: images/, videos/,
4-
// speech/, omni/. This module discovers those files, classifies them, and
5-
// provides safe path resolution for serving/deleting individual assets.
3+
// Generated media may live directly under the base or in any subfolder (bl's
4+
// own images/, videos/, speech/, omni/, or user-created folders). This module
5+
// recursively discovers every file under the base, classifies each by type,
6+
// derives its category from the top-level folder, and provides safe path
7+
// resolution for serving/deleting individual assets.
68
import { readdirSync, statSync, existsSync, type Dirent } from "node:fs";
79
import { homedir } from "node:os";
810
import { join, extname, relative, resolve, sep } from "node:path";
@@ -23,8 +25,8 @@ export interface AssetInfo {
2325
ext: string;
2426
}
2527

26-
/** Category subdirectories that `bl` writes generated media into. */
27-
const CATEGORY_DIRS = ["images", "videos", "speech", "omni"] as const;
28+
/** Max directory depth to descend from the output base when scanning. */
29+
const MAX_SCAN_DEPTH = 8;
2830

2931
const KIND_BY_EXT: Record<string, AssetKind> = {
3032
".png": "image",
@@ -101,9 +103,11 @@ function walk(dir: string, depth: number, out: string[]): void {
101103
}
102104

103105
/**
104-
* List generated assets under `base`, newest first. Scans each known category
105-
* subdirectory plus any loose files directly under the base (grouped as
106-
* "other"). Returns the resolved base so callers can surface it in the UI.
106+
* List generated assets under `base`, newest first. Recursively scans every
107+
* subfolder under the base (plus loose files at the root), so assets in bl's
108+
* own category dirs and any user-created folders are all discovered. Each
109+
* file's `category` is its top-level folder name, or "other" for root files.
110+
* Returns the resolved base so callers can surface it in the UI.
107111
*/
108112
export function listAssets(base: string = defaultOutputBase()): {
109113
base: string;
@@ -112,44 +116,30 @@ export function listAssets(base: string = defaultOutputBase()): {
112116
const assets: AssetInfo[] = [];
113117
if (!existsSync(base)) return { base, assets };
114118

115-
const seen = new Set<string>();
116-
const addFile = (full: string, category: string): void => {
117-
if (seen.has(full)) return;
118-
seen.add(full);
119+
const files: string[] = [];
120+
walk(base, MAX_SCAN_DEPTH, files);
121+
122+
for (const full of files) {
119123
let st;
120124
try {
121125
st = statSync(full);
122126
} catch {
123-
return;
127+
continue;
124128
}
125-
if (!st.isFile()) return;
129+
if (!st.isFile()) continue;
130+
const rel = relative(base, full);
131+
const segments = rel.split(sep);
132+
const category = segments.length > 1 ? segments[0]! : "other";
126133
const ext = extname(full);
127134
assets.push({
128135
name: full.split(sep).pop() ?? full,
129136
category,
130137
kind: kindOf(ext),
131-
relPath: relative(base, full),
138+
relPath: rel,
132139
size: st.size,
133140
mtime: st.mtimeMs,
134141
ext: ext.replace(/^\./, "").toLowerCase(),
135142
});
136-
};
137-
138-
for (const cat of CATEGORY_DIRS) {
139-
const files: string[] = [];
140-
walk(join(base, cat), 4, files);
141-
for (const f of files) addFile(f, cat);
142-
}
143-
144-
// Loose files placed directly under the base directory.
145-
let rootEntries: Dirent[] = [];
146-
try {
147-
rootEntries = readdirSync(base, { withFileTypes: true });
148-
} catch {
149-
rootEntries = [];
150-
}
151-
for (const e of rootEntries) {
152-
if (e.isFile() || e.isSymbolicLink()) addFile(join(base, e.name), "other");
153143
}
154144

155145
assets.sort((a, b) => b.mtime - a.mtime);

0 commit comments

Comments
 (0)