diff --git a/plugins/claude-code/scripts/run-sleep.sh b/plugins/claude-code/scripts/run-sleep.sh index 310d8de7..8d11dcb8 100755 --- a/plugins/claude-code/scripts/run-sleep.sh +++ b/plugins/claude-code/scripts/run-sleep.sh @@ -24,28 +24,56 @@ 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 +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 + 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="" -# 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 +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 - done -fi -if [ -z "$PY" ]; then - echo "[sleep] ERROR: need Python >= 3.10 (found none)." >&2 - exit 1 + fi +done +if [ -n "$PY" ]; then + exec "$PY" -m skillopt_sleep "$@" fi -if [ "$#" -eq 0 ]; then set -- status; fi -cd "$REPO_ROOT" -exec "$PY" -m skillopt_sleep "$@" +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 diff --git a/plugins/run-sleep.sh b/plugins/run-sleep.sh index 310d8de7..8d11dcb8 100755 --- a/plugins/run-sleep.sh +++ b/plugins/run-sleep.sh @@ -24,28 +24,56 @@ 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 +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 + 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="" -# 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 +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 - done -fi -if [ -z "$PY" ]; then - echo "[sleep] ERROR: need Python >= 3.10 (found none)." >&2 - exit 1 + fi +done +if [ -n "$PY" ]; then + exec "$PY" -m skillopt_sleep "$@" fi -if [ "$#" -eq 0 ]; then set -- status; fi -cd "$REPO_ROOT" -exec "$PY" -m skillopt_sleep "$@" +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 diff --git a/tests/test_run_sleep_fallback.py b/tests/test_run_sleep_fallback.py new file mode 100644 index 00000000..dbb000a5 --- /dev/null +++ b/tests/test_run_sleep_fallback.py @@ -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()