diff --git a/docs/workpad/goals/uv_detection.md b/docs/workpad/goals/uv_detection.md index 551707f..8afd859 100644 --- a/docs/workpad/goals/uv_detection.md +++ b/docs/workpad/goals/uv_detection.md @@ -25,3 +25,5 @@ - it should also therefore get the idea of where a local scratch pad (SCRIPTS_STORE), project scripts folder (SCRIPTS_DIR), etc. might exist, and can therefore substitute this also as knowledge if it has to work with other tools, that want files or scripts written, but do not define where a certain folder is. These directories build good fallbacks for such concerns, and python therefore a solid tool to support the developer in tasks. - for full python projects, it is very likely, that the configured uv is also capable of running actual project code, so namespaces should be respected. - for rust projects, the uv configuration is almost certainly the local python environment for the agent. + +- best would be if we would simply run a universal shell that every skill should support and print out env variables that are maybe relevant immediately. diff --git a/plugins/project-uv/skills/project-uv/SKILL.md b/plugins/project-uv/skills/project-uv/SKILL.md index 7de8cc0..e603f95 100644 --- a/plugins/project-uv/skills/project-uv/SKILL.md +++ b/plugins/project-uv/skills/project-uv/SKILL.md @@ -1,67 +1,44 @@ --- name: project-uv -description: Use whenever you are about to run, install, or modify Python code in a project whose root contains a `pyproject.toml` — especially before invoking `python`, `pip`, `uv run`, or any test/lint command. Detects how uv is wired up here (project vs script mode, lockfile, declared scripts, src/ layout, tool table) and tells you the right invocation pattern *for this repo* before you guess. Trigger words: "run this script", "install this dependency", "test", "sync", any mention of `uv`, `pip`, `python -m`, or running a `.py` file. +description: Probe for python once per conversation, before the first `python`, `pip`, `uv run`, or `.py` invocation. Skip if the correct uv invocation for this repo is already established in the conversation. +globs: + - pyproject.toml --- # project-uv -The point of this skill is to **stop guessing how to run Python in this project**. `uv` supports several distinct workflows (project-managed, script-with-inline-deps, ad-hoc venv) and the right invocation differs accordingly. Probe first, then run. +Probe once per conversation. Read keys, don't guess. -## Activation +## Run -This skill applies when the current project root contains a `pyproject.toml`. If there is no `pyproject.toml`, this skill is not relevant — fall back to whatever Python tooling the user already established. +POSIX: `sh "$CLAUDE_PLUGIN_ROOT/skills/project-uv/scripts/probe.sh"` +Windows: `& "$env:CLAUDE_PLUGIN_ROOT\skills\project-uv\scripts\probe.ps1"` -## Step 0: Probe the project +Successful execution is the proof python works here. If it fails, stop and report. -**MANDATORY FIRST STEP** when this skill is invoked for the first time in a conversation. Run the probe script: +## Env-var contract -```bash -uv run --no-project python ${CLAUDE_PLUGIN_ROOT}/skills/project-uv/scripts/probe.py -``` +- `SCRIPTS_UV` — path to the `uv` executable. +- `SCRIPTS_DIR` — project script dir (default `scripts/`). +- `SCRIPTS_STORE` — scratch dir for throwaway scripts (default: platform temp). -(Or copy `scripts/probe.py` and run it directly with any available Python — it has no dependencies.) +Output echoes each as a key — empty value means unset or invalid. Use the `uv=` value if `scripts_uv=` is empty. -The probe prints a short structured report describing: +## Acting on `mode` -- whether `uv` is installed and its version -- the project mode: managed project (`[project]` table) vs PEP 723 script mode vs neither -- whether a `uv.lock` exists and is up to date -- whether `[tool.uv]` is configured, with notable keys -- declared scripts: `[project.scripts]` console entrypoints -- src layout: presence of `src/`, top-level packages -- presence of sibling tooling: `Justfile`, `Makefile`, `mise.toml`, `.python-version` +- `managed-project-locked` — `uv run `; `uv sync` if `.venv` missing. +- `managed-project-unlocked` — `uv run `; `uv sync` to create env. +- `pep723-only` — `uv run path/to/script.py`; edit `# /// script` block for deps. +- `uv-tool-only` — `[tool.uv]` without `[project]`; ask before assuming. +- `agent-tool-only` — uv exists but no python project; use for agent scratchpad in `scripts_store`. +- `none` — no python here; stop. -Treat the probe output as ground truth for the rest of the conversation. Don't re-probe unless files relevant to the probe have changed. +## Other keys -## Invocation rules of thumb +- `venv_tainted=true` — warn the user; uv isn't fully embraced here. +- `recipes=` — if a `just:`/`make:` recipe covers the task, prefer it. +- `default_packages=` — already importable; no install needed. -Once you know the project mode, apply these defaults: +## Out of scope -**Managed project (`[project]` table present, optionally with `uv.lock`):** -- Sync first if `uv.lock` is out of date or `.venv` is missing: `uv sync` -- Run anything inside the env via `uv run`, e.g. `uv run pytest`, `uv run python -m mypkg.cli` -- Add deps with `uv add `, not `pip install` -- Declared console scripts are runnable as `uv run ` - -**PEP 723 inline-deps script:** -- Run with `uv run path/to/script.py` — uv will install inline deps in an ephemeral env -- Do not `uv add` to a non-project file; edit the `# /// script` block instead - -**Neither (raw `pyproject.toml` with no `[project]` and no inline deps):** -- The user probably has another build backend or only metadata. Ask before assuming. - -## Composing with other tooling - -If the probe shows a `Justfile` / `Makefile`, **prefer the recipe over the raw uv command** whenever a recipe exists for the task — that's the user's preferred entry point. Only fall through to raw `uv run` when no recipe covers it. - -If the probe shows `mise.toml` and the agent has access to `mise`, defer to mise for tool versions (Python version in particular). - -## What this skill does NOT do - -- It does not install `uv` for the user. If uv is missing, report that and stop. -- It does not modify `pyproject.toml`. Adding/removing deps is a separate, user-driven decision — surface the recommended command, let the user accept. -- It does not pick a Python version. That's `.python-version` / `mise.toml` / `[tool.uv]` territory. - -## Future granularity (pilot note) - -This skill is intentionally narrow. Companion skills like `run-uv-with-just` are planned: they would activate only when both `pyproject.toml` and `Justfile` are present, and would teach the agent the just-recipe vocabulary in this specific repo. The granularity is meant to compose, not duplicate. +Does not install uv, modify `pyproject.toml`, or pick a Python version. diff --git a/plugins/project-uv/skills/project-uv/scripts/probe.ps1 b/plugins/project-uv/skills/project-uv/scripts/probe.ps1 new file mode 100644 index 0000000..a38bfdd --- /dev/null +++ b/plugins/project-uv/skills/project-uv/scripts/probe.ps1 @@ -0,0 +1,21 @@ +$ErrorActionPreference = "Stop" +$probe = Join-Path $PSScriptRoot "probe.py" + +if ($env:SCRIPTS_UV -and (Test-Path $env:SCRIPTS_UV)) { + & $env:SCRIPTS_UV run --no-project python $probe + exit $LASTEXITCODE +} +$uv = Get-Command uv -ErrorAction SilentlyContinue +if ($uv) { + & $uv.Source run --no-project python $probe + exit $LASTEXITCODE +} +foreach ($name in @("python", "python3", "py")) { + $cmd = Get-Command $name -ErrorAction SilentlyContinue + if ($cmd) { + & $cmd.Source $probe + exit $LASTEXITCODE + } +} +Write-Error "probe: no python found" +exit 1 diff --git a/plugins/project-uv/skills/project-uv/scripts/probe.py b/plugins/project-uv/skills/project-uv/scripts/probe.py index e3d2113..5655c40 100644 --- a/plugins/project-uv/skills/project-uv/scripts/probe.py +++ b/plugins/project-uv/skills/project-uv/scripts/probe.py @@ -1,149 +1,301 @@ #!/usr/bin/env python3 -"""Probe a Python project to detect how uv is wired up. +"""project-uv probe: detect uv wiring and prove python works here. -Run from the project root (or pass --root). Output is a short structured -report intended for an LLM agent to read before suggesting uv commands. +Stdlib only, targets Python 3.7+. No TOML parser — pyproject.toml is +line-grepped for the handful of facts we need, so this probe runs on any +Python the launcher can reach. Successful execution is itself the proof +that python works in this project. -No third-party dependencies; stdlib only so it can run with `uv run --no-project` -or any system Python. +Output: KEY=VALUE lines on stdout. One key per line. Read keys, not prose. """ -from __future__ import annotations -import argparse import os +import re import shutil import subprocess import sys +import tempfile from pathlib import Path -try: - import tomllib # Python 3.11+ -except ModuleNotFoundError: # pragma: no cover - import tomli as tomllib # type: ignore[no-redef] + +def emit(key, value): + if isinstance(value, bool): + value = "true" if value else "false" + elif isinstance(value, (list, tuple)): + value = ",".join(str(v) for v in value) if value else "" + elif value is None: + value = "" + value = str(value).replace("\n", " ").replace("\r", " ") + print("{}={}".format(key, value)) -def detect_uv() -> tuple[bool, str | None]: - uv = shutil.which("uv") - if not uv: - return False, None +def detect_platform(): + if sys.platform.startswith("win"): + return "windows" + if sys.platform == "darwin": + return "macos" + if sys.platform.startswith("linux"): + return "linux" + return sys.platform + + +def detect_uv(preferred): + candidates = [] + if preferred: + candidates.append(preferred) + found = shutil.which("uv") + if found and found not in candidates: + candidates.append(found) + for c in candidates: + try: + out = subprocess.run( + [c, "--version"], capture_output=True, text=True, timeout=5 + ) + if out.returncode == 0: + return c, (out.stdout or out.stderr).strip() + except (OSError, subprocess.SubprocessError): + continue + return None, None + + +def scan_pyproject(path): + facts = { + "has_project": False, + "has_tool_uv": False, + "scripts": [], + "requires_python": "", + } + if not path.exists(): + return facts try: - out = subprocess.run( - [uv, "--version"], capture_output=True, text=True, timeout=5, check=False - ) - return True, out.stdout.strip() or out.stderr.strip() or None - except Exception: - return True, None + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return facts + + section = None + for raw in text.splitlines(): + line = raw.strip() + if line.startswith("[") and line.endswith("]"): + section = line[1:-1].strip() + if section == "project": + facts["has_project"] = True + elif section == "tool.uv" or section.startswith("tool.uv."): + facts["has_tool_uv"] = True + continue + if not line or line.startswith("#"): + continue + if section == "project" and line.startswith("requires-python"): + m = re.search(r'"([^"]+)"', line) + if m: + facts["requires_python"] = m.group(1) + elif section == "project.scripts": + m = re.match(r"([A-Za-z0-9_.\-]+)\s*=", line) + if m: + facts["scripts"].append(m.group(1)) + return facts -def load_pyproject(root: Path) -> dict | None: - pp = root / "pyproject.toml" - if not pp.exists(): - return None - try: - return tomllib.loads(pp.read_text(encoding="utf-8")) - except Exception as e: - print(f"WARN: failed to parse pyproject.toml: {e}", file=sys.stderr) - return None - - -def detect_pep723_scripts(root: Path) -> list[str]: - hits: list[str] = [] +def detect_pep723(root): + hits = [] for py in root.glob("*.py"): try: head = py.read_text(encoding="utf-8", errors="replace")[:2000] - except Exception: + except OSError: continue if "# /// script" in head: hits.append(py.name) return hits -def main() -> int: - ap = argparse.ArgumentParser() - ap.add_argument("--root", default=".", help="project root (default: cwd)") - args = ap.parse_args() - root = Path(args.root).resolve() +def detect_venv_taint(venv): + if not venv.is_dir(): + return False, [] + reasons = [] + if not (venv / "pyvenv.cfg").exists(): + reasons.append("no-pyvenv.cfg") + expected = { + "pyvenv.cfg", + "bin", + "Scripts", + "Lib", + "lib", + "Lib64", + "lib64", + "Include", + "include", + "share", + ".gitignore", + ".lock", + "uv", + } + try: + extras = sorted(p.name for p in venv.iterdir() if p.name not in expected) + except OSError: + extras = [] + if extras: + reasons.append("extra:" + "|".join(extras[:5])) + libdir = venv / "lib" + if libdir.is_dir(): + try: + py_dirs = [p.name for p in libdir.iterdir() if p.name.startswith("py")] + except OSError: + py_dirs = [] + if len(py_dirs) > 1: + reasons.append("multi-python:" + "|".join(py_dirs)) + return bool(reasons), reasons - print(f"# project-uv probe") - print(f"root: {root}") - print() - uv_present, uv_version = detect_uv() - print(f"uv installed: {uv_present}") - if uv_version: - print(f"uv version: {uv_version}") - print() +def detect_default_packages(venv): + if not venv.is_dir(): + return [] + targets = { + "requests", + "httpx", + "python-dotenv", + "pydantic", + "pytest", + "pyyaml", + "tomli", + "mcp", + } + site_dirs = [] + libdir = venv / "lib" + if libdir.is_dir(): + try: + for child in libdir.iterdir(): + sp = child / "site-packages" + if sp.is_dir(): + site_dirs.append(sp) + except OSError: + pass + win_sp = venv / "Lib" / "site-packages" + if win_sp.is_dir(): + site_dirs.append(win_sp) - pyproject = load_pyproject(root) - has_pyproject = pyproject is not None - print(f"pyproject.toml present: {has_pyproject}") + found = set() + for sp in site_dirs: + try: + for entry in sp.iterdir(): + if not entry.name.endswith(".dist-info"): + continue + stem = entry.name[: -len(".dist-info")] + pkg = stem.rsplit("-", 1)[0].lower().replace("_", "-") + if pkg in targets: + found.add(pkg) + except OSError: + continue + if sys.version_info >= (3, 11): + found.add("tomllib(stdlib)") + return sorted(found) - if not has_pyproject: - scripts = detect_pep723_scripts(root) - print(f"PEP 723 inline-dep scripts at root: {scripts or 'none'}") - print("mode: no-pyproject (skill does not apply unless inline-dep scripts found)") - return 0 - assert pyproject is not None - has_project = "project" in pyproject - tool_uv = pyproject.get("tool", {}).get("uv") - scripts_table = pyproject.get("project", {}).get("scripts", {}) if has_project else {} +def detect_recipes(root): + hits = [] + for name in ("Justfile", "justfile", ".justfile"): + p = root / name + if p.exists(): + try: + for line in p.read_text( + encoding="utf-8", errors="replace" + ).splitlines(): + m = re.match(r"^([a-zA-Z0-9_\-]+)\s*:", line) + if m: + hits.append("just:" + m.group(1)) + except OSError: + pass + break + mk = root / "Makefile" + if mk.exists(): + try: + for line in mk.read_text(encoding="utf-8", errors="replace").splitlines(): + m = re.match(r"^([a-zA-Z0-9_\-]+)\s*:", line) + if m and m.group(1) not in ("PHONY",): + hits.append("make:" + m.group(1)) + except OSError: + pass + return hits - print(f"[project] table: {has_project}") - print(f"[tool.uv] table: {tool_uv is not None}") - if tool_uv: - notable = sorted(tool_uv.keys()) - print(f"[tool.uv] keys: {notable}") - print() - lock = root / "uv.lock" +def main(): + root = Path(os.environ.get("PROJECT_ROOT", ".")).resolve() + emit("platform", detect_platform()) + emit("root", root) + + scripts_uv = os.environ.get("SCRIPTS_UV", "").strip() + scripts_dir = os.environ.get("SCRIPTS_DIR", "scripts/").strip() + scripts_store = os.environ.get("SCRIPTS_STORE", tempfile.gettempdir()).strip() + + scripts_uv_valid = ( + bool(scripts_uv) + and Path(scripts_uv).exists() + and os.access(scripts_uv, os.X_OK) + ) + scripts_dir_valid = (root / scripts_dir).is_dir() + scripts_store_valid = Path(scripts_store).is_dir() and os.access( + scripts_store, os.W_OK + ) + emit("scripts_uv", scripts_uv if scripts_uv_valid else "") + emit("scripts_dir", scripts_dir if scripts_dir_valid else "") + emit("scripts_store", scripts_store if scripts_store_valid else "") + + uv_path, _ = detect_uv(scripts_uv if scripts_uv_valid else None) + emit("uv", uv_path or "") + + pp = root / "pyproject.toml" + facts = scan_pyproject(pp) + emit("pyproject_scripts", facts["scripts"]) + emit("uv_lock", (root / "uv.lock").exists()) + emit("uv_toml", (root / "uv.toml").exists()) + emit("requirements", sorted(p.name for p in root.glob("requirements*.txt"))) + venv = root / ".venv" - print(f"uv.lock present: {lock.exists()}") - print(f".venv present: {venv.exists()}") - print() + emit("venv", venv.exists()) + tainted, reasons = detect_venv_taint(venv) + emit("venv_tainted", tainted) + emit("venv_taint", reasons) - if scripts_table: - print("declared console scripts ([project.scripts]):") - for name, target in scripts_table.items(): - print(f" {name} -> {target}") - else: - print("declared console scripts: none") - print() + pep723 = detect_pep723(root) + emit("pep723", pep723) src = root / "src" - print(f"src/ layout: {src.is_dir()}") if src.is_dir(): - pkgs = [p.name for p in src.iterdir() if p.is_dir() and (p / "__init__.py").exists()] - print(f"src/ packages: {pkgs or 'none'}") + pkgs = sorted( + p.name for p in src.iterdir() if p.is_dir() and (p / "__init__.py").exists() + ) else: - top_pkgs = [ - p.name for p in root.iterdir() - if p.is_dir() and (p / "__init__.py").exists() and not p.name.startswith(".") - ] - print(f"top-level packages: {top_pkgs or 'none'}") - print() + pkgs = sorted( + p.name + for p in root.iterdir() + if p.is_dir() + and (p / "__init__.py").exists() + and not p.name.startswith(".") + ) + emit("packages", pkgs) - siblings = { - "Justfile": (root / "Justfile").exists() or (root / "justfile").exists() or (root / ".justfile").exists(), - "Makefile": (root / "Makefile").exists(), - "mise.toml": (root / "mise.toml").exists() or (root / ".mise.toml").exists(), - ".python-version": (root / ".python-version").exists(), - ".env": (root / ".env").exists(), - } - print("sibling tooling:") - for name, present in siblings.items(): - print(f" {name}: {present}") - print() + emit( + "justfile", + any((root / n).exists() for n in ("Justfile", "justfile", ".justfile")), + ) + emit("makefile", (root / "Makefile").exists()) + emit("mise", any((root / n).exists() for n in ("mise.toml", ".mise.toml"))) - if has_project and lock.exists(): + emit("recipes", detect_recipes(root)) + emit("default_packages", detect_default_packages(venv)) + + if facts["has_project"] and (root / "uv.lock").exists(): mode = "managed-project-locked" - elif has_project: + elif facts["has_project"]: mode = "managed-project-unlocked" - elif tool_uv is not None: + elif pep723: + mode = "pep723-only" + elif facts["has_tool_uv"]: mode = "uv-tool-only" + elif uv_path: + mode = "agent-tool-only" else: - mode = "metadata-only-or-other-backend" - print(f"mode: {mode}") + mode = "none" + emit("mode", mode) + return 0 diff --git a/plugins/project-uv/skills/project-uv/scripts/probe.sh b/plugins/project-uv/skills/project-uv/scripts/probe.sh new file mode 100644 index 0000000..d5e9c0d --- /dev/null +++ b/plugins/project-uv/skills/project-uv/scripts/probe.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env sh +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROBE="$SCRIPT_DIR/probe.py" + +if [ -n "$SCRIPTS_UV" ] && [ -x "$SCRIPTS_UV" ]; then + exec "$SCRIPTS_UV" run --no-project python "$PROBE" +fi +if command -v uv >/dev/null 2>&1; then + exec uv run --no-project python "$PROBE" +fi +for name in python3 python; do + if command -v "$name" >/dev/null 2>&1; then + exec "$name" "$PROBE" + fi +done +echo "probe: no python found" >&2 +exit 1