R4^x`mS++@*z=I(lbATuV}o7es7&h1EKDC)&ko{k~WG@>&Wu3EcFm
zgqr$B_uv$Be%~Px^*kKC#x%m{VIvU!%djOlIW?_F{YhtZGrxTO3zuL*N^IeJEqM^X
z2W~xuqJywfIu&h6#n(R7z0DS|MP`TFe#Q*jc7)wD!=l_Q{F8DX`p%qVkJ7;Br>{_5
zk;)Q@@TM2HL~`#Wj>9bJ3v`?r^Ik2s;4xe0PkmXYiCqJej&EcehJ)lnE@qRzW3Lrw
z0J8+8mycUqf#zZ7x$3FLp6QDq)lt^mWVi2$dBOhk#G1$$Ito_HGNabFgF(fsxBTg5
zsZ_%!nA)fa?+u=z*JI%qa=`(R6xOulI|1R?ly57S%{y^k=37}05IiJJcHNjzcSyMJ
z;`&)J4zf1W0|WU+aS*ry_3}|_&Xd`=<8-MZ(%I6%pS}|>aF^6ny@%2;$T(6<_|N|2
z6du$8vjZMB?P^}@O%w`86Yn*omCyH)2-`EYwLKz7zhi=Ht;bKB56a(6erajjpsWUm
zDd-IKM_@yXnCy~7+&VNBpIfFi_I)knDBZX+gP<>lR
zt^IfIx{nLcpSoJO?iIz^h2sq_;fwQ_P7Obh@VgGB@l^ZW99O^p^WR%5R5(~+n(D7>
z1T%v&+UE0Q-9z7)xM86UOpYV{yw8kDnunj2M-^X1_(IRtK2t{_W05s};ATn8O_~VK
z^nYhuu}*g$M)EH!`B$)^LE>zDJs-bY`ywOJBJlp^ghVlxis)5|i-cXc*b@b>dvG-#
z#(*4qpP{nO4=wpP9{gAFI}br#RtD=c8Mk2ya={7=!I=4xQ+y?A@zrt0&F+yNxWbZa
zx#Jb4k}J0%q+tPvpGM|R<4n2DI}oX^Z31@Gr*?K*5X?|dJTGtrqQG)8W3u5upk>7T
zPkpW?&!hRcPR`r?iD{?E*CUgy=Q(~YRb=zk%S
zHJP_VH2|i+UVK!>`LENyC^lDuYJ0O3#dI|rI+)6pN+|w1dPoe$5_46#
z0+`LLfD5PQ!&IiQTP@<5?P6n<|K7J$hKJGg$&bLvJy=oWA4X#hRp@krYE)bGXZ{-g
z2lqLo#cZ2Yju#r#v!>mYuu*Vr>%jV-`Jtm~Y0VNc&CKZYNZ?bAUfyaJH9f${Pa#DU
z3+iV3WJ6THGnrfS3WyX}P##DHMI%to5q8z=lgqQ}DLP-5fD;wQEV7ajU^l7;g+=D6
z@ExJ9Lj?8OImAsAE7krG1Reju$GnDa*$coZ8t`dzc2
zR$5}T1{trb0C!79F6;iJ-E@4~*d7bQXi+7G&QvDj-F-tGvl5Jf`VNMwR^_%cOlKy|
z{JrvqmBXmsJ=o5|D<7-&{U0)6FruT;_pb6I_$lag)bMJoM$sABRb8t?_{CD~@+~ZG
zXu|tJ$3P+&RB)0xq#UFWw_-k^I}u#5_GKb
zDxMv-nmSptd9VG)UEanyhcy1(|A`c~Mx1Km`-5O$u%d^4i1eE*No|JKXlQnBE*nME
zAlEj@ZKehp&~=p|=2P9c@l|yFkK*F54CgjO4Xdo%D(1i+ng!vuj+Ujog{)==14`luU?&WUZBiZVlJ;r5dGT2?RZWvKEqd#N)>k^lR1WBU9z6+{H=
zY`7s29`Ik-p`8FGDL0LsJ(s`)O?yiiar(lhaeBJf-Nmp!6!vK~lD0LDF*?Y(ZP7r1
zHZFeeN22X}$>S9qdY2hvFP__rhVvo5Z9_atmr5KQK5dSxx*3xLtKZ#))YWSs)0&%Y
z{%=6GTp!hU4HlCa@=5zsb-cdB_PO#K&1#>EZOkVaVc3_5ECs|f)-?tTib`B&C`*Mj
z>Uz*hX{aYF?I6EU6T*FWJY77-iw%?jmcbmat3q9Y|R%K-9^o8-LJ2Q5g({2YhV&
z{GW`ACekg-`|MmAs^Iz#bme<1<*P=jA;?c5;E<%k_*XsN7gp1@mfS-zgr*&^KE}x$
zQ!nIsn1<)Eq0EiqunAO}Qe?;lGv4M}+E9B|YvSU3JSb~GpPwdoue-ei+Yw$h3k1nm
zkb0$R8u#zBb6HaMb)jb-90L^pQAn2sA?mV}ku20w=;0pRF6ucFK~q6ru*sLc?*ZKE
z6|?vYlF;bnl^@znK9{?ibIl(1$n7}9k{^_ZkN!B!;>nTdWTtJ(CH^kX3GJ6qUSwge
zO*UwB$;*k|w&6jc8-d8hAqXSqf+eCh-okfHuIurNRCtc_wRrz9L&eDUXCBZKA=AYZ
zYYx0~e8kN81k!mITpX(N3wu*35ug-$L1Tjrm@?-Uav|i^DPphV#jXC>xsdO9ps2aS
zEbZ;{L4wCcxK^*piqbk!A019SQ3L>PFyXjtXruub@*CLT^~uUf
z1tLlLv2-Wpw%%BP1S~}$C!?Dpf)j;Vu%M=nN|?st@_A(!Rc#Th$yd2O^n>OQgD!}f
zDBgqZ+i_F?B@P*VuP_cd>YwAKG6afR8-n>3G+8Ns&;ak+xcmlf6N#Mb)S#O~s!Y88vUVAr-)e)HZU0J!;fD@b%UrP7I6Z)W-#0{xDA=~B(@<3
zQb8gx$9A|Y9B%4BP8o4nEHv9Nv!^ePX5)u{|%QD
zn`Zwcg~JFj_~?B;T_P-!WL-hIBi%dVe|{C(HJ~yB>p_jL8Vko_2e&?&b6fIXPp{q0
zW-HBkZr?u+iJ;*ABH+3IkKewh11Iuy&Oq^34T2tM|JfV>a%tv!2BpPj0T(8-M(okJ
zmER@gryKv3x&e_DA|xi>d5MisG_f?$V*9)xO|6;oP+3UG32MHuWPXAvac%YktJ2;yv4}%@n8@Xi
z=Md!9U}~Xj2f&3doZ@o%x2!4^RwXuKE)<@fYA#txUJ#54pc}UO@>gIZ()b@Q9i1%I
z^pn;+!cFpYDj`xxm2!On2xEGE4g&)_ll#b($U{G9ULpfTK^qW1%o3tk(9d`2CD1CX
zW>$DCZ_*2xK^UmAUb5+ybe)7gyoHP8YM|I!#v<11B)URj=u~3fqjSm>R`X{%gZ=GI
zf>;IEfb8rzhRPld2QrPGCL-NxpA2RTH^NA$2vl?~*r&@yudi4S3X7s81TA-o3`j3G
zY4bX0^U%--JVq@SgNV!+wMi>vfE?cnPbjY6Drnr;?206K+0AGp3lE2R(0suL$l<7!
z>m+yY0Sm1B#Squ)!?|iOn@BiMs-i2@L_9XUx-4ZpCn5!iVQ6NZ&I4rlF{DMwadGb3
zL+KWvUbg^i*=q0U%5XM2(xJ$p$`l4|dB;|p$72PGTICY$KS{nrUOODd(<^p?hg>&k
z+(1y;okPgO<>$HmEBM)iPLx Bool {
+ isNonEmpty(environment["GROK_SESSION_ID"])
+ || isNonEmpty(environment["GROK_HOOK_EVENT"])
+ }
+
+ /// Outside Grok, all existing hook sources keep their original behavior.
+ /// Inside Grok, only CodeIsland's explicitly managed `--source grok` hook
+ /// is forwarded; imported Claude/Cursor hooks are duplicate deliveries.
+ public static func shouldForward(
+ source: String?,
+ environment: [String: String]
+ ) -> Bool {
+ !isGrokRuntime(environment: environment) || source == "grok"
+ }
+
+ private static func isNonEmpty(_ value: String?) -> Bool {
+ guard let value else { return false }
+ return !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+}
diff --git a/Sources/CodeIslandCore/Models.swift b/Sources/CodeIslandCore/Models.swift
index b0a46241..a9d4c324 100644
--- a/Sources/CodeIslandCore/Models.swift
+++ b/Sources/CodeIslandCore/Models.swift
@@ -22,6 +22,15 @@ public enum CLIProcessResolver {
|| lowercasedPath.contains("/qwen-code ")
case "gemini":
return lowercasedPath.hasSuffix("/gemini") || lowercasedPath.contains("/gemini ")
+ case "grok":
+ // Managed Grok installs execute the versioned binary directly from
+ // $GROK_HOME/downloads (e.g. grok-0.2.106-macos-aarch64), while
+ // package-manager installs may expose a plain `.../bin/grok`.
+ let basename = (lowercasedPath as NSString).lastPathComponent
+ return basename == "grok"
+ || (basename.hasPrefix("grok-")
+ && (lowercasedPath.contains("/.grok/downloads/")
+ || lowercasedPath.contains("/grok/downloads/")))
case "cursor-cli":
// Cursor's CLI agent installs to ~/.local/share/cursor-agent/versions//cursor-agent
// and is also referenced by /cursor-agent/index.js when invoked via Node.
diff --git a/Sources/CodeIslandCore/SessionSnapshot.swift b/Sources/CodeIslandCore/SessionSnapshot.swift
index 789b2f23..4843e37c 100644
--- a/Sources/CodeIslandCore/SessionSnapshot.swift
+++ b/Sources/CodeIslandCore/SessionSnapshot.swift
@@ -31,6 +31,7 @@ public struct SessionSnapshot: Sendable {
"google-antigravity",
"workbuddy",
"hermes",
+ "grok",
"openclaw",
"qwen",
"kimi",
@@ -171,6 +172,10 @@ public struct SessionSnapshot: Sendable {
"hermes-agents": "hermes",
"hermes agent": "hermes",
"hermes agents": "hermes",
+ "grok-cli": "grok",
+ "grokcli": "grok",
+ "grok-build": "grok",
+ "grok build": "grok",
// OpenClaw (openclaw.ai) — personal-assistant Gateway daemon,
// formerly branded Clawdbot. Keep the old name as an alias so
// events from a not-yet-renamed install land on the same source.
@@ -222,6 +227,7 @@ public struct SessionSnapshot: Sendable {
if canonical.hasPrefix("antigravity") { return "antigravity" }
if canonical.hasPrefix("workbuddy") { return "workbuddy" }
if canonical.hasPrefix("hermes") { return "hermes" }
+ if canonical.hasPrefix("grok") { return "grok" }
if canonical.hasPrefix("openclaw") { return "openclaw" }
if canonical.hasPrefix("qwen") { return "qwen" }
if canonical.hasPrefix("kiro") { return "kiro" }
@@ -306,7 +312,7 @@ public struct SessionSnapshot: Sendable {
".claude", ".cursor", ".codex", ".gemini", ".qoder", ".qoderwork",
".trae", ".trae-cn", ".kiro", ".copilot", ".factory", ".codebuddy",
".codybuddycn", ".stepfun", ".workbuddy", ".hermes", ".kimi", ".kimi-code",
- ".pi", ".omp", ".qwen", ".zcode", ".openclaw", ".codeisland",
+ ".grok", ".pi", ".omp", ".qwen", ".zcode", ".openclaw", ".codeisland",
]
/// Resolve a human project folder label from a cwd path.
@@ -532,6 +538,7 @@ public struct SessionSnapshot: Sendable {
case "google-antigravity": return "Google Antigravity"
case "workbuddy": return "WorkBuddy"
case "hermes": return "Hermes"
+ case "grok": return "Grok CLI"
case "openclaw": return "OpenClaw"
case "qwen": return "Qwen Code"
case "kimi": return "Kimi Code CLI"
diff --git a/Tests/CodeIslandCoreTests/GrokHookForwardingPolicyTests.swift b/Tests/CodeIslandCoreTests/GrokHookForwardingPolicyTests.swift
new file mode 100644
index 00000000..a2f27989
--- /dev/null
+++ b/Tests/CodeIslandCoreTests/GrokHookForwardingPolicyTests.swift
@@ -0,0 +1,82 @@
+import XCTest
+@testable import CodeIslandCore
+
+final class GrokHookForwardingPolicyTests: XCTestCase {
+ func testGrokRuntimeAcceptsEitherSignalAndRejectsBlankValues() {
+ XCTAssertTrue(GrokHookForwardingPolicy.isGrokRuntime(environment: [
+ "GROK_SESSION_ID": "session-123",
+ ]))
+ XCTAssertFalse(GrokHookForwardingPolicy.shouldForward(
+ source: "cursor",
+ environment: ["GROK_SESSION_ID": "session-123"]
+ ))
+ XCTAssertTrue(GrokHookForwardingPolicy.isGrokRuntime(environment: [
+ "GROK_HOOK_EVENT": "SessionStart",
+ ]))
+ XCTAssertFalse(GrokHookForwardingPolicy.shouldForward(
+ source: nil,
+ environment: ["GROK_HOOK_EVENT": "SessionStart"]
+ ))
+ XCTAssertTrue(GrokHookForwardingPolicy.isGrokRuntime(environment: [
+ "GROK_SESSION_ID": " \n\t ",
+ "GROK_HOOK_EVENT": "Stop",
+ ]))
+
+ XCTAssertFalse(GrokHookForwardingPolicy.isGrokRuntime(environment: [:]))
+ XCTAssertFalse(GrokHookForwardingPolicy.isGrokRuntime(environment: [
+ "GROK_SESSION_ID": " \n\t ",
+ "GROK_HOOK_EVENT": " ",
+ ]))
+ }
+
+ func testImportedHooksDeliverEachManagedGrokEventExactlyOnce() {
+ let invocations: [(label: String, source: String?)] = [
+ ("managed-grok", "grok"),
+ ("imported-claude-untagged", nil),
+ ("imported-claude-tagged", "claude"),
+ ("imported-cursor", "cursor"),
+ ]
+
+ for event in GrokHookForwardingPolicy.managedHookEvents {
+ let environment = [
+ "GROK_SESSION_ID": "session-123",
+ "GROK_HOOK_EVENT": event,
+ ]
+ let forwarded = invocations.filter {
+ GrokHookForwardingPolicy.shouldForward(
+ source: $0.source,
+ environment: environment
+ )
+ }
+
+ XCTAssertEqual(
+ forwarded.map { $0.label },
+ ["managed-grok"],
+ "\(event) must be forwarded exactly once"
+ )
+ }
+ }
+
+ func testNonGrokRuntimeDoesNotSuppressExistingSources() {
+ let sources: [String?] = [nil, "claude", "cursor"]
+ let environments = [
+ [String: String](),
+ [
+ "GROK_SESSION_ID": " \n\t ",
+ "GROK_HOOK_EVENT": " ",
+ ],
+ ]
+
+ for environment in environments {
+ for source in sources {
+ XCTAssertTrue(
+ GrokHookForwardingPolicy.shouldForward(
+ source: source,
+ environment: environment
+ ),
+ "Non-Grok source \(source ?? "nil") must not be suppressed"
+ )
+ }
+ }
+ }
+}
diff --git a/Tests/CodeIslandCoreTests/JSONLTailerTests.swift b/Tests/CodeIslandCoreTests/JSONLTailerTests.swift
index 08e17693..150c81a6 100644
--- a/Tests/CodeIslandCoreTests/JSONLTailerTests.swift
+++ b/Tests/CodeIslandCoreTests/JSONLTailerTests.swift
@@ -141,6 +141,18 @@ final class JSONLTailerTests: XCTestCase {
XCTAssertFalse(result.delta.isEmpty)
}
+ func testScanLinesExtractsGrokChatHistoryRows() {
+ let lines = [
+ #"{"type":"user","content":[{"type":"text","text":"build it"}]}"#,
+ #"{"type":"assistant","content":"done","model_id":"grok-code"}"#,
+ ].joined(separator: "\n") + "\n"
+
+ let result = JSONLTailer.scanLines(Data(lines.utf8))
+
+ XCTAssertEqual(result.delta.lastUserPrompt, "build it")
+ XCTAssertEqual(result.delta.lastAssistantMessage, "done")
+ }
+
// MARK: - extractText
func testExtractTextFromPlainString() {
diff --git a/Tests/CodeIslandTests/GrokSupportTests.swift b/Tests/CodeIslandTests/GrokSupportTests.swift
new file mode 100644
index 00000000..49640100
--- /dev/null
+++ b/Tests/CodeIslandTests/GrokSupportTests.swift
@@ -0,0 +1,211 @@
+import XCTest
+@testable import CodeIsland
+@testable import CodeIslandCore
+
+final class GrokSupportTests: XCTestCase {
+ func testSourceNormalizationAndLabel() {
+ XCTAssertEqual(SessionSnapshot.normalizedSupportedSource("grok"), "grok")
+ XCTAssertEqual(SessionSnapshot.normalizedSupportedSource("Grok CLI"), "grok")
+ XCTAssertEqual(SessionSnapshot.normalizedSupportedSource("grok-build"), "grok")
+
+ var snapshot = SessionSnapshot()
+ snapshot.source = "grok"
+ XCTAssertEqual(snapshot.sourceLabel, "Grok CLI")
+ }
+
+ func testGrokExecutableResolverAcceptsManagedAndPathInstalls() {
+ XCTAssertTrue(CLIProcessResolver.sourceMatchesExecutablePath(
+ "/Users/test/.grok/downloads/grok-0.2.106-macos-aarch64",
+ source: "grok"
+ ))
+ XCTAssertTrue(CLIProcessResolver.sourceMatchesExecutablePath(
+ "/opt/homebrew/bin/grok",
+ source: "grok-cli"
+ ))
+ XCTAssertFalse(CLIProcessResolver.sourceMatchesExecutablePath(
+ "/Applications/Browser.app/Contents/MacOS/grok-helper",
+ source: "grok"
+ ))
+ }
+
+ func testGrokEventAliasesReachTerminalStates() {
+ XCTAssertEqual(EventNormalizer.normalize("permission_denied"), "PermissionDenied")
+ XCTAssertEqual(EventNormalizer.normalize("stop_failure"), "Stop")
+ XCTAssertEqual(EventNormalizer.normalize("StopFailure"), "Stop")
+ }
+
+ func testGrokCLIUsesEveryManagedHookEvent() throws {
+ let cli = try XCTUnwrap(ConfigInstaller.allCLIs.first { $0.source == "grok" })
+ XCTAssertEqual(cli.events.map(\.0), GrokHookForwardingPolicy.managedHookEvents)
+ }
+
+ func testGrokEncodedCwdEscapesSlashesAndSpaces() {
+ XCTAssertEqual(
+ AppState.grokEncodedCwd("/Users/test/My Project"),
+ "%2FUsers%2Ftest%2FMy%20Project"
+ )
+ }
+
+ func testGrokProcessMatchingRejectsLateSessionFromOlderProcess() {
+ let now = Date(timeIntervalSince1970: 10_000)
+ let oldProcessStart = now.addingTimeInterval(-35 * 60)
+ let newProcessStart = now.addingTimeInterval(-5)
+ let sessionCreatedAt = now.addingTimeInterval(-4)
+
+ XCTAssertNil(AppState.grokSessionProcessMatchScore(
+ createdAt: sessionCreatedAt,
+ activityAt: now,
+ processStart: oldProcessStart,
+ now: now
+ ))
+ XCTAssertNotNil(AppState.grokSessionProcessMatchScore(
+ createdAt: sessionCreatedAt,
+ activityAt: now,
+ processStart: newProcessStart,
+ now: now
+ ))
+ }
+
+ func testGrokProcessMatchingIsOneToOneForParallelSameCwdSessions() {
+ let base = Date(timeIntervalSince1970: 20_000)
+ let mapping = AppState.matchGrokSessionsToProcesses(
+ processes: [
+ (pid: 101, startedAt: base),
+ (pid: 202, startedAt: base.addingTimeInterval(30)),
+ ],
+ sessions: [
+ (
+ id: "first",
+ createdAt: base.addingTimeInterval(2),
+ activityAt: base.addingTimeInterval(40)
+ ),
+ (
+ id: "second",
+ createdAt: base.addingTimeInterval(32),
+ activityAt: base.addingTimeInterval(50)
+ ),
+ ],
+ now: base.addingTimeInterval(60)
+ )
+
+ XCTAssertEqual(mapping["first"], 101)
+ XCTAssertEqual(mapping["second"], 202)
+ XCTAssertEqual(Set(mapping.values).count, mapping.count)
+ }
+
+ func testGrokProcessMatchingUsesAugmentingPathToKeepEveryViableSession() {
+ let base = Date(timeIntervalSince1970: 30_000)
+ let mapping = AppState.matchGrokSessionsToProcesses(
+ processes: [
+ (pid: 101, startedAt: base),
+ (pid: 202, startedAt: base.addingTimeInterval(100)),
+ ],
+ sessions: [
+ (
+ id: "newer-resumed",
+ createdAt: base.addingTimeInterval(1),
+ activityAt: base.addingTimeInterval(110)
+ ),
+ (
+ id: "older-one-shot",
+ createdAt: base,
+ activityAt: base.addingTimeInterval(50)
+ ),
+ ],
+ now: base.addingTimeInterval(110)
+ )
+
+ XCTAssertEqual(mapping["newer-resumed"], 202)
+ XCTAssertEqual(mapping["older-one-shot"], 101)
+ XCTAssertEqual(mapping.count, 2)
+ }
+
+ func testGrokProcessMatchingBreaksEqualScoresDeterministically() {
+ let base = Date(timeIntervalSince1970: 40_000)
+ let processes: [(pid: pid_t, startedAt: Date?)] = [
+ (pid: 202, startedAt: base),
+ (pid: 101, startedAt: base),
+ ]
+ let sessions: [(id: String, createdAt: Date?, activityAt: Date)] = [
+ (id: "beta", createdAt: base, activityAt: base.addingTimeInterval(10)),
+ (id: "alpha", createdAt: base, activityAt: base.addingTimeInterval(10)),
+ ]
+
+ let first = AppState.matchGrokSessionsToProcesses(
+ processes: processes,
+ sessions: sessions,
+ now: base.addingTimeInterval(10)
+ )
+ let second = AppState.matchGrokSessionsToProcesses(
+ processes: Array(processes.reversed()),
+ sessions: Array(sessions.reversed()),
+ now: base.addingTimeInterval(10)
+ )
+
+ XCTAssertEqual(first, ["alpha": 101, "beta": 202])
+ XCTAssertEqual(second, first)
+ }
+
+ func testGrokNativeHookInstallIsNestedMatcherFreeAndIdempotent() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("grok-hooks-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ let cli = grokCLI(root: root)
+ XCTAssertTrue(ConfigInstaller.installExternalHooks(cli: cli, fm: .default))
+ XCTAssertTrue(ConfigInstaller.installExternalHooks(cli: cli, fm: .default))
+
+ let data = try Data(contentsOf: root.appendingPathComponent("hooks/codeisland.json"))
+ let rootJSON = try XCTUnwrap(JSONSerialization.jsonObject(with: data) as? [String: Any])
+ let hooks = try XCTUnwrap(rootJSON["hooks"] as? [String: Any])
+
+ for event in cli.events.map(\.0) {
+ let entries = try XCTUnwrap(hooks[event] as? [[String: Any]])
+ XCTAssertEqual(entries.count, 1)
+ XCTAssertNil(entries[0]["matcher"], "Grok rejects matcher on lifecycle hooks")
+ let commands = try XCTUnwrap(entries[0]["hooks"] as? [[String: Any]])
+ XCTAssertEqual(commands.count, 1)
+ XCTAssertTrue((commands[0]["command"] as? String)?.hasSuffix("--source grok") == true)
+ }
+ }
+
+ func testGrokHookUninstallPreservesUserEntries() throws {
+ let root = FileManager.default.temporaryDirectory
+ .appendingPathComponent("grok-hooks-uninstall-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(at: root) }
+
+ let cli = grokCLI(root: root)
+ XCTAssertTrue(ConfigInstaller.installExternalHooks(cli: cli, fm: .default))
+ let file = root.appendingPathComponent("hooks/codeisland.json")
+ var json = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any])
+ var hooks = try XCTUnwrap(json["hooks"] as? [String: Any])
+ hooks["Stop"] = (hooks["Stop"] as? [[String: Any]] ?? []) + [
+ ["hooks": [["type": "command", "command": "/usr/bin/true"]]]
+ ]
+ json["hooks"] = hooks
+ try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]).write(to: file)
+
+ ConfigInstaller.uninstallHooks(cli: cli, fm: .default)
+
+ let cleaned = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: file)) as? [String: Any])
+ let cleanedHooks = try XCTUnwrap(cleaned["hooks"] as? [String: Any])
+ let stopEntries = try XCTUnwrap(cleanedHooks["Stop"] as? [[String: Any]])
+ XCTAssertEqual(stopEntries.count, 1)
+ let userCommands = try XCTUnwrap(stopEntries[0]["hooks"] as? [[String: Any]])
+ XCTAssertEqual(userCommands[0]["command"] as? String, "/usr/bin/true")
+ }
+
+ private func grokCLI(root: URL) -> CLIConfig {
+ CLIConfig(
+ name: "Grok CLI",
+ source: "grok",
+ configPath: "hooks/codeisland.json",
+ configKey: "hooks",
+ format: .nested,
+ events: GrokHookForwardingPolicy.managedHookEvents.map { ($0, 5, false) },
+ rootOverride: { root.path }
+ )
+ }
+}
diff --git a/Tests/CodeIslandTests/MascotRenderHarness.swift b/Tests/CodeIslandTests/MascotRenderHarness.swift
index 2eaaec5e..8c1ff19c 100644
--- a/Tests/CodeIslandTests/MascotRenderHarness.swift
+++ b/Tests/CodeIslandTests/MascotRenderHarness.swift
@@ -129,6 +129,7 @@ private struct MascotContactSheet: View {
static func routedMascot(source: String, status: MascotAgentStatus, size: CGFloat) -> some View {
switch source {
case "codex": DexView(status: status, size: size)
+ case "grok": GrokView(status: status, size: size)
case "gemini": GeminiView(status: status, size: size)
case "cursor": CursorView(status: status, size: size)
case "trae": TraeView(status: status, size: size)