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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ codebase-intelligence <command> <path> [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

Expand Down
34 changes: 11 additions & 23 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,31 +198,19 @@ const program = new Command();

program
.name("codebase-intelligence")
.description(
"Analyze TypeScript codebases — architecture, dependencies, metrics.\n\n" +
"Commands:\n" +
" overview <path> High-level codebase snapshot\n" +
" hotspots <path> Rank files by metric\n" +
" file <path> <file> Detailed file context\n" +
" search <path> <query> Keyword search\n" +
" changes <path> Git diff analysis\n" +
" dependents <path> <file> File-level blast radius\n" +
" modules <path> Module architecture\n" +
" forces <path> Architectural force analysis\n" +
" dead-exports <path> Find unused exports\n" +
" groups <path> Top-level directory groups\n" +
" symbol <path> <name> Function/class context\n" +
" impact <path> <symbol> Symbol-level blast radius\n" +
" rename <path> <old> <new> Find references for rename\n" +
" processes <path> Entry point execution flows\n" +
" clusters <path> Community-detected file clusters\n" +
" init [path] Make AI agents use CI (writes agent instruction files + skill)\n\n" +
"MCP mode:\n" +
" codebase-intelligence <path> 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 <path> Start MCP stdio server\n\n" +
"Try: codebase-intelligence overview ./src",
);

// ── Subcommand: overview ────────────────────────────────────

program
Expand Down
16 changes: 16 additions & 0 deletions src/install/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
toggleAll,
collectSelection,
renderMenu,
promptSelection,
} from "./prompt.js";
import { AGENT_TARGETS } from "./index.js";

Expand Down Expand Up @@ -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 });
});
});
188 changes: 188 additions & 0 deletions tests/cli-init.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading