u1s1-cli 1.14.6 → 1.15.1

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.
Files changed (44) hide show
  1. package/dist/agent-setup.d.ts +7 -0
  2. package/dist/agent-setup.js +14 -0
  3. package/dist/agents/agent-config.d.ts +83 -0
  4. package/dist/agents/agent-config.js +154 -0
  5. package/dist/agents/claude.d.ts +113 -0
  6. package/dist/agents/claude.js +411 -0
  7. package/dist/agents/codex.d.ts +111 -0
  8. package/dist/agents/codex.js +392 -0
  9. package/dist/agents/launch.d.ts +109 -0
  10. package/dist/agents/launch.js +444 -0
  11. package/dist/agents/pi.d.ts +9 -0
  12. package/dist/agents/pi.js +562 -0
  13. package/dist/agents/prefs.d.ts +35 -0
  14. package/dist/agents/prefs.js +96 -0
  15. package/dist/agents/runtime.d.ts +354 -0
  16. package/dist/agents/runtime.js +884 -0
  17. package/dist/agents/status.d.ts +20 -0
  18. package/dist/agents/status.js +241 -0
  19. package/dist/config-command.d.ts +18 -0
  20. package/dist/config-command.js +335 -0
  21. package/dist/config.d.ts +17 -0
  22. package/dist/config.js +35 -1
  23. package/dist/device-auth.d.ts +37 -1
  24. package/dist/device-auth.js +43 -8
  25. package/dist/embed.d.ts +13 -0
  26. package/dist/embed.js +15 -0
  27. package/dist/index.js +22 -556
  28. package/dist/launcher.d.ts +48 -0
  29. package/dist/launcher.js +235 -0
  30. package/dist/mcp-server.d.ts +105 -0
  31. package/dist/mcp-server.js +449 -0
  32. package/dist/pi-ui-text.d.ts +57 -0
  33. package/dist/pi-ui-text.js +148 -0
  34. package/dist/usage.js +2 -2
  35. package/package.json +1 -1
  36. package/scripts/p0-claude-code/README.md +39 -0
  37. package/scripts/p0-claude-code/launch-claude.ts +94 -0
  38. package/scripts/p0-claude-code/replay.ts +74 -0
  39. package/scripts/p0-claude-code/seed-local.mjs +145 -0
  40. package/scripts/p0-claude-code/tap.mjs +105 -0
  41. package/scripts/patch-pi.js +76 -55
  42. package/scripts/pi-patch-report.mjs +224 -0
  43. package/scripts/pi-upgrade.mjs +829 -0
  44. package/scripts/render-regression.mjs +67 -5
@@ -13,49 +13,102 @@
13
13
  */
14
14
 
15
15
  import { readFileSync, writeFileSync, existsSync, realpathSync } from "node:fs";
16
- import { join, dirname, resolve, sep } from "node:path";
16
+ import { basename, join, dirname, resolve, sep } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
- import { createRequire } from "node:module";
19
18
 
20
19
  const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
21
20
 
