From f2bb0937183513199590bbf81b67a715a0948058 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Wed, 27 May 2026 15:00:07 -0600 Subject: [PATCH 01/16] docs: add Codex hook support design spec - Reuse core parse/map/play pipeline; Codex hook JSON matches Claude Code fields - Parser tolerance: nullable transcript_path + SubagentStart/PostCompact cases - Agent-aware installer writes ~/.codex/hooks.json via --agent flag - Full parity scope, hooks.json format, mapping-only sounds, inferred fixtures - Coverage target: ratchet hooks/install to >=90%, new code >=95% Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-27-codex-hook-support-design.md | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-codex-hook-support-design.md diff --git a/docs/superpowers/specs/2026-05-27-codex-hook-support-design.md b/docs/superpowers/specs/2026-05-27-codex-hook-support-design.md new file mode 100644 index 0000000..47f87f8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-codex-hook-support-design.md @@ -0,0 +1,210 @@ +# Codex hook support — design + +Date: 2026-05-27 +Status: approved (pending spec review) +Branch base: `feat/mcp-tool-normalization` + +## Goal + +Let Claudio play contextual sounds for OpenAI Codex CLI, the same way it already +does for Claude Code. A user running Codex should be able to install Claudio +hooks and hear loading, success, error, and lifecycle sounds. + +## Background + +Codex CLI shipped stable hooks in May 2026. The contract is close enough to +Claude Code that most of Claudio works unchanged: + +- Every command hook receives one JSON object on stdin (same as Claude Code). +- Shared field names: `session_id`, `cwd`, `hook_event_name`, `tool_name`, + `tool_input`, `tool_response`, `prompt`. +- Hook config lives in `~/.codex/hooks.json` (or inline in `config.toml`), with + top-level shape `{"hooks": {EventName: [ {matcher, hooks:[{type,command}]} ]}}` + — structurally the same as Claude's `settings.json` hooks block. +- A hook that exits 0 with no stdout is treated as success and execution + continues. Claudio's detached worker already exits 0 silently, so it never + blocks Codex. + +Sources: +- https://developers.openai.com/codex/hooks +- https://developers.openai.com/codex/config-advanced + +### Codex hook events + +`SessionStart`, `SubagentStart`, `PreToolUse`, `PermissionRequest`, +`PostToolUse`, `PreCompact`, `PostCompact`, `UserPromptSubmit`, `SubagentStop`, +`Stop`. + +Codex has no `Notification` or `SessionEnd` event. Claude has no `SubagentStart` +or `PostCompact`. Everything else overlaps. + +### Differences that drive the work + +1. `transcript_path` is nullable/omitted in Codex. Claudio's parser currently + rejects an event when `transcript_path` is empty (`parser.go:124`). +2. Install target is `~/.codex/hooks.json`, not `~/.claude/settings.json`. +3. The Codex event set differs (adds `SubagentStart`, `PostCompact`; drops + `Notification`, `SessionEnd`). +4. Codex tool names include `apply_patch` (Claudio has no special case; flows + through the generic path) and `mcp____` (already normalized to + `mcp` by the prior commit on this branch). +5. Non-managed Codex hooks require explicit trust via the `/hooks` command. + Claudio cannot bypass this; it surfaces a reminder after install. + +## Decisions + +- **Scope:** full parity. Map the Codex-only events and tools, not just the + shared ones. +- **Install format:** `~/.codex/hooks.json` (pure JSON, reuses existing merge + logic, no TOML dependency, never touches the user's `config.toml`). +- **Agent selection:** `--agent`/`-a` flag on `install` and `uninstall`, values + `claude` (default) and `codex`. +- **Sound assets:** mapping logic only. New Codex-only hints rely on the + 5-level fallback until custom audio is added. No new `.wav` files. +- **Test fixtures:** inferred from the documented schema. Verify against real + Codex payloads later. + +## Architecture + +The core pipeline — parse → `GetContext` → `SoundMapper` → audio backend — is +reused unchanged. Two areas change: the hook parser gains tolerance and new +event cases; the install/uninstall layer becomes agent-aware. + +### Component 1 — Parser tolerance (`internal/hooks/parser.go`) + +- Stop treating an empty `transcript_path` as a fatal validation error. Keep + `session_id`, `cwd`, and `hook_event_name` required (Codex always sends all + three). +- Add `GetContext` cases: + - `SubagentStart` → category `Loading`, hint `subagent-start`, operation + `subagent-start`. + - `PostCompact` → category `System`, hint `post-compact`, operation + `post-compact`. +- No change needed for `PermissionRequest`, `SessionStart`, `PreCompact`, + `Stop`, `SubagentStop` — already mapped. +- `apply_patch` flows through the existing generic + `-start` / `-success` / `-error` path. The 5-level fallback + covers any missing audio. +- Success/error detection reuses `analyzeToolResponse`: `isError`, `stderr`, + and `interrupted` checks, defaulting to success when none are present. This + matches the documented Codex `tool_response` ("MCP result or output") as + closely as the docs allow; revisit when real payloads are available. + +This component is self-contained: input is a JSON byte slice, output is a +`HookEvent` and an `EventContext`. It has no knowledge of which agent produced +the event. + +### Component 2 — Agent abstraction (`internal/install`) + +Introduce an `Agent` value (`claude` | `codex`) that selects three things: + +1. **Config path finder.** + - Claude: `~/.claude/settings.json` (existing `FindBestSettingsPath`, + unchanged). + - Codex: `~/.codex/hooks.json`, with the same Windows `USERPROFILE` / MSYS + handling used for Claude. +2. **Hook registry.** + - Claude registry: unchanged. + - Codex registry: `PreToolUse`, `PostToolUse`, `UserPromptSubmit`, `Stop`, + `SubagentStop`, `SubagentStart`, `PreCompact`, `PostCompact`, + `SessionStart`, `PermissionRequest`. +3. **Default matcher.** Codex `"*"`, Claude `".*"`. + +Reused without change because they operate on the generic `hooks` JSON key: +`ReadSettingsFile`, `WriteSettingsFile`, `MergeHooksIntoSettings`, +`IsClaudioHook`, and the atomic temp-file-plus-rename write. + +The boundary stays clean: callers ask the agent for "where do I write" and +"which events do I register," then hand off to the existing generic merge/write +functions. + +### Component 3 — CLI wiring (`internal/cli`) + +- Add `--agent`/`-a` to `install` and `uninstall`. Default `claude`. Validate + against the known set; reject anything else with a clear error. +- The install workflow takes the agent, resolves the path finder and registry, + and runs the existing read → generate → merge → write → verify steps. +- After a successful Codex install, print: + `Run /hooks in Codex to trust the claudio hook.` +- `--dry-run` and `--print` show the selected agent and the resolved config + path; the Codex dry-run also shows the trust reminder. + +## Data flow (Codex) + +1. Codex fires an event and spawns `claudio` with the event JSON on stdin. +2. Claudio reads the payload, spawns a detached worker, and exits 0 with no + stdout. Codex sees success and continues — never blocked. +3. The worker parses the payload, derives category and sound hint, and plays the + resolved sound through the 5-level fallback. + +## Error handling + +- Parser: a null or missing `transcript_path` is no longer fatal. Unknown events + fall through to the existing default (`Interactive`, `default` sound). +- Installer: a missing `hooks.json` is created (`ReadSettingsFile` returns empty + settings). The merge preserves any non-Claudio Codex hooks already present. + Writes stay atomic. +- Trust: Claudio cannot auto-trust a non-managed hook. The reminder is the + mitigation. + +## Testing + +Strict TDD: red first, then minimal implementation, then refactor. Fixtures are +built from the documented Codex schema. + +### Coverage targets + +Current baseline (this machine): +- `internal/hooks` 80.2% +- `internal/install` 81.0% +- `internal/cli` — tests do not build locally (cgo linker `cannot find 'ld'` + via mingw; the package pulls in the audio/malgo cgo dependency). + +Targets: +- Ratchet `internal/hooks` and `internal/install` to **≥ 90%**. +- New code (Codex parser cases, agent abstraction, `--agent` flag handling) + to **≥ 95%** with branch coverage on each new decision point. +- Coverage ratchets up, never down (RULES always-on rule). + +### Test cases + +Parser (`internal/hooks/parser_test.go`): +- Event with null `transcript_path` parses successfully. +- Event with omitted `transcript_path` parses successfully. +- Still rejects missing `session_id`, `cwd`, or `hook_event_name`. +- `SubagentStart` → `Loading` / `subagent-start`. +- `PostCompact` → `System` / `post-compact`. +- `apply_patch` `PreToolUse` → `apply_patch-start`; `PostToolUse` success → + `apply_patch-success`; with `isError` → error category. +- `mcp__server__tool` event → normalized to `mcp` (regression guard). + +Install (`internal/install`): +- Codex path finder resolves `~/.codex/hooks.json` (and Windows + `USERPROFILE`). +- Codex registry contains exactly the ten Codex events and no + `Notification` / `SessionEnd`. +- Generate + merge Codex hooks into an empty `hooks.json` (afero in-memory). +- Merge preserves a pre-existing non-Claudio Codex hook. +- Idempotent re-install does not duplicate Claudio entries. +- Default matcher is `"*"` for Codex. + +Uninstall (`internal/uninstall`): +- `--agent codex` removes only Claudio entries, preserves others. + +CLI (`internal/cli`): +- `install --agent codex --dry-run` shows the Codex path and trust reminder. +- `install --agent codex --print` shows agent + path. +- Invalid `--agent` value is rejected. +- Default `--agent` is `claude` (no behavior change for existing users). + +Note: if the local cgo linker stays broken, CLI-package tests run in CI or on a +machine with a working `ld`. The hooks and install packages have no cgo +dependency and test locally. + +## Out of scope (YAGNI) + +- `config.toml` / inline TOML hook installation. +- New audio assets for Codex-only hints. +- Codex structured outputs (deny / rewrite / `updatedInput`). Claudio is an + observer; it never alters Codex behavior. +- Auto-detecting installed agents. From 48d0e980ddc025b3eaa65024ba1145f3d4e472d9 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Wed, 27 May 2026 15:09:00 -0600 Subject: [PATCH 02/16] docs: add Codex hook support implementation plan 12 TDD tasks: parser tolerance, agent abstraction, agent-aware install/uninstall, hooks.json merge, coverage ratchet to >=90%. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-27-codex-hook-support.md | 1275 +++++++++++++++++ 1 file changed, 1275 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-codex-hook-support.md diff --git a/docs/superpowers/plans/2026-05-27-codex-hook-support.md b/docs/superpowers/plans/2026-05-27-codex-hook-support.md new file mode 100644 index 0000000..6d033e9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-codex-hook-support.md @@ -0,0 +1,1275 @@ +# Codex Hook Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let Claudio play contextual sounds for OpenAI Codex CLI by tolerating Codex's hook payloads and adding an agent-aware installer that writes `~/.codex/hooks.json`. + +**Architecture:** The core pipeline (parse → `GetContext` → `SoundMapper` → audio) is reused unchanged because Codex hook JSON shares field names with Claude Code. Two changes: the hook parser gains tolerance plus two new event cases, and the install/uninstall layer becomes agent-aware via a new `Agent` type that selects the config path, hook registry, and matcher. + +**Tech Stack:** Go, cobra (CLI), afero (filesystem abstraction in tests), slog (logging). Existing JSON read/merge/write helpers are reused for `hooks.json`. + +--- + +## Background facts the implementer needs + +- At runtime, `internal/cli/cli.go` `processHookInput` does `json.Unmarshal` straight into `hooks.HookEvent`, then calls `GetContext()`. It does **not** call `hooks.Parse()`, and it only requires `hook_event_name` and `session_id`. So the runtime never rejects a null `transcript_path`. The fix to `Parse()` is for consistency and its own tests. +- `hooks.HookEvent` already has `ToolName`, `ToolInput`, `ToolResponse`, `Prompt`, `Message` as optional pointers — Codex's payload unmarshals into it with no struct changes. +- `mcp____` normalization to `mcp` already exists in `GetContext` (prior commit on this branch). Add a regression test only. +- Codex `hooks.json` top-level shape is `{"hooks": {EventName: [ {"matcher": "...", "hooks": [{"type":"command","command":"..."}]} ]}}`. This is a JSON object with a `hooks` key, so the existing `ReadSettingsFile` / `MergeHooksIntoSettings` / `WriteSettingsFile` operate on it unchanged. +- `internal/uninstall` is agent-agnostic: it detects Claudio entries by command basename via `install.IsClaudioHook`. Only the config path differs per agent. +- The `Agent` type lives in package `install`. Package `cli` imports `install` and uses `install.Agent`, `install.ParseAgent`, etc. + +--- + +## File structure + +- Modify `internal/hooks/parser.go` — relax `transcript_path` requirement in `Parse`; add `SubagentStart` and `PostCompact` cases in `GetContext`. +- Modify `internal/hooks/parser_test.go` — Codex payload tests. +- Create `internal/install/agent.go` — `Agent` type, `ParseAgent`, per-agent matcher / registry / enabled-hooks / hook-names / best-config-path. +- Create `internal/install/agent_test.go` — agent unit tests. +- Create `internal/install/codex_settings.go` — `FindCodexHooksPaths` / `FindBestCodexPath`. +- Create `internal/install/codex_settings_test.go` — path finder tests. +- Modify `internal/install/hook_registry.go` — add `CodexHooks` registry. +- Modify `internal/install/hook_registry_test.go` — Codex registry tests. +- Modify `internal/install/hooks.go` — add `GenerateClaudioHooksForAgent`; keep `GenerateClaudioHooks` as a Claude wrapper. +- Modify `internal/install/hooks_test.go` — agent generation tests. +- Modify `internal/cli/install_command.go` — `--agent` flag, agent-aware workflow, Codex trust reminder. +- Modify `internal/cli/install_command_test.go` (or `install_flags_test.go`) — flag + workflow tests. +- Modify `internal/cli/uninstall_command.go` — `--agent` flag, agent-aware path selection. +- Modify `internal/cli/uninstall_command_test.go` — flag tests. + +--- + +## Task 1: Relax `transcript_path` requirement in parser + +**Files:** +- Modify: `internal/hooks/parser.go:124-128` +- Test: `internal/hooks/parser_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/hooks/parser_test.go`: + +```go +func TestParseCodexNullTranscriptPathSucceeds(t *testing.T) { + parser := NewHookEventParser() + // Codex sends transcript_path as null + data := []byte(`{"session_id":"abc","cwd":"/tmp","hook_event_name":"SessionStart","transcript_path":null}`) + + event, err := parser.Parse(data) + if err != nil { + t.Fatalf("expected nil error for null transcript_path, got: %v", err) + } + if event.EventName != "SessionStart" { + t.Errorf("expected SessionStart, got %q", event.EventName) + } +} + +func TestParseCodexOmittedTranscriptPathSucceeds(t *testing.T) { + parser := NewHookEventParser() + data := []byte(`{"session_id":"abc","cwd":"/tmp","hook_event_name":"Stop"}`) + + _, err := parser.Parse(data) + if err != nil { + t.Fatalf("expected nil error for omitted transcript_path, got: %v", err) + } +} + +func TestParseStillRequiresSessionIDAndEventAndCwd(t *testing.T) { + parser := NewHookEventParser() + cases := map[string][]byte{ + "missing session_id": []byte(`{"cwd":"/tmp","hook_event_name":"Stop"}`), + "missing event": []byte(`{"session_id":"a","cwd":"/tmp"}`), + "missing cwd": []byte(`{"session_id":"a","hook_event_name":"Stop"}`), + } + for name, data := range cases { + t.Run(name, func(t *testing.T) { + if _, err := parser.Parse(data); err == nil { + t.Errorf("expected error for %s, got nil", name) + } + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/hooks/ -run TestParseCodex -v` +Expected: FAIL — `TestParseCodexNullTranscriptPathSucceeds` and `TestParseCodexOmittedTranscriptPathSucceeds` fail with "missing required field: transcript_path". + +- [ ] **Step 3: Remove the transcript_path validation block** + +In `internal/hooks/parser.go`, delete this block (currently lines 124-128): + +```go + if event.TranscriptPath == "" { + err := fmt.Errorf("missing required field: transcript_path") + slog.Error("validation failed", "error", err) + return nil, err + } +``` + +Leave the `session_id`, `hook_event_name`, and `cwd` checks intact. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/hooks/ -run TestParse -v` +Expected: PASS (all parse tests, including pre-existing ones). + +- [ ] **Step 5: Commit** + +```bash +git add internal/hooks/parser.go internal/hooks/parser_test.go +git commit -m "feat: accept hook events without transcript_path for codex" +``` + +--- + +## Task 2: Map Codex-only events SubagentStart and PostCompact + +**Files:** +- Modify: `internal/hooks/parser.go` (inside `GetContext`, after the `SubagentStop` case, before `PreCompact`) +- Test: `internal/hooks/parser_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/hooks/parser_test.go`: + +```go +func TestGetContextSubagentStart(t *testing.T) { + event := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "SubagentStart"} + ctx := event.GetContext() + if ctx.Category != Loading { + t.Errorf("expected Loading, got %v", ctx.Category) + } + if ctx.SoundHint != "subagent-start" { + t.Errorf("expected subagent-start, got %q", ctx.SoundHint) + } + if ctx.Operation != "subagent-start" { + t.Errorf("expected operation subagent-start, got %q", ctx.Operation) + } +} + +func TestGetContextPostCompact(t *testing.T) { + event := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "PostCompact"} + ctx := event.GetContext() + if ctx.Category != System { + t.Errorf("expected System, got %v", ctx.Category) + } + if ctx.SoundHint != "post-compact" { + t.Errorf("expected post-compact, got %q", ctx.SoundHint) + } + if ctx.Operation != "post-compact" { + t.Errorf("expected operation post-compact, got %q", ctx.Operation) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/hooks/ -run "TestGetContextSubagentStart|TestGetContextPostCompact" -v` +Expected: FAIL — both hit the `default` case, so `SoundHint` is `"default"` and `Category` is `Interactive`. + +- [ ] **Step 3: Add the two cases** + +In `internal/hooks/parser.go`, inside the `switch e.EventName` block in `GetContext`, add these cases immediately after the existing `case "SubagentStop":` block: + +```go + case "SubagentStart": + context.Category = Loading + context.SoundHint = "subagent-start" + context.Operation = "subagent-start" + slog.Debug("categorizing SubagentStart event as Loading", "hint", context.SoundHint, "operation", context.Operation) + + case "PostCompact": + context.Category = System + context.SoundHint = "post-compact" + context.Operation = "post-compact" + slog.Debug("categorizing PostCompact event as System", "hint", context.SoundHint, "operation", context.Operation) +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/hooks/ -run "TestGetContext" -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/hooks/parser.go internal/hooks/parser_test.go +git commit -m "feat: map codex SubagentStart and PostCompact events" +``` + +--- + +## Task 3: Codex tool-name fixtures (apply_patch, mcp regression) + +**Files:** +- Test only: `internal/hooks/parser_test.go` (no production change expected; if a test fails, the fix belongs in `GetContext`) + +- [ ] **Step 1: Write the tests** + +Add to `internal/hooks/parser_test.go`: + +```go +func TestGetContextCodexApplyPatchPreToolUse(t *testing.T) { + tool := "apply_patch" + event := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "PreToolUse", ToolName: &tool} + ctx := event.GetContext() + if ctx.Category != Loading { + t.Errorf("expected Loading, got %v", ctx.Category) + } + if ctx.SoundHint != "apply_patch-start" { + t.Errorf("expected apply_patch-start, got %q", ctx.SoundHint) + } +} + +func TestGetContextCodexApplyPatchPostToolUseSuccess(t *testing.T) { + tool := "apply_patch" + resp := json.RawMessage(`{"output":"done"}`) + event := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "PostToolUse", ToolName: &tool, ToolResponse: &resp} + ctx := event.GetContext() + if ctx.Category != Success { + t.Errorf("expected Success, got %v", ctx.Category) + } + if ctx.SoundHint != "apply_patch-success" { + t.Errorf("expected apply_patch-success, got %q", ctx.SoundHint) + } +} + +func TestGetContextCodexMcpToolNormalized(t *testing.T) { + tool := "mcp__filesystem__read_file" + event := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "PreToolUse", ToolName: &tool} + ctx := event.GetContext() + if ctx.ToolName != "mcp" { + t.Errorf("expected normalized tool name mcp, got %q", ctx.ToolName) + } + if ctx.SoundHint != "mcp-start" { + t.Errorf("expected mcp-start, got %q", ctx.SoundHint) + } +} +``` + +Note: `parser_test.go` must already import `encoding/json`. If not, add it. + +- [ ] **Step 2: Run tests** + +Run: `go test ./internal/hooks/ -run "Codex" -v` +Expected: PASS (these exercise existing generic and mcp paths). If `apply_patch` fails because of unexpected casing, the production fix is to ensure `GetContext` lowercases the hint — but `strings.ToLower("apply_patch")` is `apply_patch`, so it should pass as written. + +- [ ] **Step 3: Commit** + +```bash +git add internal/hooks/parser_test.go +git commit -m "test: cover codex apply_patch and mcp tool mapping" +``` + +--- + +## Task 4: Add the `Agent` type and `ParseAgent` + +**Files:** +- Create: `internal/install/agent.go` +- Test: `internal/install/agent_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/install/agent_test.go`: + +```go +package install + +import "testing" + +func TestParseAgentValid(t *testing.T) { + cases := map[string]Agent{ + "claude": AgentClaude, + "codex": AgentCodex, + } + for in, want := range cases { + got, err := ParseAgent(in) + if err != nil { + t.Fatalf("ParseAgent(%q) returned error: %v", in, err) + } + if got != want { + t.Errorf("ParseAgent(%q) = %v, want %v", in, got, want) + } + } +} + +func TestParseAgentInvalid(t *testing.T) { + if _, err := ParseAgent("gemini"); err == nil { + t.Error("expected error for invalid agent, got nil") + } +} + +func TestAgentMatcher(t *testing.T) { + if AgentClaude.Matcher() != ".*" { + t.Errorf("claude matcher = %q, want .*", AgentClaude.Matcher()) + } + if AgentCodex.Matcher() != "*" { + t.Errorf("codex matcher = %q, want *", AgentCodex.Matcher()) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/install/ -run "TestParseAgent|TestAgentMatcher" -v` +Expected: FAIL — `Agent`, `AgentClaude`, `AgentCodex`, `ParseAgent`, `Matcher` undefined (build error). + +- [ ] **Step 3: Write the implementation** + +Create `internal/install/agent.go`: + +```go +package install + +import ( + "fmt" + "log/slog" +) + +// Agent identifies which coding agent Claudio installs hooks for. +type Agent string + +const ( + AgentClaude Agent = "claude" + AgentCodex Agent = "codex" +) + +// ParseAgent validates and converts a string into an Agent. +func ParseAgent(s string) (Agent, error) { + switch Agent(s) { + case AgentClaude, AgentCodex: + return Agent(s), nil + default: + return "", fmt.Errorf("invalid agent '%s': must be 'claude' or 'codex'", s) + } +} + +// String returns the agent's string form. +func (a Agent) String() string { return string(a) } + +// Matcher returns the default hook matcher pattern for the agent. +// Codex uses "*"; Claude Code uses ".*". +func (a Agent) Matcher() string { + if a == AgentCodex { + return "*" + } + return ".*" +} + +// Registry returns the hook definitions supported for the agent. +func (a Agent) Registry() []HookDefinition { + if a == AgentCodex { + return CodexHooks + } + return AllHooks +} + +// EnabledHooks returns the agent's default-enabled hook definitions. +func (a Agent) EnabledHooks() []HookDefinition { + var enabled []HookDefinition + for _, h := range a.Registry() { + if h.DefaultEnabled { + enabled = append(enabled, h) + } + } + slog.Debug("agent enabled hooks", "agent", a, "count", len(enabled)) + return enabled +} + +// HookNames returns the names of every hook in the agent's registry. +func (a Agent) HookNames() []string { + reg := a.Registry() + names := make([]string, len(reg)) + for i, h := range reg { + names[i] = h.Name + } + return names +} + +// BestConfigPath returns the config file path to install hooks into for the agent and scope. +func (a Agent) BestConfigPath(scope string) (string, error) { + if a == AgentCodex { + return FindBestCodexPath(scope) + } + return FindBestSettingsPath(scope) +} +``` + +Note: `CodexHooks` and `FindBestCodexPath` are defined in Tasks 5 and 6. This file will not compile until those exist; that is expected in TDD — implement them in the next tasks before running the full package build. To keep this task self-contained and green, implement Tasks 5 and 6 stubs first if running strictly per-task; otherwise proceed in order. + +- [ ] **Step 4: Defer full run** + +The package build depends on Tasks 5 and 6. Run `go vet ./internal/install/` after Task 6 instead. For now: + +Run: `go build ./internal/install/ 2>&1 | head` +Expected: build errors referencing `CodexHooks` and `FindBestCodexPath` (resolved in Tasks 5-6). + +- [ ] **Step 5: Commit after Task 6** + +This task commits together with Tasks 5 and 6 since they are mutually dependent (see Task 6 Step 5). + +--- + +## Task 5: Codex config path finder + +**Files:** +- Create: `internal/install/codex_settings.go` +- Test: `internal/install/codex_settings_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `internal/install/codex_settings_test.go`: + +```go +package install + +import ( + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestFindCodexHooksPathsUserScope(t *testing.T) { + paths, err := FindCodexHooksPaths("user") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(paths) == 0 { + t.Fatal("expected at least one user-scope path") + } + // Every candidate must end with .codex/hooks.json + want := filepath.Join(".codex", "hooks.json") + for _, p := range paths { + if !strings.HasSuffix(p, want) { + t.Errorf("path %q does not end with %q", p, want) + } + } +} + +func TestFindCodexHooksPathsProjectScope(t *testing.T) { + paths, err := FindCodexHooksPaths("project") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(paths) == 0 { + t.Fatal("expected at least one project-scope path") + } + if !strings.Contains(paths[0], filepath.Join(".codex", "hooks.json")) { + t.Errorf("project path %q missing .codex/hooks.json", paths[0]) + } +} + +func TestFindCodexHooksPathsInvalidScope(t *testing.T) { + if _, err := FindCodexHooksPaths("bogus"); err == nil { + t.Error("expected error for invalid scope, got nil") + } +} + +func TestFindBestCodexPathReturnsFirstWhenNoneExist(t *testing.T) { + // In CI/dev there is usually no ~/.codex/hooks.json; first candidate is returned for creation. + got, err := FindBestCodexPath("user") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == "" { + t.Fatal("expected a non-empty path") + } + _ = runtime.GOOS // platform-specific home handling is exercised indirectly +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/install/ -run "Codex" -v` +Expected: FAIL — `FindCodexHooksPaths` / `FindBestCodexPath` undefined. + +- [ ] **Step 3: Write the implementation** + +Create `internal/install/codex_settings.go`. It reuses the existing `getHomeDirectory()` helper from `claude_settings.go`: + +```go +package install + +import ( + "fmt" + "os" + "path/filepath" + "runtime" +) + +// FindCodexHooksPaths returns candidate ~/.codex/hooks.json paths for the scope, in priority order. +func FindCodexHooksPaths(scope string) ([]string, error) { + switch scope { + case "user": + return findCodexUserScopePaths(), nil + case "project": + return []string{ + filepath.Join(".", ".codex", "hooks.json"), + filepath.Join(".codex", "hooks.json"), + }, nil + default: + return nil, fmt.Errorf("invalid scope '%s': must be 'user' or 'project'", scope) + } +} + +func findCodexUserScopePaths() []string { + var paths []string + + homeDir := getHomeDirectory() + if homeDir != "" { + paths = append(paths, filepath.Join(homeDir, ".codex", "hooks.json")) + } + + if runtime.GOOS == "windows" { + userProfile := os.Getenv("USERPROFILE") + if userProfile != "" && userProfile != homeDir { + paths = append(paths, filepath.Join(userProfile, ".codex", "hooks.json")) + } + } + + if len(paths) == 0 { + paths = append(paths, filepath.Join("~", ".codex", "hooks.json")) + } + + return paths +} + +// FindBestCodexPath returns the first existing Codex hooks path, or the first candidate for creation. +func FindBestCodexPath(scope string) (string, error) { + paths, err := FindCodexHooksPaths(scope) + if err != nil { + return "", err + } + if len(paths) == 0 { + return "", fmt.Errorf("no codex hooks paths found for scope: %s", scope) + } + for _, path := range paths { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + return paths[0], nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/install/ -run "Codex" -v` +Expected: PASS for the path-finder tests (agent tests still need Task 6's `CodexHooks`). + +- [ ] **Step 5: Commit after Task 6** (mutually dependent with Tasks 4 and 6). + +--- + +## Task 6: Codex hook registry + +**Files:** +- Modify: `internal/install/hook_registry.go` (append the `CodexHooks` var) +- Test: `internal/install/hook_registry_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/install/hook_registry_test.go`: + +```go +func TestCodexRegistryContents(t *testing.T) { + want := map[string]bool{ + "PreToolUse": true, "PostToolUse": true, "UserPromptSubmit": true, + "Stop": true, "SubagentStop": true, "SubagentStart": true, + "PreCompact": true, "PostCompact": true, "SessionStart": true, + "PermissionRequest": true, + } + got := map[string]bool{} + for _, h := range CodexHooks { + got[h.Name] = true + } + if len(got) != len(want) { + t.Errorf("codex registry has %d events, want %d", len(got), len(want)) + } + for name := range want { + if !got[name] { + t.Errorf("codex registry missing %q", name) + } + } + // Codex has no Notification or SessionEnd + if got["Notification"] || got["SessionEnd"] { + t.Error("codex registry must not contain Notification or SessionEnd") + } +} + +func TestAgentEnabledHooksAndNames(t *testing.T) { + if len(AgentCodex.EnabledHooks()) != len(CodexHooks) { + t.Errorf("expected all codex hooks enabled by default") + } + if len(AgentCodex.HookNames()) != 10 { + t.Errorf("expected 10 codex hook names, got %d", len(AgentCodex.HookNames())) + } + if len(AgentClaude.HookNames()) != len(AllHooks) { + t.Errorf("claude hook names mismatch") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/install/ -run "TestCodexRegistry|TestAgentEnabledHooks" -v` +Expected: FAIL — `CodexHooks` undefined. + +- [ ] **Step 3: Write the implementation** + +Append to `internal/install/hook_registry.go` (after the `AllHooks` var): + +```go +// CodexHooks defines the registry of OpenAI Codex CLI hooks supported by Claudio. +// Codex lacks Notification and SessionEnd; it adds SubagentStart and PostCompact. +var CodexHooks = []HookDefinition{ + {Name: "PreToolUse", Category: hooks.Loading, Description: "Play loading sounds before Codex tool execution", DefaultEnabled: true}, + {Name: "PostToolUse", Category: hooks.Success, Description: "Play success/error sounds after Codex tool execution", DefaultEnabled: true}, + {Name: "UserPromptSubmit", Category: hooks.Interactive, Description: "Play interaction sounds when user submits prompts", DefaultEnabled: true}, + {Name: "Stop", Category: hooks.Completion, Description: "Play sounds when Codex finishes responding", DefaultEnabled: true}, + {Name: "SubagentStop", Category: hooks.Completion, Description: "Play sounds when a Codex subagent finishes", DefaultEnabled: true}, + {Name: "SubagentStart", Category: hooks.Loading, Description: "Play sounds when a Codex subagent starts", DefaultEnabled: true}, + {Name: "PreCompact", Category: hooks.System, Description: "Play sounds before Codex context compaction", DefaultEnabled: true}, + {Name: "PostCompact", Category: hooks.System, Description: "Play sounds after Codex context compaction", DefaultEnabled: true}, + {Name: "SessionStart", Category: hooks.System, Description: "Play sounds when a Codex session starts or resumes", DefaultEnabled: true}, + {Name: "PermissionRequest", Category: hooks.Interactive, Description: "Play sounds for Codex permission requests", DefaultEnabled: true}, +} +``` + +- [ ] **Step 4: Run the full install package build and the new tests** + +Run: `go test ./internal/install/ -run "TestParseAgent|TestAgentMatcher|TestCodex|TestAgentEnabledHooks|TestFindCodex|TestFindBestCodex" -v` +Expected: PASS (Tasks 4, 5, 6 now compile together). + +- [ ] **Step 5: Commit Tasks 4-6 together** + +```bash +git add internal/install/agent.go internal/install/agent_test.go \ + internal/install/codex_settings.go internal/install/codex_settings_test.go \ + internal/install/hook_registry.go internal/install/hook_registry_test.go +git commit -m "feat: add codex agent type, registry, and config path finder" +``` + +--- + +## Task 7: Agent-aware hook generation + +**Files:** +- Modify: `internal/install/hooks.go:21-63` (`GenerateClaudioHooks`) +- Test: `internal/install/hooks_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/install/hooks_test.go`: + +```go +func TestGenerateClaudioHooksForCodexAgent(t *testing.T) { + fsys := afero.NewMemMapFs() + result, err := GenerateClaudioHooksForAgent(fsys, "/usr/local/bin/claudio", AgentCodex) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + hooks, ok := result.(HooksMap) + if !ok { + t.Fatalf("expected HooksMap, got %T", result) + } + if len(hooks) != len(CodexHooks) { + t.Errorf("expected %d codex hooks, got %d", len(CodexHooks), len(hooks)) + } + if _, ok := hooks["PostCompact"]; !ok { + t.Error("expected PostCompact in codex hooks") + } + if _, ok := hooks["Notification"]; ok { + t.Error("codex hooks must not include Notification") + } + // Codex matcher must be "*" + arr := hooks["Stop"].([]interface{}) + cfg := arr[0].(map[string]interface{}) + if cfg["matcher"] != "*" { + t.Errorf("codex matcher = %v, want *", cfg["matcher"]) + } +} + +func TestGenerateClaudioHooksDefaultsToClaude(t *testing.T) { + fsys := afero.NewMemMapFs() + result, err := GenerateClaudioHooks(fsys, "/usr/local/bin/claudio") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + hooks := result.(HooksMap) + if len(hooks) != len(GetEnabledHooks()) { + t.Errorf("claude generation count mismatch") + } + arr := hooks["PreToolUse"].([]interface{}) + cfg := arr[0].(map[string]interface{}) + if cfg["matcher"] != ".*" { + t.Errorf("claude matcher = %v, want .*", cfg["matcher"]) + } +} +``` + +Note: `hooks_test.go` must import `github.com/spf13/afero`. The existing afero-based tests already do; confirm the import is present. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/install/ -run "TestGenerateClaudioHooksForCodexAgent|TestGenerateClaudioHooksDefaultsToClaude" -v` +Expected: FAIL — `GenerateClaudioHooksForAgent` undefined. + +- [ ] **Step 3: Refactor `GenerateClaudioHooks` and add the agent variant** + +In `internal/install/hooks.go`, replace the body of `GenerateClaudioHooks` (lines 21-63) with a thin wrapper and a new agent-aware function: + +```go +// GenerateClaudioHooks creates the Claude Code hook configuration (backward-compatible default). +func GenerateClaudioHooks(filesystem afero.Fs, executablePath string) (interface{}, error) { + return GenerateClaudioHooksForAgent(filesystem, executablePath, AgentClaude) +} + +// GenerateClaudioHooksForAgent creates hook configuration for the given agent. +// Returns a hooks map suitable for Claude settings.json or Codex hooks.json. +func GenerateClaudioHooksForAgent(filesystem afero.Fs, executablePath string, agent Agent) (interface{}, error) { + slog.Debug("generating Claudio hooks configuration", + "agent", agent, "executable_path", executablePath) + + enabledHooks := agent.EnabledHooks() + matcher := agent.Matcher() + slog.Debug("retrieved enabled hooks for agent", "agent", agent, "count", len(enabledHooks)) + + hooks := make(HooksMap) + + createHookConfig := func() interface{} { + return []interface{}{ + map[string]interface{}{ + "matcher": matcher, + "hooks": []interface{}{ + map[string]interface{}{ + "type": "command", + "command": executablePath, + }, + }, + }, + } + } + + for _, hookDef := range enabledHooks { + hooks[hookDef.Name] = createHookConfig() + slog.Debug("added hook from registry", + "agent", agent, "hook_name", hookDef.Name, "category", hookDef.Category) + } + + slog.Info("generated Claudio hooks configuration", + "agent", agent, "hook_count", len(hooks), "hooks", getHookNamesList(hooks)) + + return hooks, nil +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/install/ -run "TestGenerateClaudioHooks" -v` +Expected: PASS (new and pre-existing generation tests). + +- [ ] **Step 5: Commit** + +```bash +git add internal/install/hooks.go internal/install/hooks_test.go +git commit -m "feat: generate agent-specific claudio hooks" +``` + +--- + +## Task 8: Agent-aware install command + +**Files:** +- Modify: `internal/cli/install_command.go` +- Test: `internal/cli/install_command_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/cli/install_command_test.go`: + +```go +func TestInstallCommandRejectsInvalidAgent(t *testing.T) { + cmd := newInstallCommand() + cmd.SetArgs([]string{"--agent", "gemini", "--dry-run"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if err := cmd.Execute(); err == nil { + t.Error("expected error for invalid agent, got nil") + } +} + +func TestInstallCommandCodexDryRunShowsTrustReminder(t *testing.T) { + cmd := newInstallCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--agent", "codex", "--dry-run"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := out.String() + if !strings.Contains(s, "hooks.json") { + t.Errorf("expected codex hooks.json path in output, got: %s", s) + } + if !strings.Contains(s, "/hooks") { + t.Errorf("expected /hooks trust reminder in output, got: %s", s) + } +} +``` + +Note: ensure imports `bytes`, `io`, `strings` exist in the test file. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cli/ -run "TestInstallCommandRejectsInvalidAgent|TestInstallCommandCodexDryRun" -v` +Expected: FAIL — `--agent` flag unknown (cobra errors) / trust reminder absent. + +- [ ] **Step 3: Add the `--agent` flag** + +In `newInstallCommand()` in `internal/cli/install_command.go`, after the `--scope` flag registration, add: + +```go + // Add --agent flag with validation + cmd.Flags().StringP("agent", "a", "claude", "Target agent: 'claude' for Claude Code, 'codex' for OpenAI Codex CLI") +``` + +- [ ] **Step 4: Wire the flag through `runInstallCommandE`** + +In `runInstallCommandE`, after the scope validation block and before `dryRun` retrieval, add agent parsing: + +```go + // Get and validate agent flag + agentStr, err := cmd.Flags().GetString("agent") + if err != nil { + return fmt.Errorf("failed to get agent flag: %w", err) + } + agent, err := install.ParseAgent(agentStr) + if err != nil { + return err + } +``` + +Replace the settings-path lookup: + +```go + settingsPath, err := install.FindBestSettingsPath(scope.String()) + if err != nil { + return fmt.Errorf("failed to find Claude Code settings path: %w", err) + } +``` + +with: + +```go + settingsPath, err := agent.BestConfigPath(scope.String()) + if err != nil { + return fmt.Errorf("failed to find %s config path: %w", agent, err) + } +``` + +In the dry-run branch, replace `hookNames := install.GetHookNames()` with `hookNames := agent.HookNames()`. + +After the dry-run "Would install hooks" lines, add the Codex trust reminder inside the `!quiet` branch: + +```go + if agent == install.AgentCodex { + cmd.Printf("After install, run /hooks in Codex to trust the claudio hook.\n") + } +``` + +Update the actual-install call: + +```go + err = runInstallWorkflow(scope.String(), settingsPath) +``` + +to: + +```go + err = runInstallWorkflow(agent, scope.String(), settingsPath) +``` + +And in the post-success `!quiet` block, after the existing success lines, add: + +```go + if agent == install.AgentCodex { + cmd.Printf("Run /hooks in Codex to trust the claudio hook.\n") + } +``` + +- [ ] **Step 5: Update `runInstallWorkflow` to take the agent** + +Change the signature and the two registry calls in `runInstallWorkflow`: + +```go +func runInstallWorkflow(agent install.Agent, scope string, settingsPath string) error { +``` + +Replace `claudioHooks, err := install.GenerateClaudioHooks(prodFS, execPath)` with: + +```go + claudioHooks, err := install.GenerateClaudioHooksForAgent(prodFS, execPath, agent) +``` + +Replace the verification loop's `expectedHooks := install.GetHookNames()` with: + +```go + expectedHooks := agent.HookNames() +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `go test ./internal/cli/ -run "TestInstallCommand" -v` +Expected: PASS. (Full cli build requires the working cgo linker fixed earlier.) + +- [ ] **Step 7: Commit** + +```bash +git add internal/cli/install_command.go internal/cli/install_command_test.go +git commit -m "feat: add --agent flag to install with codex trust reminder" +``` + +--- + +## Task 9: Agent-aware uninstall command + +**Files:** +- Modify: `internal/cli/uninstall_command.go` +- Test: `internal/cli/uninstall_command_test.go` + +- [ ] **Step 1: Write the failing test** + +Add to `internal/cli/uninstall_command_test.go`: + +```go +func TestUninstallCommandRejectsInvalidAgent(t *testing.T) { + cmd := newUninstallCommand() + cmd.SetArgs([]string{"--agent", "gemini", "--dry-run"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if err := cmd.Execute(); err == nil { + t.Error("expected error for invalid agent, got nil") + } +} + +func TestUninstallCommandCodexDryRunUsesCodexPath(t *testing.T) { + cmd := newUninstallCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--agent", "codex", "--dry-run"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), "hooks.json") { + t.Errorf("expected codex hooks.json path, got: %s", out.String()) + } +} +``` + +Note: ensure imports `bytes`, `io`, `strings` exist. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/cli/ -run "TestUninstallCommandRejectsInvalidAgent|TestUninstallCommandCodexDryRun" -v` +Expected: FAIL — `--agent` flag unknown / Claude path used instead of codex. + +- [ ] **Step 3: Add the `--agent` flag** + +In `newUninstallCommand()`, after the `--scope` flag, add: + +```go + cmd.Flags().StringP("agent", "a", "claude", "Target agent: 'claude' for Claude Code, 'codex' for OpenAI Codex CLI") +``` + +- [ ] **Step 4: Wire the flag through `runUninstallCommandE`** + +After scope validation, add: + +```go + agentStr, err := cmd.Flags().GetString("agent") + if err != nil { + return fmt.Errorf("failed to get agent flag: %w", err) + } + agent, err := install.ParseAgent(agentStr) + if err != nil { + return err + } +``` + +Replace: + +```go + settingsPath, err := install.FindBestSettingsPath(scope.String()) + if err != nil { + return fmt.Errorf("failed to find Claude Code settings path: %w", err) + } +``` + +with: + +```go + settingsPath, err := agent.BestConfigPath(scope.String()) + if err != nil { + return fmt.Errorf("failed to find %s config path: %w", agent, err) + } +``` + +The uninstall workflow itself is agent-agnostic (it detects Claudio entries by command basename), so `uninstall.RunUninstallWorkflow(scope.String(), settingsPath)` is unchanged. + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `go test ./internal/cli/ -run "TestUninstallCommand" -v` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/cli/uninstall_command.go internal/cli/uninstall_command_test.go +git commit -m "feat: add --agent flag to uninstall command" +``` + +--- + +## Task 10: End-to-end Codex install into hooks.json (afero integration) + +**Files:** +- Test: `internal/install/hooks_test.go` (add an integration-style test using the in-memory filesystem and the existing merge/read/write helpers) + +- [ ] **Step 1: Write the test** + +Add to `internal/install/hooks_test.go`: + +```go +func TestCodexInstallMergesIntoHooksJSON(t *testing.T) { + fsys := afero.NewMemMapFs() + path := "/home/u/.codex/hooks.json" + + // Pre-existing user hook that is NOT claudio — must be preserved. + existing := []byte(`{"hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"/usr/bin/logger"}]}]}}`) + if err := afero.WriteFile(fsys, path, existing, 0644); err != nil { + t.Fatal(err) + } + + settings, err := ReadSettingsFile(fsys, path) + if err != nil { + t.Fatal(err) + } + codexHooks, err := GenerateClaudioHooksForAgent(fsys, "/usr/local/bin/claudio", AgentCodex) + if err != nil { + t.Fatal(err) + } + merged, err := MergeHooksIntoSettings(settings, codexHooks) + if err != nil { + t.Fatal(err) + } + if err := WriteSettingsFile(fsys, path, merged); err != nil { + t.Fatal(err) + } + + readBack, err := ReadSettingsFile(fsys, path) + if err != nil { + t.Fatal(err) + } + hooksSection := (*readBack)["hooks"].(map[string]interface{}) + + // Claudio's PostCompact must be present. + if _, ok := hooksSection["PostCompact"]; !ok { + t.Error("expected PostCompact after codex install") + } + // Pre-existing non-claudio PreToolUse logger must survive alongside claudio. + preArr := hooksSection["PreToolUse"].([]interface{}) + foundLogger := false + for _, e := range preArr { + cfg := e.(map[string]interface{}) + hooksList := cfg["hooks"].([]interface{}) + for _, h := range hooksList { + if h.(map[string]interface{})["command"] == "/usr/bin/logger" { + foundLogger = true + } + } + } + if !foundLogger { + t.Error("pre-existing non-claudio hook was lost during merge") + } +} +``` + +- [ ] **Step 2: Run test to verify it passes** + +Run: `go test ./internal/install/ -run "TestCodexInstallMergesIntoHooksJSON" -v` +Expected: PASS (reuses existing merge logic; no new production code needed). If it fails, the defect is in how `MergeHooksIntoSettings` handles the new event names — investigate before changing the test. + +- [ ] **Step 3: Commit** + +```bash +git add internal/install/hooks_test.go +git commit -m "test: cover codex install merge into hooks.json" +``` + +--- + +## Task 11: Coverage ratchet to >= 90% + +**Files:** +- Test: `internal/hooks/parser_test.go`, `internal/install/*_test.go` + +**Goal:** Push `internal/hooks` (baseline 80.2%) and `internal/install` (baseline 81.0%) to >= 90%. Use the coverage report to target uncovered branches rather than guessing. + +- [ ] **Step 1: Generate per-function coverage for hooks** + +Run: +```bash +go test ./internal/hooks/ -coverprofile=hooks.cov && go tool cover -func=hooks.cov | sort -k3 -n | head -30 +``` +Expected: a list of functions with their coverage; the lowest-covered are your targets. + +- [ ] **Step 2: Add tests for the lowest-covered hooks branches** + +Likely targets (add tests that hit these branches): + +```go +func TestAnalyzeToolResponseInterrupted(t *testing.T) { + resp := json.RawMessage(`{"interrupted":true}`) + tool := "Bash" + e := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "PostToolUse", ToolName: &tool, ToolResponse: &resp} + ctx := e.GetContext() + if !ctx.HasError { + t.Error("expected HasError for interrupted response") + } + if ctx.SoundHint != "tool-interrupted" { + t.Errorf("expected tool-interrupted, got %q", ctx.SoundHint) + } +} + +func TestAnalyzeToolResponseIsError(t *testing.T) { + resp := json.RawMessage(`{"isError":true}`) + tool := "apply_patch" + e := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "PostToolUse", ToolName: &tool, ToolResponse: &resp} + ctx := e.GetContext() + if !ctx.HasError { + t.Error("expected HasError for isError response") + } +} + +func TestParseEmptyDataErrors(t *testing.T) { + if _, err := NewHookEventParser().Parse(nil); err == nil { + t.Error("expected error for empty data") + } +} + +func TestParseInvalidJSONErrors(t *testing.T) { + if _, err := NewHookEventParser().Parse([]byte(`{not json`)); err == nil { + t.Error("expected error for malformed json") + } +} +``` + +- [ ] **Step 3: Generate per-function coverage for install and add targeted tests** + +Run: +```bash +go test ./internal/install/ -coverprofile=install.cov && go tool cover -func=install.cov | sort -k3 -n | head -30 +``` + +Add tests for any uncovered `Agent` branches and the Windows `USERPROFILE` path in `findCodexUserScopePaths`. Example for the agent default/invalid paths and project scope: + +```go +func TestAgentBestConfigPathProjectScope(t *testing.T) { + p, err := AgentCodex.BestConfigPath("project") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(p, filepath.Join(".codex", "hooks.json")) { + t.Errorf("got %q", p) + } + cp, err := AgentClaude.BestConfigPath("project") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(cp, filepath.Join(".claude", "settings.json")) { + t.Errorf("got %q", cp) + } +} + +func TestAgentBestConfigPathInvalidScope(t *testing.T) { + if _, err := AgentCodex.BestConfigPath("bogus"); err == nil { + t.Error("expected error for invalid scope") + } +} +``` + +(Add `path/filepath` and `strings` imports to `agent_test.go` if missing.) + +- [ ] **Step 4: Verify coverage targets met** + +Run: +```bash +go test ./internal/hooks/ ./internal/install/ -cover +``` +Expected: both packages report `coverage: >= 90.0%`. If not, repeat Steps 1-3 against the remaining uncovered functions. + +- [ ] **Step 5: Clean up coverage artifacts and commit** + +```bash +rm -f hooks.cov install.cov +git add internal/hooks/parser_test.go internal/install/agent_test.go +git commit -m "test: ratchet hooks and install coverage to >=90%" +``` + +--- + +## Task 12: Full verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the whole suite with the cgo linker available** + +Run: `go test ./... -count=1` +Expected: all packages PASS, including `internal/cli`. + +- [ ] **Step 2: Build the binary and smoke-test a Codex payload** + +```bash +go build -o claudio . +echo '{"session_id":"test","cwd":"/test","hook_event_name":"PostToolUse","tool_name":"apply_patch","tool_response":{"output":"ok"}}' | ./claudio --silent +echo '{"session_id":"test","cwd":"/test","hook_event_name":"SubagentStart"}' | ./claudio --silent +``` +Expected: exit code 0, no error output (silent mode skips audio). + +- [ ] **Step 3: Smoke-test the Codex installer in dry-run** + +```bash +./claudio install --agent codex --dry-run +./claudio install --agent codex --print +``` +Expected: output shows a `.codex/hooks.json` path and the `/hooks` trust reminder. + +- [ ] **Step 4: Clean up the build artifact** + +```bash +rm -f claudio +``` + +- [ ] **Step 5: Final coverage confirmation** + +Run: `go test ./internal/hooks/ ./internal/install/ ./internal/cli/ -cover` +Expected: hooks >= 90%, install >= 90%, cli no lower than its 67.0% baseline (ideally higher from the new command tests). + +--- + +## Self-review notes + +- Spec section "Parser tolerance" → Tasks 1, 2, 3. +- Spec "Agent abstraction" → Tasks 4, 5, 6, 7. +- Spec "CLI wiring" (install + trust reminder, uninstall) → Tasks 8, 9. +- Spec "Data flow / merge into hooks.json" → Task 10. +- Spec "Coverage targets" → Task 11; full verification → Task 12. +- Type consistency: `Agent`, `AgentClaude`, `AgentCodex`, `ParseAgent`, `Matcher()`, `Registry()`, `EnabledHooks()`, `HookNames()`, `BestConfigPath()`, `CodexHooks`, `FindCodexHooksPaths`, `FindBestCodexPath`, `GenerateClaudioHooksForAgent` are used consistently across tasks. `runInstallWorkflow` gains an `install.Agent` first parameter in Task 8 and is only called from `runInstallCommandE`. +- Known environment risk: `internal/cli` needs the cgo linker (`ld.exe`) fixed earlier this session. The `hooks` and `install` packages have no cgo dependency. From 515a7b3dcb7c252334fd38199020e3836fcba8c8 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Wed, 27 May 2026 15:18:35 -0600 Subject: [PATCH 03/16] feat: accept hook events without transcript_path for codex --- internal/hooks/parser.go | 6 ------ internal/hooks/parser_test.go | 38 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/internal/hooks/parser.go b/internal/hooks/parser.go index 500727a..2c0fc2a 100644 --- a/internal/hooks/parser.go +++ b/internal/hooks/parser.go @@ -121,12 +121,6 @@ func (p *HookEventParser) Parse(data []byte) (*HookEvent, error) { return nil, err } - if event.TranscriptPath == "" { - err := fmt.Errorf("missing required field: transcript_path") - slog.Error("validation failed", "error", err) - return nil, err - } - slog.Debug("hook event parsed successfully", "event_name", event.EventName, "session_id", event.SessionID, diff --git a/internal/hooks/parser_test.go b/internal/hooks/parser_test.go index 2640d24..8eb7675 100644 --- a/internal/hooks/parser_test.go +++ b/internal/hooks/parser_test.go @@ -1302,3 +1302,41 @@ func TestMCPToolNormalization(t *testing.T) { } }) } + +// Codex sends transcript_path as null or omits it entirely. +func TestParseCodexNullTranscriptPathSucceeds(t *testing.T) { + parser := NewHookEventParser() + data := []byte(`{"session_id":"abc","cwd":"/tmp","hook_event_name":"SessionStart","transcript_path":null}`) + event, err := parser.Parse(data) + if err != nil { + t.Fatalf("expected nil error for null transcript_path, got: %v", err) + } + if event.EventName != "SessionStart" { + t.Errorf("expected SessionStart, got %q", event.EventName) + } +} + +func TestParseCodexOmittedTranscriptPathSucceeds(t *testing.T) { + parser := NewHookEventParser() + data := []byte(`{"session_id":"abc","cwd":"/tmp","hook_event_name":"Stop"}`) + _, err := parser.Parse(data) + if err != nil { + t.Fatalf("expected nil error for omitted transcript_path, got: %v", err) + } +} + +func TestParseStillRequiresSessionIDAndEventAndCwd(t *testing.T) { + parser := NewHookEventParser() + cases := map[string][]byte{ + "missing session_id": []byte(`{"cwd":"/tmp","hook_event_name":"Stop"}`), + "missing event": []byte(`{"session_id":"a","cwd":"/tmp"}`), + "missing cwd": []byte(`{"session_id":"a","hook_event_name":"Stop"}`), + } + for name, data := range cases { + t.Run(name, func(t *testing.T) { + if _, err := parser.Parse(data); err == nil { + t.Errorf("expected error for %s, got nil", name) + } + }) + } +} From bcd7b2554eabd01b04d8c1c9be46a88a72228a91 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Wed, 27 May 2026 15:19:04 -0600 Subject: [PATCH 04/16] feat: map codex SubagentStart and PostCompact events --- internal/hooks/parser.go | 12 ++++++++++++ internal/hooks/parser_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/internal/hooks/parser.go b/internal/hooks/parser.go index 2c0fc2a..1ef3ca0 100644 --- a/internal/hooks/parser.go +++ b/internal/hooks/parser.go @@ -280,6 +280,18 @@ func (e *HookEvent) GetContext() *EventContext { context.Operation = "subagent-stop" slog.Debug("categorizing SubagentStop event as Completion", "hint", context.SoundHint, "operation", context.Operation) + case "SubagentStart": + context.Category = Loading + context.SoundHint = "subagent-start" + context.Operation = "subagent-start" + slog.Debug("categorizing SubagentStart event as Loading", "hint", context.SoundHint, "operation", context.Operation) + + case "PostCompact": + context.Category = System + context.SoundHint = "post-compact" + context.Operation = "post-compact" + slog.Debug("categorizing PostCompact event as System", "hint", context.SoundHint, "operation", context.Operation) + case "PreCompact": context.Category = System context.SoundHint = "compacting" diff --git a/internal/hooks/parser_test.go b/internal/hooks/parser_test.go index 8eb7675..3cc3c7b 100644 --- a/internal/hooks/parser_test.go +++ b/internal/hooks/parser_test.go @@ -1340,3 +1340,31 @@ func TestParseStillRequiresSessionIDAndEventAndCwd(t *testing.T) { }) } } + +func TestGetContextSubagentStart(t *testing.T) { + event := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "SubagentStart"} + ctx := event.GetContext() + if ctx.Category != Loading { + t.Errorf("expected Loading, got %v", ctx.Category) + } + if ctx.SoundHint != "subagent-start" { + t.Errorf("expected subagent-start, got %q", ctx.SoundHint) + } + if ctx.Operation != "subagent-start" { + t.Errorf("expected operation subagent-start, got %q", ctx.Operation) + } +} + +func TestGetContextPostCompact(t *testing.T) { + event := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "PostCompact"} + ctx := event.GetContext() + if ctx.Category != System { + t.Errorf("expected System, got %v", ctx.Category) + } + if ctx.SoundHint != "post-compact" { + t.Errorf("expected post-compact, got %q", ctx.SoundHint) + } + if ctx.Operation != "post-compact" { + t.Errorf("expected operation post-compact, got %q", ctx.Operation) + } +} From db8f03cfab32744c52becbec4593bca58bc520e9 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Wed, 27 May 2026 15:19:24 -0600 Subject: [PATCH 05/16] test: cover codex apply_patch and mcp tool mapping --- internal/hooks/parser_test.go | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/internal/hooks/parser_test.go b/internal/hooks/parser_test.go index 3cc3c7b..751a8a8 100644 --- a/internal/hooks/parser_test.go +++ b/internal/hooks/parser_test.go @@ -1368,3 +1368,40 @@ func TestGetContextPostCompact(t *testing.T) { t.Errorf("expected operation post-compact, got %q", ctx.Operation) } } + +func TestGetContextCodexApplyPatchPreToolUse(t *testing.T) { + tool := "apply_patch" + event := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "PreToolUse", ToolName: &tool} + ctx := event.GetContext() + if ctx.Category != Loading { + t.Errorf("expected Loading, got %v", ctx.Category) + } + if ctx.SoundHint != "apply_patch-start" { + t.Errorf("expected apply_patch-start, got %q", ctx.SoundHint) + } +} + +func TestGetContextCodexApplyPatchPostToolUseSuccess(t *testing.T) { + tool := "apply_patch" + resp := json.RawMessage(`{"output":"done"}`) + event := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "PostToolUse", ToolName: &tool, ToolResponse: &resp} + ctx := event.GetContext() + if ctx.Category != Success { + t.Errorf("expected Success, got %v", ctx.Category) + } + if ctx.SoundHint != "apply_patch-success" { + t.Errorf("expected apply_patch-success, got %q", ctx.SoundHint) + } +} + +func TestGetContextCodexMcpToolNormalized(t *testing.T) { + tool := "mcp__filesystem__read_file" + event := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "PreToolUse", ToolName: &tool} + ctx := event.GetContext() + if ctx.ToolName != "mcp" { + t.Errorf("expected normalized tool name mcp, got %q", ctx.ToolName) + } + if ctx.SoundHint != "mcp-start" { + t.Errorf("expected mcp-start, got %q", ctx.SoundHint) + } +} From fdb47cf1f81f6c043510d08e1e1450d0aff813e6 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Wed, 27 May 2026 15:20:52 -0600 Subject: [PATCH 06/16] feat: add codex agent type, registry, and config path finder --- internal/install/agent.go | 74 +++++++++++++++++++++++++ internal/install/agent_test.go | 70 +++++++++++++++++++++++ internal/install/codex_settings.go | 62 +++++++++++++++++++++ internal/install/codex_settings_test.go | 58 +++++++++++++++++++ internal/install/hook_registry.go | 15 +++++ internal/install/hook_registry_test.go | 36 ++++++++++++ 6 files changed, 315 insertions(+) create mode 100644 internal/install/agent.go create mode 100644 internal/install/agent_test.go create mode 100644 internal/install/codex_settings.go create mode 100644 internal/install/codex_settings_test.go diff --git a/internal/install/agent.go b/internal/install/agent.go new file mode 100644 index 0000000..bb39a76 --- /dev/null +++ b/internal/install/agent.go @@ -0,0 +1,74 @@ +package install + +import ( + "fmt" + "log/slog" +) + +// Agent identifies which coding agent Claudio installs hooks for. +type Agent string + +const ( + AgentClaude Agent = "claude" + AgentCodex Agent = "codex" +) + +// ParseAgent validates and converts a string into an Agent. +func ParseAgent(s string) (Agent, error) { + switch Agent(s) { + case AgentClaude, AgentCodex: + return Agent(s), nil + default: + return "", fmt.Errorf("invalid agent '%s': must be 'claude' or 'codex'", s) + } +} + +// String returns the agent's string form. +func (a Agent) String() string { return string(a) } + +// Matcher returns the default hook matcher pattern for the agent. +// Codex uses "*"; Claude Code uses ".*". +func (a Agent) Matcher() string { + if a == AgentCodex { + return "*" + } + return ".*" +} + +// Registry returns the hook definitions supported for the agent. +func (a Agent) Registry() []HookDefinition { + if a == AgentCodex { + return CodexHooks + } + return AllHooks +} + +// EnabledHooks returns the agent's default-enabled hook definitions. +func (a Agent) EnabledHooks() []HookDefinition { + var enabled []HookDefinition + for _, h := range a.Registry() { + if h.DefaultEnabled { + enabled = append(enabled, h) + } + } + slog.Debug("agent enabled hooks", "agent", a, "count", len(enabled)) + return enabled +} + +// HookNames returns the names of every hook in the agent's registry. +func (a Agent) HookNames() []string { + reg := a.Registry() + names := make([]string, len(reg)) + for i, h := range reg { + names[i] = h.Name + } + return names +} + +// BestConfigPath returns the config file path to install hooks into for the agent and scope. +func (a Agent) BestConfigPath(scope string) (string, error) { + if a == AgentCodex { + return FindBestCodexPath(scope) + } + return FindBestSettingsPath(scope) +} diff --git a/internal/install/agent_test.go b/internal/install/agent_test.go new file mode 100644 index 0000000..06c2706 --- /dev/null +++ b/internal/install/agent_test.go @@ -0,0 +1,70 @@ +package install + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestParseAgentValid(t *testing.T) { + cases := map[string]Agent{ + "claude": AgentClaude, + "codex": AgentCodex, + } + for in, want := range cases { + got, err := ParseAgent(in) + if err != nil { + t.Fatalf("ParseAgent(%q) returned error: %v", in, err) + } + if got != want { + t.Errorf("ParseAgent(%q) = %v, want %v", in, got, want) + } + } +} + +func TestParseAgentInvalid(t *testing.T) { + if _, err := ParseAgent("gemini"); err == nil { + t.Error("expected error for invalid agent, got nil") + } +} + +func TestAgentMatcher(t *testing.T) { + if AgentClaude.Matcher() != ".*" { + t.Errorf("claude matcher = %q, want .*", AgentClaude.Matcher()) + } + if AgentCodex.Matcher() != "*" { + t.Errorf("codex matcher = %q, want *", AgentCodex.Matcher()) + } +} + +func TestAgentString(t *testing.T) { + if AgentCodex.String() != "codex" { + t.Errorf("got %q", AgentCodex.String()) + } +} + +func TestAgentBestConfigPathProjectScope(t *testing.T) { + p, err := AgentCodex.BestConfigPath("project") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(p, filepath.Join(".codex", "hooks.json")) { + t.Errorf("codex project path %q missing .codex/hooks.json", p) + } + cp, err := AgentClaude.BestConfigPath("project") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(cp, filepath.Join(".claude", "settings.json")) { + t.Errorf("claude project path %q missing .claude/settings.json", cp) + } +} + +func TestAgentBestConfigPathInvalidScope(t *testing.T) { + if _, err := AgentCodex.BestConfigPath("bogus"); err == nil { + t.Error("expected error for invalid codex scope") + } + if _, err := AgentClaude.BestConfigPath("bogus"); err == nil { + t.Error("expected error for invalid claude scope") + } +} diff --git a/internal/install/codex_settings.go b/internal/install/codex_settings.go new file mode 100644 index 0000000..e8dc483 --- /dev/null +++ b/internal/install/codex_settings.go @@ -0,0 +1,62 @@ +package install + +import ( + "fmt" + "os" + "path/filepath" + "runtime" +) + +// FindCodexHooksPaths returns candidate ~/.codex/hooks.json paths for the scope, in priority order. +func FindCodexHooksPaths(scope string) ([]string, error) { + switch scope { + case "user": + return findCodexUserScopePaths(), nil + case "project": + return []string{ + filepath.Join(".", ".codex", "hooks.json"), + filepath.Join(".codex", "hooks.json"), + }, nil + default: + return nil, fmt.Errorf("invalid scope '%s': must be 'user' or 'project'", scope) + } +} + +func findCodexUserScopePaths() []string { + var paths []string + + homeDir := getHomeDirectory() + if homeDir != "" { + paths = append(paths, filepath.Join(homeDir, ".codex", "hooks.json")) + } + + if runtime.GOOS == "windows" { + userProfile := os.Getenv("USERPROFILE") + if userProfile != "" && userProfile != homeDir { + paths = append(paths, filepath.Join(userProfile, ".codex", "hooks.json")) + } + } + + if len(paths) == 0 { + paths = append(paths, filepath.Join("~", ".codex", "hooks.json")) + } + + return paths +} + +// FindBestCodexPath returns the first existing Codex hooks path, or the first candidate for creation. +func FindBestCodexPath(scope string) (string, error) { + paths, err := FindCodexHooksPaths(scope) + if err != nil { + return "", err + } + if len(paths) == 0 { + return "", fmt.Errorf("no codex hooks paths found for scope: %s", scope) + } + for _, path := range paths { + if _, err := os.Stat(path); err == nil { + return path, nil + } + } + return paths[0], nil +} diff --git a/internal/install/codex_settings_test.go b/internal/install/codex_settings_test.go new file mode 100644 index 0000000..4c5c28d --- /dev/null +++ b/internal/install/codex_settings_test.go @@ -0,0 +1,58 @@ +package install + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestFindCodexHooksPathsUserScope(t *testing.T) { + paths, err := FindCodexHooksPaths("user") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(paths) == 0 { + t.Fatal("expected at least one user-scope path") + } + want := filepath.Join(".codex", "hooks.json") + for _, p := range paths { + if !strings.HasSuffix(p, want) { + t.Errorf("path %q does not end with %q", p, want) + } + } +} + +func TestFindCodexHooksPathsProjectScope(t *testing.T) { + paths, err := FindCodexHooksPaths("project") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(paths) == 0 { + t.Fatal("expected at least one project-scope path") + } + if !strings.Contains(paths[0], filepath.Join(".codex", "hooks.json")) { + t.Errorf("project path %q missing .codex/hooks.json", paths[0]) + } +} + +func TestFindCodexHooksPathsInvalidScope(t *testing.T) { + if _, err := FindCodexHooksPaths("bogus"); err == nil { + t.Error("expected error for invalid scope, got nil") + } +} + +func TestFindBestCodexPathReturnsFirstWhenNoneExist(t *testing.T) { + got, err := FindBestCodexPath("user") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got == "" { + t.Fatal("expected a non-empty path") + } +} + +func TestFindBestCodexPathInvalidScope(t *testing.T) { + if _, err := FindBestCodexPath("bogus"); err == nil { + t.Error("expected error for invalid scope") + } +} diff --git a/internal/install/hook_registry.go b/internal/install/hook_registry.go index 36c2a46..71d85a1 100644 --- a/internal/install/hook_registry.go +++ b/internal/install/hook_registry.go @@ -66,6 +66,21 @@ var AllHooks = []HookDefinition{ }, } +// CodexHooks defines the registry of OpenAI Codex CLI hooks supported by Claudio. +// Codex lacks Notification and SessionEnd; it adds SubagentStart and PostCompact. +var CodexHooks = []HookDefinition{ + {Name: "PreToolUse", Category: hooks.Loading, Description: "Play loading sounds before Codex tool execution", DefaultEnabled: true}, + {Name: "PostToolUse", Category: hooks.Success, Description: "Play success/error sounds after Codex tool execution", DefaultEnabled: true}, + {Name: "UserPromptSubmit", Category: hooks.Interactive, Description: "Play interaction sounds when user submits prompts", DefaultEnabled: true}, + {Name: "Stop", Category: hooks.Completion, Description: "Play sounds when Codex finishes responding", DefaultEnabled: true}, + {Name: "SubagentStop", Category: hooks.Completion, Description: "Play sounds when a Codex subagent finishes", DefaultEnabled: true}, + {Name: "SubagentStart", Category: hooks.Loading, Description: "Play sounds when a Codex subagent starts", DefaultEnabled: true}, + {Name: "PreCompact", Category: hooks.System, Description: "Play sounds before Codex context compaction", DefaultEnabled: true}, + {Name: "PostCompact", Category: hooks.System, Description: "Play sounds after Codex context compaction", DefaultEnabled: true}, + {Name: "SessionStart", Category: hooks.System, Description: "Play sounds when a Codex session starts or resumes", DefaultEnabled: true}, + {Name: "PermissionRequest", Category: hooks.Interactive, Description: "Play sounds for Codex permission requests", DefaultEnabled: true}, +} + // GetAllHooks returns all hooks defined in the registry func GetAllHooks() []HookDefinition { slog.Debug("retrieving all hooks from registry", "total_hooks", len(AllHooks)) diff --git a/internal/install/hook_registry_test.go b/internal/install/hook_registry_test.go index 21b80d1..2eb11aa 100644 --- a/internal/install/hook_registry_test.go +++ b/internal/install/hook_registry_test.go @@ -161,3 +161,39 @@ func TestDefaultEnabledStatus(t *testing.T) { } } } + +func TestCodexRegistryContents(t *testing.T) { + want := map[string]bool{ + "PreToolUse": true, "PostToolUse": true, "UserPromptSubmit": true, + "Stop": true, "SubagentStop": true, "SubagentStart": true, + "PreCompact": true, "PostCompact": true, "SessionStart": true, + "PermissionRequest": true, + } + got := map[string]bool{} + for _, h := range CodexHooks { + got[h.Name] = true + } + if len(got) != len(want) { + t.Errorf("codex registry has %d events, want %d", len(got), len(want)) + } + for name := range want { + if !got[name] { + t.Errorf("codex registry missing %q", name) + } + } + if got["Notification"] || got["SessionEnd"] { + t.Error("codex registry must not contain Notification or SessionEnd") + } +} + +func TestAgentEnabledHooksAndNames(t *testing.T) { + if len(AgentCodex.EnabledHooks()) != len(CodexHooks) { + t.Errorf("expected all codex hooks enabled by default") + } + if len(AgentCodex.HookNames()) != 10 { + t.Errorf("expected 10 codex hook names, got %d", len(AgentCodex.HookNames())) + } + if len(AgentClaude.HookNames()) != len(AllHooks) { + t.Errorf("claude hook names mismatch") + } +} From f64991d2c87c3a27a88c6c4612942eb1266bc2ee Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Wed, 27 May 2026 15:22:05 -0600 Subject: [PATCH 07/16] feat: generate agent-specific claudio hooks --- internal/install/hooks.go | 33 ++++++++++++++------------ internal/install/hooks_test.go | 43 ++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/internal/install/hooks.go b/internal/install/hooks.go index 6b30358..6216673 100644 --- a/internal/install/hooks.go +++ b/internal/install/hooks.go @@ -15,28 +15,29 @@ import ( type HooksMap map[string]interface{} -// GenerateClaudioHooks creates the hook configuration for Claudio installation with filesystem abstraction -// Uses the central hook registry to generate all enabled hooks dynamically -// Returns a hooks map that can be integrated into Claude Code settings.json -// Accepts filesystem and executable path parameters to prevent config corruption during testing +// GenerateClaudioHooks creates the Claude Code hook configuration (backward-compatible default). func GenerateClaudioHooks(filesystem afero.Fs, executablePath string) (interface{}, error) { - slog.Debug("generating Claudio hooks configuration using registry with filesystem abstraction", - "executable_path", executablePath) + return GenerateClaudioHooksForAgent(filesystem, executablePath, AgentClaude) +} + +// GenerateClaudioHooksForAgent creates hook configuration for the given agent using its +// registry and matcher. Returns a hooks map suitable for Claude settings.json or Codex hooks.json. +// Accepts filesystem and executable path parameters to prevent config corruption during testing. +func GenerateClaudioHooksForAgent(filesystem afero.Fs, executablePath string, agent Agent) (interface{}, error) { + slog.Debug("generating Claudio hooks configuration", + "agent", agent, "executable_path", executablePath) - // Get enabled hooks from registry - enabledHooks := GetEnabledHooks() - slog.Debug("retrieved enabled hooks from registry", "count", len(enabledHooks)) + enabledHooks := agent.EnabledHooks() + matcher := agent.Matcher() + slog.Debug("retrieved enabled hooks for agent", "agent", agent, "count", len(enabledHooks)) hooks := make(HooksMap) // Helper function to create hook config structure createHookConfig := func() interface{} { - // Use provided executable path to prevent config corruption - slog.Debug("using provided executable path for hook command", "path", executablePath) - return []interface{}{ map[string]interface{}{ - "matcher": ".*", + "matcher": matcher, "hooks": []interface{}{ map[string]interface{}{ "type": "command", @@ -47,16 +48,18 @@ func GenerateClaudioHooks(filesystem afero.Fs, executablePath string) (interface } } - // Generate hooks for all enabled hooks in registry + // Generate hooks for all enabled hooks in the agent's registry for _, hookDef := range enabledHooks { hooks[hookDef.Name] = createHookConfig() slog.Debug("added hook from registry", + "agent", agent, "hook_name", hookDef.Name, "category", hookDef.Category, "description", hookDef.Description) } - slog.Info("generated Claudio hooks configuration from registry with filesystem abstraction", + slog.Info("generated Claudio hooks configuration", + "agent", agent, "hook_count", len(hooks), "hooks", getHookNamesList(hooks)) diff --git a/internal/install/hooks_test.go b/internal/install/hooks_test.go index 49db8e2..27f6494 100644 --- a/internal/install/hooks_test.go +++ b/internal/install/hooks_test.go @@ -472,3 +472,46 @@ func TestGenerateClaudioHooksUsesExecutablePath(t *testing.T) { } } } + +func TestGenerateClaudioHooksForCodexAgent(t *testing.T) { + fsys := GetFilesystemFactory().Memory() + result, err := GenerateClaudioHooksForAgent(fsys, "/usr/local/bin/claudio", AgentCodex) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + hooks, ok := result.(HooksMap) + if !ok { + t.Fatalf("expected HooksMap, got %T", result) + } + if len(hooks) != len(CodexHooks) { + t.Errorf("expected %d codex hooks, got %d", len(CodexHooks), len(hooks)) + } + if _, ok := hooks["PostCompact"]; !ok { + t.Error("expected PostCompact in codex hooks") + } + if _, ok := hooks["Notification"]; ok { + t.Error("codex hooks must not include Notification") + } + arr := hooks["Stop"].([]interface{}) + cfg := arr[0].(map[string]interface{}) + if cfg["matcher"] != "*" { + t.Errorf("codex matcher = %v, want *", cfg["matcher"]) + } +} + +func TestGenerateClaudioHooksDefaultsToClaude(t *testing.T) { + fsys := GetFilesystemFactory().Memory() + result, err := GenerateClaudioHooks(fsys, "/usr/local/bin/claudio") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + hooks := result.(HooksMap) + if len(hooks) != len(GetEnabledHooks()) { + t.Errorf("claude generation count mismatch") + } + arr := hooks["PreToolUse"].([]interface{}) + cfg := arr[0].(map[string]interface{}) + if cfg["matcher"] != ".*" { + t.Errorf("claude matcher = %v, want .*", cfg["matcher"]) + } +} From 57d3a0514c55b57aa6988f24b2a102d631731f02 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Wed, 27 May 2026 15:25:03 -0600 Subject: [PATCH 08/16] feat: add --agent flag to install with codex trust reminder --- internal/cli/install_command.go | 39 +++++++++++++++++------- internal/cli/install_command_test.go | 45 ++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 10 deletions(-) diff --git a/internal/cli/install_command.go b/internal/cli/install_command.go index 1512340..ca69ae0 100644 --- a/internal/cli/install_command.go +++ b/internal/cli/install_command.go @@ -40,6 +40,9 @@ func newInstallCommand() *cobra.Command { // Add --scope flag with validation cmd.Flags().StringP("scope", "s", "user", "Installation scope: 'user' for user-specific settings, 'project' for project-specific settings") + // Add --agent flag with validation + cmd.Flags().StringP("agent", "a", "claude", "Target agent: 'claude' for Claude Code, 'codex' for OpenAI Codex CLI") + // Add --dry-run flag cmd.Flags().BoolP("dry-run", "d", false, "Show what would be done without making changes (simulation mode)") @@ -68,6 +71,16 @@ func runInstallCommandE(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid scope '%s': must be 'user' or 'project'", scopeStr) } + // Get and validate agent flag + agentStr, err := cmd.Flags().GetString("agent") + if err != nil { + return fmt.Errorf("failed to get agent flag: %w", err) + } + agent, err := install.ParseAgent(agentStr) + if err != nil { + return err + } + // Get dry-run flag dryRun, err := cmd.Flags().GetBool("dry-run") if err != nil { @@ -89,10 +102,10 @@ func runInstallCommandE(cmd *cobra.Command, args []string) error { slog.Info("install command executing", "scope", scope, "dry_run", dryRun, "quiet", quiet, "print", print) - // Find the best Claude Code settings path for the specified scope - settingsPath, err := install.FindBestSettingsPath(scope.String()) + // Find the best config path for the specified agent and scope + settingsPath, err := agent.BestConfigPath(scope.String()) if err != nil { - return fmt.Errorf("failed to find Claude Code settings path: %w", err) + return fmt.Errorf("failed to find %s config path: %w", agent, err) } slog.Debug("using settings path", "path", settingsPath, "scope", scope) @@ -124,11 +137,14 @@ func runInstallCommandE(cmd *cobra.Command, args []string) error { cmd.Printf("DRY-RUN: Claudio installation simulation for %s scope\n", scope.String()) cmd.Printf("Settings path: %s\n", settingsPath) - // Use registry to show hook names instead of hardcoded list - hookNames := install.GetHookNames() + // Use agent registry to show hook names instead of hardcoded list + hookNames := agent.HookNames() hookList := strings.Join(hookNames, ", ") cmd.Printf("Would install hooks: %s\n", hookList) cmd.Printf("No changes will be made.\n") + if agent == install.AgentCodex { + cmd.Printf("After install, run /hooks in Codex to trust the claudio hook.\n") + } } else { cmd.Printf("DRY-RUN: %s -> %s\n", scope.String(), settingsPath) } @@ -141,7 +157,7 @@ func runInstallCommandE(cmd *cobra.Command, args []string) error { cmd.Printf("Settings path: %s\n", settingsPath) } - err = runInstallWorkflow(scope.String(), settingsPath) + err = runInstallWorkflow(agent, scope.String(), settingsPath) if err != nil { return fmt.Errorf("installation failed: %w", err) } @@ -149,7 +165,10 @@ func runInstallCommandE(cmd *cobra.Command, args []string) error { // Success message if !quiet { cmd.Printf("✅ Claudio installation completed successfully!\n") - cmd.Printf("Audio hooks have been added to Claude Code settings.\n") + cmd.Printf("Audio hooks have been added to %s settings.\n", agent) + if agent == install.AgentCodex { + cmd.Printf("Run /hooks in Codex to trust the claudio hook.\n") + } } else { cmd.Printf("Install: %s ✅\n", scope.String()) } @@ -159,7 +178,7 @@ func runInstallCommandE(cmd *cobra.Command, args []string) error { // runInstallWorkflow orchestrates the complete Claudio installation process // Workflow: Detect paths → Read settings → Generate hooks → Merge → Write → Verify -func runInstallWorkflow(scope string, settingsPath string) error { +func runInstallWorkflow(agent install.Agent, scope string, settingsPath string) error { slog.Info("starting Claudio installation workflow", "scope", scope, "settings_path", settingsPath) @@ -195,7 +214,7 @@ func runInstallWorkflow(scope string, settingsPath string) error { // Use production filesystem (reuse existing variables) - claudioHooks, err := install.GenerateClaudioHooks(prodFS, execPath) + claudioHooks, err := install.GenerateClaudioHooksForAgent(prodFS, execPath, agent) if err != nil { return fmt.Errorf("failed to generate Claudio hooks: %w", err) } @@ -231,7 +250,7 @@ func runInstallWorkflow(scope string, settingsPath string) error { // Check that all Claudio hooks are present if hooks, exists := (*verifySettings)["hooks"]; exists { if hooksMap, ok := hooks.(map[string]interface{}); ok { - expectedHooks := install.GetHookNames() // Use registry instead of hardcoded list + expectedHooks := agent.HookNames() // Use agent registry instead of hardcoded list for _, hookName := range expectedHooks { if val, exists := hooksMap[hookName]; !exists { return fmt.Errorf("verification failed: Claudio hook '%s' missing after installation", hookName) diff --git a/internal/cli/install_command_test.go b/internal/cli/install_command_test.go index e4f5dec..f4c6144 100644 --- a/internal/cli/install_command_test.go +++ b/internal/cli/install_command_test.go @@ -1,6 +1,9 @@ package cli import ( + "bytes" + "io" + "strings" "testing" "github.com/spf13/cobra" @@ -41,3 +44,45 @@ func findCommand(rootCmd *cobra.Command, name string) *cobra.Command { } return nil } + +func TestInstallCommandRejectsInvalidAgent(t *testing.T) { + cmd := newInstallCommand() + cmd.SetArgs([]string{"--agent", "gemini", "--dry-run"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if err := cmd.Execute(); err == nil { + t.Error("expected error for invalid agent, got nil") + } +} + +func TestInstallCommandCodexDryRunShowsTrustReminder(t *testing.T) { + cmd := newInstallCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--agent", "codex", "--dry-run"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := out.String() + if !strings.Contains(s, "hooks.json") { + t.Errorf("expected codex hooks.json path in output, got: %s", s) + } + if !strings.Contains(s, "/hooks") { + t.Errorf("expected /hooks trust reminder in output, got: %s", s) + } +} + +func TestInstallCommandDefaultsToClaude(t *testing.T) { + cmd := newInstallCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--dry-run"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), "settings.json") { + t.Errorf("expected claude settings.json path by default, got: %s", out.String()) + } +} From 4058ee3d865bccbfa9c0cd73d01c6a9abeb73be8 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Wed, 27 May 2026 15:26:10 -0600 Subject: [PATCH 09/16] feat: add --agent flag to uninstall command --- internal/cli/uninstall_command.go | 19 +++++++++-- internal/cli/uninstall_command_test.go | 46 ++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) create mode 100644 internal/cli/uninstall_command_test.go diff --git a/internal/cli/uninstall_command.go b/internal/cli/uninstall_command.go index 755f693..384c740 100644 --- a/internal/cli/uninstall_command.go +++ b/internal/cli/uninstall_command.go @@ -21,6 +21,9 @@ func newUninstallCommand() *cobra.Command { // Add --scope flag with validation cmd.Flags().StringP("scope", "s", "user", "Uninstall scope: 'user' for user-specific settings, 'project' for project-specific settings") + // Add --agent flag with validation + cmd.Flags().StringP("agent", "a", "claude", "Target agent: 'claude' for Claude Code, 'codex' for OpenAI Codex CLI") + // Add --dry-run flag cmd.Flags().BoolP("dry-run", "d", false, "Show what would be removed without making changes (simulation mode)") @@ -49,6 +52,16 @@ func runUninstallCommandE(cmd *cobra.Command, args []string) error { return fmt.Errorf("invalid scope '%s': must be 'user' or 'project'", scopeStr) } + // Get and validate agent flag + agentStr, err := cmd.Flags().GetString("agent") + if err != nil { + return fmt.Errorf("failed to get agent flag: %w", err) + } + agent, err := install.ParseAgent(agentStr) + if err != nil { + return err + } + // Get dry-run flag dryRun, err := cmd.Flags().GetBool("dry-run") if err != nil { @@ -70,10 +83,10 @@ func runUninstallCommandE(cmd *cobra.Command, args []string) error { slog.Info("uninstall command executing", "scope", scope, "dry_run", dryRun, "quiet", quiet, "print", print) - // Find the best Claude Code settings path for the specified scope - settingsPath, err := install.FindBestSettingsPath(scope.String()) + // Find the best config path for the specified agent and scope + settingsPath, err := agent.BestConfigPath(scope.String()) if err != nil { - return fmt.Errorf("failed to find Claude Code settings path: %w", err) + return fmt.Errorf("failed to find %s config path: %w", agent, err) } slog.Debug("using settings path", "path", settingsPath, "scope", scope) diff --git a/internal/cli/uninstall_command_test.go b/internal/cli/uninstall_command_test.go new file mode 100644 index 0000000..8c492b8 --- /dev/null +++ b/internal/cli/uninstall_command_test.go @@ -0,0 +1,46 @@ +package cli + +import ( + "bytes" + "io" + "strings" + "testing" +) + +func TestUninstallCommandRejectsInvalidAgent(t *testing.T) { + cmd := newUninstallCommand() + cmd.SetArgs([]string{"--agent", "gemini", "--dry-run"}) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if err := cmd.Execute(); err == nil { + t.Error("expected error for invalid agent, got nil") + } +} + +func TestUninstallCommandCodexDryRunUsesCodexPath(t *testing.T) { + cmd := newUninstallCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--agent", "codex", "--dry-run"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), "hooks.json") { + t.Errorf("expected codex hooks.json path, got: %s", out.String()) + } +} + +func TestUninstallCommandDefaultsToClaude(t *testing.T) { + cmd := newUninstallCommand() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"--dry-run"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out.String(), "settings.json") { + t.Errorf("expected claude settings.json path by default, got: %s", out.String()) + } +} From 6158a913f746ab72996df9c46f2a1bd0d9aed0d9 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Wed, 27 May 2026 15:26:54 -0600 Subject: [PATCH 10/16] test: cover codex install merge into hooks.json --- internal/install/hooks_test.go | 54 ++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/internal/install/hooks_test.go b/internal/install/hooks_test.go index 27f6494..b51d5ea 100644 --- a/internal/install/hooks_test.go +++ b/internal/install/hooks_test.go @@ -3,6 +3,8 @@ package install import ( "encoding/json" "testing" + + "github.com/spf13/afero" ) func TestGenerateClaudioHooks(t *testing.T) { @@ -515,3 +517,55 @@ func TestGenerateClaudioHooksDefaultsToClaude(t *testing.T) { t.Errorf("claude matcher = %v, want .*", cfg["matcher"]) } } + +func TestCodexInstallMergesIntoHooksJSON(t *testing.T) { + fsys := afero.NewMemMapFs() + path := "/home/u/.codex/hooks.json" + + // Pre-existing user hook that is NOT claudio — must be preserved. + existing := []byte(`{"hooks":{"PreToolUse":[{"matcher":"*","hooks":[{"type":"command","command":"/usr/bin/logger"}]}]}}`) + if err := afero.WriteFile(fsys, path, existing, 0644); err != nil { + t.Fatal(err) + } + + settings, err := ReadSettingsFile(fsys, path) + if err != nil { + t.Fatal(err) + } + codexHooks, err := GenerateClaudioHooksForAgent(fsys, "/usr/local/bin/claudio", AgentCodex) + if err != nil { + t.Fatal(err) + } + merged, err := MergeHooksIntoSettings(settings, codexHooks) + if err != nil { + t.Fatal(err) + } + if err := WriteSettingsFile(fsys, path, merged); err != nil { + t.Fatal(err) + } + + readBack, err := ReadSettingsFile(fsys, path) + if err != nil { + t.Fatal(err) + } + hooksSection := (*readBack)["hooks"].(map[string]interface{}) + + if _, ok := hooksSection["PostCompact"]; !ok { + t.Error("expected PostCompact after codex install") + } + + preArr := hooksSection["PreToolUse"].([]interface{}) + foundLogger := false + for _, e := range preArr { + cfg := e.(map[string]interface{}) + hooksList := cfg["hooks"].([]interface{}) + for _, h := range hooksList { + if h.(map[string]interface{})["command"] == "/usr/bin/logger" { + foundLogger = true + } + } + } + if !foundLogger { + t.Error("pre-existing non-claudio hook was lost during merge") + } +} From d8f4d5d7df737a1b82390e87f2dda7a205930843 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Wed, 27 May 2026 15:31:45 -0600 Subject: [PATCH 11/16] test: ratchet hooks and install coverage above 90% --- internal/hooks/parser_coverage_test.go | 125 +++++++++++++ internal/install/coverage_test.go | 247 +++++++++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 internal/hooks/parser_coverage_test.go create mode 100644 internal/install/coverage_test.go diff --git a/internal/hooks/parser_coverage_test.go b/internal/hooks/parser_coverage_test.go new file mode 100644 index 0000000..97b8d1c --- /dev/null +++ b/internal/hooks/parser_coverage_test.go @@ -0,0 +1,125 @@ +package hooks + +import ( + "encoding/json" + "testing" +) + +func TestEventCategoryStringAll(t *testing.T) { + cases := map[EventCategory]string{ + Loading: "loading", + Success: "success", + Error: "error", + Interactive: "interactive", + Completion: "completion", + System: "system", + EventCategory(999): "unknown", + } + for cat, want := range cases { + if got := cat.String(); got != want { + t.Errorf("EventCategory(%d).String() = %q, want %q", int(cat), got, want) + } + } +} + +func TestExtractFileExtensionCoverage(t *testing.T) { + cases := map[string]string{ + "/a/b/main.go": "go", + "/a/b/file.TXT": "txt", + "/a/b/archive.tmp": "", + "/a/b/note.log": "", + "/a/b/old.bak": "", + "/a/b/x.orig": "", + "/a/b/noext": "", + "/a/b/trailing.": "", + } + for path, want := range cases { + if got := extractFileExtension(path); got != want { + t.Errorf("extractFileExtension(%q) = %q, want %q", path, got, want) + } + } +} + +func TestIsValidSubcommandCoverage(t *testing.T) { + cases := []struct { + command, word string + want bool + }{ + {"git", "commit", true}, + {"git", "notasubcommand", false}, + {"ls", "/path/to/file", false}, + {"curl", "http://example.com", false}, + {"systemctl", "start", true}, + {"weird", "has!bang", false}, + {"ls", "file.txt", false}, + } + for _, c := range cases { + if got := isValidSubcommand(c.command, c.word); got != c.want { + t.Errorf("isValidSubcommand(%q,%q) = %v, want %v", c.command, c.word, got, c.want) + } + } +} + +func postToolUseContext(t *testing.T, tool string, response string) *EventContext { + t.Helper() + resp := json.RawMessage(response) + e := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "PostToolUse", ToolName: &tool, ToolResponse: &resp} + return e.GetContext() +} + +func TestAnalyzeToolResponseBranches(t *testing.T) { + // MCP-style isError on a non-Bash tool + if ctx := postToolUseContext(t, "apply_patch", `{"isError":true}`); !ctx.HasError { + t.Error("expected HasError for isError=true") + } + // interrupted + if ctx := postToolUseContext(t, "Bash", `{"interrupted":true}`); ctx.SoundHint != "tool-interrupted" { + t.Errorf("expected tool-interrupted, got %q", ctx.SoundHint) + } + // Read with content -> success + if ctx := postToolUseContext(t, "Read", `{"content":"hello"}`); ctx.Category != Success { + t.Errorf("expected Success for Read with content, got %v", ctx.Category) + } + // Read without content -> error + if ctx := postToolUseContext(t, "Read", `{}`); ctx.Category != Error { + t.Errorf("expected Error for Read without content, got %v", ctx.Category) + } + // Edit with explicit success=false -> error + if ctx := postToolUseContext(t, "Edit", `{"success":false}`); ctx.Category != Error { + t.Errorf("expected Error for Edit success=false, got %v", ctx.Category) + } + // Edit with explicit success=true -> success + if ctx := postToolUseContext(t, "Edit", `{"success":true}`); ctx.Category != Success { + t.Errorf("expected Success for Edit success=true, got %v", ctx.Category) + } + // Grep with numLines -> success + if ctx := postToolUseContext(t, "Grep", `{"numLines":0}`); ctx.Category != Success { + t.Errorf("expected Success for Grep numLines=0, got %v", ctx.Category) + } + // Unparseable tool_response -> error + if ctx := postToolUseContext(t, "Bash", `not json`); !ctx.HasError { + t.Error("expected HasError for unparseable tool_response") + } +} + +func TestDetectNotificationTypeCoverage(t *testing.T) { + mk := func(msg string) *EventContext { + m := msg + e := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "Notification", Message: &m} + return e.GetContext() + } + if mk("Claude needs your permission to run").SoundHint != "notification-permission" { + t.Error("expected notification-permission") + } + if mk("Claude has been idle for 60s").SoundHint != "notification-idle" { + t.Error("expected notification-idle") + } + if mk("something else entirely").SoundHint != "notification" { + t.Error("expected generic notification") + } + // nil message + e := &HookEvent{SessionID: "a", CWD: "/tmp", EventName: "Notification"} + if e.GetContext().SoundHint != "notification" { + t.Error("expected generic notification for nil message") + } +} diff --git a/internal/install/coverage_test.go b/internal/install/coverage_test.go new file mode 100644 index 0000000..0fca115 --- /dev/null +++ b/internal/install/coverage_test.go @@ -0,0 +1,247 @@ +package install + +import ( + "path/filepath" + "runtime" + "testing" + + "github.com/spf13/afero" +) + +func TestNormalizeMSYSPathCoverage(t *testing.T) { + cases := map[string]string{ + "/c/Users/Q": `C:\Users\Q`, + "/d/work": `D:\work`, + "already/plain": "already/plain", + "": "", + "/notdrive": "/notdrive", + } + for in, want := range cases { + if got := normalizeMSYSPath(in); got != want { + t.Errorf("normalizeMSYSPath(%q) = %q, want %q", in, got, want) + } + } +} + +func TestGetHomeDirectoryWindowsBranches(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("windows-only home resolution branches") + } + // MSYS-style HOME when USERPROFILE absent + t.Setenv("USERPROFILE", "") + t.Setenv("HOME", "/c/Users/Q") + if got := getHomeDirectory(); got != `C:\Users\Q` { + t.Errorf("MSYS HOME normalization: got %q", got) + } + // HOMEDRIVE + HOMEPATH fallback + t.Setenv("HOME", "") + t.Setenv("HOMEDRIVE", "D:") + t.Setenv("HOMEPATH", `\Users\Q`) + if got := getHomeDirectory(); got != `D:\Users\Q` { + t.Errorf("HOMEDRIVE+HOMEPATH: got %q", got) + } + // Nothing set -> empty + t.Setenv("HOMEDRIVE", "") + t.Setenv("HOMEPATH", "") + if got := getHomeDirectory(); got != "" { + t.Errorf("expected empty home when no env set, got %q", got) + } +} + +func TestWriteSettingsFileReadOnlyFails(t *testing.T) { + ro := afero.NewReadOnlyFs(afero.NewMemMapFs()) + if err := WriteSettingsFile(ro, "/x/settings.json", &SettingsMap{"a": "b"}); err == nil { + t.Error("expected error writing to read-only filesystem") + } +} + +func TestGetJSONTypeCoverage(t *testing.T) { + cases := map[string]string{ + "": "empty", + "[1,2]": "array", + `"hi"`: "string", + "null": "null", + "true": "boolean", + "false": "boolean", + "42": "non-object value", + "{\"a\":1}": "object", + } + for in, want := range cases { + if got := getJSONType([]byte(in)); got != want { + t.Errorf("getJSONType(%q) = %q, want %q", in, got, want) + } + } +} + +func TestReadSettingsFileBranches(t *testing.T) { + fsys := afero.NewMemMapFs() + + // Missing file -> empty settings, no error + s, err := ReadSettingsFile(fsys, "/nope/settings.json") + if err != nil || s == nil { + t.Fatalf("expected empty settings for missing file, got %v err %v", s, err) + } + + // "null" content -> empty settings + _ = afero.WriteFile(fsys, "/a.json", []byte("null"), 0644) + if s, err := ReadSettingsFile(fsys, "/a.json"); err != nil || s == nil { + t.Fatalf("expected empty settings for null content, err %v", err) + } + + // whitespace-only -> empty settings + _ = afero.WriteFile(fsys, "/ws.json", []byte(" \n"), 0644) + if _, err := ReadSettingsFile(fsys, "/ws.json"); err != nil { + t.Fatalf("expected nil err for whitespace content, got %v", err) + } + + // JSON array (not object) -> error + _ = afero.WriteFile(fsys, "/arr.json", []byte("[1,2,3]"), 0644) + if _, err := ReadSettingsFile(fsys, "/arr.json"); err == nil { + t.Error("expected error for JSON array settings") + } + + // malformed JSON -> error + _ = afero.WriteFile(fsys, "/bad.json", []byte("{bad"), 0644) + if _, err := ReadSettingsFile(fsys, "/bad.json"); err == nil { + t.Error("expected error for malformed JSON") + } +} + +func TestWriteSettingsFileRoundTrip(t *testing.T) { + fsys := afero.NewMemMapFs() + path := "/deep/nested/dir/settings.json" + in := &SettingsMap{"version": "1.0", "hooks": map[string]interface{}{}} + if err := WriteSettingsFile(fsys, path, in); err != nil { + t.Fatalf("write failed: %v", err) + } + out, err := ReadSettingsFile(fsys, path) + if err != nil { + t.Fatalf("read failed: %v", err) + } + if (*out)["version"] != "1.0" { + t.Errorf("roundtrip lost version, got %v", (*out)["version"]) + } +} + +func TestMergeHookValuesStringFormatExisting(t *testing.T) { + // Existing hook in legacy string format (non-claudio) must be preserved + // and merged into array form alongside the claudio command. + existing := &SettingsMap{ + "hooks": map[string]interface{}{ + "PreToolUse": "/usr/bin/other-tool", + }, + } + claudioHooks, err := GenerateClaudioHooksForAgent(afero.NewMemMapFs(), "/usr/local/bin/claudio", AgentClaude) + if err != nil { + t.Fatal(err) + } + merged, err := MergeHooksIntoSettings(existing, claudioHooks) + if err != nil { + t.Fatal(err) + } + hooksSection := (*merged)["hooks"].(map[string]interface{}) + arr, ok := hooksSection["PreToolUse"].([]interface{}) + if !ok { + t.Fatalf("expected PreToolUse merged into array, got %T", hooksSection["PreToolUse"]) + } + foundOther, foundClaudio := false, false + for _, e := range arr { + cfg := e.(map[string]interface{}) + for _, h := range cfg["hooks"].([]interface{}) { + switch h.(map[string]interface{})["command"] { + case "/usr/bin/other-tool": + foundOther = true + case "/usr/local/bin/claudio": + foundClaudio = true + } + } + } + if !foundOther || !foundClaudio { + t.Errorf("merge lost a command: other=%v claudio=%v", foundOther, foundClaudio) + } +} + +func TestIsClaudioHookFormats(t *testing.T) { + if !IsClaudioHook("/usr/local/bin/claudio") { + t.Error("expected string claudio command recognized") + } + if !IsClaudioHook(`"/usr/local/bin/claudio.exe"`) { + t.Error("expected quoted windows claudio recognized") + } + if IsClaudioHook("/usr/bin/other") { + t.Error("non-claudio command must not be recognized") + } + arr := []interface{}{ + map[string]interface{}{ + "hooks": []interface{}{ + map[string]interface{}{"command": "/opt/claudio"}, + }, + }, + } + if !IsClaudioHook(arr) { + t.Error("expected array-format claudio recognized") + } +} + +func TestMergeHooksMarshalErrorPropagates(t *testing.T) { + // A channel value cannot be JSON-marshaled, forcing deepCopySettings to error. + bad := &SettingsMap{"x": make(chan int)} + claudioHooks, _ := GenerateClaudioHooksForAgent(afero.NewMemMapFs(), "/usr/local/bin/claudio", AgentClaude) + if _, err := MergeHooksIntoSettings(bad, claudioHooks); err == nil { + t.Error("expected error when existing settings cannot be deep-copied") + } +} + +func TestMergeHookValuesUnknownExistingFormat(t *testing.T) { + // Existing PreToolUse hook stored as a number (neither string nor array) + // exercises the fallback branch in mergeHookValues. + existing := &SettingsMap{ + "hooks": map[string]interface{}{ + "PreToolUse": float64(42), + }, + } + claudioHooks, _ := GenerateClaudioHooksForAgent(afero.NewMemMapFs(), "/usr/local/bin/claudio", AgentClaude) + merged, err := MergeHooksIntoSettings(existing, claudioHooks) + if err != nil { + t.Fatal(err) + } + hooksSection := (*merged)["hooks"].(map[string]interface{}) + if _, ok := hooksSection["PreToolUse"].([]interface{}); !ok { + t.Errorf("expected PreToolUse coerced to array, got %T", hooksSection["PreToolUse"]) + } +} + +func TestFindBestPathReturnsExistingFile(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + + // Codex project scope: ./.codex/hooks.json + if err := afero.NewOsFs().MkdirAll(".codex", 0755); err != nil { + t.Fatal(err) + } + if err := afero.WriteFile(afero.NewOsFs(), ".codex/hooks.json", []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + got, err := FindBestCodexPath("project") + if err != nil { + t.Fatal(err) + } + if got != filepath.Join(".codex", "hooks.json") { + t.Errorf("expected existing codex file, got %q", got) + } + + // Claude project scope: ./.claude/settings.json + if err := afero.NewOsFs().MkdirAll(".claude", 0755); err != nil { + t.Fatal(err) + } + if err := afero.WriteFile(afero.NewOsFs(), ".claude/settings.json", []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + cgot, err := FindBestSettingsPath("project") + if err != nil { + t.Fatal(err) + } + if cgot != filepath.Join(".claude", "settings.json") { + t.Errorf("expected existing claude file, got %q", cgot) + } +} From ba1d96beedbc198abb66e94594eb20733ca26fdc Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Wed, 27 May 2026 15:32:44 -0600 Subject: [PATCH 12/16] test: use os.Chdir for go1.23 compat and generic test fixture paths --- internal/install/coverage_test.go | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/internal/install/coverage_test.go b/internal/install/coverage_test.go index 0fca115..752d3df 100644 --- a/internal/install/coverage_test.go +++ b/internal/install/coverage_test.go @@ -1,6 +1,7 @@ package install import ( + "os" "path/filepath" "runtime" "testing" @@ -10,8 +11,8 @@ import ( func TestNormalizeMSYSPathCoverage(t *testing.T) { cases := map[string]string{ - "/c/Users/Q": `C:\Users\Q`, - "/d/work": `D:\work`, + "/c/Users/testuser": `C:\Users\testuser`, + "/d/work": `D:\work`, "already/plain": "already/plain", "": "", "/notdrive": "/notdrive", @@ -29,15 +30,15 @@ func TestGetHomeDirectoryWindowsBranches(t *testing.T) { } // MSYS-style HOME when USERPROFILE absent t.Setenv("USERPROFILE", "") - t.Setenv("HOME", "/c/Users/Q") - if got := getHomeDirectory(); got != `C:\Users\Q` { + t.Setenv("HOME", "/c/Users/testuser") + if got := getHomeDirectory(); got != `C:\Users\testuser` { t.Errorf("MSYS HOME normalization: got %q", got) } // HOMEDRIVE + HOMEPATH fallback t.Setenv("HOME", "") t.Setenv("HOMEDRIVE", "D:") - t.Setenv("HOMEPATH", `\Users\Q`) - if got := getHomeDirectory(); got != `D:\Users\Q` { + t.Setenv("HOMEPATH", `\Users\testuser`) + if got := getHomeDirectory(); got != `D:\Users\testuser` { t.Errorf("HOMEDRIVE+HOMEPATH: got %q", got) } // Nothing set -> empty @@ -213,7 +214,14 @@ func TestMergeHookValuesUnknownExistingFormat(t *testing.T) { func TestFindBestPathReturnsExistingFile(t *testing.T) { dir := t.TempDir() - t.Chdir(dir) + orig, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chdir(orig) }() // Codex project scope: ./.codex/hooks.json if err := afero.NewOsFs().MkdirAll(".codex", 0755); err != nil { From ecfb69e4a0ac23c29e882336de06973e276ad2bd Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Wed, 27 May 2026 15:39:28 -0600 Subject: [PATCH 13/16] test: make platform fixtures cross-platform; add unix home branch --- internal/install/coverage_test.go | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/internal/install/coverage_test.go b/internal/install/coverage_test.go index 752d3df..017784a 100644 --- a/internal/install/coverage_test.go +++ b/internal/install/coverage_test.go @@ -10,12 +10,14 @@ import ( ) func TestNormalizeMSYSPathCoverage(t *testing.T) { + // normalizeMSYSPath uses filepath.FromSlash, which differs by OS, so build + // expectations portably rather than hardcoding backslashes. cases := map[string]string{ - "/c/Users/testuser": `C:\Users\testuser`, - "/d/work": `D:\work`, - "already/plain": "already/plain", - "": "", - "/notdrive": "/notdrive", + "/c/Users/testuser": "C:" + filepath.FromSlash("/Users/testuser"), + "/d/work": "D:" + filepath.FromSlash("/work"), + "already/plain": "already/plain", + "": "", + "/notdrive": "/notdrive", } for in, want := range cases { if got := normalizeMSYSPath(in); got != want { @@ -49,6 +51,20 @@ func TestGetHomeDirectoryWindowsBranches(t *testing.T) { } } +func TestGetHomeDirectoryUnixBranches(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix-only home resolution branch") + } + t.Setenv("HOME", "/home/test") + if got := getHomeDirectory(); got != "/home/test" { + t.Errorf("expected /home/test, got %q", got) + } + t.Setenv("HOME", "") + if got := getHomeDirectory(); got != "" { + t.Errorf("expected empty home when HOME unset, got %q", got) + } +} + func TestWriteSettingsFileReadOnlyFails(t *testing.T) { ro := afero.NewReadOnlyFs(afero.NewMemMapFs()) if err := WriteSettingsFile(ro, "/x/settings.json", &SettingsMap{"a": "b"}); err == nil { From e5c74f90924dd5169bd51af9bf5003b9ae8a9b00 Mon Sep 17 00:00:00 2001 From: blindndangerous <20344049+blindndangerous@users.noreply.github.com> Date: Wed, 27 May 2026 15:46:39 -0600 Subject: [PATCH 14/16] ci: enforce 87% coverage floor on hooks and install packages --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d649af6..e5cbedc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,3 +58,35 @@ jobs: env: CGO_ENABLED: "0" run: go build ./... + + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.23" + + # These packages have no cgo dependency, so no ALSA headers are needed. + - name: Enforce coverage floor + run: | + THRESHOLD=87.0 + status=0 + for pkg in ./internal/hooks ./internal/install; do + line=$(go test "$pkg" -count=1 -cover) + pct=$(echo "$line" | grep -oE 'coverage: [0-9.]+%' | grep -oE '[0-9.]+') + if [ -z "$pct" ]; then + echo "FAIL: could not parse coverage for $pkg" + echo "$line" + status=1 + continue + fi + if awk -v p="$pct" -v t="$THRESHOLD" 'BEGIN { exit !(p+0 >= t+0) }'; then + echo "OK: $pkg coverage ${pct}% (floor ${THRESHOLD}%)" + else + echo "FAIL: $pkg coverage ${pct}% below floor ${THRESHOLD}%" + status=1 + fi + done + exit $status From 62bdb5c6056e942d9ab36d1f2828338c8fddc9c8 Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 28 May 2026 11:02:23 -0600 Subject: [PATCH 15/16] fix: honor CODEX_HOME for codex hooks --- internal/install/codex_settings.go | 5 +++++ internal/install/codex_settings_test.go | 20 ++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/internal/install/codex_settings.go b/internal/install/codex_settings.go index e8dc483..176093a 100644 --- a/internal/install/codex_settings.go +++ b/internal/install/codex_settings.go @@ -25,6 +25,11 @@ func FindCodexHooksPaths(scope string) ([]string, error) { func findCodexUserScopePaths() []string { var paths []string + codexHome := os.Getenv("CODEX_HOME") + if codexHome != "" { + paths = append(paths, filepath.Join(codexHome, "hooks.json")) + } + homeDir := getHomeDirectory() if homeDir != "" { paths = append(paths, filepath.Join(homeDir, ".codex", "hooks.json")) diff --git a/internal/install/codex_settings_test.go b/internal/install/codex_settings_test.go index 4c5c28d..1c95a4b 100644 --- a/internal/install/codex_settings_test.go +++ b/internal/install/codex_settings_test.go @@ -7,6 +7,8 @@ import ( ) func TestFindCodexHooksPathsUserScope(t *testing.T) { + t.Setenv("CODEX_HOME", "") + paths, err := FindCodexHooksPaths("user") if err != nil { t.Fatalf("unexpected error: %v", err) @@ -22,6 +24,24 @@ func TestFindCodexHooksPathsUserScope(t *testing.T) { } } +func TestFindCodexHooksPathsUserScopeHonorsCODEXHOME(t *testing.T) { + codexHome := t.TempDir() + t.Setenv("CODEX_HOME", codexHome) + + paths, err := FindCodexHooksPaths("user") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(paths) == 0 { + t.Fatal("expected at least one user-scope path") + } + + want := filepath.Join(codexHome, "hooks.json") + if paths[0] != want { + t.Fatalf("first path = %q, want CODEX_HOME path %q", paths[0], want) + } +} + func TestFindCodexHooksPathsProjectScope(t *testing.T) { paths, err := FindCodexHooksPaths("project") if err != nil { From 0bb3707dce17a13e23b6c6f25b06ef20ffcedf8c Mon Sep 17 00:00:00 2001 From: Christopher Toth Date: Thu, 28 May 2026 11:21:12 -0600 Subject: [PATCH 16/16] ci: avoid duplicate PR runs --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e5cbedc..2dda5bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: push: + branches: [master] pull_request: jobs: