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
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"name": "token-saver",
"source": "./",
"description": "Automatically compresses verbose CLI output to save tokens. 21 specialized processors for git, docker, npm, terraform, kubectl, helm, ansible, and more.",
"version": "2.2.1",
"version": "2.3.0",
"author": {
"name": "ppgranger"
},
Expand Down
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "token-saver",
"description": "Automatically compresses verbose CLI output (git, docker, npm, terraform, kubectl, etc.) to save tokens in Claude Code sessions. 21 specialized processors with content-aware compression.",
"version": "2.2.1",
"version": "2.3.0",
"author": {
"name": "ppgranger",
"url": "https://github.com/ppgranger"
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "token-saver"
version = "2.2.1"
version = "2.3.0"
requires-python = ">=3.10"

[project.optional-dependencies]
Expand Down
25 changes: 22 additions & 3 deletions scripts/hook_pretool.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,18 @@ def _load_compressible_patterns() -> list[str]:

COMPILED_EXCLUDED = [re.compile(p) for p in EXCLUDED_PATTERNS]

# Strip leading path prefix so '/usr/bin/git status' → 'git status',
# './node_modules/.bin/jest' → 'jest', '.venv/bin/pip' → 'pip', etc.
# Greedy match: captures everything up to and including the last '/'
# in the first token (before any space).
_PATH_PREFIX_RE = re.compile(r"^(\S*/)(?=\S)")


def _normalize_cmd(cmd: str) -> str:
"""Strip leading path prefix for pattern matching."""
return _PATH_PREFIX_RE.sub("", cmd)


# Per-segment safety checks applied inside _is_chain_compressible().
# These catch dangerous constructs within individual chain segments.
_SEGMENT_EXCLUDED_PATTERNS = [
Expand Down Expand Up @@ -138,8 +150,11 @@ def _is_chain_compressible(command: str) -> bool:
check_seg = _SAFE_TRAILING_PIPE_RE.sub("", seg) if i == len(segments) - 1 else seg
if not _is_segment_safe(check_seg):
return False
is_silent = bool(SILENT_CMDS_RE.match(check_seg))
is_comp = any(p.search(check_seg) for p in COMPILED_PATTERNS)
norm_seg = _normalize_cmd(check_seg)
is_silent = bool(SILENT_CMDS_RE.match(check_seg)) or bool(SILENT_CMDS_RE.match(norm_seg))
is_comp = any(p.search(check_seg) for p in COMPILED_PATTERNS) or any(
p.search(norm_seg) for p in COMPILED_PATTERNS
)
if not is_silent and not is_comp:
return False # unknown command in chain -> reject
if is_comp:
Expand Down Expand Up @@ -176,7 +191,11 @@ def is_compressible(command: str) -> bool:
for pattern in COMPILED_EXCLUDED:
if pattern.search(check_cmd):
return False
return any(pattern.search(check_cmd) for pattern in COMPILED_PATTERNS)
# Try original first, then path-normalized version
norm_cmd = _normalize_cmd(check_cmd)
return any(pattern.search(check_cmd) for pattern in COMPILED_PATTERNS) or any(
pattern.search(norm_cmd) for pattern in COMPILED_PATTERNS
)


def main():
Expand Down
2 changes: 1 addition & 1 deletion src/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import os

__version__ = "2.2.1"
__version__ = "2.3.0"


def data_dir() -> str:
Expand Down
4 changes: 4 additions & 0 deletions src/processors/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

from abc import ABC, abstractmethod

# Matches any Python invocation: python, python3, python3.11,
# .venv/bin/python3, /usr/bin/python, etc.
PYTHON_CMD = r"(?:\S+/)?python[23]?(?:\.\d+)?"


class Processor(ABC):
"""Base class for all output processors.
Expand Down
4 changes: 3 additions & 1 deletion src/processors/build_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class BuildOutputProcessor(Processor):
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",
r"^npx\s+(webpack|vite|esbuild|tsc|next\s+build|nuxt\s+build|turbo\s+run)\b",
]

@property
Expand All @@ -40,7 +41,8 @@ def can_handle(self, command: str) -> bool:
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|"
r"bun\s+(install|build|run))\b",
r"bun\s+(install|build|run)|"
r"npx\s+(webpack|vite|esbuild|tsc|next\s+build|nuxt\s+build|turbo\s+run))\b",
command,
)
)
Expand Down
13 changes: 9 additions & 4 deletions src/processors/lint_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,17 @@
from collections import defaultdict

from .. import config
from .base import Processor
from .base import PYTHON_CMD, Processor


class LintOutputProcessor(Processor):
priority = 27
hook_patterns = [
r"^(eslint|ruff(\s+check)?|flake8|pylint|rubocop|golangci-lint|stylelint|biome\s+(check|lint))\b",
r"^python3?\s+-m\s+(flake8|pylint|ruff|mypy)\b",
rf"^{PYTHON_CMD}\s+-m\s+(flake8|pylint|ruff|mypy)\b",
r"^(mypy|prettier\s+--check|shellcheck|hadolint|tflint|ktlint|swiftlint)\b",
r"^(oxlint|deno\s+lint)\b",
r"^(npx\s+(eslint|prettier|stylelint|biome)\b|poetry\s+run\s+(flake8|pylint|ruff|mypy)\b|uv\s+run\s+(flake8|pylint|ruff|mypy|ruff\s+check)\b|bundle\s+exec\s+rubocop\b)",
]

@property
Expand All @@ -25,9 +26,13 @@ def can_handle(self, command: str) -> bool:
re.search(
r"\b(eslint|ruff(\s+check)?|flake8|pylint|clippy|rubocop|"
r"golangci-lint|stylelint|prettier\s+--check|biome\s+(check|lint)|"
r"python3?\s+-m\s+(flake8|pylint|ruff|mypy)|mypy|"
rf"{PYTHON_CMD}\s+-m\s+(flake8|pylint|ruff|mypy)|mypy|"
r"shellcheck|hadolint|tflint|ktlint|swiftlint|cargo\s+clippy|"
r"oxlint|deno\s+lint)\b",
r"oxlint|deno\s+lint|"
r"npx\s+(eslint|prettier|stylelint|biome)|"
r"poetry\s+run\s+(flake8|pylint|ruff|mypy)|"
r"uv\s+run\s+(flake8|pylint|ruff|mypy|ruff\s+check)|"
r"bundle\s+exec\s+rubocop)\b",
command,
)
)
Expand Down
5 changes: 3 additions & 2 deletions src/processors/python_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

import re

from .base import Processor
from .base import PYTHON_CMD, Processor

_PIP_INSTALL_RE = re.compile(r"\bpip3?\s+install\b")
_PIP_INSTALL_RE = re.compile(rf"\bpip3?\s+install\b|{PYTHON_CMD}\s+-m\s+pip\s+install\b")
_POETRY_RE = re.compile(r"\bpoetry\s+(install|update|add)\b")
_UV_RE = re.compile(r"\buv\s+(pip\s+install|sync)\b")

Expand All @@ -30,6 +30,7 @@ class PythonInstallProcessor(Processor):
priority = 24
hook_patterns = [
r"^(pip3?\s+install|poetry\s+(install|update|add)|uv\s+(pip\s+install|sync))\b",
rf"^{PYTHON_CMD}\s+-m\s+pip\s+install\b",
]

@property
Expand Down
24 changes: 17 additions & 7 deletions src/processors/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@
import re

from .. import config
from .base import Processor
from .base import PYTHON_CMD, Processor


class TestOutputProcessor(Processor):
priority = 21
hook_patterns = [
r"^(pytest|py\.test|python3?\s+-m\s+pytest|jest|mocha|vitest|cargo\s+test|go\s+test|rspec|phpunit|bun\s+test|dotnet\s+test|swift\s+test|mix\s+test)\b",
rf"^(pytest|py\.test|{PYTHON_CMD}\s+-m\s+pytest|jest|mocha|vitest|cargo\s+test|go\s+test|rspec|phpunit|bun\s+test|dotnet\s+test|swift\s+test|mix\s+test)\b",
r"^(npm\s+test|yarn\s+test|pnpm\s+test)\b",
r"^(npx\s+(jest|mocha|vitest|playwright)\b|poetry\s+run\s+(pytest|py\.test)\b|uv\s+run\s+(pytest|py\.test)\b|pipx\s+run\s+pytest\b|bundle\s+exec\s+(rspec|rails\s+test)\b)",
]

@property
Expand All @@ -20,10 +21,13 @@ def name(self) -> str:
def can_handle(self, command: str) -> bool:
return bool(
re.search(
r"\b(pytest|py\.test|python3?\s+-m\s+pytest|jest|mocha|"
rf"\b(pytest|py\.test|{PYTHON_CMD}\s+-m\s+pytest|jest|mocha|"
r"cargo\s+test|go\s+test|rspec|phpunit|vitest|bun\s+test|"
r"npm\s+test|yarn\s+test|pnpm\s+test|"
r"dotnet\s+test|swift\s+test|mix\s+test)\b",
r"dotnet\s+test|swift\s+test|mix\s+test|"
r"npx\s+(jest|mocha|vitest|playwright)|"
r"poetry\s+run\s+(pytest|py\.test)|uv\s+run\s+(pytest|py\.test)|"
r"pipx\s+run\s+pytest|bundle\s+exec\s+(rspec|rails\s+test))\b",
command,
)
)
Expand All @@ -34,17 +38,23 @@ def process(self, command: str, output: str) -> str:

lines = output.splitlines()

if re.search(r"\bpytest\b|py\.test|python3?\s+-m\s+pytest", command):
if re.search(
rf"\bpytest\b|py\.test|{PYTHON_CMD}\s+-m\s+pytest"
r"|\b(poetry|uv|pipx)\s+run\s+pytest",
command,
):
return self._process_pytest(lines)
if re.search(
r"\bjest\b|\bvitest\b|\bnpm\s+test\b|\byarn\s+test\b|\bpnpm\s+test\b", command
r"\bjest\b|\bvitest\b|\bnpm\s+test\b|\byarn\s+test\b"
r"|\bpnpm\s+test\b|\bnpx\s+(jest|vitest)\b",
command,
):
return self._process_jest(lines)
if re.search(r"\bcargo\s+test\b", command):
return self._process_cargo_test(lines)
if re.search(r"\bgo\s+test\b", command):
return self._process_go_test(lines)
if re.search(r"\brspec\b", command):
if re.search(r"\brspec\b|\bbundle\s+exec\s+rspec\b", command):
return self._process_rspec(lines)
if re.search(r"\bdotnet\s+test\b", command):
return self._process_dotnet_test(lines)
Expand Down
127 changes: 127 additions & 0 deletions tests/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,3 +597,130 @@ def test_jq_compressible(self):
def test_yq_compressible(self):
assert is_compressible("yq . config.yaml")
assert is_compressible("yq eval '.spec' deployment.yaml")


class TestPathPrefixNormalization:
"""Commands invoked via full or relative paths should still be detected."""

def test_absolute_path_git(self):
assert is_compressible("/usr/bin/git status")
assert is_compressible("/usr/local/bin/git log --oneline")

def test_absolute_path_npm(self):
assert is_compressible("/usr/local/bin/npm install")
assert is_compressible("/usr/local/bin/npm test")

def test_venv_path_pip(self):
assert is_compressible(".venv/bin/pip install flask")
assert is_compressible(".venv/bin/pip3 install -r requirements.txt")
assert is_compressible(".venv/bin/pip list")

def test_venv_path_pytest(self):
assert is_compressible(".venv/bin/pytest tests/")

def test_node_modules_path(self):
assert is_compressible("./node_modules/.bin/jest --coverage")
assert is_compressible("./node_modules/.bin/eslint src/")
assert is_compressible("./node_modules/.bin/tsc")

def test_relative_path(self):
assert is_compressible("./bin/ruff check .")

def test_nvm_path(self):
assert is_compressible("/home/user/.nvm/versions/node/v18/bin/npm run build")

def test_cargo_path(self):
assert is_compressible("/home/user/.cargo/bin/cargo build")
assert is_compressible("/home/user/.cargo/bin/cargo test")

def test_pyenv_path(self):
assert is_compressible("/home/user/.pyenv/shims/pip install flask")

def test_vendor_path(self):
assert is_compressible("./vendor/bin/phpunit tests/")

def test_path_prefix_in_chain(self):
assert is_compressible("cd /project && /usr/bin/git status")
assert is_compressible("cd /project && .venv/bin/pytest tests/")

def test_path_prefix_with_trailing_pipe(self):
assert is_compressible("/usr/bin/git log --oneline | head -20")
assert is_compressible(".venv/bin/pip list | grep torch")


class TestWrapperRunners:
"""Commands invoked via wrapper runners (npx, poetry run, uv run, etc.)."""

# --- npx ---
def test_npx_jest(self):
assert is_compressible("npx jest --coverage")
assert is_compressible("npx jest tests/")

def test_npx_vitest(self):
assert is_compressible("npx vitest run")

def test_npx_mocha(self):
assert is_compressible("npx mocha tests/")

def test_npx_playwright(self):
assert is_compressible("npx playwright test")

def test_npx_eslint(self):
assert is_compressible("npx eslint src/")
assert is_compressible("npx eslint --fix src/")

def test_npx_prettier(self):
assert is_compressible("npx prettier --check src/")

def test_npx_build_tools(self):
assert is_compressible("npx webpack")
assert is_compressible("npx vite build")
assert is_compressible("npx tsc")
assert is_compressible("npx next build")
assert is_compressible("npx turbo run build")

# --- poetry run ---
def test_poetry_run_pytest(self):
assert is_compressible("poetry run pytest tests/")
assert is_compressible("poetry run pytest -v")

def test_poetry_run_lint(self):
assert is_compressible("poetry run flake8 src/")
assert is_compressible("poetry run pylint src/")
assert is_compressible("poetry run ruff check .")
assert is_compressible("poetry run mypy src/")

# --- uv run ---
def test_uv_run_pytest(self):
assert is_compressible("uv run pytest tests/")
assert is_compressible("uv run pytest -v")

def test_uv_run_lint(self):
assert is_compressible("uv run flake8 src/")
assert is_compressible("uv run pylint src/")
assert is_compressible("uv run ruff check .")
assert is_compressible("uv run mypy src/")

# --- pipx run ---
def test_pipx_run_pytest(self):
assert is_compressible("pipx run pytest tests/")

# --- bundle exec ---
def test_bundle_exec_rspec(self):
assert is_compressible("bundle exec rspec spec/")

def test_bundle_exec_rubocop(self):
assert is_compressible("bundle exec rubocop")

# --- python -m pip install ---
def test_python_m_pip_install(self):
assert is_compressible("python -m pip install flask")
assert is_compressible("python3 -m pip install -r requirements.txt")
assert is_compressible("python3.11 -m pip install flask")
assert is_compressible(".venv/bin/python -m pip install flask")

# --- Wrapper in chain ---
def test_wrapper_in_chain(self):
assert is_compressible("cd /project && npx jest --coverage")
assert is_compressible("cd /project && poetry run pytest tests/")
assert is_compressible("cd /project && uv run ruff check .")
Loading