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