From c1652c12cad690e21c0b79cae75b6c82af6317ec Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 13 Jul 2026 09:17:40 +0700 Subject: [PATCH 1/2] feat(installer): add codev target (opencode fork with renamed config paths) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codev (npm codev-code) is an opencode fork that keeps opencode's config shape byte-for-byte but renames the on-disk app identity, so an opencode install was invisible to it: the fork reads ~/.config/codev/codev.jsonc / ./codev.jsonc, never ~/.config/opencode/opencode.jsonc. The opencode target's mechanics (XDG resolution, .jsonc-preferred fallback, surgical jsonc-parser edits, AGENTS.md block) all apply verbatim, so they move to a shared opencode-family factory that both targets are thin specs over. The pre-#535 %APPDATA% sweep stays opencode-only — the fork never shipped through those versions. Verified end-to-end: --target=auto detects a ~/.config/codev dir, writes codev.jsonc, and the real codev binary connects to the codegraph MCP server from that config; uninstall reverses it. --- CHANGELOG.md | 4 + __tests__/installer-targets.test.ts | 122 ++++++++- src/installer/index.ts | 4 +- src/installer/targets/codev.ts | 33 +++ src/installer/targets/opencode-family.ts | 326 +++++++++++++++++++++++ src/installer/targets/opencode.ts | 298 ++------------------- src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 8 files changed, 504 insertions(+), 287 deletions(-) create mode 100644 src/installer/targets/codev.ts create mode 100644 src/installer/targets/opencode-family.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 22fb4c5fc..f62032ada 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` gained a `codev` target for agents that read opencode-style config from renamed paths (`~/.config/codev/codev.jsonc` globally, `./codev.jsonc` per project) — those paths were invisible to the opencode target. Auto-detected, or select it explicitly with `--target=codev`. + ### 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/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index e11efd3cc..ec428eb5c 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -131,8 +131,9 @@ describe('Installer targets — contract', () => { // Seed pre-existing config. fs.mkdirSync(path.dirname(jsonPath), { recursive: true }); const seed: Record = { mcpServers: { other: { command: 'x' } } }; - // opencode uses `mcp` not `mcpServers`. Match its shape too. - if (target.id === 'opencode') { + // opencode-family targets use `mcp` not `mcpServers`. + // Match that shape too. + if (target.id === 'opencode' || target.id === 'codev') { delete seed.mcpServers; seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } }; } @@ -141,7 +142,7 @@ describe('Installer targets — contract', () => { target.install(location, { autoAllow: true }); const after = JSON.parse(fs.readFileSync(jsonPath, 'utf-8')); - if (target.id === 'opencode') { + if (target.id === 'opencode' || target.id === 'codev') { expect(after.mcp.other).toBeDefined(); expect(after.mcp.codegraph).toBeDefined(); } else { @@ -345,6 +346,120 @@ describe('Installer targets — partial-state idempotency', () => { expect(fs.existsSync(path.join(process.cwd(), 'AGENTS.md'))).toBe(true); }); + // codev is an opencode fork with renamed paths (~/.config/codev/ + // codev.jsonc) but the identical config shape. The shared contract + // suite covers install/idempotency/uninstall; these pin the paths and + // the fork's independence from opencode's files. + + it('codev: global install writes ~/.config/codev/codev.jsonc and its AGENTS.md block', () => { + const codev = getTarget('codev')!; + const result = codev.install('global', { autoAllow: true }); + const dir = path.join(tmpHome, '.config', 'codev'); + expect(result.files.some((f) => f.path === path.join(dir, 'codev.jsonc'))).toBe(true); + const cfg = JSON.parse(fs.readFileSync(path.join(dir, 'codev.jsonc'), 'utf-8')); + expect(cfg.mcp.codegraph).toEqual({ type: 'local', command: ['codegraph', 'serve', '--mcp'], enabled: true }); + const agentsMd = path.join(dir, 'AGENTS.md'); + expect(fs.existsSync(agentsMd)).toBe(true); + expect(fs.readFileSync(agentsMd, 'utf-8')).toContain('codegraph explore'); + }); + + it('codev: prefers .jsonc when both codev.json and codev.jsonc exist', () => { + const codev = getTarget('codev')!; + const dir = path.join(tmpHome, '.config', 'codev'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'codev.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n'); + fs.writeFileSync(path.join(dir, 'codev.jsonc'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n'); + + const result = codev.install('global', { autoAllow: true }); + const written = result.files.find((f) => /codev\.jsonc?$/.test(f.path))!; + expect(written.path).toMatch(/codev\.jsonc$/); + const jsonText = fs.readFileSync(path.join(dir, 'codev.json'), 'utf-8'); + expect(jsonText).not.toContain('codegraph'); + }); + + it('codev: uses codev.json when only .json exists (no .jsonc)', () => { + const codev = getTarget('codev')!; + const dir = path.join(tmpHome, '.config', 'codev'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'codev.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n'); + + const result = codev.install('global', { autoAllow: true }); + expect(result.files[0].path).toMatch(/codev\.json$/); + expect(fs.existsSync(path.join(dir, 'codev.jsonc'))).toBe(false); + }); + + it('codev: local install writes ./codev.jsonc and the ./AGENTS.md block', () => { + const codev = getTarget('codev')!; + const result = codev.install('local', { autoAllow: true }); + const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); + // macOS realpath shenanigans (/var vs /private/var) — suffix match. + expect(paths.some((p) => p.endsWith('/codev.jsonc'))).toBe(true); + expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(true); + expect(fs.existsSync(path.join(process.cwd(), 'AGENTS.md'))).toBe(true); + }); + + it('codev: never writes opencode paths, and vice versa (fork independence)', () => { + const codev = getTarget('codev')!; + const opencode = getTarget('opencode')!; + + codev.install('global', { autoAllow: true }); + expect(fs.existsSync(path.join(tmpHome, '.config', 'opencode'))).toBe(false); + + opencode.install('global', { autoAllow: true }); + const opencodeCfg = JSON.parse( + fs.readFileSync(path.join(tmpHome, '.config', 'opencode', 'opencode.jsonc'), 'utf-8'), + ); + expect(opencodeCfg.mcp.codegraph).toBeDefined(); + + // Uninstalling one leaves the other's config wired. + codev.uninstall('global'); + expect(opencode.detect('global').alreadyConfigured).toBe(true); + expect(codev.detect('global').alreadyConfigured).toBe(false); + }); + + it('codev: detect reports installed only when ~/.config/codev exists, ignoring %APPDATA% (no #535 history)', () => { + const codev = getTarget('codev')!; + expect(codev.detect('global').installed).toBe(false); + + // A legacy %APPDATA%/codev dir must NOT count — the pre-#535 + // misplacement is opencode history that the fork never had. + // (setHome points APPDATA at /.config, so plant the dir via a + // distinct APPDATA to prove it's ignored.) + const appData = mkTmpDir('appdata'); + const prevAppData = process.env.APPDATA; + process.env.APPDATA = appData; + try { + fs.mkdirSync(path.join(appData, 'codev'), { recursive: true }); + expect(codev.detect('global').installed).toBe(false); + + fs.mkdirSync(path.join(tmpHome, '.config', 'codev'), { recursive: true }); + expect(codev.detect('global').installed).toBe(true); + } finally { + process.env.APPDATA = prevAppData; + fs.rmSync(appData, { recursive: true, force: true }); + } + }); + + it('codev: global install never touches a legacy %APPDATA%/codev dir', () => { + const appData = mkTmpDir('appdata'); + const prevAppData = process.env.APPDATA; + process.env.APPDATA = appData; + try { + const planted = path.join(appData, 'codev', 'codev.jsonc'); + fs.mkdirSync(path.dirname(planted), { recursive: true }); + const plantedBody = JSON.stringify({ mcp: { codegraph: { type: 'local', command: ['stale'], enabled: true } } }, null, 2); + fs.writeFileSync(planted, plantedBody); + + const codev = getTarget('codev')!; + const result = codev.install('global', { autoAllow: true }); + expect(result.files.some((f) => f.path.startsWith(appData))).toBe(false); + expect(fs.readFileSync(planted, 'utf-8')).toBe(plantedBody); + } finally { + process.env.APPDATA = prevAppData; + fs.rmSync(appData, { recursive: true, force: true }); + } + }); + it('gemini: install writes settings.json (mcpServers.codegraph) and the GEMINI.md block (#704)', () => { const gemini = getTarget('gemini')!; const result = gemini.install('global', { autoAllow: true }); @@ -1183,6 +1298,7 @@ describe('Installer targets — registry', () => { expect(getTarget('cursor')?.id).toBe('cursor'); expect(getTarget('codex')?.id).toBe('codex'); expect(getTarget('opencode')?.id).toBe('opencode'); + expect(getTarget('codev')?.id).toBe('codev'); expect(getTarget('hermes')?.id).toBe('hermes'); expect(getTarget('gemini')?.id).toBe('gemini'); expect(getTarget('antigravity')?.id).toBe('antigravity'); diff --git a/src/installer/index.ts b/src/installer/index.ts index 05bfc4b26..141907427 100644 --- a/src/installer/index.ts +++ b/src/installer/index.ts @@ -455,8 +455,8 @@ export async function runUninstaller(opts: RunUninstallerOptions): Promise const sel = await clack.select({ message: 'Remove CodeGraph from all your projects, or just this one?', options: [ - { value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.hermes, ~/.gemini, ~/.kiro' }, - { value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./opencode.jsonc, ./.gemini, ./.kiro' }, + { value: 'global' as const, label: 'All projects (global)', hint: '~/.claude, ~/.cursor, ~/.codex, ~/.config/opencode, ~/.config/codev, ~/.hermes, ~/.gemini, ~/.kiro' }, + { value: 'local' as const, label: 'Just this project (local)', hint: './.claude, ./.cursor, ./opencode.jsonc, ./codev.jsonc, ./.gemini, ./.kiro' }, ], initialValue: 'global' as const, }); diff --git a/src/installer/targets/codev.ts b/src/installer/targets/codev.ts new file mode 100644 index 000000000..f34bcec16 --- /dev/null +++ b/src/installer/targets/codev.ts @@ -0,0 +1,33 @@ +/** + * codev target. + * + * codev (npm `codev-code`) is a fork of opencode that renames the + * on-disk app identity but keeps opencode's config shape + * byte-for-byte: same `mcp.` wrapper, same + * `{ type: "local", command: [...], enabled: true }` entry, and the + * same `https://opencode.ai/config.json` `$schema` (codev stamps that + * URL into fresh configs itself). Only the paths differ: + * + * - `~/.config/codev/codev.jsonc` instead of + * `~/.config/opencode/opencode.jsonc` (global; XDG on every platform) + * - `./codev.jsonc` instead of `./opencode.jsonc` (local) + * - `~/.config/codev/AGENTS.md` for the instructions block; the + * project-local `./AGENTS.md` convention is unchanged. + * + * So this is a thin spec over the shared opencode-family implementation. + * No `%APPDATA%` sweep: the pre-#535 Windows misplacement is opencode + * install history that codev never had. + */ + +import { AgentTarget } from './types'; +import { createOpencodeFamilyTarget } from './opencode-family'; + +export const codevTarget: AgentTarget = createOpencodeFamilyTarget({ + id: 'codev', + // Lowercase like opencode's own display name; the fork brands itself + // in lowercase as `codev` on the CLI. + displayName: 'codev', + docsUrl: 'https://github.com/quickbeard/codev-code', + appName: 'codev', + sweepLegacyWindowsAppData: false, +}); diff --git a/src/installer/targets/opencode-family.ts b/src/installer/targets/opencode-family.ts new file mode 100644 index 000000000..246f4a02a --- /dev/null +++ b/src/installer/targets/opencode-family.ts @@ -0,0 +1,326 @@ +/** + * Shared implementation for opencode-style targets: opencode itself and + * forks that keep its config shape but rename the app. + * + * What every family member shares: + * + * - MCP server entry in `$XDG_CONFIG_HOME//.jsonc` (global, + * XDG-style on EVERY platform, Windows included — see below) or + * `./.jsonc` (local). Falls back to `.json` when a + * `.json` file already exists; defaults new installs to `.jsonc` + * because that's what opencode itself creates on first run. + * + * opencode resolves its config dir with the `xdg-basedir` package + * (sst/opencode `packages/core/src/global.ts`): `XDG_CONFIG_HOME` + * if set, else `~/.config` — unconditionally, on all platforms. It + * never reads `%APPDATA%`; that layout belonged to the discontinued + * Go fork. We previously wrote there on Windows, so opencode never + * saw the entry (#535) — install/uninstall on the opencode target + * also sweep a stale codegraph entry out of the legacy + * `%APPDATA%/opencode` location (spec.sweepLegacyWindowsAppData). + * - Instructions block in `/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: + * { + * "$schema": "https://opencode.ai/config.json", + * "mcp": { "codegraph": { "type": "local", "command": [...], "enabled": true } } + * } + * + * The shape differs from Claude/Cursor — opencode uses `mcp.` + * (not `mcpServers`), takes `command` as a string array combining + * binary + args, and includes an explicit `enabled` flag. Forks keep + * the `$schema` URL too (each stamps the opencode.ai one into fresh + * configs itself), so it is shared here rather than per-spec. + * + * 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, + InstallOptions, + Location, + TargetId, + WriteResult, +} from './types'; +import { + atomicWriteFileSync, + jsonDeepEqual, + removeMarkedSection, + upsertInstructionsEntry, +} from './shared'; +import { + CODEGRAPH_SECTION_END, + CODEGRAPH_SECTION_START, +} from '../instructions-template'; + +export interface OpencodeFamilySpec { + id: TargetId; + displayName: string; + docsUrl: string; + /** + * App name as it appears on disk — the config dir under XDG config + * AND the config file base name (`opencode` → `~/.config/opencode/ + * opencode.jsonc`). + */ + appName: string; + /** + * Whether install/uninstall should heal the pre-#535 Windows state + * (a codegraph entry written to `%APPDATA%/` that the agent + * never reads). Only opencode has that history; forks that never + * shipped through the buggy versions must not touch `%APPDATA%`. + */ + sweepLegacyWindowsAppData: boolean; +} + +const SCHEMA_URL = 'https://opencode.ai/config.json'; + +const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' }; + +function globalConfigDir(spec: OpencodeFamilySpec): string { + // XDG_CONFIG_HOME if set, else ~/.config — on every platform, matching + // opencode's own `xdg-basedir` resolution (no Windows special case; #535). + const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim().length > 0 + ? process.env.XDG_CONFIG_HOME + : path.join(os.homedir(), '.config'); + return path.join(xdg, spec.appName); +} + +/** + * Pre-#535 installs wrote the global entry to `%APPDATA%/` — a dir + * today's opencode never reads. Returns that legacy dir when it could hold + * stale state (sweeping enabled for this spec, APPDATA set and resolving + * somewhere other than the real config dir). Gated on the env var rather + * than `process.platform` so the cleanup logic runs under the + * cross-platform test suite; on POSIX, APPDATA is unset in real life and + * this is a no-op. + */ +function legacyWindowsConfigDir(spec: OpencodeFamilySpec): string | null { + if (!spec.sweepLegacyWindowsAppData) return null; + const appData = process.env.APPDATA; + if (!appData || !appData.trim()) return null; + const legacy = path.join(appData, spec.appName); + return path.resolve(legacy) === path.resolve(globalConfigDir(spec)) ? null : legacy; +} + +function configBaseDir(spec: OpencodeFamilySpec, loc: Location): string { + return loc === 'global' ? globalConfigDir(spec) : 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(spec: OpencodeFamilySpec, loc: Location): string { + const dir = configBaseDir(spec, loc); + const jsonc = path.join(dir, `${spec.appName}.jsonc`); + const json = path.join(dir, `${spec.appName}.json`); + if (fs.existsSync(jsonc)) return jsonc; + if (fs.existsSync(json)) return json; + return jsonc; +} + +function instructionsPath(spec: OpencodeFamilySpec, loc: Location): string { + return path.join(configBaseDir(spec, loc), 'AGENTS.md'); +} + +function readConfigText(file: string): string { + if (!fs.existsSync(file)) return ''; + return fs.readFileSync(file, 'utf-8'); +} + +function parseConfig(text: string): Record { + 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; +} + +function getServerEntry(): { type: string; command: string[]; enabled: boolean } { + return { + type: 'local', + command: ['codegraph', 'serve', '--mcp'], + enabled: true, + }; +} + +class OpencodeFamilyTarget implements AgentTarget { + readonly id: TargetId; + readonly displayName: string; + readonly docsUrl: string; + + constructor(private readonly spec: OpencodeFamilySpec) { + this.id = spec.id; + this.displayName = spec.displayName; + this.docsUrl = spec.docsUrl; + } + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = configPath(this.spec, loc); + const config = parseConfig(readConfigText(file)); + const alreadyConfigured = !!config.mcp?.codegraph; + // Global: the XDG dir is what the agent creates on first run; the + // legacy %APPDATA% dir still counts as "present" (opencode only) so a + // re-install can sweep the stale pre-#535 entry out of it. + const legacy = legacyWindowsConfigDir(this.spec); + const installed = loc === 'global' + ? fs.existsSync(globalConfigDir(this.spec)) || (!!legacy && fs.existsSync(legacy)) + : fs.existsSync(file); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(this.spec, loc)); + + // AGENTS.md gets the short marker-fenced CodeGraph block (#704): + // subagents and non-MCP harnesses read AGENTS.md but never the MCP + // initialize instructions. Upsert self-heals a stale pre-#529 block. + files.push(upsertInstructionsEntry(instructionsPath(this.spec, loc))); + + // Self-heal a pre-#535 install that wrote to %APPDATA%/opencode — + // opencode never reads it, so anything of ours there is stale. + if (loc === 'global') files.push(...cleanupLegacyWindowsState(this.spec)); + + return { files }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + files.push(removeMcpEntryAt(configPath(this.spec, loc))); + files.push(removeInstructionsEntry(this.spec, loc)); + if (loc === 'global') files.push(...cleanupLegacyWindowsState(this.spec)); + return { files }; + } + + printConfig(loc: Location): string { + const target = configPath(this.spec, loc); + const snippet = JSON.stringify({ + $schema: SCHEMA_URL, + mcp: { codegraph: getServerEntry() }, + }, null, 2); + return `# Add to ${target}\n\n${snippet}\n`; + } + + describePaths(loc: Location): string[] { + return [configPath(this.spec, loc), instructionsPath(this.spec, loc)]; + } +} + +export function createOpencodeFamilyTarget(spec: OpencodeFamilySpec): AgentTarget { + return new OpencodeFamilyTarget(spec); +} + +function writeMcpEntry(spec: OpencodeFamilySpec, loc: Location): WriteResult['files'][number] { + const file = configPath(spec, loc); + const existed = fs.existsSync(file); + let text = readConfigText(file); + + // Seed a minimal 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": "${SCHEMA_URL}"\n}\n`; + } + + const config = parseConfig(text); + const before = config.mcp?.codegraph; + const after = getServerEntry(); + + 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'], SCHEMA_URL, { + 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' }; +} + +/** + * Surgically drop `mcp.codegraph` from one config file. Leaves sibling + * servers, comments, and formatting untouched; drops an emptied `mcp` + * wrapper too. Shared by uninstall and the legacy-%APPDATA% sweep. + */ +function removeMcpEntryAt(file: string): WriteResult['files'][number] { + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + const text = readConfigText(file); + const config = parseConfig(text); + if (!config.mcp?.codegraph) return { path: file, action: 'not-found' }; + + 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); + return { path: file, action: 'removed' }; +} + +/** + * Remove whatever a pre-#535 install left in `%APPDATA%/opencode` — an MCP + * entry opencode never reads, plus our marker-fenced AGENTS.md block. Returns + * only files actually changed, so install output stays quiet when there is + * nothing to heal. Never touches anything else in the legacy dir: a user may + * genuinely keep other tools' state under %APPDATA%. No-op for specs with + * sweeping disabled (forks without the pre-#535 history). + */ +function cleanupLegacyWindowsState(spec: OpencodeFamilySpec): WriteResult['files'] { + const dir = legacyWindowsConfigDir(spec); + if (!dir || !fs.existsSync(dir)) return []; + const out: WriteResult['files'] = []; + for (const name of [`${spec.appName}.jsonc`, `${spec.appName}.json`]) { + const res = removeMcpEntryAt(path.join(dir, name)); + if (res.action === 'removed') out.push(res); + } + const agents = path.join(dir, 'AGENTS.md'); + const action = removeMarkedSection(agents, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); + if (action === 'removed') out.push({ path: agents, action }); + return out; +} + +/** + * Strip the marker-delimited CodeGraph block from AGENTS.md if a prior + * install wrote one. Used by both install (self-heal on upgrade) and + * uninstall — see issue #529. + */ +function removeInstructionsEntry(spec: OpencodeFamilySpec, loc: Location): WriteResult['files'][number] { + const file = instructionsPath(spec, loc); + const action = removeMarkedSection(file, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); + return { path: file, action }; +} diff --git a/src/installer/targets/opencode.ts b/src/installer/targets/opencode.ts index a44558ad9..3b50678b5 100644 --- a/src/installer/targets/opencode.ts +++ b/src/installer/targets/opencode.ts @@ -1,288 +1,24 @@ /** * opencode target. * - * - MCP server entry to `~/.config/opencode/opencode.jsonc` (global, - * XDG-style on EVERY platform, Windows included — see below) 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. - * - * opencode resolves its config dir with the `xdg-basedir` package - * (sst/opencode `packages/core/src/global.ts`): `XDG_CONFIG_HOME` - * if set, else `~/.config` — unconditionally, on all platforms. It - * never reads `%APPDATA%`; that layout belonged to the discontinued - * Go fork. We previously wrote there on Windows, so opencode never - * saw the entry (#535) — install/uninstall now also sweep a stale - * codegraph entry out of the legacy `%APPDATA%/opencode` location. - * - 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: - * { - * "$schema": "https://opencode.ai/config.json", - * "mcp": { "codegraph": { "type": "local", "command": [...], "enabled": true } } - * } + * Thin spec over the shared opencode-family implementation (see + * `opencode-family.ts` for the config-resolution and write mechanics): * - * The shape differs from Claude/Cursor — opencode uses `mcp.` - * (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, - InstallOptions, - Location, - WriteResult, -} from './types'; -import { - atomicWriteFileSync, - jsonDeepEqual, - removeMarkedSection, - upsertInstructionsEntry, -} from './shared'; -import { - CODEGRAPH_SECTION_END, - CODEGRAPH_SECTION_START, -} from '../instructions-template'; - -function globalConfigDir(): string { - // XDG_CONFIG_HOME if set, else ~/.config — on every platform, matching - // opencode's own `xdg-basedir` resolution (no Windows special case; #535). - const xdg = process.env.XDG_CONFIG_HOME && process.env.XDG_CONFIG_HOME.trim().length > 0 - ? process.env.XDG_CONFIG_HOME - : path.join(os.homedir(), '.config'); - return path.join(xdg, 'opencode'); -} - -/** - * Pre-#535 installs wrote the global entry to `%APPDATA%/opencode` — a dir - * today's opencode never reads. Returns that legacy dir when it could hold - * stale state (APPDATA set and resolving somewhere other than the real config - * dir). Gated on the env var rather than `process.platform` so the cleanup - * logic runs under the cross-platform test suite; on POSIX, APPDATA is unset - * in real life and this is a no-op. - */ -function legacyWindowsConfigDir(): string | null { - const appData = process.env.APPDATA; - if (!appData || !appData.trim()) return null; - const legacy = path.join(appData, 'opencode'); - return path.resolve(legacy) === path.resolve(globalConfigDir()) ? null : legacy; -} - -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 { - 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 { - 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; -} - -function getOpencodeServerEntry(): { type: string; command: string[]; enabled: boolean } { - return { - type: 'local', - command: ['codegraph', 'serve', '--mcp'], - enabled: true, - }; -} - -const FORMATTING = { tabSize: 2, insertSpaces: true, eol: '\n' }; - -class OpencodeTarget implements AgentTarget { - readonly id = 'opencode' as const; - readonly displayName = 'opencode'; - readonly docsUrl = 'https://opencode.ai/docs/config'; - - supportsLocation(_loc: Location): boolean { - return true; - } - - detect(loc: Location): DetectionResult { - const file = configPath(loc); - const config = parseConfig(readConfigText(file)); - const alreadyConfigured = !!config.mcp?.codegraph; - // Global: the XDG dir is what current opencode creates on first run; the - // legacy %APPDATA% dir still counts as "opencode present" so a re-install - // can sweep the stale pre-#535 entry out of it. - const legacy = legacyWindowsConfigDir(); - const installed = loc === 'global' - ? fs.existsSync(globalConfigDir()) || (!!legacy && fs.existsSync(legacy)) - : fs.existsSync(file); - return { installed, alreadyConfigured, configPath: file }; - } - - install(loc: Location, _opts: InstallOptions): WriteResult { - const files: WriteResult['files'] = []; - files.push(writeMcpEntry(loc)); - - // AGENTS.md gets the short marker-fenced CodeGraph block (#704): - // subagents and non-MCP harnesses read AGENTS.md but never the MCP - // initialize instructions. Upsert self-heals a stale pre-#529 block. - files.push(upsertInstructionsEntry(instructionsPath(loc))); - - // Self-heal a pre-#535 install that wrote to %APPDATA%/opencode — - // opencode never reads it, so anything of ours there is stale. - if (loc === 'global') files.push(...cleanupLegacyWindowsState()); - - return { files }; - } - - uninstall(loc: Location): WriteResult { - const files: WriteResult['files'] = []; - files.push(removeMcpEntryAt(configPath(loc))); - files.push(removeInstructionsEntry(loc)); - if (loc === 'global') files.push(...cleanupLegacyWindowsState()); - return { files }; - } - - printConfig(loc: Location): string { - const target = configPath(loc); - const snippet = JSON.stringify({ - $schema: 'https://opencode.ai/config.json', - mcp: { codegraph: getOpencodeServerEntry() }, - }, null, 2); - return `# Add to ${target}\n\n${snippet}\n`; - } - - describePaths(loc: Location): string[] { - 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' }; -} - -/** - * Surgically drop `mcp.codegraph` from one config file. Leaves sibling - * servers, comments, and formatting untouched; drops an emptied `mcp` - * wrapper too. Shared by uninstall and the legacy-%APPDATA% sweep. - */ -function removeMcpEntryAt(file: string): WriteResult['files'][number] { - if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; - const text = readConfigText(file); - const config = parseConfig(text); - if (!config.mcp?.codegraph) return { path: file, action: 'not-found' }; - - 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); - return { path: file, action: 'removed' }; -} - -/** - * Remove whatever a pre-#535 install left in `%APPDATA%/opencode` — an MCP - * entry opencode never reads, plus our marker-fenced AGENTS.md block. Returns - * only files actually changed, so install output stays quiet when there is - * nothing to heal. Never touches anything else in the legacy dir: a user may - * genuinely keep other tools' state under %APPDATA%. + * - MCP server entry to `~/.config/opencode/opencode.jsonc` (global, + * XDG-style on every platform; #535) or `./opencode.jsonc` (local). + * - Instructions block to the matching `AGENTS.md`. + * - `sweepLegacyWindowsAppData`: pre-#535 codegraph versions wrote the + * global entry to `%APPDATA%/opencode`, a dir opencode never reads — + * install/uninstall sweep a stale codegraph entry out of it. */ -function cleanupLegacyWindowsState(): WriteResult['files'] { - const dir = legacyWindowsConfigDir(); - if (!dir || !fs.existsSync(dir)) return []; - const out: WriteResult['files'] = []; - for (const name of ['opencode.jsonc', 'opencode.json']) { - const res = removeMcpEntryAt(path.join(dir, name)); - if (res.action === 'removed') out.push(res); - } - const agents = path.join(dir, 'AGENTS.md'); - const action = removeMarkedSection(agents, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); - if (action === 'removed') out.push({ path: agents, action }); - return out; -} -/** - * Strip the marker-delimited CodeGraph block from AGENTS.md if a prior - * install wrote one. Used by both install (self-heal on upgrade) and - * uninstall — see issue #529. - */ -function removeInstructionsEntry(loc: Location): WriteResult['files'][number] { - const file = instructionsPath(loc); - const action = removeMarkedSection(file, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); - return { path: file, action }; -} +import { AgentTarget } from './types'; +import { createOpencodeFamilyTarget } from './opencode-family'; -export const opencodeTarget: AgentTarget = new OpencodeTarget(); +export const opencodeTarget: AgentTarget = createOpencodeFamilyTarget({ + id: 'opencode', + displayName: 'opencode', + docsUrl: 'https://opencode.ai/docs/config', + appName: 'opencode', + sweepLegacyWindowsAppData: true, +}); diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 5e929d468..3ad2cb3a2 100644 --- a/src/installer/targets/registry.ts +++ b/src/installer/targets/registry.ts @@ -12,6 +12,7 @@ import { claudeTarget } from './claude'; import { cursorTarget } from './cursor'; import { codexTarget } from './codex'; import { opencodeTarget } from './opencode'; +import { codevTarget } from './codev'; import { hermesTarget } from './hermes'; import { geminiTarget } from './gemini'; import { antigravityTarget } from './antigravity'; @@ -22,6 +23,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ cursorTarget, codexTarget, opencodeTarget, + codevTarget, hermesTarget, geminiTarget, antigravityTarget, diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 833a801ae..40c387962 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' | 'codev' | 'hermes' | 'gemini' | 'antigravity' | 'kiro'; /** * Result of `target.detect(location)`. From dfdf8898e670addc85c619fc4390b8528300fcd2 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 13 Jul 2026 10:12:39 +0700 Subject: [PATCH 2/2] test(installer): pin the opencode-family factory contract directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registered opencode and codev targets exercise the shared class end-to-end; these six tests target the factory itself with a synthetic spec ('sampleapp') that belongs to neither, proving every path derives from appName and that %APPDATA% handling follows the sweepLegacyWindowsAppData flag rather than the app name — the contract the next fork target relies on. --- __tests__/installer-targets.test.ts | 127 ++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index ec428eb5c..86af65657 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -19,6 +19,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { ALL_TARGETS, getTarget, resolveTargetFlag } from '../src/installer/targets/registry'; +import { createOpencodeFamilyTarget } from '../src/installer/targets/opencode-family'; import { uninstallTargets, refreshTargets } from '../src/installer'; import { upsertTomlTable, removeTomlTable, buildTomlTable } from '../src/installer/targets/toml'; import { cleanupLegacyHooks, writePromptHookEntry, removePromptHookEntry } from '../src/installer/targets/claude'; @@ -1825,3 +1826,129 @@ describe('Installer targets — opencode XDG config path (#535)', () => { expect(opencode.detect('global').alreadyConfigured).toBe(false); }); }); + +// --------------------------------------------------------------------------- +// opencode-family factory — direct contract tests +// +// The registered opencode and codev targets exercise the shared +// implementation end-to-end above; these pin the FACTORY's spec +// semantics with a synthetic spec that belongs to neither, so the +// parameterization contract holds for the next fork target anyone adds: +// every path derives from `appName`, and `%APPDATA%` handling follows +// `sweepLegacyWindowsAppData` — not the app name. +// --------------------------------------------------------------------------- +describe('Installer targets — opencode-family factory contract', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + let appDataDir: string; // distinct from ~/.config, like real Windows + + beforeEach(() => { + tmpHome = mkTmpDir('home'); + tmpCwd = mkTmpDir('cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + appDataDir = path.join(tmpHome, 'AppData', 'Roaming'); + process.env.APPDATA = appDataDir; // realistic split: APPDATA ≠ ~/.config + delete process.env.XDG_CONFIG_HOME; // default resolution: ~/.config + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + // `id` reuses a real TargetId union member (specs of unregistered + // targets still have to type-check), but nothing about 'sampleapp' + // exists in the registry — every asserted path can only come from + // the spec. + const makeSample = (sweep: boolean) => createOpencodeFamilyTarget({ + id: 'opencode', + displayName: 'Sample App', + docsUrl: 'https://example.com/docs', + appName: 'sampleapp', + sweepLegacyWindowsAppData: sweep, + }); + + const sampleConfigFile = () => path.join(tmpHome, '.config', 'sampleapp', 'sampleapp.jsonc'); + const legacyDir = () => path.join(appDataDir, 'sampleapp'); + const plantLegacyEntry = () => { + fs.mkdirSync(legacyDir(), { recursive: true }); + const file = path.join(legacyDir(), 'sampleapp.json'); + const body = '{\n "mcp": {\n "codegraph": { "type": "local", "command": ["codegraph", "serve", "--mcp"], "enabled": true }\n }\n}\n'; + fs.writeFileSync(file, body); + return { file, body }; + }; + + it('exposes the spec identity verbatim (id, displayName, docsUrl)', () => { + const sample = makeSample(false); + expect(sample.id).toBe('opencode'); + expect(sample.displayName).toBe('Sample App'); + expect(sample.docsUrl).toBe('https://example.com/docs'); + }); + + it('derives every path from appName: global /.jsonc + AGENTS.md, local ./.jsonc', () => { + const sample = makeSample(false); + + const result = sample.install('global', { autoAllow: true }); + expect(result.files.some((f) => path.resolve(f.path) === path.resolve(sampleConfigFile()))).toBe(true); + const cfg = JSON.parse(fs.readFileSync(sampleConfigFile(), 'utf-8')); + expect(cfg.mcp.codegraph).toEqual({ type: 'local', command: ['codegraph', 'serve', '--mcp'], enabled: true }); + expect(fs.existsSync(path.join(tmpHome, '.config', 'sampleapp', 'AGENTS.md'))).toBe(true); + + const localPaths = sample.describePaths('local').map((p) => p.replace(/\\/g, '/')); + expect(localPaths.some((p) => p.endsWith('/sampleapp.jsonc'))).toBe(true); + expect(sample.printConfig('global')).toContain('sampleapp.jsonc'); + }); + + it('prefers an existing .json over creating .jsonc', () => { + const dir = path.join(tmpHome, '.config', 'sampleapp'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'sampleapp.json'), '{\n "$schema": "https://opencode.ai/config.json"\n}\n'); + + const sample = makeSample(false); + const result = sample.install('global', { autoAllow: true }); + expect(result.files[0].path).toMatch(/sampleapp\.json$/); + expect(fs.existsSync(sampleConfigFile())).toBe(false); + }); + + it('honors XDG_CONFIG_HOME with the spec appName', () => { + const custom = path.join(tmpHome, 'xdg-custom'); + process.env.XDG_CONFIG_HOME = custom; + const sample = makeSample(false); + const result = sample.install('global', { autoAllow: true }); + expect(path.resolve(result.files[0]!.path)) + .toBe(path.resolve(path.join(custom, 'sampleapp', 'sampleapp.jsonc'))); + }); + + it('sweep disabled: %APPDATA%/ is invisible to detect/install/uninstall', () => { + const planted = plantLegacyEntry(); + const sample = makeSample(false); + + // Legacy-only presence does NOT count as installed… + expect(sample.detect('global').installed).toBe(false); + + // …and neither install nor uninstall reads or writes under %APPDATA%. + const installed = sample.install('global', { autoAllow: true }); + const removed = sample.uninstall('global'); + for (const f of [...installed.files, ...removed.files]) { + expect(path.resolve(f.path).startsWith(path.resolve(appDataDir) + path.sep)).toBe(false); + } + expect(fs.readFileSync(planted.file, 'utf-8')).toBe(planted.body); + }); + + it('sweep enabled: the same legacy state is detected and healed on install', () => { + const planted = plantLegacyEntry(); + const sample = makeSample(true); + + expect(sample.detect('global').installed).toBe(true); + + const result = sample.install('global', { autoAllow: true }); + expect(result.files.some((f) => f.action === 'removed' && path.resolve(f.path) === path.resolve(planted.file))).toBe(true); + expect(fs.readFileSync(planted.file, 'utf-8')).not.toContain('codegraph'); + }); +});