Skip to content

ctx init installs non-functional Claude Code hooks: flat schema (silently ignored) + ignores CLAUDE_CONFIG_DIR #5

@DamieMoon

Description

@DamieMoon

Summary

ctx init reports Hooks ✓ / Statusline ✓ but the hooks it writes never execute on current Claude Code. Two independent defects combine:

  1. Wrong schemactx init writes the flat hook form {"type":"command","command":...} directly into the event array. Current Claude Code requires the nested matcher-group form {"hooks":[{"type":"command","command":...}]} and silently ignores flat entries. (The README §3 "Agent hooks" example shows the same flat form and is likewise affected.)
  2. Wrong filectx init hardcodes ~/.claude/settings.json. Claude Code honors the CLAUDE_CONFIG_DIR environment variable to relocate its config directory; when it is set, the live settings file is $CLAUDE_CONFIG_DIR/settings.json and ~/.claude/settings.json is not read. ctx init ignores CLAUDE_CONFIG_DIR.

Either defect alone makes the hooks dead; together they produce a silent false-positive (Hooks ✓) while ctx brief/ctx persist never fire.

A third, minor issue: hasHookEntry() only inspects a top-level command key, so it cannot detect already-installed nested entries — re-running ctx init would keep appending duplicates.

The statusline step is affected by defect (2) as well: its JSON shape (statusLine: {type, command}) is correct, but it is written to the same hardcoded ~/.claude/settings.json, so it does not render when CLAUDE_CONFIG_DIR points elsewhere.

Environment

  • ctx: v2.4.7 (CLI + daemon)
  • Claude Code: v2.1.x (Linux/Unix install with CLAUDE_CONFIG_DIR set to an XDG-style dir)
  • Hook events affected: SubagentStart, SubagentStop (the events ctx init configures)

Authoritative schema (current Claude Code)

Per the Claude Code hooks docs, every event maps to an array of matcher-group objects — including events that don't support matchers (SubagentStart/SubagentStop/UserPromptSubmit/Stop/…). The matcher key may be omitted or empty for non-tool events, but the hooks wrapper is mandatory. A bare {type,command} without the hooks wrapper is explicitly invalid.

Required:

{
  "hooks": {
    "SubagentStart": [
      { "hooks": [ { "type": "command", "command": "ctx brief --hook" } ] }
    ],
    "SubagentStop": [
      { "hooks": [ { "type": "command", "command": "ctx persist --hook" } ] }
    ]
  }
}

What ctx init writes today (does not execute):

{
  "hooks": {
    "SubagentStart": [ { "type": "command", "command": "ctx brief --hook" } ],
    "SubagentStop":  [ { "type": "command", "command": "ctx persist --hook" } ]
  }
}

Reproduction

  1. On a Claude Code install where CLAUDE_CONFIG_DIR points at an XDG-style config dir, run ctx init and accept the hooks + statusline prompts.
  2. Observe ctx init reports Hooks ✓ and writes them to ~/.claude/settings.json.
  3. Spawn any subagent. The SubagentStart/SubagentStop hooks do not run (no ctx brief/ctx persist invocation).

Evidence (deterministic A/B/A)

Pointing the SubagentStart/SubagentStop hooks at a probe script that appends to a logfile when executed, in the correct settings file ($CLAUDE_CONFIG_DIR/settings.json), changing only the schema between runs:

FLAT   schema → logfile EMPTY                         (hooks did NOT execute)
NESTED schema → "FIRED SubagentStart", "FIRED SubagentStop"   (both executed)

Same script, same file, same session — the only variable was flat vs nested. Nested firing after a mid-session edit also confirms Claude Code reloads settings live, so the flat "empty" result is genuine (not a reload artifact).

Root cause (go/internal/cli/init.go)

  • claudeSettingsPath() returns filepath.Join(home, ".claude", "settings.json") unconditionally (Unix branch) — no CLAUDE_CONFIG_DIR check, no awareness of the XDG ~/.config/claude-code/settings.json location used by newer installs.
  • stepHooks() constructs each entry as {"type":"command","command":...} and appends it directly to the event array — the flat (now-invalid) form.
  • hasHookEntry() only reads a top-level command; it does not recurse into a nested hooks[], so it misreports correctly-nested entries as "missing" and re-appends on every run.

Proposed fix

  1. claudeSettingsPath() — resolve the settings file as: CLAUDE_CONFIG_DIR/settings.json if the env var is set; else prefer ~/.config/claude-code/settings.json when it already exists; else fall back to ~/.claude/settings.json. (Writing to a file Claude Code does not read is the root of the silent failure.)
  2. stepHooks() — emit the nested matcher-group shape, e.g. a small newHookGroup(cmd) helper returning {"hooks":[{"type":"command","command":cmd}]} (matcher omitted; these events are not tool-scoped).
  3. hasHookEntry() — recurse into a nested hooks[] (in addition to the legacy flat command) so detection is idempotent against both shapes and re-runs don't append duplicates.
  4. README §3 — update the "Agent hooks" example (and the statusLine note's surrounding context) to the nested matcher-group form.

I have these changes implemented and verified (gofmt/vet clean; package tests pass; the patched ctx init writes nested entries to the CLAUDE_CONFIG_DIR location, is idempotent on re-run, and the hooks fire under the A/B/A probe above). Happy to open a PR.

Impact

  • Silent: ctx init claims success while subagent briefing/persistence never run, so users believe the integration is active when it is not.
  • The flat form was likely valid against an earlier Claude Code hook schema; this is a platform-schema evolution that ctx init (and the README example) have not yet tracked.

Separate minor observation (not part of the fix above)

During ctx init, the version step prints Version … (parse error) [skip] for both a release build and a dev build — the latest-release comparison in stepVersion() fails to parse. Cosmetic (the step is non-fatal and skips cleanly), filed here only for awareness.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions