Drop a Python file in ~/.config/tito/filters/. No registration step — tito loads every *.py file in that directory automatically. A plain flat file (e.g. docker.py) works fine for a hand-written filter, but tito new-filter/enable use one folder per command with the file named default.py — see Managing filters below.
# ~/.config/tito/filters/docker/default.py
COMMANDS = ["docker"]
def filter(argv, stdout, stderr, code):
"""Return compact output, or None to keep the raw output."""
if code != 0:
return None # let Claude see real errors, unfiltered
return "\n".join(l for l in stdout.splitlines() if "Up " in l)That's the whole contract:
COMMANDS— list of command names this file handles (usually one).filter(argv, stdout, stderr, code) -> str | None—argvis the full command as a list (["docker", "ps", "-a"]),stdout/stderrare the captured strings,codeis the exit code. ReturnNoneto fall back to raw output for this particular invocation (e.g. a shape of output your filter doesn't understand yet).
If you need a machine-readable flag that isn't there by default, add a transform:
def transform(argv):
"""docker ps -> docker ps --format '{{.Names}}\t{{.Status}}'"""
if "--format" in argv:
return argv
return argv + ["--format", "{{.Names}}\t{{.Status}}"]transform(argv) -> argv runs before the command executes. Prefer this over parsing whatever the tool prints by default — structured flags (--porcelain, --format, --json, -1F…) are far more stable across tool versions and locales than scraping human-oriented output.
- Fail safe. If your filter raises an exception, tito silently falls back to raw output — it will never crash a command or hide its result. You don't need defensive
try/exceptinsidefilter()unless you specifically want partial results instead of a full fallback. - Check the exit code first. Compact rendering is for successful runs. On failure, return
Noneso Claude sees the real error output — don't try to compress error messages. - Return
None, don't guess. If the output doesn't look like what you expected (unexpected flags used, unfamiliar format), returnNonerather than producing a misleading compact summary.
If a command's subcommands print genuinely different output shapes (go build's compiler diagnostics vs. go test's pass/fail report), put one file per subcommand in a folder instead of cramming both shapes into one filter():
~/.config/tito/filters/go/build.py # COMMANDS = ["go"], SUBCOMMANDS = ["build"]
~/.config/tito/filters/go/test.py # COMMANDS = ["go"], SUBCOMMANDS = ["test"]
Each file adds SUBCOMMANDS = [...] (the first non-flag argument after the command, e.g. "build" in go build -v) alongside its COMMANDS. tito merges every file under go/ into one filter for go that routes by subcommand at call time. A file with no SUBCOMMANDS is the folder's default and handles any subcommand none of its siblings claimed; with no default, an unclaimed subcommand just passes through raw. tito enable/disable/new-filter accept the nested form directly: tito new-filter go/vet.
A folder can also just group unrelated commands from the same ecosystem with no routing involved — e.g. java/mvn.py and java/gradle.py each declare their own distinct COMMANDS. That's purely organizational.
There's no test harness required — a filter is just a plain function. Feed it fixture stdout/stderr and assert on the returned string, the same way tests/extensions/git/test_git.py tests the bundled git filter. See Contributing for running the test suite.
tito filters # list core filters + installed extensions
tito new-filter <name> # scaffold ~/.config/tito/filters/<name>/default.py
tito disable <name> # rename to <name>/default.py.disabled (kept, not deleted)
tito enable <name> # re-enable a locally disabled filter, or
# download an official one — see Extensions
(<name> can also be a nested path like go/vet, in which case it's used as-is: go/vet.py, no default involved.)