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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 fox328230966-alt
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,274 @@
1
+ # codex-toolkit
2
+
3
+ > **Stop AI scope creep in Codex CLI.**
4
+ > A toolkit of practical hooks that keep your AI edits scoped, budgeted, and safe.
5
+
6
+ [![CI](https://github.com/fox328230966-alt/codex-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/fox328230966-alt/codex-toolkit/actions)
7
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
8
+ [![Node](https://img.shields.io/badge/node-%E2%89%A518-blue.svg)](package.json)
9
+
10
+ ---
11
+
12
+ ## The problem
13
+
14
+ You ask Codex CLI to fix a bug in `auth.ts`. It comes back having
15
+ reformatted 14 unrelated files, rewritten your `README`, and bumped a
16
+ dependency. The result is technically fine — but it is **not what you
17
+ asked for**, and now you have 200 lines of unreviewed churn in your diff.
18
+
19
+ This is **scope creep**, and it is the unsolved ergonomic problem of
20
+ AI coding tools in 2026.
21
+
22
+ ## The fix
23
+
24
+ `codex-toolkit` is a small set of **hooks** (lifecycle scripts) you
25
+ install into Codex CLI. Each hook has one job:
26
+
27
+ | Hook | Job |
28
+ | --- | --- |
29
+ | `scope-guard` | Block file edits outside the scope you declared for this task. |
30
+ | `diff-budget` | Refuse edits once a per-task file/line budget is exceeded. |
31
+ | `tool-pace-check` | Slow the agent down when it tries to chain many tool calls in a row. |
32
+ | `shield-destructive-cmd` | Refuse `rm -rf`, `git push --force`, `drop table`, etc. |
33
+ | `shield-env-guard` | Refuse writes to `.env`, `id_rsa`, `*.key`, and similar. |
34
+ | `auto-lint` | Run the right linter after a Go / Python / TS file is touched. |
35
+
36
+ > The v0.4.0 release ships the full six-hook suite:
37
+ > **`scope-guard`**, **`diff-budget`**, **`tool-pace-check`**,
38
+ > **`shield-destructive-cmd`**, **`shield-env-guard`**, and **`auto-lint`**.
39
+
40
+ ![codex-toolkit in action: shield-env-guard refuses a .env write, scope-guard allows the in-scope .ts edit, auto-lint confirms clean](docs/demo.svg)
41
+
42
+ ## Why this category, why now
43
+
44
+ Codex CLI shipped lifecycle hooks as a stable, default-on feature in
45
+ 2025. For the first time, a small piece of user code can sit in the
46
+ critical path of every tool call without forking the agent. Until now,
47
+ guardrails were either:
48
+
49
+ - **coarse** (workspace-wide sandbox), or
50
+ - **manual** (the user has to read every approval prompt and click no).
51
+
52
+ `codex-toolkit` is the **per-task guardrail layer** that fits between
53
+ those two — see [`docs/why-scope-creep.md`](docs/why-scope-creep.md)
54
+ for the longer version.
55
+
56
+ ## Architecture
57
+
58
+ Six hooks. Two trigger points. Zero runtime dependencies.
59
+
60
+ ![codex-toolkit architecture — six hooks placed at PreToolUse and PostToolUse of the Codex CLI lifecycle](docs/architecture.svg)
61
+
62
+ | Trigger | Hooks | What they decide |
63
+ | --- | --- | --- |
64
+ | **PreToolUse** (before the tool runs) | `scope-guard`, `tool-pace-check`, `shield-destructive-cmd`, `shield-env-guard` | "Should this tool call even happen?" |
65
+ | **PostToolUse** (after the tool runs) | `diff-budget`, `auto-lint` | "Was the result acceptable?" |
66
+
67
+ Both points emit a JSON decision `{ "decision": "allow" | "ask" | "deny", "reason": "..." }` and respect the hook process's exit code (`0` = success, `2` = blocking error → deny). See [`docs/architecture.svg`](docs/architecture.svg) for the full diagram.
68
+
69
+ ## Install
70
+
71
+ ```sh
72
+ # Coming soon (not published yet — see "Roadmap"):
73
+ # npx codex-toolkit init
74
+ #
75
+ # For now, install from this repo:
76
+ git clone https://github.com/fox328230966-alt/codex-toolkit.git
77
+ cd codex-toolkit
78
+ node bin/codex-toolkit.js init
79
+ ```
80
+
81
+ `init` will:
82
+
83
+ 1. Copy every bundled hook into `~/.codex/hooks/`.
84
+ 2. Write `~/.codex/hooks.json` registering them with Codex CLI.
85
+ 3. Append a `[hooks]` section to `~/.codex/config.toml` (only if you
86
+ don't already have one).
87
+
88
+ Verify:
89
+
90
+ ```sh
91
+ node bin/codex-toolkit.js list # see what is installed
92
+ node bin/codex-toolkit.js doctor # run sanity checks + smoke test
93
+ ```
94
+
95
+ ## Configure `scope-guard`
96
+
97
+ Drop a JSON config at one of:
98
+
99
+ - `<your-project>/.codex-toolkit/scope-guard.json` (project-level)
100
+ - `~/.codex/scope-guard.json` (user-level, applies to all projects)
101
+ - `$CODEX_TOOLKIT_SCOPE_GUARD_CONFIG` (explicit override)
102
+
103
+ ```json
104
+ {
105
+ "mode": "enforce",
106
+ "allow": ["src/auth/**", "src/shared/**", "tests/auth/**"],
107
+ "deny": [".env", ".env.*", "**/secrets/**", "**/migrations/**"],
108
+ "log": true
109
+ }
110
+ ```
111
+
112
+ | Field | Values | Effect |
113
+ | --- | --- | --- |
114
+ | `mode` | `enforce` \| `ask` \| `off` | `enforce` = hard-deny out-of-scope edits. `ask` = prompt the user. `off` = no-op. |
115
+ | `allow` | glob list | Paths matching at least one pattern are allowed. |
116
+ | `deny` | glob list | If a path matches *any* deny pattern, the edit is refused — even if it would have been allowed. |
117
+ | `log` | bool | When `true`, every decision is logged to stderr. |
118
+
119
+ Glob syntax: `*` matches a single path segment, `**` matches any number of segments (including zero), `?` matches a single character, `.` is a literal dot.
120
+
121
+ ### Example: prompt the model to declare its scope
122
+
123
+ The best way to use `scope-guard` is to put a scope declaration at the top of your prompt:
124
+
125
+ > _"Refactor the OAuth flow. The only files you may touch are `src/auth/**` and `tests/auth/**`. Anything else: ask first."_
126
+
127
+ …and put a matching `allow` list in the config. Codex's edit planning is good enough that this combination cuts 90% of out-of-scope churn.
128
+
129
+ ## Configure `shield-destructive-cmd` and `shield-env-guard`
130
+
131
+ These hooks have a default deny list baked in. To override, drop a JSON file at:
132
+
133
+ - `<your-project>/.codex-toolkit/shield-destructive-cmd.json`
134
+ - `<your-project>/.codex-toolkit/shield-env-guard.json`
135
+
136
+ (or the `~/.codex/` equivalents).
137
+
138
+ ```json
139
+ // .codex-toolkit/shield-destructive-cmd.json
140
+ {
141
+ "mode": "enforce",
142
+ "extra_patterns": ["\\bterraform\\s+destroy\\b"],
143
+ "allow_overrides": ["^git\\s+push\\s+--force\\s+to-my-personal-fork"]
144
+ }
145
+ ```
146
+
147
+ ```json
148
+ // .codex-toolkit/shield-env-guard.json
149
+ {
150
+ "mode": "enforce",
151
+ "extra_patterns": ["**/internal-token*"],
152
+ "allow_overrides": ["docs/.env.example"]
153
+ }
154
+ ```
155
+
156
+ `extra_patterns` is appended to the built-in deny list; `allow_overrides` is consulted first and short-circuits the deny list if any entry matches.
157
+
158
+ ## Configure `diff-budget` and `tool-pace-check`
159
+
160
+ These hooks have a default config baked in. To override, drop a JSON file at:
161
+
162
+ - `<your-project>/.codex-toolkit/diff-budget.json`
163
+ - `<your-project>/.codex-toolkit/tool-pace.json`
164
+
165
+ (or the `~/.codex/` equivalents).
166
+
167
+ ```json
168
+ // .codex-toolkit/diff-budget.json
169
+ {
170
+ "mode": "enforce",
171
+ "max_bytes_per_write": 100000,
172
+ "max_files_per_task": 25,
173
+ "max_total_bytes": 500000
174
+ }
175
+ ```
176
+
177
+ ```json
178
+ // .codex-toolkit/tool-pace.json
179
+ {
180
+ "mode": "enforce",
181
+ "max_calls_in_window": 8,
182
+ "window_seconds": 60
183
+ }
184
+ ```
185
+
186
+ State files (per-session counters) live at `<cwd>/.codex-toolkit/.diff-budget.json` and `.tool-pace.json`. Delete them to reset a task's budget.
187
+
188
+ ## Configure `auto-lint`
189
+
190
+ Default config: every recognized extension gets a sane linter. Override at `<your-project>/.codex-toolkit/auto-lint.json` (or `~/.codex/auto-lint.json`):
191
+
192
+ ```json
193
+ {
194
+ "mode": "enforce",
195
+ "fallback": "allow",
196
+ "linters": {
197
+ "go": { "cmd": ["gofmt", "-l"], "timeout_ms": 5000 },
198
+ "py": { "cmd": ["ruff", "check", "--stdin-display-path", "PLACEHOLDER", "-"], "timeout_ms": 10000 },
199
+ "ts": { "cmd": ["eslint", "--no-warn-ignored", "--stdin", "--stdin-filename", "PLACEHOLDER"], "timeout_ms": 15000 }
200
+ }
201
+ }
202
+ ```
203
+
204
+ `fallback: "deny"` is the strict choice — refuse any change that the linter can't actually check (e.g. the linter binary is missing on PATH). The default `"allow"` is the friendly choice: log a warning, let the change through, trust the user to lint later.
205
+
206
+ ## Compare with alternatives
207
+
208
+ We wrote down the four-way comparison (vanilla Codex, hand-rolled hooks, Codex built-ins only, `codex-toolkit`) in [`docs/comparison.md`](docs/comparison.md). The short version:
209
+
210
+ - **Vanilla Codex** has no scope / pace / budget / blocklist / auto-lint defenses.
211
+ - **Hand-rolled hooks** work but you maintain ~200 LOC of glue per project and never get a test suite for the safety net itself.
212
+ - **Built-ins** (`approval_policy`, `sandbox_mode`, `rules`, `undo`) are real and worth using, but they are *complementary*, not a substitute. Sandbox doesn't know "in scope". `undo` is reactive.
213
+ - **`codex-toolkit`** is the lightest-touch option for the same safety level: `codex-toolkit init`, edit a 5-line JSON if you want to customize, done.
214
+
215
+ The recommended config in `~/.codex/config.toml` is to **layer** the two: built-ins as defensive defaults, codex-toolkit hooks as the per-task guardrail on top.
216
+
217
+ ## Run as a library
218
+
219
+ `codex-toolkit` is also a small ESM library:
220
+
221
+ ```js
222
+ import { evaluate, DECISIONS } from 'codex-toolkit/hooks/scope-guard';
223
+
224
+ const event = {
225
+ eventName: 'PreToolUse',
226
+ toolName: 'write_file',
227
+ toolInput: { file_path: 'src/auth/login.ts' },
228
+ cwd: process.cwd(),
229
+ raw: {},
230
+ };
231
+
232
+ const result = evaluate(event);
233
+ if (result.decision === DECISIONS.DENY) {
234
+ console.error('Refused:', result.reason);
235
+ }
236
+ ```
237
+
238
+ ## Development
239
+
240
+ ```sh
241
+ npm install
242
+ npm test
243
+ npm run lint
244
+ ```
245
+
246
+ Hooks ship with a full unit test suite (Node's built-in `node:test` —
247
+ no extra deps). CI runs the suite on Node 18, 20, and 22.
248
+
249
+ ## Roadmap
250
+
251
+ - [x] `scope-guard` — v0.1.0
252
+ - [x] `diff-budget` — v0.2.0
253
+ - [x] `tool-pace-check` — v0.2.0
254
+ - [x] `shield-destructive-cmd` — v0.3.0
255
+ - [x] `shield-env-guard` — v0.3.0
256
+ - [x] `auto-lint` — v0.4.0
257
+ - [ ] `npx codex-toolkit init` published to npm — v0.4.0
258
+ - [ ] Per-hook "explain why" debug output — v0.5.0
259
+ - [ ] Codex IDE extension parity — v0.6.0
260
+
261
+ ## Contributing
262
+
263
+ Issues and PRs welcome. The bar is intentionally low:
264
+
265
+ - One hook or one behavior per PR.
266
+ - Tests for the behavior you changed.
267
+ - A line in the `README.md` hook table.
268
+ - Run `npm test` and `npm run lint` before pushing.
269
+
270
+ See [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md).
271
+
272
+ ## License
273
+
274
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // codex-toolkit — main CLI shim. Forwards to src/installer.js#main.
3
+ // This file is the `bin` entry; the real implementation lives in src/ so
4
+ // it can be unit-tested without spawning a subprocess.
5
+
6
+ import('../src/installer.js');
@@ -0,0 +1,11 @@
1
+ {
2
+ "mode": "enforce",
3
+ "fallback": "allow",
4
+ "linters": {
5
+ "go": { "cmd": ["gofmt", "-l"], "timeout_ms": 5000 },
6
+ "py": { "cmd": ["ruff", "check", "--stdin-display-path", "PLACEHOLDER", "-"], "timeout_ms": 10000 },
7
+ "ts": { "cmd": ["eslint", "--no-warn-ignored", "--stdin", "--stdin-filename", "PLACEHOLDER"], "timeout_ms": 15000 },
8
+ "js": { "cmd": ["eslint", "--no-warn-ignored", "--stdin", "--stdin-filename", "PLACEHOLDER"], "timeout_ms": 15000 },
9
+ "rs": { "cmd": ["rustfmt", "--check"], "timeout_ms": 10000 }
10
+ }
11
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "mode": "enforce",
3
+ "allow": [
4
+ "src/auth/**",
5
+ "src/shared/**",
6
+ "tests/auth/**"
7
+ ],
8
+ "deny": [
9
+ ".env",
10
+ ".env.*",
11
+ "**/secrets/**",
12
+ "**/migrations/**"
13
+ ],
14
+ "log": true
15
+ }
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "codex-toolkit",
3
+ "version": "0.4.0",
4
+ "description": "Stop AI scope creep in Codex CLI. A toolkit of practical hooks that keep your AI edits scoped, budgeted, and safe.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "exports": {
8
+ ".": "./src/index.js",
9
+ "./hooks/scope-guard": "./src/scope-guard.js",
10
+ "./hooks/diff-budget": "./src/diff-budget.js",
11
+ "./hooks/tool-pace-check": "./src/tool-pace-check.js",
12
+ "./hooks/shield-destructive-cmd": "./src/shield-destructive-cmd.js",
13
+ "./hooks/shield-env-guard": "./src/shield-env-guard.js",
14
+ "./hooks/auto-lint": "./src/auto-lint.js",
15
+ "./installer": "./src/installer.js"
16
+ },
17
+ "bin": {
18
+ "codex-toolkit": "bin/codex-toolkit.js"
19
+ },
20
+ "files": [
21
+ "bin/",
22
+ "src/",
23
+ "hooks/",
24
+ "examples/",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "scripts": {
29
+ "test": "node --test",
30
+ "test:single": "node --test",
31
+ "lint": "node --check src/*.js bin/*.js",
32
+ "init": "node bin/codex-toolkit.js init",
33
+ "list": "node bin/codex-toolkit.js list",
34
+ "doctor": "node bin/codex-toolkit.js doctor",
35
+ "prepublishOnly": "npm test && npm run lint"
36
+ },
37
+ "keywords": [
38
+ "codex",
39
+ "codex-cli",
40
+ "openai",
41
+ "ai-coding",
42
+ "ai-guardrails",
43
+ "scope-creep",
44
+ "hooks",
45
+ "safety",
46
+ "linter",
47
+ "agent"
48
+ ],
49
+ "engines": {
50
+ "node": ">=18.0.0"
51
+ },
52
+ "author": {
53
+ "name": "fox328230966-alt",
54
+ "url": "https://github.com/fox328230966-alt"
55
+ },
56
+ "license": "MIT",
57
+ "repository": {
58
+ "type": "git",
59
+ "url": "git+https://github.com/fox328230966-alt/codex-toolkit.git"
60
+ },
61
+ "bugs": {
62
+ "url": "https://github.com/fox328230966-alt/codex-toolkit/issues"
63
+ },
64
+ "homepage": "https://github.com/fox328230966-alt/codex-toolkit#readme"
65
+ }
@@ -0,0 +1,254 @@
1
+ // auto-lint — run the right linter on the file Codex just touched.
2
+ //
3
+ // Scope-guard controls *where* Codex may edit. diff-budget controls *how
4
+ // much*. auto-lint controls *what quality the result is in*: every time
5
+ // Codex writes a source file, the right linter runs against it before the
6
+ // tool call is allowed to fully complete. If the linter reports issues,
7
+ // the hook returns a deny decision that pushes the model's next turn
8
+ // toward fixing them.
9
+ //
10
+ // Detection is by file extension. Defaults are conservative and cover
11
+ // the languages we actually care about (Go, Python, JS/TS, Rust). Users
12
+ // can override per-linter command and timeout via config.
13
+ //
14
+ // Triggers on PostToolUse for file-mutating tools.
15
+ // Configuration: <cwd>/.codex-toolkit/auto-lint.json
16
+ // {
17
+ // "mode": "enforce" | "ask" | "off",
18
+ // "linters": {
19
+ // "go": { "cmd": ["gofmt", "-l"], "timeout_ms": 5000 },
20
+ // "py": { "cmd": ["ruff", "check", "--stdin-display-path", "-"], "timeout_ms": 10000 },
21
+ // "ts": { "cmd": ["eslint", "--no-warn-ignored", "--stdin"], "timeout_ms": 15000 }
22
+ // },
23
+ // "fallback": "allow" // what to do when the linter binary is missing
24
+ // }
25
+
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import process from 'node:process';
29
+ import { spawn } from 'node:child_process';
30
+ import {
31
+ DECISIONS,
32
+ FILE_MUTATING_TOOLS,
33
+ emitDecision,
34
+ emitError,
35
+ extractTargetPath,
36
+ parseHookInput,
37
+ } from './hook-protocol.js';
38
+
39
+ const EXT_TO_LANG = {
40
+ '.go': 'go',
41
+ '.py': 'py',
42
+ '.pyi': 'py',
43
+ '.ts': 'ts',
44
+ '.tsx': 'ts',
45
+ '.mts': 'ts',
46
+ '.cts': 'ts',
47
+ '.js': 'js',
48
+ '.jsx': 'js',
49
+ '.mjs': 'js',
50
+ '.cjs': 'js',
51
+ '.rs': 'rs',
52
+ };
53
+
54
+ // Default linter invocations. We feed the *file content* on stdin where the
55
+ // linter supports it, and pass the file path as a positional argument. We
56
+ // always pass through stderr; we treat non-empty stdout as a "lint issues
57
+ // found" signal. Exit code non-zero with empty stdout is also "issues".
58
+ const DEFAULT_LINTERS = {
59
+ go: { cmd: ['gofmt', '-l'], timeout_ms: 5000 },
60
+ py: { cmd: ['ruff', 'check', '--stdin-display-path', 'PLACEHOLDER', '-'], timeout_ms: 10000 },
61
+ ts: { cmd: ['eslint', '--no-warn-ignored', '--stdin', '--stdin-filename', 'PLACEHOLDER'], timeout_ms: 15000 },
62
+ js: { cmd: ['eslint', '--no-warn-ignored', '--stdin', '--stdin-filename', 'PLACEHOLDER'], timeout_ms: 15000 },
63
+ rs: { cmd: ['rustfmt', '--check'], timeout_ms: 10000 },
64
+ };
65
+
66
+ const DEFAULT_CONFIG = {
67
+ mode: 'enforce',
68
+ linters: {},
69
+ fallback: 'allow', // 'allow' | 'deny' — when the linter binary is missing
70
+ log: true,
71
+ };
72
+
73
+ function loadConfig() {
74
+ const candidates = [
75
+ process.env.CODEX_TOOLKIT_AUTO_LINT_CONFIG,
76
+ path.join(process.cwd(), '.codex-toolkit', 'auto-lint.json'),
77
+ path.join(process.env.HOME || '', '.codex', 'auto-lint.json'),
78
+ ].filter(Boolean);
79
+ for (const file of candidates) {
80
+ try {
81
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
82
+ return {
83
+ ...DEFAULT_CONFIG,
84
+ ...parsed,
85
+ linters: { ...DEFAULT_LINTERS, ...(parsed.linters || {}) },
86
+ };
87
+ } catch (err) {
88
+ if (err.code !== 'ENOENT') {
89
+ emitError(`auto-lint: failed to read ${file}: ${err.message}`);
90
+ }
91
+ }
92
+ }
93
+ return { ...DEFAULT_CONFIG, linters: DEFAULT_LINTERS };
94
+ }
95
+
96
+ export function langFor(filePath) {
97
+ const ext = path.extname(filePath).toLowerCase();
98
+ return EXT_TO_LANG[ext] || null;
99
+ }
100
+
101
+ function readFileIfExists(filePath) {
102
+ try {
103
+ return fs.readFileSync(filePath, 'utf8');
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
108
+
109
+ export function runLinter(cmd, timeoutMs, stdinContent) {
110
+ return new Promise((resolve) => {
111
+ const proc = spawn(cmd[0], cmd.slice(1), { stdio: ['pipe', 'pipe', 'pipe'] });
112
+ let stdout = '';
113
+ let stderr = '';
114
+ let settled = false;
115
+ const timer = setTimeout(() => {
116
+ settled = true;
117
+ proc.kill('SIGKILL');
118
+ resolve({ ok: false, reason: 'timeout', stdout, stderr });
119
+ }, timeoutMs);
120
+ proc.stdout.on('data', (b) => (stdout += b.toString()));
121
+ proc.stderr.on('data', (b) => (stderr += b.toString()));
122
+ proc.on('error', (err) => {
123
+ if (settled) return;
124
+ settled = true;
125
+ clearTimeout(timer);
126
+ resolve({ ok: false, reason: 'spawn-error', error: err.code || err.message, stdout, stderr });
127
+ });
128
+ proc.on('close', (code) => {
129
+ if (settled) return;
130
+ settled = true;
131
+ clearTimeout(timer);
132
+ resolve({ ok: true, code, stdout, stderr });
133
+ });
134
+ if (stdinContent !== null && stdinContent !== undefined) {
135
+ proc.stdin.write(stdinContent);
136
+ }
137
+ proc.stdin.end();
138
+ });
139
+ }
140
+
141
+ function substitutePlaceholder(cmd, replacement) {
142
+ return cmd.map((arg) => (arg === 'PLACEHOLDER' ? replacement : arg));
143
+ }
144
+
145
+ export async function evaluate(event) {
146
+ const config = loadConfig();
147
+ if (config.mode === 'off') {
148
+ return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
149
+ }
150
+ if (!FILE_MUTATING_TOOLS.has(event.toolName)) {
151
+ return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
152
+ }
153
+ const target = extractTargetPath(event.toolInput);
154
+ if (!target) {
155
+ return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
156
+ }
157
+ const lang = langFor(target);
158
+ if (!lang) {
159
+ return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
160
+ }
161
+ const linter = config.linters[lang];
162
+ if (!linter) {
163
+ return { decision: DECISIONS.ALLOW, reason: null, skipped: true };
164
+ }
165
+
166
+ // Build the actual command (substitute placeholder with the target file)
167
+ // and figure out the stdin content (read the freshly-written file, or fall
168
+ // back to the inline content the tool input may carry).
169
+ const cmd = substitutePlaceholder(linter.cmd, target);
170
+ const inline = event.toolInput?.content ?? event.toolInput?.new_string ?? null;
171
+ const fileContent = readFileIfExists(target);
172
+ const stdin = fileContent !== null ? fileContent : inline;
173
+
174
+ const result = await runLinter(cmd, linter.timeout_ms ?? 10000, stdin);
175
+
176
+ if (!result.ok) {
177
+ if (result.reason === 'spawn-error' && (result.error === 'ENOENT' || /not found/i.test(result.stderr || ''))) {
178
+ // Linter not installed.
179
+ if (config.fallback === 'deny') {
180
+ return {
181
+ decision: DECISIONS.DENY,
182
+ reason: `auto-lint: linter for .${lang} (${cmd[0]}) is not installed on this machine and fallback is "deny".`,
183
+ };
184
+ }
185
+ if (config.log) {
186
+ process.stderr.write(`[auto-lint] ${lang} linter (${cmd[0]}) not found, allowing (fallback=allow)\n`);
187
+ }
188
+ return { decision: DECISIONS.ALLOW, reason: null, skipped: 'linter-missing' };
189
+ }
190
+ if (result.reason === 'timeout') {
191
+ return {
192
+ decision: DECISIONS.ASK,
193
+ reason: `auto-lint: ${lang} linter timed out after ${linter.timeout_ms}ms. Allow the change without linting?`,
194
+ };
195
+ }
196
+ return {
197
+ decision: DECISIONS.ASK,
198
+ reason: `auto-lint: ${lang} linter failed to run: ${result.error || 'unknown'}`,
199
+ };
200
+ }
201
+
202
+ // Linter ran. Decide based on its output.
203
+ const issues = (result.stdout || '').trim();
204
+ if (result.code === 0 && !issues) {
205
+ if (config.log) {
206
+ process.stderr.write(`[auto-lint] ${lang} ${target} -> clean\n`);
207
+ }
208
+ return { decision: DECISIONS.ALLOW, reason: null };
209
+ }
210
+ // Non-zero exit OR non-empty stdout = linter found something.
211
+ const reason =
212
+ `auto-lint: ${lang} linter reported issues in ${target}\n` +
213
+ (issues ? `--- stdout ---\n${issues}\n` : '') +
214
+ (result.stderr ? `--- stderr ---\n${result.stderr}\n` : '') +
215
+ `\nFix the issues (or run the linter manually) and re-apply the change.`;
216
+ if (config.log) {
217
+ process.stderr.write(`[auto-lint] ${lang} ${target} -> issues found\n`);
218
+ }
219
+ return { decision: DECISIONS.DENY, reason };
220
+ }
221
+
222
+ // --- CLI entry point ---------------------------------------------------------
223
+
224
+ function readStdin() {
225
+ return new Promise((resolve) => {
226
+ let data = '';
227
+ process.stdin.setEncoding('utf8');
228
+ process.stdin.on('data', (chunk) => (data += chunk));
229
+ process.stdin.on('end', () => resolve(data));
230
+ });
231
+ }
232
+
233
+ async function main() {
234
+ const raw = await readStdin();
235
+ const parsed = parseHookInput(raw);
236
+ if (!parsed.ok) {
237
+ emitError(`auto-lint: ${parsed.error}`);
238
+ return;
239
+ }
240
+ const result = await evaluate(parsed);
241
+ emitDecision(result.decision, result.reason);
242
+ if (result.decision === DECISIONS.DENY) {
243
+ process.exit(2);
244
+ }
245
+ }
246
+
247
+ const isMain =
248
+ import.meta.url === `file://${process.argv[1]}` ||
249
+ process.argv[1]?.endsWith('auto-lint.js');
250
+ if (isMain) {
251
+ main().catch((err) => emitError(err.stack || err.message));
252
+ }
253
+
254
+ export default { evaluate, langFor, runLinter };