@kaydash9999/banker-plugins 0.18.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +103 -0
  4. package/README.md +18 -7
  5. package/bin/lib/telemetry-config.mjs +25 -0
  6. package/codex/manifest.json +1 -0
  7. package/codex/transform-matrix.md +1 -0
  8. package/hooks/graceful-pause.mjs +4 -2
  9. package/hooks/obsidize-hook.mjs +61 -32
  10. package/hooks/progress-plan.mjs +14 -8
  11. package/hooks/progress.mjs +187 -48
  12. package/hooks/register.mjs +15 -7
  13. package/hooks/telemetry-count-skill.mjs +4 -6
  14. package/hooks/telemetry-count.mjs +4 -6
  15. package/hooks/update-checkin.mjs +46 -43
  16. package/hooks/update-fetch.mjs +8 -7
  17. package/package.json +1 -1
  18. package/skills/anti-drm/SKILL.md +46 -15
  19. package/skills/anti-drm/scripts/anti-drm.mjs +213 -61
  20. package/skills/anti-drm/scripts/instruction-toggle.mjs +280 -44
  21. package/skills/lineage/SKILL.md +4 -1
  22. package/skills/lineage/lineage.py +196 -73
  23. package/skills/omc-patch/SKILL.md +37 -11
  24. package/skills/omc-patch/scripts/omc-patch.mjs +635 -103
  25. package/skills/theme-selection/SKILL.md +165 -0
  26. package/skills/theme-selection/assets/LICENSE +21 -0
  27. package/skills/theme-selection/assets/NOTICE.md +30 -0
  28. package/skills/theme-selection/assets/palettes.json +81 -0
  29. package/skills/theme-selection/references/contrast.md +138 -0
  30. package/skills/theme-selection/references/integrate.md +161 -0
  31. package/skills/theme-selection/scripts/themes.mjs +1067 -0
  32. package/skills/tone-compact/SKILL.md +14 -5
  33. package/skills/tone-compact/scripts/instruction-toggle.mjs +280 -44
  34. package/skills/tone-compact/scripts/tone-compact.mjs +4 -4
  35. package/skills/update-banker/SKILL.md +1 -1
@@ -9,8 +9,13 @@
9
9
  * node anti-drm.mjs off --runtime claude|codex
10
10
  * node anti-drm.mjs status
11
11
  * node anti-drm.mjs config --deny <list> --alt <list> [--tsv tab|comma]
12
+ * node anti-drm.mjs on --runtime claude|codex --stdin
13
+ * node anti-drm.mjs config --stdin
12
14
  *
13
15
  * <list> is extensions split by commas or spaces, such as ".txt,.pdf".
16
+ * --stdin reads the settings from standard input instead, one line each:
17
+ * deny=<list>, alt=<list> and tsv=tab|comma. The skill passes them in a
18
+ * quoted heredoc, so no value the user chose is ever read by a shell.
14
19
  *
15
20
  * The settings are one JSON file both runtimes share, ~/.config/banker/anti-drm.json
16
21
  * ($BANKER_ANTI_DRM_CONFIG names another): the denied extensions, the
@@ -40,8 +45,8 @@ import {
40
45
  mkdirSync,
41
46
  openSync,
42
47
  readFileSync,
48
+ readSync,
43
49
  realpathSync,
44
- statSync,
45
50
  } from "node:fs";
46
51
  import { homedir } from "node:os";
47
52
  import { dirname, join } from "node:path";
