Repository: colbymchenry/codegraph (2026-05-26T17:20:53Z)

Issue:
Bug: `codegraph install --target hermes` corrupts `config.yaml` — drops `hermes-cli` and breaks nested YAML indentation
Description

Running `codegraph install --target hermes -y` corrupts the `platform_toolsets` section of Hermes Agent's `~/.hermes/config.yaml`. The installer:

1. **Drops `hermes-cli`** from the `cli` toolset list
2. **Shifts indentation** — all other tool entries (`browser`, `clarify`, `code_execution`, etc.) that were under `cli:` are moved up to the `platform_toolsets` level, breaking YAML structure
3. **Shifts platform indentation** — entries under `telegram:`, `discord:` etc. also shift by one level

After the corruption, Hermes cannot parse `config.yaml`:

    hermes config: Failed to parse /home/user/.hermes/config.yaml:
    while parsing a block mapping
      in "/home/user/.hermes/config.yaml", line 502, column 3
    expected <block end>, but found '-'
      in "/home/user/.hermes/config.yaml", line 504, column 3.
    Falling back to default config — every user override is being IGNORED.

All user configuration (model settings, auxiliary providers, custom toolsets) is silently lost until the YAML is manually repaired.

### Environment

| Item | Detail |
| --- | --- |
| **CodeGraph version** | v0.9.5 (self-contained bundle, installed via official install.sh) |
| **OS** | Windows 11 23H2 (build 22631) + WSL2 |
| **WSL2 distro** | Ubuntu 24.04 LTS |
| **Hermes Agent** | latest main branch (local git clone at `~/.hermes/hermes-agent/`) |
| **Hermes config path** | `~/.hermes/config.yaml` |
| **Hermes install method** | git clone + `pip install -e .` |
| **CodeGraph install command** | `codegraph install --target hermes -y` |
| **Node.js (host)** | v22.x (not used by self-contained bundle) |

### Reproduction

**Step 1 — Start with a real Hermes `~/.hermes/config.yaml`:**

A fresh `hermes setup` generates this `platform_toolsets` section by default:

    platform_toolsets:
      cli:
        - hermes-cli
        - browser
        - clarify
        - code_execution
        - computer_use
        - cronjob
        - delegation
        - file
        - image_gen
        - memory
        - messaging
        - session_search
        - skills
        - terminal
        - todo
        - tts
        - video
        - vision
        - web
      telegram:
        - hermes-telegram
      discord:
        - hermes-discord
      whatsapp:
        - hermes-whatsapp
      slack:
        - hermes-slack
      signal:
        - hermes-signal
      homeassistant:
        - hermes-homeassistant
      qqbot:
        - hermes-qqbot
      yuanbao:
        - hermes-yuanbao
      teams:
        - hermes-teams
      google_chat:
        - hermes-google_chat

**Step 2 — Run the install command:**

    codegraph install --target hermes -y

Output says success:

    ◆  Hermes Agent: Updated ~/.hermes/config.yaml
    ●  Hermes Agent: Start a new Hermes session for MCP changes to take effect.
    ◇  Done! Restart your agent to use CodeGraph.

**Step 3 — Check the corrupted result:**

The `platform_toolsets` section now reads:

    platform_toolsets:
      cli:
        - mcp-codegraph       # ← ADDED (correct), but `hermes-cli` was DROPPED
      - browser               # ← WRONG: moved up to platform_toolsets level
      - clarify               # ← same
      - code_execution
      - computer_use
      - cronjob
      - delegation
      - file
      - image_gen
      - memory
      - messaging
      - session_search
      - skills
      - terminal
      - todo
      - tts
      - video
      - vision
      - web
      telegram:
      - hermes-telegram       # ← WRONG indentation (should be 4 spaces)
      discord:
      - hermes-discord
      whatsapp:
      - hermes-whatsapp
      slack:
      - hermes-slack
      signal:
      - hermes-signal
      homeassistant:
      - hermes-homeassistant
      qqbot:
      - hermes-qqbot
      yuanbao:
      - hermes-yuanbao
      teams:
      - hermes-teams
      google_chat:
      - hermes-google_chat

**Step 4 — Verify the YAML is broken:**

    python3 -c "import yaml; yaml.safe_load(open('~/.hermes/config.yaml'))"
    # → YAMLError: while parsing a block mapping
    
    hermes mcp list
    # → Failed to parse config.yaml, falling back to default config
    # → No MCP servers configured

### Expected Behavior

After `codegraph install --target hermes`, the config should be:

    platform_toolsets:
      cli:
        - hermes-cli          # ← PRESERVED
        - mcp-codegraph       # ← ADDED (by installer)
        - browser             # ← PRESERVED, correct 4-space indentation
        - clarify
        - code_execution
        - terminal
        - web
      telegram:
        - hermes-telegram     # ← PRESERVED, correct indentation
      discord:
        - hermes-discord

The `mcp_servers.codegraph` section (added further down in the file) is written correctly — the bug is isolated to the `platform_toolsets` section.

### Additional Observations

1. **`--print-config` is correct** — Running `codegraph install --print-config hermes` outputs the **expected** config, complete with `hermes-cli` preserved and proper indentation. The config-generation logic is fine.
  
2. **Only the file-write step corrupts it** — The bug is in the YAML file patching/writing step, not in the config template generation.
  
3. **`mcp_servers` section is written correctly** — The CodeGraph MCP server definition under `mcp_servers:` is added with correct indentation and works when applied manually.
  

### Root Cause Hypothesis

The diff between the backup (before install) and the corrupted file tells the story:

    --- backup/config.yaml (before install)
    +++ corrupted config.yaml (after install)
    
     502a503
     >     - hermes-cli              ← installer ADDED this (somehow)
     504,521c505,522
    -  - browser                     ← was at platform_toolsets level (WRONG in output)
    -  - clarify
    +    - browser                   ← correctly under cli: (in backup, after manual fix)
    +    - clarify
     523c524
    -  - hermes-telegram             ← wrong indent (2 spaces in corrupted)
    +    - hermes-telegram           ← correct indent (4 spaces in backup)

> **Note:** In the diff above, the `<` lines are from the **corrupted** output; the `>` lines are from a **manually repaired** backup that has the correct structure.

This suggests the installer's YAML writer is not properly tracking nesting depth. It appears to:

* Read the YAML as lines, not as a parsed document tree
* Find the `platform_toolsets.cli` list and add `mcp-codegraph` to it
* But when serializing back, it doesn't account for the original indentation depth of all other items in that block

The fact that `hermes-cli` was also dropped (even though `--print-config` includes it) suggests the writer may be replacing the entire `platform_toolsets.cli` content with a new flat list instead of merging into the existing list.

### Workaround

Do **not** run `codegraph install` on an existing Hermes config. Instead, use `--print-config` to see the intended snippet and apply it manually:

    codegraph install --print-config hermes

Then edit `~/.hermes/config.yaml` to:

1. Add the `mcp_servers.codegraph` block
2. Add `- mcp-codegraph` to the `platform_toolsets.cli` list (keeping `- hermes-cli` and all other items)


Discussion:
Fixed in #461 — thanks for the detailed report (and especially for capturing the diff against a backup, which made the root cause easy to confirm).

**Root cause**: Hermes serializes `config.yaml` with PyYAML's default block style, where list items sit at the *same* indent as the parent mapping key:

```yaml
platform_toolsets:
  cli:
  - hermes-cli      # indent 2, same column as `cli:`
  - browser
```

The previous line-based YAML patcher used a `^  \S` heuristic to find the end of the `cli:` block, which mistook that first `  - hermes-cli` line for the next sibling key. So the patcher saw the `cli:` block as empty, and then spliced `    - mcp-codegraph` at indent 4 *before* the existing items — promoting `- browser`, `- clarify`, …, and even the next platform's `telegram:` to invalid positions. Your observation in the bug report that "`hermes-cli` was dropped" turned out to be a symptom of the YAML no longer parsing, not an actual line removal — Hermes silently fell back to the default config, hiding the entire `platform_toolsets` map.

**Fix**: a new `listChildBlock` helper recognizes `  - ` lines as list-item continuations (not sibling keys), finds the real end of the block at the next sibling mapping key, and detects the indent existing items already use so `- mcp-codegraph` is appended at the *same* indent. The 4-space-nested layout (what `--print-config` shows) still works the same way.

**End-to-end verified** against a real `hermes-agent` install on the exact config from your bug report:

- `hermes mcp list` → `codegraph  codegraph serve --mcp  all  ✓ enabled`
- `hermes tools --summary` → `mcp-codegraph` and `codegraph` both enabled in the CLI toolset, alongside `browser`, `file`, `terminal`, `web`, …
- `hermes mcp test codegraph` → `✓ Connected (264ms)  ✓ Tools discovered: 10` (search, context, callers, callees, impact, node, explore, status, files, trace)
- Re-running `codegraph install --target hermes` reports `Unchanged`; the file still has exactly one `mcp-codegraph` and one `codegraph:` server block.

Also validated the installer test suite on macOS, Linux (Docker `node:22-bookworm`), and Windows 11 (Parallels). The fix will ship in the next release (currently in `[Unreleased]` in `CHANGELOG.md`).



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d7a8aa88..6d3ac79f3 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/src/installer/targets/hermes.ts b/src/installer/targets/hermes.ts
index b6abfb941..eacab6d3d 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


Test patch:
diff --git a/__tests__/installer-targets.test.ts b/__tests__/installer-targets.test.ts
index 2683cbb99..82155eab5 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');