codex-toolkit 0.4.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.
@@ -0,0 +1,173 @@
1
+ // diff-budget — set a per-task ceiling on how much Codex may change.
2
+ //
3
+ // Why: scope-guard controls *where* Codex may edit. diff-budget controls
4
+ // *how much*. Even within an allowed scope, an agent can quietly rewrite
5
+ // half the file or churn 30 files in one turn. This hook catches that.
6
+ //
7
+ // Two layers of defense, both configurable:
8
+ //
9
+ // 1. Per-call ceiling (stateless, easy to reason about):
10
+ // max_bytes_per_write — refuse writes whose input is larger than N bytes.
11
+ //
12
+ // 2. Per-session ceiling (stateful, file-based counter):
13
+ // max_files_per_task — refuse once N distinct files have been touched
14
+ // in the current session.
15
+ // max_total_bytes — refuse once total bytes written exceeds N.
16
+ //
17
+ // State persists at <cwd>/.codex-toolkit/.diff-budget.json and is keyed
18
+ // by session id (best-effort) or cwd. Reset by deleting the file or
19
+ // running `codex-toolkit diff-budget reset`.
20
+
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import process from 'node:process';
24
+ import {
25
+ DECISIONS,
26
+ FILE_MUTATING_TOOLS,
27
+ emitDecision,
28
+ emitError,
29
+ parseHookInput,
30
+ extractTargetPath,
31
+ } from './hook-protocol.js';
32
+ import { readState, resolveStateFile, sessionKey, updateState } from './state-store.js';
33
+
34
+ const DEFAULT_CONFIG = {
35
+ mode: 'enforce', // 'enforce' | 'ask' | 'off'
36
+ max_bytes_per_write: 100_000, // ~100 KB per single write
37
+ max_files_per_task: 25, // ~distinct files per session
38
+ max_total_bytes: 500_000, // ~500 KB total per session
39
+ log: true,
40
+ };
41
+
42
+ function loadConfig() {
43
+ const candidates = [
44
+ process.env.CODEX_TOOLKIT_DIFF_BUDGET_CONFIG,
45
+ path.join(process.cwd(), '.codex-toolkit', 'diff-budget.json'),
46
+ path.join(process.env.HOME || '', '.codex', 'diff-budget.json'),
47
+ ].filter(Boolean);
48
+ for (const file of candidates) {
49
+ try {
50
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
51
+ return { ...DEFAULT_CONFIG, ...parsed };
52
+ } catch (err) {
53
+ if (err.code !== 'ENOENT') {
54
+ emitError(`diff-budget: failed to read ${file}: ${err.message}`);
55
+ }
56
+ }
57
+ }
58
+ return { ...DEFAULT_CONFIG };
59
+ }
60
+
61
+ function byteSizeOfWrite(input) {
62
+ if (!input || typeof input !== 'object') return 0;
63
+ const candidates = [
64
+ input.content,
65
+ input.contents,
66
+ input.new_string,
67
+ input.newString,
68
+ input.text,
69
+ input.body,
70
+ input.patch,
71
+ ];
72
+ for (const c of candidates) {
73
+ if (typeof c === 'string') return Buffer.byteLength(c, 'utf8');
74
+ }
75
+ return 0;
76
+ }
77
+
78
+ function overThreshold(value, limit) {
79
+ return typeof limit === 'number' && limit > 0 && value > limit;
80
+ }
81
+
82
+ function formatReason(kind, current, limit) {
83
+ return `diff-budget: ${kind} (${current}) exceeded limit (${limit}). Increase the limit in .codex-toolkit/diff-budget.json or split the task.`;
84
+ }
85
+
86
+ export function evaluate(event) {
87
+ const config = loadConfig();
88
+ if (config.mode === 'off') {
89
+ return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
90
+ }
91
+ if (!FILE_MUTATING_TOOLS.has(event.toolName)) {
92
+ return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
93
+ }
94
+
95
+ const target = extractTargetPath(event.toolInput);
96
+ const writeBytes = byteSizeOfWrite(event.toolInput);
97
+
98
+ // (1) per-call ceiling
99
+ if (overThreshold(writeBytes, config.max_bytes_per_write)) {
100
+ return respond(config, 'deny', formatReason('per-write size', writeBytes, config.max_bytes_per_write));
101
+ }
102
+
103
+ // (2) per-session ceiling
104
+ const stateFile = resolveStateFile('diff-budget');
105
+ const key = sessionKey(event);
106
+ const state = updateState(
107
+ stateFile,
108
+ (s) => {
109
+ s.sessions = s.sessions || {};
110
+ const sess = s.sessions[key] || { files: {}, totalBytes: 0 };
111
+ if (target && !sess.files[target]) {
112
+ sess.files[target] = { firstAt: Date.now() };
113
+ }
114
+ sess.totalBytes += writeBytes;
115
+ s.sessions[key] = sess;
116
+ return s;
117
+ },
118
+ { sessions: {} }
119
+ );
120
+ const sess = state.sessions[key] || { files: {}, totalBytes: 0 };
121
+ const fileCount = Object.keys(sess.files).length;
122
+
123
+ if (overThreshold(fileCount, config.max_files_per_task)) {
124
+ return respond(config, 'deny', formatReason('files touched this task', fileCount, config.max_files_per_task));
125
+ }
126
+ if (overThreshold(sess.totalBytes, config.max_total_bytes)) {
127
+ return respond(config, 'deny', formatReason('total bytes this task', sess.totalBytes, config.max_total_bytes));
128
+ }
129
+
130
+ return { decision: DECISIONS.ALLOW, reason: null, stats: { fileCount, totalBytes: sess.totalBytes } };
131
+ }
132
+
133
+ function respond(config, severity, reason) {
134
+ const decision = severity === 'deny' && config.mode === 'ask' ? DECISIONS.ASK : DECISIONS.DENY;
135
+ if (config.log) {
136
+ process.stderr.write(`[diff-budget] -> ${decision}: ${reason}\n`);
137
+ }
138
+ return { decision, reason };
139
+ }
140
+
141
+ // --- CLI entry point ---------------------------------------------------------
142
+
143
+ function readStdin() {
144
+ return new Promise((resolve) => {
145
+ let data = '';
146
+ process.stdin.setEncoding('utf8');
147
+ process.stdin.on('data', (chunk) => (data += chunk));
148
+ process.stdin.on('end', () => resolve(data));
149
+ });
150
+ }
151
+
152
+ async function main() {
153
+ const raw = await readStdin();
154
+ const parsed = parseHookInput(raw);
155
+ if (!parsed.ok) {
156
+ emitError(`diff-budget: ${parsed.error}`);
157
+ return;
158
+ }
159
+ const result = evaluate(parsed);
160
+ emitDecision(result.decision, result.reason);
161
+ if (result.decision === DECISIONS.DENY) {
162
+ process.exit(2);
163
+ }
164
+ }
165
+
166
+ const isMain =
167
+ import.meta.url === `file://${process.argv[1]}` ||
168
+ process.argv[1]?.endsWith('diff-budget.js');
169
+ if (isMain) {
170
+ main().catch((err) => emitError(err.stack || err.message));
171
+ }
172
+
173
+ export default { evaluate };
@@ -0,0 +1,146 @@
1
+ // Shared constants and helpers for Codex CLI hooks.
2
+ //
3
+ // The Codex CLI hook protocol (as observed in the openai/codex feature flag
4
+ // "hooks", default-on since v0.50) follows the same shape used by other major
5
+ // AI coding CLIs:
6
+ //
7
+ // 1. Codex invokes a hook as a subprocess and writes a JSON event to its stdin.
8
+ // 2. The hook reads the event, decides, and writes a JSON decision to stdout.
9
+ // 3. The hook exits:
10
+ // 0 -> success, decision in stdout is honored
11
+ // 2 -> blocking error, stderr is fed back to the model
12
+ // * -> non-blocking error, stderr is shown to the user, execution continues
13
+ //
14
+ // We keep the schema loose (`parseHookInput` tolerates a few common shapes)
15
+ // because Codex has not yet published a formal protocol spec; once it does,
16
+ // the parser becomes a single-file change without touching any individual hook.
17
+
18
+ export const HOOK_EVENTS = Object.freeze({
19
+ PRE_TOOL_USE: 'PreToolUse',
20
+ POST_TOOL_USE: 'PostToolUse',
21
+ USER_PROMPT_SUBMIT: 'UserPromptSubmit',
22
+ SESSION_START: 'SessionStart',
23
+ SESSION_END: 'SessionEnd',
24
+ });
25
+
26
+ export const DECISIONS = Object.freeze({
27
+ ALLOW: 'allow',
28
+ DENY: 'deny',
29
+ ASK: 'ask',
30
+ });
31
+
32
+ // Tools that operate on file paths. Used by hooks that care about *where* a
33
+ // change is being made (scope-guard, env-guard, auto-lint).
34
+ export const FILE_MUTATING_TOOLS = new Set([
35
+ 'write_file',
36
+ 'edit_file',
37
+ 'apply_patch',
38
+ 'create_file',
39
+ 'patch_file',
40
+ 'multi_edit',
41
+ 'Write',
42
+ 'Edit',
43
+ 'MultiEdit',
44
+ 'NotebookEdit',
45
+ ]);
46
+
47
+ // Tools that run shell commands. Used by hooks that care about *what*
48
+ // command is being executed (shield-destructive-cmd, shield-env-guard).
49
+ export const SHELL_TOOLS = new Set([
50
+ 'shell',
51
+ 'bash',
52
+ 'exec',
53
+ 'Bash',
54
+ 'Shell',
55
+ ]);
56
+
57
+ // Best-effort extraction of the file path an event is about to mutate.
58
+ // Returns `null` if no obvious path is present.
59
+ export function extractTargetPath(input) {
60
+ if (!input || typeof input !== 'object') return null;
61
+ const candidates = [
62
+ input.file_path,
63
+ input.path,
64
+ input.filePath,
65
+ input.target_file,
66
+ input.targetPath,
67
+ input?.tool_input?.file_path,
68
+ input?.tool_input?.path,
69
+ input?.tool_input?.filePath,
70
+ input?.tool_input?.target_file,
71
+ ];
72
+ for (const c of candidates) {
73
+ if (typeof c === 'string' && c.length > 0) return c;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ // Best-effort extraction of the shell command an event is about to run.
79
+ export function extractShellCommand(input) {
80
+ if (!input || typeof input !== 'object') return null;
81
+ const candidates = [
82
+ input.command,
83
+ input.cmd,
84
+ input.shell_command,
85
+ input?.tool_input?.command,
86
+ input?.tool_input?.cmd,
87
+ ];
88
+ for (const c of candidates) {
89
+ if (typeof c === 'string' && c.length > 0) return c;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ // Tolerate several observed JSON shapes. We do not throw on unknown shapes —
95
+ // instead we return a `parseHookInput` result with what we could extract.
96
+ export function parseHookInput(rawJson) {
97
+ let event = rawJson;
98
+ if (typeof rawJson === 'string') {
99
+ try {
100
+ event = JSON.parse(rawJson);
101
+ } catch {
102
+ return { ok: false, error: 'invalid JSON' };
103
+ }
104
+ }
105
+ if (!event || typeof event !== 'object') {
106
+ return { ok: false, error: 'event is not an object' };
107
+ }
108
+
109
+ const eventName =
110
+ event.event ||
111
+ event.hook_event_name ||
112
+ event.type ||
113
+ event.eventName ||
114
+ null;
115
+
116
+ const toolName =
117
+ event.tool_name ||
118
+ event.toolName ||
119
+ event?.tool?.name ||
120
+ event?.tool_input?.tool ||
121
+ null;
122
+
123
+ const toolInput =
124
+ event.tool_input ||
125
+ event.toolInput ||
126
+ event?.tool?.input ||
127
+ event.input ||
128
+ null;
129
+
130
+ const cwd =
131
+ event.cwd || event.working_directory || event.cwd || process.cwd();
132
+
133
+ return { ok: true, eventName, toolName, toolInput, cwd, raw: event };
134
+ }
135
+
136
+ // Standard JSON decision writer used by every hook.
137
+ export function emitDecision(decision, reason) {
138
+ const out = { decision };
139
+ if (reason) out.reason = reason;
140
+ process.stdout.write(JSON.stringify(out) + '\n');
141
+ }
142
+
143
+ export function emitError(message) {
144
+ process.stderr.write(`[codex-toolkit] ${message}\n`);
145
+ process.exit(2);
146
+ }
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ // Public entry point. Re-exports the stable API surface.
2
+ // All hook modules are also runnable as standalone scripts — see bin/codex-toolkit.js.
3
+
4
+ export { default as scopeGuard } from './scope-guard.js';
5
+ export { default as diffBudget } from './diff-budget.js';
6
+ export { default as toolPaceCheck } from './tool-pace-check.js';
7
+ export { default as shieldDestructiveCmd } from './shield-destructive-cmd.js';
8
+ export { default as shieldEnvGuard } from './shield-env-guard.js';
9
+ export { default as autoLint } from './auto-lint.js';
10
+ export { install, uninstall, list, doctor } from './installer.js';
11
+ export { HOOK_EVENTS, DECISIONS, parseHookInput } from './hook-protocol.js';
12
+ export { readState, writeState, updateState, sessionKey } from './state-store.js';
@@ -0,0 +1,331 @@
1
+ // codex-toolkit — installer and CLI.
2
+ //
3
+ // Subcommands:
4
+ // init install the bundled hooks into ~/.codex/ (user-level)
5
+ // list show installed hooks and their current state
6
+ // doctor sanity-check the install (config files, executables, permissions)
7
+ // uninstall remove every file this tool wrote
8
+
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import process from 'node:process';
13
+ import { spawnSync } from 'node:child_process';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const PKG_ROOT = path.resolve(__dirname, '..');
18
+ const HOOKS_DIR = path.join(PKG_ROOT, 'src');
19
+
20
+ const CODEX_HOME =
21
+ process.env.CODEX_HOME ||
22
+ path.join(os.homedir(), '.codex');
23
+
24
+ const HOOKS_INSTALLED = path.join(CODEX_HOME, 'hooks');
25
+ const HOOKS_CONFIG = path.join(CODEX_HOME, 'hooks.json');
26
+ const HOOKS_CONFIG_TOML = path.join(CODEX_HOME, 'config.toml');
27
+
28
+ // Bundled hooks (relative to src/).
29
+ const BUNDLED = [
30
+ {
31
+ id: 'scope-guard',
32
+ file: 'scope-guard.js',
33
+ event: 'PreToolUse',
34
+ description: 'Block file edits outside the declared task scope.',
35
+ },
36
+ {
37
+ id: 'diff-budget',
38
+ file: 'diff-budget.js',
39
+ event: 'PostToolUse',
40
+ description: 'Refuse writes that exceed a per-task file/byte budget.',
41
+ },
42
+ {
43
+ id: 'tool-pace-check',
44
+ file: 'tool-pace-check.js',
45
+ event: 'PreToolUse',
46
+ description: 'Slow Codex down when it chains many tool calls in a short window.',
47
+ },
48
+ {
49
+ id: 'shield-destructive-cmd',
50
+ file: 'shield-destructive-cmd.js',
51
+ event: 'PreToolUse',
52
+ description: 'Refuse shell commands that can destroy the project (rm -rf, force push, drop table, etc.).',
53
+ },
54
+ {
55
+ id: 'shield-env-guard',
56
+ file: 'shield-env-guard.js',
57
+ event: 'PreToolUse',
58
+ description: 'Refuse writes to .env, SSH keys, cloud creds, and other sensitive files.',
59
+ },
60
+ {
61
+ id: 'auto-lint',
62
+ file: 'auto-lint.js',
63
+ event: 'PostToolUse',
64
+ description: 'Run the right linter (gofmt/ruff/eslint/rustfmt) on every file Codex touches.',
65
+ },
66
+ ];
67
+
68
+ // --- helpers -----------------------------------------------------------------
69
+
70
+ function log(msg) {
71
+ process.stdout.write(`[codex-toolkit] ${msg}\n`);
72
+ }
73
+ function warn(msg) {
74
+ process.stderr.write(`[codex-toolkit] WARN: ${msg}\n`);
75
+ }
76
+
77
+ function ensureDir(dir) {
78
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
79
+ }
80
+
81
+ function copyHook(file) {
82
+ const src = path.join(HOOKS_DIR, file);
83
+ const dst = path.join(HOOKS_INSTALLED, file);
84
+ fs.copyFileSync(src, dst);
85
+ fs.chmodSync(dst, 0o755);
86
+ return dst;
87
+ }
88
+
89
+ function readJson(file) {
90
+ try {
91
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ function writeJson(file, value) {
98
+ ensureDir(path.dirname(file));
99
+ fs.writeFileSync(file, JSON.stringify(value, null, 2) + '\n', {
100
+ mode: 0o600,
101
+ });
102
+ }
103
+
104
+ // Generate a TOML fragment that declares the hooks. We append (not overwrite)
105
+ // so existing user config is preserved. If the user has no config.toml at
106
+ // all we create a minimal one.
107
+ function patchConfigToml(hookEntries) {
108
+ let existing = '';
109
+ try {
110
+ existing = fs.readFileSync(HOOKS_CONFIG_TOML, 'utf8');
111
+ } catch (err) {
112
+ if (err.code !== 'ENOENT') throw err;
113
+ }
114
+
115
+ if (existing.includes('[hooks]')) {
116
+ log(`[hooks] section already present in ${HOOKS_CONFIG_TOML} — leaving as-is.`);
117
+ return { status: 'skipped', reason: 'hooks section exists' };
118
+ }
119
+
120
+ const header =
121
+ '# Added by codex-toolkit — do not edit by hand unless you know what you are doing.\n';
122
+ const lines = ['[hooks]'];
123
+ for (const entry of hookEntries) {
124
+ const cmd = entry.command.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
125
+ lines.push(`"${entry.event}" = [{ command = "${cmd}", timeout = 30 }]`);
126
+ }
127
+ const fragment = header + lines.join('\n') + '\n';
128
+
129
+ const next = existing.endsWith('\n') || existing === ''
130
+ ? existing + fragment
131
+ : existing + '\n' + fragment;
132
+
133
+ fs.writeFileSync(HOOKS_CONFIG_TOML, next, { mode: 0o600 });
134
+ return { status: 'written', file: HOOKS_CONFIG_TOML };
135
+ }
136
+
137
+ // --- subcommands -------------------------------------------------------------
138
+
139
+ export function install({ dryRun = false, scope = 'user' } = {}) {
140
+ if (scope !== 'user') {
141
+ warn(`Only --scope=user is supported in this version.`);
142
+ }
143
+ log(`Installing into ${CODEX_HOME}`);
144
+
145
+ ensureDir(CODEX_HOME);
146
+ ensureDir(HOOKS_INSTALLED);
147
+
148
+ const entries = [];
149
+ for (const hook of BUNDLED) {
150
+ if (dryRun) {
151
+ log(`(dry-run) would install ${hook.id} -> ${HOOKS_INSTALLED}/${hook.file}`);
152
+ } else {
153
+ const dst = copyHook(hook.file);
154
+ log(`installed ${hook.id} -> ${dst}`);
155
+ }
156
+ entries.push({
157
+ event: hook.event,
158
+ command: `node ${path.join(HOOKS_INSTALLED, hook.file)}`,
159
+ id: hook.id,
160
+ });
161
+ }
162
+
163
+ if (!dryRun) {
164
+ const hooksJson = readJson(HOOKS_CONFIG) || { hooks: {} };
165
+ hooksJson.hooks = hooksJson.hooks || {};
166
+ for (const entry of entries) {
167
+ hooksJson.hooks[entry.event] = hooksJson.hooks[entry.event] || [];
168
+ // Don't double-register.
169
+ const dup = hooksJson.hooks[entry.event].some(
170
+ (e) => e?.command === entry.command
171
+ );
172
+ if (!dup) {
173
+ hooksJson.hooks[entry.event].push({
174
+ type: 'command',
175
+ command: entry.command,
176
+ });
177
+ }
178
+ }
179
+ writeJson(HOOKS_CONFIG, hooksJson);
180
+ log(`wrote ${HOOKS_CONFIG}`);
181
+
182
+ try {
183
+ const result = patchConfigToml(entries);
184
+ if (result.status === 'written') {
185
+ log(`appended [hooks] to ${result.file}`);
186
+ }
187
+ } catch (err) {
188
+ warn(`could not patch config.toml (${err.message}). ${HOOKS_CONFIG} still works.`);
189
+ }
190
+ }
191
+
192
+ log('Done. Next: run `codex-toolkit list` to verify, then `codex-toolkit doctor`.');
193
+ }
194
+
195
+ export function list() {
196
+ log(`Codex home: ${CODEX_HOME}`);
197
+ for (const hook of BUNDLED) {
198
+ const dst = path.join(HOOKS_INSTALLED, hook.file);
199
+ const exists = fs.existsSync(dst);
200
+ const stats = exists ? fs.statSync(dst) : null;
201
+ log(
202
+ ` ${hook.id.padEnd(20)} ${exists ? 'OK ' : 'MISSING'} ${dst}` +
203
+ (stats ? ` (${stats.size} bytes)` : '')
204
+ );
205
+ }
206
+ const cfg = readJson(HOOKS_CONFIG);
207
+ if (cfg) {
208
+ log(` hooks.json: ${HOOKS_CONFIG}`);
209
+ } else {
210
+ warn(`no ${HOOKS_CONFIG} found. Run \`codex-toolkit init\`.`);
211
+ }
212
+ }
213
+
214
+ export function doctor() {
215
+ const checks = [];
216
+ checks.push({
217
+ name: 'Node version >= 18',
218
+ ok: Number(process.versions.node.split('.')[0]) >= 18,
219
+ detail: process.version,
220
+ });
221
+ checks.push({
222
+ name: '~/.codex exists',
223
+ ok: fs.existsSync(CODEX_HOME),
224
+ detail: CODEX_HOME,
225
+ });
226
+ checks.push({
227
+ name: 'hooks.json present',
228
+ ok: Boolean(readJson(HOOKS_CONFIG)),
229
+ detail: HOOKS_CONFIG,
230
+ });
231
+ for (const hook of BUNDLED) {
232
+ const dst = path.join(HOOKS_INSTALLED, hook.file);
233
+ let ok = false;
234
+ let detail = dst;
235
+ try {
236
+ const stats = fs.statSync(dst);
237
+ ok = stats.isFile();
238
+ detail += ` (${stats.size} bytes)`;
239
+ } catch {
240
+ detail += ' (missing)';
241
+ }
242
+ checks.push({ name: `hook installed: ${hook.id}`, ok, detail });
243
+ }
244
+ let allOk = true;
245
+ for (const c of checks) {
246
+ log(`${c.ok ? 'OK ' : 'FAIL'} ${c.name.padEnd(34)} ${c.detail}`);
247
+ if (!c.ok) allOk = false;
248
+ }
249
+ // Smoke test: run scope-guard with a sample event.
250
+ try {
251
+ const sample = JSON.stringify({
252
+ event: 'PreToolUse',
253
+ tool_name: 'write_file',
254
+ tool_input: { file_path: 'src/api/handlers/auth.ts' },
255
+ });
256
+ const proc = spawnSync(
257
+ 'node',
258
+ [path.join(HOOKS_INSTALLED, 'scope-guard.js')],
259
+ { input: sample, encoding: 'utf8' }
260
+ );
261
+ log(`smoke test: scope-guard stdout=${proc.stdout.trim() || '(empty)'} stderr=${proc.stderr.trim() || '(empty)'} exit=${proc.status}`);
262
+ } catch (err) {
263
+ warn(`smoke test failed: ${err.message}`);
264
+ }
265
+ if (!allOk) {
266
+ process.exitCode = 1;
267
+ }
268
+ }
269
+
270
+ export function uninstall() {
271
+ for (const hook of BUNDLED) {
272
+ const dst = path.join(HOOKS_INSTALLED, hook.file);
273
+ if (fs.existsSync(dst)) {
274
+ fs.unlinkSync(dst);
275
+ log(`removed ${dst}`);
276
+ }
277
+ }
278
+ log('Uninstall complete. Manual cleanup: edit ' + HOOKS_CONFIG_TOML + ' and ' + HOOKS_CONFIG);
279
+ }
280
+
281
+ // --- CLI dispatcher ----------------------------------------------------------
282
+
283
+ function main() {
284
+ const [, , subcommand, ...rest] = process.argv;
285
+ const flags = new Set(rest);
286
+ switch (subcommand) {
287
+ case 'init':
288
+ return install({ dryRun: flags.has('--dry-run') });
289
+ case 'list':
290
+ return list();
291
+ case 'doctor':
292
+ return doctor();
293
+ case 'uninstall':
294
+ return uninstall();
295
+ case 'version':
296
+ case '--version':
297
+ case '-v':
298
+ log(JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')).version);
299
+ return;
300
+ case undefined:
301
+ case 'help':
302
+ case '--help':
303
+ case '-h':
304
+ log(
305
+ [
306
+ 'codex-toolkit <command>',
307
+ '',
308
+ 'Commands:',
309
+ ' init install hooks into ~/.codex/',
310
+ ' list show installed hooks and config',
311
+ ' doctor run sanity checks + smoke test',
312
+ ' uninstall remove installed hooks',
313
+ ' version print the version',
314
+ '',
315
+ 'Flags:',
316
+ ' --dry-run show what `init` would do without touching the filesystem',
317
+ ].join('\n')
318
+ );
319
+ return;
320
+ default:
321
+ warn(`unknown command: ${subcommand}. Try \`codex-toolkit help\`.`);
322
+ process.exitCode = 2;
323
+ }
324
+ }
325
+
326
+ const invokedDirectly =
327
+ import.meta.url === `file://${process.argv[1]}` ||
328
+ process.argv[1]?.endsWith('codex-toolkit.js');
329
+ if (invokedDirectly) {
330
+ main();
331
+ }