@kaydash9999/banker-plugins 0.12.0 → 0.14.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,499 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tone-compact: keeps the compact Korean writing rules on or off across sessions.
4
+ *
5
+ * node tone-compact.mjs on --runtime claude|codex save the rules where that runtime loads them
6
+ * node tone-compact.mjs off --runtime claude|codex take them out again
7
+ * node tone-compact.mjs status say where they are on, for both runtimes
8
+ *
9
+ * The rules are the part of ../SKILL.md between the tone-compact:rules markers,
10
+ * so the text the skill applies in the session and the text later sessions load
11
+ * are the same.
12
+ *
13
+ * Claude Code loads every rules file under <config>/rules/ (<config> is
14
+ * $CLAUDE_CONFIG_DIR or ~/.claude) at session start and again after compaction.
15
+ * `on` writes banker-tone-compact.md there and `off` deletes it. A file at that
16
+ * name that does not start with banker's mark belongs to someone else and is
17
+ * left alone.
18
+ *
19
+ * Codex reads $CODEX_HOME/AGENTS.override.md when it holds any non-whitespace
20
+ * text and $CODEX_HOME/AGENTS.md otherwise (CODEX_HOME defaults to ~/.codex).
21
+ * `on` adds one marked block at the end of AGENTS.md, or of the override when
22
+ * the override has text of its own (a block alone there would hide AGENTS.md);
23
+ * `off` takes it out and gives back the bytes from before. The block sits inside
24
+ * OMX's USER:OMX:POLICY markers, the only part of a generated AGENTS.md that
25
+ * `omx setup --merge-agents` carries over; a plain `omx setup` with team mode on
26
+ * only refreshes the model table. `omx setup --force`, confirming its overwrite
27
+ * prompt, or a plain `omx setup` with team mode off replaces the file block and
28
+ * all; `on` then has to run again. Text other tools keep in these files is left
29
+ * alone, and the bytes from before each change are kept in
30
+ * <file>.tone-compact.bak. Markers count only as whole lines that open and close
31
+ * in turn; a marker quoted inside a sentence, or an end before a start, makes on
32
+ * and off refuse rather than guess where the block ends. So does a file that is
33
+ * not UTF-8 (CP949, UTF-16): written back as text it would lose its bytes.
34
+ *
35
+ * `off` never needs the rules, so it works even when SKILL.md cannot be read.
36
+ * Exit status 0 means done or already so; 1 means refused or a usage error, with
37
+ * nothing changed; 2 means an unexpected error stopped the command partway, so
38
+ * check `status`.
39
+ */
40
+ import {
41
+ accessSync,
42
+ constants,
43
+ lstatSync,
44
+ mkdirSync,
45
+ readFileSync,
46
+ realpathSync,
47
+ renameSync,
48
+ rmSync,
49
+ statSync,
50
+ writeFileSync,
51
+ } from "node:fs";
52
+ import { homedir } from "node:os";
53
+ import { dirname, join } from "node:path";
54
+ import { fileURLToPath } from "node:url";
55
+
56
+ const RULES_START = "<!-- tone-compact:rules:start -->";
57
+ const RULES_END = "<!-- tone-compact:rules:end -->";
58
+ // The Claude Code rules file starts with this mark; it is how off and on know the file is banker's.
59
+ const OWNER_MARK = "<!-- banker tone-compact";
60
+ const HEADER = `${OWNER_MARK}: banker 가 관리하는 파일입니다. 끄려면 tone-compact 에 off 를 실행하세요. -->`;
61
+ const OPEN = "<!-- banker:tone-compact:start -->";
62
+ const CLOSE = "<!-- banker:tone-compact:end -->";
63
+ // OMX keeps only these regions when `omx setup` rewrites a generated AGENTS.md.
64
+ const KEEP_OPEN = "<!-- USER:OMX:POLICY:START -->";
65
+ const KEEP_CLOSE = "<!-- USER:OMX:POLICY:END -->";
66
+ const MARKERS = [OPEN, CLOSE, KEEP_OPEN, KEEP_CLOSE, RULES_START, RULES_END];
67
+
68
+ const DEFAULTS = {
69
+ claudeDir: process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"),
70
+ codexDir: process.env.CODEX_HOME || join(homedir(), ".codex"),
71
+ skillFile: fileURLToPath(new URL("../SKILL.md", import.meta.url)),
72
+ };
73
+
74
+ const count = (text, needle) => text.split(needle).length - 1;
75
+ const lf = (text) => text.replace(/\r\n/g, "\n");
76
+ const eolOf = (text) => (text.includes("\r\n") ? "\r\n" : "\n");
77
+ const isDir = (path) => {
78
+ try {
79
+ return statSync(path).isDirectory();
80
+ } catch {
81
+ return false;
82
+ }
83
+ };
84
+ const isLink = (path) => {
85
+ try {
86
+ return lstatSync(path).isSymbolicLink();
87
+ } catch {
88
+ return false;
89
+ }
90
+ };
91
+
92
+ const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
93
+
94
+ // A file's bytes and text, null when there is no such file. text is null when
95
+ // the bytes are not UTF-8, which callers refuse: decoding them would replace
96
+ // them with U+FFFD for good. Any other read failure (permissions, a folder at
97
+ // that name) is not "missing" and propagates.
98
+ function readFile(path) {
99
+ let bytes;
100
+ try {
101
+ bytes = readFileSync(path);
102
+ } catch (e) {
103
+ if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
104
+ throw e;
105
+ }
106
+ try {
107
+ return { bytes, text: UTF8.decode(bytes) };
108
+ } catch {
109
+ return { bytes, text: null };
110
+ }
111
+ }
112
+
113
+ const done = (message) => ({ ok: true, message });
114
+ const refuse = (message) => ({ ok: false, message });
115
+
116
+ /** The rules: the text between the markers in SKILL.md, without the line breaks around it. */
117
+ export function rulesOf(skillText) {
118
+ const text = lf(skillText);
119
+ const start = text.indexOf(RULES_START);
120
+ const end = text.indexOf(RULES_END);
121
+ if (count(text, RULES_START) !== 1 || count(text, RULES_END) !== 1 || end < start) {
122
+ throw new Error("SKILL.md 에서 규칙 구간(tone-compact:rules 표시)을 찾지 못했습니다");
123
+ }
124
+ const rules = text.slice(start + RULES_START.length, end).replace(/^\n+|\n+$/g, "");
125
+ if (rules === "" || MARKERS.some((m) => rules.includes(m))) {
126
+ throw new Error("SKILL.md 의 규칙 구간이 비었거나 관리 표시를 담고 있습니다");
127
+ }
128
+ return rules;
129
+ }
130
+
131
+ const loadRules = (o) => rulesOf(readFileSync(o.skillFile, "utf8"));
132
+
133
+ // Replaces file through a fresh temp file ("wx") and a rename: a link planted at
134
+ // either name is replaced rather than written through, and a write that fails
135
+ // leaves the old file as it was.
136
+ function writeFresh(file, data, mode = 0o644) {
137
+ const tmp = `${file}.${process.pid}.tmp`;
138
+ rmSync(tmp, { force: true });
139
+ try {
140
+ writeFileSync(tmp, data, { flag: "wx", mode });
141
+ renameSync(tmp, file);
142
+ } catch (e) {
143
+ rmSync(tmp, { force: true });
144
+ throw e;
145
+ }
146
+ }
147
+
148
+ const denied = (e) => e.code === "EACCES" || e.code === "EPERM" || e.code === "EROFS";
149
+
150
+ // ---- Claude Code: one rules file that is banker's alone ----
151
+
152
+ const claudeFile = (o) => join(o.claudeDir, "rules", "banker-tone-compact.md");
153
+ const claudeText = (rules) => `${HEADER}\n${rules}\n`;
154
+
155
+ // "off", "foreign" (not banker's file), or, when rules are given, "on" or
156
+ // "stale" (an earlier version's rules); without rules a banker file is "ours".
157
+ function claudeState(file, rules) {
158
+ if (file === null) return "off";
159
+ if (file.text === null) return "foreign";
160
+ const body = lf(file.text.replace(/^\uFEFF/, ""));
161
+ if (!body.startsWith(OWNER_MARK)) return "foreign";
162
+ if (rules === undefined) return "ours";
163
+ return body === claudeText(rules) ? "on" : "stale";
164
+ }
165
+
166
+ function claudeOn(o) {
167
+ const file = claudeFile(o);
168
+ if (!isDir(o.claudeDir)) return refuse(`Claude Code 설정 폴더가 없습니다: ${o.claudeDir}`);
169
+ const rules = loadRules(o);
170
+ const state = claudeState(readFile(file), rules);
171
+ if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${file}`);
172
+ if (state === "on") return done(`Claude Code: 이미 켜져 있습니다 (변경 없음). ${file}`);
173
+ try {
174
+ mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
175
+ writeFresh(file, claudeText(rules));
176
+ } catch (e) {
177
+ if (denied(e)) return refuse(`쓸 권한이 없어 켜지 못했습니다 (${e.code}): ${file}`);
178
+ throw e;
179
+ }
180
+ return done(
181
+ state === "stale"
182
+ ? `Claude Code: 규칙을 이 버전으로 바꿨습니다. ${file}`
183
+ : `Claude Code: 켰습니다. 다음 세션부터 자동 적용됩니다. ${file}`,
184
+ );
185
+ }
186
+
187
+ function claudeOff(o) {
188
+ const file = claudeFile(o);
189
+ const state = claudeState(readFile(file));
190
+ if (state === "off") return done("Claude Code: 이미 꺼져 있습니다 (변경 없음).");
191
+ if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${file}`);
192
+ try {
193
+ rmSync(file);
194
+ } catch (e) {
195
+ if (denied(e)) return refuse(`지울 권한이 없어 끄지 못했습니다 (${e.code}): ${file}`);
196
+ throw e;
197
+ }
198
+ return done(`Claude Code: 껐습니다. 다음 세션부터 적용되지 않습니다. ${file}`);
199
+ }
200
+
201
+ // ---- Codex: one block in the global instructions file Codex reads ----
202
+
203
+ const lineBare = (line) => line.replace(/\r?\n$/, "");
204
+ // A line without its line break, and the first one without the byte order mark
205
+ // an editor may put in front of it.
206
+ const bareAt = (lines, i) => (i === 0 ? lineBare(lines[i]).replace(/^\uFEFF/, "") : lineBare(lines[i]));
207
+
208
+ // One block's place in the text: [from, to) runs from its first line (the keep
209
+ // marker when one wraps it directly) through the end of its last line; cut is
210
+ // from minus the line break `on` puts before the block, and blankBefore says
211
+ // whether that break makes a blank line of its own.
212
+ function spanOf(lines, offsets, first, last) {
213
+ const wrapped =
214
+ first > 0 &&
215
+ last + 1 < lines.length &&
216
+ bareAt(lines, first - 1) === KEEP_OPEN &&
217
+ bareAt(lines, last + 1) === KEEP_CLOSE;
218
+ const top = wrapped ? first - 1 : first;
219
+ const bottom = wrapped ? last + 1 : last;
220
+ const before = top > 0 ? lines[top - 1] : "";
221
+ const joint = before.endsWith("\r\n") ? 2 : before.endsWith("\n") ? 1 : 0;
222
+ const blankBefore = before !== "" && lineBare(before) === "";
223
+ return { cut: offsets[top] - joint, from: offsets[top], to: offsets[bottom] + lines[bottom].length, blankBefore };
224
+ }
225
+
226
+ /**
227
+ * Where the tone-compact blocks sit in text, or null when the markers are not
228
+ * whole lines that open and close in turn. on and off refuse null, so a marker
229
+ * quoted inside a sentence can never make them cut the user's own text.
230
+ */
231
+ export function blockSpans(text) {
232
+ const lines = text.split(/(?<=\n)/);
233
+ const offsets = [];
234
+ let at = 0;
235
+ for (const line of lines) {
236
+ offsets.push(at);
237
+ at += line.length;
238
+ }
239
+ const spans = [];
240
+ let open = -1;
241
+ for (let i = 0; i < lines.length; i++) {
242
+ const bare = bareAt(lines, i);
243
+ if (bare === OPEN && open === -1) open = i;
244
+ else if (bare === CLOSE && open !== -1) {
245
+ spans.push(spanOf(lines, offsets, open, i));
246
+ open = -1;
247
+ } else if (lines[i].includes(OPEN) || lines[i].includes(CLOSE)) return null;
248
+ }
249
+ return open === -1 ? spans : null;
250
+ }
251
+
252
+ // The text without its blocks: exactly what it was before `on` added them. The
253
+ // line break `on` put in front goes with the block when the block ends the file
254
+ // or has a blank line of its own in front; with other text after it, the break
255
+ // stays, so the lines on either side never run together.
256
+ function strip(text, spans) {
257
+ let out = "";
258
+ let at = 0;
259
+ for (const s of spans) {
260
+ const keepBreak = s.to < text.length && !s.blankBefore;
261
+ out += text.slice(at, keepBreak ? s.from : s.cut);
262
+ at = s.to;
263
+ }
264
+ return out + text.slice(at);
265
+ }
266
+
267
+ // Whether text holds anything but whitespace, the way Codex judges a file empty
268
+ // (Rust's trim, which unlike JavaScript's keeps a byte order mark).
269
+ const hasContent = (text) => /[^\t\n\v\f\r \u0085\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000]/.test(text);
270
+
271
+ // Both global instruction files with their bytes and text (null when absent;
272
+ // text null when not UTF-8) and blocks (null when the markers do not pair up).
273
+ // The block's place (target) is the override when the override has text of its
274
+ // own, AGENTS.md otherwise: an override holding only the block would hide
275
+ // AGENTS.md from Codex, which reads any override with non-whitespace text.
276
+ function codexFiles(o) {
277
+ const read = (name) => {
278
+ const path = join(o.codexDir, name);
279
+ const file = readFile(path);
280
+ if (file === null) return { path, bytes: null, text: null, spans: [] };
281
+ return { path, ...file, spans: file.text === null ? null : blockSpans(file.text) };
282
+ };
283
+ const override = read("AGENTS.override.md");
284
+ const main = read("AGENTS.md");
285
+ const ownContent =
286
+ binary(override) ||
287
+ (override.text !== null && hasContent(override.spans === null ? override.text : strip(override.text, override.spans)));
288
+ const target = ownContent ? override : main;
289
+ return { target, other: target === override ? main : override, all: [override, main] };
290
+ }
291
+
292
+ const blockFor = (rules, eol) => [KEEP_OPEN, OPEN, ...rules.split("\n"), CLOSE, KEEP_CLOSE, ""].join(eol);
293
+ const binary = (f) => f.bytes !== null && f.text === null;
294
+ const unpaired = (f) => f.spans === null;
295
+ const hasBlock = (f) => f.spans !== null && f.spans.length > 0;
296
+ const stripped = (f) => (f.text === null ? "" : strip(f.text, f.spans));
297
+ // A block's text compared without line-break style, a leading byte order mark
298
+ // or the final line break, which editors add and drop.
299
+ const normBlock = (text) => lf(text).replace(/^\uFEFF/, "").replace(/\n$/, "");
300
+ // The block exactly as `on` writes it, and only once.
301
+ const blockCurrent = (f, rules) =>
302
+ f.spans.length === 1 && normBlock(f.text.slice(f.spans[0].from, f.spans[0].to)) === normBlock(blockFor(rules, "\n"));
303
+
304
+ // The text with the block at the end, one line break between them; an empty
305
+ // file gets the block alone. strip() undoes exactly this.
306
+ function withBlock(text, rules) {
307
+ const eol = eolOf(text);
308
+ return text === "" ? blockFor(rules, eol) : text + eol + blockFor(rules, eol);
309
+ }
310
+
311
+ // "off", "on", "stale", "misplaced" (only in AGENTS.md while Codex reads the
312
+ // override, so not applied), "hidden" (alone in the override, applied but hiding
313
+ // AGENTS.md), "unpaired" or "notutf8". Without rules any block in its place
314
+ // counts as "on".
315
+ function codexState(files, rules) {
316
+ if (files.all.some(binary)) return "notutf8";
317
+ if (files.all.some(unpaired)) return "unpaired";
318
+ const inTarget = hasBlock(files.target);
319
+ const inOther = hasBlock(files.other);
320
+ if (!inTarget) return inOther ? (files.other === files.all[0] ? "hidden" : "misplaced") : "off";
321
+ if (rules === undefined) return "on";
322
+ return blockCurrent(files.target, rules) && !inOther ? "on" : "stale";
323
+ }
324
+
325
+ // Why these files cannot be written, or "" when they can. The folder counts
326
+ // too: the .bak is written through a temp file beside the file.
327
+ function writeBlocked(changes) {
328
+ for (const { file } of changes) {
329
+ for (const path of file.bytes === null ? [dirname(file.path)] : [file.path, dirname(file.path)]) {
330
+ try {
331
+ accessSync(path, constants.W_OK);
332
+ } catch (e) {
333
+ return `쓸 권한이 없어 그대로 두었습니다 (${e.code || e.message}): ${path}`;
334
+ }
335
+ }
336
+ }
337
+ return "";
338
+ }
339
+
340
+ // Writes each file in place, so a symlinked AGENTS.md stays a link and its
341
+ // target changes, after keeping the old bytes in a .bak only this account can
342
+ // read (the file may be a link into a private folder). A new file gets 0644
343
+ // whatever the umask allows more. A file left empty is removed, unless it is a
344
+ // link: Codex skips an empty file anyway.
345
+ function apply(changes) {
346
+ for (const { file, after } of changes) {
347
+ if (file.bytes !== null) writeFresh(`${file.path}.tone-compact.bak`, file.bytes, 0o600);
348
+ if (after === "" && !isLink(file.path)) rmSync(file.path, { force: true });
349
+ else writeFileSync(file.path, after, { mode: 0o644 });
350
+ }
351
+ }
352
+
353
+ const OMX_NOTE =
354
+ "참고: omx setup 이 AGENTS.md 를 새로 만들면(--force, 덮어쓰기 확인, team 모드를 끈 상태의 omx setup) 이 블록이 사라집니다. 그 뒤에는 on 을 다시 실행하세요. --merge-agents 와 기본 omx setup 은 블록을 남깁니다.";
355
+
356
+ const notUtf8Refusal = (files) => refuse(`UTF-8 이 아닌 파일이라 그대로 두었습니다: ${files.all.find(binary).path}`);
357
+
358
+ const unpairedRefusal = (files) =>
359
+ refuse(`tone-compact 표시의 짝이 맞지 않아 그대로 두었습니다: ${files.all.find(unpaired).path}`);
360
+
361
+ function codexOn(o) {
362
+ if (!isDir(o.codexDir)) return refuse(`Codex 설정 폴더가 없습니다: ${o.codexDir}`);
363
+ const rules = loadRules(o);
364
+ const files = codexFiles(o);
365
+ const state = codexState(files, rules);
366
+ if (state === "notutf8") return notUtf8Refusal(files);
367
+ if (state === "unpaired") return unpairedRefusal(files);
368
+ if (state === "on") return done(`Codex: 이미 켜져 있습니다 (변경 없음). ${files.target.path}`);
369
+ const changes = [{ file: files.target, after: withBlock(stripped(files.target), rules) }];
370
+ if (hasBlock(files.other)) changes.push({ file: files.other, after: stripped(files.other) });
371
+ const why = writeBlocked(changes);
372
+ if (why) return refuse(why);
373
+ apply(changes);
374
+ const moved = "블록을 맞는 파일로 옮겼습니다";
375
+ const what = { stale: "규칙을 이 버전으로 바꿨습니다", misplaced: moved, hidden: moved }[state];
376
+ const note = files.target === files.all[1] ? `\n${OMX_NOTE}` : "";
377
+ return done(`Codex: ${what ?? "켰습니다. 다음 세션부터 자동 적용됩니다"}. ${files.target.path}${note}`);
378
+ }
379
+
380
+ function codexOff(o) {
381
+ if (!isDir(o.codexDir)) return done(`Codex: 설정 폴더가 없어 끌 것이 없습니다 (변경 없음). ${o.codexDir}`);
382
+ const files = codexFiles(o);
383
+ const state = codexState(files);
384
+ if (state === "notutf8") return notUtf8Refusal(files);
385
+ if (state === "unpaired") return unpairedRefusal(files);
386
+ const changes = files.all.filter(hasBlock).map((f) => ({ file: f, after: stripped(f) }));
387
+ if (changes.length === 0) return done("Codex: 이미 꺼져 있습니다 (변경 없음).");
388
+ const why = writeBlocked(changes);
389
+ if (why) return refuse(why);
390
+ apply(changes);
391
+ return done(`Codex: 껐습니다. 다음 세션부터 적용되지 않습니다. ${changes.map((c) => c.file.path).join(", ")}`);
392
+ }
393
+
394
+ // ---- status ----
395
+
396
+ const STATE_TEXT = {
397
+ on: "켜짐",
398
+ ours: "켜짐 (규칙 버전 확인 불가)",
399
+ stale: "켜짐, 단 규칙이 이 버전과 다름 (on 으로 갱신)",
400
+ off: "꺼짐",
401
+ foreign: "꺼짐, 같은 이름의 다른 파일이 있음 (손대지 않음)",
402
+ misplaced: "꺼짐, 단 블록이 Codex 가 읽지 않는 AGENTS.md 에 있음 (on 으로 옮김)",
403
+ hidden: "켜짐, 단 AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐 (on 으로 옮김)",
404
+ notutf8: "UTF-8 이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
405
+ unpaired: "표시의 짝이 맞지 않음 (on, off 모두 거부. 남은 표시 줄 정리 필요)",
406
+ };
407
+
408
+ // One status line per runtime. Read failures are reported, not thrown: status
409
+ // is where an exit 2 sends the user to look.
410
+ function describe(name, look) {
411
+ try {
412
+ const { state, path } = look();
413
+ return `- ${name}: ${STATE_TEXT[state] ?? state} (${path})`;
414
+ } catch (e) {
415
+ return `- ${name}: 읽을 수 없음 (${e.code || e.message})`;
416
+ }
417
+ }
418
+
419
+ export function status(opts = {}) {
420
+ const o = { ...DEFAULTS, ...opts };
421
+ const lines = ["tone-compact 상태"];
422
+ let rules;
423
+ try {
424
+ rules = loadRules(o);
425
+ } catch (e) {
426
+ lines.push(`- 규칙 원문을 읽지 못해 버전은 비교하지 않음 (${e.code || e.message})`);
427
+ }
428
+ lines.push(
429
+ describe("Claude Code", () => {
430
+ if (!isDir(o.claudeDir)) return { state: "설정 폴더 없음", path: o.claudeDir };
431
+ return { state: claudeState(readFile(claudeFile(o)), rules), path: claudeFile(o) };
432
+ }),
433
+ describe("Codex", () => {
434
+ if (!isDir(o.codexDir)) return { state: "설정 폴더 없음", path: o.codexDir };
435
+ const files = codexFiles(o);
436
+ return { state: codexState(files, rules), path: files.target.path };
437
+ }),
438
+ );
439
+ return done(lines.join("\n"));
440
+ }
441
+
442
+ const ACTIONS = new Map([
443
+ ["claude", { on: claudeOn, off: claudeOff }],
444
+ ["codex", { on: codexOn, off: codexOff }],
445
+ ]);
446
+
447
+ export const turnOn = (runtime, opts = {}) => ACTIONS.get(runtime).on({ ...DEFAULTS, ...opts });
448
+ export const turnOff = (runtime, opts = {}) => ACTIONS.get(runtime).off({ ...DEFAULTS, ...opts });
449
+
450
+ // { command, runtime, extra } from the command line; --runtime takes "x" or "=x".
451
+ export function parseArgs(argv) {
452
+ const rest = [];
453
+ let runtime = null;
454
+ for (let i = 0; i < argv.length; i++) {
455
+ const a = argv[i];
456
+ if (a === "--runtime") runtime = argv[++i] ?? "";
457
+ else if (a.startsWith("--runtime=")) runtime = a.slice("--runtime=".length);
458
+ else rest.push(a);
459
+ }
460
+ return { command: (rest[0] ?? "status").toLowerCase(), runtime: runtime?.toLowerCase() ?? null, extra: rest.slice(1) };
461
+ }
462
+
463
+ const USAGE = "사용법: tone-compact on | off | status [--runtime claude|codex]";
464
+
465
+ // The runtime on and off act on: the flag, else Claude Code when it runs this.
466
+ const runtimeOf = (flag, env) => flag ?? (env.CLAUDECODE === "1" ? "claude" : null);
467
+
468
+ function main(argv, env = process.env) {
469
+ const { command, runtime: flag, extra } = parseArgs(argv);
470
+ if (extra.length > 0 || !["on", "off", "status"].includes(command) || (flag !== null && !ACTIONS.has(flag))) {
471
+ process.stdout.write(`${USAGE}\n`);
472
+ return 1;
473
+ }
474
+ const runtime = runtimeOf(flag, env);
475
+ if (command !== "status" && !ACTIONS.has(runtime)) {
476
+ process.stdout.write(`런타임을 정할 수 없습니다. --runtime claude 또는 --runtime codex 를 붙이세요.\n${USAGE}\n`);
477
+ return 1;
478
+ }
479
+ try {
480
+ const { ok, message } = command === "status" ? status() : (command === "on" ? turnOn : turnOff)(runtime);
481
+ process.stdout.write(`${message}\n`);
482
+ return ok ? 0 : 1;
483
+ } catch (e) {
484
+ process.stdout.write(`예기치 못한 오류로 중단했습니다: ${e.message}\nstatus 로 상태를 확인하세요.\n`);
485
+ return 2;
486
+ }
487
+ }
488
+
489
+ // Run as a command, also when reached through a symlinked path: Node resolves
490
+ // symlinks for import.meta.url but not for process.argv[1].
491
+ const invokedDirectly = () => {
492
+ try {
493
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
494
+ } catch {
495
+ return false;
496
+ }
497
+ };
498
+
499
+ if (invokedDirectly()) process.exitCode = main(process.argv.slice(2));