From e76cc547b0291eba6b34c4416f5d17814b033cbc Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 26 May 2026 12:20:58 -0500 Subject: [PATCH] fix(hermes): preserve YAML list-at-same-indent style on install (#456) (#461) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes Agent writes ~/.hermes/config.yaml with PyYAML's default block style, which puts list items at the SAME indent as the parent key: platform_toolsets: cli: - hermes-cli # indent 2, same as `cli:` - browser The previous line-based YAML patcher used `^ \S` to find the end of the `cli:` block, which mistook that first ` - hermes-cli` line for the next sibling key, truncated the block, and spliced ` - mcp-codegraph` at indent 4 BEFORE the existing items. The result was unparseable YAML: every subsequent item (`- browser`, `- clarify`, …) and every sibling platform (`telegram:`, `discord:`) appeared at the `platform_toolsets:` level. Hermes silently fell back to the default config, dropping every user override. The new `listChildBlock` helper recognizes ` - ` as a list-item continuation (not a sibling key), finds the real end of the block at the next sibling mapping key, and detects the existing item indent so the new entry matches it. Two regression tests cover the PyYAML-default style; the existing 4-space-nested test still passes. End-to-end verified against a real `hermes-agent` install on the exact bug-triggering config: `hermes mcp list` shows codegraph as enabled, `hermes tools --summary` lists both `mcp-codegraph` and `codegraph` in the CLI toolset, and `hermes mcp test codegraph` connects in 264ms and discovers all 10 codegraph tools. Re-running `codegraph install` reports `Unchanged` and the file still has exactly one entry. Closes #456. Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + __tests__/installer-targets.test.ts | 80 +++++++++++++++++++++++++++++ src/installer/targets/hermes.ts | 63 +++++++++++++++++++++-- 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d7a8aa..6d3ac79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Fixed +- **Hermes Agent: `codegraph install --target hermes` no longer corrupts `~/.hermes/config.yaml`.** Hermes serializes its config with PyYAML's default block style, which writes list items at the *same* indent as the parent mapping key (`cli:` and `- hermes-cli` both at column 2). The previous line-based YAML patcher mistook that first ` - hermes-cli` for the next sibling key, truncated the `cli:` block, and then spliced `- mcp-codegraph` at indent 4 *before* the existing items — leaving subsequent entries (`- browser`, `- clarify`, …) and even other platforms (`telegram:`, `discord:`) appearing at the `platform_toolsets:` level, which is no longer parseable YAML. The installer now recognizes the same-indent list style, finds the real end of the block at the next sibling key, and appends `- mcp-codegraph` at whatever indent the existing items already use. Re-installing on an already-corrupted file (or a 4-space-nested config that worked before) still produces a clean, parseable result. Closes #456. - **NestJS: `RouterModule.register([...])` route prefixes now propagate to controller routes.** Previously a controller declared inside a module wired through NestJS's `RouterModule` (a common pattern for modular apps with nested route prefixes) was indexed with its raw `@Controller(...) + @Get(...)` path — so `UsersController` under `RouterModule.register([{ path: 'admin', module: AdminModule, children: [{ path: 'users', module: UsersModule }] }])` showed up as `GET /` instead of `GET /admin/users`. The new cross-file pass walks every `*.module.{ts,js}` for `RouterModule.register/forRoot/forChild([...])` (recursive `children`) and `@Module({ controllers: [...] })`, then prepends the correct prefix to each affected route — including non-empty `@Controller` paths and method-level params (`/admin/users/:id`). The route node's `id` is preserved across the update so existing route→handler edges stay intact, and the pass is idempotent so incremental sync recovers when `app.module.ts` itself is edited. Closes #459. ### Added diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts index 2683cbb..82155ea 100644 --- a/__tests__/installer-targets.test.ts +++ b/__tests__/installer-targets.test.ts @@ -635,6 +635,86 @@ describe('Installer targets — partial-state idempotency', () => { expect(body).toContain('custom:\n keep: true'); }); + // Regression for #456: PyYAML's default block style writes list items at the + // SAME indent as the parent key (`cli:` and its `- hermes-cli` are both at + // indent 2). The pre-fix line-based patcher mistook that first list item for + // the next sibling key, truncated the cli block, and spliced `- mcp-codegraph` + // at indent 4 BEFORE the existing items — producing unparseable YAML. + it('hermes: install preserves PyYAML-default list-at-same-indent style (issue #456)', () => { + const hermes = getTarget('hermes')!; + const config = path.join(tmpHome, '.hermes', 'config.yaml'); + fs.mkdirSync(path.dirname(config), { recursive: true }); + const original = [ + 'model:', + ' default: gpt-4o', + 'platform_toolsets:', + ' cli:', + ' - hermes-cli', + ' - browser', + ' - clarify', + ' - terminal', + ' - web', + ' telegram:', + ' - hermes-telegram', + ' discord:', + ' - hermes-discord', + '', + ].join('\n'); + fs.writeFileSync(config, original); + + hermes.install('global', { autoAllow: true }); + const body = fs.readFileSync(config, 'utf-8'); + + // mcp-codegraph appended at the same 2-space indent as existing items + expect(body).toContain('\n - mcp-codegraph\n'); + // hermes-cli preserved + expect(body).toContain('\n - hermes-cli\n'); + // Sibling sections kept their indent — `telegram:` is still a key under + // platform_toolsets, not promoted up. + expect(body).toContain('\n telegram:\n - hermes-telegram\n'); + expect(body).toContain('\n discord:\n - hermes-discord\n'); + // No list items leaked to the platform_toolsets level (indent 0). + expect(body).not.toMatch(/^- browser/m); + expect(body).not.toMatch(/^- hermes-telegram/m); + + // The whole platform_toolsets block extracted by line search should + // start with `cli:` and not contain a stray 4-space `mcp-codegraph` + // appearing before the rest of the existing items. + expect(body).toContain(' cli:\n - hermes-cli\n - browser'); + + // Idempotent + const second = hermes.install('global', { autoAllow: true }); + expect(second.files[0]?.action).toBe('unchanged'); + }); + + it('hermes: uninstall reverses the install on a PyYAML-default config', () => { + const hermes = getTarget('hermes')!; + const config = path.join(tmpHome, '.hermes', 'config.yaml'); + fs.mkdirSync(path.dirname(config), { recursive: true }); + const original = [ + 'platform_toolsets:', + ' cli:', + ' - hermes-cli', + ' - browser', + ' telegram:', + ' - hermes-telegram', + '', + ].join('\n'); + fs.writeFileSync(config, original); + + hermes.install('global', { autoAllow: true }); + const installed = fs.readFileSync(config, 'utf-8'); + expect(installed).toContain('- mcp-codegraph'); + expect(installed).toContain('codegraph:'); + + hermes.uninstall('global'); + const body = fs.readFileSync(config, 'utf-8'); + expect(body).not.toContain('mcp-codegraph'); + expect(body).not.toContain('command: codegraph'); + expect(body).toContain(' cli:\n - hermes-cli\n - browser'); + expect(body).toContain(' telegram:\n - hermes-telegram'); + }); + it('opencode: uninstall removes only mcp.codegraph, preserves comments and siblings', () => { const opencode = getTarget('opencode')!; const dir = path.join(tmpHome, '.config', 'opencode'); diff --git a/src/installer/targets/hermes.ts b/src/installer/targets/hermes.ts index b6abfb9..eacab6d 100644 --- a/src/installer/targets/hermes.ts +++ b/src/installer/targets/hermes.ts @@ -188,6 +188,63 @@ function childRange(lines: string[], parent: LineRange, child: string): LineRang return { start, end }; } +/** + * Block-range for a 2-space-indented child whose value is a YAML block list. + * + * Unlike `childRange`, this handles PyYAML's default `default_flow_style=False` + * serialization, where list items sit at the SAME indent as the parent key: + * + * cli: + * - hermes-cli # indent 2 — belongs to cli, not a sibling + * - browser + * + * `childRange`'s `^ \S` heuristic mistakes that first ` - hermes-cli` line + * for the next sibling key and truncates the block, causing inserts to land + * before the existing items at a different indent (issue #456). This helper + * recognizes a ` - ` line as part of the block instead, and reports back + * the actual indent used by existing items so the inserter matches it. + */ +function listChildBlock( + lines: string[], + parent: LineRange, + child: string, +): (LineRange & { itemIndent: string }) | 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; + const indentMatch = line.match(/^( *)/); + const indent = indentMatch?.[1]?.length ?? 0; + if (indent >= 4) continue; + if (indent === 2 && /^ - /.test(line)) continue; + end = i; + break; + } + while (end > start + 1 && (lines[end - 1] ?? '').trim() === '') { + end--; + } + + let itemIndent = ' '; + for (let i = start + 1; i < end; i++) { + const m = (lines[i] ?? '').match(/^( +)- /); + if (m && m[1]) { + itemIndent = m[1]; + break; + } + } + return { start, end, itemIndent }; +} + function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } @@ -251,7 +308,7 @@ function removeCodeGraphMcpServer(content: string): string { function upsertCodeGraphToolset(content: string): string { const lines = splitLines(content); const parent = topLevelRange(lines, 'platform_toolsets'); - const cli = parent ? childRange(lines, parent, 'cli') : null; + const cli = parent ? listChildBlock(lines, parent, 'cli') : null; if (!parent) { if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); @@ -270,14 +327,14 @@ function upsertCodeGraphToolset(content: string): string { .some((line) => line.trim() === '- mcp-codegraph'); if (hasEntry) return joinLines(lines); - lines.splice(cli.end, 0, ' - mcp-codegraph'); + lines.splice(cli.end, 0, `${cli.itemIndent}- 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; + const cli = parent ? listChildBlock(lines, parent, 'cli') : null; if (!cli) return content; const hasEntry = lines