Merge pull request #107 from ichoosetoaccept/fix/plugin-pip-fallback
fix(plugin): fall back to pip/uv-installed skillopt-sleep in run-sleep.sh
This commit is contained in:
@@ -24,11 +24,10 @@ else
|
||||
d="$(dirname "$d")"
|
||||
done
|
||||
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
|
||||
if [ "$#" -eq 0 ]; then set -- status; fi
|
||||
|
||||
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
|
||||
@@ -45,7 +44,36 @@ if [ -z "$PY" ]; then
|
||||
echo "[sleep] ERROR: need Python >= 3.10 (found none)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$#" -eq 0 ]; then set -- status; 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
|
||||
|
||||
+34
-6
@@ -24,11 +24,10 @@ else
|
||||
d="$(dirname "$d")"
|
||||
done
|
||||
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
|
||||
if [ "$#" -eq 0 ]; then set -- status; fi
|
||||
|
||||
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
|
||||
@@ -45,7 +44,36 @@ if [ -z "$PY" ]; then
|
||||
echo "[sleep] ERROR: need Python >= 3.10 (found none)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$#" -eq 0 ]; then set -- status; 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