diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index f653bc61..868e4f6f 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -380,10 +380,17 @@ struct AppFeature { state.settings.repositorySettings = nil state.settings.globalCustomCommands = .init() state.settings.agentProfiles = nil + state.settings.agentSkills = nil case .profiles: state.settings.repositorySettings = nil state.settings.globalCustomCommands = nil state.settings.agentProfiles = .init() + state.settings.agentSkills = nil + case .commandLineTool: + state.settings.repositorySettings = nil + state.settings.globalCustomCommands = nil + state.settings.agentProfiles = nil + state.settings.agentSkills = .init() case .repository(let repositoryID): guard let repository = state.repositories.repositories[id: repositoryID] else { state.settings.repositorySettings = nil @@ -407,10 +414,12 @@ struct AppFeature { state.settings.repositorySettings = repoSettingsState state.settings.globalCustomCommands = nil state.settings.agentProfiles = nil - case .general, .notifications, .shortcuts, .worktree, .updates, .advanced, .github, .commandLineTool: + state.settings.agentSkills = nil + case .general, .notifications, .shortcuts, .worktree, .updates, .advanced, .github: state.settings.repositorySettings = nil state.settings.globalCustomCommands = nil state.settings.agentProfiles = nil + state.settings.agentSkills = nil } return .none @@ -537,6 +546,16 @@ struct AppFeature { return .send(.repositories(.showToast(.warning("CLI install failed: \(message)")))) } + case .settings(.agentSkills(.delegate(.linkChanged(let result)))): + switch result { + case .installed(let skill, let target): + return .send(.repositories(.showToast(.success("\(skill) skill linked for \(target)")))) + case .removed(let skill, let target): + return .send(.repositories(.showToast(.success("\(skill) skill link removed for \(target)")))) + case .failed(let message): + return .send(.repositories(.showToast(.warning("Skill link failed: \(message)")))) + } + case .settings(.delegate(.terminalLayoutSnapshotCleared(let success))): if success { state.suppressLayoutSaveUntilRelaunch = true diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 58694ad8..2a499cba 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -63,6 +63,7 @@ struct SettingsFeature { var repositorySettings: RepositorySettingsFeature.State? var globalCustomCommands: GlobalCustomCommandsFeature.State? var agentProfiles: AgentProfilesFeature.State? + var agentSkills: AgentSkillsFeature.State? @Presents var alert: AlertState? init(settings: GlobalSettings = .default) { @@ -186,6 +187,7 @@ struct SettingsFeature { case repositorySettings(RepositorySettingsFeature.Action) case globalCustomCommands(GlobalCustomCommandsFeature.Action) case agentProfiles(AgentProfilesFeature.Action) + case agentSkills(AgentSkillsFeature.Action) case alert(PresentationAction) case delegate(Delegate) case binding(BindingAction) @@ -470,6 +472,9 @@ struct SettingsFeature { case .agentProfiles: return .none + case .agentSkills: + return .none + case .delegate: return .none } @@ -483,6 +488,9 @@ struct SettingsFeature { .ifLet(\.agentProfiles, action: \.agentProfiles) { AgentProfilesFeature() } + .ifLet(\.agentSkills, action: \.agentSkills) { + AgentSkillsFeature() + } // Without this, alert state is only cleared by the view's dismiss // writeback: state set while the Settings window is closed (or closed // while an alert is up) would wedge as permanently "presented". diff --git a/supacode/Features/Settings/Views/AgentSkillsSectionView.swift b/supacode/Features/Settings/Views/AgentSkillsSectionView.swift new file mode 100644 index 00000000..fa10a372 --- /dev/null +++ b/supacode/Features/Settings/Views/AgentSkillsSectionView.swift @@ -0,0 +1,191 @@ +import ComposableArchitecture +import SwiftUI + +/// Settings → Agents → Command Line Tool → Agent Skills: one row per bundled `user` +/// skill with a status chip and one action per detected target. Link behavior stays +/// in `AgentSkillsFeature`; this view only presents it. +struct AgentSkillsSectionView: View { + @Bindable var store: StoreOf + + var body: some View { + Section { + VStack(alignment: .leading, spacing: 12) { + content + } + .frame(maxWidth: .infinity, alignment: .leading) + .task { store.send(.task) } + .alert($store.scope(state: \.alert, action: \.alert)) + } header: { + VStack(alignment: .leading, spacing: 4) { + Text("Agent Skills") + Text( + "Link the skills bundled in this app into your agents' skill folders, so every agent " + + "reads the version that matches the installed app. Same status as prowl skills list." + ) + .foregroundStyle(.secondary) + } + } + } + + @ViewBuilder + private var content: some View { + if let loadError = store.loadError { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + .accessibilityLabel("Unavailable") + VStack(alignment: .leading, spacing: 2) { + Text("Bundled skills are unavailable.") + Text(loadError) + .foregroundStyle(.secondary) + } + } + .font(.callout) + } else if store.skills.isEmpty { + Text("This app bundles no installable skills.") + .foregroundStyle(.secondary) + .font(.callout) + } else { + if store.noTargetsDetected { + Text( + "No agent skill folder was found in your home directory. Run Claude Code, Codex, or another " + + "agent once so it creates its folder, or create one from a terminal with " + + "prowl skills install --target claude|codex|agents." + ) + .foregroundStyle(.secondary) + .font(.callout) + } + ForEach(store.skills) { row in + skillRow(row) + if row.id != store.skills.last?.id { + Divider() + } + } + } + } + + private func skillRow(_ row: AgentSkillsFeature.SkillRow) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(row.skill.name) + .font(.headline) + if row.skill.name != row.skill.id { + Text(row.skill.id) + .font(.callout.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + Button("Reveal") { + store.send(.revealSkillButtonTapped(skillID: row.id)) + } + .help("Show the bundled \(row.skill.id) skill folder in Finder") + .buttonStyle(.bordered) + .controlSize(.small) + } + Text(row.skill.description) + .foregroundStyle(.secondary) + .font(.callout) + .lineLimit(3) + .help(row.skill.description) + ForEach(row.links) { link in + linkRow(skill: row.skill, link: link) + } + } + } + + private func linkRow(skill: BundledSkill, link: AgentSkillsFeature.SkillLink) -> some View { + HStack(spacing: 8) { + HStack(spacing: 6) { + statusIcon(link.status) + Text(link.target.displayName) + Text(statusText(link.status)) + .foregroundStyle(.secondary) + } + .font(.callout) + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background(.quaternary, in: Capsule()) + .help(link.linkPath) + + if let destination = link.status.destination { + Text("→ \(destination)") + .font(.callout.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(destination) + } + + Spacer() + + actionButton(skill: skill, link: link) + } + } + + @ViewBuilder + private func actionButton(skill: BundledSkill, link: AgentSkillsFeature.SkillLink) -> some View { + switch link.status { + case .notInstalled: + Button("Install") { + store.send(.installLink(skillID: skill.id, targetID: link.id)) + } + .help("Link the bundled \(skill.id) skill into \(link.linkPath)") + .buttonStyle(.bordered) + .controlSize(.small) + case .installed: + Button("Remove") { + store.send(.removeLink(skillID: skill.id, targetID: link.id)) + } + .help("Remove the \(skill.id) skill link at \(link.linkPath); the bundled skill stays in the app") + .buttonStyle(.bordered) + .controlSize(.small) + case .broken: + Button("Repair") { + store.send(.installLink(skillID: skill.id, targetID: link.id)) + } + .help("Replace the broken link at \(link.linkPath) with this app's bundled \(skill.id) skill") + .buttonStyle(.bordered) + .controlSize(.small) + case .installedDifferentSource(_, let destination): + if destination != nil { + Button("Replace") { + store.send(.installLink(skillID: skill.id, targetID: link.id)) + } + .help("Replace the link to another Prowl build at \(link.linkPath) with this app's bundled \(skill.id) skill") + .buttonStyle(.bordered) + .controlSize(.small) + } else { + Text("Prowl never deletes it; remove it manually to link here.") + .foregroundStyle(.secondary) + .font(.callout) + } + } + } + + @ViewBuilder + private func statusIcon(_ status: SymlinkInstallStatus) -> some View { + switch status { + case .installed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .accessibilityLabel("Installed") + case .notInstalled: + Image(systemName: "xmark.circle") + .foregroundStyle(.secondary) + .accessibilityLabel("Not installed") + case .installedDifferentSource, .broken: + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.yellow) + .accessibilityLabel(statusText(status)) + } + } + + private func statusText(_ status: SymlinkInstallStatus) -> String { + switch status { + case .installed: "Installed" + case .notInstalled: "Not installed" + case .installedDifferentSource(_, let destination): destination == nil ? "Real file or directory" : "Linked elsewhere" + case .broken: "Broken link" + } + } +} diff --git a/supacode/Features/Settings/Views/CommandLineToolSettingsView.swift b/supacode/Features/Settings/Views/CommandLineToolSettingsView.swift index 7f71d6de..30d3c2e0 100644 --- a/supacode/Features/Settings/Views/CommandLineToolSettingsView.swift +++ b/supacode/Features/Settings/Views/CommandLineToolSettingsView.swift @@ -2,8 +2,9 @@ import ComposableArchitecture import SwiftUI /// Settings → Agents → Command Line Tool: install/status for the bundled `prowl` -/// CLI and the socket it reaches the app through. Installation behavior stays in -/// `SettingsFeature`; this view only presents it. +/// CLI, the socket it reaches the app through, and the bundled agent skills +/// (`AgentSkillsFeature`). Installation behavior stays in the reducers; this view +/// only presents it. struct CommandLineToolSettingsView: View { @Bindable var store: StoreOf @@ -93,6 +94,10 @@ struct CommandLineToolSettingsView: View { .foregroundStyle(.secondary) .font(.callout) } + + if let agentSkillsStore = store.scope(state: \.agentSkills, action: \.agentSkills) { + AgentSkillsSectionView(store: agentSkillsStore) + } } .formStyle(.grouped) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) diff --git a/supacodeTests/AppFeatureAgentSkillsTests.swift b/supacodeTests/AppFeatureAgentSkillsTests.swift new file mode 100644 index 00000000..1a583b7d --- /dev/null +++ b/supacodeTests/AppFeatureAgentSkillsTests.swift @@ -0,0 +1,37 @@ +import ComposableArchitecture +import DependenciesTestSupport +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct AppFeatureAgentSkillsTests { + @Test(.dependencies) func skillLinkResultsShowToasts() async { + var state = AppFeature.State(settings: SettingsFeature.State()) + state.settings.selection = .commandLineTool + state.settings.agentSkills = .init() + let store = TestStore(initialState: state) { + AppFeature() + } + // The toast auto-dismiss sleeps on a real clock, exactly like the CLI install toast test. + store.exhaustivity = .off + + await store.send( + .settings(.agentSkills(.delegate(.linkChanged(.installed(skill: "prowl-cli", target: "Claude Code"))))) + ) + await store.receive(\.repositories.showToast) { + $0.repositories.statusToast = .success("prowl-cli skill linked for Claude Code") + } + + await store.send(.settings(.agentSkills(.delegate(.linkChanged(.removed(skill: "prowl-cli", target: "Codex")))))) + await store.receive(\.repositories.showToast) { + $0.repositories.statusToast = .success("prowl-cli skill link removed for Codex") + } + + await store.send(.settings(.agentSkills(.delegate(.linkChanged(.failed(message: "boom")))))) + await store.receive(\.repositories.showToast) { + $0.repositories.statusToast = .warning("Skill link failed: boom") + } + } +} diff --git a/supacodeTests/AppFeatureSettingsSelectionTests.swift b/supacodeTests/AppFeatureSettingsSelectionTests.swift index 371cc26f..a26cf63e 100644 --- a/supacodeTests/AppFeatureSettingsSelectionTests.swift +++ b/supacodeTests/AppFeatureSettingsSelectionTests.swift @@ -201,6 +201,38 @@ struct AppFeatureSettingsSelectionTests { await store.send(.settings(.setSelection(section))) { $0.settings.selection = section $0.settings.agentProfiles = nil + if section == .commandLineTool { + $0.settings.agentSkills = .init() + } + } + } + + @Test func selectingCommandLineToolInitialisesAgentSkillsState() async { + let store = TestStore(initialState: AppFeature.State(settings: SettingsFeature.State())) { + AppFeature() + } + + await store.send(.settings(.setSelection(.commandLineTool))) { + $0.settings.selection = .commandLineTool + $0.settings.agentSkills = .init() + } + } + + @Test(arguments: [SettingsSection.general, .profiles]) + func selectingAnotherSectionClearsAgentSkillsState(section: SettingsSection) async { + var state = AppFeature.State(settings: SettingsFeature.State()) + state.settings.selection = .commandLineTool + state.settings.agentSkills = .init() + let store = TestStore(initialState: state) { + AppFeature() + } + + await store.send(.settings(.setSelection(section))) { + $0.settings.selection = section + $0.settings.agentSkills = nil + if section == .profiles { + $0.settings.agentProfiles = .init() + } } } }