@kaydash9999/banker-plugins 0.20.2 → 0.20.5

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 (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +179 -1
  4. package/PRIVACY.md +13 -3
  5. package/README.md +25 -2
  6. package/bin/banker.js +84 -40
  7. package/bin/lib/telemetry-config.mjs +31 -16
  8. package/codex/manifest.json +1 -1
  9. package/codex/transform-matrix.md +1 -1
  10. package/commands/setup.md +23 -0
  11. package/hooks/progress.mjs +143 -38
  12. package/hooks/register.mjs +13 -3
  13. package/hooks/update-checkin.mjs +15 -7
  14. package/hooks/update-fetch.mjs +15 -9
  15. package/hooks/update-notify.mjs +2 -9
  16. package/package.json +1 -1
  17. package/skills/3d-intro-build/SKILL.md +11 -5
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  19. package/skills/3d-intro-build/references/curate.mjs +48 -14
  20. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  21. package/skills/3d-intro-build/references/prompts.md +4 -4
  22. package/skills/3d-intro-build/references/serve.mjs +6 -1
  23. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  24. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  25. package/skills/3d-intro-setup/SKILL.md +41 -10
  26. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  28. package/skills/anti-drm/SKILL.md +6 -3
  29. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  30. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  31. package/skills/audit-mock/SKILL.md +1 -1
  32. package/skills/audit-security/SKILL.md +64 -2
  33. package/skills/deep-research/SKILL.md +5 -0
  34. package/skills/docs-setup/SKILL.md +37 -7
  35. package/skills/lineage/SKILL.md +12 -12
  36. package/skills/lineage/lineage.py +399 -300
  37. package/skills/motion-graphic-make/SKILL.md +1 -1
  38. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  39. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  40. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  41. package/skills/motion-graphic-setup/SKILL.md +58 -13
  42. package/skills/obsidizer/SKILL.md +1 -1
  43. package/skills/obsidizer/obsidize.mjs +8 -1
  44. package/skills/omc-patch/SKILL.md +80 -23
  45. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  46. package/skills/payload-mon/SKILL.md +4 -2
  47. package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
  48. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  49. package/skills/pdf-vision-extract/SKILL.md +11 -6
  50. package/skills/ralph-qa/SKILL.md +125 -151
  51. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  52. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  53. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  54. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  55. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  56. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  57. package/skills/setup-harness-factory/SKILL.md +17 -1
  58. package/skills/setup-insane-search/SKILL.md +16 -0
  59. package/skills/setup-java/SKILL.md +20 -1
  60. package/skills/setup-lsp/SKILL.md +56 -13
  61. package/skills/setup-mcp/SKILL.md +73 -4
  62. package/skills/setup-node/SKILL.md +31 -6
  63. package/skills/setup-omc/SKILL.md +24 -0
  64. package/skills/setup-omc-hud/SKILL.md +9 -6
  65. package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
  66. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  67. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  68. package/skills/setup-playwright/SKILL.md +45 -2
  69. package/skills/setup-pwsh/SKILL.md +19 -0
  70. package/skills/setup-python/SKILL.md +45 -7
  71. package/skills/setup-sandbox/SKILL.md +30 -5
  72. package/skills/setup-stitch/SKILL.md +22 -0
  73. package/skills/setup-tmux/SKILL.md +24 -4
  74. package/skills/tone-compact/SKILL.md +2 -1
  75. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  76. package/skills/ultra-chain/SKILL.md +4 -3
  77. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  78. package/skills/ultra-chain/references/serve.mjs +6 -1
  79. package/skills/ultra-interview/SKILL.md +1 -0
  80. package/skills/update-banker/SKILL.md +19 -3
  81. package/skills/vertical-pptx/SKILL.md +6 -3
  82. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -19,13 +19,16 @@
19
19
  * column when Node reports one but never quoting it; one holding only white
20
20
  * space is read as {}, as Claude Code does; UTF-8 BOM accepted; CLAUDE_CONFIG_DIR trimmed, "~" expanded
21
21
  * and made absolute; formatter and HUD config backed up when they differ (the
22
- * blocks payload-mon and claude-update-last add do not count), settings.json only
22
+ * blocks payload-mon and claude-update-last add do not count unless edited by hand), settings.json only
23
23
  * when it changes; pre-releases sort below their release; no
24
24
  * folder is made when OMC is missing. Also refused before any change: a config
25
25
  * folder path the statusLine shell would act on, a file or folder it cannot
26
26
  * write, an OMC folder without both loader files, a relative OMC_PLUGIN_ROOT.
27
27
  * Backups keep the original bytes and mode; the files it writes are 0644 and new
28
- * folders 0755; the other statusLine fields (padding, ...) stay.
28
+ * folders 0755; the other statusLine fields (padding, ...) stay. settings.json goes
29
+ * in as a new file through a rename (a link keeps pointing at it), in place where
30
+ * that cannot be done or would part a hard link or change its owner or group, and
31
+ * is left as it was when the disk is too full for the new file.
29
32
  * At the end it runs the installed formatter once with no input and reports