@@ -50,9 +55,15 @@ import {
50
55
  KEEP_CLOSE,
51
56
  KEEP_OPEN,
52
57
  RUNTIMES,
58
+ count,
53
59
  denied,
60
+ done,
54
61
  hasMarker,
62
+ lf,
63
+ LINK_REPLACED,
64
+ noBom,
55
65
  present,
66
+ refuse,
56
67
  stateLines,
57
68
  toggleSpec,
58
69
  turnOff as toggleOff,
@@ -80,12 +91,7 @@ const DEFAULTS = {
80
91
  skillFile: fileURLToPath(new URL("../SKILL.md", import.meta.url)),
81
92
  };
82
93
 
83
- const BOM = String.fromCharCode(0xfeff);
84
94
  const RUNTIME_NAME = { claude: "Claude Code", codex: "Codex" };
85
- const done = (message) => ({ ok: true, message });
86
- const refuse = (message) => ({ ok: false, message });
87
- const count = (text, needle) => text.split(needle).length - 1;
88
- const lf = (text) => text.replace(/\r\n/g, "\n");
89
95
  // A value from outside, as a message shows it. An extension is printable
90
96
  // ASCII, so anything else becomes "?" (a line break or a direction mark cannot
91
97
  // forge a line or reorder the text), at most 40 characters stay, and a value
@@ -136,10 +142,12 @@ export function checkConfig({ deny, alt, tsvDelimiter = "tab" }) {
136
142
  if (reserved.length > 0) return { error: `anti-drm 이 쓰는 형식이라 금지할 수 없습니다: ${reserved.join(" ")}` };
137
143
  const both = d.list.filter((x) => a.list.includes(x));
138
144
  if (both.length > 0) return { error: `금지 확장자와 대체 확장자에 함께 있습니다: ${both.join(" ")}` };
139
- if (tsvDelimiter !== "tab" && tsvDelimiter !== "comma") {
145
+ // In any letter case, as the --tsv flag takes it; kept lower-cased.
146
+ const tsv = typeof tsvDelimiter === "string" ? tsvDelimiter.toLowerCase() : tsvDelimiter;
147
+ if (tsv !== "tab" && tsv !== "comma") {
140
148
  return { error: `TSV 구분자는 tab 이나 comma 여야 합니다: ${shown(tsvDelimiter)}` };
141
149
  }
142
- return { config: { version: 1, deny: d.list, alt: a.list, tsvDelimiter } };
150
+ return { config: { version: 1, deny: d.list, alt: a.list, tsvDelimiter: tsv } };
143
151
  }
144
152
 
145
153
  /**
@@ -188,7 +196,7 @@ export function readConfig(file) {
188
196
  const { text } = got;
189
197
  let raw;
190
198
  try {
191
- raw = JSON.parse(text.startsWith(BOM) ? text.slice(BOM.length) : text);
199
+ raw = JSON.parse(noBom(text));
192
200
  } catch {
193
201
  return { error: "JSON 이 아닙니다" };
194
202
  }
@@ -196,9 +204,9 @@ export function readConfig(file) {
196
204
  return checkConfig({ deny: raw.deny, alt: raw.alt, tsvDelimiter: raw.tsvDelimiter ?? "tab" });
197
205
  }
198
206
 
199
- // What sits at the settings path: "none", "file" (a regular file, or a link to
200
- // one), or "other" (a folder, a FIFO). A link that points nowhere or loops is
201
- // "none": the save replaces the link.
207
+ // What sits at the settings path: "none", "file" (a regular file with this one name), "link" (a link, wherever it
208
+ // leads), "hardlink" (a regular file with another name too) or "other" (a folder, a FIFO). The save replaces a link
209
+ // or a hard link by this name only: what it led to stays, so nothing of it is read or kept.
202
210
  function pathKind(file) {
203
211
  let st;
204
212
  try {
@@ -207,31 +215,64 @@ function pathKind(file) {
207
215
  if (e.code === "ENOENT" || e.code === "ENOTDIR") return "none";
208
216
  throw e;
209
217
  }
210
- if (st.isFile()) return "file";
211
- if (!st.isSymbolicLink()) return "other";
218
+ if (st.isSymbolicLink()) return "link";
219
+ if (!st.isFile()) return "other";
220
+ return st.nlink > 1 ? "hardlink" : "file";
221
+ }
222
+
223
+ // Opened without blocking. O_NOFOLLOW, where the system has it, keeps the open from following a link put at the name
224
+ // after the first look; Windows has none, so there the name is looked at again after the open.
225
+ const PRIOR_OPEN = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
226
+ const NO_FOLLOW = constants.O_NOFOLLOW ?? 0;
227
+
228
+ /**
229
+ * What the settings name holds after the open, against the opened file, from their bigint stats: "same", "link" (a
230
+ * link now: the open followed it), "hardlink" (the opened file has another name now) or "changed" (gone, not a regular
231
+ * file, or another file: the file IDs differ; an ID of 0, which some file systems give, proves nothing).
232
+ */
233
+ export function nameNow(opened, now) {
234
+ if (now?.isSymbolicLink()) return "link";
235
+ if (!now?.isFile()) return "changed";
236
+ if (opened.ino !== 0n && (now.ino !== opened.ino || now.dev !== opened.dev)) return "changed";
237
+ return now.nlink > 1n ? "hardlink" : "same";
238
+ }
239
+
240
+ // The name's own stats in bigint, or null when it cannot be looked at.
241
+ function lstatOrNull(file) {
212
242
  try {
213
- return statSync(file).isFile() ? "file" : "none";
243
+ return lstatSync(file, { bigint: true });
214
244
  } catch {
215
- return "none";
245
+ return null;
216
246
  }
217
247
  }
218
248
 
219
- // The bytes of the file at the settings path, opened without blocking: { bytes }
220
- // (null when it turned out not to be a regular file) or { refusal }.
221
- export function priorBytes(file) {
249
+ // What the file opened at the settings path gives, as priorBytes says. Without O_NOFOLLOW the name must still hold it.
250
+ function openedBytes(file, fd, noFollow) {
251
+ const st = fstatSync(fd);
252
+ if (!st.isFile() || st.nlink > 1) return { bytes: null };
253
+ const now = noFollow ? "same" : nameNow(fstatSync(fd, { bigint: true }), lstatOrNull(file));
254
+ if (now === "changed") return { refusal: "확인하는 사이에 바뀐 자리라 설정을 저장하지 않았습니다" };
255
+ if (now !== "same") return { bytes: null };
256
+ if (st.size > MAX_BYTES) return { refusal: "64KiB 보다 큰 파일이라 설정을 저장하지 않았습니다" };
257
+ return { bytes: readFileSync(fd) };
258
+ }
259
+
260
+ /**
261
+ * The bytes of the regular file with this one name at the settings path: { bytes } (null when it turned out to be
262
+ * something else: gone, a link, not a regular file, or a file with another name too, which the save loses nothing
263
+ * of) or { refusal }. noFollow is O_NOFOLLOW where the system has it; 0 takes the Windows way.
264
+ */
265
+ export function priorBytes(file, noFollow = NO_FOLLOW) {
222
266
  let fd;
223
267
  try {
224
- fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
268
+ fd = openSync(file, PRIOR_OPEN | noFollow);
225
269
  } catch (e) {
226
- if (e.code === "ENOENT" || e.code === "ENOTDIR") return { bytes: null };
270
+ if (e.code === "ENOENT" || e.code === "ENOTDIR" || e.code === "ELOOP") return { bytes: null };
227
271
  if (denied(e)) return { refusal: "읽을 수 없는 파일이라 설정을 저장하지 않았습니다" };
228
272
  throw e;
229
273
  }
230
274
  try {
231
- const st = fstatSync(fd);
232
- if (!st.isFile()) return { bytes: null };
233
- if (st.size > MAX_BYTES) return { refusal: "64KiB 보다 큰 파일이라 설정을 저장하지 않았습니다" };
234
- return { bytes: readFileSync(fd) };
275
+ return openedBytes(file, fd, noFollow);
235
276
  } finally {
236
277
  closeSync(fd);
237
278
  }
@@ -244,7 +285,7 @@ const OWN_KEYS = ["version", "deny", "alt", "tsvDelimiter"];
244
285
  function ownSettings(bytes) {
245
286
  try {
246
287
  const text = bytes.toString("utf8");
247
- const raw = JSON.parse(text.startsWith(BOM) ? text.slice(BOM.length) : text);
288
+ const raw = JSON.parse(noBom(text));
248
289
  const plain = Boolean(raw) && typeof raw === "object" && !Array.isArray(raw);
249
290
  return plain && Object.keys(raw).every((k) => OWN_KEYS.includes(k)) && Boolean(checkConfig(raw).config);
250
291
  } catch {
@@ -252,22 +293,27 @@ function ownSettings(bytes) {
252
293
  }
253
294
  }
254
295
 
255
- // Whether anything, a dangling link included, already sits at path.
256
- function taken(path) {
296
+ // Keeps a file anti-drm did not write at bak, once, and says what it did: "kept", "earlier" (a regular file is there
297
+ // already and stays) or "other" (something else is there: a link, a dangling one too, or a folder; nothing is written).
298
+ // The note comes from the same look as the write.
299
+ function keepOnce(bak, bytes) {
300
+ let st;
257
301
  try {
258
- lstatSync(path);
259
- return true;
260
- } catch {
261
- return false;
302
+ st = lstatSync(bak);
303
+ } catch (e) {
304
+ if (e.code !== "ENOENT") throw e;
305
+ writeFresh(bak, bytes, 0o600);
306
+ return "kept";
262
307
  }
308
+ return st.isFile() ? "earlier" : "other";
263
309
  }
264
310
 
265
- // What a save finds at the settings path: { keep } (the bytes to put in .bak,
266
- // null for none: nothing there, or anti-drm's own settings) or { refusal }.
311
+ // What a save finds at the settings path: { keep } (the bytes to put in .bak, null for none: nothing there, a link
312
+ // or a hard link, whose file stays, or anti-drm's own settings) or { refusal }.
267
313
  function priorSettings(file) {
268
314
  const kind = pathKind(file);
269
- if (kind === "none") return { keep: null };
270
315
  if (kind === "other") return { refusal: "일반 파일이 아니라 설정을 저장하지 않았습니다" };
316
+ if (kind !== "file") return { keep: null };
271
317
  const got = priorBytes(file);
272
318
  if (got.refusal) return got;
273
319
  return { keep: got.bytes === null || ownSettings(got.bytes) ? null : got.bytes };
@@ -277,12 +323,14 @@ function priorSettings(file) {
277
323
  * Saves the settings: the folder 0755, the file 0644 through a temp file and a
278
324
  * rename. keep, when given, is a file anti-drm did not write; its bytes go to
279
325
  * <file>.bak, readable by this account only, once: a .bak already there is the
280
- * first such file and stays.
326
+ * first such file and stays. Gives back what became of keep: null when none was
327
+ * given, else "kept", "earlier" or "other" (see keepOnce).
281
328
  */
282
329
  export function writeConfig(file, config, keep = null) {
283
330
  mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
284
- if (keep !== null && !taken(`${file}.bak`)) writeFresh(`${file}.bak`, keep, 0o600);
331
+ const kept = keep === null ? null : keepOnce(`${file}.bak`, keep);
285
332
  writeFresh(file, `${JSON.stringify(config, null, 2)}\n`, 0o644);
333
+ return kept;
286
334
  }
287
335
 
288
336
  // ---- rules ----
@@ -337,13 +385,16 @@ const code = (ext) => `\`${ext}\``;
337
385
  export function renderRules(template, config) {
338
386
  const map = table(["금지 확장자", "대신 쓸 확장자"], config.deny.map((d) => [code(d), code(replacementFor(d, config.alt))]));
339
387
  const alts = table(["확장자", "쓰는 곳"], config.alt.map((a) => [code(a), useOf(a, config)]));
340
- return template.replace("{{map}}", () => map).replace("{{alts}}", () => alts);
388
+ const rules = template.replace("{{map}}", () => map).replace("{{alts}}", () => alts);
389
+ if (rules.includes("{{")) throw new Error("규칙을 채운 뒤에도 자리 표시({{)가 남았습니다");
390
+ return rules;
341
391
  }
342
392
 
343
393
  // ---- commands ----
344
394
 
345
395
  const USAGE =
346
- "사용법: anti-drm on | off | status | config [--runtime claude|codex] [--deny <확장자 목록> --alt <확장자 목록> [--tsv tab|comma]]";
396
+ "사용법: anti-drm on | off | status | config [--runtime claude|codex] [--stdin | --deny <확장자 목록> --alt <확장자 목록> [--tsv tab|comma]]\n" +
397
+ "--stdin 은 표준 입력이 끝날 때까지 기다립니다.";
347
398
 
348
399
  // The settings as `status` shows them.
349
400
  function configLines(o, found) {
@@ -385,17 +436,38 @@ export function status(opts = {}) {
385
436
  return done(lines.join("\n"));
386
437
  }
387
438
 
388
- // Saves the settings, as a result to report.
439
+ // What a save does beyond writing the settings, as lines to report: a link or a hard link at the settings path becomes
440
+ // a regular file (what it led to stays, and nothing of it goes to .bak), and a file anti-drm did not write goes to
441
+ // .bak, or is not kept when an earlier one, or something that is not a regular file, is there already. kept is what
442
+ // writeConfig gave back.
443
+ const KEPT_NOTES = {
444
+ kept: (bak) => `참고: anti-drm 이 쓰지 않은 원래 파일은 ${bak} 에 남겼습니다.`,
445
+ earlier: (bak) => `참고: anti-drm 이 쓰지 않은 원래 파일은 남기지 않았습니다. 먼저 남긴 파일이 있습니다: ${bak}`,
446
+ other: (bak) => `참고: anti-drm 이 쓰지 않은 원래 파일은 남기지 않았습니다. 그 자리에 일반 파일이 아닌 것이 있습니다: ${bak}`,
447
+ };
448
+ const REPLACED_NOTES = {
449
+ link: LINK_REPLACED,
450
+ hardlink: "참고: 하드 링크였던 자리를 일반 파일로 바꿨습니다. 같은 파일의 다른 이름은 건드리지 않았습니다.",
451
+ };
452
+ function saveNotes(file, replaced, kept) {
453
+ const notes = REPLACED_NOTES[replaced] ? [REPLACED_NOTES[replaced]] : [];
454
+ if (kept) notes.push(KEPT_NOTES[kept](where(`${file}.bak`)));
455
+ return notes;
456
+ }
457
+
458
+ // Saves the settings, as a result to report. The look just before the write names what the rename replaces.
389
459
  function save(o, config) {
460
+ let notes;
390
461
  try {
391
462
  const prior = priorSettings(o.configFile);
392
463
  if (prior.refusal) return refuse(`${prior.refusal}: ${where(o.configFile)}`);
393
- writeConfig(o.configFile, config, prior.keep);
464
+ const replaced = pathKind(o.configFile);
465
+ notes = saveNotes(o.configFile, replaced, writeConfig(o.configFile, config, prior.keep));
394
466
  } catch (e) {
395
467
  if (denied(e)) return refuse(`쓸 권한이 없어 설정을 저장하지 못했습니다 (${e.code}): ${where(o.configFile)}`);
396
468
  throw e;
397
469
  }
398
- return done(`설정을 저장했습니다: ${where(o.configFile)}`);
470
+ return done([`설정을 저장했습니다: ${where(o.configFile)}`, ...notes].join("\n"));
399
471
  }
400
472
 
401
473
  // New rules in each runtime that holds the rules, but `skip`. A runtime that
@@ -416,8 +488,7 @@ function refresh(o, rules, skip) {
416
488
  // The settings from --deny, --alt and --tsv: { config }, { error }, or { usage } when a list is missing.
417
489
  function configFromFlags(flags) {
418
490
  if (flags.deny === undefined || flags.alt === undefined) return { usage: true };
419
- const tsvDelimiter = (flags.tsv ?? "tab").toLowerCase();
420
- return checkConfig({ deny: splitList(flags.deny), alt: splitList(flags.alt), tsvDelimiter });
491
+ return checkConfig({ deny: splitList(flags.deny), alt: splitList(flags.alt), tsvDelimiter: flags.tsv ?? "tab" });
421
492
  }
422
493
 
423
494
  const settingsGiven = (flags) => ["deny", "alt", "tsv"].some((k) => flags[k] !== undefined);
@@ -438,7 +509,7 @@ function applySettings(o, flags, skip) {
438
509
  // The rules from the saved settings: { rules, lines, ok } or { refusal }.
439
510
  function savedRules(o) {
440
511
  const found = readConfig(o.configFile);
441
- if (found === null) return { refusal: refuse("설정이 없습니다. 금지 확장자와 대체 확장자를 --deny, --alt 로 함께 주세요.") };
512
+ if (found === null) return { refusal: refuse("설정이 없습니다. 금지 확장자와 대체 확장자를 --stdin 이나 --deny, --alt 로 함께 주세요.") };
442
513
  if (found.error) {
443
514
  return { refusal: refuse(`설정 파일이 올바르지 않습니다 (${found.error}): ${where(o.configFile)}. config 로 다시 만드세요.`) };
444
515
  }
@@ -481,17 +552,89 @@ export const configure = (opts = {}, flags = {}) => cmdConfig({ ...DEFAULTS, ...
481
552
  const FLAG_NAMES = ["runtime", "deny", "alt", "tsv"];
482
553
  const COMMANDS = ["on", "off", "status", "config"];
483
554
 
484
- /** { command, runtime, flags, extra } from the command line; each flag takes "x" or "=x". */
555
+ /**
556
+ * { command, runtime, flags, extra, stdin } from the command line; each value
557
+ * flag takes "x" or "=x", and --stdin takes nothing.
558
+ */
485
559
  export function parseArgs(argv) {
486
560
  const rest = [];
487
561
  const flags = {};
562
+ let stdin = false;
488
563
  for (let i = 0; i < argv.length; i++) {
489
564
  const m = /^--([a-z]+)(?:=(.*))?$/.exec(argv[i]);
490
565
  if (m && FLAG_NAMES.includes(m[1])) flags[m[1]] = m[2] ?? argv[++i] ?? "";
566
+ else if (argv[i] === "--stdin") stdin = true;
491
567
  else rest.push(argv[i]);
492
568
  }
493
569
  const { runtime, ...settings } = flags;
494
- return { command: (rest[0] ?? "status").toLowerCase(), runtime: runtime?.toLowerCase() ?? null, flags: settings, extra: rest.slice(1) };
570
+ const command = (rest[0] ?? "status").toLowerCase();
571
+ return { command, runtime: runtime?.toLowerCase() ?? null, flags: settings, extra: rest.slice(1), stdin };
572
+ }
573
+
574
+ // Lines on standard input that hold the settings: the key, "=", the value.
575
+ const STDIN_LINE = /^(deny|alt|tsv)=(.*)$/;
576
+ const UTF8_STRICT = new TextDecoder("utf-8", { fatal: true });
577
+
578
+ /**
579
+ * The settings on standard input as flags: { flags } or { error }. At most
580
+ * 64 KiB of UTF-8; a leading byte order mark and CRLF line ends are fine;
581
+ * each of deny=, alt= and tsv= once; blank lines are skipped.
582
+ */
583
+ export function stdinSettings(bytes) {
584
+ if (bytes.length > MAX_BYTES) return { error: "표준 입력이 64KiB 보다 큽니다" };
585
+ let text;
586
+ try {
587
+ text = UTF8_STRICT.decode(bytes);
588
+ } catch {
589
+ return { error: "표준 입력이 UTF-8 이 아닙니다" };
590
+ }
591
+ const flags = {};
592
+ for (const line of noBom(text).split(/\r?\n/)) {
593
+ if (line.trim() === "") continue;
594
+ const m = STDIN_LINE.exec(line);
595
+ if (!m || flags[m[1]] !== undefined) return { error: `표준 입력의 설정 줄을 읽을 수 없습니다: ${shown(line)}` };
596
+ flags[m[1]] = m[2].trim();
597
+ }
598
+ return { flags };
599
+ }
600
+
601
+ const PAUSE = new Int32Array(new SharedArrayBuffer(4));
602
+
603
+ // Standard input, read up to cap bytes and one more: { bytes } at the end of input, or { tooBig: true } as soon as
604
+ // more than cap came. A pipe left non-blocking answers EAGAIN until data comes: wait a little and read again.
605
+ function readCapped(fd, cap) {
606
+ const chunks = [];
607
+ const buf = Buffer.alloc(64 * 1024);
608
+ let total = 0;
609
+ for (;;) {
610
+ const n = readSome(fd, buf);
611
+ if (n === 0) return { bytes: Buffer.concat(chunks) };
612
+ total += n;
613
+ if (total > cap) return { tooBig: true };
614
+ chunks.push(Buffer.from(buf.subarray(0, n)));
615
+ }
616
+ }
617
+
618
+ // One read into buf: how many bytes came, 0 at the end of input (Windows may say EOF instead).
619
+ function readSome(fd, buf) {
620
+ for (;;) {
621
+ try {
622
+ return readSync(fd, buf, 0, buf.length, null);
623
+ } catch (e) {
624
+ if (e.code === "EOF") return 0;
625
+ if (e.code !== "EAGAIN") throw e;
626
+ Atomics.wait(PAUSE, 0, 0, 10);
627
+ }
628
+ }
629
+ }
630
+
631
+ // The settings standard input held: an error when it was too big or unreadable, or held no settings line, which
632
+ // on and config both refuse rather than reach for the saved settings.
633
+ function stdinFrom(got) {
634
+ if (got.tooBig) return { error: "표준 입력이 64KiB 보다 큽니다" };
635
+ const read = stdinSettings(got.bytes);
636
+ if (read.error || Object.keys(read.flags).length > 0) return read;
637
+ return { error: "표준 입력에 설정 줄이 없습니다" };
495
638
  }
496
639
 
497
640
  // Settings flags go only with on and config, and config needs both lists.
@@ -501,9 +644,11 @@ function settingsUsage(command, flags) {
501
644
  }
502
645
 
503
646
  // Why the command line cannot run as given, or "" when it can.
504
- function usageError({ command, runtime, flags, extra }) {
647
+ function usageError({ command, runtime, flags, extra, stdin }) {
505
648
  if (extra.length > 0 || !COMMANDS.includes(command)) return USAGE;
506
649
  if (runtime !== null && !RUNTIMES.includes(runtime)) return USAGE;
650
+ // --stdin brings all the settings, so it goes with on and config only and never with a settings flag.
651
+ if (stdin) return (command === "on" || command === "config") && Object.keys(flags).length === 0 ? "" : USAGE;
507
652
  return settingsUsage(command, flags);
508
653
  }
509
654
 
@@ -517,25 +662,32 @@ function run(command, runtime, flags) {
517
662
  return command === "on" ? cmdOn(o, runtime, flags) : cmdOff(o, runtime);
518
663
  }
519
664
 
520
- function main(argv, env = process.env) {
665
+ // Writes a message line and gives back the exit status.
666
+ const say = (text, code) => {
667
+ process.stdout.write(`${text}\n`);
668
+ return code;
669
+ };
670
+
671
+ // Whether a command acts on one runtime and so needs to know which.
672
+ const needsRuntime = (command) => command === "on" || command === "off";
673
+ // The settings flags: from standard input with --stdin, else from the command line.
674
+ const settingsOf = (args, input) => (args.stdin ? stdinFrom(input()) : { flags: args.flags });
675
+
676
+ function main(argv, env = process.env, input = () => readCapped(0, MAX_BYTES)) {
521
677
  const args = parseArgs(argv);
522
678
  const why = usageError(args);
523
- if (why) {
524
- process.stdout.write(`${why}\n`);
525
- return 1;
526
- }
679
+ if (why) return say(why, 1);
527
680
  const runtime = runtimeOf(args.runtime, env);
528
- if ((args.command === "on" || args.command === "off") && !RUNTIMES.includes(runtime)) {
529
- process.stdout.write(`런타임을 정할 수 없습니다. --runtime claude 또는 --runtime codex 를 붙이세요.\n${USAGE}\n`);
530
- return 1;
681
+ if (needsRuntime(args.command) && !RUNTIMES.includes(runtime)) {
682
+ return say(`런타임을 정할 수 없습니다. --runtime claude 또는 --runtime codex 를 붙이세요.\n${USAGE}`, 1);
531
683
  }
532
684
  try {
533
- const { ok, message } = run(args.command, runtime, args.flags);
534
- process.stdout.write(`${message}\n`);
535
- return ok ? 0 : 1;
685
+ const got = settingsOf(args, input);
686
+ if (got.error) return say(`${got.error}\n${USAGE}`, 1);
687
+ const { ok, message } = run(args.command, runtime, got.flags);
688
+ return say(message, ok ? 0 : 1);
536
689
  } catch (e) {
537
- process.stdout.write(`예기치 못한 오류로 중단했습니다: ${where(e.message)}\nstatus 로 상태를 확인하세요.\n`);
538
- return 2;
690
+ return say(`예기치 못한 오류로 중단했습니다: ${where(e.message)}\nstatus 로 상태를 확인하세요.`, 2);
539
691
  }
540
692
  }
541
693