diff --git a/supacode/Features/Settings/Reducer/AgentSkillsFeature.swift b/supacode/Features/Settings/Reducer/AgentSkillsFeature.swift new file mode 100644 index 00000000..6f50a110 --- /dev/null +++ b/supacode/Features/Settings/Reducer/AgentSkillsFeature.swift @@ -0,0 +1,176 @@ +import ComposableArchitecture +import Foundation + +/// Settings → Agents → Command Line Tool → Agent Skills (docs-ai 065): the `user`-audience +/// skills bundled in this app, one status chip per detected user target, and one explicit +/// action per skill × target link. Statuses come from the shared installer, so the section +/// always agrees with `prowl skills list`. +@Reducer +struct AgentSkillsFeature { + @ObservableState + struct State: Equatable { + var skills: IdentifiedArrayOf = [] + /// Why the bundle could not be read (for example a Debug build without staged skills). + var loadError: String? + @Presents var alert: AlertState? + + /// Skills exist but no agent skill folder was found in the home directory. + var noTargetsDetected: Bool { + !skills.isEmpty && skills.allSatisfy { $0.links.isEmpty } + } + } + + struct SkillRow: Equatable, Identifiable { + let skill: BundledSkill + /// Detected targets only, in `SkillInstallTarget.all` order. + var links: IdentifiedArrayOf + + var id: String { skill.id } + } + + struct SkillLink: Equatable, Identifiable { + let target: SkillInstallTarget + let linkPath: String + var status: SymlinkInstallStatus + + var id: String { target.id } + } + + enum Action: Equatable { + case task + /// Install, Repair, and Replace: every one replaces the slot with a link to this bundle. + case installLink(skillID: String, targetID: String) + case removeLink(skillID: String, targetID: String) + case revealSkillButtonTapped(skillID: String) + case linkChangeCompleted(Result) + case alert(PresentationAction) + case delegate(Delegate) + } + + enum LinkChange: Equatable { + case installed(skillID: String, targetID: String) + case removed(skillID: String, targetID: String) + } + + enum Alert: Equatable { + case dismiss + } + + @CasePathable + enum Delegate: Equatable { + case linkChanged(AgentSkillsResultMessage) + } + + @Dependency(SkillInstallClient.self) private var skillInstallClient + + var body: some Reducer { + Reduce { state, action in + switch action { + case .task: + reload(&state) + return .none + + case .installLink(let skillID, let targetID): + guard let (skill, target) = link(skillID: skillID, targetID: targetID, in: state) else { return .none } + return .run { [skillInstallClient] send in + do { + try await skillInstallClient.install(skill, target) + await send(.linkChangeCompleted(.success(.installed(skillID: skillID, targetID: targetID)))) + } catch let error as SkillInstallError { + await send(.linkChangeCompleted(.failure(error))) + } catch { + await send(.linkChangeCompleted(.failure(SkillInstallError(message: error.localizedDescription)))) + } + } + + case .removeLink(let skillID, let targetID): + guard let (skill, target) = link(skillID: skillID, targetID: targetID, in: state) else { return .none } + return .run { [skillInstallClient] send in + do { + try await skillInstallClient.uninstall(skill, target) + await send(.linkChangeCompleted(.success(.removed(skillID: skillID, targetID: targetID)))) + } catch let error as SkillInstallError { + await send(.linkChangeCompleted(.failure(error))) + } catch { + await send(.linkChangeCompleted(.failure(SkillInstallError(message: error.localizedDescription)))) + } + } + + case .revealSkillButtonTapped(let skillID): + guard let skill = state.skills[id: skillID]?.skill else { return .none } + return .run { [skillInstallClient] _ in + skillInstallClient.revealSkill(skill) + } + + case .linkChangeCompleted(.success(let change)): + // Targets can alias one folder (synced ~/.claude/skills and ~/.codex/skills), so every + // chip is recomputed rather than just the one that was acted on. + reload(&state) + let message: AgentSkillsResultMessage = + switch change { + case .installed(let skillID, let targetID): + .installed(skill: skillID, target: targetDisplayName(targetID)) + case .removed(let skillID, let targetID): + .removed(skill: skillID, target: targetDisplayName(targetID)) + } + return .send(.delegate(.linkChanged(message))) + + case .linkChangeCompleted(.failure(let error)): + reload(&state) + state.alert = AlertState { + TextState("Agent Skills Error") + } actions: { + ButtonState(action: .dismiss) { TextState("OK") } + } message: { + TextState(error.message) + } + return .send(.delegate(.linkChanged(.failed(message: error.message)))) + + case .alert: + return .none + + case .delegate: + return .none + } + } + .ifLet(\.$alert, action: \.alert) + } + + private func reload(_ state: inout State) { + do { + let skills = try skillInstallClient.bundledSkills().filter { $0.audience == .user } + state.skills = IdentifiedArray( + uniqueElements: skills.map { skill in + let links = SkillInstallTarget.all.compactMap { target -> SkillLink? in + let status = skillInstallClient.status(skill, target) + guard status.detected else { return nil } + return SkillLink(target: target, linkPath: status.linkPath, status: status.status) + } + return SkillRow(skill: skill, links: IdentifiedArray(uniqueElements: links)) + } + ) + state.loadError = nil + } catch let error as SkillInstallError { + state.skills = [] + state.loadError = error.message + } catch { + state.skills = [] + state.loadError = error.localizedDescription + } + } + + private func link(skillID: String, targetID: String, in state: State) -> (BundledSkill, SkillInstallTarget)? { + guard let row = state.skills[id: skillID], let link = row.links[id: targetID] else { return nil } + return (row.skill, link.target) + } + + private func targetDisplayName(_ targetID: String) -> String { + SkillInstallTarget.target(id: targetID)?.displayName ?? targetID + } +} + +enum AgentSkillsResultMessage: Equatable { + case installed(skill: String, target: String) + case removed(skill: String, target: String) + case failed(message: String) +} diff --git a/supacodeTests/AgentSkillsFeatureTests.swift b/supacodeTests/AgentSkillsFeatureTests.swift new file mode 100644 index 00000000..cdb498de --- /dev/null +++ b/supacodeTests/AgentSkillsFeatureTests.swift @@ -0,0 +1,270 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct AgentSkillsFeatureTests { + @Test(.dependencies) func taskListsUserSkillsWithDetectedTargetsOnly() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let store = makeStore(fixture.client) + + await store.send(.task) { + $0.skills = [ + try fixture.row("prowl-cli", links: [("claude", .notInstalled), ("agents", .notInstalled)]) + ] + } + } + + @Test(.dependencies) func taskReportsAMissingBundle() async { + var client = SkillInstallClient.testValue + client.bundledSkills = { throw SkillInstallError(message: "Bundled skills were not found at /nowhere/skills.") } + let store = makeStore(client) + + await store.send(.task) { + $0.loadError = "Bundled skills were not found at /nowhere/skills." + } + } + + @Test(.dependencies) func installLinksTheSkillAndRefreshesTheRow() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let store = makeStore(fixture.client) + await store.send(.task) { + $0.skills = [ + try fixture.row("prowl-cli", links: [("claude", .notInstalled), ("agents", .notInstalled)]) + ] + } + + await store.send(.installLink(skillID: "prowl-cli", targetID: "claude")) + await store.receive(\.linkChangeCompleted.success) { + $0.skills[id: "prowl-cli"]?.links[id: "claude"]?.status = + .installed(path: fixture.linkPath(target: ".claude", skill: "prowl-cli")) + } + await store.receive(.delegate(.linkChanged(.installed(skill: "prowl-cli", target: "Claude Code")))) + + #expect( + try FileManager.default.destinationOfSymbolicLink(atPath: fixture.linkPath(target: ".claude", skill: "prowl-cli")) + == fixture.skillDirectory("prowl-cli") + ) + #expect(!FileManager.default.fileExists(atPath: fixture.linkPath(target: ".agents", skill: "prowl-cli"))) + } + + @Test(.dependencies) func removeDeletesTheLinkAndRefreshesTheRow() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + try fixture.link(target: ".claude", skill: "prowl-cli", to: fixture.skillDirectory("prowl-cli")) + let claudePath = fixture.linkPath(target: ".claude", skill: "prowl-cli") + let store = makeStore(fixture.client) + await store.send(.task) { + $0.skills = [ + try fixture.row("prowl-cli", links: [("claude", .installed(path: claudePath)), ("agents", .notInstalled)]) + ] + } + + await store.send(.removeLink(skillID: "prowl-cli", targetID: "claude")) + await store.receive(\.linkChangeCompleted.success) { + $0.skills[id: "prowl-cli"]?.links[id: "claude"]?.status = .notInstalled + } + await store.receive(.delegate(.linkChanged(.removed(skill: "prowl-cli", target: "Claude Code")))) + + #expect((try? FileManager.default.attributesOfItem(atPath: claudePath)) == nil) + #expect(FileManager.default.fileExists(atPath: fixture.skillDirectory("prowl-cli"))) + } + + @Test(.dependencies) func installRepairsABrokenLink() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let gone = fixture.root.appending(path: "gone").path(percentEncoded: false) + try fixture.link(target: ".claude", skill: "prowl-cli", to: gone) + let claudePath = fixture.linkPath(target: ".claude", skill: "prowl-cli") + let store = makeStore(fixture.client) + await store.send(.task) { + $0.skills = [ + try fixture.row( + "prowl-cli", + links: [("claude", .broken(path: claudePath, destination: gone)), ("agents", .notInstalled)] + ) + ] + } + + await store.send(.installLink(skillID: "prowl-cli", targetID: "claude")) + await store.receive(\.linkChangeCompleted.success) { + $0.skills[id: "prowl-cli"]?.links[id: "claude"]?.status = .installed(path: claudePath) + } + await store.receive(.delegate(.linkChanged(.installed(skill: "prowl-cli", target: "Claude Code")))) + } + + @Test(.dependencies) func installReplacesALinkToAnotherBuild() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let debugBuild = fixture.root.appending(path: "DerivedData/skills/prowl-cli", directoryHint: .isDirectory) + try fixture.makeDirectory(debugBuild) + let debugPath = debugBuild.path(percentEncoded: false).trimmingTrailingPathSeparator() + try fixture.link(target: ".agents", skill: "prowl-cli", to: debugPath) + let agentsPath = fixture.linkPath(target: ".agents", skill: "prowl-cli") + let store = makeStore(fixture.client) + await store.send(.task) { + $0.skills = [ + try fixture.row( + "prowl-cli", + links: [ + ("claude", .notInstalled), + ("agents", .installedDifferentSource(path: agentsPath, destination: debugPath)), + ] + ) + ] + } + + await store.send(.installLink(skillID: "prowl-cli", targetID: "agents")) + await store.receive(\.linkChangeCompleted.success) { + $0.skills[id: "prowl-cli"]?.links[id: "agents"]?.status = .installed(path: agentsPath) + } + await store.receive(.delegate(.linkChanged(.installed(skill: "prowl-cli", target: "Shared agents directory")))) + + #expect(FileManager.default.fileExists(atPath: debugPath), "The other build's skill directory is never deleted") + } + + @Test(.dependencies) func aliasedTargetsRefreshTogether() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + _ = try fixture.aliasClaudeAndCodexSkills() + let claudePath = fixture.linkPath(target: ".claude", skill: "prowl-cli") + let codexPath = fixture.linkPath(target: ".codex", skill: "prowl-cli") + let store = makeStore(fixture.client) + await store.send(.task) { + $0.skills = [ + try fixture.row( + "prowl-cli", + links: [("claude", .notInstalled), ("codex", .notInstalled), ("agents", .notInstalled)] + ) + ] + } + + await store.send(.installLink(skillID: "prowl-cli", targetID: "claude")) + await store.receive(\.linkChangeCompleted.success) { + $0.skills[id: "prowl-cli"]?.links[id: "claude"]?.status = .installed(path: claudePath) + $0.skills[id: "prowl-cli"]?.links[id: "codex"]?.status = .installed(path: codexPath) + } + await store.receive(.delegate(.linkChanged(.installed(skill: "prowl-cli", target: "Claude Code")))) + + await store.send(.removeLink(skillID: "prowl-cli", targetID: "codex")) + await store.receive(\.linkChangeCompleted.success) { + $0.skills[id: "prowl-cli"]?.links[id: "claude"]?.status = .notInstalled + $0.skills[id: "prowl-cli"]?.links[id: "codex"]?.status = .notInstalled + } + await store.receive(.delegate(.linkChanged(.removed(skill: "prowl-cli", target: "Codex")))) + } + + @Test(.dependencies) func conflictShowsAnAlertAndLeavesTheDirectoryAlone() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let realDirectory = fixture.home.appending(path: ".claude/skills/prowl-cli", directoryHint: .isDirectory) + try fixture.makeDirectory(realDirectory) + try Data("keep".utf8).write(to: realDirectory.appending(path: "SKILL.md")) + let claudePath = fixture.linkPath(target: ".claude", skill: "prowl-cli") + let store = makeStore(fixture.client) + await store.send(.task) { + $0.skills = [ + try fixture.row( + "prowl-cli", + links: [ + ("claude", .installedDifferentSource(path: claudePath, destination: nil)), + ("agents", .notInstalled), + ] + ) + ] + } + + let message = + "A real file or directory occupies \(claudePath). " + + "Prowl only manages symlinks and never deletes it; remove it manually first." + await store.send(.installLink(skillID: "prowl-cli", targetID: "claude")) + await store.receive(\.linkChangeCompleted.failure) { + $0.alert = AlertState { + TextState("Agent Skills Error") + } actions: { + ButtonState(action: .dismiss) { TextState("OK") } + } message: { + TextState(message) + } + } + await store.receive(.delegate(.linkChanged(.failed(message: message)))) + + #expect(try Data(contentsOf: realDirectory.appending(path: "SKILL.md")) == Data("keep".utf8)) + + await store.send(.alert(.presented(.dismiss))) { + $0.alert = nil + } + } + + @Test(.dependencies) func revealHandsTheSkillToTheClient() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let revealed = LockIsolated(nil) + var client = fixture.client + client.revealSkill = { skill in revealed.setValue(skill.id) } + let store = makeStore(client) + await store.send(.task) { + $0.skills = [ + try fixture.row("prowl-cli", links: [("claude", .notInstalled), ("agents", .notInstalled)]) + ] + } + + await store.send(.revealSkillButtonTapped(skillID: "prowl-cli")) + await store.finish() + + #expect(revealed.value == "prowl-cli") + } + + @Test(.dependencies) func unknownSkillOrTargetIsIgnored() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let store = makeStore(fixture.client) + await store.send(.task) { + $0.skills = [ + try fixture.row("prowl-cli", links: [("claude", .notInstalled), ("agents", .notInstalled)]) + ] + } + + await store.send(.installLink(skillID: "reviewer", targetID: "claude")) + await store.send(.installLink(skillID: "prowl-cli", targetID: "codex")) + await store.send(.removeLink(skillID: "prowl-cli", targetID: "codex")) + await store.send(.revealSkillButtonTapped(skillID: "reviewer")) + + #expect(!FileManager.default.fileExists(atPath: fixture.linkPath(target: ".claude", skill: "reviewer"))) + #expect(!FileManager.default.fileExists(atPath: fixture.home.appending(path: ".codex").path())) + } + + private func makeStore(_ client: SkillInstallClient) -> TestStoreOf { + TestStore(initialState: AgentSkillsFeature.State()) { + AgentSkillsFeature() + } withDependencies: { + $0.skillInstallClient = client + } + } +} + +extension SkillInstallFixture { + func row( + _ skillID: String, + links: [(target: String, status: SymlinkInstallStatus)] + ) throws -> AgentSkillsFeature.SkillRow { + AgentSkillsFeature.SkillRow( + skill: try skill(skillID), + links: IdentifiedArray( + uniqueElements: try links.map { link in + let target = try target(link.target) + return AgentSkillsFeature.SkillLink( + target: target, + linkPath: linkPath(target: "." + link.target, skill: skillID), + status: link.status + ) + } + ) + ) + } +}