Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 25 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,24 +237,28 @@ processor is in [`docs/processors/`](docs/processors/).
| 1 | **Package List** | 15 | pip list/freeze, npm ls, conda list, gem list, brew list | [package_list.md](docs/processors/package_list.md) |
| 2 | **Git** | 20 | status, diff, log, show, push/pull/fetch, branch, stash, reflog, blame, cherry-pick, rebase, merge | [git.md](docs/processors/git.md) |
| 3 | **Test** | 21 | pytest, jest, vitest, mocha, cargo test, go test, rspec, phpunit, bun test, npm/yarn/pnpm test, dotnet test, swift test, mix test | [test_output.md](docs/processors/test_output.md) |
| 4 | **Build** | 25 | npm/yarn/pnpm build/install, cargo build, make, cmake, gradle, mvn, pip install, tsc, webpack, vite, next build, turbo, nx, bazel, sbt, mix compile, docker build | [build_output.md](docs/processors/build_output.md) |
| 5 | **Lint** | 27 | eslint, ruff, flake8, pylint, clippy, mypy, prettier, biome, shellcheck, hadolint, rubocop, golangci-lint | [lint_output.md](docs/processors/lint_output.md) |
| 6 | **Network** | 30 | curl, wget, http/https (httpie) | [network.md](docs/processors/network.md) |
| 7 | **Docker** | 31 | ps, images, logs, pull/push, inspect, stats, compose up/down/build/ps/logs | [docker.md](docs/processors/docker.md) |
| 8 | **Kubernetes** | 32 | kubectl/oc get, describe, logs, top, apply, delete, create | [kubectl.md](docs/processors/kubectl.md) |
| 9 | **Terraform** | 33 | terraform/tofu plan, apply, destroy, init, output, state list/show | [terraform.md](docs/processors/terraform.md) |
| 10 | **Environment** | 34 | env, printenv (with secret redaction) | [env.md](docs/processors/env.md) |
| 11 | **Search** | 35 | grep -r, rg, ag, fd, fdfind | [search.md](docs/processors/search.md) |
| 12 | **System Info** | 36 | du, wc, df | [system_info.md](docs/processors/system_info.md) |
| 13 | **GitHub CLI** | 37 | gh pr/issue/run list/view/diff/checks/status | [gh.md](docs/processors/gh.md) |
| 14 | **Database Query** | 38 | psql, mysql, sqlite3, pgcli, mycli, litecli | [db_query.md](docs/processors/db_query.md) |
| 15 | **Cloud CLI** | 39 | aws, gcloud, az (JSON/table/text output compression) | [cloud_cli.md](docs/processors/cloud_cli.md) |
| 16 | **Ansible** | 40 | ansible-playbook, ansible (ok/skipped counting, error preservation) | [ansible.md](docs/processors/ansible.md) |
| 17 | **Helm** | 41 | helm install/upgrade/list/template/status/history | [helm.md](docs/processors/helm.md) |
| 18 | **Syslog** | 42 | journalctl, dmesg (head/tail with error extraction) | [syslog.md](docs/processors/syslog.md) |
| 19 | **File Listing** | 50 | ls, find, tree, exa, eza, rsync | [file_listing.md](docs/processors/file_listing.md) |
| 20 | **File Content** | 51 | cat, head, tail, bat, less, more (content-aware: code, config, log, CSV) | [file_content.md](docs/processors/file_content.md) |
| 21 | **Generic** | 999 | Any command (fallback: ANSI strip, dedup, truncation) | [generic.md](docs/processors/generic.md) |
| 4 | **Python Install** | 24 | pip install, poetry install/update/add, uv pip install, uv sync | [python_install.md](docs/processors/python_install.md) |
| 5 | **Build** | 25 | npm/yarn/pnpm build/install, cargo build, make, cmake, tsc, webpack, vite, next build, turbo, nx, bazel, sbt, mix compile, docker build | [build_output.md](docs/processors/build_output.md) |
| 6 | **Cargo Clippy** | 26 | cargo clippy (multi-line block grouping with span/help preservation) | [cargo_clippy.md](docs/processors/cargo_clippy.md) |
| 7 | **Lint** | 27 | eslint, ruff, flake8, pylint, clippy, mypy, prettier, biome, shellcheck, hadolint, rubocop, golangci-lint | [lint_output.md](docs/processors/lint_output.md) |
| 8 | **Maven/Gradle** | 28 | mvn, ./mvnw, gradle, ./gradlew (download stripping, task noise removal) | [maven_gradle.md](docs/processors/maven_gradle.md) |
| 9 | **Network** | 30 | curl, wget, http/https (httpie) | [network.md](docs/processors/network.md) |
| 10 | **Docker** | 31 | ps, images, logs, pull/push, inspect, stats, compose up/down/build/ps/logs | [docker.md](docs/processors/docker.md) |
| 11 | **Kubernetes** | 32 | kubectl/oc get, describe, logs, top, apply, delete, create | [kubectl.md](docs/processors/kubectl.md) |
| 12 | **Terraform** | 33 | terraform/tofu plan, apply, destroy, init, output, state list/show | [terraform.md](docs/processors/terraform.md) |
| 13 | **Environment** | 34 | env, printenv (with secret redaction) | [env.md](docs/processors/env.md) |
| 14 | **Search** | 35 | grep -r, rg, ag, fd, fdfind | [search.md](docs/processors/search.md) |
| 15 | **System Info** | 36 | du, wc, df | [system_info.md](docs/processors/system_info.md) |
| 16 | **GitHub CLI** | 37 | gh pr/issue/run list/view/diff/checks/status | [gh.md](docs/processors/gh.md) |
| 17 | **Database Query** | 38 | psql, mysql, sqlite3, pgcli, mycli, litecli | [db_query.md](docs/processors/db_query.md) |
| 18 | **Cloud CLI** | 39 | aws, gcloud, az (JSON/table/text output compression) | [cloud_cli.md](docs/processors/cloud_cli.md) |
| 19 | **Ansible** | 40 | ansible-playbook, ansible (ok/skipped counting, error preservation) | [ansible.md](docs/processors/ansible.md) |
| 20 | **Helm** | 41 | helm install/upgrade/list/template/status/history | [helm.md](docs/processors/helm.md) |
| 21 | **Syslog** | 42 | journalctl, dmesg (head/tail with error extraction) | [syslog.md](docs/processors/syslog.md) |
| 22 | **Structured Log** | 45 | stern, kubetail (JSON Lines grouping by level) | [structured_log.md](docs/processors/structured_log.md) |
| 23 | **File Listing** | 50 | ls, find, tree, exa, eza, rsync | [file_listing.md](docs/processors/file_listing.md) |
| 24 | **File Content** | 51 | cat, head, tail, bat, less, more (content-aware: code, config, log, CSV) | [file_content.md](docs/processors/file_content.md) |
| 25 | **Generic** | 999 | Any command (fallback: ANSI strip, dedup, truncation) | [generic.md](docs/processors/generic.md) |

## Configuration

Expand Down Expand Up @@ -341,11 +345,13 @@ Project settings are merged with global settings. Token-Saver walks up parent di
| `max_traceback_lines` | 30 | Max traceback lines before truncation |
| `db_prune_days` | 90 | Stats retention in days |
| `user_processors_dir` | `~/.token-saver/processors/` | Directory for custom processors |
| `disabled_processors` | `[]` | List of processor names to disable (env: comma-separated) |
| `max_chain_depth` | 3 | Maximum processor chain depth |
| `debug` | false | Enable debug logging |

## Custom Processors

You can extend Token-Saver with your own processors for commands not covered by the built-in 21.
You can extend Token-Saver with your own processors for commands not covered by the built-in 25.

1. Create a Python file with a class inheriting from `src.processors.base.Processor`
2. Implement `can_handle()`, `process()`, `name`, and set `priority`
Expand Down
2 changes: 1 addition & 1 deletion docs/processors/cargo.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ cargo build, cargo check, cargo doc, cargo update, cargo bench.
## Exclusions

- `cargo test` is routed to `TestOutputProcessor`
- `cargo clippy` is routed to `LintOutputProcessor`
- `cargo clippy` is routed to `CargoClippyProcessor`

## Configuration

Expand Down
39 changes: 39 additions & 0 deletions docs/processors/cargo_clippy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Cargo Clippy Processor

