Skip to content
Open
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
24 changes: 22 additions & 2 deletions app/Roster/Data/ProviderInstallers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,27 @@ enum CodexInstaller {
}
}

/// Places `block` as a document-root TOML entry. A bare key written after a
/// `[table]` header belongs to that table, so appending at end-of-file would
/// scope our `notify` into the file's last table (commonly an
/// `[mcp_servers.*]` block) and Codex would never see it. The block is
/// inserted just above the first table header instead; a file with no tables
/// (or an empty file) simply gets the block appended.
static func insertingAtRoot(_ block: String, into text: String) -> String {
if text.isEmpty { return block + "\n" }
let lines = text.components(separatedBy: "\n")
guard let idx = lines.firstIndex(where: {
$0.trimmingCharacters(in: .whitespaces).hasPrefix("[")
}) else {
return text + "\n\n" + block + "\n"
}
var head = Array(lines[..<idx])
if head.last?.trimmingCharacters(in: .whitespaces).isEmpty == false {
head.append("")
}
return (head + [block, ""] + Array(lines[idx...])).joined(separator: "\n")
}

static func install(at url: URL = defaultConfigURL) throws {
try HelperScript.install()
let text = (try? String(contentsOf: url, encoding: .utf8)) ?? ""
Expand All @@ -333,12 +354,11 @@ enum CodexInstaller {
}
try backup(url)
let block = """

# Added by Roster.app — appends turn events to Roster's spool.
# Safe to remove; Roster re-adds it on the next Connect.
notify = ["\(HelperScript.url.path)", "codex"]
"""
let updated = text.isEmpty ? String(block.dropFirst()) : text + block + "\n"
let updated = insertingAtRoot(block, into: text)
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(), withIntermediateDirectories: true
)
Expand Down
32 changes: 32 additions & 0 deletions app/RosterTests/ProviderInstallersTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,36 @@ final class ProviderInstallersTests: XCTestCase {
in: "# notify = [\"commented-out\"]\n"
), "a commented notify is no notify")
}

func testCodexNotifyStaysTopLevelAboveTables() {
let block = "notify = [\"/Users/me/.roster/roster-hook.sh\", \"codex\"]"

// Config whose last section is a table (e.g. an MCP server). The bare
// `notify` must be inserted ABOVE the table: appended after the header
// it would become `mcp_servers.everything.notify` and Codex, which only
// reads a document-root `notify`, would never see it.
let withTable = """
model = "o4-mini"

[mcp_servers.everything]
command = "npx"
"""
let out = CodexInstaller.insertingAtRoot(block, into: withTable)
let lines = out.components(separatedBy: "\n")
let notifyIdx = lines.firstIndex { $0.hasPrefix("notify") }
let tableIdx = lines.firstIndex { $0.hasPrefix("[") }
XCTAssertNotNil(notifyIdx, "notify must be written")
XCTAssertNotNil(tableIdx, "the table header must be preserved")
XCTAssertLessThan(
notifyIdx!, tableIdx!,
"notify must precede the first table header to stay a root key"
)

// A flat config (no tables) still gets the key.
XCTAssertTrue(
CodexInstaller.insertingAtRoot(block, into: "model = \"o4-mini\"\n")
.contains("notify = ["),
"notify is still written when the config has no tables"
)
}
}