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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Changed

- The `SessionStart` hook `install-python-deps.sh` starts no process while its ready stamp holds: after a successful install with an interpreter found on PATH, `pydeps.py` writes `<data>/python/.hook-ready`, and the hook skips the interpreter probe, `cygpath` and the load probe until the plugin root, a candidate interpreter's resolution, that interpreter's file, `requirements.txt`, `pydeps.py` or `pydeps.json` changes, or the installed set is gone. `pydeps.py run` drops the stamp when the set is missing or a script exits non-zero, and `check` when a row fails, so the next session start probes and rebuilds ([#12258](https://github.com/melodic-software/claude-code-plugins/issues/12258)).
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Changed

- The `SessionStart` hook `install-python-deps.sh` starts no process while its ready stamp holds: after a successful install with an interpreter found on PATH, `pydeps.py` writes `<data>/python/.hook-ready`, and the hook skips the interpreter probe, `cygpath` and the ManimCE load probe until the plugin root, a candidate interpreter's resolution, that interpreter's file, `requirements.txt`, `pydeps.py` or `pydeps.json` changes, or the installed set is gone. `pydeps.py run` drops the stamp when the set is missing or a script exits non-zero, and `check` when a row fails, so the next session start probes and rebuilds ([#12258](https://github.com/melodic-software/claude-code-plugins/issues/12258)).
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Changed

- The `SessionStart` hook `install-python-deps.sh` starts no process while its ready stamp holds: after a successful install with an interpreter found on PATH, `pydeps.py` writes `<data>/python/.hook-ready`, and the hook skips the interpreter probe, `cygpath` and the numpy and onnxruntime load probe until the plugin root, a candidate interpreter's resolution, that interpreter's file, `requirements.txt`, `pydeps.py` or `pydeps.json` changes, or the installed set is gone. `pydeps.py run` drops the stamp when the set is missing or a script exits non-zero, and `check` when a row fails, so the next session start probes and rebuilds ([#12258](https://github.com/melodic-software/claude-code-plugins/issues/12258)).
13 changes: 13 additions & 0 deletions .github/workflows/pr-test-platforms.yml
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,19 @@ jobs:
- name: Test the developer-experience pointer-line helper on Windows
if: contains(fromJSON(needs.select-tests-windows.outputs.steps), 'plugins/developer-experience/skills/setup/scripts/pointer-line.test.sh')
run: bash plugins/developer-experience/skills/setup/scripts/pointer-line.test.sh
# The install-python-deps SessionStart hooks under real Git Bash: a ready stamp
# pydeps.py writes with native Windows paths must let the hook start no Python.
# Only the real Git Bash cases run here; the rest stub interpreters and run on
# Linux. explainer-video's suite skips unless Python 3.12 or 3.13 is on PATH.
- name: Test the animation install-python-deps hook on Windows
if: contains(fromJSON(needs.select-tests-windows.outputs.steps), 'plugins/animation/hooks/install-python-deps.test.sh')
run: bash plugins/animation/hooks/install-python-deps.test.sh
- name: Test the speech install-python-deps hook on Windows
if: contains(fromJSON(needs.select-tests-windows.outputs.steps), 'plugins/speech/hooks/install-python-deps.test.sh')
run: bash plugins/speech/hooks/install-python-deps.test.sh
- name: Test the explainer-video install-python-deps hook on Windows
if: contains(fromJSON(needs.select-tests-windows.outputs.steps), 'plugins/explainer-video/hooks/install-python-deps.test.sh')
run: bash plugins/explainer-video/hooks/install-python-deps.test.sh

- name: Log the API budget at the end
if: always()
Expand Down
13 changes: 13 additions & 0 deletions docs/contracts/on-demand-dependencies/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ Notable changes to the on-demand dependency contract. The contract is versioned
stamp in `README.md` (SemVer). A `[SPEC]` rule that tightens is a major bump; a new rule, exception
or adopter is a minor bump; wording is a patch.

## [2.4.0] - 2026-11-02

- **Rule P2**: the install hook skips the install and the load probe, starting no process, while
`pydeps.py`'s ready stamp holds. A successful install through an interpreter found on PATH writes
the stamp; another plugin root, a changed candidate resolution, a newer interpreter file, lock,
`pydeps.py` or `pydeps.json`, or a missing installed set invalidates it. Paths are compared as
files, not strings ([#12258](https://github.com/melodic-software/claude-code-plugins/issues/12258)).
- **Rule P3**: the load probe runs on the hook's full path, in the repair install and in `check`.
- **Rule P4**: a stamped set that breaks surfaces at skill time; the launcher and `check` drop the
stamp so the next session start rebuilds, and a failed launcher run with a stamp present prints
the repair line. The two cases that outlive the stamp's checks, a candidate symlink repointed to
an older interpreter file and a version-manager shim whose selected version changed, are stated.

## [2.3.0] - 2026-10-04

- **Rule P4**: in an interactive session the agent offers to run the repair line and runs it on the
Expand Down
31 changes: 28 additions & 3 deletions docs/contracts/on-demand-dependencies/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# On-demand dependencies: install pinned packages, never vendor them

Version: 2.3.0
Last updated: 2026-10-04
Version: 2.4.0
Last updated: 2026-11-02

A marketplace-wide rule for **third-party packages a plugin needs at run time**: commit the pinned
manifest and lockfile, install from them on demand into the plugin's data directory, and fail
Expand Down Expand Up @@ -121,6 +121,18 @@ and does nothing when the set already loads (Rule P3). pip refuses the install w
requirement, transitive ones included, is unpinned or unhashed, or a wheel's hash differs from the
lock ([pip secure installs](https://pip.pypa.io/en/stable/topics/secure-installs/)).

**Ready stamp:** while the reference's ready stamp holds, the hook skips the install and the load
probe and starts no process, since starting Python and importing the set take most of the hook's
run time on Windows. After a successful install with an interpreter found through a PATH
candidate, `pydeps.py` writes `<plugin data dir>/python/.hook-ready` atomically, recording the
plugin root, the installed directory and what each of the hook's candidate names resolves to. An
interpreter only the py launcher listed writes no stamp. The hook checks the stamp with shell
builtins alone, and any of these sends it down the full path: another plugin root, a candidate
name resolving to another file, a resolved interpreter file, `requirements.txt`, `pydeps.py` or
`pydeps.json` newer than the stamp, or the installed directory missing. The root and the
candidates are compared as files (`-ef`), not as path strings, so a symlink, another mount, an 8.3
short name, another letter case or a dropped `.exe` still matches the same file.

**Nothing else fetches.** No skill body, script or hook other than the install hook runs
`uv run`, `uvx`, `pip install` or `--with-requirements`. A skill runs its scripts against the
installed set through a launcher that puts the install directory on `PYTHONPATH` and never
Expand Down Expand Up @@ -148,7 +160,9 @@ or script runs it on its own.
distribution and import names differ, `opencv-python-headless` and `cv2`) and imports them with
the install as the only third-party path (`PYTHONPATH` set to it, `-S` so no ambient
`site-packages`), so an ambient copy never passes for the locked set. The launcher runs scripts
the same way. A directory that is present but no longer loads is rebuilt.
the same way. A directory that is present but no longer loads is rebuilt. The probe runs on the
hook's full path, in the install the repair line runs, and in `check`; while the ready stamp
(Rule P2) holds, the hook trusts the stamp instead.
- **Floor:** the component states its Python floor and the hook looks for an interpreter at or
above it, so a host with an older Python gets Rule P4's notice, not a failed install.

Expand All @@ -165,6 +179,17 @@ packages, installs on its own, or reports a partial result as complete. In an in
the agent offers to run the repair line and runs it on the user's yes, per
[When a check fails](../prerequisites/README.md#when-a-check-fails-offer-the-fix-run-it-on-a-yes).

A stamped set that breaks surfaces when a skill runs, not at session start. The launcher drops the
ready stamp and prints the repair line both for a missing set and when a script exits non-zero
while the stamp is present, since a set that stops importing fails the script; the script's own
exit code stands. `check` drops the stamp when a row fails. Either way the next session start
probes and rebuilds. Two cases outlive the stamp's checks, since the hook cannot learn which
interpreter a name runs without starting it: a candidate symlink repointed to an interpreter file
older than the stamp, and a version-manager shim (pyenv, asdf, mise) whose selected version
changed, which leaves the shim's path and file time as they were. In both, the hook keeps trusting
the stamp until the first skill run, which finds no set for the new interpreter, prints the repair
line and drops the stamp.

## Exceptions

| Component | Rule | Exception and reason |
Expand Down
113 changes: 88 additions & 25 deletions lib/install-python-deps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
# SessionStart hook: install the plugin's hash-locked Python packages (requirements.txt) into
# ${CLAUDE_PLUGIN_DATA} on demand, so its skills run against them and nothing fetches a package
# while a skill runs (docs/contracts/on-demand-dependencies, Python). A no-op once the set
# loads. A failed install is a user notice with the repair line, never a silent skip; the model
# loads, and one that starts no process while pydeps.py's ready stamp holds. A failed install is a user notice with the repair line, never a silent skip; the model
# hears nothing here, since pydeps.py run prints the same repair line when a skill runs.
# Always exits 0: a failed install must not block the session. The plugin's wording (its name,
# the Pythons it supports, the skills that need the packages) is in install-python-deps.conf.
Expand Down Expand Up @@ -50,9 +50,72 @@ launcher_listed() {
# such an alias too, and it runs, so it is only asked to list.
candidates=(python3.13 python3.12 python3 python)
case "${OSTYPE:-}" in
msys* | cygwin*) candidates+=(py) ;;
*) ;;
msys* | cygwin*) candidates+=(py) ;;
*) ;;
esac

# as_posix <path> -> REPLY: a drive-letter path (C:\x or C:/x) in this shell's POSIX form under Git Bash or Cygwin,
# by builtins alone: cygpath would cost a process start. The drive mounts at /c/ or /cygdrive/c/, whichever exists:
# OSTYPE does not tell them apart, since Git Bash can report cygwin. Fails when neither exists. Any other path is
# returned as is.
as_posix() {
REPLY="$1"
case "${OSTYPE:-}" in
msys* | cygwin*)
REPLY="${REPLY//\\//}"
if [[ "$REPLY" =~ ^([A-Za-z]):(/.*)?$ ]]; then
local drive="${BASH_REMATCH[1],,}" rest="${BASH_REMATCH[2]}"
if [[ -d "/$drive" ]]; then
REPLY="/$drive$rest"
elif [[ -d "/cygdrive/$drive" ]]; then
REPLY="/cygdrive/$drive$rest"
else
return 1
fi
fi
;;
*) ;;
esac
}

# stamp_holds -> 0 when pydeps.py's ready stamp (written after a successful install with an interpreter found on
# PATH) still describes this session: the same plugin root, every candidate name resolving to the same file, none of
# those files, requirements.txt, pydeps.py or pydeps.json newer than the stamp, and the installed set present.
# Files are compared by identity (-ef), not spelling: one file can be named through a symlink, another mount, an 8.3
# short name, another letter case, or without its .exe. Builtins only (hash fills BASH_CMDS the way command -v looks
# up a name, without a subshell), so a session start whose set is ready starts no process. Anything else falls
# through to the full path, which probes and rebuilds.
stamp_holds() {
((BASH_VERSINFO[0] >= 4)) || return 1
local stamp lines name got i=3 f
as_posix "$data" || return 1
stamp="$REPLY/python/.hook-ready"
[[ -f "$stamp" ]] || return 1
mapfile -t lines <"$stamp" || return 1
lines=("${lines[@]%$'\r'}")
((${#lines[@]} == 3 + ${#candidates[@]})) && [[ "${lines[0]}" == 'pydeps-hook-ready 1' ]] || return 1
as_posix "${lines[1]}" || return 1
[[ "$REPLY/scripts/pydeps.py" -ef "$ROOT/scripts/pydeps.py" ]] || return 1
as_posix "${lines[2]}" || return 1
[[ -d "$REPLY" ]] || return 1
for name in "${candidates[@]}"; do
[[ "${lines[i]%%=*}" == "$name" ]] || return 1
as_posix "${lines[i]#*=}" || return 1
got=""
hash -- "$name" 2>/dev/null && got="${BASH_CMDS[$name]-}"
if [[ -n "$REPLY" || -n "$got" ]]; then
[[ -n "$REPLY" && -n "$got" && "$REPLY" -ef "$got" ]] || return 1
[[ "$got" -nt "$stamp" ]] && return 1
fi
i=$((i + 1))
done
for f in requirements.txt scripts/pydeps.py scripts/pydeps.json; do
[[ "$ROOT/$f" -nt "$stamp" ]] && return 1
done
return 0
}
stamp_holds && exit 0

py=""
for candidate in "${candidates[@]}"; do
resolved="$(command -v "$candidate" 2>/dev/null)" || continue
Expand All @@ -76,19 +139,19 @@ fi
repair_line() {
local parts=("$(command -v "$py")" "$ROOT/scripts/pydeps.py" install --data-dir "$data") line="" part
case "${OSTYPE:-}" in
msys* | cygwin*)
for part in "${parts[@]}"; do
[[ "$part" == /* ]] && part="$(cygpath -w "$part" 2>/dev/null || printf '%s' "$part")"
line+=" '${part//\'/\'\'}'"
done
printf '&%s' "$line"
;;
*)
for part in "${parts[@]}"; do
line+=" '${part//\'/\'\\\'\'}'"
done
printf '%s' "${line# }"
;;
msys* | cygwin*)
for part in "${parts[@]}"; do
[[ "$part" == /* ]] && part="$(cygpath -w "$part" 2>/dev/null || printf '%s' "$part")"
line+=" '${part//\'/\'\'}'"
done
printf '&%s' "$line"
;;
*)
for part in "${parts[@]}"; do
line+=" '${part//\'/\'\\\'\'}'"
done
printf '%s' "${line# }"
;;
esac
}

Expand All @@ -98,15 +161,15 @@ repair_line() {
# return the unconverted path.
to_native() {
case "${OSTYPE:-}" in
msys* | cygwin*)
[[ "$1" == /* ]] || {
printf '%s' "$1"
return 0
}
local converted
converted="$(cygpath -m "$1" 2>/dev/null)" && [[ -n "$converted" ]] && printf '%s' "$converted"
;;
*) printf '%s' "$1" ;;
msys* | cygwin*)
[[ "$1" == /* ]] || {
printf '%s' "$1"
return 0
}
local converted
converted="$(cygpath -m "$1" 2>/dev/null)" && [[ -n "$converted" ]] && printf '%s' "$converted"
;;
*) printf '%s' "$1" ;;
esac
}

Expand Down
Loading
Loading