Contributing to Token-Saver
August 10, 2026 · View on GitHub
How to contribute
This project uses a fork-based workflow. Direct pushes to main are not
allowed — all changes go through pull requests reviewed by maintainers.
1. Fork and clone
# Fork the repo on GitHub (click the "Fork" button), then:
git clone https://github.com/<your-username>/token-saver.git
cd token-saver/extension
2. Create a branch
git checkout -b my-feature
3. Make your changes
Edit the code, add tests, and make sure everything passes locally:
# Lint
ruff check .
ruff format --check .
# Tests
python3 -m pytest tests/ -v
4. Commit and push
git add -A
git commit -m "Add my feature"
git push origin my-feature
5. Open a pull request
Go to your fork on GitHub and click "New pull request". Target the main
branch of the upstream repository. Fill in the PR template.
6. CI and review
- A maintainer must approve your PR before CI runs (GitHub's "Require approval for first-time contributors" setting).
- CI runs ruff check, ruff format --check, and pytest on Python 3.10, 3.12, and 3.13.
- Both CI and a maintainer review must pass before merging.
- Only maintainers can merge to
main.
Keeping your fork up to date
git remote add upstream https://github.com/<org>/token-saver.git
git fetch upstream
git rebase upstream/main
Code quality
This project uses Ruff for linting and
formatting. Configuration is in pyproject.toml.
# Check for lint issues
ruff check .
# Auto-fix safe issues
ruff check --fix .
# Check formatting
ruff format --check .
# Auto-format
ruff format .
All code must pass ruff check and ruff format --check before merging.
Adding a new processor
Adding a new processor is a single-file operation. Create one file in
src/processors/ and it will be automatically discovered.
Quick start
- Create
src/processors/my_tool.py - Run
python3 -m pytest tests/ -vto verify
That's it. No other files need editing.
Processor template
"""MyTool output processor: describe what it handles."""
import re
from .base import Processor
class MyToolProcessor(Processor):
priority = 40 # See priority conventions below
hook_patterns = [
r"^mytool\s+(subcommand1|subcommand2)\b",
]
@property
def name(self) -> str:
return "my_tool"
def can_handle(self, command: str) -> bool:
return bool(re.search(r"\bmytool\s+(subcommand1|subcommand2)\b", command))
def process(self, command: str, output: str) -> str:
if not output or not output.strip():
return output
# Your compression logic here
return output
Required attributes
| Attribute | Type | Description |
|---|---|---|
priority | int | Determines processor ordering. Lower = checked first. |
hook_patterns | list[str] | Regex patterns for the pre-tool hook to intercept matching commands. |
name | str (property) | Identifier returned by the engine to report which processor handled a command. |
can_handle(command) | method | Returns True if this processor should handle the given command string. |
process(command, output) | method | Takes the command and its raw output, returns a compressed version. |
Priority conventions
| Range | Category | Examples |
|---|---|---|
| 10-19 | High priority overrides | PackageListProcessor (15) — must run before BuildOutputProcessor |
| 20-29 | Core processors | git (20), test (21), build (25), lint (27) |
| 30-49 | Specialized tools | network (30), docker (31), kubectl (32), terraform (33), env (34), search (35), system_info (36) |
| 50-69 | Content-based | file_listing (50), file_content (51) |
| 999 | Generic fallback | GenericProcessor — always last, do not use this value |
When choosing a priority:
- Pick a value in the appropriate range for your processor category.
- If your processor must run before another (e.g., to avoid misrouting), use a lower number.
- Leave gaps between values to allow future insertions without renumbering.
hook_patterns
These are regex patterns that the pre-tool hook uses to decide whether to intercept a command for compression. They should match the start of commands your processor handles.
- Use
^anchors so patterns match the beginning of the command. - Patterns are compiled with
re.compile()and matched withre.search(). - GenericProcessor should have
hook_patterns = [](it's a fallback).
How auto-discovery works
processors/__init__.pyscans all.pyfiles in theprocessors/directory.- It imports each module and finds all non-abstract
Processorsubclasses. - Instances are sorted by
priority(ascending). discover_processors()returns the sorted list (used byengine.py).collect_hook_patterns()collects allhook_patterns(used byhook_pretool.py).
Adding tests
Tests for processors live in tests/test_processors.py. Each
processor has its own test class. Add a new class following this pattern:
class TestMyToolProcessor:
def setup_method(self):
self.p = MyToolProcessor()
def test_can_handle(self):
# Commands your processor should match
assert self.p.can_handle("mytool subcommand1")
assert self.p.can_handle("mytool subcommand2 --flag")
# Commands it should NOT match
assert not self.p.can_handle("othertool run")
assert not self.p.can_handle("ls -la")
def test_empty_output(self):
result = self.p.process("mytool subcommand1", "")
assert result == ""
def test_short_output_unchanged(self):
output = "one line of output"
result = self.p.process("mytool subcommand1", output)
assert result == output
def test_compression_logic(self):
# Build a realistic output that should be compressed
lines = [f"processing item {i}" for i in range(100)]
lines.append("Done: 100 items processed")
output = "\n".join(lines)
result = self.p.process("mytool subcommand1", output)
assert len(result) < len(output)
# Verify important information is preserved
assert "100 items processed" in result
Don't forget to add the import at the top of test_processors.py:
from src.processors.my_tool import MyToolProcessor
You should also add an integration test in test_engine.py to verify
your processor is picked up by the engine:
def test_mytool_output_compressed(self):
output = "\n".join(f"item {i}" for i in range(100))
compressed, processor, was_compressed = self.engine.compress(
"mytool subcommand1", output
)
assert was_compressed
assert processor == "my_tool"
And a hook test in test_hooks.py to verify the hook intercepts your
commands:
def test_mytool_commands_compressible(self):
assert is_compressible("mytool subcommand1")
assert is_compressible("mytool subcommand2 --verbose")
What to test
| Category | What to check |
|---|---|
can_handle | Positive matches, negative matches, edge cases |
| Empty/short output | Returns input unchanged |
| Compression | Output is shorter, key information is preserved |
| Error preservation | Error messages, stack traces, failure details are never dropped |
| Edge cases | Unicode, very long lines, unusual formatting |
Running tests
python3 -m pytest tests/ -v
All existing tests must continue to pass after adding a new processor.
Releasing
src/__init__.py holds __version__ and is the single source of truth — the
installer stamps the manifests from it. But the copies committed in the repo are
what users resolve against, and Claude Code keys its update cache on
.claude-plugin/plugin.json: a release that forgets to bump that file reaches
nobody, and /plugin update reports "already on the latest version".
So a release commit touches all five:
| File | Where |
|---|---|
src/__init__.py | __version__ |
.claude-plugin/plugin.json | version |
.claude-plugin/marketplace.json | plugins[0].version |
antigravity/antigravity-plugin.json | version |
docs/benchmarks.md | Version: **X.Y.Z** header line |
Plus a new ## [X.Y.Z] - YYYY-MM-DD section in CHANGELOG.md.
Verify before pushing — CI runs the same check and fails the PR otherwise:
python3 scripts/check_versions.py
Version numbers follow semver: MAJOR for breaking changes,
MINOR for new features, PATCH for bug fixes. Tag the merge commit vX.Y.Z.