diff --git a/.gitignore b/.gitignore index 4efcff69a..33d5dc558 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,6 @@ assets/generate-waitlist.py # Local scratch tests (never commit) __tests__/zz-scratch* + +.history/ +.qoder/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 22fb4c5fc..56bd7293e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- `codegraph install` now detects and configures **Qoder**. `--location=global` writes the MCP entry to Qoder's shared `mcp.json` under `~/Library/Application Support/Qoder/SharedClientCache/` on macOS (macOS verified; the corresponding Electron config dir is used on Linux and Windows by convention), sibling MCP servers are left intact, and re-runs are idempotent. `--location=local` is a superset: it writes the same shared MCP entry **plus** a project-scoped rule file at `.qoder/rules/codegraph.md` with `trigger: always_on` frontmatter — Qoder auto-loads `.qoder/rules/*.md` as Always Apply rules (higher precedence than `AGENTS.md`), and since Qoder's client consumes the MCP `initialize.instructions` field weakly (tools list but the agent does not proactively reach for `codegraph_explore`), the rule file is the surface that actually steers the agent. The rule body is a Qoder-specific conditional playbook (not the minimal shared block other targets use) with explicit triage, staleness handling, and a PATH-aware shell fallback. Upgrading from an earlier build that wrote a marker-fenced section into `AGENTS.md` is handled automatically: both install and uninstall strip any legacy CodeGraph fence from `AGENTS.md` (self-heal). Two Qoder-specific MCP quirks are handled at install time: (a) Qoder does not expand `${workspaceFolder}` in MCP args in older builds, so codegraph is installed without a `--path` argument and instead relies on Qoder's MCP `roots/list` handshake to discover the workspace — if your Qoder build does not send roots, open the project from Qoder itself (not via `cd` in a terminal) so the client emits it; (b) Qoder launches from Dock/launchd on macOS with a stripped PATH, so the `command` is resolved to the absolute `codegraph` path from your login shell — the same trick already used for Antigravity IDE. Uninstall on `local` unlinks the rule file and strips any legacy `AGENTS.md` fence but leaves the shared MCP entry intact (another project may depend on it); `--location=global` uninstall removes the MCP entry itself. + ### Fixes - Callers and impact analysis no longer silently under-count a function that calls the same callee many times. When one caller contained several call sites to the same callee and an internal resolution batch boundary happened to split them, cleanup after the first batch removed the later sites' pending rows before they were ever attempted — their edges were never created, deterministically, and which edges went missing shifted with unrelated changes to the project's total reference count. Post-pass cleanup now targets the exact database row each processed reference came from. Found while validating the operator-call fix on nlohmann/json, where `write_cbor`'s 11 calls to `to_char_type` indexed as 10. (#1269) diff --git a/README.md b/README.md index 73e00aa55..e55ce82b1 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Already installed? Run `codegraph upgrade` Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. -### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, and Kiro with Semantic Code Intelligence +### Supercharge Claude Code, Cursor, Codex, OpenCode, Hermes Agent, Gemini, Antigravity, Kiro, and Qoder with Semantic Code Intelligence **Surgical context · fewer tool calls · faster answers · 100% local** @@ -30,6 +30,7 @@ Follow [@getcodegraph](https://x.com/getcodegraph) on X for updates. [![Gemini](https://img.shields.io/badge/Gemini-supported-blueviolet.svg)](#supported-agents) [![Antigravity](https://img.shields.io/badge/Antigravity-supported-blueviolet.svg)](#supported-agents) [![Kiro](https://img.shields.io/badge/Kiro-supported-blueviolet.svg)](#supported-agents) +[![Qoder](https://img.shields.io/badge/Qoder-supported-blueviolet.svg)](#supported-agents)
@@ -99,7 +100,7 @@ In a **new terminal**, run the installer to connect CodeGraph to the agents you codegraph install ``` -Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.) +Detects and auto-configures Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and Qoder — wiring the CodeGraph MCP server into each. **This is the step that connects CodeGraph to your agent;** installing the CLI in step 1 does not do it on its own. It only wires up your agent — it does **not** index any code; building each project's graph is the separate `codegraph init` in step 3. (Shortcut: `npx @colbymchenry/codegraph` downloads and runs this in one go.) ### 3. Initialize each project @@ -412,7 +413,7 @@ npx @colbymchenry/codegraph ``` The installer will: -- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro** +- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**, **Gemini CLI**, **Antigravity IDE**, **Kiro**, **Qoder** - Prompt to install `codegraph` on your PATH (so agents can launch the MCP server) - Ask whether configs apply to all your projects or just this one - Write each chosen agent's MCP server config, plus a small marker-fenced CodeGraph section in the agent's instructions file (`CLAUDE.md` / `AGENTS.md` / `GEMINI.md`) — that's how subagents and non-MCP agents learn the `codegraph explore` command, since the MCP server's own guidance only reaches the main agent. Removed cleanly by `codegraph uninstall`. @@ -439,7 +440,7 @@ codegraph install --print-config codex # print snippet, no file wr ### 2. Restart Your Agent -Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro) for the MCP server to load. +Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent / Gemini CLI / Antigravity IDE / Kiro / Qoder) for the MCP server to load. ### 3. Initialize Projects @@ -770,6 +771,7 @@ is written): - **Gemini CLI** - **Antigravity IDE** - **Kiro** +- **Qoder** ## Supported Languages @@ -878,7 +880,7 @@ MIT
-**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, and Kiro** +**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, and Qoder** [Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues) diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index e11efd3cc..7d2894b77 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -699,6 +699,212 @@ describe('Installer targets — partial-state idempotency', () => { expect(cliAfter.mcpServers.codegraph).toBeDefined(); }); + it('qoder: install writes SharedClientCache/mcp.json under the platform config dir', () => { + const qoder = getTarget('qoder')!; + const result = qoder.install('global', { autoAllow: true }); + const file = result.files[0]!.path; + // Path lives under the platform-appropriate Qoder config dir. + expect(file.endsWith(path.join('Qoder', 'SharedClientCache', 'mcp.json'))).toBe(true); + expect(fs.existsSync(file)).toBe(true); + const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(cfg.mcpServers.codegraph).toBeDefined(); + expect(cfg.mcpServers.codegraph.type).toBe('stdio'); + expect(cfg.mcpServers.codegraph.args).toEqual(['serve', '--mcp']); + // command must be a non-empty string. On macOS it may resolve to an + // absolute path via `command -v codegraph`; elsewhere the bare name + // is fine. Either way, it must end in `codegraph` — an empty or + // truncated string here would spawn ENOENT inside Qoder. + expect(typeof cfg.mcpServers.codegraph.command).toBe('string'); + expect(cfg.mcpServers.codegraph.command.length).toBeGreaterThan(0); + expect(cfg.mcpServers.codegraph.command).toMatch(/codegraph(\.(cmd|exe|bat))?$/); + }); + + it('qoder: entry never carries --path or ${workspaceFolder} (Qoder does not substitute vars)', () => { + // Regression guard: Qoder passes MCP args to the child verbatim, so a + // literal `${workspaceFolder}` reaches codegraph as a bogus path and + // (pre-#964 codegraph) yields an empty tools/list. We install with no + // --path at all and rely on the MCP roots/list handshake instead. + const qoder = getTarget('qoder')!; + qoder.install('global', { autoAllow: true }); + const cfg = JSON.parse(fs.readFileSync(qoder.describePaths('global')[0]!, 'utf-8')); + const args: string[] = cfg.mcpServers.codegraph.args; + expect(args).not.toContain('--path'); + expect(args.some((a: string) => a.includes('${workspaceFolder}'))).toBe(false); + }); + + it('qoder: uninstall strips codegraph but leaves sibling MCP servers intact', () => { + const qoder = getTarget('qoder')!; + const file = qoder.describePaths('global')[0]!; + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, JSON.stringify({ + mcpServers: { 'forge-gateway': { url: 'http://example.test/mcp' } }, + }, null, 2) + '\n'); + + qoder.install('global', { autoAllow: true }); + qoder.uninstall('global'); + + const cfg = JSON.parse(fs.readFileSync(file, 'utf-8')); + expect(cfg.mcpServers['forge-gateway']).toBeDefined(); + expect(cfg.mcpServers.codegraph).toBeUndefined(); + }); + + it('qoder: --location=local writes both the project .qoder/rules/codegraph.md AND the shared MCP config (one command sets up this project, matching opencode/gemini local semantics)', () => { + const qoder = getTarget('qoder')!; + expect(qoder.supportsLocation('local')).toBe(true); + + const result = qoder.install('local', { autoAllow: true }); + // Two files touched: project-scoped `.qoder/rules/codegraph.md` + // (Qoder's own-whole-file rule surface, higher precedence than + // AGENTS.md) + shared user-scope mcp.json (Qoder has no + // project-local mcp.json path). + const ruleEntry = result.files.find((f) => f.path.endsWith(path.join('.qoder', 'rules', 'codegraph.md'))); + const mcpEntry = result.files.find((f) => f.path.endsWith('mcp.json')); + expect(ruleEntry).toBeDefined(); + expect(mcpEntry).toBeDefined(); + // process.cwd() returns the realpath (macOS /var → /private/var), so + // resolve tmpCwd through realpath before comparing. + expect(ruleEntry!.path).toBe(path.join(fs.realpathSync(tmpCwd), '.qoder', 'rules', 'codegraph.md')); + expect(fs.existsSync(ruleEntry!.path)).toBe(true); + expect(fs.existsSync(mcpEntry!.path)).toBe(true); + + // Rule file is own-whole-file (NO marker fence) and mentions codegraph. + const body = fs.readFileSync(ruleEntry!.path, 'utf-8'); + expect(body).not.toContain(''); + expect(body).not.toContain(''); + expect(body.toLowerCase()).toContain('codegraph'); + // Qoder-specific frontmatter: `trigger: always_on` makes this an + // Always Apply rule (in-context every turn). Without it Qoder + // classifies the rule as Model Decision and the agent may skip it, + // defeating the purpose of shipping the rule at all. + expect(body.startsWith('---\n')).toBe(true); + expect(body).toMatch(/^---\ntrigger: always_on\n---\n/); + // Streamlined English playbook inlined (not the minimal + // shared body) because Qoder's client does not consume MCP + // `initialize.instructions`. This version is conditional + // ("For indexed code"), has explicit triage ("When to use / + // When not to"), and softened trust guidance. Signature + // content must be present. + expect(body).toContain('codegraph_explore'); + expect(body).toMatch(/When to use.*When not to/); + expect(body).toMatch(/Staleness/); + // Shell fallback for when MCP isn't reachable (with Qoder caveat). + expect(body).toMatch(/codegraph explore/); + + // Rule file is written BEFORE the MCP entry — the Qoder-unique, + // project-scoped artefact should lead the output. + const ruleIdx = result.files.findIndex((f) => f === ruleEntry); + const mcpIdx = result.files.findIndex((f) => f === mcpEntry); + expect(ruleIdx).toBeLessThan(mcpIdx); + + // MCP entry landed at the shared user-scope location with the + // expected shape (no --path, no ${workspaceFolder} — same + // guarantees as the global install). + const cfg = JSON.parse(fs.readFileSync(mcpEntry!.path, 'utf-8')); + expect(cfg.mcpServers.codegraph).toBeDefined(); + expect(cfg.mcpServers.codegraph.args).toEqual(['serve', '--mcp']); + expect(cfg.mcpServers.codegraph.args).not.toContain('--path'); + + // Notes surface the (deliberate) shared-vs-project asymmetry. + expect(result.notes?.join(' ')).toMatch(/shared user-scope/i); + + // Idempotent: second install is fully unchanged. + const second = qoder.install('local', { autoAllow: true }); + for (const f of second.files) expect(f.action).toBe('unchanged'); + + // Uninstall local unlinks the rule file and leaves the shared MCP + // entry alone — another project on this machine may still rely on + // it; removal of the MCP entry is gated behind + // `--location=global` uninstall. + qoder.uninstall('local'); + expect(fs.existsSync(ruleEntry!.path)).toBe(false); + const cfgAfter = JSON.parse(fs.readFileSync(mcpEntry!.path, 'utf-8')); + expect(cfgAfter.mcpServers?.codegraph).toBeDefined(); + }); + + it('qoder: --location=local self-heals a legacy AGENTS.md CodeGraph fence left by pre-.qoder/rules builds', () => { + const qoder = getTarget('qoder')!; + const agentsMd = path.join(fs.realpathSync(tmpCwd), 'AGENTS.md'); + // Simulate a repo upgraded from an earlier build that wrote the + // marker-fenced block into AGENTS.md. + fs.writeFileSync( + agentsMd, + 'User content above.\n\n\nlegacy body\n\n\nUser content below.\n', + ); + + const result = qoder.install('local', { autoAllow: true }); + // Legacy fence is stripped; user content is preserved. + const after = fs.readFileSync(agentsMd, 'utf-8'); + expect(after).not.toContain(''); + expect(after).not.toContain(''); + expect(after).toContain('User content above'); + expect(after).toContain('User content below'); + // The cleanup is surfaced as a `removed` file action. + const cleanup = result.files.find((f) => f.path === agentsMd); + expect(cleanup?.action).toBe('removed'); + }); + + it('qoder: printConfig(global) shows the mcp.json path and JSON snippet without touching the filesystem', () => { + const qoder = getTarget('qoder')!; + const before = fs.existsSync(qoder.describePaths('global')[0]!); + const out = qoder.printConfig('global'); + // Mentions the shared mcp.json path. + expect(out).toContain('mcp.json'); + // Contains a valid JSON snippet with the codegraph entry. + expect(out).toContain('codegraph'); + expect(out).toContain('serve'); + expect(out).toContain('--mcp'); + // Must NOT have created any file. + const after = fs.existsSync(qoder.describePaths('global')[0]!); + expect(after).toBe(before); + }); + + it('qoder: printConfig(local) shows both the rule file and MCP snippet', () => { + const qoder = getTarget('qoder')!; + const out = qoder.printConfig('local'); + // Rule file section. + expect(out).toContain('.qoder/rules/codegraph.md'); + expect(out).toContain('trigger: always_on'); + expect(out).toContain('codegraph_explore'); + // MCP section. + expect(out).toContain('mcp.json'); + expect(out).toContain('serve'); + // Rule file content precedes the MCP snippet (same order as install). + const ruleIdx = out.indexOf('.qoder/rules/codegraph.md'); + const mcpIdx = out.indexOf('mcp.json'); + expect(ruleIdx).toBeLessThan(mcpIdx); + expect(ruleIdx).toBeGreaterThan(-1); + }); + + it('qoder: detect() reflects alreadyConfigured before and after install/uninstall', () => { + const qoder = getTarget('qoder')!; + // Before any install: nothing configured. + expect(qoder.detect('global').alreadyConfigured).toBe(false); + expect(qoder.detect('local').alreadyConfigured).toBe(false); + + // After global install: global detect flips, local detect stays false + // (local configuredness is gated on the project rule file, not the + // shared MCP entry). + qoder.install('global', { autoAllow: true }); + expect(qoder.detect('global').alreadyConfigured).toBe(true); + expect(qoder.detect('local').alreadyConfigured).toBe(false); + + // After local install: both detect true. + qoder.install('local', { autoAllow: true }); + expect(qoder.detect('global').alreadyConfigured).toBe(true); + expect(qoder.detect('local').alreadyConfigured).toBe(true); + + // After local uninstall: local flips back to false; global stays true + // (shared MCP entry is intentionally left intact by local uninstall). + qoder.uninstall('local'); + expect(qoder.detect('local').alreadyConfigured).toBe(false); + expect(qoder.detect('global').alreadyConfigured).toBe(true); + + // After global uninstall: global also flips back to false. + qoder.uninstall('global'); + expect(qoder.detect('global').alreadyConfigured).toBe(false); + expect(qoder.detect('local').alreadyConfigured).toBe(false); + }); + it('hermes: install adds codegraph MCP server and cli toolset, preserving existing yaml', () => { const hermes = getTarget('hermes')!; const config = path.join(tmpHome, '.hermes', 'config.yaml'); diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index 4d85e204f..140da8e62 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -2187,7 +2187,7 @@ program */ program .command('install') - .description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)') + .description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent, Gemini CLI, Antigravity IDE, Kiro, Qoder)') .option('-t, --target ', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt') .option('-l, --location ', 'Install location: "global" or "local". Default: prompt') .option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on') diff --git a/src/installer/instructions-template.ts b/src/installer/instructions-template.ts index 924749190..c5cc4a40f 100644 --- a/src/installer/instructions-template.ts +++ b/src/installer/instructions-template.ts @@ -29,6 +29,24 @@ export const CODEGRAPH_SECTION_START = ''; export const CODEGRAPH_SECTION_END = ''; +/** + * The body of the codegraph guidance, without the marker fence. + * + * Module-internal — only used to compose `CODEGRAPH_INSTRUCTIONS_BLOCK` + * below. Targets that own their entire instruction file outright (e.g. + * Qoder's `.qoder/rules/codegraph.md`) define their own rule body + * tailored to that agent's constraints, so they don't consume this + * constant. + */ +const CODEGRAPH_INSTRUCTIONS_BODY = `## CodeGraph + +In repositories indexed by CodeGraph (a \`.codegraph/\` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code: + +- **MCP tool** (when available): \`codegraph_explore\` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them, including dynamic-dispatch hops grep can't follow. Name a file or symbol in the query to read its current line-numbered source. If it's listed but deferred, load it by name via tool search. +- **Shell** (always works): \`codegraph explore ""\` prints the same output. + +If there is no \`.codegraph/\` directory, skip CodeGraph entirely — indexing is the user's decision.`; + /** * The full block, markers included, exactly as written to disk. * @@ -40,12 +58,5 @@ export const CODEGRAPH_SECTION_END = ''; * noise the unindexed-session policy exists to prevent). */ export const CODEGRAPH_INSTRUCTIONS_BLOCK = `${CODEGRAPH_SECTION_START} -## CodeGraph - -In repositories indexed by CodeGraph (a \`.codegraph/\` directory exists at the repo root), reach for it BEFORE grep/find or reading files when you need to understand or locate code: - -- **MCP tool** (when available): \`codegraph_explore\` answers most code questions in one call — the relevant symbols' verbatim source plus the call paths between them, including dynamic-dispatch hops grep can't follow. Name a file or symbol in the query to read its current line-numbered source. If it's listed but deferred, load it by name via tool search. -- **Shell** (always works): \`codegraph explore ""\` prints the same output. - -If there is no \`.codegraph/\` directory, skip CodeGraph entirely — indexing is the user's decision. +${CODEGRAPH_INSTRUCTIONS_BODY} ${CODEGRAPH_SECTION_END}`; diff --git a/src/installer/targets/qoder.ts b/src/installer/targets/qoder.ts new file mode 100644 index 000000000..1c3f837b1 --- /dev/null +++ b/src/installer/targets/qoder.ts @@ -0,0 +1,505 @@ +/** + * Qoder IDE target. Writes different files at different locations: + * + * - **`--location=global`** — MCP server entry to Qoder's shared + * `mcp.json`, at: + * macOS — `~/Library/Application Support/Qoder/SharedClientCache/mcp.json` + * (verified against a working Qoder install) + * Linux — `$XDG_CONFIG_HOME/Qoder/SharedClientCache/mcp.json` + * (defaults to `~/.config/Qoder/…`) + * Windows — `%APPDATA%/Qoder/SharedClientCache/mcp.json` + * + * NOTE: only the macOS path is empirically verified; the Linux and + * Windows paths follow the Electron / VS-Code-fork convention and + * remain unverified. If Qoder ships those platforms under a different + * path (`~/.qoder/`, versioned dir, …) we'll adjust when reports + * surface. A wrong path is at worst an orphan `mcp.json` Qoder + * never reads — we only create files / directories we fully own, + * never overwrite pre-existing user data at the wrong location + * (`uninstall` short-circuits on a missing file; `install` only + * seeds a fresh JSON tree when none exists). + * + * Same `mcpServers.` JSON schema Claude / Cursor / Kiro use, + * so the standard `shared.ts` merge helpers keep sibling MCP servers + * intact through install / uninstall round-trips. The `type: "stdio"` + * field appears in real working Qoder configs alongside `command` / + * `args`, so we emit it too; if a future Qoder rejects it we can drop + * the field (Antigravity's target already does). + * + * - **`--location=local`** — the project-scoped "one command sets up + * everything you need in this project" install, matching how + * opencode / gemini / codex handle their local target. Writes BOTH: + * + * 1. the shared user-scope MCP entry (same file as `global` above — + * Qoder has NO project-local `mcp.json` path; the in-project + * `.qoder/` dir is a cache for MCP purposes but a first-class + * rules dir for agent instructions — see (2)), and + * 2. a Qoder project rule at `./.qoder/rules/codegraph.md`. This + * is a deliberate WORKAROUND for Qoder consuming the MCP + * `initialize.instructions` field weakly: tools list correctly + * and are callable, but Qoder's agent will not proactively + * reach for `codegraph_explore` — it continues to grep / read + * files even when the MCP tool would give the answer in one + * round-trip. **This intentionally departs from issue #529's + * "MCP initialize.instructions as single source of truth" + * rule** — Cursor / Claude / Kiro all rely on that rule; Qoder + * currently doesn't honor it, so we bridge the gap with a + * project-local rule. When Qoder starts loading MCP + * instructions like the rest, the rule-file branch can be + * removed in one commit. + * + * Why `.qoder/rules/` and not `./AGENTS.md`? Both work, but: + * + * - **Precedence.** Qoder's own docs specify that when + * `.qoder/rules/*` and `./AGENTS.md` disagree, the rules + * directory wins. A project may already have an AGENTS.md + * authored by Codex CLI, a teammate, or the user — writing + * codegraph guidance into AGENTS.md would either fight + * their content (with unpredictable merge outcomes for + * Qoder's agent) or ride on top of it. `.qoder/rules/` + * guarantees the codegraph guidance is the one Qoder + * actually applies for this project. + * - **Ownership boundary.** `.qoder/rules/codegraph.md` is + * a filename we OWN — no marker fence needed, no risk of + * trampling user content, and uninstall is a clean + * `unlink`. Same pattern as `~/.kiro/steering/codegraph.md` + * used to be; it avoids the whole class of "marker + * vs. user-content" bugs that AGENTS.md upsert has to + * actively guard against. + * - **Cross-target hygiene.** Codex CLI / opencode already + * write to `./AGENTS.md` via their own installer targets; + * their fence + ours would double-nudge the agent with + * near-identical text. Splitting Qoder off to + * `.qoder/rules/` avoids that duplication. + * + * On upgrade from an earlier build that wrote `./AGENTS.md`, + * both `install` and `uninstall` also strip the legacy + * CodeGraph fence from `./AGENTS.md` if present (self-heal), + * so users don't end up with two competing hints. + * + * Uninstall on `local` unlinks `./.qoder/rules/codegraph.md`, strips + * any legacy `./AGENTS.md` fence, and leaves the shared MCP entry + * alone — the MCP entry is user-scope and another project may still + * be relying on it, so per-project uninstalls never step on each + * other. Removing the MCP entry itself is gated behind + * `--location=global` uninstall. + * + * `global` never writes a rule file — Qoder only auto-loads project-scoped + * `.qoder/rules/*` and project-root `AGENTS.md`, so a user-scope rule + * would apply nowhere. A user who wants MCP without a per-project agent + * nudge runs `global`; a user who wants both runs `local`, which is a + * superset of `global` for the current project (matches + * opencode / gemini / codex). + * + * Two Qoder-specific quirks force this file to look different from + * Kiro / Claude: + * + * 1. **No `${workspaceFolder}` substitution.** Unlike VS Code, Cursor, + * and most VS Code forks, Qoder (at least older builds) passes MCP + * `args` to the child process verbatim. A `--path "${workspaceFolder}"` + * therefore reaches codegraph as the literal 12-character string, + * `findNearestCodeGraphRoot` returns null, and the (pre-#964) empty + * `tools/list` gate leaves the agent with zero tools. Recent Qoder + * builds have started expanding `${workspaceFolder}` — but we can't + * detect the Qoder version at install time, and the moment a `--path` + * is set the MCP session skips the `roots/list` fallback entirely + * (see `session.ts` — `explicitProjectPath` short-circuits + * `initFromRoots`). Emitting `--path` would therefore break every + * older-Qoder user with a `NotIndexedError` on every tool call. + * Fix: install with NO `--path` at all — codegraph resolves the + * workspace via the MCP `roots/list` handshake (see + * `MCPSession.initFromRoots`) on every Qoder version, old and new, + * and falls back to `process.cwd()` if roots ever returns empty. + * + * 2. **Launched from Dock / launchd → no nvm PATH.** Qoder is Electron; + * macOS launches it without the login shell's PATH, so a `command: "codegraph"` + * that resolves fine in a terminal fails inside Qoder when codegraph + * is installed under nvm. Same problem antigravity hits — same fix: + * resolve to an absolute path at install time, via the user's login shell. + * (Linux GUI launchers vary; we don't special-case Linux here to stay + * consistent with antigravity's existing behavior, but nvm users on a + * stripped-PATH launcher may hit the same class of bug there too.) + * + * Docs: https://qoder.com/ + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { execSync } from 'child_process'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + jsonDeepEqual, + readJsonFile, + removeMarkedSection, + writeJsonFile, +} from './shared'; +import { + CODEGRAPH_SECTION_END, + CODEGRAPH_SECTION_START, +} from '../instructions-template'; + +/** + * Directory Qoder stores its shared (across-project) settings under. + * macOS uses the standard `~/Library/Application Support/Qoder`; other + * platforms follow the Electron / VS-Code-fork convention (`$XDG_CONFIG_HOME` + * on Linux, `%APPDATA%` on Windows) so a user with an existing Qoder + * install has zero surprises. + */ +function qoderConfigDir(): string { + if (process.platform === 'darwin') { + return path.join(os.homedir(), 'Library', 'Application Support', 'Qoder'); + } + if (process.platform === 'win32') { + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + return path.join(appData, 'Qoder'); + } + // Linux / other POSIX + const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + return path.join(xdg, 'Qoder'); +} + +function mcpJsonPath(): string { + return path.join(qoderConfigDir(), 'SharedClientCache', 'mcp.json'); +} + +/** + * Project-scoped Qoder rule file we fully own. `.qoder/rules/*.md` has + * higher precedence than project-root `AGENTS.md` in Qoder's own docs, + * so this is the surface most likely to actually steer the agent. + */ +function qoderRulePath(): string { + return path.join(process.cwd(), '.qoder', 'rules', 'codegraph.md'); +} + +/** + * Legacy project-root `AGENTS.md` a pre-`.qoder/rules` build of this + * target used to write into. Kept for self-heal on upgrade (both + * install and uninstall strip any leftover CodeGraph fence from it). + */ +function legacyAgentsMdPath(): string { + return path.join(process.cwd(), 'AGENTS.md'); +} + +/** + * File body we write into `.qoder/rules/codegraph.md`. We own the + * whole file (no marker fence), so a plain trailing newline is the + * entire contract: byte-equal on subsequent installs = idempotent. + * + * Two Qoder-specific choices in this body: + * + * 1. **YAML frontmatter `trigger: always_on`.** Without it Qoder + * classifies the rule as "Model Decision" (agent decides per-turn + * whether to consult it), which defeats the whole purpose — we + * need this guidance in front of the agent on EVERY turn so it + * reaches for `codegraph_explore` before its built-in repo/code + * search tools. `always_on` maps to Qoder's "Always Apply". + * + * 2. **A streamlined English playbook, not the full + * `SERVER_INSTRUCTIONS`.** Every other target's instruction file + * (CLAUDE.md / AGENTS.md / GEMINI.md) stays minimal because those + * agents ALSO receive the full playbook via MCP + * `initialize.instructions`. Qoder's client does NOT consume that + * field, so we need a rule file — but the full playbook had + * several issues: (a) unconditional phrasing conflicted with + * Qoder's own system-level `SearchCodebase`-first rule; (b) "Trust + * — don't re-verify" discouraged legitimate verification; (c) "ONE + * call usually answers" over-promised; (d) Shell fallback ignored + * Qoder's stripped-PATH launch scenario. This version fixes all of + * those: conditional opening ("For indexed code"), explicit "When + * to use / When not to" triage, softened trust guidance ("verify + * with Read if contradictory"), and Qoder-aware shell fallback + * caveat. Total size ~1.8k chars, well under Qoder's 100k rule + * budget. + */ +const QODER_RULE_BODY = `# CodeGraph + +For **indexed code** (project root has \`.codegraph/\`), prefer \`codegraph_explore\` for structural/flow questions — it returns the full source, call paths, and impact radius in one call, replacing multiple Read/Grep rounds. Index lags file writes by ~1s; for unindexed content (configs, docs, \`.qoder/\`, Markdown, etc.), use Read/Grep directly. + +## When to use / When not to + +**Use \`codegraph_explore\`:** +- Understanding code logic: "how does X work", "X's call chain" +- Finding definitions/references: "where is X defined", "who calls X" +- Exploring areas: "this module's structure", "relationship between X and Y" +- Reading a named symbol/file (with call paths and impact radius attached) + +**Use Read / Grep / SearchCodebase directly:** +- Config files, Markdown, JSON, \`.qoder/\` content (not indexed by codegraph) +- User-attached or explicitly specified file paths +- Searching memory/knowledge base content +- After two consecutive \`codegraph_explore\` calls return empty/insufficient → fallback immediately + +## How to query + +Pass a natural-language question or symbol names (e.g., \`mutateElement renderScene\`) to \`codegraph_explore\`. It returns line-numbered source grouped by file (usable as Read output) plus call paths and impact radius. Overloaded symbols return all matching definitions in one call. Need more? Call again with more specific symbol names. + +## Staleness & Safety + +- **Check the staleness banner.** If the response starts with "⚠️ Some files referenced below were edited since the last index sync…", the listed files need re-indexing — use Read for those. Files not listed are fresh; codegraph results are trustworthy. +- **"⚠️ CodeGraph auto-sync is DISABLED…"** → index frozen; use Read for everything. +- Index lags writes by ~1s; prefer Read for recently edited files. +- Codegraph results are AST-based and generally trustworthy; if they contradict the context, verify with Read. + +## Limitations + +- No \`.codegraph/\` in project → don't use codegraph; use built-in tools. You may mention \`codegraph init\` but don't run it yourself. +- Cross-file resolution is best-effort; dynamic dispatch has coverage gaps (silent empty when no static edges). +- No correctness validation — that's the compiler/test/linter's job. + +## Shell fallback + +When MCP is unavailable: \`codegraph explore ""\` (requires binary in PATH; Qoder launched from desktop may have incomplete PATH — use Read/Grep instead). +`; + +function qoderRuleFileContents(): string { + return `---\ntrigger: always_on\n---\n${QODER_RULE_BODY}`; +} + +/** + * macOS Qoder is launched by launchd / Dock, which strips PATH of any + * nvm-managed entries — so a bare `codegraph` command fails to launch + * from inside the app even when it works in every terminal. Resolve to + * the login shell's `codegraph` at install time; that's the shell PATH + * the user actually uses. + * + * Non-darwin returns the bare name — Linux launchers inherit the user + * PATH, and Windows installs codegraph to a global location on PATH. + */ +function resolveCodegraphCommand(): string { + if (process.platform !== 'darwin') return 'codegraph'; + try { + const resolved = execSync('command -v codegraph || which codegraph', { + encoding: 'utf-8', + stdio: ['ignore', 'pipe', 'ignore'], + shell: '/bin/bash', + windowsHide: true, + }).trim(); + if (resolved && fs.existsSync(resolved)) return resolved; + } catch { + /* fall through to bare name */ + } + return 'codegraph'; +} + +/** + * Build the codegraph MCP-server entry for Qoder. Deliberately NO + * `--path` argument — see file header for why. + */ +function buildQoderEntry(): { type: string; command: string; args: string[] } { + return { + type: 'stdio', + command: resolveCodegraphCommand(), + args: ['serve', '--mcp'], + }; +} + +class QoderTarget implements AgentTarget { + readonly id = 'qoder' as const; + readonly displayName = 'Qoder'; + readonly docsUrl = 'https://qoder.com/'; + + supportsLocation(_loc: Location): boolean { + // Global: shared user-scope `mcp.json` only. Local: shared + // `mcp.json` + project-scoped `.qoder/rules/codegraph.md` rule + // file (superset — matches the opencode / gemini / codex local + // contract of "one command sets up this project end-to-end"). + return true; + } + + detect(loc: Location): DetectionResult { + if (loc === 'global') { + const file = mcpJsonPath(); + const config = readJsonFile(file); + const alreadyConfigured = !!config.mcpServers?.codegraph; + const installed = + fs.existsSync(qoderConfigDir()) || fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + // local — configured when the project owns `./.qoder/rules/codegraph.md`. + // The MCP entry is user-scope and reflected via `global` detect; + // mirroring it here would flip `alreadyConfigured` for every project + // on the machine the moment any one of them was installed, breaking + // the contract test that expects `uninstall local` to bring + // `alreadyConfigured` back to false. + const rule = qoderRulePath(); + const alreadyConfigured = fs.existsSync(rule); + // Prefer Qoder's own config dir as the installedness signal — a + // stray `.qoder/` in the project might have been made by Qoder's + // cache without any actual codegraph configuration. + const installed = fs.existsSync(qoderConfigDir()) || fs.existsSync(rule); + return { installed, alreadyConfigured, configPath: rule }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + if (loc === 'global') { + return { + files: [writeMcpEntry()], + notes: [ + 'Restart Qoder for MCP changes to take effect.', + 'If codegraph tools do not appear after restart, verify the workspace was opened via Qoder (not via `cd` in a terminal) so Qoder emits an MCP `roots/list` notification — codegraph relies on that to find your project root, since Qoder does not substitute `${workspaceFolder}` in MCP args.', + 'For per-project agent nudging, run `codegraph install --target=qoder --location=local` inside each project — it writes a `.qoder/rules/codegraph.md` rule alongside the MCP entry so Qoder\'s agent proactively uses codegraph_explore.', + ], + }; + } + // local — one-command onboarding: shared MCP + project-scoped + // Qoder rule file. Matches how opencode / gemini / codex handle + // local (a single call sets up everything the agent needs to + // actually invoke codegraph in this project). Qoder has no + // per-project mcp.json path, so the MCP entry naturally lands at + // the shared user-scope location. + // Order matters: write the Qoder-owned rule file first so it's + // the visually leading artefact in --dry-run / verbose output — + // it's the piece that's actually project-scoped and unique to + // Qoder. The shared MCP entry that follows is user-scope and + // identical to what a `--location=global` install would produce. + const files: WriteResult['files'] = []; + files.push(writeQoderRuleEntry()); + files.push(writeMcpEntry()); + + // Self-heal: an older build of this target wrote a marker-fenced + // CodeGraph section into `./AGENTS.md`. If it's still there, + // strip it — `.qoder/rules/codegraph.md` has higher precedence and + // the duplicated fence in AGENTS.md is just noise now. + const legacyCleanup = removeLegacyAgentsMdFence(); + if (legacyCleanup) files.push(legacyCleanup); + + return { + files, + notes: [ + 'Restart Qoder for MCP changes to take effect.', + 'Qoder has no project-local mcp.json — the MCP entry was written to the shared user-scope config; the `.qoder/rules/codegraph.md` rule is scoped to this project only.', + 'If your repo has `.qoder/` in .gitignore, teammates won\'t inherit this rule. Add a `!.qoder/rules/codegraph.md` exception (or share the rule via a top-level convention) if you want it committed.', + ], + }; + } + + uninstall(loc: Location): WriteResult { + if (loc === 'global') { + const file = mcpJsonPath(); + const config = readJsonFile(file); + if (config.mcpServers?.codegraph) { + delete config.mcpServers.codegraph; + if (Object.keys(config.mcpServers).length === 0) { + delete config.mcpServers; + } + writeJsonFile(file, config); + return { files: [{ path: file, action: 'removed' }] }; + } + return { files: [{ path: file, action: 'not-found' }] }; + } + // local — unlink `./.qoder/rules/codegraph.md` and sweep any + // legacy `./AGENTS.md` fence. The shared MCP entry is user-scope + // and other projects may depend on it; run `--location=global` + // uninstall to remove the MCP entry itself. + const files: WriteResult['files'] = []; + files.push(removeQoderRuleEntry()); + const legacyCleanup = removeLegacyAgentsMdFence(); + if (legacyCleanup) files.push(legacyCleanup); + return { files }; + } + + printConfig(loc: Location): string { + const file = mcpJsonPath(); + const snippet = JSON.stringify({ mcpServers: { codegraph: buildQoderEntry() } }, null, 2); + if (loc === 'global') { + return `# Add to ${file}\n\n${snippet}\n`; + } + // Print BOTH artefacts so a user following --print-config manually + // ends up with the same on-disk state install would produce. + // Symmetric with the local install() behaviour: rule file first, + // MCP entry second. + return [ + `# Write to ${qoderRulePath()} (Qoder auto-loads .qoder/rules/*.md; higher precedence than AGENTS.md):`, + '', + qoderRuleFileContents(), + `# Add to ${file} (Qoder's shared user-scope MCP config — Qoder has no project-local mcp.json):`, + '', + snippet, + ].join('\n'); + } + + describePaths(loc: Location): string[] { + return loc === 'global' ? [mcpJsonPath()] : [qoderRulePath(), mcpJsonPath()]; + } +} + +function writeMcpEntry(): WriteResult['files'][number] { + const file = mcpJsonPath(); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const existing = readJsonFile(file); + const before = existing.mcpServers?.codegraph; + const after = buildQoderEntry(); + + if (jsonDeepEqual(before, after)) { + return { path: file, action: 'unchanged' }; + } + const action: 'created' | 'updated' = + before ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + if (!existing.mcpServers) existing.mcpServers = {}; + existing.mcpServers.codegraph = after; + writeJsonFile(file, existing); + return { path: file, action }; +} + +/** + * Write the `.qoder/rules/codegraph.md` file we fully own. Byte-equal + * re-run reports `unchanged` so re-installs stay idempotent; a + * different existing body (user hand-edit, older content shape) is + * treated as `updated` and overwritten — this is a name we claim, + * same policy Kiro's `steering/codegraph.md` used to enforce. + */ +function writeQoderRuleEntry(): WriteResult['files'][number] { + const file = qoderRulePath(); + const dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const desired = qoderRuleFileContents(); + if (fs.existsSync(file)) { + const current = fs.readFileSync(file, 'utf-8'); + if (current === desired) return { path: file, action: 'unchanged' }; + atomicWriteFileSync(file, desired); + return { path: file, action: 'updated' }; + } + atomicWriteFileSync(file, desired); + return { path: file, action: 'created' }; +} + +/** + * Remove the `.qoder/rules/codegraph.md` we own. Filename is a name + * we claim, so a partial-install leaving the file behind is worse + * than a clean delete — mirrors kiro's old steering-file policy. + */ +function removeQoderRuleEntry(): WriteResult['files'][number] { + const file = qoderRulePath(); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + try { fs.unlinkSync(file); } catch { /* ignore */ } + return { path: file, action: 'removed' }; +} + +/** + * Strip a leftover CodeGraph marker fence from `./AGENTS.md` if a + * pre-`.qoder/rules` build of this target wrote one there. Returns + * the file action for reporting, or `null` when there's nothing to + * clean up (no AGENTS.md, or no fence present). Called from both + * install (self-heal on upgrade) and uninstall so the two paths + * converge on the same clean state. + */ +function removeLegacyAgentsMdFence(): WriteResult['files'][number] | null { + const md = legacyAgentsMdPath(); + if (!fs.existsSync(md)) return null; + const action = removeMarkedSection(md, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); + if (action === 'removed') return { path: md, action: 'removed' }; + return null; +} + +export const qoderTarget: AgentTarget = new QoderTarget(); diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 5e929d468..c9b93e731 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -16,6 +16,7 @@ import { hermesTarget } from './hermes'; import { geminiTarget } from './gemini'; import { antigravityTarget } from './antigravity'; import { kiroTarget } from './kiro'; +import { qoderTarget } from './qoder'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ claudeTarget, @@ -26,6 +27,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ geminiTarget, antigravityTarget, kiroTarget, + qoderTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 833a801ae..4dd3a7316 100644 --- a/src/installer/targets/types.ts +++ b/src/installer/targets/types.ts @@ -19,7 +19,7 @@ export type Location = 'global' | 'local'; * lookup. New targets add a value here when they're added to the * registry. Keep these short and lowercase. */ -export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro'; +export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes' | 'gemini' | 'antigravity' | 'kiro' | 'qoder'; /** * Result of `target.detect(location)`.