tito (Tiny Tokens) is a single-file (tito.py, stdlib only) command
wrapper for Claude Code. Golden rule: it can never break a command.
Recognized commands get compact output; everything else, and anything that
goes wrong, falls back to the raw output with the real exit code untouched.
User-facing docs (install, writing filters, contributing) live in
docs/ as a GitHub wiki. This file is the internals map —
read it before touching tito.py.
tito now has its full pipeline: dispatch, the extension/filter registry,
the hook rewriter, stats, and all meta commands (gain, filters,
enable, browse, disable, new-filter, hook, rewrite, install
— see USAGE and the meta dict in main()). Only one filter ships
today: extensions/git/default.py, the reference/example extension —
everything else the docs describe (cargo, npm, docker, per-language
toolchains…) is roadmap, not yet in extensions/.
All user-facing messages go through _say(emoji, color, msg, stream),
which prints an emoji-prefixed, color-coded line. _paint strips the ANSI
color when the target stream isn't a TTY, so piped/captured output stays
clean. Colors live in module-level constants (_RED, _GREEN, _YELLOW,
_CYAN, _RESET). When adding output, use _say rather than raw print
so the styling and TTY behavior stay consistent. (cmd_install is the only
caller today; cmd_gain, cmd_enable, etc. still use raw print.)
config_dir()—~/.config/tito, overridable viaTITO_HOME.filters_dir()—<config_dir>/filters, where user/downloaded extensions live.bundled_extensions_dir()—./extensionsnext totito.py. Only present for repo checkouts; an installed binary has no bundled dir, so extensions there only come fromtito enable/tito browse.stats_file()—<config_dir>/stats.jsonl.
A Filter bundles commands (list of command names), render(argv, stdout, stderr, code) -> str | None, optional transform(argv) -> argv,
a source label, and an optional subcommands set restricting it to
specific first non-flag args (lets e.g. go/build.py and go/test.py
split one command across files).
load_registry() builds {command -> Filter} by walking
bundled_extensions_dir() then filters_dir() (load_extensions()), so a
user copy always overrides the bundled one — later load wins. Each *.py
file must define COMMANDS (and filter, optionally transform /
SUBCOMMANDS); a broken file is skipped with a stderr warning, never
takes tito down (load_extensions's try/except).
When multiple filters claim the same command, _resolve() picks a single
Filter: no subcommand-restricted filter among them → last loaded wins
(plain override). Otherwise _router() builds a routing Filter that
dispatches by _argv_subcommand(argv) — a subcommand-specific filter wins
for its subcommand, the one filter with no subcommands (if any) is the
default for everything unmatched, and with no default an unmatched
subcommand just passes through (render returns None).
- Look up the
Filterforargv[0]in the registry; no filter → run the command as-is viasubprocess.run(argv)(no capture), preserving its exit code;FileNotFoundError→ printtito: <cmd>: command not found, exit 127. - Filter found → run its
transform(argv)if present (any exception falls back to the untransformed argv). run()executes the (possibly transformed) argv, capturing stdout/stderr/exit code.flt.render(argv, stdout, stderr, code)— any exception is treated asNone.None→ write the raw stdout/stderr straight through, return the real code. A string →record_stats(), write the compact string (newline appended if missing) to stdout; on non-zero exit also write the raw stderr (so Claude still sees the real error) — seefilterconventions inextensions/git/default.py(checkcode != 0first).
Every step after "filter found" is wrapped so an exception degrades to raw output rather than crashing or hiding the command's result — that's the golden rule made concrete.
Every filtered (non-None) dispatch appends one JSONL line — ts, cmd,
raw (raw byte length), filtered (compact byte length) — to
stats_file(); write failures are swallowed (except OSError: pass),
bookkeeping must never fail a command.
tito gain aggregates the file per command and overall: count, tokens
estimated as bytes // 4, percent saved, and a rough time-saved estimate
(tokens / 50, shown only under 24h of estimated savings — past that the
number is too coarse to be meaningful). Rows are colored green/red
relative to the average when _gain_color_enabled() (TTY and no
NO_COLOR). tito gain --reset deletes the stats file.
registry_url()—TITO_REGISTRYenv override, elseREGISTRY_URL(github.com/pyxel-dev/Tiny-Tokensraw URL)._filter_rel_path(name)/_filter_path(name)— map a user-facing name to<filters_dir>/<name>/default.py(bare name) or<name>.py(already contains/, e.g.go/build);_filter_pathrejects path traversal (anything that normalizes outsidefilters_dir()).tito enable <name>(cmd_enable/_do_enable) — if<path>.disabledexists locally, restores it (no re-download, so local edits survive a disable/enable cycle); otherwise fetches<name>/default.pyfrom the registry.tito disable <name>— renames the installed file to<path>.disabled(kept on disk, not deleted).tito browse(cmd_browse/_fetch_manifest/_browse_ui) — requires a TTY; fetchesmanifest.jsonfrom the registry, shows a curses checkbox picker (↑/↓ or j/k, space to toggle, a/n select-all/none, Enter to install checked, q/Esc to cancel), then_do_enables each selection.tito new-filter <name>— writesFILTER_SKELETONto<filters_dir>/<name>/default.py(or<name>.pyif nested), refuses to overwrite an existing file.tito filters(cmd_filters) — lists bundled and user filters found by walking both directories; strips the.py/.py.disabledsuffix and a trailing/default, tags disabled ones.
_split_segments(cmd) splits a shell command on &&/||/; outside
quotes, tracking single/double quotes and backslash-escapes. Returns
None (refuse to touch anything) on: unbalanced quotes, command
substitution (` or $(), redirection (</>), a pipe (| —
downstream consumes raw output, so filtering here would be wrong), or
backgrounding (&). _rewrite_segment prefixes a segment's first word
with tito only if that word is in the handled-commands set, isn't
already tito/rtk, and isn't a VAR=value assignment.
rewrite_command(cmd, handled) composes both: doubt always favors no
rewrite — a missed rewrite costs one unfiltered turn, a wrong rewrite
could change what a pipeline actually does.
cmd_hook is the registered PreToolUse hook body (tito hook, reading
JSON on stdin): loads the registry to get the handled-commands set,
rewrites tool_input.command, and if changed, emits the
hookSpecificOutput.updatedInput JSON Claude Code expects. Wrapped in a
bare try/except: pass — never crash, never block; any problem means no
output, exit 0, original command runs untouched.
tito rewrite <cmd>... (or piped via stdin) prints the rewritten form
without executing anything — for debugging the rewrite logic. Same
never-fail contract: on any error it echoes the input unchanged.
Order matters and is deliberate:
- Validate
~/.claude/settings.jsonis parseable before touching any file — a corrupt settings file must fail loudly with nothing copied, not half-install. - Copy the binary to
~/.local/bin/tito(skipped viashould_copy()if already running from that exact path, avoidingshutil.SameFileErrorontito installre-runs). - Warn (don't fail) if
~/.local/binisn't onPATH. - Register the
PreToolUsehook idempotently (register_hook()scans for an existingtitohook command before appending).
The registered hook command is ~/.local/bin/tito hook — and hook is
now implemented (see above), so a fresh tito install produces a live
hook, not an inert one.
load_settings(path) returns {} when the file is missing, None when
it's unreadable — the None sentinel is what cmd_install keys on to
refuse to proceed.
- Binary:
~/.local/bin/tito - Claude Code settings:
~/.claude/settings.json - Config dir:
~/.config/tito(filters underfilters/, stats atstats.jsonl), overridable viaTITO_HOME.
tests/ currently covers output helpers (test_output.py), settings
load/hook registration (test_settings.py), install (test_install.py),
top-level dispatch (test_main.py), and the bundled git extension
(tests/extensions/git/test_git.py) — one file per concern, mirrored
against extensions/ for per-extension golden tests. CONTRIBUTING.md's
test-layout table describes a larger target structure (core/,
filters/, hook/, registry/, meta/) that tests haven't grown into
yet; treat it as where new test files should land, not as what exists
today.
scripts/ holds dev-only shell wrappers, not part of tito.py itself:
scripts/test.sh—cds to the repo root and runspython3 -m unittest discover -s tests -v.scripts/update.sh—cds to the repo root and runspython3 tito.py install(cmd_install, above), so a local edit totito.pyis redeployed to~/.local/bin/titoand the hook re-registered. Run this after any change totito.pyyou want your own Claude Code session to pick up.scripts/commit.sh—cds to the repo root and runscz commit "$@"(commitizen's Conventional Commits wizard, see "Commit messages" below), failing with an install hint ifczisn't onPATH.
Every commit is Conventional Commits with a required scope:
<type>(<scope>): <description>, e.g. feat(extensions): add git. Scope
is one of core (tito.py itself), extensions (extensions/), docs
(docs/, README.md, CONTRIBUTING.md, CLAUDE.md), tests
(tests/), or any other short scope name when none of those fit (the *
catch-all — see CONTRIBUTING.md). Enforced via commitizen
([tool.commitizen] in pyproject.toml). When committing on the user's
behalf, write messages in this format rather than plain
git commit -m "...".
- Stdlib only, single file. Stated design constraint, not an oversight
— don't introduce a dependency or split
tito.pyinto a package without discussing it first. - The golden rule. When extending, wrap anything that can fail so an exception degrades to the raw path rather than crashing or hiding output.
- Doubt favors passthrough, especially in the hook rewriter — a missed optimization costs nothing, a wrong rewrite changes behavior.