Skip to content

Commit 4ed4806

Browse files
fix: route ask/docs/diff options past root-command flag collisions (#52)
The root command defines -b/--branch, --model, --format, --full-clone, --keep-temp, etc. Because the root has options + an action alongside subcommands, Commander captures those flags at the root before the subcommand parses them, so several subcommand options were silently ignored: - ask: --branch, --model - docs: --branch - diff: --format, --full-clone, --keep-temp Extract pure, tested getFlagValue/hasFlag helpers in src/utils.ts and read the affected options from raw argv (the approach diff already used for --output). Removed the masking default on diff --format so the fallback can take effect (runPullRequestDiff already defaults to markdown). Tests: getFlagValue/hasFlag unit tests; an in-process cli routing test that mocks the command runners and asserts values reach them; a real-CLI diff e2e asserting --format html and --keep-temp take effect. Co-authored-by: Arthur742Ramos <223556219+Copilot@users.noreply.github.com>
1 parent 41df25a commit 4ed4806

6 files changed

Lines changed: 204 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
4343

4444
### Fixed
4545

46+
- `bootcamp ask`, `docs`, and `diff` now honor options whose flag names collide with the root command. `ask --branch/--model`, `docs --branch`, and `diff --format/--full-clone/--keep-temp` were captured by the root command and silently ignored; they are now read from raw argv (via shared, tested `getFlagValue`/`hasFlag` helpers in `src/utils.ts`).
4647
- `bootcamp health` now honors `--branch` and `--max-files`. These short flags (`-b`/`-m`) collide with the root command's options, which captured them before the subcommand could; the command now falls back to reading raw argv (matching the `diff --output` approach). The `--json` output also gained a `filesScanned` field.
4748
- Reused shared prompt helper builders in `src/agent.ts` for standard/fast prompt construction.
4849
- Preserved caught error causes in `src/watch.ts` non-fast-forward rethrows.

src/cli.ts

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import { resolveOutputFormat } from "./services/config-resolution.js";
3535
import type { OutputFormat } from "./formatter.js";
3636
import type { BootcampOptions, StylePack } from "./types.js";
3737
import { startServer } from "./web/server.js";
38+
import { getFlagValue, hasFlag } from "./utils.js";
3839
import pkg from "../package.json" with { type: "json" };
3940

4041
const VERSION = pkg.version;
@@ -149,22 +150,11 @@ function getActionOptions<T extends Record<string, unknown>>(opts: Command | T):
149150
}
150151

151152
function getCliFlagValue(flags: string[]): string | undefined {
152-
const args = process.argv.slice(2);
153-
154-
for (let index = 0; index < args.length; index++) {
155-
const arg = args[index];
156-
if (flags.includes(arg)) {
157-
return args[index + 1];
158-
}
159-
160-
for (const flag of flags) {
161-
if (arg.startsWith(`${flag}=`)) {
162-
return arg.slice(flag.length + 1);
163-
}
164-
}
165-
}
153+
return getFlagValue(process.argv.slice(2), flags);
154+
}
166155

167-
return undefined;
156+
function hasCliFlag(flags: string[]): boolean {
157+
return hasFlag(process.argv.slice(2), flags);
168158
}
169159

170160
function isNegativeOptionEnabled(
@@ -290,8 +280,8 @@ program
290280
.action(async (repoUrl: string, rawOpts) => {
291281
const opts = getActionOptions<AskActionOptions>(rawOpts as Command | AskActionOptions);
292282
await runAskCommand(repoUrl, {
293-
branch: opts.branch,
294-
model: opts.model,
283+
branch: opts.branch || getCliFlagValue(["--branch", "-b"]),
284+
model: opts.model || getCliFlagValue(["--model"]),
295285
noClone: isNegativeOptionEnabled(opts, "noClone", "clone"),
296286
verbose: opts.verbose,
297287
});
@@ -301,18 +291,18 @@ program
301291
.command("diff <repo-pr>")
302292
.description("Generate onboarding diff for a GitHub PR")
303293
.option("-o, --output <dir>", "Output directory")
304-
.option("--format <format>", "Output format: markdown, html, pdf", "markdown")
294+
.option("--format <format>", "Output format: markdown, html, pdf")
305295
.option("--full-clone", "Perform a full clone instead of shallow clone (slower but includes full history)")
306296
.option("--keep-temp", "Keep temporary clone directory")
307297
.option("-v, --verbose", "Show detailed output")
308298
.action(async (repoPr: string, rawOpts) => {
309299
const opts = getActionOptions<DiffActionOptions>(rawOpts as Command | DiffActionOptions);
310300
await runPullRequestDiff(repoPr, {
311301
output: opts.output || getCliFlagValue(["--output", "-o"]),
312-
format: opts.format,
313-
fullClone: opts.fullClone || false,
314-
keepTemp: opts.keepTemp || false,
315-
verbose: opts.verbose || false,
302+
format: opts.format || getCliFlagValue(["--format"]),
303+
fullClone: opts.fullClone || hasCliFlag(["--full-clone"]),
304+
keepTemp: opts.keepTemp || hasCliFlag(["--keep-temp"]),
305+
verbose: opts.verbose || hasCliFlag(["--verbose", "-v"]),
316306
});
317307
});
318308

@@ -335,7 +325,12 @@ program
335325
.option("-v, --verbose", "Show detailed output")
336326
.action(async (repoUrl: string, rawOpts) => {
337327
const opts = getActionOptions<DocsActionOptions>(rawOpts as Command | DocsActionOptions);
338-
await runDocsCommand(repoUrl, opts);
328+
await runDocsCommand(repoUrl, {
329+
check: opts.check,
330+
fix: opts.fix,
331+
branch: opts.branch || getCliFlagValue(["--branch", "-b"]),
332+
verbose: opts.verbose || hasCliFlag(["--verbose", "-v"]),
333+
});
339334
});
340335

341336
program

src/utils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,39 @@ export function escapeRegex(str: string): string {
4949
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
5050
}
5151

52+
/**
53+
* Read the value of a CLI flag directly from a raw argument list.
54+
*
55+
* Supports both `--flag value` and `--flag=value` forms. This exists to work
56+
* around Commander routing options whose flag names collide with the root
57+
* command (e.g. `--branch`, `--max-files`, `--style`) to the root rather than
58+
* the subcommand. Pure (takes `args`) so it is easy to test.
59+
*/
60+
export function getFlagValue(args: string[], flags: string[]): string | undefined {
61+
for (let index = 0; index < args.length; index++) {
62+
const arg = args[index];
63+
if (flags.includes(arg)) {
64+
return args[index + 1];
65+
}
66+
for (const flag of flags) {
67+
if (arg.startsWith(`${flag}=`)) {
68+
return arg.slice(flag.length + 1);
69+
}
70+
}
71+
}
72+
return undefined;
73+
}
74+
75+
/**
76+
* Whether any of the given boolean flags is present in a raw argument list.
77+
*
78+
* Companion to {@link getFlagValue} for boolean options (e.g. `--full-clone`)
79+
* that collide with root-command flags. Pure (takes `args`) for testability.
80+
*/
81+
export function hasFlag(args: string[], flags: string[]): boolean {
82+
return args.some((arg) => flags.includes(arg) || flags.some((flag) => arg.startsWith(`${flag}=`)));
83+
}
84+
5285
/**
5386
* Convert a filesystem path to POSIX form (forward slashes).
5487
*

test/cli-option-routing.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
3+
const runAskCommand = vi.fn().mockResolvedValue(undefined);
4+
const runDocsCommand = vi.fn().mockResolvedValue(undefined);
5+
const runPullRequestDiff = vi.fn().mockResolvedValue(undefined);
6+
7+
vi.mock("../src/commands/ask-command.js", () => ({
8+
runAskCommand: (...args: any[]) => runAskCommand(...args),
9+
}));
10+
vi.mock("../src/commands/docs-command.js", () => ({
11+
runDocsCommand: (...args: any[]) => runDocsCommand(...args),
12+
}));
13+
vi.mock("../src/commands/diff-command.js", () => ({
14+
runPullRequestDiff: (...args: any[]) => runPullRequestDiff(...args),
15+
}));
16+
17+
import { program } from "../src/cli.js";
18+
19+
async function runArgv(argv: string[]): Promise<void> {
20+
const saved = process.argv;
21+
process.argv = ["node", "cli", ...argv];
22+
try {
23+
await program.parseAsync(process.argv);
24+
} finally {
25+
process.argv = saved;
26+
}
27+
}
28+
29+
describe("CLI option routing past root-command flag collisions", () => {
30+
beforeEach(() => {
31+
vi.clearAllMocks();
32+
});
33+
34+
it("routes ask --branch and --model to the ask command", async () => {
35+
await runArgv(["ask", "./repo", "--branch", "dev", "--model", "m1"]);
36+
expect(runAskCommand).toHaveBeenCalledTimes(1);
37+
const [repo, opts] = runAskCommand.mock.calls[0];
38+
expect(repo).toBe("./repo");
39+
expect(opts.branch).toBe("dev");
40+
expect(opts.model).toBe("m1");
41+
});
42+
43+
it("routes docs --branch to the docs command", async () => {
44+
await runArgv(["docs", "./repo", "--branch", "release", "--check"]);
45+
expect(runDocsCommand).toHaveBeenCalledTimes(1);
46+
const [repo, opts] = runDocsCommand.mock.calls[0];
47+
expect(repo).toBe("./repo");
48+
expect(opts.branch).toBe("release");
49+
expect(opts.check).toBe(true);
50+
});
51+
52+
it("routes diff --format, --full-clone, and --keep-temp to the diff command", async () => {
53+
await runArgv(["diff", "owner/repo#1", "--format", "html", "--full-clone", "--keep-temp"]);
54+
expect(runPullRequestDiff).toHaveBeenCalledTimes(1);
55+
const [target, opts] = runPullRequestDiff.mock.calls[0];
56+
expect(target).toBe("owner/repo#1");
57+
expect(opts.format).toBe("html");
58+
expect(opts.fullClone).toBe(true);
59+
expect(opts.keepTemp).toBe(true);
60+
});
61+
62+
it("leaves options unset when not provided", async () => {
63+
await runArgv(["diff", "owner/repo#2"]);
64+
const [, opts] = runPullRequestDiff.mock.calls[0];
65+
expect(opts.format).toBeUndefined();
66+
expect(opts.fullClone).toBe(false);
67+
expect(opts.keepTemp).toBe(false);
68+
});
69+
});

test/e2e/diff-command.e2e.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,4 +214,46 @@ describe("diff command", () => {
214214
expect(diffDoc).toContain("Major version bump: 1.0.0 → 2.0.0");
215215
expect(diffDoc).toContain("Removed export: greet in src/index.ts");
216216
}, 90_000);
217+
218+
it("honors --format and --keep-temp routed past root-option collisions", async () => {
219+
const tempDir = await mkdtemp(join(tmpdir(), "bootcamp-diff-e2e-"));
220+
tempDirs.push(tempDir);
221+
222+
const { bareRepoPath, baseSha, headSha } = await createDiffFixture(tempDir);
223+
const { server, apiBaseUrl } = await startPullRequestApiServer(baseSha, headSha);
224+
servers.push(server);
225+
226+
const outputDir = join(tempDir, "diff-output-html");
227+
const bareRepoUrl = pathToFileURL(bareRepoPath).href;
228+
const result = await runCli(
229+
[
230+
"diff",
231+
`${FIXTURE_OWNER}/${FIXTURE_REPO}#${PR_NUMBER}`,
232+
"--output",
233+
outputDir,
234+
"--format",
235+
"html",
236+
"--keep-temp",
237+
],
238+
{
239+
GIT_CONFIG_COUNT: "1",
240+
GIT_CONFIG_KEY_0: `url.${bareRepoUrl}.insteadOf`,
241+
GIT_CONFIG_VALUE_0: `https://github.com/${FIXTURE_OWNER}/${FIXTURE_REPO}.git`,
242+
REPO_BOOTCAMP_GITHUB_API_BASE_URL: apiBaseUrl,
243+
},
244+
60_000,
245+
tempDir
246+
);
247+
248+
expect(result.exitCode).toBe(0);
249+
const plainStdout = stripAnsi(result.stdout);
250+
// --format routed correctly -> HTML output file.
251+
expect(plainStdout).toMatch(/File:\s+\S+\.html/);
252+
// --keep-temp routed correctly -> clone retained.
253+
expect(plainStdout).toContain("Temporary clone kept at");
254+
255+
const htmlDoc = await readFile(join(outputDir, "DIFF.html"), "utf-8");
256+
expect(htmlDoc).toContain("<");
257+
expect(htmlDoc).toContain("Fixture PR");
258+
}, 90_000);
217259
});

test/utils.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { describe, it, expect } from "vitest";
66
import { mkdtemp, rm, writeFile } from "fs/promises";
77
import { join } from "path";
88
import { tmpdir } from "os";
9-
import { SKIP_DIRS, README_NAMES, readFileSafe, escapeRegex } from "../src/utils.js";
9+
import { SKIP_DIRS, README_NAMES, readFileSafe, escapeRegex, getFlagValue, hasFlag } from "../src/utils.js";
1010

1111
describe("SKIP_DIRS", () => {
1212
it("is a Set containing common directories to skip", () => {
@@ -103,3 +103,43 @@ describe("escapeRegex", () => {
103103
expect(escapeRegex(input)).toBe(expected);
104104
});
105105
});
106+
107+
describe("getFlagValue", () => {
108+
it("reads a value from the `--flag value` form", () => {
109+
expect(getFlagValue(["--branch", "dev", "--json"], ["--branch", "-b"])).toBe("dev");
110+
});
111+
112+
it("reads a value from the `--flag=value` form", () => {
113+
expect(getFlagValue(["--branch=dev"], ["--branch", "-b"])).toBe("dev");
114+
});
115+
116+
it("matches any of the provided aliases", () => {
117+
expect(getFlagValue(["-b", "main"], ["--branch", "-b"])).toBe("main");
118+
});
119+
120+
it("returns undefined when the flag is absent", () => {
121+
expect(getFlagValue(["--json"], ["--branch", "-b"])).toBeUndefined();
122+
});
123+
124+
it("returns the first occurrence when repeated", () => {
125+
expect(getFlagValue(["--branch", "a", "--branch", "b"], ["--branch"])).toBe("a");
126+
});
127+
});
128+
129+
describe("hasFlag", () => {
130+
it("detects a present boolean flag", () => {
131+
expect(hasFlag(["--full-clone", "--json"], ["--full-clone"])).toBe(true);
132+
});
133+
134+
it("detects an alias", () => {
135+
expect(hasFlag(["-v"], ["--verbose", "-v"])).toBe(true);
136+
});
137+
138+
it("detects the `--flag=value` form", () => {
139+
expect(hasFlag(["--keep-temp=true"], ["--keep-temp"])).toBe(true);
140+
});
141+
142+
it("returns false when absent", () => {
143+
expect(hasFlag(["--json"], ["--full-clone"])).toBe(false);
144+
});
145+
});

0 commit comments

Comments
 (0)