**File:** `src/processors/cargo_clippy.py` | **Priority:** 26 | **Name:** `cargo_clippy`

Dedicated processor for Rust clippy lint output with multi-line block awareness.

## Supported Commands

cargo clippy (with any flags like `--all-targets`, `-- -W clippy::all`).

## Strategy

Parses clippy's multi-line warning blocks (header + `-->` span + code + `= help:` annotations) as coherent units. Groups warnings by clippy lint rule. Shows N example blocks per rule with full context. Preserves all errors in full.

| Output Type | Strategy |
|---|---|
| **Warnings** | Group by lint rule (e.g., `clippy::needless_return`). Show count + N example blocks per rule. Categorize as style/correctness/complexity/perf |
| **Errors** | Keep all error blocks in full with spans and context |
| **Checking/Compiling** | Collapse into count (e.g., `[12 checked, 3 compiled]`) |
| **Summary** | Keep `warning: X generated N warnings` summary line |

## Key Difference from Lint Processor

The generic `LintOutputProcessor` groups violations as single lines. Clippy output has multi-line blocks with `-->` spans, code snippets, and `= help:` annotations that need to be preserved as coherent units. This processor keeps the block structure intact.

## Configuration

| Parameter | Default | Description |
|---|---|---|
| cargo_warning_example_count | 2 | Number of example warning blocks to show per rule |
| cargo_warning_group_threshold | 3 | Minimum occurrences before warnings are grouped |

## Chaining

After clippy-specific processing, output is chained to the `lint` processor (`chain_to = ["lint"]`). This allows any non-clippy-specific warnings in the output to be grouped by the generic lint rule parser.

## Fallback

If this processor is disabled, `cargo clippy` falls back to the `LintOutputProcessor` which handles it at a line-by-line level.
40 changes: 40 additions & 0 deletions docs/processors/maven_gradle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Maven/Gradle Processor

**File:** `src/processors/maven_gradle.py` | **Priority:** 28 | **Name:** `maven_gradle`

Dedicated processor for Maven and Gradle build output.

## Supported Commands

mvn, ./mvnw, gradle, ./gradlew (all subcommands).

## Strategy

### Maven

| Output Type | Strategy |
|---|---|
| **Download lines** | Strip `[INFO] Downloading from` and `[INFO] Downloaded from` lines. Show count |
| **Module lines** | Count `[INFO] Building module-name` lines |
| **Errors** | Keep all `[ERROR]` and `[FATAL]` lines |
| **Warnings** | Keep first 5 `[WARNING]` lines, summarize rest |
| **Test results** | Keep `Tests run: N, Failures: N` lines |
| **Reactor summary** | Keep reactor summary block |
| **Build result** | Keep `BUILD SUCCESS`/`BUILD FAILURE` and timing |

### Gradle

| Output Type | Strategy |
|---|---|
| **Task lines** | Strip `UP-TO-DATE`, `NO-SOURCE`, `SKIPPED`, `FROM-CACHE` tasks. Keep executed tasks. Show counts |
| **Errors** | Keep `FAILURE:` blocks, error details, `What went wrong` sections |
| **Test results** | Keep test result summary lines |
| **Build result** | Keep `BUILD SUCCESSFUL`/`BUILD FAILED` and actionable task summary |

## Configuration

No dedicated configuration keys. Uses default compression thresholds.

## Removed Noise

Maven: `[INFO] Downloading/Downloaded` lines, separator lines (`-----`), empty `[INFO]` lines. Gradle: `UP-TO-DATE`/`NO-SOURCE` task lines, progress indicators.
29 changes: 29 additions & 0 deletions docs/processors/python_install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Python Install Processor

**File:** `src/processors/python_install.py` | **Priority:** 24 | **Name:** `python_install`

Dedicated processor for Python package installation output.

## Supported Commands

pip install, pip3 install, poetry install/update/add, uv pip install, uv sync.

## Strategy

| Tool | Strategy |
|---|---|
| **pip install** | Strip `Collecting` and `Downloading` lines. Remove progress bars. Count packages installed. Show `already satisfied` count. Preserve all errors and warnings. Show installed package summary (first 10 + count) |
| **poetry install/update/add** | Strip `Resolving dependencies` progress. Count installed/updated/removed packages. Show package names with versions. Preserve errors |
| **uv pip install/sync** | Strip download progress. Keep `Resolved N packages` and `Installed N packages` summaries. Preserve errors |

## Exclusions

- `pip list` and `pip freeze` are routed to `PackageListProcessor`

## Configuration

No dedicated configuration keys. Uses default compression thresholds.

## Removed Noise

`Collecting X>=1.0` lines, `Downloading X-1.0.whl` lines, pip progress bars, `Installing collected packages:` line, `Using cached` lines, `Resolving dependencies...` output from poetry.
35 changes: 35 additions & 0 deletions docs/processors/structured_log.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Structured Log Processor

**File:** `src/processors/structured_log.py` | **Priority:** 45 | **Name:** `structured_log`

Processor for JSON Lines log output from log tailing tools.

## Supported Commands

stern, kubetail.

## Strategy

| Content Type | Strategy |
|---|---|
| **JSON Lines (>50% valid JSON)** | Parse each JSON object. Group entries by log level (error/warn/info/debug/trace). Show count per level. Extract and display error messages (up to 10). Detect level from common keys: `level`, `severity`, `log_level`, `lvl` |
| **Non-JSON output** | Fall back to log compression (head/tail with error preservation) |

## Level Detection

Checks these JSON keys in order: `level`, `severity`, `log_level`, `loglevel`, `lvl`, `log.level`. Falls back to regex matching on message content for `ERROR`/`WARN` patterns.

## Message Extraction

Checks these JSON keys in order: `msg`, `message`, `text`, `log`, `body`. Truncates messages longer than 200 characters.

## Configuration

| Parameter | Default | Description |
|---|---|---|
| kubectl_keep_head | 5 | Lines to keep from start (non-JSON fallback) |
| kubectl_keep_tail | 10 | Lines to keep from end (non-JSON fallback) |

## Future Use

This processor can be activated via `chain_to` from other processors for outputs that contain embedded JSON Lines.
4 changes: 4 additions & 0 deletions src/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@
"cargo_warning_example_count": 2,
"cargo_warning_group_threshold": 3,
"jq_passthrough_threshold": 50,
"disabled_processors": [],
"max_chain_depth": 3,
"debug": False,
}

Expand Down Expand Up @@ -129,6 +131,8 @@ def _load_config() -> dict[str, Any]:
elif isinstance(default_val, float):
with contextlib.suppress(ValueError):
config[key] = float(env_val)
elif isinstance(default_val, list):
config[key] = [s.strip() for s in env_val.split(",") if s.strip()]
else:
config[key] = env_val
config.setdefault("_config_source", {})[key] = f"env:{env_key}"
Expand Down
36 changes: 25 additions & 11 deletions src/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ class CompressionEngine:
_by_name: dict[str, Processor]

def __init__(self) -> None:
self.processors = discover_processors()
all_processors = discover_processors()
raw_disabled = config.get("disabled_processors") or []
disabled = set(raw_disabled if isinstance(raw_disabled, list) else [])
# Never disable generic — it's the fallback and provides clean()
disabled.discard("generic")
self.processors = [p for p in all_processors if p.name not in disabled]
self._generic = self.processors[-1] # Last = GenericProcessor (priority 999)
self._by_name = {p.name: p for p in self.processors}

Expand Down Expand Up @@ -51,16 +56,25 @@ def compress(self, command: str, output: str) -> tuple[str, str, bool]:
if compressed is output or compressed == output:
return output, processor.name, False

# Chain to secondary processor if declared (max depth = 1)
if (
processor.chain_to
and processor.chain_to != processor.name
and processor.chain_to in self._by_name
):
secondary = self._by_name[processor.chain_to]
chained = secondary.process(command, compressed)
if chained is not compressed and chained != compressed:
compressed = chained
# Chain to secondary processors if declared
chain_list = processor.chain_to
if chain_list:
if isinstance(chain_list, str):
chain_list = [chain_list]
max_depth = config.get("max_chain_depth")
visited = {processor.name}
depth = 0
for chain_name in chain_list:
if depth >= max_depth:
break
if chain_name in visited or chain_name not in self._by_name:
continue
secondary = self._by_name[chain_name]
visited.add(chain_name)
chained = secondary.process(command, compressed)
if chained is not compressed and chained != compressed:
compressed = chained
depth += 1

# If a specialized processor handled it, also run generic
# cleanup (ANSI strip, blank line collapse) but not truncation
Expand Down
8 changes: 7 additions & 1 deletion src/processors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,14 @@ def collect_hook_patterns() -> list[str]:
"""Collect all hook_patterns from discovered processors.

Returns a flat list of regex pattern strings, used by hook_pretool.py.
Disabled processors are excluded so their commands are not intercepted.
"""
from .. import config # noqa: PLC0415

raw_disabled = config.get("disabled_processors") or []
disabled = set(raw_disabled if isinstance(raw_disabled, list) else [])
patterns: list[str] = []
for processor in discover_processors():
patterns.extend(processor.hook_patterns)
if processor.name not in disabled:
patterns.extend(processor.hook_patterns)
return patterns
2 changes: 1 addition & 1 deletion src/processors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ class Processor(ABC):

priority: int = 50
hook_patterns: list[str] = []
chain_to: str | None = None
chain_to: str | list[str] | None = None

@abstractmethod
def can_handle(self, command: str) -> bool:
Expand Down
19 changes: 10 additions & 9 deletions src/processors/build_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@ class BuildOutputProcessor(Processor):
priority = 25
hook_patterns = [
r"^(npm\s+(run|install|build|ci|audit)|yarn\s+(run|install|build|add|audit)|pnpm\s+(run|install|build|add|audit))\b",
r"^(cargo\s+(build|check)|make\b|cmake\b|gradle\b|mvn\b|ant\b)",
r"^(pip3?\s+install|poetry\s+(install|update)|uv\s+(pip|sync))\b",
r"^(make|cmake|ant)\b",
r"^(tsc|webpack|vite(\s+build)?|esbuild|rollup|next\s+build|nuxt\s+build)\b",
r"^(turbo\s+run|turbo\s+build|nx\s+(run|build)|bazel\s+build|sbt\s|mix\s+compile)\b",
r"^(turbo\s+run|turbo\s+build|nx\s+(run|build)|bazel\s+build|sbt\b|mix\s+compile)\b",
r"^docker\s+(build|compose\s+build)\b",
r"^bun\s+(install|build|run)\b",
]
Expand All @@ -25,17 +24,19 @@ def can_handle(self, command: str) -> bool:
# Exclude package listing commands (handled by PackageListProcessor)
if re.search(r"\b(pip3?\s+(list|freeze)|npm\s+(ls|list)|conda\s+list)\b", command):
return False
# Exclude cargo clippy (handled by LintOutputProcessor)
if re.search(r"\bcargo\s+clippy\b", command):
# Exclude Python install (handled by PythonInstallProcessor)
if re.search(
r"\b(pip3?\s+install|poetry\s+(install|update|add)|uv\s+(pip\s+install|sync))\b",
command,
):
return False
# Exclude cargo build/check (handled by CargoProcessor)
if re.search(r"\bcargo\s+(build|check)\b", command):
# Exclude Maven/Gradle (handled by MavenGradleProcessor)
if re.search(r"\b(mvn|mvnw|gradle|gradlew)\b", command):
return False
return bool(
re.search(
r"\b(npm\s+(run|install|ci|build|audit)|yarn\s+(run|install|build|add|audit)|pnpm\s+(run|install|build|add|audit)|"
r"cargo\s+(build|check)|make\b|cmake\b|gradle\b|mvn\b|ant\b|"
r"pip3?\s+install|poetry\s+(install|update)|uv\s+(pip|sync)|"
r"make\b|cmake\b|ant\b|"
r"tsc\b|webpack\b|vite(\s+build)?|esbuild\b|rollup\b|next\s+build|nuxt\s+build|"
r"docker\s+(build|compose\s+build)|"
r"turbo\s+(run|build)|nx\s+(run|build)|bazel\s+build|sbt\b|mix\s+compile|"
Expand Down
Loading
Loading