Skip to content
Open
20 changes: 15 additions & 5 deletions cli/lib/launch.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,35 +78,45 @@ function launchInteractive(contentDir, cliName) {
);
}

// Preserve the user's working directory before staging content.
const originalCwd = process.cwd();

// Copy content to a temp directory so the LLM can read the files
const tmpDir = copyContentToTemp(contentDir);
console.log(`PromptKit content staged at: ${tmpDir}`);
console.log(`Launching ${cli}...\n`);

const bootstrapPrompt = "Read and execute bootstrap.md";
// Use an absolute path so the LLM can locate bootstrap.md regardless of
// which directory it treats as its working directory.
const bootstrapPrompt = `Read and execute ${path.join(tmpDir, "bootstrap.md")}`;

let cmd, args;
switch (cli) {
case "copilot":
cmd = "copilot";
args = ["-i", bootstrapPrompt];
// --add-dir grants file access to the staging directory.
args = ["--add-dir", tmpDir, "-i", bootstrapPrompt];
break;
case "gh-copilot":
cmd = "gh";
args = ["copilot", "-i", bootstrapPrompt];
args = ["copilot", "--add-dir", tmpDir, "-i", bootstrapPrompt];
break;
case "claude":
// --add-dir grants file access to the staging directory.
cmd = "claude";
args = [bootstrapPrompt];
args = ["--add-dir", tmpDir, bootstrapPrompt];
break;
default:
console.error(`Unknown CLI: ${cli}`);
process.exit(1);
}

// 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, {
cwd: tmpDir,
cwd: originalCwd,
stdio: "inherit",
shell: true,
});

