From 0a236130ccb30a16f27246b423f67d58eb8afc71 Mon Sep 17 00:00:00 2001 From: lucazagaia Date: Tue, 29 Sep 2026 18:31:28 +0200 Subject: [PATCH] feat: install with uv tool install; refuse to wire hooks from uvx `uv tool install skill-plus-plus` already works like pipx: the console script lands in uv's bin folder and `install` writes that lasting path into the hooks. The README and the usage page now say so, with upgrade and uninstall. `uvx skill-plus-plus install` would write hooks into uv's cache (~/.cache/uv/archive-v0//), which `uv cache clean` and `prune` delete, so the hooks would stop running with nothing said. `install --apply` now refuses that, and the dry run says it would. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ README.md | 13 +++++++++---- docs/usage.md | 14 ++++++++++---- skill_plus_plus/cli.py | 27 ++++++++++++++++++++------- skill_plus_plus/install.py | 30 +++++++++++++++++++++++++----- tests/test_skill_plus_plus.py | 14 ++++++++++++++ 6 files changed, 81 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84e5266..4f095b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ All notable changes to Skill++. The format follows `skill-plus-plus edit-skill` does the same from the terminal. Right after **Apply**, **Undo** puts the skill back as it was, until your next action or a reload of the page. +- Installing with uv: `uv tool install skill-plus-plus`, documented next to + pipx. `install` refuses to write hooks from `uvx`, whose temporary copy uv + deletes when it cleans its cache. ### Changed diff --git a/README.md b/README.md index a7ec872..535911f 100644 --- a/README.md +++ b/README.md @@ -133,11 +133,12 @@ it. ## ⚡ Quick Start You need macOS or Linux (Windows isn't supported yet; WSL may work but is -untested), Python 3.10+, [Claude Code](https://docs.anthropic.com/en/docs/claude-code) -logged in, and [Ollama](https://ollama.com) running (`brew install ollama` on a Mac). +untested), Python 3.10+ (or [uv](https://docs.astral.sh/uv/), which brings its +own), [Claude Code](https://docs.anthropic.com/en/docs/claude-code) logged in, +and [Ollama](https://ollama.com) running (`brew install ollama` on a Mac). ```bash -pipx install skill-plus-plus +pipx install skill-plus-plus # or: uv tool install skill-plus-plus skill-plus-plus install --project ~/code/my-repo --apply # hooks, slash commands and the two local models skill-plus-plus doctor # everything green? ``` @@ -160,7 +161,11 @@ new Code session in the desktop app) and work as usual. - `skill-plus-plus install --project ~/code/my-repo --remove --apply` takes the hooks and slash commands out again. - The latest `main`, before it is released: - `pipx install git+https://github.com/skill-plus-plus-org/skill-plus-plus`. + `pipx install git+https://github.com/skill-plus-plus-org/skill-plus-plus`, + or the same URL with `uv tool install`. +- With uv, install it as a tool (`uv tool install`), not with `uvx`: `uvx` + runs a temporary copy that uv deletes when it cleans its cache, and the + hooks would go with it, so `install` refuses to run from there. - From a clone: `pip install -e .`, or run `python3 bin/skill-plus-plus` without installing anything. diff --git a/docs/usage.md b/docs/usage.md index 2ec2754..3946cad 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -27,11 +27,17 @@ worker after the session, not while you work. ## Installing ```bash -pipx install skill-plus-plus +pipx install skill-plus-plus # or: uv tool install skill-plus-plus ``` The latest `main`, before it is released: -`pipx install git+https://github.com/skill-plus-plus-org/skill-plus-plus`. +`pipx install git+https://github.com/skill-plus-plus-org/skill-plus-plus`, or the +same URL with `uv tool install`. Upgrade later with `pipx upgrade skill-plus-plus` +or `uv tool upgrade skill-plus-plus`. + +With uv, install it as a tool, not with `uvx`. `uvx` runs a temporary copy from +uv's cache, which `uv cache clean` deletes; hooks written from it would stop +running with nothing said, so `install` refuses to write them from there. Then choose where the hooks go. Every form is a dry run that prints the exact change until you add `--apply`, and an existing settings file is backed up @@ -62,7 +68,7 @@ skill-plus-plus doctor whether Ollama answers and has both models, how many sessions are waiting to be banked, and what the ledger holds. -From a clone instead of `pipx`, `pip install -e .` puts `skill-plus-plus` on your PATH +From a clone instead of `pipx` or uv, `pip install -e .` puts `skill-plus-plus` on your PATH the same way; `python3 bin/skill-plus-plus` works without installing anything. ## Daily use @@ -318,7 +324,7 @@ do nothing for it. ```bash skill-plus-plus install --project ~/code/my-repo --remove --apply # or --user rm -rf ~/.claude/skill-plus-plus/ # the ledger, sessions and drafts -pipx uninstall skill-plus-plus +pipx uninstall skill-plus-plus # or: uv tool uninstall skill-plus-plus ``` `--remove` takes out only Skill++'s own hook entries and the slash commands it diff --git a/skill_plus_plus/cli.py b/skill_plus_plus/cli.py index ae00e66..746e2ab 100644 --- a/skill_plus_plus/cli.py +++ b/skill_plus_plus/cli.py @@ -142,10 +142,10 @@ def _agent_home(entry_id: str) -> Path: prompt relies on, `/skill-plus-plus-draft` and `python3 bin/skill-plus-plus`. An installed package has neither beside it, so the agent cannot run from - this checkout: a `pipx` install could draft nothing. The `bin/skill-plus-plus` - written here runs whichever skill-plus-plus started the agent. Kept apart from the - draft workspace, because everything beside a written SKILL.md is collected - into the draft. + this checkout: a `pipx` or `uv tool` install could draft nothing. The + `bin/skill-plus-plus` written here runs whichever skill-plus-plus started + the agent. Kept apart from the draft workspace, because everything beside a + written SKILL.md is collected into the draft. """ from .install import COMMANDS home = Path(tempfile.mkdtemp(prefix=f"skill-plus-plus-agent-{entry_id[:8]}-")) @@ -1787,7 +1787,8 @@ def _usable_interpreter(python: str | None) -> str: def cmd_install(args: argparse.Namespace) -> int: from .install import (apply_settings, hook_command, install_command_files, - plan_removal, plan_settings, remove_command_files) + plan_removal, plan_settings, remove_command_files, + temporary_environment) target = _settings_target(args) if target is None: @@ -1812,8 +1813,11 @@ def cmd_install(args: argparse.Namespace) -> int: return 1 print(f"settings file : {settings_path}") + temporary = "" if not args.remove: - print(f"hook command : {hook_command(args.python)}") + command = hook_command(args.python) + temporary = temporary_environment(command) + print(f"hook command : {command}") print("planned changes:") for change in changes: print(f" - {change}") @@ -1821,7 +1825,10 @@ def cmd_install(args: argparse.Namespace) -> int: models = not args.remove and not args.no_models if not args.apply: print("\nDry run. Nothing was written.") - if not args.remove: + if temporary: + # Copying the block by hand would wire the same short-lived path. + print(f"--apply would refuse: {temporary}") + elif not args.remove: print("Re-run with --apply to install, or copy the hooks block below " "into your settings manually:\n") print(json.dumps({"hooks": merged.get("hooks", {})}, indent=2)) @@ -1829,6 +1836,12 @@ def cmd_install(args: argparse.Namespace) -> int: _install_models(Config(args.root), apply=False) return 0 + # Ahead of the "already in that state" check: hooks wired from uvx before + # this refusal existed are no reason to wire them again. + if temporary: + print(f"\nrefusing to write: {temporary}", file=sys.stderr) + return 1 + settled = ("no change", "nothing to remove", "skill-plus-plus is not wired") if all(change.endswith("no change") or change.startswith(settled[1:]) for change in changes): diff --git a/skill_plus_plus/install.py b/skill_plus_plus/install.py index 102bf7d..d85dd0e 100644 --- a/skill_plus_plus/install.py +++ b/skill_plus_plus/install.py @@ -10,6 +10,7 @@ import json import os +import re import shutil from datetime import datetime, timezone from pathlib import Path @@ -37,11 +38,11 @@ def hook_command(python: str | None = None, package_root: Path | None = None, """The shell command Claude Code will run for each hook event. From a checkout, ``PYTHONPATH`` makes ``-m skill_plus_plus`` importable without - installing the package. Installed (``pipx install``), the console script is - used instead: its path survives an upgrade, while a ``PYTHONPATH`` into a - venv's ``site-packages`` names the Python version and breaks with it. The - ledger root is deliberately *not* passed, so it defaults to - ``~/.claude/skill-plus-plus`` rather than landing inside the repo. + installing the package. Installed (``pipx install`` or ``uv tool install``), + the console script is used instead: its path survives an upgrade, while a + ``PYTHONPATH`` into a venv's ``site-packages`` names the Python version and + breaks with it. The ledger root is deliberately *not* passed, so it defaults + to ``~/.claude/skill-plus-plus`` rather than landing inside the repo. The interpreter is looked up on PATH rather than pinned. This used to write ``sys.executable`` — on the machine this was developed on, that is @@ -62,6 +63,25 @@ def hook_command(python: str | None = None, package_root: Path | None = None, return f'PYTHONPATH="{root}" {interpreter} -m skill_plus_plus hook' +# uvx runs a tool from a temporary environment in uv's cache +# (`~/.cache/uv/archive-v0//`), which `uv cache clean` and `uv cache prune` +# delete. A hook pointing there stops running with nothing said. +_UV_CACHE = re.compile(r"[/\\]archive-v\d+[/\\]") + + +def temporary_environment(command: str) -> str: + """Empty if *command* will still be there tomorrow, else why it may not. + + `uvx skill-plus-plus install` would write hooks into uv's cache; `uv tool + install skill-plus-plus` puts the same command where it lasts, like pipx. + """ + if _UV_CACHE.search(command): + return ("this copy runs from uvx's temporary environment, which uv deletes " + "when it cleans its cache; install it with `uv tool install " + "skill-plus-plus` and run `skill-plus-plus install` from there") + return "" + + def desired_hooks(python: str | None = None, package_root: Path | None = None) -> dict: cmd = hook_command(python, package_root) return { diff --git a/tests/test_skill_plus_plus.py b/tests/test_skill_plus_plus.py index 6dbc61c..9441e20 100644 --- a/tests/test_skill_plus_plus.py +++ b/tests/test_skill_plus_plus.py @@ -1517,6 +1517,20 @@ def test_hook_command_does_not_redirect_the_ledger_into_the_repo(self): self.assertIn('PYTHONPATH="/repo"', cmd) self.assertNotIn("--root", cmd) + def test_a_hook_into_uvxs_cache_is_refused_and_a_lasting_one_is_not(self): + """uvx runs from uv's cache, which `uv cache clean` deletes; `uv tool + install` and pipx put the command where it lasts.""" + from skill_plus_plus.install import hook_command, temporary_environment + cache = "/Users/dev/.cache/uv/archive-v0/QKhUDXGOV2kEM9Bb" + uvx = hook_command(package_root=Path(f"{cache}/lib/python3.12/site-packages"), + script=f"{cache}/bin/skill-plus-plus") + self.assertIn("uv tool install", temporary_environment(uvx)) + self.assertIn("uv tool install", temporary_environment( + f'PYTHONPATH="{cache}/lib/python3.12/site-packages" python3 -m skill_plus_plus hook')) + for lasting in ("/Users/dev/.local/bin/skill-plus-plus hook", + 'PYTHONPATH="/Users/dev/code/skill-plus-plus" python3 -m skill_plus_plus hook'): + self.assertEqual(temporary_environment(lasting), "", lasting) + def test_plan_preserves_existing_hooks(self): from skill_plus_plus.install import plan_settings with tempfile.TemporaryDirectory() as tmp: