diff --git a/README.md b/README.md index cb5e1dc..9699731 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ To use the interactive mode, you'll also need one of the following LLM CLI tools - **GitHub Copilot CLI** — Install the [GitHub CLI](https://cli.github.com/), authenticate with `gh auth login`, ensure Copilot access is enabled for your account/organization, then run `gh extension install github/gh-copilot` - **Claude Code** — [Install Claude Code](https://docs.anthropic.com/en/docs/claude-code) +- **OpenAI Codex CLI** — [Install Codex CLI](https://github.com/openai/codex) Not using a CLI tool? See [Using with any LLM (manual)](#using-with-any-llm-manual). @@ -179,6 +180,15 @@ cd promptkit claude "Read and execute bootstrap.md" ``` +### Using with Codex CLI + +Codex also supports reading the bootstrap file directly from the repo root: + +```bash +cd promptkit +codex "Read and execute bootstrap.md" +``` + ### Using with any LLM (manual) If your tool doesn't support skills or file access, paste the bootstrap diff --git a/cli/bin/cli.js b/cli/bin/cli.js index 4c9a778..9e64730 100644 --- a/cli/bin/cli.js +++ b/cli/bin/cli.js @@ -55,7 +55,7 @@ program .description("Launch an interactive session with your LLM CLI (default)") .option( "--cli ", - "LLM CLI to use (copilot, gh-copilot, claude)" + "LLM CLI to use (copilot, gh-copilot, claude, codex)" ) .option( "--dry-run", diff --git a/cli/lib/launch.js b/cli/lib/launch.js index 037b549..8461d52 100644 --- a/cli/lib/launch.js +++ b/cli/lib/launch.js @@ -8,18 +8,37 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); +function pathDirs() { + return (process.env.PATH || "").split(path.delimiter).filter(Boolean); +} + +function windowsPathExts() { + return (process.env.PATHEXT || ".EXE;.COM;.BAT;.CMD") + .split(";") + .map((e) => e.toLowerCase()); +} + +function isExactFileOnPath(fileName) { + for (const dir of pathDirs()) { + try { + fs.accessSync(path.join(dir, fileName), fs.constants.F_OK); + return true; + } catch { + // not found in this directory, continue + } + } + return false; +} + function isOnPath(cmd) { // Search PATH entries directly rather than shelling out to `which`/`where`. // This avoids requiring `which` to be on PATH itself (important in test // environments where PATH is restricted to a mock directory). - const pathDirs = (process.env.PATH || "").split(path.delimiter).filter(Boolean); - const exts = process.platform === "win32" - ? (process.env.PATHEXT || ".EXE;.COM;.BAT;.CMD").split(";").map((e) => e.toLowerCase()) - : [""]; + const exts = process.platform === "win32" ? windowsPathExts() : [""]; // On Windows, X_OK is not meaningful — any file with a matching PATHEXT // extension is considered executable, so we check for existence (F_OK) only. const accessFlag = process.platform === "win32" ? fs.constants.F_OK : fs.constants.X_OK; - for (const dir of pathDirs) { + for (const dir of pathDirs()) { for (const ext of exts) { try { fs.accessSync(path.join(dir, cmd + ext), accessFlag); @@ -32,6 +51,31 @@ function isOnPath(cmd) { return false; } +function resolveSpawnCommand(cmd) { + if (process.platform !== "win32") return cmd; + + const shim = `${cmd}.cmd`; + return isExactFileOnPath(shim) ? shim : cmd; +} + +function quoteWindowsArg(arg) { + if (arg === "") return '""'; + if (!/[\s"]/u.test(arg)) return arg; + return `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/g, '$1$1')}"`; +} + +function spawnCli(cmd, args, options) { + if (process.platform === "win32" && /\.cmd$/i.test(cmd)) { + const comspec = process.env.ComSpec || "cmd.exe"; + const commandLine = [cmd, ...args].map(quoteWindowsArg).join(" "); + return spawn(comspec, ["/d", "/s", "/c", commandLine], { + ...options, + windowsVerbatimArguments: true, + }); + } + return spawn(cmd, args, options); +} + function detectCli() { // Check for GitHub Copilot CLI first (most common) if (isOnPath("copilot")) return "copilot"; @@ -45,6 +89,7 @@ function detectCli() { } } if (isOnPath("claude")) return "claude"; + if (isOnPath("codex")) return "codex"; return null; } @@ -76,7 +121,8 @@ function launchInteractive(contentDir, cliName, { dryRun = false } = {}) { "No supported LLM CLI found on PATH.\n\n" + "Install one of:\n" + " - GitHub Copilot CLI: gh extension install github/gh-copilot\n" + - " - Claude Code: https://docs.anthropic.com/en/docs/claude-code\n\n" + + " - Claude Code: https://docs.anthropic.com/en/docs/claude-code\n" + + " - OpenAI Codex CLI: https://github.com/openai/codex\n\n" + "Alternatively, load bootstrap.md in your LLM manually from:\n" + ` ${contentDir}` ); @@ -107,7 +153,7 @@ function launchInteractive(contentDir, cliName, { dryRun = false } = {}) { let cmd, args; switch (cli) { case "copilot": - cmd = "copilot"; + cmd = resolveSpawnCommand("copilot"); // --add-dir grants file access to the staging directory. args = ["--add-dir", tmpDir, "-i", bootstrapPrompt]; break; @@ -117,7 +163,11 @@ function launchInteractive(contentDir, cliName, { dryRun = false } = {}) { break; case "claude": // --add-dir grants file access to the staging directory. - cmd = "claude"; + cmd = resolveSpawnCommand("claude"); + args = ["--add-dir", tmpDir, bootstrapPrompt]; + break; + case "codex": + cmd = resolveSpawnCommand("codex"); args = ["--add-dir", tmpDir, bootstrapPrompt]; break; default: @@ -142,7 +192,7 @@ function launchInteractive(contentDir, cliName, { dryRun = false } = {}) { // All CLIs are spawned from the user's original directory so the LLM // session reflects the directory the user was working in. - const child = spawn(cmd, args, { + const child = spawnCli(cmd, args, { cwd: originalCwd, stdio: "inherit", }); diff --git a/cli/package.json b/cli/package.json index 640dfc5..0ddfe91 100644 --- a/cli/package.json +++ b/cli/package.json @@ -37,6 +37,7 @@ "llm", "ai", "copilot", + "codex", "prompt-templates", "agentic-ai", "developer-tools" diff --git a/cli/specs/design.md b/cli/specs/design.md index e9ff5be..e730ffc 100644 --- a/cli/specs/design.md +++ b/cli/specs/design.md @@ -108,7 +108,7 @@ validate content availability. by category, and displays the result. No separate `manifest.js` module is used (see REQ-CLI-103). - The `--cli` flag documents valid values (`copilot`, `gh-copilot`, - `claude`) in its help text (see REQ-CLI-011). + `claude`, `codex`) in its help text (see REQ-CLI-011). **Key function**: @@ -143,13 +143,16 @@ is inlined in `cli.js`. See REQ-CLI-101, REQ-CLI-103.* interactive session. **Design decisions**: -- CLI detection uses `execFileSync` with `where` (Windows) or `which` - (Unix) — this is the most reliable cross-platform way to check if a - command exists on PATH without actually executing it. -- The detection order (copilot → gh-copilot → claude) prioritizes GitHub +- CLI detection uses direct PATH scanning for better Windows compatibility. +- The detection order (copilot → gh-copilot → claude → codex) prioritizes GitHub Copilot CLI as the primary target. The `gh copilot` variant is checked by actually running `gh copilot --help` to verify the extension is installed, not just that `gh` exists. +- On Windows, npm-installed CLIs such as `copilot`, `claude`, and `codex` + may need their `.cmd` shims invoked explicitly because Node's + `child_process.spawn()` does not resolve commands the same way an + interactive shell does. The launcher therefore prefers `.cmd` + when present on `PATH`. - Content is copied to a temp directory (`os.tmpdir()` + `mkdtempSync`) because LLM CLIs need to read the files from their CWD, and the npm package's `content/` directory may be in a read-only or non-obvious @@ -176,7 +179,7 @@ Internal helper. Checks if a command exists on PATH using platform- appropriate lookup. ``` -detectCli() → "copilot" | "gh-copilot" | "claude" | null +detectCli() → "copilot" | "gh-copilot" | "claude" | "codex" | null ``` Probes PATH for supported LLM CLIs in priority order. @@ -397,7 +400,7 @@ Global options: Interactive options: --cli Override LLM CLI auto-detection - Valid values: copilot, gh-copilot, claude + Valid values: copilot, gh-copilot, claude, codex ``` ### 5.2 Module Exports @@ -405,7 +408,7 @@ Interactive options: **launch.js**: ```javascript module.exports = { - detectCli, // () → "copilot" | "gh-copilot" | "claude" | null + detectCli, // () → "copilot" | "gh-copilot" | "claude" | "codex" | null launchInteractive, // (contentDir: string, cliName: string | null) → never copyContentToTemp // (contentDir: string) → string (tmpDir path) } diff --git a/cli/specs/requirements.md b/cli/specs/requirements.md index 0ae456b..b7c0803 100644 --- a/cli/specs/requirements.md +++ b/cli/specs/requirements.md @@ -526,4 +526,4 @@ dirs) in all normal and error scenarios. `lib/launch.js`, and `content/` with all prompt components. The tarball MUST NOT contain `lib/assemble.js` or `lib/manifest.js`. -**AC-005**: The CLI runs without errors on Node.js 18, 20, and 22. +**AC-005**: The CLI runs without errors on Node.js 18, 20, and 22. \ No newline at end of file diff --git a/cli/specs/validation.md b/cli/specs/validation.md index 483bed0..97176b1 100644 --- a/cli/specs/validation.md +++ b/cli/specs/validation.md @@ -221,6 +221,12 @@ See REQ-CLI-100.* - *Steps*: Ensure only `claude` is on PATH. - *Expected*: Returns `"claude"`. +**TC-CLI-072A**: detectCli finds codex after claude is absent. +- *Requirement*: REQ-CLI-010 +- *Type*: Unit +- *Steps*: Ensure only `codex` is on PATH. +- *Expected*: Returns `"codex"`. + **TC-CLI-073**: detectCli returns null when nothing found. - *Requirement*: REQ-CLI-010 - *Type*: Unit @@ -275,7 +281,7 @@ See REQ-CLI-100.* **TC-CLI-081**: Correct command construction for each CLI. - *Requirement*: REQ-CLI-017 - *Type*: Unit -- *Steps*: Verify spawn cmd/args for `copilot`, `gh-copilot`, `claude`. +- *Steps*: Verify spawn cmd/args for `copilot`, `gh-copilot`, `claude`, `codex`. - *Expected*: - copilot: `cmd="copilot"`, args include `"--add-dir"`, ``, `"-i"`, `"Read and execute /bootstrap.md"` @@ -283,6 +289,8 @@ See REQ-CLI-100.* ``, `"-i"`, `"Read and execute /bootstrap.md"` - claude: `cmd="claude"`, args include `"--add-dir"`, ``, `"Read and execute /bootstrap.md"` + - codex: `cmd="codex"`, args include `"--add-dir"`, ``, + `"Read and execute /bootstrap.md"` **TC-CLI-082**: All CLIs are spawned with the user's original working directory. - *Requirement*: REQ-CLI-024 @@ -422,7 +430,7 @@ concern.* | REQ-CLI-002 | TC-CLI-001, TC-CLI-004 | High | Active | | REQ-CLI-003 | TC-CLI-002 | Medium | Active | | REQ-CLI-004 | TC-CLI-003, TC-CLI-003a | High | Active | -| REQ-CLI-010 | TC-CLI-070 through TC-CLI-074 | High | Active | +| REQ-CLI-010 | TC-CLI-070 through TC-CLI-074, TC-CLI-072A | High | Active | | REQ-CLI-011 | TC-CLI-075 | Medium | Active | | REQ-CLI-012 | TC-CLI-076 | High | Active | | REQ-CLI-013 | TC-CLI-077 | Low | Active | diff --git a/cli/tests/launch.test.js b/cli/tests/launch.test.js index c584525..19b9856 100644 --- a/cli/tests/launch.test.js +++ b/cli/tests/launch.test.js @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// cli/tests/launch.test.js — Launch module unit tests +// cli/tests/launch.test.js ??? Launch module unit tests const { describe, it, before, after } = require("node:test"); const assert = require("node:assert"); @@ -46,7 +46,7 @@ describe("Launch Module", () => { before(() => { assert.ok( fs.existsSync(contentDir), - "content/ must exist — run 'npm run prepare' first" + "content/ must exist ??? run 'npm run prepare' first" ); }); @@ -110,8 +110,8 @@ describe("Launch Module", () => { // Run an inline Node script that requires launch.js by absolute path // and calls detectCli() with PATH set to mockDir only. - // isOnPath() in launch.js searches PATH directories directly (no `which`), - // so mockDir is sufficient — no system binary directories are needed. + // isOnPath() in launch.js scans PATH directories directly, + // so mockDir is sufficient and no system binary directories are needed. function runDetectCli() { const testPath = mockDir; const script = [ @@ -140,6 +140,14 @@ describe("Launch Module", () => { assert.strictEqual(runDetectCli(), "claude"); }); + it("TC-CLI-072A: detectCli finds codex after claude", () => { + removeMockCmd("copilot"); + removeMockCmd("gh"); + removeMockCmd("claude"); + createMockCmd("codex"); + assert.strictEqual(runDetectCli(), "codex"); + }); + it("TC-CLI-074: gh without copilot extension is not detected as gh-copilot", () => { removeMockCmd("copilot"); removeMockCmd("claude"); @@ -249,7 +257,7 @@ describe("Launch Module", () => { .join("\n"); assert.ok( !/\bshell\s*:\s*true\b/.test(nonCommentLines), - "launch.js must not pass shell: true to spawn() — doing so splits the bootstrap prompt into multiple arguments" + "launch.js must not pass shell: true to spawn() ??? doing so splits the bootstrap prompt into multiple arguments" ); }); }); @@ -287,7 +295,7 @@ describe("Launch Module", () => { if (process.platform === "win32") { fs.writeFileSync( path.join(mockBinDir, `${binName}.cmd`), - `@"${process.execPath}" "${implScript}" %*\r\n` + `@echo off\r\n"${process.execPath}" "${implScript}" %*\r\n` ); } else { const p = path.join(mockBinDir, binName); @@ -322,8 +330,8 @@ describe("Launch Module", () => { return JSON.parse(fs.readFileSync(captureFile, "utf8")); } - for (const cliName of ["claude", "copilot", "gh-copilot"]) { - // TC-CLI-082 and TC-CLI-083 combined — run once per CLI + for (const cliName of ["claude", "copilot", "gh-copilot", "codex"]) { + // TC-CLI-082 and TC-CLI-083 combined ??? run once per CLI it(`TC-CLI-082/083: ${cliName} spawned with originalCwd and --add-dir for staging dir`, () => { const mockBinDir = path.join(cwdTestTmpDir, `mock-bin-${cliName}`); fs.mkdirSync(mockBinDir, { recursive: true }); @@ -371,7 +379,7 @@ describe("Launch Module", () => { }); describe("--dry-run flag", () => { - for (const cliName of ["copilot", "gh-copilot", "claude"]) { + for (const cliName of ["copilot", "gh-copilot", "claude", "codex"]) { it(`TC-CLI-085: --dry-run prints spawn command for ${cliName} without launching`, () => { // --dry-run must print the command and args then exit 0 without // spawning the real LLM CLI. We run with an empty PATH so that @@ -379,6 +387,13 @@ describe("Launch Module", () => { const emptyBinDir = fs.mkdtempSync( path.join(os.tmpdir(), "promptkit-dryrun-empty-") ); + if (process.platform === "win32" && cliName !== "gh-copilot") { + // Provide a local .cmd shim so resolveSpawnCommand() can find it. + fs.writeFileSync( + path.join(emptyBinDir, `${cliName}.cmd`), + "@echo off\r\nexit /b 0\r\n" + ); + } let stdout = ""; let exitCode = 0; @@ -406,10 +421,25 @@ describe("Launch Module", () => { // Parse the args line as JSON so we verify structure, not wording. const lines = stdout.split("\n"); + const cmdLine = lines.find((l) => l.trim().startsWith("cmd:")); const argsLine = lines.find((l) => l.trim().startsWith("args:")); + assert.ok(cmdLine, `--dry-run output should include a 'cmd:' line for ${cliName}`); assert.ok(argsLine, `--dry-run output should include an 'args:' line for ${cliName}`); + const parsedCmd = cmdLine.trim().slice("cmd:".length).trim(); const parsedArgs = JSON.parse(argsLine.trim().slice("args:".length).trim()); + if (cliName === "gh-copilot") { + assert.strictEqual(parsedCmd, "gh", "gh-copilot should spawn gh"); + } else if (process.platform === "win32") { + assert.strictEqual( + parsedCmd, + `${cliName}.cmd`, + `${cliName} should spawn the Windows .cmd shim` + ); + } else { + assert.strictEqual(parsedCmd, cliName, `${cliName} should spawn its bare command`); + } + // The bootstrap prompt must appear as exactly one element containing bootstrap.md, // not split across multiple elements (the shell: true regression). const bootstrapArgs = parsedArgs.filter((a) => a.includes("bootstrap.md")); @@ -436,3 +466,4 @@ describe("Launch Module", () => { } }); }); + diff --git a/docs/faq.md b/docs/faq.md index eb2bebb..837d9f0 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -23,12 +23,12 @@ Yes. PromptKit generates standard Markdown prompts. The assembled output can be pasted into any LLM interface — ChatGPT, Claude, Gemini, Copilot Chat, or any other tool that accepts text input. -Interactive mode requires GitHub Copilot CLI or Claude Code, but the +Interactive mode requires GitHub Copilot CLI, Claude Code, or OpenAI Codex CLI, but the `assemble` command produces a plain text file usable anywhere. ### Do I need GitHub Copilot CLI? -No. GitHub Copilot CLI (or Claude Code) is only needed for **interactive +No. GitHub Copilot CLI (or Claude Code or OpenAI Codex CLI) is only needed for **interactive mode**, which launches a live prompt-building session. The `list` and `assemble` commands work standalone with just Node.js 18+. @@ -205,8 +205,8 @@ npx promptkit list ### Interactive mode says "No supported LLM CLI found" -Interactive mode requires GitHub Copilot CLI (`copilot`) or Claude Code -(`claude`) on your PATH. Install one of them, or use `assemble` mode +Interactive mode requires GitHub Copilot CLI (`copilot`), Claude Code +(`claude`), or OpenAI Codex CLI (`codex`) on your PATH. Install one of them, or use `assemble` mode instead. ### The assembled prompt is missing a section diff --git a/docs/getting-started.md b/docs/getting-started.md index 203b901..ac9c758 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -16,7 +16,8 @@ your behalf. - **Node.js 18+** — required for the `npx` CLI - **Optional:** [GitHub Copilot CLI](https://docs.github.com/en/copilot) - or [Claude Code](https://docs.anthropic.com/en/docs/claude-code) for + or [Claude Code](https://docs.anthropic.com/en/docs/claude-code) or + [OpenAI Codex CLI](https://github.com/openai/codex) for interactive mode ## Quick Start @@ -59,8 +60,8 @@ and you're running. npx promptkit ``` -Interactive mode auto-detects your LLM CLI (GitHub Copilot CLI or Claude -Code), copies PromptKit's content to a temp directory, and launches an +Interactive mode auto-detects your LLM CLI (GitHub Copilot CLI, Claude +Code, or OpenAI Codex CLI), copies PromptKit's content to a temp directory, and launches an interactive session with `bootstrap.md` as the custom instruction. The bootstrap engine walks you through: