fix(plugin): fall back to pip/uv-installed skillopt-sleep in run-sleep.sh
The Claude Code and Codex plugin shells' runner (run-sleep.sh) only
resolved the engine by searching for a skillopt_sleep/ source directory
on disk. After v0.2.0 shipped the engine on PyPI (skillopt-sleep CLI),
the plugins still errored for users who installed via pip/uv without
cloning the repo — even though the error message said "pip install
skillopt" would work.
Add two fallbacks before the error:
1. skillopt-sleep CLI on PATH (covers uv tool install, pipx, pip install)
2. python -m skillopt_sleep when importable (covers pip install into
the active Python)
Fallback 1 is checked before fallback 2 because uv tool install / pipx
isolate the package from the system Python's import path, so the import
check would fail even though the CLI is available.
The existing source-checkout resolution path is unchanged — repo-clone
users keep working as before. The fix lands in both plugins/run-sleep.sh
(shared, used by Codex) and plugins/claude-code/scripts/run-sleep.sh
(bundled copy, byte-identical, used by the Claude Code marketplace
install which fetches only the plugins/claude-code/ subtree).
This commit is contained in:
@@ -24,28 +24,56 @@ else
|
|||||||
d="$(dirname "$d")"
|
d="$(dirname "$d")"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
if [ -z "${REPO_ROOT:-}" ]; then
|
|
||||||
echo "[sleep] ERROR: could not locate the skillopt_sleep package. Set SKILLOPT_SLEEP_REPO to the repo root." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
PY=""
|
|
||||||
# Allow explicit Python override (useful on macOS with old system Python).
|
|
||||||
if [ -n "${SKILLOPT_SLEEP_PYTHON:-}" ]; then
|
|
||||||
PY="$SKILLOPT_SLEEP_PYTHON"
|
|
||||||
else
|
|
||||||
for cand in python3.12 python3.11 python3.10 python3; do
|
|
||||||
if command -v "$cand" >/dev/null 2>&1; then
|
|
||||||
ver="$("$cand" -c 'import sys; print("%d%d" % sys.version_info[:2])' 2>/dev/null || echo 0)"
|
|
||||||
if [ "${ver:-0}" -ge 310 ]; then PY="$cand"; break; fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
if [ -z "$PY" ]; then
|
|
||||||
echo "[sleep] ERROR: need Python >= 3.10 (found none)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$#" -eq 0 ]; then set -- status; fi
|
if [ "$#" -eq 0 ]; then set -- status; fi
|
||||||
cd "$REPO_ROOT"
|
|
||||||
exec "$PY" -m skillopt_sleep "$@"
|
if [ -n "${REPO_ROOT:-}" ]; then
|
||||||
|
# Source checkout: run from repo root so skillopt_sleep/ is importable.
|
||||||
|
PY=""
|
||||||
|
# Allow explicit Python override (useful on macOS with old system Python).
|
||||||
|
if [ -n "${SKILLOPT_SLEEP_PYTHON:-}" ]; then
|
||||||
|
PY="$SKILLOPT_SLEEP_PYTHON"
|
||||||
|
else
|
||||||
|
for cand in python3.12 python3.11 python3.10 python3; do
|
||||||
|
if command -v "$cand" >/dev/null 2>&1; then
|
||||||
|
ver="$("$cand" -c 'import sys; print("%d%d" % sys.version_info[:2])' 2>/dev/null || echo 0)"
|
||||||
|
if [ "${ver:-0}" -ge 310 ]; then PY="$cand"; break; fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ -z "$PY" ]; then
|
||||||
|
echo "[sleep] ERROR: need Python >= 3.10 (found none)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
exec "$PY" -m skillopt_sleep "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No source checkout found — fall back to an installed engine.
|
||||||
|
|
||||||
|
# Fallback 1: skillopt-sleep CLI on PATH (uv tool install / pipx / pip install).
|
||||||
|
# Checked before the import fallback because uv tool install / pipx isolate the
|
||||||
|
# package from the system Python's import path, so `python -c "import
|
||||||
|
# skillopt_sleep"` would fail even though the CLI is available.
|
||||||
|
if command -v skillopt-sleep >/dev/null 2>&1; then
|
||||||
|
exec skillopt-sleep "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback 2: importable as a module (pip install into the active Python).
|
||||||
|
# Pick a Python >= 3.10 and check importability.
|
||||||
|
PY=""
|
||||||
|
for cand in python3.12 python3.11 python3.10 python3; do
|
||||||
|
if command -v "$cand" >/dev/null 2>&1; then
|
||||||
|
ver="$("$cand" -c 'import sys; print("%d%d" % sys.version_info[:2])' 2>/dev/null || echo 0)"
|
||||||
|
if [ "${ver:-0}" -ge 310 ] && "$cand" -c "import skillopt_sleep" >/dev/null 2>&1; then
|
||||||
|
PY="$cand"; break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$PY" ]; then
|
||||||
|
exec "$PY" -m skillopt_sleep "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[sleep] ERROR: could not locate the skillopt_sleep package." >&2
|
||||||
|
echo "[sleep] Install it with 'uv tool install skillopt' or 'pip install skillopt'," >&2
|
||||||
|
echo "[sleep] or set SKILLOPT_SLEEP_REPO to a clone of the SkillOpt repo." >&2
|
||||||
|
exit 1
|
||||||
|
|||||||
+52
-24
@@ -24,28 +24,56 @@ else
|
|||||||
d="$(dirname "$d")"
|
d="$(dirname "$d")"
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
if [ -z "${REPO_ROOT:-}" ]; then
|
|
||||||
echo "[sleep] ERROR: could not locate the skillopt_sleep package. Set SKILLOPT_SLEEP_REPO to the repo root." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
PY=""
|
|
||||||
# Allow explicit Python override (useful on macOS with old system Python).
|
|
||||||
if [ -n "${SKILLOPT_SLEEP_PYTHON:-}" ]; then
|
|
||||||
PY="$SKILLOPT_SLEEP_PYTHON"
|
|
||||||
else
|
|
||||||
for cand in python3.12 python3.11 python3.10 python3; do
|
|
||||||
if command -v "$cand" >/dev/null 2>&1; then
|
|
||||||
ver="$("$cand" -c 'import sys; print("%d%d" % sys.version_info[:2])' 2>/dev/null || echo 0)"
|
|
||||||
if [ "${ver:-0}" -ge 310 ]; then PY="$cand"; break; fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
if [ -z "$PY" ]; then
|
|
||||||
echo "[sleep] ERROR: need Python >= 3.10 (found none)." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$#" -eq 0 ]; then set -- status; fi
|
if [ "$#" -eq 0 ]; then set -- status; fi
|
||||||
cd "$REPO_ROOT"
|
|
||||||
exec "$PY" -m skillopt_sleep "$@"
|
if [ -n "${REPO_ROOT:-}" ]; then
|
||||||
|
# Source checkout: run from repo root so skillopt_sleep/ is importable.
|
||||||
|
PY=""
|
||||||
|
# Allow explicit Python override (useful on macOS with old system Python).
|
||||||
|
if [ -n "${SKILLOPT_SLEEP_PYTHON:-}" ]; then
|
||||||
|
PY="$SKILLOPT_SLEEP_PYTHON"
|
||||||
|
else
|
||||||
|
for cand in python3.12 python3.11 python3.10 python3; do
|
||||||
|
if command -v "$cand" >/dev/null 2>&1; then
|
||||||
|
ver="$("$cand" -c 'import sys; print("%d%d" % sys.version_info[:2])' 2>/dev/null || echo 0)"
|
||||||
|
if [ "${ver:-0}" -ge 310 ]; then PY="$cand"; break; fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
if [ -z "$PY" ]; then
|
||||||
|
echo "[sleep] ERROR: need Python >= 3.10 (found none)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
exec "$PY" -m skillopt_sleep "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# No source checkout found — fall back to an installed engine.
|
||||||
|
|
||||||
|
# Fallback 1: skillopt-sleep CLI on PATH (uv tool install / pipx / pip install).
|
||||||
|
# Checked before the import fallback because uv tool install / pipx isolate the
|
||||||
|
# package from the system Python's import path, so `python -c "import
|
||||||
|
# skillopt_sleep"` would fail even though the CLI is available.
|
||||||
|
if command -v skillopt-sleep >/dev/null 2>&1; then
|
||||||
|
exec skillopt-sleep "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback 2: importable as a module (pip install into the active Python).
|
||||||
|
# Pick a Python >= 3.10 and check importability.
|
||||||
|
PY=""
|
||||||
|
for cand in python3.12 python3.11 python3.10 python3; do
|
||||||
|
if command -v "$cand" >/dev/null 2>&1; then
|
||||||
|
ver="$("$cand" -c 'import sys; print("%d%d" % sys.version_info[:2])' 2>/dev/null || echo 0)"
|
||||||
|
if [ "${ver:-0}" -ge 310 ] && "$cand" -c "import skillopt_sleep" >/dev/null 2>&1; then
|
||||||
|
PY="$cand"; break
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [ -n "$PY" ]; then
|
||||||
|
exec "$PY" -m skillopt_sleep "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[sleep] ERROR: could not locate the skillopt_sleep package." >&2
|
||||||
|
echo "[sleep] Install it with 'uv tool install skillopt' or 'pip install skillopt'," >&2
|
||||||
|
echo "[sleep] or set SKILLOPT_SLEEP_REPO to a clone of the SkillOpt repo." >&2
|
||||||
|
exit 1
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"""Tests for plugins/run-sleep.sh engine resolution and fallback logic.
|
||||||
|
|
||||||
|
Verifies that the runner:
|
||||||
|
1. Uses the source checkout when skillopt_sleep/ is present (existing path).
|
||||||
|
2. Falls back to the skillopt-sleep CLI on PATH when no source dir is found.
|
||||||
|
3. Falls back to `python -m skillopt_sleep` when the package is importable
|
||||||
|
but no CLI is on PATH.
|
||||||
|
4. Errors with a helpful message when no engine can be found.
|
||||||
|
|
||||||
|
Pure-stdlib (unittest). No API key, no third-party deps.
|
||||||
|
Run: python3 -m pytest tests/test_run_sleep_fallback.py -v
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
RUNNER = os.path.join(REPO, "plugins", "run-sleep.sh")
|
||||||
|
BUNDLED = os.path.join(REPO, "plugins", "claude-code", "scripts", "run-sleep.sh")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(script, args, env, cwd):
|
||||||
|
"""Run a sleep runner script and return (returncode, stdout, stderr)."""
|
||||||
|
cmd = ["bash", script] + args
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd, capture_output=True, text=True, env=env, cwd=cwd, timeout=30,
|
||||||
|
)
|
||||||
|
return proc.returncode, proc.stdout, proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunnerSourceCheckout(unittest.TestCase):
|
||||||
|
"""When skillopt_sleep/ is found in the repo, the source-checkout path is used."""
|
||||||
|
|
||||||
|
def test_source_checkout_runs_engine(self):
|
||||||
|
# The repo root contains skillopt_sleep/, so running from the repo
|
||||||
|
# should find it via the SCRIPT_DIR/../skillopt_sleep check.
|
||||||
|
env = {k: v for k, v in os.environ.items()
|
||||||
|
if k not in ("SKILLOPT_SLEEP_REPO", "CLAUDE_PLUGIN_ROOT")}
|
||||||
|
rc, out, err = _run(RUNNER, ["--help"], env, REPO)
|
||||||
|
self.assertEqual(rc, 0, f"stderr:\n{err}")
|
||||||
|
self.assertIn("skillopt_sleep", out)
|
||||||
|
self.assertIn("run", out)
|
||||||
|
self.assertIn("status", out)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunnerCliFallback(unittest.TestCase):
|
||||||
|
"""When no source dir is found, fall back to the skillopt-sleep CLI on PATH."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# Copy the runner to a temp dir so SCRIPT_DIR/../skillopt_sleep doesn't
|
||||||
|
# resolve to the repo's source checkout (SCRIPT_DIR is the script's
|
||||||
|
# location, not CWD, so running the repo copy always finds the source).
|
||||||
|
self._tmp = tempfile.TemporaryDirectory()
|
||||||
|
self._script = os.path.join(self._tmp.name, "run-sleep.sh")
|
||||||
|
shutil.copy2(RUNNER, self._script)
|
||||||
|
# Build a fake skillopt-sleep CLI on PATH that records its args.
|
||||||
|
self._bindir = os.path.join(self._tmp.name, "bin")
|
||||||
|
os.makedirs(self._bindir)
|
||||||
|
self._fake_cli = os.path.join(self._bindir, "skillopt-sleep")
|
||||||
|
with open(self._fake_cli, "w") as f:
|
||||||
|
f.write("#!/usr/bin/env bash\n")
|
||||||
|
f.write('echo "fake-cli invoked: $@"\n')
|
||||||
|
os.chmod(self._fake_cli, 0o755)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self._tmp.cleanup()
|
||||||
|
|
||||||
|
def _env_without_source(self):
|
||||||
|
"""Env with no source dir resolvable and fake CLI on PATH."""
|
||||||
|
env = {k: v for k, v in os.environ.items()
|
||||||
|
if k not in ("SKILLOPT_SLEEP_REPO", "CLAUDE_PLUGIN_ROOT")}
|
||||||
|
# Prepend fake bin dir so our skillopt-sleep is found first.
|
||||||
|
env["PATH"] = self._bindir + os.pathsep + env.get("PATH", "")
|
||||||
|
return env
|
||||||
|
|
||||||
|
def test_cli_fallback_used_when_no_source_dir(self):
|
||||||
|
env = self._env_without_source()
|
||||||
|
rc, out, err = _run(self._script, ["status", "--project", "/tmp"], env, "/tmp")
|
||||||
|
self.assertEqual(rc, 0, f"stderr:\n{err}")
|
||||||
|
self.assertIn("fake-cli invoked: status --project /tmp", out)
|
||||||
|
|
||||||
|
def test_cli_fallback_passes_through_all_args(self):
|
||||||
|
env = self._env_without_source()
|
||||||
|
rc, out, err = _run(
|
||||||
|
self._script, ["run", "--project", "/tmp", "--backend", "mock"], env, "/tmp",
|
||||||
|
)
|
||||||
|
self.assertEqual(rc, 0, f"stderr:\n{err}")
|
||||||
|
self.assertIn("run --project /tmp --backend mock", out)
|
||||||
|
|
||||||
|
def test_bundled_copy_also_uses_cli_fallback(self):
|
||||||
|
"""The byte-identical bundled copy in plugins/claude-code/scripts/ must
|
||||||
|
also fall back to the CLI — it's what the marketplace install ships."""
|
||||||
|
bundled_copy = os.path.join(self._tmp.name, "bundled-run-sleep.sh")
|
||||||
|
shutil.copy2(BUNDLED, bundled_copy)
|
||||||
|
env = self._env_without_source()
|
||||||
|
rc, out, err = _run(bundled_copy, ["status"], env, "/tmp")
|
||||||
|
self.assertEqual(rc, 0, f"stderr:\n{err}")
|
||||||
|
self.assertIn("fake-cli invoked: status", out)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunnerNoEngine(unittest.TestCase):
|
||||||
|
"""When no source dir, no CLI on PATH, and no importable package, error cleanly."""
|
||||||
|
|
||||||
|
def test_error_message_mentions_install_options(self):
|
||||||
|
# Copy the runner to a temp dir so SCRIPT_DIR/../skillopt_sleep doesn't
|
||||||
|
# resolve to the repo's source checkout.
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
script = os.path.join(tmp, "run-sleep.sh")
|
||||||
|
shutil.copy2(RUNNER, script)
|
||||||
|
# Build an env with a minimal PATH so skillopt-sleep isn't found,
|
||||||
|
# and no source dir resolvable.
|
||||||
|
env = {
|
||||||
|
"PATH": "/usr/bin:/bin",
|
||||||
|
"HOME": os.environ.get("HOME", "/tmp"),
|
||||||
|
}
|
||||||
|
# Use a Python that almost certainly doesn't have skillopt_sleep
|
||||||
|
# importable (system python3). If it does, skip — we can't easily
|
||||||
|
# simulate "not installed" in that case.
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-c", "import skillopt_sleep"],
|
||||||
|
capture_output=True, check=True, timeout=10,
|
||||||
|
)
|
||||||
|
has_package = True
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||||
|
has_package = False
|
||||||
|
|
||||||
|
if has_package:
|
||||||
|
self.skipTest("skillopt_sleep is importable in this env; "
|
||||||
|
"cannot test the no-engine error path")
|
||||||
|
|
||||||
|
rc, out, err = _run(script, ["status"], env, "/tmp")
|
||||||
|
self.assertNotEqual(rc, 0)
|
||||||
|
self.assertIn("could not locate the skillopt_sleep package", err)
|
||||||
|
self.assertIn("uv tool install skillopt", err)
|
||||||
|
self.assertIn("pip install skillopt", err)
|
||||||
|
self.assertIn("SKILLOPT_SLEEP_REPO", err)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunnerBundledMatchesShared(unittest.TestCase):
|
||||||
|
"""The bundled copy must stay in sync with the shared runner."""
|
||||||
|
|
||||||
|
def test_bundled_equals_shared(self):
|
||||||
|
with open(RUNNER) as f:
|
||||||
|
shared = f.read()
|
||||||
|
with open(BUNDLED) as f:
|
||||||
|
bundled = f.read()
|
||||||
|
self.assertEqual(shared, bundled,
|
||||||
|
"plugins/claude-code/scripts/run-sleep.sh has drifted "
|
||||||
|
"from plugins/run-sleep.sh — they must stay in sync")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user