Просмотр исходного кода

fix(installer): opencode .jsonc + AGENTS.md (0.7.8)

v0.7.7 wrote ~/.config/opencode/opencode.json, but opencode reads
opencode.jsonc by default — so the codegraph MCP entry never appeared
in any opencode session. Also installs AGENTS.md so opencode's model
reaches for codegraph_* tools instead of native Grep.

- Prefer existing .jsonc, fall back to .json, default new installs
  to .jsonc.
- Surgical edits via jsonc-parser preserve user comments and
  formatting across install / re-install / uninstall round-trips.
- Install AGENTS.md (global ~/.config/opencode/AGENTS.md, local
  ./AGENTS.md) with the shared INSTRUCTIONS_TEMPLATE — same
  marker-delimited approach Codex uses.
- +9 opencode-specific tests covering filename precedence, comment
  preservation, AGENTS.md install + sibling-content preservation,
  uninstall reverses both files.

575/575 tests pass. Hand-verified end-to-end: opencode session calls
codegraph_node + codegraph_callers for a structural query, zero Grep
calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Colby McHenry 1 месяц назад
Родитель
Сommit
105a95963e
5 измененных файлов с 333 добавлено и 44 удалено
  1. 23 0
      CHANGELOG.md
  2. 148 1
      __tests__/installer-targets.test.ts
  3. 9 2
      package-lock.json
  4. 2 1
      package.json
  5. 151 40
      src/installer/targets/opencode.ts

+ 23 - 0
CHANGELOG.md

@@ -7,6 +7,29 @@ a [GitHub Release](https://github.com/colbymchenry/codegraph/releases) tagged
 This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
 and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [0.7.8] - 2026-05-17
+
+### Fixed
+- **opencode**: install actually wires up the MCP server now. v0.7.7 wrote
+  `~/.config/opencode/opencode.json`, but opencode reads `opencode.jsonc` by
+  default — so the `codegraph` entry never showed up in any opencode session.
+  The installer now prefers an existing `.jsonc`, falls back to `.json` when
+  only that exists, and creates `.jsonc` for greenfield installs. **Re-run
+  `codegraph install --target=opencode` after upgrading** so the entry lands
+  in the file opencode actually reads.
+
+### Added
+- **opencode**: installer now writes `AGENTS.md` (global
+  `~/.config/opencode/AGENTS.md`, local `./AGENTS.md`) with the same
+  codegraph usage guidance the other agents already received. Without it,
+  opencode's model would call native `Grep` instead of the `codegraph_*`
+  tools it could see in its MCP list.
+- User comments and formatting in `opencode.jsonc` survive install /
+  re-install / uninstall round-trips — surgical edits via `jsonc-parser`
+  rather than full-file rewrites.
+
+[0.7.8]: https://github.com/colbymchenry/codegraph/releases/tag/v0.7.8
+
 ## [0.7.7] - 2026-05-17
 
 ### Added

+ 148 - 1
__tests__/installer-targets.test.ts

@@ -98,7 +98,8 @@ describe('Installer targets — contract', () => {
             // and any target with no JSON config — they get covered
             // by their own dedicated tests below.
             const paths = target.describePaths(location);
-            const jsonPath = paths.find((p) => p.endsWith('.json'));
+            // Match .json or .jsonc — opencode prefers .jsonc.
+            const jsonPath = paths.find((p) => /\.jsonc?$/.test(p));
             if (!jsonPath) return;
 
             // Seed pre-existing config.
@@ -184,6 +185,152 @@ describe('Installer targets — partial-state idempotency', () => {
     for (const f of third.files) expect(f.action).toBe('unchanged');
   });
 
+  it('opencode: prefers .jsonc when both .json and .jsonc exist', () => {
+    const opencode = getTarget('opencode')!;
+    const dir = path.join(tmpHome, '.config', 'opencode');
+    fs.mkdirSync(dir, { recursive: true });
+    fs.writeFileSync(path.join(dir, 'opencode.json'), '{\n  "$schema": "https://opencode.ai/config.json"\n}\n');
+    fs.writeFileSync(path.join(dir, 'opencode.jsonc'), '{\n  "$schema": "https://opencode.ai/config.json"\n}\n');
+
+    const result = opencode.install('global', { autoAllow: true });
+    const written = result.files.find((f) => /\.jsonc$/.test(f.path))!;
+    expect(written).toBeDefined();
+    expect(written.action).not.toBe('not-found');
+    // The .json file is left alone.
+    const jsonText = fs.readFileSync(path.join(dir, 'opencode.json'), 'utf-8');
+    expect(jsonText).not.toContain('codegraph');
+  });
+
+  it('opencode: uses .json when only .json exists (no .jsonc)', () => {
+    const opencode = getTarget('opencode')!;
+    const dir = path.join(tmpHome, '.config', 'opencode');
+    fs.mkdirSync(dir, { recursive: true });
+    fs.writeFileSync(path.join(dir, 'opencode.json'), '{\n  "$schema": "https://opencode.ai/config.json"\n}\n');
+
+    const result = opencode.install('global', { autoAllow: true });
+    expect(result.files[0].path).toMatch(/opencode\.json$/);
+    expect(fs.existsSync(path.join(dir, 'opencode.jsonc'))).toBe(false);
+  });
+
+  it('opencode: defaults to .jsonc for fresh installs (no existing file)', () => {
+    const opencode = getTarget('opencode')!;
+    const result = opencode.install('global', { autoAllow: true });
+    expect(result.files[0].path).toMatch(/opencode\.jsonc$/);
+    expect(result.files[0].action).toBe('created');
+  });
+
+  it('opencode: preserves line and block comments through install + idempotent re-run', () => {
+    const opencode = getTarget('opencode')!;
+    const dir = path.join(tmpHome, '.config', 'opencode');
+    fs.mkdirSync(dir, { recursive: true });
+    const file = path.join(dir, 'opencode.jsonc');
+    const original = [
+      '{',
+      '  // top-level note about my opencode setup',
+      '  "$schema": "https://opencode.ai/config.json",',
+      '  /* multi-line block comment',
+      '     describing the providers section */',
+      '  "providers": {',
+      '    "anthropic": { "model": "claude-opus-4-7" } // pinned',
+      '  }',
+      '}',
+      '',
+    ].join('\n');
+    fs.writeFileSync(file, original);
+
+    opencode.install('global', { autoAllow: true });
+    const afterInstall = fs.readFileSync(file, 'utf-8');
+    expect(afterInstall).toContain('// top-level note about my opencode setup');
+    expect(afterInstall).toContain('/* multi-line block comment');
+    expect(afterInstall).toContain('// pinned');
+    expect(afterInstall).toContain('"codegraph"');
+    expect(afterInstall).toContain('"providers"');
+
+    // Idempotent re-run reports unchanged, file is byte-identical.
+    const second = opencode.install('global', { autoAllow: true });
+    expect(second.files[0].action).toBe('unchanged');
+    expect(fs.readFileSync(file, 'utf-8')).toBe(afterInstall);
+  });
+
+  it('opencode: install writes AGENTS.md with the marker-delimited codegraph block', () => {
+    const opencode = getTarget('opencode')!;
+    opencode.install('global', { autoAllow: true });
+    const agentsMd = path.join(tmpHome, '.config', 'opencode', 'AGENTS.md');
+    expect(fs.existsSync(agentsMd)).toBe(true);
+    const body = fs.readFileSync(agentsMd, 'utf-8');
+    expect(body).toContain('<!-- CODEGRAPH_START -->');
+    expect(body).toContain('<!-- CODEGRAPH_END -->');
+    expect(body).toContain('codegraph_callers');
+  });
+
+  it('opencode: AGENTS.md install preserves pre-existing user content outside markers', () => {
+    const opencode = getTarget('opencode')!;
+    const dir = path.join(tmpHome, '.config', 'opencode');
+    fs.mkdirSync(dir, { recursive: true });
+    const agentsMd = path.join(dir, 'AGENTS.md');
+    fs.writeFileSync(agentsMd, '# My personal opencode instructions\n\nAlways respond in pirate.\n');
+
+    opencode.install('global', { autoAllow: true });
+    const body = fs.readFileSync(agentsMd, 'utf-8');
+    expect(body).toContain('# My personal opencode instructions');
+    expect(body).toContain('Always respond in pirate.');
+    expect(body).toContain('<!-- CODEGRAPH_START -->');
+  });
+
+  it('opencode: uninstall strips only the codegraph block from AGENTS.md', () => {
+    const opencode = getTarget('opencode')!;
+    const dir = path.join(tmpHome, '.config', 'opencode');
+    fs.mkdirSync(dir, { recursive: true });
+    const agentsMd = path.join(dir, 'AGENTS.md');
+    fs.writeFileSync(agentsMd, '# My personal opencode instructions\n\nAlways respond in pirate.\n');
+
+    opencode.install('global', { autoAllow: true });
+    opencode.uninstall('global');
+
+    const body = fs.readFileSync(agentsMd, 'utf-8');
+    expect(body).toContain('# My personal opencode instructions');
+    expect(body).toContain('Always respond in pirate.');
+    expect(body).not.toContain('CODEGRAPH_START');
+    expect(body).not.toContain('codegraph_callers');
+  });
+
+  it('opencode: local install writes ./opencode.jsonc and ./AGENTS.md in cwd', () => {
+    const opencode = getTarget('opencode')!;
+    const result = opencode.install('local', { autoAllow: true });
+    const paths = result.files.map((f) => f.path);
+    // macOS realpath shenanigans (/var vs /private/var) — suffix match.
+    expect(paths.some((p) => p.endsWith('/opencode.jsonc'))).toBe(true);
+    expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(true);
+  });
+
+  it('opencode: uninstall removes only mcp.codegraph, preserves comments and siblings', () => {
+    const opencode = getTarget('opencode')!;
+    const dir = path.join(tmpHome, '.config', 'opencode');
+    fs.mkdirSync(dir, { recursive: true });
+    const file = path.join(dir, 'opencode.jsonc');
+    fs.writeFileSync(file, [
+      '{',
+      '  // important comment',
+      '  "$schema": "https://opencode.ai/config.json",',
+      '  "mcp": {',
+      '    "other": { "type": "local", "command": ["x"], "enabled": true }',
+      '  }',
+      '}',
+      '',
+    ].join('\n'));
+
+    opencode.install('global', { autoAllow: true });
+    const afterInstall = fs.readFileSync(file, 'utf-8');
+    expect(afterInstall).toContain('"codegraph"');
+    expect(afterInstall).toContain('"other"');
+
+    opencode.uninstall('global');
+    const afterUninstall = fs.readFileSync(file, 'utf-8');
+    expect(afterUninstall).not.toContain('codegraph');
+    expect(afterUninstall).toContain('// important comment');
+    expect(afterUninstall).toContain('"other"');
+  });
+
   it('codex: user-added key inside [mcp_servers.codegraph] survives idempotent re-install', () => {
     const codex = getTarget('codex')!;
     codex.install('global', { autoAllow: false });

+ 9 - 2
package-lock.json

@@ -1,18 +1,19 @@
 {
   "name": "@colbymchenry/codegraph",
-  "version": "0.7.7",
+  "version": "0.7.8",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@colbymchenry/codegraph",
-      "version": "0.7.7",
+      "version": "0.7.8",
       "license": "MIT",
       "dependencies": {
         "@clack/prompts": "^1.3.0",
         "commander": "^14.0.2",
         "fast-string-width": "^3.0.2",
         "fast-wrap-ansi": "^0.2.0",
+        "jsonc-parser": "^3.3.1",
         "node-sqlite3-wasm": "^0.8.30",
         "picomatch": "^4.0.3",
         "sisteransi": "^1.0.5",
@@ -1347,6 +1348,12 @@
       "license": "ISC",
       "optional": true
     },
+    "node_modules/jsonc-parser": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz",
+      "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==",
+      "license": "MIT"
+    },
     "node_modules/loupe": {
       "version": "3.2.1",
       "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",

+ 2 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "@colbymchenry/codegraph",
-  "version": "0.7.7",
+  "version": "0.7.8",
   "description": "Supercharge Claude Code with semantic code intelligence. 94% fewer tool calls • 77% faster exploration • 100% local.",
   "main": "dist/index.js",
   "types": "dist/index.d.ts",
@@ -36,6 +36,7 @@
     "commander": "^14.0.2",
     "fast-string-width": "^3.0.2",
     "fast-wrap-ansi": "^0.2.0",
+    "jsonc-parser": "^3.3.1",
     "node-sqlite3-wasm": "^0.8.30",
     "picomatch": "^4.0.3",
     "sisteransi": "^1.0.5",

+ 151 - 40
src/installer/targets/opencode.ts

@@ -1,11 +1,14 @@
 /**
  * opencode target.
  *
- *   - MCP server entry to `~/.config/opencode/opencode.json` (global,
- *     XDG-style; `%APPDATA%/opencode/opencode.json` on Windows) or
- *     `./opencode.json` (local).
- *   - No instructions file built in (opencode doesn't have a
- *     conventional agent-rules surface as of 2026-05).
+ *   - MCP server entry to `~/.config/opencode/opencode.jsonc` (global,
+ *     XDG-style; `%APPDATA%/opencode/opencode.jsonc` on Windows) or
+ *     `./opencode.jsonc` (local). Falls back to `opencode.json` when a
+ *     `.json` file already exists; defaults new installs to `.jsonc`
+ *     because that's what opencode itself creates on first run.
+ *   - Instructions to `~/.config/opencode/AGENTS.md` (global) or
+ *     `./AGENTS.md` (local). opencode reads AGENTS.md for agent
+ *     instructions — same convention Codex CLI uses.
  *   - No permissions concept.
  *
  * Config shape uses opencode's wrapper:
@@ -17,11 +20,16 @@
  * The shape differs from Claude/Cursor — opencode uses `mcp.<name>`
  * (not `mcpServers`), takes `command` as a string array combining
  * binary + args, and includes an explicit `enabled` flag.
+ *
+ * Reads + writes go through `jsonc-parser` so any `//` and `/* *\/`
+ * comments the user has added to their `.jsonc` survive idempotent
+ * re-runs.
  */
 
 import * as fs from 'fs';
 import * as path from 'path';
 import * as os from 'os';
+import { parse as parseJsonc, modify, applyEdits } from 'jsonc-parser';
 import {
   AgentTarget,
   DetectionResult,
@@ -30,10 +38,16 @@ import {
   WriteResult,
 } from './types';
 import {
+  atomicWriteFileSync,
   jsonDeepEqual,
-  readJsonFile,
-  writeJsonFile,
+  removeMarkedSection,
+  replaceOrAppendMarkedSection,
 } from './shared';
+import {
+  CODEGRAPH_SECTION_END,
+  CODEGRAPH_SECTION_START,
+  INSTRUCTIONS_TEMPLATE,
+} from '../instructions-template';
 
 function globalConfigDir(): string {
   if (process.platform === 'win32') {
@@ -47,10 +61,39 @@ function globalConfigDir(): string {
   return path.join(xdg, 'opencode');
 }
 
+function configBaseDir(loc: Location): string {
+  return loc === 'global' ? globalConfigDir() : process.cwd();
+}
+
+// Pick existing .jsonc, then .json, default to .jsonc for new files.
+// opencode auto-creates .jsonc on first run, so that's the dominant
+// real-world case and the sensible default for greenfield installs.
 function configPath(loc: Location): string {
-  return loc === 'global'
-    ? path.join(globalConfigDir(), 'opencode.json')
-    : path.join(process.cwd(), 'opencode.json');
+  const dir = configBaseDir(loc);
+  const jsonc = path.join(dir, 'opencode.jsonc');
+  const json = path.join(dir, 'opencode.json');
+  if (fs.existsSync(jsonc)) return jsonc;
+  if (fs.existsSync(json)) return json;
+  return jsonc;
+}
+
+function instructionsPath(loc: Location): string {
+  return path.join(configBaseDir(loc), 'AGENTS.md');
+}
+
+function readConfigText(file: string): string {
+  if (!fs.existsSync(file)) return '';
+  return fs.readFileSync(file, 'utf-8');
+}
+
+function parseConfig(text: string): Record<string, any> {
+  if (!text.trim()) return {};
+  const errors: any[] = [];
+  const result = parseJsonc(text, errors, { allowTrailingComma: true });
+  if (result == null || typeof result !== 'object' || Array.isArray(result)) {
+    return {};
+  }
+  return result as Record<string, any>;
 }
 
 function getOpencodeServerEntry(): { type: string; command: string[]; enabled: boolean } {
@@ -61,6 +104,8 @@ function getOpencodeServerEntry(): { type: string; command: string[]; enabled: b
   };
 }
 
+const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' };
+
 class OpencodeTarget implements AgentTarget {
   readonly id = 'opencode' as const;
   readonly displayName = 'opencode';
@@ -72,7 +117,7 @@ class OpencodeTarget implements AgentTarget {
 
   detect(loc: Location): DetectionResult {
     const file = configPath(loc);
-    const config = readJsonFile(file);
+    const config = parseConfig(readConfigText(file));
     const alreadyConfigured = !!config.mcp?.codegraph;
     const installed = loc === 'global'
       ? fs.existsSync(globalConfigDir())
@@ -81,39 +126,48 @@ class OpencodeTarget implements AgentTarget {
   }
 
   install(loc: Location, _opts: InstallOptions): WriteResult {
-    const file = configPath(loc);
-    const existing = readJsonFile(file);
-    const before = existing.mcp?.codegraph;
-    const after = getOpencodeServerEntry();
-
-    if (jsonDeepEqual(before, after)) {
-      return { files: [{ path: file, action: 'unchanged' }] };
-    }
-
-    const created = !fs.existsSync(file);
-    if (!existing.$schema) existing.$schema = 'https://opencode.ai/config.json';
-    if (!existing.mcp) existing.mcp = {};
-    existing.mcp.codegraph = after;
-    writeJsonFile(file, existing);
-    return {
-      files: [{ path: file, action: created ? 'created' : 'updated' }],
-    };
+    const files: WriteResult['files'] = [];
+    files.push(writeMcpEntry(loc));
+    files.push(writeInstructionsEntry(loc));
+    return { files };
   }
 
   uninstall(loc: Location): WriteResult {
+    const files: WriteResult['files'] = [];
     const file = configPath(loc);
-    const config = readJsonFile(file);
-    if (!config.mcp?.codegraph) {
-      return { files: [{ path: file, action: 'not-found' }] };
-    }
-    delete config.mcp.codegraph;
-    if (Object.keys(config.mcp).length === 0) {
-      delete config.mcp;
+
+    if (!fs.existsSync(file)) {
+      files.push({ path: file, action: 'not-found' });
+    } else {
+      const text = readConfigText(file);
+      const config = parseConfig(text);
+      if (!config.mcp?.codegraph) {
+        files.push({ path: file, action: 'not-found' });
+      } else {
+        // Drop our key surgically. Leaves siblings + comments untouched.
+        let edits = modify(text, ['mcp', 'codegraph'], undefined, {
+          formattingOptions: FORMATTING,
+        });
+        let updated = applyEdits(text, edits);
+
+        // If `mcp` is now an empty object, drop the wrapper too.
+        const afterParsed = parseConfig(updated);
+        if (afterParsed.mcp && typeof afterParsed.mcp === 'object' &&
+            Object.keys(afterParsed.mcp).length === 0) {
+          edits = modify(updated, ['mcp'], undefined, { formattingOptions: FORMATTING });
+          updated = applyEdits(updated, edits);
+        }
+
+        atomicWriteFileSync(file, updated);
+        files.push({ path: file, action: 'removed' });
+      }
     }
-    // If the file is now degenerate (only $schema or empty), leave it
-    // — the user may have other config we shouldn't nuke.
-    writeJsonFile(file, config);
-    return { files: [{ path: file, action: 'removed' }] };
+
+    const instr = instructionsPath(loc);
+    const instrAction = removeMarkedSection(instr, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END);
+    files.push({ path: instr, action: instrAction });
+
+    return { files };
   }
 
   printConfig(loc: Location): string {
@@ -126,8 +180,65 @@ class OpencodeTarget implements AgentTarget {
   }
 
   describePaths(loc: Location): string[] {
-    return [configPath(loc)];
+    return [configPath(loc), instructionsPath(loc)];
+  }
+}
+
+function writeMcpEntry(loc: Location): WriteResult['files'][number] {
+  const file = configPath(loc);
+  const existed = fs.existsSync(file);
+  let text = readConfigText(file);
+
+  // Seed a minimal opencode config when the file is brand-new so
+  // the result is a complete, schema-tagged file (not just a bare
+  // `{ "mcp": {...} }`).
+  if (!text.trim()) {
+    text = '{\n  "$schema": "https://opencode.ai/config.json"\n}\n';
+  }
+
+  const config = parseConfig(text);
+  const before = config.mcp?.codegraph;
+  const after = getOpencodeServerEntry();
+
+  if (jsonDeepEqual(before, after)) {
+    return { path: file, action: 'unchanged' };
   }
+
+  // Add $schema if the user's existing file is missing it.
+  if (!config.$schema) {
+    const schemaEdits = modify(text, ['$schema'], 'https://opencode.ai/config.json', {
+      formattingOptions: FORMATTING,
+    });
+    text = applyEdits(text, schemaEdits);
+  }
+
+  // Surgical edit — preserves comments, formatting, and order of
+  // every key we don't touch.
+  const edits = modify(text, ['mcp', 'codegraph'], after, {
+    formattingOptions: FORMATTING,
+  });
+  const updated = applyEdits(text, edits);
+  atomicWriteFileSync(file, updated);
+
+  return { path: file, action: existed ? 'updated' : 'created' };
+}
+
+function writeInstructionsEntry(loc: Location): WriteResult['files'][number] {
+  const file = instructionsPath(loc);
+  const dir = path.dirname(file);
+  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
+
+  const action = replaceOrAppendMarkedSection(
+    file,
+    INSTRUCTIONS_TEMPLATE,
+    CODEGRAPH_SECTION_START,
+    CODEGRAPH_SECTION_END,
+  );
+  const mapped: 'created' | 'updated' | 'unchanged' =
+    action === 'created' ? 'created'
+      : action === 'unchanged' ? 'unchanged'
+        : 'updated';
+  return { path: file, action: mapped };
 }
 
 export const opencodeTarget: AgentTarget = new OpencodeTarget();