feat(installer): add Hermes Agent target (#274)
Adds Hermes Agent (Nous Research) as a CodeGraph installer target. Writes mcp_servers.codegraph and ensures platform_toolsets.cli includes mcp-codegraph in $HERMES_HOME/config.yaml, with full installer contract-test coverage.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
# CodeGraph
|
||||
|
||||
### Supercharge Claude Code, Cursor, Codex, and OpenCode with Semantic Code Intelligence
|
||||
### Supercharge Claude Code, Cursor, Codex, OpenCode, and Hermes Agent with Semantic Code Intelligence
|
||||
|
||||
**~35% cheaper · ~70% fewer tool calls · 100% local**
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
[](#)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -40,7 +41,7 @@ npx @colbymchenry/codegraph # zero-install, or:
|
||||
npm i -g @colbymchenry/codegraph
|
||||
```
|
||||
|
||||
<sub>CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode.</sub>
|
||||
<sub>CodeGraph bundles its own runtime — nothing to compile, no native build, works the same everywhere. The interactive installer auto-configures your agent(s) — Claude Code, Cursor, Codex CLI, opencode, Hermes Agent.</sub>
|
||||
|
||||
### Initialize Projects
|
||||
|
||||
@@ -159,7 +160,7 @@ npx @colbymchenry/codegraph
|
||||
```
|
||||
|
||||
The installer will:
|
||||
- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**
|
||||
- Ask which agent(s) to configure — auto-detects installed ones from: **Claude Code**, **Cursor**, **Codex CLI**, **opencode**, **Hermes Agent**
|
||||
- Prompt to install `codegraph` on your PATH (so agents can launch the MCP server)
|
||||
- Ask whether configs apply to all your projects or just this one
|
||||
- Write each chosen agent's MCP server config + an instructions file (e.g. `CLAUDE.md`, `.cursor/rules/codegraph.mdc`, `~/.codex/AGENTS.md`)
|
||||
@@ -185,7 +186,7 @@ codegraph install --print-config codex # print snippet, no file wr
|
||||
|
||||
### 2. Restart Your Agent
|
||||
|
||||
Restart your agent (Claude Code / Cursor / Codex CLI / opencode) for the MCP server to load.
|
||||
Restart your agent (Claude Code / Cursor / Codex CLI / opencode / Hermes Agent) for the MCP server to load.
|
||||
|
||||
### 3. Initialize Projects
|
||||
|
||||
@@ -498,7 +499,7 @@ MIT
|
||||
|
||||
<div align="center">
|
||||
|
||||
**Made for AI coding agents — Claude Code, Cursor, Codex CLI, and opencode**
|
||||
**Made for AI coding agents — Claude Code, Cursor, Codex CLI, opencode, and Hermes Agent**
|
||||
|
||||
[Report Bug](https://github.com/colbymchenry/codegraph/issues) · [Request Feature](https://github.com/colbymchenry/codegraph/issues)
|
||||
|
||||
|
||||
@@ -31,13 +31,25 @@ function mkTmpDir(label: string): string {
|
||||
// `os.homedir()` reads first. Same trick the rest of the suite uses
|
||||
// when it needs a mock home.
|
||||
function setHome(dir: string): { restore: () => void } {
|
||||
const prev = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
|
||||
const prev = {
|
||||
HOME: process.env.HOME,
|
||||
USERPROFILE: process.env.USERPROFILE,
|
||||
APPDATA: process.env.APPDATA,
|
||||
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
||||
HERMES_HOME: process.env.HERMES_HOME,
|
||||
};
|
||||
process.env.HOME = dir;
|
||||
process.env.USERPROFILE = dir;
|
||||
process.env.APPDATA = path.join(dir, '.config');
|
||||
process.env.XDG_CONFIG_HOME = path.join(dir, '.config');
|
||||
delete process.env.HERMES_HOME;
|
||||
return {
|
||||
restore() {
|
||||
if (prev.HOME === undefined) delete process.env.HOME; else process.env.HOME = prev.HOME;
|
||||
if (prev.USERPROFILE === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = prev.USERPROFILE;
|
||||
if (prev.APPDATA === undefined) delete process.env.APPDATA; else process.env.APPDATA = prev.APPDATA;
|
||||
if (prev.XDG_CONFIG_HOME === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prev.XDG_CONFIG_HOME;
|
||||
if (prev.HERMES_HOME === undefined) delete process.env.HERMES_HOME; else process.env.HERMES_HOME = prev.HERMES_HOME;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -298,12 +310,59 @@ describe('Installer targets — partial-state idempotency', () => {
|
||||
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);
|
||||
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('/opencode.jsonc'))).toBe(true);
|
||||
expect(paths.some((p) => p.endsWith('/AGENTS.md'))).toBe(true);
|
||||
});
|
||||
|
||||
it('hermes: install adds codegraph MCP server and cli toolset, preserving existing yaml', () => {
|
||||
const hermes = getTarget('hermes')!;
|
||||
const config = path.join(tmpHome, '.hermes', 'config.yaml');
|
||||
fs.mkdirSync(path.dirname(config), { recursive: true });
|
||||
fs.writeFileSync(config, [
|
||||
'model:',
|
||||
' default: qwen-3.7',
|
||||
'mcp_servers:',
|
||||
' other:',
|
||||
' command: other',
|
||||
'platform_toolsets:',
|
||||
' cli:',
|
||||
' - hermes-cli',
|
||||
' discord:',
|
||||
' - hermes-discord',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const result = hermes.install('global', { autoAllow: true });
|
||||
expect(result.files[0].action).toBe('updated');
|
||||
const body = fs.readFileSync(config, 'utf-8');
|
||||
expect(body).toContain('model:\n default: qwen-3.7');
|
||||
expect(body).toContain('mcp_servers:\n other:\n command: other');
|
||||
expect(body).toContain(' codegraph:\n command: codegraph');
|
||||
expect(body).toContain(' - hermes-cli');
|
||||
expect(body).toContain(' - mcp-codegraph');
|
||||
expect(body).toContain(' discord:\n - hermes-discord');
|
||||
|
||||
const second = hermes.install('global', { autoAllow: true });
|
||||
expect(second.files[0].action).toBe('unchanged');
|
||||
});
|
||||
|
||||
it('hermes: uninstall removes only codegraph MCP server and toolset entry', () => {
|
||||
const hermes = getTarget('hermes')!;
|
||||
const config = path.join(tmpHome, '.hermes', 'config.yaml');
|
||||
fs.mkdirSync(path.dirname(config), { recursive: true });
|
||||
|
||||
hermes.install('global', { autoAllow: true });
|
||||
fs.appendFileSync(config, 'custom:\n keep: true\n');
|
||||
|
||||
hermes.uninstall('global');
|
||||
const body = fs.readFileSync(config, 'utf-8');
|
||||
expect(body).not.toContain('codegraph:');
|
||||
expect(body).not.toContain('mcp-codegraph');
|
||||
expect(body).toContain('custom:\n keep: true');
|
||||
});
|
||||
|
||||
it('opencode: uninstall removes only mcp.codegraph, preserves comments and siblings', () => {
|
||||
const opencode = getTarget('opencode')!;
|
||||
const dir = path.join(tmpHome, '.config', 'opencode');
|
||||
@@ -358,7 +417,7 @@ describe('Installer targets — partial-state idempotency', () => {
|
||||
const claude = getTarget('claude')!;
|
||||
const result = claude.install('local', { autoAllow: false });
|
||||
// The MCP entry lands in ./.mcp.json — the file Claude Code reads.
|
||||
expect(result.files.some((f) => f.path.endsWith('/.mcp.json'))).toBe(true);
|
||||
expect(result.files.some((f) => f.path.replace(/\\/g, '/').endsWith('/.mcp.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpCwd, '.mcp.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tmpCwd, '.claude.json'))).toBe(false);
|
||||
const cfg = JSON.parse(fs.readFileSync(path.join(tmpCwd, '.mcp.json'), 'utf-8'));
|
||||
@@ -556,6 +615,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('hermes')?.id).toBe('hermes');
|
||||
expect(getTarget('not-a-real-target')).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
@@ -1341,7 +1341,7 @@ program
|
||||
*/
|
||||
program
|
||||
.command('install')
|
||||
.description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode)')
|
||||
.description('Install codegraph MCP server into one or more agents (Claude Code, Cursor, Codex CLI, opencode, Hermes Agent)')
|
||||
.option('-t, --target <ids>', 'Target agent(s): comma-separated ids, or "auto"|"all"|"none". Default: prompt')
|
||||
.option('-l, --location <where>', 'Install location: "global" or "local". Default: prompt')
|
||||
.option('-y, --yes', 'Non-interactive: defaults to --location=global --target=auto, auto-allow on')
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* CodeGraph Interactive Installer
|
||||
*
|
||||
* Multi-target: writes MCP server config + instructions for the
|
||||
* agents the user picks (Claude Code, Cursor, Codex CLI, opencode).
|
||||
* agents the user picks (Claude Code, Cursor, Codex CLI, opencode,
|
||||
* Hermes Agent).
|
||||
* Defaults to the Claude-only behavior for backwards compatibility
|
||||
* when no targets are explicitly chosen and nothing else is detected.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Hermes Agent target.
|
||||
*
|
||||
* Hermes reads MCP servers from `$HERMES_HOME/config.yaml` under the
|
||||
* top-level `mcp_servers` key, and exposes discovered MCP tools through
|
||||
* dynamic toolsets named `mcp-<server>`. We add:
|
||||
*
|
||||
* mcp_servers.codegraph -> `codegraph serve --mcp`
|
||||
* platform_toolsets.cli -> `mcp-codegraph`
|
||||
*
|
||||
* The second entry matters because Hermes CLI profiles often enable an
|
||||
* explicit `platform_toolsets.cli` list. Without `mcp-codegraph` in that
|
||||
* list, the MCP server can be configured and connected but its tools may
|
||||
* still be filtered out of normal CLI sessions.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import {
|
||||
AgentTarget,
|
||||
DetectionResult,
|
||||
InstallOptions,
|
||||
Location,
|
||||
WriteResult,
|
||||
} from './types';
|
||||
import { atomicWriteFileSync } from './shared';
|
||||
|
||||
type LineRange = { start: number; end: number };
|
||||
|
||||
class HermesTarget implements AgentTarget {
|
||||
readonly id = 'hermes' as const;
|
||||
readonly displayName = 'Hermes Agent';
|
||||
readonly docsUrl = 'https://hermes-agent.nousresearch.com';
|
||||
|
||||
supportsLocation(loc: Location): boolean {
|
||||
return loc === 'global';
|
||||
}
|
||||
|
||||
detect(loc: Location): DetectionResult {
|
||||
if (loc !== 'global') {
|
||||
return { installed: false, alreadyConfigured: false };
|
||||
}
|
||||
const file = configPath();
|
||||
const content = readText(file);
|
||||
const installed = fs.existsSync(hermesHome()) || fs.existsSync(file);
|
||||
return {
|
||||
installed,
|
||||
alreadyConfigured: hasCodeGraphMcpServer(content),
|
||||
configPath: file,
|
||||
};
|
||||
}
|
||||
|
||||
install(loc: Location, _opts: InstallOptions): WriteResult {
|
||||
if (loc !== 'global') {
|
||||
return {
|
||||
files: [],
|
||||
notes: ['Hermes Agent uses $HERMES_HOME/config.yaml; re-run with --location=global.'],
|
||||
};
|
||||
}
|
||||
return {
|
||||
files: [writeHermesConfig()],
|
||||
notes: ['Start a new Hermes session for MCP changes to take effect.'],
|
||||
};
|
||||
}
|
||||
|
||||
uninstall(loc: Location): WriteResult {
|
||||
if (loc !== 'global') return { files: [] };
|
||||
const file = configPath();
|
||||
if (!fs.existsSync(file)) {
|
||||
return { files: [{ path: file, action: 'not-found' }] };
|
||||
}
|
||||
|
||||
const before = readText(file);
|
||||
const after = removeCodeGraphToolset(removeCodeGraphMcpServer(before));
|
||||
if (after === before) {
|
||||
return { files: [{ path: file, action: 'not-found' }] };
|
||||
}
|
||||
atomicWriteFileSync(file, ensureTrailingNewline(after));
|
||||
return { files: [{ path: file, action: 'removed' }] };
|
||||
}
|
||||
|
||||
printConfig(loc: Location): string {
|
||||
if (loc !== 'global') {
|
||||
return '# Hermes Agent uses $HERMES_HOME/config.yaml; use --location=global.\n';
|
||||
}
|
||||
return [
|
||||
`# Add to ${configPath()}`,
|
||||
'',
|
||||
renderCodeGraphMcpBlock().join('\n'),
|
||||
'',
|
||||
'platform_toolsets:',
|
||||
' cli:',
|
||||
' - hermes-cli',
|
||||
' - mcp-codegraph',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
describePaths(loc: Location): string[] {
|
||||
return loc === 'global' ? [configPath()] : [];
|
||||
}
|
||||
}
|
||||
|
||||
function hermesHome(): string {
|
||||
return process.env.HERMES_HOME
|
||||
? path.resolve(process.env.HERMES_HOME)
|
||||
: path.join(os.homedir(), '.hermes');
|
||||
}
|
||||
|
||||
function configPath(): string {
|
||||
return path.join(hermesHome(), 'config.yaml');
|
||||
}
|
||||
|
||||
function readText(file: string): string {
|
||||
try {
|
||||
return fs.readFileSync(file, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function writeHermesConfig(): WriteResult['files'][number] {
|
||||
const file = configPath();
|
||||
const existed = fs.existsSync(file);
|
||||
const before = readText(file);
|
||||
const afterMcp = upsertCodeGraphMcpServer(before);
|
||||
const after = upsertCodeGraphToolset(afterMcp);
|
||||
|
||||
if (after === before) {
|
||||
return { path: file, action: 'unchanged' };
|
||||
}
|
||||
atomicWriteFileSync(file, ensureTrailingNewline(after));
|
||||
return { path: file, action: existed ? 'updated' : 'created' };
|
||||
}
|
||||
|
||||
function ensureTrailingNewline(text: string): string {
|
||||
return text.endsWith('\n') ? text : text + '\n';
|
||||
}
|
||||
|
||||
function splitLines(content: string): string[] {
|
||||
return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
|
||||
}
|
||||
|
||||
function joinLines(lines: string[]): string {
|
||||
while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function topLevelRange(lines: string[], key: string): LineRange | null {
|
||||
const start = lines.findIndex((line) => line.trim() === `${key}:`);
|
||||
if (start === -1) return null;
|
||||
let end = lines.length;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const line = lines[i] ?? '';
|
||||
if (line.trim() === '') continue;
|
||||
if (/^[A-Za-z_][A-Za-z0-9_-]*:\s*(?:#.*)?$/.test(line)) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function childRange(lines: string[], parent: LineRange, child: string): LineRange | null {
|
||||
const startPattern = new RegExp(`^ ${escapeRegExp(child)}:\\s*(?:#.*)?$`);
|
||||
let start = -1;
|
||||
for (let i = parent.start + 1; i < parent.end; i++) {
|
||||
if (startPattern.test(lines[i] ?? '')) {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (start === -1) return null;
|
||||
|
||||
let end = parent.end;
|
||||
for (let i = start + 1; i < parent.end; i++) {
|
||||
const line = lines[i] ?? '';
|
||||
if (line.trim() === '') continue;
|
||||
if (/^ \S/.test(line)) {
|
||||
end = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
while (end > start + 1 && (lines[end - 1] ?? '').trim() === '') {
|
||||
end--;
|
||||
}
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function renderCodeGraphMcpChild(): string[] {
|
||||
return [
|
||||
' codegraph:',
|
||||
' command: codegraph',
|
||||
' args:',
|
||||
' - serve',
|
||||
' - --mcp',
|
||||
' timeout: 120',
|
||||
' connect_timeout: 60',
|
||||
' enabled: true',
|
||||
];
|
||||
}
|
||||
|
||||
function renderCodeGraphMcpBlock(): string[] {
|
||||
return ['mcp_servers:', ...renderCodeGraphMcpChild()];
|
||||
}
|
||||
|
||||
function hasCodeGraphMcpServer(content: string): boolean {
|
||||
const lines = splitLines(content);
|
||||
const parent = topLevelRange(lines, 'mcp_servers');
|
||||
return !!parent && !!childRange(lines, parent, 'codegraph');
|
||||
}
|
||||
|
||||
function upsertCodeGraphMcpServer(content: string): string {
|
||||
const lines = splitLines(content);
|
||||
const parent = topLevelRange(lines, 'mcp_servers');
|
||||
const child = parent ? childRange(lines, parent, 'codegraph') : null;
|
||||
const replacement = renderCodeGraphMcpChild();
|
||||
|
||||
if (!parent) {
|
||||
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
||||
if (lines.length > 0) lines.push('');
|
||||
lines.push(...renderCodeGraphMcpBlock());
|
||||
return joinLines(lines);
|
||||
}
|
||||
|
||||
if (child) {
|
||||
const existing = lines.slice(child.start, child.end);
|
||||
if (arrayEqual(existing, replacement)) return joinLines(lines);
|
||||
lines.splice(child.start, child.end - child.start, ...replacement);
|
||||
return joinLines(lines);
|
||||
}
|
||||
|
||||
lines.splice(parent.end, 0, ...replacement);
|
||||
return joinLines(lines);
|
||||
}
|
||||
|
||||
function removeCodeGraphMcpServer(content: string): string {
|
||||
const lines = splitLines(content);
|
||||
const parent = topLevelRange(lines, 'mcp_servers');
|
||||
const child = parent ? childRange(lines, parent, 'codegraph') : null;
|
||||
if (!child) return content;
|
||||
lines.splice(child.start, child.end - child.start);
|
||||
return joinLines(lines);
|
||||
}
|
||||
|
||||
function upsertCodeGraphToolset(content: string): string {
|
||||
const lines = splitLines(content);
|
||||
const parent = topLevelRange(lines, 'platform_toolsets');
|
||||
const cli = parent ? childRange(lines, parent, 'cli') : null;
|
||||
|
||||
if (!parent) {
|
||||
if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
|
||||
if (lines.length > 0) lines.push('');
|
||||
lines.push('platform_toolsets:', ' cli:', ' - hermes-cli', ' - mcp-codegraph');
|
||||
return joinLines(lines);
|
||||
}
|
||||
|
||||
if (!cli) {
|
||||
lines.splice(parent.end, 0, ' cli:', ' - hermes-cli', ' - mcp-codegraph');
|
||||
return joinLines(lines);
|
||||
}
|
||||
|
||||
const hasEntry = lines
|
||||
.slice(cli.start + 1, cli.end)
|
||||
.some((line) => line.trim() === '- mcp-codegraph');
|
||||
if (hasEntry) return joinLines(lines);
|
||||
|
||||
lines.splice(cli.end, 0, ' - mcp-codegraph');
|
||||
return joinLines(lines);
|
||||
}
|
||||
|
||||
function removeCodeGraphToolset(content: string): string {
|
||||
const lines = splitLines(content);
|
||||
const parent = topLevelRange(lines, 'platform_toolsets');
|
||||
const cli = parent ? childRange(lines, parent, 'cli') : null;
|
||||
if (!cli) return content;
|
||||
|
||||
const hasEntry = lines
|
||||
.slice(cli.start + 1, cli.end)
|
||||
.some((line) => line.trim() === '- mcp-codegraph');
|
||||
if (!hasEntry) return content;
|
||||
|
||||
const next = lines.filter((line, idx) => {
|
||||
if (idx <= cli.start || idx >= cli.end) return true;
|
||||
return line.trim() !== '- mcp-codegraph';
|
||||
});
|
||||
return joinLines(next);
|
||||
}
|
||||
|
||||
function arrayEqual(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((value, idx) => value === b[idx]);
|
||||
}
|
||||
|
||||
export const hermesTarget: AgentTarget = new HermesTarget();
|
||||
@@ -12,12 +12,14 @@ import { claudeTarget } from './claude';
|
||||
import { cursorTarget } from './cursor';
|
||||
import { codexTarget } from './codex';
|
||||
import { opencodeTarget } from './opencode';
|
||||
import { hermesTarget } from './hermes';
|
||||
|
||||
export const ALL_TARGETS: readonly AgentTarget[] = Object.freeze([
|
||||
claudeTarget,
|
||||
cursorTarget,
|
||||
codexTarget,
|
||||
opencodeTarget,
|
||||
hermesTarget,
|
||||
]);
|
||||
|
||||
export function getTarget(id: string): AgentTarget | undefined {
|
||||
|
||||
@@ -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';
|
||||
export type TargetId = 'claude' | 'cursor' | 'codex' | 'opencode' | 'hermes';
|
||||
|
||||
/**
|
||||
* Result of `target.detect(location)`.
|
||||
|
||||
Reference in New Issue
Block a user