u1s1-cli 1.5.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -221,8 +221,10 @@ export function writeWebToolsExtension(cfg, features) {
221
221
  ` }\n` +
222
222
  ` });\n` +
223
223
  ` pi.registerTool(tools.createSubagentTool(getParentModel));\n` +
224
- ` const workflow = await import(${JSON.stringify(new URL("./workflow/tool.js", import.meta.url).href)});\n` +
225
- ` pi.registerTool(workflow.createRunWorkflowTool(getParentModel));\n` +
224
+ ` try {\n` +
225
+ ` const workflow = await import(${JSON.stringify(new URL("./workflow/tool.js", import.meta.url).href)});\n` +
226
+ ` pi.registerTool(workflow.createRunWorkflowTool(getParentModel));\n` +
227
+ ` } catch {}\n` +
226
228
  ` }\n`;
227
229
  // 密钥剥离守卫也从这里装:Desktop App 的 pi 跑在 pi-web-ui 子进程里,没有
228
230
  // 启动器代码,只有扩展能替它把 U1S1_API_KEY / U1S1_EP_KEY_* 挡在子进程之外
@@ -232,7 +234,17 @@ export function writeWebToolsExtension(cfg, features) {
232
234
  ` (await import(${JSON.stringify(secretEnvUrl)})).installChildEnvGuard();\n` +
233
235
  ` if (process.env.U1S1_TOOLS_VIA_EXTENSION !== "1") return;\n` +
234
236
  ` const baseUrl = process.env.U1S1_SIGNING_PROXY_URL || ${JSON.stringify(cfg.baseUrl)};\n` +
235
- ` const tools = await import(${JSON.stringify(toolsUrl)});\n` +
237
+ // 升级半途 / 权限问题导致 CLI dist 不可读时,宁可这一会话没有联网工具,
238
+ // 也不能让扩展加载失败刷英文堆栈(审计 B4);只在 UI 里提示一句中文
239
+ ` let tools;\n` +
240
+ ` try {\n` +
241
+ ` tools = await import(${JSON.stringify(toolsUrl)});\n` +
242
+ ` } catch {\n` +
243
+ ` pi.on("session_start", (_event, ctx) => {\n` +
244
+ ` try { if (ctx.hasUI) ctx.ui.notify("联网工具本次未能加载(程序文件可能正在更新),重启 u1s1 即可恢复", "warning"); } catch {}\n` +
245
+ ` });\n` +
246
+ ` return;\n` +
247
+ ` }\n` +
236
248
  searchLine +
237
249
  ` pi.registerTool(tools.createFetchTool({ baseUrl, apiKey: process.env.U1S1_API_KEY, renderFallback: ${features.webFetchRender} }));\n` +
238
250
  imageLine +
@@ -291,7 +303,12 @@ export function writeAnnouncementsExtension() {
291
303
  ` } catch {\n` +
292
304
  ` return;\n` +
293
305
  ` }\n` +
294
- ` const { Text } = await import("@earendil-works/pi-tui");\n` +
306
+ ` let Text;\n` +
307
+ ` try {\n` +
308
+ ` Text = (await import("@earendil-works/pi-tui")).Text;\n` +
309
+ ` } catch {\n` +
310
+ ` return;\n` +
311
+ ` }\n` +
295
312
  ` pi.registerEntryRenderer("u1s1-announcement", (entry, _opts, theme) => {\n` +
296
313
  ` const d = entry.data ?? {};\n` +
297
314
  ` let text = theme.fg("text", theme.bold("📢 " + String(d.text ?? "")));\n` +
package/dist/deploy.d.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  import { type CliConfig } from "./config.js";
2
+ /**
3
+ * `u1s1 deploy remove` 的站点参数可以是 slug、完整网址或域名:
4
+ * `my-site` / `https://my-site.u1abc123.u1s1.app/` / `my-site.u1abc123.u1s1.app` 都指向 my-site。
5
+ */
6
+ export declare function siteReferenceFromInput(input: string): string;
2
7
  interface SiteFile {
3
8
  path: string;
4
9
  abs: string;
package/dist/deploy.js CHANGED
@@ -38,6 +38,30 @@ function rememberSite(dir, site) {
38
38
  // ignore
39
39
  }
40
40
  }
41
+ /** 站点删掉后把 deploys.json 里指向它的目录记录一并清掉,下次 deploy 重新问名字。 */
42
+ function forgetSite(site) {
43
+ try {
44
+ const all = readDeploys();
45
+ const remaining = Object.fromEntries(Object.entries(all).filter(([, value]) => value !== site));
46
+ if (Object.keys(remaining).length !== Object.keys(all).length) {
47
+ writeFileSync(deploysFile, JSON.stringify(remaining, null, 2) + "\n");
48
+ }
49
+ }
50
+ catch {
51
+ // ignore
52
+ }
53
+ }
54
+ /**
55
+ * `u1s1 deploy remove` 的站点参数可以是 slug、完整网址或域名:
56
+ * `my-site` / `https://my-site.u1abc123.u1s1.app/` / `my-site.u1abc123.u1s1.app` 都指向 my-site。
57
+ */
58
+ export function siteReferenceFromInput(input) {
59
+ let value = input.trim().toLowerCase();
60
+ value = value.replace(/^[a-z]+:\/\//, "");
61
+ value = value.replace(/[/?#].*$/, "");
62
+ const firstLabel = value.split(".")[0] ?? "";
63
+ return firstLabel;
64
+ }
41
65
  /** 找要部署的目录:显式参数 > 含 index.html 的构建产物目录 > 当前目录本身。 */
42
66
  function resolveSiteDir(explicit) {
43
67
  if (explicit) {
@@ -242,6 +266,44 @@ async function listDeployments(cfg) {
242
266
  }
243
267
  console.log("");
244
268
  }
269
+ async function confirmRemoval(url) {
270
+ const answer = (await askLine(` 确定删除 ${url} 吗?网址会立即失效,站点文件会被清除,无法恢复。输入 y 确认:`)).toLowerCase();
271
+ return answer === "y" || answer === "yes";
272
+ }
273
+ async function removeDeployment(cfg, args) {
274
+ const yes = args.some((arg) => arg === "--yes" || arg === "-y");
275
+ const positional = args.filter((arg) => !arg.startsWith("-"));
276
+ if (positional.length !== 1) {
277
+ throw new Error("用法:u1s1 deploy remove <站点名或网址> [--yes]");
278
+ }
279
+ const reference = siteReferenceFromInput(positional[0]);
280
+ if (!reference)
281
+ throw new Error("站点名不能为空");
282
+ // 先按列表核对,拿到完整网址给确认提示;名字打错在这里就能提前发现
283
+ const { sites } = await api(cfg, { method: "GET", path: "/deploy/sites" });
284
+ const site = sites.find((s) => (s.slug || s.name) === reference || s.name === reference);
285
+ if (!site) {
286
+ throw new Error(`没有找到站点「${reference}」。运行 u1s1 deploy list 查看已有站点`);
287
+ }
288
+ if (!yes) {
289
+ if (!process.stdin.isTTY) {
290
+ throw new Error("非交互环境下删除站点需要加 --yes 明确确认");
291
+ }
292
+ if (!(await confirmRemoval(site.url))) {
293
+ console.log(" 已取消,站点保留。");
294
+ return;
295
+ }
296
+ }
297
+ await api(cfg, {
298
+ method: "DELETE",
299
+ path: `/deploy/sites/${encodeURIComponent(site.name)}`,
300
+ });
301
+ forgetSite(site.slug || site.name);
302
+ console.log("");
303
+ console.log(` ✓ 已删除 ${site.url}`);
304
+ console.log(" 同一个名字可以再次 u1s1 deploy 重新发布。");
305
+ console.log("");
306
+ }
245
307
  async function selectDeployment(args) {
246
308
  const parsed = parseDeployArgs(args);
247
309
  let { name, dirArg, visibility } = parsed;
@@ -336,11 +398,13 @@ export function printDeployHelp() {
336
398
  console.log(" 不带参数时自动找构建产物目录(dist/build/out 等),否则用当前目录。");
337
399
  console.log(" 首次发布会问项目名和公开/私密,之后记住;再跑一次即为更新。");
338
400
  console.log("");
339
- console.log(" u1s1 deploy list 查看已发布的站点");
401
+ console.log(" u1s1 deploy list 查看已发布的站点");
402
+ console.log(" u1s1 deploy remove <站点名或网址> 删除站点(会二次确认,--yes 跳过)");
340
403
  console.log("");
341
404
  }
342
405
  export async function deployCommand(cfg, args) {
343
- // 参数:[dir] [--name xxx] [--public|--private];u1s1 deploy list 列出已有站点
406
+ // 参数:[dir] [--name xxx] [--public|--private];u1s1 deploy list 列出已有站点;
407
+ // u1s1 deploy remove <站点> 删除站点(rm/delete 同义)
344
408
  if (args.includes("--help") || args.includes("-h")) {
345
409
  printDeployHelp();
346
410
  return;
@@ -349,6 +413,10 @@ export async function deployCommand(cfg, args) {
349
413
  await listDeployments(cfg);
350
414
  return;
351
415
  }
416
+ if (args[0] === "remove" || args[0] === "rm" || args[0] === "delete") {
417
+ await removeDeployment(cfg, args.slice(1));
418
+ return;
419
+ }
352
420
  const selection = await selectDeployment(args);
353
421
  const { name, start } = await startDeployment(cfg, selection.name);
354
422
  validateDeploymentLimits(start, selection.files, selection.totalBytes);
@@ -1,3 +1,21 @@
1
+ /**
2
+ * 模型调用失败时,网关的中文说明会被 OpenAI SDK + pi-ai 包成
3
+ * `429: {"message":"…","type":"…","code":"…"}` 直出到聊天区。
4
+ * 这里把 JSON 里的 message 提出来当正文,状态码与错误代号收进尾巴。
5
+ *
6
+ * 尾巴不是装饰,必须保留:pi 按 errorMessage 正则做三类分类——
7
+ * - 重试:429/5xx 数字、rate_limit、fetch failed 等(retry.js RETRYABLE)
8
+ * - 不重试:insufficient_quota(额度用尽时快速失败,不做无谓退避)
9
+ * - 上下文超长:context_length_exceeded(触发自动压缩而非重试)
10
+ * 改写后的文本必须与原文落进同一分类,否则会破坏重试/压缩行为。
11
+ *
12
+ * 网关会在错误 JSON 里注入 error.request_id(见 gateway request-id.ts),
13
+ * 拼进尾巴让用户报障时有编号可给,客服凭它直查日志和 usage 记录。
14
+ *
15
+ * 额度用尽(reason=exhausted)时网关还带 error.resets_at(免费池刷新时刻,ISO),
16
+ * 这里换算成「距刷新还有 N 小时 M 分」接在正文后:比「北京时间 0 点」更省用户脑子。
17
+ */
18
+ export declare function quotaResetHint(resetsAt: unknown, now?: number): string;
1
19
  /** 只取错误里的 request_id(纯函数,给 request-trace 记「最近一次请求编号」用)。 */
2
20
  export declare function extractRequestId(raw: string): string | undefined;
3
- export declare function humanizeModelError(raw: string): string | undefined;
21
+ export declare function humanizeModelError(raw: string, now?: number): string | undefined;
@@ -11,7 +11,24 @@
11
11
  *
12
12
  * 网关会在错误 JSON 里注入 error.request_id(见 gateway request-id.ts),
13
13
  * 拼进尾巴让用户报障时有编号可给,客服凭它直查日志和 usage 记录。
14
+ *
15
+ * 额度用尽(reason=exhausted)时网关还带 error.resets_at(免费池刷新时刻,ISO),
16
+ * 这里换算成「距刷新还有 N 小时 M 分」接在正文后:比「北京时间 0 点」更省用户脑子。
14
17
  */
18
+ export function quotaResetHint(resetsAt, now = Date.now()) {
19
+ if (typeof resetsAt !== "string")
20
+ return "";
21
+ const at = Date.parse(resetsAt);
22
+ if (!Number.isFinite(at))
23
+ return "";
24
+ const remainingMinutes = Math.ceil((at - now) / 60_000);
25
+ if (remainingMinutes <= 0 || remainingMinutes > 48 * 60)
26
+ return "";
27
+ const hours = Math.floor(remainingMinutes / 60);
28
+ const minutes = remainingMinutes % 60;
29
+ const span = hours > 0 ? `${hours} 小时${minutes > 0 ? ` ${minutes} 分` : ""}` : `${minutes} 分`;
30
+ return `距免费额度刷新还有 ${span}`;
31
+ }
15
32
  function parseErrorPayload(raw) {
16
33
  const jsonStart = raw.indexOf("{");
17
34
  const jsonEnd = raw.lastIndexOf("}");
@@ -38,7 +55,7 @@ export function extractRequestId(raw) {
38
55
  const id = payload?.err["request_id"];
39
56
  return typeof id === "string" && id.length > 0 && id.length <= 128 ? id : undefined;
40
57
  }
41
- export function humanizeModelError(raw) {
58
+ export function humanizeModelError(raw, now = Date.now()) {
42
59
  const payload = parseErrorPayload(raw);
43
60
  if (!payload)
44
61
  return undefined;
@@ -55,6 +72,8 @@ export function humanizeModelError(raw) {
55
72
  type === "insufficient_quota" || code === "quota_exceeded" ? "insufficient_quota" : code,
56
73
  requestId ? `请求编号 ${requestId}` : "",
57
74
  ].filter(Boolean);
58
- const friendly = tags.length ? `${message} (${tags.join(" · ")})` : message;
75
+ const resetHint = err["reason"] === "exhausted" ? quotaResetHint(err["resets_at"], now) : "";
76
+ const body = resetHint ? `${message}〔${resetHint}〕` : message;
77
+ const friendly = tags.length ? `${body} (${tags.join(" · ")})` : body;
59
78
  return friendly === raw ? undefined : friendly;
60
79
  }
@@ -126,7 +126,7 @@ function parseFlags(args) {
126
126
  }
127
127
  function printHelp() {
128
128
  console.log("");
129
- console.log(" 把 Claude Code / Codex 的历史对话导入 u1s1");
129
+ console.log(" 把 Claude Code / Codex 的历史对话和技能导入 u1s1");
130
130
  console.log("");
131
131
  console.log(" 用法:");
132
132
  console.log(" u1s1 import 导入当前目录的对话");
@@ -136,6 +136,7 @@ function printHelp() {
136
136
  console.log(" u1s1 import --cwd 目录 指定项目目录(默认当前目录)");
137
137
  console.log(" u1s1 import --dry-run 只看会导哪些,不写盘");
138
138
  console.log(" u1s1 import --force 已经导过的也再导一遍");
139
+ console.log(" u1s1 import skills 导入 Claude Code / Codex 等工具的技能(SKILL.md),详见 u1s1 import skills -h");
139
140
  console.log("");
140
141
  console.log(" 导入后在对应项目里跑 u1s1,输入 /resume 就能看到。");
141
142
  console.log("");
@@ -255,6 +256,11 @@ function printImportSummary(summary) {
255
256
  console.log("");
256
257
  }
257
258
  export async function importCommand(args) {
259
+ if (args[0] === "skills" || args[0] === "skill") {
260
+ const { importSkillsCommand } = await import("./skills.js");
261
+ await importSkillsCommand(args.slice(1));
262
+ return;
263
+ }
258
264
  if (args.includes("-h") || args.includes("--help")) {
259
265
  printHelp();
260
266
  return;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `u1s1 import skills`: 把 Claude Code / Codex(以及任意 --from 目录)里的
3
+ * Agent Skills(SKILL.md)复制进 ~/.u1s1/agent/skills。pi 用同一套 SKILL.md
4
+ * 规范,所以只需要按 pi 自己的加载器校验一遍,合规的原样复制,不合规的列出来跳过。
5
+ */
6
+ export type SkillTool = "claude" | "codex" | "custom";
7
+ export interface SkillSource {
8
+ tool: SkillTool;
9
+ label: string;
10
+ dir: string;
11
+ scope: "user" | "project" | "custom";
12
+ }
13
+ export interface DiscoveredSkill {
14
+ name: string;
15
+ description: string;
16
+ tool: SkillTool;
17
+ label: string;
18
+ /** SKILL.md 所在目录;单文件技能时为该 .md 文件所在目录。 */
19
+ baseDir: string;
20
+ skillFile: string;
21
+ /** 单文件技能(skills 根目录直接放 foo.md)只复制那一个文件。 */
22
+ singleFile: boolean;
23
+ files: number;
24
+ bytes: number;
25
+ hints: string[];
26
+ }
27
+ export interface SkillProblem {
28
+ path: string;
29
+ reason: string;
30
+ }
31
+ export interface SkillImportRecord {
32
+ name: string;
33
+ tool: SkillTool;
34
+ sourcePath: string;
35
+ destPath: string;
36
+ importedAt: string;
37
+ }
38
+ export interface SkillImportIndex {
39
+ version: 1;
40
+ items: Record<string, SkillImportRecord>;
41
+ }
42
+ export interface SkillImportPlan {
43
+ pending: DiscoveredSkill[];
44
+ /** 已由本命令导过、这次不重复导(--force 可重导)。 */
45
+ alreadyImported: DiscoveredSkill[];
46
+ /** 目标目录已有同名技能但不是本命令导入的,不覆盖。 */
47
+ conflicts: DiscoveredSkill[];
48
+ /** 多个来源同名,只保留先发现的那个。 */
49
+ duplicates: DiscoveredSkill[];
50
+ }
51
+ export interface SkillImportResultItem {
52
+ name: string;
53
+ status: "imported" | "error";
54
+ destPath?: string;
55
+ detail?: string;
56
+ }
57
+ /** 超过这个体积的技能目录多半塞了数据集或依赖,先不动。 */
58
+ export declare const MAX_SKILL_BYTES: number;
59
+ export declare function skillsDestDir(): string;
60
+ export declare function skillIndexPath(): string;
61
+ /** Claude Code / Codex 各自约定的技能目录;项目级排在用户级前面,先发现的同名技能优先。 */
62
+ export declare function defaultSkillSources(cwd: string, home?: string): SkillSource[];
63
+ export declare function customSkillSource(dir: string): SkillSource;
64
+ /**
65
+ * 用 pi 自己的加载器扫一遍来源目录:能被 pi 认出来、且没有任何告警(名称/说明不合规)
66
+ * 的才算兼容,其他的进 problems 并带上 pi 给的原因。
67
+ */
68
+ export declare function discoverSkills(sources: SkillSource[]): {
69
+ skills: DiscoveredSkill[];
70
+ problems: SkillProblem[];
71
+ };
72
+ export declare function loadSkillIndex(path?: string): SkillImportIndex;
73
+ export declare function planSkillImport(skills: DiscoveredSkill[], opts: {
74
+ destDir: string;
75
+ index: SkillImportIndex;
76
+ force: boolean;
77
+ }): SkillImportPlan;
78
+ /** 复制到 <destDir>/<name>;单文件技能落成 <name>/SKILL.md,这样 --remove 只需删一个目录。 */
79
+ export declare function copySkill(skill: DiscoveredSkill, destDir: string): string;
80
+ export declare function runSkillImport(pending: DiscoveredSkill[], index: SkillImportIndex, destDir: string): SkillImportResultItem[];
81
+ /** 只删本命令导入过的技能,别人手放进去的同名目录不碰。 */
82
+ export declare function removeImportedSkill(name: string, index: SkillImportIndex, destDir: string): {
83
+ ok: true;
84
+ } | {
85
+ ok: false;
86
+ reason: string;
87
+ };
88
+ export declare function printSkillsHelp(): void;
89
+ export declare function importSkillsCommand(args: string[]): Promise<void>;
@@ -0,0 +1,492 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ import { stdin as input, stdout as output } from "node:process";
3
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { basename, join, resolve } from "node:path";
6
+ import { loadSkillsFromDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
7
+ import { formatHomePath } from "../brand.js";
8
+ import { agentDir } from "../config.js";
9
+ import { asRecord, asString, formatBytes, listHomeClaudeDirs, oneLine, uniqueExistingDirs } from "./util.js";
10
+ import { readJsonIfExists, writeJson } from "./write.js";
11
+ const TOOL_LABEL = {
12
+ claude: "Claude Code",
13
+ codex: "Codex",
14
+ custom: "自定义目录",
15
+ };
16
+ const SCRIPT_EXTENSIONS = new Set([".sh", ".bash", ".zsh", ".ps1", ".bat", ".cmd", ".py", ".js", ".mjs", ".cjs", ".ts", ".rb", ".pl"]);
17
+ /** 超过这个体积的技能目录多半塞了数据集或依赖,先不动。 */
18
+ export const MAX_SKILL_BYTES = 20 * 1024 * 1024;
19
+ const MAX_SKILL_FILES = 2000;
20
+ const LARGE_SKILL_BYTES = 1024 * 1024;
21
+ export function skillsDestDir() {
22
+ return join(agentDir, "skills");
23
+ }
24
+ export function skillIndexPath() {
25
+ return join(agentDir, "imported-skills.json");
26
+ }
27
+ /** Claude Code / Codex 各自约定的技能目录;项目级排在用户级前面,先发现的同名技能优先。 */
28
+ export function defaultSkillSources(cwd, home = homedir()) {
29
+ const candidates = [
30
+ { tool: "claude", scope: "project", dir: join(cwd, ".claude", "skills") },
31
+ { tool: "codex", scope: "project", dir: join(cwd, ".agents", "skills") },
32
+ ...listHomeClaudeDirs(home).map((dir) => ({ tool: "claude", scope: "user", dir: join(dir, "skills") })),
33
+ { tool: "codex", scope: "user", dir: join(home, ".codex", "skills") },
34
+ { tool: "codex", scope: "user", dir: join(home, ".agents", "skills") },
35
+ ];
36
+ const seen = new Set();
37
+ const out = [];
38
+ for (const c of candidates) {
39
+ const [dir] = uniqueExistingDirs([c.dir]);
40
+ if (!dir || seen.has(dir))
41
+ continue;
42
+ seen.add(dir);
43
+ out.push({ tool: c.tool, label: TOOL_LABEL[c.tool], dir, scope: c.scope });
44
+ }
45
+ return out;
46
+ }
47
+ export function customSkillSource(dir) {
48
+ return { tool: "custom", label: TOOL_LABEL.custom, dir: resolve(dir), scope: "custom" };
49
+ }
50
+ function walkStats(dir, stats, depth = 0) {
51
+ if (stats.truncated || depth > 16)
52
+ return;
53
+ let entries;
54
+ try {
55
+ entries = readdirSync(dir, { withFileTypes: true });
56
+ }
57
+ catch {
58
+ return;
59
+ }
60
+ for (const entry of entries) {
61
+ if (entry.name === "node_modules" || entry.name === ".git")
62
+ continue;
63
+ const full = join(dir, entry.name);
64
+ let st;
65
+ try {
66
+ st = statSync(full);
67
+ }
68
+ catch {
69
+ continue;
70
+ }
71
+ if (st.isDirectory()) {
72
+ walkStats(full, stats, depth + 1);
73
+ }
74
+ else if (st.isFile()) {
75
+ stats.files += 1;
76
+ stats.bytes += st.size;
77
+ const dot = entry.name.lastIndexOf(".");
78
+ const ext = dot >= 0 ? entry.name.slice(dot).toLowerCase() : "";
79
+ const executable = process.platform !== "win32" && (st.mode & 0o111) !== 0;
80
+ if (SCRIPT_EXTENSIONS.has(ext) || executable)
81
+ stats.scripts += 1;
82
+ }
83
+ if (stats.files > MAX_SKILL_FILES || stats.bytes > MAX_SKILL_BYTES) {
84
+ stats.truncated = true;
85
+ return;
86
+ }
87
+ }
88
+ }
89
+ function skillHints(stats) {
90
+ const hints = [];
91
+ if (stats.scripts > 0)
92
+ hints.push(`含 ${stats.scripts} 个脚本,只导可信来源`);
93
+ if (stats.bytes > LARGE_SKILL_BYTES)
94
+ hints.push(`体积 ${formatBytes(stats.bytes)}`);
95
+ return hints;
96
+ }
97
+ const SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
98
+ /** 单文件技能(skills 根目录直接放 foo.md)没写 name 时 pi 会拿父目录名,导入后按文件名落目录更合理。 */
99
+ function singleFileSkillName(filePath, fallback) {
100
+ try {
101
+ const { frontmatter } = parseFrontmatter(readFileSync(filePath, "utf8"));
102
+ const declared = frontmatter["name"];
103
+ if (typeof declared === "string" && declared.trim())
104
+ return declared.trim();
105
+ }
106
+ catch {
107
+ return fallback;
108
+ }
109
+ return basename(filePath).replace(/\.md$/i, "");
110
+ }
111
+ /**
112
+ * 用 pi 自己的加载器扫一遍来源目录:能被 pi 认出来、且没有任何告警(名称/说明不合规)
113
+ * 的才算兼容,其他的进 problems 并带上 pi 给的原因。
114
+ */
115
+ export function discoverSkills(sources) {
116
+ const skills = [];
117
+ const problems = [];
118
+ for (const source of sources) {
119
+ let result;
120
+ try {
121
+ result = loadSkillsFromDir({ dir: source.dir, source: source.label });
122
+ }
123
+ catch (e) {
124
+ problems.push({ path: source.dir, reason: e instanceof Error ? e.message : String(e) });
125
+ continue;
126
+ }
127
+ const reasons = new Map();
128
+ for (const diag of result.diagnostics) {
129
+ if (diag.type === "collision")
130
+ continue;
131
+ const key = diag.path ?? source.dir;
132
+ reasons.set(key, [...(reasons.get(key) ?? []), diag.message]);
133
+ }
134
+ for (const [path, list] of reasons)
135
+ problems.push({ path, reason: list.join("; ") });
136
+ for (const skill of result.skills) {
137
+ if (reasons.has(skill.filePath))
138
+ continue;
139
+ const singleFile = basename(skill.filePath) !== "SKILL.md";
140
+ const name = singleFile ? singleFileSkillName(skill.filePath, skill.name) : skill.name;
141
+ if (!SKILL_NAME_RE.test(name) || name.length > 64) {
142
+ problems.push({ path: skill.filePath, reason: `技能名「${name}」不合规:只能用小写字母、数字和连字符` });
143
+ continue;
144
+ }
145
+ const stats = { files: 0, bytes: 0, scripts: 0, truncated: false };
146
+ if (singleFile) {
147
+ try {
148
+ stats.files = 1;
149
+ stats.bytes = statSync(skill.filePath).size;
150
+ }
151
+ catch {
152
+ stats.files = 0;
153
+ }
154
+ }
155
+ else {
156
+ walkStats(skill.baseDir, stats);
157
+ }
158
+ if (stats.truncated) {
159
+ problems.push({
160
+ path: singleFile ? skill.filePath : skill.baseDir,
161
+ reason: `技能目录太大(超过 ${formatBytes(MAX_SKILL_BYTES)} 或 ${MAX_SKILL_FILES} 个文件),先跳过`,
162
+ });
163
+ continue;
164
+ }
165
+ skills.push({
166
+ name,
167
+ description: skill.description,
168
+ tool: source.tool,
169
+ label: source.label,
170
+ baseDir: skill.baseDir,
171
+ skillFile: skill.filePath,
172
+ singleFile,
173
+ files: stats.files,
174
+ bytes: stats.bytes,
175
+ hints: skillHints(stats),
176
+ });
177
+ }
178
+ }
179
+ return { skills, problems };
180
+ }
181
+ export function loadSkillIndex(path = skillIndexPath()) {
182
+ const rec = asRecord(readJsonIfExists(path));
183
+ const items = asRecord(rec?.["items"]);
184
+ const out = { version: 1, items: {} };
185
+ if (!items)
186
+ return out;
187
+ for (const [key, value] of Object.entries(items)) {
188
+ const item = asRecord(value);
189
+ if (!item)
190
+ continue;
191
+ const name = asString(item["name"]);
192
+ const tool = asString(item["tool"]);
193
+ const sourcePath = asString(item["sourcePath"]);
194
+ const destPath = asString(item["destPath"]);
195
+ const importedAt = asString(item["importedAt"]);
196
+ if (!name || !sourcePath || !destPath || !importedAt)
197
+ continue;
198
+ if (tool !== "claude" && tool !== "codex" && tool !== "custom")
199
+ continue;
200
+ out.items[key] = { name, tool, sourcePath, destPath, importedAt };
201
+ }
202
+ return out;
203
+ }
204
+ export function planSkillImport(skills, opts) {
205
+ const plan = { pending: [], alreadyImported: [], conflicts: [], duplicates: [] };
206
+ const seen = new Set();
207
+ for (const skill of skills) {
208
+ if (seen.has(skill.name)) {
209
+ plan.duplicates.push(skill);
210
+ continue;
211
+ }
212
+ seen.add(skill.name);
213
+ const dest = join(opts.destDir, skill.name);
214
+ const record = opts.index.items[skill.name];
215
+ const destExists = existsSync(dest);
216
+ if (record && destExists && !opts.force) {
217
+ plan.alreadyImported.push(skill);
218
+ continue;
219
+ }
220
+ if (!record && destExists) {
221
+ plan.conflicts.push(skill);
222
+ continue;
223
+ }
224
+ plan.pending.push(skill);
225
+ }
226
+ return plan;
227
+ }
228
+ /** 复制到 <destDir>/<name>;单文件技能落成 <name>/SKILL.md,这样 --remove 只需删一个目录。 */
229
+ export function copySkill(skill, destDir) {
230
+ const dest = join(destDir, skill.name);
231
+ if (resolve(skill.baseDir) === resolve(dest))
232
+ throw new Error("来源就是目标目录");
233
+ mkdirSync(destDir, { recursive: true });
234
+ rmSync(dest, { recursive: true, force: true });
235
+ if (skill.singleFile) {
236
+ mkdirSync(dest, { recursive: true });
237
+ cpSync(skill.skillFile, join(dest, "SKILL.md"));
238
+ }
239
+ else {
240
+ cpSync(skill.baseDir, dest, {
241
+ recursive: true,
242
+ dereference: true,
243
+ filter: (src) => {
244
+ const name = basename(src);
245
+ return name !== "node_modules" && name !== ".git";
246
+ },
247
+ });
248
+ }
249
+ return dest;
250
+ }
251
+ export function runSkillImport(pending, index, destDir) {
252
+ const items = [];
253
+ for (const skill of pending) {
254
+ try {
255
+ const destPath = copySkill(skill, destDir);
256
+ index.items[skill.name] = {
257
+ name: skill.name,
258
+ tool: skill.tool,
259
+ sourcePath: skill.singleFile ? skill.skillFile : skill.baseDir,
260
+ destPath,
261
+ importedAt: new Date().toISOString(),
262
+ };
263
+ items.push({ name: skill.name, status: "imported", destPath });
264
+ }
265
+ catch (e) {
266
+ items.push({ name: skill.name, status: "error", detail: e instanceof Error ? e.message : String(e) });
267
+ }
268
+ }
269
+ return items;
270
+ }
271
+ /** 只删本命令导入过的技能,别人手放进去的同名目录不碰。 */
272
+ export function removeImportedSkill(name, index, destDir) {
273
+ const record = index.items[name];
274
+ if (!record)
275
+ return { ok: false, reason: "不是 u1s1 import skills 导入的技能,请手动处理" };
276
+ const expected = join(destDir, name);
277
+ if (resolve(record.destPath) !== resolve(expected)) {
278
+ return { ok: false, reason: `记录的位置 ${record.destPath} 与技能目录不一致,请手动处理` };
279
+ }
280
+ try {
281
+ if (existsSync(expected) && lstatSync(expected).isDirectory())
282
+ rmSync(expected, { recursive: true, force: true });
283
+ }
284
+ catch (e) {
285
+ return { ok: false, reason: e instanceof Error ? e.message : String(e) };
286
+ }
287
+ delete index.items[name];
288
+ return { ok: true };
289
+ }
290
+ function parseSkillsArgs(args) {
291
+ const opts = {
292
+ cwd: process.cwd(),
293
+ from: [],
294
+ tools: undefined,
295
+ dryRun: false,
296
+ force: false,
297
+ yes: false,
298
+ list: false,
299
+ remove: [],
300
+ };
301
+ const needValue = (flag, i) => {
302
+ const next = args[i + 1];
303
+ if (!next || next.startsWith("-")) {
304
+ console.error(` ${flag} 后面要跟一个值`);
305
+ process.exit(1);
306
+ }
307
+ return next;
308
+ };
309
+ for (let i = 0; i < args.length; i++) {
310
+ const a = args[i];
311
+ if (a === "--dry-run")
312
+ opts.dryRun = true;
313
+ else if (a === "--force" || a === "--update")
314
+ opts.force = true;
315
+ else if (a === "-y" || a === "--yes")
316
+ opts.yes = true;
317
+ else if (a === "--list")
318
+ opts.list = true;
319
+ else if (a === "--from")
320
+ opts.from.push(needValue(a, i++));
321
+ else if (a === "--cwd")
322
+ opts.cwd = needValue(a, i++);
323
+ else if (a === "--remove")
324
+ opts.remove.push(needValue(a, i++));
325
+ else if (a.startsWith("-")) {
326
+ console.error(` 不认识参数 ${a}`);
327
+ process.exit(1);
328
+ }
329
+ else {
330
+ const tool = parseToolArg(a);
331
+ opts.tools = opts.tools ? [...opts.tools, tool] : [tool];
332
+ }
333
+ }
334
+ return opts;
335
+ }
336
+ function parseToolArg(raw) {
337
+ const aliases = {
338
+ claude: "claude",
339
+ "claude-code": "claude",
340
+ cc: "claude",
341
+ anthropic: "claude",
342
+ codex: "codex",
343
+ openai: "codex",
344
+ };
345
+ const tool = aliases[raw.toLowerCase()];
346
+ if (!tool) {
347
+ console.error(` 不认识来源「${raw}」,可选: claude / codex;其他工具用 --from 目录`);
348
+ process.exit(1);
349
+ }
350
+ return tool;
351
+ }
352
+ export function printSkillsHelp() {
353
+ console.log("");
354
+ console.log(" 把 Claude Code / Codex 等工具的技能(SKILL.md)导入 u1s1");
355
+ console.log("");
356
+ console.log(" 用法:");
357
+ console.log(" u1s1 import skills 自动找本机 Claude Code / Codex 的技能");
358
+ console.log(" u1s1 import skills claude 只导 Claude Code 的");
359
+ console.log(" u1s1 import skills codex 只导 Codex 的");
360
+ console.log(" u1s1 import skills --from 目录 其他工具:指定放 SKILL.md 的目录(可重复)");
361
+ console.log(" u1s1 import skills --cwd 目录 项目级技能按这个目录找(默认当前目录)");
362
+ console.log(" u1s1 import skills --dry-run 只看会导哪些,不写盘");
363
+ console.log(" u1s1 import skills --update 已导过的重新复制一遍(同 --force)");
364
+ console.log(" u1s1 import skills --list 看已导入的技能");
365
+ console.log(" u1s1 import skills --remove 名称 删掉某个导入的技能");
366
+ console.log("");
367
+ console.log(` 技能会复制到 ${formatHomePath(skillsDestDir())},下次进对话就能用:`);
368
+ console.log(" 模型会按说明自动调用,也可以在会话里输入 /skill:名称 手动调用。");
369
+ console.log("");
370
+ }
371
+ async function confirm(question) {
372
+ if (!input.isTTY || !output.isTTY)
373
+ return true;
374
+ const rl = createInterface({ input, output });
375
+ try {
376
+ const ans = (await rl.question(question)).trim().toLowerCase();
377
+ return ans === "" || ans === "y" || ans === "yes" || ans === "是";
378
+ }
379
+ finally {
380
+ rl.close();
381
+ }
382
+ }
383
+ function describeSource(skill) {
384
+ return formatHomePath(skill.singleFile ? skill.skillFile : skill.baseDir);
385
+ }
386
+ function printList(index) {
387
+ const items = Object.values(index.items).sort((a, b) => a.name.localeCompare(b.name));
388
+ console.log("");
389
+ if (items.length === 0) {
390
+ console.log(" 还没导入过技能。跑 u1s1 import skills 试试。");
391
+ console.log("");
392
+ return;
393
+ }
394
+ console.log(` 已导入 ${items.length} 个技能(${formatHomePath(skillsDestDir())}):`);
395
+ for (const item of items) {
396
+ const missing = existsSync(item.destPath) ? "" : "(目录已不在)";
397
+ console.log(` · ${item.name} [${TOOL_LABEL[item.tool]}] ${missing}`);
398
+ console.log(` 来自 ${formatHomePath(item.sourcePath)}`);
399
+ }
400
+ console.log("");
401
+ }
402
+ function printPreview(skills, plan, problems, sources) {
403
+ console.log("");
404
+ console.log(` 在 ${sources.length} 个目录里找到 ${skills.length} 个技能:`);
405
+ for (const source of sources)
406
+ console.log(` ${formatHomePath(source.dir)} [${source.label}]`);
407
+ console.log("");
408
+ for (const skill of plan.pending) {
409
+ console.log(` + ${skill.name} [${skill.label}] ${oneLine(skill.description, 60)}`);
410
+ console.log(` ${describeSource(skill)}${skill.hints.length ? ` ⚠ ${skill.hints.join(";")}` : ""}`);
411
+ }
412
+ for (const skill of plan.alreadyImported)
413
+ console.log(` · ${skill.name} 已导过(加 --update 重导)`);
414
+ for (const skill of plan.conflicts) {
415
+ console.log(` · ${skill.name} 目标目录已有同名技能(不是本命令导的),不覆盖`);
416
+ }
417
+ for (const skill of plan.duplicates)
418
+ console.log(` · ${skill.name} 与前面同名,只保留先发现的(${describeSource(skill)})`);
419
+ for (const problem of problems) {
420
+ console.log(` × ${formatHomePath(problem.path)} 不兼容,跳过: ${oneLine(problem.reason, 80)}`);
421
+ }
422
+ }
423
+ export async function importSkillsCommand(args) {
424
+ if (args.includes("-h") || args.includes("--help")) {
425
+ printSkillsHelp();
426
+ return;
427
+ }
428
+ const opts = parseSkillsArgs(args);
429
+ const destDir = skillsDestDir();
430
+ const index = loadSkillIndex();
431
+ if (opts.remove.length > 0) {
432
+ for (const name of opts.remove) {
433
+ const result = removeImportedSkill(name, index, destDir);
434
+ if (result.ok)
435
+ console.log(` ✓ 已删除技能 ${name}`);
436
+ else
437
+ console.log(` × ${name}: ${result.reason}`);
438
+ }
439
+ writeJson(skillIndexPath(), index);
440
+ return;
441
+ }
442
+ if (opts.list) {
443
+ printList(index);
444
+ return;
445
+ }
446
+ const sources = [
447
+ ...opts.from.map(customSkillSource),
448
+ ...defaultSkillSources(opts.cwd).filter((s) => !opts.tools || opts.tools.includes(s.tool)),
449
+ ];
450
+ const missingFrom = opts.from.filter((dir) => !existsSync(dir));
451
+ for (const dir of missingFrom)
452
+ console.log(` --from 目录不存在: ${dir}`);
453
+ if (sources.length === 0) {
454
+ console.log("");
455
+ console.log(" 没找到 Claude Code / Codex 的技能目录(~/.claude/skills、~/.codex/skills、项目里的 .claude/skills 或 .agents/skills)。");
456
+ console.log(" 其他工具的技能可以用 --from 指定目录,只要里面有 SKILL.md。");
457
+ console.log("");
458
+ return;
459
+ }
460
+ const { skills, problems } = discoverSkills(sources);
461
+ const plan = planSkillImport(skills, { destDir, index, force: opts.force });
462
+ printPreview(skills, plan, problems, sources);
463
+ if (plan.pending.length === 0) {
464
+ console.log("");
465
+ console.log(skills.length === 0 ? " 这些目录里没有能用的技能。" : " 没有新的可导。想重导一遍就加 --update。");
466
+ console.log("");
467
+ return;
468
+ }
469
+ if (opts.dryRun) {
470
+ console.log("");
471
+ console.log(` 预演结束,以上 ${plan.pending.length} 个还没真正导入。`);
472
+ console.log("");
473
+ return;
474
+ }
475
+ if (!opts.yes && !(await confirm(` 导入这 ${plan.pending.length} 个技能?(回车=好 / n=取消) `))) {
476
+ console.log(" 已取消。");
477
+ return;
478
+ }
479
+ const results = runSkillImport(plan.pending, index, destDir);
480
+ writeJson(skillIndexPath(), index);
481
+ const imported = results.filter((r) => r.status === "imported").length;
482
+ const errors = results.filter((r) => r.status === "error");
483
+ console.log("");
484
+ console.log(` ✓ 导入 ${imported} 个技能到 ${formatHomePath(destDir)}${errors.length ? `,失败 ${errors.length}` : ""}。`);
485
+ for (const item of errors)
486
+ console.log(` × ${item.name}: ${item.detail ?? "失败"}`);
487
+ if (imported > 0) {
488
+ console.log(" 下次进 u1s1 对话就能用:模型会按说明自动调用,或输入 /skill:名称 手动调用。");
489
+ console.log(` 管理: u1s1 import skills --list / --update / --remove 名称`);
490
+ }
491
+ console.log("");
492
+ }
@@ -2,7 +2,7 @@ export declare const MAX_TOOL_RESULT_CHARS = 80000;
2
2
  export declare const MAX_TEXT_CHARS = 200000;
3
3
  export declare const PREVIEW_TITLE_CHARS = 48;
4
4
  export declare function resolveExistingDir(path: string): string | undefined;
5
- export declare function listHomeClaudeDirs(): string[];
5
+ export declare function listHomeClaudeDirs(home?: string): string[];
6
6
  export declare function uniqueExistingDirs(paths: Array<string | undefined>): string[];
7
7
  export declare function encodeClaudeProjectDir(cwd: string): string;
8
8
  export declare function samePath(a: string, b: string): boolean;
@@ -15,8 +15,7 @@ export function resolveExistingDir(path) {
15
15
  return undefined;
16
16
  }
17
17
  }
18
- export function listHomeClaudeDirs() {
19
- const home = homedir();
18
+ export function listHomeClaudeDirs(home = homedir()) {
20
19
  const out = [];
21
20
  try {
22
21
  for (const name of readdirSync(home)) {
package/dist/index.js CHANGED
@@ -349,7 +349,7 @@ async function runAgent(cfg, args) {
349
349
  theme.fg("text", "/model 切换模型(留意免费/价格提示) · /clear 清空上下文开新会话"),
350
350
  theme.fg("text", "/usage 查剩余额度 · /resume 恢复历史会话 · /settings 设置"),
351
351
  theme.fg("text", "/feedback 一句话反馈问题或建议(自动附带版本与请求编号建工单)"),
352
- theme.fg("dim", "/compact 压缩上下文 · /hotkeys 全部快捷键(英文) · /exit 退出"),
352
+ theme.fg("dim", "/compact 压缩上下文 · /hotkeys 全部快捷键 · /exit 退出"),
353
353
  theme.fg("dim", "退出后在终端:u1s1 deploy 发布网页 · u1s1 update 升级"),
354
354
  theme.fg("dim", "匿名使用统计只记会话开始/结束等节点,不含任何内容;U1S1_TELEMETRY=0 可关闭"),
355
355
  theme.fg("dim", "新手教程 → https://u1s1.io/guides"),
@@ -527,8 +527,9 @@ async function run() {
527
527
  console.log(" u1s1 update 升级到最新版");
528
528
  console.log(" u1s1 deploy 发布网页(--public / --private)");
529
529
  console.log(" u1s1 deploy list 查看已发布的站点");
530
+ console.log(" u1s1 deploy remove 删除已发布的站点");
530
531
  console.log(" u1s1 feedback \"一句话\" 反馈问题或建议(自动建工单,--bug/--question…)");
531
- console.log(" u1s1 import 导入历史会话");
532
+ console.log(" u1s1 import 导入历史会话(import skills 导入技能)");
532
533
  console.log(" u1s1 bench 模型编码能力评测");
533
534
  console.log(" u1s1 --version 查看版本");
534
535
  console.log("");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u1s1-cli",
3
- "version": "1.5.0",
3
+ "version": "1.7.0",
4
4
  "description": "u1s1 — 有一说一,最省心的 AI 编程搭子。终端里用中文说需求,AI 帮你读文件、改代码、跑命令。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -90,7 +90,7 @@ const SLASH_COMMANDS_REPLACEMENTS = [
90
90
  ['{ name: "name", description: "Set session display name" },', '{ name: "name", description: "给当前会话起个名字" },'],
91
91
  ['{ name: "session", description: "Show session info and stats" },', '{ name: "session", description: "查看会话信息与统计" },'],
92
92
  ['{ name: "changelog", description: "Show changelog entries" },', '{ name: "changelog", description: "查看引擎更新日志(英文)" },'],
93
- ['{ name: "hotkeys", description: "Show all keyboard shortcuts" },', '{ name: "hotkeys", description: "查看全部快捷键(英文)" },'],
93
+ ['{ name: "hotkeys", description: "Show all keyboard shortcuts" },', '{ name: "hotkeys", description: "查看全部快捷键" },'],
94
94
  ['{ name: "fork", description: "Create a new fork from a previous user message" },', '{ name: "fork", description: "从之前某条消息分叉出一个新会话" },'],
95
95
  ['{ name: "clone", description: "Duplicate the current session at the current position" },', '{ name: "clone", description: "复制当前会话再继续" },'],
96
96
  ['{ name: "trust", description: "Save project trust decision for future sessions" },', '{ name: "trust", description: "记住对这个项目目录的信任选择" },'],
@@ -103,6 +103,114 @@ const SLASH_COMMANDS_REPLACEMENTS = [
103
103
  ['{ name: "quit", description: `Quit ${APP_NAME}` },', '{ name: "quit", description: `退出 ${APP_NAME}` },'],
104
104
  ];
105
105
 
106
+ // 扩展报错文案汉化 + 默认不刷堆栈(dist/modes/interactive/interactive-mode.js),与 pnpm coding-agent patch 同步。
107
+ // 小白看到一屏英文 JS 堆栈只会以为程序坏了;一句中文 + 下一步(重启 / u1s1 feedback)就够。
108
+ const EXTENSION_ERROR_REPLACEMENTS = [
109
+ ['const errorMsg = `Extension "${extensionPath}" error: ${error}`;', 'const errorMsg = `扩展 ${String(extensionPath).split(/[\\\\/]/).pop()} 出错(重启 u1s1 一般可恢复;反复出现请运行 u1s1 feedback 反馈):${error}`;'],
110
+ ['if (stack) {', 'if (stack && process.env.U1S1_DEBUG_EXTENSIONS === "1") {'],
111
+ ];
112
+
113
+ function patchExtensionError() {
114
+ for (const piDir of findPackageDirs("@earendil-works/pi-coding-agent")) {
115
+ const target = join(piDir, "dist", "modes", "interactive", "interactive-mode.js");
116
+ try {
117
+ let text = readFileSync(target, "utf8");
118
+ let applied = 0;
119
+ for (const [from, to] of EXTENSION_ERROR_REPLACEMENTS) {
120
+ if (text.includes(to)) {
121
+ applied++;
122
+ continue;
123
+ }
124
+ if (text.split(from).length !== 2) continue;
125
+ text = text.replace(from, to);
126
+ applied++;
127
+ }
128
+ if (applied < EXTENSION_ERROR_REPLACEMENTS.length) {
129
+ console.log(
130
+ `[u1s1] 提示: pi 版本可能已更新,扩展报错文案补丁只应用了 ${applied}/${EXTENSION_ERROR_REPLACEMENTS.length} 处(不影响使用)`,
131
+ );
132
+ }
133
+ writeFileSync(target, text);
134
+ } catch {
135
+ continue;
136
+ }
137
+ }
138
+ }
139
+
140
+ // /hotkeys 快捷键表汉化(dist/modes/interactive/interactive-mode.js),与 pnpm coding-agent patch 同步。
141
+ // 表格由 pi 硬编码英文拼出来,启动横幅/help 都在引导新手输入 /hotkeys,整页英文等于没给。
142
+ // 只译说明列,按键显示(`${cursorUp}` 等)原样保留;表头出现 4 次,用 expected 计数整体替换。
143
+ const HOTKEYS_REPLACEMENTS = [
144
+ ['"Keyboard Shortcuts"', '"快捷键一览"'],
145
+ ["**Navigation**", "**移动**"],
146
+ ["**Editing**", "**编辑**"],
147
+ ["**Other**", "**其他**"],
148
+ ["**Extensions**", "**扩展**"],
149
+ { from: "| Key | Action |", to: "| 按键 | 操作 |", expected: 4 },
150
+ ["| Move cursor / browse history |", "| 移动光标 / 翻看输入历史 |"],
151
+ ["| Move by word |", "| 按词移动 |"],
152
+ ["| Start of line |", "| 跳到行首 |"],
153
+ ["| End of line |", "| 跳到行尾 |"],
154
+ ["| Jump forward to character |", "| 向后跳到指定字符 |"],
155
+ ["| Jump backward to character |", "| 向前跳到指定字符 |"],
156
+ ["| Scroll by page |", "| 整页滚动 |"],
157
+ ["| Send message |", "| 发送消息 |"],
158
+ ['| New line${process.platform === "win32" ? " (Ctrl+Enter on Windows Terminal)" : ""} |', '| 换行${process.platform === "win32" ? "(Windows Terminal 用 Ctrl+Enter)" : ""} |'],
159
+ ["| Delete word backwards |", "| 删除光标前一个词 |"],
160
+ ["| Delete word forwards |", "| 删除光标后一个词 |"],
161
+ ["| Delete to start of line |", "| 删到行首 |"],
162
+ ["| Delete to end of line |", "| 删到行尾 |"],
163
+ ["| Paste the most-recently-deleted text |", "| 粘贴最近删除的文本 |"],
164
+ ["| Cycle through the deleted text after pasting |", "| 粘贴后在更早删除的文本间循环 |"],
165
+ ["| Undo |", "| 撤销 |"],
166
+ ["| Path completion / accept autocomplete |", "| 路径补全 / 接受自动补全 |"],
167
+ ["| Cancel autocomplete / abort streaming |", "| 取消补全 / 中断模型回复 |"],
168
+ ["| Clear editor (first) / exit (second) |", "| 清空输入框(再按一次退出) |"],
169
+ ["| Exit (when editor is empty) |", "| 退出(输入框为空时) |"],
170
+ ["| Suspend to background |", "| 挂起到后台 |"],
171
+ ["| Cycle thinking level |", "| 切换思考深度 |"],
172
+ ["| Cycle models |", "| 轮换模型 |"],
173
+ ["| Open model selector |", "| 打开模型选择器 |"],
174
+ ["| Toggle tool output expansion |", "| 展开 / 收起工具输出 |"],
175
+ ["| Toggle thinking block visibility |", "| 显示 / 隐藏思考过程 |"],
176
+ ["| Edit message in external editor |", "| 用外部编辑器写消息 |"],
177
+ ["| Copy last assistant message |", "| 复制最近一条回复 |"],
178
+ ["| Queue follow-up message |", "| 把消息排到当前回复之后 |"],
179
+ ["| Restore queued messages |", "| 取回排队中的消息 |"],
180
+ ["| Paste image or text from clipboard |", "| 从剪贴板粘贴图片或文本 |"],
181
+ ["| Slash commands |", "| 斜杠命令(/help 看常用操作) |"],
182
+ ["| Run bash command |", "| 运行终端命令 |"],
183
+ ["| Run bash command (excluded from context) |", "| 运行终端命令(结果不进上下文) |"],
184
+ ];
185
+
186
+ function patchHotkeys() {
187
+ for (const piDir of findPackageDirs("@earendil-works/pi-coding-agent")) {
188
+ const target = join(piDir, "dist", "modes", "interactive", "interactive-mode.js");
189
+ try {
190
+ let text = readFileSync(target, "utf8");
191
+ let applied = 0;
192
+ for (const rule of HOTKEYS_REPLACEMENTS) {
193
+ const { from, to, expected } = Array.isArray(rule) ? { from: rule[0], to: rule[1], expected: 1 } : rule;
194
+ if (text.includes(to)) {
195
+ applied++;
196
+ continue;
197
+ }
198
+ if (text.split(from).length !== expected + 1) continue;
199
+ text = text.split(from).join(to);
200
+ applied++;
201
+ }
202
+ if (applied < HOTKEYS_REPLACEMENTS.length) {
203
+ console.log(
204
+ `[u1s1] 提示: pi 版本可能已更新,/hotkeys 汉化补丁只应用了 ${applied}/${HOTKEYS_REPLACEMENTS.length} 处(不影响使用)`,
205
+ );
206
+ }
207
+ writeFileSync(target, text);
208
+ } catch {
209
+ continue;
210
+ }
211
+ }
212
+ }
213
+
106
214
  function patchSlashCommands() {
107
215
  for (const piDir of findPackageDirs("@earendil-works/pi-coding-agent")) {
108
216
  const target = join(piDir, "dist", "core", "slash-commands.js");
@@ -219,6 +327,8 @@ const ENABLE_AUTOWRAP = "\\x1b[?7h";`,
219
327
  try {
220
328
  patchCompactUi();
221
329
  patchSlashCommands();
330
+ patchExtensionError();
331
+ patchHotkeys();
222
332
  patchMainScreenAutowrap();
223
333
  } catch (err) {
224
334
  console.log(`[u1s1] 提示: pi 运行时补丁未生效(${err?.message ?? err}),不影响安装`);