Skip to content

Commit 6d69092

Browse files
Arthur742RamosCopilotclaude
authored
feat: add slash commands to interactive Q&A mode (#59)
Interactive mode (`bootcamp ask` and `--interactive`) previously only recognized `exit`/`quit`. It now supports slash commands: - /help (alias /?) — command reference - /files — list detected repository files - /clear — clear the screen - /exit (/quit) — end the session Unknown slash commands are reported instead of being silently sent to the assistant as a prompt. The core is a pure, exported `classifyInteractiveInput(raw)` returning a discriminated result (empty | exit | command | unknown-command | question), plus pure `renderInteractiveHelp()` and `renderFileList(scan)` renderers — all unit-testable without an LLM session. The REPL loop dispatches on the result. Tests: 15 unit tests (classification of every input kind + alias + arg cases, help/file-list rendering incl. truncation and empty scan); 1 E2E test driving the real `ask` process through /help, /files, and an unknown command. Co-authored-by: Arthur742Ramos <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a61124e commit 6d69092

5 files changed

Lines changed: 271 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
### Added
13+
14+
- Interactive Q&A mode (`bootcamp ask` and `--interactive`) now supports **slash commands**: `/help` (alias `/?`) shows the command reference, `/files` lists the detected repository files, `/clear` clears the screen, and `/exit` (aliases `/quit`, `exit`, `quit`) ends the session. Unknown slash commands are reported instead of being sent to the assistant. The input classifier and renderers are pure and unit-tested.
1215
- `bootcamp metrics <repo-url>` command: a standalone, deterministic codebase-metrics report for any repository (local path or remote URL) without invoking the LLM. Surfaces language breakdown, source/test/doc/config composition, average/median file size, test-to-source ratio, size classification, top-level directory distribution, largest-file hotspots, and an **approachability score (0-100 + A–F grade)** with human-readable drivers. Prints a human-readable report by default, supports `--json` for scripting, and offers a CI gate via `--check`/`--min-score` (exits non-zero when approachability is below the threshold). Reuses the same `computeCodebaseMetrics` engine that powers `METRICS.md` — mirroring the existing `bootcamp health` command.
1316
- Web demo file viewer now has **Copy** and **Download** buttons: copy a generated doc's contents to the clipboard (async Clipboard API with a `document.execCommand` fallback and a timeout guard) or download it as a file, directly from the preview modal.
1417
- `bootcamp completion <bash|zsh|fish>` command to print a shell completion script for tab-completing subcommands, their aliases, and option flags. The completion data is derived from the live CLI definition, so it can never drift from the actual command surface; pipe it to your shell's completion directory (or `source <(bootcamp completion bash)`).

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,8 @@ bootcamp https://github.com/owner/repo --interactive
421421
bootcamp ask https://github.com/owner/repo
422422
```
423423

424+
Inside the session, type a question to ask the assistant, or use a slash command: `/help` (command reference), `/files` (list detected files), `/clear` (clear the screen), `/exit` (end the session).
425+
424426
### Version Comparison
425427

426428
```bash

src/interactive.ts

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,92 @@ GUIDELINES:
3636
3737
When citing files, use the format: \`path/to/file.ts:lineNumber\``;
3838

39+
/**
40+
* Result of classifying a line of interactive input.
41+
*
42+
* - `question`: a normal prompt to send to the assistant
43+
* - `exit`: the user wants to end the session
44+
* - `empty`: blank input, re-prompt without doing anything
45+
* - `command`: a recognized slash command (with any trailing args)
46+
* - `unknown-command`: a slash command we don't recognize
47+
*/
48+
export type InteractiveInput =
49+
| { kind: "question"; text: string }
50+
| { kind: "exit" }
51+
| { kind: "empty" }
52+
| { kind: "command"; name: InteractiveCommand; args: string }
53+
| { kind: "unknown-command"; name: string };
54+
55+
/** Slash commands supported in interactive mode. */
56+
export type InteractiveCommand = "help" | "files" | "clear" | "exit";
57+
58+
const SLASH_COMMANDS: Record<string, InteractiveCommand> = {
59+
"/help": "help",
60+
"/?": "help",
61+
"/files": "files",
62+
"/clear": "clear",
63+
"/exit": "exit",
64+
"/quit": "exit",
65+
};
66+
67+
/**
68+
* Classify a raw line of interactive input. Pure and synchronous so it can be
69+
* unit-tested without an LLM session.
70+
*/
71+
export function classifyInteractiveInput(raw: string): InteractiveInput {
72+
const trimmed = raw.trim();
73+
if (!trimmed) {
74+
return { kind: "empty" };
75+
}
76+
77+
const lower = trimmed.toLowerCase();
78+
if (lower === "exit" || lower === "quit") {
79+
return { kind: "exit" };
80+
}
81+
82+
if (trimmed.startsWith("/")) {
83+
const [token, ...rest] = trimmed.split(/\s+/);
84+
const command = SLASH_COMMANDS[token.toLowerCase()];
85+
if (!command) {
86+
return { kind: "unknown-command", name: token };
87+
}
88+
if (command === "exit") {
89+
return { kind: "exit" };
90+
}
91+
return { kind: "command", name: command, args: rest.join(" ") };
92+
}
93+
94+
return { kind: "question", text: trimmed };
95+
}
96+
97+
/** Render the interactive-mode help text (slash command reference). */
98+
export function renderInteractiveHelp(): string {
99+
return [
100+
chalk.bold("Commands:"),
101+
` ${chalk.cyan("/help")} Show this help (alias: /?)`,
102+
` ${chalk.cyan("/files")} List the key files detected in the repository`,
103+
` ${chalk.cyan("/clear")} Clear the screen`,
104+
` ${chalk.cyan("/exit")} End the session (aliases: exit, quit, /quit)`,
105+
"",
106+
chalk.dim("Anything else is sent to the assistant as a question."),
107+
].join("\n");
108+
}
109+
110+
/** Render the detected file list for the `/files` command. */
111+
export function renderFileList(scanResult: ScanResult, limit = 40): string {
112+
const files = scanResult.files.filter((f) => !f.isDirectory).map((f) => f.path);
113+
if (files.length === 0) {
114+
return chalk.dim("No files detected in the scan.");
115+
}
116+
117+
const shown = files.slice(0, limit);
118+
const lines = shown.map((path) => ` ${chalk.cyan(path)}`);
119+
if (files.length > shown.length) {
120+
lines.push(chalk.dim(` …and ${files.length - shown.length} more`));
121+
}
122+
return [chalk.bold(`Detected files (${files.length}):`), ...lines].join("\n");
123+
}
124+
39125
/**
40126
* Create context message with repo info
41127
*/
@@ -350,7 +436,7 @@ export async function runInteractiveMode(
350436
): Promise<void> {
351437
console.log(chalk.bold.cyan("\n=== Interactive Mode ==="));
352438
console.log(chalk.gray(`Repository: ${repoInfo.fullName}`));
353-
console.log(chalk.gray("Type your questions about the codebase. Type 'exit' to quit.\n"));
439+
console.log(chalk.gray("Type your questions about the codebase. Type '/help' for commands, 'exit' to quit.\n"));
354440

355441
const session = new InteractiveSession(
356442
repoPath,
@@ -395,21 +481,46 @@ export async function runInteractiveMode(
395481
promptUser();
396482

397483
for await (const input of rl) {
398-
const question = input.trim();
484+
const classified = classifyInteractiveInput(input);
399485

400-
if (!question) {
486+
if (classified.kind === "empty") {
401487
promptUser();
402488
continue;
403489
}
404490

405-
if (question.toLowerCase() === "exit" || question.toLowerCase() === "quit") {
491+
if (classified.kind === "exit") {
406492
console.log(chalk.gray("\nEnding session..."));
407493
break;
408494
}
409495

496+
if (classified.kind === "unknown-command") {
497+
console.log(
498+
chalk.yellow(`Unknown command: ${classified.name}.`) +
499+
chalk.dim(" Type /help for the list of commands.")
500+
);
501+
promptUser();
502+
continue;
503+
}
504+
505+
if (classified.kind === "command") {
506+
switch (classified.name) {
507+
case "help":
508+
console.log("\n" + renderInteractiveHelp() + "\n");
509+
break;
510+
case "files":
511+
console.log("\n" + renderFileList(scanResult) + "\n");
512+
break;
513+
case "clear":
514+
console.clear();
515+
break;
516+
}
517+
promptUser();
518+
continue;
519+
}
520+
410521
try {
411522
console.log(chalk.gray("\nAssistant: "));
412-
await session.ask(question);
523+
await session.ask(classified.text);
413524
console.log();
414525
} catch (error: unknown) {
415526
console.error(chalk.red(`Error: ${(error as Error).message}`));

test/e2e/ask-command.e2e.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,4 +108,44 @@ describe("ask command", () => {
108108
expect(transcript).toContain("Where is the main entrypoint?");
109109
expect(transcript).toContain(expectedAnswer);
110110
}, 90_000);
111+
112+
it("handles /help and /files slash commands without invoking the assistant", async () => {
113+
const tempDir = await mkdtemp(join(tmpdir(), "bootcamp-ask-e2e-"));
114+
tempDirs.push(tempDir);
115+
116+
const repoPath = await createAskFixtureRepo(tempDir);
117+
const responseFile = join(tempDir, "mock-response.txt");
118+
await writeFile(responseFile, "Should not be needed for slash commands.", "utf-8");
119+
120+
const askProcess = spawnCli(
121+
["ask", repoPath, "--no-clone"],
122+
{
123+
NODE_ENV: "test",
124+
REPO_BOOTCAMP_TEST_LLM_RESPONSE_FILE: responseFile,
125+
},
126+
tempDir
127+
);
128+
children.push(askProcess);
129+
130+
await waitForOutput(askProcess.getOutput, "Ready!", 60_000);
131+
await new Promise((resolve) => setTimeout(resolve, 200));
132+
133+
askProcess.child.stdin.write("/help\n");
134+
await waitForOutput(askProcess.getOutput, "/files");
135+
136+
askProcess.child.stdin.write("/files\n");
137+
await waitForOutput(askProcess.getOutput, "Detected files");
138+
139+
askProcess.child.stdin.write("/bogus\n");
140+
await waitForOutput(askProcess.getOutput, "Unknown command");
141+
142+
askProcess.child.stdin.write("/exit\n");
143+
144+
const [exitCode] = await once(askProcess.child, "close");
145+
expect(exitCode).toBe(0);
146+
147+
const { stdout } = askProcess.getOutput();
148+
expect(stdout).toContain("Commands:");
149+
expect(stdout).toContain("src/index.ts");
150+
}, 90_000);
111151
});

test/interactive.test.ts

Lines changed: 110 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,13 @@ vi.mock("fs/promises", () => ({
2525
writeFile: vi.fn(),
2626
}));
2727

28-
import { InteractiveSession, quickAsk } from "../src/interactive.js";
28+
import {
29+
InteractiveSession,
30+
quickAsk,
31+
classifyInteractiveInput,
32+
renderInteractiveHelp,
33+
renderFileList,
34+
} from "../src/interactive.js";
2935
import { getRepoTools } from "../src/tools.js";
3036
import { writeFile } from "fs/promises";
3137

@@ -492,3 +498,106 @@ describe("quickAsk", () => {
492498
);
493499
});
494500
});
501+
502+
// eslint-disable-next-line no-control-regex
503+
const ANSI = /\[[0-9;]*m/g;
504+
const stripAnsi = (s: string): string => s.replace(ANSI, "");
505+
506+
function scanWith(paths: Array<{ path: string; isDirectory?: boolean }>): ScanResult {
507+
return {
508+
files: paths.map((p) => ({ path: p.path, size: 1, isDirectory: Boolean(p.isDirectory) })),
509+
stack: { languages: [], frameworks: [], buildSystem: "", packageManager: "", hasDocker: false, hasCi: false },
510+
commands: [],
511+
ciWorkflows: [],
512+
readme: null,
513+
contributing: null,
514+
keySourceFiles: new Map(),
515+
} as unknown as ScanResult;
516+
}
517+
518+
describe("classifyInteractiveInput", () => {
519+
it("treats blank input as empty", () => {
520+
expect(classifyInteractiveInput("")).toEqual({ kind: "empty" });
521+
expect(classifyInteractiveInput(" ")).toEqual({ kind: "empty" });
522+
});
523+
524+
it("recognizes exit/quit (case-insensitive)", () => {
525+
expect(classifyInteractiveInput("exit")).toEqual({ kind: "exit" });
526+
expect(classifyInteractiveInput("QUIT")).toEqual({ kind: "exit" });
527+
expect(classifyInteractiveInput(" Exit ")).toEqual({ kind: "exit" });
528+
});
529+
530+
it("maps /exit and /quit to exit", () => {
531+
expect(classifyInteractiveInput("/exit")).toEqual({ kind: "exit" });
532+
expect(classifyInteractiveInput("/quit")).toEqual({ kind: "exit" });
533+
});
534+
535+
it("recognizes known slash commands and their aliases", () => {
536+
expect(classifyInteractiveInput("/help")).toEqual({ kind: "command", name: "help", args: "" });
537+
expect(classifyInteractiveInput("/?")).toEqual({ kind: "command", name: "help", args: "" });
538+
expect(classifyInteractiveInput("/files")).toEqual({ kind: "command", name: "files", args: "" });
539+
expect(classifyInteractiveInput("/clear")).toEqual({ kind: "command", name: "clear", args: "" });
540+
});
541+
542+
it("captures trailing args for slash commands", () => {
543+
expect(classifyInteractiveInput("/files src")).toEqual({ kind: "command", name: "files", args: "src" });
544+
});
545+
546+
it("is case-insensitive about the command token", () => {
547+
expect(classifyInteractiveInput("/HELP")).toEqual({ kind: "command", name: "help", args: "" });
548+
});
549+
550+
it("flags unknown slash commands", () => {
551+
expect(classifyInteractiveInput("/bogus")).toEqual({ kind: "unknown-command", name: "/bogus" });
552+
});
553+
554+
it("treats normal text as a question and trims it", () => {
555+
expect(classifyInteractiveInput(" How does auth work? ")).toEqual({
556+
kind: "question",
557+
text: "How does auth work?",
558+
});
559+
});
560+
561+
it("does not treat a question containing a slash as a command", () => {
562+
expect(classifyInteractiveInput("what is src/index.ts")).toEqual({
563+
kind: "question",
564+
text: "what is src/index.ts",
565+
});
566+
});
567+
});
568+
569+
describe("renderInteractiveHelp", () => {
570+
it("lists every supported command", () => {
571+
const help = stripAnsi(renderInteractiveHelp());
572+
expect(help).toContain("/help");
573+
expect(help).toContain("/files");
574+
expect(help).toContain("/clear");
575+
expect(help).toContain("/exit");
576+
});
577+
});
578+
579+
describe("renderFileList", () => {
580+
it("lists detected files and excludes directories", () => {
581+
const out = stripAnsi(renderFileList(scanWith([
582+
{ path: "src/index.ts" },
583+
{ path: "src", isDirectory: true },
584+
{ path: "README.md" },
585+
])));
586+
expect(out).toContain("Detected files (2)");
587+
expect(out).toContain("src/index.ts");
588+
expect(out).toContain("README.md");
589+
expect(out).not.toMatch(/^\s+src$/m);
590+
});
591+
592+
it("truncates to the limit and reports the remainder", () => {
593+
const files = Array.from({ length: 50 }, (_, i) => ({ path: `f${i}.ts` }));
594+
const out = stripAnsi(renderFileList(scanWith(files), 40));
595+
expect(out).toContain("Detected files (50)");
596+
expect(out).toContain("…and 10 more");
597+
});
598+
599+
it("handles an empty scan", () => {
600+
const out = stripAnsi(renderFileList(scanWith([])));
601+
expect(out).toContain("No files detected");
602+
});
603+
});

0 commit comments

Comments
 (0)