@kaydash9999/banker-plugins 0.17.0 → 0.18.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.
@@ -0,0 +1,552 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * anti-drm: keeps rules on or off across sessions that steer the agent away
4
+ * from the file extensions this PC's DRM encrypts, toward replacements the user
5
+ * chose. A file the DRM encrypted can no longer be read by the agent or by
6
+ * development tools.
7
+ *
8
+ * node anti-drm.mjs on --runtime claude|codex [--deny <list> --alt <list> [--tsv tab|comma]]
9
+ * node anti-drm.mjs off --runtime claude|codex
10
+ * node anti-drm.mjs status
11
+ * node anti-drm.mjs config --deny <list> --alt <list> [--tsv tab|comma]
12
+ *
13
+ * <list> is extensions split by commas or spaces, such as ".txt,.pdf".
14
+ *
15
+ * The settings are one JSON file both runtimes share, ~/.config/banker/anti-drm.json
16
+ * ($BANKER_ANTI_DRM_CONFIG names another): the denied extensions, the
17
+ * replacements, and whether a .tsv file splits its columns with tabs or commas.
18
+ * `off` keeps the file, so turning the rules on again asks nothing. .md and
19
+ * .json cannot be denied: the rules file and AGENTS.md are .md, the settings
20
+ * .json.
21
+ *
22
+ * The rules are the template in ../SKILL.md between the anti-drm:rules markers,
23
+ * filled from the settings. Where they go, and how they come out again byte for
24
+ * byte, is instruction-toggle.mjs beside this file, shared with tone-compact.
25
+ * `on` and `off` act on one runtime. Settings given to `on`, and every
26
+ * `config`, also refresh each runtime that holds the rules, since both runtimes
27
+ * read the same settings.
28
+ *
29
+ * Exit status 0 means done or already so; 1 means refused or a usage error
30
+ * (with settings given, also a runtime that could not be refreshed after they
31
+ * were saved); 2 means an unexpected error stopped the command partway, so
32
+ * check `status`.
33
+ */
34
+ import {
35
+ closeSync,
36
+ constants,
37
+ existsSync,
38
+ fstatSync,
39
+ lstatSync,
40
+ mkdirSync,
41
+ openSync,
42
+ readFileSync,
43
+ realpathSync,
44
+ statSync,
45
+ } from "node:fs";
46
+ import { homedir } from "node:os";
47
+ import { dirname, join } from "node:path";
48
+ import { fileURLToPath } from "node:url";
49
+ import {
50
+ KEEP_CLOSE,
51
+ KEEP_OPEN,
52
+ RUNTIMES,
53
+ denied,
54
+ hasMarker,
55
+ present,
56
+ stateLines,
57
+ toggleSpec,
58
+ turnOff as toggleOff,
59
+ turnOn as toggleOn,
60
+ where,
61
+ writeFresh,
62
+ } from "./instruction-toggle.mjs";
63
+
64
+ const RULES_START = "<!-- anti-drm:rules:start -->";
65
+ const RULES_END = "<!-- anti-drm:rules:end -->";
66
+ const TOGGLE = toggleSpec({
67
+ slug: "anti-drm",
68
+ staleText: "켜짐, 단 규칙이 지금 설정이나 이 버전과 다름 (on 으로 갱신)",
69
+ updatedText: "규칙을 지금 설정으로 바꿨습니다",
70
+ });
71
+ const MARKERS = [TOGGLE.open, TOGGLE.close, KEEP_OPEN, KEEP_CLOSE, RULES_START, RULES_END];
72
+ const PLACES = ["{{map}}", "{{alts}}"];
73
+ // anti-drm's own files: the rules file and AGENTS.md are .md, the settings .json.
74
+ const RESERVED = [".md", ".json"];
75
+
76
+ const DEFAULTS = {
77
+ claudeDir: process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"),
78
+ codexDir: process.env.CODEX_HOME || join(homedir(), ".codex"),
79
+ configFile: process.env.BANKER_ANTI_DRM_CONFIG || join(homedir(), ".config", "banker", "anti-drm.json"),
80
+ skillFile: fileURLToPath(new URL("../SKILL.md", import.meta.url)),
81
+ };
82
+
83
+ const BOM = String.fromCharCode(0xfeff);
84
+ 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
+ // A value from outside, as a message shows it. An extension is printable
90
+ // ASCII, so anything else becomes "?" (a line break or a direction mark cannot
91
+ // forge a line or reorder the text), at most 40 characters stay, and a value
92
+ // that is not a string shows only its type.
93
+ const shown = (value) => (typeof value === "string" ? value.replace(/[^ -~]/g, "?").slice(0, 40) : `(${typeof value})`);
94
+
95
+ // ---- settings ----
96
+
97
+ const EXT = /^\.[a-z0-9]{1,15}$/;
98
+ // At most this many extensions in each list: the rules load into every session.
99
+ const MAX_EXTS = 64;
100
+ // The largest settings file read; the settings hold two short lists.
101
+ const MAX_BYTES = 64 * 1024;
102
+
103
+ /** The words of an extension list: split by commas or spaces. */
104
+ export const splitList = (text) => String(text ?? "").split(/[\s,]+/).filter(Boolean);
105
+
106
+ // One extension as the settings keep it (lower case, a leading dot, then 1 to
107
+ // 15 letters or digits), or null when the word is not one.
108
+ function normalExt(word) {
109
+ if (typeof word !== "string") return null;
110
+ const lower = word.trim().toLowerCase();
111
+ const ext = lower.startsWith(".") ? lower : `.${lower}`;
112
+ return EXT.test(ext) ? ext : null;
113
+ }
114
+
115
+ // The extensions in words, normalized and each once, or why they are not a list.
116
+ function extList(words, what) {
117
+ if (!Array.isArray(words)) return { error: `${what}가 목록이 아닙니다` };
118
+ if (words.length === 0) return { error: `${what}가 비어 있습니다` };
119
+ if (words.length > MAX_EXTS) return { error: `${what}는 ${MAX_EXTS}개까지 쓸 수 있습니다` };
120
+ const list = new Set();
121
+ for (const word of words) {
122
+ const ext = normalExt(word);
123
+ if (ext === null) return { error: `${what}로 쓸 수 없는 값입니다: ${shown(word)}` };
124
+ list.add(ext);
125
+ }
126
+ return { list: [...list] };
127
+ }
128
+
129
+ /** Settings from their parts, normalized: { config } or { error }. */
130
+ export function checkConfig({ deny, alt, tsvDelimiter = "tab" }) {
131
+ const d = extList(deny, "금지 확장자");
132
+ if (d.error) return d;
133
+ const a = extList(alt, "대체 확장자");
134
+ if (a.error) return a;
135
+ const reserved = d.list.filter((x) => RESERVED.includes(x));
136
+ if (reserved.length > 0) return { error: `anti-drm 이 쓰는 형식이라 금지할 수 없습니다: ${reserved.join(" ")}` };
137
+ const both = d.list.filter((x) => a.list.includes(x));
138
+ if (both.length > 0) return { error: `금지 확장자와 대체 확장자에 함께 있습니다: ${both.join(" ")}` };
139
+ if (tsvDelimiter !== "tab" && tsvDelimiter !== "comma") {
140
+ return { error: `TSV 구분자는 tab 이나 comma 여야 합니다: ${shown(tsvDelimiter)}` };
141
+ }
142
+ return { config: { version: 1, deny: d.list, alt: a.list, tsvDelimiter } };
143
+ }
144
+
145
+ /**
146
+ * Whether the owner of a settings file may steer this account's rules: this
147
+ * account, or root when only root can write the file (settings IT deployed).
148
+ */
149
+ export const ownerTrusted = (st, uid) => st.uid === uid || (st.uid === 0 && (st.mode & 0o022) === 0);
150
+ const ownedByOther = (st) => typeof process.getuid === "function" && !ownerTrusted(st, process.getuid());
151
+
152
+ // What opening the settings file can fail with, as the status line says it.
153
+ const OPEN_ERRORS = new Map([
154
+ ["EISDIR", "일반 파일이 아닙니다"],
155
+ ["ELOOP", "일반 파일이 아닙니다"],
156
+ ["EACCES", "읽을 권한이 없습니다"],
157
+ ["EPERM", "읽을 권한이 없습니다"],
158
+ ]);
159
+
160
+ // The settings file's text: null when there is none, { error } when it is not a
161
+ // regular file of at most 64 KiB that a trusted owner holds. Opening without
162
+ // blocking keeps a FIFO from hanging the command, and another account's file
163
+ // cannot steer this account's rules.
164
+ function settingsText(file) {
165
+ let fd;
166
+ try {
167
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
168
+ } catch (e) {
169
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
170
+ if (OPEN_ERRORS.has(e.code)) return { error: OPEN_ERRORS.get(e.code) };
171
+ throw e;
172
+ }
173
+ try {
174
+ const st = fstatSync(fd);
175
+ if (!st.isFile()) return { error: "일반 파일이 아닙니다" };
176
+ if (st.size > MAX_BYTES) return { error: "64KiB 보다 큽니다" };
177
+ if (ownedByOther(st)) return { error: "다른 사용자 소유입니다" };
178
+ return { text: readFileSync(fd, "utf8") };
179
+ } finally {
180
+ closeSync(fd);
181
+ }
182
+ }
183
+
184
+ /** The settings file: null when there is none, else { config } or { error }. */
185
+ export function readConfig(file) {
186
+ const got = settingsText(file);
187
+ if (got === null || got.error) return got;
188
+ const { text } = got;
189
+ let raw;
190
+ try {
191
+ raw = JSON.parse(text.startsWith(BOM) ? text.slice(BOM.length) : text);
192
+ } catch {
193
+ return { error: "JSON 이 아닙니다" };
194
+ }
195
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return { error: "JSON 객체가 아닙니다" };
196
+ return checkConfig({ deny: raw.deny, alt: raw.alt, tsvDelimiter: raw.tsvDelimiter ?? "tab" });
197
+ }
198
+
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.
202
+ function pathKind(file) {
203
+ let st;
204
+ try {
205
+ st = lstatSync(file);
206
+ } catch (e) {
207
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return "none";
208
+ throw e;
209
+ }
210
+ if (st.isFile()) return "file";
211
+ if (!st.isSymbolicLink()) return "other";
212
+ try {
213
+ return statSync(file).isFile() ? "file" : "none";
214
+ } catch {
215
+ return "none";
216
+ }
217
+ }
218
+
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) {
222
+ let fd;
223
+ try {
224
+ fd = openSync(file, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
225
+ } catch (e) {
226
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return { bytes: null };
227
+ if (denied(e)) return { refusal: "읽을 수 없는 파일이라 설정을 저장하지 않았습니다" };
228
+ throw e;
229
+ }
230
+ 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) };
235
+ } finally {
236
+ closeSync(fd);
237
+ }
238
+ }
239
+
240
+ // The keys anti-drm writes; a file with any other key holds something of the user's.
241
+ const OWN_KEYS = ["version", "deny", "alt", "tsvDelimiter"];
242
+
243
+ // Whether bytes are settings anti-drm wrote (or could have): nothing is lost when they go.
244
+ function ownSettings(bytes) {
245
+ try {
246
+ const text = bytes.toString("utf8");
247
+ const raw = JSON.parse(text.startsWith(BOM) ? text.slice(BOM.length) : text);
248
+ const plain = Boolean(raw) && typeof raw === "object" && !Array.isArray(raw);
249
+ return plain && Object.keys(raw).every((k) => OWN_KEYS.includes(k)) && Boolean(checkConfig(raw).config);
250
+ } catch {
251
+ return false;
252
+ }
253
+ }
254
+
255
+ // Whether anything, a dangling link included, already sits at path.
256
+ function taken(path) {
257
+ try {
258
+ lstatSync(path);
259
+ return true;
260
+ } catch {
261
+ return false;
262
+ }
263
+ }
264
+
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 }.
267
+ function priorSettings(file) {
268
+ const kind = pathKind(file);
269
+ if (kind === "none") return { keep: null };
270
+ if (kind === "other") return { refusal: "일반 파일이 아니라 설정을 저장하지 않았습니다" };
271
+ const got = priorBytes(file);
272
+ if (got.refusal) return got;
273
+ return { keep: got.bytes === null || ownSettings(got.bytes) ? null : got.bytes };
274
+ }
275
+
276
+ /**
277
+ * Saves the settings: the folder 0755, the file 0644 through a temp file and a
278
+ * rename. keep, when given, is a file anti-drm did not write; its bytes go to
279
+ * <file>.bak, readable by this account only, once: a .bak already there is the
280
+ * first such file and stays.
281
+ */
282
+ export function writeConfig(file, config, keep = null) {
283
+ mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
284
+ if (keep !== null && !taken(`${file}.bak`)) writeFresh(`${file}.bak`, keep, 0o600);
285
+ writeFresh(file, `${JSON.stringify(config, null, 2)}\n`, 0o644);
286
+ }
287
+
288
+ // ---- rules ----
289
+
290
+ /** The rules template: the text between the markers in SKILL.md, each placeholder in it once. */
291
+ export function templateOf(skillText) {
292
+ const text = lf(skillText);
293
+ const start = text.indexOf(RULES_START);
294
+ const end = text.indexOf(RULES_END);
295
+ if (count(text, RULES_START) !== 1 || count(text, RULES_END) !== 1 || end < start) {
296
+ throw new Error("SKILL.md 에서 규칙 틀(anti-drm:rules 표시)을 찾지 못했습니다");
297
+ }
298
+ const template = text.slice(start + RULES_START.length, end).replace(/^\n+|\n+$/g, "");
299
+ if (PLACES.some((p) => count(template, p) !== 1) || MARKERS.some((m) => template.includes(m)) || hasMarker(template)) {
300
+ throw new Error("SKILL.md 의 규칙 틀에 자리 표시가 한 번씩 있지 않거나 관리 표시가 들어 있습니다");
301
+ }
302
+ return template;
303
+ }
304
+
305
+ const loadTemplate = (o) => templateOf(readFileSync(o.skillFile, "utf8"));
306
+
307
+ // Where each denied extension goes: the first of `order` the settings allow,
308
+ // else the first replacement in the settings.
309
+ const PREFER = [
310
+ { exts: [".txt", ".doc", ".docx", ".hwp", ".hwpx"], order: [".md", ".html"] },
311
+ { exts: [".pdf", ".ppt", ".pptx"], order: [".html", ".md"] },
312
+ { exts: [".xls", ".xlsx", ".csv"], order: [".tsv", ".md", ".html"] },
313
+ ];
314
+ const OTHER_ORDER = [".md", ".html", ".tsv"];
315
+
316
+ /** The replacement for one denied extension. */
317
+ export function replacementFor(ext, alt) {
318
+ const order = PREFER.find((p) => p.exts.includes(ext))?.order ?? OTHER_ORDER;
319
+ return order.find((x) => alt.includes(x)) ?? alt[0];
320
+ }
321
+
322
+ // What a replacement is for, as the rules say it.
323
+ function useOf(ext, config) {
324
+ if (ext === ".md") return config.alt.includes(".tsv") ? "글과 메모" : "글과 메모. 표 데이터는 마크다운 표";
325
+ if (ext === ".html") return "서식 문서와 발표 자료";
326
+ if (ext !== ".tsv") return "사용자가 정한 대체 확장자";
327
+ return config.tsvDelimiter === "comma"
328
+ ? "표 데이터. CSV 내용을 그대로 쓰고 확장자만 .tsv. 읽고 쓰는 코드에 구분자 쉼표를 명시"
329
+ : "표 데이터. 탭으로 구분하고 첫 줄은 머리글";
330
+ }
331
+
332
+ const table = (head, rows) =>
333
+ [`| ${head[0]} | ${head[1]} |`, "|---|---|", ...rows.map(([a, b]) => `| ${a} | ${b} |`)].join("\n");
334
+ const code = (ext) => `\`${ext}\``;
335
+
336
+ /** The rules: the template filled from the settings. */
337
+ export function renderRules(template, config) {
338
+ const map = table(["금지 확장자", "대신 쓸 확장자"], config.deny.map((d) => [code(d), code(replacementFor(d, config.alt))]));
339
+ const alts = table(["확장자", "쓰는 곳"], config.alt.map((a) => [code(a), useOf(a, config)]));
340
+ return template.replace("{{map}}", () => map).replace("{{alts}}", () => alts);
341
+ }
342
+
343
+ // ---- commands ----
344
+
345
+ const USAGE =
346
+ "사용법: anti-drm on | off | status | config [--runtime claude|codex] [--deny <확장자 목록> --alt <확장자 목록> [--tsv tab|comma]]";
347
+
348
+ // The settings as `status` shows them.
349
+ function configLines(o, found) {
350
+ if (found === null) return ["- 설정: 없음 (on 이나 config 로 만듦)"];
351
+ if (found.error) return [`- 설정: 올바르지 않음 (${found.error}): ${where(o.configFile)}`];
352
+ const { deny, alt, tsvDelimiter } = found.config;
353
+ const lines = [
354
+ `- 설정: ${where(o.configFile)}`,
355
+ ` - 금지 확장자 ${deny.length}개: ${deny.join(" ")}`,
356
+ ` - 대체 확장자 ${alt.length}개: ${alt.join(" ")}`,
357
+ ];
358
+ if (alt.includes(".tsv")) lines.push(` - TSV 구분자: ${tsvDelimiter === "comma" ? "쉼표" : "탭"}`);
359
+ return lines;
360
+ }
361
+
362
+ // The saved settings, or why they cannot be read, for status.
363
+ function foundConfig(o) {
364
+ try {
365
+ return readConfig(o.configFile);
366
+ } catch (e) {
367
+ return { error: `읽을 수 없음 (${where(e.code || e.message)})` };
368
+ }
369
+ }
370
+
371
+ /** The settings and both runtimes' state. */
372
+ export function status(opts = {}) {
373
+ const o = { ...DEFAULTS, ...opts };
374
+ const found = foundConfig(o);
375
+ const lines = ["anti-drm 상태", ...configLines(o, found)];
376
+ let rules;
377
+ if (found?.config) {
378
+ try {
379
+ rules = renderRules(loadTemplate(o), found.config);
380
+ } catch (e) {
381
+ lines.push(`- 규칙 틀을 읽지 못해 규칙은 비교하지 않음 (${where(e.code || e.message)})`);
382
+ }
383
+ }
384
+ lines.push(...stateLines(TOGGLE, o, rules));
385
+ return done(lines.join("\n"));
386
+ }
387
+
388
+ // Saves the settings, as a result to report.
389
+ function save(o, config) {
390
+ try {
391
+ const prior = priorSettings(o.configFile);
392
+ if (prior.refusal) return refuse(`${prior.refusal}: ${where(o.configFile)}`);
393
+ writeConfig(o.configFile, config, prior.keep);
394
+ } catch (e) {
395
+ if (denied(e)) return refuse(`쓸 권한이 없어 설정을 저장하지 못했습니다 (${e.code}): ${where(o.configFile)}`);
396
+ throw e;
397
+ }
398
+ return done(`설정을 저장했습니다: ${where(o.configFile)}`);
399
+ }
400
+
401
+ // New rules in each runtime that holds the rules, but `skip`. A runtime that
402
+ // refuses, or whose files cannot be read, is reported, not thrown: the
403
+ // settings are saved by now.
404
+ function refresh(o, rules, skip) {
405
+ const results = [];
406
+ for (const runtime of RUNTIMES.filter((r) => r !== skip)) {
407
+ try {
408
+ if (present(TOGGLE, runtime, o)) results.push(toggleOn(TOGGLE, runtime, o, () => rules));
409
+ } catch (e) {
410
+ results.push(refuse(`${RUNTIME_NAME[runtime]}: 규칙을 갱신하지 못했습니다 (${where(e.code || e.message)})`));
411
+ }
412
+ }
413
+ return results;
414
+ }
415
+
416
+ // The settings from --deny, --alt and --tsv: { config }, { error }, or { usage } when a list is missing.
417
+ function configFromFlags(flags) {
418
+ 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 });
421
+ }
422
+
423
+ const settingsGiven = (flags) => ["deny", "alt", "tsv"].some((k) => flags[k] !== undefined);
424
+
425
+ // Saves settings given on the command line and refreshes the runtimes that
426
+ // hold the rules, but `skip`: { rules, lines, ok, refreshed } or { refusal }.
427
+ function applySettings(o, flags, skip) {
428
+ const got = configFromFlags(flags);
429
+ if (got.usage) return { refusal: refuse(USAGE) };
430
+ if (got.error) return { refusal: refuse(`설정을 저장하지 않았습니다: ${got.error}`) };
431
+ const rules = renderRules(loadTemplate(o), got.config);
432
+ const saved = save(o, got.config);
433
+ if (!saved.ok) return { refusal: saved };
434
+ const results = refresh(o, rules, skip);
435
+ return { rules, lines: [saved.message, ...results.map((r) => r.message)], ok: results.every((r) => r.ok), refreshed: results.length };
436
+ }
437
+
438
+ // The rules from the saved settings: { rules, lines, ok } or { refusal }.
439
+ function savedRules(o) {
440
+ const found = readConfig(o.configFile);
441
+ if (found === null) return { refusal: refuse("설정이 없습니다. 금지 확장자와 대체 확장자를 --deny, --alt 로 함께 주세요.") };
442
+ if (found.error) {
443
+ return { refusal: refuse(`설정 파일이 올바르지 않습니다 (${found.error}): ${where(o.configFile)}. config 로 다시 만드세요.`) };
444
+ }
445
+ return { rules: renderRules(loadTemplate(o), found.config), lines: [], ok: true };
446
+ }
447
+
448
+ function cmdOn(o, runtime, flags) {
449
+ const s = settingsGiven(flags) ? applySettings(o, flags, runtime) : savedRules(o);
450
+ if (s.refusal) return s.refusal;
451
+ const result = toggleOn(TOGGLE, runtime, o, () => s.rules);
452
+ const lines = [...s.lines, result.message];
453
+ if (result.ok) lines.push("", "적용할 규칙:", s.rules);
454
+ return { ok: s.ok && result.ok, message: lines.join("\n") };
455
+ }
456
+
457
+ function cmdOff(o, runtime) {
458
+ const result = toggleOff(TOGGLE, runtime, o);
459
+ if (!result.ok || !existsSync(o.configFile)) return result;
460
+ return done(`${result.message}\n설정은 남겨 두었습니다. 다시 켜면 그대로 씁니다: ${where(o.configFile)}`);
461
+ }
462
+
463
+ function cmdConfig(o, flags) {
464
+ const s = applySettings(o, flags, null);
465
+ if (s.refusal) return s.refusal;
466
+ const lines = [...s.lines];
467
+ if (s.refreshed === 0) lines.push("켜진 런타임이 없어 규칙은 바꾸지 않았습니다. 켜려면 on 을 실행하세요.");
468
+ else lines.push("", "적용할 규칙:", s.rules);
469
+ return { ok: s.ok, message: lines.join("\n") };
470
+ }
471
+
472
+ /** `on` for one runtime; flags are --deny, --alt and --tsv as the command line gives them. */
473
+ export const turnOn = (runtime, opts = {}, flags = {}) => cmdOn({ ...DEFAULTS, ...opts }, runtime, flags);
474
+ /** `off` for one runtime. */
475
+ export const turnOff = (runtime, opts = {}) => cmdOff({ ...DEFAULTS, ...opts }, runtime);
476
+ /** `config`: saves the settings and refreshes the runtimes that hold the rules. */
477
+ export const configure = (opts = {}, flags = {}) => cmdConfig({ ...DEFAULTS, ...opts }, flags);
478
+
479
+ // ---- command line ----
480
+
481
+ const FLAG_NAMES = ["runtime", "deny", "alt", "tsv"];
482
+ const COMMANDS = ["on", "off", "status", "config"];
483
+
484
+ /** { command, runtime, flags, extra } from the command line; each flag takes "x" or "=x". */
485
+ export function parseArgs(argv) {
486
+ const rest = [];
487
+ const flags = {};
488
+ for (let i = 0; i < argv.length; i++) {
489
+ const m = /^--([a-z]+)(?:=(.*))?$/.exec(argv[i]);
490
+ if (m && FLAG_NAMES.includes(m[1])) flags[m[1]] = m[2] ?? argv[++i] ?? "";
491
+ else rest.push(argv[i]);
492
+ }
493
+ const { runtime, ...settings } = flags;
494
+ return { command: (rest[0] ?? "status").toLowerCase(), runtime: runtime?.toLowerCase() ?? null, flags: settings, extra: rest.slice(1) };
495
+ }
496
+
497
+ // Settings flags go only with on and config, and config needs both lists.
498
+ function settingsUsage(command, flags) {
499
+ if (command === "config") return flags.deny === undefined || flags.alt === undefined ? USAGE : "";
500
+ return command === "on" || Object.keys(flags).length === 0 ? "" : USAGE;
501
+ }
502
+
503
+ // Why the command line cannot run as given, or "" when it can.
504
+ function usageError({ command, runtime, flags, extra }) {
505
+ if (extra.length > 0 || !COMMANDS.includes(command)) return USAGE;
506
+ if (runtime !== null && !RUNTIMES.includes(runtime)) return USAGE;
507
+ return settingsUsage(command, flags);
508
+ }
509
+
510
+ // The runtime on and off act on: the flag, else Claude Code when it runs this.
511
+ const runtimeOf = (flag, env) => flag ?? (env.CLAUDECODE === "1" ? "claude" : null);
512
+
513
+ function run(command, runtime, flags) {
514
+ const o = { ...DEFAULTS };
515
+ if (command === "status") return status(o);
516
+ if (command === "config") return cmdConfig(o, flags);
517
+ return command === "on" ? cmdOn(o, runtime, flags) : cmdOff(o, runtime);
518
+ }
519
+
520
+ function main(argv, env = process.env) {
521
+ const args = parseArgs(argv);
522
+ const why = usageError(args);
523
+ if (why) {
524
+ process.stdout.write(`${why}\n`);
525
+ return 1;
526
+ }
527
+ 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;
531
+ }
532
+ try {
533
+ const { ok, message } = run(args.command, runtime, args.flags);
534
+ process.stdout.write(`${message}\n`);
535
+ return ok ? 0 : 1;
536
+ } catch (e) {
537
+ process.stdout.write(`예기치 못한 오류로 중단했습니다: ${where(e.message)}\nstatus 로 상태를 확인하세요.\n`);
538
+ return 2;
539
+ }
540
+ }
541
+
542
+ // Run as a command, also when reached through a symlinked path: Node resolves
543
+ // symlinks for import.meta.url but not for process.argv[1].
544
+ const invokedDirectly = () => {
545
+ try {
546
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
547
+ } catch {
548
+ return false;
549
+ }
550
+ };
551
+
552
+ if (invokedDirectly()) process.exitCode = main(process.argv.slice(2));