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:
@@ -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