From fbda9b7929816967b689bd1a8cb4b9efdb86a998 Mon Sep 17 00:00:00 2001 From: Kevin Py Date: Thu, 9 Jul 2026 19:18:08 +0200 Subject: [PATCH 01/10] feat: add main file --- tests/test_install.py | 157 +++++++++++++++++++++++++++++++++++++++++ tests/test_main.py | 46 ++++++++++++ tests/test_output.py | 42 +++++++++++ tests/test_settings.py | 99 ++++++++++++++++++++++++++ tito.py | 136 +++++++++++++++++++++++++++++++++++ 5 files changed, 480 insertions(+) create mode 100644 tests/test_install.py create mode 100644 tests/test_main.py create mode 100644 tests/test_output.py create mode 100644 tests/test_settings.py create mode 100644 tito.py diff --git a/tests/test_install.py b/tests/test_install.py new file mode 100644 index 0000000..d189463 --- /dev/null +++ b/tests/test_install.py @@ -0,0 +1,157 @@ +"""Tests for should_copy() and the cmd_install() flow. + +cmd_install touches ~/.local/bin and ~/.claude/settings.json, so each +integration test isolates it by redirecting "~" to a temp HOME and pointing +tito.__file__ at a fake source file. No real install ever happens. + +_say's default stream is bound at import time, so rather than fight stdout +capture we record _say calls directly (it's a module global resolved at call +time, hence patchable). +""" + +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import tito # noqa: E402 + + +# --- should_copy ----------------------------------------------------------- + +class ShouldCopyTests(unittest.TestCase): + def test_true_when_target_missing(self): + with tempfile.TemporaryDirectory() as d: + src = os.path.join(d, "src") + open(src, "w").close() + self.assertTrue(tito.should_copy(src, os.path.join(d, "missing"))) + + def test_false_when_same_file(self): + with tempfile.TemporaryDirectory() as d: + p = os.path.join(d, "f") + open(p, "w").close() + self.assertFalse(tito.should_copy(p, p)) + + def test_true_when_different_files(self): + with tempfile.TemporaryDirectory() as d: + a = os.path.join(d, "a") + b = os.path.join(d, "b") + open(a, "w").close() + open(b, "w").close() + self.assertTrue(tito.should_copy(a, b)) + + +# --- helpers --------------------------------------------------------------- + +@contextlib.contextmanager +def _fake_env(which_returns=None): + """Redirect ~ to a temp HOME and tito.__file__ to a fake source. + + Yields a dict with home/src/settings/target paths. The real settings.json + and ~/.local/bin/tito stay untouched. + """ + with tempfile.TemporaryDirectory() as home: + src_dir = os.path.join(home, "repo") + os.makedirs(src_dir) + src = os.path.join(src_dir, "tito.py") + with open(src, "w") as f: + f.write("#!/usr/bin/env python3\n") + + settings_path = os.path.join(home, ".claude", "settings.json") + target = os.path.join(home, ".local", "bin", "tito") + + def fake_expanduser(path): + return home + path[1:] if path.startswith("~") else path + + with mock.patch("tito.os.path.expanduser", side_effect=fake_expanduser), \ + mock.patch.object(tito, "__file__", src), \ + mock.patch("tito.shutil.which", return_value=which_returns): + yield {"home": home, "src": src, "settings": settings_path, "target": target} + + +@contextlib.contextmanager +def _record_say(): + """Record every _say() call as (emoji, message) tuples.""" + calls = [] + + def fake(emoji, color, msg, stream=sys.stdout): + calls.append((emoji, msg)) + + with mock.patch("tito._say", fake): + yield calls + + +def _tito_hook_count(settings_path): + with open(settings_path) as f: + s = json.load(f) + return sum( + 1 + for entry in s["hooks"]["PreToolUse"] + for h in entry.get("hooks", []) + if "tito" in h.get("command", "") + ) + + +# --- cmd_install (isolated) ------------------------------------------------ + +class CmdInstallTests(unittest.TestCase): + def test_corrupt_settings_returns_1_and_copies_nothing(self): + # All assertions run inside the with-block: _fake_env's tempdir is + # torn down on exit, so files only exist while it's open. + with _fake_env() as env, _record_say() as calls: + os.makedirs(os.path.dirname(env["settings"]), exist_ok=True) + with open(env["settings"], "w") as f: + f.write("{ broken") + rc = tito.cmd_install([]) + + self.assertEqual(rc, 1) + # Golden rule: nothing copied when settings are bad. + self.assertFalse(os.path.exists(env["target"])) + self.assertTrue(any("not valid JSON" in m for _, m in calls)) + + def test_clean_install_copies_binary_and_registers_hook(self): + with _fake_env() as env, _record_say() as calls: + rc = tito.cmd_install([]) + + self.assertEqual(rc, 0) + self.assertTrue(os.path.isfile(env["target"])) + self.assertTrue(os.access(env["target"], os.X_OK)) # chmod 0o755 + self.assertTrue(os.path.isfile(env["settings"])) + self.assertEqual(_tito_hook_count(env["settings"]), 1) + self.assertTrue(any("installed" in m for _, m in calls)) + + def test_rerun_from_installed_path_is_idempotent(self): + with _fake_env() as env, _record_say() as calls: + tito.cmd_install([]) # first install + self.assertEqual(_tito_hook_count(env["settings"]), 1) + + # Re-run as if invoked from the installed binary itself. + with mock.patch.object(tito, "__file__", env["target"]): + rc = tito.cmd_install([]) + + self.assertEqual(rc, 0) + self.assertTrue(any("up to date" in m for _, m in calls)) + # No duplicate hook on re-run. + self.assertEqual(_tito_hook_count(env["settings"]), 1) + + def test_warns_when_tito_not_on_path(self): + with _fake_env(which_returns=None) as env, _record_say() as calls: + rc = tito.cmd_install([]) + self.assertEqual(rc, 0) + self.assertTrue(any("PATH" in m for _, m in calls)) + + def test_no_path_warning_when_tito_on_path(self): + with _fake_env(which_returns="/already/here/tito") as env, _record_say() as calls: + rc = tito.cmd_install([]) + self.assertEqual(rc, 0) + self.assertFalse(any("PATH" in m for _, m in calls)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..09ddc8e --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,46 @@ +"""Tests for the top-level dispatch in tito.main().""" + +import io +import os +import sys +import unittest +from unittest import mock + +# Make the repo-root tito module importable no matter the cwd. +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import tito # noqa: E402 + + +class MainDispatchTests(unittest.TestCase): + def test_no_args_prints_usage_and_returns_zero(self): + with mock.patch("sys.stdout", new=io.StringIO()) as out: + rc = tito.main([]) + self.assertEqual(rc, 0) + self.assertIn("usage:", out.getvalue()) + + def test_unknown_command_prints_usage_and_returns_zero(self): + with mock.patch("sys.stdout", new=io.StringIO()) as out: + rc = tito.main(["frobnicate", "x"]) + self.assertEqual(rc, 0) + self.assertIn("usage:", out.getvalue()) + + def test_install_dispatches_to_cmd_install(self): + with mock.patch.object(tito, "cmd_install", return_value=0) as m: + rc = tito.main(["install"]) + m.assert_called_once_with([]) + self.assertEqual(rc, 0) + + def test_install_passes_through_extra_args(self): + with mock.patch.object(tito, "cmd_install", return_value=0) as m: + tito.main(["install", "--force", "yes"]) + m.assert_called_once_with(["--force", "yes"]) + + def test_return_code_propagates_from_cmd_install(self): + with mock.patch.object(tito, "cmd_install", return_value=1): + rc = tito.main(["install"]) + self.assertEqual(rc, 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..d3e47c3 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,42 @@ +"""Tests for the _paint / _say output helpers.""" + +import io +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import tito # noqa: E402 + + +class _Stream(io.StringIO): + """A StringIO whose isatty() is controllable.""" + + def __init__(self, is_tty): + super().__init__() + self._is_tty = is_tty + + def isatty(self): + return self._is_tty + + +class PaintTests(unittest.TestCase): + def test_wraps_with_color_when_stream_is_tty(self): + s = _Stream(is_tty=True) + self.assertEqual(tito._paint(tito._RED, "hi", s), f"{tito._RED}hi{tito._RESET}") + + def test_plain_when_stream_is_not_tty(self): + s = _Stream(is_tty=False) + self.assertEqual(tito._paint(tito._RED, "hi", s), "hi") + + +class SayTests(unittest.TestCase): + def test_writes_emoji_space_message_to_stream(self): + s = _Stream(is_tty=False) + tito._say("✅", tito._GREEN, "done", stream=s) + self.assertEqual(s.getvalue(), "✅ done\n") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..b83e7e9 --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,99 @@ +"""Tests for load_settings() and register_hook().""" + +import json +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import tito # noqa: E402 + + +def _tmpfile(content=None): + """Create a temp file (optionally pre-written) and return its path.""" + f = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) + if content is not None: + f.write(content) + f.flush() + f.close() + return f.name + + +class LoadSettingsTests(unittest.TestCase): + def test_missing_file_returns_empty_dict(self): + missing = os.path.join(tempfile.gettempdir(), "tito_does_not_exist.json") + self.assertEqual(tito.load_settings(missing), {}) + + def test_valid_json_returns_parsed_dict(self): + path = _tmpfile(json.dumps({"hooks": {}})) + try: + self.assertEqual(tito.load_settings(path), {"hooks": {}}) + finally: + os.unlink(path) + + def test_invalid_json_returns_none(self): + path = _tmpfile("{ not valid json") + try: + self.assertIsNone(tito.load_settings(path)) + finally: + os.unlink(path) + + def test_empty_file_returns_none(self): + path = _tmpfile("") + try: + self.assertIsNone(tito.load_settings(path)) + finally: + os.unlink(path) + + +class RegisterHookTests(unittest.TestCase): + HOOK_CMD = "/home/u/.local/bin/tito hook" + + def test_adds_hook_to_empty_settings(self): + s = {} + out = tito.register_hook(s, self.HOOK_CMD) + # Mutated in place. + self.assertIs(out, s) + entries = out["hooks"]["PreToolUse"] + self.assertEqual(len(entries), 1) + self.assertEqual(entries[0]["matcher"], "Bash") + self.assertEqual(entries[0]["hooks"][0]["type"], "command") + self.assertEqual(entries[0]["hooks"][0]["command"], self.HOOK_CMD) + + def test_idempotent_no_duplicate(self): + s = tito.register_hook({}, self.HOOK_CMD) + count_before = len(s["hooks"]["PreToolUse"]) + tito.register_hook(s, self.HOOK_CMD) + self.assertEqual(len(s["hooks"]["PreToolUse"]), count_before) + + def test_preserves_existing_pretooluse_hooks(self): + existing = { + "hooks": { + "PreToolUse": [ + {"matcher": "Edit", "hooks": [{"type": "command", "command": "other"}]} + ] + } + } + out = tito.register_hook(existing, self.HOOK_CMD) + entries = out["hooks"]["PreToolUse"] + self.assertEqual(len(entries), 2) + self.assertEqual(entries[0]["matcher"], "Edit") # untouched + self.assertEqual(entries[1]["matcher"], "Bash") + + def test_recognizes_existing_tito_hook_with_different_path(self): + # Any command containing "tito" counts as already registered. + s = { + "hooks": { + "PreToolUse": [ + {"matcher": "Bash", "hooks": [{"type": "command", "command": "/elsewhere/tito hook"}]} + ] + } + } + out = tito.register_hook(s, "/new/path/tito hook") + self.assertEqual(len(out["hooks"]["PreToolUse"]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tito.py b/tito.py new file mode 100644 index 0000000..99339ae --- /dev/null +++ b/tito.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 + +import json +import os +import shutil +import sys + +# --- Menu ----------------------------------------------------------------- + +USAGE = """ +usage: tito [args...] run a command with filtered output + tito install install binary and configure the Claude Code hook +""" + + +# --- Custom print helpers ------------------------------------------------- + +# ANSI colors for terminal output. _paint strips them when the target stream +# isn't a TTY, so piped or captured output stays clean. +_RED = "\033[31m" +_GREEN = "\033[32m" +_YELLOW = "\033[33m" +_CYAN = "\033[36m" +_RESET = "\033[0m" + + +def _paint(color, text, stream): + """Wrap text in ANSI color, but only when stream is a TTY.""" + if stream.isatty(): + return f"{color}{text}{_RESET}" + return text + + +def _say(emoji, color, msg, stream=sys.stdout): + """Print an emoji-prefixed, color-coded message to stream.""" + print(f"{emoji} {_paint(color, msg, stream)}", file=stream) + + +# --- install ----------------------------------------------------------- + +def load_settings(path): + """Read a settings JSON file. {} when missing, None when unreadable.""" + if not os.path.exists(path): + return {} + try: + with open(path) as f: + return json.load(f) + except (ValueError, OSError): + return None + + +def register_hook(settings, hook_cmd): + """Add the tito PreToolUse hook to a settings dict. Idempotent.""" + hooks = settings.setdefault("hooks", {}).setdefault("PreToolUse", []) + for entry in hooks: + if any("tito" in h.get("command", "") for h in entry.get("hooks", [])): + return settings + hooks.append({ + "matcher": "Bash", + "hooks": [{"type": "command", "command": hook_cmd}], + }) + return settings + + +def should_copy(src, target): + """False when target is already src (running from the installed binary). + + Avoids shutil.SameFileError on `~/.local/bin/tito install` re-runs. + """ + if not os.path.exists(target): + return True + return not os.path.samefile(src, target) + + +def cmd_install(args): + """Copy the binary to ~/.local/bin/tito and register the Claude Code hook. + + Order matters: validate ~/.claude/settings.json BEFORE touching the + binary — a corrupt settings file must fail loudly with nothing copied, + not half-install. + """ + settings_path = os.path.expanduser("~/.claude/settings.json") + settings = load_settings(settings_path) + if settings is None: + _say("❌", _RED, f"error: {settings_path} is not valid JSON — fix it and re-run", sys.stderr) + return 1 + + target = os.path.expanduser("~/.local/bin/tito") + src = os.path.abspath(__file__) + os.makedirs(os.path.dirname(target), exist_ok=True) + if should_copy(src, target): + shutil.copy(src, target) + os.chmod(target, 0o755) + _say("✅", _GREEN, f"installed {target}") + else: + _say("ℹ️ ", _CYAN, f"{target} is already up to date") + + if shutil.which("tito") is None: + _say( + "⚠️ ", + _YELLOW, + "warning: ~/.local/bin is not on your PATH — the hook will fail " + "on every handled command until it is.\n" + ' add this to your shell profile: export PATH="$HOME/.local/bin:$PATH"', + sys.stderr, + ) + + settings = register_hook(settings, target + " hook") + + os.makedirs(os.path.dirname(settings_path), exist_ok=True) + with open(settings_path, "w") as f: + json.dump(settings, f, indent=2) + _say("🔧", _CYAN, "Claude Code: hook registered") + + _say("💡", _CYAN, "restart your agent session to activate the hook") + return 0 + + +# --- main ----------------------------------------------------------------- + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if not argv: + print(USAGE, end="") + return 0 + meta = { + "install": cmd_install, + } + if argv[0] in meta: + return meta[argv[0]](argv[1:]) + print(USAGE, end="") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 85d4022eebc1460ac0183f787c11f655619a5d9c Mon Sep 17 00:00:00 2001 From: Kevin Py Date: Fri, 10 Jul 2026 11:53:21 +0200 Subject: [PATCH 02/10] feat: add extension system tito.py becomes pure infrastructure: a Filter/registry that loads extensions from ./extensions (bundled) then ~/.config/tito/filters (user overrides), the dispatch pipeline, the PreToolUse hook rewriter, gain stats, and the enable/disable/browse/new-filter/filters/rewrite meta commands. No command logic lives here anymore. main() now dispatches non-meta commands through dispatch() (passthrough wrapper) instead of printing usage, so test_main is updated to match. Co-Authored-By: Claude --- tests/test_main.py | 10 +- tito.py | 785 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 786 insertions(+), 9 deletions(-) diff --git a/tests/test_main.py b/tests/test_main.py index 09ddc8e..3b181bf 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -19,11 +19,13 @@ def test_no_args_prints_usage_and_returns_zero(self): self.assertEqual(rc, 0) self.assertIn("usage:", out.getvalue()) - def test_unknown_command_prints_usage_and_returns_zero(self): - with mock.patch("sys.stdout", new=io.StringIO()) as out: - rc = tito.main(["frobnicate", "x"]) + def test_non_meta_command_dispatches_to_run(self): + # Non-meta commands fall through to dispatch() (the passthrough + # wrapper) rather than printing usage. + with mock.patch.object(tito, "dispatch", return_value=0) as m: + rc = tito.main(["git", "status"]) + m.assert_called_once_with(["git", "status"]) self.assertEqual(rc, 0) - self.assertIn("usage:", out.getvalue()) def test_install_dispatches_to_cmd_install(self): with mock.patch.object(tito, "cmd_install", return_value=0) as m: diff --git a/tito.py b/tito.py index 99339ae..810301b 100644 --- a/tito.py +++ b/tito.py @@ -1,14 +1,40 @@ #!/usr/bin/env python3 +"""tito - token-saving command wrapper for Claude Code. +Recognized commands get compact, filtered output; anything else passes +through untouched. Exit codes are always preserved. Golden rule: tito +can never break a command. + +This file is pure infrastructure: a dispatch pipeline, an extension +system, a PreToolUse hook that rewrites handled commands into +`tito `, and the install/gain meta commands. Every command's filter +logic lives in an extension — bundled ones under ./extensions (next to +this script) and user ones under ~/.config/tito/filters — so a user copy +always overrides the bundled one. +""" + +import curses +import importlib.util import json import os import shutil +import subprocess import sys +import time +import urllib.request # --- Menu ----------------------------------------------------------------- -USAGE = """ -usage: tito [args...] run a command with filtered output +USAGE = """usage: tito [args...] run a command with filtered output + tito gain show token savings stats + tito gain --reset wipe recorded savings stats + tito filters list loaded filters + tito enable download and enable an official extension + tito browse browse the registry and install extensions via checkbox + tito disable disable an extension + tito new-filter generate a custom filter skeleton + tito hook PreToolUse-style hook (reads JSON on stdin); Claude Code + tito rewrite ... print the tito-prefixed rewrite of a command (or unchanged) tito install install binary and configure the Claude Code hook """ @@ -36,7 +62,749 @@ def _say(emoji, color, msg, stream=sys.stdout): print(f"{emoji} {_paint(color, msg, stream)}", file=stream) -# --- install ----------------------------------------------------------- +# --- config / paths ------------------------------------------------------- + +def config_dir(): + return os.path.expanduser(os.environ.get("TITO_HOME", "~/.config/tito")) + + +def filters_dir(): + return os.path.join(config_dir(), "filters") + + +def bundled_extensions_dir(): + """Extensions shipped alongside this script (repo checkouts). + + Loaded before the user filters dir so a user copy in + ~/.config/tito/filters overrides the bundled one. Absent for an + installed binary — there, extensions come from `tito enable`. + """ + return os.path.join(os.path.dirname(os.path.abspath(__file__)), "extensions") + + +def stats_file(): + return os.path.join(config_dir(), "stats.jsonl") + + +# --- run helper ----------------------------------------------------------- + +def run(argv): + """Run argv capturing output. Returns (stdout, stderr, exit_code).""" + proc = subprocess.run(argv, capture_output=True, text=True, errors="replace") + return proc.stdout, proc.stderr, proc.returncode + + +# --- Filter --------------------------------------------------------------- + +class Filter: + """A command filter. render(argv, stdout, stderr, code) -> str | None. + + Returning None keeps the raw output. transform(argv) -> argv may + rewrite the command before execution (e.g. add --porcelain). + `subcommands` (optional set) restricts the filter to specific first + non-flag args — lets `go/build.py` and `go/test.py` split one command + (`go`) across files. None means "any subcommand". + """ + + def __init__(self, commands, render, transform=None, source="core", subcommands=None): + self.commands = list(commands) + self.render = render + self.transform = transform + self.source = source + self.subcommands = set(subcommands) if subcommands else None + + +# --- registry ------------------------------------------------------------- + +def _argv_subcommand(argv): + """First non-flag argument after the command, or None.""" + for arg in argv[1:]: + if not arg.startswith("-"): + return arg + return None + + +def _iter_filter_files(directory): + """Recursively yield sorted *.py paths under directory. + + Skips __pycache__ and dot-dirs. .py.disabled files don't end in .py, + so they're naturally excluded (kept on disk for `disable`/re-enable). + """ + out = [] + for root, dirs, files in os.walk(directory): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + for fn in files: + if fn == "__init__.py": + continue + if fn.endswith(".py"): + out.append(os.path.join(root, fn)) + return sorted(out) + + +def _load_filter_module(path): + """Load a filter file as a module, returning the module object.""" + name = "tito_ext_" + os.path.basename(path)[:-3] + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def load_extensions(by_command, directory): + """Load /**/*.py filters, appending to by_command. + + by_command maps command name -> list[Filter] in load order; called + once for bundled extensions and once for the user filters dir, so a + user's copy overrides the bundled one. A file may declare + SUBCOMMANDS to restrict which subcommand it handles (e.g. + go/build.py handles only `go build`). A broken file is skipped with + a warning — it must never take tito down. + """ + if not os.path.isdir(directory): + return + for path in _iter_filter_files(directory): + rel = os.path.relpath(path, directory) + try: + mod = _load_filter_module(path) + flt = Filter( + list(mod.COMMANDS), + mod.filter, + getattr(mod, "transform", None), + source=rel, + subcommands=getattr(mod, "SUBCOMMANDS", None), + ) + except Exception as exc: + print(f"tito: skipping broken filter {rel}: {exc}", file=sys.stderr) + continue + for cmd in flt.commands: + by_command.setdefault(cmd, []).append(flt) + + +def _resolve(filters): + """Pick one Filter for a command from its loaded filters. + + Load order is bundled first, then user. No subcommand filters -> + last writer wins (user overrides bundled). Any subcommand filter -> + build a routing Filter. + """ + if not any(f.subcommands for f in filters): + return filters[-1] + return _router(filters) + + +def _router(filters): + """Combine filters for one command into one that routes by subcommand. + + A subcommand-specific filter wins for its subcommand; the last + no-subcommand (default) filter handles anything unmatched; with no + default, unmatched subcommands pass through (render returns None). + """ + default = None + by_sub = {} + for f in filters: # bundled, then user in load order + if f.subcommands: + for s in f.subcommands: + by_sub[s] = f + else: + default = f + commands = sorted({c for f in filters for c in f.commands}) + + def transform(argv): + f = by_sub.get(_argv_subcommand(argv), default) + return f.transform(list(argv)) if (f and f.transform) else list(argv) + + def render(argv, stdout, stderr, code): + f = by_sub.get(_argv_subcommand(argv), default) + return f.render(argv, stdout, stderr, code) if f else None + + return Filter(commands, render, transform, source=commands[0] if commands else "?") + + +def load_registry(): + """Map command name -> Filter. + + Bundled extensions (shipped in ./extensions next to this script) are + loaded first, then user extensions from ~/.config/tito/filters/, so a + user copy overrides the bundled one. Subcommand-split extensions + (e.g. go/build.py + go/test.py) are merged into one routing Filter + per command. + """ + by_command = {} + load_extensions(by_command, bundled_extensions_dir()) + load_extensions(by_command, filters_dir()) + return {cmd: _resolve(filters) for cmd, filters in by_command.items()} + + +# --- stats ---------------------------------------------------------------- + +def record_stats(cmd, raw, filtered): + """Append one JSONL savings record. Never let bookkeeping fail a command.""" + try: + os.makedirs(config_dir(), exist_ok=True) + with open(stats_file(), "a") as f: + f.write(json.dumps( + {"ts": int(time.time()), "cmd": cmd, "raw": raw, "filtered": filtered} + ) + "\n") + except OSError: + pass + + +def _gain_color_enabled(): + """True when it's safe to emit ANSI color: a real terminal, and the + user hasn't opted out via NO_COLOR (https://no-color.org).""" + return sys.stdout.isatty() and "NO_COLOR" not in os.environ + + +def cmd_gain(args): + """Aggregate and print token savings, or reset stats with --reset.""" + if args and args[0] == "--reset": + return cmd_gain_reset(args[1:]) + path = stats_file() + if not os.path.exists(path): + print("no stats yet") + return 0 + totals = {} + with open(path) as f: + for line in f: + try: + rec = json.loads(line) + key, raw, flt = rec["cmd"], int(rec["raw"]), int(rec["filtered"]) + except (ValueError, KeyError, TypeError): + continue + t = totals.setdefault(key, [0, 0, 0]) + t[0] += 1 + t[1] += raw + t[2] += flt + if not totals: + print("no stats yet") + return 0 + grand_raw = sum(t[1] for t in totals.values()) + grand_flt = sum(t[2] for t in totals.values()) + grand_runs = sum(t[0] for t in totals.values()) + + # Tokens are estimated as bytes // 4 (the rule of thumb used across + # tito). Time saved is linear in tokens saved (~50 tok/s below), so + # sorting by tokens saved is the same order as sorting by time saved. + rows = [] + for cmd, (n, raw, flt) in totals.items(): + raw_tok, flt_tok = raw // 4, flt // 4 + saved_tok = raw_tok - flt_tok + pct = 100 * saved_tok / raw_tok if raw_tok else 0 + rows.append((cmd, n, saved_tok, pct)) + rows.sort(key=lambda r: r[2], reverse=True) + avg_saved = sum(r[2] for r in rows) / len(rows) + + color = _gain_color_enabled() + green, red, reset = "\033[32m", "\033[31m", "\033[0m" + + header = f"{'command':<10} {'count':>5} {'saved':>8}" + print(header) + print("-" * len(header)) + for cmd, n, saved_tok, pct in rows: + line = f"{cmd:<10} {n:>5} {saved_tok:>8} ({pct:.0f}%)" + if color: + c = green if saved_tok >= avg_saved else red + line = f"{c}{line}{reset}" + print(line) + + saved_tokens = grand_raw // 4 - grand_flt // 4 + pct = 100 * saved_tokens / (grand_raw // 4) if grand_raw else 0 + print() + print(f"count: {grand_runs}") + print(f"input tokens (raw, unfiltered): ~{grand_raw // 4}") + print(f"output tokens (filtered, sent): ~{grand_flt // 4}") + print(f"total saved: ~{saved_tokens} tokens ({pct:.0f}%)") + + # Rough reading-speed estimate (~50 tok/s), not a measured latency. + # Past a day the estimate is too coarse to be meaningful, so it's + # omitted rather than shown as a misleadingly precise number. + seconds = saved_tokens / 50 + if 0 < seconds < 60: + print(f"time saved: ~{seconds:.0f}s (rough estimate, ~50 tok/s)") + elif 60 <= seconds < 3600: + print(f"time saved: ~{seconds / 60:.1f}min (rough estimate, ~50 tok/s)") + elif 3600 <= seconds < 86400: + print(f"time saved: ~{seconds / 3600:.1f}h (rough estimate, ~50 tok/s)") + return 0 + + +def cmd_gain_reset(args): + """Delete the stats file, wiping all recorded savings data.""" + path = stats_file() + if not os.path.exists(path): + print("no stats yet") + return 0 + os.remove(path) + print(f"reset: removed {path}") + return 0 + + +# --- extensions registry -------------------------------------------------- + +# Official extensions registry (github.com/Tiny-Tokens/tito). Override +# with the TITO_REGISTRY env var if you publish extensions elsewhere. +REGISTRY_URL = "https://raw.githubusercontent.com/Tiny-Tokens/tito/main/extensions" + + +def registry_url(): + """TITO_REGISTRY env override if set, else the official REGISTRY_URL.""" + return os.environ.get("TITO_REGISTRY", REGISTRY_URL) + + +FILTER_SKELETON = '''"""Custom tito filter.""" + +COMMANDS = ["mycmd"] # commands this filter intercepts +# SUBCOMMANDS = ["build"] # optional: only handle this subcommand + # (lets you split one command across files, + # e.g. extensions/go/build.py + go/test.py) + + +def filter(argv, stdout, stderr, code): + """Return the compact output, or None to keep the raw output.""" + if code != 0: + return None # let Claude see real errors + lines = [l for l in stdout.splitlines() if l.strip()] + return "\\n".join(lines) +''' + + +def _filter_rel_path(name): + """Map a user-facing filter name to its relative path under filters_dir(). + + Every extension lives in its own folder, one file per name. A bare + name (no "/") is a single-file extension, e.g. "curl" -> + "curl/default.py". A name that already contains "/" (e.g. + "go/build") points at an explicit file inside a multi-file + ecosystem folder and is used as-is. + """ + if "/" in name: + return name + ".py" + return name + "/default.py" + + +def _filter_path(name): + """Resolve a (possibly nested) filter name to a .py path under + filters_dir(). Returns None (and warns) on path traversal — `name` + may be `go/build` but never `../x` or absolute. + """ + base = filters_dir() + path = os.path.normpath(os.path.join(base, _filter_rel_path(name))) + if path != base and not path.startswith(base + os.sep): + print(f"error: invalid filter name {name!r}", file=sys.stderr) + return None + return path + + +def _do_enable(name): + """Fetch or restore extension `name` into filters_dir(). + + Returns (ok, message). message is "" when _filter_path() already + printed its own error (invalid/traversal name) — callers should + skip printing an empty message. + """ + dest = _filter_path(name) + if dest is None: + return False, "" + disabled = dest + ".disabled" + if os.path.exists(disabled): + os.makedirs(os.path.dirname(dest) or ".", exist_ok=True) + os.replace(disabled, dest) + return True, f"enabled {name} (local)" + url = f"{registry_url()}/{_filter_rel_path(name)}" + try: + with urllib.request.urlopen(url, timeout=10) as resp: + code = resp.read().decode("utf-8") + except Exception as exc: + return False, f"error: could not fetch {url}: {exc}" + os.makedirs(os.path.dirname(dest) or ".", exist_ok=True) + with open(dest, "w") as f: + f.write(code) + return True, f"enabled {name}" + + +def cmd_enable(args): + if not args: + print("usage: tito enable ", file=sys.stderr) + return 1 + ok, msg = _do_enable(args[0]) + if msg: + print(msg, sys.stdout if ok else sys.stderr) + return 0 if ok else 1 + + +def cmd_disable(args): + if not args: + print("usage: tito disable ", file=sys.stderr) + return 1 + name = args[0] + src = _filter_path(name) + if src is None: + return 1 + if not os.path.exists(src): + print(f"error: {name} is not installed", file=sys.stderr) + return 1 + os.replace(src, src + ".disabled") + print(f"disabled {name}") + return 0 + + +def _fetch_manifest(): + """Fetch and parse extensions/manifest.json from the registry. + + Returns a list of {"name", "description", "commands"} dicts, or + None on network/parse failure (prints its own error to stderr). + """ + url = f"{registry_url()}/manifest.json" + try: + with urllib.request.urlopen(url, timeout=10) as resp: + raw = resp.read().decode("utf-8") + except Exception as exc: + print(f"error: could not fetch {url}: {exc}", file=sys.stderr) + return None + try: + data = json.loads(raw) + return list(data["extensions"]) + except Exception as exc: + print(f"error: malformed manifest at {url}: {exc}", file=sys.stderr) + return None + + +def _local_status(name): + """Return "enabled", "disabled", or "" for extension `name`.""" + path = _filter_path(name) + if path is None: + return "" + if os.path.exists(path): + return "enabled" + if os.path.exists(path + ".disabled"): + return "disabled" + return "" + + +def _browse_step(state, key, n): + """Apply one keypress to browse selection state. + + state: {"cursor": int, "selected": set[int]}. Returns (new_state, + action) where action is None (continue), "confirm", or "cancel". + """ + cursor, selected = state["cursor"], set(state["selected"]) + action = None + if key in (curses.KEY_UP, ord("k")): + cursor = max(0, cursor - 1) + elif key in (curses.KEY_DOWN, ord("j")): + cursor = min(n - 1, cursor + 1) + elif key == ord(" "): + selected ^= {cursor} + elif key == ord("a"): + selected = set(range(n)) + elif key == ord("n"): + selected = set() + elif key in (curses.KEY_ENTER, 10, 13): + action = "confirm" + elif key in (27, ord("q")): + action = "cancel" + return {"cursor": cursor, "selected": selected}, action + + +def _browse_ui(stdscr, entries): + """Render the checkbox picker and drive it until confirm/cancel. + + Returns the selected extension names (possibly empty) on confirm, + or None on cancel. Scrolls the visible window to keep the cursor + in view when there are more entries than terminal rows. + """ + try: + curses.curs_set(0) + except curses.error: + pass + stdscr.keypad(True) + n = len(entries) + state = {"cursor": 0, "selected": set()} + top = 0 + while True: + height, width = stdscr.getmaxyx() + visible_rows = max(1, height - 1) # last row reserved for the footer + if state["cursor"] < top: + top = state["cursor"] + elif state["cursor"] >= top + visible_rows: + top = state["cursor"] - visible_rows + 1 + top = max(0, min(top, max(0, n - visible_rows))) + stdscr.clear() + for row, i in enumerate(range(top, min(n, top + visible_rows))): + e = entries[i] + mark = "x" if i in state["selected"] else " " + pointer = ">" if i == state["cursor"] else " " + status = f" [{e['status']}]" if e["status"] else "" + line = f"{pointer} [{mark}] {e['name']:<16} {e['description']}{status}" + attr = curses.A_REVERSE if i == state["cursor"] else curses.A_NORMAL + stdscr.addstr(row, 0, line[:max(0, width - 1)], attr) + footer = "space:toggle a:all n:none enter:install q:cancel" + stdscr.addstr(min(visible_rows, height - 1), 0, footer[:max(0, width - 1)]) + stdscr.refresh() + key = stdscr.getch() + state, action = _browse_step(state, key, n) + if action == "confirm": + return [entries[i]["name"] for i in sorted(state["selected"])] + if action == "cancel": + return None + + +def _run_browse_ui(entries): + return curses.wrapper(_browse_ui, entries) + + +def cmd_browse(args): + if not sys.stdout.isatty(): + print("error: tito browse requires an interactive terminal", file=sys.stderr) + return 1 + entries = _fetch_manifest() + if entries is None: + return 1 + for e in entries: + e["status"] = _local_status(e["name"]) + selected = _run_browse_ui(entries) + if selected is None: + print("cancelled") + return 0 + if not selected: + print("nothing selected") + return 0 + ok_count, failed = 0, [] + for name in selected: + ok, msg = _do_enable(name) + if msg: + print(msg) + if ok: + ok_count += 1 + else: + failed.append(name) + summary = f"installed {ok_count}/{len(selected)}" + if failed: + summary += f", failed: {', '.join(failed)}" + print(summary) + return 1 if failed else 0 + + +def cmd_filters(args): + """List bundled and user extensions (user copies override bundled).""" + found_any = False + for label, directory in (("bundled", bundled_extensions_dir()), + ("user", filters_dir())): + if not os.path.isdir(directory): + continue + entries = [] + for root, dirs, files in os.walk(directory): + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + for fn in files: + if fn.endswith(".py") or fn.endswith(".py.disabled"): + entries.append(os.path.relpath(os.path.join(root, fn), directory)) + for rel in sorted(entries): + found_any = True + disabled = rel.endswith(".py.disabled") + name = rel[: -len(".py.disabled")] if disabled else rel[: -len(".py")] + if name.endswith(os.sep + "default"): + name = name[: -len(os.sep + "default")] + status = " (disabled)" if disabled else "" + print(f"{label}: {name}{status}") + if not found_any: + print("no filters installed") + return 0 + + +def cmd_new_filter(args): + if not args: + print("usage: tito new-filter ", file=sys.stderr) + return 1 + name = args[0] + path = _filter_path(name) + if path is None: + return 1 + if os.path.exists(path): + print(f"error: {path} already exists", file=sys.stderr) + return 1 + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + with open(path, "w") as f: + f.write(FILTER_SKELETON) + print(f"created {path} — edit COMMANDS and filter()") + return 0 + + +# --- hook rewriter -------------------------------------------------------- + +def _split_segments(cmd): + """Split cmd on && / || / ; outside quotes. + + Returns [(segment_text, separator), ...] or None when the command + contains constructs we refuse to touch (pipes, substitutions, + redirections, heredocs, backgrounding, unclosed quotes). + """ + segs, buf = [], [] + in_single = in_double = False + i, n = 0, len(cmd) + while i < n: + ch = cmd[i] + nxt = cmd[i + 1] if i + 1 < n else "" + if in_single: + buf.append(ch) + if ch == "'": + in_single = False + i += 1 + continue + if ch == "\\": + buf.append(cmd[i : i + 2]) + i += 2 + continue + if in_double: + if ch == '"': + in_double = False + elif ch == "`" or (ch == "$" and nxt == "("): + return None # substitution still expands inside double quotes + buf.append(ch) + i += 1 + continue + if ch == "'": + in_single = True + elif ch == '"': + in_double = True + elif ch == "`" or (ch == "$" and nxt == "("): + return None # command substitution + elif ch in "<>": + return None # redirection or heredoc + elif ch == "&" and nxt == "&": + segs.append(("".join(buf), "&&")) + buf = [] + i += 2 + continue + elif ch == "&": + return None # backgrounding + elif ch == "|" and nxt == "|": + segs.append(("".join(buf), "||")) + buf = [] + i += 2 + continue + elif ch == "|": + return None # pipe: downstream consumes raw output + elif ch == ";": + segs.append(("".join(buf), ";")) + buf = [] + i += 1 + continue + buf.append(ch) + i += 1 + if in_single or in_double: + return None + segs.append(("".join(buf), "")) + return segs + + +def _rewrite_segment(text, handled): + stripped = text.lstrip() + if not stripped: + return text + first = stripped.split()[0] + if first in ("tito", "rtk") or "=" in first or first not in handled: + return text + pad = text[: len(text) - len(stripped)] + return pad + "tito " + stripped + + +def rewrite_command(cmd, handled): + """Prefix handled commands with 'tito '. Doubt favors no rewrite.""" + segments = _split_segments(cmd) + if segments is None: + return cmd + return "".join(_rewrite_segment(text, handled) + sep for text, sep in segments) + + +def _hook_claude(payload): + """Claude Code PreToolUse hook body (unchanged behavior).""" + cmd = payload.get("tool_input", {}).get("command", "") + if not cmd: + return + handled = set(load_registry()) + new = rewrite_command(cmd, handled) + if new != cmd: + print(json.dumps({ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": {**payload.get("tool_input", {}), "command": new}, + } + })) + + +def cmd_hook(args): + """Claude Code PreToolUse hook (reads JSON on stdin). + + Absolute rule: never crash, never block. Any problem -> no output, + exit 0, the agent runs the original command untouched. + """ + try: + payload = json.load(sys.stdin) + _hook_claude(payload) + except Exception: + pass + return 0 + + +def cmd_rewrite(args): + """Print the tito-prefixed rewrite of a command, or the command + unchanged if nothing applies. Always exits 0 — same "never break a + command" contract as every other tito entry point. Handy for + debugging the rewrite logic without running a command. + """ + cmd = None + try: + cmd = " ".join(args) if args else sys.stdin.read().rstrip("\n") + handled = set(load_registry()) + print(rewrite_command(cmd, handled)) + except Exception: + # doubt favors no rewrite: on any error, echo the input unchanged + if cmd is not None: + print(cmd) + return 0 + + +# --- dispatch ------------------------------------------------------------- + +def dispatch(argv, registry=None): + """Run argv through its filter, falling back to raw on any problem.""" + registry = load_registry() if registry is None else registry + flt = registry.get(argv[0]) + if flt is None: + try: + return subprocess.run(argv).returncode + except FileNotFoundError: + print(f"tito: {argv[0]}: command not found", file=sys.stderr) + return 127 + run_argv = list(argv) + if flt.transform is not None: + try: + run_argv = flt.transform(list(argv)) + except Exception: + run_argv = list(argv) + try: + stdout, stderr, code = run(run_argv) + except FileNotFoundError: + print(f"tito: {argv[0]}: command not found", file=sys.stderr) + return 127 + try: + out = flt.render(argv, stdout, stderr, code) + except Exception: + out = None + if out is None: + sys.stdout.write(stdout) + sys.stderr.write(stderr) + return code + record_stats(argv[0], len(stdout) + len(stderr), len(out)) + if out and not out.endswith("\n"): + out += "\n" + sys.stdout.write(out) + if code != 0: + sys.stderr.write(stderr) + return code + + +# --- install -------------------------------------------------------------- def load_settings(path): """Read a settings JSON file. {} when missing, None when unreadable.""" @@ -124,12 +892,19 @@ def main(argv=None): print(USAGE, end="") return 0 meta = { + "gain": cmd_gain, + "filters": cmd_filters, + "enable": cmd_enable, + "browse": cmd_browse, + "disable": cmd_disable, + "new-filter": cmd_new_filter, + "hook": cmd_hook, + "rewrite": cmd_rewrite, "install": cmd_install, } if argv[0] in meta: return meta[argv[0]](argv[1:]) - print(USAGE, end="") - return 0 + return dispatch(argv) if __name__ == "__main__": From 205585fe99aeb5bc5329dfa882bf5ed1e5062bcd Mon Sep 17 00:00:00 2001 From: Kevin Py Date: Fri, 10 Jul 2026 11:57:56 +0200 Subject: [PATCH 03/10] feat: add git extension The first bundled extension under extensions/git/default.py: porcelain status, hunk-only diffs/show, short-format log (via transform), and one-word confirmations for add/commit/push/pull. Errors pass through raw. Loaded automatically by the registry as the example extension. Adds tests/test_git_extension.py covering transform, each render path, and registry loading (including _resolve user-overrides-bundled). Co-Authored-By: Claude --- extensions/git/default.py | 97 ++++++++++++++++++++++++++ tests/extensions/git/test_git.py | 112 +++++++++++++++++++++++++++++++ 2 files changed, 209 insertions(+) create mode 100644 extensions/git/default.py create mode 100644 tests/extensions/git/test_git.py diff --git a/extensions/git/default.py b/extensions/git/default.py new file mode 100644 index 0000000..fea71d7 --- /dev/null +++ b/extensions/git/default.py @@ -0,0 +1,97 @@ +"""git filter for tito — the example bundled extension. + +Compact git output: porcelain status, hunk-only diffs, short log, and +one-word confirmations for write ops. Errors (code != 0) fall through +raw so real failures are never hidden. +""" + +COMMANDS = ["git"] + + +def _subcommand(argv): + """First non-flag argument after 'git', or None.""" + for arg in argv[1:]: + if not arg.startswith("-"): + return arg + return None + + +def transform(argv): + """Prefer machine-readable output over scraping human-formatted text.""" + sub = _subcommand(argv) + if sub == "status" and not any(a.startswith("--porcelain") for a in argv): + return argv + ["--porcelain=v1", "-b"] + if sub == "log" and not any( + a.startswith("--format") or a.startswith("--pretty") for a in argv + ): + return argv + ["--format=%h %ad %s", "--date=short"] + return argv + + +def _render_status(stdout): + """Porcelain v1 -b output -> branch line + one line per file.""" + lines = [l for l in stdout.splitlines() if l] + parts = [] + for line in lines: + parts.append(line[3:] if line.startswith("## ") else line) + if len(parts) == 1: + return parts[0] + " clean" + return "\n".join(parts) + + +def _render_diff(stdout): + """Unified diff -> file headers, hunk markers, changed lines only. + + Returns None (raw fallback) unless at least one hunk/content line was + kept, so --stat/--name-only/header-only diffs pass through raw. + """ + if not stdout.strip(): + return "no diff" + kept = [] + has_content = False + for line in stdout.splitlines(): + if line.startswith("diff --git"): + kept.append("=== " + line.split(" b/")[-1]) + elif line.startswith("@@"): + end = line.find("@@", 2) + kept.append(line[: end + 2] if end != -1 else line) + has_content = True + elif line[:1] in ("+", "-") and not line.startswith(("+++", "---")): + kept.append(line) + has_content = True + if not has_content: + return None + return "\n".join(kept) + + +def _render_confirm(sub, stdout, stderr): + """Ultra-short success confirmations for write operations.""" + if sub == "add": + return "ok" + if sub == "commit": + ref, summary = "", "" + for line in stdout.splitlines(): + if line.startswith("[") and "]" in line: + ref = line[1 : line.index("]")] + elif "changed" in line: + summary = line.strip() + return f"ok [{ref}] {summary}".rstrip() + if sub == "push": + return "ok pushed" + lines = [l.strip() for l in stdout.splitlines() if l.strip()] + return "ok " + (lines[-1] if lines else "done") + + +def filter(argv, stdout, stderr, code): + if code != 0: + return None # errors go through raw so Claude sees them + sub = _subcommand(argv) + if sub == "status": + return _render_status(stdout) + if sub == "log": + return stdout + if sub in ("diff", "show"): + return _render_diff(stdout) + if sub in ("add", "commit", "push", "pull"): + return _render_confirm(sub, stdout, stderr) + return None diff --git a/tests/extensions/git/test_git.py b/tests/extensions/git/test_git.py new file mode 100644 index 0000000..6dd46ab --- /dev/null +++ b/tests/extensions/git/test_git.py @@ -0,0 +1,112 @@ +"""Tests for the bundled git extension (extensions/git/default.py). + +Loads the extension file directly and exercises its filter()/transform(), +then confirms the registry picks it up as a bundled extension. +""" + +import importlib.util +import os +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import tito # noqa: E402 + + +def _load_git_extension(): + path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "extensions", "git", "default.py", + ) + spec = importlib.util.spec_from_file_location("tito_git_ext_test", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +class GitTransformTests(unittest.TestCase): + def setUp(self): + self.git = _load_git_extension() + + def test_status_transform_adds_porcelain(self): + self.assertEqual( + self.git.transform(["git", "status"]), + ["git", "status", "--porcelain=v1", "-b"], + ) + + def test_log_transform_adds_format(self): + self.assertEqual( + self.git.transform(["git", "log"]), + ["git", "log", "--format=%h %ad %s", "--date=short"], + ) + + def test_transform_left_alone_when_porcelain_present(self): + out = self.git.transform(["git", "status", "--porcelain=v2"]) + self.assertEqual(out, ["git", "status", "--porcelain=v2"]) + + def test_transform_left_alone_for_unknown_subcommand(self): + self.assertEqual(self.git.transform(["git", "stash"]), ["git", "stash"]) + + +class GitFilterTests(unittest.TestCase): + def setUp(self): + self.git = _load_git_extension() + + def test_error_code_passes_through_raw(self): + self.assertIsNone(self.git.filter(["git", "status"], "err", "err", 1)) + + def test_clean_status(self): + self.assertEqual(self.git.filter(["git", "status"], "## main\n", "", 0), "main clean") + + def test_status_lists_changed_files(self): + out = self.git.filter( + ["git", "status"], + "## main\n## origin/main\nM a.py\n?? b.txt\n", + "", 0, + ) + self.assertEqual(out, "main\norigin/main\nM a.py\n?? b.txt") + + def test_diff_keeps_hunks_and_changes_only(self): + diff = "diff --git a/a.py b/a.py\nindex 1..2\n@@ -1,2 +1,2 @@\n-old\n+new\n ctx\n" + self.assertEqual( + self.git.filter(["git", "diff"], diff, "", 0), + "=== a.py\n@@ -1,2 +1,2 @@\n-old\n+new", + ) + + def test_diff_empty_is_no_diff(self): + self.assertEqual(self.git.filter(["git", "diff"], "", "", 0), "no diff") + + def test_add_confirms_ok(self): + self.assertEqual(self.git.filter(["git", "add", "."], "", "", 0), "ok") + + def test_push_confirms(self): + self.assertEqual(self.git.filter(["git", "push"], "", "", 0), "ok pushed") + + def test_commit_confirms_with_ref_and_summary(self): + stdout = "[main abc1234] msg\n 1 file changed, 1 insertion(+)\n" + self.assertEqual( + self.git.filter(["git", "commit"], stdout, "", 0), + "ok [main abc1234] 1 file changed, 1 insertion(+)", + ) + + def test_log_passes_through_verbatim(self): + self.assertEqual(self.git.filter(["git", "log"], "abc1234 x\n", "", 0), "abc1234 x\n") + + +class GitRegistryTests(unittest.TestCase): + def test_bundled_git_is_loaded_by_registry(self): + reg = tito.load_registry() + self.assertIn("git", reg) + + def test_resolve_user_overrides_bundled(self): + # _resolve picks the last-loaded filter, so a user extension + # (loaded after bundled) wins for the same command. + bundled = tito.Filter(["git"], lambda *a: "bundled", source="b") + user = tito.Filter(["git"], lambda *a: "user", source="u") + resolved = tito._resolve([bundled, user]) + self.assertEqual(resolved.render(["git"], "", "", 0), "user") + + +if __name__ == "__main__": + unittest.main() From 444dc51ca7cf97dac16789108b4948e592cf3cde Mon Sep 17 00:00:00 2001 From: Kevin Py Date: Fri, 10 Jul 2026 13:34:41 +0200 Subject: [PATCH 04/10] feat: add CLAUDE.md --- CLAUDE.md | 70 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0a84bc7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,70 @@ +# tito — internals for future development + +tito (**Ti**ny **To**kens) 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. + +tito is a fresh, simplified reimplementation migrated from +[`cloclo`](../cloclo) — renamed `cloclo` → `tito` throughout. It is being +built up commit by commit: today only `install` exists; filters, the +dispatch pipeline, the hook rewriter, stats, and the rest arrive +progressively. + +This file is the internals map — read it before touching `tito.py`. + +## Current scope + +Only one subcommand is implemented: + +- **`tito install`** — copy the binary to `~/.local/bin/tito` and register + the Claude Code `PreToolUse` hook. + +Anything else prints the usage and exits 0. There is no dispatch, no +registry, no filters, no stats yet. + +## Output helpers (`_say` / `_paint`) + +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. + +## Install (`cmd_install`) + +Order matters and is deliberate: + +1. Validate `~/.claude/settings.json` is parseable **before** touching any + file — a corrupt settings file must fail loudly with nothing copied, not + half-install. +2. Copy the binary to `~/.local/bin/tito` (skipped via `should_copy()` if + already running from that exact path, avoiding `shutil.SameFileError` on + `tito install` re-runs). +3. Warn (don't fail) if `~/.local/bin` isn't on `PATH`. +4. Register the `PreToolUse` hook idempotently (`register_hook()` scans for + an existing `tito` hook command before appending). + +The registered hook command is `~/.local/bin/tito hook` — the `hook` +subcommand does not exist yet; it lands in a later commit. Until then the +hook is inert. + +`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. + +## Locations + +- Binary: `~/.local/bin/tito` +- Claude Code settings: `~/.claude/settings.json` +- (Reserved for later, not yet in code) config dir `~/.config/tito`, + overridable via `TITO_HOME`. + +## Design constraints + +- **Stdlib only, single file.** Stated design constraint, not an oversight + — don't introduce a dependency or split `tito.py` into 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. From 02a14bc82100832b5cd8871ffa91aa3055427619 Mon Sep 17 00:00:00 2001 From: Kevin Py Date: Fri, 10 Jul 2026 13:35:21 +0200 Subject: [PATCH 05/10] docs: add documentation --- CLAUDE.md | 171 +++++++++++++++++++++++++++++++++++----- CONTRIBUTING.md | 53 +++++++++++++ SECURITY.md | 11 +-- docs/Architecture.md | 69 ++++++++++++++++ docs/Extensions.md | 48 +++++++++++ docs/Home.md | 19 +++++ docs/Writing-Filters.md | 68 ++++++++++++++++ tito.py | 4 +- 8 files changed, 413 insertions(+), 30 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/Architecture.md create mode 100644 docs/Extensions.md create mode 100644 docs/Home.md create mode 100644 docs/Writing-Filters.md diff --git a/CLAUDE.md b/CLAUDE.md index 0a84bc7..57a95f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,23 +5,19 @@ 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. -tito is a fresh, simplified reimplementation migrated from -[`cloclo`](../cloclo) — renamed `cloclo` → `tito` throughout. It is being -built up commit by commit: today only `install` exists; filters, the -dispatch pipeline, the hook rewriter, stats, and the rest arrive -progressively. - -This file is the internals map — read it before touching `tito.py`. +User-facing docs (install, writing filters, contributing) live in +[docs/](docs/Home.md) as a GitHub wiki. This file is the internals map — +read it before touching `tito.py`. ## Current scope -Only one subcommand is implemented: - -- **`tito install`** — copy the binary to `~/.local/bin/tito` and register - the Claude Code `PreToolUse` hook. - -Anything else prints the usage and exits 0. There is no dispatch, no -registry, no filters, no stats yet. +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/`. ## Output helpers (`_say` / `_paint`) @@ -30,7 +26,128 @@ 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. +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 / paths + +- `config_dir()` — `~/.config/tito`, overridable via `TITO_HOME`. +- `filters_dir()` — `/filters`, where user/downloaded + extensions live. +- `bundled_extensions_dir()` — `./extensions` next to `tito.py`. Only + present for repo checkouts; an installed binary has no bundled dir, so + extensions there only come from `tito enable`/`tito browse`. +- `stats_file()` — `/stats.jsonl`. + +## Filter / registry (`Filter`, `load_registry`, `_resolve`, `_router`) + +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`). + +## Dispatch (`dispatch`) + +1. Look up the `Filter` for `argv[0]` in the registry; no filter → run + the command as-is via `subprocess.run(argv)` (no capture), preserving + its exit code; `FileNotFoundError` → print `tito: : command not + found`, exit 127. +2. Filter found → run its `transform(argv)` if present (any exception + falls back to the untransformed argv). +3. `run()` executes the (possibly transformed) argv, capturing + stdout/stderr/exit code. +4. `flt.render(argv, stdout, stderr, code)` — any exception is treated as + `None`. +5. `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) — see `filter` + conventions in `extensions/git/default.py` (check `code != 0` first). + +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. + +## Stats (`record_stats`, `cmd_gain`) + +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. + +## Extensions registry (`enable`/`disable`/`browse`/`new-filter`/`filters`) + +- `registry_url()` — `TITO_REGISTRY` env override, else `REGISTRY_URL` + (`github.com/pyxel-dev/Tiny-Tokens` raw URL). +- `_filter_rel_path(name)` / `_filter_path(name)` — map a user-facing name + to `//default.py` (bare name) or `.py` (already + contains `/`, e.g. `go/build`); `_filter_path` rejects path traversal + (anything that normalizes outside `filters_dir()`). +- `tito enable ` (`cmd_enable`/`_do_enable`) — if + `.disabled` exists locally, restores it (no re-download, so local + edits survive a disable/enable cycle); otherwise fetches + `/default.py` from the registry. +- `tito disable ` — renames the installed file to `.disabled` + (kept on disk, not deleted). +- `tito browse` (`cmd_browse`/`_fetch_manifest`/`_browse_ui`) — requires a + TTY; fetches `manifest.json` from 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_enable`s each + selection. +- `tito new-filter ` — writes `FILTER_SKELETON` to + `//default.py` (or `.py` if 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.disabled` suffix and a + trailing `/default`, tags disabled ones. + +## Hook rewriter (`rewrite_command`, `_split_segments`, `cmd_hook`, `cmd_rewrite`) + +`_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 ...` (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. ## Install (`cmd_install`) @@ -46,9 +163,9 @@ Order matters and is deliberate: 4. Register the `PreToolUse` hook idempotently (`register_hook()` scans for an existing `tito` hook command before appending). -The registered hook command is `~/.local/bin/tito hook` — the `hook` -subcommand does not exist yet; it lands in a later commit. Until then the -hook is inert. +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 @@ -58,8 +175,20 @@ refuse to proceed. - Binary: `~/.local/bin/tito` - Claude Code settings: `~/.claude/settings.json` -- (Reserved for later, not yet in code) config dir `~/.config/tito`, - overridable via `TITO_HOME`. +- Config dir: `~/.config/tito` (filters under `filters/`, stats at + `stats.jsonl`), overridable via `TITO_HOME`. + +## Tests + +`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. ## Design constraints @@ -68,3 +197,5 @@ refuse to proceed. 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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0281093 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,53 @@ +# Contributing + +## Setup + +No install step beyond Python 3.9+ — tito is stdlib-only. + +``` +git clone +cd tito +python3 -m unittest discover -s tests -v +``` + +## Design principles + +These are load-bearing — PRs that violate them will get pushback: + +- **tito can never break a command.** Any exception in a filter's `transform`/`render`, any unrecognized output shape, any missing extension — all of it falls back to raw output and the real exit code. Never let a filter bug surface as a crash or a hidden result. +- **Doubt favors passthrough.** In the hook rewriter especially: if parsing a shell command is ambiguous, don't rewrite it. A missed optimization costs nothing; a wrong rewrite changes behavior. +- **Prefer machine-readable formats over parsing human output.** `--porcelain`, `--format=`, `-1F`, `--json`, etc. are more robust across tool versions and locales than scraping default CLI output. +- **stdlib only, single file.** This is a deliberate constraint, not an oversight. Don't add a dependency or split `tito.py` into a package without discussing it first. +- **Exit codes are sacred.** Whatever the wrapped command would have returned, tito returns. Never swallow or remap it. + +## Tests + +`unittest`, one file per concern. Current layout (still growing — treat +gaps as where new files should land, not as missing work to backfill +blindly): + +| File | Covers | +|---|---| +| `test_output.py` | `_say`/`_paint` | +| `test_settings.py` | `load_settings`, `register_hook` | +| `test_install.py` | `cmd_install`, `should_copy` | +| `test_main.py` | top-level dispatch | +| `extensions/git/test_git.py` | the bundled `git` extension — loads `extensions/git/default.py` directly via `importlib`, exercises `filter()`/`transform()`, then confirms the registry picks it up | + +Run everything: + +``` +python3 -m unittest discover -s tests -v +``` + +When you add a filter or change its output shape, add a golden test: raw fixture in → expected compact string out, following the pattern in `tests/extensions/git/test_git.py`. These tests are the executable spec of what "compact" means for each command. + +## Adding an extension + +There's no separate "core filter" registration step — every filter, bundled or user-written, is just a file with `COMMANDS` (+ `filter`, optionally `transform`/`SUBCOMMANDS`) that `load_registry()` picks up, from [`extensions/`](../extensions/) (bundled) or `~/.config/tito/filters/` (user). See [Writing Filters](Writing-Filters.md) and [Extensions](Extensions.md) for the contract, and `extensions/git/default.py` for a real example with both `filter` and `transform`. + +To add an official one: create `extensions//default.py` (check `code != 0` → `None` first, same as every existing filter), add a matching golden test under `tests/extensions//` following the git example above, and run `python3 -m unittest discover -s tests -v` before committing. + +If the command's subcommands need genuinely different render logic (not just different flags), use one file per subcommand in that folder instead of a single `default.py` — see "Optional: split one command across subcommands" in [Writing Filters](Writing-Filters.md#optional-split-one-command-across-subcommands). Prefer a stable machine-readable output flag over parsing default text when one exists (see Design principles above); when it doesn't, a long-documented default text shape is acceptable but verify it against a real install of the tool first — an unverified regex against a fast-moving output format (e.g. `next build`'s route table) is worse than leaving the command unhandled. + +See [Architecture](Architecture.md) for how this plugs into the dispatch pipeline, and the repo-root `CLAUDE.md` for line-level detail. diff --git a/SECURITY.md b/SECURITY.md index ee8496e..bd172da 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,13 +2,9 @@ ## Reporting a Vulnerability -If you find a security issue (for example, a way a filter or the hook -rewriter could execute unintended code, leak data, or bypass the "never -break a command" guarantee), please **do not open a public issue**. +If you find a security issue (for example, a way a filter or the hook rewriter could execute unintended code, leak data, or bypass the "never break a command" guarantee), please **do not open a public issue**. -Instead, report it privately using -[GitHub's private vulnerability reporting](https://github.com/pyxel-dev/Tiny-Tokens/security/advisories/new), -or email security@pyxel.dev. +Instead, report it privately using [GitHub's private vulnerability reporting](https://github.com/pyxel-dev/Tiny-Tokens/security/advisories/new), or email security@pyxel.dev. Please include: @@ -16,5 +12,4 @@ Please include: - Steps to reproduce, or a minimal example - Any relevant version/commit information -We'll acknowledge reports as soon as possible and keep you updated as the -issue is investigated and fixed. +We'll acknowledge reports as soon as possible and keep you updated as the issue is investigated and fixed. diff --git a/docs/Architecture.md b/docs/Architecture.md new file mode 100644 index 0000000..33bef43 --- /dev/null +++ b/docs/Architecture.md @@ -0,0 +1,69 @@ +# Architecture + +tito is one Python file, no dependencies. It does two unrelated jobs, connected only by the fact that they both know which commands are "handled": + +``` +Claude (Bash tool) + │ + ▼ +PreToolUse hook — rewrites "git status" into "tito git status" + │ (never runs anything, never sees output) + ▼ +Bash tool executes the rewritten command + │ + ▼ +tito runs the real command, filters its output, +prints the compact version, records byte savings +``` + +## 1. The hook (interception) + +Registered once by `tito install` as a Claude Code `PreToolUse` hook on the Bash tool. It reads the hook JSON on stdin, and if the command string is a chain of recognized commands, rewrites each segment to be prefixed with `tito`. This is what makes the tool "transparent" — you type `git status`, Claude Code actually runs `tito git status`, and the model never has to know tito exists. + +Rewrite rules, in order of importance: + +- Only rewrite a segment if its first word is a recognized command. +- Never rewrite anything whose output is consumed by something else: pipes (`git diff | wc -l`), command substitution (`` $(...) `` / backticks), or redirections. +- Never double-rewrite (`tito`/`rtk` prefix already present). +- Skip anything with unclear shell structure (unbalanced quotes, backgrounding, heredocs). + +**Doubt always favors passthrough.** A missed rewrite just means one command runs unfiltered for a turn; a wrong rewrite could change what a pipeline actually does. The hook is deliberately conservative. + +## 2. Dispatch (the actual filtering) + +When tito runs a command, the flow is: + +1. Look up a **filter** for the command name in the registry. +2. No filter → run the command as-is, print raw output, done. +3. Filter found → optionally **transform** the argv (e.g. add `--porcelain=v1` to `git status`) before running it. +4. Run the (possibly transformed) command for real, capturing stdout/stderr/exit code. +5. **Render** the captured output into a compact string — or `None` if the renderer doesn't recognize the shape of this particular output. +6. `None` → print raw output. A string → print the compact version and record the bytes saved. + +Every step after "filter found" is wrapped so that any exception (transform crashing, render crashing) falls back to raw output rather than surfacing an error or hiding the command's result. That fallback behavior is the core guarantee of the tool: **tito can never break a command**, only fail to compress it. + +Convention: renderers check the exit code first and return `None` on failure, so command errors always reach Claude in full — compact rendering is a courtesy for success, not for debugging failures. + +## 3. Filters today + +Only one extension ships in the repo so far — `git` +(`extensions/git/default.py`), the reference/example filter: + +| Command | Strategy | +|---|---| +| `git status` | `--porcelain=v1 -b` internally, 1 line per file + branch | +| `git diff` / `show` | file headers + hunk markers + changed lines only | +| `git log` | 1 line per commit (`--format=%h %ad %s --date=short`) | +| `git add` / `commit` / `push` / `pull` | ultra-short confirmation | + +Everything else — `grep`, `find`, `diff`, `env`, `ls`, and the rest of +the [planned catalog](Extensions.md#whats-available) — passes straight +through until an extension for it is written. + +## 4. Extensions + +See [Extensions](Extensions.md) — the same registry mechanism that loads core filters also loads user-installed ones from `~/.config/tito/filters/`, with local files always taking priority. + +## 5. Stats + +Every filtered run appends one JSONL line (timestamp, command, raw bytes, filtered bytes) to `~/.config/tito/stats.jsonl`. `tito gain` aggregates it into a per-command and total savings report, estimating tokens as bytes ÷ 4. Passthrough runs record nothing — there's nothing saved to measure. diff --git a/docs/Extensions.md b/docs/Extensions.md new file mode 100644 index 0000000..b9dbab9 --- /dev/null +++ b/docs/Extensions.md @@ -0,0 +1,48 @@ +# Extensions + +An extension is just a filter file (see [Writing Filters](Writing-Filters.md)) that lives in the same directory whether you wrote it yourself or downloaded it: `~/.config/tito/filters/`. There's no separate mechanism for "official" vs "custom" — only where the file came from. + +## Official registry + +``` +tito enable # downloads /default.py from the registry +tito disable # renames it to /default.py.disabled +``` + +`tito enable` fetches `/default.py` from: + +``` +https://raw.githubusercontent.com/pyxel-dev/Tiny-Tokens/main/extensions//default.py +``` + +(A nested name like `go/build` is used as-is instead: `go/build.py`.) + +Set `TITO_REGISTRY` to point at a different source (including a `file://` URL) if you maintain your own extensions directory. + +If `/default.py.disabled` already exists locally, `enable` just renames it back rather than re-downloading — so disabling and re-enabling an extension never loses local edits you made to it. + +## Browsing the registry + +``` +tito browse +``` + +Fetches `extensions/manifest.json` from the registry, shows every available extension with its description and local status (`enabled`/`disabled`/not installed) in a checkbox picker (`↑`/`↓` or `j`/`k` to move, `space` to toggle, `a`/`n` to select/deselect all, `Enter` to install everything checked, `q`/`Esc` to cancel), and installs each selection the same way `tito enable ` would. Requires an interactive terminal. + +## Priority + +Extensions are loaded after core filters and always win — there's no separate override step, it falls out of load order: + +``` +core filters → ~/.config/tito/filters/*.py (alphabetical) +``` + +So a custom filter with the same `COMMANDS` entry as a core filter (or as another extension loaded earlier alphabetically) replaces it entirely. This is intentional: you can always override any built-in behavior by dropping a file with the same command name. + +## Safety + +A broken extension (syntax error, missing `COMMANDS`, import failure) is skipped with a warning to stderr — it never prevents tito from loading the rest of the registry, and never blocks the command you were trying to run. + +## What's available + +The `extensions/` directory in the repo holds the official extensions served over the registry URL. Today that's just `git` (`extensions/git/default.py`) — the reference/example extension covering `status`, `diff`, `show`, `log`, `add`, `commit`, `push`, `pull`. Everything else (cargo, gh, npm, pnpm, docker, kubectl, curl, playwright, the JS/TS and Python toolchains, per-ecosystem folders for Go/.NET/Java/Ruby/PHP) is planned but not yet in the repo. Contributions of new ones are welcome; see [Contributing](../CONTRIBUTING.md). diff --git a/docs/Home.md b/docs/Home.md new file mode 100644 index 0000000..fcb5b90 --- /dev/null +++ b/docs/Home.md @@ -0,0 +1,19 @@ +# tito wiki + +tito is a tiny, single-file command wrapper that saves tokens in [Claude Code](https://claude.com/claude-code). Recognized commands (git, grep, find, diff, env, ls…) get compact, machine-friendly output; everything else passes through untouched. Exit codes are always preserved, and any filter problem falls back to the raw output — tito can never break a command. + +## Pages + +- **[Architecture](Architecture.md)** — how the dispatch pipeline and the Claude Code hook fit together. +- **[Writing Filters](Writing-Filters.md)** — add support for a new command, locally. +- **[Extensions](Extensions.md)** — the official extension registry, `enable`/`disable`, priority rules. +- **[Contributing](../CONTRIBUTING.md)** — dev setup, tests, design principles for PRs. + +For AI agents (Claude Code sessions) working on tito itself, see the repo-root [CLAUDE.md](../CLAUDE.md) — it's a denser internals map keyed to function names and line numbers, meant to be read before editing `tito.py`. + +## Quick links + +- Install: `python3 tito.py install` +- Stats: `tito gain` +- List active filters: `tito filters` +- Source: [pyxel-dev/Tiny-Tokens](https://github.com/pyxel-dev/Tiny-Tokens) diff --git a/docs/Writing-Filters.md b/docs/Writing-Filters.md new file mode 100644 index 0000000..e6c506e --- /dev/null +++ b/docs/Writing-Filters.md @@ -0,0 +1,68 @@ +# Writing your own filter + +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](#managing-filters) below. + +```python +# ~/.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` — `argv` is the full command as a list (`["docker", "ps", "-a"]`), `stdout`/`stderr` are the captured strings, `code` is the exit code. Return `None` to fall back to raw output for this particular invocation (e.g. a shape of output your filter doesn't understand yet). + +## Optional: rewrite the command before it runs + +If you need a machine-readable flag that isn't there by default, add a `transform`: + +```python +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. + +## Rules of thumb + +- **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/except` inside `filter()` 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 `None` so 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), return `None` rather than producing a misleading compact summary. + +## Optional: split one command across subcommands + +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. + +## Testing a filter + +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](../CONTRIBUTING.md) for running the test suite. + +## Managing filters + +``` +tito filters # list core filters + installed extensions +tito new-filter # scaffold ~/.config/tito/filters//default.py +tito disable # rename to /default.py.disabled (kept, not deleted) +tito enable # re-enable a locally disabled filter, or + # download an official one — see Extensions +``` + +(`` can also be a nested path like `go/vet`, in which case it's used as-is: `go/vet.py`, no `default` involved.) diff --git a/tito.py b/tito.py index 810301b..c97a2b9 100644 --- a/tito.py +++ b/tito.py @@ -341,9 +341,9 @@ def cmd_gain_reset(args): # --- extensions registry -------------------------------------------------- -# Official extensions registry (github.com/Tiny-Tokens/tito). Override +# Official extensions registry (github.com/pyxel-dev/Tiny-Tokens). Override # with the TITO_REGISTRY env var if you publish extensions elsewhere. -REGISTRY_URL = "https://raw.githubusercontent.com/Tiny-Tokens/tito/main/extensions" +REGISTRY_URL = "https://raw.githubusercontent.com/pyxel-dev/Tiny-Tokens/main/extensions" def registry_url(): From 9c9a76a29490cded54491293ffe47d06de23d22f Mon Sep 17 00:00:00 2001 From: Kevin Py Date: Fri, 10 Jul 2026 14:53:06 +0200 Subject: [PATCH 06/10] build(other): add commitizen --- CLAUDE.md | 12 +++++++++++ CONTRIBUTING.md | 22 +++++++++++++++++++ pyproject.toml | 57 ++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 57a95f4..c71b260 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,6 +190,18 @@ test-layout table describes a larger target structure (`core/`, yet; treat it as where new test files should land, not as what exists today. +## Commit messages + +Every commit is Conventional Commits with a required scope: +`(): `, 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 "..."`. + ## Design constraints - **Stdlib only, single file.** Stated design constraint, not an oversight diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0281093..898cf7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -20,6 +20,28 @@ These are load-bearing — PRs that violate them will get pushback: - **stdlib only, single file.** This is a deliberate constraint, not an oversight. Don't add a dependency or split `tito.py` into a package without discussing it first. - **Exit codes are sacred.** Whatever the wrapped command would have returned, tito returns. Never swallow or remap it. +## Commit messages + +Commits follow Conventional Commits with a required scope: +`(): `, e.g. `feat(extensions): add git`. + +Scopes are enforced via [commitizen](https://commitizen-tools.github.io/commitizen/) +(`[tool.commitizen]` in `pyproject.toml`, `cz_customize` provider): + +| Scope | Covers | +|---|---| +| `core` | `tito.py` itself — dispatch, registry, hook rewriter, stats, install | +| `extensions` | bundled or user filters (`extensions/`) | +| `docs` | `docs/`, `README.md`, `CONTRIBUTING.md`, `CLAUDE.md` | +| `tests` | `tests/` | +| `*` (other) | anything else — pick "other" and type a custom scope | + +Install commitizen (`brew install commitizen`), then run `cz commit` (or +`cz c`) instead of `git commit` — it walks through type, scope, and +description and produces a correctly formatted message. `cz check +--message "..."` validates a message without committing (useful in a +pre-commit hook or CI). + ## Tests `unittest`, one file per concern. Current layout (still growing — treat diff --git a/pyproject.toml b/pyproject.toml index b1d04f7..b133178 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,4 +12,59 @@ keywords = ["cli", "llm", "tokens", "proxy"] [project.urls] Homepage = "https://github.com/pyxel-dev/Tiny-Tokens" -Repository = "https://github.com/pyxel-dev/Tiny-Tokens" \ No newline at end of file +Repository = "https://github.com/pyxel-dev/Tiny-Tokens" + +[tool.commitizen] +name = "cz_customize" +version = "0.0.0" +version_files = ["pyproject.toml:version"] +tag_format = "v$version" + +[tool.commitizen.customize] +message_template = "{{change_type}}({{custom_scope if custom_scope else scope}}): {{message}}" +example = "feat(extensions): add git" +schema = "(): " +schema_pattern = "(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\\(.+\\))?:\\s.+" +bump_pattern = "^(feat|fix|refactor|perf)" +bump_map = { feat = "MINOR", fix = "PATCH", refactor = "PATCH", perf = "PATCH" } +info = "Scopes: core (tito.py itself), extensions (extensions/), docs (docs/, README, CONTRIBUTING, CLAUDE.md), tests (tests/), or 'other' for anything else (the * catch-all) — you'll be asked to type it." + +[[tool.commitizen.customize.questions]] +type = "list" +name = "change_type" +message = "Select the type of change you are committing" +choices = [ + { value = "feat", name = "feat: A new feature" }, + { value = "fix", name = "fix: A bug fix" }, + { value = "docs", name = "docs: Documentation only changes" }, + { value = "style", name = "style: Formatting only, no code change" }, + { value = "refactor", name = "refactor: Neither fixes a bug nor adds a feature" }, + { value = "perf", name = "perf: A code change that improves performance" }, + { value = "test", name = "test: Adding or correcting tests" }, + { value = "build", name = "build: Build process or auxiliary tools" }, + { value = "ci", name = "ci: CI configuration" }, + { value = "chore", name = "chore: Other changes that don't touch src or tests" }, + { value = "revert", name = "revert: Revert to a previous commit" }, +] + +[[tool.commitizen.customize.questions]] +type = "list" +name = "scope" +message = "Scope of this change" +choices = [ + { value = "core", name = "core: tito.py dispatch/registry/hook/stats/install" }, + { value = "extensions", name = "extensions: bundled or user filters (extensions/)" }, + { value = "docs", name = "docs: docs/, README, CONTRIBUTING.md, CLAUDE.md" }, + { value = "tests", name = "tests: tests/" }, + { value = "other", name = "* other: anything else — type a custom scope next" }, +] + +[[tool.commitizen.customize.questions]] +type = "input" +name = "custom_scope" +message = "Custom scope (only if you picked 'other' above, else press Enter)" + +[[tool.commitizen.customize.questions]] +type = "input" +name = "message" +message = "Short description of the change" \ No newline at end of file From 72c2b897d7c157feaad97bf367db3dca64f722c8 Mon Sep 17 00:00:00 2001 From: Kevin Py Date: Fri, 10 Jul 2026 15:12:26 +0200 Subject: [PATCH 07/10] build(other): add format for create PR --- .github/workflows/pr-version-and-commits.yml | 157 +++++++++++++++++++ .github/workflows/release.yml | 81 ++++++++++ 2 files changed, 238 insertions(+) create mode 100644 .github/workflows/pr-version-and-commits.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/pr-version-and-commits.yml b/.github/workflows/pr-version-and-commits.yml new file mode 100644 index 0000000..01fc435 --- /dev/null +++ b/.github/workflows/pr-version-and-commits.yml @@ -0,0 +1,157 @@ +name: PR Version And Commits + +on: + pull_request: + branches: + - main + types: + - opened + - synchronize + - reopened + - edited + - ready_for_review + +permissions: + contents: write + pull-requests: write + +jobs: + update-pr-metadata: + if: github.event.pull_request.base.ref == 'main' && github.event.pull_request.head.ref == 'develop' && github.actor != 'github-actions[bot]' + runs-on: ubuntu-latest + + steps: + - name: Ensure main branch exists + uses: actions/github-script@v7 + with: + script: | + try { + await github.rest.repos.getBranch({ + owner: context.repo.owner, + repo: context.repo.repo, + branch: "main", + }); + } catch (error) { + core.setFailed("Branch 'main' does not exist yet. Create it before opening PRs from develop to main."); + } + + - name: Validate PR version + id: version + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const version = pr.title.trim(); + + if (!/^\d+\.\d+\.\d+$/.test(version)) { + core.setFailed("PR title must be a version in x.y.z format, for example 1.2.3."); + return; + } + + core.setOutput("version", version); + + - name: Check out develop + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 + + - name: Commit PR version + env: + VERSION: ${{ steps.version.outputs.version }} + BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + python3 - "$VERSION" <<'PY' + from pathlib import Path + import re + import sys + + path = Path("pyproject.toml") + content = path.read_text() + updated, count = re.subn( + r'^version\s*=\s*"\d+\.\d+\.\d+"\s*$', + f'version = "{sys.argv[1]}"', + content, + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise SystemExit("Unable to find one project version in pyproject.toml.") + path.write_text(updated) + PY + + if git diff --quiet -- pyproject.toml; then + echo "pyproject.toml already has version $VERSION." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add pyproject.toml + git commit -m "$VERSION" + git push origin "HEAD:$BRANCH" + + - name: Build commit list + id: build + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + const owner = context.repo.owner; + const repo = context.repo.repo; + + const version = pr.title.trim(); + + const commits = await github.paginate(github.rest.pulls.listCommits, { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }); + + const commitLines = commits.map(commit => { + const shortSha = commit.sha.substring(0, 7); + const subject = (commit.commit.message || "").split("\n")[0].trim(); + return `- ${shortSha} ${subject}`; + }); + + const bodyLines = [ + "## Commit List", + "", + ...commitLines, + ]; + + const body = bodyLines.join("\n"); + + core.setOutput("version", version); + core.setOutput("body", body); + + - name: Update PR body + uses: actions/github-script@v7 + env: + PR_BODY: ${{ steps.build.outputs.body }} + with: + script: | + const pr = context.payload.pull_request; + const newBody = process.env.PR_BODY || ""; + + if ((pr.body || "") === newBody) { + core.info("PR body is already up to date."); + return; + } + + try { + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + body: newBody, + }); + core.info("PR body updated successfully."); + } catch (error) { + if (error.status === 403) { + core.setFailed("Unable to update this PR with GITHUB_TOKEN permissions (likely fork restrictions). Update title/body manually or use pull_request_target with strict safeguards."); + return; + } + throw error; + } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..480ba09 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: Create Release + +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + create-release: + runs-on: ubuntu-latest + + steps: + - name: Check out main + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Read project version + id: version + run: | + version="$(python3 - <<'PY' + from pathlib import Path + import re + + content = Path("pyproject.toml").read_text() + match = re.search(r'^version\s*=\s*"(\d+\.\d+\.\d+)"\s*$', content, re.MULTILINE) + if not match: + raise SystemExit("Version not found in pyproject.toml. Expected x.y.z.") + print(match.group(1)) + PY + )" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=v$version" >> "$GITHUB_OUTPUT" + + - name: Check whether the release tag exists + id: tag + env: + TAG: ${{ steps.version.outputs.tag }} + run: | + if git rev-parse --verify --quiet "refs/tags/$TAG"; then + echo "exists=true" >> "$GITHUB_OUTPUT" + echo "Tag $TAG already exists; skipping release creation." + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build release notes + if: steps.tag.outputs.exists == 'false' + id: notes + run: | + previous_tag="$(git tag --merged "$GITHUB_SHA" --list 'v[0-9]*' --sort=-version:refname | head -n 1)" + if [ -n "$previous_tag" ]; then + git log --format='- %h %s' "$previous_tag..$GITHUB_SHA" > release-notes.md + else + git log --format='- %h %s' "$GITHUB_SHA" > release-notes.md + fi + + if [ ! -s release-notes.md ]; then + echo "- No commits found." > release-notes.md + fi + + - name: Create GitHub release + if: steps.tag.outputs.exists == 'false' + uses: actions/github-script@v7 + env: + TAG: ${{ steps.version.outputs.tag }} + VERSION: ${{ steps.version.outputs.version }} + with: + script: | + const fs = require("fs"); + await github.rest.repos.createRelease({ + owner: context.repo.owner, + repo: context.repo.repo, + tag_name: process.env.TAG, + target_commitish: context.sha, + name: process.env.VERSION, + body: fs.readFileSync("release-notes.md", "utf8"), + }); \ No newline at end of file From 9a0ecf365a39a6fb5a6d87b5b3fffe5bfb8cce53 Mon Sep 17 00:00:00 2001 From: Kevin Py Date: Fri, 10 Jul 2026 18:21:13 +0200 Subject: [PATCH 08/10] build(scripts): add scripts folder --- CLAUDE.md | 15 +++++++++++++++ CONTRIBUTING.md | 23 ++++++++++++++++------- pyproject.toml | 8 ++------ scripts/commit.sh | 12 ++++++++++++ scripts/test.sh | 6 ++++++ scripts/update.sh | 7 +++++++ 6 files changed, 58 insertions(+), 13 deletions(-) create mode 100755 scripts/commit.sh create mode 100755 scripts/test.sh create mode 100755 scripts/update.sh diff --git a/CLAUDE.md b/CLAUDE.md index c71b260..ecd8234 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -190,6 +190,21 @@ test-layout table describes a larger target structure (`core/`, yet; treat it as where new test files should land, not as what exists today. +## Scripts + +`scripts/` holds dev-only shell wrappers, not part of `tito.py` itself: + +- `scripts/test.sh` — `cd`s to the repo root and runs + `python3 -m unittest discover -s tests -v`. +- `scripts/update.sh` — `cd`s to the repo root and runs `python3 tito.py + install` (`cmd_install`, above), so a local edit to `tito.py` is + redeployed to `~/.local/bin/tito` and the hook re-registered. Run this + after any change to `tito.py` you want your own Claude Code session to + pick up. +- `scripts/commit.sh` — `cd`s to the repo root and runs `cz commit "$@"` + (commitizen's Conventional Commits wizard, see "Commit messages" + below), failing with an install hint if `cz` isn't on `PATH`. + ## Commit messages Every commit is Conventional Commits with a required scope: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 898cf7e..60d92ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,9 +7,15 @@ No install step beyond Python 3.9+ — tito is stdlib-only. ``` git clone cd tito -python3 -m unittest discover -s tests -v +scripts/test.sh ``` +`scripts/` holds small dev helpers: `scripts/test.sh` runs the suite +(below), `scripts/update.sh` re-runs `tito.py install` so a local edit to +`tito.py` is redeployed to `~/.local/bin/tito` and picked up by your own +Claude Code hook, and `scripts/commit.sh` wraps `cz commit` (see "Commit +messages" below). + ## Design principles These are load-bearing — PRs that violate them will get pushback: @@ -36,11 +42,11 @@ Scopes are enforced via [commitizen](https://commitizen-tools.github.io/commitiz | `tests` | `tests/` | | `*` (other) | anything else — pick "other" and type a custom scope | -Install commitizen (`brew install commitizen`), then run `cz commit` (or -`cz c`) instead of `git commit` — it walks through type, scope, and -description and produces a correctly formatted message. `cz check ---message "..."` validates a message without committing (useful in a -pre-commit hook or CI). +Install commitizen (`brew install commitizen`), then run `scripts/commit.sh` +(or `cz commit` / `cz c` directly) instead of `git commit` — it walks +through type, scope, and description and produces a correctly formatted +message. `cz check --message "..."` validates a message without +committing (useful in a pre-commit hook or CI). ## Tests @@ -59,9 +65,12 @@ blindly): Run everything: ``` -python3 -m unittest discover -s tests -v +scripts/test.sh ``` +(equivalent to `python3 -m unittest discover -s tests -v`, run from the +repo root). + When you add a filter or change its output shape, add a golden test: raw fixture in → expected compact string out, following the pattern in `tests/extensions/git/test_git.py`. These tests are the executable spec of what "compact" means for each command. ## Adding an extension diff --git a/pyproject.toml b/pyproject.toml index b133178..4c4ffcb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,14 +56,10 @@ choices = [ { value = "extensions", name = "extensions: bundled or user filters (extensions/)" }, { value = "docs", name = "docs: docs/, README, CONTRIBUTING.md, CLAUDE.md" }, { value = "tests", name = "tests: tests/" }, - { value = "other", name = "* other: anything else — type a custom scope next" }, + { value = "scripts", name = "scripts: scripts/" }, + { value = "*", name = "* other: anything else — type a custom scope next" }, ] -[[tool.commitizen.customize.questions]] -type = "input" -name = "custom_scope" -message = "Custom scope (only if you picked 'other' above, else press Enter)" - [[tool.commitizen.customize.questions]] type = "input" name = "message" diff --git a/scripts/commit.sh b/scripts/commit.sh new file mode 100755 index 0000000..45744a4 --- /dev/null +++ b/scripts/commit.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Commit staged changes via commitizen's Conventional Commits wizard +# (type + required scope, see CONTRIBUTING.md "Commit messages"). +set -euo pipefail +cd "$(dirname "$0")/.." + +if ! command -v cz >/dev/null 2>&1; then + echo "error: commitizen (cz) is not installed — 'brew install commitizen' (see CONTRIBUTING.md)" >&2 + exit 1 +fi + +cz commit "$@" diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..55a9062 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Run the full test suite (see CONTRIBUTING.md "Tests"). +set -euo pipefail +cd "$(dirname "$0")/.." + +python3 -m unittest discover -s tests -v diff --git a/scripts/update.sh b/scripts/update.sh new file mode 100755 index 0000000..380e872 --- /dev/null +++ b/scripts/update.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Reinstall tito after local changes to tito.py: re-copies the binary to +# ~/.local/bin/tito and re-registers the Claude Code hook (see cmd_install). +set -euo pipefail +cd "$(dirname "$0")/.." + +python3 tito.py install From 3a43484ab1eb80938a4341e51f468be5e4fe7f44 Mon Sep 17 00:00:00 2001 From: Kevin Py Date: Fri, 10 Jul 2026 20:44:42 +0200 Subject: [PATCH 09/10] build(*): update Github workflow versions --- .github/workflows/pr-version-and-commits.yml | 10 +++++----- .github/workflows/release.yml | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr-version-and-commits.yml b/.github/workflows/pr-version-and-commits.yml index 01fc435..7ef6269 100644 --- a/.github/workflows/pr-version-and-commits.yml +++ b/.github/workflows/pr-version-and-commits.yml @@ -22,7 +22,7 @@ jobs: steps: - name: Ensure main branch exists - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | try { @@ -37,7 +37,7 @@ jobs: - name: Validate PR version id: version - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const pr = context.payload.pull_request; @@ -51,7 +51,7 @@ jobs: core.setOutput("version", version); - name: Check out develop - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: ref: ${{ github.event.pull_request.head.ref }} fetch-depth: 0 @@ -93,7 +93,7 @@ jobs: - name: Build commit list id: build - uses: actions/github-script@v7 + uses: actions/github-script@v9 with: script: | const pr = context.payload.pull_request; @@ -127,7 +127,7 @@ jobs: core.setOutput("body", body); - name: Update PR body - uses: actions/github-script@v7 + uses: actions/github-script@v9 env: PR_BODY: ${{ steps.build.outputs.body }} with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 480ba09..c6d10ac 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Check out main - uses: actions/checkout@v4 + uses: actions/checkout@v7 with: fetch-depth: 0 @@ -64,7 +64,7 @@ jobs: - name: Create GitHub release if: steps.tag.outputs.exists == 'false' - uses: actions/github-script@v7 + uses: actions/github-script@v9 env: TAG: ${{ steps.version.outputs.tag }} VERSION: ${{ steps.version.outputs.version }} From 994c45f381d574eb3075efab69e6190d857a9194 Mon Sep 17 00:00:00 2001 From: Kevin Py Date: Fri, 10 Jul 2026 20:44:42 +0200 Subject: [PATCH 10/10] build(*): update Github workflow versions --- .github/workflows/pr-version-and-commits.yml | 181 +++++++++++++++---- 1 file changed, 144 insertions(+), 37 deletions(-) diff --git a/.github/workflows/pr-version-and-commits.yml b/.github/workflows/pr-version-and-commits.yml index 7ef6269..0f769d8 100644 --- a/.github/workflows/pr-version-and-commits.yml +++ b/.github/workflows/pr-version-and-commits.yml @@ -50,47 +50,154 @@ jobs: core.setOutput("version", version); - - name: Check out develop - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.ref }} - fetch-depth: 0 - - name: Commit PR version + uses: actions/github-script@v9 env: VERSION: ${{ steps.version.outputs.version }} - BRANCH: ${{ github.event.pull_request.head.ref }} - run: | - python3 - "$VERSION" <<'PY' - from pathlib import Path - import re - import sys - - path = Path("pyproject.toml") - content = path.read_text() - updated, count = re.subn( - r'^version\s*=\s*"\d+\.\d+\.\d+"\s*$', - f'version = "{sys.argv[1]}"', - content, - count=1, - flags=re.MULTILINE, - ) - if count != 1: - raise SystemExit("Unable to find one project version in pyproject.toml.") - path.write_text(updated) - PY - - if git diff --quiet -- pyproject.toml; then - echo "pyproject.toml already has version $VERSION." - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add pyproject.toml - git commit -m "$VERSION" - git push origin "HEAD:$BRANCH" + with: + script: | + const version = process.env.VERSION; + const { owner, repo } = context.repo; + const headBranch = context.payload.pull_request.head.ref; + + // Helper: return true when an Octokit error is a 422 whose message matches the + // given substring. GitHub's documented error messages for createRef and + // pulls.create are stable, but we centralise the check here so any future + // adjustment only needs one edit. + const is422 = (e, msg) => + e.status === 422 && + String(e.response?.data?.message || e.message).includes(msg); + + const { data: file } = await github.rest.repos.getContent({ + owner, repo, path: "pyproject.toml", ref: headBranch, + }); + + const content = Buffer.from(file.content, "base64").toString("utf8"); + + if (!/^version\s*=\s*"\d+\.\d+\.\d+"\s*$/m.test(content)) { + core.setFailed("Unable to find one project version in pyproject.toml."); + return; + } + + const updated = content.replace( + /^version\s*=\s*"\d+\.\d+\.\d+"\s*$/m, + `version = "${version}"`, + ); + + if (content === updated) { + core.info(`pyproject.toml already has version ${version}.`); + return; + } + + const sanitizedVersion = version.replace(/\./g, "-"); + const tempBranch = `ci/version-bump-${sanitizedVersion}-${context.runId}`; + + const { data: baseRef } = await github.rest.git.getRef({ + owner, repo, ref: `heads/${headBranch}`, + }); + + try { + await github.rest.git.createRef({ + owner, repo, ref: `refs/heads/${tempBranch}`, sha: baseRef.object.sha, + }); + } catch (e) { + if (is422(e, "Reference already exists")) { + core.warning( + `Temp branch ${tempBranch} already exists (likely a retried run). ` + + `Force-updating to current HEAD of ${headBranch}.` + ); + await github.rest.git.updateRef({ + owner, repo, ref: `heads/${tempBranch}`, sha: baseRef.object.sha, force: true, + }); + } else { + throw e; + } + } + + // Re-fetch from tempBranch to obtain the SHA that createOrUpdateFileContents + // requires. Using file.sha from the earlier read would cause a 409 if headBranch + // advanced between that read and the branch creation above, because tempBranch + // would then point to a different commit than file.sha. + const { data: fileOnTemp } = await github.rest.repos.getContent({ + owner, repo, path: "pyproject.toml", ref: tempBranch, + }); + + await github.rest.repos.createOrUpdateFileContents({ + owner, repo, path: "pyproject.toml", + message: `ci(core): bump version to ${version}`, + content: Buffer.from(updated).toString("base64"), + sha: fileOnTemp.sha, + branch: tempBranch, + }); + + let bumpPrNumber; + try { + const { data: bumpPr } = await github.rest.pulls.create({ + owner, repo, + title: `ci(core): bump version to ${version}`, + head: tempBranch, + base: headBranch, + body: `Automated version bump to ${version}.`, + }); + bumpPrNumber = bumpPr.number; + } catch (e) { + if (is422(e, "A pull request already exists for")) { + const { data: existing } = await github.rest.pulls.list({ + owner, repo, head: `${owner}:${tempBranch}`, base: headBranch, state: "open", + }); + if (existing.length > 0) { + core.warning(`PR from ${tempBranch} already exists (#${existing[0].number}); reusing it.`); + bumpPrNumber = existing[0].number; + } else { + throw e; + } + } else { + throw e; + } + } + + const deleteTempBranch = async () => { + try { + await github.rest.git.deleteRef({ + owner, repo, ref: `heads/${tempBranch}`, + }); + } catch (deleteErr) { + core.warning( + `Could not delete temp branch ${tempBranch} ` + + `(HTTP ${deleteErr.status ?? "unknown"}): ${deleteErr.message}` + ); + } + }; + + try { + await github.rest.pulls.merge({ + owner, repo, + pull_number: bumpPrNumber, + merge_method: "squash", + commit_title: `ci(core): bump version to ${version}`, + }); + } catch (mergeErr) { + core.error( + `Merge details — HTTP ${mergeErr.status ?? "unknown"}: ` + + JSON.stringify(mergeErr.response?.data ?? mergeErr.message) + ); + try { + await github.rest.pulls.update({ + owner, repo, pull_number: bumpPrNumber, state: "closed", + }); + } catch (closeErr) { + core.warning(`Could not close PR #${bumpPrNumber}: ${closeErr.message}`); + } + await deleteTempBranch(); + core.setFailed( + `Automated merge of version bump PR #${bumpPrNumber} failed: ${mergeErr.message}. ` + + `This may be caused by required reviews, status checks, merge conflicts, ` + + `or other branch protection settings on the '${headBranch}' branch.` + ); + return; + } + await deleteTempBranch(); - name: Build commit list id: build uses: actions/github-script@v9