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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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?
```
Expand All @@ -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.

Expand Down
14 changes: 10 additions & 4 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand 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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 20 additions & 7 deletions skill_plus_plus/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]}-"))
Expand Down Expand Up @@ -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:
Expand All @@ -1812,23 +1813,35 @@ 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}")

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))
if models:
_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):
Expand Down
30 changes: 25 additions & 5 deletions skill_plus_plus/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import json
import os
import re
import shutil
from datetime import datetime, timezone
from pathlib import Path
Expand Down Expand Up @@ -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
Expand All @@ -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/<hash>/`), 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 {
Expand Down
14 changes: 14 additions & 0 deletions tests/test_skill_plus_plus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading