diff --git a/README.md b/README.md index 6eb01d56..159ad057 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 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, or MCP entries whose command is `codegraph`. + +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 f3b83c86..06f0e4ad 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. Use `--dry-run` or `--print-config ` first; uninstall removes only Codegraph-owned marker blocks, marker files, or MCP entries whose command is `codegraph`. ## Output Choice diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 1f02a8cb..ba2a1a37 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, or MCP entries whose command is `codegraph`. + ## 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 df74afa4..9606c6c6 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -157,6 +157,12 @@ codegraph mcp serve --root . --stdio --allow-build codegraph mcp serve --root . --port 7331 codegraph mcp serve --root . --stdio --warmup codegraph mcp serve --root . --port 7331 --warmup-symbols + +# 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 mcp --help # Chunk a file for LLM processing @@ -264,6 +270,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 and marker files for supported local agent clients: `codex`, `claude`, `cursor`, `gemini`, `opencode`, and `agents`. +- Writes require `--yes`; use `--dry-run` to preview exact file changes or `--print-config ` to print a copyable MCP snippet without writing. +- `uninstall` removes only Codegraph-owned marker blocks, marker files, or MCP entries whose command is `codegraph`. +- `skill install` remains the lower-level primitive for copying the bundled skill directly. + #### 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..89bd348f 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 and skill 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, or MCP entries whose command is `codegraph`. +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 c78bf193..65936119 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, or `codegraph` 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 1380c03c..a1ca6122 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 @@ -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] [--json] + +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] [--json] + +Safety: + Removes only Codegraph-owned marker blocks, marker files, or MCP entries whose command is codegraph. +`; + 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] @@ -360,6 +386,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..4a6fe26f --- /dev/null +++ b/src/cli/install.ts @@ -0,0 +1,61 @@ +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"); + const targetIds = parseInstallerTargets(context); + const options = { + ...(targetIds !== undefined ? { targetIds } : {}), + yes: context.hasFlag("--yes"), + dryRun: context.hasFlag("--dry-run"), + }; + + if (printConfigTarget !== undefined) { + const targetId = parseInstallTargetId(printConfigTarget); + context.writeStdoutLine(printInstallConfig({ targetId }).trimEnd()); + return; + } + + 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) { + throw new Error("Use either --target or a positional target, not both."); + } + return parseInstallTargetIds(targetOpt ?? positionalTarget); +} diff --git a/src/cli/options.ts b/src/cli/options.ts index 8730c706..3fe037e9 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,22 @@ const CLI_COMMAND_SCHEMAS = new Map([ ), ], ["index", graphCommandSchema({ kind: "any" })], + [ + "install", + commandSchema(["--detect", "--dry-run", "--json", "--yes"], ["--print-config", "--target"], { + kind: "max", + max: 1, + usage: "Usage: codegraph install [target] [--target ] [--yes | --dry-run] [--print-config ]", + }), + ], + [ + "uninstall", + commandSchema(["--detect", "--dry-run", "--json", "--yes"], ["--target"], { + kind: "max", + max: 1, + usage: "Usage: codegraph uninstall [target] [--target ] [--yes | --dry-run]", + }), + ], [ "inspect", commandSchema([...SHARED_BUILD_FLAGS, ...JSON_OUTPUT_FLAGS], [...SHARED_BUILD_OPTIONS, "--limit"], { kind: "any" }), diff --git a/src/installer/registry.ts b/src/installer/registry.ts new file mode 100644 index 00000000..334a2952 --- /dev/null +++ b/src/installer/registry.ts @@ -0,0 +1,487 @@ +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 { 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: ({ env, homeDir }) => + path.join(env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config"), "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 detections = await detectInstallTargets(options); + const detectedIds = detections + .filter((detection) => detection.detected) + .map((detection, index) => { + const definition = TARGET_DEFINITIONS[index]!; + return definition.id; + }); + return detectedIds.map((id) => createInstallTarget(definitionForTarget(id))); +} + +function assertWriteAllowed(options: InstallOptions): void { + if (options.dryRun) return; + if (options.yes) return; + throw new Error("Install writes require --yes. Use --dry-run or --print-config 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), + }; + } + return { + detected: definition.kind === "skill-only" && pathExists(path.dirname(path.dirname(skillTargetDir))), + reason: `${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 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 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 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 removeSkillPointer( + definition: TargetDefinition, + skillTargetDir: string, + dryRun: boolean, +): Promise { + const markerPath = path.join(skillTargetDir, "CODEGRAPH_INSTALLED"); + if (!pathExists(markerPath)) 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") { + parsed.mcp = mergeRecord(readRecordProperty(parsed, "mcp"), { + codegraph: { + type: "local", + enabled: true, + command: ["codegraph", "mcp", "serve", "--root", ".", "--stdio"], + }, + }); + } else { + parsed.mcpServers = mergeRecord(readRecordProperty(parsed, "mcpServers"), { + codegraph: { + type: "stdio", + command: "codegraph", + args: ["mcp", "serve", "--root", ".", "--stdio"], + }, + }); + } + return `${JSON.stringify(parsed, null, 2)}\n`; +} + +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 (!isCodegraphJsonServer(server)) return existing; + delete servers.codegraph; + if (Object.keys(servers).length) { + parsed[property] = servers; + } else { + delete parsed[property]; + } + return `${JSON.stringify(parsed, null, 2)}\n`; +} + +function printTargetConfig(definition: TargetDefinition, options: PrintConfigOptions): string { + const settings = installerSettings(options); + const skillTargetDir = getSkillTargetDirForAgent(definition.id, settings.homeDir, settings.env); + if (definition.kind === "toml-block") return codexTomlSnippet(); + if (definition.kind === "json-opencode-mcp") { + return `${JSON.stringify({ mcp: { codegraph: { type: "local", enabled: true, command: ["codegraph", "mcp", "serve", "--root", ".", "--stdio"] } } }, null, 2)}\n`; + } + if (definition.kind === "skill-only") { + return `codegraph skill install --agent ${definition.id} --target ${normalizePathForDisplay(skillTargetDir)}\n`; + } + return `${JSON.stringify({ mcpServers: { codegraph: { type: "stdio", command: "codegraph", args: ["mcp", "serve", "--root", ".", "--stdio"] } } }, null, 2)}\n`; +} + +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 codegraph install.`, + { + 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 merge into it.`); +} + +function mergeRecord(left: JsonRecord, right: JsonRecord): JsonRecord { + return { ...left, ...right }; +} + +function isCodegraphJsonServer(value: unknown): boolean { + if (!isJsonRecord(value)) return false; + const command = value.command; + if (command === "codegraph") return true; + if (Array.isArray(command) && command[0] === "codegraph") return true; + return false; +} + +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 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/installer.test.ts b/tests/installer.test.ts new file mode 100644 index 00000000..0e8db946 --- /dev/null +++ b/tests/installer.test.ts @@ -0,0 +1,108 @@ +import fsp from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { + detectInstallTargets, + installCodegraphTargets, + printInstallConfig, + uninstallCodegraphTargets, +} 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"); +} + +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("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("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("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("requires --yes for writes", async () => { + const homeDir = await mkTmpDir("cg-install-yes-"); + + await expect(installCodegraphTargets({ homeDir, targetIds: ["codex"] })).rejects.toThrow(/--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("prints direct config snippets through the library helper", async () => { + expect(printInstallConfig({ targetId: "codex" })).toContain("startup_timeout_ms"); + expect(printInstallConfig({ targetId: "opencode" })).toContain('"type": "local"'); + }); +});