Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@ public struct CodexHookConfigWriter: HookConfigWriter {
"PermissionRequest",
]

/// Post-execution events safe to run async (fire-and-forget).
private static let asyncEvents: Set<String> = ["PostToolUse", "Stop"]
/// Events that should run async (fire-and-forget). Codex's hook runtime
/// is sync-only as of v0.139.0 — declaring `async = true` causes the
/// entry to be silently skipped on startup, which breaks Crow's
/// session-state detection. Keep this empty until/unless Codex grows
/// real async support upstream.
private static let asyncEvents: Set<String> = []

public init() {}

Expand Down Expand Up @@ -89,9 +93,15 @@ public struct CodexHookConfigWriter: HookConfigWriter {
}

/// Install or update `<codexHome>/config.toml` with the
/// `features.codex_hooks = true` flag and the Crow `notify` line.
/// `features.hooks = true` flag and the Crow `notify` line.
/// Preserves any other user-authored config — minimal line-oriented merge
/// avoids pulling in a TOML dependency for two simple keys.
///
/// Also runs a one-shot migration: legacy installs (Crow before this
/// fix, or older Codex versions) wrote `codex_hooks = true` under
/// `[features]`. Codex v0.139.0+ renamed the key to `hooks` and emits
/// a deprecation warning for the old one — strip it so users converging
/// from older configs end up with a single, current entry.
public static func installGlobalTomlConfig(codexHome: String, crowPath: String) throws {
try FileManager.default.createDirectory(atPath: codexHome, withIntermediateDirectories: true)
let tomlPath = (codexHome as NSString).appendingPathComponent("config.toml")
Expand All @@ -104,11 +114,14 @@ public struct CodexHookConfigWriter: HookConfigWriter {

let notifyLine = "notify = [\"\(escapeTomlString(crowPath))\", \"codex-notify\"]"
content = upsertTomlLine(content, key: "notify", line: notifyLine)
// Strip the deprecated `codex_hooks` key before writing the modern
// `hooks` key so we don't leave both lines behind on migration.
content = removeTomlSectionLine(content, section: "features", key: "codex_hooks")
content = upsertTomlSectionLine(
content,
section: "features",
key: "codex_hooks",
line: "codex_hooks = true"
key: "hooks",
line: "hooks = true"
)

try content.write(toFile: tomlPath, atomically: true, encoding: .utf8)
Expand Down Expand Up @@ -200,6 +213,40 @@ public struct CodexHookConfigWriter: HookConfigWriter {
return lines.joined(separator: "\n")
}

/// Remove `key = …` from inside `[section]` if present. Returns the
/// content unchanged when the section or key is absent — idempotent and
/// safe to chain before an `upsertTomlSectionLine` call.
static func removeTomlSectionLine(
_ content: String,
section: String,
key: String
) -> String {
var lines = content.components(separatedBy: "\n")
var sectionStart: Int? = nil
var sectionEnd: Int = lines.count
let sectionHeader = "[\(section)]"
for (i, raw) in lines.enumerated() {
let trimmed = raw.trimmingCharacters(in: .whitespaces)
if trimmed == sectionHeader {
sectionStart = i
continue
}
if let _ = sectionStart, trimmed.hasPrefix("[") && trimmed.hasSuffix("]") {
sectionEnd = i
break
}
}

guard let start = sectionStart else { return content }
for i in (start + 1)..<sectionEnd {
if lineKey(of: lines[i]) == key {
lines.remove(at: i)
return lines.joined(separator: "\n")
}
}
return content
}

/// Extract the bare `key` from a `key = value` TOML line, ignoring
/// comments and quoted keys. Returns `nil` for non-assignment lines.
private static func lineKey(of raw: String) -> String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,8 @@ struct CodexHookConfigWriterTests {
let toml = try String(contentsOf: codexHome.appendingPathComponent("config.toml"))
#expect(toml.contains("notify = [\"/opt/homebrew/bin/crow\", \"codex-notify\"]"))
#expect(toml.contains("[features]"))
#expect(toml.contains("codex_hooks = true"))
#expect(toml.contains("hooks = true"))
#expect(!toml.contains("codex_hooks"), "deprecated codex_hooks key must not be written")
}

@Test func installGlobalTomlConfigPreservesUserSettings() throws {
Expand Down Expand Up @@ -132,6 +133,75 @@ struct CodexHookConfigWriterTests {
#expect(toml.contains("memories = true"))
// Crow entries added.
#expect(toml.contains("notify = "))
#expect(toml.contains("codex_hooks = true"))
#expect(toml.contains("hooks = true"))
#expect(!toml.contains("codex_hooks"), "deprecated codex_hooks key must not be written")
}

@Test func installGlobalTomlConfigMigratesLegacyCodexHooksKey() throws {
let codexHome = try makeTempCodexHome()
defer { try? FileManager.default.removeItem(at: codexHome) }

// Pre-seed with the deprecated `codex_hooks` key that pre-fix
// installs left behind. The migration should strip it and replace
// it with the current `hooks` key.
let legacy = """
model = "gpt-4o"

[features]
codex_hooks = true
memories = true
"""
try legacy.write(
toFile: codexHome.appendingPathComponent("config.toml").path,
atomically: true, encoding: .utf8
)

try CodexHookConfigWriter.installGlobalTomlConfig(
codexHome: codexHome.path,
crowPath: "/usr/local/bin/crow"
)

let toml = try String(contentsOf: codexHome.appendingPathComponent("config.toml"))
#expect(toml.contains("hooks = true"), "modern hooks key should be present")
#expect(!toml.contains("codex_hooks"), "deprecated codex_hooks key should be stripped")
// Unrelated user entries survive.
#expect(toml.contains("model = \"gpt-4o\""))
#expect(toml.contains("memories = true"))

// Migration is idempotent — re-running produces the same content.
try CodexHookConfigWriter.installGlobalTomlConfig(
codexHome: codexHome.path,
crowPath: "/usr/local/bin/crow"
)
let second = try String(contentsOf: codexHome.appendingPathComponent("config.toml"))
#expect(toml == second)
}

@Test func installGlobalConfigEmitsNoAsyncHooks() throws {
// Codex's hook runtime is sync-only; declaring async causes the
// entry to be silently skipped at startup, which breaks Crow's
// session-state detection. Guard against the regression.
let codexHome = try makeTempCodexHome()
defer { try? FileManager.default.removeItem(at: codexHome) }
try CodexHookConfigWriter.installGlobalConfig(
codexHome: codexHome.path,
crowPath: "/usr/local/bin/crow"
)

let data = try Data(contentsOf: codexHome.appendingPathComponent("hooks.json"))
let json = try JSONSerialization.jsonObject(with: data) as! [String: Any]
let hooks = json["hooks"] as! [String: Any]
for (event, value) in hooks {
let entries = value as! [[String: Any]]
for outer in entries {
let inner = outer["hooks"] as! [[String: Any]]
for entry in inner {
#expect(
entry["async"] == nil,
"event \(event) has async flag; Codex silently skips async hooks"
)
}
}
}
}
}
Loading