22
- // npm may hoist dependencies above the package, especially for global installs.
23
- // Resolve packages through Node first, then walk upward as a fallback.
24
- function addPackageDir(found, candidate) {
25
- if (!existsSync(join(candidate, "package.json"))) return;
21
+ // Only patch the pi copies this CLI actually loads at runtime. Earlier versions also walked every
22
+ // ancestor node_modules up to the filesystem root and patched whatever pi they found there (a
23
+ // user's own `npm i -g` pi next to u1s1-cli, ~/node_modules, the main checkout above a worktree).
24
+ // Node's package lookup stops at the first node_modules/<name> folder from the importer upward,
25
+ // so that one folder is the only copy that can ever load; everything above it is someone else's.
26
+ function realDir(dir) {
26
27
  try {
27
- found.add(realpathSync(candidate));
28
+ return realpathSync(dir);
28
29
  } catch {
29
- found.add(candidate);
30
+ return dir;
30
31
  }
31
32
  }
32
33
 
33
- // Tests set U1S1_PATCH_PI_SCAN_ROOT so fixtures under the OS temp dir never touch stray installs above them.
34
+ // Tests and tooling (pi-upgrade.mjs) set U1S1_PATCH_PI_SCAN_ROOT: the lookup never leaves that
35
+ // directory and anything resolved outside it is ignored, so fixtures under the OS temp dir stay
36
+ // isolated even when no copy exists inside them.
34
37
  const scanRoot = process.env.U1S1_PATCH_PI_SCAN_ROOT ? realpathSync(resolve(process.env.U1S1_PATCH_PI_SCAN_ROOT)) : undefined;
38
+ const insideScanRoot = (dir) => !scanRoot || dir === scanRoot || dir.startsWith(scanRoot + sep);
35
39
 
36
- function findPackageDirs(packageName) {
37
- const found = new Set();
40
+ function readPackageName(dir) {
38
41
  try {
39
- const req = createRequire(join(pkgRoot, "package.json"));
40
- addPackageDir(found, dirname(req.resolve(`${packageName}/package.json`)));
42
+ return JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).name;
41
43
  } catch {
42
- // The package may block package.json through exports; the upward scan below still finds it.
44
+ return undefined;
43
45
  }
46
+ }
47
+
48
+ // From a file inside a package (its resolved entry point) up to the package root.
49
+ function packageRootOf(file, packageName) {
50
+ let dir = dirname(file);
51
+ while (true) {
52
+ if (readPackageName(dir) === packageName) return dir;
53
+ const parent = dirname(dir);
54
+ if (parent === dir || basename(dir) === "node_modules") return undefined;
55
+ dir = parent;
56
+ }
57
+ }
58
+
59
+ // Node's package folder lookup (node_modules of fromDir, then of each ancestor; first folder wins),
60
+ // for when the entry point can't be resolved: fixtures without dist/index.js, a broken install.
61
+ function nearestPackageDir(packageName, fromDir) {
44
62
  const [scope, name] = packageName.split("/");
45
- let dir = pkgRoot;
63
+ let dir = fromDir;
46
64
  while (true) {
47
- addPackageDir(found, join(dir, "node_modules", scope, name));
65
+ const candidate = join(dir, "node_modules", scope, name);
66
+ if (existsSync(join(candidate, "package.json"))) return candidate;
48
67
  const parent = dirname(dir);
49
- if (parent === dir || resolve(dir) === scanRoot) break;
68
+ if (parent === dir || dir === scanRoot) return undefined;
50
69
  dir = parent;
51
70
  }
52
- return [...found].filter((d) => !scanRoot || d === scanRoot || d.startsWith(scanRoot + sep));
53
71
  }
54
72
 
73
+ // The CLI loads pi with ESM import from its own package, and import.meta.resolve runs that same
74
+ // resolver. pi-coding-agent's exports hides package.json (and only has an "import" condition, so
75
+ // require.resolve can't see it either): fall back to resolving the entry point and walking up.
76
+ function resolveFromCli(packageName) {
77
+ if (typeof import.meta.resolve !== "function") return undefined;
78
+ for (const spec of [`${packageName}/package.json`, packageName]) {
79
+ try {
80
+ const root = packageRootOf(realDir(fileURLToPath(import.meta.resolve(spec))), packageName);
81
+ if (root) return root;
82
+ } catch {
83
+ // Blocked by exports or entry file missing; try the next form.
84
+ }
85
+ }
86
+ return undefined;
87
+ }
88
+
89
+ // The package directory Node loads for `import(packageName)` from fromDir, or undefined.
90
+ // Deliberately not require.resolve: it also consults NODE_PATH and global folders ESM never reads.
91
+ function resolvePackageDir(packageName, fromDir) {
92
+ const resolved = fromDir === pkgRoot ? resolveFromCli(packageName) : undefined;
93
+ const nearest = resolved ? undefined : nearestPackageDir(packageName, fromDir);
94
+ const dir = resolved ?? (nearest && realDir(nearest));
95
+ return dir && insideScanRoot(dir) ? dir : undefined;
96
+ }
97
+
98
+ function findPackageDirs(packageName) {
99
+ const dir = resolvePackageDir(packageName, pkgRoot);
100
+ return dir ? [dir] : [];
101
+ }
102
+
103
+ // pi-coding-agent loads its own pi-tui (nested node_modules, a pnpm sibling in .pnpm, or a hoisted
104
+ // copy), which can differ from the one the CLI resolves directly; patch both.
55
105
  function findTuiDirs() {
56
106
  const found = new Set(findPackageDirs("@earendil-works/pi-tui"));
57
107
  for (const codingAgentDir of findPackageDirs("@earendil-works/pi-coding-agent")) {
58
- addPackageDir(found, join(codingAgentDir, "node_modules", "@earendil-works", "pi-tui"));
108
+ const nested = join(codingAgentDir, "node_modules", "@earendil-works", "pi-tui");
109
+ if (existsSync(join(nested, "package.json"))) found.add(realDir(nested));
110
+ const own = resolvePackageDir("@earendil-works/pi-tui", codingAgentDir);
111
+ if (own) found.add(own);
59
112
  }
60
113
  return [...found];
61
114
  }
@@ -67,35 +120,9 @@ function findTuiDirs() {
67
120
  // 不抽成文件内 helper:规则逐条独立应用,helper 那条没打上而用到它的打上了会直接 ReferenceError。
68
121
  // 旧版安装打的是纯中文替换,zhOnly(to) 能还原出那个旧结果,applyTextRules 会把它升级成双语版。
69
122
 
70
- // 内置斜杠命令描述(dist/core/slash-commands.js)。模块加载时求值:CLI 在动态 import pi 之前就设好了 U1S1_LANG。
71
- const SLASH_COMMANDS_REPLACEMENTS = [
72
- ['{ name: "settings", description: "Open settings menu" },', '{ name: "settings", description: process.env.U1S1_LANG === "en" ? "Open settings menu" : "打开设置菜单" },'],
73
- ['{ name: "model", description: "Select model (opens selector UI)", argumentHint: "<provider/model>" },', '{ name: "model", description: process.env.U1S1_LANG === "en" ? "Select model (opens selector UI)" : "切换模型(列表里有免费/价格提示)", argumentHint: "<provider/model>" },'],
74
- ['{ name: "tree", description: "Navigate session tree (switch branches)" },', '{ name: "tree", description: process.env.U1S1_LANG === "en" ? "Navigate session tree (switch branches)" : "查看会话分支树,切换分支" },'],
75
- ['{ name: "thinking", description: "Set thinking level", argumentHint: "<level>" },', '{ name: "thinking", description: process.env.U1S1_LANG === "en" ? "Set thinking level" : "调整思考强度(想得越深越费额度)", argumentHint: "<level>" },'],
76
- ['{ name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" },', '{ name: "scoped-models", description: process.env.U1S1_LANG === "en" ? "Enable/disable models for Ctrl+P cycling" : "设置 Ctrl+P 轮换的模型范围" },'],
77
- ['{ name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" },', '{ name: "export", description: process.env.U1S1_LANG === "en" ? "Export session (HTML default, or specify path: .html/.jsonl)" : "导出会话(默认 HTML,也可指定 .html/.jsonl 路径)" },'],
78
- ['{ name: "import", description: "Import and resume a session from a JSONL file" },', '{ name: "import", description: process.env.U1S1_LANG === "en" ? "Import and resume a session from a JSONL file" : "从 JSONL 文件导入并继续会话" },'],
79
- ['{ name: "share", description: "Share session as a secret GitHub gist" },', '{ name: "share", description: process.env.U1S1_LANG === "en" ? "Share session as a secret GitHub gist" : "把会话分享为 GitHub 私密 gist" },'],
80
- // /bug 会把诊断信息上传给 Pi 上游开发者,u1s1 用户的问题应走 /feedback 到我们这边,从候选列表里去掉
81
- ['{ name: "bug", description: "Report a bug to the Pi developers", argumentHint: "<description>" },', ''],
82
- ['{ name: "copy", description: "Copy last agent message to clipboard" },', '{ name: "copy", description: process.env.U1S1_LANG === "en" ? "Copy last agent message to clipboard" : "复制上一条回复到剪贴板" },'],
83
- ['{ name: "name", description: "Set session display name" },', '{ name: "name", description: process.env.U1S1_LANG === "en" ? "Set session display name" : "给当前会话起个名字" },'],
84
- ['{ name: "session", description: "Show session info and stats" },', '{ name: "session", description: process.env.U1S1_LANG === "en" ? "Show session info and stats" : "查看会话信息与统计" },'],
85
- ['{ name: "changelog", description: "Show changelog entries" },', '{ name: "changelog", description: process.env.U1S1_LANG === "en" ? "Show changelog entries" : "查看引擎更新日志(英文)" },'],
86
- ['{ name: "hotkeys", description: "Show all keyboard shortcuts" },', '{ name: "hotkeys", description: process.env.U1S1_LANG === "en" ? "Show all keyboard shortcuts" : "查看全部快捷键" },'],
87
- ['{ name: "fork", description: "Create a new fork from a previous user message" },', '{ name: "fork", description: process.env.U1S1_LANG === "en" ? "Create a new fork from a previous user message" : "从之前某条消息分叉出一个新会话" },'],
88
- ['{ name: "clone", description: "Duplicate the current session at the current position" },', '{ name: "clone", description: process.env.U1S1_LANG === "en" ? "Duplicate the current session at the current position" : "复制当前会话再继续" },'],
89
- ['{ name: "trust", description: "Save project trust decision for future sessions" },', '{ name: "trust", description: process.env.U1S1_LANG === "en" ? "Save project trust decision for future sessions" : "记住对这个项目目录的信任选择" },'],
90
- // /login /logout 管的是 pi 自己的渠道登录,u1s1 账号要退出 TUI 用 u1s1 login/logout,英文也照这个意思写
91
- ['{ name: "login", description: "Configure provider authentication", argumentHint: "<provider>" },', '{ name: "login", description: process.env.U1S1_LANG === "en" ? "To switch u1s1 accounts, /exit and run u1s1 login" : "换 u1s1 账号请先 /exit,再运行 u1s1 login", argumentHint: "<provider>" },'],
92
- ['{ name: "logout", description: "Remove provider authentication" },', '{ name: "logout", description: process.env.U1S1_LANG === "en" ? "To sign out, /exit and run u1s1 logout" : "退出登录请先 /exit,再运行 u1s1 logout" },'],
93
- ['{ name: "new", description: "Start a new session" },', '{ name: "new", description: process.env.U1S1_LANG === "en" ? "Start a new session" : "开始新会话" },'],
94
- ['{ name: "compact", description: "Manually compact the session context" },', '{ name: "compact", description: process.env.U1S1_LANG === "en" ? "Manually compact the session context" : "压缩会话上下文,腾出空间继续聊" },'],
95
- ['{ name: "resume", description: "Resume a different session" },', '{ name: "resume", description: process.env.U1S1_LANG === "en" ? "Resume a different session" : "恢复或切换到其他会话" },'],
96
- ['{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, themes, and context files" },', '{ name: "reload", description: process.env.U1S1_LANG === "en" ? "Reload keybindings, extensions, skills, prompts, themes, and context files" : "重新加载扩展、技能、主题等资源" },'],
97
- ['{ name: "quit", description: `Quit ${APP_NAME}` },', '{ name: "quit", description: process.env.U1S1_LANG === "en" ? `Quit ${APP_NAME}` : `退出 ${APP_NAME}` },'],
98
- ];
123
+ // 内置斜杠命令的补全描述(SLASH_COMMANDS 24 条)和工作提示(WORKING 2 条)已改由 u1s1 扩展实现,
124
+ // 见 src/pi-ui-text.ts;手动输入 /bug 的拦截仍在下面的 BUG_REPORT。这里不再碰那两个位置:从旧版原地升级时
125
+ // node_modules 里的 Pi 可能还留着旧补丁(双语或纯中文),扩展按命令名 + 现有描述匹配,三种状态都换成当前语言的文案。
99
126
 
100
127
  // 扩展报错一句话 + 默认不刷堆栈(dist/modes/interactive/interactive-mode.js)。
101
128
  // 小白看到一屏 JS 堆栈只会以为程序坏了;一句话 + 下一步(重启 / u1s1 feedback)就够。
@@ -151,7 +178,7 @@ const HOTKEYS_REPLACEMENTS = [
151
178
  ];
152
179
 
153
180
  // 新手最常撞见的几句:Esc 中断后的 "Operation aborted"、每条报错前缀 "Error:"、
154
- // 每轮都在转的 "Working..."、选择器脚注的 "navigate/select/cancel"、/model 列表只显示 id
181
+ // 选择器脚注的 "navigate/select/cancel"、/model 列表只显示 id
155
182
  // 看不到免费/价格提示、/settings 整页英文。行为不变,只换文案;选择器改为渲染 name(带提示)。
156
183
  const ASSISTANT_TEXT_REPLACEMENTS = [
157
184
  ['theme.fg("error", "Response was truncated before completion.")', 'theme.fg("error", process.env.U1S1_LANG === "en" ? "Response was truncated before completion. Type \\"continue\\" to pick up where it left off." : "回复超出单次长度上限被截断了,输入「继续」可以接着写。")'],
@@ -159,10 +186,6 @@ const ASSISTANT_TEXT_REPLACEMENTS = [
159
186
  ['const errorMsg = message.errorMessage || "Unknown error";', 'const errorMsg = message.errorMessage || (process.env.U1S1_LANG === "en" ? "Unknown error (if it keeps happening, run u1s1 feedback)" : "未知错误(反复出现可运行 u1s1 feedback 反馈)");'],
160
187
  ['theme.fg("error", `Error: ${errorMsg}`)', 'theme.fg("error", process.env.U1S1_LANG === "en" ? `Error: ${errorMsg}` : `出错:${errorMsg}`)'],
161
188
  ];
162
- const WORKING_TEXT_REPLACEMENTS = [
163
- ['defaultWorkingMessage = "Working";', 'defaultWorkingMessage = process.env.U1S1_LANG === "en" ? "Working" : "思考中…";'],
164
- ['defaultHiddenThinkingLabel = "Thinking...";', 'defaultHiddenThinkingLabel = process.env.U1S1_LANG === "en" ? "Thinking..." : "思考中…";'],
165
- ];
166
189
  // 脚注提示(↑↓ navigate · enter select · esc cancel …)在十几个组件里各写各的,
167
190
  // 在 keyHint/rawKeyHint 出口处统一查表翻译,新组件加进来也能覆盖到;英文直接用原文。
168
191
  const KEY_HINT_REPLACEMENTS = [
@@ -389,11 +412,9 @@ const CODEMODE_RENDER_REPLACEMENTS = [
389
412
  ];
390
413
 
391
414
  const TUI_TEXT_TARGETS = [
392
- [["core", "slash-commands.js"], SLASH_COMMANDS_REPLACEMENTS, "命令描述"],
393
415
  [["modes", "interactive", "components", "assistant-message.js"], ASSISTANT_TEXT_REPLACEMENTS, "回复状态文案"],
394
416
  [["modes", "interactive", "interactive-mode.js"], EXTENSION_ERROR_REPLACEMENTS, "扩展报错"],
395
417
  [["modes", "interactive", "interactive-mode.js"], HOTKEYS_REPLACEMENTS, "/hotkeys"],
396
- [["modes", "interactive", "interactive-mode.js"], WORKING_TEXT_REPLACEMENTS, "工作中提示"],
397
418
  [["modes", "interactive", "interactive-mode.js"], BUG_REPORT_REPLACEMENTS, "/bug 入口"],
398
419
  [["modes", "interactive", "interactive-mode.js"], LOGIN_PROVIDER_REPLACEMENTS, "/login 渠道"],
399
420
  [["modes", "interactive", "components", "keybinding-hints.js"], KEY_HINT_REPLACEMENTS, "按键脚注"],
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Report how many of u1s1's Pi patches still fit a given Pi install.
4
+ *
5
+ * Checks both patch tracks against already-installed packages, fully offline:
6
+ * 1. patches/@earendil-works__pi-{coding-agent,tui}@*.patch (pnpm, dev installs) — per hunk
7
+ * 2. packages/cli/scripts/patch-pi.js (postinstall, npm -g installs) — per replacement rule
8
+ *
9
+ * Each item is classified against the target file:
10
+ * applied the patched text is already there (pnpm/postinstall already ran)
11
+ * clean the original text is there exactly as the patch expects
12
+ * fuzzy (hunks only) the removed lines are there but surrounding context changed
13
+ * count (rules only) the source text is there, but not `expected` times (patch-pi.js skips it)
14
+ * missing neither — the upstream code changed; the item has to be redone by hand
15
+ *
16
+ * Usage:
17
+ * node scripts/pi-patch-report.mjs [--pi <pi-coding-agent dir>] [--tui <pi-tui dir>] [--patch <file>]... [--patch-pi <file>] [--json] [--verbose]
18
+ * Defaults resolve both packages from packages/cli/node_modules and use every patches/@earendil-works__pi-*.patch
19
+ * and the patch-pi.js next to this script. Point --pi/--tui at unpacked tarballs (npm pack) to test another version.
20
+ * Exit code: 0 when every item is applied/clean, 1 otherwise (usable as a CI gate before attempting an upgrade).
21
+ */
22
+
23
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
24
+ import { dirname, join, resolve } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const here = dirname(fileURLToPath(import.meta.url));
28
+ const cliRoot = join(here, "..");
29
+ const repoRoot = join(cliRoot, "..", "..");
30
+
31
+ const args = process.argv.slice(2);
32
+ const opt = (name) => {
33
+ const i = args.indexOf(name);
34
+ return i >= 0 ? args[i + 1] : undefined;
35
+ };
36
+ const optAll = (name) => args.flatMap((a, i) => (a === name ? [args[i + 1]] : []));
37
+ const json = args.includes("--json");
38
+ const verbose = args.includes("--verbose");
39
+
40
+ const resolvePkg = (name) => {
41
+ const dir = join(cliRoot, "node_modules", "@earendil-works", name);
42
+ return existsSync(dir) ? realpathSync(dir) : undefined;
43
+ };
44
+ const piDir = opt("--pi") ? resolve(opt("--pi")) : resolvePkg("pi-coding-agent");
45
+ const tuiDir = opt("--tui") ? resolve(opt("--tui")) : resolvePkg("pi-tui");
46
+ if (!piDir || !tuiDir) {
47
+ console.error("pi-coding-agent / pi-tui not found; pass --pi and --tui");
48
+ process.exit(2);
49
+ }
50
+ const version = (dir) => JSON.parse(readFileSync(join(dir, "package.json"), "utf8")).version;
51
+
52
+ const read = (file) => {
53
+ try {
54
+ return readFileSync(file, "utf8");
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ };
59
+ const count = (text, needle) => (needle === "" ? 0 : text.split(needle).length - 1);
60
+
61
+ // ---------- track 1: pnpm patches, per hunk ----------
62
+ function parsePatch(text) {
63
+ const hunks = [];
64
+ let file;
65
+ let hunk;
66
+ for (const line of text.split("\n")) {
67
+ if (line.startsWith("diff --git ")) {
68
+ file = line.split(" ")[2].replace(/^a\//, "");
69
+ hunk = undefined;
70
+ continue;
71
+ }
72
+ if (line.startsWith("--- ") || line.startsWith("+++ ") || line.startsWith("index ")) continue;
73
+ const m = line.match(/^@@ -(\d+)(?:,\d+)? \+\d+(?:,\d+)? @@/);
74
+ if (m) {
75
+ hunk = { file, oldStart: Number(m[1]), lines: [] };
76
+ hunks.push(hunk);
77
+ continue;
78
+ }
79
+ if (hunk && /^[ +-]/.test(line)) hunk.lines.push(line);
80
+ else if (hunk && line === "") hunk.lines.push(" ");
81
+ }
82
+ for (const h of hunks) {
83
+ // trailing blank context produced by the final newline is noise
84
+ while (h.lines.length && h.lines[h.lines.length - 1] === " ") h.lines.pop();
85
+ h.old = h.lines.filter((l) => l[0] !== "+").map((l) => l.slice(1)).join("\n");
86
+ h.new = h.lines.filter((l) => l[0] !== "-").map((l) => l.slice(1)).join("\n");
87
+ h.removed = h.lines.filter((l) => l[0] === "-").map((l) => l.slice(1));
88
+ h.added = h.lines.filter((l) => l[0] === "+").map((l) => l.slice(1));
89
+ h.bytes = h.lines.reduce((n, l) => n + Buffer.byteLength(l) + 1, 0);
90
+ }
91
+ return hunks;
92
+ }
93
+
94
+ function classifyHunk(h, text) {
95
+ if (text === undefined) return "missing-file";
96
+ if (count(text, h.new) >= 1 && h.added.length) return "applied";
97
+ if (count(text, h.old) === 1) return "clean";
98
+ // removed lines still present as one contiguous block (context drifted)
99
+ if (h.removed.length && count(text, h.removed.join("\n")) === 1) return "fuzzy";
100
+ // pure insertion: anchor on the context line right before the first '+'
101
+ if (!h.removed.length) {
102
+ const i = h.lines.findIndex((l) => l[0] === "+");
103
+ const before = h.lines.slice(0, i).filter((l) => l[0] === " ").map((l) => l.slice(1));
104
+ const after = h.lines.slice(i).filter((l) => l[0] === " ").map((l) => l.slice(1));
105
+ const anchor = [before.at(-1), after[0]].filter((l) => l && l.trim()).join("\n");
106
+ if (anchor && count(text, anchor) === 1) return "fuzzy";
107
+ }
108
+ return "missing";
109
+ }
110
+
111
+ const patchFiles = optAll("--patch").length
112
+ ? optAll("--patch").map((p) => resolve(p))
113
+ : readdirSync(join(repoRoot, "patches"))
114
+ .filter((f) => f.startsWith("@earendil-works__pi-") && f.endsWith(".patch"))
115
+ .map((f) => join(repoRoot, "patches", f));
116
+
117
+ const hunkResults = [];
118
+ for (const patchFile of patchFiles) {
119
+ const base = patchFile.split("/").pop();
120
+ for (const h of parsePatch(readFileSync(patchFile, "utf8"))) {
121
+ // pnpm names patches after the package; other file names fall back to whichever package has the file
122
+ const target = base.includes("pi-tui") ? tuiDir : base.includes("pi-coding-agent") ? piDir : existsSync(join(piDir, h.file)) ? piDir : tuiDir;
123
+ hunkResults.push({
124
+ patch: base,
125
+ file: h.file,
126
+ line: h.oldStart,
127
+ bytes: h.bytes,
128
+ status: classifyHunk(h, read(join(target, h.file))),
129
+ firstChange: (h.removed[0] ?? h.added[0] ?? "").trim().slice(0, 90),
130
+ });
131
+ }
132
+ }
133
+
134
+ // ---------- track 2: patch-pi.js, per rule ----------
135
+ const patchPiSource = readFileSync(opt("--patch-pi") ? resolve(opt("--patch-pi")) : join(here, "patch-pi.js"), "utf8");
136
+ // patch-pi.js runs on import and calls process.exit, so lift the rule tables out of its source (same as test/patch-pi.test.ts)
137
+ const tables = {};
138
+ for (const m of patchPiSource.matchAll(/const ([A-Z_]+_REPLACEMENTS) = (\[[\s\S]*?\n\s*\]);/g)) {
139
+ tables[m[1]] = new Function(`return ${m[2]};`)();
140
+ }
141
+ const targetsMatch = patchPiSource.match(/const TUI_TEXT_TARGETS = (\[[\s\S]*?\n\]);/);
142
+ const textTargets = new Function(...Object.keys(tables), `return ${targetsMatch[1]};`)(...Object.values(tables));
143
+ const autowrapMatch = patchPiSource.match(/const rules = (\[[\s\S]*?\n\s*\]);/);
144
+ const autowrapRules = new Function(`return ${autowrapMatch[1]};`)();
145
+
146
+ const zhOnlyMatch = patchPiSource.match(/function zhOnly\(to\) \{[\s\S]*?\n\}/);
147
+ const zhOnly = new Function(`${zhOnlyMatch[0]}\nreturn zhOnly;`)();
148
+
149
+ function classifyRule(rule, text) {
150
+ const { from, to, expected = 1, legacy } = Array.isArray(rule) ? { from: rule[0], to: rule[1] } : rule;
151
+ if (text === undefined) return "missing-file";
152
+ if (to === "") return count(text, from) === 0 ? "applied(deleted)" : count(text, from) === expected ? "clean" : "count";
153
+ if (text.includes(to)) return "applied";
154
+ const old = legacy ?? zhOnly(to);
155
+ if (old !== to && count(text, old) === expected) return "applied(legacy)";
156
+ const n = count(text, from);
157
+ if (n === expected) return "clean";
158
+ return n > 0 ? "count" : "missing";
159
+ }
160
+
161
+ const tableName = (rules) => Object.keys(tables).find((k) => tables[k] === rules);
162
+ const ruleResults = [];
163
+ for (const [parts, rules, label] of textTargets) {
164
+ const text = read(join(piDir, "dist", ...parts));
165
+ rules.forEach((rule, i) => {
166
+ const from = Array.isArray(rule) ? rule[0] : rule.from;
167
+ const to = Array.isArray(rule) ? rule[1] : rule.to;
168
+ ruleResults.push({
169
+ table: tableName(rules),
170
+ label,
171
+ index: i,
172
+ file: `dist/${parts.join("/")}`,
173
+ bytes: Buffer.byteLength(from) + Buffer.byteLength(to),
174
+ status: classifyRule(rule, text),
175
+ from: from.trim().slice(0, 90),
176
+ });
177
+ });
178
+ }
179
+ {
180
+ const text = read(join(tuiDir, "dist", "tui-main-screen.js"));
181
+ // patch-pi.js short-circuits the whole autowrap group when DISABLE_AUTOWRAP is already declared
182
+ const done = text?.includes('const DISABLE_AUTOWRAP = "\\x1b[?7l";');
183
+ autowrapRules.forEach((rule, i) => {
184
+ ruleResults.push({
185
+ table: "AUTOWRAP(rules)",
186
+ label: "Windows 重绘",
187
+ index: i,
188
+ file: "pi-tui/dist/tui-main-screen.js",
189
+ bytes: Buffer.byteLength(rule.from) + Buffer.byteLength(rule.to),
190
+ status: done ? "applied" : classifyRule(rule, text),
191
+ from: rule.from.trim().slice(0, 90),
192
+ });
193
+ });
194
+ }
195
+
196
+ // ---------- output ----------
197
+ const ok = (s) => s === "clean" || s.startsWith("applied");
198
+ const tally = (items) => {
199
+ const t = {};
200
+ for (const { status } of items) t[status] = (t[status] ?? 0) + 1;
201
+ return t;
202
+ };
203
+ const summary = {
204
+ piCodingAgent: { dir: piDir, version: version(piDir) },
205
+ piTui: { dir: tuiDir, version: version(tuiDir) },
206
+ hunks: { total: hunkResults.length, ok: hunkResults.filter((r) => ok(r.status)).length, byStatus: tally(hunkResults) },
207
+ rules: { total: ruleResults.length, ok: ruleResults.filter((r) => ok(r.status)).length, byStatus: tally(ruleResults) },
208
+ };
209
+
210
+ if (json) {
211
+ console.log(JSON.stringify({ summary, hunks: hunkResults, rules: ruleResults }, null, 2));
212
+ } else {
213
+ console.log(`pi-coding-agent ${summary.piCodingAgent.version} (${piDir})`);
214
+ console.log(`pi-tui ${summary.piTui.version} (${tuiDir})`);
215
+ console.log(`\npnpm hunks: ${summary.hunks.ok}/${summary.hunks.total} ok ${JSON.stringify(summary.hunks.byStatus)}`);
216
+ for (const r of hunkResults) {
217
+ if (verbose || !ok(r.status)) console.log(` ${r.status.padEnd(12)} ${r.file}:${r.line} ${r.firstChange}`);
218
+ }
219
+ console.log(`\npatch-pi.js rules: ${summary.rules.ok}/${summary.rules.total} ok ${JSON.stringify(summary.rules.byStatus)}`);
220
+ for (const r of ruleResults) {
221
+ if (verbose || !ok(r.status)) console.log(` ${r.status.padEnd(12)} ${r.table}[${r.index}] ${r.file} ${r.from}`);
222
+ }
223
+ }
224
+ process.exit(summary.hunks.ok === summary.hunks.total && summary.rules.ok === summary.rules.total ? 0 : 1);