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:
Ismar
2026-07-06 14:19:52 +02:00
parent e4ea6a6771
commit ce55241a89
3 changed files with 261 additions and 48 deletions
+157
View File
@@ -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()