1
0
Эх сурвалжийг харах

fix(hermes): preserve YAML list-at-same-indent style on install (#456) (#461)

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) <noreply@anthropic.com>
Colby Mchenry 3 долоо хоног өмнө
parent
commit
e76cc547b0

+ 1 - 0
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

+ 80 - 0
__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');

+ 60 - 3
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