No description.

Formats the files that a Write, an Edit, or a Bash call changed with oxfmt, selected by extension. It runs after each tool call, so the model reads back formatted files and Markdown tables stay aligned while the model edits them.
The hook replaced format-prettier on 2026-10-25. See format-oxfmt replaces format-prettier. The research for the move is in oxfmt-and-prettier-alternatives.md.
One core, core.ts, serves four agents through three adapters. An adapter connects the core to one agent. A mod is a Claude Code hook module that runs in the Claude Code process, and a command hook is a program that the agent starts for each call.
| 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, a command hook: 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, also after a call that failed. It tells the calls apart by the file path: a Write or an Edit names one file in file_path, and a Bash call names none.
omp.ts also formats each file of an edit of more than one file, and each file of an applied ast_edit. It treats the code of an eval call as a Bash command.apply_patch: apply-patch.ts runs command.ts once for each file in the patch, as a Write.| Extensions | Note |
|---|---|
.md, .markdown | Native Markdown formatter since oxfmt 0.72.0 |
.js, .jsx, .ts, .tsx, .mts, .cts, .mjs, .cjs | |
.json, .jsonc, .json5 | |
.css, .scss, .less | |
.yml, .yaml | |
.toml | |
.html | oxfmt sends HTML to the Prettier in its npm package |
The hook skips any other extension and does not start oxfmt for it. The separate hooks format-org-tables and format-code format .org, Terraform, shell, and Python, because oxfmt has no parser for them. A Bash call formats only .md and .markdown.
/tmp, /var/tmp, or $TMPDIR. See Throwaway files..prettierignore lists. See Ignore rules.agent-hook.format-oxfmt.enabled to false.PATH, the npm package. The standalone binary from the GitHub release skips .html with exit 0. oxfmt releases each week and has had breaking changes, so install a pinned version and read the release notes before an upgrade. With mise, as on this computer, ~/.config/mise/config.toml holds "npm:oxfmt" = "0.74.0". Without mise: pnpm add -g oxfmt@0.74.0
command.ts, which Codex and format-staged-files run. With no node, the Codex entry exits 0, and the SessionStart check of Codex shows a warning. The mod and pi.ts need no node of their own.The hook starts the oxfmt binary directly, as lint-code starts its linters. It never installs oxfmt from the network.
AGENT_HOOKS_DISABLE names format-oxfmt, the hook stops.agent-hook.format-oxfmt.enabled to false in git config. It runs one git config for each folder, and only for the files that step 3 kept.oxfmt <files> once, in the session folder. oxfmt finds the config of each file, see Config. The hook stops oxfmt after 20 s.A Bash call has no file_path, but a shell command (sed, perl, echo, a redirect) can change files. Markdown is the most important case: a table that a command changed stays misaligned until the next Edit. So on a Bash call, the hook sweeps the git working tree for Markdown files:
HEAD (git diff --name-only --diff-filter=d HEAD) and the untracked files (git ls-files --others --exclude-standard).The sweep is Markdown only. A Write or an Edit formats each supported extension, because the edit is the reason to format. A Bash call does not, because a format of each changed .ts, .css, or .json file after each shell command would interfere with edits that are not complete.
Misaligned Markdown tables are the problem that justifies a sweep of the whole repository. Other files do not. So a non-Markdown file that a shell command writes stays unformatted until an Edit changes it, see The gap that format-staged-files closes.
The sweep asks git what changed, so it cannot find a file that git does not report. The hooks run after the full command ends. A command that writes docs/refactor.md and adds it to .gitignore leaves a file that git ls-files --others --exclude-standard does not list.
So before the hook asks git, it takes each .md or .markdown path in the command text and formats it directly. An absolute path stays as it is, and a relative path resolves against the session folder. A file that the command names is a file that the model writes now, so it gets a format pass that the other ignored files do not get.
cd in the command does not change the session folder, so a relative path after a cd can resolve to the wrong file. The file check then skips it, or the hook formats a file with the same name that is already formatted.The session folder of the hook does not change when the command runs cd. A command such as cd /other/repo && cat > UPGRADING.md <<'EOF' writes Markdown in a different repository.
So the sweep has more than one root: the session folder, and each absolute path in tool_input.command. A folder counts as it is, and the hook uses the parent folder of any other path. The hook resolves each root to its git top level and removes duplicates.
The hook follows only absolute paths. A relative path is ambiguous after a cd, and a wrong guess sweeps the wrong repository. So the hook misses an edit in a different repository after a relative cd ../other-repo: use an absolute path, or make the edit with a Write or an Edit.
A shortcut: comment in core.ts marks this limit.
One side effect: the hook formats the changed and untracked Markdown of each repository whose absolute path is in a command, also when the command only reads from it. That repository gets the same pass as the session repository, and the hook changes only files that git reports as changed or untracked.
A git repository inside a repository is opaque to the parent repository, so the sweep of the parent cannot see its Markdown files.
git ls-files --others as one nested/ entry, not as the files in it.git diff HEAD as a gitlink path.git worktree add .worktrees/feat) shows as one .worktrees/feat/ entry, because the folder holds a .git file and no tracked content. This is the most frequent case here.The roots are a queue. The sweep lists the files of one root and adds each folder entry to the queue as a new root, with its own git top level. For a worktree, git rev-parse --show-toplevel gives the worktree path, so the hook sweeps the worktree as a root of its own.
The sweep continues until the queue is empty, so it covers each nesting depth. The roots are a set, so a symlink that points back to a root does not add that root a second time and cannot cause a loop.
One Bash call can write a file, commit it, and push it: cat > README.md <<'EOF' ... && git commit -am docs && git push. When the hook runs, the working tree is clean, so the working-tree sweep finds no files. The pushed commit holds the unformatted table.
So when the command runs git commit, also with global options as in git -C <dir> commit, the sweep also takes the files of the last commit in each root: git diff-tree --no-commit-id --name-only -r --root --diff-filter=d HEAD. --root makes this work for the first commit of a repository, which has no HEAD~1.
The format changes the file, not the commit. The commit holds the unformatted text, and the working tree is now dirty. Only the model can amend the commit, so the hook gives the model the amend hint, see What the model sees.
In an agent session, the format-staged-files pre-commit hook formats the file before the commit. So the amend hint comes only when that hook skipped the file:
git commit --no-verify.node.The hook skips a path under /tmp, /var/tmp, or $TMPDIR, for a Write, an Edit, and a Bash call. The scratchpad of the agent is there, and a file that the agent will delete is not project code. lint-code and format-org-tables skip the same paths.
oxfmt does not read .prettierrc. It looks for .oxfmtrc.json, .oxfmtrc.jsonc, oxfmt.config.ts, or oxfmt.config.mts, from the folder of each file up, and the nearest config wins.
With no config, oxfmt uses its defaults: a print width of 100, and it sorts the keys of package.json. Prettier uses 80 and does not sort. So in a project that Prettier formats with no oxfmt config, each file that the hook formats can reflow to 100 columns.
To keep the Prettier style of a project, run oxfmt --migrate prettier in that project, which writes .oxfmtrc.json from a JSON or JS Prettier config. Or turn the hook off there with /toggle-hook --repo format-oxfmt.
proseWrap is preserve by default in oxfmt, so oxfmt keeps the line breaks that the author wrote. A paragraph on one line, as writing.md asks, stays on one line, and oxfmt pads each table also when it is wider than the print width. Why not proseWrap never gives the rejected alternative.
oxfmt reads .gitignore and .prettierignore in the session folder, but it formats a gitignored file that the command line names. A file that .prettierignore lists stays as it is. So the hook passes no --ignore-path. .prettierignore is a formatting decision, so the hook obeys it, but .gitignore is a version-control decision and does not apply to a file that the hook names.
oxfmt runs in the session folder: $.session.cwd() in the mod, ctx.cwd in pi, and the payload cwd in command.ts. It finds the ignore files from there, so a .prettierignore in a different repository does not apply to an edit in that repository. A --ignore-path to a file that does not exist makes oxfmt exit 1 and format nothing.
In most calls the hook gives the model no output. The model sees the formatted file when it reads the file again. A format directly after a Write can make the copy of the file that the model has stale, see Why after each call, and not Stop.
The amend hint is the only output. It comes when all of these conditions are true:
git commit.git diff-tree --root HEAD -- <file>).The hint names each file that it applies to:
oxfmt reformatted markdown after your commit, so the commit holds the unformatted version and the working tree is now dirty: <files>. Fold the formatting into that commit (amend, or a fixup commit if it is already pushed).
| Adapter | How the hint reaches the model |
|---|---|
| Mod | context on the result of the call |
pi.ts, omp.ts | A text block added after the content of the result |
command.ts | hookSpecificOutput.additionalContext on stdout. hookEventName is the hook_event_name of the payload, or PostToolUse |
The hook never blocks a call and never marks a result as an error. command.ts exits 0, also when oxfmt is missing.
When the hook works, you see only the formatted files. A missing oxfmt, a parse error, and a git error give no message. In Codex, a command.ts that crashes shows as a failed hook, and with no node the SessionStart check shows a warning.
| Setting | Effect |
|---|---|
AGENT_HOOKS_DISABLE | A value that names format-oxfmt, separated by spaces or commas, turns the hook off. The hook reads it on each run. format-staged-files ignores it. |
git config agent-hook.format-oxfmt.enabled false | Turns the hook off in one repository, or globally, across sessions, also in format-staged-files. Outside git, or with a value that is not a boolean, the hook stays on. |
.oxfmtrc.json and the other oxfmt configs | The style of the project. A file with none gets the oxfmt defaults. |
.prettierignore | oxfmt skips the files that it lists. |
git config hook.format-staged-files.enabled false | Turns off format-staged-files in one repository. That hook ignores AGENT_HOOKS_DISABLE. |
To turn the hook off until you quit the agent, use /toggle-hook in Claude Code, pi, or omp. In Codex, set AGENT_HOOKS_DISABLE when you start Codex.
To turn the hook off in one repository, run /toggle-hook --repo format-oxfmt. In Codex, run git config agent-hook.format-oxfmt.enabled false in your own terminal. The dcg pack local.agent_hook blocks an agent command that writes this key.
To remove the hook from one agent, remove its entry from the file in the "Loaded by" column of the adapter table. In Codex, remove the two entries in dot_codex/modify_hooks.json, one for Bash and one for apply_patch.
The hook fails open: when it fails, the tool call and its result stay as they are. A formatter changes the layout of working code. It must not reject an edit.
2 and leaves that file as it is. It still formats the other files of the run.command.ts after 20 s, the timeout of its entry.HEAD): the hook ignores the failure and drops the stderr of git, so "not a git repository" does not show. The sweep finds no files from that list and ends with no error.When the code of the hook throws an error, each adapter fails open:
| 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 oxfmt exits 0. |
test/check.ts runs the hook against real git repositories and the real oxfmt, see docs/testing.md.
hooks/register.ts 34 lines1/**
2 * Claude Code mod: after a Write, Edit, or Bash call, formats the files it
3 * changed with oxfmt. A failed Bash call is formatted too: it may still have
4 * written files. Adds the amend hint as context when the call committed a
5 * file that oxfmt then changed. Fails open: a hook that throws is skipped and
6 * 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 read: (path) => $.fs.read(path).catch(() => undefined),
22 // Rejects when the program is not installed, or timed out.
23 run: (argv, options) => $.process.run(argv, options).catch(() => undefined),
24 };
25 const call = e.tool === "Bash" ? { command: e.command, cwd } : { filePath: e.file_path, cwd };
26 const hint = await format(call, io, {
27 tmpdir: await $.env.get("TMPDIR"),
28 hooksOff: await $.env.get("AGENT_HOOKS_DISABLE"),
29 });
30 if (!hint) return ran;
31 return { ...ran, context: [...(ran.context ?? []), hint] };
32 });
33};
34core.ts 213 lines1/**
2 * The format-oxfmt logic: finds the files a tool call changed, formats them
3 * with oxfmt, and writes the amend hint. Never blocks: a missing oxfmt or a
4 * parse error leaves the files as they are. The adapters pass in the file and
5 * process functions, because a mod reaches them only through `$`.
6 *
7 * Adapters: hooks/register.ts (Claude Code mod), pi.ts (pi and omp),
8 * command.ts (Codex, and the format-staged-files pre-commit hook).
9 * Design notes: README.md.
10 */
11
12export interface Io {
13 /** The kind of a path; undefined if missing. */
14 stat(path: string): Promise<{ kind: "file" | "dir" | "other" } | undefined>;
15 /** The text of a file; undefined if it cannot be read. */
16 read(path: string): Promise<string | 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/** The extensions oxfmt formats after a Write or an Edit. README "Supported extensions". */
44const EDITED =
45 /\.(md|markdown|js|jsx|ts|tsx|mts|cts|mjs|cjs|json|jsonc|json5|css|scss|less|html|yml|yaml|toml)$/;
46/** The Bash sweep takes Markdown only. README "Bash (markdown sweep)". */
47const MARKDOWN = /\.(md|markdown)$/;
48/** A Markdown path that a command names. */
49const NAMED = /[^\s:;|&"'`()<>=]+\.(?:md|markdown)\b/g;
50/**
51 * An absolute path in a command: a root for the sweep. The `/` starts a word,
52 * so the end of a relative path such as `docs/a.md` does not match.
53 */
54const ABSOLUTE = /(?<![^\s"'`=(;|&<>])\/[^\s:;|&"'`()<>]+/g;
55/**
56 * A command that commits: `git`, then global options such as `-C <dir>`, then
57 * `commit` as a word, in one simple command. `git commit-tree` does not match.
58 */
59const COMMIT = /\bgit[ \t](?:[^;&|\n]*[ \t])?commit(?![\w-])/;
60/** The files of the last commit; --root also answers for the first commit. */
61const LAST_COMMIT = ["diff-tree", "--no-commit-id", "--name-only", "-r", "--root"];
62
63/** Whether a command commits: the sweep then also covers the last commit. */
64const commits = (call: Call) => !call.filePath && COMMIT.test(call.command ?? "");
65
66/** Whether AGENT_HOOKS_DISABLE names the hook. */
67const sessionOff = (settings: Settings) =>
68 (settings.hooksOff ?? "").split(/[\s,]+/).includes("format-oxfmt");
69
70/**
71 * A file under /tmp, /var/tmp, or TMPDIR is a scratch file, not project code.
72 * macOS resolves /tmp to /private/tmp, so both sides drop that prefix.
73 */
74function throwaway(file: string, settings: Settings): boolean {
75 const tmpdir = (settings.tmpdir || "/nonexistent").replace(/^\/private/, "");
76 const plain = file.replace(/^\/private/, "");
77 return plain.startsWith("/tmp/") || plain.startsWith("/var/tmp/") || plain.startsWith(tmpdir);
78}
79
80const parent = (path: string) => path.slice(0, path.lastIndexOf("/")) || "/";
81
82async function toplevel(dir: string, io: Io): Promise<string | undefined> {
83 const ran = await io.run(["git", "rev-parse", "--show-toplevel"], {
84 cwd: dir,
85 });
86 return ran?.exitCode === 0 ? ran.stdout.trim() : undefined;
87}
88
89/**
90 * A boolean git config key as the repository that holds `dir` reads it, with
91 * the global config: "true", "false", or "" when the key is unset, the value
92 * is not a boolean, or git cannot start.
93 */
94async function gitBool(dir: string, key: string, io: Io): Promise<string> {
95 const ran = await io.run(["git", "config", "--type=bool", "--get", key], {
96 cwd: dir,
97 });
98 return ran?.stdout.trim() ?? "";
99}
100
101/**
102 * The Markdown files a Bash call may have changed: the paths the command
103 * names, and the files that git reports as changed or untracked in each
104 * repository the command touches. README "Bash (markdown sweep)".
105 */
106async function sweep(call: Call, io: Io): Promise<string[]> {
107 const command = call.command ?? "";
108 // Named paths come first: git hides an ignored file from the sweep, and a
109 // commit in the same call hides a tracked one.
110 const found = [...command.matchAll(NAMED)].map(([path]) =>
111 path.startsWith("/") ? path : `${call.cwd}/${path}`,
112 );
113 // The roots: the cwd and each absolute path in the command, because a `cd`
114 // in the command never moves the cwd of the hook. shortcut: absolute paths
115 // only, because a relative one is ambiguous after a cd.
116 const roots = new Set<string>();
117 const paths = new Set([call.cwd, ...[...command.matchAll(ABSOLUTE)].map(([path]) => path)]);
118 for (const path of paths) {
119 const dir = (await io.stat(path))?.kind === "dir" ? path : parent(path);
120 const root = await toplevel(dir, io);
121 if (root) roots.add(root);
122 }
123 // A repository inside a repository lists as one folder entry, so each such
124 // entry joins the roots as a root of its own. for...of over a Set visits the
125 // roots that join while it runs.
126 const lists = [
127 ["diff", "--name-only", "-z", "--diff-filter=d", "HEAD"],
128 ["ls-files", "--others", "--exclude-standard", "-z"],
129 ];
130 // A command that writes and commits leaves a clean tree, so the sweep also
131 // takes the files of the last commit. README "Write and commit in one command".
132 if (commits(call)) lists.push([...LAST_COMMIT, "-z", "--diff-filter=d", "HEAD"]);
133 for (const root of roots) {
134 const changed = new Set<string>();
135 for (const args of lists) {
136 // A repository with no commits has no HEAD, so the diff fails and the
137 // untracked files still count.
138 const ran = await io.run(["git", ...args], { cwd: root });
139 if (ran?.exitCode === 0)
140 for (const path of ran.stdout.split("\0")) if (path) changed.add(path);
141 }
142 for (const path of changed) {
143 const full = `${root}/${path}`;
144 if ((await io.stat(full))?.kind !== "dir") found.push(full);
145 else {
146 const nested = await toplevel(full, io);
147 if (nested) roots.add(nested);
148 }
149 }
150 }
151 return found;
152}
153
154/**
155 * The files to format, each once. A Write or Edit: the file it names, by
156 * EDITED. A Bash call: the Markdown files of the sweep. A file whose
157 * repository sets agent-hook.format-oxfmt.enabled to false, as /toggle-hook
158 * --repo does, stays as it is.
159 */
160async function targets(call: Call, io: Io, settings: Settings): Promise<string[]> {
161 const named = call.filePaths ?? (call.filePath ? [call.filePath] : undefined);
162 const candidates = named ?? (await sweep(call, io));
163 const extensions = named ? EDITED : MARKDOWN;
164 const kept: string[] = [];
165 // One git run for each folder: a commit can name many files.
166 const asked = new Map<string, Promise<string>>();
167 for (const file of new Set(candidates)) {
168 if (
169 !extensions.test(file) ||
170 throwaway(file, settings) ||
171 (await io.stat(file))?.kind !== "file"
172 )
173 continue;
174 const dir = parent(file);
175 if (!asked.has(dir)) asked.set(dir, gitBool(dir, "agent-hook.format-oxfmt.enabled", io));
176 if ((await asked.get(dir)) !== "false") kept.push(file);
177 }
178 return kept;
179}
180
181/**
182 * Formats the files a call changed. Returns the amend hint for the model when
183 * the call committed a Markdown file that oxfmt then changed, else undefined.
184 * README "Write and commit in one command".
185 */
186export async function format(call: Call, io: Io, settings: Settings): Promise<string | undefined> {
187 if (sessionOff(settings)) return undefined;
188 const files = await targets(call, io, settings);
189 if (!files.length) return undefined;
190 // The hint names only the files that oxfmt itself changed, so keep the text
191 // from before the run. A commit sweep holds only Markdown.
192 const watched = commits(call) ? files : [];
193 const before = await Promise.all(watched.map((file) => io.read(file)));
194 // oxfmt uses the nearest config of each file, else its defaults. README
195 // "Config".
196 const ran = await io.run(["oxfmt", ...files], {
197 cwd: call.cwd,
198 timeoutMs: 20_000,
199 });
200 if (!ran) return undefined;
201 const dirty: string[] = [];
202 for (const [i, file] of watched.entries()) {
203 if ((await io.read(file)) === before[i]) continue;
204 // Only a file that the last commit holds needs an amend.
205 const held = await io.run(["git", ...LAST_COMMIT, "HEAD", "--", file], {
206 cwd: parent(file),
207 });
208 if (held?.stdout.trim()) dirty.push(file);
209 }
210 if (!dirty.length) return undefined;
211 return `oxfmt reformatted markdown 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).`;
212}
213