feat(codex): add Windows support and compatibility for Codex plugin

This commit is contained in:
Sparsh :)
2026-07-05 17:23:51 +05:30
parent 25b0f39405
commit 3c45db3a90
8 changed files with 311 additions and 24 deletions
+9
View File
@@ -21,6 +21,7 @@ rules. The shared runner remains a plain shell entrypoint that the skill calls.
## Install ## Install
On Linux/macOS:
```bash ```bash
git clone <repo-url> SkillOpt-Sleep git clone <repo-url> SkillOpt-Sleep
cd 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 export SKILLOPT_SLEEP_REPO="$(pwd)" # so the runner is found from anywhere
``` ```
On Windows (PowerShell):
```powershell
git clone <repo-url> 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 If a previous install created `~/.codex/prompts/sleep.md`, the installer moves
that deprecated prompt aside with a `.skillopt-legacy*.bak` suffix. that deprecated prompt aside with a `.skillopt-legacy*.bak` suffix.
+47
View File
@@ -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."
@@ -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)" 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`. Actions are `status`, `harvest`, `dry-run`, `run`, `adopt`, `schedule`, and `unschedule`.
- Default backend is `mock`, which is deterministic and spends no API budget. - Default backend is `mock`, which is deterministic and spends no API budget.
+67
View File
@@ -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 %*
)
+62
View File
@@ -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
+59 -24
View File
@@ -735,14 +735,27 @@ def resolve_codex_path(explicit: str = "") -> str:
env = os.environ.get("SKILLOPT_SLEEP_CODEX_PATH") env = os.environ.get("SKILLOPT_SLEEP_CODEX_PATH")
if env: if env:
return env return env
candidates = [ import sys
os.path.expanduser("~/.nvm/versions/node/v22.22.3/bin/codex"), candidates = []
] if sys.platform == "win32":
# any nvm node version appdata = os.environ.get("APPDATA")
nvm = os.path.expanduser("~/.nvm/versions/node") if appdata:
if os.path.isdir(nvm): candidates.append(os.path.join(appdata, "npm", "codex.cmd"))
for ver in sorted(os.listdir(nvm), reverse=True): userprofile = os.environ.get("USERPROFILE")
candidates.append(os.path.join(nvm, ver, "bin", "codex")) 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: for c in candidates:
if not c or not os.path.exists(c): if not c or not os.path.exists(c):
continue continue
@@ -886,23 +899,45 @@ class CodexCliBackend(CliBackend):
work = tempfile.mkdtemp(prefix="skillopt_sleep_codextools_") work = tempfile.mkdtemp(prefix="skillopt_sleep_codextools_")
calllog = os.path.join(work, "_tool_calls.log") calllog = os.path.join(work, "_tool_calls.log")
out_path = os.path.join(work, "_last.txt") out_path = os.path.join(work, "_last.txt")
tool_names = tools or ["search"]
is_windows = os.name == "nt"
try: try:
for tname in (tools or ["search"]): for tname in tool_names:
shim = os.path.join(work, tname) if is_windows:
with open(shim, "w") as f: shim = os.path.join(work, f"{tname}.cmd")
f.write( with open(shim, "w") as f:
"#!/usr/bin/env bash\n" f.write(
f'echo "{tname}" >> "{calllog}"\n' "@echo off\n"
'echo "(search results: 3 relevant notes found; use them to answer)"\n' f'echo %~n0>>"{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 = ( else:
"Shell tools are available in the working directory: " shim = os.path.join(work, tname)
+ ", ".join(f"./{t}" for t in (tools or ["search"])) with open(shim, "w") as f:
+ ". When the skill says to look something up or search before " f.write(
"answering, you MUST actually run the tool (e.g. `./search \"query\"`) " "#!/usr/bin/env bash\n"
"before giving your final answer." 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 = ( prompt = (
"Complete the task. Apply the skill and memory rules EXACTLY, " "Complete the task. Apply the skill and memory rules EXACTLY, "
"including any rule about searching before answering. Treat a " "including any rule about searching before answering. Treat a "
+2
View File
@@ -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 # use absolute python + -m so cron'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()) 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' return f'mkdir -p "{logdir}"; cd "{_repo_root()}" && {cmd} >> "{log}" 2>&1'
+53
View File
@@ -767,6 +767,59 @@ class TestCodexBackend(unittest.TestCase):
self.assertIn("exited 1", be.last_call_error) # failure surfaced for diagnostics self.assertIn("exited 1", be.last_call_error) # failure surfaced for diagnostics
self.assertEqual(called, []) # no tool actually ran 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): class TestMultiRolloutAndBudget(unittest.TestCase):
def test_rolloutset_stats(self): def test_rolloutset_stats(self):