diff --git a/docs/content/commands.md b/docs/content/commands.md index 033eb6f..68949b1 100644 --- a/docs/content/commands.md +++ b/docs/content/commands.md @@ -464,7 +464,8 @@ lib = true # copied only, never registered (supp ``` - **Runtimes:** `node`, `python3`, or empty (run the file directly via its shebang). -- **Events:** the standard Claude events (`SessionStart`, `Stop`, `Notification`, `PreToolUse`, `PostToolUse`, `SubagentStart`, `SubagentStop`, `UserPromptSubmit`, `PreCompact`, …) plus the `statusLine` pseudo-event. Unknown events **and** unknown TOML fields are rejected. +- **Events:** the standard Claude events (`SessionStart`, `Stop`, `Notification`, `PreToolUse`, `PostToolUse`, `SubagentStart`, `SubagentStop`, `UserPromptSubmit`, `PreCompact`, …) plus two pseudo-events: `statusLine` (Claude status line) and **`codex.notify`** (wires the hook into Codex's `notify` program). Unknown events **and** unknown TOML fields are rejected. +- **Codex (`codex.notify`):** an entry with `event = "codex.notify"` installs the file like any other hook (into `~/.claude/hooks/`) but registers it in `~/.codex/config.toml` as the `notify` program (absolute path — Codex execs directly, no `$HOME` expansion). One file serves both agents. Any prior `notify` is replaced and reported, so you can chain it via your notifier's env (e.g. `CODEX_NOTIFY_FORWARD`). Example: `file = "agent-notify.py"`, `runtime = "python3"`, `event = "codex.notify"`, `args = ["codex"]`. - **Registered command:** ` "$HOME/.claude/hooks/" ` — `$HOME` stays literal; args with shell metacharacters are quoted. - The manifest is the only hook source: `[hooks].source` in `skills.toml` accepts only `local`. diff --git a/internal/claudeconfig/codex.go b/internal/claudeconfig/codex.go new file mode 100644 index 0000000..38922ac --- /dev/null +++ b/internal/claudeconfig/codex.go @@ -0,0 +1,186 @@ +package claudeconfig + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" +) + +const codexConfigFile = "config.toml" + +// notifyLineRe matches the top-level `notify = ...` assignment, including a +// multi-line array value (`notify = [\n "a",\n "b"\n]`). We replace the whole +// match surgically and leave every other byte untouched. +var notifyLineRe = regexp.MustCompile(`(?m)^[ \t]*notify[ \t]*=[ \t]*(?:\[[^\]]*\]|[^\n]*)`) + +// CodexConfigPath returns the absolute path to ~/.codex/config.toml. +func CodexConfigPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".codex", codexConfigFile), nil +} + +// codexBackupName builds a one-shot backup path with a UTC timestamp, matching +// the hooks installer's backupName style. +func codexBackupName(path string) string { + ext := filepath.Ext(path) + base := strings.TrimSuffix(path, ext) + return base + ".bak." + time.Now().UTC().Format("20060102T150405") + ext +} + +// tomlQuote returns a TOML basic string for s: double-quoted, with backslash, +// double-quote, and control characters (U+0000–U+001F) escaped per the spec. +func tomlQuote(s string) string { + var b strings.Builder + b.WriteByte('"') + for _, r := range s { + switch r { + case '\\': + b.WriteString(`\\`) + case '"': + b.WriteString(`\"`) + case '\b': + b.WriteString(`\b`) + case '\t': + b.WriteString(`\t`) + case '\n': + b.WriteString(`\n`) + case '\f': + b.WriteString(`\f`) + case '\r': + b.WriteString(`\r`) + default: + if r < 0x20 { + fmt.Fprintf(&b, `\u%04X`, r) + } else { + b.WriteRune(r) + } + } + } + b.WriteByte('"') + return b.String() +} + +// notifyArray renders command as a TOML array literal: ["a", "b", ...]. +func notifyArray(command []string) string { + parts := make([]string, len(command)) + for i, c := range command { + parts[i] = tomlQuote(c) + } + return "notify = [" + strings.Join(parts, ", ") + "]" +} + +// WireCodexNotify surgically sets the top-level notify key in the Codex config +// at path to command. A missing file is created with just the notify line. +// Every other line/comment is preserved byte-for-byte (no go-toml round-trip). +// +// If an existing notify line was present and does not already reference our +// command's program path, its full text is returned as replacedPrev so the +// caller can warn the user (they can chain it via CODEX_NOTIFY_FORWARD). If the +// existing line already targets our program, replacedPrev is "". +// +// The file is backed up once (path.bak.) before the atomic write. +func WireCodexNotify(path string, command []string) (replacedPrev string, err error) { + if len(command) == 0 { + return "", fmt.Errorf("codex notify command is empty") + } + programPath := codexProgramPath(command) + newLine := notifyArray(command) + + existing, readErr := os.ReadFile(path) + if readErr != nil { + if !errors.Is(readErr, os.ErrNotExist) { + return "", fmt.Errorf("read %s: %w", path, readErr) + } + // Missing file: create with just the notify line. + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", fmt.Errorf("create codex config dir: %w", err) + } + return "", atomicWrite(path, []byte(newLine+"\n")) + } + + var out []byte + if loc := notifyLineRe.FindIndex(existing); loc != nil { + prev := strings.TrimRight(string(existing[loc[0]:loc[1]]), "\r") + if !strings.Contains(prev, programPath) { + replacedPrev = prev + } + out = append(out, existing[:loc[0]]...) + out = append(out, []byte(newLine)...) + out = append(out, existing[loc[1]:]...) + } else { + // No notify line — append one (keep a trailing newline tidy). + out = append(out, existing...) + if len(out) > 0 && out[len(out)-1] != '\n' { + out = append(out, '\n') + } + out = append(out, []byte(newLine+"\n")...) + } + + backupPath := codexBackupName(path) + if err := os.WriteFile(backupPath, existing, 0o644); err != nil { + return "", fmt.Errorf("backup %s: %w", path, err) + } + if err := atomicWrite(path, out); err != nil { + return "", err + } + return replacedPrev, nil +} + +// UnwireCodexNotify removes the managed notify line from path if it references +// programPath. No-op when the file is absent, has no notify line, or the line is +// not ours. The file is backed up once before any change. +func UnwireCodexNotify(path, programPath string) error { + existing, readErr := os.ReadFile(path) + if readErr != nil { + if os.IsNotExist(readErr) { + return nil + } + return fmt.Errorf("read %s: %w", path, readErr) + } + + loc := notifyLineRe.FindIndex(existing) + if loc == nil { + return nil + } + line := string(existing[loc[0]:loc[1]]) + if !strings.Contains(line, programPath) { + return nil // not ours — leave it + } + + // Drop the whole line including its trailing newline (if any). + end := loc[1] + if end < len(existing) && existing[end] == '\n' { + end++ + } + out := append([]byte{}, existing[:loc[0]]...) + out = append(out, existing[end:]...) + + backupPath := codexBackupName(path) + if err := os.WriteFile(backupPath, existing, 0o644); err != nil { + return fmt.Errorf("backup %s: %w", path, err) + } + return atomicWrite(path, out) +} + +// codexProgramPath returns the program element of a notify command — the runtime +// when one is present (argv[0] is python3/node, argv[1] is the script), else the +// first element. Used to detect whether an existing notify line is ours. +func codexProgramPath(command []string) string { + if len(command) == 0 { + return "" + } + switch command[0] { + case "python3", "node": + if len(command) > 1 { + return command[1] + } + } + return command[0] +} diff --git a/internal/claudeconfig/codex_test.go b/internal/claudeconfig/codex_test.go new file mode 100644 index 0000000..39d76d2 --- /dev/null +++ b/internal/claudeconfig/codex_test.go @@ -0,0 +1,272 @@ +package claudeconfig + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWireCodexNotifyMultiLineArray(t *testing.T) { + p := filepath.Join(t.TempDir(), "config.toml") + if err := os.WriteFile(p, []byte("model = \"x\"\nnotify = [\n \"/old\",\n \"turn-ended\"\n]\nfoo = 1\n"), 0o644); err != nil { + t.Fatal(err) + } + prev, err := WireCodexNotify(p, []string{"python3", "/abs/n.py", "codex"}) + if err != nil { + t.Fatal(err) + } + s := readFile(t, p) + if !strings.Contains(s, `notify = ["python3", "/abs/n.py", "codex"]`) { + t.Errorf("notify not replaced:\n%s", s) + } + if strings.Contains(s, "/old") || strings.Contains(s, "turn-ended") { + t.Errorf("old multi-line array not fully replaced:\n%s", s) + } + if !strings.Contains(s, `model = "x"`) || !strings.Contains(s, "foo = 1") { + t.Errorf("surrounding keys not preserved:\n%s", s) + } + if !strings.Contains(prev, "/old") { + t.Errorf("replacedPrev = %q, want the old multi-line notify", prev) + } +} + +func TestTomlQuoteControlChars(t *testing.T) { + if got := tomlQuote("a\tb\nc"); got != `"a\tb\nc"` { + t.Errorf("tab/newline = %q", got) + } + if got := tomlQuote("x\x00y"); got != `"x\u0000y"` { + t.Errorf("NUL = %q", got) + } +} + +func readFile(t *testing.T, p string) string { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func codexBackupCount(t *testing.T, path string) int { + t.Helper() + dir := filepath.Dir(path) + base := filepath.Base(path) + ext := filepath.Ext(base) + prefix := strings.TrimSuffix(base, ext) + ".bak." + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("read dir: %v", err) + } + n := 0 + for _, e := range entries { + if strings.HasPrefix(e.Name(), prefix) { + n++ + } + } + return n +} + +func TestWireCodexNotifyPreservesOtherLines(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + orig := `# Codex config +model = "x" +notify = ["old-notify"] +approval_policy = "on-request" +` + if err := os.WriteFile(path, []byte(orig), 0o644); err != nil { + t.Fatalf("seed config: %v", err) + } + + cmd := []string{"python3", "/abs/.claude/hooks/agent-notify.py", "codex"} + prev, err := WireCodexNotify(path, cmd) + if err != nil { + t.Fatalf("WireCodexNotify: %v", err) + } + if prev != `notify = ["old-notify"]` { + t.Errorf("replacedPrev = %q, want the old notify line", prev) + } + + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read result: %v", err) + } + want := `# Codex config +model = "x" +notify = ["python3", "/abs/.claude/hooks/agent-notify.py", "codex"] +approval_policy = "on-request" +` + if string(got) != want { + t.Errorf("result mismatch:\n--- got\n%s\n--- want\n%s", got, want) + } + if codexBackupCount(t, path) != 1 { + t.Errorf("expected exactly one backup file, got %d", codexBackupCount(t, path)) + } +} + +func TestWireCodexNotifyAlreadyOurs(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + orig := `model = "x" +notify = ["python3", "/abs/.claude/hooks/agent-notify.py", "codex"] +` + if err := os.WriteFile(path, []byte(orig), 0o644); err != nil { + t.Fatalf("seed config: %v", err) + } + cmd := []string{"python3", "/abs/.claude/hooks/agent-notify.py", "codex"} + prev, err := WireCodexNotify(path, cmd) + if err != nil { + t.Fatalf("WireCodexNotify: %v", err) + } + if prev != "" { + t.Errorf("replacedPrev = %q, want empty (line already ours)", prev) + } + got, _ := os.ReadFile(path) + if string(got) != orig { + t.Errorf("line should be unchanged:\n%s", got) + } +} + +func TestWireCodexNotifyMissingFileCreates(t *testing.T) { + path := filepath.Join(t.TempDir(), "nested", "config.toml") + cmd := []string{"/abs/.claude/hooks/notify.sh"} + prev, err := WireCodexNotify(path, cmd) + if err != nil { + t.Fatalf("WireCodexNotify: %v", err) + } + if prev != "" { + t.Errorf("replacedPrev = %q, want empty for new file", prev) + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read result: %v", err) + } + if string(got) != `notify = ["/abs/.claude/hooks/notify.sh"]`+"\n" { + t.Errorf("new file content = %q", got) + } +} + +func TestWireCodexNotifyAppendsWhenNoNotifyLine(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + orig := `model = "x"` // no trailing newline, no notify + if err := os.WriteFile(path, []byte(orig), 0o644); err != nil { + t.Fatalf("seed config: %v", err) + } + cmd := []string{"node", "/abs/hooks/n.cjs"} + prev, err := WireCodexNotify(path, cmd) + if err != nil { + t.Fatalf("WireCodexNotify: %v", err) + } + if prev != "" { + t.Errorf("replacedPrev = %q, want empty (appended)", prev) + } + got, _ := os.ReadFile(path) + want := "model = \"x\"\nnotify = [\"node\", \"/abs/hooks/n.cjs\"]\n" + if string(got) != want { + t.Errorf("appended content mismatch:\n--- got\n%q\n--- want\n%q", got, want) + } +} + +func TestWireCodexNotifyQuotesSpecialChars(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + cmd := []string{`/path with "quote"\and-backslash`} + if _, err := WireCodexNotify(path, cmd); err != nil { + t.Fatalf("WireCodexNotify: %v", err) + } + got, _ := os.ReadFile(path) + want := `notify = ["/path with \"quote\"\\and-backslash"]` + "\n" + if string(got) != want { + t.Errorf("escaping mismatch:\n got: %q\nwant: %q", got, want) + } +} + +func TestUnwireCodexNotifyRemovesOnlyOurs(t *testing.T) { + progPath := "/abs/.claude/hooks/agent-notify.py" + path := filepath.Join(t.TempDir(), "config.toml") + orig := `model = "x" +notify = ["python3", "` + progPath + `", "codex"] +approval_policy = "on-request" +` + if err := os.WriteFile(path, []byte(orig), 0o644); err != nil { + t.Fatalf("seed config: %v", err) + } + if err := UnwireCodexNotify(path, progPath); err != nil { + t.Fatalf("UnwireCodexNotify: %v", err) + } + got, _ := os.ReadFile(path) + want := `model = "x" +approval_policy = "on-request" +` + if string(got) != want { + t.Errorf("unwire mismatch:\n--- got\n%s\n--- want\n%s", got, want) + } + if codexBackupCount(t, path) != 1 { + t.Errorf("expected one backup, got %d", codexBackupCount(t, path)) + } +} + +func TestUnwireCodexNotifyLeavesForeignLine(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.toml") + orig := `notify = ["/some/other/notifier"]` + "\n" + if err := os.WriteFile(path, []byte(orig), 0o644); err != nil { + t.Fatalf("seed config: %v", err) + } + if err := UnwireCodexNotify(path, "/abs/.claude/hooks/agent-notify.py"); err != nil { + t.Fatalf("UnwireCodexNotify: %v", err) + } + got, _ := os.ReadFile(path) + if string(got) != orig { + t.Errorf("foreign notify line should be untouched, got:\n%s", got) + } + if codexBackupCount(t, path) != 0 { + t.Errorf("expected no backup for no-op, got %d", codexBackupCount(t, path)) + } +} + +func TestUnwireCodexNotifyMissingFileNoOp(t *testing.T) { + path := filepath.Join(t.TempDir(), "absent.toml") + if err := UnwireCodexNotify(path, "/abs/x"); err != nil { + t.Fatalf("UnwireCodexNotify on missing file should be no-op, got: %v", err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("missing file should not be created") + } +} + +func TestCodexNotifyCommand(t *testing.T) { + hooksDir := "/home/u/.claude/hooks" + cases := []struct { + name string + hook Hook + want []string + }{ + { + name: "python runtime with args yields absolute path", + hook: Hook{File: "agent-notify.py", Runtime: "python3", Event: "codex.notify", Args: []string{"codex"}}, + want: []string{"python3", "/home/u/.claude/hooks/agent-notify.py", "codex"}, + }, + { + name: "empty runtime puts abs path first", + hook: Hook{File: "notify.sh", Event: "codex.notify"}, + want: []string{"/home/u/.claude/hooks/notify.sh"}, + }, + { + name: "nested lib path joined", + hook: Hook{File: "lib/notify.cjs", Runtime: "node", Event: "codex.notify", Args: []string{"a", "b"}}, + want: []string{"node", "/home/u/.claude/hooks/lib/notify.cjs", "a", "b"}, + }, + } + for _, c := range cases { + got := CodexNotifyCommand(c.hook, hooksDir) + if len(got) != len(c.want) { + t.Errorf("%s: got %v, want %v", c.name, got, c.want) + continue + } + for i := range got { + if got[i] != c.want[i] { + t.Errorf("%s: got %v, want %v", c.name, got, c.want) + break + } + } + } +} diff --git a/internal/claudeconfig/hook.go b/internal/claudeconfig/hook.go index b2734ea..928ecfb 100644 --- a/internal/claudeconfig/hook.go +++ b/internal/claudeconfig/hook.go @@ -1,13 +1,16 @@ package claudeconfig -import "strings" +import ( + "path/filepath" + "strings" +) // Hook describes one entry from the hooks manifest. A non-lib hook is registered // in settings.json under its Event; a lib hook is copied only (support file). type Hook struct { File string // path relative to the hooks dir, e.g. "session-init.cjs" or "lib/config.cjs" Runtime string // "node" | "python3" | "" (direct exec via shebang) - Event string // Claude event (SessionStart, Stop, ...) or "statusLine" + Event string // Claude event (SessionStart, Stop, ...), "statusLine", or "codex.notify" Matcher string // optional matcher Args []string // extra argv appended after the file path Lib bool // true => support file only (copied, never registered) @@ -35,6 +38,23 @@ func HookCommand(h Hook) string { return cmd } +// CodexNotifyCommand builds the exec array Codex runs for a codex.notify hook. +// Codex execs the program DIRECTLY (no shell), so the hook path must be absolute +// (joined under hooksDir, the real install dest — not the $HOME-literal form). +// The runtime is prepended as argv[0] when set; otherwise the absolute path is +// argv[0]. Args are appended. +func CodexNotifyCommand(h Hook, hooksDir string) []string { + abs := filepath.Join(hooksDir, filepath.FromSlash(h.File)) + cmd := make([]string, 0, 2+len(h.Args)) + if h.Runtime != "" { + cmd = append(cmd, h.Runtime, abs) + } else { + cmd = append(cmd, abs) + } + cmd = append(cmd, h.Args...) + return cmd +} + // shellMeta are characters that, unquoted, the shell would interpret. const shellMeta = " \t\n\"'`$\\&|;<>()*?[]{}~#!=" diff --git a/internal/claudeconfig/settings.go b/internal/claudeconfig/settings.go index c23db99..a9f8f76 100644 --- a/internal/claudeconfig/settings.go +++ b/internal/claudeconfig/settings.go @@ -18,6 +18,16 @@ const settingsFile = "settings.json" // key instead of the hooks{} map. const statusLineEvent = "statusLine" +// codexNotifyEvent is the sentinel Event value for a Hook that targets Codex's +// ~/.codex/config.toml notify key — it never goes into settings.json. +const codexNotifyEvent = "codex.notify" + +// skipsSettingsJSON reports whether a hook event is registered somewhere other +// than settings.json's hooks{} map (statusLine key, or Codex config). +func skipsSettingsJSON(event string) bool { + return event == statusLineEvent || event == codexNotifyEvent +} + // HookEntry is one entry in a hooks event array in settings.json. type HookEntry struct { Matcher string `json:"matcher,omitempty"` @@ -108,6 +118,9 @@ func RegisterHooks(s *Settings, hooks []Hook) { if h.Lib || h.Event == "" { continue // lib files are copied only; empty event has nothing to register } + if h.Event == codexNotifyEvent { + continue // wired into ~/.codex/config.toml, not settings.json + } if h.Event == statusLineEvent { SetStatusLine(s, HookCommand(h)) continue @@ -385,6 +398,9 @@ func UnregisterHooks(s *Settings, hooks []Hook) { if h.Lib { continue } + if h.Event == codexNotifyEvent { + continue // unwired from ~/.codex/config.toml, not settings.json + } if h.Event == statusLineEvent { UnsetStatusLine(s) continue @@ -412,7 +428,7 @@ func IsManagedCommand(cmd string) bool { // IsRegistered reports whether all non-lib, non-statusLine hooks are present in s. func IsRegistered(s *Settings, hooks []Hook) bool { for _, h := range hooks { - if h.Lib || h.Event == statusLineEvent { + if h.Lib || skipsSettingsJSON(h.Event) { continue } cmd := HookCommand(h) diff --git a/internal/cli/hooks.go b/internal/cli/hooks.go index 3842745..683baec 100644 --- a/internal/cli/hooks.go +++ b/internal/cli/hooks.go @@ -2,6 +2,7 @@ package cli import ( "fmt" + "io" "os" "path/filepath" "sort" @@ -103,6 +104,12 @@ func runHooksUninstall(cmd *cobra.Command, hooksDir string, manifestHooks []clau _, _ = fmt.Fprintf(out, "dry-run: would remove %s\n", f.full) } _, _ = fmt.Fprintln(out, "dry-run: would unregister managed hooks from settings.json") + for _, h := range manifestHooks { + if h.Event == "codex.notify" { + _, _ = fmt.Fprintln(out, "dry-run: would unwire managed Codex notify from ~/.codex/config.toml") + break + } + } return nil } @@ -114,6 +121,10 @@ func runHooksUninstall(cmd *cobra.Command, hooksDir string, manifestHooks []clau _, _ = fmt.Fprintln(out, "unregistered managed hooks from settings.json") } + if err := unwireCodexNotify(out, hooksDir, manifestHooks); err != nil { + return fmt.Errorf("unwire Codex notify: %w", err) + } + // Delete managed hook files. for _, f := range toDelete { if err := os.Remove(f.full); err != nil && !os.IsNotExist(err) { @@ -138,6 +149,33 @@ func runHooksUninstall(cmd *cobra.Command, hooksDir string, manifestHooks []clau return nil } +// unwireCodexNotify removes managed codex.notify lines from ~/.codex/config.toml. +// The hook's absolute script path identifies our line; foreign notify lines are +// left untouched by UnwireCodexNotify. +func unwireCodexNotify(out io.Writer, hooksDir string, manifestHooks []claudeconfig.Hook) error { + var codexPath string + for _, h := range manifestHooks { + if h.Event != "codex.notify" { + continue + } + if codexPath == "" { + var err error + codexPath, err = claudeconfig.CodexConfigPath() + if err != nil { + return err + } + } + abs := filepath.Join(hooksDir, filepath.FromSlash(h.File)) + if err := claudeconfig.UnwireCodexNotify(codexPath, abs); err != nil { + return err + } + if !flagQuiet { + _, _ = fmt.Fprintf(out, "unwired managed Codex notify from %s\n", codexPath) + } + } + return nil +} + // unregisterHooksFromSettings removes only our managed commands from settings.json. func unregisterHooksFromSettings(manifestHooks []claudeconfig.Hook) error { s, err := claudeconfig.ReadSettings() diff --git a/internal/cli/install.go b/internal/cli/install.go index 806b571..d45c4d2 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -274,6 +274,12 @@ func runInstallHooks(cmd *cobra.Command, repoRoot string, opts installOptions) e if err := claudeconfig.WriteSettings(s, claudeconfig.WriteOptions{DryRun: true}); err != nil { return err } + for _, h := range manifestHooks { + if h.Event == "codex.notify" { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "would wire Codex notify: %s\n", + strings.Join(claudeconfig.CodexNotifyCommand(h, dest), " ")) + } + } return nil } @@ -301,6 +307,42 @@ func runInstallHooks(cmd *cobra.Command, repoRoot string, opts installOptions) e if !flagQuiet { _, _ = fmt.Fprintln(cmd.OutOrStdout(), "hooks registered in settings.json") } + + return wireCodexNotify(cmd, manifestHooks, dest) +} + +// wireCodexNotify registers each codex.notify hook into ~/.codex/config.toml. +func wireCodexNotify(cmd *cobra.Command, manifestHooks []claudeconfig.Hook, dest string) error { + var codex []claudeconfig.Hook + for _, h := range manifestHooks { + if h.Event == "codex.notify" { + codex = append(codex, h) + } + } + if len(codex) == 0 { + return nil + } + // Codex has a single top-level notify program — more than one would silently + // clobber each other, so refuse rather than pick a winner. + if len(codex) > 1 { + return fmt.Errorf("manifest declares %d codex.notify hooks, but Codex supports only one notify program", len(codex)) + } + + codexPath, err := claudeconfig.CodexConfigPath() + if err != nil { + return fmt.Errorf("resolve codex config path: %w", err) + } + prev, err := claudeconfig.WireCodexNotify(codexPath, claudeconfig.CodexNotifyCommand(codex[0], dest)) + if err != nil { + return fmt.Errorf("wire Codex notify: %w", err) + } + if !flagQuiet { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "wired Codex notify in %s\n", codexPath) + if prev != "" { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), + "note: replaced existing Codex notify (set CODEX_NOTIFY_FORWARD in your env to chain it): %s\n", prev) + } + } return nil } diff --git a/internal/hooks/manifest.go b/internal/hooks/manifest.go index 68ba268..908bea2 100644 --- a/internal/hooks/manifest.go +++ b/internal/hooks/manifest.go @@ -12,14 +12,15 @@ import ( "github.com/vanducng/vd-cli/v2/internal/claudeconfig" ) -// knownEvents are the Claude hook events (plus the statusLine pseudo-event) a -// manifest may target. A typo'd event would otherwise become a dead key in -// settings.json. +// knownEvents are the Claude hook events (plus the statusLine and codex.notify +// pseudo-events) a manifest may target. A typo'd event would otherwise become a +// dead key in settings.json. codex.notify targets ~/.codex/config.toml instead. var knownEvents = map[string]bool{ "SessionStart": true, "SessionEnd": true, "UserPromptSubmit": true, "PreToolUse": true, "PostToolUse": true, "Stop": true, "SubagentStart": true, "SubagentStop": true, "Notification": true, "PreCompact": true, "PermissionRequest": true, "statusLine": true, + "codex.notify": true, } // tomlHook mirrors one [[hook]] table in hooks.toml. diff --git a/internal/hooks/manifest_test.go b/internal/hooks/manifest_test.go index c17014d..c672dd0 100644 --- a/internal/hooks/manifest_test.go +++ b/internal/hooks/manifest_test.go @@ -127,6 +127,23 @@ event = "Stop" } } +func TestLoadManifestAcceptsCodexNotifyEvent(t *testing.T) { + path := writeManifest(t, ` +[[hook]] +file = "agent-notify.py" +runtime = "python3" +event = "codex.notify" +args = ["codex"] +`) + hooks, err := LoadManifest(path) + if err != nil { + t.Fatalf("LoadManifest: %v", err) + } + if len(hooks) != 1 || hooks[0].Event != "codex.notify" { + t.Errorf("codex.notify hook parsed wrong: %+v", hooks) + } +} + func TestLoadManifestRejectsUnknownEvent(t *testing.T) { path := writeManifest(t, ` [[hook]]