|
@@ -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();
|