SLOPSHOPPER

lint-code

No description.

newguardprocess
A shopper browsing a rack in a slop shop
README

lint-code

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:

AgentAdapterLoaded by
Claude Codehooks/register.ts, a mod: tool.call on Write, Edit, and Bash adds context after nextCLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl
pipi.ts: tool_result on write, edit, and bash returns { content, isError: true }extensions in dot_pi/agent/modify_settings.json
omppi.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 evalextensions in dot_omp/private_agent/private_config.yml
Codexcommand.ts: exit code 2 with the errors on stderrPostToolUse on Bash, and on apply_patch through dot_codex/hooks/apply-patch.ts

What it checks

The hook lints these files, by extension:

ExtensionLinterInvocationDocumentation
.pyruffruff check --quiethttps://docs.astral.sh/ruff/
.js .jsx .ts .tsx .mts .cts .mjs .cjsoxlintoxlint --deny-warningshttps://oxc.rs/docs/guide/usage/linter
.sh .bashshellcheckshellcheck -S warninghttps://www.shellcheck.net/wiki/
.yml .yaml (plain)yamllintyamllint -c config/.yamllinthttps://yamllint.readthedocs.io/
.yml .yaml (Ansible)ansible-lintansible-lint -c config/.ansible-lint -qhttps://ansible.readthedocs.io/projects/lint/
.tftflinttflint --filter=<file> --format=compacthttps://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:

  • Write and Edit: the file that the call names.
  • Bash: the files that the command names and just wrote. See Bash calls.
  • omp only: each file of an 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.

Requirements

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:

  • Claude Code 2.1.296 or later for the mod. It is the oldest version tested.
  • Node 23.6 or later on 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.
  • Codex trust: Codex runs a hook only after you trust its entry. After the change from 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.

How it works

For each tool call, the core does these steps:

  1. It finds the target files of the call. A Write or an Edit names one file, which the hook lints at any age. For a Bash call, see Bash calls. A relative path resolves against the session working directory.
  2. It drops each path under /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.
  3. It picks the linter for the extension of each file. It skips a file when CLAUDE_LINT_DISABLE turns its linter off, or when no linter is installed.
  4. It runs the linters one file after the other, and lints every target before it stops. So one Bash command reports every file that it wrote, not only the first failure.
  5. When at least one linter exits with a code other than 0, it gives the output of all the linters that ran to the model.

Bash calls

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.

YAML routing

The hook treats a YAML file as Ansible and sends it to ansible-lint, which runs yamllint internally, when one of these is true:

  • The path holds a standard Ansible directory: roles/, tasks/, handlers/, playbooks/, group_vars/, host_vars/, or molecule/.
  • The file has an entry point name: site, playbook, main, or requirements, with the extension .yml or .yaml.
  • A line of the file starts with the key hosts:, tasks:, or roles:, after optional spaces and an optional - .

All other YAML goes to yamllint.

Terraform

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.

What the model sees

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:

  • Claude Code: the mod adds the errors to the context of the result, after the line Lint errors in the files this call changed. Fix them:. The model gets them with the result.
  • pi and omp: the extension appends a text block that starts with Lint errors: to the result content, and marks the result as an error.
  • Codex: 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:

  • 0: no linter failed, or the call changed no file to lint. No output.
  • 1: the payload is not valid JSON. The model does not see it.
  • 2: at least one linter failed. The output of all the linters that ran is on stderr.

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.

What you see

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.

Settings

Turn the hook 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.

Turn off one linter

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.

KeyTurns off
pyruff
jsoxlint
shshellcheck
yamlyamllint and ansible-lint
tftflint
allThe 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

Tune the linters

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.

Failure behavior

The hook fails open in every agent: when the lint run breaks, the tool result stays as it is.

  • A linter is not installed: the hook skips its files.
  • A mise shim has no version set: the shim exits 1 with No version is set for shim, and the hook skips the file, as for a linter that is not installed.
  • A tflint plugin is not installed: tflint stops with 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.
  • The mod throws in Claude Code: Claude Code skips the mod hook, and the result stands.
  • 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.
  • A linter runs too long: the mod stops each linter after 30 s, the default timeout of $.process.run. Codex stops the whole hook after 15 s.
  • Codex has no node: the Codex entries start with command -v node >/dev/null || exit 0, so a machine without Node is not blocked.

Testing

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.

Source 2 files
hooks/register.ts 55 lines
1/**
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};
55
core.ts 201 lines
1/**
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