♻️ Rework project-uv probe for cross-platform determinism

Probe now emits KEY=VALUE so the agent reads facts instead of inferring
from prose. Stdlib-only (no tomllib) so it runs on whatever Python the
launcher reaches. Adds sh/ps1 launcher pair, SCRIPTS_UV/SCRIPTS_DIR/
SCRIPTS_STORE env-var contract, tainted-venv + recipe + default-package
detection, and broader mode classification (incl. agent-tool-only).
SKILL.md trimmed and gated by pyproject.toml glob.
This commit is contained in:
2026-05-19 19:10:08 +02:00
parent 840e79fde4
commit 4f67b63918
5 changed files with 321 additions and 151 deletions
+2
View File
@@ -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. - 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 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. - 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.
+26 -49
View File
@@ -1,67 +1,44 @@
--- ---
name: project-uv 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 # 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 - `SCRIPTS_UV` — path to the `uv` executable.
uv run --no-project python ${CLAUDE_PLUGIN_ROOT}/skills/project-uv/scripts/probe.py - `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 - `managed-project-locked``uv run <cmd>`; `uv sync` if `.venv` missing.
- the project mode: managed project (`[project]` table) vs PEP 723 script mode vs neither - `managed-project-unlocked``uv run <cmd>`; `uv sync` to create env.
- whether a `uv.lock` exists and is up to date - `pep723-only``uv run path/to/script.py`; edit `# /// script` block for deps.
- whether `[tool.uv]` is configured, with notable keys - `uv-tool-only``[tool.uv]` without `[project]`; ask before assuming.
- declared scripts: `[project.scripts]` console entrypoints - `agent-tool-only` — uv exists but no python project; use for agent scratchpad in `scripts_store`.
- src layout: presence of `src/`, top-level packages - `none` — no python here; stop.
- presence of sibling tooling: `Justfile`, `Makefile`, `mise.toml`, `.python-version`
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`):** Does not install uv, modify `pyproject.toml`, or pick a Python version.
- 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 <pkg>`, not `pip install`
- Declared console scripts are runnable as `uv run <name>`
**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.
@@ -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
@@ -1,149 +1,301 @@
#!/usr/bin/env python3 #!/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 Stdlib only, targets Python 3.7+. No TOML parser — pyproject.toml is
report intended for an LLM agent to read before suggesting uv commands. 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` Output: KEY=VALUE lines on stdout. One key per line. Read keys, not prose.
or any system Python.
""" """
from __future__ import annotations
import argparse
import os import os
import re
import shutil import shutil
import subprocess import subprocess
import sys import sys
import tempfile
from pathlib import Path from pathlib import Path
try:
import tomllib # Python 3.11+ def emit(key, value):
except ModuleNotFoundError: # pragma: no cover if isinstance(value, bool):
import tomli as tomllib # type: ignore[no-redef] 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]: def detect_platform():
uv = shutil.which("uv") if sys.platform.startswith("win"):
if not uv: return "windows"
return False, None 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: try:
out = subprocess.run( out = subprocess.run(
[uv, "--version"], capture_output=True, text=True, timeout=5, check=False [c, "--version"], capture_output=True, text=True, timeout=5
) )
return True, out.stdout.strip() or out.stderr.strip() or None if out.returncode == 0:
except Exception: return c, (out.stdout or out.stderr).strip()
return True, None except (OSError, subprocess.SubprocessError):
continue
return None, None
def load_pyproject(root: Path) -> dict | None: def scan_pyproject(path):
pp = root / "pyproject.toml" facts = {
if not pp.exists(): "has_project": False,
return None "has_tool_uv": False,
"scripts": [],
"requires_python": "",
}
if not path.exists():
return facts
try: try:
return tomllib.loads(pp.read_text(encoding="utf-8")) text = path.read_text(encoding="utf-8", errors="replace")
except Exception as e: except OSError:
print(f"WARN: failed to parse pyproject.toml: {e}", file=sys.stderr) return facts
return None
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 detect_pep723_scripts(root: Path) -> list[str]: def detect_pep723(root):
hits: list[str] = [] hits = []
for py in root.glob("*.py"): for py in root.glob("*.py"):
try: try:
head = py.read_text(encoding="utf-8", errors="replace")[:2000] head = py.read_text(encoding="utf-8", errors="replace")[:2000]
except Exception: except OSError:
continue continue
if "# /// script" in head: if "# /// script" in head:
hits.append(py.name) hits.append(py.name)
return hits return hits
def main() -> int: def detect_venv_taint(venv):
ap = argparse.ArgumentParser() if not venv.is_dir():
ap.add_argument("--root", default=".", help="project root (default: cwd)") return False, []
args = ap.parse_args() reasons = []
root = Path(args.root).resolve() 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() def detect_default_packages(venv):
print(f"uv installed: {uv_present}") if not venv.is_dir():
if uv_version: return []
print(f"uv version: {uv_version}") targets = {
print() "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) found = set()
has_pyproject = pyproject is not None for sp in site_dirs:
print(f"pyproject.toml present: {has_pyproject}") 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 def detect_recipes(root):
has_project = "project" in pyproject hits = []
tool_uv = pyproject.get("tool", {}).get("uv") for name in ("Justfile", "justfile", ".justfile"):
scripts_table = pyproject.get("project", {}).get("scripts", {}) if has_project else {} 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" venv = root / ".venv"
print(f"uv.lock present: {lock.exists()}") emit("venv", venv.exists())
print(f".venv present: {venv.exists()}") tainted, reasons = detect_venv_taint(venv)
print() emit("venv_tainted", tainted)
emit("venv_taint", reasons)
if scripts_table: pep723 = detect_pep723(root)
print("declared console scripts ([project.scripts]):") emit("pep723", pep723)
for name, target in scripts_table.items():
print(f" {name} -> {target}")
else:
print("declared console scripts: none")
print()
src = root / "src" src = root / "src"
print(f"src/ layout: {src.is_dir()}")
if src.is_dir(): if src.is_dir():
pkgs = [p.name for p in src.iterdir() if p.is_dir() and (p / "__init__.py").exists()] pkgs = sorted(
print(f"src/ packages: {pkgs or 'none'}") p.name for p in src.iterdir() if p.is_dir() and (p / "__init__.py").exists()
)
else: else:
top_pkgs = [ pkgs = sorted(
p.name for p in root.iterdir() p.name
if p.is_dir() and (p / "__init__.py").exists() and not p.name.startswith(".") for p in root.iterdir()
] if p.is_dir()
print(f"top-level packages: {top_pkgs or 'none'}") and (p / "__init__.py").exists()
print() and not p.name.startswith(".")
)
emit("packages", pkgs)
siblings = { emit(
"Justfile": (root / "Justfile").exists() or (root / "justfile").exists() or (root / ".justfile").exists(), "justfile",
"Makefile": (root / "Makefile").exists(), any((root / n).exists() for n in ("Justfile", "justfile", ".justfile")),
"mise.toml": (root / "mise.toml").exists() or (root / ".mise.toml").exists(), )
".python-version": (root / ".python-version").exists(), emit("makefile", (root / "Makefile").exists())
".env": (root / ".env").exists(), emit("mise", any((root / n).exists() for n in ("mise.toml", ".mise.toml")))
}
print("sibling tooling:")
for name, present in siblings.items():
print(f" {name}: {present}")
print()
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" mode = "managed-project-locked"
elif has_project: elif facts["has_project"]:
mode = "managed-project-unlocked" mode = "managed-project-unlocked"
elif tool_uv is not None: elif pep723:
mode = "pep723-only"
elif facts["has_tool_uv"]:
mode = "uv-tool-only" mode = "uv-tool-only"
elif uv_path:
mode = "agent-tool-only"
else: else:
mode = "metadata-only-or-other-backend" mode = "none"
print(f"mode: {mode}") emit("mode", mode)
return 0 return 0
@@ -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