diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 31f03ae..5f73855 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "loop-it", - "version": "0.3.19", + "version": "0.3.20", "description": "Find, recommend, and run bounded agent loops across turn-based, goal-based, time-based, and proactive patterns.", "author": { "name": "Loop it", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 70de388..f17218f 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "loop-it", - "version": "0.3.19", + "version": "0.3.20", "description": "Find, recommend, and run bounded agent loops across turn-based, goal-based, time-based, and proactive patterns.", "author": { "name": "Loop it", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 02f7b15..4551479 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "loop-it", - "version": "0.3.19", + "version": "0.3.20", "description": "Find, recommend, and run bounded agent loops across turn-based, goal-based, time-based, and proactive patterns.", "homepage": "https://swarmixai.com/experiments/loop-it-poc", "license": "MIT", diff --git a/CHANGELOG.md b/CHANGELOG.md index f1c28aa..346b76b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +## 0.3.20 + +### Fixed + +- `loop-it run --execute codex` and `loop-it doctor` now discover the Codex executable bundled with Codex Desktop on macOS when `codex` is not available on the terminal `PATH`. Explicit `--codex-bin` and `LOOP_IT_CODEX_BIN` overrides still take precedence. + ## 0.3.19 ### Changed diff --git a/README.md b/README.md index c9df614..e16f5e1 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Loop it has three product verbs: 2. **Recommend** one bundled loop with a verifier gate. 3. **Run** the selected loop in Codex, Claude Code, or Cursor until proof, blocker, or approval. -`loop-it run --execute codex` is the happy path when the user wants work done. It inspects repo signals, picks or applies a loop, writes the run contract, calls Codex CLI, reruns the verifier after each pass, and repeats up to the iteration cap until proof, a repeated failure, a blocker, or approval-sensitive work stops it. `loop-it run` without `--execute` prepares the same loop contract and launch prompt without calling Codex. `write` and `start` are lower-level preparation commands. A result that only creates or edits `.loop-it` files is not a successful repair. +`loop-it run --execute codex` is the happy path when the user wants work done. It inspects repo signals, picks or applies a loop, writes the run contract, calls Codex CLI, reruns the verifier after each pass, and repeats up to the iteration cap until proof, a repeated failure, a blocker, or approval-sensitive work stops it. When `codex` is not on the terminal `PATH`, Loop It automatically discovers the executable bundled with Codex Desktop on macOS. Use `--codex-bin ` or `LOOP_IT_CODEX_BIN` only when you need an explicit override. `loop-it run` without `--execute` prepares the same loop contract and launch prompt without calling Codex. `write` and `start` are lower-level preparation commands. A result that only creates or edits `.loop-it` files is not a successful repair. Add `--checker codex` when the run needs a second, read-only review after the verifier passes. The checker inspects the changed files, verifier output, Codex output, and `.loop-it/progress.json`, then writes a checker receipt. Loop it records whether the checker passed, blocked, was inconclusive, or was skipped. @@ -96,7 +96,7 @@ Use `loop-it schedules list` to see local schedules and whether the Codex heartb Use `loop-it github pr` when the trigger should come from GitHub. It reads a PR through the GitHub CLI, chooses `review-comment-resolver-routine`, `ci-health-watch`, or `pr-review-watch`, writes a read-only connector snapshot under `.loop-it/connectors/github/`, and creates a local schedule. It never comments, pushes, requests review, merges, deploys, or changes GitHub state without explicit approval. -Use `loop-it doctor` when the user needs to know whether Loop It is actually ready. It reports the local package version, npm latest version, personal Codex plugin cache version, project skill install, Codex CLI availability, schedule records, Codex heartbeat files, and GitHub CLI auth when connector state exists. It exits non-zero for real blockers such as missing Codex CLI, missing configured heartbeat files, or missing GitHub auth for GitHub-backed schedules. +Use `loop-it doctor` when the user needs to know whether Loop It is actually ready. It reports the local package version, npm latest version, personal Codex plugin cache version, project skill install, Codex CLI availability (including the Codex Desktop fallback), schedule records, Codex heartbeat files, and GitHub CLI auth when connector state exists. It exits non-zero for real blockers such as missing Codex CLI, missing configured heartbeat files, or missing GitHub auth for GitHub-backed schedules. Before `--execute codex` starts, Loop it runs a readiness preflight: diff --git a/bin/loop-it.mjs b/bin/loop-it.mjs index 62b5db1..5bcffe0 100755 --- a/bin/loop-it.mjs +++ b/bin/loop-it.mjs @@ -177,6 +177,7 @@ Install options: Run execution options: --execute codex Call Codex CLI and rerun the verifier after each pass --checker codex Run a second read-only checker after the verifier passes + --codex-bin Override PATH and Codex Desktop executable discovery --worktree Create a fresh git worktree/branch and run Codex there`); } diff --git a/docs/examples.md b/docs/examples.md index 66f2288..396ee28 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -14,7 +14,7 @@ npx @fhajjej/loop-it@latest run \ --worktree ``` -`run --execute codex` inspects repo signals, recommends the highest-confidence loop, writes the loop contract, calls `codex exec`, reruns the verifier after each pass, and repeats up to the iteration cap until proof, a repeated failure, a blocker, or approval-sensitive work stops it. Add `--checker codex` when you want a second read-only Codex pass to inspect the changed files, verifier output, Codex output, and `.loop-it/progress.json` before the run is accepted. Add `--worktree` when Codex should edit a fresh git worktree/branch instead of your current checkout. When the verifier passes, the CLI prints a `Run proof` summary and records a machine-readable `proof` object with the selected loop, executor, verifier, checker result, final Codex output path, changed files, optional worktree metadata, and per-iteration evidence. +`run --execute codex` inspects repo signals, recommends the highest-confidence loop, writes the loop contract, calls `codex exec`, reruns the verifier after each pass, and repeats up to the iteration cap until proof, a repeated failure, a blocker, or approval-sensitive work stops it. If `codex` is not on the terminal `PATH`, Loop It discovers the executable bundled with Codex Desktop on macOS; `--codex-bin ` and `LOOP_IT_CODEX_BIN` are explicit overrides. Add `--checker codex` when you want a second read-only Codex pass to inspect the changed files, verifier output, Codex output, and `.loop-it/progress.json` before the run is accepted. Add `--worktree` when Codex should edit a fresh git worktree/branch instead of your current checkout. When the verifier passes, the CLI prints a `Run proof` summary and records a machine-readable `proof` object with the selected loop, executor, verifier, checker result, final Codex output path, changed files, optional worktree metadata, and per-iteration evidence. Omit `--execute codex` when you only want to prepare the launch prompt for a human to paste into Codex, Claude Code, or Cursor. diff --git a/docs/install.md b/docs/install.md index a288fc1..7b7e316 100644 --- a/docs/install.md +++ b/docs/install.md @@ -74,7 +74,7 @@ For Codex CLI execution, use `run --execute codex` instead of only preparing a l npx @fhajjej/loop-it@latest run --goal "Fix failing CI" --check "npm run check" --agent codex --execute codex --checker codex --worktree ``` -That path calls Codex, reruns the verifier after each pass, repeats up to the iteration cap, optionally runs a read-only checker, prints a `Run proof` summary on success, and records the selected loop, executor, verifier, checker result, Codex output paths, changed files, optional worktree metadata, and per-iteration evidence in `.loop-it/progress.json`. +That path calls Codex, reruns the verifier after each pass, repeats up to the iteration cap, optionally runs a read-only checker, prints a `Run proof` summary on success, and records the selected loop, executor, verifier, checker result, Codex output paths, changed files, optional worktree metadata, and per-iteration evidence in `.loop-it/progress.json`. On macOS, Loop It automatically uses the Codex executable bundled with Codex Desktop when `codex` is not on your terminal `PATH`. Use `--codex-bin ` or `LOOP_IT_CODEX_BIN` to override discovery. Use `--worktree` when you want Codex to edit a fresh branch/worktree instead of the current checkout. Loop it chooses `origin/main`, `main`, `origin/master`, `master`, or `HEAD` as the base unless you pass `--worktree-base `. diff --git a/package.json b/package.json index a8c81f7..c73a259 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fhajjej/loop-it", - "version": "0.3.19", + "version": "0.3.20", "description": "Portable loop router, library, launcher, and Agent Skill for finding, recommending, and running bounded agent loops across turn-based, goal-based, time-based, and proactive patterns.", "type": "module", "author": { @@ -41,7 +41,7 @@ "test": "node tests/smoke-install.mjs", "sync:project": "node ./bin/loop-it.mjs install --agent all --scope project --force", "sync:codex-plugin": "node scripts/sync-codex-plugin.mjs", - "check": "node --check bin/loop-it.mjs && node --check skills/loop-it/scripts/create-loop.mjs && node --check skills/loop-it/scripts/select-loop.mjs && node --check skills/loop-it/scripts/start-loop.mjs && node --check skills/loop-it/scripts/run-loop.mjs && node --check skills/loop-it/scripts/schedule-loop.mjs && node --check skills/loop-it/scripts/github-connector.mjs && node --check skills/loop-it/scripts/doctor.mjs && node --check scripts/sync-codex-plugin.mjs && node --check tests/smoke-install.mjs && node --check tests/public-install-smoke.mjs && node --check tests/run-proof-smoke.mjs && node --check tests/readiness-preflight-smoke.mjs && node -e \"const fs=require('fs'); ['.codex-plugin/plugin.json','.claude-plugin/plugin.json','.cursor-plugin/plugin.json','skills/loop-it/references/library/loops.json','skills/loop-it/references/library/schema.json','skills/loop-it/references/library/evals.json'].forEach((file)=>JSON.parse(fs.readFileSync(file,'utf8')))\" && node skills/loop-it/scripts/select-loop.mjs eval && npm run smoke && npm run smoke:run-proof && npm run smoke:readiness && npm pack --dry-run", + "check": "node --check bin/loop-it.mjs && node --check skills/loop-it/scripts/create-loop.mjs && node --check skills/loop-it/scripts/select-loop.mjs && node --check skills/loop-it/scripts/start-loop.mjs && node --check skills/loop-it/scripts/run-loop.mjs && node --check skills/loop-it/scripts/lib/codex-cli.mjs && node --check skills/loop-it/scripts/schedule-loop.mjs && node --check skills/loop-it/scripts/github-connector.mjs && node --check skills/loop-it/scripts/doctor.mjs && node --check scripts/sync-codex-plugin.mjs && node --check tests/smoke-install.mjs && node --check tests/public-install-smoke.mjs && node --check tests/run-proof-smoke.mjs && node --check tests/readiness-preflight-smoke.mjs && node -e \"const fs=require('fs'); ['.codex-plugin/plugin.json','.claude-plugin/plugin.json','.cursor-plugin/plugin.json','skills/loop-it/references/library/loops.json','skills/loop-it/references/library/schema.json','skills/loop-it/references/library/evals.json'].forEach((file)=>JSON.parse(fs.readFileSync(file,'utf8')))\" && node skills/loop-it/scripts/select-loop.mjs eval && npm run smoke && npm run smoke:run-proof && npm run smoke:readiness && npm pack --dry-run", "smoke": "node tests/smoke-install.mjs", "smoke:public-install": "node tests/public-install-smoke.mjs", "smoke:public-codex": "node tests/public-install-smoke.mjs --codex-run", diff --git a/skills/loop-it/scripts/doctor.mjs b/skills/loop-it/scripts/doctor.mjs index 2216f70..f9b0613 100644 --- a/skills/loop-it/scripts/doctor.mjs +++ b/skills/loop-it/scripts/doctor.mjs @@ -4,6 +4,7 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import { homedir } from "node:os"; +import { resolveCodexCli } from "./lib/codex-cli.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const packageRoot = resolve(scriptDir, "..", "..", ".."); @@ -40,7 +41,11 @@ function buildDoctorReport(options) { const schedules = loadSchedules(cwd); const githubConnectorCount = countJsonFiles(resolve(cwd, ".loop-it", "connectors", "github")); const githubRequired = githubConnectorCount > 0 || schedules.some((schedule) => schedule.connector === "github"); - const codexCli = checkCommand(stringArg(options["codex-bin"], "codex"), ["--version"]); + const codexResolution = resolveCodexCli({ requested: stringArg(options["codex-bin"], "") }); + const codexCli = { + ...checkCommand(codexResolution.bin, ["--version"]), + source: codexResolution.source, + }; const githubCli = githubRequired || options["check-gh"] ? checkCommand(stringArg(options["gh-bin"], "gh"), ["auth", "status"]) : { status: "not-required", ok: true, output: "", detail: "No GitHub connector state found." }; @@ -545,7 +550,7 @@ function printUsage() { Options: --cwd Repository to inspect. Default: current directory. --codex-home Codex home for plugin and automation files. Default: $CODEX_HOME or ~/.codex. - --codex-bin Codex executable override. Default: codex. + --codex-bin Override PATH and Codex Desktop executable discovery. --gh-bin GitHub CLI executable override. Default: gh. --npm-bin npm executable override. Default: npm. --check-gh Check gh auth even when no GitHub connector state exists. diff --git a/skills/loop-it/scripts/lib/codex-cli.mjs b/skills/loop-it/scripts/lib/codex-cli.mjs new file mode 100644 index 0000000..4b518b6 --- /dev/null +++ b/skills/loop-it/scripts/lib/codex-cli.mjs @@ -0,0 +1,82 @@ +import { accessSync, constants } from "node:fs"; +import { homedir } from "node:os"; +import { delimiter, resolve } from "node:path"; + +const CODEX_DESKTOP_RELATIVE_PATHS = [ + ["Applications", "ChatGPT.app", "Contents", "Resources", "codex"], + ["Applications", "Codex.app", "Contents", "Resources", "codex"], +]; + +export function resolveCodexCli(options = {}) { + const env = options.env ?? process.env; + const requested = nonEmpty(options.requested) || nonEmpty(env.LOOP_IT_CODEX_BIN) || "codex"; + + if (requested !== "codex") { + return { + bin: requested, + source: options.requested ? "argument" : "environment", + }; + } + + if (isCommandOnPath("codex", env)) { + return { + bin: "codex", + source: "path", + }; + } + + for (const candidate of desktopCandidates(env)) { + if (isExecutable(candidate)) { + return { + bin: candidate, + source: "desktop", + }; + } + } + + return { + bin: "codex", + source: "missing", + }; +} + +function desktopCandidates(env) { + const home = nonEmpty(env.HOME) || homedir(); + const candidates = CODEX_DESKTOP_RELATIVE_PATHS.map((parts) => resolve(home, ...parts)); + + if (process.platform === "darwin") { + candidates.push( + "/Applications/ChatGPT.app/Contents/Resources/codex", + "/Applications/Codex.app/Contents/Resources/codex" + ); + } + + return [...new Set(candidates)]; +} + +function isCommandOnPath(command, env) { + const pathEntries = String(env.PATH ?? "") + .split(delimiter) + .map((entry) => entry.trim().replace(/^"|"$/g, "")) + .filter(Boolean); + const extensions = process.platform === "win32" + ? String(env.PATHEXT ?? ".EXE;.CMD;.BAT;.COM").split(delimiter) + : [""]; + + return pathEntries.some((entry) => + extensions.some((extension) => isExecutable(resolve(entry, `${command}${extension}`))) + ); +} + +function isExecutable(path) { + try { + accessSync(path, constants.X_OK); + return true; + } catch { + return false; + } +} + +function nonEmpty(value) { + return typeof value === "string" && value.trim() ? value.trim() : ""; +} diff --git a/skills/loop-it/scripts/run-loop.mjs b/skills/loop-it/scripts/run-loop.mjs index 88fd164..6918d3b 100644 --- a/skills/loop-it/scripts/run-loop.mjs +++ b/skills/loop-it/scripts/run-loop.mjs @@ -4,6 +4,7 @@ import { basename, dirname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; import { findLoopById, recommendLoop } from "./select-loop.mjs"; +import { resolveCodexCli } from "./lib/codex-cli.mjs"; const scriptDir = dirname(fileURLToPath(import.meta.url)); const startScript = resolve(scriptDir, "start-loop.mjs"); @@ -161,9 +162,12 @@ process.exit(0); function executeWithCodex(run) { const launchPath = resolve(run.cwd, ".loop-it", "LAUNCH.md"); const outputPath = resolve(run.cwd, stringArg(args["codex-output"], ".loop-it/CODEX_FINAL.md")); - const codexBin = stringArg(args["codex-bin"], "codex"); + const codexCli = resolveCodexCli({ requested: stringArg(args["codex-bin"], "") }); + const codexBin = codexCli.bin; const sandbox = stringArg(args["codex-sandbox"], "workspace-write"); - const checkerBin = stringArg(args["checker-bin"], codexBin); + const checkerBin = args["checker-bin"] + ? resolveCodexCli({ requested: stringArg(args["checker-bin"], "") }).bin + : codexBin; const checkerSandbox = stringArg(args["checker-sandbox"], "read-only"); const checkerOutputPath = resolve(run.cwd, stringArg(args["checker-output"], ".loop-it/CODEX_CHECKER.md")); @@ -177,6 +181,10 @@ function executeWithCodex(run) { let previousVerifierOutput = ""; const proofIterations = []; + if (codexCli.source === "desktop") { + console.log(`Using Codex Desktop CLI: ${codexBin}`); + } + for (let iteration = 1; iteration <= run.maxIterations; iteration += 1) { const iterationOutputPath = outputPathForIteration(outputPath, iteration); mkdirSync(dirname(iterationOutputPath), { recursive: true }); @@ -1218,7 +1226,7 @@ Options: --max-iterations Iteration cap, default selected loop cap --execute Execute the generated loop with Codex CLI, default none --checker Optional read-only checker after the verifier passes, default none - --codex-bin Codex executable for --execute codex, default codex + --codex-bin Codex executable override; otherwise discover PATH or Codex Desktop --codex-sandbox Codex sandbox mode, default workspace-write; use none to omit --codex-output Last Codex message path, default .loop-it/CODEX_FINAL.md --checker-bin Checker Codex executable, default --codex-bin diff --git a/tests/run-proof-smoke.mjs b/tests/run-proof-smoke.mjs index e507c94..a0d48e5 100644 --- a/tests/run-proof-smoke.mjs +++ b/tests/run-proof-smoke.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, resolve } from "node:path"; +import { delimiter, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; @@ -13,6 +13,9 @@ const projectDir = resolve(tempRoot, "repo"); const blockedProjectDir = resolve(tempRoot, "blocked-repo"); const isolatedProjectDir = resolve(tempRoot, "isolated-repo"); const isolatedWorktreeDir = resolve(tempRoot, "isolated-run-worktree"); +const desktopProjectDir = resolve(tempRoot, "desktop-codex-repo"); +const desktopHomeDir = resolve(tempRoot, "home"); +const desktopCodex = resolve(desktopHomeDir, "Applications", "ChatGPT.app", "Contents", "Resources", "codex"); const fakeCodex = resolve(tempRoot, "fake-codex.mjs"); const fakeCodexPass = resolve(tempRoot, "fake-codex-pass.mjs"); const fakeChecker = resolve(tempRoot, "fake-checker.mjs"); @@ -25,6 +28,7 @@ try { createFakeCodexPass(); createFakeChecker(); createFakeCheckerBlocker(); + createDesktopCodexFallback(); const executed = run(nodeBin, [ cliPath, @@ -202,6 +206,42 @@ try { fail("Expected isolated run to record completed proof with worktree metadata"); } + createFailingFixture(desktopProjectDir); + expectFailure("npm", ["test"], "Expected desktop fallback fixture npm test to fail before Codex runs", desktopProjectDir); + const desktopResolved = run(nodeBin, [ + cliPath, + "run", + "--from", + "failing-ci-repair", + "--goal", + "Fix the failing test using the Codex Desktop CLI fallback", + "--check", + "npm test", + "--agent", + "codex", + "--execute", + "codex", + "--codex-sandbox", + "none", + "--max-iterations", + "1", + "--skip-git-repo-check", + ], { + cwd: desktopProjectDir, + env: { + ...process.env, + HOME: desktopHomeDir, + PATH: [dirname(process.execPath), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].join(delimiter), + }, + }); + + assertIncludes(desktopResolved.stdout, `Using Codex Desktop CLI: ${desktopCodex}`, "Codex Desktop fallback output"); + run("npm", ["test"], { cwd: desktopProjectDir }); + const desktopProgress = JSON.parse(readFileSync(resolve(desktopProjectDir, ".loop-it", "progress.json"), "utf8")); + if (desktopProgress.status !== "completed" || desktopProgress.proof?.result !== "pass") { + fail("Expected Codex Desktop fallback run to complete with proof"); + } + console.log("Run proof smoke passed"); console.log("Verified: failing fixture before run, selected loop, repeated fake Codex execution, verifier pass, checker pass/block, isolated worktree execution, and progress proof"); } finally { @@ -332,6 +372,29 @@ function createFakeCheckerBlocker() { chmodSync(fakeCheckerBlocker, 0o755); } +function createDesktopCodexFallback() { + mkdirSync(dirname(desktopCodex), { recursive: true }); + writeFileSync( + desktopCodex, + [ + "#!/usr/bin/env node", + "import { readFileSync, writeFileSync } from 'node:fs';", + "import { resolve } from 'node:path';", + "", + "const testPath = resolve(process.cwd(), 'test.mjs');", + "const before = readFileSync(testPath, 'utf8');", + "writeFileSync(testPath, before.replace('assert.equal(total, 3);', 'assert.equal(total, 2);'));", + "const outputIndex = process.argv.indexOf('--output-last-message');", + "if (outputIndex !== -1 && process.argv[outputIndex + 1]) {", + " writeFileSync(resolve(process.cwd(), process.argv[outputIndex + 1]), 'Codex Desktop fallback fixed the test.\\n');", + "}", + "", + ].join("\n"), + "utf8" + ); + chmodSync(desktopCodex, 0o755); +} + function initGitRepo(targetDir) { run("git", ["init", "-b", "main"], { cwd: targetDir }); run("git", ["add", "."], { cwd: targetDir }); @@ -349,6 +412,7 @@ function initGitRepo(targetDir) { function run(command, commandArgs, options = {}) { const result = spawnSync(command, commandArgs, { cwd: options.cwd ?? repoRoot, + env: options.env ?? process.env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); diff --git a/tests/smoke-install.mjs b/tests/smoke-install.mjs index 849e0fd..2f32b5f 100644 --- a/tests/smoke-install.mjs +++ b/tests/smoke-install.mjs @@ -11,7 +11,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, resolve } from "node:path"; +import { delimiter, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; @@ -1119,6 +1119,8 @@ function smokeScheduledRunner() { function smokeDoctor() { const projectDir = resolve(tempRoot, "doctor"); const codexHome = resolve(tempRoot, "doctor-codex-home"); + const desktopHome = resolve(tempRoot, "doctor-desktop-home"); + const desktopCodex = resolve(desktopHome, "Applications", "ChatGPT.app", "Contents", "Resources", "codex"); const fakeCodex = resolve(tempRoot, "fake-codex-doctor.mjs"); const fakeNpm = resolve(tempRoot, "fake-npm-doctor.mjs"); const packageJson = JSON.parse(readFileSync(resolve(repoRoot, "package.json"), "utf8")); @@ -1144,6 +1146,10 @@ function smokeDoctor() { ); chmodSync(fakeNpm, 0o755); + mkdirSync(dirname(desktopCodex), { recursive: true }); + writeFileSync(desktopCodex, "#!/usr/bin/env node\nconsole.log('codex-cli desktop-1.2.3');\n"); + chmodSync(desktopCodex, 0o755); + const pluginPath = resolve( codexHome, "plugins", @@ -1207,6 +1213,30 @@ function smokeDoctor() { fail("Expected doctor to report a ready Loop It install with Codex heartbeat"); } + const desktopReady = JSON.parse(run(nodeBin, [ + cliPath, + "doctor", + "--cwd", + projectDir, + "--codex-home", + codexHome, + "--npm-bin", + fakeNpm, + "--json", + ], { + env: { + HOME: desktopHome, + PATH: [dirname(process.execPath), "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"].join(delimiter), + }, + }).stdout); + if ( + desktopReady.ok !== true || + desktopReady.codex.cli.status !== "ready" || + desktopReady.codex.cli.command !== desktopCodex + ) { + fail("Expected doctor to discover the bundled Codex Desktop CLI when codex is not on PATH"); + } + const human = run(nodeBin, [ cliPath, "doctor", @@ -1315,6 +1345,9 @@ function smokeDoctor() { const isolatedDoctorPath = resolve(invalidPackageRoot, "skills", "loop-it", "scripts", "doctor.mjs"); mkdirSync(dirname(isolatedDoctorPath), { recursive: true }); writeFileSync(isolatedDoctorPath, readFileSync(resolve(skillSource, "scripts", "doctor.mjs"))); + const isolatedCodexCliPath = resolve(dirname(isolatedDoctorPath), "lib", "codex-cli.mjs"); + mkdirSync(dirname(isolatedCodexCliPath), { recursive: true }); + writeFileSync(isolatedCodexCliPath, readFileSync(resolve(skillSource, "scripts", "lib", "codex-cli.mjs"))); writeFileSync(resolve(invalidPackageRoot, "package.json"), "{\n"); const invalidPackage = spawnSync(nodeBin, [ isolatedDoctorPath,