Packages

AST-aware editing for Elixir: every verb parses the file, changes the tree and parse-checks what it writes.

Current section

Files

Jump to
menard README.md
Raw

README.md

# Menard

> Pierre Menard, author of the Quixote: rewrite a text word for word and have it come out
> different.

AST-aware editing for Elixir. Every verb parses the file, changes the tree, and parse-checks what
it writes — so a half-applied edit is impossible, and only the bytes a verb names move
(Sourceror patches, not a reformat of the whole file).

Parsing is not preservation, though: 0.2.0 fixed edits that parsed and still lost code (a
`rescue` dropped by `clause replace`). What guards that is the identity corpus: every `do … end`
clause, attribute, block and statement in this repo and a fixture of odd syntax is replaced with
itself, and the file must come back unchanged: byte for byte, or at least formatting the same
(`test/menard/identity_test.exs`). `mise run bench:identity` runs the same edits over 22 pinned
hex packages and counts each outcome.

It runs as a project of its own, which means `menard` keeps working on a codebase that does not
currently compile — the case where you most need it.

## Install

Two things ship, and they install differently.

**The library** — `Menard.Rename`, `Menard.Outline`, `Menard.Clause` and the rest as plain
functions, for a tool of your own, plus every verb as `mix menard.*` inside your project:

```elixir
{:menard, "~> 0.4", only: :dev, runtime: false}
```

`only: :dev, runtime: false` is for the tasks; a tool that calls the library at runtime drops both.
The MCP door (`mix menard.mcp`) also needs the optional `{:anubis_mcp, "~> 2.0"}`; without it,
menard brings only Sourceror.

As a dep, the tasks run inside your project, so they survive less of a broken build:

| your project | `mix menard.*` (hex) | the verbs below |
|---|---|---|
| its code does not compile or parse | works | works |
| a dep does not resolve | fails | works |
| its `mix.exs` does not parse | fails | works |

**The verbs** — the CLI, the MCP door and the harness adapters — run from *this* project, with
its own deps, which is what lets them work on a codebase that does not compile:

```
/plugin marketplace add lessthanseventy/menard
/plugin install menard@menard
```

Or clone it and put `bin/menard` on your `PATH`. Either way the first invocation fetches and
compiles its deps for you.

### Wiring a harness

menard ships one adapter per harness, all from this repo — no host repo required. `mise run
install:pi` and `mise run install:claude` write the right config into `~/.pi/agent/` or register
the Claude Code plugin. The plugin installs per project: run `mise -C ~/path/to/menard run
install:claude` from the Elixir repo, and its tools load there and nowhere else (`--user` for
every project). See `docs/adapters.md`.

## Verbs

```
outline FILE                                   what is in the file
find PATTERN FILES                             where a thing is
deps FILE name/arity                           what a function calls
deps    add [--in DIR] SPEC|NAME · upgrade [APPS] [--to REQ]   a project dependency, fetched and compiled
attr    get|set|delete|list  FILE NAME [VALUE]
clause  replace|rewrite|delete|insert_after|insert_before  FILE name/arity HEAD [CODE] [--nth N]
clause  insert_at FILE (Mod|-) top|bottom CODE
clause  move FILE name/arity --to DEST [--as Mod.Name]   with its doc, spec and comment
clause  spec FILE name/arity [SPEC]                 the function's @spec
clause  visibility FILE name/arity public|private
stmt    insert_after|insert_before|replace|delete|list  FILE name/arity HEAD MATCH [CODE]
block   get|replace|add|delete|relabel|list  FILE NAME [CODE] [--label L] [--args CONTEXT]
module  add|list FILE [CODE]
module  replace FILE Mod.Name CODE              one whole module, in a file of several
directive add|replace|remove|list FILE KIND MOD [OPTS]
rename  OLD NEW [--only functions|variables] [--atoms] [--comments] FILES
write   FILE CODE
run     [--in DIR] check|test|format|compile     one JSON line; test takes mix test's flags, answers with seed and runs
mcp                                            the same verbs over MCP
version                                        which menard, on which Elixir and OTP
```

menard complements Igniter, it does not compete with it. Igniter runs the installers and upgraders a
package ships for its users; menard is the edit an agent makes by hand. They meet at `deps
upgrade`, which goes through the host's `mix igniter.upgrade` when its lock has Igniter, so each
package's upgraders run.

`run` answers what you look for: green, `{"ok":true,"tests":621,"failed":0,"failures":[]}`; red,
the failures and the seed that reproduces them. It answers every verb's failures in one shape, `{kind, message, at}`: `kind` is `test`,
`error`, `warning` or `format`, `message` says why, `at` is `file:line`. A test failure adds its
`name`, `module`, `source` and, for an assertion, `code`, `left`, `right`. `check` (format,
warnings-as-errors, tests) reports all three kinds the same way, with `test`'s counts.

stdout is the answer and stderr is only a refusal or an error: menard compiling itself after an
update is silent unless that build fails.

`replace` takes a body, and `rewrite` a whole clause; handed the whole clause (or `block`'s whole
`test "…" do … end`) of the very thing it names, `replace` takes it as meant rather than refusing.

Every writing verb answers with the file's `version`; pass it back with `--version SHA` and an
edit against a file that has changed since is refused, with the diff (`--force` writes anyway).

`--stdin` reads a verb's last argument from stdin, for CODE that shell quoting would mangle.
menard runs on its own toolchain (`.tool-versions`, through mise when it is installed), never
the caller's.

`--frozen` runs the last build straight off `_build` with no compile step: the escape hatch for
editing menard *with* menard, where a half-applied edit would otherwise lock the tool out of
finishing its own change.

## pi adapter

`pi/extension.ts` is a pi extension menard ships from its own repo (`bin/menard` and `hooks/`
self-located from `import.meta.url`: pi loads an extension as a `data:` URL, where
`import.meta.dir` is not a directory):

- **`tool_call` — the guard.** A raw `edit`/`write` on an `.ex`/`.exs` holding a `defmodule` is
  blocked — use the verbs instead. `menard guard FILE --edit INPUT` decides, so an edit that only
  changes text inside a string passes. Fail open: a missing menard never blocks an edit.
- **`tool_result` — format-on-save.** `menard run format FILE` on what was just written, so the
  file on disk is always formatter-compliant. Best-effort and invisible.
- **`tool_result` — advice.** `hooks/shell-edits.sh` and `hooks/read-hint.sh`, the scripts Claude
  Code runs, fed its payload shape; what they say is appended to the tool's result.

`mise run install:pi` wires the extension, the MCP server, and the skill into `~/.pi/agent/`.
Idempotent — re-running updates paths without duplicating. No ficciones, no Nix required.

## The Claude Code plugin

One plugin, `menard` (this directory: `.claude-plugin/plugin.json`, `hooks/hooks.json`,
`skills/menard/SKILL.md`): an MCP server, and hooks that are calls to it.

**The hook.** Every `.ex`/`.exs` an `Edit`/`Write` or a shell command writes is formatted with its
*own* project's formatter, plugins included (Styler, the HEEx formatter). What the formatter
changed goes back to the agent as context, so its next `Edit` is written against the file as it is
now; a file that does not parse is named, with the compiler's why. For a shell command it marks
the time before and formats every Elixir file newer than the mark after, on `PostToolUseFailure`
too, since Claude Code runs `PostToolUse` only when a tool succeeds. Each hook is a `mcp_tool`
call to the server already running (`Menard.Hook`), and the project's formatter is kept warm
between writes (`Menard.Format.Worker`): 6-9 ms a write, where a script that started a VM for
menard and another for the host's formatter took 0.8-1.0 s.

**The tools** are for what an edit by hand does badly: a rename across files, who calls what, one
function read out of a big module, a function moved with its docs, a module split in one call.
Claude Code loads their schemas on demand (behind its tool search): with the plugin installed a
session starts ~440 tokens larger, against ~4,700 with every schema loaded up front.

Why this shape: the eval (`eval/STATUS.md`, bench5) measured the tools plus a guard blocking `Edit`
on modules against the formatting hook alone. The hook gave the same clean output at the cost of
no menard at all; the tools, their schemas in every turn, cost 46% more for sonnet and paid only
on renames and big-file reads. So the hook is what every write gets, the tools wait until they are
asked for, and the guard is gone from Claude Code: its blocks were a turn each and bought nothing
measurable over the hook. `hooks/format-report.sh` is the same hook for a harness that can only
run a command (`menard hook`); `hooks/shell-edits.sh` and `read-hint.sh` stay for the pi adapter.
The stop and commit gates, and the hooks wired nowhere, went with the slim (tag
`pre-slim-2026-10-01` keeps them).