Skip to content
Open
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
66 changes: 47 additions & 19 deletions plugins/claude-code/scripts/run-sleep.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
66 changes: 47 additions & 19 deletions plugins/run-sleep.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
157 changes: 157 additions & 0 deletions tests/test_run_sleep_fallback.py
Original file line number Diff line number Diff line change
@@ -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()