@kaydash9999/banker-plugins 0.20.2 → 0.21.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 +203 -1
- package/PRIVACY.md +34 -4
- package/README.md +46 -15
- package/bin/banker.js +228 -51
- package/bin/lib/codex-hooks.mjs +249 -0
- package/bin/lib/telemetry-config.mjs +33 -16
- package/codex/manifest.json +14 -11
- package/codex/skills/graceful-pause/SKILL.md +30 -0
- package/codex/skills/progress/SKILL.md +42 -0
- package/codex/skills/progress/scripts/progress-plan.mjs +722 -0
- package/codex/skills/progress/scripts/progress.mjs +295 -0
- package/codex/transform-matrix.md +19 -14
- package/commands/setup.md +23 -0
- package/hooks/obsidize-hook.mjs +116 -4
- package/hooks/progress-plan.mjs +1 -1
- package/hooks/progress.mjs +143 -38
- package/hooks/register.mjs +13 -3
- package/hooks/telemetry-count-codex.mjs +222 -0
- package/hooks/update-checkin.mjs +15 -7
- package/hooks/update-fetch.mjs +15 -9
- package/hooks/update-notify.mjs +30 -17
- package/package.json +1 -1
- package/skills/3d-intro-build/SKILL.md +11 -5
- package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
- package/skills/3d-intro-build/references/curate.mjs +48 -14
- package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
- package/skills/3d-intro-build/references/prompts.md +4 -4
- package/skills/3d-intro-build/references/serve.mjs +6 -1
- package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
- package/skills/3d-intro-build/references/video-pool.mjs +250 -135
- package/skills/3d-intro-setup/SKILL.md +41 -10
- package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
- package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
- package/skills/anti-drm/SKILL.md +6 -3
- package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
- package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
- package/skills/append_wiki/SKILL.md +34 -8
- package/skills/audit-mock/SKILL.md +1 -1
- package/skills/audit-security/SKILL.md +64 -2
- package/skills/compact-wiki/SKILL.md +37 -6
- package/skills/deep-research/SKILL.md +11 -0
- package/skills/docs-setup/SKILL.md +37 -7
- package/skills/lfm/SKILL.md +148 -0
- package/skills/lineage/SKILL.md +25 -15
- package/skills/lineage/lineage.py +564 -301
- package/skills/motion-graphic-make/SKILL.md +1 -1
- package/skills/motion-graphic-make/references/curate.mjs +48 -14
- package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
- package/skills/motion-graphic-make/references/serve.mjs +6 -1
- package/skills/motion-graphic-setup/SKILL.md +58 -13
- package/skills/obsidizer/SKILL.md +30 -11
- package/skills/obsidizer/obsidize.mjs +50 -12
- package/skills/omc-patch/SKILL.md +86 -23
- package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
- package/skills/payload-mon/SKILL.md +6 -2
- package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
- package/skills/payload-mon/scripts/payload-size.mjs +4 -1
- package/skills/pdf-vision-extract/SKILL.md +11 -6
- package/skills/ralph-qa/SKILL.md +125 -151
- package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
- package/skills/ralph-qa/references/payload-scan.mjs +22 -7
- package/skills/ralph-qa/references/seat-payload.mjs +646 -0
- package/skills/ralph-qa/references/seat-run.mjs +956 -0
- package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
- package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
- package/skills/rfp-author/SKILL.md +5 -3
- package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
- package/skills/setup-bypass-permissions/SKILL.md +27 -9
- package/skills/setup-bypass-permissions/scripts/codex-bypass.mjs +135 -0
- package/skills/setup-bypass-permissions/scripts/codex-config.mjs +210 -0
- package/skills/setup-harness-factory/SKILL.md +17 -1
- package/skills/setup-insane-search/SKILL.md +16 -0
- package/skills/setup-java/SKILL.md +20 -1
- package/skills/setup-lsp/SKILL.md +56 -13
- package/skills/setup-mcp/SKILL.md +73 -4
- package/skills/setup-node/SKILL.md +31 -6
- package/skills/setup-omc/SKILL.md +24 -0
- package/skills/setup-omc-hud/SKILL.md +42 -9
- package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
- package/skills/setup-omc-hud/assets/codex-config.mjs +210 -0
- package/skills/setup-omc-hud/assets/codex-status-line.mjs +90 -0
- package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
- package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
- package/skills/setup-playwright/SKILL.md +45 -2
- package/skills/setup-pwsh/SKILL.md +19 -0
- package/skills/setup-python/SKILL.md +45 -7
- package/skills/setup-sandbox/SKILL.md +30 -5
- package/skills/setup-stitch/SKILL.md +22 -0
- package/skills/setup-tmux/SKILL.md +24 -4
- package/skills/smart-compact/SKILL.md +58 -12
- package/skills/smart-compact/scripts/codex-gate.mjs +384 -0
- package/skills/tone-compact/SKILL.md +2 -1
- package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
- package/skills/ultra-chain/SKILL.md +12 -7
- package/skills/ultra-chain/references/preview-lib.mjs +51 -15
- package/skills/ultra-chain/references/serve.mjs +6 -1
- package/skills/ultra-interview/SKILL.md +14 -2
- package/skills/update-banker/SKILL.md +19 -3
- package/skills/vertical-pptx/SKILL.md +6 -3
- package/skills/vertical-pptx-setup/SKILL.md +76 -20
package/bin/banker.js
CHANGED
|
@@ -26,6 +26,12 @@ function version() {
|
|
|
26
26
|
}
|
|
27
27
|
function log(...a) { console.log(...a); }
|
|
28
28
|
function warn(...a) { console.error(...a); }
|
|
29
|
+
// 캐시에 적힌 밀리초 시각의 ISO 글, 없거나 날짜로 바꿀 수 없으면(깨진 캐시) ''.
|
|
30
|
+
function isoTime(t) {
|
|
31
|
+
if (typeof t !== 'number' || !Number.isFinite(t)) return '';
|
|
32
|
+
const d = new Date(t);
|
|
33
|
+
return Number.isNaN(d.getTime()) ? '' : d.toISOString();
|
|
34
|
+
}
|
|
29
35
|
|
|
30
36
|
// GitHub 저장소에 별을 단다 (gh CLI 인증 사용). star 는 repo 쓰기 권한이 필요 없어 인증된 어느 계정으로도
|
|
31
37
|
// 가능하다. gh 미설치/미인증/실패는 {ok:false, reason} 로 반환해 호출부가 URL 안내로 폴백하게 한다.
|
|
@@ -50,8 +56,9 @@ function starRepo(spawn = cp.spawnSync) {
|
|
|
50
56
|
function parseArgs(argv) {
|
|
51
57
|
const a = { cmd: null, sub: null, claude: false, codex: false, scope: 'user', scopeExplicit: false, dryRun: false, help: false, version: false };
|
|
52
58
|
for (const t of argv) {
|
|
53
|
-
if (t === 'setup' || t === 'doctor' || t === 'uninstall' || t === 'help' || t === 'telemetry') { if (!a.cmd) a.cmd = t; }
|
|
54
|
-
else if (t === 'on' || t === 'off' || t === 'status') { if (!a.sub) a.sub = t; } // telemetry subcommand
|
|
59
|
+
if (t === 'setup' || t === 'doctor' || t === 'uninstall' || t === 'help' || t === 'telemetry' || t === 'hooks') { if (!a.cmd) a.cmd = t; }
|
|
60
|
+
else if (t === 'on' || t === 'off' || t === 'status' || t === 'enable' || t === 'disable') { if (!a.sub) a.sub = t; } // telemetry, hooks subcommand
|
|
61
|
+
else if (a.cmd === 'hooks' && /^[a-z]+$/.test(t)) (a.hookIds = a.hookIds || []).push(t);
|
|
55
62
|
else if (t === '--claude') a.claude = true;
|
|
56
63
|
else if (t === '--codex') a.codex = true;
|
|
57
64
|
else if (t === '--dry-run' || t === '-n') a.dryRun = true;
|
|
@@ -75,11 +82,19 @@ function assertNotRoot() {
|
|
|
75
82
|
}
|
|
76
83
|
|
|
77
84
|
function readManifest() { return JSON.parse(fs.readFileSync(MANIFEST, 'utf8')); }
|
|
78
|
-
|
|
85
|
+
// Codex gets every 'both' surface and the 'codex-only' ones (their source lives in codex/skills/, which the Claude
|
|
86
|
+
// plugin never loads: Claude Code has its own surface for them, e.g. the /graceful-pause and /progress mods).
|
|
87
|
+
function eligible(m) { return m.surfaces.filter(s => s.target === 'both' || s.target === 'codex-only'); }
|
|
88
|
+
function skillSource(s) { return s.target === 'codex-only' ? path.join(PKG_ROOT, 'codex', 'skills', s.name) : path.join(PKG_ROOT, 'skills', s.name); }
|
|
79
89
|
|
|
80
90
|
function homeDir() { return os.homedir(); }
|
|
81
91
|
function claudePluginsDir() { return path.join(homeDir(), '.claude', 'plugins'); }
|
|
82
|
-
|
|
92
|
+
// User scope is where Codex itself reads skills and hooks: $CODEX_HOME when set, else ~/.codex.
|
|
93
|
+
function codexBase(scope) {
|
|
94
|
+
if (scope === 'project') return path.join(process.cwd(), '.codex');
|
|
95
|
+
const ch = process.env.CODEX_HOME;
|
|
96
|
+
return ch ? path.resolve(ch) : path.join(homeDir(), '.codex'); // a relative one from here, as Codex reads it
|
|
97
|
+
}
|
|
83
98
|
|
|
84
99
|
function have(bin) {
|
|
85
100
|
// PATH scan — no shell (avoids DEP0190 + injection). Cross-OS.
|
|
@@ -147,6 +162,19 @@ function setCodexSkillName(skillMd, newName, dryRun) {
|
|
|
147
162
|
fs.writeFileSync(skillMd, src.slice(0, fm.index) + `---\n${block}\n---` + src.slice(fm.index + fm[0].length));
|
|
148
163
|
}
|
|
149
164
|
|
|
165
|
+
// A command as a Codex skill: the command's description becomes the skill's, under name: <name>. Codex skills get
|
|
166
|
+
// no $ARGUMENTS substitution, so a note says where the arguments come from.
|
|
167
|
+
function writeCommandSkill(src, destDir, name, dryRun) {
|
|
168
|
+
if (dryRun) { log(` [dry-run] write command skill ${path.relative(PKG_ROOT, src)} -> ${destDir}`); return; }
|
|
169
|
+
const text = fs.readFileSync(src, 'utf8');
|
|
170
|
+
const fm = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
|
171
|
+
const desc = fm && fm[1].match(/^description:\s*(.*)$/m);
|
|
172
|
+
const body = fm ? text.slice(fm[0].length) : text;
|
|
173
|
+
const note = '> Codex: 이 명령은 스킬로 설치됐다. 본문의 `$ARGUMENTS`, `$1` 은 사용자가 `$' + name + '` 뒤에 쓴 글이다.\n\n';
|
|
174
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
175
|
+
fs.writeFileSync(path.join(destDir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${desc ? desc[1] : '""'}\n---\n\n${note}${body}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
150
178
|
/* ---------- Claude Code target ---------- */
|
|
151
179
|
function setupClaude(dryRun) {
|
|
152
180
|
log('• Claude Code:');
|
|
@@ -179,21 +207,77 @@ function setupCodex(dryRun, scope) {
|
|
|
179
207
|
for (const s of eligible(m)) {
|
|
180
208
|
if (s.type === 'skill') {
|
|
181
209
|
const dest = path.join(skillsDir, `banker-${s.name}`);
|
|
182
|
-
copyDir(
|
|
210
|
+
copyDir(skillSource(s), dest, dryRun);
|
|
183
211
|
setCodexSkillName(path.join(dest, 'SKILL.md'), `banker-${s.name}`, dryRun); // dir==name (Codex discovery)
|
|
184
212
|
nSkill++;
|
|
185
213
|
} else if (s.type === 'command') {
|
|
186
|
-
|
|
214
|
+
const src = path.join(PKG_ROOT, 'commands', `${s.name}.md`);
|
|
215
|
+
copyFile(src, path.join(promptsDir, `banker-${s.name}.md`), dryRun);
|
|
216
|
+
writeCommandSkill(src, path.join(skillsDir, `banker-${s.name}`), `banker-${s.name}`, dryRun);
|
|
187
217
|
nCmd++;
|
|
188
218
|
}
|
|
189
219
|
}
|
|
190
|
-
log(` → ${nSkill} skills -> ${path.join(base, 'skills', 'banker-*')}, ${nCmd}
|
|
220
|
+
log(` → ${nSkill} skills -> ${path.join(base, 'skills', 'banker-*')}, ${nCmd} commands -> skills banker-* and prompts ${path.join(base, 'prompts', 'banker-*.md')}`);
|
|
221
|
+
log(' → Codex 0.144+ has no custom prompts, so each command is also a skill: invoke it as $banker-<name>.');
|
|
191
222
|
log(' → skills are invoked as `banker-<name>` (frontmatter name rewritten to match the dir).');
|
|
192
223
|
log(' → ~/.codex/AGENTS.md is NOT modified (omx regenerates it); skills auto-discovered from ~/.codex/skills/.');
|
|
193
224
|
return true;
|
|
194
225
|
}
|
|
195
226
|
|
|
196
227
|
/* ---------- doctor ---------- */
|
|
228
|
+
// The newest Codex session record (rollout) under <base>/sessions/YYYY/MM/DD/, or null.
|
|
229
|
+
function newestRollout(base) {
|
|
230
|
+
let at = path.join(base, 'sessions');
|
|
231
|
+
for (let depth = 0; depth < 3; depth++) {
|
|
232
|
+
let names = [];
|
|
233
|
+
try { names = fs.readdirSync(at).filter(n => /^\d+$/.test(n)).sort(); } catch { return null; }
|
|
234
|
+
if (!names.length) return null;
|
|
235
|
+
at = path.join(at, names[names.length - 1]);
|
|
236
|
+
}
|
|
237
|
+
try {
|
|
238
|
+
const files = fs.readdirSync(at).filter(n => /^rollout-.*\.jsonl$/.test(n)).sort();
|
|
239
|
+
return files.length ? path.join(at, files[files.length - 1]) : null;
|
|
240
|
+
} catch { return null; }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// What lineage, progress and the usage-count hook look for in a rollout. Codex does not promise its record shape,
|
|
244
|
+
// so after a Codex update this tells whether banker still reads it (a miss would otherwise leave empty results).
|
|
245
|
+
function codexShapeReport(file) {
|
|
246
|
+
let fd;
|
|
247
|
+
try {
|
|
248
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
|
|
249
|
+
const st = fs.fstatSync(fd);
|
|
250
|
+
if (!st.isFile()) return null;
|
|
251
|
+
const read = (start, len) => { const b = Buffer.alloc(len); fs.readSync(fd, b, 0, len, start); return b.toString('utf8'); };
|
|
252
|
+
const first = read(0, Math.min(st.size, 8 << 20)); // session_meta carries the instructions and can be large
|
|
253
|
+
const head = first.split('\n')[0];
|
|
254
|
+
if (!first.includes('\n') && st.size > (8 << 20)) return { version: '', tooLong: true }; // the first line is past 8 MB (bytes)
|
|
255
|
+
if (!first.slice(head.length).trim()) return { version: 'new', fresh: true };
|
|
256
|
+
const body = read(Math.max(0, st.size - (32 << 20)), Math.min(st.size, 32 << 20));
|
|
257
|
+
let version = '';
|
|
258
|
+
try { const m = JSON.parse(head); version = m.type === 'session_meta' ? String((m.payload || {}).cli_version || '?') : ''; } catch {}
|
|
259
|
+
return {
|
|
260
|
+
version,
|
|
261
|
+
turnStart: /"task_started"[^\n]*"turn_id"|"turn_id"[^\n]*"task_started"/.test(body),
|
|
262
|
+
userText: /"(user_message|UserMessage)"/.test(body),
|
|
263
|
+
toolCall: /"type":"[a-z_]+_call"/.test(body),
|
|
264
|
+
};
|
|
265
|
+
} catch { return null; } finally { if (fd !== undefined) try { fs.closeSync(fd); } catch {} }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function doctorCodexShape(base) {
|
|
269
|
+
const file = newestRollout(base);
|
|
270
|
+
if (!file) { log(' Codex record shape: no session record yet (run one Codex session, then doctor again)'); return; }
|
|
271
|
+
const r = codexShapeReport(file);
|
|
272
|
+
if (r && r.tooLong) { log(` Codex record shape: ${path.basename(file)} opens with a line over 8 MB, not checked`); return; }
|
|
273
|
+
if (r && r.fresh) { log(` Codex record shape: ${path.basename(file)} holds no turn yet (run one Codex turn, then doctor again)`); return; }
|
|
274
|
+
if (!r || !r.version) { log(` ⚠ Codex record shape: ${path.basename(file)} does not open with session_meta - lineage, progress and usage counting cannot read it`); return; }
|
|
275
|
+
const mark = (ok) => (ok ? 'ok' : 'MISSING');
|
|
276
|
+
log(` Codex record shape (Codex ${r.version}, ${path.basename(file)}): turn start ${mark(r.turnStart)}, user text ${mark(r.userText)}, tool calls ${mark(r.toolCall)}`);
|
|
277
|
+
if (!r.turnStart || !r.userText) log(' ⚠ banker may not read this Codex version\'s records (lineage stops, progress warns, usage counting misses); update banker');
|
|
278
|
+
else if (!r.toolCall) log(' (no tool calls: fine for a session that used no tools)');
|
|
279
|
+
}
|
|
280
|
+
|
|
197
281
|
function doctor() {
|
|
198
282
|
log(`banker v${version()} (pkg: ${PKG_ROOT})`);
|
|
199
283
|
log(`plugin.json version: ${(() => { try { return require(PLUGIN_JSON).version; } catch { return '?'; } })()}`);
|
|
@@ -205,7 +289,8 @@ function doctor() {
|
|
|
205
289
|
let installed = [];
|
|
206
290
|
try { installed = fs.readdirSync(sdir).filter(d => d.startsWith('banker-')); } catch {}
|
|
207
291
|
log(` codex CLI: ${have('codex') ? 'found' : 'NOT found'}`);
|
|
208
|
-
log(` installed banker skills in
|
|
292
|
+
log(` installed banker skills in ${sdir}: ${installed.length}`);
|
|
293
|
+
doctorCodexShape(base);
|
|
209
294
|
// Codex only discovers a skill when its dir name equals the SKILL.md `name:` — flag any mismatch.
|
|
210
295
|
const mismatched = installed
|
|
211
296
|
.map(d => ({ d, name: readSkillName(path.join(sdir, d, 'SKILL.md')) }))
|
|
@@ -252,6 +337,54 @@ function uninstallCodex(dryRun, scopes) {
|
|
|
252
337
|
}
|
|
253
338
|
}
|
|
254
339
|
|
|
340
|
+
/* ---------- Codex hooks ---------- */
|
|
341
|
+
// bin/lib/codex-hooks.mjs is ESM; loaded lazily like telemetry-config. Hooks go into the same Codex home as the skills.
|
|
342
|
+
async function loadCodexHooks() {
|
|
343
|
+
return import(require('url').pathToFileURL(path.join(__dirname, 'lib', 'codex-hooks.mjs')).href);
|
|
344
|
+
}
|
|
345
|
+
function codexHookPaths(ch) { return ch.codexPaths({ CODEX_HOME: codexBase('user') }); }
|
|
346
|
+
|
|
347
|
+
// Update notice and usage counting on Codex, as the Claude plugin's hooks.json gives them to Claude Code. User scope
|
|
348
|
+
// only: a project-scope copy would sit in a workspace, and Codex runs trusted hooks outside the sandbox.
|
|
349
|
+
async function setupCodexHooks(dryRun, scope) {
|
|
350
|
+
if (scope !== 'user') { log(' → Codex hooks (update notice, usage counting) are user-scope only; skipped for scope=project.'); return; }
|
|
351
|
+
const ch = await loadCodexHooks();
|
|
352
|
+
if (dryRun) { log(` [dry-run] Codex hooks: ${ch.DEFAULT_HOOKS.join(', ')} -> ${codexHookPaths(ch).hooks}`); return; }
|
|
353
|
+
const r = ch.enableHooks(codexHookPaths(ch), ch.DEFAULT_HOOKS, PKG_ROOT);
|
|
354
|
+
if (r.code) { warn(` Codex hooks not changed: ${r.msg}`); return; }
|
|
355
|
+
log(` → Codex hooks ${ch.DEFAULT_HOOKS.join(', ')} in ${codexHookPaths(ch).hooks}${r.changed.length ? ` (changed: ${r.changed.join(', ')})` : ' (already in place)'}.`);
|
|
356
|
+
if (r.changed.length) log(` → ${ch.TRUST_NOTE}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function uninstallCodexHooks(dryRun) {
|
|
360
|
+
const ch = await loadCodexHooks();
|
|
361
|
+
if (dryRun) { log(` [dry-run] remove banker Codex hooks from ${codexHookPaths(ch).hooks}`); return; }
|
|
362
|
+
const r = ch.removeAll(codexHookPaths(ch));
|
|
363
|
+
if (r.code) warn(` Codex hooks not changed: ${r.msg}`);
|
|
364
|
+
else if (r.changed.length) log(` removed banker Codex hooks: ${r.changed.join(', ')}${r.shifted.length ? ` (later ${r.shifted.join(', ')} hooks may need trusting again in /hooks)` : ''}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// banker hooks <status|enable|disable> [ids]: status of all, or turn named hooks on or off (obsidizer --enable on Codex).
|
|
368
|
+
async function hooksCmd(sub, ids) {
|
|
369
|
+
const ch = await loadCodexHooks();
|
|
370
|
+
const p = codexHookPaths(ch);
|
|
371
|
+
const all = Object.keys(ch.HOOKS);
|
|
372
|
+
const want = ids && ids.length ? ids : all;
|
|
373
|
+
const unknown = want.filter((id) => !all.includes(id));
|
|
374
|
+
if (unknown.length || (sub !== 'status' && !(ids && ids.length))) { warn(`usage: banker hooks <status|enable|disable> [${all.join('|')}]`); process.exitCode = 1; return; }
|
|
375
|
+
if (sub === 'status' || !sub) {
|
|
376
|
+
const s = ch.hookStatus(p, want, PKG_ROOT);
|
|
377
|
+
if (s.why) { warn(`banker hooks: ${s.why}`); process.exitCode = 1; return; }
|
|
378
|
+
for (const id of want) log(`${id}: ${s.status[id]}`);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
const r = sub === 'enable' ? ch.enableHooks(p, want, PKG_ROOT) : ch.disableHooks(p, want);
|
|
382
|
+
if (r.code) { warn(`banker hooks: not changed: ${r.msg}`); process.exitCode = 1; return; }
|
|
383
|
+
log(`banker hooks ${sub}: ${r.changed.length ? r.changed.join(', ') : 'nothing to change'}`);
|
|
384
|
+
if (sub === 'enable' && r.changed.length) log(ch.TRUST_NOTE);
|
|
385
|
+
if (r.shifted && r.shifted.length) log(`later ${r.shifted.join(', ')} hooks may need trusting again in /hooks.`);
|
|
386
|
+
}
|
|
387
|
+
|
|
255
388
|
/* ---------- telemetry config bridge ---------- */
|
|
256
389
|
// bin/banker.js is CommonJS; telemetry-config is ESM (.mjs). Load it lazily via dynamic import
|
|
257
390
|
// (pathToFileURL keeps the specifier valid on Windows). Only the interactive setup path and the
|
|
@@ -311,65 +444,105 @@ const TELEMETRY_USAGE = `Usage: banker telemetry <on|off|status>
|
|
|
311
444
|
|
|
312
445
|
async function telemetryCmd(sub) {
|
|
313
446
|
const tc = await loadTelemetry();
|
|
314
|
-
if (sub === 'on' || sub === 'off')
|
|
315
|
-
const cfg = tc.readConfig();
|
|
316
|
-
if (sub === 'off') cfg.telemetry = false; // opt-out 저장
|
|
317
|
-
else delete cfg.telemetry; // opt-out 해제: 기본-on 으로 복귀
|
|
318
|
-
if (!tc.writeConfig(cfg)) {
|
|
319
|
-
warn('banker: 설정을 저장하지 못했습니다 (config 쓰기 실패).');
|
|
320
|
-
process.exitCode = 1;
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
if (sub === 'off') {
|
|
324
|
-
log('카운팅을 껐습니다 (off). config.telemetry=false 로 opt-out 을 저장했습니다.');
|
|
325
|
-
} else {
|
|
326
|
-
log('카운팅 opt-out 을 해제했습니다 (on). 기본값(켜짐)으로 복귀합니다.');
|
|
327
|
-
if (!tc.endpoint()) log('참고: 엔드포인트가 설정되어 있지 않아 지금은 아무것도 전송하지 않습니다.');
|
|
328
|
-
}
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
447
|
+
if (sub === 'on' || sub === 'off') return telemetrySwitch(tc, sub);
|
|
331
448
|
if (sub === 'status') {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
const envOptOut = !(v === undefined || v === '' || v === '0' || v === 'false');
|
|
335
|
-
const cfgOptOut = cfg.telemetry === false;
|
|
336
|
-
log('banker 상태:');
|
|
337
|
-
log('[카운팅] 익명 사용량 카운팅 (기본값: 켜짐 · count-default-on)');
|
|
338
|
-
log(` 엔드포인트 설정: ${tc.endpoint() != null ? '설정됨' : '미설정'}`);
|
|
339
|
-
log(` opt-out(BANKER_NO_TELEMETRY): ${envOptOut ? '적용됨' : '미적용'}`);
|
|
340
|
-
log(` opt-out(config.telemetry=false): ${cfgOptOut ? '적용됨' : '미적용'}`);
|
|
341
|
-
log(` 실효 상태(effective): ${tc.countingActive() ? 'enabled' : 'disabled'}`);
|
|
342
|
-
log(' 참고: 엔드포인트가 설정되어 있고 opt-out 이 아닐 때(env·config 둘 다 아님)만 실제로 전송됩니다.');
|
|
343
|
-
|
|
344
|
-
let cache = {};
|
|
345
|
-
try { cache = JSON.parse(tc.readRegular(tc.UPDATE_CHECK_PATH)); } catch { /* 캐시 없음/malformed/일반 파일 아님(FIFO 를 기다리지 않음) */ }
|
|
346
|
-
const installed = tc.installedVersion();
|
|
347
|
-
const cmp = (cache.latest && installed) ? tc.compareVersions(cache.latest, installed) : null;
|
|
348
|
-
log('[업데이트 체크] 새 버전 알림 (기본값: 켜짐)');
|
|
349
|
-
log(` opt-out(BANKER_NO_UPDATE_CHECK/config.updateCheck=false): ${tc.noUpdateCheck() ? '적용됨' : '미적용'}`);
|
|
350
|
-
log(` 마지막 체크: ${cache.checkedAt ? new Date(cache.checkedAt).toISOString() : '없음'}`);
|
|
351
|
-
log(` 캐시된 최신 버전: ${cache.latest || '없음'} / 설치된 버전: ${installed || '알 수 없음'}`);
|
|
352
|
-
log(cmp === null ? ' 비교 불가 (캐시 없음 또는 malformed)'
|
|
353
|
-
: cmp > 0 ? ` 업데이트 있음 (${installed} → ${cache.latest})`
|
|
354
|
-
: ' 최신 버전 사용 중');
|
|
449
|
+
printCountingStatus(tc);
|
|
450
|
+
printUpdateCheckStatus(tc);
|
|
355
451
|
return;
|
|
356
452
|
}
|
|
357
453
|
log(TELEMETRY_USAGE); // 알 수 없는 하위명령: 사용법 출력
|
|
358
454
|
}
|
|
359
455
|
|
|
456
|
+
// `telemetry on|off`: config 에 opt-out 을 저장하거나(off) 지워 기본값(켜짐)으로 돌아간다(on).
|
|
457
|
+
function telemetrySwitch(tc, sub) {
|
|
458
|
+
const cfg = tc.readConfig();
|
|
459
|
+
if (sub === 'off') cfg.telemetry = false; // opt-out 저장
|
|
460
|
+
else delete cfg.telemetry; // opt-out 해제: 기본-on 으로 복귀
|
|
461
|
+
if (!tc.writeConfig(cfg)) {
|
|
462
|
+
warn('banker: 설정을 저장하지 못했습니다 (config 쓰기 실패).');
|
|
463
|
+
process.exitCode = 1;
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (sub === 'off') {
|
|
467
|
+
log('카운팅을 껐습니다 (off). config.telemetry=false 로 opt-out 을 저장했습니다.');
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
log('카운팅 opt-out 을 해제했습니다 (on). 기본값(켜짐)으로 복귀합니다.');
|
|
471
|
+
if (!tc.endpoint()) log('참고: 엔드포인트가 설정되어 있지 않아 지금은 아무것도 전송하지 않습니다.');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// `telemetry status` 의 카운팅 절: 엔드포인트, 두 opt-out, 그 결과.
|
|
475
|
+
function printCountingStatus(tc) {
|
|
476
|
+
const cfg = tc.readConfig();
|
|
477
|
+
const v = process.env.BANKER_NO_TELEMETRY;
|
|
478
|
+
const envOptOut = !(v === undefined || v === '' || v === '0' || v === 'false');
|
|
479
|
+
log('banker 상태:');
|
|
480
|
+
log('[카운팅] 익명 사용량 카운팅 (기본값: 켜짐 · count-default-on)');
|
|
481
|
+
log(` 엔드포인트 설정: ${tc.endpoint() != null ? '설정됨' : '미설정'}`);
|
|
482
|
+
log(` opt-out(BANKER_NO_TELEMETRY): ${envOptOut ? '적용됨' : '미적용'}`);
|
|
483
|
+
log(` opt-out(config.telemetry=false): ${cfg.telemetry === false ? '적용됨' : '미적용'}`);
|
|
484
|
+
log(` 실효 상태(effective): ${tc.countingActive() ? 'enabled' : 'disabled'}`);
|
|
485
|
+
log(' 참고: 엔드포인트가 설정되어 있고 opt-out 이 아닐 때(env·config 둘 다 아님)만 실제로 전송됩니다.');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// `telemetry status` 의 업데이트 확인 절: opt-out, 마지막 확인과 실패한 시도, 캐시의 최신 버전과 설치 버전.
|
|
489
|
+
function printUpdateCheckStatus(tc) {
|
|
490
|
+
const cache = readUpdateCacheFile(tc);
|
|
491
|
+
const installed = tc.installedVersion();
|
|
492
|
+
log('[업데이트 체크] 새 버전 알림 (기본값: 켜짐)');
|
|
493
|
+
log(` opt-out(BANKER_NO_UPDATE_CHECK/config.updateCheck=false): ${tc.noUpdateCheck() ? '적용됨' : '미적용'}`);
|
|
494
|
+
printLastChecks(tc, cache);
|
|
495
|
+
log(` 캐시된 최신 버전: ${cache.latest || '없음'} / 설치된 버전: ${installed || '알 수 없음'}`);
|
|
496
|
+
log(versionVerdict(tc, cache, installed));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// update-check 캐시 객체. 없음, malformed, 객체가 아닌 JSON, 일반 파일 아님(FIFO 를 기다리지 않음)은 {}.
|
|
500
|
+
function readUpdateCacheFile(tc) {
|
|
501
|
+
try {
|
|
502
|
+
const cache = JSON.parse(tc.readRegular(tc.UPDATE_CHECK_PATH));
|
|
503
|
+
return cache && typeof cache === 'object' && !Array.isArray(cache) ? cache : {};
|
|
504
|
+
} catch {
|
|
505
|
+
return {};
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// 마지막 확인 시각, 그리고 그 뒤에 실패한 시도가 있으면 그 시각과 다음 시도. 다음 시도는 훅이 쓰는 updateCheckDue 로
|
|
510
|
+
// 정한다: 지금보다 뒤의 시도 시각은 쉼으로 세지 않지만, 하루가 안 된 마지막 체크는 여전히 쉬게 한다.
|
|
511
|
+
function printLastChecks(tc, cache) {
|
|
512
|
+
log(` 마지막 체크: ${isoTime(cache.checkedAt) || '없음'}`);
|
|
513
|
+
if (isoTime(cache.triedAt) && !(cache.triedAt <= cache.checkedAt)) log(` 마지막 시도: ${isoTime(cache.triedAt)} (실패. ${nextTry(tc, cache)})`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// 실패한 시도 뒤 다음 시도가 언제인지, 훅이 쓰는 updateCheckDue 와 같은 규칙으로.
|
|
517
|
+
function nextTry(tc, cache) {
|
|
518
|
+
if (!(cache.triedAt > Date.now())) return '그 뒤 24시간은 다시 시도하지 않습니다';
|
|
519
|
+
return tc.updateCheckDue(cache) ? '시각이 지금보다 뒤라 다음 세션 시작에 다시 시도합니다'
|
|
520
|
+
: '시각이 지금보다 뒤라 쉼으로 세지 않지만, 마지막 체크에서 24시간이 지나야 다시 시도합니다';
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// 캐시의 최신 버전과 설치 버전을 견준 한 줄.
|
|
524
|
+
function versionVerdict(tc, cache, installed) {
|
|
525
|
+
if (!cache.latest && Object.keys(cache).length) return ' 비교 불가 (확인에 성공한 적이 없어 최신 버전 정보 없음)';
|
|
526
|
+
const cmp = (cache.latest && installed) ? tc.compareVersions(cache.latest, installed) : null;
|
|
527
|
+
if (cmp === null) return ' 비교 불가 (캐시 없음 또는 malformed)';
|
|
528
|
+
return cmp > 0 ? ` 업데이트 있음 (${installed} → ${cache.latest})` : ' 최신 버전 사용 중';
|
|
529
|
+
}
|
|
530
|
+
|
|
360
531
|
const HELP = `banker v${version()} — install the banker plugin into Claude Code and/or Codex CLI
|
|
361
532
|
|
|
362
533
|
Usage:
|
|
363
534
|
banker setup [--claude] [--codex] [--scope user|project] [--dry-run]
|
|
364
535
|
banker uninstall [--claude] [--codex] [--scope user|project] [--dry-run]
|
|
365
536
|
banker telemetry <on|off|status>
|
|
537
|
+
banker hooks <status|enable|disable> [update|count|obsidize] (Codex hooks in $CODEX_HOME/hooks.json, default ~/.codex)
|
|
366
538
|
banker doctor
|
|
367
539
|
banker help
|
|
368
540
|
|
|
369
541
|
Notes:
|
|
370
542
|
- setup/uninstall with no target flag applies to BOTH tools.
|
|
371
543
|
- --claude registers the marketplace + installs banker@banker-plugins (skills as /banker:*).
|
|
372
|
-
- --codex copies codex-eligible skills -> ~/.codex/skills/banker-* and commands -> ~/.codex/prompts/banker-*.md
|
|
544
|
+
- --codex copies codex-eligible skills -> ~/.codex/skills/banker-* and commands -> ~/.codex/prompts/banker-*.md
|
|
545
|
+
($CODEX_HOME instead of ~/.codex when it is set).
|
|
373
546
|
- telemetry on|off toggles anonymous usage counting opt-out (default-on; config only); status prints counting + update-check state. See PRIVACY.md.
|
|
374
547
|
- Never runs as root. --dry-run prints planned actions without writing.`;
|
|
375
548
|
|
|
@@ -379,11 +552,12 @@ async function main() {
|
|
|
379
552
|
if (a.help || a.cmd === 'help' || !a.cmd) { log(HELP); return; }
|
|
380
553
|
if (a.cmd === 'doctor') { doctor(); return; }
|
|
381
554
|
if (a.cmd === 'telemetry') { await telemetryCmd(a.sub); return; }
|
|
555
|
+
if (a.cmd === 'hooks') { assertNotRoot(); await hooksCmd(a.sub, a.hookIds); return; }
|
|
382
556
|
assertNotRoot();
|
|
383
557
|
if (a.cmd === 'setup') {
|
|
384
558
|
log(`banker setup${a.dryRun ? ' (dry-run)' : ''}: claude=${a.claude} codex=${a.codex}`);
|
|
385
559
|
if (a.claude) setupClaude(a.dryRun);
|
|
386
|
-
if (a.codex
|
|
560
|
+
if (a.codex && setupCodex(a.dryRun, a.scope)) await setupCodexHooks(a.dryRun, a.scope);
|
|
387
561
|
// Best-effort first-run prompts (interactive TTY only). A prompt failure must never fail setup.
|
|
388
562
|
try { await maybeFirstRunPrompts(a); } catch { /* non-fatal */ }
|
|
389
563
|
log('done.');
|
|
@@ -391,7 +565,10 @@ async function main() {
|
|
|
391
565
|
}
|
|
392
566
|
if (a.cmd === 'uninstall') {
|
|
393
567
|
if (a.claude) uninstallClaude(a.dryRun);
|
|
394
|
-
if (a.codex)
|
|
568
|
+
if (a.codex) {
|
|
569
|
+
uninstallCodex(a.dryRun, a.scopeExplicit ? [a.scope] : ['user', 'project']);
|
|
570
|
+
if (!a.scopeExplicit || a.scope === 'user') await uninstallCodexHooks(a.dryRun);
|
|
571
|
+
}
|
|
395
572
|
log('done.');
|
|
396
573
|
return;
|
|
397
574
|
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* banker 의 Codex 훅 설치. Claude Code 는 플러그인의 hooks/hooks.json 을 바로 읽지만 Codex 는
|
|
3
|
+
* <CODEX_HOME>/hooks.json 만 읽고, 새 훅은 사용자가 신뢰해야 돈다("Hooks need review", /hooks).
|
|
4
|
+
*
|
|
5
|
+
* - 훅 스크립트와 그 의존 파일을 <CODEX_HOME>/hooks-custom/banker/ 에 패키지와 같은 배치로 복사하고, 훅은 그 사본을
|
|
6
|
+
* 돌린다. Codex 는 신뢰한 훅을 샌드박스 밖에서 돌리므로, 작업 폴더 안의 파일을 가리키지 않게 하기 위해서다.
|
|
7
|
+
* - 묶음은 각 이벤트의 맨 뒤에 더한다. Codex 는 신뢰를 <hooks.json>:<이벤트>:<묶음 번호>:<항목 번호> 로 기록해, 앞에
|
|
8
|
+
* 끼우면 OMX 훅의 번호가 밀려 다시 신뢰해야 한다. 빼기는 우리 항목만, 낡은 명령은 같은 자리에서 고친다.
|
|
9
|
+
* - Windows 의 Codex 는 훅 명령을 PowerShell 로 돌린다(Codex 0.160.1 실측): `& '<node>' '<스크립트>' 인자`.
|
|
10
|
+
* - hooks.json 이 링크이거나 JSON 객체가 아니거나 "없음" 말고 다른 이유로 확인할 수 없으면 바꾸지 않는다. 바꿀 때마다
|
|
11
|
+
* 직전 내용을 hooks.json.banker.bak 에 둔다. smart-compact 의 codex-gate.mjs 와 같은 규칙이다.
|
|
12
|
+
*/
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
|
|
18
|
+
// 훅마다 이벤트, 스크립트(패키지 기준 경로), 인자, 도구 이름 matcher(있으면 그 도구 호출 뒤에만 돈다).
|
|
19
|
+
export const HOOKS = {
|
|
20
|
+
update: { event: 'SessionStart', script: 'hooks/update-notify.mjs', args: ['--codex'] },
|
|
21
|
+
count: { event: 'Stop', script: 'hooks/telemetry-count-codex.mjs', args: [] },
|
|
22
|
+
obsidize: { event: 'PostToolUse', script: 'hooks/obsidize-hook.mjs', args: ['--codex'], matcher: 'Bash' },
|
|
23
|
+
};
|
|
24
|
+
// 사본으로 옮길 파일(훅 스크립트와 그 의존 파일). 상대 경로를 그대로 둬 '../bin/lib/...' 가져오기가 맞는다.
|
|
25
|
+
export const PAYLOAD = [
|
|
26
|
+
'.claude-plugin/plugin.json',
|
|
27
|
+
'bin/lib/telemetry-config.mjs',
|
|
28
|
+
'hooks/update-notify.mjs',
|
|
29
|
+
'hooks/update-fetch.mjs',
|
|
30
|
+
'hooks/update-checkin.mjs',
|
|
31
|
+
'hooks/telemetry-count-codex.mjs',
|
|
32
|
+
'hooks/obsidize-hook.mjs',
|
|
33
|
+
'skills/obsidizer/obsidize.mjs',
|
|
34
|
+
];
|
|
35
|
+
export const DEFAULT_HOOKS = ['update', 'count'];
|
|
36
|
+
|
|
37
|
+
// Codex 처럼 상대 경로 CODEX_HOME 은 지금 폴더 기준으로 푼다.
|
|
38
|
+
export function codexPaths(env = process.env) {
|
|
39
|
+
const home = env.CODEX_HOME ? path.resolve(env.CODEX_HOME) : path.join(os.homedir(), '.codex');
|
|
40
|
+
return { home, hooks: path.join(home, 'hooks.json'), backup: path.join(home, 'hooks.json.banker.bak'), dir: path.join(home, 'hooks-custom', 'banker') };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
44
|
+
|
|
45
|
+
function writeAtomic(file, data) {
|
|
46
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
47
|
+
try {
|
|
48
|
+
fs.writeFileSync(tmp, data, { flag: 'wx' });
|
|
49
|
+
const mode = modeOf(file);
|
|
50
|
+
if (mode !== null) fs.chmodSync(tmp, mode);
|
|
51
|
+
fs.renameSync(tmp, file);
|
|
52
|
+
} catch (e) {
|
|
53
|
+
fs.rmSync(tmp, { force: true });
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function modeOf(file) {
|
|
59
|
+
try {
|
|
60
|
+
const st = fs.lstatSync(file);
|
|
61
|
+
return st.isFile() ? st.mode & 0o7777 : null;
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// 명령 글. POSIX 는 큰따옴표, 셸이 그 안에서 읽는 글자는 거부. Windows 는 PowerShell 호출 연산자와 작은따옴표.
|
|
68
|
+
export function formatCommand(node, script, args, win = process.platform === 'win32') {
|
|
69
|
+
const all = [node, script, ...args].join('');
|
|
70
|
+
if (/[\r\n\0]/.test(all)) return null;
|
|
71
|
+
if (win) {
|
|
72
|
+
const q = (s) => `'${s.replace(/['‘-‛]/g, '$&$&')}'`;
|
|
73
|
+
return ['&', q(node), q(script), ...args].join(' ');
|
|
74
|
+
}
|
|
75
|
+
if (/["$`\\]/.test(all)) return null;
|
|
76
|
+
return [`"${node}"`, `"${script}"`, ...args].join(' ');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 임시 폴더의 node 는 거부한다. Codex 안에서 돌 때는 작업 폴더 안의 node 도(샌드박스 안 세션이 고칠 수 있다).
|
|
80
|
+
export function safeNode(node, { win = process.platform === 'win32', cwd = process.cwd(), codex = Boolean(process.env.CODEX_THREAD_ID) } = {}) {
|
|
81
|
+
const P = win ? path.win32 : path.posix;
|
|
82
|
+
const under = (f, dir) => {
|
|
83
|
+
const r = P.relative(dir, f);
|
|
84
|
+
return r === '' || (!r.startsWith('..') && !P.isAbsolute(r));
|
|
85
|
+
};
|
|
86
|
+
// A link elsewhere is judged where it leads too, so a link in a safe place cannot pin a node from the temp folder.
|
|
87
|
+
const places = [node, real(node)];
|
|
88
|
+
return !places.some((f) => under(f, os.tmpdir()) || (codex && under(f, cwd)));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function real(file) {
|
|
92
|
+
try {
|
|
93
|
+
return fs.realpathSync(file);
|
|
94
|
+
} catch {
|
|
95
|
+
return file;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const scriptOf = (p, id) => path.join(p.dir, ...HOOKS[id].script.split('/'));
|
|
100
|
+
const markOf = (id) => `/hooks-custom/banker/${HOOKS[id].script}`;
|
|
101
|
+
const isOurs = (h, id) => isObject(h) && typeof h.command === 'string' && h.command.replace(/\\/g, '/').includes(markOf(id));
|
|
102
|
+
|
|
103
|
+
function fileKind(file) {
|
|
104
|
+
try {
|
|
105
|
+
const st = fs.lstatSync(file);
|
|
106
|
+
return st.isSymbolicLink() || !st.isFile() ? `${file} 이 일반 파일이 아님(링크나 폴더)` : '';
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return e && e.code === 'ENOENT' ? 'missing' : `${file} 을 확인하지 못함(${e && e.code})`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// hooks.json 을 객체로, 또는 바꿀 수 없는 까닭. 쓰는 이벤트의 값이 배열이 아니면 거부한다.
|
|
113
|
+
export function loadHooks(file, events) {
|
|
114
|
+
const kind = fileKind(file);
|
|
115
|
+
if (kind === 'missing') return { doc: { hooks: {} } };
|
|
116
|
+
if (kind) return { why: kind };
|
|
117
|
+
let doc;
|
|
118
|
+
try {
|
|
119
|
+
doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
120
|
+
} catch {
|
|
121
|
+
doc = null;
|
|
122
|
+
}
|
|
123
|
+
if (!isObject(doc)) return { why: `${file} 이 JSON 객체가 아님` };
|
|
124
|
+
if (doc.hooks === undefined) doc.hooks = {};
|
|
125
|
+
if (!isObject(doc.hooks)) return { why: `${file} 의 hooks 가 객체가 아님` };
|
|
126
|
+
const bad = events.find((e) => doc.hooks[e] !== undefined && !Array.isArray(doc.hooks[e]));
|
|
127
|
+
return bad ? { why: `${file} 의 ${bad} 가 배열이 아님` } : { doc };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function findOurs(groups, id) {
|
|
131
|
+
for (let g = 0; g < groups.length; g++) {
|
|
132
|
+
const hs = isObject(groups[g]) && Array.isArray(groups[g].hooks) ? groups[g].hooks : [];
|
|
133
|
+
const h = hs.findIndex((x) => isOurs(x, id));
|
|
134
|
+
if (h >= 0) return [g, h];
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function saveHooks(p, doc) {
|
|
140
|
+
if (fs.existsSync(p.hooks)) writeAtomic(p.backup, fs.readFileSync(p.hooks));
|
|
141
|
+
writeAtomic(p.hooks, `${JSON.stringify(doc, null, 2)}\n`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 사본 폴더. 링크인 폴더는 거부하고, 파일은 내용이 다를 때만 새로 쓴다.
|
|
145
|
+
export function installPayload(p, pkgRoot) {
|
|
146
|
+
fs.mkdirSync(p.dir, { recursive: true });
|
|
147
|
+
if (fs.lstatSync(p.dir).isSymbolicLink()) return `${p.dir} 이 링크임`;
|
|
148
|
+
for (const rel of PAYLOAD) {
|
|
149
|
+
const dest = path.join(p.dir, ...rel.split('/'));
|
|
150
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
151
|
+
if (fs.existsSync(dest) && !fs.lstatSync(dest).isFile()) return `${dest} 이 일반 파일이 아님`;
|
|
152
|
+
const data = fs.readFileSync(path.join(pkgRoot, ...rel.split('/')));
|
|
153
|
+
if (!fs.existsSync(dest) || !fs.readFileSync(dest).equals(data)) writeAtomic(dest, data);
|
|
154
|
+
}
|
|
155
|
+
return '';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 한 훅을 제자리에 두기: 없으면 이벤트 맨 뒤 묶음으로, 명령이 낡았으면 같은 자리에서 고친다. 바뀌었으면 true.
|
|
159
|
+
function place(doc, id, command) {
|
|
160
|
+
const ev = HOOKS[id].event;
|
|
161
|
+
const groups = doc.hooks[ev] || [];
|
|
162
|
+
const at = findOurs(groups, id);
|
|
163
|
+
if (at && groups[at[0]].hooks[at[1]].command === command) return false;
|
|
164
|
+
if (at) groups[at[0]].hooks[at[1]] = { ...groups[at[0]].hooks[at[1]], command, timeout: 5 };
|
|
165
|
+
else groups.push({ ...(HOOKS[id].matcher ? { matcher: HOOKS[id].matcher } : {}), hooks: [{ type: 'command', command, timeout: 5 }] });
|
|
166
|
+
doc.hooks[ev] = groups;
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Codex 홈이 지금 폴더나 그 저장소 안이면(예: 저장소의 .envrc 가 CODEX_HOME=./.codex) 훅 사본도 작업 폴더 안에 놓여,
|
|
171
|
+
// 샌드박스 안의 세션이 샌드박스 밖에서 도는 훅을 고칠 수 있다. 홈 폴더와 파일 시스템 루트는 작업 폴더로 보지 않는다.
|
|
172
|
+
export function homeInWorkspace(home, cwd = process.cwd()) {
|
|
173
|
+
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', windowsHide: true });
|
|
174
|
+
const roots = [cwd, top.status === 0 ? top.stdout.trim() : ''].filter(Boolean).map(real);
|
|
175
|
+
const skip = new Set([real(os.homedir()), path.parse(real(cwd)).root]);
|
|
176
|
+
const h = real(home);
|
|
177
|
+
return roots.some((r) => !skip.has(r) && (h === r || h.startsWith(r.endsWith(path.sep) ? r : r + path.sep)));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ids 를 켠다. { code, changed:[id], msg }. code 1 이면 아무것도 바꾸지 않았다.
|
|
181
|
+
export function enableHooks(p, ids, pkgRoot, node = process.execPath, cwd = process.cwd()) {
|
|
182
|
+
if (!safeNode(node)) return { code: 1, changed: [], msg: `node 가 안전하지 않은 곳에 있음(${node})` };
|
|
183
|
+
if (homeInWorkspace(p.home, cwd)) return { code: 1, changed: [], msg: `Codex 홈(${p.home})이 작업 폴더 안이라 훅을 설치하지 않음. 샌드박스가 고칠 수 있는 곳임` };
|
|
184
|
+
const { doc, why } = loadHooks(p.hooks, ids.map((id) => HOOKS[id].event));
|
|
185
|
+
if (why) return { code: 1, changed: [], msg: why };
|
|
186
|
+
const cmds = ids.map((id) => [id, formatCommand(node, scriptOf(p, id), HOOKS[id].args)]);
|
|
187
|
+
if (cmds.some(([, c]) => !c)) return { code: 1, changed: [], msg: '경로에 셸이 읽는 글자가 있음' };
|
|
188
|
+
const bad = installPayload(p, pkgRoot);
|
|
189
|
+
if (bad) return { code: 1, changed: [], msg: bad };
|
|
190
|
+
const changed = cmds.filter(([id, c]) => place(doc, id, c)).map(([id]) => id);
|
|
191
|
+
if (changed.length) saveHooks(p, doc);
|
|
192
|
+
return { code: 0, changed, msg: '' };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ids 를 끈다: 우리 항목만 빼고, 비는 묶음과 이벤트를 지운다. 뒤 묶음의 번호가 밀리면 shifted 에 이벤트를 적는다.
|
|
196
|
+
export function disableHooks(p, ids) {
|
|
197
|
+
const { doc, why } = loadHooks(p.hooks, ids.map((id) => HOOKS[id].event));
|
|
198
|
+
if (why) return { code: 1, changed: [], shifted: [], msg: why };
|
|
199
|
+
const changed = [];
|
|
200
|
+
const shifted = [];
|
|
201
|
+
for (const id of ids) {
|
|
202
|
+
const ev = HOOKS[id].event;
|
|
203
|
+
const groups = doc.hooks[ev] || [];
|
|
204
|
+
const at = findOurs(groups, id);
|
|
205
|
+
if (!at) continue;
|
|
206
|
+
const hs = groups[at[0]].hooks.filter((h) => !isOurs(h, id));
|
|
207
|
+
if (hs.length) groups[at[0]].hooks = hs;
|
|
208
|
+
else {
|
|
209
|
+
if (at[0] < groups.length - 1) shifted.push(ev);
|
|
210
|
+
groups.splice(at[0], 1);
|
|
211
|
+
}
|
|
212
|
+
if (groups.length) doc.hooks[ev] = groups;
|
|
213
|
+
else delete doc.hooks[ev];
|
|
214
|
+
changed.push(id);
|
|
215
|
+
}
|
|
216
|
+
if (changed.length) saveHooks(p, doc);
|
|
217
|
+
return { code: 0, changed, shifted, msg: '' };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 훅마다 'on' | 'off' | 'stale'(명령이 이 node 와 사본을 가리키지 않거나 사본이 이 판과 다름). 읽지 못하면 why.
|
|
221
|
+
export function hookStatus(p, ids, pkgRoot, node = process.execPath) {
|
|
222
|
+
const { doc, why } = loadHooks(p.hooks, ids.map((id) => HOOKS[id].event));
|
|
223
|
+
if (why) return { why };
|
|
224
|
+
const out = {};
|
|
225
|
+
for (const id of ids) {
|
|
226
|
+
const groups = doc.hooks[HOOKS[id].event] || [];
|
|
227
|
+
const at = findOurs(groups, id);
|
|
228
|
+
if (!at) out[id] = 'off';
|
|
229
|
+
else out[id] = groups[at[0]].hooks[at[1]].command === formatCommand(node, scriptOf(p, id), HOOKS[id].args) && copyCurrent(p, pkgRoot) ? 'on' : 'stale';
|
|
230
|
+
}
|
|
231
|
+
return { status: out };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function copyCurrent(p, pkgRoot) {
|
|
235
|
+
try {
|
|
236
|
+
return PAYLOAD.every((rel) => fs.readFileSync(path.join(p.dir, ...rel.split('/'))).equals(fs.readFileSync(path.join(pkgRoot, ...rel.split('/')))));
|
|
237
|
+
} catch {
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// 모든 banker 훅을 빼고 사본 폴더를 지운다(banker uninstall --codex).
|
|
243
|
+
export function removeAll(p) {
|
|
244
|
+
const r = disableHooks(p, Object.keys(HOOKS));
|
|
245
|
+
if (r.code === 0) fs.rmSync(p.dir, { recursive: true, force: true });
|
|
246
|
+
return r;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export const TRUST_NOTE = 'Codex 를 다시 열면 뜨는 Hooks need review 창이나 /hooks 에서 banker 훅을 신뢰하십시오. 신뢰 전에는 돌지 않습니다.';
|