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