No description.

Lints each file that a Write, an Edit, or a Bash call changed, with the linter for its extension, and gives the errors to the model. The model then sees a lint error in the result of the call that caused it, not only when a later edit touches the same file.
One core, core.ts, serves four agents through three adapters. An adapter is the code that connects the core to one agent:
| Agent | Adapter | Loaded by |
|---|---|---|
| Claude Code | hooks/register.ts, a mod: tool.call on Write, Edit, and Bash adds context after next | CLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl |
| pi | pi.ts: tool_result on write, edit, and bash returns { content, isError: true } | extensions in dot_pi/agent/modify_settings.json |
| omp | pi.ts as in pi, and omp.ts: tool_result on an omp edit of more than one file, an ast_edit that a write to xd://resolve applies, and eval | extensions in dot_omp/private_agent/private_config.yml |
| Codex | command.ts: exit code 2 with the errors on stderr | PostToolUse on Bash, and on apply_patch through dot_codex/hooks/apply-patch.ts |
The hook lints these files, by extension:
| Extension | Linter | Invocation | Documentation |
|---|---|---|---|
.py | ruff | ruff check --quiet | https://docs.astral.sh/ruff/ |
.js .jsx .ts .tsx .mts .cts .mjs .cjs | oxlint | oxlint --deny-warnings | https://oxc.rs/docs/guide/usage/linter |
.sh .bash | shellcheck | shellcheck -S warning | https://www.shellcheck.net/wiki/ |
.yml .yaml (plain) | yamllint | yamllint -c config/.yamllint | https://yamllint.readthedocs.io/ |
.yml .yaml (Ansible) | ansible-lint | ansible-lint -c config/.ansible-lint -q | https://ansible.readthedocs.io/projects/lint/ |
.tf | tflint | tflint --filter=<file> --format=compact | https://github.com/terraform-linters/tflint |
YAML goes to one of two linters, see YAML routing. Terraform goes to tflint in the folder of the file, see Terraform. The hook does not lint .tfvars files. format-code formats .tf and .tfvars files, because terraform fmt is a formatter, not a linter.
The hook runs after each of these calls, also after a call that failed, because a failed Bash call can still have written a file:
edit of more than one file, each file that an applied ast_edit changed, and the files that the code of an eval call names.The hook skips files under /tmp, /var/tmp, and $TMPDIR. A scratchpad file is not project code, so a lint error in it must not block a tool result.
The hook calls each linter binary directly, with no npx fallback. Install the linters that you want active. The hook skips a linter that is not installed.
macOS (Homebrew):
brew install ruff shellcheck yamllint ansible-lint
pnpm add -g oxlint
mise use -g tflint@0.64.0 # Homebrew has no tflint formula
Debian and Ubuntu:
apt install -y shellcheck yamllint
uv tool install ruff # apt's ruff lags; uv tracks upstream
uv tool install ansible-lint
npm install -g oxlint
mise use -g tflint@0.64.0
Then install the tflint plugins of the bundled config, once on each machine. --init downloads them from GitHub to ~/.tflint.d/plugins. Set GITHUB_TOKEN if GitHub limits the API calls:
tflint --init --config ~/.claude/hooks/lint-code/config/.tflint.hcl
On Debian and Ubuntu, install ruff with uv, because apt ships an older build. docs/research.md compares the install methods and tells why the hook uses oxlint.
The adapters have these requirements:
PATH for command.ts and dot_codex/hooks/apply-patch.ts, which Codex runs. Node 23.6 runs .ts files without a flag. The mod, pi.ts, and omp.ts run in the runtime of their agent and need no node.hook.sh to command.ts, trust the hooks again in the Codex TUI, on the host and in the devcontainer. See docs/agents/codex.md.For each tool call, the core does these steps:
/tmp, /var/tmp, or $TMPDIR, each path that does not exist, and each path that is not a regular file. Then it drops each file whose repository sets agent-hook.lint-code.enabled to false in git config, with one git -C <folder> config for each folder.CLAUDE_LINT_DISABLE turns its linter off, or when no linter is installed.A Bash payload names no file. So the hook reads the paths out of tool_input.command, the way format-org-tables does, and keeps the paths with a supported extension.
A command also names the files that it only reads, as in cat app.py. So the hook lints a named file only when its mtime is at most 120 seconds old, which shows that the command wrote it. A command that writes a file early and then runs for minutes falls outside that window, and the hook lints the file again on the next edit.
docs/implementation.md gives the reasons for this design.
The hook treats a YAML file as Ansible and sends it to ansible-lint, which runs yamllint internally, when one of these is true:
roles/, tasks/, handlers/, playbooks/, group_vars/, host_vars/, or molecule/.site, playbook, main, or requirements, with the extension .yml or .yaml.hosts:, tasks:, or roles:, after optional spaces and an optional - .All other YAML goes to yamllint.
tflint lints a whole module, which is all the .tf files of one folder. So the hook runs tflint in the folder of the file, and --filter=<file> keeps only the issues of that file. An issue in another file of the module does not reach the model. A parse error does, from each file of the module, because tflint cannot load a module that does not parse.
The hook uses the .tflint.hcl or .tflint.json of the module folder when it has one. Else it passes the bundled config/dot_tflint.hcl with --config. A config in a parent folder does not count, the same as when you run tflint in the module folder.
A Write or an Edit has already run when the hook starts, so the hook cannot block it. The hook gives the errors to the model in the way that each agent allows:
context of the result, after the line Lint errors in the files this call changed. Fix them:. The model gets them with the result.Lint errors: to the result content, and marks the result as an error.command.ts writes the errors to stderr and exits 2. A PostToolUse hook reaches the model only with exit code 2. With exit code 0, its output goes to the transcript only.command.ts uses these exit codes:
The hook reports only when a linter exits with a code other than 0. oxlint 1.87.0 reports its built-in rules as warnings, also a real bug such as an assignment to a const, and exits 0 for warnings. So the hook passes --deny-warnings, which makes oxlint exit 1 on a warning.
In Codex, when Node 23.6 or later is not on PATH, a SessionStart check shows a warning. The warning lists lint-code with the other hooks that are off.
To turn the whole hook off in a running agent, run /toggle-hook lint-code. The command adds lint-code to AGENT_HOOKS_DISABLE. Codex has no such command, so set AGENT_HOOKS_DISABLE=lint-code when you start it. See toggle-hook.
To turn the hook off in one repository, across sessions, run /toggle-hook --repo lint-code. In Codex, run git config agent-hook.lint-code.enabled false in your own terminal. Outside git, or with a value that is not a boolean, the hook stays on. The dcg pack local.agent_hook blocks an agent command that writes this key.
To remove the hook from one agent for good, remove its entry from the file in the "Loaded by" column of the table at the top.
CLAUDE_LINT_DISABLE holds a list of keys, separated by spaces or commas. The hook reads it at run time, so export it in your shell, or set it in env in settings.json.
| Key | Turns off |
|---|---|
py | ruff |
js | oxlint |
sh | shellcheck |
yaml | yamllint and ansible-lint |
tf | tflint |
all | The whole hook, and also lint-prose |
export CLAUDE_LINT_DISABLE="yaml" # stop linting YAML
export CLAUDE_LINT_DISABLE="yaml,sh" # skip YAML and shell
export CLAUDE_LINT_DISABLE="all" # off
To tune YAML instead of turning it off, edit the bundled configs in config/. The hook passes them with -c on each run. So the hook ignores the .yamllint and .ansible-lint of a repository, and you do not copy a config into each project. For Terraform, the config of the module wins, see Terraform.
config/dot_yamllint, deployed as .yamllint, sets relaxed indentation and line-length rules for plain YAML (yamllint config docs).config/dot_ansible-lint, deployed as .ansible-lint, silences the noisy yaml[indentation] and yaml[line-length] findings for Ansible files with skip_list (ansible-lint config docs). To get them as warnings that do not block, use the warn_list that the file holds as a comment. ansible-lint finds its embedded yamllint config by a directory search, so you tune the indentation for Ansible in this file, not in config/dot_yamllint.config/dot_tflint.hcl, deployed as .tflint.hcl, turns on the recommended preset of the bundled terraform ruleset, and the aws and azurerm plugins (tflint config docs). The aws plugin finds values that AWS does not accept, such as an unknown instance type. Its deep_check stays off, because it calls the AWS API and needs credentials on each run. After you add a plugin or change a version, run tflint --init again.oxlint finds a project .oxlintrc.json in the directory of the file. A rule that this file sets to off stays off, also with --deny-warnings.
If a linter reports too much, change its severity in core.ts, for example to shellcheck -S error.
The hook fails open in every agent: when the lint run breaks, the tool result stays as it is.
No version is set for shim, and the hook skips the file, as for a linter that is not installed.Did you run "tflint --init"?, and the hook skips the file. This also applies to a plugin that the .tflint.hcl of a module names.pi.ts or omp.ts throws: pi.ts catches its own errors, and the result stays as it is. omp.ts runs through the same code.command.ts gets a bad payload: it exits 1. Only exit code 2 reaches the model.$.process.run. Codex stops the whole hook after 15 s.node: the Codex entries start with command -v node >/dev/null || exit 0, so a machine without Node is not blocked.node test/check.ts tests the core and each adapter, and just hooks-check runs it. docs/testing.md tells how to run it, what each group of cases checks, and what it needs on PATH.
hooks/register.ts 55 lines1/**
2 * Claude Code mod: after a Write, Edit, or Bash call, lints the files it
3 * changed and adds the linter output to the result as context for the model.
4 * A failed Bash call is linted too: it may still have written files. Fails
5 * open: a hook that throws is skipped and the result stands.
6 */
7
8import type { Register } from "claude-code";
9import { lint, targets, type Io, type Settings } from "../core.ts";
10
11export const register: Register = (on) => {
12 on("tool.call", { tool: ["Write", "Edit", "Bash"] }, async ($, e, next) => {
13 const ran = await next(e);
14 if (ran.deny !== undefined) return ran;
15 const cwd = await $.session.cwd();
16 // The engine only takes $ calls spelled at the call site, so each
17 // function of the core is written out here.
18 const io: Io = {
19 stat: (path) =>
20 $.fs.stat(path).then(
21 (found) => ({
22 isFile: found.kind === "file",
23 mtimeMs: found.mtimeMs,
24 }),
25 () => undefined,
26 ),
27 read: (path) => $.fs.read(path),
28 run: (argv, options) =>
29 $.process.run(argv, { cwd: options?.cwd ?? cwd }).then(
30 (done) => ({
31 exitCode: done.exitCode,
32 output: done.stdout + done.stderr,
33 }),
34 () => undefined, // not installed: skip the file
35 ),
36 };
37 const settings: Settings = {
38 configDir: `${$.plugin.root}/config`,
39 disable: await $.env.get("CLAUDE_LINT_DISABLE"),
40 tmpdir: await $.env.get("TMPDIR"),
41 hooksOff: await $.env.get("AGENT_HOOKS_DISABLE"),
42 };
43 const call = e.tool === "Bash" ? { command: e.command, cwd } : { filePath: e.file_path, cwd };
44 const output = await lint(await targets(call, io, settings), io, settings);
45 if (output === undefined) return ran;
46 return {
47 ...ran,
48 context: [
49 ...(ran.context ?? []),
50 `Lint errors in the files this call changed. Fix them:\n${output}`,
51 ],
52 };
53 });
54};
55core.ts 201 lines1/**
2 * The lint-code logic: finds the files a tool call changed and lints
3 * each by its extension. The adapters pass in the file and process functions,
4 * because a mod reaches them only through `$`.
5 *
6 * Adapters: hooks/register.ts (Claude Code mod), pi.ts and omp.ts (pi and
7 * omp), command.ts (Codex command hook).
8 */
9
10export interface Io {
11 /** Whether the path is a regular file, and its mtime; undefined if missing. */
12 stat(path: string): Promise<{ isFile: boolean; mtimeMs: number } | undefined>;
13 read(path: string): Promise<string>;
14 /**
15 * The exit code and output; undefined when the program cannot start. Runs
16 * in `cwd` when given, else in the session folder.
17 */
18 run(
19 argv: string[],
20 options?: { cwd?: string },
21 ): Promise<{ exitCode: number; output: string } | undefined>;
22}
23
24export interface Settings {
25 /** The folder of the bundled .yamllint, .ansible-lint, and .tflint.hcl. */
26 configDir: string;
27 /** CLAUDE_LINT_DISABLE: keys (py js sh yaml tf) or "all", space or comma. */
28 disable?: string;
29 /** TMPDIR: files under it are throwaway and not linted. */
30 tmpdir?: string;
31 /** AGENT_HOOKS_DISABLE: the hooks /toggle-hook turned off. */
32 hooksOff?: string;
33}
34
35/**
36 * The tool call: a Write or Edit names a file, a Bash call a command. A
37 * relative path resolves against cwd.
38 */
39export interface Call {
40 filePath?: string;
41 command?: string;
42 cwd: string;
43}
44
45/** A path a Bash command names, by the extensions this hook lints. */
46const NAMED = /[^\s:;|&"'`()<>=]+\.(?:py|jsx?|tsx?|mjs|cjs|mts|cts|sh|bash|ya?ml|tf)\b/g;
47
48/**
49 * Whether the git config of the repository that holds `dir` sets
50 * agent-hook.lint-code.enabled to false, as /toggle-hook --repo does.
51 * Outside git, a missing key, or a value that is not a boolean: false. -C,
52 * because `run` runs in the session folder.
53 */
54async function repoOff(dir: string, io: Io): Promise<boolean> {
55 const key = "agent-hook.lint-code.enabled";
56 const ran = await io.run(["git", "-C", dir, "config", "--type=bool", "--get", key]);
57 return ran?.output.trim() === "false";
58}
59
60/**
61 * The files to lint. A Write or Edit: the one file it names. A Bash call: the
62 * paths its command names that it just wrote, by an mtime of at most 120 s.
63 * A path the command only read is not linted, so `cat app.py` never blocks
64 * on an error that was already there. A file whose repository turns off the
65 * hook is not linted. shortcut: a command that writes early and then runs
66 * for minutes falls outside the window. The upgrade is a start time stamped
67 * before the call.
68 */
69export async function targets(
70 call: Call,
71 io: Io,
72 settings: Settings,
73 now = Date.now(),
74): Promise<string[]> {
75 if ((settings.hooksOff ?? "").split(/[\s,]+/).includes("lint-code")) return [];
76 const named = call.filePath
77 ? [call.filePath]
78 : [...(call.command ?? "").matchAll(NAMED)].map(([path]) => path);
79 // Scratch and temp files are not project code. macOS resolves /tmp to
80 // /private/tmp, so both sides drop that prefix.
81 const tmpdir = (settings.tmpdir || "/nonexistent").replace(/^\/private/, "");
82 const kept = new Set<string>();
83 // One git run for each folder.
84 const asked = new Map<string, Promise<boolean>>();
85 for (const path of named) {
86 const file = path.startsWith("/") ? path : `${call.cwd}/${path}`;
87 const plain = file.replace(/^\/private/, "");
88 if (/^\/(var\/)?tmp\//.test(plain) || plain.startsWith(tmpdir)) continue;
89 const stat = await io.stat(file);
90 if (!stat?.isFile || (!call.filePath && now - stat.mtimeMs > 120_000)) continue;
91 const dir = file.slice(0, file.lastIndexOf("/")) || "/";
92 if (!asked.has(dir)) asked.set(dir, repoOff(dir, io));
93 if (!(await asked.get(dir))) kept.add(file);
94 }
95 return [...kept];
96}
97
98/**
99 * Ansible YAML, by path, by entry-point name, or by content. ansible-lint
100 * only runs on these; other YAML goes to yamllint.
101 */
102async function isAnsible(file: string, io: Io): Promise<boolean> {
103 return (
104 /\/(roles|tasks|handlers|playbooks|group_vars|host_vars|molecule)\//.test(file) ||
105 /(^|\/)(site|playbook|main|requirements)\.ya?ml$/.test(file) ||
106 /^\s*(- )?(hosts|tasks|roles|ansible\.builtin\.):/m.test(await io.read(file).catch(() => ""))
107 );
108}
109
110/**
111 * The linter for a file and the folder to run it in, or undefined when its
112 * key is disabled.
113 */
114async function linter(
115 file: string,
116 io: Io,
117 settings: Settings,
118): Promise<{ argv: string[]; cwd?: string } | undefined> {
119 const off = (settings.disable ?? "").split(/[ ,]+/);
120 const disabled = (key: string) => off.includes("all") || off.includes(key);
121 const config = settings.configDir;
122 switch (file.split(".").pop()) {
123 case "py":
124 return disabled("py") ? undefined : { argv: ["ruff", "check", "--quiet", file] };
125 case "js":
126 case "jsx":
127 case "ts":
128 case "tsx":
129 case "mjs":
130 case "cjs":
131 case "mts":
132 case "cts":
133 return disabled("js") ? undefined : { argv: ["oxlint", "--deny-warnings", file] };
134 case "sh":
135 case "bash":
136 return disabled("sh") ? undefined : { argv: ["shellcheck", "-S", "warning", file] };
137 case "yml":
138 case "yaml":
139 if (disabled("yaml")) return undefined;
140 return {
141 argv: (await isAnsible(file, io))
142 ? ["ansible-lint", "-c", `${config}/.ansible-lint`, "-q", file]
143 : ["yamllint", "-c", `${config}/.yamllint`, file],
144 };
145 case "tf": {
146 if (disabled("tf")) return undefined;
147 // tflint lints a whole module, so it runs in the folder of the file,
148 // and --filter keeps the issues of this file. With --chdir, --filter
149 // matches nothing (tflint 0.64.0). A .tflint.hcl or .tflint.json of
150 // the module wins over the bundled config.
151 const slash = file.lastIndexOf("/");
152 const dir = file.slice(0, slash) || "/";
153 const own = (await io.stat(`${dir}/.tflint.hcl`)) ?? (await io.stat(`${dir}/.tflint.json`));
154 return {
155 argv: [
156 "tflint",
157 ...(own ? [] : [`--config=${config}/.tflint.hcl`]),
158 `--filter=${file.slice(slash + 1)}`,
159 "--format=compact",
160 "--no-color",
161 ],
162 cwd: dir,
163 };
164 }
165 }
166 return undefined;
167}
168
169/** tflint output for a plugin that `tflint --init` did not install. */
170const TFLINT_PLUGIN_MISSING = /Did you run "tflint --init"\?/;
171/** Output of a mise shim of any linter that has no version set. */
172const MISE_SHIM_VERSION_MISSING = /No version is set for shim/;
173
174/**
175 * Lints every target, so one Bash call reports every file it wrote. Returns
176 * the output of all linters when one failed, else undefined. A missing
177 * linter skips its file.
178 */
179export async function lint(
180 files: string[],
181 io: Io,
182 settings: Settings,
183): Promise<string | undefined> {
184 let failed = false;
185 let output = "";
186 for (const file of files) {
187 const found = await linter(file, io, settings);
188 const ran = found && (await io.run(found.argv, { cwd: found.cwd }));
189 // Not a lint error: the linter or its plugin is missing.
190 if (
191 !ran ||
192 TFLINT_PLUGIN_MISSING.test(ran.output) ||
193 MISE_SHIM_VERSION_MISSING.test(ran.output)
194 )
195 continue;
196 output += ran.output;
197 if (ran.exitCode !== 0) failed = true;
198 }
199 return failed ? output : undefined;
200}
201