SLOPSHOPPER

format-code

No description.

newguardprocess
A shopper browsing a rack in a slop shop
README

format-code

Formats the files that a Write, an Edit, or a Bash call changed, with the formatter for the extension of each file. It covers the languages that oxfmt has no parser for, so format-oxfmt cannot format them:

ExtensionsFormatterCommandKey
.tf .tfvarsterraform fmtterraform fmt -no-color <file>terraform
.sh .bashshfmtshfmt -l -w [-i 2 -ci] <file>shfmt
.py .pyiruff formatruff format --force-exclude <file>ruff

The key turns off one formatter in one repository, see Settings. To add a formatter, add a row to FORMATTERS in core.ts, its extensions to NAMED, and its key to PARTS in toggle-hook/core.ts. The toggle-hook test fails when the keys differ.

One core, core.ts, serves four agents through three adapters:

AgentAdapterLoaded by
Claude Codehooks/register.ts, a mod: tool.call on Write, Edit, and Bash formats after nextCLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl
pipi.ts: tool_result on write, edit, and bashextensions 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: the amend hint as hookSpecificOutput JSON on stdoutPostToolUse on Bash, and on apply_patch through dot_codex/hooks/apply-patch.ts

The git pre-commit hook format-staged-files also runs command.ts, once for each commit. It names all staged files in tool_input.file_paths, a field that no agent payload has.

What it checks

The hook runs after each Write, Edit, and Bash call. It runs also after a call that failed, because a failed Bash command can still write a file. It formats these files:

  • Write and Edit: the file that the call names.
  • Bash: each path with an extension from the table that the command text names, absolute or relative to the session cwd. A path counts only when its mtime is at most 120 s old, so a file that the command only read stays as it is. format-org-tables uses the same rule, and its README gives the reasons.

The hook skips these paths, with no output:

  • A path that is not an existing file with an extension from the table.
  • A path under /tmp, /var/tmp, or $TMPDIR, because the scratchpad of the agent is there.
  • A file in a repository whose git config turns off the hook or the formatter of the file, see Settings.
  • A Python file that the exclude list of the ruff config holds, see ruff format.

The hook misses a Bash command that writes a file but does not name it, for example a script, terraform init, or a find -exec. It also misses a shell script with no extension, such as a file with only a #!/bin/sh line. A relative path resolves against the session cwd only, not against a folder that a cd in the command moves to.

Requirements

  • terraform on PATH, for .tf and .tfvars. Tested with Terraform 1.16.5 and 1.16.6.
  • shfmt on PATH, for .sh and .bash. Tested with shfmt 3.14.1.
  • ruff on PATH, for .py and .pyi. Tested with ruff 0.17.0. lint-code tells how to install it.

Without a formatter, the hook leaves its files as they are.

  • git, for the amend hint.
  • Node 23.6 or later for command.ts, which Codex and format-staged-files run. The mod, pi.ts, and omp.ts run in the process of the agent and need no node of their own.
brew install hashicorp/tap/terraform shfmt ruff

How it works

  1. The hook stops when AGENT_HOOKS_DISABLE names format-code.
  2. It finds the files to format, as "What it checks" tells.
  3. For each file, it reads agent-hook.format-code.enabled and the key of the formatter with git config --type=bool --get, in the folder of the file. It skips the file when either key is false. It runs git once for each folder and key, and only for a file that passed the checks of step 2.
  4. It runs the command of the formatter once for each file, in the folder of the file, with a limit of 10 s.
  5. When the call is a Bash command that commits, it asks git whether the last commit holds each file that a formatter changed.
  6. It sends the amend hint for each file that the last commit holds.

The hook finds the changed files from the stdout of each formatter, and reads no file text. terraform fmt always prints the name of a file that it changed, and shfmt prints it because of -l. Both print nothing for a file that they left as it was. ruff prints a summary on each run, so for ruff only a summary with reformatted counts as a change, see ruff format.

What the model sees

The hook formats the file in place and usually sends nothing to the model. Its only output is the amend hint. command.ts exits 0, also when it prints the hint.

Write and commit in one command

