import { constants } from "node:fs"; import { access, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; type Plugin = { id: string; label: string; packageSpec: string; components: string; }; const plugins: Plugin[] = [ { id: "hud", label: "Zhizh Agent HUD(状态栏 + /usage)", packageSpec: "npm:@ganziliang/zhizh-pi-agent-hud", components: "agent-hud、model-usage.ts" }, { id: "model-setup", label: "Zhizh Model Setup(/model-setup)", packageSpec: "npm:@ganziliang/zhizh-pi-model-setup", components: "model-setup.ts" }, { id: "token-leaderboard", label: "Zhizh Token Leaderboard(Token 花费排行榜)", packageSpec: "npm:@ganziliang/zhizh-pi-token-leaderboard", components: "/token-leaderboard、昨日/当天/周/月排行榜 overlay" }, { id: "attention", label: "Pi Attention(Windows 托盘提醒)", packageSpec: "npm:@ganziliang/pi-attention", components: "/attention、/attention:setup、/attention:check、/attention:reset、/attention:ack" }, { id: "mcp-adapter", label: "pi-mcp-adapter(MCP 工具适配器)", packageSpec: "npm:pi-mcp-adapter", components: "MCP adapter" }, { id: "web-access", label: "pi-web-access(网页访问能力)", packageSpec: "npm:pi-web-access", components: "web access" }, { id: "subagents", label: "pi-subagents(Subagent 委派能力)", packageSpec: "npm:pi-subagents", components: "subagent 工具、内置 scout/reviewer/worker/oracle 等角色" }, { id: "loading-message", label: "Pi Loading Message(自定义加载提示)", packageSpec: "npm:@ganziliang/pi-loading-message", components: "/loading-config、自定义工作中提示语" }, { id: "aa-speed-overlay", label: "AA Speed Overlay(Artificial Analysis 榜单面板)", packageSpec: "npm:@ganziliang/pi-aa-speed-overlay", components: "/aa-speed、aa_leaderboard 工具" }, { id: "github-trending-overlay", label: "GitHub Trending Overlay(Trending 浏览 + AI 归纳)", packageSpec: "npm:@ganziliang/pi-github-trending-overlay", components: "/github-trending、概述/功能/场景 AI 归纳 overlay" }, { id: "skillhub", label: "Zhizh SkillHub(团队技能仓库客户端)", packageSpec: "npm:@ganziliang/zhizh-pi-skillhub", components: "/skillhub search|all|namespaces|install|list|remove|publish|login|logout|status|model" }, { id: "ai2image", label: "Zhizh AI2Image(生图 / 改图)", packageSpec: "npm:@ganziliang/zhizh-pi-ai2image", components: "generate_image 工具" }, { id: "themes", label: "Awesome Pi Themes(38 套主题)", packageSpec: "npm:awesome-pi-themes", components: "38 套深色主题、在线预览" } ]; const SKILLHUB_CONFIG_FILE = "skillhub.json"; const SKILLHUB_REGISTRY = "https://skillhub.zhizhengroup.com"; const SKILLHUB_TOKEN_ENV = "SKILLHUB_TOKEN"; /** Glob/排除语法(Pi 的 skills 数组支持),这类条目无法做存在性校验。 */ const PATH_PATTERN_CHARS = /[*?[\]{}!]/; function agentDir(): string { return process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent"); } function commandArgs(args: string | undefined): string { return (args ?? "").trim().toLowerCase(); } function piCommand(args: string[]): { command: string; args: string[] } { // Windows cannot spawn the `pi.cmd` shim with shell:false. Reuse the // currently running Pi CLI through Node instead of relying on PATH lookup. const entrypoint = process.argv[1]; if (process.platform === "win32" && entrypoint) { return { command: process.execPath, args: [entrypoint, ...args] }; } return { command: "pi", args }; } async function runPi(pi: ExtensionAPI, args: string[], timeout: number) { const invocation = piCommand(args); return pi.exec(invocation.command, invocation.args, { timeout }); } async function install(pi: ExtensionAPI, plugin: Plugin, ctx: ExtensionContext): Promise { ctx.ui.notify(`正在安装 ${plugin.label}……`, "info"); const result = await runPi(pi, ["install", plugin.packageSpec], 120_000); if (result.code !== 0) { const detail = result.stderr.trim() || result.stdout.trim() || `退出码 ${result.code}`; ctx.ui.notify(`${plugin.label} 安装失败:${detail}`, "error"); return false; } ctx.ui.notify(`${plugin.label} 安装完成`, "info"); if (plugin.id === "hud") { await configureHudBubble(ctx); } if (plugin.id === "subagents") { await configureSubagentsModel(ctx); } if (plugin.id === "token-leaderboard") { ctx.ui.notify("Token 排行榜已安装;首次执行 /token-leaderboard 时粘贴包含 apiId 的完整页面 URL 即可完成设置。", "info"); } if (plugin.id === "skillhub") { await configureSkillHubToken(ctx); } if (plugin.id === "aa-speed-overlay") { ctx.ui.notify( "AA Speed Overlay 已安装;执行 /aa-speed 打开榜单面板(速度/智能/词量/成本/耗时/知识/办公/执行/财务/职业/图像/语音,用 Tab 或 ←→ 切换,r 强制刷新),模型也可调用 aa_leaderboard 工具查询。", "info" ); } if (plugin.id === "github-trending-overlay") { ctx.ui.notify( "GitHub Trending Overlay 已安装;执行 /github-trending 打开 overlay(可带语言和周期参数,例如 `/github-trending typescript weekly`,加 --no-ai 可跳过 AI 归纳);↑/↓ 或 j/k 切换仓库,Enter 把选中仓库 URL 填入输入框,r 重新抓取,Esc/q 关闭。AI 归纳结果默认缓存 7 天(~/.pi/agent/cache/github-trending-analysis.json),可用 PI_TRENDING_MODEL 指定分析模型,PI_TRENDING_BG=off 可关闭底色。无需任何安装后配置。", "info" ); } return true; } async function configureHudBubble(ctx: ExtensionContext): Promise { const dir = agentDir(); const configPath = path.join(dir, "pi-agent-hud.json"); let config: Record = {}; try { const content = await readFile(configPath, "utf8"); const parsed = JSON.parse(content); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("pi-agent-hud.json 必须是 JSON 对象"); } config = parsed as Record; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { ctx.ui.notify(`HUD 配置读取失败,未修改现有配置:${error instanceof Error ? error.message : "JSON 格式无效"}`, "warning"); return; } } config.editor = "bubble"; try { await mkdir(dir, { recursive: true }); await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); ctx.ui.notify("已将 HUD 默认输入框配置为 bubble 模式", "info"); } catch (error) { ctx.ui.notify(`HUD 已安装,但默认 bubble 配置写入失败:${error instanceof Error ? error.message : "未知错误"}`, "warning"); } } async function configureSubagentsModel(ctx: ExtensionContext): Promise { const dir = agentDir(); const settingsPath = path.join(dir, "settings.json"); let settings: Record = {}; try { const content = await readFile(settingsPath, "utf8"); const parsed = JSON.parse(content); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("settings.json 必须是 JSON 对象"); } settings = parsed as Record; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { ctx.ui.notify(`读取 Pi 配置失败,暂未配置 subagent 模型:${error instanceof Error ? error.message : "JSON 格式无效"}`, "warning"); return; } } const existingSubagents = settings.subagents; if (existingSubagents !== undefined && (!existingSubagents || typeof existingSubagents !== "object" || Array.isArray(existingSubagents))) { ctx.ui.notify("Pi 配置中的 subagents 不是 JSON 对象,暂未修改,请手动修正 settings.json。", "warning"); return; } const subagents = (existingSubagents ?? {}) as Record; const current = typeof subagents.defaultModel === "string" ? subagents.defaultModel : undefined; const shouldConfigure = await ctx.ui.confirm( "是否现在配置 subagent 的默认模型?", "这会写入全局 settings.json;留空可稍后手动配置。" ); if (!shouldConfigure) { notifySubagentsModelGuide(ctx, settingsPath); return; } const model = await ctx.ui.input( current ? `请输入 subagent 默认模型(当前:${current})` : "请输入 subagent 默认模型", "例如:openai-codex/gpt-5.6-sol 或 anthropic/claude-sonnet-4" ); if (!model?.trim()) { notifySubagentsModelGuide(ctx, settingsPath); return; } subagents.defaultModel = model.trim(); settings.subagents = subagents; try { await mkdir(dir, { recursive: true }); await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); ctx.ui.notify(`subagent 默认模型已写入 ${settingsPath}。请执行 /reload 或重启 Pi。`, "info"); notifySubagentsModelGuide(ctx, settingsPath); } catch (error) { ctx.ui.notify(`subagent 已安装,但模型配置写入失败:${error instanceof Error ? error.message : "未知错误"}`, "warning"); } } function notifySubagentsModelGuide(ctx: ExtensionContext, settingsPath: string): void { ctx.ui.notify( `subagent 模型配置:在 ${settingsPath} 的 "subagents" 中设置 "defaultModel";如果只想指定某个角色,可设置 "agentOverrides": { "reviewer": { "model": "provider/model" } }。修改后执行 /reload,再用 /subagents-models 查看实际映射。详见:https://github.com/nicobailon/pi-subagents/blob/main/docs/models.md`, "info" ); } async function readJsonObject(file: string): Promise | undefined> { let content: string; try { content = await readFile(file, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } const parsed = JSON.parse(content); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("必须是 JSON 对象"); } return parsed as Record; } /** Check a candidate token against the registry so typos fail fast. */ async function verifySkillHubToken(token: string): Promise<{ ok: true; identity: string } | { ok: false; error: string }> { try { const response = await fetch(`${SKILLHUB_REGISTRY}/api/cli/v1/auth/whoami`, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(15_000) }); const text = await response.text(); let parsed: { code?: number; msg?: string; data?: { handle?: string; displayName?: string; email?: string } } | undefined; try { parsed = text ? JSON.parse(text) : undefined; } catch { return { ok: false, error: `返回了非 JSON 响应 (HTTP ${response.status})` }; } if (!parsed || typeof parsed.code !== "number" || parsed.code !== 0) { if (parsed?.code === 401 || response.status === 401) { return { ok: false, error: "token 无效或已过期" }; } if (parsed?.code === 403 || response.status === 403) { return { ok: false, error: "token 有效但无访问权限" }; } return { ok: false, error: parsed?.msg || `HTTP ${response.status}` }; } const who = parsed.data ?? {}; return { ok: true, identity: who.displayName || who.handle || who.email || "已验证账号" }; } catch (error) { if (error instanceof Error && error.name === "TimeoutError") { return { ok: false, error: "连接 SkillHub 超时" }; } return { ok: false, error: `无法连接 SkillHub(${SKILLHUB_REGISTRY})` }; } } function notifySkillHubGuide(ctx: ExtensionContext, configPath: string): void { ctx.ui.notify( `SkillHub 后续步骤:在 Pi 里执行 /skillhub status 查看配置,/skillhub search <自然语言需求> 语义搜技能,/skillhub all 浏览全部,/skillhub install / 安装。Token 也可改用环境变量 ${SKILLHUB_TOKEN_ENV}(优先级高于 ${configPath})。`, "info" ); } async function configureSkillHubToken(ctx: ExtensionContext): Promise { const envToken = process.env[SKILLHUB_TOKEN_ENV]?.trim(); if (envToken) { ctx.ui.notify( `检测到环境变量 ${SKILLHUB_TOKEN_ENV},它的优先级高于配置文件,无需在此写入 token,直接执行 /skillhub status 即可。`, "info" ); return; } const dir = agentDir(); const configPath = path.join(dir, SKILLHUB_CONFIG_FILE); let config: Record = {}; try { config = (await readJsonObject(configPath)) ?? {}; } catch (error) { ctx.ui.notify( `SkillHub 配置读取失败,未修改现有配置:${error instanceof Error ? error.message : "JSON 格式无效"}`, "warning" ); return; } const current = typeof config.token === "string" ? config.token : undefined; const shouldConfigure = await ctx.ui.confirm( current ? "SkillHub 已配置 token,是否更新?" : "是否现在配置 SkillHub access token?", `Token 获取:登录 ${SKILLHUB_REGISTRY} → 个人设置 → 创建 access token(sk_ 开头)。\n将写入:${configPath}\n留空/取消可稍后用 /skillhub login 配置。` ); if (!shouldConfigure) { notifySkillHubGuide(ctx, configPath); return; } const token = await ctx.ui.input( current ? "请输入新的 SkillHub access token" : "请输入 SkillHub access token", "粘贴 sk_ 开头的 token,留空则跳过" ); const trimmed = token?.trim(); if (!trimmed) { notifySkillHubGuide(ctx, configPath); return; } if (!trimmed.startsWith("sk_")) { const proceed = await ctx.ui.confirm( "这个值不是 sk_ 开头", "SkillHub 的 access token 通常以 sk_ 开头,仍要保存吗?" ); if (!proceed) { notifySkillHubGuide(ctx, configPath); return; } } ctx.ui.notify("正在校验 token……", "info"); const checked = await verifySkillHubToken(trimmed); if (checked.ok) { ctx.ui.notify(`Token 校验通过:${checked.identity}`, "info"); } else { const proceed = await ctx.ui.confirm(`Token 校验失败:${checked.error}`, "仍要保存到配置文件吗?"); if (!proceed) { notifySkillHubGuide(ctx, configPath); return; } } config.token = trimmed; try { await mkdir(dir, { recursive: true }); await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); ctx.ui.notify(`SkillHub token 已写入 ${configPath}。请执行 /reload 或重启 Pi。`, "info"); notifySkillHubGuide(ctx, configPath); } catch (error) { ctx.ui.notify( `SkillHub 已安装,但 token 写入失败:${error instanceof Error ? error.message : "未知错误"}`, "warning" ); } } /** 把 `~`、反斜杠和引号统一成 Pi 能识别的路径写法。 */ function normalizeSkillPath(input: string): string { let value = input.trim().replace(/^['"]|['"]$/g, ""); if (value === "~") { value = os.homedir(); } else if (value.startsWith("~/") || value.startsWith("~\\")) { value = path.join(os.homedir(), value.slice(2)); } return value.replaceAll("\\", "/"); } /** 一次输入支持多个路径:换行、`;`、`;` 分隔。 */ function splitSkillInput(raw: string): string[] { return raw .split(/[\n;;]+/) .map((item) => item.trim().replace(/^['"]|['"]$/g, "")) .filter((item) => item.length > 0); } async function isFile(target: string): Promise { try { return (await stat(target)).isFile(); } catch { return false; } } /** 估算一个技能目录里能被 Pi 发现的技能数量(含 SKILL.md 的目录 + 根目录带 description 的 .md)。 */ async function countSkills(dir: string, depth: number, budget: { dirs: number }): Promise { if (depth > 4 || budget.dirs <= 0) return 0; budget.dirs -= 1; let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return 0; } let count = 0; for (const entry of entries) { if (entry.name.startsWith(".")) continue; const full = path.join(dir, entry.name); if (entry.isDirectory()) { if (await isFile(path.join(full, "SKILL.md"))) { count += 1; } else { count += await countSkills(full, depth + 1, budget); } continue; } if (depth === 0 && entry.isFile() && entry.name.toLowerCase().endsWith(".md")) { const text = await readFile(full, "utf8").catch(() => ""); if (/^---\r?\n[\s\S]*?\bdescription\s*:/m.test(text.slice(0, 2000))) count += 1; } } return count; } type SkillTargetInfo = | { ok: true; absolute: string; skillCount: number } | { ok: false; absolute: string; reason: string; skillCount: number }; async function inspectSkillTarget(raw: string, baseDir: string): Promise { const absolute = path.isAbsolute(raw) ? raw : path.resolve(baseDir, raw); if (PATH_PATTERN_CHARS.test(raw)) { return { ok: true, absolute, skillCount: 0 }; } let stats; try { stats = await stat(absolute); } catch { return { ok: false, absolute, reason: "路径不存在", skillCount: 0 }; } if (stats.isDirectory()) { const skillCount = (await countSkills(absolute, 0, { dirs: 200 })) + ((await isFile(path.join(absolute, "SKILL.md"))) ? 1 : 0); return { ok: true, absolute, skillCount }; } if (stats.isFile() && absolute.toLowerCase().endsWith(".md")) { return { ok: true, absolute, skillCount: 1 }; } return { ok: false, absolute, reason: "不是目录,也不是 Markdown 技能文件", skillCount: 0 }; } function notifySkillsGuide(ctx: ExtensionContext, settingsPath: string, paths: string[]): void { ctx.ui.notify( `Skills 配置指引:已写入 ${settingsPath} 的 skills 数组(${paths.length} 条)。执行 /reload 或重启 Pi 后生效,启动时 Pi 会扫描这些目录并把每个技能注册成 /skill:;也可以用 /pi-setup status 复查当前 skills 配置。相对路径以 ${path.dirname(settingsPath)} 为基准解析,\`~\` 和绝对路径都可直接使用。`, "info" ); } async function configureSkills(ctx: ExtensionContext): Promise { const dir = agentDir(); const settingsPath = path.join(dir, "settings.json"); let settings: Record = {}; try { settings = (await readJsonObject(settingsPath)) ?? {}; } catch (error) { ctx.ui.notify( `读取 Pi 配置失败,未修改 skills:${error instanceof Error ? error.message : "JSON 格式无效"}`, "warning" ); return; } const rawExisting = settings.skills; if (rawExisting !== undefined && (!Array.isArray(rawExisting) || rawExisting.some((item) => typeof item !== "string"))) { ctx.ui.notify("settings.json 里的 skills 不是字符串数组,暂未修改,请手动修正后再运行。", "warning"); return; } const existing = (rawExisting ?? []) as string[]; if (existing.length > 0) { ctx.ui.notify(`当前 skills 配置:\n${existing.map((item) => `- ${item}`).join("\n")}`, "info"); } else { ctx.ui.notify(`当前 skills 配置为空,将写入 ${settingsPath}`, "info"); } const collected: string[] = []; for (;;) { const hint = collected.length === 0 ? "例如:C:/Users/me/.claude/skills;多个路径可用 ; 分隔;直接回车结束" : `已收集 ${collected.length} 个路径,可继续输入(多个用 ; 分隔),直接回车结束`; const answer = await ctx.ui.input("请输入 skills 路径(存放技能目录或 SKILL.md 文件)", hint); if (answer === undefined || answer === null) break; const items = splitSkillInput(answer); if (items.length === 0) break; for (const item of items) { const normalized = normalizeSkillPath(item); if (!normalized) continue; if (collected.includes(normalized)) { ctx.ui.notify(`本次已添加相同路径,跳过:${normalized}`, "warning"); continue; } const info = await inspectSkillTarget(normalized, dir); if (!info.ok) { const keep = await ctx.ui.confirm( `路径不可用:${normalized}`, `${info.reason}。仍要写入配置吗?(Pi 启动时会忽略无效路径)` ); if (!keep) { ctx.ui.notify(`已跳过:${normalized}`, "info"); continue; } } collected.push(normalized); if (info.ok && info.skillCount > 0) { ctx.ui.notify(`已添加:${normalized}(预计可发现 ${info.skillCount} 个技能)`, "info"); } else if (info.ok) { ctx.ui.notify(`已添加:${normalized}(暂未发现技能文件,放入 SKILL.md 后即可被识别)`, "info"); } else { ctx.ui.notify(`已添加(未通过校验):${normalized}`, "warning"); } if (info.ok && info.absolute !== normalized) { ctx.ui.notify(`该相对路径解析为:${info.absolute}`, "info"); } } } if (collected.length === 0) { ctx.ui.notify("未输入任何路径,skills 配置保持不变。", "info"); return; } let merged = collected; if (existing.length > 0) { const mode = await ctx.ui.select("检测到已有 skills 配置,如何处理?", [ `追加:保留现有 ${existing.length} 条,并新增 ${collected.length} 条`, `覆盖:只保留本次输入的 ${collected.length} 条` ]); if (!mode) { ctx.ui.notify("已取消,skills 配置保持不变。", "info"); return; } if (mode.startsWith("追加")) { const additions = collected.filter((item) => !existing.includes(item)); merged = [...existing, ...additions]; } } const confirmed = await ctx.ui.confirm( "确认写入 Pi Agent 配置?", `settings.skills:\n${merged.map((item) => `- ${item}`).join("\n")}\n配置文件:${settingsPath}` ); if (!confirmed) { ctx.ui.notify("已取消,skills 配置保持不变。", "info"); return; } settings.skills = merged; try { await mkdir(dir, { recursive: true }); await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); } catch (error) { ctx.ui.notify(`写入 Pi 配置失败:${error instanceof Error ? error.message : "未知错误"}`, "error"); return; } // 写回后立即读一遍,确认配置真的落盘且 JSON 可解析。 try { const verify = await readJsonObject(settingsPath); const written = Array.isArray(verify?.skills) ? (verify.skills as unknown[]) : undefined; const missing = merged.filter((item) => !written?.includes(item)); if (!written || missing.length > 0) { ctx.ui.notify(`写入校验未通过,缺少:${missing.join("、") || "全部条目"}`, "error"); return; } ctx.ui.notify(`写入校验通过:settings.json 中共 ${written.length} 条 skills 路径。`, "info"); } catch (error) { ctx.ui.notify(`写入校验失败:${error instanceof Error ? error.message : "JSON 格式无效"}`, "warning"); } notifySkillsGuide(ctx, settingsPath, merged); } async function showStatus(pi: ExtensionAPI, ctx: ExtensionContext): Promise { const result = await runPi(pi, ["list"], 30_000); if (result.code !== 0) { ctx.ui.notify(`无法读取 Pi 包列表:${result.stderr.trim() || "未知错误"}`, "error"); return; } const installed = plugins.filter((plugin) => result.stdout.includes(plugin.packageSpec.replace("npm:", ""))); const lines = plugins.map((plugin) => `${installed.includes(plugin) ? "已安装" : "未安装"} ${plugin.label} [${plugin.components}]` ); lines.push(await describeSkills(agentDir())); ctx.ui.notify(lines.join("\n"), "info"); } /** 读取 settings.json 里的 skills,用于状态检查。 */ async function describeSkills(dir: string): Promise { const settingsPath = path.join(dir, "settings.json"); try { const settings = await readJsonObject(settingsPath); const skills = settings?.skills; if (!Array.isArray(skills) || skills.length === 0) { return `skills 未配置(${settingsPath})`; } return `skills(${skills.length} 条):${skills.join("、")}`; } catch (error) { return `skills 读取失败:${error instanceof Error ? error.message : "JSON 格式无效"}`; } } async function configureBash(pi: ExtensionAPI, ctx: ExtensionContext): Promise { const dir = agentDir(); const settingsPath = path.join(dir, "settings.json"); let settings: Record = {}; try { const content = await readFile(settingsPath, "utf8"); const parsed = JSON.parse(content); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("settings.json 必须是 JSON 对象"); } settings = parsed as Record; } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") { ctx.ui.notify(`读取 Pi 配置失败:${error instanceof Error ? error.message : "JSON 格式无效"}`, "error"); return; } } const current = typeof settings.shellPath === "string" ? settings.shellPath : undefined; const bashPath = await ctx.ui.input( current ? `请输入 bash.exe 路径(当前:${current})` : "请输入 bash.exe 路径", "例如:C:/Program Files/Git/bin/bash.exe" ); if (!bashPath?.trim()) return; const normalizedPath = bashPath.trim().replace(/^['\"]|['\"]$/g, ""); try { await access(normalizedPath, constants.F_OK); if (!(await stat(normalizedPath)).isFile()) { throw new Error("路径不是文件"); } } catch { ctx.ui.notify(`找不到 bash.exe:${normalizedPath}`, "error"); return; } const confirmed = await ctx.ui.confirm( "确认写入 Pi Agent 配置?", `shellPath: ${normalizedPath}\n配置文件:${settingsPath}` ); if (!confirmed) return; settings.shellPath = normalizedPath.replaceAll("\\", "/"); try { await mkdir(dir, { recursive: true }); await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8"); ctx.ui.notify(`Bash 路径已写入 ${settingsPath}。请重启 Pi 后生效。`, "info"); } catch (error) { ctx.ui.notify(`写入 Pi 配置失败:${error instanceof Error ? error.message : "未知错误"}`, "error"); } } async function runSetup(pi: ExtensionAPI, ctx: ExtensionContext): Promise { if (!ctx.hasUI) { throw new Error("/pi-setup 需要在 Pi 交互式界面中运行"); } const choice = await ctx.ui.select("选择要安装的 Pi Agent 插件或主题:", [ "全部推荐插件和主题", ...plugins.map((plugin) => plugin.label), "配置 Bash 环境", "配置 SkillHub Token", "配置 Skills 路径", "查看安装状态" ]); if (!choice || choice === "查看安装状态") { if (choice) await showStatus(pi, ctx); return; } if (choice === "配置 Bash 环境") { await configureBash(pi, ctx); return; } if (choice === "配置 SkillHub Token") { await configureSkillHubToken(ctx); return; } if (choice === "配置 Skills 路径") { await configureSkills(ctx); return; } const selected = choice === "全部推荐插件和主题" ? plugins : plugins.filter((plugin) => plugin.label === choice); const confirmed = await ctx.ui.confirm( "确认安装?", selected.map((plugin) => `${plugin.packageSpec}(${plugin.components})`).join("\n") ); if (!confirmed) return; let succeeded = 0; for (const plugin of selected) { if (await install(pi, plugin, ctx)) succeeded++; } if (succeeded > 0) { ctx.ui.notify(`完成:${succeeded}/${selected.length} 个包已安装。请执行 /reload 或重启 Pi。`, "info"); } } export default function (pi: ExtensionAPI) { pi.registerCommand("pi-setup", { description: "交互式安装团队 Pi Agent 插件和主题", handler: async (args, ctx) => { const action = commandArgs(args); if (action === "status") { await showStatus(pi, ctx); return; } if (action === "skills") { if (!ctx.hasUI) { throw new Error("/pi-setup skills 需要在 Pi 交互式界面中运行"); } await configureSkills(ctx); return; } await runSetup(pi, ctx); } }); }