From c7a1604f17d0b718a6daa0aa54c722999de14053 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] 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 | 59 +++++++++++++- 8 files changed, 314 insertions(+), 27 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 cf01b0af..2132d77d 100644 --- a/skillopt_sleep/backend.py +++ b/skillopt_sleep/backend.py @@ -711,14 +711,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 @@ -861,23 +874,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 aee9b7d5..bdfb526e 100644 --- a/tests/test_sleep_engine.py +++ b/tests/test_sleep_engine.py @@ -211,7 +211,7 @@ def test_cli_exposes_limits_progress_and_target_skill_path(self): self.assertTrue(cfg.get("progress")) self.assertEqual( cfg.managed_skill_path(), - os.path.join(project, ".agents/skills/taste-skill/SKILL.md"), + os.path.abspath(os.path.join(project, ".agents/skills/taste-skill/SKILL.md")), ) def test_cli_report_payload_includes_rejected_edits(self): @@ -281,7 +281,7 @@ def test_cfg_uses_tasks_file_target_skill_path_metadata(self): self.assertEqual( cfg.managed_skill_path(), - "/repo/Yoshi/.agents/skills/yoshi-monorepo/SKILL.md", + os.path.abspath("/repo/Yoshi/.agents/skills/yoshi-monorepo/SKILL.md"), ) def test_cmd_run_uses_tasks_file_without_harvest(self): @@ -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): @@ -912,7 +965,7 @@ def test_cycle_stage_then_adopt_with_backup(self): def test_cycle_can_target_repo_scoped_skill_path(self): with tempfile.TemporaryDirectory() as proj, tempfile.TemporaryDirectory() as home: - target = os.path.join(proj, ".agents/skills/taste-skill/SKILL.md") + target = os.path.abspath(os.path.join(proj, ".agents/skills/taste-skill/SKILL.md")) cfg = load_config( invoked_project=proj, projects="invoked",