30
33
  * whether OMC's HUD loads and which version draws (the render check); that never
31
34
  * changes the exit code. APPLY_HUD_CHECK_TIMEOUT_MS (tests) shortens its 25s limit.
@@ -38,9 +41,13 @@ import {
38
41
  constants,
39
42
  copyFileSync,
40
43
  existsSync,
44
+ lstatSync,
41
45
  mkdirSync,
42
46
  readdirSync,
43
47
  readFileSync,
48
+ realpathSync,
49
+ renameSync,
50
+ rmSync,
44
51
  statSync,
45
52
  writeFileSync,
46
53
  } from "node:fs";
@@ -70,6 +77,27 @@ const configDst = join(OMC_DIR, "hud-config.json");
70
77
  // PowerShell on Windows) that treats backslashes as escapes.
71
78
  const cmdPath = wrapperDst.split(sep).join("/");
72
79
  const log = (m) => process.stdout.write(m + "\n");
80
+ // Characters that could act on a terminal or pass for a line of their own are shown as "?", as
81
+ // omc-patch.mjs (UNSAFE_CH) shows file values in output an agent reads.
82
+ const UNSAFE = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\p{Cn}\p{Variation_Selector}\p{Default_Ignorable_Code_Point}\u{2800}\u{a0}\u{1680}\u{2000}-\u{200a}\u{202f}\u{205f}\u{3000}]/gu;
83
+ const shown = (s) => String(s).replace(UNSAFE, "?");
84
+ // The old statusLine command as the report shows it: its first word (the program; a NAME=value it sets is skipped) and
85
+ // its length. settings.json can hold tokens (see step 0) and the line goes into a report; masking the parts that look
86
+ // like secrets missed every form no rule named (curl -u, sshpass -p, mysql -pPW), so the rest stays in the backup.
87
+ const ASSIGN = /^[A-Za-z_][A-Za-z0-9_]*=/;
88
+ // Shell words: runs of plain characters, quoted pieces and backslash escapes (a quote or a space after a backslash
89
+ // stays in its word, inside double quotes too), a quote left open running to the end, so a value in quotes stays one
90
+ // word with its name.
91
+ const WORDS = /(?:"(?:[^"\\]|\\.)*"?|'[^']*'?|\\[\s\S]?|[^\s"'\\])+/g;
92
+ function priorShown(command) {
93
+ const text = String(command).trim();
94
+ const words = text.match(WORDS) || [];
95
+ const first = words.findIndex((w) => !ASSIGN.test(w));
96
+ // a first word that looks like a setting or an address may hold a secret itself
97
+ const name = first === -1 ? "(no program)" : /[=@]|:\/\//.test(words[first]) ? "(program hidden)" : words[first];
98
+ const head = `${name}${first !== -1 && first < words.length - 1 ? " …" : ""}`;
99
+ return `${JSON.stringify(shown(head))} (${text.length} characters)`;
100
+ }
73
101
  const stamp = new Date().toISOString().replace(/[:.]/g, "-");
