From 3c45db3a903c61e9ec0ec73d597187866d4708c5 Mon Sep 17 00:00:00 2001 From: "Sparsh :)" <76697238+SparshGarg999@users.noreply.github.com> Date: Sun, 5 Jul 2026 17:23:51 +0530 Subject: [PATCH 1/3] feat(codex): add Windows support and compatibility for Codex plugin --- plugins/codex/README.md | 9 +++ plugins/codex/install.ps1 | 47 +++++++++++ plugins/codex/skills/skillopt-sleep/SKILL.md | 12 +++ plugins/run-sleep.cmd | 67 ++++++++++++++++ plugins/run-sleep.ps1 | 62 +++++++++++++++ skillopt_sleep/backend.py | 83 ++++++++++++++------ skillopt_sleep/scheduler.py | 2 + tests/test_sleep_engine.py | 53 +++++++++++++ 8 files changed, 311 insertions(+), 24 deletions(-) create mode 100644 plugins/codex/install.ps1 create mode 100644 plugins/run-sleep.cmd create mode 100644 plugins/run-sleep.ps1 diff --git a/plugins/codex/README.md b/plugins/codex/README.md index c5455143..fedf49bd 100644 --- a/plugins/codex/README.md +++ b/plugins/codex/README.md @@ -21,6 +21,7 @@ rules. The shared runner remains a plain shell entrypoint that the skill calls. ## Install +On Linux/macOS: ```bash git clone SkillOpt-Sleep cd SkillOpt-Sleep @@ -28,6 +29,14 @@ bash plugins/codex/install.sh # installs the skill export SKILLOPT_SLEEP_REPO="$(pwd)" # so the runner is found from anywhere ``` +On Windows (PowerShell): +```powershell +git clone SkillOpt-Sleep +cd SkillOpt-Sleep +powershell -File plugins/codex/install.ps1 +[System.Environment]::SetEnvironmentVariable("SKILLOPT_SLEEP_REPO", "$(pwd)", "User") +``` + If a previous install created `~/.codex/prompts/sleep.md`, the installer moves that deprecated prompt aside with a `.skillopt-legacy*.bak` suffix. diff --git a/plugins/codex/install.ps1 b/plugins/codex/install.ps1 new file mode 100644 index 00000000..98ea38e7 --- /dev/null +++ b/plugins/codex/install.ps1 @@ -0,0 +1,47 @@ +# Install the SkillOpt-Sleep Codex integration as a user-level Codex skill on Windows. +# Idempotent; prints what it does. + +$ErrorActionPreference = "Stop" + +$RepoRoot = Resolve-Path (Join-Path $PSScriptRoot "..\..") +$CodexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $env:USERPROFILE ".codex" } +$AgentsSkills = Join-Path $env:USERPROFILE ".agents\skills" +$LegacyPrompt = Join-Path $CodexHome "prompts\sleep.md" + +Write-Output "[install] repo: $RepoRoot" + +# 1) user-level skill +$SkillDir = Join-Path $AgentsSkills "skillopt-sleep" +if (-not (Test-Path $SkillDir)) { + New-Item -ItemType Directory -Path $SkillDir -Force | Out-Null +} +Copy-Item (Join-Path $RepoRoot "plugins\codex\skills\skillopt-sleep\SKILL.md") (Join-Path $SkillDir "SKILL.md") -Force +Write-Output "[install] skill -> $(Join-Path $SkillDir 'SKILL.md')" + +# 2) retire the old custom prompt entrypoint from previous installs +if (Test-Path $LegacyPrompt) { + $Backup = "${LegacyPrompt}.skillopt-legacy.bak" + if (Test-Path $Backup) { + $DateStr = Get-Date -Format "yyyyMMddHHmmss" + $Backup = "${LegacyPrompt}.skillopt-legacy.${DateStr}.bak" + } + Move-Item $LegacyPrompt $Backup -Force + Write-Output "[install] legacy prompt -> $Backup" +} + +# 3) record the repo location so the runner is found from anywhere +Write-Output "[install] add to your environment variables:" +Write-Output " [System.Environment]::SetEnvironmentVariable('SKILLOPT_SLEEP_REPO', '$RepoRoot', 'User')" +Write-Output " Or set it via System Properties." + +# 4) optional: append an AGENTS.md hint (only if the user opts in) +Write-Output "" +Write-Output "[install] Optional — add this to ~/.codex/AGENTS.md so Codex always knows the tool:" +Write-Output "" +Write-Output " ## SkillOpt-Sleep" +Write-Output " Use the skillopt-sleep skill when I ask to run a sleep/dream/offline" +Write-Output " self-improvement cycle. The runner is:" +Write-Output " \`powershell -File `"$RepoRoot\plugins\run-sleep.ps1`" status --project `"\$(pwd)\`"\`." +Write-Output "" +Write-Output "Done. Try asking Codex:" +Write-Output " Use the skillopt-sleep skill to run status for this project." diff --git a/plugins/codex/skills/skillopt-sleep/SKILL.md b/plugins/codex/skills/skillopt-sleep/SKILL.md index 44745ddd..e269b554 100644 --- a/plugins/codex/skills/skillopt-sleep/SKILL.md +++ b/plugins/codex/skills/skillopt-sleep/SKILL.md @@ -52,6 +52,18 @@ bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" run --project "$(pwd)" --source bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" adopt --project "$(pwd)" ``` +On Windows (CMD / PowerShell): +```cmd +:: CMD +set SKILLOPT_SLEEP_REPO=C:\path\to\SkillOpt-Sleep +"%SKILLOPT_SLEEP_REPO%\plugins\run-sleep.cmd" status --project "%CD%" +``` +```powershell +# PowerShell +$env:SKILLOPT_SLEEP_REPO = "C:\path\to\SkillOpt-Sleep" +powershell -File "$env:SKILLOPT_SLEEP_REPO\plugins\run-sleep.ps1" status --project "$(pwd)" +``` + Actions are `status`, `harvest`, `dry-run`, `run`, `adopt`, `schedule`, and `unschedule`. - Default backend is `mock`, which is deterministic and spends no API budget. diff --git a/plugins/run-sleep.cmd b/plugins/run-sleep.cmd new file mode 100644 index 00000000..ca099958 --- /dev/null +++ b/plugins/run-sleep.cmd @@ -0,0 +1,67 @@ +@echo off +setlocal enabledelayedexpansion + +:: Resolve REPO_ROOT +set "SCRIPT_DIR=%~dp0" +:: Strip trailing backslash +set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%" + +if exist "%SCRIPT_DIR%\..\skillopt_sleep" ( + cd /d "%SCRIPT_DIR%\.." + set "REPO_ROOT=%CD%" +) else if not "%CLAUDE_PLUGIN_ROOT%"=="" if exist "%CLAUDE_PLUGIN_ROOT%\..\..\skillopt_sleep" ( + cd /d "%CLAUDE_PLUGIN_ROOT%\..\.." + set "REPO_ROOT=%CD%" +) else if not "%SKILLOPT_SLEEP_REPO%"=="" if exist "%SKILLOPT_SLEEP_REPO%\skillopt_sleep" ( + set "REPO_ROOT=%SKILLOPT_SLEEP_REPO%" +) else ( + :: Search upward from current directory + set "d=%CD%" + set "REPO_ROOT=" + :loop + if exist "!d!\skillopt_sleep" ( + set "REPO_ROOT=!d!" + goto found + ) + for %%I in ("!d!") do set "parent=%%~dpI" + :: Strip trailing backslash from parent if it's not root + if "!parent!"=="!d!" goto notfound + set "parent=!parent:~0,-1!" + if "!parent!"=="" goto notfound + set "d=!parent!" + goto loop + :notfound + echo [sleep] ERROR: could not locate the skillopt_sleep package. Set SKILLOPT_SLEEP_REPO to the repo root. >&2 + exit /b 1 + :found +) + +:: Find python >= 3.10 +set "PY=" +if not "%SKILLOPT_SLEEP_PYTHON%"=="" ( + set "PY=%SKILLOPT_SLEEP_PYTHON%" +) else ( + for %%p in (python3.exe python.exe py.exe) do ( + where %%p >nul 2>nul + if !errorlevel! equ 0 ( + %%p -c "import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)" >nul 2>nul + if !errorlevel! equ 0 ( + set "PY=%%p" + goto py_found + ) + ) + ) +) +:py_found + +if "%PY%"=="" ( + echo [sleep] ERROR: need Python >= 3.10 (found none). >&2 + exit /b 1 +) + +cd /d "%REPO_ROOT%" +if "%~1"=="" ( + "%PY%" -m skillopt_sleep status +) else ( + "%PY%" -m skillopt_sleep %* +) diff --git a/plugins/run-sleep.ps1 b/plugins/run-sleep.ps1 new file mode 100644 index 00000000..0c2caf1c --- /dev/null +++ b/plugins/run-sleep.ps1 @@ -0,0 +1,62 @@ +# SkillOpt-Sleep shared runner for Windows PowerShell +# Resolves the repo root, picks Python >= 3.10, and runs the engine CLI. +# +# Usage: .\run-sleep.ps1 [status|run|dry-run|adopt|...] [args...] + +$ErrorActionPreference = "Stop" + +$ScriptDir = $PSScriptRoot + +$RepoRoot = $null +if (Test-Path (Join-Path $ScriptDir "..\skillopt_sleep")) { + $RepoRoot = Resolve-Path (Join-Path $ScriptDir "..") +} elseif ($env:CLAUDE_PLUGIN_ROOT -and (Test-Path (Join-Path $env:CLAUDE_PLUGIN_ROOT "..\..\skillopt_sleep"))) { + $RepoRoot = Resolve-Path (Join-Path $env:CLAUDE_PLUGIN_ROOT "..\..") +} elseif ($env:SKILLOPT_SLEEP_REPO -and (Test-Path (Join-Path $env:SKILLOPT_SLEEP_REPO "skillopt_sleep"))) { + $RepoRoot = Resolve-Path $env:SKILLOPT_SLEEP_REPO +} else { + # search upward from current location + $d = Get-Item . + while ($d -and $d.FullName -ne $d.Root.FullName) { + if (Test-Path (Join-Path $d.FullName "skillopt_sleep")) { + $RepoRoot = $d.FullName + break + } + $d = Split-Path -Parent $d.FullName -ErrorAction SilentlyContinue | Get-Item -ErrorAction SilentlyContinue + } +} + +if (-not $RepoRoot) { + Write-Error "[sleep] ERROR: could not locate the skillopt_sleep package. Set SKILLOPT_SLEEP_REPO to the repo root." + exit 1 +} + +$Py = "" +if ($env:SKILLOPT_SLEEP_PYTHON) { + $Py = $env:SKILLOPT_SLEEP_PYTHON +} else { + foreach ($cand in @("python3", "python", "py")) { + $cmd = Get-Command $cand -ErrorAction SilentlyContinue + if ($cmd) { + $ver = & $cand -c "import sys; print('%d%d' % sys.version_info[:2])" 2>$null + if ($ver -and [int]$ver -ge 310) { + $Py = $cand + break + } + } + } +} + +if (-not $Py) { + Write-Error "[sleep] ERROR: need Python >= 3.10 (found none)." + exit 1 +} + +$argsList = @($args) +if ($argsList.Count -eq 0) { + $argsList = @("status") +} + +Set-Location $RepoRoot +# Run using the call operator +& $Py -m skillopt_sleep $argsList diff --git a/skillopt_sleep/backend.py b/skillopt_sleep/backend.py index d7bb040b..72778f98 100644 --- a/skillopt_sleep/backend.py +++ b/skillopt_sleep/backend.py @@ -735,14 +735,27 @@ def resolve_codex_path(explicit: str = "") -> str: env = os.environ.get("SKILLOPT_SLEEP_CODEX_PATH") if env: return env - candidates = [ - os.path.expanduser("~/.nvm/versions/node/v22.22.3/bin/codex"), - ] - # any nvm node version - nvm = os.path.expanduser("~/.nvm/versions/node") - if os.path.isdir(nvm): - for ver in sorted(os.listdir(nvm), reverse=True): - candidates.append(os.path.join(nvm, ver, "bin", "codex")) + import sys + candidates = [] + if sys.platform == "win32": + appdata = os.environ.get("APPDATA") + if appdata: + candidates.append(os.path.join(appdata, "npm", "codex.cmd")) + userprofile = os.environ.get("USERPROFILE") + if userprofile: + candidates.append(os.path.join(userprofile, "AppData", "Roaming", "npm", "codex.cmd")) + nvm_home = os.environ.get("NVM_HOME") + if nvm_home: + candidates.append(os.path.join(nvm_home, "codex.cmd")) + else: + candidates = [ + os.path.expanduser("~/.nvm/versions/node/v22.22.3/bin/codex"), + ] + # any nvm node version + nvm = os.path.expanduser("~/.nvm/versions/node") + if os.path.isdir(nvm): + for ver in sorted(os.listdir(nvm), reverse=True): + candidates.append(os.path.join(nvm, ver, "bin", "codex")) for c in candidates: if not c or not os.path.exists(c): continue @@ -886,23 +899,45 @@ def attempt_with_tools(self, task, skill, memory, tools): work = tempfile.mkdtemp(prefix="skillopt_sleep_codextools_") calllog = os.path.join(work, "_tool_calls.log") out_path = os.path.join(work, "_last.txt") + tool_names = tools or ["search"] + is_windows = os.name == "nt" try: - for tname in (tools or ["search"]): - shim = os.path.join(work, tname) - with open(shim, "w") as f: - f.write( - "#!/usr/bin/env bash\n" - f'echo "{tname}" >> "{calllog}"\n' - 'echo "(search results: 3 relevant notes found; use them to answer)"\n' - ) - os.chmod(shim, os.stat(shim).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) - tool_hint = ( - "Shell tools are available in the working directory: " - + ", ".join(f"./{t}" for t in (tools or ["search"])) - + ". When the skill says to look something up or search before " - "answering, you MUST actually run the tool (e.g. `./search \"query\"`) " - "before giving your final answer." - ) + for tname in tool_names: + if is_windows: + shim = os.path.join(work, f"{tname}.cmd") + with open(shim, "w") as f: + f.write( + "@echo off\n" + f'echo %~n0>>"{calllog}"\n' + "echo (search results: 3 relevant notes found; use them to answer)\n" + ) + else: + shim = os.path.join(work, tname) + with open(shim, "w") as f: + f.write( + "#!/usr/bin/env bash\n" + f'echo "{tname}" >> "{calllog}"\n' + 'echo "(search results: 3 relevant notes found; use them to answer)"\n' + ) + os.chmod(shim, os.stat(shim).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + if is_windows: + tool_hint = ( + "Shell tools are available in the working directory: " + + ", ".join(f"{t}.cmd" for t in tool_names) + + " (each callable as `" + tool_names[0] + "` or `.\\" + + tool_names[0] + "`). When the skill says to look something " + "up or search before answering, you MUST actually run the " + "tool (e.g. `" + tool_names[0] + " \"query\"`) before giving " + "your final answer." + ) + else: + tool_hint = ( + "Shell tools are available in the working directory: " + + ", ".join(f"./{t}" for t in tool_names) + + ". When the skill says to look something up or search before " + "answering, you MUST actually run the tool (e.g. `./search \"query\"`) " + "before giving your final answer." + ) prompt = ( "Complete the task. Apply the skill and memory rules EXACTLY, " "including any rule about searching before answering. Treat a " diff --git a/skillopt_sleep/scheduler.py b/skillopt_sleep/scheduler.py index 3b32cb44..ec5336c2 100644 --- a/skillopt_sleep/scheduler.py +++ b/skillopt_sleep/scheduler.py @@ -71,6 +71,8 @@ def _runner_cmd(project: str, backend: str, extra: str, python: str) -> str: # use absolute python + -m so cron's minimal env still works cmd = (f'{python} -m skillopt_sleep run --project "{project}" ' f'--scope invoked --backend {backend} {extra}'.rstrip()) + if sys.platform == "win32": + return f'if not exist "{logdir}" mkdir "{logdir}" && cd /d "{_repo_root()}" && {cmd} >> "{log}" 2>&1' return f'mkdir -p "{logdir}"; cd "{_repo_root()}" && {cmd} >> "{log}" 2>&1' diff --git a/tests/test_sleep_engine.py b/tests/test_sleep_engine.py index 46e88e52..f9466f4c 100644 --- a/tests/test_sleep_engine.py +++ b/tests/test_sleep_engine.py @@ -767,6 +767,59 @@ class Proc: self.assertIn("exited 1", be.last_call_error) # failure surfaced for diagnostics self.assertEqual(called, []) # no tool actually ran + def test_codex_resolve_path_windows(self): + from skillopt_sleep.backend import resolve_codex_path + with mock.patch("sys.platform", "win32"), \ + mock.patch.dict("os.environ", { + "APPDATA": r"C:\Users\Sparsh\AppData\Roaming", + "USERPROFILE": r"C:\Users\Sparsh", + "NVM_HOME": r"C:\Users\Sparsh\nvm" + }), \ + mock.patch("os.path.exists", return_value=True): + path = resolve_codex_path("") + self.assertEqual(path, r"C:\Users\Sparsh\AppData\Roaming\npm\codex.cmd") + + def test_codex_attempt_with_tools_windows(self): + from skillopt_sleep.backend import CodexCliBackend + be = CodexCliBackend(codex_path="codex") + task = TaskRecord(id="t", project="/p", intent="answer the question", + reference_kind="rule", + judge={"checks": [{"op": "tool_called", "arg": "search"}]}) + calls = [] + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + class Proc: + returncode = 0 + stdout = "" + stderr = "" + return Proc() + + with mock.patch("os.name", "nt"), \ + mock.patch("shutil.rmtree"), \ + mock.patch("skillopt_sleep.backend.subprocess.run", side_effect=fake_run): + orig_mkdtemp = tempfile.mkdtemp + temp_dirs = [] + def fake_mkdtemp(*args, **kwargs): + d = orig_mkdtemp(*args, **kwargs) + temp_dirs.append(d) + return d + with mock.patch("tempfile.mkdtemp", side_effect=fake_mkdtemp): + be.attempt_with_tools(task, "", "", ["search"]) + + self.assertEqual(len(temp_dirs), 1) + work_dir = temp_dirs[0] + shim_path = os.path.join(work_dir, "search.cmd") + try: + self.assertTrue(os.path.exists(shim_path)) + with open(shim_path, "r") as f: + content = f.read() + self.assertIn("@echo off", content) + self.assertIn("%~n0", content) + finally: + import shutil + shutil.rmtree(work_dir, ignore_errors=True) + + class TestMultiRolloutAndBudget(unittest.TestCase): def test_rolloutset_stats(self): From 79d6117f6f1435c14977af318e97eca48c6c10c1 Mon Sep 17 00:00:00 2001 From: "Sparsh :)" <76697238+SparshGarg999@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:20:30 +0530 Subject: [PATCH 2/3] fix: address PR reviews, add scheduler Windows tasks and Pester coverage --- plugins/run-sleep.cmd | 83 +++++++++++++++++----- plugins/run-sleep.ps1 | 76 ++++++++++++++------ skillopt_sleep/backend.py | 20 ++++-- skillopt_sleep/scheduler.py | 108 +++++++++++++++++++++++----- tests/test_alfworld_paths.py | 2 +- tests/test_run_sleep_fallback.py | 3 + tests/test_runners.Tests.ps1 | 118 +++++++++++++++++++++++++++++++ tests/test_scheduler_windows.py | 63 +++++++++++++++++ tests/test_sleep_engine.py | 1 + 9 files changed, 408 insertions(+), 66 deletions(-) create mode 100644 tests/test_runners.Tests.ps1 create mode 100644 tests/test_scheduler_windows.py diff --git a/plugins/run-sleep.cmd b/plugins/run-sleep.cmd index ca099958..7767446d 100644 --- a/plugins/run-sleep.cmd +++ b/plugins/run-sleep.cmd @@ -36,32 +36,79 @@ if exist "%SCRIPT_DIR%\..\skillopt_sleep" ( :found ) -:: Find python >= 3.10 +if not "%REPO_ROOT%"=="" ( + :: Find python >= 3.10 + set "PY=" + if not "%SKILLOPT_SLEEP_PYTHON%"=="" ( + set "PY=%SKILLOPT_SLEEP_PYTHON%" + ) else ( + for %%p in (python3.exe python.exe py.exe) do ( + where %%p >nul 2>nul + if !errorlevel! equ 0 ( + %%p -c "import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)" >nul 2>nul + if !errorlevel! equ 0 ( + set "PY=%%p" + goto py_found + ) + ) + ) + ) + :py_found + + if "%PY%"=="" ( + echo [sleep] ERROR: need Python >= 3.10 (found none). >&2 + exit /b 1 + ) + + cd /d "%REPO_ROOT%" + if "%~1"=="" ( + "%PY%" -m skillopt_sleep status + ) else ( + "%PY%" -m skillopt_sleep %* + ) + exit /b !errorlevel! +) + +:: No source checkout found — fall back to an installed engine. + +:: Fallback 1: skillopt-sleep CLI on PATH (uv tool install / pipx / pip install). +where skillopt-sleep >nul 2>nul +if !errorlevel! equ 0 ( + if "%~1"=="" ( + skillopt-sleep status + ) else ( + skillopt-sleep %* + ) + exit /b !errorlevel! +) + +:: Fallback 2: importable as a module (pip install into the active Python). set "PY=" -if not "%SKILLOPT_SLEEP_PYTHON%"=="" ( - set "PY=%SKILLOPT_SLEEP_PYTHON%" -) else ( - for %%p in (python3.exe python.exe py.exe) do ( - where %%p >nul 2>nul +for %%p in (python3.exe python.exe py.exe) do ( + where %%p >nul 2>nul + if !errorlevel! equ 0 ( + %%p -c "import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)" >nul 2>nul if !errorlevel! equ 0 ( - %%p -c "import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)" >nul 2>nul + %%p -c "import skillopt_sleep" >nul 2>nul if !errorlevel! equ 0 ( set "PY=%%p" - goto py_found + goto py_import_found ) ) ) ) -:py_found +:py_import_found -if "%PY%"=="" ( - echo [sleep] ERROR: need Python >= 3.10 (found none). >&2 - exit /b 1 +if not "%PY%"=="" ( + if "%~1"=="" ( + "%PY%" -m skillopt_sleep status + ) else ( + "%PY%" -m skillopt_sleep %* + ) + exit /b !errorlevel! ) -cd /d "%REPO_ROOT%" -if "%~1"=="" ( - "%PY%" -m skillopt_sleep status -) else ( - "%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 /b 1 diff --git a/plugins/run-sleep.ps1 b/plugins/run-sleep.ps1 index 0c2caf1c..065506f9 100644 --- a/plugins/run-sleep.ps1 +++ b/plugins/run-sleep.ps1 @@ -26,20 +26,56 @@ if (Test-Path (Join-Path $ScriptDir "..\skillopt_sleep")) { } } -if (-not $RepoRoot) { - Write-Error "[sleep] ERROR: could not locate the skillopt_sleep package. Set SKILLOPT_SLEEP_REPO to the repo root." - exit 1 +$argsList = @($args) +if ($argsList.Count -eq 0) { + $argsList = @("status") +} + +if ($RepoRoot) { + $Py = "" + if ($env:SKILLOPT_SLEEP_PYTHON) { + $Py = $env:SKILLOPT_SLEEP_PYTHON + } else { + foreach ($cand in @("python3", "python", "py")) { + $cmd = Get-Command $cand -ErrorAction SilentlyContinue + if ($cmd) { + $ver = & $cand -c "import sys; print('%d%d' % sys.version_info[:2])" 2>$null + if ($ver -and [int]$ver -ge 310) { + $Py = $cand + break + } + } + } + } + + if (-not $Py) { + [Console]::Error.WriteLine("[sleep] ERROR: need Python >= 3.10 (found none).") + exit 1 + } + + Set-Location $RepoRoot + & $Py -m skillopt_sleep $argsList + exit $LASTEXITCODE } +# No source checkout found — fall back to an installed engine. + +# Fallback 1: skillopt-sleep CLI on PATH (uv tool install / pipx / pip install). +$cliCmd = Get-Command "skillopt-sleep" -ErrorAction SilentlyContinue +if ($cliCmd) { + & skillopt-sleep $argsList + exit $LASTEXITCODE +} + +# Fallback 2: importable as a module (pip install into the active Python). $Py = "" -if ($env:SKILLOPT_SLEEP_PYTHON) { - $Py = $env:SKILLOPT_SLEEP_PYTHON -} else { - foreach ($cand in @("python3", "python", "py")) { - $cmd = Get-Command $cand -ErrorAction SilentlyContinue - if ($cmd) { - $ver = & $cand -c "import sys; print('%d%d' % sys.version_info[:2])" 2>$null - if ($ver -and [int]$ver -ge 310) { +foreach ($cand in @("python3", "python", "py")) { + $cmd = Get-Command $cand -ErrorAction SilentlyContinue + if ($cmd) { + $ver = & $cand -c "import sys; print('%d%d' % sys.version_info[:2])" 2>$null + if ($ver -and [int]$ver -ge 310) { + $hasModule = & $cand -c "import skillopt_sleep" 2>$null + if ($LASTEXITCODE -eq 0) { $Py = $cand break } @@ -47,16 +83,12 @@ if ($env:SKILLOPT_SLEEP_PYTHON) { } } -if (-not $Py) { - Write-Error "[sleep] ERROR: need Python >= 3.10 (found none)." - exit 1 -} - -$argsList = @($args) -if ($argsList.Count -eq 0) { - $argsList = @("status") +if ($Py) { + & $Py -m skillopt_sleep $argsList + exit $LASTEXITCODE } -Set-Location $RepoRoot -# Run using the call operator -& $Py -m skillopt_sleep $argsList +[Console]::Error.WriteLine("[sleep] ERROR: could not locate the skillopt_sleep package.") +[Console]::Error.WriteLine("[sleep] Install it with 'uv tool install skillopt' or 'pip install skillopt',") +[Console]::Error.WriteLine("[sleep] or set SKILLOPT_SLEEP_REPO to a clone of the SkillOpt repo.") +exit 1 diff --git a/skillopt_sleep/backend.py b/skillopt_sleep/backend.py index 72778f98..10de6699 100644 --- a/skillopt_sleep/backend.py +++ b/skillopt_sleep/backend.py @@ -736,21 +736,29 @@ def resolve_codex_path(explicit: str = "") -> str: if env: return env import sys + import shutil candidates = [] + + # Try shutil.which("codex") first so PATH, pnpm, Volta, etc. work. + which_codex = shutil.which("codex") + if which_codex: + candidates.append(which_codex) + if sys.platform == "win32": + import ntpath appdata = os.environ.get("APPDATA") if appdata: - candidates.append(os.path.join(appdata, "npm", "codex.cmd")) + candidates.append(ntpath.join(appdata, "npm", "codex.cmd")) userprofile = os.environ.get("USERPROFILE") if userprofile: - candidates.append(os.path.join(userprofile, "AppData", "Roaming", "npm", "codex.cmd")) + candidates.append(ntpath.join(userprofile, "AppData", "Roaming", "npm", "codex.cmd")) nvm_home = os.environ.get("NVM_HOME") if nvm_home: - candidates.append(os.path.join(nvm_home, "codex.cmd")) + candidates.append(ntpath.join(nvm_home, "codex.cmd")) else: - candidates = [ + candidates.extend([ os.path.expanduser("~/.nvm/versions/node/v22.22.3/bin/codex"), - ] + ]) # any nvm node version nvm = os.path.expanduser("~/.nvm/versions/node") if os.path.isdir(nvm): @@ -768,7 +776,7 @@ def resolve_codex_path(explicit: str = "") -> str: except Exception: pass return c - return "codex" # last resort (may be the wrapper) + return "codex" class CodexCliBackend(CliBackend): diff --git a/skillopt_sleep/scheduler.py b/skillopt_sleep/scheduler.py index ec5336c2..93e61df6 100644 --- a/skillopt_sleep/scheduler.py +++ b/skillopt_sleep/scheduler.py @@ -1,19 +1,7 @@ """SkillOpt-Sleep — built-in nightly scheduler. -Installs/removes a crontab entry that runs the sleep cycle automatically, so the -user doesn't have to wire cron themselves. Idempotent: a managed block delimited -by marker comments is added/replaced/removed in the user's crontab. - -Design choices: - * Off-:00 minute (3:17 local by default) so many users don't all hit the API - at the same instant. - * The entry runs `python -m skillopt_sleep run` for a specific project and - appends to /.skillopt-sleep/cron.log. - * `schedule` is additive per project (keyed by project path); `unschedule` - removes the project's line (or the whole managed block with --all). - -cron is the portable mechanism on Linux/macOS. On systems without `crontab`, -`schedule` prints the line and instructions instead of failing. +Installs/removes a crontab entry (on Unix) or a Scheduled Task (on Windows) that +runs the sleep cycle automatically, so the user doesn't have to wire it manually. """ from __future__ import annotations @@ -65,14 +53,62 @@ def _split_managed(crontab: str) -> Tuple[str, List[str]]: return "\n".join(outside).rstrip(), managed +def _have_schtasks() -> bool: + return shutil.which("schtasks") is not None + + +def _win_task_name(project: str) -> str: + project = os.path.abspath(project) + safe = project.replace(":\\", "_").replace("\\", "_").replace("/", "_").replace(" ", "_") + return f"SkillOpt-Sleep-{safe}" + + +def _create_win_task(task_name: str, command: str, hour: int, minute: int) -> bool: + try: + st = f"{hour:02d}:{minute:02d}" + cmd = ["schtasks", "/create", "/tn", task_name, "/tr", command, "/sc", "daily", "/st", st, "/f"] + proc = subprocess.run(cmd, capture_output=True, text=True) + return proc.returncode == 0 + except Exception: + return False + + +def _delete_win_task(task_name: str) -> bool: + try: + cmd = ["schtasks", "/delete", "/tn", task_name, "/f"] + proc = subprocess.run(cmd, capture_output=True, text=True) + return proc.returncode == 0 + except Exception: + return False + + +def _list_win_tasks() -> List[str]: + try: + proc = subprocess.run(["schtasks", "/query", "/fo", "csv"], capture_output=True, text=True) + if proc.returncode != 0: + return [] + tasks = [] + for line in proc.stdout.splitlines(): + if not line.startswith('"'): + continue + parts = line.split(",") + if len(parts) > 0: + name = parts[0].strip('"') + if name.startswith("SkillOpt-Sleep-"): + tasks.append(name) + return tasks + except Exception: + return [] + + def _runner_cmd(project: str, backend: str, extra: str, python: str) -> str: logdir = os.path.join(project, ".skillopt-sleep") log = os.path.join(logdir, "cron.log") - # use absolute python + -m so cron's minimal env still works + # use absolute python + -m so cron's/scheduler's minimal env still works cmd = (f'{python} -m skillopt_sleep run --project "{project}" ' f'--scope invoked --backend {backend} {extra}'.rstrip()) if sys.platform == "win32": - return f'if not exist "{logdir}" mkdir "{logdir}" && cd /d "{_repo_root()}" && {cmd} >> "{log}" 2>&1' + return f'cmd.exe /c "if not exist \\"{logdir}\\" mkdir \\"{logdir}\\" && cd /d \\"{_repo_root()}\\" && {cmd} >> \\"{log}\\" 2>&1"' return f'mkdir -p "{logdir}"; cd "{_repo_root()}" && {cmd} >> "{log}" 2>&1' @@ -89,12 +125,26 @@ def schedule(project: str, *, backend: str = "mock", hour: int = 3, minute: int extra: str = "", python: Optional[str] = None) -> Tuple[bool, str]: """Install (or replace) the nightly entry for ``project``. - Returns (installed, message). If crontab is unavailable, installed=False and - the message contains the line to add manually. + Returns (installed, message). If the scheduler backend is unavailable, installed=False and + the message contains instructions to add manually. """ project = os.path.abspath(project) python = python or sys.executable or "python3" - cron_line = f"{minute} {hour} * * * {_runner_cmd(project, backend, extra, python)} {_project_marker(project)}" + runner_cmd = _runner_cmd(project, backend, extra, python) + + if sys.platform == "win32": + if not _have_schtasks(): + return False, "schtasks.exe not found on this system. Add this command to your scheduler manually:\n" + runner_cmd + tn = _win_task_name(project) + ok = _create_win_task(tn, runner_cmd, hour, minute) + if ok: + return True, (f"Scheduled nightly at {hour:02d}:{minute:02d} for {project} " + f"(backend={backend}) via Windows Task Scheduler. Task Name: {tn}\n" + f"Logs -> {project}/.skillopt-sleep/cron.log\n" + f"Runs `skillopt_sleep run`; it only STAGES a proposal — adopt is still manual.") + return False, f"Failed to write scheduled task. Command to run manually:\nschtasks /create /tn \"{tn}\" /tr \"{runner_cmd}\" /sc daily /st {hour:02d}:{minute:02d} /f" + + cron_line = f"{minute} {hour} * * * {runner_cmd} {_project_marker(project)}" if not _have_crontab(): return False, ("crontab not found on this system. Add this line to your " @@ -118,6 +168,22 @@ def schedule(project: str, *, backend: str = "mock", hour: int = 3, minute: int def unschedule(project: Optional[str] = None, *, all_projects: bool = False) -> Tuple[bool, str]: """Remove the entry for ``project`` (or the whole managed block with all_projects).""" + if sys.platform == "win32": + if not _have_schtasks(): + return False, "schtasks.exe not found on this system." + if all_projects: + tasks = _list_win_tasks() + ok = True + for t in tasks: + if not _delete_win_task(t): + ok = False + return ok, ("Removed all scheduled tasks." if ok else "Failed to remove some tasks.") + elif project: + tn = _win_task_name(project) + ok = _delete_win_task(tn) + return ok, ("Removed." if ok else "Failed to remove scheduled task (does it exist?).") + return False, "No project specified to unschedule." + if not _have_crontab(): return False, "crontab not found; nothing to remove." outside, managed = _split_managed(_read_crontab()) @@ -136,5 +202,9 @@ def unschedule(project: Optional[str] = None, *, all_projects: bool = False) -> def list_scheduled() -> List[str]: + if sys.platform == "win32": + if not _have_schtasks(): + return [] + return _list_win_tasks() _outside, managed = _split_managed(_read_crontab()) return [ln for ln in managed if ln.strip()] diff --git a/tests/test_alfworld_paths.py b/tests/test_alfworld_paths.py index eb0229bc..904ea00e 100644 --- a/tests/test_alfworld_paths.py +++ b/tests/test_alfworld_paths.py @@ -9,7 +9,7 @@ def test_resolve_alfworld_gamefile_uses_alfworld_data_for_relative_paths(monkeyp resolved = _resolve_alfworld_gamefile("json_2.1.1/valid_seen/task/game.tw-pddl") - assert resolved == os.path.join(str(data_root), "json_2.1.1/valid_seen/task/game.tw-pddl") + assert resolved == os.path.normpath(os.path.join(str(data_root), "json_2.1.1/valid_seen/task/game.tw-pddl")) def test_resolve_alfworld_gamefile_keeps_absolute_paths(monkeypatch, tmp_path): diff --git a/tests/test_run_sleep_fallback.py b/tests/test_run_sleep_fallback.py index dbb000a5..9d89dcc7 100644 --- a/tests/test_run_sleep_fallback.py +++ b/tests/test_run_sleep_fallback.py @@ -31,6 +31,7 @@ def _run(script, args, env, cwd): return proc.returncode, proc.stdout, proc.stderr +@unittest.skipIf(sys.platform == "win32", "Unix bash runner tests require POSIX/bash environment") class TestRunnerSourceCheckout(unittest.TestCase): """When skillopt_sleep/ is found in the repo, the source-checkout path is used.""" @@ -46,6 +47,7 @@ def test_source_checkout_runs_engine(self): self.assertIn("status", out) +@unittest.skipIf(sys.platform == "win32", "Unix bash runner tests require POSIX/bash environment") class TestRunnerCliFallback(unittest.TestCase): """When no source dir is found, fall back to the skillopt-sleep CLI on PATH.""" @@ -101,6 +103,7 @@ def test_bundled_copy_also_uses_cli_fallback(self): self.assertIn("fake-cli invoked: status", out) +@unittest.skipIf(sys.platform == "win32", "Unix bash runner tests require POSIX/bash environment") class TestRunnerNoEngine(unittest.TestCase): """When no source dir, no CLI on PATH, and no importable package, error cleanly.""" diff --git a/tests/test_runners.Tests.ps1 b/tests/test_runners.Tests.ps1 new file mode 100644 index 00000000..71e6249e --- /dev/null +++ b/tests/test_runners.Tests.ps1 @@ -0,0 +1,118 @@ +Describe "run-sleep.ps1 runner" { + BeforeAll { + $script = Join-Path $PSScriptRoot "..\plugins\run-sleep.ps1" + $tempDir = [System.IO.Path]::GetTempFileName() + Remove-Item $tempDir + New-Item -ItemType Directory -Path $tempDir | Out-Null + + # Detect a working python to bypass Windows Store alias issues + if (-not $env:SKILLOPT_SLEEP_PYTHON) { + if (Test-Path "C:\Python314\python.exe") { + $env:SKILLOPT_SLEEP_PYTHON = "C:\Python314\python.exe" + } elseif (Test-Path "C:\Python313\python.exe") { + $env:SKILLOPT_SLEEP_PYTHON = "C:\Python313\python.exe" + } elseif (Test-Path "C:\Python312\python.exe") { + $env:SKILLOPT_SLEEP_PYTHON = "C:\Python312\python.exe" + } elseif (Test-Path "C:\Python311\python.exe") { + $env:SKILLOPT_SLEEP_PYTHON = "C:\Python311\python.exe" + } elseif (Test-Path "C:\Python310\python.exe") { + $env:SKILLOPT_SLEEP_PYTHON = "C:\Python310\python.exe" + } + } + } + + AfterAll { + if (Test-Path $tempDir) { + Remove-Item -Recurse -Force $tempDir + } + } + + It "runs successfully in source checkout mode" { + $env:SKILLOPT_SLEEP_REPO = Resolve-Path (Join-Path $PSScriptRoot "..") + # Run help + $result = powershell -File $script "--help" 2>&1 + $LASTEXITCODE | Should Be 0 + $result | Out-String | Should Match "skillopt_sleep" + } + + It "falls back to CLI on PATH" { + # Create a temp dir for isolated testing + $sandbox = Join-Path $tempDir "cli_fallback" + New-Item -ItemType Directory -Path $sandbox | Out-Null + $scriptCopy = Join-Path $sandbox "run-sleep.ps1" + Copy-Item $script $scriptCopy + + # Create fake CLI + $binDir = Join-Path $sandbox "bin" + New-Item -ItemType Directory -Path $binDir | Out-Null + $fakeCli = Join-Path $binDir "skillopt-sleep.cmd" + "@echo off`r`necho fake-cli invoked %*`r`nexit /b 0" | Out-File -FilePath $fakeCli -Encoding ascii + + # Save existing env vars + $oldPath = $env:PATH + $oldRepo = $env:SKILLOPT_SLEEP_REPO + $oldPlugin = $env:CLAUDE_PLUGIN_ROOT + + $env:PATH = "$binDir;$oldPath" + $env:SKILLOPT_SLEEP_REPO = $null + $env:CLAUDE_PLUGIN_ROOT = $null + + $oldLocation = Get-Location + Set-Location $sandbox + try { + $result = powershell -File $scriptCopy "status" 2>&1 + $LASTEXITCODE | Should Be 0 + $result | Out-String | Should Match "fake-cli invoked status" + } + finally { + Set-Location $oldLocation + $env:PATH = $oldPath + $env:SKILLOPT_SLEEP_REPO = $oldRepo + $env:CLAUDE_PLUGIN_ROOT = $oldPlugin + } + } + + It "propagates exit code on failure" { + $env:SKILLOPT_SLEEP_REPO = Resolve-Path (Join-Path $PSScriptRoot "..") + # Run a non-existent command to trigger python module failure + $result = powershell -File $script "non-existent-subcommand" 2>&1 + $LASTEXITCODE | Should Not Be 0 + } + + It "handles paths containing spaces correctly" { + # Create path containing spaces + $spaceDir = Join-Path $tempDir "path with spaces" + New-Item -ItemType Directory -Path $spaceDir | Out-Null + $scriptCopy = Join-Path $spaceDir "run-sleep.ps1" + Copy-Item $script $scriptCopy + + # Create fake CLI + $binDir = Join-Path $spaceDir "bin" + New-Item -ItemType Directory -Path $binDir | Out-Null + $fakeCli = Join-Path $binDir "skillopt-sleep.cmd" + "@echo off`r`necho fake-cli invoked %*`r`nexit /b 0" | Out-File -FilePath $fakeCli -Encoding ascii + + # Save env + $oldPath = $env:PATH + $oldRepo = $env:SKILLOPT_SLEEP_REPO + $oldPlugin = $env:CLAUDE_PLUGIN_ROOT + + $env:PATH = "$binDir;$oldPath" + $env:SKILLOPT_SLEEP_REPO = $null + $env:CLAUDE_PLUGIN_ROOT = $null + + $oldLocation = Get-Location + Set-Location $spaceDir + try { + $result = powershell -File $scriptCopy "status" 2>&1 + $LASTEXITCODE | Should Be 0 + $result | Out-String | Should Match "fake-cli invoked status" + } + finally { + Set-Location $oldLocation + $env:PATH = $oldPath + $env:SKILLOPT_SLEEP_REPO = $oldRepo + $env:CLAUDE_PLUGIN_ROOT = $oldPlugin + } + } +} diff --git a/tests/test_scheduler_windows.py b/tests/test_scheduler_windows.py new file mode 100644 index 00000000..f71f5ea4 --- /dev/null +++ b/tests/test_scheduler_windows.py @@ -0,0 +1,63 @@ +import os +import sys +import unittest +from unittest import mock + +class TestSchedulerWindows(unittest.TestCase): + @mock.patch("sys.platform", "win32") + @mock.patch("shutil.which", return_value="C:\\Windows\\System32\\schtasks.exe") + def test_schedule_windows(self, mock_which): + from skillopt_sleep.scheduler import schedule + + calls = [] + def fake_run(cmd, **kwargs): + calls.append(cmd) + class Proc: + returncode = 0 + stdout = "SUCCESS: The scheduled task ... has successfully been created." + stderr = "" + return Proc() + + with mock.patch("subprocess.run", side_effect=fake_run): + ok, msg = schedule("/p/my project", backend="mock", hour=3, minute=17) + self.assertTrue(ok) + self.assertIn("via Windows Task Scheduler", msg) + self.assertEqual(len(calls), 1) + cmd = calls[0] + self.assertEqual(cmd[0], "schtasks") + self.assertEqual(cmd[1], "/create") + self.assertEqual(cmd[2], "/tn") + self.assertTrue(cmd[3].startswith("SkillOpt-Sleep-")) + self.assertIn("my_project", cmd[3]) + self.assertEqual(cmd[4], "/tr") + self.assertIn("cmd.exe", cmd[5]) + self.assertEqual(cmd[6], "/sc") + self.assertEqual(cmd[7], "daily") + self.assertEqual(cmd[8], "/st") + self.assertEqual(cmd[9], "03:17") + self.assertEqual(cmd[10], "/f") + + @mock.patch("sys.platform", "win32") + @mock.patch("shutil.which", return_value="C:\\Windows\\System32\\schtasks.exe") + def test_unschedule_windows(self, mock_which): + from skillopt_sleep.scheduler import unschedule + + calls = [] + def fake_run(cmd, **kwargs): + calls.append(cmd) + class Proc: + returncode = 0 + stdout = "SUCCESS: The scheduled task ... was successfully deleted." + stderr = "" + return Proc() + + with mock.patch("subprocess.run", side_effect=fake_run): + ok, msg = unschedule("/p/my project") + self.assertTrue(ok) + self.assertEqual(len(calls), 1) + cmd = calls[0] + self.assertEqual(cmd[0], "schtasks") + self.assertEqual(cmd[1], "/delete") + self.assertEqual(cmd[2], "/tn") + self.assertTrue(cmd[3].startswith("SkillOpt-Sleep-")) + self.assertEqual(cmd[4], "/f") diff --git a/tests/test_sleep_engine.py b/tests/test_sleep_engine.py index f9466f4c..b0ba9932 100644 --- a/tests/test_sleep_engine.py +++ b/tests/test_sleep_engine.py @@ -770,6 +770,7 @@ class Proc: def test_codex_resolve_path_windows(self): from skillopt_sleep.backend import resolve_codex_path with mock.patch("sys.platform", "win32"), \ + mock.patch("shutil.which", return_value=None), \ mock.patch.dict("os.environ", { "APPDATA": r"C:\Users\Sparsh\AppData\Roaming", "USERPROFILE": r"C:\Users\Sparsh", From 7c7fdfcd6cab4e5459241d11b063ede2faf5bc01 Mon Sep 17 00:00:00 2001 From: "Sparsh :)" <76697238+SparshGarg999@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:54:27 +0530 Subject: [PATCH 3/3] fix(windows): resolve control flow and path quoting in scheduler task execution --- plugins/run-sleep.cmd | 136 ++++++++++++------------ skillopt_sleep/scheduler.py | 23 ++++- tests/test_runners.Tests.ps1 | 177 ++++++++++++++++++++++++++++++++ tests/test_scheduler_windows.py | 20 +++- 4 files changed, 287 insertions(+), 69 deletions(-) diff --git a/plugins/run-sleep.cmd b/plugins/run-sleep.cmd index 7767446d..d2a631a8 100644 --- a/plugins/run-sleep.cmd +++ b/plugins/run-sleep.cmd @@ -6,82 +6,89 @@ set "SCRIPT_DIR=%~dp0" :: Strip trailing backslash set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%" +set "REPO_ROOT=" if exist "%SCRIPT_DIR%\..\skillopt_sleep" ( cd /d "%SCRIPT_DIR%\.." set "REPO_ROOT=%CD%" -) else if not "%CLAUDE_PLUGIN_ROOT%"=="" if exist "%CLAUDE_PLUGIN_ROOT%\..\..\skillopt_sleep" ( + goto root_resolved +) +if not "%CLAUDE_PLUGIN_ROOT%"=="" if exist "%CLAUDE_PLUGIN_ROOT%\..\..\skillopt_sleep" ( cd /d "%CLAUDE_PLUGIN_ROOT%\..\.." set "REPO_ROOT=%CD%" -) else if not "%SKILLOPT_SLEEP_REPO%"=="" if exist "%SKILLOPT_SLEEP_REPO%\skillopt_sleep" ( + goto root_resolved +) +if not "%SKILLOPT_SLEEP_REPO%"=="" if exist "%SKILLOPT_SLEEP_REPO%\skillopt_sleep" ( set "REPO_ROOT=%SKILLOPT_SLEEP_REPO%" -) else ( - :: Search upward from current directory - set "d=%CD%" - set "REPO_ROOT=" - :loop - if exist "!d!\skillopt_sleep" ( - set "REPO_ROOT=!d!" - goto found - ) - for %%I in ("!d!") do set "parent=%%~dpI" - :: Strip trailing backslash from parent if it's not root - if "!parent!"=="!d!" goto notfound - set "parent=!parent:~0,-1!" - if "!parent!"=="" goto notfound - set "d=!parent!" - goto loop - :notfound - echo [sleep] ERROR: could not locate the skillopt_sleep package. Set SKILLOPT_SLEEP_REPO to the repo root. >&2 - exit /b 1 - :found + goto root_resolved ) -if not "%REPO_ROOT%"=="" ( - :: Find python >= 3.10 - set "PY=" - if not "%SKILLOPT_SLEEP_PYTHON%"=="" ( - set "PY=%SKILLOPT_SLEEP_PYTHON%" - ) else ( - for %%p in (python3.exe python.exe py.exe) do ( - where %%p >nul 2>nul - if !errorlevel! equ 0 ( - %%p -c "import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)" >nul 2>nul - if !errorlevel! equ 0 ( - set "PY=%%p" - goto py_found - ) - ) +:: Search upward from current directory +set "d=%CD%" +:loop +if exist "!d!\skillopt_sleep" ( + set "REPO_ROOT=!d!" + goto root_resolved +) +for %%I in ("!d!") do set "parent=%%~dpI" +:: Strip trailing backslash from parent if it's not root +if "!parent!"=="!d!" goto root_resolved +set "parent=!parent:~0,-1!" +if "!parent!"=="" goto root_resolved +set "d=!parent!" +goto loop + +:root_resolved + +if "%REPO_ROOT%"=="" goto fallback_mode + +:: ── Source Checkout Mode ─────────────────────────────────────────── +set "PY=" +if not "%SKILLOPT_SLEEP_PYTHON%"=="" ( + set "PY=%SKILLOPT_SLEEP_PYTHON%" + goto py_found +) + +for %%p in (python3.exe python.exe py.exe) do ( + where %%p >nul 2>nul + if !errorlevel! equ 0 ( + %%p -c "import sys; sys.exit(0 if sys.version_info >= (3, 10) else 1)" >nul 2>nul + if !errorlevel! equ 0 ( + set "PY=%%p" + goto py_found ) ) - :py_found +) - if "%PY%"=="" ( - echo [sleep] ERROR: need Python >= 3.10 (found none). >&2 - exit /b 1 - ) +:py_found +if "%PY%"=="" ( + echo [sleep] ERROR: need Python >= 3.10 (found none). >&2 + exit /b 1 +) - cd /d "%REPO_ROOT%" - if "%~1"=="" ( - "%PY%" -m skillopt_sleep status - ) else ( - "%PY%" -m skillopt_sleep %* - ) - exit /b !errorlevel! +cd /d "%REPO_ROOT%" +if "%~1" == "" ( + "%PY%" -m skillopt_sleep status +) else ( + "%PY%" -m skillopt_sleep %* ) +exit /b !errorlevel! + -:: No source checkout found — fall back to an installed engine. +:: ── Fallback Mode (No Source Checkout) ───────────────────────────── +:fallback_mode :: Fallback 1: skillopt-sleep CLI on PATH (uv tool install / pipx / pip install). where skillopt-sleep >nul 2>nul -if !errorlevel! equ 0 ( - if "%~1"=="" ( - skillopt-sleep status - ) else ( - skillopt-sleep %* - ) - exit /b !errorlevel! +if !errorlevel! neq 0 goto try_fallback_2 + +if "%~1" == "" ( + skillopt-sleep status +) else ( + skillopt-sleep %* ) +exit /b !errorlevel! +:try_fallback_2 :: Fallback 2: importable as a module (pip install into the active Python). set "PY=" for %%p in (python3.exe python.exe py.exe) do ( @@ -99,15 +106,16 @@ for %%p in (python3.exe python.exe py.exe) do ( ) :py_import_found -if not "%PY%"=="" ( - if "%~1"=="" ( - "%PY%" -m skillopt_sleep status - ) else ( - "%PY%" -m skillopt_sleep %* - ) - exit /b !errorlevel! +if "%PY%" == "" goto not_found + +if "%~1" == "" ( + "%PY%" -m skillopt_sleep status +) else ( + "%PY%" -m skillopt_sleep %* ) +exit /b !errorlevel! +:not_found 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 diff --git a/skillopt_sleep/scheduler.py b/skillopt_sleep/scheduler.py index 93e61df6..8cb115ed 100644 --- a/skillopt_sleep/scheduler.py +++ b/skillopt_sleep/scheduler.py @@ -105,10 +105,22 @@ def _runner_cmd(project: str, backend: str, extra: str, python: str) -> str: logdir = os.path.join(project, ".skillopt-sleep") log = os.path.join(logdir, "cron.log") # use absolute python + -m so cron's/scheduler's minimal env still works - cmd = (f'{python} -m skillopt_sleep run --project "{project}" ' + cmd = (f'"{python}" -m skillopt_sleep run --project "{project}" ' f'--scope invoked --backend {backend} {extra}'.rstrip()) if sys.platform == "win32": - return f'cmd.exe /c "if not exist \\"{logdir}\\" mkdir \\"{logdir}\\" && cd /d \\"{_repo_root()}\\" && {cmd} >> \\"{log}\\" 2>&1"' + helper_script = os.path.join(logdir, "run.cmd") + try: + os.makedirs(logdir, exist_ok=True) + content = ( + "@echo off\n" + f'cd /d "{_repo_root()}"\n' + f'{cmd} >> "{log}" 2>&1\n' + ) + with open(helper_script, "w", encoding="utf-8") as f: + f.write(content) + except Exception: + pass + return f'"{helper_script}"' return f'mkdir -p "{logdir}"; cd "{_repo_root()}" && {cmd} >> "{log}" 2>&1' @@ -181,6 +193,13 @@ def unschedule(project: Optional[str] = None, *, all_projects: bool = False) -> elif project: tn = _win_task_name(project) ok = _delete_win_task(tn) + try: + logdir = os.path.join(project, ".skillopt-sleep") + helper = os.path.join(logdir, "run.cmd") + if os.path.exists(helper): + os.remove(helper) + except Exception: + pass return ok, ("Removed." if ok else "Failed to remove scheduled task (does it exist?).") return False, "No project specified to unschedule." diff --git a/tests/test_runners.Tests.ps1 b/tests/test_runners.Tests.ps1 index 71e6249e..32581373 100644 --- a/tests/test_runners.Tests.ps1 +++ b/tests/test_runners.Tests.ps1 @@ -116,3 +116,180 @@ Describe "run-sleep.ps1 runner" { } } } + +Describe "run-sleep.cmd runner" { + BeforeAll { + $script = Join-Path $PSScriptRoot "..\plugins\run-sleep.cmd" + $tempDir = [System.IO.Path]::GetTempFileName() + Remove-Item $tempDir + New-Item -ItemType Directory -Path $tempDir | Out-Null + + # Detect a working python to bypass Windows Store alias issues + if (-not $env:SKILLOPT_SLEEP_PYTHON) { + if (Test-Path "C:\Python314\python.exe") { + $env:SKILLOPT_SLEEP_PYTHON = "C:\Python314\python.exe" + } elseif (Test-Path "C:\Python313\python.exe") { + $env:SKILLOPT_SLEEP_PYTHON = "C:\Python313\python.exe" + } elseif (Test-Path "C:\Python312\python.exe") { + $env:SKILLOPT_SLEEP_PYTHON = "C:\Python312\python.exe" + } elseif (Test-Path "C:\Python311\python.exe") { + $env:SKILLOPT_SLEEP_PYTHON = "C:\Python311\python.exe" + } elseif (Test-Path "C:\Python310\python.exe") { + $env:SKILLOPT_SLEEP_PYTHON = "C:\Python310\python.exe" + } + } + } + + AfterAll { + if (Test-Path $tempDir) { + Remove-Item -Recurse -Force $tempDir + } + } + + It "runs successfully in source checkout mode" { + $env:SKILLOPT_SLEEP_REPO = Resolve-Path (Join-Path $PSScriptRoot "..") + # Run help + $result = cmd.exe /c $script "--help" 2>&1 + $LASTEXITCODE | Should Be 0 + $result | Out-String | Should Match "skillopt_sleep" + } + + It "falls back to CLI on PATH" { + # Create a temp dir for isolated testing + $sandbox = Join-Path $tempDir "cmd_cli_fallback" + New-Item -ItemType Directory -Path $sandbox | Out-Null + $scriptCopy = Join-Path $sandbox "run-sleep.cmd" + Copy-Item $script $scriptCopy + + # Create fake CLI + $binDir = Join-Path $sandbox "bin" + New-Item -ItemType Directory -Path $binDir | Out-Null + $fakeCli = Join-Path $binDir "skillopt-sleep.cmd" + "@echo off`r`necho fake-cli invoked %*`r`nexit /b 0" | Out-File -FilePath $fakeCli -Encoding ascii + + # Save existing env vars + $oldPath = $env:PATH + $oldRepo = $env:SKILLOPT_SLEEP_REPO + $oldPlugin = $env:CLAUDE_PLUGIN_ROOT + + $env:PATH = "$binDir;$oldPath" + $env:SKILLOPT_SLEEP_REPO = $null + $env:CLAUDE_PLUGIN_ROOT = $null + + $oldLocation = Get-Location + Set-Location $sandbox + try { + $result = cmd.exe /c $scriptCopy "status" 2>&1 + $LASTEXITCODE | Should Be 0 + $result | Out-String | Should Match "fake-cli invoked status" + } + finally { + Set-Location $oldLocation + $env:PATH = $oldPath + $env:SKILLOPT_SLEEP_REPO = $oldRepo + $env:CLAUDE_PLUGIN_ROOT = $oldPlugin + } + } + + It "falls back to importable module" { + $sandbox = Join-Path $tempDir "cmd_module_fallback" + New-Item -ItemType Directory -Path $sandbox | Out-Null + $scriptCopy = Join-Path $sandbox "run-sleep.cmd" + Copy-Item $script $scriptCopy + + $oldRepo = $env:SKILLOPT_SLEEP_REPO + $oldPlugin = $env:CLAUDE_PLUGIN_ROOT + + $env:SKILLOPT_SLEEP_REPO = $null + $env:CLAUDE_PLUGIN_ROOT = $null + + $oldPythonPath = $env:PYTHONPATH + $env:PYTHONPATH = Resolve-Path (Join-Path $PSScriptRoot "..") + + $oldLocation = Get-Location + Set-Location $sandbox + try { + $result = cmd.exe /c $scriptCopy "--help" 2>&1 + $LASTEXITCODE | Should Be 0 + $result | Out-String | Should Match "skillopt_sleep" + } + finally { + Set-Location $oldLocation + $env:SKILLOPT_SLEEP_REPO = $oldRepo + $env:CLAUDE_PLUGIN_ROOT = $oldPlugin + $env:PYTHONPATH = $oldPythonPath + } + } + + It "fails when nothing is found" { + $sandbox = Join-Path $tempDir "cmd_failure" + New-Item -ItemType Directory -Path $sandbox | Out-Null + $scriptCopy = Join-Path $sandbox "run-sleep.cmd" + Copy-Item $script $scriptCopy + + $oldRepo = $env:SKILLOPT_SLEEP_REPO + $oldPlugin = $env:CLAUDE_PLUGIN_ROOT + $oldPath = $env:PATH + + $env:SKILLOPT_SLEEP_REPO = $null + $env:CLAUDE_PLUGIN_ROOT = $null + + $emptyBin = Join-Path $sandbox "empty_bin" + New-Item -ItemType Directory -Path $emptyBin | Out-Null + $env:PATH = $emptyBin + + $oldLocation = Get-Location + Set-Location $sandbox + try { + $result = cmd.exe /c $scriptCopy "status" 2>&1 + $LASTEXITCODE | Should Not Be 0 + $result | Out-String | Should Match "could not locate the skillopt_sleep package" + } + finally { + Set-Location $oldLocation + $env:SKILLOPT_SLEEP_REPO = $oldRepo + $env:CLAUDE_PLUGIN_ROOT = $oldPlugin + $env:PATH = $oldPath + } + } + + It "propagates exit code on failure" { + $env:SKILLOPT_SLEEP_REPO = Resolve-Path (Join-Path $PSScriptRoot "..") + $result = cmd.exe /c $script "non-existent-subcommand" 2>&1 + $LASTEXITCODE | Should Not Be 0 + } + + It "handles paths containing spaces correctly" { + $spaceDir = Join-Path $tempDir "cmd path with spaces" + New-Item -ItemType Directory -Path $spaceDir | Out-Null + $scriptCopy = Join-Path $spaceDir "run-sleep.cmd" + Copy-Item $script $scriptCopy + + $binDir = Join-Path $spaceDir "bin" + New-Item -ItemType Directory -Path $binDir | Out-Null + $fakeCli = Join-Path $binDir "skillopt-sleep.cmd" + "@echo off`r`necho fake-cli invoked %*`r`nexit /b 0" | Out-File -FilePath $fakeCli -Encoding ascii + + $oldPath = $env:PATH + $oldRepo = $env:SKILLOPT_SLEEP_REPO + $oldPlugin = $env:CLAUDE_PLUGIN_ROOT + + $env:PATH = "$binDir;$oldPath" + $env:SKILLOPT_SLEEP_REPO = $null + $env:CLAUDE_PLUGIN_ROOT = $null + + $oldLocation = Get-Location + Set-Location $spaceDir + try { + $result = cmd.exe /c $scriptCopy "status" 2>&1 + $LASTEXITCODE | Should Be 0 + $result | Out-String | Should Match "fake-cli invoked status" + } + finally { + Set-Location $oldLocation + $env:PATH = $oldPath + $env:SKILLOPT_SLEEP_REPO = $oldRepo + $env:CLAUDE_PLUGIN_ROOT = $oldPlugin + } + } +} diff --git a/tests/test_scheduler_windows.py b/tests/test_scheduler_windows.py index f71f5ea4..442f68c5 100644 --- a/tests/test_scheduler_windows.py +++ b/tests/test_scheduler_windows.py @@ -18,7 +18,10 @@ class Proc: stderr = "" return Proc() - with mock.patch("subprocess.run", side_effect=fake_run): + mock_open = mock.mock_open() + with mock.patch("subprocess.run", side_effect=fake_run), \ + mock.patch("os.makedirs") as mock_makedirs, \ + mock.patch("builtins.open", mock_open): ok, msg = schedule("/p/my project", backend="mock", hour=3, minute=17) self.assertTrue(ok) self.assertIn("via Windows Task Scheduler", msg) @@ -30,13 +33,21 @@ class Proc: self.assertTrue(cmd[3].startswith("SkillOpt-Sleep-")) self.assertIn("my_project", cmd[3]) self.assertEqual(cmd[4], "/tr") - self.assertIn("cmd.exe", cmd[5]) + self.assertIn("run.cmd", cmd[5]) self.assertEqual(cmd[6], "/sc") self.assertEqual(cmd[7], "daily") self.assertEqual(cmd[8], "/st") self.assertEqual(cmd[9], "03:17") self.assertEqual(cmd[10], "/f") + mock_makedirs.assert_called_once() + mock_open.assert_called_once() + # Verify the content written to the helper script + handle = mock_open() + written = "".join(call[0][0] for call in handle.write.call_args_list) + self.assertIn("@echo off", written) + self.assertIn("run --project", written) + @mock.patch("sys.platform", "win32") @mock.patch("shutil.which", return_value="C:\\Windows\\System32\\schtasks.exe") def test_unschedule_windows(self, mock_which): @@ -51,7 +62,9 @@ class Proc: stderr = "" return Proc() - with mock.patch("subprocess.run", side_effect=fake_run): + with mock.patch("subprocess.run", side_effect=fake_run), \ + mock.patch("os.path.exists", return_value=True), \ + mock.patch("os.remove") as mock_remove: ok, msg = unschedule("/p/my project") self.assertTrue(ok) self.assertEqual(len(calls), 1) @@ -61,3 +74,4 @@ class Proc: self.assertEqual(cmd[2], "/tn") self.assertTrue(cmd[3].startswith("SkillOpt-Sleep-")) self.assertEqual(cmd[4], "/f") + mock_remove.assert_called_once()