@kaydash9999/banker-plugins 0.17.1 → 0.19.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 +72 -0
- package/README.md +26 -8
- package/codex/manifest.json +3 -1
- package/codex/transform-matrix.md +5 -2
- package/commands/front-qa.md +3 -3
- package/hooks/graceful-pause.mjs +4 -2
- package/hooks/obsidize-hook.mjs +38 -28
- package/hooks/progress-plan.mjs +14 -8
- package/hooks/progress.mjs +174 -47
- package/hooks/register.mjs +14 -7
- package/hooks/update-checkin.mjs +29 -27
- 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 +254 -0
- package/skills/anti-drm/scripts/anti-drm.mjs +643 -0
- package/skills/anti-drm/scripts/instruction-toggle.mjs +733 -0
- package/skills/lineage/SKILL.md +3 -3
- package/skills/lineage/lineage.py +43 -7
- package/skills/omc-patch/SKILL.md +18 -6
- package/skills/omc-patch/scripts/omc-patch.mjs +400 -87
- package/skills/omc-reference/SKILL.md +1 -1
- package/skills/ralph-qa/SKILL.md +19 -19
- package/skills/theme-selection/SKILL.md +165 -0
- package/skills/theme-selection/assets/LICENSE +21 -0
- package/skills/theme-selection/assets/NOTICE.md +30 -0
- package/skills/theme-selection/assets/palettes.json +81 -0
- package/skills/theme-selection/references/contrast.md +138 -0
- package/skills/theme-selection/references/integrate.md +161 -0
- package/skills/theme-selection/scripts/themes.mjs +1067 -0
- package/skills/tone-compact/SKILL.md +8 -4
- package/skills/tone-compact/scripts/instruction-toggle.mjs +733 -0
- package/skills/tone-compact/scripts/tone-compact.mjs +48 -397
- package/skills/ultra-chain/SKILL.md +7 -6
- package/skills/update-banker/SKILL.md +1 -1
|
@@ -0,0 +1,733 @@
|
|
|
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. A folder or FIFO at a file's name is never read: a
|
|
37
|
+
* FIFO would make the read wait forever. Nor is a socket or a link loop; and an
|
|
38
|
+
* AGENTS.md that is a link to nothing is refused by turnOn, which would
|
|
39
|
+
* otherwise create the file the link points at.
|
|
40
|
+
*
|
|
41
|
+
* Codex's files are read and written under a lock file (.banker-agents.lock in
|
|
42
|
+
* the Codex folder), so both skills turned on at once keep both blocks; a lock
|
|
43
|
+
* older than a minute is taken over.
|
|
44
|
+
*
|
|
45
|
+
* Results are { ok, message }; ok false means refused with nothing changed.
|
|
46
|
+
* Anything else that goes wrong throws.
|
|
47
|
+
*/
|
|
48
|
+
import {
|
|
49
|
+
accessSync,
|
|
50
|
+
closeSync,
|
|
51
|
+
constants,
|
|
52
|
+
fstatSync,
|
|
53
|
+
lstatSync,
|
|
54
|
+
openSync,
|
|
55
|
+
mkdirSync,
|
|
56
|
+
readFileSync,
|
|
57
|
+
realpathSync,
|
|
58
|
+
renameSync,
|
|
59
|
+
rmSync,
|
|
60
|
+
statSync,
|
|
61
|
+
writeFileSync,
|
|
62
|
+
} from "node:fs";
|
|
63
|
+
import { dirname, join } from "node:path";
|
|
64
|
+
|
|
65
|
+
/** The banker skills that keep a block in Codex's instructions files. */
|
|
66
|
+
export const BANKER_SLUGS = ["tone-compact", "anti-drm"];
|
|
67
|
+
export const RUNTIMES = ["claude", "codex"];
|
|
68
|
+
// OMX keeps only these regions when `omx setup` rewrites a generated AGENTS.md.
|
|
69
|
+
export const KEEP_OPEN = "<!-- USER:OMX:POLICY:START -->";
|
|
70
|
+
export const KEEP_CLOSE = "<!-- USER:OMX:POLICY:END -->";
|
|
71
|
+
|
|
72
|
+
// The byte order mark an editor may put in front of a file.
|
|
73
|
+
const BOM = String.fromCharCode(0xfeff);
|
|
74
|
+
/** The text without the byte order mark an editor may put in front of it. */
|
|
75
|
+
export const noBom = (text) => (text.startsWith(BOM) ? text.slice(BOM.length) : text);
|
|
76
|
+
/** The text with CRLF line ends turned into LF. */
|
|
77
|
+
export const lf = (text) => text.replace(/\r\n/g, "\n");
|
|
78
|
+
/** How many times needle occurs in text. */
|
|
79
|
+
export const count = (text, needle) => text.split(needle).length - 1;
|
|
80
|
+
const eolOf = (text) => (text.includes("\r\n") ? "\r\n" : "\n");
|
|
81
|
+
const isDir = (path) => {
|
|
82
|
+
try {
|
|
83
|
+
return statSync(path).isDirectory();
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const isLink = (path) => {
|
|
89
|
+
try {
|
|
90
|
+
return lstatSync(path).isSymbolicLink();
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
|
|
97
|
+
|
|
98
|
+
// What sits at a name that is not a regular file: a folder, a FIFO or a device.
|
|
99
|
+
// It is never read; reading a FIFO waits for a writer.
|
|
100
|
+
const SPECIAL = Object.freeze({ bytes: null, text: null, special: true });
|
|
101
|
+
// What sits at a name that is a link to nothing: Codex finds no file there, and writing would follow the link.
|
|
102
|
+
const DANGLING = Object.freeze({ bytes: null, text: null, dangling: true });
|
|
103
|
+
// What opening a name fails with when no regular file can be there: links that loop (ELOOP) or a socket (ENXIO,
|
|
104
|
+
// EOPNOTSUPP). macOS answers a socket with errno 102, which Node 22 does not name ("Unknown system error -102"), so
|
|
105
|
+
// the kind of what is there decides too.
|
|
106
|
+
const NOT_A_FILE = new Set(["ELOOP", "ENXIO", "EOPNOTSUPP"]);
|
|
107
|
+
// Whether path leads to something that is there and is no regular file: a folder, a socket, a FIFO or a device.
|
|
108
|
+
const notAFile = (path) => {
|
|
109
|
+
try {
|
|
110
|
+
return !statSync(path).isFile();
|
|
111
|
+
} catch {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// What a failed open of path means: null for nothing there, DANGLING for a link to nothing, SPECIAL for a folder,
|
|
117
|
+
// a socket or a link loop. Any other failure (permissions) is not "missing" and propagates.
|
|
118
|
+
function openFailure(path, e) {
|
|
119
|
+
if (e.code === "ENOENT" || e.code === "ENOTDIR") return isLink(path) ? DANGLING : null;
|
|
120
|
+
if (e.code === "EISDIR" || NOT_A_FILE.has(e.code) || notAFile(path)) return SPECIAL;
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A file's bytes and text, null when there is no such file. text is null when
|
|
125
|
+
// the bytes are not UTF-8, which callers refuse: decoding them would replace
|
|
126
|
+
// them with U+FFFD for good. A folder, a socket, a link loop or anything but a
|
|
127
|
+
// regular file is SPECIAL, and a link to nothing is DANGLING: the name is
|
|
128
|
+
// opened without blocking and not read.
|
|
129
|
+
function readFile(path) {
|
|
130
|
+
let fd;
|
|
131
|
+
try {
|
|
132
|
+
fd = openSync(path, constants.O_RDONLY | (constants.O_NONBLOCK ?? 0));
|
|
133
|
+
} catch (e) {
|
|
134
|
+
return openFailure(path, e);
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
if (!fstatSync(fd).isFile()) return SPECIAL;
|
|
138
|
+
const bytes = readFileSync(fd);
|
|
139
|
+
try {
|
|
140
|
+
return { bytes, text: UTF8.decode(bytes) };
|
|
141
|
+
} catch {
|
|
142
|
+
return { bytes, text: null };
|
|
143
|
+
}
|
|
144
|
+
} finally {
|
|
145
|
+
closeSync(fd);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A result that did, or found already done, what was asked. */
|
|
150
|
+
export const done = (message) => ({ ok: true, message });
|
|
151
|
+
/** A result that refused and changed nothing. */
|
|
152
|
+
export const refuse = (message) => ({ ok: false, message });
|
|
153
|
+
|
|
154
|
+
/** A skill's marks: the first line of its rules file, and the lines around its Codex block. */
|
|
155
|
+
export function markersOf(slug) {
|
|
156
|
+
return { ownerMark: `<!-- banker ${slug}`, open: `<!-- banker:${slug}:start -->`, close: `<!-- banker:${slug}:end -->` };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* What one skill's toggle needs: its slug and marks, the first line of its
|
|
161
|
+
* rules file, and two texts it words its own way: the status of rules that
|
|
162
|
+
* differ from the ones given (staleText), and what turnOn says when it replaced
|
|
163
|
+
* them (updatedText).
|
|
164
|
+
*/
|
|
165
|
+
export function toggleSpec({ slug, staleText, updatedText }) {
|
|
166
|
+
const marks = markersOf(slug);
|
|
167
|
+
const header = `${marks.ownerMark}: banker 가 관리하는 파일입니다. 끄려면 ${slug} 에 off 를 실행하세요. -->`;
|
|
168
|
+
return { slug, ...marks, header, staleText, updatedText };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Replaces file through a fresh temp file ("wx") and a rename: a link planted
|
|
173
|
+
* at either name is replaced rather than written through, and a write that
|
|
174
|
+
* fails leaves the old file as it was.
|
|
175
|
+
*/
|
|
176
|
+
export function writeFresh(file, data, mode = 0o644) {
|
|
177
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
178
|
+
rmSync(tmp, { force: true });
|
|
179
|
+
try {
|
|
180
|
+
writeFileSync(tmp, data, { flag: "wx", mode });
|
|
181
|
+
renameSync(tmp, file);
|
|
182
|
+
} catch (e) {
|
|
183
|
+
rmSync(tmp, { force: true });
|
|
184
|
+
throw e;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Whether an error is about permissions or a read-only file system. */
|
|
189
|
+
export const denied = (e) => e.code === "EACCES" || e.code === "EPERM" || e.code === "EROFS";
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* A path or an error text as a message shows it. Control, format, line and
|
|
193
|
+
* paragraph separator, private-use, unassigned and variation selector
|
|
194
|
+
* characters become "?": a path from the environment cannot start a line of
|
|
195
|
+
* its own, reorder the text around it, or hide text after a visible letter.
|
|
196
|
+
* So do letters that print as nothing (default-ignorable ones such as U+3164),
|
|
197
|
+
* the braille blank and spaces other than the ASCII one, which hide text as blanks.
|
|
198
|
+
*/
|
|
199
|
+
export const where = (value) =>
|
|
200
|
+
String(value).replace(
|
|
201
|
+
/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Co}\p{Cn}\p{Variation_Selector}\p{Default_Ignorable_Code_Point}\u{2800}\u{a0}\u{1680}\u{2000}-\u{200a}\u{202f}\u{205f}\u{3000}]/gu,
|
|
202
|
+
"?",
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
// ---- Claude Code: one rules file that is banker's alone ----
|
|
206
|
+
|
|
207
|
+
const claudeFile = (t, o) => join(o.claudeDir, "rules", `banker-${t.slug}.md`);
|
|
208
|
+
const claudeText = (t, rules) => `${t.header}\n${rules}\n`;
|
|
209
|
+
|
|
210
|
+
// "off", "foreign" (not banker's file), or, when rules are given, "on" or
|
|
211
|
+
// "stale" (other rules); without rules a banker file is "ours".
|
|
212
|
+
function claudeState(t, file, rules) {
|
|
213
|
+
if (file === null || file.dangling) return "off";
|
|
214
|
+
if (file.text === null) return "foreign";
|
|
215
|
+
const body = lf(noBom(file.text));
|
|
216
|
+
if (!body.startsWith(t.ownerMark)) return "foreign";
|
|
217
|
+
if (rules === undefined) return "ours";
|
|
218
|
+
return body === claudeText(t, rules) ? "on" : "stale";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function claudeOn(t, o, getRules) {
|
|
222
|
+
const file = claudeFile(t, o);
|
|
223
|
+
const shown = where(file);
|
|
224
|
+
if (!isDir(o.claudeDir)) return refuse(`Claude Code 설정 폴더가 없습니다: ${where(o.claudeDir)}`);
|
|
225
|
+
const rules = getRules();
|
|
226
|
+
const state = claudeState(t, readFile(file), rules);
|
|
227
|
+
if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${shown}`);
|
|
228
|
+
if (state === "on") return done(`Claude Code: 이미 켜져 있습니다 (변경 없음). ${shown}`);
|
|
229
|
+
try {
|
|
230
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
|
|
231
|
+
writeFresh(file, claudeText(t, rules));
|
|
232
|
+
} catch (e) {
|
|
233
|
+
if (denied(e)) return refuse(`쓸 권한이 없어 켜지 못했습니다 (${e.code}): ${shown}`);
|
|
234
|
+
throw e;
|
|
235
|
+
}
|
|
236
|
+
return done(
|
|
237
|
+
state === "stale"
|
|
238
|
+
? `Claude Code: ${t.updatedText}. ${shown}`
|
|
239
|
+
: `Claude Code: 켰습니다. 다음 세션부터 자동 적용됩니다. ${shown}`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function claudeOff(t, o) {
|
|
244
|
+
const file = claudeFile(t, o);
|
|
245
|
+
const state = claudeState(t, readFile(file));
|
|
246
|
+
if (state === "off") return done("Claude Code: 이미 꺼져 있습니다 (변경 없음).");
|
|
247
|
+
if (state === "foreign") return refuse(`같은 이름의 다른 파일이 있어 그대로 두었습니다: ${where(file)}`);
|
|
248
|
+
try {
|
|
249
|
+
rmSync(file);
|
|
250
|
+
} catch (e) {
|
|
251
|
+
if (denied(e)) return refuse(`지울 권한이 없어 끄지 못했습니다 (${e.code}): ${where(file)}`);
|
|
252
|
+
throw e;
|
|
253
|
+
}
|
|
254
|
+
return done(`Claude Code: 껐습니다. 다음 세션부터 적용되지 않습니다. ${where(file)}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---- Codex: one block in the global instructions file Codex reads ----
|
|
258
|
+
|
|
259
|
+
const lineBare = (line) => line.replace(/\r?\n$/, "");
|
|
260
|
+
// A line without its line break, and the first one without the byte order mark
|
|
261
|
+
// an editor may put in front of it.
|
|
262
|
+
const bareAt = (lines, i) => (i === 0 ? noBom(lineBare(lines[i])) : lineBare(lines[i]));
|
|
263
|
+
|
|
264
|
+
// One block's place in the text: [from, to) runs from its first line (the keep
|
|
265
|
+
// marker when one wraps it directly) through the end of its last line; cut is
|
|
266
|
+
// from minus the line break `on` puts before the block, and blankBefore says
|
|
267
|
+
// whether that break makes a blank line of its own.
|
|
268
|
+
function spanOf(lines, offsets, first, last) {
|
|
269
|
+
const wrapped =
|
|
270
|
+
first > 0 &&
|
|
271
|
+
last + 1 < lines.length &&
|
|
272
|
+
bareAt(lines, first - 1) === KEEP_OPEN &&
|
|
273
|
+
bareAt(lines, last + 1) === KEEP_CLOSE;
|
|
274
|
+
const top = wrapped ? first - 1 : first;
|
|
275
|
+
const bottom = wrapped ? last + 1 : last;
|
|
276
|
+
const before = top > 0 ? lines[top - 1] : "";
|
|
277
|
+
const joint = before.endsWith("\r\n") ? 2 : before.endsWith("\n") ? 1 : 0;
|
|
278
|
+
const blankBefore = before !== "" && lineBare(before) === "";
|
|
279
|
+
return { cut: offsets[top] - joint, from: offsets[top], to: offsets[bottom] + lines[bottom].length, blankBefore };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Where each line starts in the text.
|
|
283
|
+
function lineOffsets(lines) {
|
|
284
|
+
const offsets = [];
|
|
285
|
+
let at = 0;
|
|
286
|
+
for (const line of lines) {
|
|
287
|
+
offsets.push(at);
|
|
288
|
+
at += line.length;
|
|
289
|
+
}
|
|
290
|
+
return offsets;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Any management marker, a banker block's or OMX's, anywhere on a line.
|
|
294
|
+
const ANY_MARKER = /<!-- (?:banker:[a-z0-9-]+:(?:start|end)|USER:OMX:POLICY:(?:START|END)) -->/;
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Whether text quotes any management marker. Rules must not: a block holding
|
|
298
|
+
* one would be refused as soon as it is written, and could never come out.
|
|
299
|
+
*/
|
|
300
|
+
export const hasMarker = (text) => ANY_MARKER.test(text);
|
|
301
|
+
// Whether a marker sits between a block's first and last line: then its end was
|
|
302
|
+
// moved, by hand or by an editor, and cutting to it would take other text along.
|
|
303
|
+
const markerInside = (lines, first, last) => lines.slice(first + 1, last).some((line) => ANY_MARKER.test(line));
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Where the blocks between whole lines `open` and `close` sit in text, or null
|
|
307
|
+
* when those markers are not whole lines that open and close in turn, or when
|
|
308
|
+
* another management marker sits inside a block. turnOn and turnOff refuse
|
|
309
|
+
* null, so a marker quoted inside a sentence, or an end put back by hand around
|
|
310
|
+
* other text, can never make them cut the user's own text.
|
|
311
|
+
*/
|
|
312
|
+
export function blockSpansOf(text, open, close) {
|
|
313
|
+
const lines = text.split(/(?<=\n)/);
|
|
314
|
+
const offsets = lineOffsets(lines);
|
|
315
|
+
const spans = [];
|
|
316
|
+
let first = -1;
|
|
317
|
+
for (let i = 0; i < lines.length; i++) {
|
|
318
|
+
const bare = bareAt(lines, i);
|
|
319
|
+
if (bare === open && first === -1) first = i;
|
|
320
|
+
else if (bare === close && first !== -1) {
|
|
321
|
+
if (markerInside(lines, first, i)) return null;
|
|
322
|
+
spans.push(spanOf(lines, offsets, first, i));
|
|
323
|
+
first = -1;
|
|
324
|
+
} else if (lines[i].includes(open) || lines[i].includes(close)) return null;
|
|
325
|
+
}
|
|
326
|
+
return first === -1 ? spans : null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// A block, banker's or any USER:OMX:POLICY one, right after a line break.
|
|
330
|
+
const BLOCK_NEXT = /^\r?\n(?:<!-- USER:OMX:POLICY:START -->|<!-- banker:)/;
|
|
331
|
+
|
|
332
|
+
// The text without its blocks: exactly what it was before `on` added them. The
|
|
333
|
+
// line break `on` put in front goes with the block when the block ends the file
|
|
334
|
+
// or has a blank line of its own in front; with other text after it, the break
|
|
335
|
+
// stays, so the lines on either side never run together. When another block
|
|
336
|
+
// follows, its own leading break keeps the lines apart, so this one goes too:
|
|
337
|
+
// two blocks taken out in the order they went in then leave a text that had no
|
|
338
|
+
// final line break as it was.
|
|
339
|
+
function strip(text, spans) {
|
|
340
|
+
let out = "";
|
|
341
|
+
let at = 0;
|
|
342
|
+
for (const s of spans) {
|
|
343
|
+
const keepBreak = s.to < text.length && !s.blankBefore && !BLOCK_NEXT.test(text.slice(s.to));
|
|
344
|
+
out += text.slice(at, keepBreak ? s.from : s.cut);
|
|
345
|
+
at = s.to;
|
|
346
|
+
}
|
|
347
|
+
return out + text.slice(at);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Whether text holds anything but whitespace, the way Codex judges a file empty:
|
|
351
|
+
// Rust's trim, which strips the Unicode White_Space set and, unlike
|
|
352
|
+
// JavaScript's trim, keeps a byte order mark.
|
|
353
|
+
const hasContent = (text) => /\P{White_Space}/u.test(text);
|
|
354
|
+
|
|
355
|
+
// The text without any banker skill's blocks. A skill whose markers do not pair
|
|
356
|
+
// up keeps its text, which then counts as content: nothing is cut on a guess.
|
|
357
|
+
function withoutBankerBlocks(text) {
|
|
358
|
+
return BANKER_SLUGS.reduce((out, slug) => {
|
|
359
|
+
const { open, close } = markersOf(slug);
|
|
360
|
+
const spans = blockSpansOf(out, open, close);
|
|
361
|
+
return spans === null ? out : strip(out, spans);
|
|
362
|
+
}, text);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// The banker skills other than t with a block in text.
|
|
366
|
+
const otherBlocks = (t, text) =>
|
|
367
|
+
BANKER_SLUGS.filter((slug) => {
|
|
368
|
+
if (slug === t.slug) return false;
|
|
369
|
+
const { open, close } = markersOf(slug);
|
|
370
|
+
const spans = blockSpansOf(text, open, close);
|
|
371
|
+
return spans !== null && spans.length > 0;
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const special = (f) => f.special === true;
|
|
375
|
+
const binary = (f) => f.bytes !== null && f.text === null;
|
|
376
|
+
const unpaired = (f) => f.spans === null;
|
|
377
|
+
const hasBlock = (f) => f.spans !== null && f.spans.length > 0;
|
|
378
|
+
const stripped = (f) => (f.text === null ? "" : strip(f.text, f.spans));
|
|
379
|
+
|
|
380
|
+
/** Whether two bigint stats name one file: one device and one inode, where the file system gives inodes at all. */
|
|
381
|
+
export const sameInode = (x, y) => x.ino !== 0n && x.dev === y.dev && x.ino === y.ino;
|
|
382
|
+
|
|
383
|
+
// Whether two paths name one file: a link (both resolve to one real path) or a hard link (one inode). A file system
|
|
384
|
+
// that gives no inode numbers (0) cannot say so for hard links, so those count as two files.
|
|
385
|
+
function sameFile(a, b) {
|
|
386
|
+
try {
|
|
387
|
+
if (realpathSync(a) === realpathSync(b)) return true;
|
|
388
|
+
return sameInode(statSync(a, { bigint: true }), statSync(b, { bigint: true }));
|
|
389
|
+
} catch {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Both global instruction files with their bytes and text (null when absent;
|
|
395
|
+
// text null when not UTF-8) and blocks (null when the markers do not pair up).
|
|
396
|
+
// The block's place (target) is the override when the override has text of its
|
|
397
|
+
// own, AGENTS.md otherwise. shadowers names the other banker skills whose
|
|
398
|
+
// blocks alone fill the override: Codex then reads the override, not AGENTS.md.
|
|
399
|
+
// An override that is a link or a hard link to AGENTS.md is one file with it.
|
|
400
|
+
function codexFiles(t, o) {
|
|
401
|
+
const read = (name) => {
|
|
402
|
+
const path = join(o.codexDir, name);
|
|
403
|
+
const file = readFile(path);
|
|
404
|
+
if (file === null || file.special || file.dangling) {
|
|
405
|
+
return { path, bytes: null, text: null, spans: [], special: file?.special === true, dangling: file?.dangling === true };
|
|
406
|
+
}
|
|
407
|
+
return { path, ...file, spans: file.text === null ? null : blockSpansOf(file.text, t.open, t.close) };
|
|
408
|
+
};
|
|
409
|
+
const override = read("AGENTS.override.md");
|
|
410
|
+
const main = read("AGENTS.md");
|
|
411
|
+
if (override.bytes !== null && main.bytes !== null && sameFile(override.path, main.path)) {
|
|
412
|
+
// One file under both names: Codex reads it either way, so it is AGENTS.md alone.
|
|
413
|
+
const none = { path: override.path, bytes: null, text: null, spans: [] };
|
|
414
|
+
return { target: main, other: none, all: [none, main], shadowers: [] };
|
|
415
|
+
}
|
|
416
|
+
const ownContent = binary(override) || (override.text !== null && hasContent(withoutBankerBlocks(override.text)));
|
|
417
|
+
const target = ownContent ? override : main;
|
|
418
|
+
const shadowers = ownContent || override.text === null ? [] : otherBlocks(t, override.text);
|
|
419
|
+
return { target, other: target === override ? main : override, all: [override, main], shadowers };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const blockFor = (t, rules, eol) => [KEEP_OPEN, t.open, ...rules.split("\n"), t.close, KEEP_CLOSE, ""].join(eol);
|
|
423
|
+
// A block's text compared without line-break style, a leading byte order mark
|
|
424
|
+
// or the final line break, which editors add and drop.
|
|
425
|
+
const normBlock = (text) => noBom(lf(text)).replace(/\n$/, "");
|
|
426
|
+
// The block exactly as `on` writes it, and only once.
|
|
427
|
+
const blockCurrent = (t, f, rules) =>
|
|
428
|
+
f.spans.length === 1 && normBlock(f.text.slice(f.spans[0].from, f.spans[0].to)) === normBlock(blockFor(t, rules, "\n"));
|
|
429
|
+
|
|
430
|
+
// The text with the block at the end, one line break between them; an empty
|
|
431
|
+
// file gets the block alone. strip() undoes exactly this.
|
|
432
|
+
function withBlock(t, text, rules) {
|
|
433
|
+
const eol = eolOf(text);
|
|
434
|
+
return text === "" ? blockFor(t, rules, eol) : text + eol + blockFor(t, rules, eol);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// The state when the block is not in its place: "hidden" (only in an override with no text of its own),
|
|
438
|
+
// "misplaced" (only in AGENTS.md while Codex reads the override), "dangling" (nowhere, and AGENTS.md is a link to
|
|
439
|
+
// nothing that on must not write through) or "off".
|
|
440
|
+
function placeState(files) {
|
|
441
|
+
if (hasBlock(files.other)) return files.other === files.all[0] ? "hidden" : "misplaced";
|
|
442
|
+
return files.target.dangling ? "dangling" : "off";
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// "off", "on", "stale", "misplaced" (only in AGENTS.md while Codex reads the
|
|
446
|
+
// override, so not applied), "hidden" (in the override with no text of its own,
|
|
447
|
+
// applied but hiding AGENTS.md), "dangling" (nowhere, and AGENTS.md is a link to
|
|
448
|
+
// nothing), "unpaired", "notutf8" or "notregular" (a folder, FIFO, socket or
|
|
449
|
+
// link loop at a file's name). Without rules any block in its place counts as "on".
|
|
450
|
+
function codexState(t, files, rules) {
|
|
451
|
+
if (files.all.some(special)) return "notregular";
|
|
452
|
+
if (files.all.some(binary)) return "notutf8";
|
|
453
|
+
if (files.all.some(unpaired)) return "unpaired";
|
|
454
|
+
if (!hasBlock(files.target)) return placeState(files);
|
|
455
|
+
if (rules === undefined) return "on";
|
|
456
|
+
return blockCurrent(t, files.target, rules) && !hasBlock(files.other) ? "on" : "stale";
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Why these files cannot be written, or "" when they can. The folder counts
|
|
460
|
+
// too: the .bak is written through a temp file beside the file.
|
|
461
|
+
function writeBlocked(changes) {
|
|
462
|
+
for (const { file } of changes) {
|
|
463
|
+
for (const path of file.bytes === null ? [dirname(file.path)] : [file.path, dirname(file.path)]) {
|
|
464
|
+
try {
|
|
465
|
+
accessSync(path, constants.W_OK);
|
|
466
|
+
} catch (e) {
|
|
467
|
+
return `쓸 권한이 없어 그대로 두었습니다 (${where(e.code || e.message)}): ${where(path)}`;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return "";
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Writes each file in place, so a symlinked AGENTS.md stays a link and its
|
|
475
|
+
// target changes, after keeping the old bytes in a .bak only this account can
|
|
476
|
+
// read (the file may be a link into a private folder). A new file gets 0644
|
|
477
|
+
// whatever the umask allows more. A file left empty is removed, unless it is a
|
|
478
|
+
// link or has other names (a hard link), whose other names would keep the old
|
|
479
|
+
// text: it is emptied instead. Codex skips an empty file anyway.
|
|
480
|
+
function apply(t, changes) {
|
|
481
|
+
for (const { file, after } of changes) {
|
|
482
|
+
if (file.bytes !== null) writeFresh(`${file.path}.${t.slug}.bak`, file.bytes, 0o600);
|
|
483
|
+
if (after === "" && !isLink(file.path) && !hasOtherNames(file.path)) rmSync(file.path, { force: true });
|
|
484
|
+
else writeFileSync(file.path, after, { mode: 0o644 });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Whether the file at path is also reachable under another name: a hard link.
|
|
489
|
+
function hasOtherNames(path) {
|
|
490
|
+
try {
|
|
491
|
+
return lstatSync(path).nlink > 1;
|
|
492
|
+
} catch {
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// The lock both banker skills take while they read and write Codex's instructions files: two on's at once would
|
|
498
|
+
// each write the file back with only their own block. A lock is held for milliseconds; one older than a minute was
|
|
499
|
+
// left by a run that died.
|
|
500
|
+
const LOCK = ".banker-agents.lock";
|
|
501
|
+
const LOCK_STALE_MS = 60_000;
|
|
502
|
+
const LOCK_STEP_MS = 50;
|
|
503
|
+
const SLEEP = new Int32Array(new SharedArrayBuffer(4));
|
|
504
|
+
|
|
505
|
+
// Makes the lock file; false when one is there already. Anything else (permissions) throws.
|
|
506
|
+
function tryLock(path, token) {
|
|
507
|
+
try {
|
|
508
|
+
writeFileSync(path, token, { flag: "wx", mode: 0o600 });
|
|
509
|
+
return true;
|
|
510
|
+
} catch (e) {
|
|
511
|
+
if (e.code === "EEXIST") return false;
|
|
512
|
+
throw e;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Removes a lock a dead run left; true when there is none now, so the next try may take it. The name itself is
|
|
517
|
+
// looked at, not where it leads: a link to nothing or a link loop there is a lock like any other. A lock dated more
|
|
518
|
+
// than a minute away either way (a clock set back) is stale too. A folder there is none of this module's: it stays,
|
|
519
|
+
// and the wait ends in a refusal.
|
|
520
|
+
function dropStale(path) {
|
|
521
|
+
let st;
|
|
522
|
+
try {
|
|
523
|
+
st = lstatSync(path);
|
|
524
|
+
} catch (e) {
|
|
525
|
+
if (e.code === "ENOENT") return true; /* gone in between */
|
|
526
|
+
throw e;
|
|
527
|
+
}
|
|
528
|
+
if (st.isDirectory() || Math.abs(Date.now() - st.mtimeMs) <= LOCK_STALE_MS) return false;
|
|
529
|
+
rmSync(path, { force: true });
|
|
530
|
+
return true;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Takes the lock: { path, token }, or null when another run held it for waitMs. The deadline is looked at every
|
|
534
|
+
// round, so no lock spot, whatever is there, keeps the wait going past it.
|
|
535
|
+
function takeLock(dir, waitMs) {
|
|
536
|
+
const path = join(dir, LOCK);
|
|
537
|
+
const token = `${process.pid} ${Date.now()} ${Math.random()}`;
|
|
538
|
+
const until = Date.now() + waitMs;
|
|
539
|
+
for (;;) {
|
|
540
|
+
if (tryLock(path, token)) return { path, token };
|
|
541
|
+
if (Date.now() >= until) return null;
|
|
542
|
+
if (!dropStale(path)) Atomics.wait(SLEEP, 0, 0, LOCK_STEP_MS);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Gives the lock back while it is still this run's. The name is opened without blocking: a pipe put there is not
|
|
547
|
+
// this run's lock and stays.
|
|
548
|
+
function dropLock(lock) {
|
|
549
|
+
try {
|
|
550
|
+
if (readFile(lock.path)?.text === lock.token) rmSync(lock.path, { force: true });
|
|
551
|
+
} catch {
|
|
552
|
+
/* already gone */
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Runs fn holding the lock; a lock another run keeps past the wait is a refusal. A folder this account cannot write
|
|
557
|
+
// takes no lock, and nothing in it can be written either: fn then only reads, and writeBlocked refuses any change,
|
|
558
|
+
// so "already on" and "already off" answer as before.
|
|
559
|
+
function locked(o, fn) {
|
|
560
|
+
let lock;
|
|
561
|
+
try {
|
|
562
|
+
lock = takeLock(o.codexDir, o.lockWaitMs ?? 5000);
|
|
563
|
+
} catch (e) {
|
|
564
|
+
if (denied(e)) return fn();
|
|
565
|
+
throw e;
|
|
566
|
+
}
|
|
567
|
+
if (!lock) return refuse(`다른 banker 지침 작업이 끝나지 않아 그대로 두었습니다: ${where(join(o.codexDir, LOCK))}`);
|
|
568
|
+
try {
|
|
569
|
+
return fn();
|
|
570
|
+
} finally {
|
|
571
|
+
dropLock(lock);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const OMX_NOTE =
|
|
576
|
+
"참고: omx setup 이 AGENTS.md 를 새로 만들면(--force, 덮어쓰기 확인, team 모드를 끈 상태의 omx setup) 이 블록이 사라집니다. 그 뒤에는 on 을 다시 실행하세요. --merge-agents 와 기본 omx setup 은 블록을 남깁니다.";
|
|
577
|
+
|
|
578
|
+
// The note for a block other banker blocks keep from being read.
|
|
579
|
+
const shadowNote = (files) =>
|
|
580
|
+
files.shadowers.length === 0
|
|
581
|
+
? ""
|
|
582
|
+
: `\n주의: AGENTS.override.md 에 다른 banker 블록(${files.shadowers.join(", ")})만 있어 Codex 가 AGENTS.md 를 읽지 않습니다. 그 스킬에서 on 을 실행해 블록을 옮기세요.`;
|
|
583
|
+
|
|
584
|
+
// The file a refused state is about: the folder or FIFO, the file that is not UTF-8, or the one whose markers do
|
|
585
|
+
// not pair up; null in any other state.
|
|
586
|
+
function problemFile(files, state) {
|
|
587
|
+
const which = { notregular: special, notutf8: binary, unpaired }[state];
|
|
588
|
+
return which ? files.all.find(which) : null;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// The refusal for files on and off must not touch, or null.
|
|
592
|
+
function codexRefusal(t, files, state) {
|
|
593
|
+
const file = problemFile(files, state);
|
|
594
|
+
if (!file) return null;
|
|
595
|
+
const why = {
|
|
596
|
+
notregular: "일반 파일이 아니라 그대로 두었습니다",
|
|
597
|
+
notutf8: "UTF-8 이 아닌 파일이라 그대로 두었습니다",
|
|
598
|
+
unpaired: `${t.slug} 표시의 짝이 맞지 않아 그대로 두었습니다`,
|
|
599
|
+
}[state];
|
|
600
|
+
return refuse(`${why}: ${where(file.path)}`);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// What codexOn says it did. A block Codex does not read, because other banker
|
|
604
|
+
// blocks alone fill the override, says so in its first words.
|
|
605
|
+
function onWords(t, state, unread) {
|
|
606
|
+
if (state === "on") return unread ? "이미 AGENTS.md 에 있지만 지금은 적용되지 않습니다 (변경 없음)" : "이미 켜져 있습니다 (변경 없음)";
|
|
607
|
+
if (unread) return "AGENTS.md 에 넣었지만 지금은 적용되지 않습니다";
|
|
608
|
+
const moved = "블록을 맞는 파일로 옮겼습니다";
|
|
609
|
+
return { stale: t.updatedText, misplaced: moved, hidden: moved }[state] ?? "켰습니다. 다음 세션부터 자동 적용됩니다";
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function codexOn(t, o, getRules) {
|
|
613
|
+
if (!isDir(o.codexDir)) return refuse(`Codex 설정 폴더가 없습니다: ${where(o.codexDir)}`);
|
|
614
|
+
return locked(o, () => codexOnHeld(t, o, getRules));
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function codexOnHeld(t, o, getRules) {
|
|
618
|
+
const rules = getRules();
|
|
619
|
+
const files = codexFiles(t, o);
|
|
620
|
+
const state = codexState(t, files, rules);
|
|
621
|
+
const refusal = codexRefusal(t, files, state);
|
|
622
|
+
if (refusal) return refusal;
|
|
623
|
+
if (files.target.dangling) return refuse(`끊어진 링크라 그대로 두었습니다: ${where(files.target.path)}`);
|
|
624
|
+
const words = onWords(t, state, files.shadowers.length > 0);
|
|
625
|
+
const tail = `${where(files.target.path)}${shadowNote(files)}`;
|
|
626
|
+
if (state === "on") return done(`Codex: ${words}. ${tail}`);
|
|
627
|
+
const changes = [{ file: files.target, after: withBlock(t, stripped(files.target), rules) }];
|
|
628
|
+
if (hasBlock(files.other)) changes.push({ file: files.other, after: stripped(files.other) });
|
|
629
|
+
const why = writeBlocked(changes);
|
|
630
|
+
if (why) return refuse(why);
|
|
631
|
+
apply(t, changes);
|
|
632
|
+
const note = files.target === files.all[1] ? `\n${OMX_NOTE}` : "";
|
|
633
|
+
return done(`Codex: ${words}. ${tail}${note}`);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function codexOff(t, o) {
|
|
637
|
+
if (!isDir(o.codexDir)) return done(`Codex: 설정 폴더가 없어 끌 것이 없습니다 (변경 없음). ${where(o.codexDir)}`);
|
|
638
|
+
return locked(o, () => codexOffHeld(t, o));
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function codexOffHeld(t, o) {
|
|
642
|
+
const files = codexFiles(t, o);
|
|
643
|
+
const refusal = codexRefusal(t, files, codexState(t, files));
|
|
644
|
+
if (refusal) return refusal;
|
|
645
|
+
const changes = files.all.filter(hasBlock).map((f) => ({ file: f, after: stripped(f) }));
|
|
646
|
+
if (changes.length === 0) return done("Codex: 이미 꺼져 있습니다 (변경 없음).");
|
|
647
|
+
const why = writeBlocked(changes);
|
|
648
|
+
if (why) return refuse(why);
|
|
649
|
+
apply(t, changes);
|
|
650
|
+
const paths = changes.map((c) => where(c.file.path)).join(", ");
|
|
651
|
+
const baks = changes.map((c) => where(`${c.file.path}.${t.slug}.bak`)).join(", ");
|
|
652
|
+
return done(`Codex: 껐습니다. 다음 세션부터 적용되지 않습니다. ${paths}\n지우기 전 파일: ${baks}`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ---- status ----
|
|
656
|
+
|
|
657
|
+
const STATE_TEXT = {
|
|
658
|
+
on: "켜짐",
|
|
659
|
+
ours: "켜짐 (규칙 버전 확인 불가)",
|
|
660
|
+
off: "꺼짐",
|
|
661
|
+
foreign: "꺼짐, 같은 이름의 다른 파일이 있음 (손대지 않음)",
|
|
662
|
+
misplaced: "꺼짐, 단 블록이 Codex 가 읽지 않는 AGENTS.md 에 있음 (on 으로 옮김)",
|
|
663
|
+
hidden: "켜짐, 단 AGENTS.override.md 에 블록만 있어 AGENTS.md 가 가려짐 (on 으로 옮김)",
|
|
664
|
+
notutf8: "UTF-8 이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
|
|
665
|
+
notregular: "일반 파일이 아닌 지침 파일이 있어 손대지 않음 (on, off 모두 거부)",
|
|
666
|
+
unpaired: "표시의 짝이 맞지 않음 (on, off 모두 거부. 남은 표시 줄 정리 필요)",
|
|
667
|
+
dangling: "꺼짐, 단 AGENTS.md 가 끊어진 링크라 on 이 거부함 (링크 확인 필요)",
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
// A state as the status line says it.
|
|
671
|
+
function stateText(t, { state, who }) {
|
|
672
|
+
if (state === "stale") return t.staleText;
|
|
673
|
+
if (state === "shadowed") {
|
|
674
|
+
return `꺼짐, 단 AGENTS.override.md 에 다른 banker 블록(${who.join(", ")})만 있어 Codex 가 AGENTS.md 를 읽지 않음 (그 스킬에서 on 으로 옮김)`;
|
|
675
|
+
}
|
|
676
|
+
return STATE_TEXT[state] ?? state;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// One status line. Read failures are reported, not thrown: status is where an
|
|
680
|
+
// exit 2 sends the user to look.
|
|
681
|
+
function describe(t, name, look) {
|
|
682
|
+
try {
|
|
683
|
+
const found = look();
|
|
684
|
+
return `- ${name}: ${stateText(t, found)} (${where(found.path)})`;
|
|
685
|
+
} catch (e) {
|
|
686
|
+
return `- ${name}: 읽을 수 없음 (${where(e.code || e.message)})`;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function codexLook(t, o, rules) {
|
|
691
|
+
if (!isDir(o.codexDir)) return { state: "설정 폴더 없음", path: o.codexDir };
|
|
692
|
+
const files = codexFiles(t, o);
|
|
693
|
+
const state = codexState(t, files, rules);
|
|
694
|
+
const shadowed = files.shadowers.length > 0 && (state === "on" || state === "stale");
|
|
695
|
+
return { state: shadowed ? "shadowed" : state, path: (problemFile(files, state) ?? files.target).path, who: files.shadowers };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** One status line per runtime: "- Claude Code: ..." and "- Codex: ...". rules may be undefined. */
|
|
699
|
+
export function stateLines(t, o, rules) {
|
|
700
|
+
return [
|
|
701
|
+
describe(t, "Claude Code", () => {
|
|
702
|
+
if (!isDir(o.claudeDir)) return { state: "설정 폴더 없음", path: o.claudeDir };
|
|
703
|
+
return { state: claudeState(t, readFile(claudeFile(t, o)), rules), path: claudeFile(t, o) };
|
|
704
|
+
}),
|
|
705
|
+
describe(t, "Codex", () => codexLook(t, o, rules)),
|
|
706
|
+
];
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Whether a runtime holds the skill's rules in some form (current, older, in
|
|
711
|
+
* the wrong file or with broken markers), so that changed rules should reach
|
|
712
|
+
* it. Throws when a file cannot be read.
|
|
713
|
+
*/
|
|
714
|
+
export function present(t, runtime, o) {
|
|
715
|
+
if (runtime === "claude") return isDir(o.claudeDir) && claudeState(t, readFile(claudeFile(t, o))) === "ours";
|
|
716
|
+
return isDir(o.codexDir) && !["off", "notutf8", "notregular", "dangling"].includes(codexState(t, codexFiles(t, o)));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const knownRuntime = (runtime) => {
|
|
720
|
+
if (!RUNTIMES.includes(runtime)) throw new Error(`알 수 없는 런타임입니다: ${runtime}`);
|
|
721
|
+
};
|
|
722
|
+
|
|
723
|
+
/** Puts the rules where one runtime loads them. getRules runs after the config folder check. */
|
|
724
|
+
export function turnOn(t, runtime, o, getRules) {
|
|
725
|
+
knownRuntime(runtime);
|
|
726
|
+
return runtime === "claude" ? claudeOn(t, o, getRules) : codexOn(t, o, getRules);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Takes the rules out for one runtime; it never needs them. */
|
|
730
|
+
export function turnOff(t, runtime, o) {
|
|
731
|
+
knownRuntime(runtime);
|
|
732
|
+
return runtime === "claude" ? claudeOff(t, o) : codexOff(t, o);
|
|
733
|
+
}
|