@kaydash9999/banker-plugins 0.17.1 → 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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +20 -0
- package/README.md +10 -3
- package/codex/manifest.json +2 -1
- package/codex/transform-matrix.md +4 -2
- package/commands/front-qa.md +3 -3
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +1 -1
- package/skills/3d-intro-build/references/assemble.mjs +3 -3
- package/skills/3d-intro-setup/SKILL.md +3 -3
- package/skills/anti-drm/SKILL.md +230 -0
- package/skills/anti-drm/scripts/anti-drm.mjs +552 -0
- package/skills/anti-drm/scripts/instruction-toggle.mjs +527 -0
- package/skills/lineage/SKILL.md +3 -3
- package/skills/lineage/lineage.py +16 -7
- package/skills/omc-reference/SKILL.md +1 -1
- package/skills/ralph-qa/SKILL.md +19 -19
- package/skills/tone-compact/SKILL.md +1 -1
- package/skills/tone-compact/scripts/instruction-toggle.mjs +527 -0
- package/skills/tone-compact/scripts/tone-compact.mjs +46 -394
- package/skills/ultra-chain/SKILL.md +7 -6
|
@@ -0,0 +1,527 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* instruction-toggle: puts one set of agent instructions where Claude Code and
|
|
3
|
+
* Codex load them at every session start, and takes it out again.
|
|
4
|
+
*
|
|
5
|
+
* banker's tone-compact and anti-drm share this module. Each skill folder holds
|
|
6
|
+
* a byte-identical copy, since Codex installs every skill folder on its own and
|
|
7
|
+
* one skill cannot import from another. tone-compact's copy is the source:
|
|
8
|
+
* `node scripts/sync-adapter.js` mirrors it, and the smoke test and anti-drm's
|
|
9
|
+
* tests compare the two.
|
|
10
|
+
*
|
|
11
|
+
* Claude Code loads every rules file under <config>/rules/ (<config> is
|
|
12
|
+
* $CLAUDE_CONFIG_DIR or ~/.claude) at session start and again after compaction.
|
|
13
|
+
* turnOn writes banker-<slug>.md there and turnOff deletes it. A file at that
|
|
14
|
+
* name that does not start with banker's mark belongs to someone else and is
|
|
15
|
+
* left alone.
|
|
16
|
+
*
|
|
17
|
+
* Codex reads $CODEX_HOME/AGENTS.override.md when it holds any non-whitespace
|
|
18
|
+
* text and $CODEX_HOME/AGENTS.md otherwise (CODEX_HOME defaults to ~/.codex).
|
|
19
|
+
* turnOn adds one marked block at the end of AGENTS.md, or of the override when
|
|
20
|
+
* the override has text of its own. No banker block counts as the override's
|
|
21
|
+
* own text: banker's blocks alone there would hide AGENTS.md. When other
|
|
22
|
+
* banker blocks alone fill the override, Codex reads only them; turnOn and the
|
|
23
|
+
* status line say so, and those skills' turnOn moves them out. turnOff takes
|
|
24
|
+
* the block out and gives back the bytes from before.
|
|
25
|
+
*
|
|
26
|
+
* The block sits inside OMX's USER:OMX:POLICY markers, the only part of a
|
|
27
|
+
* generated AGENTS.md that `omx setup --merge-agents` carries over; a plain
|
|
28
|
+
* `omx setup` with team mode on only refreshes the model table. `omx setup
|
|
29
|
+
* --force`, confirming its overwrite prompt, or a plain `omx setup` with team
|
|
30
|
+
* mode off replaces the file block and all; turnOn then has to run again. Text
|
|
31
|
+
* other tools keep in these files is left alone, and the bytes from before each
|
|
32
|
+
* change are kept in <file>.<slug>.bak. Markers count only as whole lines that
|
|
33
|
+
* open and close in turn; a marker quoted inside a sentence, or an end before a
|
|
34
|
+
* start, makes turnOn and turnOff refuse rather than guess where the block ends.
|
|
35
|
+
* So does a file that is not UTF-8 (CP949, UTF-16): written back as text it
|
|
36
|
+
* would lose its bytes.
|
|
37
|
+
*
|
|
38
|
+
* Results are { ok, message }; ok false means refused with nothing changed.
|
|
39
|
+
* Anything else that goes wrong throws.
|
|
40
|
+
*/
|
|
41
|
+
import {
|
|
42
|
+
accessSync,
|
|
43
|
+
constants,
|
|
44
|
+
lstatSync,
|
|
45
|
+
mkdirSync,
|
|
46
|
+
readFileSync,
|
|
47
|
+
renameSync,
|
|
48
|
+
rmSync,
|
|
49
|
+
statSync,
|
|
50
|
+
writeFileSync,
|
|
51
|
+
} from "node:fs";
|
|
52
|
+
import { dirname, join } from "node:path";
|
|
53
|
+
|
|
54
|
+
/** The banker skills that keep a block in Codex's instructions files. */
|
|
55
|
+
export const BANKER_SLUGS = ["tone-compact", "anti-drm"];
|
|
56
|
+
export const RUNTIMES = ["claude", "codex"];
|
|
57
|
+
// OMX keeps only these regions when `omx setup` rewrites a generated AGENTS.md.
|
|
58
|
+
export const KEEP_OPEN = "<!-- USER:OMX:POLICY:START -->";
|
|
59
|
+
export const KEEP_CLOSE = "<!-- USER:OMX:POLICY:END -->";
|
|
60
|
+
|
|
61
|
+
// The byte order mark an editor may put in front of a file.
|
|
62
|
+
const BOM = String.fromCharCode(0xfeff);
|
|
63
|
+
const noBom = (text) => (text.startsWith(BOM) ? text.slice(BOM.length) : text);
|
|
64
|
+
const lf = (text) => text.replace(/\r\n/g, "\n");
|
|
65
|
+
const eolOf = (text) => (text.includes("\r\n") ? "\r\n" : "\n");
|
|
66
|
+
const isDir = (path) => {
|
|
67
|
+
try {
|
|
68
|
+
return statSync(path).isDirectory();
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
const isLink = (path) => {
|
|
74
|
+
try {
|
|
75
|
+
return lstatSync(path).isSymbolicLink();
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
82
|
+
|
|
83
|
+
// A file's bytes and text, null when there is no such file. text is null when
|
|
84
|
+
// the bytes are not UTF-8, which callers refuse: decoding them would replace
|
|
85
|
+
// them with U+FFFD for good. Any other read failure (permissions, a folder at
|
|
86
|
+
// that name) is not "missing" and propagates.
|
|
87
|
+
function readFile(path) {
|
|
88
|
+
let bytes;
|
|
89
|
+
try {
|
|
90
|
+
bytes = readFileSync(path);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
if (e.code === "ENOENT" || e.code === "ENOTDIR") return null;
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
return { bytes, text: UTF8.decode(bytes) };
|
|
97
|
+
} catch {
|
|
98
|
+
return { bytes, text: null };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const done = (message) => ({ ok: true, message });
|
|
103
|
+
const refuse = (message) => ({ ok: false, message });
|
|
104
|
+
|
|
105
|
+
/** A skill's marks: the first line of its rules file, and the lines around its Codex block. */
|
|
106
|
+
export function markersOf(slug) {
|
|
107
|
+
return { ownerMark: `<!-- banker ${slug}`, open: `<!-- banker:${slug}:start -->`, close: `<!-- banker:${slug}:end -->` };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What one skill's toggle needs: its slug and marks, the first line of its
|
|
112
|
+
* rules file, and two texts it words its own way: the status of rules that
|
|
113
|
+
* differ from the ones given (staleText), and what turnOn says when it replaced
|
|
114
|
+
* them (updatedText).
|
|
115
|
+
*/
|
|
116
|
+
export function toggleSpec({ slug, staleText, updatedText }) {
|
|
117
|
+
const marks = markersOf(slug);
|
|
118
|
+
const header = `${marks.ownerMark}: banker 가 관리하는 파일입니다. 끄려면 ${slug} 에 off 를 실행하세요. -->`;
|
|
119
|
+
return { slug, ...marks, header, staleText, updatedText };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Replaces file through a fresh temp file ("wx") and a rename: a link planted
|
|
124
|
+
* at either name is replaced rather than written through, and a write that
|
|
125
|
+
* fails leaves the old file as it was.
|
|
126
|
+
*/
|
|
127
|
+
export function writeFresh(file, data, mode = 0o644) {
|
|
128
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
129
|
+
rmSync(tmp, { force: true });
|
|
130
|
+
try {
|
|
131
|
+
writeFileSync(tmp, data, { flag: "wx", mode });
|
|
132
|
+
renameSync(tmp, file);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
rmSync(tmp, { force: true });
|
|
135
|
+
throw e;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether an error is about permissions or a read-only file system. */
|
|
140
|
+
export const denied = (e) => e.code === "EACCES" || e.code === "EPERM" || e.code === "EROFS";
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A path or an error text as a message shows it. Control, format, line and
|
|
144
|
+
* paragraph separator, private-use, unassigned and variation selector
|
|
145
|
+
* characters become "?": a path from the environment cannot start a line of
|
|
146
|
+
* its own, reorder the text around it, or hide text after a visible letter.
|
|
147
|
+
*/
|
|
148
|
+
export const where = (value) =>
|
|
149
|
+
String(value).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\p{Cn}\p{Variation_Selector}]/gu, "?");
|
|
150
|
+
|
|
151
|
+
// ---- Claude Code: one rules file that is banker's alone ----
|
|
152
|
+
|
|
153
|
+
const claudeFile = (t, o) => join(o.claudeDir, "rules", `banker-${t.slug}.md`);
|
|
154
|
+
const claudeText = (t, rules) => `${t.header}\n${rules}\n`;
|
|
155
|
+
|
|
156
|
+
// "off", "foreign" (not banker's file), or, when rules are given, "on" or
|
|
157
|
+
// "stale" (other rules); without rules a banker file is "ours".
|
|
158
|
+
function claudeState(t, file, rules) {
|
|
159
|
+
if (file === null) return "off";
|
|
160
|
+
if (file.text === null) return "foreign";
|
|
161
|
+
const body = lf(noBom(file.text));
|
|
162
|
+
if (!body.startsWith(t.ownerMark)) return "foreign";
|
|
163
|
+
if (rules === undefined) return "ours";
|
|
164
|
+
return body === claudeText(t, rules) ? "on" : "stale";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function claudeOn(t, o, getRules) {
|
|
168
|
+
const file = claudeFile(t, o);
|
|
169
|
+
const shown = where(file);
|
|
170
|
+
if (!isDir(o.claudeDir)) return refuse(`Claude Code 설정 폴더가 없습니다: ${where(o.claudeDir)}`);
|
|
171
|
+
const rules = getRules();
|
|
172
|
+
const state = claudeState(t, readFile(file), rules);
|
|
173
|
+
if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${shown}`);
|
|
174
|
+
if (state === "on") return done(`Claude Code: 이미 켜져 있습니다 (변경 없음). ${shown}`);
|
|
175
|
+
try {
|
|
176
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
|
|
177
|
+
writeFresh(file, claudeText(t, rules));
|
|
178
|
+
} catch (e) {
|
|
179
|
+
if (denied(e)) return refuse(`쓸 권한이 없어 켜지 못했습니다 (${e.code}): ${shown}`);
|
|
180
|
+
throw e;
|
|
181
|
+
}
|
|
182
|
+
return done(
|
|
183
|
+
state === "stale"
|
|
184
|
+
? `Claude Code: ${t.updatedText}. ${shown}`
|
|
185
|
+
: `Claude Code: 켰습니다. 다음 세션부터 자동 적용됩니다. ${shown}`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function claudeOff(t, o) {
|
|
190
|
+
const file = claudeFile(t, o);
|
|
191
|
+
const state = claudeState(t, readFile(file));
|
|
192
|
+
if (state === "off") return done("Claude Code: 이미 꺼져 있습니다 (변경 없음).");
|
|
193
|
+
if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${where(file)}`);
|
|
194
|
+
try {
|
|
195
|
+
rmSync(file);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
if (denied(e)) return refuse(`지울 권한이 없어 끄지 못했습니다 (${e.code}): ${where(file)}`);
|
|
198
|
+
throw e;
|
|
199
|
+
}
|
|
200
|
+
return done(`Claude Code: 껐습니다. 다음 세션부터 적용되지 않습니다. ${where(file)}`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- Codex: one block in the global instructions file Codex reads ----
|
|
204
|
+
|
|
205
|
+
const lineBare = (line) => line.replace(/\r?\n$/, "");
|
|
206
|
+
// A line without its line break, and the first one without the byte order mark
|
|
207
|
+
// an editor may put in front of it.
|
|
208
|
+
const bareAt = (lines, i) => (i === 0 ? noBom(lineBare(lines[i])) : lineBare(lines[i]));
|
|
209
|
+
|
|
210
|
+
// One block's place in the text: [from, to) runs from its first line (the keep
|
|
211
|
+
// marker when one wraps it directly) through the end of its last line; cut is
|
|
212
|
+
// from minus the line break `on` puts before the block, and blankBefore says
|
|
213
|
+
// whether that break makes a blank line of its own.
|
|
214
|
+
function spanOf(lines, offsets, first, last) {
|
|
215
|
+
const wrapped =
|
|
216
|
+
first > 0 &&
|
|
217
|
+
last + 1 < lines.length &&
|
|
218
|
+
bareAt(lines, first - 1) === KEEP_OPEN &&
|
|
219
|
+
bareAt(lines, last + 1) === KEEP_CLOSE;
|
|
220
|
+
const top = wrapped ? first - 1 : first;
|
|
221
|
+
const bottom = wrapped ? last + 1 : last;
|
|
222
|
+
const before = top > 0 ? lines[top - 1] : "";
|
|
223
|
+
const joint = before.endsWith("\r\n") ? 2 : before.endsWith("\n") ? 1 : 0;
|
|
224
|
+
const blankBefore = before !== "" && lineBare(before) === "";
|
|
225
|
+
return { cut: offsets[top] - joint, from: offsets[top], to: offsets[bottom] + lines[bottom].length, blankBefore };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Where each line starts in the text.
|
|
229
|
+
function lineOffsets(lines) {
|
|
230
|
+
const offsets = [];
|
|
231
|
+
let at = 0;
|
|
232
|
+
for (const line of lines) {
|
|
233
|
+
offsets.push(at);
|
|
234
|
+
at += line.length;
|
|
235
|
+
}
|
|
236
|
+
return offsets;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Any management marker, a banker block's or OMX's, anywhere on a line.
|
|
240
|
+
const ANY_MARKER = /<!-- (?:banker:[a-z0-9-]+:(?:start|end)|USER:OMX:POLICY:(?:START|END)) -->/;
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Whether text quotes any management marker. Rules must not: a block holding
|
|
244
|
+
* one would be refused as soon as it is written, and could never come out.
|
|
245
|
+
*/
|
|
246
|
+
export const hasMarker = (text) => ANY_MARKER.test(text);
|
|
247
|
+
// Whether a marker sits between a block's first and last line: then its end was
|
|
248
|
+
// moved, by hand or by an editor, and cutting to it would take other text along.
|
|
249
|
+
const markerInside = (lines, first, last) => lines.slice(first + 1, last).some((line) => ANY_MARKER.test(line));
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Where the blocks between whole lines `open` and `close` sit in text, or null
|
|
253
|
+
* when those markers are not whole lines that open and close in turn, or when
|
|
254
|
+
* another management marker sits inside a block. turnOn and turnOff refuse
|
|
255
|
+
* null, so a marker quoted inside a sentence, or an end put back by hand around
|
|
256
|
+
* other text, can never make them cut the user's own text.
|
|
257
|
+
*/
|
|
258
|
+
export function blockSpansOf(text, open, close) {
|
|
259
|
+
const lines = text.split(/(?<=\n)/);
|
|
260
|
+
const offsets = lineOffsets(lines);
|
|
261
|
+
const spans = [];
|
|
262
|
+
let first = -1;
|
|
263
|
+
for (let i = 0; i < lines.length; i++) {
|
|
264
|
+
const bare = bareAt(lines, i);
|
|
265
|
+
if (bare === open && first === -1) first = i;
|
|
266
|
+
else if (bare === close && first !== -1) {
|
|
267
|
+
if (markerInside(lines, first, i)) return null;
|
|
268
|
+
spans.push(spanOf(lines, offsets, first, i));
|
|
269
|
+
first = -1;
|
|
270
|
+
} else if (lines[i].includes(open) || lines[i].includes(close)) return null;
|
|
271
|
+
}
|
|
272
|
+
return first === -1 ? spans : null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// A block, banker's or any USER:OMX:POLICY one, right after a line break.
|
|
276
|
+
const BLOCK_NEXT = /^\r?\n(?:<!-- USER:OMX:POLICY:START -->|<!-- banker:)/;
|
|
277
|
+
|
|
278
|
+
// The text without its blocks: exactly what it was before `on` added them. The
|
|
279
|
+
// line break `on` put in front goes with the block when the block ends the file
|
|
280
|
+
// or has a blank line of its own in front; with other text after it, the break
|
|
281
|
+
// stays, so the lines on either side never run together. When another block
|
|
282
|
+
// follows, its own leading break keeps the lines apart, so this one goes too:
|
|
283
|
+
// two blocks taken out in the order they went in then leave a text that had no
|
|
284
|
+
// final line break as it was.
|
|
285
|
+
function strip(text, spans) {
|
|
286
|
+
let out = "";
|
|
287
|
+
let at = 0;
|
|
288
|
+
for (const s of spans) {
|
|
289
|
+
const keepBreak = s.to < text.length && !s.blankBefore && !BLOCK_NEXT.test(text.slice(s.to));
|
|
290
|
+
out += text.slice(at, keepBreak ? s.from : s.cut);
|
|
291
|
+
at = s.to;
|
|
292
|
+
}
|
|
293
|
+
return out + text.slice(at);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Whether text holds anything but whitespace, the way Codex judges a file empty:
|
|
297
|
+
// Rust's trim, which strips the Unicode White_Space set and, unlike
|
|
298
|
+
// JavaScript's trim, keeps a byte order mark.
|
|
299
|
+
const hasContent = (text) => /\P{White_Space}/u.test(text);
|
|
300
|
+
|
|
301
|
+
// The text without any banker skill's blocks. A skill whose markers do not pair
|
|
302
|
+
// up keeps its text, which then counts as content: nothing is cut on a guess.
|
|
303
|
+
function withoutBankerBlocks(text) {
|
|
304
|
+
return BANKER_SLUGS.reduce((out, slug) => {
|
|
305
|
+
const { open, close } = markersOf(slug);
|
|
306
|
+
const spans = blockSpansOf(out, open, close);
|
|
307
|
+
return spans === null ? out : strip(out, spans);
|
|
308
|
+
}, text);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// The banker skills other than t with a block in text.
|
|
312
|
+
const otherBlocks = (t, text) =>
|
|
313
|
+
BANKER_SLUGS.filter((slug) => {
|
|
314
|
+
if (slug === t.slug) return false;
|
|
315
|
+
const { open, close } = markersOf(slug);
|
|
316
|
+
const spans = blockSpansOf(text, open, close);
|
|
317
|
+
return spans !== null && spans.length > 0;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const binary = (f) => f.bytes !== null && f.text === null;
|
|
321
|
+
const unpaired = (f) => f.spans === null;
|
|
322
|
+
const hasBlock = (f) => f.spans !== null && f.spans.length > 0;
|
|
323
|
+
const stripped = (f) => (f.text === null ? "" : strip(f.text, f.spans));
|
|
324
|
+
|
|
325
|
+
// Both global instruction files with their bytes and text (null when absent;
|
|
326
|
+
// text null when not UTF-8) and blocks (null when the markers do not pair up).
|
|
327
|
+
// The block's place (target) is the override when the override has text of its
|
|
328
|
+
// own, AGENTS.md otherwise. shadowers names the other banker skills whose
|
|
329
|
+
// blocks alone fill the override: Codex then reads the override, not AGENTS.md.
|
|
330
|
+
function codexFiles(t, o) {
|
|
331
|
+
const read = (name) => {
|
|
332
|
+
const path = join(o.codexDir, name);
|
|
333
|
+
const file = readFile(path);
|
|
334
|
+
if (file === null) return { path, bytes: null, text: null, spans: [] };
|
|
335
|
+
return { path, ...file, spans: file.text === null ? null : blockSpansOf(file.text, t.open, t.close) };
|
|
336
|
+
};
|
|
337
|
+
const override = read("AGENTS.override.md");
|
|
338
|
+
const main = read("AGENTS.md");
|
|
339
|
+
const ownContent = binary(override) || (override.text !== null && hasContent(withoutBankerBlocks(override.text)));
|
|
340
|
+
const target = ownContent ? override : main;
|
|
341
|
+
const shadowers = ownContent || override.text === null ? [] : otherBlocks(t, override.text);
|
|
342
|
+
return { target, other: target === override ? main : override, all: [override, main], shadowers };
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const blockFor = (t, rules, eol) => [KEEP_OPEN, t.open, ...rules.split("\n"), t.close, KEEP_CLOSE, ""].join(eol);
|
|
346
|
+
// A block's text compared without line-break style, a leading byte order mark
|
|
347
|
+
// or the final line break, which editors add and drop.
|
|
348
|
+
const normBlock = (text) => noBom(lf(text)).replace(/\n$/, "");
|
|
349
|
+
// The block exactly as `on` writes it, and only once.
|
|
350
|
+
const blockCurrent = (t, f, rules) =>
|
|
351
|
+
f.spans.length === 1 && normBlock(f.text.slice(f.spans[0].from, f.spans[0].to)) === normBlock(blockFor(t, rules, "\n"));
|
|
352
|
+
|
|
353
|
+
// The text with the block at the end, one line break between them; an empty
|
|
354
|
+
// file gets the block alone. strip() undoes exactly this.
|
|
355
|
+
function withBlock(t, text, rules) {
|
|
356
|
+
const eol = eolOf(text);
|
|
357
|
+
return text === "" ? blockFor(t, rules, eol) : text + eol + blockFor(t, rules, eol);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// "off", "on", "stale", "misplaced" (only in AGENTS.md while Codex reads the
|
|
361
|
+
// override, so not applied), "hidden" (in the override with no text of its own,
|
|
362
|
+
// applied but hiding AGENTS.md), "unpaired" or "notutf8". Without rules any
|
|
363
|
+
// block in its place counts as "on".
|
|
364
|
+
function codexState(t, files, rules) {
|
|
365
|
+
if (files.all.some(binary)) return "notutf8";
|
|
366
|
+
if (files.all.some(unpaired)) return "unpaired";
|
|
367
|
+
const inTarget = hasBlock(files.target);
|
|
368
|
+
const inOther = hasBlock(files.other);
|
|
369
|
+
if (!inTarget) return inOther ? (files.other === files.all[0] ? "hidden" : "misplaced") : "off";
|
|
370
|
+
if (rules === undefined) return "on";
|
|
371
|
+
return blockCurrent(t, files.target, rules) && !inOther ? "on" : "stale";
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Why these files cannot be written, or "" when they can. The folder counts
|
|
375
|
+
// too: the .bak is written through a temp file beside the file.
|
|
376
|
+
function writeBlocked(changes) {
|
|
377
|
+
for (const { file } of changes) {
|
|
378
|
+
for (const path of file.bytes === null ? [dirname(file.path)] : [file.path, dirname(file.path)]) {
|
|
379
|
+
try {
|
|
380
|
+
accessSync(path, constants.W_OK);
|
|
381
|
+
} catch (e) {
|
|
382
|
+
return `쓸 권한이 없어 그대로 두었습니다 (${where(e.code || e.message)}): ${where(path)}`;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return "";
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Writes each file in place, so a symlinked AGENTS.md stays a link and its
|
|
390
|
+
// target changes, after keeping the old bytes in a .bak only this account can
|
|
391
|
+
// read (the file may be a link into a private folder). A new file gets 0644
|
|
392
|
+
// whatever the umask allows more. A file left empty is removed, unless it is a
|
|
393
|
+
// link: Codex skips an empty file anyway.
|
|
394
|
+
function apply(t, changes) {
|
|
395
|
+
for (const { file, after } of changes) {
|
|
396
|
+
if (file.bytes !== null) writeFresh(`${file.path}.${t.slug}.bak`, file.bytes, 0o600);
|
|
397
|
+
if (after === "" && !isLink(file.path)) rmSync(file.path, { force: true });
|
|
398
|
+
else writeFileSync(file.path, after, { mode: 0o644 });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const OMX_NOTE =
|
|
403
|
+
"참고: omx setup 이 AGENTS.md 를 새로 만들면(--force, 덮어쓰기 확인, team 모드를 끈 상태의 omx setup) 이 블록이 사라집니다. 그 뒤에는 on 을 다시 실행하세요. --merge-agents 와 기본 omx setup 은 블록을 남깁니다.";
|
|
404
|
+
|
|
405
|
+
// The note for a block other banker blocks keep from being read.
|
|
406
|
+
const shadowNote = (files) =>
|
|
407
|
+
files.shadowers.length === 0
|
|
408
|
+
? ""
|
|
409
|
+
: `\n주의: AGENTS.override.md 에 다른 banker 블록(${files.shadowers.join(", ")})만 있어 Codex 가 AGENTS.md 를 읽지 않습니다. 그 스킬에서 on 을 실행해 블록을 옮기세요.`;
|
|
410
|
+
|
|
411
|
+
// The refusal for files on and off must not touch, or null.
|
|
412
|
+
function codexRefusal(t, files, state) {
|
|
413
|
+
if (state === "notutf8") return refuse(`UTF-8 이 아닌 파일이라 그대로 두었습니다: ${where(files.all.find(binary).path)}`);
|
|
414
|
+
if (state === "unpaired") return refuse(`${t.slug} 표시의 짝이 맞지 않아 그대로 두었습니다: ${where(files.all.find(unpaired).path)}`);
|
|
415
|
+
return null;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function codexOn(t, o, getRules) {
|
|
419
|
+
if (!isDir(o.codexDir)) return refuse(`Codex 설정 폴더가 없습니다: ${where(o.codexDir)}`);
|
|
420
|
+
const rules = getRules();
|
|
421
|
+
const files = codexFiles(t, o);
|
|
422
|
+
const state = codexState(t, files, rules);
|
|
423
|
+
const refusal = codexRefusal(t, files, state);
|
|
424
|
+
if (refusal) return refusal;
|
|
425
|
+
const tail = `${where(files.target.path)}${shadowNote(files)}`;
|
|
426
|
+
if (state === "on") return done(`Codex: 이미 켜져 있습니다 (변경 없음). ${tail}`);
|
|
427
|
+
const changes = [{ file: files.target, after: withBlock(t, stripped(files.target), rules) }];
|
|
428
|
+
if (hasBlock(files.other)) changes.push({ file: files.other, after: stripped(files.other) });
|
|
429
|
+
const why = writeBlocked(changes);
|
|
430
|
+
if (why) return refuse(why);
|
|
431
|
+
apply(t, changes);
|
|
432
|
+
const moved = "블록을 맞는 파일로 옮겼습니다";
|
|
433
|
+
const what = { stale: t.updatedText, misplaced: moved, hidden: moved }[state];
|
|
434
|
+
const note = files.target === files.all[1] ? `\n${OMX_NOTE}` : "";
|
|
435
|
+
return done(`Codex: ${what ?? "켰습니다. 다음 세션부터 자동 적용됩니다"}. ${tail}${note}`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function codexOff(t, o) {
|
|
439
|
+
if (!isDir(o.codexDir)) return done(`Codex: 설정 폴더가 없어 끌 것이 없습니다 (변경 없음). ${where(o.codexDir)}`);
|
|
440
|
+
const files = codexFiles(t, o);
|
|
441
|
+
const refusal = codexRefusal(t, files, codexState(t, files));
|
|
442
|
+
if (refusal) return refusal;
|
|
443
|
+
const changes = files.all.filter(hasBlock).map((f) => ({ file: f, after: stripped(f) }));
|
|
444
|
+
if (changes.length === 0) return done("Codex: 이미 꺼져 있습니다 (변경 없음).");
|
|
445
|
+
const why = writeBlocked(changes);
|
|
446
|
+
if (why) return refuse(why);
|
|
447
|
+
apply(t, changes);
|
|
448
|
+
return done(`Codex: 껐습니다. 다음 세션부터 적용되지 않습니다. ${changes.map((c) => where(c.file.path)).join(", ")}`);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ---- status ----
|
|
452
|
+
|
|
453
|
+
const STATE_TEXT = {
|
|
454
|
+
on: "켜짐",
|
|
455
|
+
ours: "켜짐 (규칙 버전 확인 불가)",
|
|
456
|
+
off: "꺼짐",
|
|
457
|
+
foreign: "꺼짐, 같은 이름의 다른 파일이 있음 (손대지 않음)",
|
|
458
|
+
misplaced: "꺼짐, 단 블록이 Codex 가 읽지 않는 AGENTS.md 에 있음 (on 으로 옮김)",
|
|
459
|
+
hidden: "켜짐, 단 AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐 (on 으로 옮김)",
|
|
460
|
+
notutf8: "UTF-8 이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
|
|
461
|
+
unpaired: "표시의 짝이 맞지 않음 (on, off 모두 거부. 남은 표시 줄 정리 필요)",
|
|
462
|
+
};
|
|
463
|
+
|
|
464
|
+
// A state as the status line says it.
|
|
465
|
+
function stateText(t, { state, who }) {
|
|
466
|
+
if (state === "stale") return t.staleText;
|
|
467
|
+
if (state === "shadowed") {
|
|
468
|
+
return `꺼짐, 단 AGENTS.override.md 에 다른 banker 블록(${who.join(", ")})만 있어 Codex 가 AGENTS.md 를 읽지 않음 (그 스킬에서 on 으로 옮김)`;
|
|
469
|
+
}
|
|
470
|
+
return STATE_TEXT[state] ?? state;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// One status line. Read failures are reported, not thrown: status is where an
|
|
474
|
+
// exit 2 sends the user to look.
|
|
475
|
+
function describe(t, name, look) {
|
|
476
|
+
try {
|
|
477
|
+
const found = look();
|
|
478
|
+
return `- ${name}: ${stateText(t, found)} (${where(found.path)})`;
|
|
479
|
+
} catch (e) {
|
|
480
|
+
return `- ${name}: 읽을 수 없음 (${where(e.code || e.message)})`;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function codexLook(t, o, rules) {
|
|
485
|
+
if (!isDir(o.codexDir)) return { state: "설정 폴더 없음", path: o.codexDir };
|
|
486
|
+
const files = codexFiles(t, o);
|
|
487
|
+
const state = codexState(t, files, rules);
|
|
488
|
+
const shadowed = files.shadowers.length > 0 && (state === "on" || state === "stale");
|
|
489
|
+
return { state: shadowed ? "shadowed" : state, path: files.target.path, who: files.shadowers };
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** One status line per runtime: "- Claude Code: ..." and "- Codex: ...". rules may be undefined. */
|
|
493
|
+
export function stateLines(t, o, rules) {
|
|
494
|
+
return [
|
|
495
|
+
describe(t, "Claude Code", () => {
|
|
496
|
+
if (!isDir(o.claudeDir)) return { state: "설정 폴더 없음", path: o.claudeDir };
|
|
497
|
+
return { state: claudeState(t, readFile(claudeFile(t, o)), rules), path: claudeFile(t, o) };
|
|
498
|
+
}),
|
|
499
|
+
describe(t, "Codex", () => codexLook(t, o, rules)),
|
|
500
|
+
];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Whether a runtime holds the skill's rules in some form (current, older, in
|
|
505
|
+
* the wrong file or with broken markers), so that changed rules should reach
|
|
506
|
+
* it. Throws when a file cannot be read.
|
|
507
|
+
*/
|
|
508
|
+
export function present(t, runtime, o) {
|
|
509
|
+
if (runtime === "claude") return isDir(o.claudeDir) && claudeState(t, readFile(claudeFile(t, o))) === "ours";
|
|
510
|
+
return isDir(o.codexDir) && !["off", "notutf8"].includes(codexState(t, codexFiles(t, o)));
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const knownRuntime = (runtime) => {
|
|
514
|
+
if (!RUNTIMES.includes(runtime)) throw new Error(`알 수 없는 런타임입니다: ${runtime}`);
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
/** Puts the rules where one runtime loads them. getRules runs after the config folder check. */
|
|
518
|
+
export function turnOn(t, runtime, o, getRules) {
|
|
519
|
+
knownRuntime(runtime);
|
|
520
|
+
return runtime === "claude" ? claudeOn(t, o, getRules) : codexOn(t, o, getRules);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** Takes the rules out for one runtime; it never needs them. */
|
|
524
|
+
export function turnOff(t, runtime, o) {
|
|
525
|
+
knownRuntime(runtime);
|
|
526
|
+
return runtime === "claude" ? claudeOff(t, o) : codexOff(t, o);
|
|
527
|
+
}
|