74
102
  const refuse = (lines) => {
75
103
  for (const l of lines) log(l);
@@ -108,6 +136,30 @@ function readSettings(raw) {
108
136
  if (value && typeof value === "object" && !Array.isArray(value)) return { settings: value, empty: false };
109
137
  return { refusal: `[x] settings.json holds ${kindOf(value)}, not a JSON object; nothing changed. Fix it and re-run.` };
110
138
  }
139
+ // A target that is there but no regular file (a folder, a FIFO, a device), or a folder the install writes into that
140
+ // is there but no folder, is refused before anything is read or written: reading or writing a FIFO waits for good,
141
+ // and the others stopped the install half-way. Links are followed, as the reads and writes follow them; a target
142
+ // that is not there (a broken link too) is written new.
143
+ function oddTarget() {
144
+ for (const p of [settingsPath, loaderDst, libDst, wrapperDst, configDst]) {
145
+ try {
146
+ if (!statSync(p).isFile()) return `${shown(p)} is not a regular file (a folder, a FIFO or a device)`;
147
+ } catch {
148
+ /* not there: written new */
149
+ }
150
+ }
151
+ for (const d of [CONFIG_DIR, HUD_DIR, join(HUD_DIR, "lib"), OMC_DIR]) {
152
+ try {
153
+ if (!statSync(d).isDirectory()) return `${shown(d)} is not a folder`;
154
+ } catch {
155
+ /* not there: made */
156
+ }
157
+ }
158
+ return "";
159
+ }
160
+ const odd = oddTarget();
161
+ if (odd) refuse([`[x] ${odd}; nothing changed. Move it aside and re-run.`]);
162
+
111
163
  let settings = {};
112
164
  let settingsRaw = null;
113
165
  if (existsSync(settingsPath)) {
@@ -199,17 +251,82 @@ function writeOwned(file, data) {
199
251
  }
200
252
 
201
253
  // The marker blocks payload-mon and claude-update-last add to the installed formatter, matched as those
202
- // tools match them (payload-mon.mjs BLOCKS, claude-update-last.mjs's block pattern). Read as latin1,
203
- // which maps every byte to one character, so text outside the blocks compares byte for byte.
254
+ // tools match them (payload-mon.mjs BLOCKS, claude-update-last.mjs BLOCKS). Read as latin1, which maps
255
+ // every byte to one character, so text outside the blocks compares byte for byte.
204
256
  const TOOL_BLOCKS = [
205
257
  /^[ \t]*\/\/ >>> payload-mon >>>[\s\S]*?\/\/ <<< payload-mon <<<[ \t]*\r?\n/gm,
206
258
  /^[ \t]*\/\/ >>> claude-update-last >>>(?:(?!\/\/ >>> claude-update-last >>>)[\s\S])*?\/\/ <<< claude-update-last <<<[ \t]*(?:\r?\n|(?![\s\S]))/gm,
207
259
  ];
208
- const withoutToolBlocks = (text) => TOOL_BLOCKS.reduce((t, blocks) => t.replace(blocks, ""), text);
260
+ // payload-mon's closing line (payload-mon.mjs CLOSE_LINE). No block reaches past the last one, so its
261
+ // pattern runs only on the text before it: otherwise every stray opening line scans to the end again
262
+ // (a 2MB file of them took 28s).
263
+ const PM_CLOSE = /\/\/ <<< payload-mon <<<[ \t]*\r?\n/g;
264
+ // The text without the tools' blocks, and the blocks it held.
265
+ function splitToolBlocks(text) {
266
+ const blocks = [];
267
+ const take = (t, pattern) =>
268
+ t.replace(pattern, (block) => {
269
+ blocks.push(block);
270
+ return "";
271
+ });
272
+ let end = -1;
273
+ for (const m of text.matchAll(PM_CLOSE)) end = m.index + m[0].length;
274
+ const rest = end < 0 ? text : take(text.slice(0, end), TOOL_BLOCKS[0]) + text.slice(end);
275
+ return { body: take(rest, TOOL_BLOCKS[1]), blocks };
276
+ }
277
+ // The blocks as the tools write them (payload-mon.mjs placeBlock, claude-update-last.mjs blockText), with
278
+ // LF line endings and payload-mon's import of its module copy as "<module copy>" (where the copy sits is the
279
+ // tool's to choose; a URL to any other file is an edit). A block that differs from both was edited by hand. When a tool's block changes, the test "payload-mon 블록만 다른 래퍼와 바뀌지 않은 settings.json 은
280
+ // 다시 설치해도 백업하지 않음" fails until this copy follows.
281
+ const TOOL_BLOCK_TEXTS = [
282
+ [
283
+ " // >>> payload-mon >>>",
284
+ " // payload-mon: estimated API request payload against the 32MB request limit, shown",
285
+ " // like ctx from 8MB (25%) on. `payload-mon off` removes this block; a missing or",
286
+ " // broken module costs only this segment, never the statusline.",
287
+ " try {",
288
+ " const transcript = JSON.parse(input)?.transcript_path;",
289
+ " if (transcript) {",
290
+ " const { estimatePayloadBytes, payloadSegment } = await <module copy>;",
291
+ " const segment = payloadSegment(estimatePayloadBytes(transcript));",
292
+ " if (segment) colored.push(colorRate(segment));",
293
+ " }",
294
+ " } catch {",
295
+ " /* no stdin JSON, unreadable transcript or module error */",
296
+ " }",
297
+ " // <<< payload-mon <<<",
298
+ "",
299
+ ].join("\n"),
300
+ [
301
+ " // >>> claude-update-last >>>",
302
+ " // claude-update-last (setup-omc-hud): OMC's Claude Code update notice",
303
+ " // (\"[Claude#X] -> Y claude update\") goes after every other segment.",
304
+ " // `claude-update-last off` removes this block; on any error the order stays as it was.",
305
+ " try {",
306
+ " const notice = /^\\[Claude(?:#[^\\]]*)?\\]\\s*->\\s*\\S+\\s+claude\\s+update\\b/;",
307
+ " const plain = (s) => stripAnsi(String(s)).trim();",
308
+ " const found = segs.find((s) => notice.test(plain(s)));",
309
+ " if (found) {",
310
+ " const moved = A(\"2\", plain(found));",
311
+ " for (let i = colored.length - 1; i >= 0; i--) if (notice.test(plain(colored[i]))) colored.splice(i, 1);",
312
+ " colored.push(moved);",
313
+ " }",
314
+ " } catch {",
315
+ " /* keep the order the wrapper chose */",
316
+ " }",
317
+ " // <<< claude-update-last <<<",
318
+ "",
319
+ ].join("\n"),
320
+ ];
321
+ const toolMade = (block) =>
322
+ TOOL_BLOCK_TEXTS.includes(block.replace(/\r\n/g, "\n").replace(/await import\("file:\/\/[^"\\]*\/payload-mon\/payload-size\.mjs"\)/, "await <module copy>"));
209
323
  const sameBytes = (a, b) => a.equals(b);
210
- // Those blocks alone are no reason for a backup: `payload-mon on` puts its block back, and wrapper v4
211
- // needs no claude-update-last block.
212
- const sameFormatter = (a, b) => withoutToolBlocks(a.toString("latin1")) === withoutToolBlocks(b.toString("latin1"));
324
+ // Blocks as the tools write them are no reason for a backup: `payload-mon on` puts its block back, and
325
+ // wrapper v4 needs no claude-update-last block. A block edited by hand is one.
326
+ function sameFormatter(a, b) {
327
+ const old = splitToolBlocks(a.toString("latin1"));
328
+ return old.body === splitToolBlocks(b.toString("latin1")).body && old.blocks.every(toolMade);
329
+ }
213
330
 
214
331
  // Keep a timestamped copy, with its bytes and mode, of a file that differs from what replaces it.
215
332
  function backupIfDifferent(dst, next, label, same = sameBytes) {
@@ -234,7 +351,7 @@ function installLoader() {
234
351
 
235
352
  // 3. Install the custom formatter and HUD config from the bundled templates.
236
353
  // Files that differ from the templates (local edits) are kept as timestamped
237
- // backups first; the blocks payload-mon / claude-update-last add do not count.
354
+ // backups first; the blocks payload-mon / claude-update-last add, as they write them, do not count.
238
355
  function installTemplates() {
239
356
  const wrapperSrc = readFileSync(join(SELF, "omc-hud-custom.mjs"));
240
357
  const configSrc = readFileSync(join(SELF, "hud-config.json"));
@@ -246,6 +363,72 @@ function installTemplates() {
246
363
  log("[ok] HUD config -> " + configDst);
247
364
  }
248
365
 
366
+ // settings.json goes in as a new file through a rename, so a stop midway never leaves it half written.
367
+ // One that is a link (dotfiles) keeps its link, and the file it points at is the one replaced. A file
368
+ // left at the temporary name (an earlier run with this pid, or a link put there) is removed first and
369
+ // never written through. It is written in place as before where the new file cannot go in (a folder
370
+ // this account may not write, a file another program holds open, a file system that refuses the
371
+ // rename) or the link leads nowhere yet, and where a rename would part a hard link (its other name
372
+ // would keep the old text) or the new file has another owner or group. Only a full disk stops it, as
373
+ // writing in place could then cut settings.json short; the new file is written before that choice, so
374
+ // a hard link or another owner finds a full disk first too.
375
+ const FULL_DISK = new Set(["ENOSPC", "EDQUOT"]);
376
+ const isLink = (p) => {
377
+ try {
378
+ return lstatSync(p).isSymbolicLink();
379
+ } catch {
380
+ return false;
381
+ }
382
+ };
383
+ const removeTmp = (tmp) => {
384
+ try {
385
+ rmSync(tmp, { force: true });
386
+ } catch {
387
+ /* a folder at that name: left alone */
388
+ }
389
+ };
390
+ // Whether the file, as `old` was, must keep its inode: it has a second name (a hard link), or the new file
391
+ // `tmp` got another owner or group than it had.
392
+ function keepsInode(old, tmp) {
393
+ if (!old) return false;
394
+ const now = statSync(tmp);
395
+ return old.nlink > 1 || now.uid !== old.uid || now.gid !== old.gid;
396
+ }
397
+ function writeSettings(file, text) {
398
+ let target = file;
399
+ let old;
400
+ let mode;
401
+ try {
402
+ target = realpathSync(file);
403
+ old = statSync(target);
404
+ mode = old.mode & 0o777;
405
+ } catch {
406
+ if (isLink(file)) return writeFileSync(file, text);
407
+ }
408
+ const tmp = `${target}.${process.pid}.tmp`;
409
+ try {
410
+ rmSync(tmp, { force: true });
411
+ writeFileSync(tmp, text, { flag: "wx", mode });
412
+ if (mode !== undefined && process.platform !== "win32") chmodSync(tmp, mode);
413
+ if (!keepsInode(old, tmp)) return renameSync(tmp, target);
414
+ } catch (e) {
415
+ if (FULL_DISK.has(e.code)) throw e;
416
+ } finally {
417
+ removeTmp(tmp);
418
+ }
419
+ writeFileSync(file, text);
420
+ }
421
+
422
+ // How many other names settings.json has (a hard link, a `cp -al` snapshot, say), 0 when it cannot be read.
423
+ // writeSettings writes such a file in place, so those names show the new text too.
424
+ function otherNames(file) {
425
+ try {
426
+ return statSync(file).nlink - 1;
427
+ } catch {
428
+ return 0;
429
+ }
430
+ }
431
+
249
432
  // 4. Point settings.json statusLine at the custom formatter; its other fields (padding, ...) stay.
250
433
  // A settings.json that already says exactly this is neither backed up nor written.
251
434
  const objectOr = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
@@ -254,7 +437,7 @@ function pointStatusLine() {
254
437
  const command = `node "${cmdPath}"`;
255
438
  const prior = objectOr(settings.statusLine);
256
439
  if (typeof prior.command === "string" && prior.command && prior.command !== command) {
257
- log(`[..] statusLine ran ${JSON.stringify(prior.command)}; it now runs the custom HUD (the old value is in the backup)`);
440
+ log(`[..] statusLine ran ${priorShown(prior.command)}; it now runs the custom HUD (the old value is in the backup)`);
258
441
  }
259
442
  settings.statusLine = { ...prior, type: "command", command };
260
443
  const next = JSON.stringify(settings, null, 2) + "\n";
@@ -267,8 +450,12 @@ function pointStatusLine() {
267
450
  copyFileSync(settingsPath, backup); // the original bytes and mode
268
451
  log("[ok] Backed up settings.json -> " + backup);
269
452
  }
270
- writeFileSync(settingsPath, next);
453
+ const others = otherNames(settingsPath);
454
+ writeSettings(settingsPath, next);
271
455
  log("[ok] statusLine -> " + command);
456
+ if (others > 0) {
457
+ log(`[..] settings.json has ${others} other name${others > 1 ? "s" : ""} (a hard link); it was written in place, so they show the new text too (the old text is in the backup)`);
458
+ }
272
459
  }
273
460
 
274
461
  // 5. Render check: run the installed formatter once with no input, as the statusLine command does,
@@ -280,7 +467,7 @@ function pointStatusLine() {
280
467
  const CHECK_MS = Number(process.env.APPLY_HUD_CHECK_TIMEOUT_MS) || 25000;
281
468
  const BLAME =
282
469
  " OMC's install is at fault. /oh-my-claudecode:omc-setup repairs it but points statusLine back at OMC, so run setup-omc-hud again after it.";
283
- const clip = (s, n = 200) => String(s).slice(0, n);
470
+ const clip = (s, n = 200) => shown(s).slice(0, n);
284
471
  // No input, and the config folder as working folder: OMC's loader looks in the working folder's
285
472
  // node_modules and runs git from it, and Windows looks there first for a bare program name, so the
286
473
  // folder the installer was started in (a project, say) must not be it.
@@ -288,20 +475,31 @@ const RUN_OPTS = { input: "", encoding: "utf8", timeout: CHECK_MS, windowsHide:
288
475
 
289
476
  // node as the statusLine shell finds it, but only in PATH folders given as absolute paths: spawning the
290
477
  // bare name would on Windows try the working folder first, where a project's own node.exe could run.
291
- function findNode(pathEnv) {
292
- const name = process.platform === "win32" ? "node.exe" : "node";
478
+ // Like the shell, it passes over a file it may not run.
479
+ function findNode(pathEnv, name = process.platform === "win32" ? "node.exe" : "node") {
293
480
  for (const entry of (pathEnv || "").split(delimiter)) {
294
481
  const dir = entry.replace(/^"(.*)"$/, "$1");
295
482
  if (!dir || !isAbsolute(dir)) continue;
483
+ const file = join(dir, name);
296
484
  try {
297
- if (statSync(join(dir, name)).isFile()) return join(dir, name);
485
+ if (statSync(file).isFile()) {
486
+ accessSync(file, constants.X_OK);
487
+ return file;
488
+ }
298
489
  } catch {
299
- /* not in this folder */
490
+ /* not in this folder, or not runnable */
300
491
  }
301
492
  }
302
493
  return null;
303
494
  }
304
495
 
496
+ const NO_NODE = '[!!] Render check: no "node" on PATH; the statusLine command runs "node", so the statusline will not show.';
497
+ // Claude Code runs statusLine with Git Bash when it finds it, else with PowerShell. Of the two only
498
+ // PowerShell runs node.cmd for "node", and this check cannot tell which it will be, so it does not run.
499
+ const NODE_CMD_ONLY =
500
+ '[..] Render check: skipped; PATH has node.cmd but no node.exe, and Git Bash, which Claude Code runs statusLine with when it finds it, does not run node.cmd as "node".';
501
+ const noNodeLine = (pathEnv) => (process.platform === "win32" && findNode(pathEnv, "node.cmd") ? NODE_CMD_ONLY : NO_NODE);
502
+
305
503
  function omcVersion(root) {
306
504
  if (!root) return null;
307
505
  try {
@@ -312,41 +510,58 @@ function omcVersion(root) {
312
510
  }
313
511
  }
314
512
 
513
+ // The first line of a Node error ("Error: ...", "TypeError [ERR_X]: ..."), "" when there is none.
514
+ const errorLine = (text) => /^\w*Error\b.*$/m.exec(text || "")?.[0] ?? "";
515
+
315
516
  // Why an OMC version does not load: its HUD's own error when run directly.
316
517
  function loadReason(node, root) {
317
518
  const hud = join(root, "dist", "hud", "index.js");
318
519
  if (!existsSync(hud)) return "no dist/hud/index.js";
319
520
  const r = spawnSync(node, [hud], RUN_OPTS);
320
- const line = /^\w*Error\b.*$/m.exec(r.stderr || "");
321
- return line ? clip(line[0]) : "";
521
+ const line = errorLine(r.stderr);
522
+ return line ? clip(line) : "";
322
523
  }
323
524
 
525
+ const NO_OUTPUT = "[..] Render check: OMC printed nothing with no input, so this check cannot tell whether the statusline draws.";
526
+
324
527
  // What the formatter printed, as report lines.
325
528
  function judgeRender(out, node) {
326
529
  const banner = /^\[OMC\] HUD v(\S+) \| preset: /m.exec(out);
327
530
  if (banner) {
328
531
  const installed = omcVersion(pluginRoot);
329
- if (!installed || installed === banner[1]) return [`[ok] Render check: the statusline loads OMC ${banner[1]}.`];
532
+ // Both version strings come from OMC (its output, its package.json): shown through clip like its lines.
533
+ const [drew, want] = [clip(banner[1], 40), clip(installed, 40)];
534
+ if (!installed || installed === banner[1]) return [`[ok] Render check: the statusline loads OMC ${drew}.`];
330
535
  const why = loadReason(node, pluginRoot);
331
- const line = `[!!] Render check: the statusline loads OMC ${banner[1]}, not the installed ${installed}; ${installed} does not load`;
332
- return [`${line}${why ? ` (${why})` : ""}.`, BLAME];
536
+ // Without an error from the installed version, all this check saw is which version drew.
537
+ if (!why) return [`[..] Render check: the statusline loads OMC ${drew}, not the installed ${want}.`];
538
+ return [`[!!] Render check: the statusline loads OMC ${drew}, not the installed ${want}; ${want} does not load (${why}).`, BLAME];
333
539
  }
540
+ // The formatter says this when OMC printed nothing; with no input that alone does not say it cannot draw.
541
+ if (/^\[OMC HUD\] no output$/m.test(out)) return [NO_OUTPUT];
334
542
  // OMC's line ends with the reason, after a path that can be long: keep it whole within reason.
335
543
  const failed = /^\[OMC HUD\] .*$/m.exec(out);
336
544
  if (failed) return [`[!!] Render check: OMC's HUD does not load: ${clip(failed[0], 1000)}`, BLAME];
545
+ // A loader that cannot load prints Node's error, header lines first; its module path can be long.
546
+ const error = errorLine(out);
547
+ if (error) return [`[!!] Render check: the statusline prints an error instead of a line: ${clip(error, 1000)}`];
337
548
  return [`[..] Render check: unrecognized output: ${clip(out.split("\n")[0])}`];
338
549
  }
339
550
 
551
+ // A statusline command that exited non-zero: its Node error line when stderr has one, else the first line.
552
+ function failedLine(r) {
553
+ const stderr = (r.stderr || "").trim();
554
+ const err = errorLine(stderr) || stderr.split("\n")[0];
555
+ return `[!!] Render check: the statusline command failed (exit ${r.status ?? r.signal})${err ? `: ${clip(err)}` : ""}`;
556
+ }
557
+
340
558
  function renderReport() {
341
559
  const node = findNode(process.env.PATH);
342
- if (!node) return ['[!!] Render check: no "node" on PATH; the statusLine command runs "node", so the statusline will not show.'];
560
+ if (!node) return [noNodeLine(process.env.PATH)];
343
561
  const r = spawnSync(node, [wrapperDst], RUN_OPTS);
344
562
  if (r.error && r.error.code === "ETIMEDOUT") return [`[!!] Render check: the statusline gave no answer in ${CHECK_MS / 1000}s.`];
345
563
  if (r.error) throw r.error;
346
- if (r.status !== 0) {
347
- const err = (r.stderr || "").trim().split("\n")[0];
348
- return [`[!!] Render check: the statusline command failed (exit ${r.status ?? r.signal})${err ? `: ${clip(err)}` : ""}`];
349
- }
564
+ if (r.status !== 0) return [failedLine(r)];
350
565
  return judgeRender((r.stdout || "").replace(/\x1b\[[0-9;]*m/g, ""), node);
351
566
  }
352
567
 
@@ -364,7 +579,7 @@ try {
364
579
  pointStatusLine();
365
580
  } catch (e) {
366
581
  log(`[x] Stopped partway (${e.code || e.message}); some files may have changed.`);
367
- log(` Earlier versions it replaced, apart from the loader and the tool blocks, are in <file>.backup.${stamp}.`);
582
+ log(` Earlier versions it replaced, apart from the loader and blocks as the tools wrote them, are in <file>.backup.${stamp}.`);
368
583
  process.exit(2);
369
584
  }
370
585
 
@@ -18,8 +18,10 @@
18
18
  * segment each: 🤖1|🔧14|⚡3. A count OMC leaves out (zero) stays out.
19
19
  * - per-model usage badge (fable:0%(1h31m)) -> Fa:0%/1h31m, after the rate windows.
20
20
  * - PL:<MB, 1 decimal>M always shown before se. It reads payload-mon's module
21
- * copy next to this file (hud/payload-mon/payload-size.mjs); without it PL
22
- * is left out.
21
+ * copy, payload-mon/payload-size.mjs, from the first of these folders that
22
+ * holds it as a regular file: this file's (the one a statusLine link points
23
+ * at), the link's, the config folder's hud/, then the folder import.meta.url
24
+ * names (the link's under --preserve-symlinks); without it PL is left out.
23
25
  * - cwd group split on "|"; branch shown without "branch:" after the account.
24
26
  * - OMC#X.Y.Z label no longer shown, nor OMC's update hint lines
25
27
  * ("[!] omc X - paste: ! ...", "[!] claude X - paste: ! claude update").
@@ -68,14 +70,43 @@
68
70
  * We wait for the first complete line instead.
69
71
  */
70
72
  import { spawnSync } from "node:child_process";
71
- import { readFileSync } from "node:fs";
73
+ import { readFileSync, realpathSync, statSync } from "node:fs";
72
74
  import { homedir } from "node:os";
73
- import { dirname, join } from "node:path";
75
+ import { dirname, isAbsolute, join, resolve } from "node:path";
74
76
  import { Readable } from "node:stream";
75
77
  import { fileURLToPath, pathToFileURL } from "node:url";
76
78
 
77
79
  const __dirname = dirname(fileURLToPath(import.meta.url));
78
- const HUD = join(__dirname, "omc-hud.mjs");
80
+ // The statusLine command names this file. When that name is a link (dotfiles, say), node runs the
81
+ // file it points at: the installer puts omc-hud.mjs beside the link and payload-mon puts its copy
82
+ // beside the file, so both folders are looked in. The command's name counts only when it leads to
83
+ // this file (another script may have imported this one), and the file's own folder comes from
84
+ // realpath: with --preserve-symlinks-main, import.meta.url names the link.
85
+ function hereDirs() {
86
+ const dirs = { named: null, real: __dirname, self: __dirname };
87
+ try {
88
+ const real = realpathSync(fileURLToPath(import.meta.url));
89
+ dirs.real = dirname(real);
90
+ const named = process.argv[1] ? resolve(process.argv[1]) : "";
91
+ if (named && realpathSync(named) === real) dirs.named = dirname(named);
92
+ } catch {
93
+ /* keep this file's own folder */
94
+ }
95
+ return dirs;
96
+ }
97
+ const HERE = hereDirs();
98
+ const isFile = (p) => {
99
+ try {
100
+ return statSync(p).isFile();
101
+ } catch {
102
+ return false;
103
+ }
104
+ };
105
+ // The first of these folders (each once, in this order) that holds the file as a regular file, "" when none
106
+ // does: a folder at that name is no module, and opening a FIFO waits for a writer for good.
107
+ function findSibling(dirs, ...parts) {
108
+ return [...new Set(dirs.filter(Boolean))].map((d) => join(d, ...parts)).find(isFile) ?? "";
109
+ }
79
110
 
80
111
  const STDIN_TIMEOUT_MS = 2000; // Claude Code pipes the payload immediately
81
112
  const RENDER_TIMEOUT_MS = 10000; // see timer discipline note above
@@ -122,10 +153,39 @@ function emit(s) {
122
153
  // that file in $CLAUDE_CONFIG_DIR when set, otherwise in the home folder.
123
154
  function claudeJsonDir() {
124
155
  const v = (process.env.CLAUDE_CONFIG_DIR || "").trim();
125
- if (!v) return homedir();
156
+ return v ? expandDir(v) : homedir();
157
+ }
158
+ // Where Claude Code was started (the statusline JSON's workspace.project_dir, else the working
159
+ // folder). A relative CLAUDE_CONFIG_DIR is read from there: Claude Code reads it from its own working
160
+ // folder, which is the same one until it moves (into a worktree, say).
161
+ function startDir() {
162
+ try {
163
+ const d = JSON.parse(input)?.workspace?.project_dir;
164
+ if (typeof d === "string" && isAbsolute(d)) return d;
165
+ } catch {
166
+ /* no or bad stdin JSON */
167
+ }
168
+ return process.cwd();
169
+ }
170
+ function expandDir(v) {
126
171
  if (v === "~") return homedir();
127
172
  if (v.startsWith("~/") || v.startsWith("~\\")) return join(homedir(), v.slice(2));
128
- return v;
173
+ return resolve(startDir(), v);
174
+ }
175
+ // Claude Code's config folder. Its hud/ is where the installer puts the loader: the third place
176
+ // siblings are looked for, as when another script imported this file through a link. "" when the
177
+ // home folder cannot be told (no HOME and no account entry) or the working folder is gone: the
178
+ // other places still count.
179
+ function configDir() {
180
+ const v = (process.env.CLAUDE_CONFIG_DIR || "").trim();
181
+ return v ? expandDir(v) : join(homedir(), ".claude");
182
+ }
183
+ function configHud() {
184
+ try {
185
+ return join(configDir(), "hud");
186
+ } catch {
187
+ return "";
188
+ }
129
189
  }
130
190
  function loginAccount() {
131
191
  try {
@@ -175,6 +235,7 @@ function readStdinBounded(ms) {
175
235
  }
176
236
 
177
237
  const input = await readStdinBounded(STDIN_TIMEOUT_MS);
238
+ const HUD = findSibling([HERE.named, HERE.real, configHud(), HERE.self], "omc-hud.mjs");
178
239
 
179
240
  // Current reasoning effort level (low/medium/high/xhigh) from the statusline
180
241
  // payload (effort.level) — shown next to the model. Empty if absent.
@@ -278,13 +339,18 @@ for (const s of [process.stdout, process.stderr]) {
278
339
  }
279
340
 
280
341
  let raw = "";
281
- try {
282
- raw = (await renderInProcess(RENDER_TIMEOUT_MS)).replace(/\r/g, "");
283
- } catch {
284
- raw = ""; // fall through to the child-process path
342
+ if (!HUD) {
343
+ // No regular file at any place looked in: nothing to run. Named where the installer puts it.
344
+ emit(`[OMC HUD] omc-hud.mjs not found: ${join(HERE.named || HERE.real, "omc-hud.mjs")}\n`);
345
+ } else {
346
+ try {
347
+ raw = (await renderInProcess(RENDER_TIMEOUT_MS)).replace(/\r/g, "");
348
+ } catch {
349
+ raw = ""; // fall through to the child-process path
350
+ }
285
351
  }
286
352
 
287
- if (!raw.trim()) {
353
+ if (HUD && !raw.trim()) {
288
354
  // In-process rendering produced nothing usable. Fall back to the original
289
355
  // out-of-process path: it costs an extra node, but a statusline that renders
290
356
  // beats one that does not — e.g. if a future OMC version stops being
@@ -481,8 +547,9 @@ try {
481
547
  // without it (payload-mon off) or without a transcript, PL is left out.
482
548
  try {
483
549
  const transcript = JSON.parse(input)?.transcript_path;
484
- if (transcript) {
485
- const { estimatePayloadBytes, REQUEST_LIMIT_BYTES } = await import(pathToFileURL(join(__dirname, "payload-mon", "payload-size.mjs")).href);
550
+ const copy = transcript ? findSibling([HERE.real, HERE.named, configHud(), HERE.self], "payload-mon", "payload-size.mjs") : "";
551
+ if (copy) {
552
+ const { estimatePayloadBytes, REQUEST_LIMIT_BYTES } = await import(pathToFileURL(copy).href);
486
553
  const bytes = estimatePayloadBytes(transcript);
487
554
  if (Number.isFinite(bytes)) {
488
555
  for (let i = colored.length - 1; i >= 0; i--) if (/^payload:/.test(stripAnsi(colored[i]))) colored.splice(i, 1);
@@ -35,7 +35,7 @@ const OPEN = "// >>> claude-update-last >>>";
35
35
  const CLOSE = "// <<< claude-update-last <<<";
36
36
  // A block's body never crosses another opening marker, so a stray one above a
37
37
  // block is left over (and refused) instead of taking the code between with it.
38
- const BLOCKS =
38
+ export const BLOCKS =
39
39
  /^[ \t]*\/\/ >>> claude-update-last >>>(?:(?!\/\/ >>> claude-update-last >>>)[\s\S])*?\/\/ <<< claude-update-last <<<[ \t]*(?:\r?\n|(?![\s\S]))/gm;
40
40
  // The block goes right before the line that joins the segments into the statusline.
41
41
  const JOIN_LINE = /^ {2}let result = colored\.join\(SEP\);\r?$/gm;
@@ -150,12 +150,14 @@ function writeFresh(file, data) {
150
150
  }
151
151
  }
152
152
 
153
- // The first lines of node's complaint about `text` as an ES module, "" if it parses.
153
+ // The first lines of node's complaint about `text` as an ES module, "" if it parses. A leading BOM
154
+ // is left out: node runs a wrapper that starts with one, but `node --check` rejects a BOM before a
155
+ // shebang (Node 20, 24).
154
156
  function syntaxError(text) {
155
157
  const dir = mkdtempSync(join(tmpdir(), "claude-update-last-"));
156
158
  try {
157
159
  const file = join(dir, "check.mjs");
158
- writeFileSync(file, text);
160
+ writeFileSync(file, text.replace(/^\uFEFF/, ""));
159
161
  const r = spawnSync(process.execPath, ["--check", file], { encoding: "utf8" });
160
162
  return r.status === 0 ? "" : String(r.stderr || r.error).trim().split("\n").slice(0, 3).join(" | ");
161
163
  } finally {