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)
}