diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2d56257..588c3d0 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f08d73e..7f44604 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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" diff --git a/pyproject.toml b/pyproject.toml index ee37ea8..c48b836 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "token-saver" -version = "2.2.1" +version = "2.3.0" requires-python = ">=3.10" [project.optional-dependencies] diff --git a/scripts/hook_pretool.py b/scripts/hook_pretool.py index 348f5a6..3ab190c 100644 --- a/scripts/hook_pretool.py +++ b/scripts/hook_pretool.py @@ -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 = [ @@ -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: @@ -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(): diff --git a/src/__init__.py b/src/__init__.py index 4fbe162..d30153c 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,6 +1,6 @@ import os -__version__ = "2.2.1" +__version__ = "2.3.0" def data_dir() -> str: diff --git a/src/processors/base.py b/src/processors/base.py index 67c8f59..84fe1cd 100644 --- a/src/processors/base.py +++ b/src/processors/base.py @@ -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. diff --git a/src/processors/build_output.py b/src/processors/build_output.py index 55ca72c..f190cc4 100644 --- a/src/processors/build_output.py +++ b/src/processors/build_output.py @@ -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 @@ -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, ) ) diff --git a/src/processors/lint_output.py b/src/processors/lint_output.py index 8577ffb..73ce196 100644 --- a/src/processors/lint_output.py +++ b/src/processors/lint_output.py @@ -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 @@ -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, ) ) diff --git a/src/processors/python_install.py b/src/processors/python_install.py index 739b19c..b819f29 100644 --- a/src/processors/python_install.py +++ b/src/processors/python_install.py @@ -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") @@ -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 diff --git a/src/processors/test_output.py b/src/processors/test_output.py index 3160e1c..77bed9e 100644 --- a/src/processors/test_output.py +++ b/src/processors/test_output.py @@ -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 @@ -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, ) ) @@ -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) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 0284ff2..c6425c0 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -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 .")