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
+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)`.