A Bash call can write a file and commit it. Formatting the file after the call fixes the file, not the commit. The hook then sends the amend hint, which tells the model to fold the formatting into that commit. The hint names a file only when all of these conditions are true:

  • The call is a Bash command that commits: git, optional global options such as -C <dir>, then commit.
  • A formatter changed the file in this run.
  • The last commit holds the file: git diff-tree --no-commit-id --name-only -r --root HEAD.

Each agent gets the hint in a different place:

  • Claude Code: context in the mod result.
  • pi and omp: a text block appended to the tool result.
  • Codex: hookSpecificOutput.additionalContext.

In an agent session, format-staged-files formats the staged file before the commit. So the hint appears only when that hook skipped the file. format-org-tables explains this in "Write and commit in one command".

What you see

The hook shows you no message when it formats a file. It drops the stderr of the formatters, so a parse error does not show as hook output. "Failure behavior" lists the messages that a failure shows.

Settings

SettingEffect
AGENT_HOOKS_DISABLEA list of the hooks that are off, separated by spaces or commas. When it names format-code, the hook does nothing.
git config agent-hook.format-code.enabled falseTurns the hook off in one repository, across sessions.
git config agent-hook.format-code.<key> falseTurns off one formatter in one repository, by the key in the table at the top. The other formatter still runs.
TMPDIRThe hook skips the files under this folder.

To turn the hook off until you quit, run /toggle-hook in Claude Code, pi, or omp. Codex has no command, so set the variable when you start it:

AGENT_HOOKS_DISABLE=format-code codex

To turn off the hook or one formatter in one repository, run /toggle-hook --repo format-code or /toggle-hook --repo format-code.shfmt. In Codex, run the git config command of the table in your own terminal. A value that is not a boolean, such as banana, keeps the formatter on. The dcg pack local.agent_hook blocks an agent command that writes the key.

To turn the hook off for good in one agent, remove it from the setting in the "Loaded by" column of the table at the top. format-staged-files removes AGENT_HOOKS_DISABLE before it runs the formatters, so the pre-commit run still formats. The git config keys still apply in the pre-commit run, because the hook reads them itself.

Failure behavior

The hook fails open: it never blocks a call and never marks a result as an error. Each of these failures leaves the file as it is, with no message:

  • The formatter is not on PATH.
  • The file does not parse. terraform fmt and ruff exit 2, and shfmt exits 1.
  • A run takes more than 10 s, and the hook stops it.

lint-code reports the parse error to the model: tflint for a .tf file, shellcheck for a shell file, and ruff check for a .py file. Nothing reports a parse error in a .tfvars file.

When an adapter throws an error, each agent does a different thing:

AdapterOn a failure
ModClaude Code skips a hook that throws, and the result stands.
pi.ts, omp.tsThe handler catches the error and returns nothing.
command.tsNode exits 1, which Codex shows as a failed hook and does not block. A missing formatter exits 0.

With no node, the Codex entry exits 0 and the hook does not run. Then the SessionStart check of Codex shows you a warning.

Testing

test/check.ts runs command.ts and the adapters against the real terraform, shfmt, ruff, and git. docs/testing.md tells how to run it and what it covers.

Design notes

One hook for the formatters that are not Prettier

The hook started as format-terraform, a copy of the design of format-org-tables. Each formatter of this hook runs one command on one file and prints the name of a changed file, so a new formatter is one row in FORMATTERS, not a new folder with its own adapters and test. docs/hooks/decisions.md gives the decision. No official hook does this job, see docs/research.md.

One run for each file

The hook starts the formatter once for each file. One run with all files does not work: when one file does not parse, terraform fmt exits 2 and does not format the files after it. A terraform fmt run takes about 15 ms, and a shfmt run about 1 ms, see docs/research.md.

terraform fmt prints a name that is not the path that the hook gave. So the hook only checks whether the output is empty.

shfmt and .editorconfig

