SLOPSHOPPER

toggle-hook

No description.

newcommandstatusprocess
A shopper browsing a rack in a slop shop
README

toggle-hook

Adds the /toggle-hook command, which turns an advisory hook off or on. Without a flag, the change lasts until you quit the agent. With --repo, the change goes in the git config of the session repository, and with --global in your global git config. Both last across sessions. Use it when a formatter fights the style of a repo, or when a linter reports on a file that you do not own, and you do not want to restart the agent.

AgentAdapterLoaded by
Claude Codehooks/register.ts, a mod: $.command.register and a command.run hookCLAUDE_CODE_PLUGIN_DIRS in settings.json.tmpl
pi and omppi.ts: registerCommand, with completion of --repo, --global, and the hook namesextensions in dot_pi/agent/modify_settings.json and dot_omp/private_agent/private_config.yml
CodexNone. Codex has no extension commands, so set the variable at launch, AGENT_HOOKS_DISABLE=lint-prose codex, or run git config in your own terminalNot applicable

What it checks

The hook checks nothing. It runs when you type /toggle-hook, and the argument selects the result:

ArgumentResult
None, or a flag aloneLists the state of each hook and formatter, and the scope that moves it off its default
A hook nameFlips that hook until you quit, and prints its new state
allTurns all 7 hooks off until you quit, or turns all of them on again when one or more are off
--repo and a hook nameFlips that hook in the session repository
--repo and <hook>.<formatter>Flips one formatter in the session repository, for example --repo format-code.shfmt
--repo allTurns all 7 hooks off in the session repository, or on again when one or more are off
--global and a name, or allAs --repo, in the global git config
A guard nameChanges nothing, and names the escape hatch of that guard
Any other textChanges nothing, and lists the hook names

The session can only turn a hook off. A hook that git config turns off stays off when you flip it with no flag: the command then changes nothing and names the flag to use. A formatter name, such as format-code.shfmt, works only with --repo or --global.

Which hooks have a toggle

Only the advisory hooks have a toggle. An advisory hook formats, lints, or type-checks, and does not exist to stop the model. The 7 advisory hooks are in HOOKS in core.ts:

  • format-oxfmt, format-org-tables, and format-code
  • lint-code and lint-prose
  • type-check-tsc and type-check-pyrefly

PARTS in core.ts lists the formatters that have their own key: terraform, shfmt, and ruff of format-code.

The guards (enforce-cli-tools, block-commit-attribution, block-secret-commits) exist to stop the model, so they keep their own escape hatches and have no toggle. track-session-changes only writes a log.

format-staged-files, the git pre-commit hook, removes the variable before it runs the formatters, so a commit is formatted also while a formatter is off for the session. The repository setting applies to that hook too: a formatter that is off for the repository does not format the commit.

Requirements

  • Claude Code: the mod needs Claude Code 2.1.296 or later, the oldest version tested.
  • pi and omp: pi.ts runs in the runtime of the agent and needs no node on PATH.
  • git, for --repo, --global, and the git config state in the list and the status line.

How it works

The command has two scopes:

ScopeStateLasts
SessionAGENT_HOOKS_DISABLE, a list of hook names, separated by spaces or commas, in the process of the agentUntil you quit the agent
Repositoryagent-hook.<hook>.enabled, and agent-hook.<hook>.<formatter>, in .git/config of the session repositoryUntil you change it again
GlobalThe same keys in ~/.gitconfig, or in the file that GIT_CONFIG_GLOBAL namesUntil you change it again

A repository key wins over a global key. A hook does nothing when a scope turns it off. Each of the 7 hooks reads the variable and the git config on each run. It reads the git config in the repository of each file, with git config --type=bool --get, so a session can edit files in more than one repository. A value that is not a boolean, such as banana, keeps the hook on.

The session scope changes the variable in the process of the agent itself. Nothing goes to disk, so the change does not reach another session, and chezmoi sees no drift. In Claude Code, type-check-tsc and type-check-pyrefly are command hooks, which Claude Code starts as a new process: each one gets the variable as Claude Code has it when Claude Code starts the hook.

The repository scope runs git config --local in the session folder. To move a key off its default, it writes the value. To move a key back to its default, it removes the key. When a file outside the repository, such as ~/.config/git/config, still decides, the command writes the value in the repository, which wins.

The global scope reads all global files, and writes ~/.gitconfig, or the file that GIT_CONFIG_GLOBAL names. It never writes ~/.config/git/config, because chezmoi manages that file. Plain git config --global writes there when ~/.gitconfig does not exist. Git reads ~/.gitconfig after ~/.config/git/config, so a value in ~/.gitconfig wins. A global write cannot win over a repository key: the command then tells you to use --repo.

/clear in Claude Code and /new in pi keep the process, so they also keep the session toggle. Quit the agent to turn all session toggles on again.

What the model sees

The model sees no output from this hook, because the model cannot run the command. Only you can. Tests on 2026-10-19 with Claude Code 2.1.303, pi 1.3.0, and omp 18.12.2 showed this:

  • In Claude Code, the Skill tool refuses the command as a built-in CLI command.
  • pi and omp give the model no tool that runs a command.
  • The omp eval tool can change process.env in its own context, but the extensions do not see that change.

A Bash call cannot change the environment of the agent. The model can read the variable, and can set it for one command, but a hook that the agent runs does not see that value.

A Bash call can run git config, so the dcg pack local.agent_hook blocks an agent command that writes an agent-hook.* key. A command that reads a key passes. The pack does not catch a write that does not name the key, such as an edit of .git/config or a key in a shell variable. See dot_config/dcg/README.md.

What you see

The command prints the state of the hooks, or the new state of one hook:

> /toggle-hook
on            format-oxfmt
on            format-org-tables
on            format-code
  on            terraform
  off  repo     shfmt
  on            ruff
on            lint-code
off  session  lint-prose
on            type-check-tsc
on            type-check-pyrefly

> /toggle-hook format-oxfmt
format-oxfmt is off until you quit the agent.

> /toggle-hook --repo format-code.shfmt
format-code.shfmt is on for this repository.

> /toggle-hook --global lint-prose
lint-prose is off in the global git config.

While a hook is off, the status line shows hooks off: <names>. A name that git config turns off has (repo) or (global) after it, for example hooks off: format-code.shfmt (repo), lint-prose. The status line reads the git config when the session starts and after each command, not when the session folder changes.

Settings

You can set AGENT_HOOKS_DISABLE before you start an agent. This command starts Claude Code with lint-prose off, and the status line shows it:

AGENT_HOOKS_DISABLE=lint-prose claude

You can also write the keys yourself, in your own terminal:

git config agent-hook.lint-prose.enabled false
git config agent-hook.format-code.shfmt false
git config --unset agent-hook.lint-prose.enabled
git config --file ~/.gitconfig agent-hook.format-oxfmt.enabled false

In Codex, these are the only ways to turn a hook off.

Failure behavior

  • The mod throws: Claude Code skips a hook that throws, so the command does nothing.
  • The engine refuses the write: for example because of an organization policy. The command prints Not changed: and the reason.
  • --repo outside a git repository: git config --local fails, and the command prints Not changed: and changes nothing.
  • git is not on PATH: the list and the status line show no repository state, and --repo prints Not changed:.

Testing

node test/check.ts checks the core and both adapters, see docs/testing.md.

Design notes

The mod changes the variable with $.env.set, and runs git with $.process.run. pi.ts changes process.env, and runs git with spawnSync. The other mods read the variable with $.env.get, and the other extensions and command.ts read it through io.ts. Each hook reads the git config through the process function of its own Io. docs/hooks/decisions.md gives the reasons for the repository scope.

