Merge pull request #101 from SparshGarg999/fix/46-windows-support-codex
feat(codex): add Windows support and compatibility for Codex plugin
This commit is contained in:
@@ -22,6 +22,7 @@ rules. The shared runner remains a plain shell entrypoint that the skill calls.
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
On Linux/macOS:
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/microsoft/SkillOpt.git
|
git clone https://github.com/microsoft/SkillOpt.git
|
||||||
cd SkillOpt
|
cd SkillOpt
|
||||||
@@ -29,6 +30,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.
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -65,6 +65,18 @@ bash "$SKILLOPT_SLEEP_REPO/plugins/run-sleep.sh" run --project "$(pwd)" \
|
|||||||
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.
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal enabledelayedexpansion
|
||||||
|
|
||||||
|
:: Resolve REPO_ROOT
|
||||||
|
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%"
|
||||||
|
goto root_resolved
|
||||||
|
)
|
||||||
|
if not "%CLAUDE_PLUGIN_ROOT%"=="" if exist "%CLAUDE_PLUGIN_ROOT%\..\..\skillopt_sleep" (
|
||||||
|
cd /d "%CLAUDE_PLUGIN_ROOT%\..\.."
|
||||||
|
set "REPO_ROOT=%CD%"
|
||||||
|
goto root_resolved
|
||||||
|
)
|
||||||
|
if not "%SKILLOPT_SLEEP_REPO%"=="" if exist "%SKILLOPT_SLEEP_REPO%\skillopt_sleep" (
|
||||||
|
set "REPO_ROOT=%SKILLOPT_SLEEP_REPO%"
|
||||||
|
goto root_resolved
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 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
|
||||||
|
)
|
||||||
|
|
||||||
|
cd /d "%REPO_ROOT%"
|
||||||
|
if "%~1" == "" (
|
||||||
|
"%PY%" -m skillopt_sleep status
|
||||||
|
) else (
|
||||||
|
"%PY%" -m skillopt_sleep %*
|
||||||
|
)
|
||||||
|
exit /b !errorlevel!
|
||||||
|
|
||||||
|
|
||||||
|
:: ── 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! 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 (
|
||||||
|
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 skillopt_sleep" >nul 2>nul
|
||||||
|
if !errorlevel! equ 0 (
|
||||||
|
set "PY=%%p"
|
||||||
|
goto py_import_found
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
:py_import_found
|
||||||
|
|
||||||
|
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
|
||||||
|
exit /b 1
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$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 = ""
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($Py) {
|
||||||
|
& $Py -m skillopt_sleep $argsList
|
||||||
|
exit $LASTEXITCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
[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
|
||||||
+68
-25
@@ -735,14 +735,35 @@ 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"),
|
import shutil
|
||||||
]
|
candidates = []
|
||||||
# any nvm node version
|
|
||||||
nvm = os.path.expanduser("~/.nvm/versions/node")
|
# Try shutil.which("codex") first so PATH, pnpm, Volta, etc. work.
|
||||||
if os.path.isdir(nvm):
|
which_codex = shutil.which("codex")
|
||||||
for ver in sorted(os.listdir(nvm), reverse=True):
|
if which_codex:
|
||||||
candidates.append(os.path.join(nvm, ver, "bin", "codex"))
|
candidates.append(which_codex)
|
||||||
|
|
||||||
|
if sys.platform == "win32":
|
||||||
|
import ntpath
|
||||||
|
appdata = os.environ.get("APPDATA")
|
||||||
|
if appdata:
|
||||||
|
candidates.append(ntpath.join(appdata, "npm", "codex.cmd"))
|
||||||
|
userprofile = os.environ.get("USERPROFILE")
|
||||||
|
if userprofile:
|
||||||
|
candidates.append(ntpath.join(userprofile, "AppData", "Roaming", "npm", "codex.cmd"))
|
||||||
|
nvm_home = os.environ.get("NVM_HOME")
|
||||||
|
if nvm_home:
|
||||||
|
candidates.append(ntpath.join(nvm_home, "codex.cmd"))
|
||||||
|
else:
|
||||||
|
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):
|
||||||
|
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
|
||||||
@@ -755,7 +776,7 @@ def resolve_codex_path(explicit: str = "") -> str:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return c
|
return c
|
||||||
return "codex" # last resort (may be the wrapper)
|
return "codex"
|
||||||
|
|
||||||
|
|
||||||
class CodexCliBackend(CliBackend):
|
class CodexCliBackend(CliBackend):
|
||||||
@@ -886,23 +907,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 "
|
||||||
|
|||||||
+110
-19
@@ -1,19 +1,7 @@
|
|||||||
"""SkillOpt-Sleep — built-in nightly scheduler.
|
"""SkillOpt-Sleep — built-in nightly scheduler.
|
||||||
|
|
||||||
Installs/removes a crontab entry that runs the sleep cycle automatically, so the
|
Installs/removes a crontab entry (on Unix) or a Scheduled Task (on Windows) that
|
||||||
user doesn't have to wire cron themselves. Idempotent: a managed block delimited
|
runs the sleep cycle automatically, so the user doesn't have to wire it manually.
|
||||||
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 <project>/.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.
|
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -65,12 +53,74 @@ def _split_managed(crontab: str) -> Tuple[str, List[str]]:
|
|||||||
return "\n".join(outside).rstrip(), managed
|
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:
|
def _runner_cmd(project: str, backend: str, extra: str, python: str) -> str:
|
||||||
logdir = os.path.join(project, ".skillopt-sleep")
|
logdir = os.path.join(project, ".skillopt-sleep")
|
||||||
log = os.path.join(logdir, "cron.log")
|
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}" '
|
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":
|
||||||
|
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'
|
return f'mkdir -p "{logdir}"; cd "{_repo_root()}" && {cmd} >> "{log}" 2>&1'
|
||||||
|
|
||||||
|
|
||||||
@@ -87,12 +137,26 @@ def schedule(project: str, *, backend: str = "mock", hour: int = 3, minute: int
|
|||||||
extra: str = "", python: Optional[str] = None) -> Tuple[bool, str]:
|
extra: str = "", python: Optional[str] = None) -> Tuple[bool, str]:
|
||||||
"""Install (or replace) the nightly entry for ``project``.
|
"""Install (or replace) the nightly entry for ``project``.
|
||||||
|
|
||||||
Returns (installed, message). If crontab is unavailable, installed=False and
|
Returns (installed, message). If the scheduler backend is unavailable, installed=False and
|
||||||
the message contains the line to add manually.
|
the message contains instructions to add manually.
|
||||||
"""
|
"""
|
||||||
project = os.path.abspath(project)
|
project = os.path.abspath(project)
|
||||||
python = python or sys.executable or "python3"
|
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():
|
if not _have_crontab():
|
||||||
return False, ("crontab not found on this system. Add this line to your "
|
return False, ("crontab not found on this system. Add this line to your "
|
||||||
@@ -116,6 +180,29 @@ 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]:
|
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)."""
|
"""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)
|
||||||
|
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."
|
||||||
|
|
||||||
if not _have_crontab():
|
if not _have_crontab():
|
||||||
return False, "crontab not found; nothing to remove."
|
return False, "crontab not found; nothing to remove."
|
||||||
outside, managed = _split_managed(_read_crontab())
|
outside, managed = _split_managed(_read_crontab())
|
||||||
@@ -134,5 +221,9 @@ def unschedule(project: Optional[str] = None, *, all_projects: bool = False) ->
|
|||||||
|
|
||||||
|
|
||||||
def list_scheduled() -> List[str]:
|
def list_scheduled() -> List[str]:
|
||||||
|
if sys.platform == "win32":
|
||||||
|
if not _have_schtasks():
|
||||||
|
return []
|
||||||
|
return _list_win_tasks()
|
||||||
_outside, managed = _split_managed(_read_crontab())
|
_outside, managed = _split_managed(_read_crontab())
|
||||||
return [ln for ln in managed if ln.strip()]
|
return [ln for ln in managed if ln.strip()]
|
||||||
|
|||||||
@@ -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")
|
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):
|
def test_resolve_alfworld_gamefile_keeps_absolute_paths(monkeypatch, tmp_path):
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ def _run(script, args, env, cwd):
|
|||||||
return proc.returncode, proc.stdout, proc.stderr
|
return proc.returncode, proc.stdout, proc.stderr
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipIf(sys.platform == "win32", "Unix bash runner tests require POSIX/bash environment")
|
||||||
class TestRunnerSourceCheckout(unittest.TestCase):
|
class TestRunnerSourceCheckout(unittest.TestCase):
|
||||||
"""When skillopt_sleep/ is found in the repo, the source-checkout path is used."""
|
"""When skillopt_sleep/ is found in the repo, the source-checkout path is used."""
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ class TestRunnerSourceCheckout(unittest.TestCase):
|
|||||||
self.assertIn("status", out)
|
self.assertIn("status", out)
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipIf(sys.platform == "win32", "Unix bash runner tests require POSIX/bash environment")
|
||||||
class TestRunnerCliFallback(unittest.TestCase):
|
class TestRunnerCliFallback(unittest.TestCase):
|
||||||
"""When no source dir is found, fall back to the skillopt-sleep CLI on PATH."""
|
"""When no source dir is found, fall back to the skillopt-sleep CLI on PATH."""
|
||||||
|
|
||||||
@@ -101,6 +103,7 @@ class TestRunnerCliFallback(unittest.TestCase):
|
|||||||
self.assertIn("fake-cli invoked: status", out)
|
self.assertIn("fake-cli invoked: status", out)
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipIf(sys.platform == "win32", "Unix bash runner tests require POSIX/bash environment")
|
||||||
class TestRunnerNoEngine(unittest.TestCase):
|
class TestRunnerNoEngine(unittest.TestCase):
|
||||||
"""When no source dir, no CLI on PATH, and no importable package, error cleanly."""
|
"""When no source dir, no CLI on PATH, and no importable package, error cleanly."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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()
|
||||||
|
|
||||||
|
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)
|
||||||
|
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("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):
|
||||||
|
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), \
|
||||||
|
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)
|
||||||
|
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")
|
||||||
|
mock_remove.assert_called_once()
|
||||||
@@ -769,6 +769,60 @@ 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("shutil.which", return_value=None), \
|
||||||
|
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):
|
||||||
|
|||||||
Reference in New Issue
Block a user