child.on("error", (err) => {
Expand Down
4 changes: 2 additions & 2 deletions cli/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

57 changes: 42 additions & 15 deletions cli/specs/requirements.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
title: "PromptKit CLI — Requirements Specification"
project: "PromptKit CLI (@alan-jowett/promptkit)"
version: "0.3.0"
date: "2025-07-17"
version: "0.4.0"
date: "2026-03-31"
status: draft
source_files:
- cli/bin/cli.js
Expand All @@ -19,6 +19,8 @@ source_files:
|-----|------|--------|-------------|
| 0.1 | 2025-07-17 | Spec-extraction-workflow | Initial draft extracted from source code |
| 0.2 | 2025-07-18 | Engineering-workflow Phase 2 | Retired assemble command (REQ-CLI-030–037), assembly engine (REQ-CLI-040–051), manifest resolution module (REQ-CLI-060–069). Kept list command with inline manifest parsing. Modified REQ-CLI-002, 004, 011, 012, 020–023, 080, 091, 094. Retired REQ-CLI-092, CON-005, ASSUMPTION-002, ASSUMPTION-006. Added REQ-CLI-100, 101, 103. |
| 0.3 | 2026-03-31 | Bug-fix | Added REQ-CLI-024 (cwd preservation for claude). Updated REQ-CLI-015 and REQ-CLI-017 to reflect per-CLI spawn cwd behaviour. |
| 0.4 | 2026-03-31 | Bug-fix | Extended cwd fix to all CLIs. Added REQ-CLI-025 (--add-dir for staging directory). Updated REQ-CLI-015, 016, 017, 024 to be CLI-agnostic. |

---

Expand Down Expand Up @@ -111,25 +113,50 @@ directory to a temporary directory before launching the LLM CLI.
content files.

**REQ-CLI-015**: The `interactive` command MUST spawn the LLM CLI process
with the working directory set to the temporary content directory and
`stdio: "inherit"` so the user can interact directly.
- *Source*: `launch.js` lines 107–110.
- *Acceptance*: The child process has `cwd` equal to the temp directory
and inherits stdin/stdout/stderr.
with `cwd` set to the user's working directory at the time the interactive
session is launched (captured when launching) and `stdio: "inherit"` so the
user can interact directly.
- *Source*: `launch.js` (`launchInteractive()`).
- *Acceptance*: The spawned process has `cwd` equal to the directory from
which `promptkit` was invoked. The process inherits stdin/stdout/stderr.

**REQ-CLI-016**: The `interactive` command MUST pass the bootstrap prompt
`"Read and execute bootstrap.md"` as the initial instruction to the LLM CLI.
- *Source*: `launch.js` line 86.
- *Acceptance*: The spawned process receives this string as an argument.
`"Read and execute <abs-path-to-bootstrap.md>"` as the initial instruction
to the LLM CLI, where `<abs-path-to-bootstrap.md>` is the absolute path to
`bootstrap.md` inside the temporary staging directory. The absolute path
allows the LLM to locate the file regardless of which directory it treats
as its working directory.
- *Source*: `launch.js` (`launchInteractive()`).
- *Acceptance*: The spawned process receives a string argument that contains
an absolute path ending in `bootstrap.md`.

**REQ-CLI-017**: The CLI MUST construct the correct command and arguments
for each supported LLM CLI:
- `copilot`: `copilot -i "Read and execute bootstrap.md"`
- `gh-copilot`: `gh copilot -i "Read and execute bootstrap.md"`
- `claude`: `claude "Read and execute bootstrap.md"`
- *Source*: `launch.js` lines 89–105.
for each supported LLM CLI. All CLIs receive `--add-dir <tmpDir>` and an
absolute path to `bootstrap.md`:
- `copilot`: `copilot --add-dir <tmpDir> -i "Read and execute <abs>/bootstrap.md"`
- `gh-copilot`: `gh copilot --add-dir <tmpDir> -i "Read and execute <abs>/bootstrap.md"`
- `claude`: `claude --add-dir <tmpDir> "Read and execute <abs>/bootstrap.md"`
- *Source*: `launch.js` (`launchInteractive()`).
- *Acceptance*: Spawn is called with the documented cmd/args for each CLI.

**REQ-CLI-024**: The `interactive` command MUST preserve the user's original
working directory for ALL supported LLM CLIs. Every LLM CLI child process
MUST be spawned with `cwd` equal to the directory from which `promptkit`
was invoked, not the temporary staging directory.
- *Source*: `launch.js` (`launchInteractive()`).
- *Acceptance*: When `promptkit --cli <name>` is run from directory `D`,
the spawned process reports `cwd = D` for every supported CLI. The cwd
is NOT the temporary `promptkit-*` staging directory.

**REQ-CLI-025**: The `interactive` command MUST grant the LLM CLI file
access to the temporary staging directory by passing `--add-dir <tmpDir>`
at launch, for every supported LLM CLI. This ensures the LLM can read
PromptKit content files from the staging directory even though the process
cwd is the user's original working directory.
- *Source*: `launch.js` (`launchInteractive()`).
- *Acceptance*: The spawn args for every supported CLI contain `--add-dir`
followed by the path of the temporary staging directory.

**REQ-CLI-018**: When the child process exits, the CLI MUST clean up the
temporary directory (best-effort) and then exit with the child's exit code.
If the child was killed by a signal, the CLI MUST re-send that signal to
Expand Down
46 changes: 39 additions & 7 deletions cli/specs/validation.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
---
title: "PromptKit CLI — Validation Plan"
project: "PromptKit CLI (@alan-jowett/promptkit)"
version: "0.3.0"
date: "2025-07-17"
version: "0.4.0"
date: "2026-03-31"
status: draft
related:
- requirements: cli/specs/requirements.md
Expand All @@ -17,6 +17,8 @@ related:
|-----|------|--------|-------------|
| 0.1 | 2025-07-17 | Spec-extraction-workflow | Initial draft extracted from source code |
| 0.2 | 2025-07-18 | Engineering-workflow Phase 2 | Retired test cases for assembly engine (TC-CLI-010–024), manifest resolution (TC-CLI-030–042), assemble command (TC-CLI-060–067), Windows frontmatter (TC-CLI-113). Updated TC-CLI-001, TC-CLI-003, TC-CLI-053. Added TC-CLI-120–122 for new requirements. Updated traceability matrix. |
| 0.3 | 2026-03-31 | Bug-fix | Added TC-CLI-082 for REQ-CLI-024 (claude cwd preservation). Updated TC-CLI-080/081 notes. Updated traceability matrix. |
| 0.4 | 2026-03-31 | Bug-fix | Extended TC-CLI-082 to all CLIs. Added TC-CLI-083 (--add-dir for staging dir). Updated TC-CLI-080/081/082. Added REQ-CLI-025 to traceability. |

---

Expand Down Expand Up @@ -267,16 +269,44 @@ See REQ-CLI-100.*
- *Requirement*: REQ-CLI-016
- *Type*: Unit
- *Steps*: Inspect the spawn arguments for each CLI type.
- *Expected*: `"Read and execute bootstrap.md"` appears in args.
- *Expected*: For every CLI, the bootstrap prompt argument is an absolute
path ending in `bootstrap.md` (e.g. `"Read and execute /tmp/promptkit-xxx/bootstrap.md"`).

**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`.
- *Expected*:
- copilot: `cmd="copilot"`, `args=["-i", "Read and execute bootstrap.md"]`
- gh-copilot: `cmd="gh"`, `args=["copilot", "-i", "Read and execute bootstrap.md"]`
- claude: `cmd="claude"`, `args=["Read and execute bootstrap.md"]`
- 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"`

**TC-CLI-082**: All CLIs are spawned with the user's original working directory.
- *Requirement*: REQ-CLI-024
- *Type*: Integration (uses mock CLI executables)
- *Steps*:
1. Create a mock executable (for each CLI under test) that records
`process.cwd()` to a JSON file and exits.
2. Run `promptkit interactive --cli <name>` from a known directory `D`
with the mock on PATH.
3. Read the recorded cwd from the file.
- *Expected*: The recorded cwd equals `D` for every supported CLI. It does
NOT equal the temporary `promptkit-*` staging directory.

**TC-CLI-083**: All CLIs receive `--add-dir <tmpDir>` in their spawn arguments.
- *Requirement*: REQ-CLI-025
- *Type*: Integration (uses mock CLI executables)
- *Steps*:
1. Create a mock executable (for each CLI under test) that records
`process.argv.slice(2)` to a JSON file and exits.
2. Run `promptkit interactive --cli <name>` with the mock on PATH.
3. Read the recorded args from the file.
- *Expected*: For every supported CLI, `"--add-dir"` appears in the recorded
args and the following argument is the path of the temporary staging
directory (a directory under `os.tmpdir()` with a `promptkit-` prefix).

### 2.7 Content Bundling (copy-content.js)

Expand Down Expand Up @@ -397,7 +427,7 @@ concern.*
| REQ-CLI-012 | TC-CLI-076 | High | Active |
| REQ-CLI-013 | TC-CLI-077 | Low | Active |
| REQ-CLI-014 | TC-CLI-078 | High | Active |
| REQ-CLI-015 | TC-CLI-078, TC-CLI-081 | High | Active |
| REQ-CLI-015 | TC-CLI-078, TC-CLI-081, TC-CLI-082 | High | Active |
| REQ-CLI-016 | TC-CLI-080 | High | Active |
| REQ-CLI-017 | TC-CLI-081 | High | Active |
| REQ-CLI-018 | TC-CLI-079 | High | Active |
Expand All @@ -406,6 +436,8 @@ concern.*
| REQ-CLI-021 | TC-CLI-051 | Medium | Active |
| REQ-CLI-022 | TC-CLI-052 | Medium | Active |
| REQ-CLI-023 | TC-CLI-053 | Low | Active |
| REQ-CLI-024 | TC-CLI-082 | High | Active |
| REQ-CLI-025 | TC-CLI-083 | High | Active |
| REQ-CLI-030 | ~~TC-CLI-060~~ | — | RETIRED |
| REQ-CLI-031 | ~~TC-CLI-060, TC-CLI-061~~ | — | RETIRED |
| REQ-CLI-032 | ~~TC-CLI-062~~ | — | RETIRED |
Expand Down
134 changes: 130 additions & 4 deletions cli/tests/launch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -204,13 +204,23 @@ describe("Launch Module", () => {
});

describe("Module exports and bootstrap prompt", () => {
it("TC-CLI-080/081: launch module exports expected functions and contains bootstrap prompt", () => {
// Note: TC-CLI-080 (bootstrap prompt arg) and TC-CLI-081 (cmd/args per CLI)
// are validated by the integration tests in "CWD preservation and staging
// directory access" (TC-CLI-082/083), which assert --add-dir, absolute
// bootstrap path, and correct spawn cwd for each CLI.
it("launch module exports expected functions and source references bootstrap prompt", () => {
const launchSrc = fs.readFileSync(launchModulePath, "utf8");

const bootstrapPrompt = "Read and execute bootstrap.md";
// The bootstrap prompt now uses an absolute path, so check for the
// constant prefix ("Read and execute ") rather than the exact string.
const bootstrapPrefix = "Read and execute ";
assert.ok(
launchSrc.includes(bootstrapPrompt),
"launch.js should contain the bootstrap prompt"
launchSrc.includes(bootstrapPrefix),
"launch.js should contain the bootstrap prompt prefix"
);
assert.ok(
launchSrc.includes("bootstrap.md"),
"launch.js should reference bootstrap.md"
);

// Verify command construction by checking source contains expected patterns.
Expand All @@ -230,4 +240,120 @@ describe("Launch Module", () => {
);
});
});

describe("CWD preservation and staging directory access", () => {
let cwdTestTmpDir;

before(() => {
cwdTestTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pk-cwdtest-"));
});

after(() => {
try {
fs.rmSync(cwdTestTmpDir, { recursive: true, force: true });
} catch {
// best effort
}
});

// Creates a mock CLI executable that records { cwd, args } to a JSON
// file at captureFile, then exits.
function createCapturingMock(mockBinDir, binName, captureFile) {
const implScript = path.join(cwdTestTmpDir, `${binName}-impl.js`);
fs.writeFileSync(
implScript,
[
`const fs = require('fs');`,
`fs.writeFileSync(`,
` ${JSON.stringify(captureFile)},`,
` JSON.stringify({ cwd: process.cwd(), args: process.argv.slice(2) })`,
`);`,
].join("\n")
);

if (process.platform === "win32") {
fs.writeFileSync(
path.join(mockBinDir, `${binName}.cmd`),
`@"${process.execPath}" "${implScript}" %*\r\n`
);
} else {
const p = path.join(mockBinDir, binName);
fs.writeFileSync(p, `#!/bin/sh\n${JSON.stringify(process.execPath)} "${implScript}" "$@"\n`);
fs.chmodSync(p, 0o755);
}
}

// Run promptkit interactive --cli <cliName> from userCwd with mockBinDir
// prepended to PATH. Returns the parsed JSON capture written by the mock.
function runAndCapture(cliName, mockBinDir, captureFile, userCwd) {
const newPath = `${mockBinDir}${path.delimiter}${process.env.PATH || ""}`;
try {
execFileSync(
process.execPath,
[cliPath, "interactive", "--cli", cliName],
{
env: envWithPath(newPath),
cwd: userCwd,
encoding: "utf8",
timeout: 15000,
}
);
} catch {
// The mock exits 0, so errors here are unexpected but we still want
// to read whatever was captured.
}
assert.ok(
fs.existsSync(captureFile),
`mock ${cliName} should have written capture file`
);
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
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 });
const captureFile = path.join(cwdTestTmpDir, `${cliName}-capture.json`);

// For gh-copilot the binary is "gh"; for others it matches cliName.
const binName = cliName === "gh-copilot" ? "gh" : cliName;
createCapturingMock(mockBinDir, binName, captureFile);

// Use cwdTestTmpDir as the simulated user working directory.
const userCwd = cwdTestTmpDir;
const result = runAndCapture(cliName, mockBinDir, captureFile, userCwd);

// TC-CLI-082: verify cwd is the user's original directory.
const actualCwd = fs.realpathSync(result.cwd);
const expectedCwd = fs.realpathSync(userCwd);
assert.strictEqual(
actualCwd,
expectedCwd,
`${cliName} should be spawned with the user's original cwd`
);

// TC-CLI-083: verify --add-dir is present and points to a
// promptkit-* staging directory under os.tmpdir().
const addDirIdx = result.args.indexOf("--add-dir");
assert.ok(
addDirIdx !== -1,
`${cliName} args should include --add-dir`
);
const addDirValue = result.args[addDirIdx + 1];
assert.ok(
addDirValue && addDirValue.startsWith(os.tmpdir()) &&
path.basename(addDirValue).startsWith("promptkit-"),
`${cliName} --add-dir value should point to a promptkit-* staging dir under tmpdir`
);

// Also verify the bootstrap prompt uses an absolute path.
const bootstrapArg = result.args.find((a) => a.includes("bootstrap.md"));
assert.ok(
bootstrapArg && path.isAbsolute(bootstrapArg.replace("Read and execute ", "")),
`${cliName} bootstrap prompt should contain an absolute path to bootstrap.md`
);
});
}
});
});
Loading