FileDeployed to ~/.claude/hooks/toggle-hook/Role
core.tsYesThe hook list, the command logic, the git config reads and writes through a git function, the status text
hooks/register.ts, hooks/hooks.jsonYesClaude Code mod
dot_claude-plugin/plugin.jsonYes, as .claude-plugin/plugin.jsonPlugin manifest
pi.tsYespi and omp extension
README.md, docs/, test/NoKept in the repo by .chezmoiignore
Source 2 files
hooks/register.ts 45 lines
1/**
2 * Claude Code mod: adds `/toggle-hook`, which sets AGENT_HOOKS_DISABLE in
3 * the Claude Code process, or with `--repo` or `--global` writes git config.
4 * The mods read both on the next tool call, and the command
5 * hooks and Bash calls started after get the variable. The status line names
6 * the hooks that are off. Only the person can run the command: the Skill
7 * tool refuses it (tested on 2026-10-19).
8 */
9
10import type { Register } from "claude-code";
11import { command, globalFile, readConfig, status, type Git } from "../core.ts";
12
13export const register: Register = (on) => {
14  on("session.start", async ($, e, next) => {
15    await $.command.register({
16      name: "toggle-hook",
17      description:
18        "Turn an advisory hook off or on, until you quit, or with --repo or --global in git config",
19      argumentHint: "[--repo|--global] [hook|all]",
20    });
21    // The engine only takes $ calls spelled at the call site. The process
22    // runs in the session folder.
23    const git: Git = (args) => $.process.run(["git", ...args]).catch(() => undefined);
24    // A value set at launch and the git config state show from the start.
25    $.ui.status(status(await $.env.get("AGENT_HOOKS_DISABLE"), await readConfig(git)));
26    return next(e);
27  });
28  on("command.run", { command: "toggle-hook" }, async ($, e) => {
29    const git: Git = (args) => $.process.run(["git", ...args]).catch(() => undefined);
30    const { value, text, config } = await command(
31      await $.env.get("AGENT_HOOKS_DISABLE"),
32      e.args,
33      git,
34      globalFile({
35        GIT_CONFIG_GLOBAL: await $.env.get("GIT_CONFIG_GLOBAL"),
36        HOME: await $.env.get("HOME"),
37      }),
38    );
39    const set = await $.env.set("AGENT_HOOKS_DISABLE", value);
40    if (set.deny !== undefined) return { text: `Not changed: ${set.deny}` };
41    $.ui.status(status(value, config));
42    return { text };
43  });
44};
45
core.ts 312 lines
1/**
2 * The toggle-hook logic: `/toggle-hook <hook>` turns an advisory hook off or
3 * on until you quit the agent, and `/toggle-hook --repo <hook>` or
4 * `--global <hook>` turns it off or on in git config, across sessions. The
5 * session state is AGENT_HOOKS_DISABLE, a list of hook names in the agent's
6 * own environment. The git config state is agent-hook.<hook>.enabled, and
7 * agent-hook.<hook>.<formatter> for one formatter of a hook, in the
8 * repository or in the global config. Each hook in HOOKS reads both on each
9 * run. The adapters read and write the variable, and pass in a git function.
10 *
11 * Adapters: hooks/register.ts (Claude Code), pi.ts (pi and omp). Codex has no
12 * extension commands, so set the variable when you start it, or run git
13 * config yourself. README.md.
14 */
15
16/** The hooks that read AGENT_HOOKS_DISABLE, in the order the list shows. */
17export const HOOKS = [
18  "format-oxfmt",
19  "format-org-tables",
20  "format-code",
21  "lint-code",
22  "lint-prose",
23  "type-check-tsc",
24  "type-check-pyrefly",
25];
26
27/**
28 * The formatters of a hook that have their own git config key, so that
29 * `--repo format-code.shfmt` turns off one formatter. The same keys are in
30 * FORMATTERS in format-code/core.ts, and test/check.ts keeps them the same.
31 */
32export const PARTS = new Map([["format-code", ["terraform", "shfmt", "ruff"]]]);
33
34/** The names that `--repo` and `--global` take: each hook, then its formatters. */
35export const NAMES = HOOKS.flatMap((hook) => [
36  hook,
37  ...(PARTS.get(hook) ?? []).map((part) => `${hook}.${part}`),
38]);
39
40/**
41 * The guards have no toggle: what to use in place of one. A Map, so that a
42 * name such as `constructor` does not find an Object.prototype property.
43 */
44const GUARDS = new Map([
45  ["enforce-cli-tools", "Use .agent-allow-cli-tools in the repo."],
46  ["block-commit-attribution", "Remove the trailer from the commit message."],
47  ["block-secret-commits", "Use .agent-allow-secrets in the repo."],
48]);
49
50/** Runs git in the session folder: the exit code and stdout, or undefined. */
51export type Git = (args: string[]) => Promise<{ exitCode: number; stdout: string } | undefined>;
52
53/**
54 * Where git config sets a key: the repository, or a file outside it, such as
55 * ~/.gitconfig or ~/.config/git/config.
56 */
57export type Scope = "repo" | "global";
58
59/**
60 * The names that git config sets, as the hooks read them: the value and the
61 * scope. A name that is unset, or set to a value that is not a boolean, is
62 * not in the map: the hook then keeps its default.
63 */
64export type Config = Map<string, { on: boolean; scope: Scope }>;
65
66/** The git config key of a name of NAMES. */
67const key = (name: string) =>
68  name.includes(".") ? `agent-hook.${name}` : `agent-hook.${name}.enabled`;
69
70/** Whether git config leaves a name on: its value, else on. */
71const gitOn = (name: string, config: Config) => config.get(name)?.on ?? true;
72
73/** The scope where git config turns a name off, else undefined. */
74function moved(name: string, config: Config): Scope | undefined {
75  const set = config.get(name);
76  return set && !set.on ? set.scope : undefined;
77}
78
79/** The text for a scope: "for this repository", "in the global git config". */
80const where = (scope: Scope) =>
81  scope === "repo" ? "for this repository" : "in the global git config";
82
83/** The flag of a scope. */
84const flag = (scope: Scope) => (scope === "repo" ? "--repo" : "--global");
85
86/** The hook names in a AGENT_HOOKS_DISABLE value: space or comma. */
87function names(value: string | undefined): string[] {
88  return (value ?? "").split(/[\s,]+/).filter(Boolean);
89}
90
91/**
92 * The git config of the session folder for each name of NAMES. One
93 * `git config --get` for each key, as the hooks read it, and one list of the
94 * keys of the repository for the scope. A folder outside git has no
95 * repository keys. With `--global`, only the global config.
96 */
97export async function readConfig(git: Git, only?: "--global"): Promise<Config> {
98  // A read of one file follows an include only with --includes. The hooks
99  // read all files, which follows each include.
100  const level = only ? [only, "--includes"] : [];
101  const [here, ...values] = await Promise.all([
102    only ? undefined : git(["config", "--local", "--name-only", "--get-regexp", "^agent-hook\\."]),
103    ...NAMES.map((name) => git(["config", ...level, "--type=bool", "--get", key(name)])),
104  ]);
105  const local = new Set(here?.exitCode === 0 ? here.stdout.split("\n").filter(Boolean) : []);
106  const config: Config = new Map();
107  NAMES.forEach((name, i) => {
108    const value = values[i]?.stdout.trim();
109    if (value === "true" || value === "false")
110      config.set(name, {
111        on: value === "true",
112        scope: local.has(key(name)) ? "repo" : "global",
113      });
114  });
115  return config;
116}
117
118/** The text for a name that is not a guard and not in `known`. */
119function unknown(name: string, known: string[]): string | undefined {
120  const guard = GUARDS.get(name);
121  if (guard) return `${name} is a guard and has no toggle. ${guard}`;
122  if (!known.includes(name)) return `${name} is not a hook. The hooks: ${known.join(", ")}, all.`;
123}
124
125/** The state of each name, with the scope that moves it off its default. */
126function list(off: string[], config: Config): string {
127  return NAMES.map((name) => {
128    const [hook = "", part] = name.split(".");
129    const session = !part && off.includes(name) && gitOn(name, config);
130    const scope = session ? "session" : (moved(name, config) ?? "");
131    const on = gitOn(name, config) && !session;
132    const line = `${on ? "on " : "off"}  ${scope.padEnd(7)}  ${part ?? hook}`;
133    return (part ? `  ${line}` : line).trimEnd();
134  }).join("\n");
135}
136
137/**
138 * Runs `/toggle-hook <args>` with no `--repo` or `--global`, on the current
139 * AGENT_HOOKS_DISABLE and the git config. Returns the new value (undefined:
140 * unset the variable) and the text for the user. No argument lists the
141 * state of each hook, a hook name flips it, and `all` turns every hook off,
142 * or on again when one is off. A name that is not in HOOKS, for example one
143 * set at launch, stays in the value. The session can only turn a hook off:
144 * a hook that git config turns off stays off, and the text names the flag.
145 */
146export function toggle(
147  current: string | undefined,
148  args: string,
149  config: Config = new Map(),
150): { value: string | undefined; text: string } {
151  const off = names(current);
152  const name = args.trim();
153  const value = (list: string[]) => list.join(" ") || undefined;
154  const same = (text: string) => ({ value: current, text });
155  if (!name) return same(list(off, config));
156  if (name === "all") {
157    const others = off.filter((hook) => !HOOKS.includes(hook));
158    const still = HOOKS.filter((hook) => moved(hook, config));
159    return HOOKS.some((hook) => off.includes(hook))
160      ? {
161          value: value(others),
162          text: still.length
163            ? `All hooks are on, but git config turns off ${still.join(", ")}. Use /toggle-hook --repo or --global.`
164            : "All hooks are on.",
165        }
166      : {
167          value: value([...others, ...HOOKS]),
168          text: "All hooks are off until you quit the agent.",
169        };
170  }
171  if (NAMES.includes(name) && !HOOKS.includes(name))
172    return same(
173      `${name} has a toggle in git config only. Use /toggle-hook --repo ${name} or --global ${name}.`,
174    );
175  const wrong = unknown(name, HOOKS);
176  if (wrong) return same(wrong);
177  const scope = moved(name, config);
178  if (scope)
179    return same(`${name} is off ${where(scope)}. Use /toggle-hook ${flag(scope)} ${name}.`);
180  return off.includes(name)
181    ? {
182        value: value(off.filter((hook) => hook !== name)),
183        text: `${name} is on.`,
184      }
185    : {
186        value: value([...off, name]),
187        text: `${name} is off until you quit the agent.`,
188      };
189}
190
191/** One change to git config: the name, and whether to turn it on or off. */
192export interface Write {
193  name: string;
194  on: boolean;
195}
196
197/**
198 * Runs `/toggle-hook --repo <name>` or `--global <name>`: flips one name of
199 * NAMES in one scope of git config, or with `all` flips every hook. `config`
200 * is the state that the flip starts from: for `--global`, the global config
201 * only, so that a repository key does not decide the global flip. Returns
202 * the writes and the text for the user. No name lists the state, as with no
203 * flag.
204 */
205export function toggleConfig(
206  current: string | undefined,
207  args: string,
208  config: Config,
209  scope: Scope,
210): { writes: Write[]; text: string } {
211  const name = args.trim();
212  const same = (text: string) => ({ writes: [], text });
213  if (!name) return same(list(names(current), config));
214  if (name === "all") {
215    const off = HOOKS.filter((hook) => !gitOn(hook, config));
216    return off.length
217      ? {
218          writes: off.map((name) => ({ name, on: true })),
219          text: `All hooks are on ${where(scope)}.`,
220        }
221      : {
222          writes: HOOKS.map((name) => ({ name, on: false })),
223          text: `All hooks are off ${where(scope)}.`,
224        };
225  }
226  const wrong = unknown(name, NAMES);
227  if (wrong) return same(wrong);
228  const on = !gitOn(name, config);
229  return {
230    writes: [{ name, on }],
231    text: `${name} is ${on ? "on" : "off"} ${where(scope)}.`,
232  };
233}
234
235/**
236 * The file that `--global` writes: GIT_CONFIG_GLOBAL, else ~/.gitconfig.
237 * Plain `git config --global` writes to ~/.config/git/config when
238 * ~/.gitconfig does not exist, and chezmoi manages that file. Git reads
239 * ~/.gitconfig after it, so a value there wins.
240 */
241export function globalFile(env: { GIT_CONFIG_GLOBAL?: string; HOME?: string }): string | undefined {
242  return env.GIT_CONFIG_GLOBAL || (env.HOME && `${env.HOME}/.gitconfig`);
243}
244
245/**
246 * Runs `/toggle-hook <args>`: reads the git config with `git`, and writes it
247 * for `--repo`, and for `--global` to `global` (see globalFile). Returns the
248 * new AGENT_HOOKS_DISABLE value, the text, and the git config after the
249 * run, for the status line.
250 */
251export async function command(
252  current: string | undefined,
253  args: string,
254  git: Git,
255  global?: string,
256): Promise<{ value: string | undefined; text: string; config: Config }> {
257  const before = await readConfig(git);
258  const [option, ...rest] = args.trim().split(/\s+/);
259  const scope: Scope | undefined =
260    option === "--repo" ? "repo" : option === "--global" ? "global" : undefined;
261  if (!scope) return { ...toggle(current, args, before), config: before };
262  const name = rest.join(" ");
263  // No name lists the state that the hooks see.
264  if (!name) return { ...toggle(current, "", before), config: before };
265  const basis = scope === "global" ? await readConfig(git, "--global") : before;
266  const { writes, text } = toggleConfig(current, name, basis, scope);
267  const level = scope === "repo" ? ["--local"] : global ? ["--file", global] : ["--global"];
268  for (const { name, on } of writes) {
269    // A name back on loses its key, so that the git config stays clean.
270    const argv = on ? ["--unset", key(name)] : [key(name), "false"];
271    const ran = await git(["config", ...level, ...argv]);
272    // --local fails outside a git repository. --unset exits 5 when the scope
273    // does not set the key.
274    if (ran?.exitCode !== 0 && !(argv[0] === "--unset" && ran?.exitCode === 5))
275      return {
276        value: current,
277        text: `Not changed: git config failed for ${name}.${scope === "repo" ? " --repo needs a git repository." : ""}`,
278        config: before,
279      };
280  }
281  // A key in a file outside the repository, such as ~/.config/git/config,
282  // can still keep a name from the state it should have. A value written in
283  // the scope wins over it.
284  const missed = (config: Config) => writes.filter(({ name, on }) => gitOn(name, config) !== on);
285  let after = await readConfig(git);
286  const outside = missed(after).filter(({ name }) => after.get(name)?.scope === "global");
287  if (outside.length) {
288    for (const { name, on } of outside) await git(["config", ...level, key(name), String(on)]);
289    after = await readConfig(git);
290  }
291  // A global write cannot win over the repository.
292  const kept = missed(after).map(({ name }) => name);
293  return {
294    value: current,
295    text: kept.length
296      ? `${text} This repository overrides ${kept.join(", ")}: use /toggle-hook --repo.`
297      : text,
298    config: after,
299  };
300}
301
302/** The status line text while a hook is off. */
303export function status(value: string | undefined, config: Config = new Map()): string | undefined {
304  const session = names(value);
305  const off = NAMES.flatMap((name) => {
306    const scope = moved(name, config);
307    if (scope) return [`${name} (${scope})`];
308    return HOOKS.includes(name) && session.includes(name) ? [name] : [];
309  });
310  return off.length ? `hooks off: ${off.join(", ")}` : undefined;
311}
312