@kaydash9999/banker-plugins 0.20.2 → 0.21.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.
Files changed (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +203 -1
  4. package/PRIVACY.md +34 -4
  5. package/README.md +46 -15
  6. package/bin/banker.js +228 -51
  7. package/bin/lib/codex-hooks.mjs +249 -0
  8. package/bin/lib/telemetry-config.mjs +33 -16
  9. package/codex/manifest.json +14 -11
  10. package/codex/skills/graceful-pause/SKILL.md +30 -0
  11. package/codex/skills/progress/SKILL.md +42 -0
  12. package/codex/skills/progress/scripts/progress-plan.mjs +722 -0
  13. package/codex/skills/progress/scripts/progress.mjs +295 -0
  14. package/codex/transform-matrix.md +19 -14
  15. package/commands/setup.md +23 -0
  16. package/hooks/obsidize-hook.mjs +116 -4
  17. package/hooks/progress-plan.mjs +1 -1
  18. package/hooks/progress.mjs +143 -38
  19. package/hooks/register.mjs +13 -3
  20. package/hooks/telemetry-count-codex.mjs +222 -0
  21. package/hooks/update-checkin.mjs +15 -7
  22. package/hooks/update-fetch.mjs +15 -9
  23. package/hooks/update-notify.mjs +30 -17
  24. package/package.json +1 -1
  25. package/skills/3d-intro-build/SKILL.md +11 -5
  26. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-build/references/curate.mjs +48 -14
  28. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  29. package/skills/3d-intro-build/references/prompts.md +4 -4
  30. package/skills/3d-intro-build/references/serve.mjs +6 -1
  31. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  32. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  33. package/skills/3d-intro-setup/SKILL.md +41 -10
  34. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  35. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  36. package/skills/anti-drm/SKILL.md +6 -3
  37. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  38. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  39. package/skills/append_wiki/SKILL.md +34 -8
  40. package/skills/audit-mock/SKILL.md +1 -1
  41. package/skills/audit-security/SKILL.md +64 -2
  42. package/skills/compact-wiki/SKILL.md +37 -6
  43. package/skills/deep-research/SKILL.md +11 -0
  44. package/skills/docs-setup/SKILL.md +37 -7
  45. package/skills/lfm/SKILL.md +148 -0
  46. package/skills/lineage/SKILL.md +25 -15
  47. package/skills/lineage/lineage.py +564 -301
  48. package/skills/motion-graphic-make/SKILL.md +1 -1
  49. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  50. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  51. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  52. package/skills/motion-graphic-setup/SKILL.md +58 -13
  53. package/skills/obsidizer/SKILL.md +30 -11
  54. package/skills/obsidizer/obsidize.mjs +50 -12
  55. package/skills/omc-patch/SKILL.md +86 -23
  56. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  57. package/skills/payload-mon/SKILL.md +6 -2
  58. package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
  59. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  60. package/skills/pdf-vision-extract/SKILL.md +11 -6
  61. package/skills/ralph-qa/SKILL.md +125 -151
  62. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  63. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  64. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  65. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  66. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  67. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  68. package/skills/rfp-author/SKILL.md +5 -3
  69. package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
  70. package/skills/setup-bypass-permissions/SKILL.md +27 -9
  71. package/skills/setup-bypass-permissions/scripts/codex-bypass.mjs +135 -0
  72. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +210 -0
  73. package/skills/setup-harness-factory/SKILL.md +17 -1
  74. package/skills/setup-insane-search/SKILL.md +16 -0
  75. package/skills/setup-java/SKILL.md +20 -1
  76. package/skills/setup-lsp/SKILL.md +56 -13
  77. package/skills/setup-mcp/SKILL.md +73 -4
  78. package/skills/setup-node/SKILL.md +31 -6
  79. package/skills/setup-omc/SKILL.md +24 -0
  80. package/skills/setup-omc-hud/SKILL.md +42 -9
  81. package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
  82. package/skills/setup-omc-hud/assets/codex-config.mjs +210 -0
  83. package/skills/setup-omc-hud/assets/codex-status-line.mjs +90 -0
  84. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  85. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  86. package/skills/setup-playwright/SKILL.md +45 -2
  87. package/skills/setup-pwsh/SKILL.md +19 -0
  88. package/skills/setup-python/SKILL.md +45 -7
  89. package/skills/setup-sandbox/SKILL.md +30 -5
  90. package/skills/setup-stitch/SKILL.md +22 -0
  91. package/skills/setup-tmux/SKILL.md +24 -4
  92. package/skills/smart-compact/SKILL.md +58 -12
  93. package/skills/smart-compact/scripts/codex-gate.mjs +384 -0
  94. package/skills/tone-compact/SKILL.md +2 -1
  95. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  96. package/skills/ultra-chain/SKILL.md +12 -7
  97. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  98. package/skills/ultra-chain/references/serve.mjs +6 -1
  99. package/skills/ultra-interview/SKILL.md +14 -2
  100. package/skills/update-banker/SKILL.md +19 -3
  101. package/skills/vertical-pptx/SKILL.md +6 -3
  102. 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
 
@@ -0,0 +1,210 @@
1
+ /*
2
+ * Codex 의 config.toml 을 줄 단위로 읽고 고치는 공통 모듈. setup-bypass-permissions 가 정본이고 setup-omc-hud 의
3
+ * assets/ 에 같은 사본을 둔다(scripts/sync-adapter.js 가 지킴).
4
+ *
5
+ * TOML 전체를 다시 쓰지 않는다. 바꾸는 키의 줄만 고쳐 주석, 순서, 다른 표는 그대로 둔다.
6
+ * - 줄을 훑으며 문자열(한 줄, 여러 줄)과 괄호 깊이를 따라가, 여러 줄 문자열이나 배열 안의 줄을 표 머리나 키로 보지 않는다.
7
+ * - 키 이름은 따옴표를 벗겨 비교한다(`"approval_policy"` 도 approval_policy). 값 끝의 주석은 값으로 보지 않는다.
8
+ * - 최상위가 표를 점 키나 인라인 표로 정했는지(definedInline) 알려, 그런 파일에 같은 표를 더하지 않게 한다.
9
+ * - 파일이 링크이거나 일반 파일이 아니면 고치지 않는다. 쓰기 전 원본을 백업(이 계정만 읽음)하고, 새 파일에 쓴 뒤
10
+ * 바꿔 넣으며 파일 모드를 지킨다.
11
+ */
12
+ import fs from 'node:fs';
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+
16
+ // Codex 처럼 상대 경로 CODEX_HOME 은 지금 폴더 기준으로 푼다.
17
+ export function codexConfigFile(env = process.env) {
18
+ const home = env.CODEX_HOME ? path.resolve(env.CODEX_HOME) : path.join(os.homedir(), '.codex');
19
+ return path.join(home, 'config.toml');
20
+ }
21
+
22
+ export class Refused extends Error {}
23
+
24
+ // { text, mode, exists }. 없으면 빈 글. 링크나 일반 파일이 아니면 Refused.
25
+ export function readToml(file) {
26
+ let st;
27
+ try {
28
+ st = fs.lstatSync(file);
29
+ } catch (e) {
30
+ if (e.code === 'ENOENT') return { text: '', mode: null, exists: false };
31
+ throw e;
32
+ }
33
+ if (!st.isFile()) throw new Refused(`${file} 이 일반 파일이 아님(링크이거나 다른 종류)`);
34
+ return { text: fs.readFileSync(file, 'utf8'), mode: st.mode & 0o7777, exists: true };
35
+ }
36
+
37
+ // 원본을 backup 에 복사한 뒤(원본이 있을 때) 새 파일에 써서 바꿔 넣는다.
38
+ export function writeToml(file, text, { mode = null, backup = null } = {}) {
39
+ if (backup && fs.existsSync(file)) {
40
+ fs.copyFileSync(file, backup);
41
+ fs.chmodSync(backup, 0o600); // 원본에 비밀 값이 있을 수 있다
42
+ }
43
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
44
+ try {
45
+ fs.writeFileSync(tmp, text, { flag: 'wx', mode: mode ?? 0o600 });
46
+ if (mode !== null) fs.chmodSync(tmp, mode);
47
+ fs.renameSync(tmp, file);
48
+ } catch (e) {
49
+ fs.rmSync(tmp, { force: true });
50
+ throw e;
51
+ }
52
+ }
53
+
54
+ const lines = (text) => text.split(/\r?\n/);
55
+ const eol = (text) => (text.includes('\r\n') ? '\r\n' : '\n');
56
+ // 파일이 쓰는 줄바꿈(CRLF 가 하나라도 있으면 CRLF).
57
+ export const eolOf = eol;
58
+ const TRIPLE = ['"""', "'''"];
59
+
60
+ // 키나 표 이름을 낱말 배열로. 따옴표 안의 점은 나누지 않는다(`profiles."a.b"` 는 ['profiles', 'a.b']).
61
+ export const nameParts = (s) => (s.match(/"[^"]*"|'[^']*'|[^.\s]+/g) ?? []).map((p) => p.replace(/^"(.*)"$|^'(.*)'$/, '$1$2'));
62
+ // 비교용 이름. 낱말 사이를 \u0000 으로 이어, 점이 든 낱말과 점으로 나뉜 낱말을 구분한다.
63
+ const plainName = (s) => nameParts(s).join('\u0000');
64
+
65
+ // 한 줄을 문자열 밖에서 훑는다. { d: 괄호 깊이 변화, multi: 줄 끝에 열려 있는 여러 줄 문자열 구분자, cut: 주석 시작 위치 }.
66
+ function scanLine(s, multi = '') {
67
+ let d = 0;
68
+ for (let i = 0; i < s.length; i++) {
69
+ if (multi) {
70
+ if (s.startsWith(multi, i)) { i += 2; multi = ''; } else if (multi === TRIPLE[0] && s[i] === '\\') i++;
71
+ continue;
72
+ }
73
+ const c = s[i];
74
+ if (TRIPLE.some((t) => s.startsWith(t, i))) { multi = s.slice(i, i + 3); i += 2; }
75
+ else if (c === '"') { for (i++; i < s.length && s[i] !== '"'; i++) if (s[i] === '\\') i++; }
76
+ else if (c === "'") { const k = s.indexOf("'", i + 1); i = k < 0 ? s.length : k; }
77
+ else if (c === '#') return { d, multi, cut: i };
78
+ else if (c === '[' || c === '{') d++;
79
+ else if (c === ']' || c === '}') d--;
80
+ }
81
+ return { d, multi, cut: s.length };
82
+ }
83
+
84
+ // 표 머리. 이름 안의 따옴표 친 낱말에는 ] 가 들어갈 수 있다(`[projects."/a]b"]`).
85
+ // 이름 부분은 탐욕 한 번으로 읽는다(겹치는 공백 패턴은 긴 줄에서 역추적이 폭증한다). 앞뒤 공백은 nameParts 가 지운다.
86
+ const HEADER_RE = /^\s*\[\[?((?:"[^"]*"|'[^']*'|[^\]"'])+)\]\]?\s*(#.*)?$/;
87
+ const KEY_RE = /^\s*((?:"[^"]*"|'[^']*'|[A-Za-z0-9_-]+)(?:\s*\.\s*(?:"[^"]*"|'[^']*'|[A-Za-z0-9_-]+))*)\s*=(.*)$/;
88
+
89
+ // 줄마다 { kind: 'header'|'key'|'inside'|'other', name, end }. header 의 name 은 표 이름, key 의 name 은 키 이름이고
90
+ // end 는 값이 끝나는 줄 다음. 여러 줄 문자열이나 배열 안의 줄은 inside.
91
+ export function scan(text) {
92
+ const ls = lines(text);
93
+ const out = ls.map(() => ({ kind: 'other' }));
94
+ for (let i = 0; i < ls.length; i++) {
95
+ const header = HEADER_RE.exec(ls[i]);
96
+ if (header) { out[i] = { kind: 'header', name: plainName(header[1]) }; continue; }
97
+ const key = KEY_RE.exec(ls[i]);
98
+ if (!key) continue;
99
+ let { d, multi } = scanLine(key[2]);
100
+ let j = i + 1;
101
+ for (; (d > 0 || multi) && j < ls.length; j++) {
102
+ out[j] = { kind: 'inside' };
103
+ const r = scanLine(ls[j], multi);
104
+ d += r.d;
105
+ multi = r.multi;
106
+ }
107
+ out[i] = { kind: 'key', name: plainName(key[1]), end: j };
108
+ i = j - 1;
109
+ }
110
+ return out;
111
+ }
112
+
113
+ // 표의 줄 범위 [from, to). table '' 은 최상위. 표가 없으면 null.
114
+ function tableRange(info, table) {
115
+ table = table.includes('\u0000') || !table ? table : plainName(table);
116
+ let from = table ? -1 : 0;
117
+ for (let i = 0; i < info.length; i++) {
118
+ if (info[i].kind !== 'header') continue;
119
+ if (from >= 0) return [from, i];
120
+ if (info[i].name === table) from = i + 1;
121
+ }
122
+ return from >= 0 ? [from, info.length] : null;
123
+ }
124
+
125
+ function keysOf(text, table) {
126
+ const info = scan(text);
127
+ const range = tableRange(info, table);
128
+ if (!range) return [];
129
+ const keys = [];
130
+ for (let i = range[0]; i < range[1]; i++) if (info[i].kind === 'key') keys.push({ at: i, end: info[i].end, name: info[i].name });
131
+ return keys;
132
+ }
133
+
134
+ // 키의 { at, end, raw }. raw 는 값 끝의 주석을 뺀 글. 없으면 null.
135
+ export function findKey(text, table, key) {
136
+ const want = nameParts(key).join('\u0000');
137
+ const hit = keysOf(text, plainName(table)).find((k) => k.name === want);
138
+ if (!hit) return null;
139
+ const ls = lines(text).slice(hit.at, hit.end);
140
+ const parts = [ls[0].slice(ls[0].indexOf('=') + 1), ...ls.slice(1)];
141
+ let multi = '';
142
+ for (const part of parts.slice(0, -1)) multi = scanLine(part, multi).multi;
143
+ const last = parts.length - 1;
144
+ parts[last] = parts[last].slice(0, scanLine(parts[last], multi).cut); // 마지막 줄의 주석을 뗀다
145
+ const raw = parts.join('\n');
146
+ return { at: hit.at, end: hit.end, raw: raw.trim() };
147
+ }
148
+
149
+ // 최상위가 이 표를 점 키나 인라인 표로 정했는지(`tui.x = 1`, `tui = { ... }`). 그런 파일에 [표] 를 더하면 키가 겹쳐
150
+ // Codex 가 설정을 읽지 못한다.
151
+ export function definedInline(text, table) {
152
+ const t = plainName(table);
153
+ return keysOf(text, '').some((k) => k.name === t || k.name.startsWith(`${t}\u0000`));
154
+ }
155
+
156
+ // 키를 rawValue 로 둔다. 있으면 그 줄을 바꾸고, 없으면 표 끝(최상위는 첫 표 머리 앞)에 넣는다. 표가 없으면 끝에 만든다.
157
+ // comment 가 있으면 키 바로 위에 그 주석 줄을 둔다.
158
+ export function setKey(text, table, key, rawValue, comment = '') {
159
+ const ls = lines(text);
160
+ const nl = eol(text);
161
+ const line = [...(comment ? [comment] : []), `${key} = ${rawValue}`];
162
+ const hit = findKey(text, table, key);
163
+ if (hit) {
164
+ const above = comment && ls[hit.at - 1] === comment ? 1 : 0;
165
+ ls.splice(hit.at - above, hit.end - hit.at + above, ...line);
166
+ return ls.join(nl);
167
+ }
168
+ const range = tableRange(scan(text), table);
169
+ if (!range) {
170
+ while (ls.length && ls[ls.length - 1] === '') ls.pop();
171
+ return [...ls, ...(ls.length ? [''] : []), `[${table}]`, ...line, ''].join(nl);
172
+ }
173
+ // 다음 표 머리에 붙은 주석(`# MCP servers` 처럼 머리 바로 위의 주석 줄)과 그 앞 빈 줄은 건너뛰고 넣는다.
174
+ let at = range[1];
175
+ if (range[1] < ls.length) while (at > range[0] && ls[at - 1].trim().startsWith('#')) at--;
176
+ while (at > range[0] && ls[at - 1].trim() === '') at--;
177
+ ls.splice(at, 0, ...line);
178
+ return ls.join(nl);
179
+ }
180
+
181
+ // 키를 뺀다. comment 와 같은 주석 줄이 바로 위에 있으면 그것도 뺀다.
182
+ export function removeKey(text, table, key, comment = '') {
183
+ const hit = findKey(text, table, key);
184
+ if (!hit) return text;
185
+ const ls = lines(text);
186
+ const above = comment && ls[hit.at - 1] === comment ? 1 : 0;
187
+ ls.splice(hit.at - above, hit.end - hit.at + above);
188
+ return ls.join(eol(text));
189
+ }
190
+
191
+ // 표에 키도 주석도 없으면 그 머리와 뒤의 빈 줄을 뺀다.
192
+ export function dropEmptyTable(text, table) {
193
+ const info = scan(text);
194
+ const range = tableRange(info, table);
195
+ if (!range) return text;
196
+ const ls = lines(text);
197
+ if (ls.slice(range[0], range[1]).some((l) => l.trim())) return text;
198
+ ls.splice(range[0] - 1, range[1] - range[0] + 1);
199
+ return ls.join(eol(text));
200
+ }
201
+
202
+ // 이름이 prefix 로 시작하는 표들 중 key 를 정한 표 이름(예: profiles.* 의 approval_policy).
203
+ export function tablesSetting(text, prefix, key) {
204
+ const p = plainName(prefix);
205
+ const names = scan(text).filter((x) => x.kind === 'header' && x.name.startsWith(`${p}\u0000`)).map((x) => x.name);
206
+ return [...new Set(names)].filter((n) => findKey(text, n, key)).map((n) => n.split('\u0000').join('.'));
207
+ }
208
+
209
+ // 쓰기 실패가 이 계정의 권한(샌드박스, 읽기 전용, 다른 소유자) 때문인지.
210
+ export const denied = (e) => ['EACCES', 'EPERM', 'EROFS'].includes(e?.code);