@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +203 -1
- package/PRIVACY.md +34 -4
- package/README.md +46 -15
- package/bin/banker.js +228 -51
- package/bin/lib/codex-hooks.mjs +249 -0
- package/bin/lib/telemetry-config.mjs +33 -16
- package/codex/manifest.json +14 -11
- package/codex/skills/graceful-pause/SKILL.md +30 -0
- package/codex/skills/progress/SKILL.md +42 -0
- package/codex/skills/progress/scripts/progress-plan.mjs +722 -0
- package/codex/skills/progress/scripts/progress.mjs +295 -0
- package/codex/transform-matrix.md +19 -14
- package/commands/setup.md +23 -0
- package/hooks/obsidize-hook.mjs +116 -4
- package/hooks/progress-plan.mjs +1 -1
- package/hooks/progress.mjs +143 -38
- package/hooks/register.mjs +13 -3
- package/hooks/telemetry-count-codex.mjs +222 -0
- package/hooks/update-checkin.mjs +15 -7
- package/hooks/update-fetch.mjs +15 -9
- package/hooks/update-notify.mjs +30 -17
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +11 -5
- package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
- package/skills/3d-intro-build/references/curate.mjs +48 -14
- package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
- package/skills/3d-intro-build/references/prompts.md +4 -4
- package/skills/3d-intro-build/references/serve.mjs +6 -1
- package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
- package/skills/3d-intro-build/references/video-pool.mjs +250 -135
- package/skills/3d-intro-setup/SKILL.md +41 -10
- package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
- package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
- package/skills/anti-drm/SKILL.md +6 -3
- package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
- package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
- package/skills/append_wiki/SKILL.md +34 -8
- package/skills/audit-mock/SKILL.md +1 -1
- package/skills/audit-security/SKILL.md +64 -2
- package/skills/compact-wiki/SKILL.md +37 -6
- package/skills/deep-research/SKILL.md +11 -0
- package/skills/docs-setup/SKILL.md +37 -7
- package/skills/lfm/SKILL.md +148 -0
- package/skills/lineage/SKILL.md +25 -15
- package/skills/lineage/lineage.py +564 -301
- package/skills/motion-graphic-make/SKILL.md +1 -1
- package/skills/motion-graphic-make/references/curate.mjs +48 -14
- package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
- package/skills/motion-graphic-make/references/serve.mjs +6 -1
- package/skills/motion-graphic-setup/SKILL.md +58 -13
- package/skills/obsidizer/SKILL.md +30 -11
- package/skills/obsidizer/obsidize.mjs +50 -12
- package/skills/omc-patch/SKILL.md +86 -23
- package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
- package/skills/payload-mon/SKILL.md +6 -2
- package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
- package/skills/payload-mon/scripts/payload-size.mjs +4 -1
- package/skills/pdf-vision-extract/SKILL.md +11 -6
- package/skills/ralph-qa/SKILL.md +125 -151
- package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
- package/skills/ralph-qa/references/payload-scan.mjs +22 -7
- package/skills/ralph-qa/references/seat-payload.mjs +646 -0
- package/skills/ralph-qa/references/seat-run.mjs +956 -0
- package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
- package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
- package/skills/rfp-author/SKILL.md +5 -3
- package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
- package/skills/setup-bypass-permissions/SKILL.md +27 -9
- package/skills/setup-bypass-permissions/scripts/codex-bypass.mjs +135 -0
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +210 -0
- package/skills/setup-harness-factory/SKILL.md +17 -1
- package/skills/setup-insane-search/SKILL.md +16 -0
- package/skills/setup-java/SKILL.md +20 -1
- package/skills/setup-lsp/SKILL.md +56 -13
- package/skills/setup-mcp/SKILL.md +73 -4
- package/skills/setup-node/SKILL.md +31 -6
- package/skills/setup-omc/SKILL.md +24 -0
- package/skills/setup-omc-hud/SKILL.md +42 -9
- package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
- package/skills/setup-omc-hud/assets/codex-config.mjs +210 -0
- package/skills/setup-omc-hud/assets/codex-status-line.mjs +90 -0
- package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
- package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
- package/skills/setup-playwright/SKILL.md +45 -2
- package/skills/setup-pwsh/SKILL.md +19 -0
- package/skills/setup-python/SKILL.md +45 -7
- package/skills/setup-sandbox/SKILL.md +30 -5
- package/skills/setup-stitch/SKILL.md +22 -0
- package/skills/setup-tmux/SKILL.md +24 -4
- package/skills/smart-compact/SKILL.md +58 -12
- package/skills/smart-compact/scripts/codex-gate.mjs +384 -0
- package/skills/tone-compact/SKILL.md +2 -1
- package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
- package/skills/ultra-chain/SKILL.md +12 -7
- package/skills/ultra-chain/references/preview-lib.mjs +51 -15
- package/skills/ultra-chain/references/serve.mjs +6 -1
- package/skills/ultra-interview/SKILL.md +14 -2
- package/skills/update-banker/SKILL.md +19 -3
- package/skills/vertical-pptx/SKILL.md +6 -3
- 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
|
|
203
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
211
|
-
// needs no claude-update-last block.
|
|
212
|
-
|
|
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 ${
|
|
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
|
-
|
|
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) =>
|
|
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
|
-
|
|
292
|
-
|
|
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(
|
|
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 =
|
|
321
|
-
return line ? clip(line
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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 [
|
|
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
|
|
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);
|