From 0b831cee6ce7d240f0441e57d06957027e8fdf83 Mon Sep 17 00:00:00 2001 From: Benjamin <1159333+benjaminburzan@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:43:51 +0200 Subject: [PATCH 1/2] 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 --- .../kiro-mark.imageset/Contents.json | 16 ++ .../kiro-mark.imageset/kiro-mark.svg | 11 ++ .../CodingAgents/KiroSettingsClient.swift | 44 +++++ .../BusinessLogic/CLISkillContent.swift | 56 ++++++ .../BusinessLogic/CLISkillInstaller.swift | 1 + .../BusinessLogic/KiroHookSettings.swift | 86 ++++++++ .../KiroHookSettingsFileInstaller.swift | 185 ++++++++++++++++++ .../BusinessLogic/KiroSettingsInstaller.swift | 144 ++++++++++++++ .../Models/AgentHooksInstallState.swift | 2 + .../Features/Settings/Models/SkillAgent.swift | 4 +- .../Settings/Reducer/SettingsFeature.swift | 27 ++- .../Views/DeveloperSettingsView.swift | 36 ++++ .../AgentHookSocketServer.swift | 4 +- 13 files changed, 611 insertions(+), 5 deletions(-) create mode 100644 supacode/Assets.xcassets/kiro-mark.imageset/Contents.json create mode 100644 supacode/Assets.xcassets/kiro-mark.imageset/kiro-mark.svg create mode 100644 supacode/Clients/CodingAgents/KiroSettingsClient.swift create mode 100644 supacode/Features/Settings/BusinessLogic/KiroHookSettings.swift create mode 100644 supacode/Features/Settings/BusinessLogic/KiroHookSettingsFileInstaller.swift create mode 100644 supacode/Features/Settings/BusinessLogic/KiroSettingsInstaller.swift diff --git a/supacode/Assets.xcassets/kiro-mark.imageset/Contents.json b/supacode/Assets.xcassets/kiro-mark.imageset/Contents.json new file mode 100644 index 00000000..9d8e86c5 --- /dev/null +++ b/supacode/Assets.xcassets/kiro-mark.imageset/Contents.json @@ -0,0 +1,16 @@ +{ + "images" : [ + { + "filename" : "kiro-mark.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true, + "template-rendering-intent" : "original" + } +} diff --git a/supacode/Assets.xcassets/kiro-mark.imageset/kiro-mark.svg b/supacode/Assets.xcassets/kiro-mark.imageset/kiro-mark.svg new file mode 100644 index 00000000..5fe3cf68 --- /dev/null +++ b/supacode/Assets.xcassets/kiro-mark.imageset/kiro-mark.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/supacode/Clients/CodingAgents/KiroSettingsClient.swift b/supacode/Clients/CodingAgents/KiroSettingsClient.swift new file mode 100644 index 00000000..7bfbb626 --- /dev/null +++ b/supacode/Clients/CodingAgents/KiroSettingsClient.swift @@ -0,0 +1,44 @@ +import ComposableArchitecture +import Foundation + +struct KiroSettingsClient: Sendable { + var checkInstalled: @Sendable (Bool) async -> Bool + var installProgress: @Sendable () async throws -> Void + var installNotifications: @Sendable () async throws -> Void + var uninstallProgress: @Sendable () async throws -> Void + var uninstallNotifications: @Sendable () async throws -> Void +} + +extension KiroSettingsClient: DependencyKey { + static let liveValue = Self( + checkInstalled: { progress in + KiroSettingsInstaller().isInstalled(progress: progress) + }, + installProgress: { + try KiroSettingsInstaller().installProgressHooks() + }, + installNotifications: { + try KiroSettingsInstaller().installNotificationHooks() + }, + uninstallProgress: { + try KiroSettingsInstaller().uninstallProgressHooks() + }, + uninstallNotifications: { + try KiroSettingsInstaller().uninstallNotificationHooks() + } + ) + static let testValue = Self( + checkInstalled: { _ in false }, + installProgress: {}, + installNotifications: {}, + uninstallProgress: {}, + uninstallNotifications: {} + ) +} + +extension DependencyValues { + var kiroSettingsClient: KiroSettingsClient { + get { self[KiroSettingsClient.self] } + set { self[KiroSettingsClient.self] = newValue } + } +} diff --git a/supacode/Features/Settings/BusinessLogic/CLISkillContent.swift b/supacode/Features/Settings/BusinessLogic/CLISkillContent.swift index afede940..0c3b32cb 100644 --- a/supacode/Features/Settings/BusinessLogic/CLISkillContent.swift +++ b/supacode/Features/Settings/BusinessLogic/CLISkillContent.swift @@ -245,6 +245,62 @@ nonisolated enum CLISkillContent { supacode surface split -d v -i "test" # BAD: missing -t/-s, targets your shell ``` + Flags: `-w` (worktree), `-t` (tab), `-s` (surface), `-r` (repo), `-i` (input), `-d` (direction), `-n` (new ID). + Env var defaults only target your own shell session. Pass explicit IDs for created resources. + """ + + // MARK: - Kiro. + + // Kiro uses SKILL.md with YAML frontmatter (same as Codex). + static let kiroSkillMd = """ + --- + name: \(skillName) + description: \(description) + --- + + # Supacode CLI + + Control Supacode from the terminal. The `supacode` command is available in all Supacode terminal sessions. + + ## CRITICAL: ID Tracking + + **NEVER call `supacode tab new` or `supacode surface split` without capturing + the output.** They print the new UUID to stdout. Without it you cannot target + the resource afterward. + + **NEVER omit `-t`/`-s` when targeting a created resource.** The env vars point + to your own shell, not to anything you created. + + For new tabs, surface ID = tab ID. + + ### Correct: + + ```sh + TAB_ID=$(supacode tab new -i "npm start") + SPLIT_ID=$(supacode surface split -t "$TAB_ID" -s "$TAB_ID" -d v -i "npm test") + supacode surface close -t "$TAB_ID" -s "$SPLIT_ID" + supacode tab close -t "$TAB_ID" + ``` + + ### WRONG: + + ```sh + supacode tab new -i "npm start" # BAD: not captured + supacode surface split -d v -i "test" # BAD: missing -t/-s, targets your shell + ``` + + ## Commands + + - `supacode worktree [list [-f]|focus|run|stop|archive|unarchive|delete|pin|unpin] [-w ]` + - `supacode tab [list [-w] [-f]|focus|new|close] [-w ] [-t ] [-i ] [-n ]` + - `supacode surface [list [-w] [-t] [-f]|focus|split|close] [-w ] [-t ] [-s ] [-i ] [-d h|v] [-n ]` + - `supacode repo [list | open | worktree-new [-r ] [--branch] [--base] [--fetch]]` + - `supacode settings [
]` + - `supacode socket` + + `list` outputs one ID per line (percent-encoded for worktrees/repos, UUIDs for tabs/surfaces). + Use these IDs directly as `-w`, `-t`, `-s`, `-r` flag values. + Flags: `-w` (worktree), `-t` (tab), `-s` (surface), `-r` (repo), `-i` (input), `-d` (direction), `-n` (new ID). Env var defaults only target your own shell session. Pass explicit IDs for created resources. """ diff --git a/supacode/Features/Settings/BusinessLogic/CLISkillInstaller.swift b/supacode/Features/Settings/BusinessLogic/CLISkillInstaller.swift index 93d6b12d..83f2b372 100644 --- a/supacode/Features/Settings/BusinessLogic/CLISkillInstaller.swift +++ b/supacode/Features/Settings/BusinessLogic/CLISkillInstaller.swift @@ -47,6 +47,7 @@ nonisolated struct CLISkillInstaller { switch agent { case .claude: CLISkillContent.claudeSkill case .codex: CLISkillContent.codexSkillMd + case .kiro: CLISkillContent.kiroSkillMd } } } diff --git a/supacode/Features/Settings/BusinessLogic/KiroHookSettings.swift b/supacode/Features/Settings/BusinessLogic/KiroHookSettings.swift new file mode 100644 index 00000000..a4735076 --- /dev/null +++ b/supacode/Features/Settings/BusinessLogic/KiroHookSettings.swift @@ -0,0 +1,86 @@ +import Foundation + +nonisolated enum KiroHookSettings { + fileprivate static let busyOn = AgentHookSettingsCommand.busyCommand(active: true) + fileprivate static let busyOff = AgentHookSettingsCommand.busyCommand(active: false) + fileprivate static let notify = AgentHookSettingsCommand.notificationCommand(agent: "kiro") + + static func progressHookEntriesByEvent() throws -> [String: [JSONValue]] { + try KiroHookPayloadSupport.extractHookEntries( + from: KiroProgressPayload(), + invalidConfiguration: KiroHookSettingsError.invalidConfiguration + ) + } + + static func notificationHookEntriesByEvent() throws -> [String: [JSONValue]] { + try KiroHookPayloadSupport.extractHookEntries( + from: KiroNotificationPayload(), + invalidConfiguration: KiroHookSettingsError.invalidConfiguration + ) + } +} + +nonisolated enum KiroHookSettingsError: Error { + case invalidConfiguration +} + +// MARK: - Kiro hook entry (flat format: command + timeout_ms, no type/group wrapper). + +nonisolated struct KiroHookEntry: Encodable { + let command: String + let timeoutMs: Int + + enum CodingKeys: String, CodingKey { + case command + case timeoutMs = "timeout_ms" + } +} + +// MARK: - Progress hooks. + +// Kiro uses camelCase event names ("userPromptSubmit", "stop") unlike +// Claude/Codex which use PascalCase ("UserPromptSubmit", "Stop"). +private nonisolated struct KiroProgressPayload: Encodable { + let hooks: [String: [KiroHookEntry]] = [ + "userPromptSubmit": [ + KiroHookEntry(command: KiroHookSettings.busyOn, timeoutMs: 10_000) + ], + "stop": [ + KiroHookEntry(command: KiroHookSettings.busyOff, timeoutMs: 10_000) + ], + ] +} + +// MARK: - Notification hooks. + +private nonisolated struct KiroNotificationPayload: Encodable { + let hooks: [String: [KiroHookEntry]] = [ + "stop": [ + KiroHookEntry(command: KiroHookSettings.notify, timeoutMs: 10_000) + ], + ] +} + +// MARK: - Payload support for flat format. + +nonisolated enum KiroHookPayloadSupport { + static func extractHookEntries( + from payload: T, + invalidConfiguration: @autoclosure () -> Error + ) throws -> [String: [JSONValue]] { + guard + let objectValue = try JSONValue(payload).objectValue, + let hooksValue = objectValue["hooks"]?.objectValue + else { + throw invalidConfiguration() + } + var result: [String: [JSONValue]] = [:] + for (event, value) in hooksValue { + guard let entries = value.arrayValue else { + throw invalidConfiguration() + } + result[event] = entries + } + return result + } +} diff --git a/supacode/Features/Settings/BusinessLogic/KiroHookSettingsFileInstaller.swift b/supacode/Features/Settings/BusinessLogic/KiroHookSettingsFileInstaller.swift new file mode 100644 index 00000000..44d9149c --- /dev/null +++ b/supacode/Features/Settings/BusinessLogic/KiroHookSettingsFileInstaller.swift @@ -0,0 +1,185 @@ +import Foundation + +private nonisolated let kiroInstallerLogger = SupaLogger("Settings") + +/// File installer for Kiro's flat hook format (`hooks → event → [{ command, timeout_ms }]`). +/// Unlike `AgentHookSettingsFileInstaller` which handles Claude/Codex grouped format. +nonisolated struct KiroHookSettingsFileInstaller { + struct Errors { + let invalidEventHooks: @Sendable (String) -> Error + let invalidHooksObject: @Sendable () -> Error + let invalidJSON: @Sendable (String) -> Error + let invalidRootObject: @Sendable () -> Error + } + + private enum LoadError: Error { + case invalidRootObject + } + + let fileManager: FileManager + let errors: Errors + let logWarning: @Sendable (String) -> Void + + init( + fileManager: FileManager, + errors: Errors, + logWarning: @escaping @Sendable (String) -> Void = { kiroInstallerLogger.warning($0) } + ) { + self.fileManager = fileManager + self.errors = errors + self.logWarning = logWarning + } + + // MARK: - Check. + + func containsMatchingHooks( + settingsURL: URL, + hookEntriesByEvent: [String: [JSONValue]] + ) -> Bool { + do { + let settingsObject = try loadSettingsObject(at: settingsURL) + guard let hooksObject = settingsObject["hooks"]?.objectValue else { return false } + let expectedCommands = Self.commands(from: hookEntriesByEvent) + guard !expectedCommands.isEmpty else { return false } + for (_, value) in hooksObject { + guard let entries = value.arrayValue else { continue } + for entry in entries { + guard let entryObject = entry.objectValue, + let command = entryObject["command"]?.stringValue + else { continue } + if expectedCommands.contains(command) { return true } + } + } + return false + } catch { + if !Self.isFileNotFound(error) { + logWarning("Failed to inspect Kiro hook settings at \(settingsURL.path): \(error)") + } + return false + } + } + + // MARK: - Install. + + func install( + settingsURL: URL, + hookEntriesByEvent: @autoclosure () throws -> [String: [JSONValue]] + ) throws { + let settingsObject = try loadSettingsObject(at: settingsURL) + let hookEntries = try hookEntriesByEvent() + let commandsToPrune = Self.commands(from: hookEntries) + var mergedObject = settingsObject + var hooksObject = (mergedObject["hooks"]?.objectValue) ?? [:] + + // Remove existing managed commands before re-adding. + for event in hooksObject.keys { + let existing = try existingEntries(for: event, hooksObject: hooksObject) + let filtered = existing.filter { !Self.isManaged($0, commands: commandsToPrune) } + if filtered.isEmpty { + hooksObject.removeValue(forKey: event) + } else { + hooksObject[event] = .array(filtered) + } + } + + // Add new entries. + for (event, newEntries) in hookEntries { + let existing = hooksObject[event]?.arrayValue ?? [] + hooksObject[event] = .array(existing + newEntries) + } + + mergedObject["hooks"] = .object(hooksObject) + try writeSettings(mergedObject, to: settingsURL) + } + + // MARK: - Uninstall. + + func uninstall( + settingsURL: URL, + hookEntriesByEvent: @autoclosure () throws -> [String: [JSONValue]] + ) throws { + let settingsObject = try loadSettingsObject(at: settingsURL) + let commandsToPrune = Self.commands(from: try hookEntriesByEvent()) + var mergedObject = settingsObject + var hooksObject = (mergedObject["hooks"]?.objectValue) ?? [:] + + for event in hooksObject.keys { + let existing = try existingEntries(for: event, hooksObject: hooksObject) + let filtered = existing.filter { !Self.isManaged($0, commands: commandsToPrune) } + if filtered.isEmpty { + hooksObject.removeValue(forKey: event) + } else { + hooksObject[event] = .array(filtered) + } + } + + mergedObject["hooks"] = .object(hooksObject) + try writeSettings(mergedObject, to: settingsURL) + } + + // MARK: - Helpers. + + private static func commands(from hookEntriesByEvent: [String: [JSONValue]]) -> Set { + var commands = Set() + for (_, entries) in hookEntriesByEvent { + for entry in entries { + guard let entryObject = entry.objectValue, + let command = entryObject["command"]?.stringValue + else { continue } + commands.insert(command) + } + } + return commands + } + + private static func isManaged(_ entry: JSONValue, commands: Set) -> Bool { + guard let entryObject = entry.objectValue, + let command = entryObject["command"]?.stringValue + else { return false } + if commands.contains(command) { return true } + return AgentHookCommandOwnership.isLegacyCommand(command) + } + + private func existingEntries( + for event: String, + hooksObject: [String: JSONValue] + ) throws -> [JSONValue] { + guard let existingValue = hooksObject[event] else { return [] } + guard let entries = existingValue.arrayValue else { + throw errors.invalidEventHooks(event) + } + return entries + } + + private func loadSettingsObject(at url: URL) throws -> [String: JSONValue] { + guard fileManager.fileExists(atPath: url.path) else { return [:] } + let data = try Data(contentsOf: url) + do { + let jsonValue = try JSONDecoder().decode(JSONValue.self, from: data) + guard let object = jsonValue.objectValue else { + throw LoadError.invalidRootObject + } + return object + } catch LoadError.invalidRootObject { + throw errors.invalidRootObject() + } catch { + throw errors.invalidJSON(error.localizedDescription) + } + } + + private func writeSettings(_ object: [String: JSONValue], to url: URL) throws { + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(JSONValue.object(object)) + try data.write(to: url, options: .atomic) + } + + private static func isFileNotFound(_ error: Error) -> Bool { + let nsError = error as NSError + return nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError + } +} diff --git a/supacode/Features/Settings/BusinessLogic/KiroSettingsInstaller.swift b/supacode/Features/Settings/BusinessLogic/KiroSettingsInstaller.swift new file mode 100644 index 00000000..fb078913 --- /dev/null +++ b/supacode/Features/Settings/BusinessLogic/KiroSettingsInstaller.swift @@ -0,0 +1,144 @@ +import Foundation + +nonisolated struct KiroSettingsInstaller { + let homeDirectoryURL: URL + let fileManager: FileManager + + init( + homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, + fileManager: FileManager = .default + ) { + self.homeDirectoryURL = homeDirectoryURL + self.fileManager = fileManager + } + + func isInstalled(progress: Bool) -> Bool { + let entries: [String: [JSONValue]] + do { + entries = + try progress + ? KiroHookSettings.progressHookEntriesByEvent() + : KiroHookSettings.notificationHookEntriesByEvent() + } catch { + Self.reportInvalidHookConfiguration(error, progress: progress) + return false + } + return fileInstaller.containsMatchingHooks( + settingsURL: settingsURL, + hookEntriesByEvent: entries + ) + } + + func installProgressHooks() throws { + try ensureDefaultAgentConfig() + try fileInstaller.install( + settingsURL: settingsURL, + hookEntriesByEvent: try KiroHookSettings.progressHookEntriesByEvent() + ) + } + + func installNotificationHooks() throws { + try ensureDefaultAgentConfig() + try fileInstaller.install( + settingsURL: settingsURL, + hookEntriesByEvent: try KiroHookSettings.notificationHookEntriesByEvent() + ) + } + + func uninstallProgressHooks() throws { + guard fileManager.fileExists(atPath: settingsURL.path) else { return } + try fileInstaller.uninstall( + settingsURL: settingsURL, + hookEntriesByEvent: try KiroHookSettings.progressHookEntriesByEvent() + ) + } + + func uninstallNotificationHooks() throws { + guard fileManager.fileExists(atPath: settingsURL.path) else { return } + try fileInstaller.uninstall( + settingsURL: settingsURL, + hookEntriesByEvent: try KiroHookSettings.notificationHookEntriesByEvent() + ) + } + + // MARK: - Default agent config. + + /// Creates `kiro_default.json` with the known built-in defaults when the file does not exist. + /// The config mirrors Kiro's built-in `kiro_default` agent — `tools: ["*"]` matches Kiro's + /// default tool access. Creating this file overrides the built-in entirely, so we must include + /// the full config (not just hooks). + private func ensureDefaultAgentConfig() throws { + guard !fileManager.fileExists(atPath: settingsURL.path) else { return } + try fileManager.createDirectory( + at: settingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let defaultConfig: [String: JSONValue] = [ + "name": .string("kiro_default"), + "tools": .array([.string("*")]), + "resources": .array([ + .string("file://AGENTS.md"), + .string("file://README.md"), + .string("skill://.kiro/skills/**/SKILL.md"), + .string("skill://.kiro/steering/**/*.md"), + ]), + "useLegacyMcpJson": .bool(true), + "hooks": .object([:]), + ] + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let data = try encoder.encode(JSONValue.object(defaultConfig)) + try data.write(to: settingsURL, options: .atomic) + } + + // MARK: - Paths. + + private var settingsURL: URL { + Self.settingsURL(homeDirectoryURL: homeDirectoryURL) + } + + static func settingsURL(homeDirectoryURL: URL) -> URL { + homeDirectoryURL + .appendingPathComponent(".kiro", isDirectory: true) + .appendingPathComponent("agents", isDirectory: true) + .appendingPathComponent("kiro_default.json", isDirectory: false) + } + + private static func reportInvalidHookConfiguration(_ error: Error, progress: Bool) { + #if DEBUG + assertionFailure("Kiro \(progress ? "progress" : "notification") hook configuration is invalid: \(error)") + #endif + } + + private var fileInstaller: KiroHookSettingsFileInstaller { + KiroHookSettingsFileInstaller( + fileManager: fileManager, + errors: .init( + invalidEventHooks: { KiroSettingsInstallerError.invalidEventHooks($0) }, + invalidHooksObject: { KiroSettingsInstallerError.invalidHooksObject }, + invalidJSON: { KiroSettingsInstallerError.invalidJSON($0) }, + invalidRootObject: { KiroSettingsInstallerError.invalidRootObject } + ) + ) + } +} + +nonisolated enum KiroSettingsInstallerError: Error, Equatable, LocalizedError { + case invalidEventHooks(String) + case invalidHooksObject + case invalidJSON(String) + case invalidRootObject + + var errorDescription: String? { + switch self { + case .invalidEventHooks(let event): + "Kiro agent config uses an unsupported hooks shape for \(event)." + case .invalidHooksObject: + "Kiro agent config uses an unsupported hooks shape." + case .invalidJSON(let detail): + "Kiro agent config must be valid JSON before Supacode can install hooks (\(detail))." + case .invalidRootObject: + "Kiro agent config must be a JSON object before Supacode can install hooks." + } + } +} diff --git a/supacode/Features/Settings/Models/AgentHooksInstallState.swift b/supacode/Features/Settings/Models/AgentHooksInstallState.swift index b2cf193a..7e87c0fe 100644 --- a/supacode/Features/Settings/Models/AgentHooksInstallState.swift +++ b/supacode/Features/Settings/Models/AgentHooksInstallState.swift @@ -35,4 +35,6 @@ enum AgentHookSlot: Equatable, Sendable { case claudeNotifications case codexProgress case codexNotifications + case kiroProgress + case kiroNotifications } diff --git a/supacode/Features/Settings/Models/SkillAgent.swift b/supacode/Features/Settings/Models/SkillAgent.swift index 68ce48ad..d6e2ff12 100644 --- a/supacode/Features/Settings/Models/SkillAgent.swift +++ b/supacode/Features/Settings/Models/SkillAgent.swift @@ -1,12 +1,14 @@ nonisolated enum SkillAgent: Equatable, Sendable, CaseIterable { case claude case codex + case kiro - /// The dot-directory name under the user's home (e.g. `.claude`, `.codex`). + /// The dot-directory name under the user's home (e.g. `.claude`, `.codex`, `.kiro`). var configDirectoryName: String { switch self { case .claude: ".claude" case .codex: ".codex" + case .kiro: ".kiro" } } } diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 687d091d..d1e3bfcf 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -40,6 +40,9 @@ struct SettingsFeature { var claudeNotificationsState = AgentHooksInstallState.checking var codexProgressState = AgentHooksInstallState.checking var codexNotificationsState = AgentHooksInstallState.checking + var kiroProgressState = AgentHooksInstallState.checking + var kiroNotificationsState = AgentHooksInstallState.checking + var kiroSkillState = AgentHooksInstallState.checking // nil = settings window closed, non-nil = open to this section. // The view layer opens the settings window when this becomes non-nil. var selection: SettingsSection? @@ -161,6 +164,7 @@ struct SettingsFeature { @Dependency(CLISkillClient.self) private var cliSkillClient @Dependency(ClaudeSettingsClient.self) private var claudeSettingsClient @Dependency(CodexSettingsClient.self) private var codexSettingsClient + @Dependency(KiroSettingsClient.self) private var kiroSettingsClient @Dependency(RepositoryPersistenceClient.self) private var repositoryPersistence @Dependency(SystemNotificationClient.self) private var systemNotificationClient @Dependency(\.date.now) private var now @@ -181,14 +185,18 @@ struct SettingsFeature { .run { [cliSkillClient] send in async let claude = cliSkillClient.checkInstalled(.claude) async let codex = cliSkillClient.checkInstalled(.codex) + async let kiro = cliSkillClient.checkInstalled(.kiro) await send(.cliSkillChecked(agent: .claude, installed: await claude)) await send(.cliSkillChecked(agent: .codex, installed: await codex)) + await send(.cliSkillChecked(agent: .kiro, installed: await kiro)) }, - .run { [claudeSettingsClient, codexSettingsClient] send in + .run { [claudeSettingsClient, codexSettingsClient, kiroSettingsClient] send in async let claudeProgressInstalled = claudeSettingsClient.checkInstalled(true) async let claudeNotificationsInstalled = claudeSettingsClient.checkInstalled(false) async let codexProgressInstalled = codexSettingsClient.checkInstalled(true) async let codexNotificationsInstalled = codexSettingsClient.checkInstalled(false) + async let kiroProgressInstalled = kiroSettingsClient.checkInstalled(true) + async let kiroNotificationsInstalled = kiroSettingsClient.checkInstalled(false) await send(.agentHookChecked(.claudeProgress, installed: await claudeProgressInstalled)) await send( @@ -196,6 +204,9 @@ struct SettingsFeature { await send(.agentHookChecked(.codexProgress, installed: await codexProgressInstalled)) await send( .agentHookChecked(.codexNotifications, installed: await codexNotificationsInstalled)) + await send(.agentHookChecked(.kiroProgress, installed: await kiroProgressInstalled)) + await send( + .agentHookChecked(.kiroNotifications, installed: await kiroNotificationsInstalled)) } ) ) @@ -369,13 +380,15 @@ struct SettingsFeature { case .agentHookInstallTapped(let slot): guard !state[hookSlot: slot].isLoading else { return .none } state[hookSlot: slot] = .installing - return .run { [claudeSettingsClient, codexSettingsClient] send in + return .run { [claudeSettingsClient, codexSettingsClient, kiroSettingsClient] send in do { switch slot { case .claudeProgress: try await claudeSettingsClient.installProgress() case .claudeNotifications: try await claudeSettingsClient.installNotifications() case .codexProgress: try await codexSettingsClient.installProgress() case .codexNotifications: try await codexSettingsClient.installNotifications() + case .kiroProgress: try await kiroSettingsClient.installProgress() + case .kiroNotifications: try await kiroSettingsClient.installNotifications() } await send(.agentHookActionCompleted(slot, .success(true))) } catch { @@ -386,13 +399,15 @@ struct SettingsFeature { case .agentHookUninstallTapped(let slot): guard !state[hookSlot: slot].isLoading else { return .none } state[hookSlot: slot] = .uninstalling - return .run { [claudeSettingsClient, codexSettingsClient] send in + return .run { [claudeSettingsClient, codexSettingsClient, kiroSettingsClient] send in do { switch slot { case .claudeProgress: try await claudeSettingsClient.uninstallProgress() case .claudeNotifications: try await claudeSettingsClient.uninstallNotifications() case .codexProgress: try await codexSettingsClient.uninstallProgress() case .codexNotifications: try await codexSettingsClient.uninstallNotifications() + case .kiroProgress: try await kiroSettingsClient.uninstallProgress() + case .kiroNotifications: try await kiroSettingsClient.uninstallNotifications() } await send(.agentHookActionCompleted(slot, .success(false))) } catch { @@ -555,12 +570,14 @@ extension SettingsFeature.State { switch agent { case .claude: claudeSkillState case .codex: codexSkillState + case .kiro: kiroSkillState } } set { switch agent { case .claude: claudeSkillState = newValue case .codex: codexSkillState = newValue + case .kiro: kiroSkillState = newValue } } } @@ -572,6 +589,8 @@ extension SettingsFeature.State { case .claudeNotifications: claudeNotificationsState case .codexProgress: codexProgressState case .codexNotifications: codexNotificationsState + case .kiroProgress: kiroProgressState + case .kiroNotifications: kiroNotificationsState } } set { @@ -580,6 +599,8 @@ extension SettingsFeature.State { case .claudeNotifications: claudeNotificationsState = newValue case .codexProgress: codexProgressState = newValue case .codexNotifications: codexNotificationsState = newValue + case .kiroProgress: kiroProgressState = newValue + case .kiroNotifications: kiroNotificationsState = newValue } } } diff --git a/supacode/Features/Settings/Views/DeveloperSettingsView.swift b/supacode/Features/Settings/Views/DeveloperSettingsView.swift index 0e6b9ee9..5899dced 100644 --- a/supacode/Features/Settings/Views/DeveloperSettingsView.swift +++ b/supacode/Features/Settings/Views/DeveloperSettingsView.swift @@ -87,6 +87,42 @@ struct DeveloperSettingsView: View { } footer: { Text("Applied to `~/.codex`.") } + Section { + AgentInstallRow( + installAction: { store.send(.agentHookInstallTapped(.kiroProgress)) }, + uninstallAction: { store.send(.agentHookUninstallTapped(.kiroProgress)) }, + installState: store.kiroProgressState, + title: "Progress Hook", + subtitle: "Display agent activity in tab and sidebar." + ) + AgentInstallRow( + installAction: { store.send(.agentHookInstallTapped(.kiroNotifications)) }, + uninstallAction: { store.send(.agentHookUninstallTapped(.kiroNotifications)) }, + installState: store.kiroNotificationsState, + title: "Notifications Hook", + subtitle: "Forward richer notifications to Supacode." + ) + AgentInstallRow( + installAction: { store.send(.cliSkillInstallTapped(.kiro)) }, + uninstallAction: { store.send(.cliSkillUninstallTapped(.kiro)) }, + installState: store.kiroSkillState, + title: "CLI Skill", + subtitle: "Teach Kiro how to use the Supacode CLI." + ) + } header: { + Label { + Text("Kiro") + } icon: { + Image("kiro-mark") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18, height: 18) + .accessibilityHidden(true) + } + .labelStyle(.titleTrailingIcon) + } footer: { + Text("Applied to `~/.kiro`.") + } } .formStyle(.grouped) .padding(.top, -20) diff --git a/supacode/Infrastructure/AgentHookSocketServer.swift b/supacode/Infrastructure/AgentHookSocketServer.swift index 333ab769..30ea2f59 100644 --- a/supacode/Infrastructure/AgentHookSocketServer.swift +++ b/supacode/Infrastructure/AgentHookSocketServer.swift @@ -385,7 +385,7 @@ final class AgentHookSocketServer { return nil } - let body = payload.message ?? payload.lastAssistantMessage + let body = payload.message ?? payload.lastAssistantMessage ?? payload.assistantResponse return AgentHookNotification( agent: agent, event: payload.hookEventName ?? "unknown", @@ -428,12 +428,14 @@ private nonisolated struct AgentHookPayload: Decodable { let title: String? let message: String? let lastAssistantMessage: String? + let assistantResponse: String? enum CodingKeys: String, CodingKey { case hookEventName = "hook_event_name" case title case message case lastAssistantMessage = "last_assistant_message" + case assistantResponse = "assistant_response" } } From 93ea4cfbc58e6d03dfdd9bd5a7b31aad6f3eb6da Mon Sep 17 00:00:00 2001 From: Benjamin <1159333+benjaminburzan@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:58:02 +0200 Subject: [PATCH 2/2] Address PR #245 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../BusinessLogic/KiroHookSettings.swift | 28 +--- .../BusinessLogic/KiroSettingsInstaller.swift | 6 +- .../CodingAgents/KiroSettingsClient.swift | 58 ++++++++ .../CodingAgents/KiroSettingsClient.swift | 44 ------ .../Views/DeveloperSettingsView.swift | 5 +- .../KiroHookSettingsFileInstallerTests.swift | 140 ++++++++++++++++++ .../KiroSettingsInstallerTests.swift | 119 +++++++++++++++ .../SettingsFeatureAgentHookTests.swift | 120 ++++++++++++++- supacodeTests/SettingsFeatureTests.swift | 5 + 9 files changed, 446 insertions(+), 79 deletions(-) create mode 100644 SupacodeSettingsShared/Clients/CodingAgents/KiroSettingsClient.swift delete mode 100644 supacode/Clients/CodingAgents/KiroSettingsClient.swift create mode 100644 supacodeTests/KiroHookSettingsFileInstallerTests.swift create mode 100644 supacodeTests/KiroSettingsInstallerTests.swift diff --git a/SupacodeSettingsShared/BusinessLogic/KiroHookSettings.swift b/SupacodeSettingsShared/BusinessLogic/KiroHookSettings.swift index a4735076..1f3d5a88 100644 --- a/SupacodeSettingsShared/BusinessLogic/KiroHookSettings.swift +++ b/SupacodeSettingsShared/BusinessLogic/KiroHookSettings.swift @@ -6,14 +6,14 @@ nonisolated enum KiroHookSettings { fileprivate static let notify = AgentHookSettingsCommand.notificationCommand(agent: "kiro") static func progressHookEntriesByEvent() throws -> [String: [JSONValue]] { - try KiroHookPayloadSupport.extractHookEntries( + try AgentHookPayloadSupport.extractHookGroups( from: KiroProgressPayload(), invalidConfiguration: KiroHookSettingsError.invalidConfiguration ) } static func notificationHookEntriesByEvent() throws -> [String: [JSONValue]] { - try KiroHookPayloadSupport.extractHookEntries( + try AgentHookPayloadSupport.extractHookGroups( from: KiroNotificationPayload(), invalidConfiguration: KiroHookSettingsError.invalidConfiguration ) @@ -60,27 +60,3 @@ private nonisolated struct KiroNotificationPayload: Encodable { ], ] } - -// MARK: - Payload support for flat format. - -nonisolated enum KiroHookPayloadSupport { - static func extractHookEntries( - from payload: T, - invalidConfiguration: @autoclosure () -> Error - ) throws -> [String: [JSONValue]] { - guard - let objectValue = try JSONValue(payload).objectValue, - let hooksValue = objectValue["hooks"]?.objectValue - else { - throw invalidConfiguration() - } - var result: [String: [JSONValue]] = [:] - for (event, value) in hooksValue { - guard let entries = value.arrayValue else { - throw invalidConfiguration() - } - result[event] = entries - } - return result - } -} diff --git a/SupacodeSettingsShared/BusinessLogic/KiroSettingsInstaller.swift b/SupacodeSettingsShared/BusinessLogic/KiroSettingsInstaller.swift index fb078913..4f28559f 100644 --- a/SupacodeSettingsShared/BusinessLogic/KiroSettingsInstaller.swift +++ b/SupacodeSettingsShared/BusinessLogic/KiroSettingsInstaller.swift @@ -64,9 +64,9 @@ nonisolated struct KiroSettingsInstaller { // MARK: - Default agent config. /// Creates `kiro_default.json` with the known built-in defaults when the file does not exist. - /// The config mirrors Kiro's built-in `kiro_default` agent — `tools: ["*"]` matches Kiro's - /// default tool access. Creating this file overrides the built-in entirely, so we must include - /// the full config (not just hooks). + /// The config mirrors Kiro's built-in `kiro_default` agent as of Kiro 1.x (2026-04). `tools: ["*"]` + /// matches Kiro's default tool access. Creating this file overrides the built-in entirely, so we must + /// include the full config (not just hooks). private func ensureDefaultAgentConfig() throws { guard !fileManager.fileExists(atPath: settingsURL.path) else { return } try fileManager.createDirectory( diff --git a/SupacodeSettingsShared/Clients/CodingAgents/KiroSettingsClient.swift b/SupacodeSettingsShared/Clients/CodingAgents/KiroSettingsClient.swift new file mode 100644 index 00000000..96c737dd --- /dev/null +++ b/SupacodeSettingsShared/Clients/CodingAgents/KiroSettingsClient.swift @@ -0,0 +1,58 @@ +import ComposableArchitecture +import Foundation + +public nonisolated struct KiroSettingsClient: Sendable { + public var checkInstalled: @Sendable (Bool) async -> Bool + public var installProgress: @Sendable () async throws -> Void + public var installNotifications: @Sendable () async throws -> Void + public var uninstallProgress: @Sendable () async throws -> Void + public var uninstallNotifications: @Sendable () async throws -> Void + + public init( + checkInstalled: @escaping @Sendable (Bool) async -> Bool, + installProgress: @escaping @Sendable () async throws -> Void, + installNotifications: @escaping @Sendable () async throws -> Void, + uninstallProgress: @escaping @Sendable () async throws -> Void, + uninstallNotifications: @escaping @Sendable () async throws -> Void + ) { + self.checkInstalled = checkInstalled + self.installProgress = installProgress + self.installNotifications = installNotifications + self.uninstallProgress = uninstallProgress + self.uninstallNotifications = uninstallNotifications + } +} + +extension KiroSettingsClient: DependencyKey { + public static let liveValue = Self( + checkInstalled: { progress in + KiroSettingsInstaller().isInstalled(progress: progress) + }, + installProgress: { + try KiroSettingsInstaller().installProgressHooks() + }, + installNotifications: { + try KiroSettingsInstaller().installNotificationHooks() + }, + uninstallProgress: { + try KiroSettingsInstaller().uninstallProgressHooks() + }, + uninstallNotifications: { + try KiroSettingsInstaller().uninstallNotificationHooks() + } + ) + public static let testValue = Self( + checkInstalled: { _ in false }, + installProgress: {}, + installNotifications: {}, + uninstallProgress: {}, + uninstallNotifications: {} + ) +} + +public extension DependencyValues { + var kiroSettingsClient: KiroSettingsClient { + get { self[KiroSettingsClient.self] } + set { self[KiroSettingsClient.self] = newValue } + } +} diff --git a/supacode/Clients/CodingAgents/KiroSettingsClient.swift b/supacode/Clients/CodingAgents/KiroSettingsClient.swift deleted file mode 100644 index 7bfbb626..00000000 --- a/supacode/Clients/CodingAgents/KiroSettingsClient.swift +++ /dev/null @@ -1,44 +0,0 @@ -import ComposableArchitecture -import Foundation - -struct KiroSettingsClient: Sendable { - var checkInstalled: @Sendable (Bool) async -> Bool - var installProgress: @Sendable () async throws -> Void - var installNotifications: @Sendable () async throws -> Void - var uninstallProgress: @Sendable () async throws -> Void - var uninstallNotifications: @Sendable () async throws -> Void -} - -extension KiroSettingsClient: DependencyKey { - static let liveValue = Self( - checkInstalled: { progress in - KiroSettingsInstaller().isInstalled(progress: progress) - }, - installProgress: { - try KiroSettingsInstaller().installProgressHooks() - }, - installNotifications: { - try KiroSettingsInstaller().installNotificationHooks() - }, - uninstallProgress: { - try KiroSettingsInstaller().uninstallProgressHooks() - }, - uninstallNotifications: { - try KiroSettingsInstaller().uninstallNotificationHooks() - } - ) - static let testValue = Self( - checkInstalled: { _ in false }, - installProgress: {}, - installNotifications: {}, - uninstallProgress: {}, - uninstallNotifications: {} - ) -} - -extension DependencyValues { - var kiroSettingsClient: KiroSettingsClient { - get { self[KiroSettingsClient.self] } - set { self[KiroSettingsClient.self] = newValue } - } -} diff --git a/supacode/Features/Settings/Views/DeveloperSettingsView.swift b/supacode/Features/Settings/Views/DeveloperSettingsView.swift index 42848abf..cfbb0489 100644 --- a/supacode/Features/Settings/Views/DeveloperSettingsView.swift +++ b/supacode/Features/Settings/Views/DeveloperSettingsView.swift @@ -5,6 +5,7 @@ import SwiftUI struct DeveloperSettingsView: View { let store: StoreOf + @State private var kiroExpanded = false var body: some View { Form { @@ -89,7 +90,7 @@ struct DeveloperSettingsView: View { } footer: { Text("Applied to `~/.codex`.") } - Section { + Section(isExpanded: $kiroExpanded) { AgentInstallRow( installAction: { store.send(.agentHookInstallTapped(.kiroProgress)) }, uninstallAction: { store.send(.agentHookUninstallTapped(.kiroProgress)) }, @@ -122,8 +123,6 @@ struct DeveloperSettingsView: View { .accessibilityHidden(true) } .labelStyle(.titleTrailingIcon) - } footer: { - Text("Applied to `~/.kiro`.") } } .formStyle(.grouped) diff --git a/supacodeTests/KiroHookSettingsFileInstallerTests.swift b/supacodeTests/KiroHookSettingsFileInstallerTests.swift new file mode 100644 index 00000000..6341d506 --- /dev/null +++ b/supacodeTests/KiroHookSettingsFileInstallerTests.swift @@ -0,0 +1,140 @@ +import Foundation +import Testing + +@testable import SupacodeSettingsShared +@testable import supacode + +struct KiroHookSettingsFileInstallerTests { + private let fileManager = FileManager.default + + private func makeErrors() -> KiroHookSettingsFileInstaller.Errors { + .init( + invalidEventHooks: { TestInstallerError.invalidEventHooks($0) }, + invalidHooksObject: { TestInstallerError.invalidHooksObject }, + invalidJSON: { TestInstallerError.invalidJSON($0) }, + invalidRootObject: { TestInstallerError.invalidRootObject }, + ) + } + + private func makeInstaller() -> KiroHookSettingsFileInstaller { + KiroHookSettingsFileInstaller(fileManager: fileManager, errors: makeErrors()) + } + + private func makeTempURL() -> URL { + URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("supacode-kiro-test-\(UUID().uuidString)") + .appendingPathComponent("kiro_default.json") + } + + private func sampleHookEntries() -> [String: [JSONValue]] { + [ + "stop": [ + .object([ + "command": .string(AgentHookSettingsCommand.busyCommand(active: false)), + "timeout_ms": 10_000, + ]), + ], + ] + } + + // MARK: - Install. + + @Test func installIntoEmptyFileCreatesCorrectStructure() throws { + let url = makeTempURL() + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } + + let installer = makeInstaller() + try installer.install(settingsURL: url, hookEntriesByEvent: sampleHookEntries()) + + let data = try Data(contentsOf: url) + let root = try JSONDecoder().decode(JSONValue.self, from: data) + guard let hooksObject = root.objectValue?["hooks"]?.objectValue else { + Issue.record("Expected hooks object") + return + } + #expect(hooksObject["stop"] != nil) + let stopEntries = hooksObject["stop"]?.arrayValue + #expect(stopEntries?.count == 1) + } + + @Test func installPreservesExistingNonHookKeys() throws { + let url = makeTempURL() + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } + + let existing: JSONValue = .object(["name": "kiro_default", "tools": .array([.string("*")])]) + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + try JSONEncoder().encode(existing).write(to: url) + + let installer = makeInstaller() + try installer.install(settingsURL: url, hookEntriesByEvent: sampleHookEntries()) + + let data = try Data(contentsOf: url) + let root = try JSONDecoder().decode(JSONValue.self, from: data) + #expect(root.objectValue?["name"]?.stringValue == "kiro_default") + #expect(root.objectValue?["hooks"] != nil) + } + + @Test func installIsIdempotent() throws { + let url = makeTempURL() + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } + + let installer = makeInstaller() + let entries = sampleHookEntries() + try installer.install(settingsURL: url, hookEntriesByEvent: entries) + try installer.install(settingsURL: url, hookEntriesByEvent: entries) + + let data = try Data(contentsOf: url) + let root = try JSONDecoder().decode(JSONValue.self, from: data) + let stopEntries = root.objectValue?["hooks"]?.objectValue?["stop"]?.arrayValue + #expect(stopEntries?.count == 1) + } + + // MARK: - Uninstall. + + @Test func uninstallRemovesHookEntries() throws { + let url = makeTempURL() + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } + + let installer = makeInstaller() + let entries = sampleHookEntries() + try installer.install(settingsURL: url, hookEntriesByEvent: entries) + try installer.uninstall(settingsURL: url, hookEntriesByEvent: entries) + + let data = try Data(contentsOf: url) + let root = try JSONDecoder().decode(JSONValue.self, from: data) + let stopEntries = root.objectValue?["hooks"]?.objectValue?["stop"]?.arrayValue + #expect(stopEntries == nil || stopEntries?.isEmpty == true) + } + + // MARK: - Check. + + @Test func containsMatchingHooksReturnsFalseForMissingFile() { + let url = makeTempURL() + let installer = makeInstaller() + #expect(installer.containsMatchingHooks(settingsURL: url, hookEntriesByEvent: sampleHookEntries()) == false) + } + + @Test func containsMatchingHooksReturnsTrueAfterInstall() throws { + let url = makeTempURL() + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } + + let installer = makeInstaller() + let entries = sampleHookEntries() + try installer.install(settingsURL: url, hookEntriesByEvent: entries) + #expect(installer.containsMatchingHooks(settingsURL: url, hookEntriesByEvent: entries) == true) + } + + @Test func containsMatchingHooksReturnsFalseAfterUninstall() throws { + let url = makeTempURL() + defer { try? fileManager.removeItem(at: url.deletingLastPathComponent()) } + + let installer = makeInstaller() + let entries = sampleHookEntries() + try installer.install(settingsURL: url, hookEntriesByEvent: entries) + try installer.uninstall(settingsURL: url, hookEntriesByEvent: entries) + #expect(installer.containsMatchingHooks(settingsURL: url, hookEntriesByEvent: entries) == false) + } +} diff --git a/supacodeTests/KiroSettingsInstallerTests.swift b/supacodeTests/KiroSettingsInstallerTests.swift new file mode 100644 index 00000000..c5a37de2 --- /dev/null +++ b/supacodeTests/KiroSettingsInstallerTests.swift @@ -0,0 +1,119 @@ +import Foundation +import Testing + +@testable import SupacodeSettingsShared +@testable import supacode + +struct KiroSettingsInstallerTests { + private let fileManager = FileManager.default + + private func makeTempHomeURL() -> URL { + URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("supacode-kiro-installer-\(UUID().uuidString)", isDirectory: true) + } + + @Test func installProgressHooksCreatesDefaultConfigWhenMissing() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let installer = KiroSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + try installer.installProgressHooks() + + let settingsURL = KiroSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) + #expect(fileManager.fileExists(atPath: settingsURL.path)) + } + + @Test func installNotificationHooksCreatesDefaultConfigWhenMissing() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let installer = KiroSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + try installer.installNotificationHooks() + + let settingsURL = KiroSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) + #expect(fileManager.fileExists(atPath: settingsURL.path)) + } + + @Test func installProgressHooksDoesNotOverwriteExistingConfig() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let installer = KiroSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + try installer.installProgressHooks() + + let settingsURL = KiroSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) + let firstWrite = try Data(contentsOf: settingsURL) + + // Second install should not recreate the base config. + try installer.installProgressHooks() + let secondWrite = try Data(contentsOf: settingsURL) + + // Content must still be valid JSON after second install. + #expect(try JSONDecoder().decode(JSONValue.self, from: secondWrite).objectValue != nil) + // Hooks section should still exist. + let json = try JSONDecoder().decode(JSONValue.self, from: secondWrite) + #expect(json.objectValue?["hooks"] != nil) + _ = firstWrite // used + } + + @Test func uninstallProgressHooksIsNoOpWhenFileMissing() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let installer = KiroSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + // Must not throw even when the file doesn't exist. + #expect(throws: Never.self) { + try installer.uninstallProgressHooks() + } + } + + @Test func uninstallNotificationHooksIsNoOpWhenFileMissing() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let installer = KiroSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + #expect(throws: Never.self) { + try installer.uninstallNotificationHooks() + } + } + + @Test func isInstalledProgressReturnsFalseBeforeInstall() { + let homeURL = makeTempHomeURL() + let installer = KiroSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + #expect(installer.isInstalled(progress: true) == false) + } + + @Test func isInstalledProgressReturnsTrueAfterInstall() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let installer = KiroSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + try installer.installProgressHooks() + #expect(installer.isInstalled(progress: true) == true) + } + + @Test func isInstalledNotificationsReturnsTrueAfterInstall() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let installer = KiroSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + try installer.installNotificationHooks() + #expect(installer.isInstalled(progress: false) == true) + } + + @Test func isInstalledProgressReturnsFalseAfterUninstall() throws { + let homeURL = makeTempHomeURL() + defer { try? fileManager.removeItem(at: homeURL) } + + let installer = KiroSettingsInstaller(homeDirectoryURL: homeURL, fileManager: fileManager) + try installer.installProgressHooks() + try installer.uninstallProgressHooks() + #expect(installer.isInstalled(progress: true) == false) + } + + @Test func settingsURLPointsToExpectedPath() { + let homeURL = URL(fileURLWithPath: "/Users/test") + let url = KiroSettingsInstaller.settingsURL(homeDirectoryURL: homeURL) + #expect(url.path == "/Users/test/.kiro/agents/kiro_default.json") + } +} diff --git a/supacodeTests/SettingsFeatureAgentHookTests.swift b/supacodeTests/SettingsFeatureAgentHookTests.swift index 4d24b610..6a845022 100644 --- a/supacodeTests/SettingsFeatureAgentHookTests.swift +++ b/supacodeTests/SettingsFeatureAgentHookTests.swift @@ -140,6 +140,14 @@ struct SettingsFeatureAgentHookTests { } return progress } + $0[KiroSettingsClient.self].checkInstalled = { progress in + let key = progress ? "kiroProgress" : "kiroNotifications" + _ = startedChecks.withValue { $0.insert(key) } + await withCheckedContinuation { continuation in + continuations.withValue { $0.append(continuation) } + } + return progress + } } store.exhaustivity = .off(showSkippedAssertions: false) @@ -148,9 +156,9 @@ struct SettingsFeatureAgentHookTests { // CLI/skill/hook checks run in parallel via `.merge`. // CLI/skill mocks return immediately; hook checks block on continuations. - // Wait for all four hook checks to start. + // Wait for all six hook checks to start. await eventually { - startedChecks.value.count == 4 + startedChecks.value.count == 6 } continuations.withValue { continuations in @@ -163,7 +171,7 @@ struct SettingsFeatureAgentHookTests { await store.skipReceivedActions() } - @Test(.dependencies) func taskChecksAllFourHookSlotsOnStartup() async { + @Test(.dependencies) func taskChecksAllSixHookSlotsOnStartup() async { let checkedSlots = LockIsolated<[String]>([]) let store = TestStore(initialState: SettingsFeature.State()) { @@ -179,6 +187,10 @@ struct SettingsFeatureAgentHookTests { checkedSlots.withValue { $0.append(progress ? "codexProgress" : "codexNotifications") } return progress } + $0[KiroSettingsClient.self].checkInstalled = { progress in + checkedSlots.withValue { $0.append(progress ? "kiroProgress" : "kiroNotifications") } + return progress + } } store.exhaustivity = .off(showSkippedAssertions: false) @@ -192,9 +204,111 @@ struct SettingsFeatureAgentHookTests { "claudeNotifications", "codexProgress", "codexNotifications", + "kiroProgress", + "kiroNotifications", ]) } + // MARK: - Kiro hook actions. + + @Test(.dependencies) func agentHookInstallTappedKiroProgressTransitionsToInstalled() async { + var state = SettingsFeature.State() + state.kiroProgressState = .notInstalled + + let store = TestStore(initialState: state) { + SettingsFeature() + } withDependencies: { + $0[KiroSettingsClient.self].installProgress = {} + } + + await store.send(.agentHookInstallTapped(.kiroProgress)) { + $0.kiroProgressState = .installing + } + await store.receive(\.agentHookActionCompleted) { + $0.kiroProgressState = .installed + } + } + + @Test(.dependencies) func agentHookUninstallTappedKiroProgressTransitionsToNotInstalled() async { + var state = SettingsFeature.State() + state.kiroProgressState = .installed + + let store = TestStore(initialState: state) { + SettingsFeature() + } withDependencies: { + $0[KiroSettingsClient.self].uninstallProgress = {} + } + + await store.send(.agentHookUninstallTapped(.kiroProgress)) { + $0.kiroProgressState = .uninstalling + } + await store.receive(\.agentHookActionCompleted) { + $0.kiroProgressState = .notInstalled + } + } + + @Test(.dependencies) func agentHookInstallTappedKiroNotificationsTransitionsToInstalled() async { + var state = SettingsFeature.State() + state.kiroNotificationsState = .notInstalled + + let store = TestStore(initialState: state) { + SettingsFeature() + } withDependencies: { + $0[KiroSettingsClient.self].installNotifications = {} + } + + await store.send(.agentHookInstallTapped(.kiroNotifications)) { + $0.kiroNotificationsState = .installing + } + await store.receive(\.agentHookActionCompleted) { + $0.kiroNotificationsState = .installed + } + } + + @Test(.dependencies) func agentHookUninstallTappedKiroNotificationsTransitionsToNotInstalled() async { + var state = SettingsFeature.State() + state.kiroNotificationsState = .installed + + let store = TestStore(initialState: state) { + SettingsFeature() + } withDependencies: { + $0[KiroSettingsClient.self].uninstallNotifications = {} + } + + await store.send(.agentHookUninstallTapped(.kiroNotifications)) { + $0.kiroNotificationsState = .uninstalling + } + await store.receive(\.agentHookActionCompleted) { + $0.kiroNotificationsState = .notInstalled + } + } + + @Test(.dependencies) func agentHookCheckedKiroProgressSetsInstalled() async { + var state = SettingsFeature.State() + state.kiroProgressState = .checking + + let store = TestStore(initialState: state) { + SettingsFeature() + } + + await store.send(.agentHookChecked(.kiroProgress, installed: true)) { + $0.kiroProgressState = .installed + } + } + + @Test(.dependencies) func agentHookCheckedKiroNotificationsSetsNotInstalled() async { + var state = SettingsFeature.State() + state.kiroNotificationsState = .checking + + let store = TestStore(initialState: state) { + SettingsFeature() + } + + await store.send(.agentHookChecked(.kiroNotifications, installed: false)) { + $0.kiroNotificationsState = .notInstalled + } + } + private func eventually( maxYields: Int = 100, _ predicate: () -> Bool diff --git a/supacodeTests/SettingsFeatureTests.swift b/supacodeTests/SettingsFeatureTests.swift index eb1d2cc3..ae556419 100644 --- a/supacodeTests/SettingsFeatureTests.swift +++ b/supacodeTests/SettingsFeatureTests.swift @@ -43,6 +43,7 @@ struct SettingsFeatureTests { $0[CLISkillClient.self].checkInstalled = { _ in false } $0[ClaudeSettingsClient.self].checkInstalled = { _ in false } $0[CodexSettingsClient.self].checkInstalled = { _ in false } + $0[KiroSettingsClient.self].checkInstalled = { _ in false } } store.exhaustivity = .off(showSkippedAssertions: false) @@ -525,6 +526,7 @@ struct SettingsFeatureTests { $0[CLISkillClient.self].checkInstalled = { _ in false } $0[ClaudeSettingsClient.self].checkInstalled = { _ in false } $0[CodexSettingsClient.self].checkInstalled = { _ in false } + $0[KiroSettingsClient.self].checkInstalled = { _ in false } } store.exhaustivity = .off(showSkippedAssertions: false) @@ -829,8 +831,11 @@ private func receiveStartupHookChecks(from store: TestStoreOf) #expect(store.state.cliInstallState == .notInstalled) #expect(store.state.claudeSkillState == .notInstalled) #expect(store.state.codexSkillState == .notInstalled) + #expect(store.state.kiroSkillState == .notInstalled) #expect(store.state.claudeProgressState == .notInstalled) #expect(store.state.claudeNotificationsState == .notInstalled) #expect(store.state.codexProgressState == .notInstalled) #expect(store.state.codexNotificationsState == .notInstalled) + #expect(store.state.kiroProgressState == .notInstalled) + #expect(store.state.kiroNotificationsState == .notInstalled) }