From 5d8fca49860d029963713468f360bcc155e3b36b Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Fri, 3 Jul 2026 05:12:48 -0400 Subject: [PATCH 01/11] Add agent installer workflow --- README.md | 15 +- codegraph-skill/codegraph/SKILL.md | 2 + docs/agent-workflows.md | 12 + docs/cli.md | 13 + docs/installation.md | 13 + docs/mcp.md | 12 + src/cli.ts | 15 + src/cli/help.ts | 28 ++ src/cli/install.ts | 61 ++++ src/cli/options.ts | 17 + src/installer/registry.ts | 487 +++++++++++++++++++++++++++++ tests/installer.test.ts | 108 +++++++ 12 files changed, 781 insertions(+), 2 deletions(-) create mode 100644 src/cli/install.ts create mode 100644 src/installer/registry.ts create mode 100644 tests/installer.test.ts 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 172457a6..1b8fadb4 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 a5ca5505..80c72108 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 2c2b2122..e4f8c67d 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 63d6ea1f..4ff209d6 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] @@ -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..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"'); + }); +}); From 5eac588e73f8197246b2d9687155856375b6721d Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Mon, 6 Jul 2026 17:07:09 -0400 Subject: [PATCH 02/11] Address installer review feedback --- src/cli/skill.ts | 3 +- src/installer/registry.ts | 63 +++++++++++++++----------- tests/installer.test.ts | 94 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 27 deletions(-) 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 index 334a2952..d31d3b9c 100644 --- a/src/installer/registry.ts +++ b/src/installer/registry.ts @@ -197,14 +197,14 @@ async function resolveRequestedTargets(options: InstallOptions): Promise 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))); + 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 { @@ -327,23 +327,27 @@ function renderConfigWithCodegraph(definition: TargetDefinition, existing: strin } 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"], - }, - }); + const mcp = readRecordProperty(parsed, "mcp"); + const desiredServer = { + type: "local", + enabled: true, + command: ["codegraph", "mcp", "serve", "--root", ".", "--stdio"], + }; + if (shouldPreserveExistingServer(mcp, desiredServer)) return existing ?? renderJsonConfig(parsed); + mcp.codegraph = desiredServer; + parsed.mcp = mcp; } else { - parsed.mcpServers = mergeRecord(readRecordProperty(parsed, "mcpServers"), { - codegraph: { - type: "stdio", - command: "codegraph", - args: ["mcp", "serve", "--root", ".", "--stdio"], - }, - }); + const mcpServers = readRecordProperty(parsed, "mcpServers"); + const desiredServer = { + type: "stdio", + command: "codegraph", + args: ["mcp", "serve", "--root", ".", "--stdio"], + }; + if (shouldPreserveExistingServer(mcpServers, desiredServer)) return existing ?? renderJsonConfig(parsed); + mcpServers.codegraph = desiredServer; + parsed.mcpServers = mcpServers; } - return `${JSON.stringify(parsed, null, 2)}\n`; + return renderJsonConfig(parsed); } function removeCodegraphConfig(definition: TargetDefinition, existing: string, configPath: string): string { @@ -361,7 +365,7 @@ function removeCodegraphConfig(definition: TargetDefinition, existing: string, c } else { delete parsed[property]; } - return `${JSON.stringify(parsed, null, 2)}\n`; + return renderJsonConfig(parsed); } function printTargetConfig(definition: TargetDefinition, options: PrintConfigOptions): string { @@ -418,8 +422,15 @@ function readRecordProperty(record: JsonRecord, property: string): JsonRecord { 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 shouldPreserveExistingServer(servers: JsonRecord, desiredServer: JsonRecord): boolean { + const existingServer = servers.codegraph; + if (existingServer === undefined) return false; + return JSON.stringify(existingServer) !== JSON.stringify(desiredServer); +} + +function renderJsonConfig(record: JsonRecord): string { + if (!Object.keys(record).length) return ""; + return `${JSON.stringify(record, null, 2)}\n`; } function isCodegraphJsonServer(value: unknown): boolean { diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 0e8db946..98986917 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -28,6 +28,21 @@ describe("agent installer workflow", () => { expect(gemini?.detected).toBeFalsy(); }); + 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"]); @@ -64,6 +79,41 @@ describe("agent installer workflow", () => { 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("uninstalls only Codegraph-owned config entries", async () => { const homeDir = await mkTmpDir("cg-install-uninstall-"); const cursorConfigPath = path.join(homeDir, ".cursor", "mcp.json"); @@ -84,6 +134,50 @@ describe("agent installer workflow", () => { expect(JSON.stringify(cursorConfig)).not.toContain("codegraph"); }); + 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-"); From 733a0f95504d627c0497e783a81a78e00a636389 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Mon, 6 Jul 2026 22:36:32 -0400 Subject: [PATCH 03/11] Stabilize OpenCode skill doctor test --- tests/cli-regressions.test.ts | 1 + 1 file changed, 1 insertion(+) 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 }); From f6bf7dd50570855683108e582c851fce0d615604 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Tue, 7 Jul 2026 00:40:53 -0400 Subject: [PATCH 04/11] Address installer ownership feedback --- README.md | 2 +- codegraph-skill/codegraph/SKILL.md | 2 +- docs/agent-workflows.md | 2 +- docs/cli.md | 2 +- docs/installation.md | 2 +- docs/mcp.md | 2 +- src/cli/help.ts | 6 +-- src/cli/options.ts | 4 +- src/installer/registry.ts | 79 +++++++++++++++++++--------- tests/cli-command-modules.test.ts | 11 ++++ tests/cli-options-validation.test.ts | 11 ++++ tests/installer.test.ts | 68 ++++++++++++++++++++++++ 12 files changed, 154 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 159ad057..0c3654ec 100644 --- a/README.md +++ b/README.md @@ -327,7 +327,7 @@ 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`. +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 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: diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index 1b8fadb4..c4907197 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -46,7 +46,7 @@ Then choose the smallest useful follow-up: 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`. +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 exact installer-owned MCP entries. ## Output Choice diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index ba2a1a37..2acb3e33 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -109,7 +109,7 @@ 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`. +Writes require `--yes`, and `--print-config ` prints the MCP snippet without touching disk. `uninstall` removes only Codegraph-owned marker blocks, marker files, or exact installer-owned MCP entries. ## MCP server diff --git a/docs/cli.md b/docs/cli.md index 80c72108..6a64b651 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -274,7 +274,7 @@ For SQL, prefer handles or schema-qualified names when basenames may be ambiguou - `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`. +- `uninstall` removes only Codegraph-owned marker blocks, marker files, or exact installer-owned MCP entries. - `skill install` remains the lower-level primitive for copying the bundled skill directly. #### MCP server diff --git a/docs/installation.md b/docs/installation.md index 89bd348f..a072fb9d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -96,7 +96,7 @@ 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`. +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 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 diff --git a/docs/mcp.md b/docs/mcp.md index e4f8c67d..c687976b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -73,7 +73,7 @@ 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. +The installer writes only Codegraph-owned marker blocks, marker files, or exact installer-owned MCP entries. `codegraph uninstall --target --yes` removes only those owned entries. ## Client Configuration Examples diff --git a/src/cli/help.ts b/src/cli/help.ts index 4ff209d6..c74af8ef 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -152,7 +152,7 @@ export function isKnownCliCommand(command: string): boolean { 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] +Usage: codegraph install [target] [--target ] [--yes | --dry-run] [--print-config ] [--detect] Targets: codex, claude, cursor, gemini, opencode, agents @@ -163,10 +163,10 @@ Safety: export const UNINSTALL_HELP_TEXT = `codegraph uninstall - Remove Codegraph-owned installer configuration -Usage: codegraph uninstall [target] [--target ] [--yes | --dry-run] [--detect] [--json] +Usage: codegraph uninstall [target] [--target ] [--yes | --dry-run] [--detect] Safety: - Removes only Codegraph-owned marker blocks, marker files, or MCP entries whose command is codegraph. + Removes only Codegraph-owned marker blocks, marker files, or exact installer-owned MCP entries. `; export const EXPLORE_HELP_TEXT = `codegraph explore - Answer a broad repo question with bounded repo context diff --git a/src/cli/options.ts b/src/cli/options.ts index 3fe037e9..b9d664af 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -363,7 +363,7 @@ const CLI_COMMAND_SCHEMAS = new Map([ ["index", graphCommandSchema({ kind: "any" })], [ "install", - commandSchema(["--detect", "--dry-run", "--json", "--yes"], ["--print-config", "--target"], { + commandSchema(["--detect", "--dry-run", "--yes"], ["--print-config", "--target"], { kind: "max", max: 1, usage: "Usage: codegraph install [target] [--target ] [--yes | --dry-run] [--print-config ]", @@ -371,7 +371,7 @@ const CLI_COMMAND_SCHEMAS = new Map([ ], [ "uninstall", - commandSchema(["--detect", "--dry-run", "--json", "--yes"], ["--target"], { + commandSchema(["--detect", "--dry-run", "--yes"], ["--target"], { kind: "max", max: 1, usage: "Usage: codegraph uninstall [target] [--target ] [--yes | --dry-run]", diff --git a/src/installer/registry.ts b/src/installer/registry.ts index d31d3b9c..0099ef69 100644 --- a/src/installer/registry.ts +++ b/src/installer/registry.ts @@ -104,8 +104,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [ id: "opencode", label: "OpenCode", kind: "json-opencode-mcp", - configPath: ({ env, homeDir }) => - path.join(env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config"), "opencode", "opencode.json"), + configPath: (settings) => path.join(opencodeConfigHome(settings), "opencode", "opencode.json"), }, { id: "agents", @@ -210,7 +209,7 @@ async function resolveRequestedTargets(options: InstallOptions): Promise 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 { diff --git a/tests/cli-command-modules.test.ts b/tests/cli-command-modules.test.ts index 26183655..e6494f9f 100644 --- a/tests/cli-command-modules.test.ts +++ b/tests/cli-command-modules.test.ts @@ -303,6 +303,17 @@ 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("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/installer.test.ts b/tests/installer.test.ts index 98986917..04d7a3e0 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -114,6 +114,29 @@ describe("agent installer workflow", () => { await expect(fsp.stat(homeMarkerPath)).rejects.toMatchObject({ code: "ENOENT" }); }); + it("treats a blank XDG_CONFIG_HOME as unset for OpenCode config writes", 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" }); + } finally { + process.chdir(previousCwd); + } + }); + it("uninstalls only Codegraph-owned config entries", async () => { const homeDir = await mkTmpDir("cg-install-uninstall-"); const cursorConfigPath = path.join(homeDir, ".cursor", "mcp.json"); @@ -134,6 +157,36 @@ describe("agent installer workflow", () => { expect(JSON.stringify(cursorConfig)).not.toContain("codegraph"); }); + it("preserves JSON codegraph entries on uninstall unless they exactly match the installer-owned shape", async () => { + const homeDir = await mkTmpDir("cg-install-uninstall-owned-shape-"); + const cursorConfigPath = path.join(homeDir, ".cursor", "mcp.json"); + const customCodegraphEntry = { + type: "stdio", + command: "codegraph", + args: ["mcp", "serve", "--root", "/custom-project", "--stdio"], + env: { CODEGRAPH_CONTEXT: "keep" }, + note: "user-owned", + }; + await fsp.mkdir(path.dirname(cursorConfigPath), { recursive: true }); + await fsp.writeFile( + cursorConfigPath, + `${JSON.stringify( + { mcpServers: { codegraph: customCodegraphEntry, other: { command: "other-tool" } } }, + null, + 2, + )}\n`, + "utf8", + ); + + await uninstallCodegraphTargets({ homeDir, targetIds: ["cursor"], yes: true }); + + const cursorConfig = JSON.parse(await readFile(cursorConfigPath)) as { + mcpServers?: { codegraph?: typeof customCodegraphEntry; other?: { command?: string } }; + }; + expect(cursorConfig.mcpServers?.codegraph).toEqual(customCodegraphEntry); + expect(cursorConfig.mcpServers?.other?.command).toBe("other-tool"); + }); + 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"); @@ -184,6 +237,21 @@ describe("agent installer workflow", () => { 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"); From efc1249cd251da7d5843bc3d3bd19a6bf84be272 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Tue, 7 Jul 2026 00:52:09 -0400 Subject: [PATCH 05/11] Resolve installer review blockers --- docs/cli.md | 2 +- src/cli/help.ts | 4 +- src/installer/registry.ts | 39 +++++- tests/cli-command-modules.test.ts | 12 ++ tests/installer.test.ts | 199 +++++++++++++++++++++++++----- 5 files changed, 217 insertions(+), 39 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 6a64b651..0ed92eb0 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -273,7 +273,7 @@ For SQL, prefer handles or schema-qualified names when basenames may be ambiguou #### 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. +- Writes require `--yes`; use `--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, or exact installer-owned MCP entries. - `skill install` remains the lower-level primitive for copying the bundled skill directly. diff --git a/src/cli/help.ts b/src/cli/help.ts index c74af8ef..52a34044 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -58,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 diff --git a/src/installer/registry.ts b/src/installer/registry.ts index 0099ef69..480c1ba5 100644 --- a/src/installer/registry.ts +++ b/src/installer/registry.ts @@ -2,7 +2,7 @@ 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"; +import { getCodegraphPackageRoot, normalizePathForDisplay, pathExists } from "../cli/packageInfo.js"; export type InstallTargetId = SkillInstallAgent; @@ -245,6 +245,7 @@ async function installTarget(definition: TargetDefinition, options: InstallOptio 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); @@ -266,6 +267,23 @@ async function uninstallTarget(definition: TargetDefinition, options: UninstallO return { uninstalled: true, dryRun, targets: [definition.id], changes }; } +async function upsertSkillPayload( + definition: TargetDefinition, + skillTargetDir: string, + dryRun: boolean, +): Promise { + const bundledSkillPath = path.join(getCodegraphPackageRoot(), "codegraph-skill", "codegraph", "SKILL.md"); + 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, @@ -288,9 +306,10 @@ async function removeSkillPointer( 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); + const markerExists = await pathExistsUnlessMissing(markerPath); + if (!markerExists) return change(definition.id, "unchanged", markerPath, dryRun); + if (!dryRun) await fsp.rm(skillTargetDir, { recursive: true, force: true }); + return change(definition.id, "delete", skillTargetDir, dryRun); } async function upsertConfig(definition: TargetDefinition, configPath: string, dryRun: boolean): Promise { @@ -397,7 +416,7 @@ function parseConfigJson(existing: string | null, configPath: string): JsonRecor if (isJsonRecord(parsed)) return parsed; } catch (error) { throw new Error( - `Unable to parse ${normalizePathForDisplay(configPath)} as JSON. Fix the file before running codegraph install.`, + `Unable to parse ${normalizePathForDisplay(configPath)} as JSON. Fix the file before running the installer.`, { cause: error, }, @@ -487,6 +506,16 @@ function requireConfigPath(definition: TargetDefinition, settings: InstallerSett return configPath; } +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}".`); diff --git a/tests/cli-command-modules.test.ts b/tests/cli-command-modules.test.ts index e6494f9f..beaf53f0 100644 --- a/tests/cli-command-modules.test.ts +++ b/tests/cli-command-modules.test.ts @@ -314,6 +314,18 @@ describe("CLI command modules", () => { } }); + test("top-level help does not show --json on installer command lines", async () => { + const result = await captureCli(["--help"]); + + expect(result.exitCode).toBeUndefined(); + expect(result.stderr).toBe(""); + 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/installer.test.ts b/tests/installer.test.ts index 04d7a3e0..7abb8463 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -14,6 +14,83 @@ 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"); + +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-"); @@ -114,9 +191,9 @@ describe("agent installer workflow", () => { await expect(fsp.stat(homeMarkerPath)).rejects.toMatchObject({ code: "ENOENT" }); }); - it("treats a blank XDG_CONFIG_HOME as unset for OpenCode config writes", async () => { + 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 env = { XDG_CONFIG_HOME: " " }; const expectedConfigPath = path.join(homeDir, ".config", "opencode", "opencode.json"); const previousCwd = process.cwd(); const sandboxCwd = path.join(homeDir, "cwd"); @@ -132,11 +209,58 @@ describe("agent installer workflow", () => { }; 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("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"); + + await uninstallCodegraphTargets({ homeDir, targetIds: ["agents"], yes: true }); + + await expect(fsp.stat(installedSkillPath)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fsp.stat(path.join(skillDir, "CODEGRAPH_INSTALLED"))).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"); @@ -157,35 +281,31 @@ describe("agent installer workflow", () => { expect(JSON.stringify(cursorConfig)).not.toContain("codegraph"); }); - it("preserves JSON codegraph entries on uninstall unless they exactly match the installer-owned shape", async () => { - const homeDir = await mkTmpDir("cg-install-uninstall-owned-shape-"); - const cursorConfigPath = path.join(homeDir, ".cursor", "mcp.json"); - const customCodegraphEntry = { - type: "stdio", - command: "codegraph", - args: ["mcp", "serve", "--root", "/custom-project", "--stdio"], - env: { CODEGRAPH_CONTEXT: "keep" }, - note: "user-owned", - }; - await fsp.mkdir(path.dirname(cursorConfigPath), { recursive: true }); - await fsp.writeFile( - cursorConfigPath, - `${JSON.stringify( - { mcpServers: { codegraph: customCodegraphEntry, other: { command: "other-tool" } } }, - null, - 2, - )}\n`, - "utf8", - ); - - await uninstallCodegraphTargets({ homeDir, targetIds: ["cursor"], yes: true }); - - const cursorConfig = JSON.parse(await readFile(cursorConfigPath)) as { - mcpServers?: { codegraph?: typeof customCodegraphEntry; other?: { command?: string } }; - }; - expect(cursorConfig.mcpServers?.codegraph).toEqual(customCodegraphEntry); - expect(cursorConfig.mcpServers?.other?.command).toBe("other-tool"); - }); + 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-"); @@ -263,6 +383,23 @@ describe("agent installer workflow", () => { ); }); + 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"'); From 59a96ad87dbb87850b576b54638d28b6f419ec1e Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Tue, 7 Jul 2026 01:01:38 -0400 Subject: [PATCH 06/11] Preserve installer skill customizations --- README.md | 4 ++-- codegraph-skill/codegraph/SKILL.md | 2 +- docs/agent-workflows.md | 2 +- docs/cli.md | 6 ++--- docs/installation.md | 4 ++-- docs/mcp.md | 2 +- src/cli/help.ts | 2 +- src/installer/registry.ts | 21 ++++++++++++++--- tests/cli-command-modules.test.ts | 17 ++++++++++++-- tests/installer.test.ts | 36 ++++++++++++++++++++++++++++++ 10 files changed, 80 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 0c3654ec..337ee94f 100644 --- a/README.md +++ b/README.md @@ -318,7 +318,7 @@ codegraph graph --root . ./src --dot --output graph.dot ## Agent setup -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: +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 @@ -327,7 +327,7 @@ 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 exact installer-owned MCP entries. +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: diff --git a/codegraph-skill/codegraph/SKILL.md b/codegraph-skill/codegraph/SKILL.md index c4907197..b4b06d6c 100644 --- a/codegraph-skill/codegraph/SKILL.md +++ b/codegraph-skill/codegraph/SKILL.md @@ -46,7 +46,7 @@ Then choose the smallest useful follow-up: 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 exact installer-owned MCP entries. +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 2acb3e33..05f7091b 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -109,7 +109,7 @@ 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 exact installer-owned MCP entries. +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 diff --git a/docs/cli.md b/docs/cli.md index 0ed92eb0..5ef59237 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -272,10 +272,10 @@ For SQL, prefer handles or schema-qualified names when basenames may be ambiguou #### Agent client installer -- `install` configures Codegraph-owned MCP entries and marker files for supported local agent clients: `codex`, `claude`, `cursor`, `gemini`, `opencode`, and `agents`. +- `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 `--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, or exact installer-owned MCP entries. -- `skill install` remains the lower-level primitive for copying the bundled skill directly. +- `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 diff --git a/docs/installation.md b/docs/installation.md index a072fb9d..2c31f6f6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -88,7 +88,7 @@ Reduced mode preserves graph-only and regex-backed recovery where available; it ## Agent client setup -After installing the CLI, `codegraph install` can configure Codegraph-owned MCP entries and skill marker files for supported agent clients: +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 @@ -96,7 +96,7 @@ 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 exact installer-owned MCP entries. +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 diff --git a/docs/mcp.md b/docs/mcp.md index c687976b..b11e92a0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -73,7 +73,7 @@ codegraph install --target codex,claude --yes codegraph install --print-config codex ``` -The installer writes only Codegraph-owned marker blocks, marker files, or exact installer-owned MCP entries. `codegraph uninstall --target --yes` removes only those owned entries. +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 diff --git a/src/cli/help.ts b/src/cli/help.ts index 52a34044..233f0613 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -166,7 +166,7 @@ export const UNINSTALL_HELP_TEXT = `codegraph uninstall - Remove Codegraph-owned Usage: codegraph uninstall [target] [--target ] [--yes | --dry-run] [--detect] Safety: - Removes only Codegraph-owned marker blocks, marker files, or exact installer-owned MCP entries. + 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 diff --git a/src/installer/registry.ts b/src/installer/registry.ts index 480c1ba5..01cd10e1 100644 --- a/src/installer/registry.ts +++ b/src/installer/registry.ts @@ -272,7 +272,7 @@ async function upsertSkillPayload( skillTargetDir: string, dryRun: boolean, ): Promise { - const bundledSkillPath = path.join(getCodegraphPackageRoot(), "codegraph-skill", "codegraph", "SKILL.md"); + const bundledSkillPath = bundledSkillFilePath(); const targetSkillPath = path.join(skillTargetDir, "SKILL.md"); const bundledSkill = await fsp.readFile(bundledSkillPath, "utf8"); const existing = await readOptionalFile(targetSkillPath); @@ -308,8 +308,11 @@ async function removeSkillPointer( 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(skillTargetDir, { recursive: true, force: true }); - return change(definition.id, "delete", skillTargetDir, dryRun); + if (!dryRun) { + await removeBundledSkillPayload(skillTargetDir); + await fsp.rm(markerPath, { force: true }); + } + return change(definition.id, "delete", markerPath, dryRun); } async function upsertConfig(definition: TargetDefinition, configPath: string, dryRun: boolean): Promise { @@ -506,6 +509,18 @@ function requireConfigPath(definition: TargetDefinition, settings: InstallerSett return configPath; } +function bundledSkillFilePath(): string { + return path.join(getCodegraphPackageRoot(), "codegraph-skill", "codegraph", "SKILL.md"); +} + +async function removeBundledSkillPayload(skillTargetDir: string): Promise { + const targetSkillPath = path.join(skillTargetDir, "SKILL.md"); + const existing = await readOptionalFile(targetSkillPath); + if (existing === null) return; + const bundledSkill = await fsp.readFile(bundledSkillFilePath(), "utf8"); + if (existing === bundledSkill) await fsp.rm(targetSkillPath, { force: true }); +} + async function pathExistsUnlessMissing(filePath: string): Promise { try { await fsp.stat(filePath); diff --git a/tests/cli-command-modules.test.ts b/tests/cli-command-modules.test.ts index beaf53f0..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"); @@ -314,11 +314,24 @@ describe("CLI command modules", () => { } }); - test("top-level help does not show --json on installer command lines", async () => { + 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)); diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 7abb8463..9840e7b0 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -235,6 +235,42 @@ describe("agent installer workflow", () => { await expect(fsp.stat(path.join(skillDir, "CODEGRAPH_INSTALLED"))).rejects.toMatchObject({ code: "ENOENT" }); }); + 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"); From 3697365fe3ea213e7f4f8894b59a1d36f43d4446 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Tue, 7 Jul 2026 01:11:46 -0400 Subject: [PATCH 07/11] Report installer skill payload removals --- src/installer/registry.ts | 31 +++++++++------ tests/installer.test.ts | 82 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 99 insertions(+), 14 deletions(-) diff --git a/src/installer/registry.ts b/src/installer/registry.ts index 01cd10e1..938031fa 100644 --- a/src/installer/registry.ts +++ b/src/installer/registry.ts @@ -259,6 +259,7 @@ async function uninstallTarget(definition: TargetDefinition, options: UninstallO 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); @@ -300,6 +301,23 @@ async function upsertSkillPointer( 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, @@ -308,10 +326,7 @@ async function removeSkillPointer( const markerPath = path.join(skillTargetDir, "CODEGRAPH_INSTALLED"); const markerExists = await pathExistsUnlessMissing(markerPath); if (!markerExists) return change(definition.id, "unchanged", markerPath, dryRun); - if (!dryRun) { - await removeBundledSkillPayload(skillTargetDir); - await fsp.rm(markerPath, { force: true }); - } + if (!dryRun) await fsp.rm(markerPath, { force: true }); return change(definition.id, "delete", markerPath, dryRun); } @@ -513,14 +528,6 @@ function bundledSkillFilePath(): string { return path.join(getCodegraphPackageRoot(), "codegraph-skill", "codegraph", "SKILL.md"); } -async function removeBundledSkillPayload(skillTargetDir: string): Promise { - const targetSkillPath = path.join(skillTargetDir, "SKILL.md"); - const existing = await readOptionalFile(targetSkillPath); - if (existing === null) return; - const bundledSkill = await fsp.readFile(bundledSkillFilePath(), "utf8"); - if (existing === bundledSkill) await fsp.rm(targetSkillPath, { force: true }); -} - async function pathExistsUnlessMissing(filePath: string): Promise { try { await fsp.stat(filePath); diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 9840e7b0..22c15c52 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -6,6 +6,7 @@ import { installCodegraphTargets, printInstallConfig, uninstallCodegraphTargets, + type InstallChange, } from "../src/installer/registry.js"; import { captureCli } from "./helpers/cli.js"; import { mkTmpDir } from "./helpers/filesystem.js"; @@ -16,6 +17,10 @@ async function readFile(filePath: string): Promise { const BUNDLED_SKILL_PATH = path.join(process.cwd(), "codegraph-skill", "codegraph", "SKILL.md"); +function expectInstallerChange(changes: InstallChange[], expected: InstallChange): void { + expect(changes).toContainEqual(expected); +} + const CURSOR_INSTALLER_SERVER = { type: "stdio", command: "codegraph", @@ -219,6 +224,34 @@ describe("agent installer workflow", () => { } }); + 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"); @@ -229,12 +262,57 @@ describe("agent installer workflow", () => { expect(await readFile(installedSkillPath)).toBe(await readFile(BUNDLED_SKILL_PATH)); expect(await readFile(path.join(skillDir, "CODEGRAPH_INSTALLED"))).toContain("Agents skill directory"); - await uninstallCodegraphTargets({ homeDir, targetIds: ["agents"], yes: true }); - + 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"); From 6fa7ec4d9bb65bc840528c4d05805956e9a139a5 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Tue, 7 Jul 2026 11:29:42 -0400 Subject: [PATCH 08/11] Validate installer print-config conflicts --- src/cli/install.ts | 58 +++++++++++++++++++++++++++++++++++------ tests/installer.test.ts | 56 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/src/cli/install.ts b/src/cli/install.ts index 4a6fe26f..f01d65d5 100644 --- a/src/cli/install.ts +++ b/src/cli/install.ts @@ -21,6 +21,14 @@ export type InstallerCommandContext = { 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 } : {}), @@ -28,12 +36,6 @@ export async function handleInstallerCommand(context: InstallerCommandContext): 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; @@ -55,7 +57,47 @@ function parseInstallerTargets(context: InstallerCommandContext): InstallTargetI context.exit(2); } if (targetOpt !== undefined && positionalTarget !== undefined) { - throw new Error("Use either --target or a positional target, not both."); + failUsage(context, "Use either --target or a positional target, not both."); } - return parseInstallTargetIds(targetOpt ?? positionalTarget); + 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/tests/installer.test.ts b/tests/installer.test.ts index 22c15c52..6b22bcaa 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -135,6 +135,62 @@ describe("agent installer workflow", () => { expect(result.stdout).toContain('["mcp", "serve", "--root", ".", "--stdio"]'); }); + 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("previews install changes without writing files", async () => { const homeDir = await mkTmpDir("cg-install-dry-run-"); const configPath = path.join(homeDir, ".codex", "config.toml"); From 932b406f833e09f1ed17cca677946e50dd530173 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 8 Jul 2026 00:00:04 -0400 Subject: [PATCH 09/11] Polish installer diagnostics and tests --- src/installer/registry.ts | 2 +- tests/installer.test.ts | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/installer/registry.ts b/src/installer/registry.ts index 938031fa..110f792a 100644 --- a/src/installer/registry.ts +++ b/src/installer/registry.ts @@ -447,7 +447,7 @@ 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.`); + throw new Error(`Existing ${property} config must be a JSON object before codegraph can update it.`); } function renderJsonConfig(record: JsonRecord): string { diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 6b22bcaa..719a40b6 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -18,7 +18,11 @@ async function readFile(filePath: string): Promise { const BUNDLED_SKILL_PATH = path.join(process.cwd(), "codegraph-skill", "codegraph", "SKILL.md"); function expectInstallerChange(changes: InstallChange[], expected: InstallChange): void { - expect(changes).toContainEqual(expected); + expect(changes).toContainEqual({ ...expected, path: normalizeExpectedPath(expected.path) }); +} + +function normalizeExpectedPath(filePath: string): string { + return filePath.split(path.sep).join("/"); } const CURSOR_INSTALLER_SERVER = { From 20cc49e3610121b9cbb9029d5d458b4060a9dada Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 8 Jul 2026 01:54:10 -0400 Subject: [PATCH 10/11] Fix installer agents feedback --- docs/cli.md | 2 +- src/cli/options.ts | 5 +++-- src/installer/registry.ts | 7 ++++--- tests/installer.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 5ef59237..bae47238 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -273,7 +273,7 @@ For SQL, prefer handles or schema-qualified names when basenames may be ambiguou #### 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 `--dry-run` to preview changed file paths and actions or `--print-config ` to print a copyable MCP snippet without writing. +- 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. diff --git a/src/cli/options.ts b/src/cli/options.ts index b9d664af..66de7001 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -366,7 +366,8 @@ const CLI_COMMAND_SCHEMAS = new Map([ commandSchema(["--detect", "--dry-run", "--yes"], ["--print-config", "--target"], { kind: "max", max: 1, - usage: "Usage: codegraph install [target] [--target ] [--yes | --dry-run] [--print-config ]", + usage: + "Usage: codegraph install [target] [--target ] [--detect] [--yes | --dry-run] [--print-config ]", }), ], [ @@ -374,7 +375,7 @@ const CLI_COMMAND_SCHEMAS = new Map([ commandSchema(["--detect", "--dry-run", "--yes"], ["--target"], { kind: "max", max: 1, - usage: "Usage: codegraph uninstall [target] [--target ] [--yes | --dry-run]", + usage: "Usage: codegraph uninstall [target] [--target ] [--detect] [--yes | --dry-run]", }), ], [ diff --git a/src/installer/registry.ts b/src/installer/registry.ts index 110f792a..a4ef7fff 100644 --- a/src/installer/registry.ts +++ b/src/installer/registry.ts @@ -232,9 +232,10 @@ function detectTarget(definition: TargetDefinition, options: InstallOptions): Ta skillTargetDir: normalizePathForDisplay(skillTargetDir), }; } + const baseSkillDirExists = definition.kind === "skill-only" && pathExists(path.dirname(path.dirname(skillTargetDir))); return { - detected: definition.kind === "skill-only" && pathExists(path.dirname(path.dirname(skillTargetDir))), - reason: `${definition.label} was not detected`, + detected: baseSkillDirExists, + reason: baseSkillDirExists ? `${definition.label} base directory exists` : `${definition.label} was not detected`, ...(configPath !== undefined ? { configPath: normalizePathForDisplay(configPath) } : {}), skillTargetDir: normalizePathForDisplay(skillTargetDir), }; @@ -404,7 +405,7 @@ function printTargetConfig(definition: TargetDefinition, options: PrintConfigOpt return renderJsonConfig({ mcp: { codegraph: codegraphJsonServer(definition) } }); } if (definition.kind === "skill-only") { - return `codegraph skill install --agent ${definition.id} --target ${normalizePathForDisplay(skillTargetDir)}\n`; + return `codegraph skill install --agent ${definition.id}\n`; } return renderJsonConfig({ mcpServers: { codegraph: codegraphJsonServer(definition) } }); } diff --git a/tests/installer.test.ts b/tests/installer.test.ts index 719a40b6..a9f45667 100644 --- a/tests/installer.test.ts +++ b/tests/installer.test.ts @@ -114,6 +114,19 @@ describe("agent installer workflow", () => { 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 }); @@ -139,6 +152,16 @@ describe("agent installer workflow", () => { 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 = [ { @@ -195,6 +218,22 @@ describe("agent installer workflow", () => { } }); + 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"); From 9b931535864fc68dfc79db8b72a3c4b991b04995 Mon Sep 17 00:00:00 2001 From: Luke Zehrung Date: Wed, 8 Jul 2026 02:27:34 -0400 Subject: [PATCH 11/11] Clean up installer print-config and mcp help placement --- docs/cli.md | 3 ++- src/installer/registry.ts | 4 +--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index bae47238..04a4151a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -157,13 +157,14 @@ 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 +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 mcp --help +codegraph install --help # Chunk a file for LLM processing codegraph chunk src/utils.js diff --git a/src/installer/registry.ts b/src/installer/registry.ts index a4ef7fff..1903043d 100644 --- a/src/installer/registry.ts +++ b/src/installer/registry.ts @@ -397,9 +397,7 @@ function removeCodegraphConfig(definition: TargetDefinition, existing: string, c return renderJsonConfig(parsed); } -function printTargetConfig(definition: TargetDefinition, options: PrintConfigOptions): string { - const settings = installerSettings(options); - const skillTargetDir = getSkillTargetDirForAgent(definition.id, settings.homeDir, settings.env); +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) } });