@kaydash9999/banker-plugins 0.20.5 → 0.22.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 +52 -0
- package/PRIVACY.md +25 -1
- package/README.md +63 -18
- package/bin/banker.js +173 -14
- package/bin/lib/codex-hooks.mjs +254 -0
- package/bin/lib/codex-routes.mjs +112 -0
- package/bin/lib/command-skill.cjs +19 -0
- package/bin/lib/telemetry-config.mjs +4 -1
- package/codex/command-skills/front-qa/SKILL.md +56 -0
- package/codex/command-skills/setup/SKILL.md +87 -0
- package/codex/hooks.json +29 -0
- package/codex/manifest.json +13 -10
- 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 +18 -13
- package/commands/setup.md +2 -2
- package/hooks/obsidize-hook.mjs +116 -4
- package/hooks/progress-plan.mjs +1 -1
- package/hooks/telemetry-count-codex.mjs +309 -0
- package/hooks/update-notify.mjs +105 -14
- package/package.json +1 -1
- package/skills/3d-intro-build/references/verify-intro.mjs +15 -1
- package/skills/anti-drm/SKILL.md +1 -1
- package/skills/anti-drm/scripts/instruction-toggle.mjs +57 -4
- package/skills/append_wiki/SKILL.md +34 -8
- package/skills/arch-diagram/build_pptx_template.py +47 -28
- package/skills/audit-web-page/SKILL.md +1 -1
- package/skills/audit-web-page/harness.py +0 -1
- package/skills/compact-wiki/SKILL.md +37 -6
- package/skills/deep-research/SKILL.md +6 -0
- package/skills/lfm/SKILL.md +148 -0
- package/skills/lineage/SKILL.md +14 -4
- package/skills/lineage/lineage.py +1582 -516
- package/skills/motion-graphic-make/SKILL.md +1 -1
- package/skills/obsidizer/SKILL.md +44 -10
- package/skills/obsidizer/obsidize.mjs +42 -11
- package/skills/omc-patch/SKILL.md +6 -0
- package/skills/payload-mon/SKILL.md +3 -1
- package/skills/play-qa/SKILL.md +1 -1
- package/skills/ralph-qa/SKILL.md +1 -1
- package/skills/remains/SKILL.md +1 -1
- package/skills/rfp-author/SKILL.md +5 -3
- package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
- package/skills/rfp-author/scripts/md2docx.py +63 -34
- package/skills/setup-bypass-permissions/SKILL.md +28 -10
- 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-bypass-permissions/scripts/fallback/bypass-permissions.py +66 -15
- package/skills/setup-omc-hud/SKILL.md +33 -3
- 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/smart-compact/SKILL.md +59 -13
- package/skills/smart-compact/scripts/codex-gate.mjs +385 -0
- package/skills/theme-selection/SKILL.md +1 -1
- package/skills/tone-compact/SKILL.md +1 -1
- package/skills/tone-compact/scripts/instruction-toggle.mjs +57 -4
- package/skills/ultra-chain/SKILL.md +10 -6
- package/skills/ultra-interview/SKILL.md +13 -2
- package/skills/ultra-ui-qa/SKILL.md +1 -1
- package/skills/update-banker/SKILL.md +31 -6
- package/skills/vertical-pptx/SKILL.md +1 -1
- package/skills/vertical-pptx/build_a4p.js +20 -2
- package/skills/vertical-pptx/build_a4p.py +560 -359
package/bin/banker.js
CHANGED
|
@@ -12,6 +12,7 @@ const os = require('os');
|
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const cp = require('child_process');
|
|
14
14
|
const { pathToFileURL } = require('url');
|
|
15
|
+
const { commandSkillText } = require('./lib/command-skill.cjs');
|
|
15
16
|
|
|
16
17
|
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
17
18
|
const MANIFEST = path.join(PKG_ROOT, 'codex', 'manifest.json');
|
|
@@ -56,8 +57,9 @@ function starRepo(spawn = cp.spawnSync) {
|
|
|
56
57
|
function parseArgs(argv) {
|
|
57
58
|
const a = { cmd: null, sub: null, claude: false, codex: false, scope: 'user', scopeExplicit: false, dryRun: false, help: false, version: false };
|
|
58
59
|
for (const t of argv) {
|
|
59
|
-
if (t === 'setup' || t === 'doctor' || t === 'uninstall' || t === 'help' || t === 'telemetry') { if (!a.cmd) a.cmd = t; }
|
|
60
|
-
else if (t === 'on' || t === 'off' || t === 'status') { if (!a.sub) a.sub = t; } // telemetry subcommand
|
|
60
|
+
if (t === 'setup' || t === 'doctor' || t === 'uninstall' || t === 'help' || t === 'telemetry' || t === 'hooks') { if (!a.cmd) a.cmd = t; }
|
|
61
|
+
else if (t === 'on' || t === 'off' || t === 'status' || t === 'enable' || t === 'disable') { if (!a.sub) a.sub = t; } // telemetry, hooks subcommand
|
|
62
|
+
else if (a.cmd === 'hooks' && /^[a-z]+$/.test(t)) (a.hookIds = a.hookIds || []).push(t);
|
|
61
63
|
else if (t === '--claude') a.claude = true;
|
|
62
64
|
else if (t === '--codex') a.codex = true;
|
|
63
65
|
else if (t === '--dry-run' || t === '-n') a.dryRun = true;
|
|
@@ -81,11 +83,19 @@ function assertNotRoot() {
|
|
|
81
83
|
}
|
|
82
84
|
|
|
83
85
|
function readManifest() { return JSON.parse(fs.readFileSync(MANIFEST, 'utf8')); }
|
|
84
|
-
|
|
86
|
+
// Codex gets every 'both' surface and the 'codex-only' ones (their source lives in codex/skills/, which the Claude
|
|
87
|
+
// plugin never loads: Claude Code has its own surface for them, e.g. the /graceful-pause and /progress mods).
|
|
88
|
+
function eligible(m) { return m.surfaces.filter(s => s.target === 'both' || s.target === 'codex-only'); }
|
|
89
|
+
function skillSource(s) { return s.target === 'codex-only' ? path.join(PKG_ROOT, 'codex', 'skills', s.name) : path.join(PKG_ROOT, 'skills', s.name); }
|
|
85
90
|
|
|
86
91
|
function homeDir() { return os.homedir(); }
|
|
87
92
|
function claudePluginsDir() { return path.join(homeDir(), '.claude', 'plugins'); }
|
|
88
|
-
|
|
93
|
+
// User scope is where Codex itself reads skills and hooks: $CODEX_HOME when set, else ~/.codex.
|
|
94
|
+
function codexBase(scope) {
|
|
95
|
+
if (scope === 'project') return path.join(process.cwd(), '.codex');
|
|
96
|
+
const ch = process.env.CODEX_HOME;
|
|
97
|
+
return ch ? path.resolve(ch) : path.join(homeDir(), '.codex'); // a relative one from here, as Codex reads it
|
|
98
|
+
}
|
|
89
99
|
|
|
90
100
|
function have(bin) {
|
|
91
101
|
// PATH scan — no shell (avoids DEP0190 + injection). Cross-OS.
|
|
@@ -153,6 +163,13 @@ function setCodexSkillName(skillMd, newName, dryRun) {
|
|
|
153
163
|
fs.writeFileSync(skillMd, src.slice(0, fm.index) + `---\n${block}\n---` + src.slice(fm.index + fm[0].length));
|
|
154
164
|
}
|
|
155
165
|
|
|
166
|
+
// A command as a Codex skill (bin/lib/command-skill.cjs, shared with the Codex plugin's codex/command-skills/).
|
|
167
|
+
function writeCommandSkill(src, destDir, name, dryRun) {
|
|
168
|
+
if (dryRun) { log(` [dry-run] write command skill ${path.relative(PKG_ROOT, src)} -> ${destDir}`); return; }
|
|
169
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
170
|
+
fs.writeFileSync(path.join(destDir, 'SKILL.md'), commandSkillText(fs.readFileSync(src, 'utf8'), name, `$${name}`));
|
|
171
|
+
}
|
|
172
|
+
|
|
156
173
|
/* ---------- Claude Code target ---------- */
|
|
157
174
|
function setupClaude(dryRun) {
|
|
158
175
|
log('• Claude Code:');
|
|
@@ -185,22 +202,99 @@ function setupCodex(dryRun, scope) {
|
|
|
185
202
|
for (const s of eligible(m)) {
|
|
186
203
|
if (s.type === 'skill') {
|
|
187
204
|
const dest = path.join(skillsDir, `banker-${s.name}`);
|
|
188
|
-
copyDir(
|
|
205
|
+
copyDir(skillSource(s), dest, dryRun);
|
|
189
206
|
setCodexSkillName(path.join(dest, 'SKILL.md'), `banker-${s.name}`, dryRun); // dir==name (Codex discovery)
|
|
190
207
|
nSkill++;
|
|
191
208
|
} else if (s.type === 'command') {
|
|
192
|
-
|
|
209
|
+
const src = path.join(PKG_ROOT, 'commands', `${s.name}.md`);
|
|
210
|
+
copyFile(src, path.join(promptsDir, `banker-${s.name}.md`), dryRun);
|
|
211
|
+
writeCommandSkill(src, path.join(skillsDir, `banker-${s.name}`), `banker-${s.name}`, dryRun);
|
|
193
212
|
nCmd++;
|
|
194
213
|
}
|
|
195
214
|
}
|
|
196
|
-
log(` → ${nSkill} skills -> ${path.join(base, 'skills', 'banker-*')}, ${nCmd}
|
|
215
|
+
log(` → ${nSkill} skills -> ${path.join(base, 'skills', 'banker-*')}, ${nCmd} commands -> skills banker-* and prompts ${path.join(base, 'prompts', 'banker-*.md')}`);
|
|
216
|
+
log(' → Codex 0.144+ has no custom prompts, so each command is also a skill: invoke it as $banker-<name>.');
|
|
197
217
|
log(' → skills are invoked as `banker-<name>` (frontmatter name rewritten to match the dir).');
|
|
198
218
|
log(' → ~/.codex/AGENTS.md is NOT modified (omx regenerates it); skills auto-discovered from ~/.codex/skills/.');
|
|
199
219
|
return true;
|
|
200
220
|
}
|
|
201
221
|
|
|
202
222
|
/* ---------- doctor ---------- */
|
|
203
|
-
|
|
223
|
+
// The newest Codex session record (rollout) under <base>/sessions/YYYY/MM/DD/, or null.
|
|
224
|
+
function newestRollout(base) {
|
|
225
|
+
let at = path.join(base, 'sessions');
|
|
226
|
+
for (let depth = 0; depth < 3; depth++) {
|
|
227
|
+
let names = [];
|
|
228
|
+
try { names = fs.readdirSync(at).filter(n => /^\d+$/.test(n)).sort(); } catch { return null; }
|
|
229
|
+
if (!names.length) return null;
|
|
230
|
+
at = path.join(at, names[names.length - 1]);
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const files = fs.readdirSync(at).filter(n => /^rollout-.*\.jsonl$/.test(n)).sort();
|
|
234
|
+
return files.length ? path.join(at, files[files.length - 1]) : null;
|
|
235
|
+
} catch { return null; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// What lineage, progress and the usage-count hook look for in a rollout. Codex does not promise its record shape,
|
|
239
|
+
// so after a Codex update this tells whether banker still reads it (a miss would otherwise leave empty results).
|
|
240
|
+
function codexShapeReport(file) {
|
|
241
|
+
let fd;
|
|
242
|
+
try {
|
|
243
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK || 0));
|
|
244
|
+
const st = fs.fstatSync(fd);
|
|
245
|
+
if (!st.isFile()) return null;
|
|
246
|
+
const read = (start, len) => { const b = Buffer.alloc(len); fs.readSync(fd, b, 0, len, start); return b.toString('utf8'); };
|
|
247
|
+
const first = read(0, Math.min(st.size, 8 << 20)); // session_meta carries the instructions and can be large
|
|
248
|
+
const head = first.split('\n')[0];
|
|
249
|
+
if (!first.includes('\n') && st.size > (8 << 20)) return { version: '', tooLong: true }; // the first line is past 8 MB (bytes)
|
|
250
|
+
if (!first.slice(head.length).trim()) return { version: 'new', fresh: true };
|
|
251
|
+
const body = read(Math.max(0, st.size - (32 << 20)), Math.min(st.size, 32 << 20));
|
|
252
|
+
let version = '';
|
|
253
|
+
try { const m = JSON.parse(head); version = m.type === 'session_meta' ? String((m.payload || {}).cli_version || '?') : ''; } catch {}
|
|
254
|
+
return {
|
|
255
|
+
version,
|
|
256
|
+
turnStart: /"task_started"[^\n]*"turn_id"|"turn_id"[^\n]*"task_started"/.test(body),
|
|
257
|
+
userText: /"(user_message|UserMessage)"/.test(body),
|
|
258
|
+
toolCall: /"type":"[a-z_]+_call"/.test(body),
|
|
259
|
+
};
|
|
260
|
+
} catch { return null; } finally { if (fd !== undefined) try { fs.closeSync(fd); } catch {} }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function doctorCodexShape(base) {
|
|
264
|
+
const file = newestRollout(base);
|
|
265
|
+
if (!file) { log(' Codex record shape: no session record yet (run one Codex session, then doctor again)'); return; }
|
|
266
|
+
const r = codexShapeReport(file);
|
|
267
|
+
if (r && r.tooLong) { log(` Codex record shape: ${path.basename(file)} opens with a line over 8 MB, not checked`); return; }
|
|
268
|
+
if (r && r.fresh) { log(` Codex record shape: ${path.basename(file)} holds no turn yet (run one Codex turn, then doctor again)`); return; }
|
|
269
|
+
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; }
|
|
270
|
+
const mark = (ok) => (ok ? 'ok' : 'MISSING');
|
|
271
|
+
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)}`);
|
|
272
|
+
if (!r.turnStart || !r.userText) log(' ⚠ banker may not read this Codex version\'s records (lineage stops, progress warns, usage counting misses); update banker');
|
|
273
|
+
else if (!r.toolCall) log(' (no tool calls: fine for a session that used no tools)');
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// bin/lib/codex-routes.mjs is ESM: the two ways banker gets into Codex (this npm install, and the Codex plugin).
|
|
277
|
+
async function loadCodexRoutes() {
|
|
278
|
+
return import(pathToFileURL(path.join(__dirname, 'lib', 'codex-routes.mjs')).href);
|
|
279
|
+
}
|
|
280
|
+
// Codex plugin installs of banker in <base> as 'banker@<marketplace> (<versions in the plugin cache>)'.
|
|
281
|
+
async function codexPluginInstalls(base) {
|
|
282
|
+
const r = await loadCodexRoutes();
|
|
283
|
+
return r.pluginRoutes(base).map((id) => {
|
|
284
|
+
let vers = [];
|
|
285
|
+
try { vers = fs.readdirSync(path.join(base, 'plugins', 'cache', id.slice(id.indexOf('@') + 1), PLUGIN_NAME)); } catch {}
|
|
286
|
+
return `${id}${vers.length ? ` (${vers.join(', ')})` : ''}`;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
// Beside the Codex plugin, this npm install shows every skill twice and runs every hook twice. Say so; remove nothing.
|
|
290
|
+
async function warnCodexPlugin(base) {
|
|
291
|
+
const ids = await codexPluginInstalls(base);
|
|
292
|
+
if (!ids.length) return;
|
|
293
|
+
warn(` ⚠ banker is also installed as a Codex plugin here: ${ids.join(', ')}. Skills show twice ($banker-<name> and $banker:<name>) and the hooks run twice.`);
|
|
294
|
+
warn(` Keep one: codex plugin remove ${ids[0].split(' ')[0]}, or banker uninstall --codex.`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function doctor() {
|
|
204
298
|
log(`banker v${version()} (pkg: ${PKG_ROOT})`);
|
|
205
299
|
log(`plugin.json version: ${(() => { try { return require(PLUGIN_JSON).version; } catch { return '?'; } })()}`);
|
|
206
300
|
log('Claude Code:');
|
|
@@ -211,7 +305,8 @@ function doctor() {
|
|
|
211
305
|
let installed = [];
|
|
212
306
|
try { installed = fs.readdirSync(sdir).filter(d => d.startsWith('banker-')); } catch {}
|
|
213
307
|
log(` codex CLI: ${have('codex') ? 'found' : 'NOT found'}`);
|
|
214
|
-
log(` installed banker skills in
|
|
308
|
+
log(` installed banker skills in ${sdir}: ${installed.length}`);
|
|
309
|
+
doctorCodexShape(base);
|
|
215
310
|
// Codex only discovers a skill when its dir name equals the SKILL.md `name:` — flag any mismatch.
|
|
216
311
|
const mismatched = installed
|
|
217
312
|
.map(d => ({ d, name: readSkillName(path.join(sdir, d, 'SKILL.md')) }))
|
|
@@ -220,7 +315,12 @@ function doctor() {
|
|
|
220
315
|
log(` ⚠ dir/name mismatch (Codex will NOT list these) — reinstall with this version:`);
|
|
221
316
|
for (const x of mismatched) log(` ${x.d}/ has name: ${x.name}`);
|
|
222
317
|
}
|
|
223
|
-
|
|
318
|
+
const plugins = await codexPluginInstalls(base);
|
|
319
|
+
log(` Codex plugin (codex plugin add): ${plugins.length ? plugins.join(', ') : 'not installed'}`);
|
|
320
|
+
// A project-scope npm install (banker setup --codex --scope project) in this folder doubles the skills here too.
|
|
321
|
+
const projectNpm = (await loadCodexRoutes()).projectNpmRoot(process.cwd());
|
|
322
|
+
if (plugins.length && (installed.length || projectNpm)) log(` ⚠ banker is installed both ways${projectNpm && !installed.length ? ` (npm in ${path.join(projectNpm, '.codex')})` : ''}: skills show twice ($banker-<name> and $banker:<name>). Keep one: banker uninstall --codex, or codex plugin remove ${plugins[0].split(' ')[0]}`);
|
|
323
|
+
if (have('codex') && installed.length === 0 && !plugins.length) log(' ⚠ codex found but 0 banker skills installed — run: banker setup --codex, or codex plugin add banker@banker-plugins');
|
|
224
324
|
const m = readManifest();
|
|
225
325
|
const nSkills = eligible(m).filter(s => s.type === 'skill').length;
|
|
226
326
|
log(` manifest: ${eligible(m).length} codex-eligible surfaces (${nSkills} skills) of ${m.surfaces.length}.`);
|
|
@@ -258,6 +358,54 @@ function uninstallCodex(dryRun, scopes) {
|
|
|
258
358
|
}
|
|
259
359
|
}
|
|
260
360
|
|
|
361
|
+
/* ---------- Codex hooks ---------- */
|
|
362
|
+
// bin/lib/codex-hooks.mjs is ESM; loaded lazily like telemetry-config. Hooks go into the same Codex home as the skills.
|
|
363
|
+
async function loadCodexHooks() {
|
|
364
|
+
return import(require('url').pathToFileURL(path.join(__dirname, 'lib', 'codex-hooks.mjs')).href);
|
|
365
|
+
}
|
|
366
|
+
function codexHookPaths(ch) { return ch.codexPaths({ CODEX_HOME: codexBase('user') }); }
|
|
367
|
+
|
|
368
|
+
// Update notice and usage counting on Codex, as the Claude plugin's hooks.json gives them to Claude Code. User scope
|
|
369
|
+
// only: a project-scope copy would sit in a workspace, and Codex runs trusted hooks outside the sandbox.
|
|
370
|
+
async function setupCodexHooks(dryRun, scope) {
|
|
371
|
+
if (scope !== 'user') { log(' → Codex hooks (update notice, usage counting) are user-scope only; skipped for scope=project.'); return; }
|
|
372
|
+
const ch = await loadCodexHooks();
|
|
373
|
+
if (dryRun) { log(` [dry-run] Codex hooks: ${ch.DEFAULT_HOOKS.join(', ')} -> ${codexHookPaths(ch).hooks}`); return; }
|
|
374
|
+
const r = ch.enableHooks(codexHookPaths(ch), ch.DEFAULT_HOOKS, PKG_ROOT);
|
|
375
|
+
if (r.code) { warn(` Codex hooks not changed: ${r.msg}`); return; }
|
|
376
|
+
log(` → Codex hooks ${ch.DEFAULT_HOOKS.join(', ')} in ${codexHookPaths(ch).hooks}${r.changed.length ? ` (changed: ${r.changed.join(', ')})` : ' (already in place)'}.`);
|
|
377
|
+
if (r.changed.length) log(` → ${ch.TRUST_NOTE}`);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function uninstallCodexHooks(dryRun) {
|
|
381
|
+
const ch = await loadCodexHooks();
|
|
382
|
+
if (dryRun) { log(` [dry-run] remove banker Codex hooks from ${codexHookPaths(ch).hooks}`); return; }
|
|
383
|
+
const r = ch.removeAll(codexHookPaths(ch));
|
|
384
|
+
if (r.code) warn(` Codex hooks not changed: ${r.msg}`);
|
|
385
|
+
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)` : ''}`);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// banker hooks <status|enable|disable> [ids]: status of all, or turn named hooks on or off (obsidizer --enable on Codex).
|
|
389
|
+
async function hooksCmd(sub, ids) {
|
|
390
|
+
const ch = await loadCodexHooks();
|
|
391
|
+
const p = codexHookPaths(ch);
|
|
392
|
+
const all = Object.keys(ch.HOOKS);
|
|
393
|
+
const want = ids && ids.length ? ids : all;
|
|
394
|
+
const unknown = want.filter((id) => !all.includes(id));
|
|
395
|
+
if (unknown.length || (sub !== 'status' && !(ids && ids.length))) { warn(`usage: banker hooks <status|enable|disable> [${all.join('|')}]`); process.exitCode = 1; return; }
|
|
396
|
+
if (sub === 'status' || !sub) {
|
|
397
|
+
const s = ch.hookStatus(p, want, PKG_ROOT);
|
|
398
|
+
if (s.why) { warn(`banker hooks: ${s.why}`); process.exitCode = 1; return; }
|
|
399
|
+
for (const id of want) log(`${id}: ${s.status[id]}`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const r = sub === 'enable' ? ch.enableHooks(p, want, PKG_ROOT) : ch.disableHooks(p, want);
|
|
403
|
+
if (r.code) { warn(`banker hooks: not changed: ${r.msg}`); process.exitCode = 1; return; }
|
|
404
|
+
log(`banker hooks ${sub}: ${r.changed.length ? r.changed.join(', ') : 'nothing to change'}`);
|
|
405
|
+
if (sub === 'enable' && r.changed.length) log(ch.TRUST_NOTE);
|
|
406
|
+
if (r.shifted && r.shifted.length) log(`later ${r.shifted.join(', ')} hooks may need trusting again in /hooks.`);
|
|
407
|
+
}
|
|
408
|
+
|
|
261
409
|
/* ---------- telemetry config bridge ---------- */
|
|
262
410
|
// bin/banker.js is CommonJS; telemetry-config is ESM (.mjs). Load it lazily via dynamic import
|
|
263
411
|
// (pathToFileURL keeps the specifier valid on Windows). Only the interactive setup path and the
|
|
@@ -407,13 +555,17 @@ Usage:
|
|
|
407
555
|
banker setup [--claude] [--codex] [--scope user|project] [--dry-run]
|
|
408
556
|
banker uninstall [--claude] [--codex] [--scope user|project] [--dry-run]
|
|
409
557
|
banker telemetry <on|off|status>
|
|
558
|
+
banker hooks <status|enable|disable> [update|count|obsidize] (Codex hooks in $CODEX_HOME/hooks.json, default ~/.codex)
|
|
410
559
|
banker doctor
|
|
411
560
|
banker help
|
|
412
561
|
|
|
413
562
|
Notes:
|
|
414
563
|
- setup/uninstall with no target flag applies to BOTH tools.
|
|
415
564
|
- --claude registers the marketplace + installs banker@banker-plugins (skills as /banker:*).
|
|
416
|
-
- --codex copies codex-eligible skills -> ~/.codex/skills/banker-* and commands -> ~/.codex/prompts/banker-*.md
|
|
565
|
+
- --codex copies codex-eligible skills -> ~/.codex/skills/banker-* and commands -> ~/.codex/prompts/banker-*.md
|
|
566
|
+
($CODEX_HOME instead of ~/.codex when it is set).
|
|
567
|
+
- Codex can also take banker as a plugin (codex plugin marketplace add smallOpenSource/banker_plugins, then
|
|
568
|
+
codex plugin add banker@banker-plugins; skills as $banker:<name>). Keep one of the two ways in.
|
|
417
569
|
- telemetry on|off toggles anonymous usage counting opt-out (default-on; config only); status prints counting + update-check state. See PRIVACY.md.
|
|
418
570
|
- Never runs as root. --dry-run prints planned actions without writing.`;
|
|
419
571
|
|
|
@@ -421,13 +573,17 @@ async function main() {
|
|
|
421
573
|
const a = parseArgs(process.argv.slice(2));
|
|
422
574
|
if (a.version) { log(version()); return; }
|
|
423
575
|
if (a.help || a.cmd === 'help' || !a.cmd) { log(HELP); return; }
|
|
424
|
-
if (a.cmd === 'doctor') { doctor(); return; }
|
|
576
|
+
if (a.cmd === 'doctor') { await doctor(); return; }
|
|
425
577
|
if (a.cmd === 'telemetry') { await telemetryCmd(a.sub); return; }
|
|
578
|
+
if (a.cmd === 'hooks') { assertNotRoot(); await hooksCmd(a.sub, a.hookIds); return; }
|
|
426
579
|
assertNotRoot();
|
|
427
580
|
if (a.cmd === 'setup') {
|
|
428
581
|
log(`banker setup${a.dryRun ? ' (dry-run)' : ''}: claude=${a.claude} codex=${a.codex}`);
|
|
429
582
|
if (a.claude) setupClaude(a.dryRun);
|
|
430
|
-
if (a.codex
|
|
583
|
+
if (a.codex && setupCodex(a.dryRun, a.scope)) {
|
|
584
|
+
await setupCodexHooks(a.dryRun, a.scope);
|
|
585
|
+
if (a.scope === 'user') await warnCodexPlugin(codexBase('user'));
|
|
586
|
+
}
|
|
431
587
|
// Best-effort first-run prompts (interactive TTY only). A prompt failure must never fail setup.
|
|
432
588
|
try { await maybeFirstRunPrompts(a); } catch { /* non-fatal */ }
|
|
433
589
|
log('done.');
|
|
@@ -435,7 +591,10 @@ async function main() {
|
|
|
435
591
|
}
|
|
436
592
|
if (a.cmd === 'uninstall') {
|
|
437
593
|
if (a.claude) uninstallClaude(a.dryRun);
|
|
438
|
-
if (a.codex)
|
|
594
|
+
if (a.codex) {
|
|
595
|
+
uninstallCodex(a.dryRun, a.scopeExplicit ? [a.scope] : ['user', 'project']);
|
|
596
|
+
if (!a.scopeExplicit || a.scope === 'user') await uninstallCodexHooks(a.dryRun);
|
|
597
|
+
}
|
|
439
598
|
log('done.');
|
|
440
599
|
return;
|
|
441
600
|
}
|
|
@@ -0,0 +1,254 @@
|
|
|
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
|
+
'bin/lib/codex-routes.mjs',
|
|
29
|
+
'hooks/update-notify.mjs',
|
|
30
|
+
'hooks/update-fetch.mjs',
|
|
31
|
+
'hooks/update-checkin.mjs',
|
|
32
|
+
'hooks/telemetry-count-codex.mjs',
|
|
33
|
+
'hooks/obsidize-hook.mjs',
|
|
34
|
+
'skills/obsidizer/obsidize.mjs',
|
|
35
|
+
];
|
|
36
|
+
export const DEFAULT_HOOKS = ['update', 'count'];
|
|
37
|
+
|
|
38
|
+
// Codex 처럼 상대 경로 CODEX_HOME 은 지금 폴더 기준으로 푼다.
|
|
39
|
+
export function codexPaths(env = process.env) {
|
|
40
|
+
const home = env.CODEX_HOME ? path.resolve(env.CODEX_HOME) : path.join(os.homedir(), '.codex');
|
|
41
|
+
return { home, hooks: path.join(home, 'hooks.json'), backup: path.join(home, 'hooks.json.banker.bak'), dir: path.join(home, 'hooks-custom', 'banker') };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isObject = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
45
|
+
|
|
46
|
+
function writeAtomic(file, data) {
|
|
47
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
48
|
+
try {
|
|
49
|
+
fs.writeFileSync(tmp, data, { flag: 'wx' });
|
|
50
|
+
const mode = modeOf(file);
|
|
51
|
+
if (mode !== null) fs.chmodSync(tmp, mode);
|
|
52
|
+
fs.renameSync(tmp, file);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
fs.rmSync(tmp, { force: true });
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function modeOf(file) {
|
|
60
|
+
try {
|
|
61
|
+
const st = fs.lstatSync(file);
|
|
62
|
+
return st.isFile() ? st.mode & 0o7777 : null;
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 명령 글. POSIX 는 큰따옴표, 셸이 그 안에서 읽는 글자는 거부. Windows 는 PowerShell 호출 연산자와 작은따옴표.
|
|
69
|
+
export function formatCommand(node, script, args, win = process.platform === 'win32') {
|
|
70
|
+
const all = [node, script, ...args].join('');
|
|
71
|
+
if (/[\r\n\0]/.test(all)) return null;
|
|
72
|
+
if (win) {
|
|
73
|
+
const q = (s) => `'${s.replace(/['‘-‛]/g, '$&$&')}'`;
|
|
74
|
+
return ['&', q(node), q(script), ...args].join(' ');
|
|
75
|
+
}
|
|
76
|
+
if (/["$`\\]/.test(all)) return null;
|
|
77
|
+
return [`"${node}"`, `"${script}"`, ...args].join(' ');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 임시 폴더의 node 는 거부한다. Codex 안에서 돌 때는 작업 폴더 안의 node 도(샌드박스 안 세션이 고칠 수 있다).
|
|
81
|
+
export function safeNode(node, { win = process.platform === 'win32', cwd = process.cwd(), codex = Boolean(process.env.CODEX_THREAD_ID), home = os.homedir() } = {}) {
|
|
82
|
+
const P = win ? path.win32 : path.posix;
|
|
83
|
+
const under = (f, dir) => {
|
|
84
|
+
const r = P.relative(dir, f);
|
|
85
|
+
return r === '' || (!r.startsWith('..') && !P.isAbsolute(r));
|
|
86
|
+
};
|
|
87
|
+
// A link elsewhere is judged where it leads too, so a link in a safe place cannot pin a node from the temp folder.
|
|
88
|
+
const places = [node, real(node)];
|
|
89
|
+
// A session in the home folder is no workspace to judge by (as homeInWorkspace): nvm keeps node under ~/.nvm. Only an
|
|
90
|
+
// absolute home names one (HOME=. would make every folder the home); path.relative compares as the OS does (case on Windows).
|
|
91
|
+
const isHome = P.isAbsolute(home) && P.relative(real(home), real(cwd)) === '';
|
|
92
|
+
const inWork = codex && !isHome;
|
|
93
|
+
return !places.some((f) => under(f, os.tmpdir()) || (inWork && under(f, cwd)));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function real(file) {
|
|
97
|
+
try {
|
|
98
|
+
return fs.realpathSync(file);
|
|
99
|
+
} catch {
|
|
100
|
+
return file;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const scriptOf = (p, id) => path.join(p.dir, ...HOOKS[id].script.split('/'));
|
|
105
|
+
const markOf = (id) => `/hooks-custom/banker/${HOOKS[id].script}`;
|
|
106
|
+
const isOurs = (h, id) => isObject(h) && typeof h.command === 'string' && h.command.replace(/\\/g, '/').includes(markOf(id));
|
|
107
|
+
|
|
108
|
+
function fileKind(file) {
|
|
109
|
+
try {
|
|
110
|
+
const st = fs.lstatSync(file);
|
|
111
|
+
return st.isSymbolicLink() || !st.isFile() ? `${file} 이 일반 파일이 아님(링크나 폴더)` : '';
|
|
112
|
+
} catch (e) {
|
|
113
|
+
return e && e.code === 'ENOENT' ? 'missing' : `${file} 을 확인하지 못함(${e && e.code})`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// hooks.json 을 객체로, 또는 바꿀 수 없는 까닭. 쓰는 이벤트의 값이 배열이 아니면 거부한다.
|
|
118
|
+
export function loadHooks(file, events) {
|
|
119
|
+
const kind = fileKind(file);
|
|
120
|
+
if (kind === 'missing') return { doc: { hooks: {} } };
|
|
121
|
+
if (kind) return { why: kind };
|
|
122
|
+
let doc;
|
|
123
|
+
try {
|
|
124
|
+
doc = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
125
|
+
} catch {
|
|
126
|
+
doc = null;
|
|
127
|
+
}
|
|
128
|
+
if (!isObject(doc)) return { why: `${file} 이 JSON 객체가 아님` };
|
|
129
|
+
if (doc.hooks === undefined) doc.hooks = {};
|
|
130
|
+
if (!isObject(doc.hooks)) return { why: `${file} 의 hooks 가 객체가 아님` };
|
|
131
|
+
const bad = events.find((e) => doc.hooks[e] !== undefined && !Array.isArray(doc.hooks[e]));
|
|
132
|
+
return bad ? { why: `${file} 의 ${bad} 가 배열이 아님` } : { doc };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function findOurs(groups, id) {
|
|
136
|
+
for (let g = 0; g < groups.length; g++) {
|
|
137
|
+
const hs = isObject(groups[g]) && Array.isArray(groups[g].hooks) ? groups[g].hooks : [];
|
|
138
|
+
const h = hs.findIndex((x) => isOurs(x, id));
|
|
139
|
+
if (h >= 0) return [g, h];
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function saveHooks(p, doc) {
|
|
145
|
+
if (fs.existsSync(p.hooks)) writeAtomic(p.backup, fs.readFileSync(p.hooks));
|
|
146
|
+
writeAtomic(p.hooks, `${JSON.stringify(doc, null, 2)}\n`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 사본 폴더. 링크인 폴더는 거부하고, 파일은 내용이 다를 때만 새로 쓴다.
|
|
150
|
+
export function installPayload(p, pkgRoot) {
|
|
151
|
+
fs.mkdirSync(p.dir, { recursive: true });
|
|
152
|
+
if (fs.lstatSync(p.dir).isSymbolicLink()) return `${p.dir} 이 링크임`;
|
|
153
|
+
for (const rel of PAYLOAD) {
|
|
154
|
+
const dest = path.join(p.dir, ...rel.split('/'));
|
|
155
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
156
|
+
if (fs.existsSync(dest) && !fs.lstatSync(dest).isFile()) return `${dest} 이 일반 파일이 아님`;
|
|
157
|
+
const data = fs.readFileSync(path.join(pkgRoot, ...rel.split('/')));
|
|
158
|
+
if (!fs.existsSync(dest) || !fs.readFileSync(dest).equals(data)) writeAtomic(dest, data);
|
|
159
|
+
}
|
|
160
|
+
return '';
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 한 훅을 제자리에 두기: 없으면 이벤트 맨 뒤 묶음으로, 명령이 낡았으면 같은 자리에서 고친다. 바뀌었으면 true.
|
|
164
|
+
function place(doc, id, command) {
|
|
165
|
+
const ev = HOOKS[id].event;
|
|
166
|
+
const groups = doc.hooks[ev] || [];
|
|
167
|
+
const at = findOurs(groups, id);
|
|
168
|
+
if (at && groups[at[0]].hooks[at[1]].command === command) return false;
|
|
169
|
+
if (at) groups[at[0]].hooks[at[1]] = { ...groups[at[0]].hooks[at[1]], command, timeout: 5 };
|
|
170
|
+
else groups.push({ ...(HOOKS[id].matcher ? { matcher: HOOKS[id].matcher } : {}), hooks: [{ type: 'command', command, timeout: 5 }] });
|
|
171
|
+
doc.hooks[ev] = groups;
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Codex 홈이 지금 폴더나 그 저장소 안이면(예: 저장소의 .envrc 가 CODEX_HOME=./.codex) 훅 사본도 작업 폴더 안에 놓여,
|
|
176
|
+
// 샌드박스 안의 세션이 샌드박스 밖에서 도는 훅을 고칠 수 있다. 홈 폴더와 파일 시스템 루트는 작업 폴더로 보지 않는다.
|
|
177
|
+
export function homeInWorkspace(home, cwd = process.cwd()) {
|
|
178
|
+
const top = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf8', windowsHide: true });
|
|
179
|
+
const roots = [cwd, top.status === 0 ? top.stdout.trim() : ''].filter(Boolean).map(real);
|
|
180
|
+
const skip = new Set([real(os.homedir()), path.parse(real(cwd)).root]);
|
|
181
|
+
const h = real(home);
|
|
182
|
+
return roots.some((r) => !skip.has(r) && (h === r || h.startsWith(r.endsWith(path.sep) ? r : r + path.sep)));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ids 를 켠다. { code, changed:[id], msg }. code 1 이면 아무것도 바꾸지 않았다.
|
|
186
|
+
export function enableHooks(p, ids, pkgRoot, node = process.execPath, cwd = process.cwd()) {
|
|
187
|
+
if (!safeNode(node)) return { code: 1, changed: [], msg: `node 가 안전하지 않은 곳에 있음(${node})` };
|
|
188
|
+
if (homeInWorkspace(p.home, cwd)) return { code: 1, changed: [], msg: `Codex 홈(${p.home})이 작업 폴더 안이라 훅을 설치하지 않음. 샌드박스가 고칠 수 있는 곳임` };
|
|
189
|
+
const { doc, why } = loadHooks(p.hooks, ids.map((id) => HOOKS[id].event));
|
|
190
|
+
if (why) return { code: 1, changed: [], msg: why };
|
|
191
|
+
const cmds = ids.map((id) => [id, formatCommand(node, scriptOf(p, id), HOOKS[id].args)]);
|
|
192
|
+
if (cmds.some(([, c]) => !c)) return { code: 1, changed: [], msg: '경로에 셸이 읽는 글자가 있음' };
|
|
193
|
+
const bad = installPayload(p, pkgRoot);
|
|
194
|
+
if (bad) return { code: 1, changed: [], msg: bad };
|
|
195
|
+
const changed = cmds.filter(([id, c]) => place(doc, id, c)).map(([id]) => id);
|
|
196
|
+
if (changed.length) saveHooks(p, doc);
|
|
197
|
+
return { code: 0, changed, msg: '' };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ids 를 끈다: 우리 항목만 빼고, 비는 묶음과 이벤트를 지운다. 뒤 묶음의 번호가 밀리면 shifted 에 이벤트를 적는다.
|
|
201
|
+
export function disableHooks(p, ids) {
|
|
202
|
+
const { doc, why } = loadHooks(p.hooks, ids.map((id) => HOOKS[id].event));
|
|
203
|
+
if (why) return { code: 1, changed: [], shifted: [], msg: why };
|
|
204
|
+
const changed = [];
|
|
205
|
+
const shifted = [];
|
|
206
|
+
for (const id of ids) {
|
|
207
|
+
const ev = HOOKS[id].event;
|
|
208
|
+
const groups = doc.hooks[ev] || [];
|
|
209
|
+
const at = findOurs(groups, id);
|
|
210
|
+
if (!at) continue;
|
|
211
|
+
const hs = groups[at[0]].hooks.filter((h) => !isOurs(h, id));
|
|
212
|
+
if (hs.length) groups[at[0]].hooks = hs;
|
|
213
|
+
else {
|
|
214
|
+
if (at[0] < groups.length - 1) shifted.push(ev);
|
|
215
|
+
groups.splice(at[0], 1);
|
|
216
|
+
}
|
|
217
|
+
if (groups.length) doc.hooks[ev] = groups;
|
|
218
|
+
else delete doc.hooks[ev];
|
|
219
|
+
changed.push(id);
|
|
220
|
+
}
|
|
221
|
+
if (changed.length) saveHooks(p, doc);
|
|
222
|
+
return { code: 0, changed, shifted, msg: '' };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 훅마다 'on' | 'off' | 'stale'(명령이 이 node 와 사본을 가리키지 않거나 사본이 이 판과 다름). 읽지 못하면 why.
|
|
226
|
+
export function hookStatus(p, ids, pkgRoot, node = process.execPath) {
|
|
227
|
+
const { doc, why } = loadHooks(p.hooks, ids.map((id) => HOOKS[id].event));
|
|
228
|
+
if (why) return { why };
|
|
229
|
+
const out = {};
|
|
230
|
+
for (const id of ids) {
|
|
231
|
+
const groups = doc.hooks[HOOKS[id].event] || [];
|
|
232
|
+
const at = findOurs(groups, id);
|
|
233
|
+
if (!at) out[id] = 'off';
|
|
234
|
+
else out[id] = groups[at[0]].hooks[at[1]].command === formatCommand(node, scriptOf(p, id), HOOKS[id].args) && copyCurrent(p, pkgRoot) ? 'on' : 'stale';
|
|
235
|
+
}
|
|
236
|
+
return { status: out };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function copyCurrent(p, pkgRoot) {
|
|
240
|
+
try {
|
|
241
|
+
return PAYLOAD.every((rel) => fs.readFileSync(path.join(p.dir, ...rel.split('/'))).equals(fs.readFileSync(path.join(pkgRoot, ...rel.split('/')))));
|
|
242
|
+
} catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// 모든 banker 훅을 빼고 사본 폴더를 지운다(banker uninstall --codex).
|
|
248
|
+
export function removeAll(p) {
|
|
249
|
+
const r = disableHooks(p, Object.keys(HOOKS));
|
|
250
|
+
if (r.code === 0) fs.rmSync(p.dir, { recursive: true, force: true });
|
|
251
|
+
return r;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export const TRUST_NOTE = 'Codex 를 다시 열면 뜨는 Hooks need review 창이나 /hooks 에서 banker 훅을 신뢰하십시오. 신뢰 전에는 돌지 않습니다.';
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* banker 가 Codex 에 들어가는 두 길.
|
|
3
|
+
* - npm: `banker setup --codex` 가 <CODEX_HOME>/skills/banker-<이름>/ 에 복사한다. 스킬은 $banker-<이름>.
|
|
4
|
+
* - 플러그인: `codex plugin add banker@<마켓플레이스>` 가 <CODEX_HOME>/plugins/cache/<마켓플레이스>/banker/<판>/ 에
|
|
5
|
+
* 저장소를 두고 config.toml 에 [plugins."banker@<마켓플레이스>"] 를 적는다. 스킬은 $banker:<이름>.
|
|
6
|
+
* 둘을 함께 두면 스킬과 훅이 두 벌이 된다. 지우지는 않고 알리기만 한다(update-notify, banker setup --codex, doctor).
|
|
7
|
+
* 훅(update-notify, telemetry-count-codex)과 bin/banker.js 가 함께 쓰므로 npm 길의 훅 사본(codex-hooks.mjs 의
|
|
8
|
+
* PAYLOAD)에도 들어간다.
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
|
|
14
|
+
export const PLUGIN = 'banker';
|
|
15
|
+
export const DEFAULT_MARKETPLACE = 'banker-plugins';
|
|
16
|
+
const NAME_RE = /^[A-Za-z0-9._-]{1,80}$/;
|
|
17
|
+
|
|
18
|
+
const MAX_CONFIG = 4 << 20;
|
|
19
|
+
// 4 MB 까지의 일반 파일만 읽는다(FIFO 를 기다리지 않음, 훅의 5초 안에 끝남). 없거나 읽지 못하면 ''.
|
|
20
|
+
function readRegular(file) {
|
|
21
|
+
let fd;
|
|
22
|
+
try {
|
|
23
|
+
fd = fs.openSync(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
24
|
+
const st = fs.fstatSync(fd);
|
|
25
|
+
return st.isFile() && st.size <= MAX_CONFIG ? fs.readFileSync(fd, 'utf8') : '';
|
|
26
|
+
} catch {
|
|
27
|
+
return '';
|
|
28
|
+
} finally {
|
|
29
|
+
if (fd !== undefined) try { fs.closeSync(fd); } catch { /* best-effort */ }
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 플러그인 훅의 PLUGIN_ROOT(<홈>/plugins/cache/<마켓플레이스>/banker/<판>)에서 { home, marketplace }. 그 모양이 아니면 null.
|
|
34
|
+
export function pluginRootInfo(root) {
|
|
35
|
+
if (!root || !path.isAbsolute(root)) return null;
|
|
36
|
+
const ver = path.resolve(root);
|
|
37
|
+
const plugin = path.dirname(ver);
|
|
38
|
+
const mkt = path.dirname(plugin);
|
|
39
|
+
const cache = path.dirname(mkt);
|
|
40
|
+
const plugins = path.dirname(cache);
|
|
41
|
+
if (path.basename(plugin) !== PLUGIN || path.basename(cache) !== 'cache' || path.basename(plugins) !== 'plugins') return null;
|
|
42
|
+
const marketplace = path.basename(mkt);
|
|
43
|
+
return NAME_RE.test(marketplace) && NAME_RE.test(path.basename(ver)) ? { home: path.dirname(plugins), marketplace } : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 이 세션의 Codex 홈: rollout 이 <홈>/sessions/YYYY/MM/DD/ 에 있으면 그 홈(상대 경로 CODEX_HOME 도 Codex 가 푼 그대로),
|
|
47
|
+
// 아니면 플러그인 훅의 PLUGIN_ROOT 가 든 홈, 아니면 CODEX_HOME, 그것도 없으면 ~/.codex.
|
|
48
|
+
export function codexHomeOf(transcript, env = process.env) {
|
|
49
|
+
const t = String(transcript ?? '');
|
|
50
|
+
const sessions = path.dirname(path.dirname(path.dirname(path.dirname(t))));
|
|
51
|
+
if (t && path.isAbsolute(t) && path.basename(sessions) === 'sessions') return path.dirname(sessions);
|
|
52
|
+
const info = pluginRootInfo(env.PLUGIN_ROOT);
|
|
53
|
+
if (info) return info.home;
|
|
54
|
+
return env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// npm 길로 설치된 banker 스킬이 있는지(<홈>/skills/banker-*/SKILL.md).
|
|
58
|
+
export function npmRoute(home) {
|
|
59
|
+
try {
|
|
60
|
+
return fs.readdirSync(path.join(home, 'skills')).some((d) => d.startsWith('banker-') && fs.existsSync(path.join(home, 'skills', d, 'SKILL.md')));
|
|
61
|
+
} catch {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 켜져 있는 banker 플러그인의 id(banker@<마켓플레이스>) 목록. config.toml 의 [plugins."banker@..."] 표 중
|
|
67
|
+
// enabled = false 가 아닌 것.
|
|
68
|
+
export function pluginRoutes(home) {
|
|
69
|
+
const lines = readRegular(path.join(home, 'config.toml')).split(/\r?\n/);
|
|
70
|
+
const out = [];
|
|
71
|
+
for (let i = 0; i < lines.length; i++) {
|
|
72
|
+
const m = /^\s*\[\s*plugins\s*\.\s*["']banker@([A-Za-z0-9._-]{1,80})["']\s*\]\s*(?:#.*)?$/.exec(lines[i]);
|
|
73
|
+
if (!m) continue;
|
|
74
|
+
let enabled = true;
|
|
75
|
+
for (let j = i + 1; j < lines.length && !/^\s*\[/.test(lines[j]); j++) {
|
|
76
|
+
if (/^\s*enabled\s*=\s*false\s*(?:#.*)?$/.test(lines[j])) enabled = false;
|
|
77
|
+
}
|
|
78
|
+
if (enabled) out.push(`${PLUGIN}@${m[1]}`);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 프로젝트 범위 npm 설치(`banker setup --codex --scope project` 의 <폴더>/.codex/skills/banker-*)가 있는 폴더. cwd 에서
|
|
84
|
+
// 저장소 최상위(.git 이 있는 폴더)까지 올라가며 찾는다(저장소 밖이면 cwd 만). 없으면 ''.
|
|
85
|
+
export function projectNpmRoot(cwd) {
|
|
86
|
+
if (!cwd || !path.isAbsolute(cwd)) return '';
|
|
87
|
+
let dir = path.resolve(cwd);
|
|
88
|
+
const top = (() => {
|
|
89
|
+
for (let d = dir, i = 0; i < 64; i++) {
|
|
90
|
+
if (fs.existsSync(path.join(d, '.git'))) return d;
|
|
91
|
+
const up = path.dirname(d);
|
|
92
|
+
if (up === d) return '';
|
|
93
|
+
d = up;
|
|
94
|
+
}
|
|
95
|
+
return '';
|
|
96
|
+
})();
|
|
97
|
+
for (let i = 0; i < 64; i++) {
|
|
98
|
+
if (npmRoute(path.join(dir, '.codex'))) return dir;
|
|
99
|
+
if (!top || dir === top) return '';
|
|
100
|
+
dir = path.dirname(dir);
|
|
101
|
+
}
|
|
102
|
+
return '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 두 길이 함께 있으면 알림 글, 아니면 ''. npm 길은 사용자 범위(<홈>/skills)와, cwd 가 있으면 프로젝트 범위도 본다.
|
|
106
|
+
export function dualInstallNote(home, cwd = '') {
|
|
107
|
+
const plugins = pluginRoutes(home);
|
|
108
|
+
if (!plugins.length || !(npmRoute(home) || projectNpmRoot(cwd))) return '';
|
|
109
|
+
return `banker 가 Codex 에 두 번 설치됨: npm 의 banker setup --codex 와 Codex 플러그인 ${plugins.join(', ')}. `
|
|
110
|
+
+ '스킬이 $banker-<이름> 과 $banker:<이름> 으로 겹칩니다. 하나만 두십시오: '
|
|
111
|
+
+ `npm 쪽을 빼려면 banker uninstall --codex, 플러그인을 빼려면 codex plugin remove ${plugins[0]}`;
|
|
112
|
+
}
|