Codex Hook Support Implementation Plan
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.goprocessHookInputdoesjson.Unmarshalstraight intohooks.HookEvent, then callsGetContext(). It does not callhooks.Parse(), and it only requireshook_event_nameandsession_id. So the runtime never rejects a nulltranscript_path. The fix toParse()is for consistency and its own tests. hooks.HookEventalready hasToolName,ToolInput,ToolResponse,Prompt,Messageas optional pointers — Codex’s payload unmarshals into it with no struct changes.mcp__<server>__<tool>normalization tomcpalready exists inGetContext(prior commit on this branch). Add a regression test only.- Codex
hooks.jsontop-level shape is{"hooks": {EventName: [ {"matcher": "...", "hooks": [{"type":"command","command":"..."}]} ]}}. This is a JSON object with ahookskey, so the existingReadSettingsFile/MergeHooksIntoSettings/WriteSettingsFileoperate on it unchanged. internal/uninstallis agent-agnostic: it detects Claudio entries by command basename viainstall.IsClaudioHook. Only the config path differs per agent.- The
Agenttype lives in packageinstall. Packagecliimportsinstalland usesinstall.Agent,install.ParseAgent, etc.
File structure
- Modify
internal/hooks/parser.go— relaxtranscript_pathrequirement inParse; addSubagentStartandPostCompactcases inGetContext. - Modify
internal/hooks/parser_test.go— Codex payload tests. - Create
internal/install/agent.go—Agenttype,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— addCodexHooksregistry. - Modify
internal/install/hook_registry_test.go— Codex registry tests. - Modify
internal/install/hooks.go— addGenerateClaudioHooksForAgent; keepGenerateClaudioHooksas a Claude wrapper. - Modify
internal/install/hooks_test.go— agent generation tests. - Modify
internal/cli/install_command.go—--agentflag, agent-aware workflow, Codex trust reminder. - Modify
internal/cli/install_command_test.go(orinstall_flags_test.go) — flag + workflow tests. - Modify
internal/cli/uninstall_command.go—--agentflag, 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:
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):
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
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(insideGetContext, after theSubagentStopcase, beforePreCompact) -
Test:
internal/hooks/parser_test.go - Step 1: Write the failing test
Add to internal/hooks/parser_test.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:
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
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 inGetContext) -
Step 1: Write the tests
Add to internal/hooks/parser_test.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
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:
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:
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:
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:
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 theCodexHooksvar) -
Test:
internal/install/hook_registry_test.go - Step 1: Write the failing test
Add to internal/install/hook_registry_test.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):
// 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
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:
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
GenerateClaudioHooksand 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:
// 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
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:
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
--agentflag
In newInstallCommand() in internal/cli/install_command.go, after the --scope flag registration, add:
// 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:
// 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:
settingsPath, err := install.FindBestSettingsPath(scope.String())
if err != nil {
return fmt.Errorf("failed to find Claude Code settings path: %w", err)
}
with:
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:
if agent == install.AgentCodex {
cmd.Printf("After install, run /hooks in Codex to trust the claudio hook.\n")
}
Update the actual-install call:
err = runInstallWorkflow(scope.String(), settingsPath)
to:
err = runInstallWorkflow(agent, scope.String(), settingsPath)
And in the post-success !quiet block, after the existing success lines, add:
if agent == install.AgentCodex {
cmd.Printf("Run /hooks in Codex to trust the claudio hook.\n")
}
- Step 5: Update
runInstallWorkflowto take the agent
Change the signature and the two registry calls in runInstallWorkflow:
func runInstallWorkflow(agent install.Agent, scope string, settingsPath string) error {
Replace claudioHooks, err := install.GenerateClaudioHooks(prodFS, execPath) with:
claudioHooks, err := install.GenerateClaudioHooksForAgent(prodFS, execPath, agent)
Replace the verification loop’s expectedHooks := install.GetHookNames() with:
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
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:
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
--agentflag
In newUninstallCommand(), after the --scope flag, add:
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:
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:
settingsPath, err := install.FindBestSettingsPath(scope.String())
if err != nil {
return fmt.Errorf("failed to find Claude Code settings path: %w", err)
}
with:
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
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:
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
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:
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):
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:
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:
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:
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
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
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
./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
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,GenerateClaudioHooksForAgentare used consistently across tasks.runInstallWorkflowgains aninstall.Agentfirst parameter in Task 8 and is only called fromrunInstallCommandE. - Known environment risk:
internal/clineeds the cgo linker (ld.exe) fixed earlier this session. Thehooksandinstallpackages have no cgo dependency.