No description.

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:
| Extensions | Formatter | Command | Key |
|---|---|---|---|
.tf .tfvars | terraform fmt | terraform fmt -no-color <file> | terraform |
.sh .bash | shfmt | shfmt -l -w [-i 2 -ci] <file> | shfmt |
.py .pyi | ruff format | ruff 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:
| Agent | Adapter | Loaded by |
|---|---|---|
| Claude Code | hooks/register.ts, a mod: tool.call on Write, Edit, and Bash formats after next | CLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl |
| pi | pi.ts: tool_result on write, edit, and bash | 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: the amend hint as hookSpecificOutput JSON on stdout | PostToolUse 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.
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:
format-org-tables uses the same rule, and its README gives the reasons.The hook skips these paths, with no output:
/tmp, /var/tmp, or $TMPDIR, because the scratchpad of the agent is there.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.
PATH, for .tf and .tfvars. Tested with Terraform 1.16.5 and 1.16.6.PATH, for .sh and .bash. Tested with shfmt 3.14.1.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.
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
AGENT_HOOKS_DISABLE names format-code.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.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.
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.
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:
git, optional global options such as -C <dir>, then commit.git diff-tree --no-commit-id --name-only -r --root HEAD.Each agent gets the hint in a different place:
context in the mod result.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".
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.
| Setting | Effect |
|---|---|
AGENT_HOOKS_DISABLE | A 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 false | Turns the hook off in one repository, across sessions. |
git config agent-hook.format-code.<key> false | Turns off one formatter in one repository, by the key in the table at the top. The other formatter still runs. |
TMPDIR | The 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.
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:
PATH.terraform fmt and ruff exit 2, and shfmt exits 1.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:
| Adapter | On a failure |
|---|---|
| Mod | Claude Code skips a hook that throws, and the result stands. |
pi.ts, omp.ts | The handler catches the error and returns nothing. |
command.ts | Node 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.
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.
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.
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 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:
shfmt -l -w <file>, and the .editorconfig sets the style. An .editorconfig with no shell settings gives the shfmt defaults, which indent with tabs.-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 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.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.
| File | Role |
|---|---|
core.ts | Finds the files and runs the formatters. No Node imports. |
io.ts | The file and process functions of the core under Node |
command.ts | The command hook for Codex and format-staged-files |
pi.ts | The pi extension, which omp also loads |
omp.ts | The omp extension, after pi.ts: the omp calls that pi.ts does not see |
hooks/register.ts, hooks/hooks.json | The Claude Code mod |
dot_claude-plugin/plugin.json | The plugin manifest, deployed as .claude-plugin/plugin.json |
hooks/register.ts 33 lines1/**
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};
33core.ts 198 lines1/**
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