shfmt reads its style from .editorconfig, but only when the command gives no printer flag. Without an .editorconfig, shfmt indents with tabs. So the hook looks for an .editorconfig in the folder of the file and in each folder above it:

  • Found: the hook runs shfmt -l -w <file>, and the .editorconfig sets the style. An .editorconfig with no shell settings gives the shfmt defaults, which indent with tabs.
  • Not found: the hook adds -i 2 -ci: a 2-space indent, and an indent for each branch of a case. These are the flags of just fmt in this repo. With the defaults, shfmt changed 21 of the 23 shell files in this repo. With -i 2 -ci, it changed 8, and only in small ways, such as (( rc > status )) to ((rc > status)).

A ~/.editorconfig does not replace the fallback, because the devcontainer repositories are under /workspaces, outside $HOME.

ruff format

ruff format was chosen over Black because lint-code already uses ruff, so it adds no tool. It reads the same config as ruff check ([tool.ruff] and [tool.ruff.format] in pyproject.toml or ruff.toml). Its default style is the Black style: a line length of 88 and double quotes.

  • --force-exclude: ruff formats a file that its command names, also when the exclude list of the project config holds it. With this flag, the list applies. ruff then prints warning: No Python files found and exits 0.
  • The change test: ruff prints 1 file reformatted or 1 file left unchanged, and it has no flag that prints only the changed files. --quiet prints nothing in both cases. So the ruff row of FORMATTERS has a changed function that looks for reformatted. The other rows have no changed function, and for them any stdout counts as a change.

ruff format does not sort imports. That is the lint rule I of ruff check --fix, and the hook does not run it.

Files

FileRole
core.tsFinds the files and runs the formatters. No Node imports.
io.tsThe file and process functions of the core under Node
command.tsThe command hook for Codex and format-staged-files
pi.tsThe pi extension, which omp also loads
omp.tsThe omp extension, after pi.ts: the omp calls that pi.ts does not see
hooks/register.ts, hooks/hooks.jsonThe Claude Code mod
dot_claude-plugin/plugin.jsonThe plugin manifest, deployed as .claude-plugin/plugin.json
Source 2 files
hooks/register.ts 33 lines
1/**
2 * Claude Code mod: after a Write, Edit, or Bash call, formats the files
3 * it changed. A failed Bash call is formatted too: it may still have written
4 * files. Adds the amend hint as context when the call committed a file that
5 * a formatter then changed. Fails open: a hook that throws is
6 * skipped and the result stands.
7 */
8
9import type { Register } from "claude-code";
10import { format, type Io } from "../core.ts";
11
12export const register: Register = (on) => {
13  on("tool.call", { tool: ["Write", "Edit", "Bash"] }, async ($, e, next) => {
14    const ran = await next(e);
15    if (ran.deny !== undefined) return ran;
16    const cwd = await $.session.cwd();
17    // The engine only takes $ calls spelled at the call site, so each
18    // function of the core is written out here.
19    const io: Io = {
20      stat: (path) => $.fs.stat(path).catch(() => undefined),
21      // Rejects when the program is not installed or times out.
22      run: (argv, options) => $.process.run(argv, options).catch(() => undefined),
23    };
24    const call = e.tool === "Bash" ? { command: e.command, cwd } : { filePath: e.file_path, cwd };
25    const hint = await format(call, io, {
26      tmpdir: await $.env.get("TMPDIR"),
27      hooksOff: await $.env.get("AGENT_HOOKS_DISABLE"),
28    });
29    if (!hint) return ran;
30    return { ...ran, context: [...(ran.context ?? []), hint] };
31  });
32};
33
core.ts 198 lines
1/**
2 * The format-code logic: finds the files a tool call changed and formats
3 * each one with the formatter for its extension, from FORMATTERS: terraform
4 * fmt, shfmt, and ruff format. oxfmt has no parser for these languages, so
5 * format-oxfmt skips them. Never blocks: a missing formatter or a file
6 * that does not parse stays as it is. The adapters pass in the file and
7 * process functions, because a mod reaches them only through `$`.
8 *
9 * Adapters: hooks/register.ts (Claude Code mod), pi.ts (pi and omp), omp.ts
10 * (omp only), command.ts (Codex, and the format-staged-files pre-commit hook).
11 * Design notes: README.md.
12 */
13
14export interface Io {
15  /** The kind of a path and its mtime; undefined if missing. */
16  stat(path: string): Promise<{ kind: "file" | "dir" | "other"; mtimeMs: number } | undefined>;
17  /** The exit code and stdout; undefined when the program cannot start. */
18  run(
19    argv: string[],
20    options: { cwd: string; timeoutMs?: number },
21  ): Promise<{ exitCode: number; stdout: string } | undefined>;
22}
23
24export interface Settings {
25  /** TMPDIR: files under it are throwaway and not formatted. */
26  tmpdir?: string;
27  /** AGENT_HOOKS_DISABLE: the hooks /toggle-hook turned off. */
28  hooksOff?: string;
29}
30
31/**
32 * The tool call: a Write or Edit names a file, a Bash call a command. The
33 * format-staged-files pre-commit hook names all staged files in filePaths, so
34 * one run takes them all.
35 */
36export interface Call {
37  filePath?: string;
38  filePaths?: string[];
39  command?: string;
40  cwd: string;
41}
42
43/**
44 * A formatter: the files it takes, and its command for one file. Each
45 * command writes the file in place and exits 0. A file that does not parse
46 * exits other than 0.
47 */
48interface Formatter {
49  name: string;
50  /** The git config key that turns it off: agent-hook.format-code.<key>. */
51  key: string;
52  files: RegExp;
53  argv(file: string, io: Io): Promise<string[]>;
54  /** Whether the stdout of a run tells of a change; default: not empty. */
55  changed?(stdout: string): boolean;
56}
57
58const FORMATTERS: Formatter[] = [
59  {
60    name: "terraform fmt",
61    key: "terraform",
62    files: /\.(tf|tfvars)$/,
63    argv: async (file) => ["terraform", "fmt", "-no-color", file],
64  },
65  {
66    name: "shfmt",
67    key: "shfmt",
68    files: /\.(sh|bash)$/,
69    // Any printer flag makes shfmt ignore .editorconfig. So the flags only
70    // where no .editorconfig applies, else shfmt indents with tabs. README
71    // "shfmt and .editorconfig".
72    argv: async (file, io) => {
73      for (let dir = parent(file); ; dir = parent(dir)) {
74        if ((await io.stat(`${dir}/.editorconfig`))?.kind === "file")
75          return ["shfmt", "-l", "-w", file];
76        if (dir === "/") return ["shfmt", "-l", "-w", "-i", "2", "-ci", file];
77      }
78    },
79  },
80  {
81    name: "ruff format",
82    key: "ruff",
83    files: /\.pyi?$/,
84    // --force-exclude: the exclude list of the ruff config applies also to a
85    // file that the command names. Ruff prints a summary for each run, also
86    // "1 file left unchanged", so only "reformatted" tells of a change.
87    argv: async (file) => ["ruff", "format", "--force-exclude", file],
88    changed: (stdout) => stdout.includes("reformatted"),
89  },
90];
91
92/**
93 * A command that commits: `git`, then global options such as `-C <dir>`, then
94 * `commit` as a word, in one simple command. `git commit-tree` does not match.
95 */
96const COMMIT = /\bgit[ \t](?:[^;&|\n]*[ \t])?commit(?![\w-])/;
97
98/** A path that a command names, with an extension of FORMATTERS. */
99const NAMED = /[^\s:;|&"'`()<>=]+\.(?:tfvars|tf|bash|sh|pyi|py)\b/g;
100
101const plain = (path: string) => path.replace(/^\/private/, "");
102const parent = (path: string) => path.slice(0, path.lastIndexOf("/")) || "/";
103const formatterOf = (file: string) => FORMATTERS.find(({ files }) => files.test(file));
104
105/**
106 * A file under /tmp, /var/tmp, or TMPDIR is a scratch file, not project
107 * code. macOS resolves /tmp to /private/tmp, so both sides drop that prefix.
108 */
109function throwaway(file: string, { tmpdir }: Settings): boolean {
110  const dirs = ["/tmp/", "/var/tmp/", ...(tmpdir ? [plain(tmpdir)] : [])];
111  return dirs.some((dir) => plain(file).startsWith(dir));
112}
113
114/**
115 * Whether the git config of the repository that holds `dir` sets
116 * agent-hook.format-code.<key> to false. /toggle-hook --repo writes the
117 * `enabled` key for the whole hook, and a FORMATTERS key for one formatter.
118 * Outside git, a missing key, or a value that is not a boolean: false.
119 */
120async function repoOff(dir: string, key: string, io: Io): Promise<boolean> {
121  const ran = await io.run(
122    ["git", "config", "--type=bool", "--get", `agent-hook.format-code.${key}`],
123    { cwd: dir },
124  );
125  return ran?.stdout.trim() === "false";
126}
127
128/**
129 * The files to format, each once. A Write or Edit: the file it names. A Bash
130 * call: the paths its command names that it just wrote, by an mtime of at
131 * most 120 s, so a file the command only read stays as it is. A file whose
132 * repository turns off the hook or its formatter stays as it is.
133 * shortcut: a command that writes early and then runs for minutes falls
134 * outside the window. The upgrade is a start time stamped before the call.
135 */
136async function targets(call: Call, io: Io, settings: Settings): Promise<string[]> {
137  const given = call.filePaths ?? (call.filePath ? [call.filePath] : undefined);
138  const paths =
139    given ??
140    [...(call.command ?? "").matchAll(NAMED)].map(([named]) =>
141      named.startsWith("/") ? named : `${call.cwd}/${named}`,
142    );
143  const kept: string[] = [];
144  // One git run for each folder and key: a commit can name many files.
145  const asked = new Map<string, Promise<boolean>>();
146  const off = (dir: string, key: string) => {
147    const id = `${dir}\0${key}`;
148    if (!asked.has(id)) asked.set(id, repoOff(dir, key, io));
149    return asked.get(id)!;
150  };
151  for (const file of new Set(paths)) {
152    const formatter = formatterOf(file);
153    if (!formatter || throwaway(file, settings)) continue;
154    const stat = await io.stat(file);
155    if (stat?.kind !== "file" || (!given && Date.now() - stat.mtimeMs > 120_000)) continue;
156    const dir = parent(file);
157    if ((await off(dir, "enabled")) || (await off(dir, formatter.key))) continue;
158    kept.push(file);
159  }
160  return kept;
161}
162
163/**
164 * Formats the files a call changed. Returns the amend hint for the model
165 * when the call committed a file that a formatter then changed, else
166 * undefined. README "Write and commit in one command".
167 */
168export async function format(call: Call, io: Io, settings: Settings): Promise<string | undefined> {
169  if ((settings.hooksOff ?? "").split(/[\s,]+/).includes("format-code")) return undefined;
170  // A command that commits: the hint then checks the last commit.
171  const commits = !call.filePath && COMMIT.test(call.command ?? "");
172  const dirty: string[] = [];
173  const names = new Set<string>();
174  for (const file of await targets(call, io, settings)) {
175    const formatter = formatterOf(file)!;
176    // One run for each file: a file that does not parse makes terraform fmt
177    // exit 2 and skip the files after it. A run takes about 15 ms.
178    const dir = parent(file);
179    const ran = await io.run(await formatter.argv(file, io), {
180      cwd: dir,
181      timeoutMs: 10_000,
182    });
183    const changed = formatter.changed ?? ((stdout) => stdout.trim() !== "");
184    if (!commits || ran?.exitCode !== 0 || !changed(ran.stdout)) continue;
185    // Only a file that the last commit holds needs an amend. diff-tree --root
186    // also answers for the first commit of a repository.
187    const held = await io.run(
188      ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", "--root", "HEAD", "--", file],
189      { cwd: dir },
190    );
191    if (!held?.stdout.trim()) continue;
192    dirty.push(file);
193    names.add(formatter.name);
194  }
195  if (!dirty.length) return undefined;
196  return `${[...names].join(" and ")} reformatted files after your commit, so the commit holds the unformatted version and the working tree is now dirty: ${dirty.join(" ")}. Fold the formatting into that commit (amend, or a fixup commit if it is already pushed).`;
197}
198