diff --git a/README.md b/README.md index 6eb01d56..337ee94f 100644 --- a/README.md +++ b/README.md @@ -318,7 +318,18 @@ codegraph graph --root . ./src --dot --output graph.dot ## Agent setup -Using a skill-aware agent? Install the bundled skill so repo navigation, semantic references, dependency tracing, and PR impact questions route to Codegraph automatically. The installer uses safe per-agent defaults and creates the target skills directory as needed: +Using a local agent client? The top-level installer configures Codegraph-owned MCP entries, bundled skill payloads, and marker files for supported clients, while preserving existing user config: + +```bash +codegraph install --target codex,claude --dry-run +codegraph install --target codex,claude --yes +codegraph install --print-config codex +codegraph uninstall --target codex --yes +``` + +Supported installer targets are `codex`, `claude`, `cursor`, `gemini`, `opencode`, and `agents`. Writes require `--yes`; `--dry-run` previews files, and `uninstall` removes only Codegraph-owned marker blocks, marker files, exact bundled skill payloads, or exact installer-owned MCP entries. + +Using a skill-aware agent only? Install the bundled skill directly so repo navigation, semantic references, dependency tracing, and PR impact questions route to Codegraph automatically: ```bash # Codex CLI: ${CODEX_HOME:-~/.codex}/skills/codegraph @@ -340,7 +351,7 @@ codegraph skill install --agent gemini codegraph skill install --agent opencode ``` -For a custom location, use `codegraph skill install --target /skills/codegraph`; the target must end with `skills/codegraph`, and the installer creates the directory as needed. Cursor CLI now supports native skills directories too, so `.cursor/skills/codegraph` works alongside the universal `~/.agents/skills/codegraph` location. To inspect the packaged skill paths and target health, run `codegraph skill doctor`. +For a custom skill location, use `codegraph skill install --target /skills/codegraph`; the target must end with `skills/codegraph`, and the installer creates the directory as needed. Cursor CLI supports native skills directories too, so `.cursor/skills/codegraph` works alongside the universal `~/.agents/skills/codegraph` location. To inspect the packaged skill paths and target health, run `codegraph skill doctor`. ## Using as a library diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 172457a6..b4b06d6c 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -42,9 +42,11 @@ Then choose the smallest useful follow-up: - impact: `codegraph impact --base HEAD --head WORKTREE --pretty` - review: `codegraph review --base HEAD --head WORKTREE --summary` - drift: `codegraph drift ./src --base origin/main --head HEAD --pretty --graph-edges summary --public-api removals` +- installer: `codegraph install --target codex,claude --dry-run` Use `--root` to define the project boundary for config lookup, cache scope, path confinement, and output normalization. For `orient`, `drift`, and positional graph commands, positional paths are include roots inside that project. +Use `codegraph install --target --yes` to configure supported local agent clients with MCP entries, bundled skill payloads, and marker files. Use `--dry-run` or `--print-config ` first; uninstall removes only Codegraph-owned marker blocks, marker files, exact bundled skill payloads, or exact installer-owned MCP entries. ## Output Choice diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 1f02a8cb..05f7091b 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -99,6 +99,18 @@ codegraph drift ./src --base origin/main --head HEAD --compact-json Drift compares structural signals over time: dependency cycles, hotspots, unresolved imports, API surface changes, duplicate group counts, and graph edges. It is review and CI evidence, not runtime validation or compiler diagnostics. Use compact JSON for CI or agent handoff, and use graph-edge/API filters to keep human review output bounded. +## Agent client installer + +Use `install` when setting up Codegraph for supported local agent clients: + +```bash +codegraph install --target codex,claude --dry-run +codegraph install --target codex,claude --yes +codegraph uninstall --target codex --yes +``` + +Writes require `--yes`, and `--print-config ` prints the MCP snippet without touching disk. `uninstall` removes only Codegraph-owned marker blocks, marker files, exact bundled skill payloads, or exact installer-owned MCP entries. + ## MCP server Use `codegraph mcp serve --root . --stdio` when an agent can spawn and own a stdio MCP subprocess. diff --git a/docs/cli.md b/docs/cli.md index a5ca5505..04a4151a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -159,6 +159,13 @@ codegraph mcp serve --root . --stdio --warmup codegraph mcp serve --root . --port 7331 --warmup-symbols codegraph mcp --help +# Install or preview agent client integration +codegraph install --target codex,claude --dry-run +codegraph install --target codex,claude --yes +codegraph install --print-config codex +codegraph uninstall --target codex --yes +codegraph install --help + # Chunk a file for LLM processing codegraph chunk src/utils.js @@ -264,6 +271,13 @@ For SQL, prefer handles or schema-qualified names when basenames may be ambiguou - Use `--force` to replace recognizable stale Codegraph artifacts while preserving unrelated files. - Artifact contents exclude their own output directory and linked outside-root files. +#### Agent client installer + +- `install` configures Codegraph-owned MCP entries, bundled skill payloads, and marker files for supported local agent clients: `codex`, `claude`, `cursor`, `gemini`, `opencode`, and `agents`. +- Writes require `--yes`; use `--detect` to list discovered targets, `--dry-run` to preview changed file paths and actions, or `--print-config ` to print a copyable MCP snippet without writing. +- `uninstall` removes only Codegraph-owned marker blocks, marker files, exact bundled skill payloads, or exact installer-owned MCP entries. +- `skill install` remains the lower-level primitive when you only want to copy the bundled skill directly without MCP config. + #### MCP server - `mcp serve` exposes explore, navigation, search, impact, review, SQLite query, session refresh, and artifact-build tools. diff --git a/docs/installation.md b/docs/installation.md index 4a3caf36..2c31f6f6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -86,6 +86,19 @@ Explicit CLI, library, and tool `native` options take precedence over `CODEGRAPH Reduced mode preserves graph-only and regex-backed recovery where available; it does not provide a non-native Tree-sitter parser. +## Agent client setup + +After installing the CLI, `codegraph install` can configure Codegraph-owned MCP entries, bundled skill payloads, and marker files for supported agent clients: + +```bash +codegraph install --target codex,claude --dry-run +codegraph install --target codex,claude --yes +codegraph install --print-config codex +``` + +Supported targets are `codex`, `claude`, `cursor`, `gemini`, `opencode`, and `agents`. Writes require `--yes`; `--dry-run` reports the files that would change, and `uninstall` removes only Codegraph-owned marker blocks, marker files, exact bundled skill payloads, or exact installer-owned MCP entries. +The lower-level `codegraph skill install --agent ` command remains available when you only want to copy the bundled skill. + ## Next steps - For CLI commands and examples, see [docs/cli.md](./cli.md). diff --git a/docs/mcp.md b/docs/mcp.md index 2c2b2122..b11e92a0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -63,6 +63,18 @@ Tool schemas are flat JSON objects for broad client compatibility; argument comb - `query_sqlite` rejects mutating SQL, recursive queries, synthetic payload functions, and stale artifact queries it cannot refresh safely. - SQLite responses are row- and byte-bounded. +## Installer + +Use `codegraph install` to configure supported local clients without manually editing MCP config files: + +```bash +codegraph install --target codex,claude --dry-run +codegraph install --target codex,claude --yes +codegraph install --print-config codex +``` + +The installer writes only Codegraph-owned marker blocks, marker files, bundled skill payloads, or exact installer-owned MCP entries. `codegraph uninstall --target --yes` removes only those owned entries. + ## Client Configuration Examples Use `command: "codegraph"` when the CLI is on `PATH`. Use the full executable path when the client runs with a narrower environment. diff --git a/src/cli.ts b/src/cli.ts index f4bf3d1a..3ed0a4ff 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -37,6 +37,7 @@ import { handleGraphQueryCommand } from "./cli/graphQueries.js"; import { handleGrepCommand } from "./cli/grep.js"; import { CLI_HELP_TEXT, helpTextForCommand, isKnownCliCommand } from "./cli/help.js"; import { handleImpactCommand } from "./cli/impact.js"; +import { handleInstallerCommand } from "./cli/install.js"; import { handleIndexCommand } from "./cli/index.js"; import { handleHotspotsCommand, handleInspectCommand } from "./cli/inspect.js"; import { handleOrientCommand } from "./cli/orient.js"; @@ -285,6 +286,20 @@ async function runCliWithActiveRuntime(rawArgs: string[]) { return; } + if (cmd === "install" || cmd === "uninstall") { + await handleInstallerCommand({ + command: cmd, + positionals: parsed.positionals, + getOpt, + hasFlag, + writeJSONLine, + writeStdoutLine, + writeStderrLine, + exit: exitCli, + }); + return; + } + if (cmd === "sql") { const { handleSqlCommand } = await import("./cli/sql.js"); await handleSqlCommand({ diff --git a/src/cli/help.ts b/src/cli/help.ts index 63d6ea1f..233f0613 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -31,6 +31,8 @@ Commands: sql Query a SQLite graph export read-only chunk Chunk file for embeddings doctor Inspect backend/runtime state and local graph artifacts + install Configure Codegraph MCP and skill integration for agent clients + uninstall Remove Codegraph-owned installer configuration skill Install or inspect the bundled agent skill version Print the installed codegraph version @@ -56,8 +58,8 @@ Build Options: --cache-verify Re-stat cached files before trusting disk cache entries --progress Show progress tracking during indexing -Output Options: - --json Output as JSON (default) +Analysis Output Options: + --json Output analysis commands as JSON (default where supported) --mermaid Output as Mermaid diagram --dot Output as DOT graph --sqlite Write to SQLite database @@ -85,6 +87,9 @@ Examples: codegraph packet get file:src%2Fcli.ts --json codegraph artifact build --root . --out codegraph-out --json codegraph mcp serve --root . --stdio + codegraph install --target codex,claude --yes + codegraph install --print-config codex + codegraph uninstall --target codex --yes codegraph inspect ./src --limit 20 codegraph duplicates ./src --min-confidence medium codegraph graph ./src @@ -124,6 +129,7 @@ const knownCliCommands = new Set([ "hotspots", "impact", "index", + "install", "inspect", "mcp", "orient", @@ -137,12 +143,32 @@ const knownCliCommands = new Set([ "sql", "unresolved", "version", + "uninstall", ]); export function isKnownCliCommand(command: string): boolean { return knownCliCommands.has(command); } +export const INSTALL_HELP_TEXT = `codegraph install - Configure Codegraph for supported agent clients + +Usage: codegraph install [target] [--target ] [--yes | --dry-run] [--print-config ] [--detect] + +Targets: + codex, claude, cursor, gemini, opencode, agents + +Safety: + Writes require --yes. Use --dry-run to preview changed files or --print-config to print the MCP snippet. +`; + +export const UNINSTALL_HELP_TEXT = `codegraph uninstall - Remove Codegraph-owned installer configuration + +Usage: codegraph uninstall [target] [--target ] [--yes | --dry-run] [--detect] + +Safety: + Removes only Codegraph-owned marker blocks, marker files, exact bundled skill payloads, or exact installer-owned MCP entries. +`; + export const EXPLORE_HELP_TEXT = `codegraph explore - Answer a broad repo question with bounded repo context Usage: codegraph explore "" [--root ] [--limit ] [--max-packets ] [--max-paths ] [--no-source] [--json | --pretty] @@ -361,6 +387,8 @@ export function helpTextForCommand(command: string, positionals: readonly string if (command === "orient") return ORIENT_HELP_TEXT; if (command === "packet") return PACKET_HELP_TEXT; if (command === "explain") return EXPLAIN_HELP_TEXT; + if (command === "install") return INSTALL_HELP_TEXT; + if (command === "uninstall") return UNINSTALL_HELP_TEXT; if (command === "drift") return DRIFT_HELP_TEXT; if (command === "duplicates") return DUPLICATES_HELP_TEXT; if (command === "artifact") return ARTIFACT_HELP_TEXT; diff --git a/src/cli/install.ts b/src/cli/install.ts new file mode 100644 index 00000000..f01d65d5 --- /dev/null +++ b/src/cli/install.ts @@ -0,0 +1,103 @@ +import { + detectInstallTargets, + installCodegraphTargets, + parseInstallTargetId, + parseInstallTargetIds, + printInstallConfig, + uninstallCodegraphTargets, + type InstallTargetId, +} from "../installer/registry.js"; + +export type InstallerCommandContext = { + command: "install" | "uninstall"; + positionals: string[]; + getOpt: (name: string) => string | undefined; + hasFlag: (name: string) => boolean; + writeJSONLine: (value: unknown) => void; + writeStdoutLine: (message: string) => void; + writeStderrLine: (message: string) => void; + exit: (code: number) => never; +}; + +export async function handleInstallerCommand(context: InstallerCommandContext): Promise { + const printConfigTarget = context.getOpt("--print-config"); + + if (printConfigTarget !== undefined) { + assertPrintConfigIsExclusive(context); + const targetId = parseInstallerTargetOrExit(context, printConfigTarget); + context.writeStdoutLine(printInstallConfig({ targetId }).trimEnd()); + return; + } + + const targetIds = parseInstallerTargets(context); + const options = { + ...(targetIds !== undefined ? { targetIds } : {}), + yes: context.hasFlag("--yes"), + dryRun: context.hasFlag("--dry-run"), + }; + + if (context.hasFlag("--detect")) { + context.writeJSONLine({ targets: await detectInstallTargets(options) }); + return; + } + + if (context.command === "install") { + context.writeJSONLine(await installCodegraphTargets(options)); + return; + } + + context.writeJSONLine(await uninstallCodegraphTargets(options)); +} + +function parseInstallerTargets(context: InstallerCommandContext): InstallTargetId[] | undefined { + const targetOpt = context.getOpt("--target"); + const positionalTarget = context.positionals[0]; + if (context.positionals.length > 1) { + context.writeStderrLine(`Unexpected positional argument for ${context.command}: ${context.positionals[1]!}`); + context.exit(2); + } + if (targetOpt !== undefined && positionalTarget !== undefined) { + failUsage(context, "Use either --target or a positional target, not both."); + } + return parseInstallerTargetIdsOrExit(context, targetOpt ?? positionalTarget); +} + +function assertPrintConfigIsExclusive(context: InstallerCommandContext): void { + const conflicts: string[] = []; + if (context.getOpt("--target") !== undefined) conflicts.push("--target"); + if (context.positionals.length) conflicts.push("positional targets"); + if (context.hasFlag("--detect")) conflicts.push("--detect"); + if (context.hasFlag("--yes")) conflicts.push("--yes"); + if (context.hasFlag("--dry-run")) conflicts.push("--dry-run"); + if (!conflicts.length) return; + failUsage(context, `--print-config cannot be combined with ${conflicts.join(", ")}.`); +} + +function parseInstallerTargetOrExit(context: InstallerCommandContext, value: string): InstallTargetId { + try { + return parseInstallTargetId(value); + } catch (error) { + failUsage(context, errorMessage(error)); + } +} + +function parseInstallerTargetIdsOrExit( + context: InstallerCommandContext, + value: string | undefined, +): InstallTargetId[] | undefined { + try { + return parseInstallTargetIds(value); + } catch (error) { + failUsage(context, errorMessage(error)); + } +} + +function failUsage(context: InstallerCommandContext, message: string): never { + context.writeStderrLine(message); + context.exit(2); +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} diff --git a/src/cli/options.ts b/src/cli/options.ts index 8730c706..66de7001 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -63,6 +63,7 @@ const CLI_VALUE_OPTIONS = new Set([ "--duplicates", "--agent", "--target", + "--print-config", "--limit", "--fail-on", "--hotspot-jump-threshold", @@ -360,6 +361,23 @@ const CLI_COMMAND_SCHEMAS = new Map([ ), ], ["index", graphCommandSchema({ kind: "any" })], + [ + "install", + commandSchema(["--detect", "--dry-run", "--yes"], ["--print-config", "--target"], { + kind: "max", + max: 1, + usage: + "Usage: codegraph install [target] [--target ] [--detect] [--yes | --dry-run] [--print-config ]", + }), + ], + [ + "uninstall", + commandSchema(["--detect", "--dry-run", "--yes"], ["--target"], { + kind: "max", + max: 1, + usage: "Usage: codegraph uninstall [target] [--target ] [--detect] [--yes | --dry-run]", + }), + ], [ "inspect", commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS], [...SHARED_BUILD_OPTIONS, "--limit"], { kind: "any" }), diff --git a/src/cli/skill.ts b/src/cli/skill.ts index 9d37ebc0..26234a70 100644 --- a/src/cli/skill.ts +++ b/src/cli/skill.ts @@ -43,7 +43,8 @@ export function getSkillTargetDirForAgent( return path.join(homeDir, ".gemini", "skills", "codegraph"); } if (agent === "opencode") { - return path.join(homeDir, ".config", "opencode", "skills", "codegraph"); + const configHome = env.XDG_CONFIG_HOME?.trim() || path.join(homeDir, ".config"); + return path.join(configHome, "opencode", "skills", "codegraph"); } const codexHome = env.CODEX_HOME?.trim(); if (codexHome) { diff --git a/src/installer/registry.ts b/src/installer/registry.ts new file mode 100644 index 00000000..1903043d --- /dev/null +++ b/src/installer/registry.ts @@ -0,0 +1,575 @@ +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { getSkillTargetDirForAgent, type SkillInstallAgent } from "../cli/skill.js"; +import { getCodegraphPackageRoot, normalizePathForDisplay, pathExists } from "../cli/packageInfo.js"; + +export type InstallTargetId = SkillInstallAgent; + +export type TargetDetection = { + detected: boolean; + reason: string; + configPath?: string; + skillTargetDir: string; +}; + +export type InstallOptions = { + targetIds?: InstallTargetId[]; + yes?: boolean; + dryRun?: boolean; + homeDir?: string; + env?: Record; +}; + +export type PrintConfigOptions = { + targetId: InstallTargetId; + homeDir?: string; + env?: Record; +}; + +export type UninstallOptions = InstallOptions; + +export type InstallChange = { + target: InstallTargetId; + action: "create" | "update" | "delete" | "unchanged"; + path: string; + dryRun: boolean; +}; + +export type InstallResult = { + installed: boolean; + dryRun: boolean; + targets: InstallTargetId[]; + changes: InstallChange[]; +}; + +export type UninstallResult = { + uninstalled: boolean; + dryRun: boolean; + targets: InstallTargetId[]; + changes: InstallChange[]; +}; + +export type InstallTarget = { + id: InstallTargetId; + label: string; + detect(options?: InstallOptions): Promise; + printConfig(options?: PrintConfigOptions): string; + install(options?: InstallOptions): Promise; + uninstall(options?: UninstallOptions): Promise; +}; + +type ConfigKind = "toml-block" | "json-mcp-servers" | "json-opencode-mcp" | "skill-only"; + +type TargetDefinition = { + id: InstallTargetId; + label: string; + kind: ConfigKind; + configPath?: (settings: InstallerSettings) => string; +}; + +type InstallerSettings = { + homeDir: string; + env: Record; +}; + +type JsonRecord = Record; + +const TARGET_DEFINITIONS: TargetDefinition[] = [ + { + id: "codex", + label: "Codex CLI", + kind: "toml-block", + configPath: ({ homeDir }) => path.join(homeDir, ".codex", "config.toml"), + }, + { + id: "claude", + label: "Claude Code", + kind: "json-mcp-servers", + configPath: ({ homeDir }) => path.join(homeDir, ".claude", "mcp.json"), + }, + { + id: "cursor", + label: "Cursor", + kind: "json-mcp-servers", + configPath: ({ homeDir }) => path.join(homeDir, ".cursor", "mcp.json"), + }, + { + id: "gemini", + label: "Gemini CLI", + kind: "json-mcp-servers", + configPath: ({ homeDir }) => path.join(homeDir, ".gemini", "settings.json"), + }, + { + id: "opencode", + label: "OpenCode", + kind: "json-opencode-mcp", + configPath: (settings) => path.join(opencodeConfigHome(settings), "opencode", "opencode.json"), + }, + { + id: "agents", + label: "Agents skill directory", + kind: "skill-only", + }, +]; + +const CODEGRAPH_TOML_MARKER_BEGIN = "# >>> codegraph mcp >>>"; +const CODEGRAPH_TOML_MARKER_END = "# <<< codegraph mcp <<<"; +const DEFAULT_TARGET_IDS = TARGET_DEFINITIONS.map((target) => target.id); + +export function listInstallTargets(): InstallTarget[] { + return TARGET_DEFINITIONS.map(createInstallTarget); +} + +export function parseInstallTargetIds(rawValue: string | undefined): InstallTargetId[] | undefined { + if (rawValue === undefined) return undefined; + const ids: InstallTargetId[] = []; + for (const part of rawValue.split(",")) { + const value = part.trim(); + if (!value) continue; + ids.push(parseInstallTargetId(value)); + } + if (!ids.length) { + throw new Error("--target must name at least one install target."); + } + return [...new Set(ids)]; +} + +export function parseInstallTargetId(value: string): InstallTargetId { + if (isInstallTargetId(value)) return value; + throw new Error(`Unknown install target "${value}". Expected ${DEFAULT_TARGET_IDS.join(", ")}.`); +} + +export async function detectInstallTargets(options: InstallOptions = {}): Promise { + return await Promise.all(listInstallTargets().map(async (target) => await target.detect(options))); +} + +export function printInstallConfig(options: PrintConfigOptions): string { + const target = createInstallTarget(definitionForTarget(options.targetId)); + return target.printConfig(options); +} + +export async function installCodegraphTargets(options: InstallOptions = {}): Promise { + assertWriteAllowed(options); + const targets = await resolveRequestedTargets(options); + const changes: InstallChange[] = []; + for (const target of targets) { + const result = await target.install(options); + changes.push(...result.changes); + } + return { + installed: changes.some((change) => change.action === "create" || change.action === "update"), + dryRun: options.dryRun ?? false, + targets: targets.map((target) => target.id), + changes, + }; +} + +export async function uninstallCodegraphTargets(options: UninstallOptions = {}): Promise { + assertWriteAllowed(options); + const targets = await resolveRequestedTargets(options); + const changes: InstallChange[] = []; + for (const target of targets) { + const result = await target.uninstall(options); + changes.push(...result.changes); + } + return { + uninstalled: changes.some((change) => change.action === "delete" || change.action === "update"), + dryRun: options.dryRun ?? false, + targets: targets.map((target) => target.id), + changes, + }; +} + +function createInstallTarget(definition: TargetDefinition): InstallTarget { + return { + id: definition.id, + label: definition.label, + detect: (options = {}) => Promise.resolve(detectTarget(definition, options)), + printConfig: (options = { targetId: definition.id }) => printTargetConfig(definition, options), + install: async (options = {}) => await installTarget(definition, options), + uninstall: async (options = {}) => await uninstallTarget(definition, options), + }; +} + +async function resolveRequestedTargets(options: InstallOptions): Promise { + if (options.targetIds?.length) { + return options.targetIds.map((id) => createInstallTarget(definitionForTarget(id))); + } + const targets = listInstallTargets(); + const detections = await Promise.all(targets.map(async (target) => await target.detect(options))); + const detectedTargets: InstallTarget[] = []; + for (const [index, target] of targets.entries()) { + const detection = detections[index]; + if (detection?.detected) detectedTargets.push(target); + } + return detectedTargets; +} + +function assertWriteAllowed(options: InstallOptions): void { + if (options.dryRun) return; + if (options.yes) return; + throw new Error("Writes require --yes. Use --dry-run to inspect changes first."); +} + +function detectTarget(definition: TargetDefinition, options: InstallOptions): TargetDetection { + const settings = installerSettings(options); + const configPath = definition.configPath?.(settings); + const skillTargetDir = getSkillTargetDirForAgent(definition.id, settings.homeDir, settings.env); + if (configPath !== undefined && pathExists(path.dirname(configPath))) { + return { + detected: true, + reason: `${definition.label} config directory exists`, + configPath: normalizePathForDisplay(configPath), + skillTargetDir: normalizePathForDisplay(skillTargetDir), + }; + } + if (pathExists(path.dirname(skillTargetDir))) { + return { + detected: true, + reason: `${definition.label} skill directory exists`, + ...(configPath !== undefined ? { configPath: normalizePathForDisplay(configPath) } : {}), + skillTargetDir: normalizePathForDisplay(skillTargetDir), + }; + } + const baseSkillDirExists = definition.kind === "skill-only" && pathExists(path.dirname(path.dirname(skillTargetDir))); + return { + detected: baseSkillDirExists, + reason: baseSkillDirExists ? `${definition.label} base directory exists` : `${definition.label} was not detected`, + ...(configPath !== undefined ? { configPath: normalizePathForDisplay(configPath) } : {}), + skillTargetDir: normalizePathForDisplay(skillTargetDir), + }; +} + +async function installTarget(definition: TargetDefinition, options: InstallOptions): Promise { + const settings = installerSettings(options); + const dryRun = options.dryRun ?? false; + const changes: InstallChange[] = []; + const skillTargetDir = getSkillTargetDirForAgent(definition.id, settings.homeDir, settings.env); + changes.push(await upsertSkillPayload(definition, skillTargetDir, dryRun)); + changes.push(await upsertSkillPointer(definition, skillTargetDir, dryRun)); + if (definition.kind !== "skill-only") { + const configPath = requireConfigPath(definition, settings); + changes.push(await upsertConfig(definition, configPath, dryRun)); + } + return { installed: true, dryRun, targets: [definition.id], changes }; +} + +async function uninstallTarget(definition: TargetDefinition, options: UninstallOptions): Promise { + const settings = installerSettings(options); + const dryRun = options.dryRun ?? false; + const changes: InstallChange[] = []; + const skillTargetDir = getSkillTargetDirForAgent(definition.id, settings.homeDir, settings.env); + changes.push(await removeSkillPayload(definition, skillTargetDir, dryRun)); + changes.push(await removeSkillPointer(definition, skillTargetDir, dryRun)); + if (definition.kind !== "skill-only") { + const configPath = requireConfigPath(definition, settings); + changes.push(await removeConfig(definition, configPath, dryRun)); + } + return { uninstalled: true, dryRun, targets: [definition.id], changes }; +} + +async function upsertSkillPayload( + definition: TargetDefinition, + skillTargetDir: string, + dryRun: boolean, +): Promise { + const bundledSkillPath = bundledSkillFilePath(); + const targetSkillPath = path.join(skillTargetDir, "SKILL.md"); + const bundledSkill = await fsp.readFile(bundledSkillPath, "utf8"); + const existing = await readOptionalFile(targetSkillPath); + if (existing === bundledSkill) return change(definition.id, "unchanged", targetSkillPath, dryRun); + if (!dryRun) { + await fsp.mkdir(skillTargetDir, { recursive: true }); + await fsp.writeFile(targetSkillPath, bundledSkill, "utf8"); + } + return change(definition.id, existing === null ? "create" : "update", targetSkillPath, dryRun); +} + +async function upsertSkillPointer( + definition: TargetDefinition, + skillTargetDir: string, + dryRun: boolean, +): Promise { + const markerPath = path.join(skillTargetDir, "CODEGRAPH_INSTALLED"); + const content = `Installed by codegraph install for ${definition.label}.\nRun codegraph skill install --agent ${definition.id} --force to refresh bundled skill files.\n`; + const existing = await readOptionalFile(markerPath); + if (existing === content) return change(definition.id, "unchanged", markerPath, dryRun); + if (!dryRun) { + await fsp.mkdir(skillTargetDir, { recursive: true }); + await fsp.writeFile(markerPath, content, "utf8"); + } + return change(definition.id, existing === null ? "create" : "update", markerPath, dryRun); +} + +async function removeSkillPayload( + definition: TargetDefinition, + skillTargetDir: string, + dryRun: boolean, +): Promise { + const markerPath = path.join(skillTargetDir, "CODEGRAPH_INSTALLED"); + const targetSkillPath = path.join(skillTargetDir, "SKILL.md"); + const markerExists = await pathExistsUnlessMissing(markerPath); + if (!markerExists) return change(definition.id, "unchanged", targetSkillPath, dryRun); + const existing = await readOptionalFile(targetSkillPath); + if (existing === null) return change(definition.id, "unchanged", targetSkillPath, dryRun); + const bundledSkill = await fsp.readFile(bundledSkillFilePath(), "utf8"); + if (existing !== bundledSkill) return change(definition.id, "unchanged", targetSkillPath, dryRun); + if (!dryRun) await fsp.rm(targetSkillPath, { force: true }); + return change(definition.id, "delete", targetSkillPath, dryRun); +} + +async function removeSkillPointer( + definition: TargetDefinition, + skillTargetDir: string, + dryRun: boolean, +): Promise { + const markerPath = path.join(skillTargetDir, "CODEGRAPH_INSTALLED"); + const markerExists = await pathExistsUnlessMissing(markerPath); + if (!markerExists) return change(definition.id, "unchanged", markerPath, dryRun); + if (!dryRun) await fsp.rm(markerPath, { force: true }); + return change(definition.id, "delete", markerPath, dryRun); +} + +async function upsertConfig(definition: TargetDefinition, configPath: string, dryRun: boolean): Promise { + const existing = await readOptionalFile(configPath); + const next = renderConfigWithCodegraph(definition, existing, configPath); + if (existing === next) return change(definition.id, "unchanged", configPath, dryRun); + if (!dryRun) { + await fsp.mkdir(path.dirname(configPath), { recursive: true }); + await fsp.writeFile(configPath, next, "utf8"); + } + return change(definition.id, existing === null ? "create" : "update", configPath, dryRun); +} + +async function removeConfig(definition: TargetDefinition, configPath: string, dryRun: boolean): Promise { + const existing = await readOptionalFile(configPath); + if (existing === null) return change(definition.id, "unchanged", configPath, dryRun); + const next = removeCodegraphConfig(definition, existing, configPath); + if (existing === next) return change(definition.id, "unchanged", configPath, dryRun); + const action = next.trim() ? "update" : "delete"; + if (!dryRun) { + if (action === "delete") { + await fsp.rm(configPath, { force: true }); + } else { + await fsp.writeFile(configPath, next, "utf8"); + } + } + return change(definition.id, action, configPath, dryRun); +} + +function renderConfigWithCodegraph(definition: TargetDefinition, existing: string | null, configPath: string): string { + if (definition.kind === "toml-block") { + return upsertMarkedTomlBlock(existing ?? "", codexTomlSnippet()); + } + const parsed = parseConfigJson(existing, configPath); + if (definition.kind === "json-opencode-mcp") { + const mcp = readRecordProperty(parsed, "mcp"); + const desiredServer = codegraphJsonServer(definition); + if (shouldPreserveExistingServer(mcp, desiredServer)) return existing ?? renderJsonConfig(parsed); + mcp.codegraph = desiredServer; + parsed.mcp = mcp; + } else { + const mcpServers = readRecordProperty(parsed, "mcpServers"); + const desiredServer = codegraphJsonServer(definition); + if (shouldPreserveExistingServer(mcpServers, desiredServer)) return existing ?? renderJsonConfig(parsed); + mcpServers.codegraph = desiredServer; + parsed.mcpServers = mcpServers; + } + return renderJsonConfig(parsed); +} + +function removeCodegraphConfig(definition: TargetDefinition, existing: string, configPath: string): string { + if (definition.kind === "toml-block") { + return removeMarkedTomlBlock(existing); + } + const parsed = parseConfigJson(existing, configPath); + const property = definition.kind === "json-opencode-mcp" ? "mcp" : "mcpServers"; + const servers = readRecordProperty(parsed, property); + const server = servers.codegraph; + if (!isInstallerOwnedJsonServer(definition, server)) return existing; + delete servers.codegraph; + if (Object.keys(servers).length) { + parsed[property] = servers; + } else { + delete parsed[property]; + } + return renderJsonConfig(parsed); +} + +function printTargetConfig(definition: TargetDefinition, _options: PrintConfigOptions): string { + if (definition.kind === "toml-block") return codexTomlSnippet(); + if (definition.kind === "json-opencode-mcp") { + return renderJsonConfig({ mcp: { codegraph: codegraphJsonServer(definition) } }); + } + if (definition.kind === "skill-only") { + return `codegraph skill install --agent ${definition.id}\n`; + } + return renderJsonConfig({ mcpServers: { codegraph: codegraphJsonServer(definition) } }); +} + +function codexTomlSnippet(): string { + return '[mcp_servers.codegraph]\ncommand = "codegraph"\nargs = ["mcp", "serve", "--root", ".", "--stdio"]\nstartup_timeout_ms = 20000\n'; +} + +function upsertMarkedTomlBlock(existing: string, snippet: string): string { + const block = `${CODEGRAPH_TOML_MARKER_BEGIN}\n${snippet.trimEnd()}\n${CODEGRAPH_TOML_MARKER_END}`; + const withoutBlock = removeMarkedTomlBlock(existing).trimEnd(); + if (!withoutBlock) return `${block}\n`; + return `${withoutBlock}\n\n${block}\n`; +} + +function removeMarkedTomlBlock(existing: string): string { + const begin = escapeRegExp(CODEGRAPH_TOML_MARKER_BEGIN); + const end = escapeRegExp(CODEGRAPH_TOML_MARKER_END); + const pattern = new RegExp(`\\n?${begin}[\\s\\S]*?${end}\\n?`, "g"); + return existing.replace(pattern, "\n").replace(/\n{3,}/g, "\n\n"); +} + +function parseConfigJson(existing: string | null, configPath: string): JsonRecord { + if (existing === null || !existing.trim()) return {}; + try { + const parsed: unknown = JSON.parse(existing); + if (isJsonRecord(parsed)) return parsed; + } catch (error) { + throw new Error( + `Unable to parse ${normalizePathForDisplay(configPath)} as JSON. Fix the file before running the installer.`, + { + cause: error, + }, + ); + } + throw new Error(`${normalizePathForDisplay(configPath)} must contain a JSON object.`); +} + +function readRecordProperty(record: JsonRecord, property: string): JsonRecord { + const value = record[property]; + if (value === undefined) return {}; + if (isJsonRecord(value)) return value; + throw new Error(`Existing ${property} config must be a JSON object before codegraph can update it.`); +} + +function renderJsonConfig(record: JsonRecord): string { + if (!Object.keys(record).length) return ""; + return `${JSON.stringify(record, null, 2)}\n`; +} + +function opencodeConfigHome(settings: InstallerSettings): string { + const configHome = settings.env.XDG_CONFIG_HOME?.trim(); + return configHome || path.join(settings.homeDir, ".config"); +} + +function codegraphJsonServer(definition: TargetDefinition): JsonRecord { + if (definition.kind === "json-opencode-mcp") { + return { + type: "local", + enabled: true, + command: ["codegraph", "mcp", "serve", "--root", ".", "--stdio"], + }; + } + return { + type: "stdio", + command: "codegraph", + args: ["mcp", "serve", "--root", ".", "--stdio"], + }; +} + +function shouldPreserveExistingServer(servers: JsonRecord, desiredServer: JsonRecord): boolean { + const existingServer = servers.codegraph; + if (existingServer === undefined) return false; + return !jsonValueEquals(existingServer, desiredServer); +} + +function isInstallerOwnedJsonServer(definition: TargetDefinition, value: unknown): boolean { + return jsonValueEquals(value, codegraphJsonServer(definition)); +} + +function jsonValueEquals(left: unknown, right: unknown): boolean { + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right)) return false; + if (left.length !== right.length) return false; + return left.every((value, index) => jsonValueEquals(value, right[index])); + } + if (isJsonRecord(left) || isJsonRecord(right)) { + if (!isJsonRecord(left) || !isJsonRecord(right)) return false; + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + if (leftKeys.length !== rightKeys.length) return false; + for (const key of rightKeys) { + if (!Object.prototype.hasOwnProperty.call(left, key)) return false; + if (!jsonValueEquals(left[key], right[key])) return false; + } + return true; + } + return left === right; +} + +function isJsonRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function installerSettings(options: { homeDir?: string; env?: Record }): InstallerSettings { + return { + homeDir: options.homeDir ?? os.homedir(), + env: options.env ?? process.env, + }; +} + +function requireConfigPath(definition: TargetDefinition, settings: InstallerSettings): string { + const configPath = definition.configPath?.(settings); + if (configPath === undefined) { + throw new Error(`${definition.label} does not have an MCP config file target.`); + } + return configPath; +} + +function bundledSkillFilePath(): string { + return path.join(getCodegraphPackageRoot(), "codegraph-skill", "codegraph", "SKILL.md"); +} + +async function pathExistsUnlessMissing(filePath: string): Promise { + try { + await fsp.stat(filePath); + return true; + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return false; + throw error; + } +} + +function definitionForTarget(id: InstallTargetId): TargetDefinition { + const definition = TARGET_DEFINITIONS.find((target) => target.id === id); + if (!definition) throw new Error(`Unknown install target "${id}".`); + return definition; +} + +function isInstallTargetId(value: string): value is InstallTargetId { + return DEFAULT_TARGET_IDS.includes(value as InstallTargetId); +} + +function change( + target: InstallTargetId, + action: InstallChange["action"], + filePath: string, + dryRun: boolean, +): InstallChange { + return { + target, + action, + path: normalizePathForDisplay(filePath), + dryRun, + }; +} + +async function readOptionalFile(filePath: string): Promise { + try { + return await fsp.readFile(filePath, "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") return null; + throw error; + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/tests/cli-command-modules.test.ts b/tests/cli-command-modules.test.ts index 26183655..8e8cacd4 100644 --- a/tests/cli-command-modules.test.ts +++ b/tests/cli-command-modules.test.ts @@ -245,7 +245,7 @@ describe("CLI command modules", () => { test("lists cache verification and progress as build options in CLI help", () => { const buildOptions = CLI_HELP_TEXT.slice( CLI_HELP_TEXT.indexOf("Build Options:"), - CLI_HELP_TEXT.indexOf("Output Options:"), + CLI_HELP_TEXT.indexOf("Analysis Output Options:"), ); expect(buildOptions).toContain("--cache-strict"); @@ -303,6 +303,42 @@ describe("CLI command modules", () => { expect(PACKET_HELP_TEXT).not.toContain("CLI orient returns file handles"); }); + test("install and uninstall help omit --json because output is always structured", async () => { + for (const command of ["install", "uninstall"]) { + const result = await captureCli([command, "--help"]); + + expect(result.exitCode, command).toBeUndefined(); + expect(result.stderr, command).toBe(""); + expect(result.stdout, command).toContain(`Usage: codegraph ${command}`); + expect(result.stdout, command).not.toContain("--json"); + } + }); + + test("top-level help scopes output options to analysis commands", async () => { + const result = await captureCli(["--help"]); + + expect(result.exitCode).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.stdout).toMatch(/^Analysis Output Options:$/m); + expect(result.stdout).not.toMatch(/^Output Options:$/m); + + const outputSectionStart = result.stdout.indexOf("Analysis Output Options:"); + const examplesStart = result.stdout.indexOf("Examples:", outputSectionStart); + expect(outputSectionStart).toBeGreaterThanOrEqual(0); + expect(examplesStart).toBeGreaterThan(outputSectionStart); + + const outputSection = result.stdout.slice(outputSectionStart, examplesStart); + expect(outputSection).toContain("--json"); + expect(outputSection).toContain("Output analysis commands as JSON"); + expect(outputSection).not.toContain("Output as JSON (default)"); + + const installerLines = result.stdout + .split("\n") + .filter((line) => /\bcodegraph (?:install|uninstall)\b|\b(?:install|uninstall)\s{2,}/.test(line)); + expect(installerLines.length).toBeGreaterThan(0); + expect(installerLines.join("\n")).not.toContain("--json"); + }); + test("search command prints usage before running without a query", async () => { const stderr: string[] = []; diff --git a/tests/cli-options-validation.test.ts b/tests/cli-options-validation.test.ts index 01905056..3fff9eab 100644 --- a/tests/cli-options-validation.test.ts +++ b/tests/cli-options-validation.test.ts @@ -5,6 +5,7 @@ import { parseNonNegativeIntegerOption, parseRefContextOption, parseSymbolGraphScopeOption, + validateCliArgs, } from "../src/cli/options.js"; describe("parseIntegerOptionValue strictness", () => { @@ -57,3 +58,13 @@ describe("parseCliArgs value-option guard", () => { expect(parsed.options.get("--limit")).toEqual(["-1"]); }); }); + +describe("CLI command option validation", () => { + it("rejects installer --json flags because installer output is already structured JSON", () => { + for (const command of ["install", "uninstall"]) { + const parsed = parseCliArgs(command, ["--json"]); + + expect(() => validateCliArgs(command, parsed)).toThrow(`Unknown option for ${command}: --json`); + } + }); +}); diff --git a/tests/cli-regressions.test.ts b/tests/cli-regressions.test.ts index ed6a6053..0fd02c6c 100644 --- a/tests/cli-regressions.test.ts +++ b/tests/cli-regressions.test.ts @@ -1272,6 +1272,7 @@ export function summarizeInvoices(rows: Array<{ amount: number; tax: number }>) HOME: tmpDir, USERPROFILE: tmpDir, CODEX_HOME: "", + XDG_CONFIG_HOME: "", }; await fsp.mkdir(path.dirname(targetDir), { recursive: true }); diff --git a/tests/installer.test.ts b/tests/installer.test.ts new file mode 100644 index 00000000..a9f45667 --- /dev/null +++ b/tests/installer.test.ts @@ -0,0 +1,620 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + detectInstallTargets, + installCodegraphTargets, + printInstallConfig, + uninstallCodegraphTargets, + type InstallChange, +} from "../src/installer/registry.js"; +import { captureCli } from "./helpers/cli.js"; +import { mkTmpDir } from "./helpers/filesystem.js"; + +async function readFile(filePath: string): Promise { + return await fsp.readFile(filePath, "utf8"); +} + +const BUNDLED_SKILL_PATH = path.join(process.cwd(), "codegraph-skill", "codegraph", "SKILL.md"); + +function expectInstallerChange(changes: InstallChange[], expected: InstallChange): void { + expect(changes).toContainEqual({ ...expected, path: normalizeExpectedPath(expected.path) }); +} + +function normalizeExpectedPath(filePath: string): string { + return filePath.split(path.sep).join("/"); +} + +const CURSOR_INSTALLER_SERVER = { + type: "stdio", + command: "codegraph", + args: ["mcp", "serve", "--root", ".", "--stdio"], +}; + +const OPENCODE_INSTALLER_SERVER = { + type: "local", + enabled: true, + command: ["codegraph", "mcp", "serve", "--root", ".", "--stdio"], +}; + +const JSON_UNINSTALL_PRESERVE_CASES = [ + { + name: "Cursor mcpServers.codegraph with custom args", + targetId: "cursor" as const, + property: "mcpServers" as const, + configPath: (homeDir: string) => path.join(homeDir, ".cursor", "mcp.json"), + codegraphEntry: { + ...CURSOR_INSTALLER_SERVER, + args: ["mcp", "serve", "--root", "/custom-project", "--stdio"], + }, + }, + { + name: "Cursor mcpServers.codegraph with added env", + targetId: "cursor" as const, + property: "mcpServers" as const, + configPath: (homeDir: string) => path.join(homeDir, ".cursor", "mcp.json"), + codegraphEntry: { + ...CURSOR_INSTALLER_SERVER, + env: { CODEGRAPH_CONTEXT: "keep" }, + }, + }, + { + name: "Cursor mcpServers.codegraph with an added extra key", + targetId: "cursor" as const, + property: "mcpServers" as const, + configPath: (homeDir: string) => path.join(homeDir, ".cursor", "mcp.json"), + codegraphEntry: { + ...CURSOR_INSTALLER_SERVER, + note: "user-owned", + }, + }, + { + name: "OpenCode mcp.codegraph with custom command args", + targetId: "opencode" as const, + property: "mcp" as const, + configPath: (homeDir: string) => path.join(homeDir, ".config", "opencode", "opencode.json"), + codegraphEntry: { + ...OPENCODE_INSTALLER_SERVER, + command: ["codegraph", "mcp", "serve", "--root", "/custom-project", "--stdio"], + }, + }, + { + name: "OpenCode mcp.codegraph with added env", + targetId: "opencode" as const, + property: "mcp" as const, + configPath: (homeDir: string) => path.join(homeDir, ".config", "opencode", "opencode.json"), + codegraphEntry: { + ...OPENCODE_INSTALLER_SERVER, + env: { CODEGRAPH_CONTEXT: "keep" }, + }, + }, + { + name: "OpenCode mcp.codegraph with an added extra key", + targetId: "opencode" as const, + property: "mcp" as const, + configPath: (homeDir: string) => path.join(homeDir, ".config", "opencode", "opencode.json"), + codegraphEntry: { + ...OPENCODE_INSTALLER_SERVER, + note: "user-owned", + }, + }, +]; + +describe("agent installer workflow", () => { + it("detects present and missing target directories", async () => { + const homeDir = await mkTmpDir("cg-install-detect-"); + await fsp.mkdir(path.join(homeDir, ".codex"), { recursive: true }); + + const detections = await detectInstallTargets({ homeDir }); + const codex = detections.find((target) => target.configPath?.endsWith(".codex/config.toml")); + const gemini = detections.find((target) => target.configPath?.endsWith(".gemini/settings.json")); + + expect(codex?.detected).toBeTruthy(); + expect(codex?.reason).toContain("config directory exists"); + expect(gemini?.detected).toBeFalsy(); + }); + + it("detects the base Agents directory without a contradictory reason", async () => { + const homeDir = await mkTmpDir("cg-install-detect-agents-base-"); + await fsp.mkdir(path.join(homeDir, ".agents"), { recursive: true }); + + const detections = await detectInstallTargets({ homeDir }); + const agentsSkillDir = normalizeExpectedPath(path.join(homeDir, ".agents", "skills", "codegraph")); + const agents = detections.find((target) => target.skillTargetDir === agentsSkillDir); + + expect(agents?.detected).toBe(true); + expect(agents?.reason).toContain("base directory exists"); + expect(agents?.reason).not.toMatch(/not detected/i); + }); + + it("auto-detect installs detected Cursor target without shifting past missing Claude", async () => { + const homeDir = await mkTmpDir("cg-install-detect-cursor-"); + await fsp.mkdir(path.join(homeDir, ".codex"), { recursive: true }); + await fsp.mkdir(path.join(homeDir, ".cursor"), { recursive: true }); + + const result = await installCodegraphTargets({ homeDir, yes: true }); + + expect(result.targets).toEqual(["codex", "cursor"]); + const cursorConfig = JSON.parse(await readFile(path.join(homeDir, ".cursor", "mcp.json"))) as { + mcpServers?: { codegraph?: { command?: string } }; + }; + expect(cursorConfig.mcpServers?.codegraph?.command).toBe("codegraph"); + await expect(fsp.stat(path.join(homeDir, ".claude", "mcp.json"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("prints a Codex MCP TOML snippet from the CLI", async () => { + const result = await captureCli(["install", "--print-config", "codex"]); + + expect(result.exitCode).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("[mcp_servers.codegraph]"); + expect(result.stdout).toContain('command = "codegraph"'); + expect(result.stdout).toContain('["mcp", "serve", "--root", ".", "--stdio"]'); + }); + + it("prints a non-conflicting Agents skill install command from the CLI", async () => { + const result = await captureCli(["install", "--print-config", "agents"]); + + expect(result.exitCode).toBeUndefined(); + expect(result.stderr).toBe(""); + expect(result.stdout.trim()).toMatch(/^codegraph skill install\b/); + expect(result.stdout).toContain("--agent agents"); + expect(result.stdout).not.toContain("--target"); + }); + + it("rejects --print-config combined with target selection or action flags", async () => { + const cases = [ + { + name: "--target", + args: ["install", "--print-config", "codex", "--target", "cursor"], + conflict: "--target", + }, + { + name: "positional target after the print-config value", + args: ["install", "--print-config", "codex", "cursor"], + conflict: "positional targets", + }, + { + name: "--detect", + args: ["install", "--print-config", "codex", "--detect"], + conflict: "--detect", + }, + { + name: "--yes", + args: ["install", "--print-config", "codex", "--yes"], + conflict: "--yes", + }, + { + name: "--dry-run", + args: ["install", "--print-config", "codex", "--dry-run"], + conflict: "--dry-run", + }, + ]; + + for (const testCase of cases) { + const result = await captureCli(testCase.args); + + expect(result.exitCode, testCase.name).toBe(2); + expect(result.stdout, testCase.name).toBe(""); + expect(result.stderr, testCase.name).toContain("--print-config cannot be combined"); + expect(result.stderr, testCase.name).toContain(testCase.conflict); + expect(result.stderr, testCase.name).not.toContain("Error:"); + } + }); + + it("rejects installer commands that combine --target with a positional target", async () => { + const cases = [ + { name: "install", args: ["install", "--target", "codex", "cursor", "--yes"] }, + { name: "uninstall", args: ["uninstall", "--target", "codex", "cursor", "--yes"] }, + ]; + + for (const testCase of cases) { + const result = await captureCli(testCase.args); + + expect(result.exitCode, testCase.name).toBe(2); + expect(result.stdout, testCase.name).toBe(""); + expect(result.stderr, testCase.name).toContain("Use either --target or a positional target"); + expect(result.stderr, testCase.name).not.toContain("Error:"); + } + }); + + it("includes --detect in install and uninstall positional usage errors", async () => { + const cases = [ + { name: "install", args: ["install", "codex", "cursor"], usage: "Usage: codegraph install" }, + { name: "uninstall", args: ["uninstall", "codex", "cursor"], usage: "Usage: codegraph uninstall" }, + ]; + + for (const testCase of cases) { + const result = await captureCli(testCase.args); + + expect(result.exitCode, testCase.name).toBe(2); + expect(result.stdout, testCase.name).toBe(""); + expect(result.stderr, testCase.name).toContain(testCase.usage); + expect(result.stderr, testCase.name).toContain("--detect"); + } + }); + + it("previews install changes without writing files", async () => { + const homeDir = await mkTmpDir("cg-install-dry-run-"); + const configPath = path.join(homeDir, ".codex", "config.toml"); + + const result = await installCodegraphTargets({ homeDir, targetIds: ["codex"], dryRun: true }); + + expect(result.dryRun).toBeTruthy(); + expect(result.changes.some((change) => change.path.endsWith(".codex/config.toml"))).toBeTruthy(); + await expect(fsp.stat(configPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("installs idempotently for TOML and JSON targets", async () => { + const homeDir = await mkTmpDir("cg-install-idempotent-"); + + const first = await installCodegraphTargets({ homeDir, targetIds: ["codex", "cursor"], yes: true }); + const second = await installCodegraphTargets({ homeDir, targetIds: ["codex", "cursor"], yes: true }); + + expect(first.installed).toBeTruthy(); + expect(second.changes.every((change) => change.action === "unchanged")).toBeTruthy(); + expect(await readFile(path.join(homeDir, ".codex", "config.toml"))).toContain("# >>> codegraph mcp >>>"); + const cursorConfig = JSON.parse(await readFile(path.join(homeDir, ".cursor", "mcp.json"))) as { + mcpServers?: { codegraph?: { command?: string } }; + }; + expect(cursorConfig.mcpServers?.codegraph?.command).toBe("codegraph"); + }); + + it("uses XDG_CONFIG_HOME for OpenCode config and installed marker on install and uninstall", async () => { + const homeDir = await mkTmpDir("cg-install-opencode-home-"); + const xdgConfigHome = await mkTmpDir("cg-install-opencode-xdg-"); + const env = { XDG_CONFIG_HOME: xdgConfigHome }; + const configPath = path.join(xdgConfigHome, "opencode", "opencode.json"); + const markerPath = path.join(xdgConfigHome, "opencode", "skills", "codegraph", "CODEGRAPH_INSTALLED"); + const homeMarkerPath = path.join(homeDir, ".config", "opencode", "skills", "codegraph", "CODEGRAPH_INSTALLED"); + await fsp.mkdir(path.dirname(configPath), { recursive: true }); + await fsp.writeFile( + configPath, + `${JSON.stringify({ mcp: { other: { type: "local", enabled: true, command: ["other-tool"] } } }, null, 2)}\n`, + "utf8", + ); + + await installCodegraphTargets({ homeDir, env, targetIds: ["opencode"], yes: true }); + + const installedConfig = JSON.parse(await readFile(configPath)) as { + mcp?: { codegraph?: { command?: string[] }; other?: { command?: string[] } }; + }; + expect(installedConfig.mcp?.codegraph?.command).toEqual(["codegraph", "mcp", "serve", "--root", ".", "--stdio"]); + expect(installedConfig.mcp?.other?.command).toEqual(["other-tool"]); + expect(await readFile(markerPath)).toContain("OpenCode"); + await expect(fsp.stat(homeMarkerPath)).rejects.toMatchObject({ code: "ENOENT" }); + + await uninstallCodegraphTargets({ homeDir, env, targetIds: ["opencode"], yes: true }); + + const remainingConfig = JSON.parse(await readFile(configPath)) as { + mcp?: { codegraph?: { command?: string[] }; other?: { command?: string[] } }; + }; + expect(remainingConfig.mcp?.other?.command).toEqual(["other-tool"]); + expect(remainingConfig.mcp?.codegraph).toBeUndefined(); + await expect(fsp.stat(markerPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fsp.stat(homeMarkerPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("treats a blank XDG_CONFIG_HOME as unset for OpenCode install and uninstall", async () => { + const homeDir = await mkTmpDir("cg-install-opencode-blank-xdg-"); + const env = { XDG_CONFIG_HOME: " " }; + const expectedConfigPath = path.join(homeDir, ".config", "opencode", "opencode.json"); + const previousCwd = process.cwd(); + const sandboxCwd = path.join(homeDir, "cwd"); + const relativeConfigDir = path.join(sandboxCwd, "opencode"); + + await fsp.mkdir(sandboxCwd, { recursive: true }); + process.chdir(sandboxCwd); + try { + await installCodegraphTargets({ homeDir, env, targetIds: ["opencode"], yes: true }); + + const installedConfig = JSON.parse(await readFile(expectedConfigPath)) as { + mcp?: { codegraph?: { command?: string[] } }; + }; + expect(installedConfig.mcp?.codegraph?.command).toEqual(["codegraph", "mcp", "serve", "--root", ".", "--stdio"]); + await expect(fsp.stat(path.join(relativeConfigDir, "opencode.json"))).rejects.toMatchObject({ code: "ENOENT" }); + + await uninstallCodegraphTargets({ homeDir, env, targetIds: ["opencode"], yes: true }); + + await expect(fsp.stat(expectedConfigPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fsp.stat(path.join(relativeConfigDir, "opencode.json"))).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + process.chdir(previousCwd); + } + }); + + it("dry-run uninstall reports separate skill payload and marker deletes without removing installer-owned files", async () => { + const homeDir = await mkTmpDir("cg-uninstall-skill-dry-run-"); + const skillDir = path.join(homeDir, ".agents", "skills", "codegraph"); + const installedSkillPath = path.join(skillDir, "SKILL.md"); + const markerPath = path.join(skillDir, "CODEGRAPH_INSTALLED"); + const bundledSkill = await readFile(BUNDLED_SKILL_PATH); + + await installCodegraphTargets({ homeDir, targetIds: ["agents"], yes: true }); + + const result = await uninstallCodegraphTargets({ homeDir, targetIds: ["agents"], dryRun: true }); + + expect(result.dryRun).toBe(true); + expectInstallerChange(result.changes, { + target: "agents", + action: "delete", + path: installedSkillPath, + dryRun: true, + }); + expectInstallerChange(result.changes, { + target: "agents", + action: "delete", + path: markerPath, + dryRun: true, + }); + expect(await readFile(installedSkillPath)).toBe(bundledSkill); + expect(await readFile(markerPath)).toContain("Agents skill directory"); + }); + + it("installs the bundled skill payload and removes marker-owned payload on uninstall", async () => { + const homeDir = await mkTmpDir("cg-install-skill-payload-"); + const skillDir = path.join(homeDir, ".agents", "skills", "codegraph"); + const installedSkillPath = path.join(skillDir, "SKILL.md"); + + await installCodegraphTargets({ homeDir, targetIds: ["agents"], yes: true }); + + expect(await readFile(installedSkillPath)).toBe(await readFile(BUNDLED_SKILL_PATH)); + expect(await readFile(path.join(skillDir, "CODEGRAPH_INSTALLED"))).toContain("Agents skill directory"); + + const result = await uninstallCodegraphTargets({ homeDir, targetIds: ["agents"], yes: true }); + + expectInstallerChange(result.changes, { + target: "agents", + action: "delete", + path: installedSkillPath, + dryRun: false, + }); + expectInstallerChange(result.changes, { + target: "agents", + action: "delete", + path: path.join(skillDir, "CODEGRAPH_INSTALLED"), + dryRun: false, + }); + await expect(fsp.stat(installedSkillPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fsp.stat(path.join(skillDir, "CODEGRAPH_INSTALLED"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("installs and removes the bundled skill payload for a Codex MCP target without deleting user files", async () => { + const homeDir = await mkTmpDir("cg-install-codex-skill-payload-"); + const skillDir = path.join(homeDir, ".codex", "skills", "codegraph"); + const installedSkillPath = path.join(skillDir, "SKILL.md"); + const markerPath = path.join(skillDir, "CODEGRAPH_INSTALLED"); + const userFilePath = path.join(skillDir, "notes.md"); + const userFile = "# Keep my Codex notes\n"; + + await installCodegraphTargets({ homeDir, targetIds: ["codex"], yes: true }); + await fsp.writeFile(userFilePath, userFile, "utf8"); + + expect(await readFile(installedSkillPath)).toBe(await readFile(BUNDLED_SKILL_PATH)); + expect(await readFile(markerPath)).toContain("Codex CLI"); + + const result = await uninstallCodegraphTargets({ homeDir, targetIds: ["codex"], yes: true }); + + expectInstallerChange(result.changes, { + target: "codex", + action: "delete", + path: installedSkillPath, + dryRun: false, + }); + expectInstallerChange(result.changes, { + target: "codex", + action: "delete", + path: markerPath, + dryRun: false, + }); + await expect(fsp.stat(installedSkillPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fsp.stat(markerPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(userFilePath)).toBe(userFile); + }); + + it("preserves user files under installer-owned skill directory on uninstall", async () => { + const homeDir = await mkTmpDir("cg-uninstall-skill-extra-file-"); + const skillDir = path.join(homeDir, ".agents", "skills", "codegraph"); + const installedSkillPath = path.join(skillDir, "SKILL.md"); + const markerPath = path.join(skillDir, "CODEGRAPH_INSTALLED"); + const extraDir = path.join(skillDir, "notes"); + const extraFilePath = path.join(extraDir, "README.md"); + const extraFile = "# User notes\n"; + + await installCodegraphTargets({ homeDir, targetIds: ["agents"], yes: true }); + await fsp.mkdir(extraDir, { recursive: true }); + await fsp.writeFile(extraFilePath, extraFile, "utf8"); + + await uninstallCodegraphTargets({ homeDir, targetIds: ["agents"], yes: true }); + + await expect(fsp.stat(installedSkillPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fsp.stat(markerPath)).rejects.toMatchObject({ code: "ENOENT" }); + expect(await readFile(extraFilePath)).toBe(extraFile); + }); + + it("preserves modified installer-owned SKILL.md on uninstall", async () => { + const homeDir = await mkTmpDir("cg-uninstall-skill-modified-"); + const skillDir = path.join(homeDir, ".agents", "skills", "codegraph"); + const installedSkillPath = path.join(skillDir, "SKILL.md"); + const markerPath = path.join(skillDir, "CODEGRAPH_INSTALLED"); + const modifiedSkill = `${await readFile(BUNDLED_SKILL_PATH)}\n# User customization\n`; + + await installCodegraphTargets({ homeDir, targetIds: ["agents"], yes: true }); + await fsp.writeFile(installedSkillPath, modifiedSkill, "utf8"); + + await uninstallCodegraphTargets({ homeDir, targetIds: ["agents"], yes: true }); + + expect(await readFile(installedSkillPath)).toBe(modifiedSkill); + await expect(fsp.stat(markerPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("preserves an unmarked user-owned skill payload on uninstall", async () => { + const homeDir = await mkTmpDir("cg-uninstall-user-skill-payload-"); + const skillDir = path.join(homeDir, ".agents", "skills", "codegraph"); + const installedSkillPath = path.join(skillDir, "SKILL.md"); + const userSkill = "# User maintained Codegraph skill\n"; + + await fsp.mkdir(skillDir, { recursive: true }); + await fsp.writeFile(installedSkillPath, userSkill, "utf8"); + + await uninstallCodegraphTargets({ homeDir, targetIds: ["agents"], yes: true }); + + expect(await readFile(installedSkillPath)).toBe(userSkill); + }); + + it("surfaces non-missing installer marker access errors during uninstall", async () => { + const homeDir = await mkTmpDir("cg-uninstall-marker-access-"); + const skillsPath = path.join(homeDir, ".cursor", "skills"); + + await fsp.mkdir(path.dirname(skillsPath), { recursive: true }); + await fsp.writeFile(skillsPath, "not a directory", "utf8"); + + await expect(uninstallCodegraphTargets({ homeDir, targetIds: ["cursor"], yes: true })).rejects.toThrow( + /CODEGRAPH_INSTALLED|marker|ENOTDIR|not a directory/i, + ); + }); + + it("uninstalls only Codegraph-owned config entries", async () => { + const homeDir = await mkTmpDir("cg-install-uninstall-"); + const cursorConfigPath = path.join(homeDir, ".cursor", "mcp.json"); + await fsp.mkdir(path.dirname(cursorConfigPath), { recursive: true }); + await fsp.writeFile( + cursorConfigPath, + `${JSON.stringify({ mcpServers: { other: { command: "other-tool" } } }, null, 2)}\n`, + "utf8", + ); + + await installCodegraphTargets({ homeDir, targetIds: ["cursor"], yes: true }); + await uninstallCodegraphTargets({ homeDir, targetIds: ["cursor"], yes: true }); + + const cursorConfig = JSON.parse(await readFile(cursorConfigPath)) as { + mcpServers?: { other?: { command?: string } }; + }; + expect(cursorConfig.mcpServers?.other?.command).toBe("other-tool"); + expect(JSON.stringify(cursorConfig)).not.toContain("codegraph"); + }); + + it.each(JSON_UNINSTALL_PRESERVE_CASES)( + "preserves user-owned $name on uninstall when only one dimension differs from the installer shape", + async ({ targetId, property, configPath, codegraphEntry }) => { + const homeDir = await mkTmpDir(`cg-install-uninstall-owned-shape-${targetId}-`); + const targetConfigPath = configPath(homeDir); + const otherEntry = { command: "other-tool" }; + + await fsp.mkdir(path.dirname(targetConfigPath), { recursive: true }); + await fsp.writeFile( + targetConfigPath, + `${JSON.stringify({ [property]: { codegraph: codegraphEntry, other: otherEntry } }, null, 2)}\n`, + "utf8", + ); + + await uninstallCodegraphTargets({ homeDir, targetIds: [targetId], yes: true }); + + const parsedConfig = JSON.parse(await readFile(targetConfigPath)) as { + mcpServers?: { codegraph?: typeof codegraphEntry; other?: typeof otherEntry }; + mcp?: { codegraph?: typeof codegraphEntry; other?: typeof otherEntry }; + }; + const servers = parsedConfig[property]; + expect(servers?.codegraph).toEqual(codegraphEntry); + expect(servers?.other).toEqual(otherEntry); + }, + ); + + it("preserves a pre-existing non-Codegraph-owned mcpServers.codegraph entry on JSON install", async () => { + const homeDir = await mkTmpDir("cg-install-json-owned-"); + const cursorConfigPath = path.join(homeDir, ".cursor", "mcp.json"); + const existingCodegraphEntry = { + type: "stdio", + command: "other-codegraph", + args: ["serve"], + env: { TOKEN: "keep" }, + }; + await fsp.mkdir(path.dirname(cursorConfigPath), { recursive: true }); + await fsp.writeFile( + cursorConfigPath, + `${JSON.stringify( + { mcpServers: { codegraph: existingCodegraphEntry, other: { command: "other-tool" } } }, + null, + 2, + )}\n`, + "utf8", + ); + + await installCodegraphTargets({ homeDir, targetIds: ["cursor"], yes: true }); + + const cursorConfig = JSON.parse(await readFile(cursorConfigPath)) as { + mcpServers?: { codegraph?: typeof existingCodegraphEntry; other?: { command?: string } }; + }; + expect(cursorConfig.mcpServers?.codegraph).toEqual(existingCodegraphEntry); + expect(cursorConfig.mcpServers?.other?.command).toBe("other-tool"); + }); + + it("deletes a JSON config file when uninstall removes its only Codegraph entry", async () => { + const homeDir = await mkTmpDir("cg-install-json-delete-"); + const cursorConfigPath = path.join(homeDir, ".cursor", "mcp.json"); + + await installCodegraphTargets({ homeDir, targetIds: ["cursor"], yes: true }); + const installedConfig = JSON.parse(await readFile(cursorConfigPath)) as { + mcpServers?: { codegraph?: { command?: string } }; + }; + expect(installedConfig.mcpServers?.codegraph?.command).toBe("codegraph"); + + await uninstallCodegraphTargets({ homeDir, targetIds: ["cursor"], yes: true }); + + await expect(fsp.stat(cursorConfigPath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("requires --yes for writes", async () => { + const homeDir = await mkTmpDir("cg-install-yes-"); + + await expect(installCodegraphTargets({ homeDir, targetIds: ["codex"] })).rejects.toThrow(/--yes/); + }); + + it("requires --yes for uninstall writes without an install-only message", async () => { + const homeDir = await mkTmpDir("cg-uninstall-yes-"); + let message = ""; + + try { + await uninstallCodegraphTargets({ homeDir, targetIds: ["codex"] }); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).toMatch(/--yes/); + expect(message).toMatch(/write/i); + expect(message).not.toMatch(/^Install writes require --yes/); + }); + + it("fails malformed existing JSON with an actionable error", async () => { + const homeDir = await mkTmpDir("cg-install-malformed-"); + const configPath = path.join(homeDir, ".cursor", "mcp.json"); + await fsp.mkdir(path.dirname(configPath), { recursive: true }); + await fsp.writeFile(configPath, "{ not json", "utf8"); + + await expect(installCodegraphTargets({ homeDir, targetIds: ["cursor"], yes: true })).rejects.toThrow( + /Unable to parse .*mcp\.json as JSON/, + ); + }); + + it("reports malformed JSON during uninstall without install-only remediation wording", async () => { + const homeDir = await mkTmpDir("cg-uninstall-malformed-"); + const configPath = path.join(homeDir, ".cursor", "mcp.json"); + await fsp.mkdir(path.dirname(configPath), { recursive: true }); + await fsp.writeFile(configPath, "{ not json", "utf8"); + + let message = ""; + try { + await uninstallCodegraphTargets({ homeDir, targetIds: ["cursor"], yes: true }); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).toMatch(/Unable to parse .*mcp\.json as JSON/); + expect(message).not.toMatch(/before running codegraph install\b/); + }); + + it("prints direct config snippets through the library helper", async () => { + expect(printInstallConfig({ targetId: "codex" })).toContain("startup_timeout_ms"); + expect(printInstallConfig({ targetId: "opencode" })).toContain('"type": "local"'); + }); +});