From b02a1020a6815933c6f3baec5411e325b5c42cf9 Mon Sep 17 00:00:00 2001 From: bntvllnt <32437578+bntvllnt@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:54:45 +0200 Subject: [PATCH] fix(cli): de-duplicate --help output and add init e2e coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The program description embedded a hand-maintained command list that commander also auto-prints, so `--help` rendered the command list twice with two contradictory `init` descriptions — the manual one stale, claiming the global skill installs by default (it is opt-in since #36). Drop the manual list so commander is the single source; preserve the MCP-mode and Try hints via addHelpText("after"). Add tests/cli-init.e2e.test.ts spawning the real binary across the full init lifecycle (the CLI action is excluded from coverage and was untested): --help no-duplication regression, --json, default, --all, --agents subset, unknown-id exit 2, empty selection, idempotency, missing-path exit 1, --skill opt-in. Add promptSelection non-TTY fallback tests. Sync README init line to opt-in skill wording. --- README.md | 2 +- src/cli.ts | 34 +++---- src/install/prompt.test.ts | 16 ++++ tests/cli-init.e2e.test.ts | 188 +++++++++++++++++++++++++++++++++++++ 4 files changed, 216 insertions(+), 24 deletions(-) create mode 100644 tests/cli-init.e2e.test.ts diff --git a/README.md b/README.md index ff3d9cb..ce8e266 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ codebase-intelligence [options] | `rename` | Reference discovery for rename planning | | `processes` | Entry-point execution flow tracing | | `clusters` | Community-detected file clusters | -| `init` | Make AI agents use CI — writes per-agent instruction files + installs the skill | +| `init` | Set up AI agents to use CI — writes per-agent instruction files (skill opt-in via `--skill`) | ### Useful flags diff --git a/src/cli.ts b/src/cli.ts index 0899afb..cbd68d5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -198,31 +198,19 @@ const program = new Command(); program .name("codebase-intelligence") - .description( - "Analyze TypeScript codebases — architecture, dependencies, metrics.\n\n" + - "Commands:\n" + - " overview High-level codebase snapshot\n" + - " hotspots Rank files by metric\n" + - " file Detailed file context\n" + - " search Keyword search\n" + - " changes Git diff analysis\n" + - " dependents File-level blast radius\n" + - " modules Module architecture\n" + - " forces Architectural force analysis\n" + - " dead-exports Find unused exports\n" + - " groups Top-level directory groups\n" + - " symbol Function/class context\n" + - " impact Symbol-level blast radius\n" + - " rename Find references for rename\n" + - " processes Entry point execution flows\n" + - " clusters Community-detected file clusters\n" + - " init [path] Make AI agents use CI (writes agent instruction files + skill)\n\n" + - "MCP mode:\n" + - " codebase-intelligence Start MCP stdio server\n\n" + - "Try: codebase-intelligence overview ./src", - ) + .description("Analyze TypeScript codebases — architecture, dependencies, metrics.") .version(pkg.version); +// Commander auto-generates the command list; only the extras it can't express +// (MCP mode, a starter hint) are appended after it. A second, hand-maintained +// command list would drift out of sync — keep commander as the single source. +program.addHelpText( + "after", + "\nMCP mode:\n" + + " codebase-intelligence Start MCP stdio server\n\n" + + "Try: codebase-intelligence overview ./src", +); + // ── Subcommand: overview ──────────────────────────────────── program diff --git a/src/install/prompt.test.ts b/src/install/prompt.test.ts index 1dfc473..8f0e8ba 100644 --- a/src/install/prompt.test.ts +++ b/src/install/prompt.test.ts @@ -5,6 +5,7 @@ import { toggleAll, collectSelection, renderMenu, + promptSelection, } from "./prompt.js"; import { AGENT_TARGETS } from "./index.js"; @@ -72,3 +73,18 @@ describe("renderMenu", () => { expect(out).toContain("[ ]"); }); }); + +describe("promptSelection (non-TTY fallback)", () => { + it("returns the preselection unchanged when stdin is not a TTY", async () => { + // Under vitest stdin is not a TTY, so promptSelection cannot read + // keystrokes and must fall back to the seeded preselection. + expect(process.stdin.isTTY).toBeFalsy(); + const sel = await promptSelection(["agents", "claude"], true); + expect(sel).toEqual({ agents: ["agents", "claude"], skill: true }); + }); + + it("falls back to an empty selection when nothing is preselected", async () => { + const sel = await promptSelection([], false); + expect(sel).toEqual({ agents: [], skill: false }); + }); +}); diff --git a/tests/cli-init.e2e.test.ts b/tests/cli-init.e2e.test.ts new file mode 100644 index 0000000..6a45113 --- /dev/null +++ b/tests/cli-init.e2e.test.ts @@ -0,0 +1,188 @@ +import { spawnSync, execSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { DEFAULT_MARKERS, renderSkill } from "../src/install/index.js"; + +// End-to-end tests for the `init` command lifecycle. These spawn the real +// compiled binary (dist/cli.js) — the CLI action handler is excluded from +// coverage and otherwise untested. Black-box: assert exit codes, stdout/stderr, +// and the files actually written to disk. No mocking of own code. + +const here = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(here, ".."); +const cli = path.join(repoRoot, "dist", "cli.js"); + +interface RunResult { + status: number | null; + stdout: string; + stderr: string; +} + +/** Spawn the compiled CLI with a sandboxed HOME so the global skill never + * touches the developer's real home directory. */ +function run(args: readonly string[], home: string): RunResult { + const res = spawnSync("node", [cli, ...args], { + cwd: repoRoot, + env: { ...process.env, HOME: home, USERPROFILE: home }, + encoding: "utf-8", + }); + return { status: res.status, stdout: res.stdout, stderr: res.stderr }; +} + +beforeAll(() => { + // E2E asserts against dist/ — the build gate runs before the test gate, but + // build here too if the artifact is missing so the suite is self-contained. + if (!fs.existsSync(cli)) { + execSync("npm run build", { cwd: repoRoot, stdio: "inherit" }); + } +}, 120_000); + +describe("codebase-intelligence --help", () => { + let home: string; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-help-home-")); + }); + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + }); + + it("renders the command list exactly once (no duplicated help)", () => { + const { stdout, status } = run(["--help"], home); + expect(status).toBe(0); + expect(stdout.split("Commands:").length - 1).toBe(1); + }); + + it("lists the init command exactly once", () => { + const { stdout } = run(["--help"], home); + const initLines = stdout.split("\n").filter((line) => /^\s+init\b/.test(line)); + expect(initLines).toHaveLength(1); + }); + + it("does not claim the skill installs by default (opt-in since #36)", () => { + const { stdout } = run(["--help"], home); + expect(stdout).not.toContain("writes agent instruction files + skill"); + }); + + it("preserves the MCP-mode and Try hints", () => { + const { stdout } = run(["--help"], home); + expect(stdout).toContain("MCP mode"); + expect(stdout).toContain("Try: codebase-intelligence overview"); + }); +}); + +describe("init lifecycle (e2e)", () => { + let repo: string; + let home: string; + + beforeEach(() => { + repo = fs.mkdtempSync(path.join(os.tmpdir(), "ci-init-repo-")); + home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-init-home-")); + }); + afterEach(() => { + fs.rmSync(repo, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + }); + + const read = (rel: string): string => fs.readFileSync(path.join(repo, rel), "utf-8"); + const exists = (rel: string): boolean => fs.existsSync(path.join(repo, rel)); + + it("--json writes the default agents and emits parseable JSON", () => { + const { status, stdout } = run(["init", "--json", repo], home); + expect(status).toBe(0); + + const parsed = JSON.parse(stdout) as { + repoFiles: { path: string; action: string }[]; + skill: unknown; + }; + expect(parsed.repoFiles.map((r) => r.path).sort()).toEqual(["AGENTS.md", "CLAUDE.md"]); + expect(parsed.repoFiles.every((r) => r.action === "created")).toBe(true); + expect(parsed.skill).toBeNull(); + + expect(read("AGENTS.md")).toContain(DEFAULT_MARKERS.start); + expect(read("CLAUDE.md")).toContain("Codebase Intelligence"); + }); + + it("default (non-TTY) run writes AGENTS.md + CLAUDE.md only", () => { + const { status, stdout } = run(["init", repo], home); + expect(status).toBe(0); + expect(stdout).toContain("created"); + expect(exists("AGENTS.md")).toBe(true); + expect(exists("CLAUDE.md")).toBe(true); + expect(exists("GEMINI.md")).toBe(false); + }); + + it("--all writes every agent file", () => { + const { status } = run(["init", "--all", repo], home); + expect(status).toBe(0); + for (const rel of [ + "AGENTS.md", + "CLAUDE.md", + "GEMINI.md", + "CONVENTIONS.md", + path.join(".cursor", "rules", "codebase-intelligence.mdc"), + path.join(".github", "copilot-instructions.md"), + ]) { + expect(exists(rel)).toBe(true); + } + }); + + it("--agents writes only the listed agents", () => { + const { status } = run(["init", "--agents", "claude,cursor", repo], home); + expect(status).toBe(0); + expect(exists("CLAUDE.md")).toBe(true); + expect(exists(path.join(".cursor", "rules", "codebase-intelligence.mdc"))).toBe(true); + expect(exists("AGENTS.md")).toBe(false); + expect(exists("GEMINI.md")).toBe(false); + }); + + it("--agents with an unknown id exits 2 and names the offender", () => { + const { status, stderr } = run(["init", "--agents", "claude,bogus", repo], home); + expect(status).toBe(2); + expect(stderr).toContain("Unknown agents: bogus"); + expect(exists("CLAUDE.md")).toBe(false); + }); + + it("--agents with an empty list writes nothing", () => { + const { status, stdout } = run(["init", "--agents", "", repo], home); + expect(status).toBe(0); + expect(stdout).toContain("Nothing selected"); + expect(fs.readdirSync(repo)).toHaveLength(0); + }); + + it("is idempotent — a second run reports unchanged", () => { + run(["init", repo], home); + const before = read("AGENTS.md"); + + const { status, stdout } = run(["init", repo], home); + expect(status).toBe(0); + expect(stdout).toContain("unchanged"); + expect(stdout).not.toContain("created"); + expect(read("AGENTS.md")).toBe(before); + }); + + it("exits 1 when the target path does not exist", () => { + const missing = path.join(os.tmpdir(), "ci-init-missing-zzz-do-not-create"); + const { status, stderr } = run(["init", missing], home); + expect(status).toBe(1); + expect(stderr).toContain("Path does not exist"); + }); + + it("--skill installs the global skill into HOME (opt-in)", () => { + const { status } = run(["init", "--agents", "claude", "--skill", repo], home); + expect(status).toBe(0); + + const skillPath = path.join(home, ".claude", "skills", "codebase-intelligence", "SKILL.md"); + expect(fs.existsSync(skillPath)).toBe(true); + expect(fs.readFileSync(skillPath, "utf-8")).toBe(renderSkill()); + }); + + it("does not install the skill unless asked", () => { + run(["init", "--all", repo], home); + const skillPath = path.join(home, ".claude", "skills", "codebase-intelligence", "SKILL.md"); + expect(fs.existsSync(skillPath)).toBe(false); + }); +});