@kaydash9999/banker-plugins 0.13.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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +35 -0
- package/README.md +30 -9
- package/codex/manifest.json +7 -4
- package/codex/transform-matrix.md +14 -2
- package/commands/front-qa.md +6 -3
- package/package.json +1 -1
- package/skills/all-in-one/SKILL.md +160 -55
- package/skills/compact-copy/SKILL.md +14 -13
- package/skills/curation/SKILL.md +36 -3
- package/skills/graceful_pause/SKILL.md +95 -0
- package/skills/omc-reference/SKILL.md +67 -32
- package/skills/payload-mon/SKILL.md +66 -0
- package/skills/payload-mon/scripts/payload-mon.mjs +377 -0
- package/skills/payload-mon/scripts/payload-size.mjs +238 -0
- package/skills/ready-compact/SKILL.md +114 -67
- package/skills/refresh-readme/SKILL.md +2 -2
- package/skills/rfp-author/SKILL.md +2 -2
- package/skills/setup-omc/SKILL.md +10 -3
- package/skills/setup-omc-hud/SKILL.md +11 -0
- package/skills/smart-compact/SKILL.md +10 -9
- package/skills/tone-compact/SKILL.md +203 -0
- package/skills/tone-compact/scripts/tone-compact.mjs +499 -0
- package/skills/ultra-init/SKILL.md +53 -42
|
@@ -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));
|