# `lets hooks` and `lets hook classify`

How `lets` reaches an agent without asking it to remember a prose rule. Two commands: `lets
hooks install|uninstall <claude-code|codex>` wires the integration into the agent's own settings;
`lets hook classify` is the program those hooks call on every shell command.

```
lets hooks install <claude-code|codex>
lets hooks uninstall <claude-code|codex>
lets hook classify
```

## What `hooks install` does

**`claude-code`** merges three hooks into `~/.claude/settings.json`:

- A `SessionStart` hook (matcher `startup|resume|clear|compact|fork`) that prints a short
  paragraph explaining `lets` and its verbs at the start of every session — including after
  compaction, since injected context does not survive compaction the way a system prompt does.
- A `SubagentStart` hook that delivers the same paragraph as `additionalContext`, since
  `--append-system-prompt` does not reach a non-fork subagent.
- A `PreToolUse` hook on `Bash`, matcher covering every shell call, that pipes the command to
  `lets hook classify` and blocks only a bare read it can classify with confidence **and** that
  has a runnable `lets` replacement — a `cat`, `sed -n` or `grep` of a repo file whose stdout goes
  to the tool result, not into a pipe or a subshell, plus a bare `sed -i 's/OLD/NEW/g'`
  substitution against an in-tree file, replaced with `lets edit <path> --old '<OLD>' --new
  '<NEW>' --all`.

**`codex`** merges a `PreToolUse` entry into `hooks.json` (default `$CODEX_HOME/hooks.json`, or
`~/.codex/hooks.json`) and prints one paragraph to add to `~/.codex/AGENTS.md` by hand — Codex has
no session-start hook to inject a paragraph automatically the way Claude Code does.

Both installers only ever write into the agent's own settings; nothing modifies your shell
profile. Installing twice is a no-op — the settings file is byte-identical across a reinstall.
`hooks uninstall` removes exactly what `hooks install` added and leaves any of the user's own
hooks in the same file untouched.

## What `lets hook classify` does

Reads one `PreToolUse` JSON event (`session_id`, `cwd`, `hook_event_name`, `tool_name`,
`tool_input.command`) on stdin. For an allowed command it prints nothing and exits 0 — the
command runs unmodified. For a blocked command it prints one JSON line on stdout naming the
`lets` replacement, in the shape Claude Code's `PreToolUse` hook contract expects
(`hookSpecificOutput.permissionDecision: "deny"`).

The classifier parses the command with a real bash grammar, walks pipelines, `&&`/`;` lists and
substitutions, and blocks only what it can translate with confidence. It fails open: `lets`
missing, crashing, or mid-update degrades every hook to allow, never an error that could wedge an
agent's turn.

What passes unblocked, deliberately: output piped into another program (`cat f | jq`, `cat f |
wc`), a command substitution or process substitution (`$(cat f)`, `<(cat f)`), a heredoc sent to
another program's stdin, and any path outside the working tree.

A `sed -i` substitution is blocked only in its narrowest form: a bare `sed -i 's/OLD/NEW/g'`
(the `g` flag is required), every target path in-tree, non-glob, resolvable, and neither a
directory nor duplicated, and a substitution that is literal — no regex metacharacters, no
line-range prefix. Anything looser (a different `-i` suffix, a non-global substitution, a regex
with `.*` or `&`) passes through unblocked, since a wrong translation is worse than none.

## Flags

Both `hooks install`/`hooks uninstall` and `hook classify` take only the shared flags
(`--json`, `--jsonl`, `--budget`, `--max-bytes`, `--max-file-bytes`, `--no-ignore`,
`--allow-outside`, `--no-check`, `-q/--quiet`); `hook classify` takes its event as stdin, not as a
flag.

## Exit codes

| Exit | Slug | Means |
|---|---|---|
| 0 | — | installed, uninstalled, or nothing to remove |
| 1 | `path_conflict` | a different `lets` comes first on `PATH`; named in the message, nothing written |
| 1 | `not_on_path` | no `lets` reachable on `PATH` at all, so a hook could not run it |
| 7 | `io_error` | the settings file could not be read or written |

## Examples

Installing for Claude Code adds all three hooks in one call:

```console
$ lets hooks install claude-code
added the PreToolUse hook
added the SubagentStart hook
added the SessionStart hook
```

A second install is a no-op, reported as such, and the settings file does not change:

```console
$ lets hooks install claude-code
the PreToolUse hook was already installed
the SubagentStart hook was already installed
the SessionStart hook was already installed
```

Installing when a different `lets` shadows this one on `PATH` refuses and names it:

```console
$ lets hooks install claude-code
? 1
a different `lets` comes first on PATH at [CWD]/fake-bin/lets — this one is [..]/lets · remove the other, or put this one's directory ahead of it on PATH
ERROR_CODE=path_conflict
```

`lets hook classify` blocking a bare `cat`, then the replacement it named:

```console
$ printf '{"session_id":"s","cwd":"%s","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cat src/usage.ts"}}' "$(pwd)" | lets hook classify
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"lets show reads several files and ranges in one call.\nrun: lets show src/usage.ts"}}

$ lets show src/usage.ts
── src/usage.ts  (1-9 of 9) · sha:9925e474e391
1   const cap = 10
...
── showed 1 target · 9 lines
```

`lets hook classify` letting a piped `cat` through untouched — its stdout never reaches the tool
result, so there is nothing to replace:

```console
$ printf '{"session_id":"s","cwd":"%s","hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"cat config/app.json | wc -l"}}' "$(pwd)" | lets hook classify
```

(stdout is empty, exit code 0 — the command runs unmodified.)

Installing for Codex adds the `PreToolUse` hook and prints the one line to add by hand:

```console
$ lets hooks install codex
added the PreToolUse hook
For reading, finding and editing files, use `lets` (run `lets guide` once) instead of `cat`,
`grep` or `sed -n`. It reads several files or ranges in one call, returns bounded numbered
output, and its edits return the changed region — so do not follow a `lets` call with a `cat` or
`sed -n` to check the result.
add this to ~/.codex/AGENTS.md by hand
```
