From 68684a36683d573cd6eed1600b8142d42b500c74 Mon Sep 17 00:00:00 2001 From: xiebaiyuan Date: Wed, 27 May 2026 15:39:51 +0800 Subject: [PATCH] feat(installer): add Reasonix as an install target Adds Reasonix (reasonix.dev) as a first-class install target in the multi-agent installer, following the same pattern as Claude/Cursor/ Gemini/Kiro. Users can now configure CodeGraph for Reasonix with: codegraph install --target=reasonix or pick it interactively from the multi-select prompt. Reasonix uses a string-array based MCP config format under the \`mcp\` key, unlike other agents' \`mcpServers\` object map: { "mcp": ["codegraph=codegraph serve --mcp"] } Instructions are written to REASONIX.md (global: ~/.reasonix/, local: project root) using the standard marker-delimited section. No permissions concept - autoAllow is silently ignored. Testing: 151 installer tests pass (19 reasonix-specific + contract) --- __tests__/installer-targets.test.ts | 161 +++++++++++++++++++++++ src/installer/targets/reasonix.ts | 194 ++++++++++++++++++++++++++++ src/installer/targets/registry.ts | 2 + src/installer/targets/types.ts | 2 +- 4 files changed, 358 insertions(+), 1 deletion(-) create mode 100644 src/installer/targets/reasonix.ts diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 697f8e97..f220c53d 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -124,6 +124,11 @@ describe('Installer targets — contract', () => { delete seed.mcpServers; seed.mcp = { other: { type: 'local', command: ['x'], enabled: true } }; } + // reasonix uses `mcp` as a string array. + if (target.id === 'reasonix') { + delete seed.mcpServers; + seed.mcp = ['other=command x']; + } fs.writeFileSync(jsonPath, JSON.stringify(seed, null, 2) + '\n'); target.install(location, { autoAllow: true }); @@ -132,6 +137,9 @@ describe('Installer targets — contract', () => { if (target.id === 'opencode') { expect(after.mcp.other).toBeDefined(); expect(after.mcp.codegraph).toBeDefined(); + } else if (target.id === 'reasonix') { + expect(after.mcp).toContain('other=command x'); + expect(after.mcp).toContain('codegraph=codegraph serve --mcp'); } else { expect(after.mcpServers.other).toBeDefined(); expect(after.mcpServers.codegraph).toBeDefined(); @@ -1039,6 +1047,158 @@ describe('Installer targets — partial-state idempotency', () => { }); }); +describe('Installer targets — reasonix', () => { + let tmpHome: string; + let tmpCwd: string; + let origCwd: string; + let homeRestore: { restore: () => void }; + + beforeEach(() => { + tmpHome = mkTmpDir('rx-home'); + tmpCwd = mkTmpDir('rx-cwd'); + origCwd = process.cwd(); + process.chdir(tmpCwd); + homeRestore = setHome(tmpHome); + }); + + afterEach(() => { + homeRestore.restore(); + process.chdir(origCwd); + fs.rmSync(tmpHome, { recursive: true, force: true }); + fs.rmSync(tmpCwd, { recursive: true, force: true }); + }); + + it('installs codegraph MCP entry into the mcp string array in config.json', () => { + const target = getTarget('reasonix')!; + expect(target.detect('global').alreadyConfigured).toBe(false); + + const result = target.install('global', { autoAllow: true }); + + const configFile = path.join(tmpHome, '.reasonix', 'config.json'); + expect(result.files.some((f) => f.path === configFile)).toBe(true); + expect(fs.existsSync(configFile)).toBe(true); + + const config = JSON.parse(fs.readFileSync(configFile, 'utf-8')); + expect(Array.isArray(config.mcp)).toBe(true); + expect(config.mcp).toContain('codegraph=codegraph serve --mcp'); + expect(target.detect('global').alreadyConfigured).toBe(true); + }); + + it('re-running install is idempotent (unchanged)', () => { + const target = getTarget('reasonix')!; + target.install('global', { autoAllow: true }); + const second = target.install('global', { autoAllow: true }); + for (const file of second.files) { + expect(file.action).toBe('unchanged'); + } + }); + + it('install preserves pre-existing sibling MCP entries', () => { + const target = getTarget('reasonix')!; + const configFile = path.join(tmpHome, '.reasonix', 'config.json'); + fs.mkdirSync(path.dirname(configFile), { recursive: true }); + fs.writeFileSync( + configFile, + JSON.stringify({ mcp: ['filesystem=npx -y @modelcontextprotocol/server-filesystem /tmp'] }, null, 2) + '\n', + ); + + target.install('global', { autoAllow: true }); + + const config = JSON.parse(fs.readFileSync(configFile, 'utf-8')); + expect(config.mcp).toContain('filesystem=npx -y @modelcontextprotocol/server-filesystem /tmp'); + expect(config.mcp).toContain('codegraph=codegraph serve --mcp'); + }); + + it('install writes REASONIX.md with the codegraph instructions block', () => { + const target = getTarget('reasonix')!; + target.install('global', { autoAllow: true }); + + const reaosnixMd = path.join(tmpHome, '.reasonix', 'REASONIX.md'); + expect(fs.existsSync(reaosnixMd)).toBe(true); + const body = fs.readFileSync(reaosnixMd, 'utf-8'); + expect(body).toContain(''); + expect(body).toContain(''); + expect(body).toContain('codegraph_callers'); + }); + + it('REASONIX.md preserves pre-existing user content outside markers', () => { + const target = getTarget('reasonix')!; + const reaosnixMd = path.join(tmpHome, '.reasonix', 'REASONIX.md'); + fs.mkdirSync(path.dirname(reaosnixMd), { recursive: true }); + fs.writeFileSync(reaosnixMd, '# My Reasonix setup\n\nAlways use Chinese.\n'); + + target.install('global', { autoAllow: true }); + + const body = fs.readFileSync(reaosnixMd, 'utf-8'); + expect(body).toContain('# My Reasonix setup'); + expect(body).toContain('Always use Chinese.'); + expect(body).toContain(''); + }); + + it('uninstall removes the codegraph MCP entry from config.json', () => { + const target = getTarget('reasonix')!; + const configFile = path.join(tmpHome, '.reasonix', 'config.json'); + fs.mkdirSync(path.dirname(configFile), { recursive: true }); + fs.writeFileSync( + configFile, + JSON.stringify({ + mcp: [ + 'filesystem=npx -y @modelcontextprotocol/server-filesystem /tmp', + 'codegraph=codegraph serve --mcp', + ], + }, null, 2) + '\n', + ); + + target.uninstall('global'); + + const config = JSON.parse(fs.readFileSync(configFile, 'utf-8')); + expect(config.mcp).not.toContain('codegraph=codegraph serve --mcp'); + expect(config.mcp).toContain('filesystem=npx -y @modelcontextprotocol/server-filesystem /tmp'); + expect(target.detect('global').alreadyConfigured).toBe(false); + }); + + it('uninstall strips the codegraph block from REASONIX.md but preserves user content', () => { + const target = getTarget('reasonix')!; + const reaosnixMd = path.join(tmpHome, '.reasonix', 'REASONIX.md'); + fs.mkdirSync(path.dirname(reaosnixMd), { recursive: true }); + fs.writeFileSync(reaosnixMd, '# My Reasonix setup\n\nAlways use Chinese.\n'); + + target.install('global', { autoAllow: true }); + target.uninstall('global'); + + const body = fs.readFileSync(reaosnixMd, 'utf-8'); + expect(body).toContain('# My Reasonix setup'); + expect(body).toContain('Always use Chinese.'); + expect(body).not.toContain('CODEGRAPH_START'); + expect(body).not.toContain('codegraph_callers'); + }); + + it('local install writes to ./.reasonix/config.json and ./REASONIX.md', () => { + const target = getTarget('reasonix')!; + const result = target.install('local', { autoAllow: true }); + + const paths = result.files.map((f) => f.path.replace(/\\/g, '/')); + expect(paths.some((p) => p.endsWith('/.reasonix/config.json'))).toBe(true); + expect(paths.some((p) => p.endsWith('/REASONIX.md') && !p.includes('.reasonix'))).toBe(true); + }); + + it('printConfig returns non-empty output without writing files', () => { + const target = getTarget('reasonix')!; + const before = [ + ...listAllFiles(tmpHome), + ...listAllFiles(tmpCwd), + ]; + const out = target.printConfig('global'); + expect(out.length).toBeGreaterThan(0); + expect(out).toContain('codegraph'); + const after = [ + ...listAllFiles(tmpHome), + ...listAllFiles(tmpCwd), + ]; + expect(after.sort()).toEqual(before.sort()); + }); +}); + describe('Installer targets — registry', () => { it('getTarget returns the right target for each id', () => { expect(getTarget('claude')?.id).toBe('claude'); @@ -1049,6 +1209,7 @@ describe('Installer targets — registry', () => { expect(getTarget('gemini')?.id).toBe('gemini'); expect(getTarget('antigravity')?.id).toBe('antigravity'); expect(getTarget('kiro')?.id).toBe('kiro'); + expect(getTarget('reasonix')?.id).toBe('reasonix'); expect(getTarget('not-a-real-target')).toBeUndefined(); }); diff --git a/src/installer/targets/reasonix.ts b/src/installer/targets/reasonix.ts new file mode 100644 index 00000000..87d6a0e6 --- /dev/null +++ b/src/installer/targets/reasonix.ts @@ -0,0 +1,194 @@ +/** + * Reasonix target. + * + * Reasonix stores MCP server config in `~/.reasonix/config.json` + * (global) or `./.reasonix/config.json` (local). The `mcp` key is a + * **string array** where each entry is `"name=command args..."`: + * + * { "mcp": ["filesystem=npx -y @modelcontextprotocol/server-filesystem /path", "codegraph=codegraph serve --mcp"] } + * + * Instructions go into `~/.reasonix/REASONIX.md` (global) or + * `./REASONIX.md` (local) with the standard marker-delimited section. + * + * No permissions concept — autoAllow is silently ignored. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + AgentTarget, + DetectionResult, + InstallOptions, + Location, + WriteResult, +} from './types'; +import { + readJsonFile, + removeMarkedSection, + replaceOrAppendMarkedSection, + writeJsonFile, +} from './shared'; +import { + CODEGRAPH_SECTION_END, + CODEGRAPH_SECTION_START, + INSTRUCTIONS_TEMPLATE, +} from '../instructions-template'; + +/** The MCP entry string for codegraph in Reasonix's array format. */ +export const CODEGRAPH_MCP_ENTRY = 'codegraph=codegraph serve --mcp'; + +function configDir(loc: Location): string { + return loc === 'global' + ? path.join(os.homedir(), '.reasonix') + : path.join(process.cwd(), '.reasonix'); +} + +function configPath(loc: Location): string { + return path.join(configDir(loc), 'config.json'); +} + +function instructionsPath(loc: Location): string { + // Global REASONIX.md lives under ~/.reasonix/; project-local + // REASONIX.md lives at the project root (NOT under .reasonix/). + return loc === 'global' + ? path.join(configDir('global'), 'REASONIX.md') + : path.join(process.cwd(), 'REASONIX.md'); +} + +/** + * Find the index of the codegraph entry in the `mcp` string array. + * Each entry is `"name=..."` — we look for the one starting with + * `"codegraph="`. + */ +function findCodegraphIndex(config: Record): number { + const mcp = config.mcp; + if (!Array.isArray(mcp)) return -1; + return mcp.findIndex((entry: any) => typeof entry === 'string' && entry.startsWith('codegraph=')); +} + +/** + * Check if a codegraph entry already exists and matches our canonical form. + */ +function hasCanonicalEntry(config: Record): boolean { + const idx = findCodegraphIndex(config); + if (idx === -1) return false; + return config.mcp[idx] === CODEGRAPH_MCP_ENTRY; +} + +class ReasonixTarget implements AgentTarget { + readonly id = 'reasonix' as const; + readonly displayName = 'Reasonix'; + readonly docsUrl = 'https://reasonix.dev'; + + supportsLocation(_loc: Location): boolean { + return true; + } + + detect(loc: Location): DetectionResult { + const file = configPath(loc); + const config = readJsonFile(file); + const alreadyConfigured = findCodegraphIndex(config) !== -1; + const installed = loc === 'global' + ? fs.existsSync(configDir('global')) || fs.existsSync(file) + : fs.existsSync(file) || fs.existsSync(configDir('local')); + return { installed, alreadyConfigured, configPath: file }; + } + + install(loc: Location, _opts: InstallOptions): WriteResult { + const files: WriteResult['files'] = []; + files.push(writeMcpEntry(loc)); + files.push(writeInstructionsEntry(loc)); + return { + files, + notes: ['Restart Reasonix for MCP changes to take effect.'], + }; + } + + uninstall(loc: Location): WriteResult { + const files: WriteResult['files'] = []; + + files.push(removeMcpEntry(loc)); + + const instr = instructionsPath(loc); + const action = removeMarkedSection(instr, CODEGRAPH_SECTION_START, CODEGRAPH_SECTION_END); + files.push({ path: instr, action }); + + return { files }; + } + + printConfig(loc: Location): string { + const target = configPath(loc); + const snippet = JSON.stringify({ mcp: [CODEGRAPH_MCP_ENTRY] }, 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 dir = path.dirname(file); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + + const config = readJsonFile(file); + + if (hasCanonicalEntry(config)) { + return { path: file, action: 'unchanged' }; + } + + const action: 'created' | 'updated' = + findCodegraphIndex(config) !== -1 ? 'updated' : (fs.existsSync(file) ? 'updated' : 'created'); + + if (!Array.isArray(config.mcp)) { + config.mcp = []; + } + + const idx = findCodegraphIndex(config); + if (idx !== -1) { + config.mcp[idx] = CODEGRAPH_MCP_ENTRY; + } else { + config.mcp.push(CODEGRAPH_MCP_ENTRY); + } + + writeJsonFile(file, config); + return { path: file, action }; +} + +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 }; +} + +function removeMcpEntry(loc: Location): WriteResult['files'][number] { + const file = configPath(loc); + if (!fs.existsSync(file)) return { path: file, action: 'not-found' }; + + const config = readJsonFile(file); + const idx = findCodegraphIndex(config); + if (idx === -1) return { path: file, action: 'not-found' }; + + config.mcp.splice(idx, 1); + if (config.mcp.length === 0) { + delete config.mcp; + } + writeJsonFile(file, config); + return { path: file, action: 'removed' }; +} + +export const reasonixTarget: AgentTarget = new ReasonixTarget(); diff --git a/src/installer/targets/registry.ts b/src/installer/targets/registry.ts index 5e929d46..53e19ea2 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 { reasonixTarget } from './reasonix'; export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ claudeTarget, @@ -26,6 +27,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([ geminiTarget, antigravityTarget, kiroTarget, + reasonixTarget, ]); export function getTarget(id: string): AgentTarget | undefined { diff --git a/src/installer/targets/types.ts b/src/installer/targets/types.ts index 0ded6ce0..904dff0f 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' | 'reasonix'; /** * Result of `target.detect(location)`.