Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cli/bin/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ program
.description("Launch an interactive session with your LLM CLI (default)")
.option(
"--cli <name>",
"LLM CLI to use (copilot, gh-copilot, claude)"
"LLM CLI to use (copilot, gh-copilot, claude, codex)"
)
.option(
"--dry-run",
Expand Down
68 changes: 59 additions & 9 deletions cli/lib/launch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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";
Expand All @@ -45,6 +89,7 @@ function detectCli() {
}
}
if (isOnPath("claude")) return "claude";
if (isOnPath("codex")) return "codex";
return null;
}

Expand Down Expand Up @@ -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}`
);
Expand Down Expand Up @@ -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;
Expand All @@ -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:
Expand All @@ -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",
});
Expand Down
1 change: 1 addition & 0 deletions cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"llm",
"ai",
"copilot",
"codex",
"prompt-templates",
"agentic-ai",
"developer-tools"
Expand Down
19 changes: 11 additions & 8 deletions cli/specs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**:

Expand Down Expand Up @@ -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
Comment thread
Alan-Jowett marked this conversation as resolved.
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 `<name>.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
Expand All @@ -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.

Expand Down Expand Up @@ -397,15 +400,15 @@ Global options:

Interactive options:
--cli <name> Override LLM CLI auto-detection
Valid values: copilot, gh-copilot, claude
Valid values: copilot, gh-copilot, claude, codex
```

### 5.2 Module Exports

**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)
}
Expand Down
2 changes: 1 addition & 1 deletion cli/specs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
12 changes: 10 additions & 2 deletions cli/specs/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -275,14 +281,16 @@ 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"`, `<tmpDir>`,
`"-i"`, `"Read and execute <abs>/bootstrap.md"`
- gh-copilot: `cmd="gh"`, args include `"copilot"`, `"--add-dir"`,
`<tmpDir>`, `"-i"`, `"Read and execute <abs>/bootstrap.md"`
- claude: `cmd="claude"`, args include `"--add-dir"`, `<tmpDir>`,
`"Read and execute <abs>/bootstrap.md"`
- codex: `cmd="codex"`, args include `"--add-dir"`, `<tmpDir>`,
`"Read and execute <abs>/bootstrap.md"`

**TC-CLI-082**: All CLIs are spawned with the user's original working directory.
- *Requirement*: REQ-CLI-024
Expand Down Expand Up @@ -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 |
Expand Down
49 changes: 40 additions & 9 deletions cli/tests/launch.test.js
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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"
);
});

Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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"
);
});
});
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -371,14 +379,21 @@ 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
// no real CLI can be found, proving nothing was actually spawned.
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;
Expand Down Expand Up @@ -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`
Comment thread
Alan-Jowett marked this conversation as resolved.
);
} 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"));
Expand All @@ -436,3 +466,4 @@ describe("Launch Module", () => {
}
});
});

Loading
Loading