hermes.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. /**
  2. * Hermes Agent target.
  3. *
  4. * Hermes reads MCP servers from `$HERMES_HOME/config.yaml` under the
  5. * top-level `mcp_servers` key, and exposes discovered MCP tools through
  6. * dynamic toolsets named `mcp-<server>`. We add:
  7. *
  8. * mcp_servers.codegraph -> `codegraph serve --mcp`
  9. * platform_toolsets.cli -> `mcp-codegraph`
  10. *
  11. * The second entry matters because Hermes CLI profiles often enable an
  12. * explicit `platform_toolsets.cli` list. Without `mcp-codegraph` in that
  13. * list, the MCP server can be configured and connected but its tools may
  14. * still be filtered out of normal CLI sessions.
  15. */
  16. import * as fs from 'fs';
  17. import * as path from 'path';
  18. import * as os from 'os';
  19. import {
  20. AgentTarget,
  21. DetectionResult,
  22. InstallOptions,
  23. Location,
  24. WriteResult,
  25. } from './types';
  26. import { atomicWriteFileSync } from './shared';
  27. type LineRange = { start: number; end: number };
  28. class HermesTarget implements AgentTarget {
  29. readonly id = 'hermes' as const;
  30. readonly displayName = 'Hermes Agent';
  31. readonly docsUrl = 'https://hermes-agent.nousresearch.com';
  32. supportsLocation(loc: Location): boolean {
  33. return loc === 'global';
  34. }
  35. detect(loc: Location): DetectionResult {
  36. if (loc !== 'global') {
  37. return { installed: false, alreadyConfigured: false };
  38. }
  39. const file = configPath();
  40. const content = readText(file);
  41. const installed = fs.existsSync(hermesHome()) || fs.existsSync(file);
  42. return {
  43. installed,
  44. alreadyConfigured: hasCodeGraphMcpServer(content),
  45. configPath: file,
  46. };
  47. }
  48. install(loc: Location, _opts: InstallOptions): WriteResult {
  49. if (loc !== 'global') {
  50. return {
  51. files: [],
  52. notes: ['Hermes Agent uses $HERMES_HOME/config.yaml; re-run with --location=global.'],
  53. };
  54. }
  55. return {
  56. files: [writeHermesConfig()],
  57. notes: ['Start a new Hermes session for MCP changes to take effect.'],
  58. };
  59. }
  60. uninstall(loc: Location): WriteResult {
  61. if (loc !== 'global') return { files: [] };
  62. const file = configPath();
  63. if (!fs.existsSync(file)) {
  64. return { files: [{ path: file, action: 'not-found' }] };
  65. }
  66. const before = readText(file);
  67. const after = removeCodeGraphToolset(removeCodeGraphMcpServer(before));
  68. if (after === before) {
  69. return { files: [{ path: file, action: 'not-found' }] };
  70. }
  71. atomicWriteFileSync(file, ensureTrailingNewline(after));
  72. return { files: [{ path: file, action: 'removed' }] };
  73. }
  74. printConfig(loc: Location): string {
  75. if (loc !== 'global') {
  76. return '# Hermes Agent uses $HERMES_HOME/config.yaml; use --location=global.\n';
  77. }
  78. return [
  79. `# Add to ${configPath()}`,
  80. '',
  81. renderCodeGraphMcpBlock().join('\n'),
  82. '',
  83. 'platform_toolsets:',
  84. ' cli:',
  85. ' - hermes-cli',
  86. ' - mcp-codegraph',
  87. '',
  88. ].join('\n');
  89. }
  90. describePaths(loc: Location): string[] {
  91. return loc === 'global' ? [configPath()] : [];
  92. }
  93. }
  94. function hermesHome(): string {
  95. return process.env.HERMES_HOME
  96. ? path.resolve(process.env.HERMES_HOME)
  97. : path.join(os.homedir(), '.hermes');
  98. }
  99. function configPath(): string {
  100. return path.join(hermesHome(), 'config.yaml');
  101. }
  102. function readText(file: string): string {
  103. try {
  104. return fs.readFileSync(file, 'utf-8');
  105. } catch {
  106. return '';
  107. }
  108. }
  109. function writeHermesConfig(): WriteResult['files'][number] {
  110. const file = configPath();
  111. const existed = fs.existsSync(file);
  112. const before = readText(file);
  113. const afterMcp = upsertCodeGraphMcpServer(before);
  114. const after = upsertCodeGraphToolset(afterMcp);
  115. if (after === before) {
  116. return { path: file, action: 'unchanged' };
  117. }
  118. atomicWriteFileSync(file, ensureTrailingNewline(after));
  119. return { path: file, action: existed ? 'updated' : 'created' };
  120. }
  121. function ensureTrailingNewline(text: string): string {
  122. return text.endsWith('\n') ? text : text + '\n';
  123. }
  124. function splitLines(content: string): string[] {
  125. return content.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
  126. }
  127. function joinLines(lines: string[]): string {
  128. while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
  129. return lines.join('\n') + '\n';
  130. }
  131. function topLevelRange(lines: string[], key: string): LineRange | null {
  132. const start = lines.findIndex((line) => line.trim() === `${key}:`);
  133. if (start === -1) return null;
  134. let end = lines.length;
  135. for (let i = start + 1; i < lines.length; i++) {
  136. const line = lines[i] ?? '';
  137. if (line.trim() === '') continue;
  138. if (/^[A-Za-z_][A-Za-z0-9_-]*:\s*(?:#.*)?$/.test(line)) {
  139. end = i;
  140. break;
  141. }
  142. }
  143. return { start, end };
  144. }
  145. function childRange(lines: string[], parent: LineRange, child: string): LineRange | null {
  146. const startPattern = new RegExp(`^ ${escapeRegExp(child)}:\\s*(?:#.*)?$`);
  147. let start = -1;
  148. for (let i = parent.start + 1; i < parent.end; i++) {
  149. if (startPattern.test(lines[i] ?? '')) {
  150. start = i;
  151. break;
  152. }
  153. }
  154. if (start === -1) return null;
  155. let end = parent.end;
  156. for (let i = start + 1; i < parent.end; i++) {
  157. const line = lines[i] ?? '';
  158. if (line.trim() === '') continue;
  159. if (/^ \S/.test(line)) {
  160. end = i;
  161. break;
  162. }
  163. }
  164. while (end > start + 1 && (lines[end - 1] ?? '').trim() === '') {
  165. end--;
  166. }
  167. return { start, end };
  168. }
  169. function escapeRegExp(value: string): string {
  170. return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  171. }
  172. function renderCodeGraphMcpChild(): string[] {
  173. return [
  174. ' codegraph:',
  175. ' command: codegraph',
  176. ' args:',
  177. ' - serve',
  178. ' - --mcp',
  179. ' timeout: 120',
  180. ' connect_timeout: 60',
  181. ' enabled: true',
  182. ];
  183. }
  184. function renderCodeGraphMcpBlock(): string[] {
  185. return ['mcp_servers:', ...renderCodeGraphMcpChild()];
  186. }
  187. function hasCodeGraphMcpServer(content: string): boolean {
  188. const lines = splitLines(content);
  189. const parent = topLevelRange(lines, 'mcp_servers');
  190. return !!parent && !!childRange(lines, parent, 'codegraph');
  191. }
  192. function upsertCodeGraphMcpServer(content: string): string {
  193. const lines = splitLines(content);
  194. const parent = topLevelRange(lines, 'mcp_servers');
  195. const child = parent ? childRange(lines, parent, 'codegraph') : null;
  196. const replacement = renderCodeGraphMcpChild();
  197. if (!parent) {
  198. if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
  199. if (lines.length > 0) lines.push('');
  200. lines.push(...renderCodeGraphMcpBlock());
  201. return joinLines(lines);
  202. }
  203. if (child) {
  204. const existing = lines.slice(child.start, child.end);
  205. if (arrayEqual(existing, replacement)) return joinLines(lines);
  206. lines.splice(child.start, child.end - child.start, ...replacement);
  207. return joinLines(lines);
  208. }
  209. lines.splice(parent.end, 0, ...replacement);
  210. return joinLines(lines);
  211. }
  212. function removeCodeGraphMcpServer(content: string): string {
  213. const lines = splitLines(content);
  214. const parent = topLevelRange(lines, 'mcp_servers');
  215. const child = parent ? childRange(lines, parent, 'codegraph') : null;
  216. if (!child) return content;
  217. lines.splice(child.start, child.end - child.start);
  218. return joinLines(lines);
  219. }
  220. function upsertCodeGraphToolset(content: string): string {
  221. const lines = splitLines(content);
  222. const parent = topLevelRange(lines, 'platform_toolsets');
  223. const cli = parent ? childRange(lines, parent, 'cli') : null;
  224. if (!parent) {
  225. if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
  226. if (lines.length > 0) lines.push('');
  227. lines.push('platform_toolsets:', ' cli:', ' - hermes-cli', ' - mcp-codegraph');
  228. return joinLines(lines);
  229. }
  230. if (!cli) {
  231. lines.splice(parent.end, 0, ' cli:', ' - hermes-cli', ' - mcp-codegraph');
  232. return joinLines(lines);
  233. }
  234. const hasEntry = lines
  235. .slice(cli.start + 1, cli.end)
  236. .some((line) => line.trim() === '- mcp-codegraph');
  237. if (hasEntry) return joinLines(lines);
  238. lines.splice(cli.end, 0, ' - mcp-codegraph');
  239. return joinLines(lines);
  240. }
  241. function removeCodeGraphToolset(content: string): string {
  242. const lines = splitLines(content);
  243. const parent = topLevelRange(lines, 'platform_toolsets');
  244. const cli = parent ? childRange(lines, parent, 'cli') : null;
  245. if (!cli) return content;
  246. const hasEntry = lines
  247. .slice(cli.start + 1, cli.end)
  248. .some((line) => line.trim() === '- mcp-codegraph');
  249. if (!hasEntry) return content;
  250. const next = lines.filter((line, idx) => {
  251. if (idx <= cli.start || idx >= cli.end) return true;
  252. return line.trim() !== '- mcp-codegraph';
  253. });
  254. return joinLines(next);
  255. }
  256. function arrayEqual(a: string[], b: string[]): boolean {
  257. return a.length === b.length && a.every((value, idx) => value === b[idx]);
  258. }
  259. export const hermesTarget: AgentTarget = new HermesTarget();