native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add Kiro CLI as third coding agent with hooks and CLI skill (#245)

* Add Kiro CLI as third coding agent with hooks and CLI skill

- KiroHookSettings: progress (userPromptSubmit/stop) and notification (stop) payloads in Kiro's flat format
- KiroHookSettingsFileInstaller: thin wrapper for Kiro's flat hook JSON format
- KiroSettingsInstaller: targets ~/.kiro/agents/kiro_default.json, creates default agent config when absent
- KiroSettingsClient: TCA dependency for install/uninstall/check
- AgentHookSlot: add .kiroProgress/.kiroNotifications
- SkillAgent: add .kiro
- SettingsFeature: wire Kiro hook checks, install/uninstall, skill install
- CLISkillContent: add Kiro skill (SKILL.md with frontmatter)
- AgentHookPayload: decode assistant_response for Kiro stop events
- DeveloperSettingsView: add Kiro section with progress, notifications, CLI skill rows
- kiro-mark asset from kiro.dev/icon.svg

* Address PR #245 review comments

- Move KiroSettingsClient to SupacodeSettingsShared/Clients/CodingAgents/ with public access
- Collapse Kiro section in DeveloperSettingsView (isExpanded: false by default)
- Update KiroSettingsInstaller comment with Kiro version (1.x, 2026-04)
- Remove KiroHookPayloadSupport; reuse AgentHookPayloadSupport.extractHookGroups
- Add tests: KiroSettingsInstallerTests, KiroHookSettingsFileInstallerTests
- Extend SettingsFeatureAgentHookTests with kiro hook install/uninstall cases
- Update taskChecksAllFourHookSlotsOnStartup → All Six (includes kiro slots)
- Update receiveStartupHookChecks to assert kiro{Progress,Notifications,Skill}State

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Address PR feedback: assistant_response tests, full config assertions, fix skill:// URIs

- Add decode test for Kiro's assistant_response fallback field
- Add precedence test (message > lastAssistantMessage > assistantResponse)
- Assert full JSON config contents in KiroSettingsInstallerTests (name,
tools, useLegacyMcpJson, resources, hooks) instead of just fileExists
- Fix firstWrite/secondWrite comparison in does-not-overwrite test
- Fix skill:// URIs to use ~/ prefix for home-directory resolution
(skill://.kiro/... is project-relative, skill://~/.kiro/... is global)

* Add nil-body warning log + uninstall-only-matching test

- Log warning when all notification body fields are nil so field
renames surface in make log-stream instead of silently delivering
empty notifications
- Add comment explaining the body fallback chain across agents
- Add uninstallRemovesOnlyMatchingCommands test for Kiro's flat hook
format, matching the existing Claude/Codex grouped-format coverage

* Harden Kiro version gate, hook file installer, and body decoder

- KiroSettingsInstaller: gate `ensureDefaultAgentConfig` on a version probe
(`kiro --version` via login shell) so future Kiro releases cannot be silently
overridden by our hardcoded defaults; 5s watchdog + `process.terminate()` on
hang; concurrent stdout/stderr drain to avoid pipe-buffer deadlock; parse
stdout before stderr; log non-UTF8 output; differentiate 127 vs. other
non-zero exits; `isSupportedVersion` compares the first dot-delimited
component so `10.x` is rejected.
- KiroHookSettingsFileInstaller: throw `invalidHooksObject` when existing
`"hooks"` is non-nil but not a JSON object (previously silently overwrote
user data); drop WHAT-narration comment.
- KiroHookSettings: extract `defaultTimeoutMs`; `KiroHookEntry` init guards
empty command / non-positive timeout via `assertionFailure` + `max(1, _)`.
- AgentHookSocketServer: collapse `message` / `last_assistant_message` /
`assistant_response` into a single `body` via custom `init(from:)`; each
field decoded through a tolerant helper that logs on type mismatch; skip
empty strings so an empty Claude `message` still yields to Codex/Kiro
fallbacks.
- CLISkillContent: document `-c` script UUID flag + `worktree run/stop/script
list` in `kiroSkillMd`.
- Tests: error paths (malformed JSON, array root, non-object hooks,
non-array event, legacy pruning), version-gate matrix (missing binary,
command-throw, unsupported version, unparseable output, 10.x rejection,
stderr-banner precedence, file-exists short-circuit), and body-decoder
edge cases (null, empty string, type mismatch, all-precedence chains).

---------

Co-authored-by: Benjamin <1159333+benjaminburzan@users.noreply.github.com>
Co-authored-by: Stefano Bertagno <stefano@bertagno.com>

authored by

Benjamin
Claude Sonnet 4.6
Benjamin
Stefano Bertagno
and committed by
GitHub
943f3fab 4d19b068

+1647 -20
+24 -3
SupacodeSettingsFeature/Reducer/SettingsFeature.swift
··· 41 41 public var claudeNotificationsState = AgentHooksInstallState.checking 42 42 public var codexProgressState = AgentHooksInstallState.checking 43 43 public var codexNotificationsState = AgentHooksInstallState.checking 44 + public var kiroProgressState = AgentHooksInstallState.checking 45 + public var kiroNotificationsState = AgentHooksInstallState.checking 46 + public var kiroSkillState = AgentHooksInstallState.checking 44 47 /// `nil` when the settings window is closed; non-nil selects the visible section. 45 48 public var selection: SettingsSection? 46 49 public var repositorySummaries: [SettingsRepositorySummary] = [] ··· 161 164 @Dependency(CLISkillClient.self) private var cliSkillClient 162 165 @Dependency(ClaudeSettingsClient.self) private var claudeSettingsClient 163 166 @Dependency(CodexSettingsClient.self) private var codexSettingsClient 167 + @Dependency(KiroSettingsClient.self) private var kiroSettingsClient 164 168 @Dependency(ArchivedWorktreeDatesClient.self) private var archivedWorktreeDatesClient 165 169 @Dependency(SystemNotificationClient.self) private var systemNotificationClient 166 170 @Dependency(\.date.now) private var now ··· 183 187 .run { [cliSkillClient] send in 184 188 async let claude = cliSkillClient.checkInstalled(.claude) 185 189 async let codex = cliSkillClient.checkInstalled(.codex) 190 + async let kiro = cliSkillClient.checkInstalled(.kiro) 186 191 await send(.cliSkillChecked(agent: .claude, installed: await claude)) 187 192 await send(.cliSkillChecked(agent: .codex, installed: await codex)) 193 + await send(.cliSkillChecked(agent: .kiro, installed: await kiro)) 188 194 }, 189 - .run { [claudeSettingsClient, codexSettingsClient] send in 195 + .run { [claudeSettingsClient, codexSettingsClient, kiroSettingsClient] send in 190 196 async let claudeProgressInstalled = claudeSettingsClient.checkInstalled(true) 191 197 async let claudeNotificationsInstalled = claudeSettingsClient.checkInstalled(false) 192 198 async let codexProgressInstalled = codexSettingsClient.checkInstalled(true) 193 199 async let codexNotificationsInstalled = codexSettingsClient.checkInstalled(false) 200 + async let kiroProgressInstalled = kiroSettingsClient.checkInstalled(true) 201 + async let kiroNotificationsInstalled = kiroSettingsClient.checkInstalled(false) 194 202 195 203 await send(.agentHookChecked(.claudeProgress, installed: await claudeProgressInstalled)) 196 204 await send( ··· 198 206 await send(.agentHookChecked(.codexProgress, installed: await codexProgressInstalled)) 199 207 await send( 200 208 .agentHookChecked(.codexNotifications, installed: await codexNotificationsInstalled)) 209 + await send(.agentHookChecked(.kiroProgress, installed: await kiroProgressInstalled)) 210 + await send( 211 + .agentHookChecked(.kiroNotifications, installed: await kiroNotificationsInstalled)) 201 212 } 202 213 ) 203 214 ) ··· 372 383 case .agentHookInstallTapped(let slot): 373 384 guard !state[hookSlot: slot].isLoading else { return .none } 374 385 state[hookSlot: slot] = .installing 375 - return .run { [claudeSettingsClient, codexSettingsClient] send in 386 + return .run { [claudeSettingsClient, codexSettingsClient, kiroSettingsClient] send in 376 387 do { 377 388 switch slot { 378 389 case .claudeProgress: try await claudeSettingsClient.installProgress() 379 390 case .claudeNotifications: try await claudeSettingsClient.installNotifications() 380 391 case .codexProgress: try await codexSettingsClient.installProgress() 381 392 case .codexNotifications: try await codexSettingsClient.installNotifications() 393 + case .kiroProgress: try await kiroSettingsClient.installProgress() 394 + case .kiroNotifications: try await kiroSettingsClient.installNotifications() 382 395 } 383 396 await send(.agentHookActionCompleted(slot, .success(true))) 384 397 } catch { ··· 389 402 case .agentHookUninstallTapped(let slot): 390 403 guard !state[hookSlot: slot].isLoading else { return .none } 391 404 state[hookSlot: slot] = .uninstalling 392 - return .run { [claudeSettingsClient, codexSettingsClient] send in 405 + return .run { [claudeSettingsClient, codexSettingsClient, kiroSettingsClient] send in 393 406 do { 394 407 switch slot { 395 408 case .claudeProgress: try await claudeSettingsClient.uninstallProgress() 396 409 case .claudeNotifications: try await claudeSettingsClient.uninstallNotifications() 397 410 case .codexProgress: try await codexSettingsClient.uninstallProgress() 398 411 case .codexNotifications: try await codexSettingsClient.uninstallNotifications() 412 + case .kiroProgress: try await kiroSettingsClient.uninstallProgress() 413 + case .kiroNotifications: try await kiroSettingsClient.uninstallNotifications() 399 414 } 400 415 await send(.agentHookActionCompleted(slot, .success(false))) 401 416 } catch { ··· 593 608 switch agent { 594 609 case .claude: claudeSkillState 595 610 case .codex: codexSkillState 611 + case .kiro: kiroSkillState 596 612 } 597 613 } 598 614 set { 599 615 switch agent { 600 616 case .claude: claudeSkillState = newValue 601 617 case .codex: codexSkillState = newValue 618 + case .kiro: kiroSkillState = newValue 602 619 } 603 620 } 604 621 } ··· 610 627 case .claudeNotifications: claudeNotificationsState 611 628 case .codexProgress: codexProgressState 612 629 case .codexNotifications: codexNotificationsState 630 + case .kiroProgress: kiroProgressState 631 + case .kiroNotifications: kiroNotificationsState 613 632 } 614 633 } 615 634 set { ··· 618 637 case .claudeNotifications: claudeNotificationsState = newValue 619 638 case .codexProgress: codexProgressState = newValue 620 639 case .codexNotifications: codexNotificationsState = newValue 640 + case .kiroProgress: kiroProgressState = newValue 641 + case .kiroNotifications: kiroNotificationsState = newValue 621 642 } 622 643 } 623 644 }
+57
SupacodeSettingsShared/BusinessLogic/CLISkillContent.swift
··· 251 251 Flags: `-w` (worktree), `-t` (tab), `-s` (surface), `-r` (repo), `-c` (script UUID for `worktree run`/`stop`), `-i` (input), `-d` (direction), `-n` (new ID). 252 252 Env var defaults only target your own shell session. Pass explicit IDs for created resources. 253 253 """ 254 + 255 + // MARK: - Kiro. 256 + 257 + // Kiro uses SKILL.md with YAML frontmatter (same as Codex). 258 + static let kiroSkillMd = """ 259 + --- 260 + name: \(skillName) 261 + description: \(description) 262 + --- 263 + 264 + # Supacode CLI 265 + 266 + Control Supacode from the terminal. The `supacode` command is available in all Supacode terminal sessions. 267 + 268 + ## CRITICAL: ID Tracking 269 + 270 + **NEVER call `supacode tab new` or `supacode surface split` without capturing 271 + the output.** They print the new UUID to stdout. Without it you cannot target 272 + the resource afterward. 273 + 274 + **NEVER omit `-t`/`-s` when targeting a created resource.** The env vars point 275 + to your own shell, not to anything you created. 276 + 277 + For new tabs, surface ID = tab ID. 278 + 279 + ### Correct: 280 + 281 + ```sh 282 + TAB_ID=$(supacode tab new -i "npm start") 283 + SPLIT_ID=$(supacode surface split -t "$TAB_ID" -s "$TAB_ID" -d v -i "npm test") 284 + supacode surface close -t "$TAB_ID" -s "$SPLIT_ID" 285 + supacode tab close -t "$TAB_ID" 286 + ``` 287 + 288 + ### WRONG: 289 + 290 + ```sh 291 + supacode tab new -i "npm start" # BAD: not captured 292 + supacode surface split -d v -i "test" # BAD: missing -t/-s, targets your shell 293 + ``` 294 + 295 + ## Commands 296 + 297 + - `supacode worktree [list [-f]|focus|run [-c]|stop [-c]|script list|archive|unarchive|delete|pin|unpin] [-w <id>]` 298 + - `supacode tab [list [-w] [-f]|focus|new|close] [-w <id>] [-t <id>] [-i <cmd>] [-n <uuid>]` 299 + - `supacode surface [list [-w] [-t] [-f]|focus|split|close] [-w <id>] [-t <id>] [-s <id>] [-i <cmd>] [-d h|v] [-n <uuid>]` 300 + - `supacode repo [list | open <path> | worktree-new [-r <id>] [--branch] [--base] [--fetch]]` 301 + - `supacode settings [<section>]` 302 + - `supacode socket` 303 + 304 + `list` outputs one ID per line (percent-encoded for worktrees/repos, UUIDs for tabs/surfaces). 305 + `worktree script list` outputs tab-separated `<uuid>\\t<kind>\\t<displayName>` rows; running scripts are ANSI-underlined. 306 + Use these IDs directly as `-w`, `-t`, `-s`, `-r`, `-c` flag values. 307 + 308 + Flags: `-w` (worktree), `-t` (tab), `-s` (surface), `-r` (repo), `-c` (script UUID for `worktree run`/`stop`), `-i` (input), `-d` (direction), `-n` (new ID). 309 + Env var defaults only target your own shell session. Pass explicit IDs for created resources. 310 + """ 254 311 }
+1
SupacodeSettingsShared/BusinessLogic/CLISkillInstaller.swift
··· 47 47 switch agent { 48 48 case .claude: CLISkillContent.claudeSkill 49 49 case .codex: CLISkillContent.codexSkillMd 50 + case .kiro: CLISkillContent.kiroSkillMd 50 51 } 51 52 } 52 53 }
+74
SupacodeSettingsShared/BusinessLogic/KiroHookSettings.swift
··· 1 + import Foundation 2 + 3 + nonisolated enum KiroHookSettings { 4 + fileprivate static let busyOn = AgentHookSettingsCommand.busyCommand(active: true) 5 + fileprivate static let busyOff = AgentHookSettingsCommand.busyCommand(active: false) 6 + fileprivate static let notify = AgentHookSettingsCommand.notificationCommand(agent: "kiro") 7 + fileprivate static let defaultTimeoutMs = 10_000 8 + 9 + static func progressHookEntriesByEvent() throws -> [String: [JSONValue]] { 10 + try AgentHookPayloadSupport.extractHookGroups( 11 + from: KiroProgressPayload(), 12 + invalidConfiguration: KiroHookSettingsError.invalidConfiguration 13 + ) 14 + } 15 + 16 + static func notificationHookEntriesByEvent() throws -> [String: [JSONValue]] { 17 + try AgentHookPayloadSupport.extractHookGroups( 18 + from: KiroNotificationPayload(), 19 + invalidConfiguration: KiroHookSettingsError.invalidConfiguration 20 + ) 21 + } 22 + } 23 + 24 + nonisolated enum KiroHookSettingsError: Error { 25 + case invalidConfiguration 26 + } 27 + 28 + // MARK: - Kiro hook entry (flat format: command + timeout_ms, no type/group wrapper). 29 + 30 + nonisolated struct KiroHookEntry: Encodable { 31 + let command: String 32 + let timeoutMs: Int 33 + 34 + init(command: String, timeoutMs: Int) { 35 + if command.isEmpty { 36 + assertionFailure("Kiro hook command must not be empty.") 37 + } 38 + if timeoutMs <= 0 { 39 + assertionFailure("Kiro hook timeout_ms must be positive, got \(timeoutMs).") 40 + } 41 + self.command = command 42 + self.timeoutMs = max(1, timeoutMs) 43 + } 44 + 45 + enum CodingKeys: String, CodingKey { 46 + case command 47 + case timeoutMs = "timeout_ms" 48 + } 49 + } 50 + 51 + // MARK: - Progress hooks. 52 + 53 + // Kiro uses camelCase event names ("userPromptSubmit", "stop") unlike 54 + // Claude/Codex which use PascalCase ("UserPromptSubmit", "Stop"). 55 + private nonisolated struct KiroProgressPayload: Encodable { 56 + let hooks: [String: [KiroHookEntry]] = [ 57 + "userPromptSubmit": [ 58 + KiroHookEntry(command: KiroHookSettings.busyOn, timeoutMs: KiroHookSettings.defaultTimeoutMs) 59 + ], 60 + "stop": [ 61 + KiroHookEntry(command: KiroHookSettings.busyOff, timeoutMs: KiroHookSettings.defaultTimeoutMs) 62 + ], 63 + ] 64 + } 65 + 66 + // MARK: - Notification hooks. 67 + 68 + private nonisolated struct KiroNotificationPayload: Encodable { 69 + let hooks: [String: [KiroHookEntry]] = [ 70 + "stop": [ 71 + KiroHookEntry(command: KiroHookSettings.notify, timeoutMs: KiroHookSettings.defaultTimeoutMs) 72 + ] 73 + ] 74 + }
+194
SupacodeSettingsShared/BusinessLogic/KiroHookSettingsFileInstaller.swift
··· 1 + import Foundation 2 + 3 + private nonisolated let kiroInstallerLogger = SupaLogger("Settings") 4 + 5 + /// File installer for Kiro's flat hook format (`hooks → event → [{ command, timeout_ms }]`). 6 + /// Unlike `AgentHookSettingsFileInstaller` which handles Claude/Codex grouped format. 7 + nonisolated struct KiroHookSettingsFileInstaller { 8 + struct Errors { 9 + let invalidEventHooks: @Sendable (String) -> Error 10 + let invalidHooksObject: @Sendable () -> Error 11 + let invalidJSON: @Sendable (String) -> Error 12 + let invalidRootObject: @Sendable () -> Error 13 + } 14 + 15 + private enum LoadError: Error { 16 + case invalidRootObject 17 + } 18 + 19 + let fileManager: FileManager 20 + let errors: Errors 21 + let logWarning: @Sendable (String) -> Void 22 + 23 + init( 24 + fileManager: FileManager, 25 + errors: Errors, 26 + logWarning: @escaping @Sendable (String) -> Void = { kiroInstallerLogger.warning($0) } 27 + ) { 28 + self.fileManager = fileManager 29 + self.errors = errors 30 + self.logWarning = logWarning 31 + } 32 + 33 + // MARK: - Check. 34 + 35 + func containsMatchingHooks( 36 + settingsURL: URL, 37 + hookEntriesByEvent: [String: [JSONValue]] 38 + ) -> Bool { 39 + do { 40 + let settingsObject = try loadSettingsObject(at: settingsURL) 41 + guard let hooksObject = settingsObject["hooks"]?.objectValue else { return false } 42 + let expectedCommands = Self.commands(from: hookEntriesByEvent) 43 + guard !expectedCommands.isEmpty else { return false } 44 + for (_, value) in hooksObject { 45 + guard let entries = value.arrayValue else { continue } 46 + for entry in entries { 47 + guard let entryObject = entry.objectValue, 48 + let command = entryObject["command"]?.stringValue 49 + else { continue } 50 + if expectedCommands.contains(command) { return true } 51 + } 52 + } 53 + return false 54 + } catch { 55 + if !Self.isFileNotFound(error) { 56 + logWarning("Failed to inspect Kiro hook settings at \(settingsURL.path): \(error)") 57 + } 58 + return false 59 + } 60 + } 61 + 62 + // MARK: - Install. 63 + 64 + func install( 65 + settingsURL: URL, 66 + hookEntriesByEvent: @autoclosure () throws -> [String: [JSONValue]] 67 + ) throws { 68 + let settingsObject = try loadSettingsObject(at: settingsURL) 69 + let hookEntries = try hookEntriesByEvent() 70 + let commandsToPrune = Self.commands(from: hookEntries) 71 + var mergedObject = settingsObject 72 + var hooksObject = try existingHooksObject(in: mergedObject) 73 + 74 + // Remove existing managed commands before re-adding. 75 + for event in hooksObject.keys { 76 + let existing = try existingEntries(for: event, hooksObject: hooksObject) 77 + let filtered = existing.filter { !Self.isManaged($0, commands: commandsToPrune) } 78 + if filtered.isEmpty { 79 + hooksObject.removeValue(forKey: event) 80 + } else { 81 + hooksObject[event] = .array(filtered) 82 + } 83 + } 84 + 85 + for (event, newEntries) in hookEntries { 86 + let existing = hooksObject[event]?.arrayValue ?? [] 87 + hooksObject[event] = .array(existing + newEntries) 88 + } 89 + 90 + mergedObject["hooks"] = .object(hooksObject) 91 + try writeSettings(mergedObject, to: settingsURL) 92 + } 93 + 94 + // MARK: - Uninstall. 95 + 96 + func uninstall( 97 + settingsURL: URL, 98 + hookEntriesByEvent: @autoclosure () throws -> [String: [JSONValue]] 99 + ) throws { 100 + let settingsObject = try loadSettingsObject(at: settingsURL) 101 + let commandsToPrune = Self.commands(from: try hookEntriesByEvent()) 102 + var mergedObject = settingsObject 103 + var hooksObject = try existingHooksObject(in: mergedObject) 104 + 105 + for event in hooksObject.keys { 106 + let existing = try existingEntries(for: event, hooksObject: hooksObject) 107 + let filtered = existing.filter { !Self.isManaged($0, commands: commandsToPrune) } 108 + if filtered.isEmpty { 109 + hooksObject.removeValue(forKey: event) 110 + } else { 111 + hooksObject[event] = .array(filtered) 112 + } 113 + } 114 + 115 + mergedObject["hooks"] = .object(hooksObject) 116 + try writeSettings(mergedObject, to: settingsURL) 117 + } 118 + 119 + // MARK: - Helpers. 120 + 121 + private static func commands(from hookEntriesByEvent: [String: [JSONValue]]) -> Set<String> { 122 + var commands = Set<String>() 123 + for (_, entries) in hookEntriesByEvent { 124 + for entry in entries { 125 + guard let entryObject = entry.objectValue, 126 + let command = entryObject["command"]?.stringValue 127 + else { continue } 128 + commands.insert(command) 129 + } 130 + } 131 + return commands 132 + } 133 + 134 + private static func isManaged(_ entry: JSONValue, commands: Set<String>) -> Bool { 135 + guard let entryObject = entry.objectValue, 136 + let command = entryObject["command"]?.stringValue 137 + else { return false } 138 + if commands.contains(command) { return true } 139 + return AgentHookCommandOwnership.isLegacyCommand(command) 140 + } 141 + 142 + private func existingHooksObject( 143 + in settingsObject: [String: JSONValue] 144 + ) throws -> [String: JSONValue] { 145 + guard let hooksValue = settingsObject["hooks"] else { return [:] } 146 + guard let hooksObject = hooksValue.objectValue else { 147 + throw errors.invalidHooksObject() 148 + } 149 + return hooksObject 150 + } 151 + 152 + private func existingEntries( 153 + for event: String, 154 + hooksObject: [String: JSONValue] 155 + ) throws -> [JSONValue] { 156 + guard let existingValue = hooksObject[event] else { return [] } 157 + guard let entries = existingValue.arrayValue else { 158 + throw errors.invalidEventHooks(event) 159 + } 160 + return entries 161 + } 162 + 163 + private func loadSettingsObject(at url: URL) throws -> [String: JSONValue] { 164 + guard fileManager.fileExists(atPath: url.path) else { return [:] } 165 + let data = try Data(contentsOf: url) 166 + do { 167 + let jsonValue = try JSONDecoder().decode(JSONValue.self, from: data) 168 + guard let object = jsonValue.objectValue else { 169 + throw LoadError.invalidRootObject 170 + } 171 + return object 172 + } catch LoadError.invalidRootObject { 173 + throw errors.invalidRootObject() 174 + } catch { 175 + throw errors.invalidJSON(error.localizedDescription) 176 + } 177 + } 178 + 179 + private func writeSettings(_ object: [String: JSONValue], to url: URL) throws { 180 + try fileManager.createDirectory( 181 + at: url.deletingLastPathComponent(), 182 + withIntermediateDirectories: true 183 + ) 184 + let encoder = JSONEncoder() 185 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 186 + let data = try encoder.encode(JSONValue.object(object)) 187 + try data.write(to: url, options: .atomic) 188 + } 189 + 190 + private static func isFileNotFound(_ error: Error) -> Bool { 191 + let nsError = error as NSError 192 + return nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError 193 + } 194 + }
+311
SupacodeSettingsShared/BusinessLogic/KiroSettingsInstaller.swift
··· 1 + import Foundation 2 + 3 + private nonisolated let kiroVersionLogger = SupaLogger("Settings") 4 + 5 + nonisolated struct KiroSettingsInstaller { 6 + struct CommandResult: Equatable, Sendable { 7 + let status: Int32 8 + let standardOutput: String 9 + let standardError: String 10 + } 11 + 12 + /// Version prefix we have validated Kiro's built-in `kiro_default` agent against. 13 + /// When the installed Kiro's first version component changes, the hardcoded 14 + /// defaults in `ensureDefaultAgentConfig` may no longer match upstream and would 15 + /// silently override a legitimately different config — gate on this prefix to 16 + /// fail loudly instead. 17 + static let supportedVersionPrefix = "1." 18 + 19 + /// Maximum time to wait on `kiro --version`. A misconfigured login shell (e.g. 20 + /// an rc file blocking on stdin) can hang the child indefinitely; when that 21 + /// happens we terminate the process so `waitUntilExit` cannot pin the 22 + /// cooperative pool thread. 23 + private static let versionCommandTimeoutSeconds: UInt64 = 5 24 + 25 + let homeDirectoryURL: URL 26 + let fileManager: FileManager 27 + let runKiroVersionCommand: @Sendable () async throws -> CommandResult 28 + 29 + init( 30 + homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, 31 + fileManager: FileManager = .default 32 + ) { 33 + self.init( 34 + homeDirectoryURL: homeDirectoryURL, 35 + fileManager: fileManager, 36 + runKiroVersionCommand: Self.runKiroVersionCommand 37 + ) 38 + } 39 + 40 + init( 41 + homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, 42 + fileManager: FileManager = .default, 43 + runKiroVersionCommand: @escaping @Sendable () async throws -> CommandResult 44 + ) { 45 + self.homeDirectoryURL = homeDirectoryURL 46 + self.fileManager = fileManager 47 + self.runKiroVersionCommand = runKiroVersionCommand 48 + } 49 + 50 + func isInstalled(progress: Bool) -> Bool { 51 + let entries: [String: [JSONValue]] 52 + do { 53 + entries = 54 + try progress 55 + ? KiroHookSettings.progressHookEntriesByEvent() 56 + : KiroHookSettings.notificationHookEntriesByEvent() 57 + } catch { 58 + Self.reportInvalidHookConfiguration(error, progress: progress) 59 + return false 60 + } 61 + return fileInstaller.containsMatchingHooks( 62 + settingsURL: settingsURL, 63 + hookEntriesByEvent: entries 64 + ) 65 + } 66 + 67 + func installProgressHooks() async throws { 68 + try await ensureDefaultAgentConfig() 69 + try fileInstaller.install( 70 + settingsURL: settingsURL, 71 + hookEntriesByEvent: try KiroHookSettings.progressHookEntriesByEvent() 72 + ) 73 + } 74 + 75 + func installNotificationHooks() async throws { 76 + try await ensureDefaultAgentConfig() 77 + try fileInstaller.install( 78 + settingsURL: settingsURL, 79 + hookEntriesByEvent: try KiroHookSettings.notificationHookEntriesByEvent() 80 + ) 81 + } 82 + 83 + func uninstallProgressHooks() throws { 84 + guard fileManager.fileExists(atPath: settingsURL.path) else { return } 85 + try fileInstaller.uninstall( 86 + settingsURL: settingsURL, 87 + hookEntriesByEvent: try KiroHookSettings.progressHookEntriesByEvent() 88 + ) 89 + } 90 + 91 + func uninstallNotificationHooks() throws { 92 + guard fileManager.fileExists(atPath: settingsURL.path) else { return } 93 + try fileInstaller.uninstall( 94 + settingsURL: settingsURL, 95 + hookEntriesByEvent: try KiroHookSettings.notificationHookEntriesByEvent() 96 + ) 97 + } 98 + 99 + // MARK: - Default agent config. 100 + 101 + /// Creates `kiro_default.json` with the known built-in defaults when the file does not exist. 102 + /// Creating this file overrides Kiro's built-in agent entirely, so we must include the full 103 + /// config (not just hooks) — and we gate on `supportedVersionPrefix` so a future Kiro release 104 + /// that ships different defaults fails loudly instead of being silently stomped. 105 + private func ensureDefaultAgentConfig() async throws { 106 + guard !fileManager.fileExists(atPath: settingsURL.path) else { return } 107 + try await validateSupportedKiroVersion() 108 + try fileManager.createDirectory( 109 + at: settingsURL.deletingLastPathComponent(), 110 + withIntermediateDirectories: true 111 + ) 112 + let defaultConfig: [String: JSONValue] = [ 113 + "name": .string("kiro_default"), 114 + "tools": .array([.string("*")]), 115 + "resources": .array([ 116 + .string("file://AGENTS.md"), 117 + .string("file://README.md"), 118 + .string("skill://~/.kiro/skills/**/SKILL.md"), 119 + .string("skill://~/.kiro/steering/**/*.md"), 120 + ]), 121 + "useLegacyMcpJson": .bool(true), 122 + "hooks": .object([:]), 123 + ] 124 + let encoder = JSONEncoder() 125 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 126 + let data = try encoder.encode(JSONValue.object(defaultConfig)) 127 + try data.write(to: settingsURL, options: .atomic) 128 + } 129 + 130 + private func validateSupportedKiroVersion() async throws { 131 + let result: CommandResult 132 + do { 133 + result = try await runKiroVersionCommand() 134 + } catch { 135 + kiroVersionLogger.warning("Kiro version check failed to execute: \(error)") 136 + throw KiroSettingsInstallerError.kiroUnavailable 137 + } 138 + if result.status == 127 { 139 + throw KiroSettingsInstallerError.kiroUnavailable 140 + } 141 + if result.status != 0 { 142 + kiroVersionLogger.warning( 143 + "Kiro version check exited with status \(result.status); stderr: \(result.standardError)") 144 + throw KiroSettingsInstallerError.unsupportedKiroVersion("exit status \(result.status)") 145 + } 146 + // Parse stdout first so a verbose login shell (rc-file banners on stderr) 147 + // cannot hijack the version match. 148 + let detected = 149 + Self.extractVersion(from: result.standardOutput) 150 + ?? Self.extractVersion(from: result.standardError) 151 + guard let detected else { 152 + kiroVersionLogger.warning( 153 + "Kiro version output unparseable; stdout: \(result.standardOutput)") 154 + throw KiroSettingsInstallerError.unsupportedKiroVersion( 155 + result.standardOutput.trimmingCharacters(in: .whitespacesAndNewlines) 156 + ) 157 + } 158 + guard Self.isSupportedVersion(detected) else { 159 + throw KiroSettingsInstallerError.unsupportedKiroVersion(detected) 160 + } 161 + } 162 + 163 + /// Returns `true` when `detected`'s first dot-delimited component matches 164 + /// `supportedVersionPrefix` (after stripping its trailing dot). `"1."` matches 165 + /// `1.2.3` but not `10.0` — `10` is its own component, not "starts-with 1". 166 + static func isSupportedVersion(_ detected: String) -> Bool { 167 + let prefix = Self.supportedVersionPrefix.trimmingCharacters(in: CharacterSet(charactersIn: ".")) 168 + let components = detected.split(separator: ".", omittingEmptySubsequences: false) 169 + return components.first.map(String.init) == prefix 170 + } 171 + 172 + /// Pulls the first dotted-digit token out of a version string such as 173 + /// `kiro 1.2.3` or `Kiro CLI v1.0.0 (build abcd)`. 174 + static func extractVersion(from text: String) -> String? { 175 + var current = "" 176 + for character in text { 177 + if character.isNumber || character == "." { 178 + current.append(character) 179 + continue 180 + } 181 + if current.contains(".") { 182 + return current.trimmingCharacters(in: CharacterSet(charactersIn: ".")) 183 + } 184 + current = "" 185 + } 186 + guard current.contains(".") else { return nil } 187 + return current.trimmingCharacters(in: CharacterSet(charactersIn: ".")) 188 + } 189 + 190 + // MARK: - Paths. 191 + 192 + private var settingsURL: URL { 193 + Self.settingsURL(homeDirectoryURL: homeDirectoryURL) 194 + } 195 + 196 + static func settingsURL(homeDirectoryURL: URL) -> URL { 197 + homeDirectoryURL 198 + .appendingPathComponent(".kiro", isDirectory: true) 199 + .appendingPathComponent("agents", isDirectory: true) 200 + .appendingPathComponent("kiro_default.json", isDirectory: false) 201 + } 202 + 203 + static func runKiroVersionCommand() async throws -> CommandResult { 204 + let process = Process() 205 + process.executableURL = CodexSettingsInstaller.loginShellURL() 206 + process.arguments = ["-l", "-c", "kiro --version"] 207 + let outputPipe = Pipe() 208 + let errorPipe = Pipe() 209 + process.standardOutput = outputPipe 210 + process.standardError = errorPipe 211 + try process.run() 212 + 213 + let watchdog = Task { [process] in 214 + try? await Task.sleep(nanoseconds: versionCommandTimeoutSeconds * 1_000_000_000) 215 + if process.isRunning { 216 + kiroVersionLogger.warning( 217 + "kiro --version exceeded \(versionCommandTimeoutSeconds)s; terminating.") 218 + process.terminate() 219 + } 220 + } 221 + defer { watchdog.cancel() } 222 + 223 + // Drain both pipes concurrently; a verbose login shell (banners from 224 + // rc files under `-l`) can exceed the ~64KB pipe buffer and deadlock 225 + // the child on write if we wait for termination before reading. 226 + async let outputData = Self.readDataToEnd(from: outputPipe.fileHandleForReading) 227 + async let errorData = Self.readDataToEnd(from: errorPipe.fileHandleForReading) 228 + let standardOutputData = await outputData 229 + let standardErrorData = await errorData 230 + process.waitUntilExit() 231 + 232 + let standardOutput = Self.decodeUTF8(standardOutputData, descriptor: "stdout") 233 + let standardError = Self.decodeUTF8(standardErrorData, descriptor: "stderr") 234 + return .init( 235 + status: process.terminationStatus, 236 + standardOutput: standardOutput, 237 + standardError: standardError.trimmingCharacters(in: .whitespacesAndNewlines) 238 + ) 239 + } 240 + 241 + /// Reads a file handle to EOF on a detached Task so `async let` callers can 242 + /// drain stdout and stderr concurrently while the child is still writing. 243 + /// 244 + /// NOTE: if the parent Task is cancelled mid-await, the detached reader 245 + /// stays alive until the pipe closes (child exits). Acceptable for a 246 + /// one-shot version probe — do not copy this into a streaming path. 247 + private static func readDataToEnd(from handle: FileHandle) async -> Data { 248 + await withCheckedContinuation { continuation in 249 + Task.detached { 250 + continuation.resume(returning: handle.readDataToEndOfFile()) 251 + } 252 + } 253 + } 254 + 255 + private static func decodeUTF8(_ data: Data, descriptor: String) -> String { 256 + if let string = String(data: data, encoding: .utf8) { return string } 257 + if !data.isEmpty { 258 + kiroVersionLogger.warning( 259 + "Kiro version \(descriptor) was not valid UTF-8 (\(data.count) bytes); dropped.") 260 + } 261 + return "" 262 + } 263 + 264 + private static func reportInvalidHookConfiguration(_ error: Error, progress: Bool) { 265 + #if DEBUG 266 + assertionFailure( 267 + "Kiro \(progress ? "progress" : "notification") hook configuration is invalid: \(error)") 268 + #endif 269 + } 270 + 271 + private var fileInstaller: KiroHookSettingsFileInstaller { 272 + KiroHookSettingsFileInstaller( 273 + fileManager: fileManager, 274 + errors: .init( 275 + invalidEventHooks: { KiroSettingsInstallerError.invalidEventHooks($0) }, 276 + invalidHooksObject: { KiroSettingsInstallerError.invalidHooksObject }, 277 + invalidJSON: { KiroSettingsInstallerError.invalidJSON($0) }, 278 + invalidRootObject: { KiroSettingsInstallerError.invalidRootObject } 279 + ) 280 + ) 281 + } 282 + } 283 + 284 + nonisolated enum KiroSettingsInstallerError: Error, Equatable, LocalizedError { 285 + case invalidEventHooks(String) 286 + case invalidHooksObject 287 + case invalidJSON(String) 288 + case invalidRootObject 289 + case kiroUnavailable 290 + case unsupportedKiroVersion(String) 291 + 292 + var errorDescription: String? { 293 + switch self { 294 + case .invalidEventHooks(let event): 295 + "Kiro agent config uses an unsupported hooks shape for \(event)." 296 + case .invalidHooksObject: 297 + "Kiro agent config uses an unsupported hooks shape." 298 + case .invalidJSON(let detail): 299 + "Kiro agent config must be valid JSON before Supacode can install hooks (\(detail))." 300 + case .invalidRootObject: 301 + "Kiro agent config must be a JSON object before Supacode can install hooks." 302 + case .kiroUnavailable: 303 + "Kiro must be installed and available in your login shell before Supacode can install hooks." 304 + case .unsupportedKiroVersion(let detected): 305 + """ 306 + Supacode only knows Kiro \(KiroSettingsInstaller.supportedVersionPrefix)x defaults \ 307 + (detected \(detected.isEmpty ? "unknown" : detected)). Update Supacode before installing hooks. 308 + """ 309 + } 310 + } 311 + }
+58
SupacodeSettingsShared/Clients/CodingAgents/KiroSettingsClient.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + 4 + public nonisolated struct KiroSettingsClient: Sendable { 5 + public var checkInstalled: @Sendable (Bool) async -> Bool 6 + public var installProgress: @Sendable () async throws -> Void 7 + public var installNotifications: @Sendable () async throws -> Void 8 + public var uninstallProgress: @Sendable () async throws -> Void 9 + public var uninstallNotifications: @Sendable () async throws -> Void 10 + 11 + public init( 12 + checkInstalled: @escaping @Sendable (Bool) async -> Bool, 13 + installProgress: @escaping @Sendable () async throws -> Void, 14 + installNotifications: @escaping @Sendable () async throws -> Void, 15 + uninstallProgress: @escaping @Sendable () async throws -> Void, 16 + uninstallNotifications: @escaping @Sendable () async throws -> Void 17 + ) { 18 + self.checkInstalled = checkInstalled 19 + self.installProgress = installProgress 20 + self.installNotifications = installNotifications 21 + self.uninstallProgress = uninstallProgress 22 + self.uninstallNotifications = uninstallNotifications 23 + } 24 + } 25 + 26 + extension KiroSettingsClient: DependencyKey { 27 + public static let liveValue = Self( 28 + checkInstalled: { progress in 29 + KiroSettingsInstaller().isInstalled(progress: progress) 30 + }, 31 + installProgress: { 32 + try await KiroSettingsInstaller().installProgressHooks() 33 + }, 34 + installNotifications: { 35 + try await KiroSettingsInstaller().installNotificationHooks() 36 + }, 37 + uninstallProgress: { 38 + try KiroSettingsInstaller().uninstallProgressHooks() 39 + }, 40 + uninstallNotifications: { 41 + try KiroSettingsInstaller().uninstallNotificationHooks() 42 + } 43 + ) 44 + public static let testValue = Self( 45 + checkInstalled: { _ in false }, 46 + installProgress: {}, 47 + installNotifications: {}, 48 + uninstallProgress: {}, 49 + uninstallNotifications: {} 50 + ) 51 + } 52 + 53 + extension DependencyValues { 54 + public var kiroSettingsClient: KiroSettingsClient { 55 + get { self[KiroSettingsClient.self] } 56 + set { self[KiroSettingsClient.self] = newValue } 57 + } 58 + }
+2
SupacodeSettingsShared/Models/AgentHooksInstallState.swift
··· 35 35 case claudeNotifications 36 36 case codexProgress 37 37 case codexNotifications 38 + case kiroProgress 39 + case kiroNotifications 38 40 }
+3 -1
SupacodeSettingsShared/Models/SkillAgent.swift
··· 1 1 public nonisolated enum SkillAgent: Equatable, Sendable, CaseIterable { 2 2 case claude 3 3 case codex 4 + case kiro 4 5 5 - /// The dot-directory name under the user's home (e.g. `.claude`, `.codex`). 6 + /// The dot-directory name under the user's home (e.g. `.claude`, `.codex`, `.kiro`). 6 7 public var configDirectoryName: String { 7 8 switch self { 8 9 case .claude: ".claude" 9 10 case .codex: ".codex" 11 + case .kiro: ".kiro" 10 12 } 11 13 } 12 14 }
+16
supacode/Assets.xcassets/kiro-mark.imageset/Contents.json
··· 1 + { 2 + "images" : [ 3 + { 4 + "filename" : "kiro-mark.svg", 5 + "idiom" : "universal" 6 + } 7 + ], 8 + "info" : { 9 + "author" : "xcode", 10 + "version" : 1 11 + }, 12 + "properties" : { 13 + "preserves-vector-representation" : true, 14 + "template-rendering-intent" : "original" 15 + } 16 + }
+11
supacode/Assets.xcassets/kiro-mark.imageset/kiro-mark.svg
··· 1 + <svg width="1200" height="1200" viewBox="0 0 1200 1200" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 + <rect width="1200" height="1200" rx="260" fill="#9046FF"/> 3 + <mask id="mask0_1106_4856" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="272" y="202" width="655" height="796"> 4 + <path d="M926.578 202.793H272.637V997.857H926.578V202.793Z" fill="white"/> 5 + </mask> 6 + <g mask="url(#mask0_1106_4856)"> 7 + <path d="M398.554 818.914C316.315 1001.03 491.477 1046.74 620.672 940.156C658.687 1059.66 801.052 970.473 852.234 877.795C964.787 673.567 919.318 465.357 907.64 422.374C827.637 129.443 427.623 128.946 358.8 423.865C342.651 475.544 342.402 534.18 333.458 595.051C328.986 625.86 325.507 645.488 313.83 677.785C306.873 696.424 297.68 712.819 282.773 740.645C259.915 783.881 269.604 867.113 387.87 823.883L399.051 818.914H398.554Z" fill="white"/> 8 + <path d="M636.123 549.353C603.328 549.353 598.359 510.097 598.359 486.742C598.359 465.623 602.086 448.977 609.293 438.293C615.504 428.852 624.697 424.131 636.123 424.131C647.555 424.131 657.492 428.852 664.447 438.541C672.398 449.474 676.623 466.12 676.623 486.742C676.623 525.998 661.471 549.353 636.375 549.353H636.123Z" fill="black"/> 9 + <path d="M771.24 549.353C738.445 549.353 733.477 510.097 733.477 486.742C733.477 465.623 737.203 448.977 744.41 438.293C750.621 428.852 759.814 424.131 771.24 424.131C782.672 424.131 792.609 428.852 799.564 438.541C807.516 449.474 811.74 466.12 811.74 486.742C811.74 525.998 796.588 549.353 771.492 549.353H771.24Z" fill="black"/> 10 + </g> 11 + </svg>
+35
supacode/Features/Settings/Views/DeveloperSettingsView.swift
··· 5 5 6 6 struct DeveloperSettingsView: View { 7 7 let store: StoreOf<SettingsFeature> 8 + @State private var kiroExpanded = false 8 9 9 10 var body: some View { 10 11 Form { ··· 88 89 .labelStyle(.titleTrailingIcon) 89 90 } footer: { 90 91 Text("Applied to `~/.codex`.") 92 + } 93 + Section(isExpanded: $kiroExpanded) { 94 + AgentInstallRow( 95 + installAction: { store.send(.agentHookInstallTapped(.kiroProgress)) }, 96 + uninstallAction: { store.send(.agentHookUninstallTapped(.kiroProgress)) }, 97 + installState: store.kiroProgressState, 98 + title: "Progress Hook", 99 + subtitle: "Display agent activity in tab and sidebar." 100 + ) 101 + AgentInstallRow( 102 + installAction: { store.send(.agentHookInstallTapped(.kiroNotifications)) }, 103 + uninstallAction: { store.send(.agentHookUninstallTapped(.kiroNotifications)) }, 104 + installState: store.kiroNotificationsState, 105 + title: "Notifications Hook", 106 + subtitle: "Forward richer notifications to Supacode." 107 + ) 108 + AgentInstallRow( 109 + installAction: { store.send(.cliSkillInstallTapped(.kiro)) }, 110 + uninstallAction: { store.send(.cliSkillUninstallTapped(.kiro)) }, 111 + installState: store.kiroSkillState, 112 + title: "CLI Skill", 113 + subtitle: "Teach Kiro how to use the Supacode CLI." 114 + ) 115 + } header: { 116 + Label { 117 + Text("Kiro") 118 + } icon: { 119 + Image("kiro-mark") 120 + .resizable() 121 + .aspectRatio(contentMode: .fit) 122 + .frame(width: 18, height: 18) 123 + .accessibilityHidden(true) 124 + } 125 + .labelStyle(.titleTrailingIcon) 91 126 } 92 127 } 93 128 .formStyle(.grouped)
+57 -13
supacode/Infrastructure/AgentHookSocketServer.swift
··· 142 142 guard let base = buffer.baseAddress else { return } 143 143 var totalWritten = 0 144 144 while totalWritten < data.count { 145 - let written = write(fileDescriptor, base.advanced(by: totalWritten), data.count - totalWritten) 145 + let written = write( 146 + fileDescriptor, base.advanced(by: totalWritten), data.count - totalWritten) 146 147 if written < 0 { 147 148 guard errno == EINTR else { 148 149 socketLogger.warning("write() failed: \(String(cString: strerror(errno)))") ··· 207 208 208 209 nonisolated enum Message: Sendable { 209 210 case busy(worktreeID: String, tabID: UUID, surfaceID: UUID, active: Bool) 210 - case notification(worktreeID: String, tabID: UUID, surfaceID: UUID, notification: AgentHookNotification) 211 + case notification( 212 + worktreeID: String, tabID: UUID, surfaceID: UUID, notification: AgentHookNotification) 211 213 /// CLI command with the client FD kept open for writing a response. 212 214 case command(deeplinkURL: URL, clientFD: Int32) 213 215 /// CLI query with the client FD kept open for writing data back. ··· 219 221 let json: [String: Any] = ["ok": true, "data": data] 220 222 guard let encoded = try? JSONSerialization.data(withJSONObject: json) else { 221 223 socketLogger.warning("Failed to encode query response") 222 - writeAll(to: clientFD, data: Data("{\"ok\":false,\"error\":\"Internal encoding error.\"}".utf8)) 224 + writeAll( 225 + to: clientFD, data: Data("{\"ok\":false,\"error\":\"Internal encoding error.\"}".utf8)) 223 226 close(clientFD) 224 227 return 225 228 } ··· 228 231 } 229 232 230 233 /// Writes a JSON response to a command client and closes the FD. 231 - nonisolated static func sendCommandResponse(clientFD: Int32, ok succeeded: Bool, error: String? = nil) { 234 + nonisolated static func sendCommandResponse( 235 + clientFD: Int32, ok succeeded: Bool, error: String? = nil 236 + ) { 232 237 var json: [String: Any] = ["ok": succeeded] 233 238 if let error { json["error"] = error } 234 239 guard let data = try? JSONSerialization.data(withJSONObject: json) else { 235 240 socketLogger.warning("Failed to encode command response") 236 - writeAll(to: clientFD, data: Data("{\"ok\":false,\"error\":\"Internal encoding error.\"}".utf8)) 241 + writeAll( 242 + to: clientFD, data: Data("{\"ok\":false,\"error\":\"Internal encoding error.\"}".utf8)) 237 243 close(clientFD) 238 244 return 239 245 } ··· 255 261 256 262 // Set a read timeout so a misbehaving client cannot block the accept loop. 257 263 var timeout = timeval(tv_sec: 5, tv_usec: 0) 258 - guard setsockopt(clientFD, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size)) == 0 else { 264 + guard 265 + setsockopt(clientFD, SOL_SOCKET, SO_RCVTIMEO, &timeout, socklen_t(MemoryLayout<timeval>.size)) 266 + == 0 267 + else { 259 268 socketLogger.warning("setsockopt(SO_RCVTIMEO) failed: \(String(cString: strerror(errno)))") 260 269 close(clientFD) 261 270 return nil ··· 386 395 return nil 387 396 } 388 397 389 - let body = payload.message ?? payload.lastAssistantMessage 398 + if payload.body == nil { 399 + socketLogger.warning( 400 + "All body fields nil in \(agent) \(payload.hookEventName ?? "unknown") notification") 401 + } 390 402 return AgentHookNotification( 391 403 agent: agent, 392 404 event: payload.hookEventName ?? "unknown", 393 405 title: payload.title, 394 - body: body 406 + body: payload.body 395 407 ) 396 408 } 397 409 ··· 423 435 let body: String? 424 436 } 425 437 426 - /// Raw JSON payload from a coding agent hook event. 438 + /// Raw JSON payload from a coding agent hook event. The `body` is decoded from 439 + /// whichever agent-specific field is present: Claude uses `message`, Codex uses 440 + /// `last_assistant_message`, Kiro uses `assistant_response`. Precedence favors 441 + /// `message` so unknown agents that speak the Claude shape keep working. 427 442 private nonisolated struct AgentHookPayload: Decodable { 428 443 let hookEventName: String? 429 444 let title: String? 430 - let message: String? 431 - let lastAssistantMessage: String? 445 + let body: String? 432 446 433 - enum CodingKeys: String, CodingKey { 447 + private enum CodingKeys: String, CodingKey { 434 448 case hookEventName = "hook_event_name" 435 449 case title 436 450 case message 437 451 case lastAssistantMessage = "last_assistant_message" 452 + case assistantResponse = "assistant_response" 453 + } 454 + 455 + init(from decoder: Decoder) throws { 456 + let container = try decoder.container(keyedBy: CodingKeys.self) 457 + hookEventName = try container.decodeIfPresent(String.self, forKey: .hookEventName) 458 + title = try container.decodeIfPresent(String.self, forKey: .title) 459 + // Tolerate per-field decode errors (e.g. `"message": 42`) so a single 460 + // malformed field does not drop the whole notification — fall through 461 + // to the next candidate instead. 462 + let candidates = [CodingKeys.message, .lastAssistantMessage, .assistantResponse] 463 + .map { key in Self.decodeOptionalString(container, forKey: key) } 464 + // Skip empty strings too: Claude occasionally emits `"message": ""`, in 465 + // which case Codex's `last_assistant_message` / Kiro's `assistant_response` 466 + // still hold the useful body. 467 + body = candidates.compactMap { $0 }.first { !$0.isEmpty } 468 + } 469 + 470 + private static func decodeOptionalString( 471 + _ container: KeyedDecodingContainer<CodingKeys>, 472 + forKey key: CodingKeys 473 + ) -> String? { 474 + do { 475 + return try container.decodeIfPresent(String.self, forKey: key) 476 + } catch { 477 + socketLogger.warning("Failed to decode hook payload field \(key.rawValue): \(error)") 478 + return nil 479 + } 438 480 } 439 481 } 440 482 ··· 444 486 case query(resource: String, params: [String: String]) 445 487 446 488 init?(data: Data) { 447 - guard let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } 489 + guard let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { 490 + return nil 491 + } 448 492 var extracted: [String: String] = [:] 449 493 for (key, value) in dict where key != "deeplink" && key != "query" { 450 494 if let str = value as? String { extracted[key] = str }
+93
supacodeTests/AgentHookSocketServerTests.swift
··· 87 87 #expect(notification.body == "fallback body") 88 88 } 89 89 90 + @Test func parsesNotificationWithAssistantResponseFallback() { 91 + let tabID = UUID() 92 + let surfaceID = UUID() 93 + let payload = #"{"hook_event_name":"stop","assistant_response":"kiro body"}"# 94 + let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) kiro\n\(payload)" 95 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 96 + 97 + guard case .notification(_, _, _, let notification) = message else { 98 + Issue.record("Expected notification message") 99 + return 100 + } 101 + #expect(notification.agent == "kiro") 102 + #expect(notification.body == "kiro body") 103 + } 104 + 105 + @Test func lastAssistantMessageTakesPrecedenceOverAssistantResponse() { 106 + let tabID = UUID() 107 + let surfaceID = UUID() 108 + let payload = """ 109 + {"hook_event_name":"Stop","last_assistant_message":"codex body","assistant_response":"kiro body"} 110 + """ 111 + let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) codex\n\(payload)" 112 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 113 + 114 + guard case .notification(_, _, _, let notification) = message else { 115 + Issue.record("Expected notification message") 116 + return 117 + } 118 + #expect(notification.body == "codex body") 119 + } 120 + 121 + @Test func messageFieldTakesPrecedenceOverAllFallbacks() { 122 + let tabID = UUID() 123 + let surfaceID = UUID() 124 + let payload = 125 + #"{"hook_event_name":"Stop","message":"primary","# 126 + + #""last_assistant_message":"secondary","assistant_response":"tertiary"}"# 127 + let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) claude\n\(payload)" 128 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 129 + 130 + guard case .notification(_, _, _, let notification) = message else { 131 + Issue.record("Expected notification message") 132 + return 133 + } 134 + #expect(notification.body == "primary") 135 + } 136 + 137 + @Test func nullMessageFieldFallsThroughToLastAssistantMessage() { 138 + let tabID = UUID() 139 + let surfaceID = UUID() 140 + let payload = #"{"hook_event_name":"Stop","message":null,"last_assistant_message":"fallback"}"# 141 + let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) codex\n\(payload)" 142 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 143 + 144 + guard case .notification(_, _, _, let notification) = message else { 145 + Issue.record("Expected notification message") 146 + return 147 + } 148 + #expect(notification.body == "fallback") 149 + } 150 + 151 + @Test func emptyStringMessageFieldFallsThroughToFallback() { 152 + let tabID = UUID() 153 + let surfaceID = UUID() 154 + let payload = 155 + #"{"hook_event_name":"Stop","message":"","last_assistant_message":"real body"}"# 156 + let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) codex\n\(payload)" 157 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 158 + 159 + guard case .notification(_, _, _, let notification) = message else { 160 + Issue.record("Expected notification message") 161 + return 162 + } 163 + #expect(notification.body == "real body") 164 + } 165 + 166 + @Test func typeMismatchOnMessageFieldFallsThroughToFallback() { 167 + let tabID = UUID() 168 + let surfaceID = UUID() 169 + // Claude-shape with an unexpectedly numeric message: decoder must 170 + // tolerate the mismatch and fall through to assistant_response. 171 + let payload = 172 + #"{"hook_event_name":"stop","message":42,"assistant_response":"kiro body"}"# 173 + let raw = "wt \(tabID.uuidString) \(surfaceID.uuidString) kiro\n\(payload)" 174 + let message = AgentHookSocketServer.parse(data: Data(raw.utf8)) 175 + 176 + guard case .notification(_, _, _, let notification) = message else { 177 + Issue.record("Expected notification message") 178 + return 179 + } 180 + #expect(notification.body == "kiro body") 181 + } 182 + 90 183 @Test func invalidJSONPayloadDropsNotification() { 91 184 let tabID = UUID() 92 185 let surfaceID = UUID()
+290
supacodeTests/KiroHookSettingsFileInstallerTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import SupacodeSettingsShared 5 + @testable import supacode 6 + 7 + struct KiroHookSettingsFileInstallerTests { 8 + private let fileManager = FileManager.default 9 + 10 + private func makeErrors() -> KiroHookSettingsFileInstaller.Errors { 11 + .init( 12 + invalidEventHooks: { TestInstallerError.invalidEventHooks($0) }, 13 + invalidHooksObject: { TestInstallerError.invalidHooksObject }, 14 + invalidJSON: { TestInstallerError.invalidJSON($0) }, 15 + invalidRootObject: { TestInstallerError.invalidRootObject }, 16 + ) 17 + } 18 + 19 + private func makeInstaller() -> KiroHookSettingsFileInstaller { 20 + KiroHookSettingsFileInstaller(fileManager: fileManager, errors: makeErrors()) 21 + } 22 + 23 + private func makeTempURL() -> URL { 24 + URL(fileURLWithPath: NSTemporaryDirectory()) 25 + .appendingPathComponent("supacode-kiro-test-\(UUID().uuidString)") 26 + .appendingPathComponent("kiro_default.json") 27 + } 28 + 29 + private func sampleHookEntries() -> [String: [JSONValue]] { 30 + [ 31 + "stop": [ 32 + .object([ 33 + "command": .string(AgentHookSettingsCommand.busyCommand(active: false)), 34 + "timeout_ms": 10_000, 35 + ]) 36 + ] 37 + ] 38 + } 39 + 40 + // MARK: - Install. 41 + 42 + @Test func installIntoEmptyFileCreatesCorrectStructure() throws { 43 + let url = makeTempURL() 44 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 45 + 46 + let installer = makeInstaller() 47 + try installer.install(settingsURL: url, hookEntriesByEvent: sampleHookEntries()) 48 + 49 + let data = try Data(contentsOf: url) 50 + let root = try JSONDecoder().decode(JSONValue.self, from: data) 51 + guard let hooksObject = root.objectValue?["hooks"]?.objectValue else { 52 + Issue.record("Expected hooks object") 53 + return 54 + } 55 + #expect(hooksObject["stop"] != nil) 56 + let stopEntries = hooksObject["stop"]?.arrayValue 57 + #expect(stopEntries?.count == 1) 58 + } 59 + 60 + @Test func installPreservesExistingNonHookKeys() throws { 61 + let url = makeTempURL() 62 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 63 + 64 + let existing: JSONValue = .object(["name": "kiro_default", "tools": .array([.string("*")])]) 65 + try fileManager.createDirectory( 66 + at: url.deletingLastPathComponent(), 67 + withIntermediateDirectories: true, 68 + ) 69 + try JSONEncoder().encode(existing).write(to: url) 70 + 71 + let installer = makeInstaller() 72 + try installer.install(settingsURL: url, hookEntriesByEvent: sampleHookEntries()) 73 + 74 + let data = try Data(contentsOf: url) 75 + let root = try JSONDecoder().decode(JSONValue.self, from: data) 76 + #expect(root.objectValue?["name"]?.stringValue == "kiro_default") 77 + #expect(root.objectValue?["hooks"] != nil) 78 + } 79 + 80 + @Test func installIsIdempotent() throws { 81 + let url = makeTempURL() 82 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 83 + 84 + let installer = makeInstaller() 85 + let entries = sampleHookEntries() 86 + try installer.install(settingsURL: url, hookEntriesByEvent: entries) 87 + try installer.install(settingsURL: url, hookEntriesByEvent: entries) 88 + 89 + let data = try Data(contentsOf: url) 90 + let root = try JSONDecoder().decode(JSONValue.self, from: data) 91 + let stopEntries = root.objectValue?["hooks"]?.objectValue?["stop"]?.arrayValue 92 + #expect(stopEntries?.count == 1) 93 + } 94 + 95 + // MARK: - Uninstall. 96 + 97 + @Test func uninstallRemovesHookEntries() throws { 98 + let url = makeTempURL() 99 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 100 + 101 + let installer = makeInstaller() 102 + let entries = sampleHookEntries() 103 + try installer.install(settingsURL: url, hookEntriesByEvent: entries) 104 + try installer.uninstall(settingsURL: url, hookEntriesByEvent: entries) 105 + 106 + let data = try Data(contentsOf: url) 107 + let root = try JSONDecoder().decode(JSONValue.self, from: data) 108 + let stopEntries = root.objectValue?["hooks"]?.objectValue?["stop"]?.arrayValue 109 + #expect(stopEntries == nil || stopEntries?.isEmpty == true) 110 + } 111 + 112 + @Test func uninstallRemovesOnlyMatchingCommands() throws { 113 + let url = makeTempURL() 114 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 115 + 116 + let installer = makeInstaller() 117 + let entries = sampleHookEntries() 118 + try installer.install(settingsURL: url, hookEntriesByEvent: entries) 119 + 120 + // Add a user's hand-written hook entry alongside the managed one. 121 + var data = try Data(contentsOf: url) 122 + var root = try JSONDecoder().decode(JSONValue.self, from: data).objectValue! 123 + var hooks = root["hooks"]!.objectValue! 124 + var stopEntries = hooks["stop"]!.arrayValue! 125 + stopEntries.append(.object(["command": .string("echo user-hook"), "timeout_ms": 5_000])) 126 + hooks["stop"] = .array(stopEntries) 127 + root["hooks"] = .object(hooks) 128 + let encoder = JSONEncoder() 129 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 130 + try encoder.encode(JSONValue.object(root)).write(to: url) 131 + 132 + // Uninstall managed hooks. 133 + try installer.uninstall(settingsURL: url, hookEntriesByEvent: entries) 134 + 135 + data = try Data(contentsOf: url) 136 + let updated = try JSONDecoder().decode(JSONValue.self, from: data) 137 + let remaining = updated.objectValue?["hooks"]?.objectValue?["stop"]?.arrayValue ?? [] 138 + 139 + // User's hook should remain. 140 + #expect(remaining.count == 1) 141 + #expect(remaining[0].objectValue?["command"]?.stringValue == "echo user-hook") 142 + } 143 + 144 + // MARK: - Check. 145 + 146 + @Test func containsMatchingHooksReturnsFalseForMissingFile() { 147 + let url = makeTempURL() 148 + let installer = makeInstaller() 149 + #expect( 150 + installer.containsMatchingHooks(settingsURL: url, hookEntriesByEvent: sampleHookEntries()) 151 + == false) 152 + } 153 + 154 + @Test func containsMatchingHooksReturnsTrueAfterInstall() throws { 155 + let url = makeTempURL() 156 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 157 + 158 + let installer = makeInstaller() 159 + let entries = sampleHookEntries() 160 + try installer.install(settingsURL: url, hookEntriesByEvent: entries) 161 + #expect(installer.containsMatchingHooks(settingsURL: url, hookEntriesByEvent: entries) == true) 162 + } 163 + 164 + @Test func containsMatchingHooksReturnsFalseAfterUninstall() throws { 165 + let url = makeTempURL() 166 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 167 + 168 + let installer = makeInstaller() 169 + let entries = sampleHookEntries() 170 + try installer.install(settingsURL: url, hookEntriesByEvent: entries) 171 + try installer.uninstall(settingsURL: url, hookEntriesByEvent: entries) 172 + #expect(installer.containsMatchingHooks(settingsURL: url, hookEntriesByEvent: entries) == false) 173 + } 174 + 175 + // MARK: - Error paths. 176 + 177 + @Test func installThrowsInvalidJSONForMalformedFile() throws { 178 + let url = makeTempURL() 179 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 180 + 181 + try fileManager.createDirectory( 182 + at: url.deletingLastPathComponent(), 183 + withIntermediateDirectories: true, 184 + ) 185 + try Data("{{{".utf8).write(to: url) 186 + 187 + let installer = makeInstaller() 188 + #expect(throws: TestInstallerError.self) { 189 + try installer.install(settingsURL: url, hookEntriesByEvent: sampleHookEntries()) 190 + } 191 + } 192 + 193 + @Test func installThrowsInvalidRootObjectForArrayRoot() throws { 194 + let url = makeTempURL() 195 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 196 + 197 + try fileManager.createDirectory( 198 + at: url.deletingLastPathComponent(), 199 + withIntermediateDirectories: true, 200 + ) 201 + try Data("[1,2,3]".utf8).write(to: url) 202 + 203 + let installer = makeInstaller() 204 + #expect(throws: TestInstallerError.invalidRootObject) { 205 + try installer.install(settingsURL: url, hookEntriesByEvent: sampleHookEntries()) 206 + } 207 + } 208 + 209 + @Test func installThrowsInvalidHooksObjectWhenHooksIsNotAnObject() throws { 210 + let url = makeTempURL() 211 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 212 + 213 + try fileManager.createDirectory( 214 + at: url.deletingLastPathComponent(), 215 + withIntermediateDirectories: true, 216 + ) 217 + let encoder = JSONEncoder() 218 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 219 + let settings: JSONValue = .object(["hooks": .string("definitely not an object")]) 220 + try encoder.encode(settings).write(to: url) 221 + 222 + let installer = makeInstaller() 223 + #expect(throws: TestInstallerError.invalidHooksObject) { 224 + try installer.install(settingsURL: url, hookEntriesByEvent: sampleHookEntries()) 225 + } 226 + #expect(throws: TestInstallerError.invalidHooksObject) { 227 + try installer.uninstall(settingsURL: url, hookEntriesByEvent: sampleHookEntries()) 228 + } 229 + } 230 + 231 + @Test func installThrowsInvalidEventHooksWhenEventValueIsNotAnArray() throws { 232 + let url = makeTempURL() 233 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 234 + 235 + try fileManager.createDirectory( 236 + at: url.deletingLastPathComponent(), 237 + withIntermediateDirectories: true, 238 + ) 239 + let encoder = JSONEncoder() 240 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 241 + let settings: JSONValue = .object([ 242 + "hooks": .object(["stop": .string("not an array")]) 243 + ]) 244 + try encoder.encode(settings).write(to: url) 245 + 246 + let installer = makeInstaller() 247 + #expect(throws: TestInstallerError.invalidEventHooks("stop")) { 248 + try installer.install(settingsURL: url, hookEntriesByEvent: sampleHookEntries()) 249 + } 250 + } 251 + 252 + @Test func uninstallRemovesLegacyManagedCommands() throws { 253 + let url = makeTempURL() 254 + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } 255 + 256 + try fileManager.createDirectory( 257 + at: url.deletingLastPathComponent(), 258 + withIntermediateDirectories: true, 259 + ) 260 + let legacyCommand = "SUPACODE_CLI_PATH=/usr/bin/supacode agent-hook --stop" 261 + #expect(AgentHookCommandOwnership.isLegacyCommand(legacyCommand)) 262 + let seeded: JSONValue = .object([ 263 + "hooks": .object([ 264 + "stop": .array([ 265 + .object(["command": .string(legacyCommand), "timeout_ms": 5_000]), 266 + .object(["command": .string("echo user-hook"), "timeout_ms": 5_000]), 267 + ]) 268 + ]) 269 + ]) 270 + let encoder = JSONEncoder() 271 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 272 + try encoder.encode(seeded).write(to: url) 273 + 274 + let installer = makeInstaller() 275 + try installer.uninstall(settingsURL: url, hookEntriesByEvent: sampleHookEntries()) 276 + 277 + let data = try Data(contentsOf: url) 278 + let root = try JSONDecoder().decode(JSONValue.self, from: data) 279 + let remaining = root.objectValue?["hooks"]?.objectValue?["stop"]?.arrayValue ?? [] 280 + #expect(remaining.count == 1) 281 + #expect(remaining.first?.objectValue?["command"]?.stringValue == "echo user-hook") 282 + } 283 + } 284 + 285 + private enum TestInstallerError: Error, Equatable { 286 + case invalidEventHooks(String) 287 + case invalidHooksObject 288 + case invalidJSON(String) 289 + case invalidRootObject 290 + }
+299
supacodeTests/KiroSettingsInstallerTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import SupacodeSettingsShared 5 + @testable import supacode 6 + 7 + struct KiroSettingsInstallerTests { 8 + private let fileManager = FileManager.default 9 + 10 + private func makeTempHomeURL() -> URL { 11 + URL(fileURLWithPath: NSTemporaryDirectory()) 12 + .appendingPathComponent("supacode-kiro-installer-\(UUID().uuidString)", isDirectory: true) 13 + } 14 + 15 + private func makeInstaller( 16 + homeURL: URL, 17 + versionOutput: String = "kiro 1.0.0", 18 + versionStatus: Int32 = 0, 19 + versionError: Error? = nil 20 + ) -> KiroSettingsInstaller { 21 + KiroSettingsInstaller( 22 + homeDirectoryURL: homeURL, 23 + fileManager: fileManager, 24 + runKiroVersionCommand: { 25 + if let versionError { throw versionError } 26 + return .init(status: versionStatus, standardOutput: versionOutput, standardError: "") 27 + }, 28 + ) 29 + } 30 + 31 + @Test func installProgressHooksCreatesDefaultConfigWhenMissing() async throws { 32 + let homeURL = makeTempHomeURL() 33 + defer { try? fileManager.removeItem(at: homeURL) } 34 + 35 + let installer = makeInstaller(homeURL: homeURL) 36 + try await installer.installProgressHooks() 37 + 38 + let settingsURL = KiroSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) 39 + let data = try Data(contentsOf: settingsURL) 40 + let json = try JSONDecoder().decode(JSONValue.self, from: data) 41 + let root = try #require(json.objectValue) 42 + #expect(root["name"] == .string("kiro_default")) 43 + #expect(root["tools"] == .array([.string("*")])) 44 + #expect(root["useLegacyMcpJson"] == .bool(true)) 45 + let resources = try #require(root["resources"]?.arrayValue) 46 + #expect(resources.count == 4) 47 + #expect(resources.contains(.string("file://AGENTS.md"))) 48 + #expect(resources.contains(.string("skill://~/.kiro/skills/**/SKILL.md"))) 49 + #expect(resources.contains(.string("skill://~/.kiro/steering/**/*.md"))) 50 + #expect(root["hooks"]?.objectValue != nil) 51 + } 52 + 53 + @Test func installNotificationHooksCreatesDefaultConfigWhenMissing() async throws { 54 + let homeURL = makeTempHomeURL() 55 + defer { try? fileManager.removeItem(at: homeURL) } 56 + 57 + let installer = makeInstaller(homeURL: homeURL) 58 + try await installer.installNotificationHooks() 59 + 60 + let settingsURL = KiroSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) 61 + let data = try Data(contentsOf: settingsURL) 62 + let json = try JSONDecoder().decode(JSONValue.self, from: data) 63 + let root = try #require(json.objectValue) 64 + #expect(root["name"] == .string("kiro_default")) 65 + #expect(root["tools"] == .array([.string("*")])) 66 + } 67 + 68 + @Test func installProgressHooksDoesNotOverwriteExistingConfig() async throws { 69 + let homeURL = makeTempHomeURL() 70 + defer { try? fileManager.removeItem(at: homeURL) } 71 + 72 + let installer = makeInstaller(homeURL: homeURL) 73 + try await installer.installProgressHooks() 74 + 75 + let settingsURL = KiroSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) 76 + let firstWrite = try Data(contentsOf: settingsURL) 77 + 78 + try await installer.installProgressHooks() 79 + let secondWrite = try Data(contentsOf: settingsURL) 80 + 81 + #expect(firstWrite == secondWrite) 82 + } 83 + 84 + @Test func installPreservesExistingConfigWithoutHooksSection() async throws { 85 + let homeURL = makeTempHomeURL() 86 + defer { try? fileManager.removeItem(at: homeURL) } 87 + 88 + // Simulate a user-authored kiro_default.json that has custom `tools` and 89 + // `resources` but no `hooks` key yet — install must merge hooks in place 90 + // without stomping the user's fields. 91 + let settingsURL = KiroSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) 92 + try fileManager.createDirectory( 93 + at: settingsURL.deletingLastPathComponent(), 94 + withIntermediateDirectories: true, 95 + ) 96 + let userConfig: [String: JSONValue] = [ 97 + "name": .string("kiro_default"), 98 + "tools": .array([.string("filesystem"), .string("web")]), 99 + "resources": .array([.string("file://custom.md")]), 100 + ] 101 + let encoder = JSONEncoder() 102 + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] 103 + try encoder.encode(JSONValue.object(userConfig)).write(to: settingsURL) 104 + 105 + let installer = makeInstaller(homeURL: homeURL) 106 + try await installer.installProgressHooks() 107 + 108 + let data = try Data(contentsOf: settingsURL) 109 + let root = try #require( 110 + try JSONDecoder().decode(JSONValue.self, from: data).objectValue) 111 + #expect(root["tools"] == .array([.string("filesystem"), .string("web")])) 112 + #expect(root["resources"] == .array([.string("file://custom.md")])) 113 + #expect(root["hooks"]?.objectValue != nil) 114 + } 115 + 116 + @Test func uninstallProgressHooksIsNoOpWhenFileMissing() throws { 117 + let homeURL = makeTempHomeURL() 118 + defer { try? fileManager.removeItem(at: homeURL) } 119 + 120 + let installer = makeInstaller(homeURL: homeURL) 121 + #expect(throws: Never.self) { 122 + try installer.uninstallProgressHooks() 123 + } 124 + } 125 + 126 + @Test func uninstallNotificationHooksIsNoOpWhenFileMissing() throws { 127 + let homeURL = makeTempHomeURL() 128 + defer { try? fileManager.removeItem(at: homeURL) } 129 + 130 + let installer = makeInstaller(homeURL: homeURL) 131 + #expect(throws: Never.self) { 132 + try installer.uninstallNotificationHooks() 133 + } 134 + } 135 + 136 + @Test func isInstalledProgressReturnsFalseBeforeInstall() { 137 + let homeURL = makeTempHomeURL() 138 + let installer = makeInstaller(homeURL: homeURL) 139 + #expect(installer.isInstalled(progress: true) == false) 140 + } 141 + 142 + @Test func isInstalledProgressReturnsTrueAfterInstall() async throws { 143 + let homeURL = makeTempHomeURL() 144 + defer { try? fileManager.removeItem(at: homeURL) } 145 + 146 + let installer = makeInstaller(homeURL: homeURL) 147 + try await installer.installProgressHooks() 148 + #expect(installer.isInstalled(progress: true) == true) 149 + } 150 + 151 + @Test func isInstalledNotificationsReturnsTrueAfterInstall() async throws { 152 + let homeURL = makeTempHomeURL() 153 + defer { try? fileManager.removeItem(at: homeURL) } 154 + 155 + let installer = makeInstaller(homeURL: homeURL) 156 + try await installer.installNotificationHooks() 157 + #expect(installer.isInstalled(progress: false) == true) 158 + } 159 + 160 + @Test func isInstalledProgressReturnsFalseAfterUninstall() async throws { 161 + let homeURL = makeTempHomeURL() 162 + defer { try? fileManager.removeItem(at: homeURL) } 163 + 164 + let installer = makeInstaller(homeURL: homeURL) 165 + try await installer.installProgressHooks() 166 + try installer.uninstallProgressHooks() 167 + #expect(installer.isInstalled(progress: true) == false) 168 + } 169 + 170 + @Test func settingsURLPointsToExpectedPath() { 171 + let homeURL = URL(fileURLWithPath: "/Users/test") 172 + let url = KiroSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) 173 + #expect(url.path == "/Users/test/.kiro/agents/kiro_default.json") 174 + } 175 + 176 + // MARK: - Version gating. 177 + 178 + @Test func installFailsWhenKiroBinaryMissing() async { 179 + let homeURL = makeTempHomeURL() 180 + defer { try? fileManager.removeItem(at: homeURL) } 181 + 182 + let installer = makeInstaller(homeURL: homeURL, versionStatus: 127) 183 + await #expect(throws: KiroSettingsInstallerError.kiroUnavailable) { 184 + try await installer.installProgressHooks() 185 + } 186 + let settingsURL = KiroSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) 187 + #expect(fileManager.fileExists(atPath: settingsURL.path) == false) 188 + } 189 + 190 + @Test func installFailsWhenKiroCommandThrows() async { 191 + struct Boom: Error {} 192 + let homeURL = makeTempHomeURL() 193 + defer { try? fileManager.removeItem(at: homeURL) } 194 + 195 + let installer = makeInstaller(homeURL: homeURL, versionError: Boom()) 196 + await #expect(throws: KiroSettingsInstallerError.kiroUnavailable) { 197 + try await installer.installProgressHooks() 198 + } 199 + } 200 + 201 + @Test func installFailsOnUnsupportedKiroVersion() async { 202 + let homeURL = makeTempHomeURL() 203 + defer { try? fileManager.removeItem(at: homeURL) } 204 + 205 + let installer = makeInstaller(homeURL: homeURL, versionOutput: "kiro 2.0.0") 206 + await #expect(throws: KiroSettingsInstallerError.unsupportedKiroVersion("2.0.0")) { 207 + try await installer.installProgressHooks() 208 + } 209 + } 210 + 211 + @Test func installFailsWhenVersionOutputHasNoVersionNumber() async { 212 + let homeURL = makeTempHomeURL() 213 + defer { try? fileManager.removeItem(at: homeURL) } 214 + 215 + let installer = makeInstaller(homeURL: homeURL, versionOutput: "nothing to see here") 216 + await #expect( 217 + throws: KiroSettingsInstallerError.unsupportedKiroVersion("nothing to see here") 218 + ) { 219 + try await installer.installProgressHooks() 220 + } 221 + } 222 + 223 + @Test func installFailsOnNonZeroNonMissingStatus() async { 224 + let homeURL = makeTempHomeURL() 225 + defer { try? fileManager.removeItem(at: homeURL) } 226 + 227 + let installer = makeInstaller( 228 + homeURL: homeURL, 229 + versionOutput: "", 230 + versionStatus: 1, 231 + ) 232 + await #expect( 233 + throws: KiroSettingsInstallerError.unsupportedKiroVersion("exit status 1") 234 + ) { 235 + try await installer.installProgressHooks() 236 + } 237 + } 238 + 239 + @Test func installFailsForDoubleDigitMajorVersion() async { 240 + let homeURL = makeTempHomeURL() 241 + defer { try? fileManager.removeItem(at: homeURL) } 242 + 243 + let installer = makeInstaller(homeURL: homeURL, versionOutput: "kiro 10.0.0") 244 + await #expect( 245 + throws: KiroSettingsInstallerError.unsupportedKiroVersion("10.0.0") 246 + ) { 247 + try await installer.installProgressHooks() 248 + } 249 + } 250 + 251 + @Test func installSucceedsWhenStderrBannerPrecedesStdoutVersion() async throws { 252 + let homeURL = makeTempHomeURL() 253 + defer { try? fileManager.removeItem(at: homeURL) } 254 + 255 + // Login shells can print their own banners to stderr; parsing must prefer 256 + // stdout so a stderr "Python 3.11" banner does not reject valid Kiro 1.x. 257 + let installer = KiroSettingsInstaller( 258 + homeDirectoryURL: homeURL, 259 + fileManager: fileManager, 260 + runKiroVersionCommand: { 261 + .init( 262 + status: 0, 263 + standardOutput: "kiro 1.2.3\n", 264 + standardError: "Python 3.11.0 (banner)", 265 + ) 266 + }, 267 + ) 268 + try await installer.installProgressHooks() 269 + let settingsURL = KiroSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) 270 + #expect(fileManager.fileExists(atPath: settingsURL.path)) 271 + } 272 + 273 + @Test func installSkipsVersionCheckWhenFileAlreadyExists() async throws { 274 + let homeURL = makeTempHomeURL() 275 + defer { try? fileManager.removeItem(at: homeURL) } 276 + 277 + // Seed the file so ensureDefaultAgentConfig short-circuits. 278 + let settingsURL = KiroSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) 279 + try fileManager.createDirectory( 280 + at: settingsURL.deletingLastPathComponent(), 281 + withIntermediateDirectories: true, 282 + ) 283 + let seeded: [String: JSONValue] = ["name": .string("kiro_default")] 284 + try JSONEncoder().encode(JSONValue.object(seeded)).write(to: settingsURL) 285 + 286 + // Version command would fail if invoked — test that install still succeeds. 287 + let installer = makeInstaller(homeURL: homeURL, versionStatus: 127) 288 + try await installer.installProgressHooks() 289 + #expect(fileManager.fileExists(atPath: settingsURL.path)) 290 + } 291 + 292 + @Test func extractVersionHandlesCommonFormats() { 293 + #expect(KiroSettingsInstaller.extractVersion(from: "kiro 1.2.3") == "1.2.3") 294 + #expect(KiroSettingsInstaller.extractVersion(from: "Kiro CLI v1.0.0 (build abcd)") == "1.0.0") 295 + #expect(KiroSettingsInstaller.extractVersion(from: "1.4") == "1.4") 296 + #expect(KiroSettingsInstaller.extractVersion(from: "no version here") == nil) 297 + #expect(KiroSettingsInstaller.extractVersion(from: "just 42") == nil) 298 + } 299 + }
+117 -3
supacodeTests/SettingsFeatureAgentHookTests.swift
··· 140 140 } 141 141 return progress 142 142 } 143 + $0[KiroSettingsClient.self].checkInstalled = { progress in 144 + let key = progress ? "kiroProgress" : "kiroNotifications" 145 + _ = startedChecks.withValue { $0.insert(key) } 146 + await withCheckedContinuation { continuation in 147 + continuations.withValue { $0.append(continuation) } 148 + } 149 + return progress 150 + } 143 151 } 144 152 145 153 store.exhaustivity = .off(showSkippedAssertions: false) ··· 148 156 149 157 // CLI/skill/hook checks run in parallel via `.merge`. 150 158 // CLI/skill mocks return immediately; hook checks block on continuations. 151 - // Wait for all four hook checks to start. 159 + // Wait for all six hook checks to start. 152 160 await eventually { 153 - startedChecks.value.count == 4 161 + startedChecks.value.count == 6 154 162 } 155 163 156 164 continuations.withValue { continuations in ··· 163 171 await store.skipReceivedActions() 164 172 } 165 173 166 - @Test(.dependencies) func taskChecksAllFourHookSlotsOnStartup() async { 174 + @Test(.dependencies) func taskChecksAllSixHookSlotsOnStartup() async { 167 175 let checkedSlots = LockIsolated<[String]>([]) 168 176 169 177 let store = TestStore(initialState: SettingsFeature.State()) { ··· 179 187 checkedSlots.withValue { $0.append(progress ? "codexProgress" : "codexNotifications") } 180 188 return progress 181 189 } 190 + $0[KiroSettingsClient.self].checkInstalled = { progress in 191 + checkedSlots.withValue { $0.append(progress ? "kiroProgress" : "kiroNotifications") } 192 + return progress 193 + } 182 194 } 183 195 184 196 store.exhaustivity = .off(showSkippedAssertions: false) ··· 192 204 "claudeNotifications", 193 205 "codexProgress", 194 206 "codexNotifications", 207 + "kiroProgress", 208 + "kiroNotifications", 195 209 ]) 210 + } 211 + 212 + // MARK: - Kiro hook actions. 213 + 214 + @Test(.dependencies) func agentHookInstallTappedKiroProgressTransitionsToInstalled() async { 215 + var state = SettingsFeature.State() 216 + state.kiroProgressState = .notInstalled 217 + 218 + let store = TestStore(initialState: state) { 219 + SettingsFeature() 220 + } withDependencies: { 221 + $0[KiroSettingsClient.self].installProgress = {} 222 + } 223 + 224 + await store.send(.agentHookInstallTapped(.kiroProgress)) { 225 + $0.kiroProgressState = .installing 226 + } 227 + await store.receive(\.agentHookActionCompleted) { 228 + $0.kiroProgressState = .installed 229 + } 230 + } 231 + 232 + @Test(.dependencies) func agentHookUninstallTappedKiroProgressTransitionsToNotInstalled() async { 233 + var state = SettingsFeature.State() 234 + state.kiroProgressState = .installed 235 + 236 + let store = TestStore(initialState: state) { 237 + SettingsFeature() 238 + } withDependencies: { 239 + $0[KiroSettingsClient.self].uninstallProgress = {} 240 + } 241 + 242 + await store.send(.agentHookUninstallTapped(.kiroProgress)) { 243 + $0.kiroProgressState = .uninstalling 244 + } 245 + await store.receive(\.agentHookActionCompleted) { 246 + $0.kiroProgressState = .notInstalled 247 + } 248 + } 249 + 250 + @Test(.dependencies) func agentHookInstallTappedKiroNotificationsTransitionsToInstalled() async { 251 + var state = SettingsFeature.State() 252 + state.kiroNotificationsState = .notInstalled 253 + 254 + let store = TestStore(initialState: state) { 255 + SettingsFeature() 256 + } withDependencies: { 257 + $0[KiroSettingsClient.self].installNotifications = {} 258 + } 259 + 260 + await store.send(.agentHookInstallTapped(.kiroNotifications)) { 261 + $0.kiroNotificationsState = .installing 262 + } 263 + await store.receive(\.agentHookActionCompleted) { 264 + $0.kiroNotificationsState = .installed 265 + } 266 + } 267 + 268 + @Test(.dependencies) func agentHookUninstallTappedKiroNotificationsTransitionsToNotInstalled() async { 269 + var state = SettingsFeature.State() 270 + state.kiroNotificationsState = .installed 271 + 272 + let store = TestStore(initialState: state) { 273 + SettingsFeature() 274 + } withDependencies: { 275 + $0[KiroSettingsClient.self].uninstallNotifications = {} 276 + } 277 + 278 + await store.send(.agentHookUninstallTapped(.kiroNotifications)) { 279 + $0.kiroNotificationsState = .uninstalling 280 + } 281 + await store.receive(\.agentHookActionCompleted) { 282 + $0.kiroNotificationsState = .notInstalled 283 + } 284 + } 285 + 286 + @Test(.dependencies) func agentHookCheckedKiroProgressSetsInstalled() async { 287 + var state = SettingsFeature.State() 288 + state.kiroProgressState = .checking 289 + 290 + let store = TestStore(initialState: state) { 291 + SettingsFeature() 292 + } 293 + 294 + await store.send(.agentHookChecked(.kiroProgress, installed: true)) { 295 + $0.kiroProgressState = .installed 296 + } 297 + } 298 + 299 + @Test(.dependencies) func agentHookCheckedKiroNotificationsSetsNotInstalled() async { 300 + var state = SettingsFeature.State() 301 + state.kiroNotificationsState = .checking 302 + 303 + let store = TestStore(initialState: state) { 304 + SettingsFeature() 305 + } 306 + 307 + await store.send(.agentHookChecked(.kiroNotifications, installed: false)) { 308 + $0.kiroNotificationsState = .notInstalled 309 + } 196 310 } 197 311 198 312 private func eventually(
+5
supacodeTests/SettingsFeatureTests.swift
··· 43 43 $0[CLISkillClient.self].checkInstalled = { _ in false } 44 44 $0[ClaudeSettingsClient.self].checkInstalled = { _ in false } 45 45 $0[CodexSettingsClient.self].checkInstalled = { _ in false } 46 + $0[KiroSettingsClient.self].checkInstalled = { _ in false } 46 47 } 47 48 48 49 store.exhaustivity = .off(showSkippedAssertions: false) ··· 569 570 $0[CLISkillClient.self].checkInstalled = { _ in false } 570 571 $0[ClaudeSettingsClient.self].checkInstalled = { _ in false } 571 572 $0[CodexSettingsClient.self].checkInstalled = { _ in false } 573 + $0[KiroSettingsClient.self].checkInstalled = { _ in false } 572 574 } 573 575 574 576 store.exhaustivity = .off(showSkippedAssertions: false) ··· 921 923 #expect(store.state.cliInstallState == .notInstalled) 922 924 #expect(store.state.claudeSkillState == .notInstalled) 923 925 #expect(store.state.codexSkillState == .notInstalled) 926 + #expect(store.state.kiroSkillState == .notInstalled) 924 927 #expect(store.state.claudeProgressState == .notInstalled) 925 928 #expect(store.state.claudeNotificationsState == .notInstalled) 926 929 #expect(store.state.codexProgressState == .notInstalled) 927 930 #expect(store.state.codexNotificationsState == .notInstalled) 931 + #expect(store.state.kiroProgressState == .notInstalled) 932 + #expect(store.state.kiroNotificationsState == .notInstalled) 928 933 }