Răsfoiți Sursa

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.
roach 1 lună în urmă
părinte
comite
95dace987e

+ 6 - 5
README.md

@@ -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
__tests__/installer-targets.test.ts

@@ -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
src/bin/codegraph.ts

@@ -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
src/installer/index.ts

@@ -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 - 0
src/installer/targets/hermes.ts

@@ -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 - 0
src/installer/targets/registry.ts

@@ -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
src/installer/targets/types.ts

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