diff --git a/CHANGELOG.md b/CHANGELOG.md index 67424129..dd42a283 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p ## [Unreleased] ### Added +- Codex integration now matches Claude Code: "Needs input" notifications, guaranteed cleanup when a session dies without a clean stop, an "Install Codex Integration…" menu item, and `programa codex install-integration` naming (the old install-hooks name still works). Also fixed duplicate desktop notifications for Codex sessions — suppression of raw terminal notifications now applies to any hook-managed agent, not just Claude Code. - File menu: "Install Claude Code Integration…" opens a terminal running `programa claude install-integration`, which shows the exact diff it wants to make to your Claude settings and asks before writing. It durably registers Programa's lifecycle hooks so the integration works from any terminal, not just inside Programa; hooks silently no-op elsewhere. Fully reversible with `programa claude uninstall-integration`; your own hooks are never touched. - Closing a terminal is now undoable for 5 seconds: Cmd+Shift+T brings it back with its process still running — whether you closed it or an agent did. The confirmation dialog still appears when a command is actively running. diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index ca932df5..b6bf0899 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -1259,6 +1259,20 @@ extension ProgramaCLI { "command": codexHookCommand("stop"), "timeout": 10 ] as [String: Any]] + ] as [String: Any]], + "Notification": [[ + "hooks": [[ + "type": "command", + "command": codexHookCommand("notification"), + "timeout": 10 + ] as [String: Any]] + ] as [String: Any]], + "SessionEnd": [[ + "hooks": [[ + "type": "command", + "command": codexHookCommand("session-end"), + "timeout": 1 + ] as [String: Any]] ] as [String: Any]] ] as [String: Any] ] @@ -2000,14 +2014,171 @@ extension ProgramaCLI { throw error } + case "notification", "notify": + var summary = summarizeCodexHookNotification(parsedInput: parsedInput) + + let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } + let workspaceId = try resolvePreferredWorkspaceIdForClaudeHook( + preferred: mappedSession?.workspaceId, + fallback: workspaceArg, + client: client + ) + if let mappedSession, + let savedBody = mappedSession.lastBody, !savedBody.isEmpty, + summary.body.contains("needs your attention") || summary.body.contains("needs your input") { + summary = (subtitle: mappedSession.lastSubtitle ?? summary.subtitle, body: savedBody) + } + + let surfaceId = try resolvePreferredSurfaceIdForClaudeHook( + preferred: mappedSession?.surfaceId, + fallback: surfaceArg, + workspaceId: workspaceId, + client: client + ) + let agentPIDKey = codexAgentPIDKey(sessionId: parsedInput.sessionId ?? mappedSession?.sessionId) + let codexPid = mappedSession?.pid ?? inferredCodexAgentPID() + + let title = "Codex" + let subtitle = sanitizeNotificationField(summary.subtitle) + let body = sanitizeNotificationField(summary.body) + + if let sessionId = parsedInput.sessionId { + try? sessionStore.upsert( + sessionId: sessionId, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: parsedInput.cwd, + pid: codexPid, + lastSubtitle: summary.subtitle, + lastBody: summary.body + ) + } + if let codexPid { + _ = try? client.sendV2(method: "workspace.set_agent_pid", params: [ + "workspace_id": workspaceId, + "key": agentPIDKey, + "pid": codexPid, + ]) + } + + _ = try? client.sendV2(method: "notification.create_for_target", params: [ + "workspace_id": workspaceId, + "surface_id": surfaceId, + "title": title, + "subtitle": subtitle, + "body": body, + ]) + _ = try? setCodexStatus( + client: client, + workspaceId: workspaceId, + value: "Needs input", + icon: "bell.fill", + color: "#4C8DFF" + ) + print("{}") + + case "session-end": + do { + // Final cleanup when Codex process exits (e.g. Ctrl+C or kill), covering + // the case where Stop never fires. If Stop already consumed the session, + // consumedSession is nil here and we skip to avoid wiping the completion + // notification that Stop just delivered. + let mappedSession = parsedInput.sessionId.flatMap { try? sessionStore.lookup(sessionId: $0) } + let fallbackWorkspaceId = try? resolvePreferredWorkspaceIdForClaudeHook( + preferred: mappedSession?.workspaceId, + fallback: workspaceArg, + client: client + ) + let fallbackSurfaceId: String? = { + guard let fallbackWorkspaceId else { return nil } + return try? resolvePreferredSurfaceIdForClaudeHook( + preferred: mappedSession?.surfaceId, + fallback: surfaceArg, + workspaceId: fallbackWorkspaceId, + client: client + ) + }() + let consumedSession = try? sessionStore.consume( + sessionId: parsedInput.sessionId, + workspaceId: fallbackWorkspaceId, + surfaceId: fallbackSurfaceId + ) + if let consumedSession { + let workspaceId = consumedSession.workspaceId + let agentPIDKey = codexAgentPIDKey(sessionId: parsedInput.sessionId ?? consumedSession.sessionId) + _ = try? clearCodexStatus(client: client, workspaceId: workspaceId) + _ = try? client.sendV2(method: "workspace.clear_agent_pid", params: ["workspace_id": workspaceId, "key": agentPIDKey]) + _ = try? client.sendV2(method: "notification.clear", params: ["workspace_id": workspaceId]) + } + print("{}") + } catch { + if shouldIgnoreClaudeHookTeardownError(error) { + print("{}") + return + } + throw error + } + case "help", "--help", "-h": - print("programa codex-hook [--workspace ] [--surface ]") + print("programa codex-hook [--workspace ] [--surface ]") default: throw CLIError(message: "Unknown codex-hook subcommand: \(subcommand)") } } + private func summarizeCodexHookNotification(parsedInput: ClaudeHookParsedInput) -> (subtitle: String, body: String) { + guard let object = parsedInput.object else { + if let fallback = parsedInput.rawFallback, !fallback.isEmpty { + return classifyCodexNotification(signal: fallback, message: fallback) + } + return ("Waiting", "Codex is waiting for your input") + } + + let nested = (object["notification"] as? [String: Any]) ?? (object["data"] as? [String: Any]) ?? [:] + let signalParts = [ + firstString(in: object, keys: ["event", "event_name", "hook_event_name", "type", "kind"]), + firstString(in: object, keys: ["notification_type", "matcher", "reason"]), + firstString(in: nested, keys: ["type", "kind", "reason"]) + ] + let messageCandidates = [ + firstString(in: object, keys: ["message", "body", "text", "prompt", "error", "description"]), + firstString(in: nested, keys: ["message", "body", "text", "prompt", "error", "description"]) + ] + let message = messageCandidates.compactMap { $0 }.first ?? "Codex needs your input" + let normalizedMessage = normalizedSingleLine(message) + let signal = signalParts.compactMap { $0 }.joined(separator: " ") + var classified = classifyCodexNotification(signal: signal, message: normalizedMessage) + + classified.body = truncate(classified.body, maxLength: 180) + return classified + } + + private func classifyCodexNotification(signal: String, message: String) -> (subtitle: String, body: String) { + let lower = "\(signal) \(message)".lowercased() + if lower.contains("permission") || lower.contains("approve") || lower.contains("approval") || lower.contains("permission_prompt") { + let body = message.isEmpty ? "Approval needed" : message + return ("Permission", body) + } + if lower.contains("error") || lower.contains("failed") || lower.contains("exception") { + let body = message.isEmpty ? "Codex reported an error" : message + return ("Error", body) + } + if lower.contains("complet") || lower.contains("finish") || lower.contains("done") || lower.contains("success") { + let body = message.isEmpty ? "Task completed" : message + return ("Completed", body) + } + if lower.contains("idle") || lower.contains("wait") || lower.contains("input") || lower.contains("idle_prompt") { + let body = message.isEmpty ? "Waiting for input" : message + return ("Waiting", body) + } + // Use the message directly if it's meaningful (not a generic placeholder). + if !message.isEmpty, message != "Codex needs your input" { + return ("Attention", message) + } + return ("Attention", "Codex needs your attention") + } + private func setCodexStatus( client: SocketClient, workspaceId: String, @@ -2024,6 +2195,10 @@ extension ProgramaCLI { ]) } + private func clearCodexStatus(client: SocketClient, workspaceId: String) throws { + _ = try client.sendV2(method: "workspace.clear_status", params: ["workspace_id": workspaceId, "key": "codex"]) + } + private func codexAgentPIDKey(sessionId: String?) -> String { guard let sessionId = sessionId?.trimmingCharacters(in: .whitespacesAndNewlines), !sessionId.isEmpty else { @@ -2125,7 +2300,7 @@ extension ProgramaCLI { """ case "codex-hook": return """ - Usage: programa codex-hook [flags] + Usage: programa codex-hook [flags] Hook for Codex CLI integration. Reads JSON from stdin. Gracefully no-ops when not running inside programa. @@ -2134,6 +2309,8 @@ extension ProgramaCLI { session-start Register a Codex session prompt-submit Set Running status on user prompt stop Send completion notification, set Idle + notification Send an attention-classified notification, set Needs input + session-end Final cleanup when the Codex process exits (Ctrl+C/kill) Flags: --workspace Target workspace (default: $PROGRAMA_WORKSPACE_ID) diff --git a/CLI/programa.swift b/CLI/programa.swift index f86b2f9b..8ac98f07 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -1452,10 +1452,10 @@ struct ProgramaCLI { // Codex hooks management (no socket needed) if command == "codex" { let sub = commandArgs.first?.lowercased() ?? "help" - if sub == "install-hooks" { + if sub == "install-hooks" || sub == "install-integration" { try runCodexInstallHooks() return - } else if sub == "uninstall-hooks" { + } else if sub == "uninstall-hooks" || sub == "uninstall-integration" { try runCodexUninstallHooks() return } @@ -1585,12 +1585,14 @@ struct ProgramaCLI { CommandDescriptor(names: ["omc"], helpLines: ["omc [omc-args...]"], connectionPolicy: .local, helpPolicy: .passthrough, execute: nil), CommandDescriptor( names: ["codex"], - helpLines: ["codex "], + helpLines: ["codex "], connectionPolicy: .local, detailedUsage: """ - Usage: programa codex + Usage: programa codex + programa codex (legacy aliases, still supported) - Install or remove Programa's Codex notification hooks. + Install or remove Programa's Codex notification hooks in + ~/.codex/hooks.json (or $CODEX_HOME/hooks.json). """, execute: nil ), @@ -5271,7 +5273,7 @@ struct ProgramaCLI { guard ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] != nil else { return } let parsed = try parse(values: ["workspace", "surface"], maxPositionals: 1) if let subcommand = parsed.positional.first?.lowercased(), - !["session-start", "prompt-submit", "stop", "help", "--help", "-h"].contains(subcommand) { + !["session-start", "prompt-submit", "stop", "notification", "notify", "session-end", "help", "--help", "-h"].contains(subcommand) { throw CLIError(message: "codex-hook: unknown event \(subcommand)") } @@ -5354,8 +5356,8 @@ struct ProgramaCLI { return case "codex": let parsed = try parse(booleans: ["yes", "y"], minPositionals: 1, maxPositionals: 1) - guard ["install-hooks", "uninstall-hooks"].contains(parsed.positional[0].lowercased()) else { - throw CLIError(message: "codex: expected install-hooks or uninstall-hooks") + guard ["install-hooks", "uninstall-hooks", "install-integration", "uninstall-integration"].contains(parsed.positional[0].lowercased()) else { + throw CLIError(message: "codex: expected install-integration or uninstall-integration") } case "claude": let parsed = try parse(booleans: ["yes", "y"], minPositionals: 1, maxPositionals: 1) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index ac4e2518..0e3c3330 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -5857,6 +5857,23 @@ } } }, + "menu.file.installCodexIntegration": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Install Codex Integration…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Codex 連携をインストール…" + } + } + } + }, "menu.file.reopenClosedBrowserPanel": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index ee49b02a..1af44559 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -4722,6 +4722,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, @preconcurrency UNUser return workspace.id } + /// Opens a new workspace and runs `programa codex install-integration` in it, + /// mirroring openClaudeIntegrationInstaller: no working-directory override, since + /// the installer targets the user's global Codex settings, not a project. + @discardableResult + func openCodexIntegrationInstaller(event: NSEvent? = nil, debugSource: String = "unspecified") -> UUID? { + discardOrphanedMainWindowContexts() + guard let context = preferredMainWindowContextForWorkspaceCreation(event: event, debugSource: debugSource) else { + openNewMainWindow(nil) + return nil + } + guard let window = resolvedWindow(for: context) else { + discardOrphanedMainWindowContext(context) + openNewMainWindow(nil) + return nil + } + setActiveMainWindow(window) + + let workspace = context.tabManager.addWorkspace( + initialTerminalInput: "programa codex install-integration\n", + select: true + ) + return workspace.id + } + private func preferredMainWindowContextForWorkspaceCreation( event: NSEvent? = nil, debugSource: String = "unspecified" diff --git a/Sources/GhosttyApp.swift b/Sources/GhosttyApp.swift index 985e8a19..80b8cfb1 100644 --- a/Sources/GhosttyApp.swift +++ b/Sources/GhosttyApp.swift @@ -1451,12 +1451,13 @@ class GhosttyApp { let tabId = tabManager.selectedTabId else { return false } - // Suppress OSC notifications for workspaces with active Claude hook sessions. - // The hook system manages notifications with proper lifecycle tracking; - // raw OSC notifications would duplicate or outlive the structured hooks. + // Suppress OSC notifications for workspaces with active hook-managed agent + // sessions (Claude Code, Codex, etc.). The hook system manages notifications + // with proper lifecycle tracking; raw OSC notifications would duplicate or + // outlive the structured hooks. let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? tabManager if let workspace = owningManager.tabs.first(where: { $0.id == tabId }), - workspace.agentPIDs["claude_code"] != nil { + workspace.hasHookManagedAgent { return true } let tabTitle = owningManager.titleForTab(tabId) ?? "Terminal" @@ -1731,10 +1732,11 @@ class GhosttyApp { let actionBody = action.action.desktop_notification.body .flatMap { String(cString: $0) } ?? "" performOnMain { - // Suppress OSC notifications for workspaces with active Claude hook sessions. + // Suppress OSC notifications for workspaces with active hook-managed agent + // sessions (Claude Code, Codex, etc.). let owningManager = AppDelegate.shared?.tabManagerFor(tabId: tabId) ?? AppDelegate.shared?.tabManager if let workspace = owningManager?.tabs.first(where: { $0.id == tabId }), - workspace.agentPIDs["claude_code"] != nil { + workspace.hasHookManagedAgent { return } let tabTitle = owningManager?.titleForTab(tabId) ?? "Terminal" diff --git a/Sources/ProgramaApp.swift b/Sources/ProgramaApp.swift index a5db535a..193aa7c4 100644 --- a/Sources/ProgramaApp.swift +++ b/Sources/ProgramaApp.swift @@ -526,6 +526,15 @@ struct programaApp: App { ) { AppDelegate.shared?.openClaudeIntegrationInstaller(debugSource: "menu.installClaudeIntegration") } + + Button( + String( + localized: "menu.file.installCodexIntegration", + defaultValue: "Install Codex Integration…" + ) + ) { + AppDelegate.shared?.openCodexIntegrationInstaller(debugSource: "menu.installCodexIntegration") + } } // Close tab/workspace diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 44dcbff0..aba80002 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -152,6 +152,13 @@ final class Workspace: Identifiable, ObservableObject { var agentPIDs: [String: pid_t] = [:] var restoredTerminalScrollbackByPanelId: [UUID: String] = [:] + /// True when any hook-managed agent (Claude Code, Codex, etc.) is registered for this + /// workspace. Used to suppress raw OSC desktop notifications in favor of the structured + /// hook-driven notification lifecycle. + var hasHookManagedAgent: Bool { + !agentPIDs.isEmpty + } + func sidebarObservationSignal( _ publisher: Published.Publisher ) -> AnyPublisher { diff --git a/tests_v2/test_codex_install_integration.py b/tests_v2/test_codex_install_integration.py new file mode 100644 index 00000000..3d3e34e4 --- /dev/null +++ b/tests_v2/test_codex_install_integration.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Regression: `programa codex install-integration` / `uninstall-integration` (#139). + +Runs entirely against the CLI binary with no app/socket involved — the `codex` +command branch is dispatched before any socket connection is opened. Exercises: +- Fresh install: writes all five lifecycle hook events into hooks.json, and + config.toml gains `codex_hooks = true` under `[features]`. +- Idempotency: a second install run makes no further changes. +- Preservation: unrelated user hooks and other event keys are left alone; a + stale programa entry is replaced (not duplicated) on reinstall. +- Uninstall: programa entries are removed, user hooks and unrelated event + keys survive, emptied event keys are dropped, and config.toml's + `codex_hooks` key is removed. +- Declining the confirmation prompt leaves the files untouched. +- Legacy alias: `programa codex install-hooks` / `uninstall-hooks` behave + identically to `install-integration` / `uninstall-integration`. +""" + +import glob +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any, Dict, List, Optional + +sys.path.insert(0, str(Path(__file__).parent)) +from cmux import cmuxError + + +CODEX_HOOK_MARKER = "programa codex-hook" +EXPECTED_EVENTS = [ + "SessionStart", + "UserPromptSubmit", + "Stop", + "Notification", + "SessionEnd", +] + + +def _must(cond: bool, msg: str) -> None: + if not cond: + raise cmuxError(msg) + + +def _find_cli_binary() -> str: + env_cli = os.environ.get("CMUXTERM_CLI") + if env_cli and os.path.isfile(env_cli) and os.access(env_cli, os.X_OK): + return env_cli + + fixed = os.path.expanduser("~/Library/Developer/Xcode/DerivedData/cmux-tests-v2/Build/Products/Debug/cmux") + if os.path.isfile(fixed) and os.access(fixed, os.X_OK): + return fixed + + candidates = glob.glob(os.path.expanduser("~/Library/Developer/Xcode/DerivedData/**/Build/Products/Debug/cmux"), recursive=True) + candidates += glob.glob("/tmp/programa-*/Build/Products/Debug/programa") + candidates = [p for p in candidates if os.path.isfile(p) and os.access(p, os.X_OK)] + if not candidates: + raise cmuxError("Could not locate programa CLI binary; set CMUXTERM_CLI") + candidates.sort(key=lambda p: os.path.getmtime(p), reverse=True) + return candidates[0] + + +def _run( + cli: str, + args: List[str], + codex_home: str, + input_text: Optional[str] = None, +) -> subprocess.CompletedProcess: + env = os.environ.copy() + for var in ("PROGRAMA_WORKSPACE_ID", "PROGRAMA_SURFACE_ID", "PROGRAMA_PANEL_ID", "PROGRAMA_TAB_ID"): + env.pop(var, None) + env["CODEX_HOME"] = codex_home + return subprocess.run( + [cli] + args, + capture_output=True, + text=True, + check=False, + env=env, + input=input_text, + ) + + +def _merged(proc: subprocess.CompletedProcess) -> str: + return f"{proc.stdout}\n{proc.stderr}".strip() + + +def _hooks_commands(hooks: Dict[str, Any], event: str) -> List[str]: + commands = [] + for group in hooks.get(event, []): + for hook in group.get("hooks", []): + command = hook.get("command", "") + commands.append(command) + return commands + + +def _has_codex_hooks_feature(config_toml: str) -> bool: + for line in config_toml.splitlines(): + stripped = line.strip() + if stripped.startswith("#"): + continue + if stripped.replace(" ", "") == "codex_hooks=true": + return True + return False + + +def test_fresh_install(cli: str) -> None: + with tempfile.TemporaryDirectory() as codex_home: + proc = _run(cli, ["codex", "install-integration", "--yes"], codex_home) + _must(proc.returncode == 0, f"install-integration should exit 0: {_merged(proc)}") + + hooks_path = Path(codex_home) / "hooks.json" + _must(hooks_path.exists(), f"hooks.json should be created at {hooks_path}") + + data = json.loads(hooks_path.read_text(encoding="utf-8")) + hooks = data.get("hooks", {}) + for event in EXPECTED_EVENTS: + commands = _hooks_commands(hooks, event) + _must(len(commands) >= 1, f"expected at least one hook for {event}, got: {hooks.get(event)}") + _must( + any(CODEX_HOOK_MARKER in cmd for cmd in commands), + f"expected a '{CODEX_HOOK_MARKER}' command for {event}, got: {commands}", + ) + + config_path = Path(codex_home) / "config.toml" + _must(config_path.exists(), f"config.toml should be created at {config_path}") + _must( + _has_codex_hooks_feature(config_path.read_text(encoding="utf-8")), + "config.toml should gain codex_hooks = true under [features]", + ) + + print(" PASS: fresh install writes all five lifecycle hook events and config.toml feature flag") + + +def test_idempotent_reinstall(cli: str) -> None: + with tempfile.TemporaryDirectory() as codex_home: + proc = _run(cli, ["codex", "install-integration", "--yes"], codex_home) + _must(proc.returncode == 0, f"first install should exit 0: {_merged(proc)}") + + hooks_path = Path(codex_home) / "hooks.json" + config_path = Path(codex_home) / "config.toml" + hooks_before = hooks_path.read_bytes() + config_before = config_path.read_bytes() + + proc2 = _run(cli, ["codex", "install-integration", "--yes"], codex_home) + _must(proc2.returncode == 0, f"second install should exit 0: {_merged(proc2)}") + + hooks_after = hooks_path.read_bytes() + config_after = config_path.read_bytes() + _must(hooks_before == hooks_after, "reinstalling should not change hooks.json bytes") + _must(config_before == config_after, "reinstalling should not change config.toml bytes") + + print(" PASS: reinstall is idempotent (byte-identical)") + + +def test_preserves_user_hooks_and_replaces_stale_entry(cli: str) -> None: + with tempfile.TemporaryDirectory() as codex_home: + hooks_path = Path(codex_home) / "hooks.json" + seed = { + "hooks": { + "PostToolUse": [ + { + "hooks": [{"type": "command", "command": "echo user-post-tool-hook"}], + } + ], + "SessionStart": [ + { + "hooks": [{"type": "command", "command": "echo my-custom-start-hook"}], + }, + { + "hooks": [{"type": "command", "command": f"{CODEX_HOOK_MARKER} old-form"}], + }, + ], + "Stop": [ + { + "hooks": [{"type": "command", "command": f"{CODEX_HOOK_MARKER} stop-old-format"}], + } + ], + } + } + hooks_path.write_text(json.dumps(seed, indent=2), encoding="utf-8") + + proc = _run(cli, ["codex", "install-integration", "--yes"], codex_home) + _must(proc.returncode == 0, f"install should exit 0: {_merged(proc)}") + + data = json.loads(hooks_path.read_text(encoding="utf-8")) + hooks = data.get("hooks", {}) + + # Unrelated event untouched. + post_tool_commands = _hooks_commands(hooks, "PostToolUse") + _must( + post_tool_commands == ["echo user-post-tool-hook"], + f"PostToolUse should be preserved verbatim, got: {post_tool_commands}", + ) + + # SessionStart: user's custom hook preserved, stale programa entry replaced + # (exactly one programa group remains, with the new guard-form command). + session_start_groups = hooks.get("SessionStart", []) + user_groups = [ + g for g in session_start_groups + if not all(CODEX_HOOK_MARKER in h.get("command", "") for h in g.get("hooks", [])) + ] + programa_groups = [ + g for g in session_start_groups + if all(CODEX_HOOK_MARKER in h.get("command", "") for h in g.get("hooks", [])) + ] + _must(len(user_groups) == 1, f"expected 1 preserved user SessionStart group, got: {user_groups}") + _must( + user_groups[0]["hooks"][0]["command"] == "echo my-custom-start-hook", + f"user SessionStart hook should be untouched, got: {user_groups[0]}", + ) + _must(len(programa_groups) == 1, f"expected exactly 1 programa SessionStart group, got: {programa_groups}") + _must( + "old-form" not in programa_groups[0]["hooks"][0]["command"], + f"stale programa SessionStart entry should be replaced, got: {programa_groups[0]}", + ) + + # Stop: exactly one programa entry, stale one replaced. + stop_commands = _hooks_commands(hooks, "Stop") + _must(len(stop_commands) == 1, f"expected exactly 1 Stop hook command, got: {stop_commands}") + _must(CODEX_HOOK_MARKER in stop_commands[0], f"Stop hook should be a programa hook, got: {stop_commands}") + _must("stop-old-format" not in stop_commands[0], f"stale Stop command should be replaced, got: {stop_commands}") + + print(" PASS: install preserves user hooks and replaces stale programa entries") + + # Uninstall from this same seeded+installed state. + proc_uninstall = _run(cli, ["codex", "uninstall-integration", "--yes"], codex_home) + _must(proc_uninstall.returncode == 0, f"uninstall should exit 0: {_merged(proc_uninstall)}") + + data_after = json.loads(hooks_path.read_text(encoding="utf-8")) + hooks_after = data_after.get("hooks", {}) + + # User hooks survive uninstall. + post_tool_after = _hooks_commands(hooks_after, "PostToolUse") + _must( + post_tool_after == ["echo user-post-tool-hook"], + f"PostToolUse should survive uninstall, got: {post_tool_after}", + ) + session_start_after = hooks_after.get("SessionStart", []) + _must( + len(session_start_after) == 1 + and session_start_after[0]["hooks"][0]["command"] == "echo my-custom-start-hook", + f"user SessionStart hook should survive uninstall, got: {session_start_after}", + ) + + # Programa-only events are fully removed (empty key dropped). + _must("Stop" not in hooks_after, f"emptied Stop key should be removed after uninstall, got: {hooks_after}") + for event in EXPECTED_EVENTS: + if event == "SessionStart": + continue + _must( + not any(CODEX_HOOK_MARKER in cmd for cmd in _hooks_commands(hooks_after, event)), + f"no programa hooks should remain for {event} after uninstall: {hooks_after.get(event)}", + ) + _must( + not any(CODEX_HOOK_MARKER in h.get("command", "") for g in session_start_after for h in g.get("hooks", [])), + f"no programa hooks should remain in SessionStart after uninstall: {session_start_after}", + ) + + config_path = Path(codex_home) / "config.toml" + config_after = config_path.read_text(encoding="utf-8") if config_path.exists() else "" + _must( + not _has_codex_hooks_feature(config_after), + f"config.toml codex_hooks key should be removed after uninstall, got: {config_after!r}", + ) + + print(" PASS: uninstall removes programa entries, preserves user hooks, drops empty keys, and config.toml key") + + +def test_decline_confirmation_leaves_file_untouched(cli: str) -> None: + with tempfile.TemporaryDirectory() as codex_home: + hooks_path = Path(codex_home) / "hooks.json" + _must(not hooks_path.exists(), "hooks.json should not pre-exist for this test") + + proc = _run(cli, ["codex", "install-integration"], codex_home, input_text="n\n") + + if proc.returncode == 0: + _must( + not hooks_path.exists(), + f"declining confirmation with exit 0 must leave hooks.json untouched, but it was created: {_merged(proc)}", + ) + # A non-zero exit code is also an acceptable "clean abort" outcome. + + print(" PASS: declining the confirmation prompt leaves hooks.json untouched") + + +def test_legacy_alias_install_hooks(cli: str) -> None: + with tempfile.TemporaryDirectory() as codex_home: + proc = _run(cli, ["codex", "install-hooks", "--yes"], codex_home) + _must(proc.returncode == 0, f"legacy install-hooks should exit 0: {_merged(proc)}") + + hooks_path = Path(codex_home) / "hooks.json" + _must(hooks_path.exists(), f"hooks.json should be created at {hooks_path}") + data = json.loads(hooks_path.read_text(encoding="utf-8")) + hooks = data.get("hooks", {}) + for event in EXPECTED_EVENTS: + commands = _hooks_commands(hooks, event) + _must( + any(CODEX_HOOK_MARKER in cmd for cmd in commands), + f"legacy install-hooks: expected a '{CODEX_HOOK_MARKER}' command for {event}, got: {commands}", + ) + + proc_uninstall = _run(cli, ["codex", "uninstall-hooks", "--yes"], codex_home) + _must(proc_uninstall.returncode == 0, f"legacy uninstall-hooks should exit 0: {_merged(proc_uninstall)}") + data_after = json.loads(hooks_path.read_text(encoding="utf-8")) + hooks_after = data_after.get("hooks", {}) + for event in EXPECTED_EVENTS: + _must( + not any(CODEX_HOOK_MARKER in cmd for cmd in _hooks_commands(hooks_after, event)), + f"legacy uninstall-hooks: no programa hooks should remain for {event}, got: {hooks_after.get(event)}", + ) + + print(" PASS: legacy install-hooks/uninstall-hooks aliases behave identically") + + +def main() -> int: + cli = _find_cli_binary() + print(f"Using CLI: {cli}") + + test_fresh_install(cli) + test_idempotent_reinstall(cli) + test_preserves_user_hooks_and_replaces_stale_entry(cli) + test_decline_confirmation_leaves_file_untouched(cli) + test_legacy_alias_install_hooks(cli) + + print("\nPASS: All codex install-integration tests passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())