Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 161 additions & 0 deletions __tests__/installer-targets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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();
Expand Down Expand Up @@ -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('<!-- CODEGRAPH_START -->');
expect(body).toContain('<!-- CODEGRAPH_END -->');
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('<!-- CODEGRAPH_START -->');
});

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');
Expand All @@ -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();
});

Expand Down
194 changes: 194 additions & 0 deletions src/installer/targets/reasonix.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>): 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<string, any>): 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();
2 changes: 2 additions & 0 deletions src/installer/targets/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,6 +27,7 @@ export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
geminiTarget,
antigravityTarget,
kiroTarget,
reasonixTarget,
]);

export function getTarget(id: string): AgentTarget | undefined {
Expand Down
Loading