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:
roach
2026-05-21 17:03:18 -05:00
committed by GitHub
parent cda42c8222
commit 95dace987e
7 changed files with 374 additions and 11 deletions
+6 -5
View File
@@ -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 @@
[![Cursor](https://img.shields.io/badge/Cursor-supported-blueviolet.svg)](#)
[![Codex CLI](https://img.shields.io/badge/Codex_CLI-supported-blueviolet.svg)](#)
[![opencode](https://img.shields.io/badge/opencode-supported-blueviolet.svg)](#)
[![Hermes Agent](https://img.shields.io/badge/Hermes_Agent-supported-blueviolet.svg)](#)
</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)
+63 -3
View File
@@ -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();
});
+1 -1
View File
@@ -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 -1
View File
@@ -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.
*
+299
View File
@@ -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();
+2
View File
@@ -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 {
+1 -1
View File
@@ -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)`.