From 48ce3aa77036b1cd29f3daf469691167ffc9111f Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 28 Aug 2026 01:41:04 +0900 Subject: [PATCH 01/11] Record K2 merged and start the K3 slice Claude-Session: https://claude.ai/code/session_01PpjWDXtmY9eFHWhwicxkvR --- docs-ai/063-agent-workflows/release-plan.md | 13 ++++++++----- docs-ai/065-bundled-agent-skills/000-plan.md | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs-ai/063-agent-workflows/release-plan.md b/docs-ai/063-agent-workflows/release-plan.md index ca05a529..b7d774dd 100644 --- a/docs-ai/063-agent-workflows/release-plan.md +++ b/docs-ai/063-agent-workflows/release-plan.md @@ -24,7 +24,7 @@ that 063-B3 consumes; 064-S3 attaches launch-scoped hooks through 063-A2's launc PRs merge to `main` one at a time (each keeps `main` shippable); engine PRs without a user-facing surface may merge before "their" release and stay dormant. Three releases: -### Current R1 status (2026-08-27) +### Current R1 status (2026-08-28) | Slice(s) | State | PR / next action | | --- | --- | --- | @@ -36,12 +36,12 @@ user-facing surface may merge before "their" release and stay dormant. Three rel | S2 | Merged | #718: paired dispatch receipt, strict ID wait, generic evidence wait; [action record](../064-agent-completion-signals/005-s2-action.md) | | S3 wave 1 | Complete | Merged in #721/#723/#725/#728; S3c plan [064.010](../064-agent-completion-signals/010-s3c-plan.md), record [064.011](../064-agent-completion-signals/011-s3c-action.md) | | 065-S0/K1 | Merged | #729; [065.003](../065-bundled-agent-skills/003-k1-bundle-registry.md) | -| 065-K2 | In review | #730; [065.004](../065-bundled-agent-skills/004-k2-skill-installer-cli.md) | -| 065-K3 | Planned | Follows K2 inside R1 | +| 065-K2 | Merged | #730; [065.004](../065-bundled-agent-skills/004-k2-skill-installer-cli.md) | +| 065-K3 | In progress | `feat/bundled-skills-k3`; Agent Skills section on Settings › Command Line Tool; closes 065 | A2 completes 063's R1 implementation work, and S1/S2/S3 wave 1 are on `main`. The remaining -R1 work is 065 bundled skill distribution: S0 and K1 are merged (#729), K2 is implemented and -awaiting review, and K3 follows. +R1 work is 065 bundled skill distribution: S0, K1 (#729), and K2 (#730) are merged; K3, the +Settings section, is in progress and closes 065. #### S3 wave 1 PR breakdown @@ -124,6 +124,9 @@ R3+: V2 / S5 rest; delete HANDOFF_RETIRED stubs ## Change log +- 2026-08-28 — 065-K2 merged in #730 after four review rounds and a real-environment check. + K3 (Agent Skills section on Settings › Command Line Tool) started on + `feat/bundled-skills-k3`; it is the last 065 slice and the last open R1 item. - 2026-08-27 — 065-K1 merged in #729. K2 (shared `SymlinkInstaller` + `prowl skills`) was implemented on `feat/bundled-skills-k2` and opened as #730; K3 remains planned inside R1. Record: [065.004](../065-bundled-agent-skills/004-k2-skill-installer-cli.md). diff --git a/docs-ai/065-bundled-agent-skills/000-plan.md b/docs-ai/065-bundled-agent-skills/000-plan.md index 93dee4e9..44612884 100644 --- a/docs-ai/065-bundled-agent-skills/000-plan.md +++ b/docs-ai/065-bundled-agent-skills/000-plan.md @@ -2,7 +2,7 @@ | | | | --- | --- | -| **Status** | In progress — S0, K1, and K2 complete; K3 planned | +| **Status** | In progress — S0, K1, and K2 complete (#729, #730 merged); K3 in progress | | **Anchor date** | 2026-08-22 | | **Primary PRs** | #712 (plan); #729 (K1); #730 (K2); K3 to fill in | | **Related** | [063-agent-workflows](../063-agent-workflows/000-plan.md) (D1 `skill:` materialization, D1–D3 new skills), [060-prowl-cli-targeting-and-contract-governance](../060-prowl-cli-targeting-and-contract-governance/000-plan.md) (four-layer CLI rule), [013-prowl-cli](../013-prowl-cli/000-plan.md), `docs/components/cli.md`, `skills/prowl-cli/SKILL.md` | @@ -138,7 +138,7 @@ depends on it); K2 and K3 follow inside R1 so the R1 user can `prowl skills inst | --- | --- | --- | | **S0** spike | **Complete** — verified Codex per-directory links and `.codex/skills`, mapped installed `.agents/skills` readers, and confirmed dangling links do not block discovery. Copy mode stays out of K2. See [002-s0-skill-targets.md](002-s0-skill-targets.md). | — | | **K1** | **Complete (#729)** — `embed-skills`, `Resources/skills` folder reference, Foundation-only `ProwlSkills` registry + typed errors and frontmatter parser, CLI bundle resolution, tests. See [003-k1-bundle-registry.md](003-k1-bundle-registry.md). | — | -| **K2** | **Complete** — shared `SymlinkInstaller` extracted from `CLIInstallClient`, declarative targets, `prowl skills list\|install\|uninstall\|path`, contract, schema, `cli.md`, `prowl-cli` skill line, unit + integration tests (temp dirs, `PROWL_SKILLS_DIR`). See [004-k2-skill-installer-cli.md](004-k2-skill-installer-cli.md). | S0, K1 | +| **K2** | **Complete (#730)** — shared `SymlinkInstaller` extracted from `CLIInstallClient`, declarative targets, `prowl skills list\|install\|uninstall\|path`, contract, schema, `cli.md`, `prowl-cli` skill line, unit + integration tests (temp dirs, `PROWL_SKILLS_DIR`). See [004-k2-skill-installer-cli.md](004-k2-skill-installer-cli.md). | S0, K1 | | **K3** | Agent Skills section on the Command Line Tool page, `AgentSkillsFeature` + `SkillInstallClient`, reducer tests, `docs/components/settings.md`. | K2 | ## Alternatives & decisions -- 2.51.2 From 6ead6a6c86865db43ecb7ad336efc518b9e4297f Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 28 Aug 2026 01:49:50 +0900 Subject: [PATCH 02/11] Add SkillInstallClient over the shared skill installer Claude-Session: https://claude.ai/code/session_01PpjWDXtmY9eFHWhwicxkvR --- .../SkillInstall/SkillInstallClient.swift | 97 +++++++++ supacodeTests/SkillInstallClientTests.swift | 196 ++++++++++++++++++ supacodeTests/SkillInstallFixture.swift | 85 ++++++++ 3 files changed, 378 insertions(+) create mode 100644 supacode/Clients/SkillInstall/SkillInstallClient.swift create mode 100644 supacodeTests/SkillInstallClientTests.swift create mode 100644 supacodeTests/SkillInstallFixture.swift diff --git a/supacode/Clients/SkillInstall/SkillInstallClient.swift b/supacode/Clients/SkillInstall/SkillInstallClient.swift new file mode 100644 index 00000000..19b1bac1 --- /dev/null +++ b/supacode/Clients/SkillInstall/SkillInstallClient.swift @@ -0,0 +1,97 @@ +import AppKit +import ComposableArchitecture +import Foundation + +struct SkillInstallError: Error, Equatable, Sendable, LocalizedError { + let message: String + + var errorDescription: String? { message } +} + +/// User-scope skill links for the Settings Agent Skills section (docs-ai 065): the same shared +/// installer `prowl skills` uses, so both surfaces report identical statuses. Real files and +/// directories are never replaced or deleted; only symlinks are managed. +struct SkillInstallClient: Sendable { + /// Every bundled skill in bundle order, any audience; callers filter by `audience`. + var bundledSkills: @Sendable () throws -> [BundledSkill] + var status: @Sendable (_ skill: BundledSkill, _ target: SkillInstallTarget) -> SkillTargetStatus + /// Links the bundled skill into the target's user skills directory, replacing a live or dangling + /// symlink; this doubles as Repair and Replace. + var install: @Sendable (_ skill: BundledSkill, _ target: SkillInstallTarget) async throws -> Void + var uninstall: @Sendable (_ skill: BundledSkill, _ target: SkillInstallTarget) async throws -> Void + var revealSkill: @Sendable (_ skill: BundledSkill) -> Void +} + +extension SkillInstallClient: DependencyKey { + static let liveValue = SkillInstallClient.live( + resourcesURL: Bundle.main.resourceURL, + userRoot: FileManager.default.homeDirectoryForCurrentUser + ) + + static let testValue = SkillInstallClient( + bundledSkills: { [] }, + status: { _, target in SkillTargetStatus(target: target, detected: false, linkPath: "", status: .notInstalled) }, + install: { _, _ in }, + uninstall: { _, _ in }, + revealSkill: { _ in } + ) + + /// The live client over an explicit bundle and home, so tests run against temporary roots. + static func live(resourcesURL: URL?, userRoot: URL) -> SkillInstallClient { + SkillInstallClient( + bundledSkills: { + guard let resourcesURL else { + throw SkillInstallError(message: "Could not locate the app's bundled resources.") + } + do { + return try ProwlSkills.bundled(resourcesURL: resourcesURL) + } catch { + throw SkillInstallError(message: error.localizedDescription) + } + }, + status: { skill, target in + ProwlSkillInstaller.status(skill: skill, target: target, scope: .user, root: userRoot) + }, + install: { skill, target in + do { + _ = try ProwlSkillInstaller.install(skill: skill, target: target, scope: .user, root: userRoot) + } catch let error as SymlinkInstallError { + throw SkillInstallError(message: skillInstallErrorMessage(error)) + } catch { + throw SkillInstallError(message: error.localizedDescription) + } + }, + uninstall: { skill, target in + do { + _ = try ProwlSkillInstaller.uninstall(skill: skill, target: target, scope: .user, root: userRoot) + } catch let error as SymlinkInstallError { + throw SkillInstallError(message: skillInstallErrorMessage(error)) + } catch { + throw SkillInstallError(message: error.localizedDescription) + } + }, + revealSkill: { skill in + NSWorkspace.shared.activateFileViewerSelecting([skill.directoryURL]) + } + ) + } +} + +private nonisolated func skillInstallErrorMessage(_ error: SymlinkInstallError) -> String { + switch error { + case .conflict(let path): + "A real file or directory occupies \(path). " + + "Prowl only manages symlinks and never deletes it; remove it manually first." + case .notInstalled(let path): + "No skill link found at \(path)." + case .sourceNotFound(let path): + "Bundled skill not found at \(path)." + } +} + +extension DependencyValues { + var skillInstallClient: SkillInstallClient { + get { self[SkillInstallClient.self] } + set { self[SkillInstallClient.self] = newValue } + } +} diff --git a/supacodeTests/SkillInstallClientTests.swift b/supacodeTests/SkillInstallClientTests.swift new file mode 100644 index 00000000..b05e0d0d --- /dev/null +++ b/supacodeTests/SkillInstallClientTests.swift @@ -0,0 +1,196 @@ +import Foundation +import Testing + +@testable import supacode + +/// Exercises the live client against a temporary skills root and a temporary home; the real +/// `~/.claude`, `~/.codex`, and `~/.agents` are never read or written. +struct SkillInstallClientTests { + @Test func bundledSkillsListsEveryAudienceInBundleOrder() throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + + let skills = try fixture.client.bundledSkills() + + #expect(skills.map(\.id) == ["prowl-cli", "reviewer"]) + #expect(skills.map(\.audience) == [.user, .workflow]) + #expect(ProwlSkillInstaller.sourcePath(skills[0]) == fixture.skillDirectory("prowl-cli")) + } + + @Test func bundledSkillsFailsWhenTheBundleIsMissing() throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let client = SkillInstallClient.live( + resourcesURL: fixture.root.appending(path: "no-resources", directoryHint: .isDirectory), + userRoot: fixture.home + ) + + #expect(throws: SkillInstallError.self) { + try client.bundledSkills() + } + do { + _ = try client.bundledSkills() + } catch let error as SkillInstallError { + #expect(error.message.contains("Bundled skills were not found")) + } + } + + @Test func bundledSkillsFailsWithoutAResourcesURL() throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let client = SkillInstallClient.live(resourcesURL: nil, userRoot: fixture.home) + + #expect(throws: SkillInstallError.self) { + try client.bundledSkills() + } + } + + @Test func statusReportsDetectionAndAllFourStates() throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let skill = try fixture.skill("prowl-cli") + try fixture.link(target: ".claude", skill: "prowl-cli", to: fixture.skillDirectory("prowl-cli")) + try fixture.makeDirectory(fixture.home.appending(path: ".agents/skills/prowl-cli")) + + let claude = fixture.client.status(skill, try fixture.target("claude")) + #expect(claude.detected) + #expect(claude.linkPath == fixture.linkPath(target: ".claude", skill: "prowl-cli")) + #expect(claude.status == .installed(path: claude.linkPath)) + + let codex = fixture.client.status(skill, try fixture.target("codex")) + #expect(!codex.detected, "~/.codex does not exist in the fixture") + #expect(codex.status == .notInstalled) + + let agents = fixture.client.status(skill, try fixture.target("agents")) + #expect(agents.status == .installedDifferentSource(path: agents.linkPath, destination: nil)) + + let gone = fixture.root.appending(path: "gone").path(percentEncoded: false) + try fixture.link(target: ".claude", skill: "reviewer", to: gone) + let broken = fixture.client.status(try fixture.skill("reviewer"), try fixture.target("claude")) + #expect(broken.status == .broken(path: broken.linkPath, destination: gone)) + } + + @Test func statusNamesTheOtherSourceOfAForeignLink() 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: ".claude", skill: "prowl-cli", to: debugPath) + + let status = fixture.client.status(try fixture.skill("prowl-cli"), try fixture.target("claude")) + + #expect(status.status == .installedDifferentSource(path: status.linkPath, destination: debugPath)) + } + + @Test func installCreatesTheSkillsDirectoryAndLinksTheBundledSkill() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let skill = try fixture.skill("prowl-cli") + + try await fixture.client.install(skill, try fixture.target("claude")) + + let linkPath = fixture.linkPath(target: ".claude", skill: "prowl-cli") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: linkPath) == fixture.skillDirectory("prowl-cli")) + #expect(fixture.client.status(skill, try fixture.target("claude")).status == .installed(path: linkPath)) + } + + @Test func installReplacesForeignAndDanglingLinks() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let skill = try fixture.skill("prowl-cli") + let other = fixture.root.appending(path: "other", directoryHint: .isDirectory) + try fixture.makeDirectory(other) + try fixture.link(target: ".claude", skill: "prowl-cli", to: other.path(percentEncoded: false)) + try fixture.link(target: ".agents", skill: "prowl-cli", to: fixture.root.appending(path: "gone").path()) + + try await fixture.client.install(skill, try fixture.target("claude")) + try await fixture.client.install(skill, try fixture.target("agents")) + + for target in [".claude", ".agents"] { + let linkPath = fixture.linkPath(target: target, skill: "prowl-cli") + #expect(try FileManager.default.destinationOfSymbolicLink(atPath: linkPath) == fixture.skillDirectory("prowl-cli")) + } + #expect(FileManager.default.fileExists(atPath: other.path(percentEncoded: false)), "The other source is never removed") + } + + @Test func installRefusesARealDirectoryWithoutTouchingIt() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let skill = try fixture.skill("prowl-cli") + 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")) + + await #expect(throws: SkillInstallError.self) { + try await fixture.client.install(skill, try fixture.target("claude")) + } + do { + try await fixture.client.install(skill, try fixture.target("claude")) + } catch let error as SkillInstallError { + #expect(error.message.contains("real file or directory")) + #expect(error.message.contains("manually")) + } + #expect(try Data(contentsOf: realDirectory.appending(path: "SKILL.md")) == Data("keep".utf8)) + #expect(!fixture.isSymlink(realDirectory.path(percentEncoded: false))) + } + + @Test func uninstallRemovesLinksOnlyAndReportsMissingOnes() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let skill = try fixture.skill("prowl-cli") + try fixture.link(target: ".claude", skill: "prowl-cli", to: fixture.skillDirectory("prowl-cli")) + try fixture.link(target: ".agents", skill: "prowl-cli", to: fixture.root.appending(path: "gone").path()) + + try await fixture.client.uninstall(skill, try fixture.target("claude")) + try await fixture.client.uninstall(skill, try fixture.target("agents")) + + #expect((try? FileManager.default.attributesOfItem(atPath: fixture.linkPath(target: ".claude", skill: "prowl-cli"))) == nil) + #expect((try? FileManager.default.attributesOfItem(atPath: fixture.linkPath(target: ".agents", skill: "prowl-cli"))) == nil) + #expect(FileManager.default.fileExists(atPath: fixture.skillDirectory("prowl-cli")), "The bundled skill stays") + + do { + try await fixture.client.uninstall(skill, try fixture.target("claude")) + Issue.record("Expected uninstall to fail for an empty slot") + } catch let error as SkillInstallError { + #expect(error.message.contains("No skill link")) + } + } + + @Test func uninstallRefusesARealDirectory() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let skill = try fixture.skill("prowl-cli") + let realDirectory = fixture.home.appending(path: ".claude/skills/prowl-cli", directoryHint: .isDirectory) + try fixture.makeDirectory(realDirectory) + + do { + try await fixture.client.uninstall(skill, try fixture.target("claude")) + Issue.record("Expected uninstall to refuse a real directory") + } catch let error as SkillInstallError { + #expect(error.message.contains("real file or directory")) + } + #expect(FileManager.default.fileExists(atPath: realDirectory.path(percentEncoded: false))) + } + + @Test func aliasedTargetsShareOneLink() async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let skill = try fixture.skill("prowl-cli") + let shared = try fixture.aliasClaudeAndCodexSkills() + + try await fixture.client.install(skill, try fixture.target("claude")) + + let codex = fixture.client.status(skill, try fixture.target("codex")) + #expect(codex.detected) + #expect(codex.status == .installed(path: codex.linkPath), "The codex chip sees the link installed through claude") + #expect( + try FileManager.default.destinationOfSymbolicLink(atPath: shared.appending(path: "prowl-cli").path()) + == fixture.skillDirectory("prowl-cli") + ) + + try await fixture.client.uninstall(skill, try fixture.target("codex")) + + #expect(fixture.client.status(skill, try fixture.target("claude")).status == .notInstalled) + } +} diff --git a/supacodeTests/SkillInstallFixture.swift b/supacodeTests/SkillInstallFixture.swift new file mode 100644 index 00000000..75c2df8f --- /dev/null +++ b/supacodeTests/SkillInstallFixture.swift @@ -0,0 +1,85 @@ +import Foundation +import Testing + +@testable import supacode + +/// A temporary skills bundle plus a temporary home with `~/.claude` and `~/.agents` detected +/// and `~/.codex` absent. The real `~/.claude`, `~/.codex`, and `~/.agents` are never touched. +struct SkillInstallFixture { + let root: URL + let home: URL + let client: SkillInstallClient + + init() throws { + root = FileManager.default.temporaryDirectory + .appending(path: "prowl-skill-install-\(UUID().uuidString)", directoryHint: .isDirectory) + .standardizedFileURL + home = root.appending(path: "home", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: home.appending(path: ".claude"), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: home.appending(path: ".agents"), withIntermediateDirectories: true) + let resources = root.appending(path: "Resources", directoryHint: .isDirectory) + let skillsRoot = resources.appending(path: "skills", directoryHint: .isDirectory) + try Self.writeSkill(id: "prowl-cli", name: "Prowl CLI", audience: nil, skillsRoot: skillsRoot) + try Self.writeSkill(id: "reviewer", name: "Reviewer", audience: "workflow", skillsRoot: skillsRoot) + client = SkillInstallClient.live(resourcesURL: resources, userRoot: home) + } + + func cleanup() { + try? FileManager.default.removeItem(at: root) + } + + func skill(_ id: String) throws -> BundledSkill { + try #require(try client.bundledSkills().first { $0.id == id }) + } + + func target(_ id: String) throws -> SkillInstallTarget { + try #require(SkillInstallTarget.target(id: id)) + } + + func skillDirectory(_ id: String) -> String { + root.appending(path: "Resources/skills/\(id)", directoryHint: .notDirectory).path(percentEncoded: false) + } + + func linkPath(target: String, skill: String) -> String { + home.appending(path: "\(target)/skills/\(skill)").path(percentEncoded: false) + } + + func link(target: String, skill: String, to destination: String) throws { + let skillsDirectory = home.appending(path: "\(target)/skills", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: skillsDirectory, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink( + atPath: linkPath(target: target, skill: skill), withDestinationPath: destination) + } + + func makeDirectory(_ url: URL) throws { + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + } + + /// Synced dotfiles: `~/.claude/skills` and `~/.codex/skills` both symlink to one folder. + func aliasClaudeAndCodexSkills() throws -> URL { + let shared = root.appending(path: "skills-shared", directoryHint: .isDirectory) + try makeDirectory(shared) + try makeDirectory(home.appending(path: ".codex")) + for target in [".claude", ".codex"] { + try FileManager.default.createSymbolicLink( + at: home.appending(path: "\(target)/skills"), withDestinationURL: shared) + } + return shared + } + + func isSymlink(_ path: String) -> Bool { + (try? FileManager.default.attributesOfItem(atPath: path))?[.type] as? FileAttributeType + == .typeSymbolicLink + } + + private static func writeSkill(id: String, name: String, audience: String?, skillsRoot: URL) throws { + let directory = skillsRoot.appending(path: id, directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + var frontmatter = "---\nname: \(name)\ndescription: \(name) description.\n" + if let audience { + frontmatter += "metadata:\n prowl-install: \(audience)\n" + } + frontmatter += "---\n" + try Data(frontmatter.utf8).write(to: directory.appending(path: "SKILL.md")) + } +} -- 2.51.2 From 0dd56a468db249d7acbbbde45750abbaff983fe9 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 28 Aug 2026 01:49:51 +0900 Subject: [PATCH 03/11] =?UTF-8?q?Add=20AgentSkillsFeature=20for=20skill=20?= =?UTF-8?q?=C3=97=20target=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01PpjWDXtmY9eFHWhwicxkvR --- .../Settings/Reducer/AgentSkillsFeature.swift | 176 ++++++++++++ supacodeTests/AgentSkillsFeatureTests.swift | 270 ++++++++++++++++++ 2 files changed, 446 insertions(+) create mode 100644 supacode/Features/Settings/Reducer/AgentSkillsFeature.swift create mode 100644 supacodeTests/AgentSkillsFeatureTests.swift 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 + ) + } + ) + ) + } +} -- 2.51.2 From 710d3b033d0a413ec23af9d0e6bd3514b7fb2d20 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 28 Aug 2026 01:53:48 +0900 Subject: [PATCH 04/11] Show the Agent Skills section on the Command Line Tool page Claude-Session: https://claude.ai/code/session_01PpjWDXtmY9eFHWhwicxkvR --- .../Features/App/Reducer/AppFeature.swift | 21 +- .../Settings/Reducer/SettingsFeature.swift | 8 + .../Views/AgentSkillsSectionView.swift | 191 ++++++++++++++++++ .../Views/CommandLineToolSettingsView.swift | 9 +- .../AppFeatureAgentSkillsTests.swift | 37 ++++ .../AppFeatureSettingsSelectionTests.swift | 32 +++ 6 files changed, 295 insertions(+), 3 deletions(-) create mode 100644 supacode/Features/Settings/Views/AgentSkillsSectionView.swift create mode 100644 supacodeTests/AppFeatureAgentSkillsTests.swift 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() + } } } } -- 2.51.2 From e215c95091640dee4174dd4721a7bf67a620a090 Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 28 Aug 2026 01:54:45 +0900 Subject: [PATCH 05/11] Document the Agent Skills section Claude-Session: https://claude.ai/code/session_01PpjWDXtmY9eFHWhwicxkvR --- docs/components/cli.md | 3 ++ docs/components/settings.md | 38 ++++++++++++++++++- .../Views/AgentSkillsSectionView.swift | 3 +- supacodeTests/SkillInstallClientTests.swift | 14 +++++-- supacodeTests/SkillInstallFixture.swift | 3 +- 5 files changed, 53 insertions(+), 8 deletions(-) diff --git a/docs/components/cli.md b/docs/components/cli.md index ffa9b58d..27709b71 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -387,6 +387,9 @@ and `targets[]` (`id`, `detected`, `path`, `status`, optional `destination`); `i `.data.scope`, `.data.root`, `.data.results[]` (`skill`, `target`, `path`, `before`, `after`) and, for project scope, `.data.note`; `path` → `.data.skill.{id,name,audience,path}`. `PROWL_SKILLS_DIR` points the command at a different skills root for development. +Settings › Agents › Command Line Tool › **Agent Skills** offers the same user-scope actions +from the GUI (Install / Remove / Repair / Replace per skill × detected target) and shows +the same status as `prowl skills list` — see [settings](settings.md#agent-skills). ### `prowl read [target]` Read a pane's content. diff --git a/docs/components/settings.md b/docs/components/settings.md index 8e1c41fb..045737be 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -3,7 +3,7 @@ > The Settings window (`⌘,`): what each tab controls. For the exhaustive > field-by-field list, see [`reference/settings-fields.md`](../reference/settings-fields.md). -**Keywords:** settings, preferences, ⌘comma, general, notifications, shortcuts, worktree, updates, advanced, github, agents, agent profiles, command line tool, cli, repo settings, appearance +**Keywords:** settings, preferences, ⌘comma, general, notifications, shortcuts, worktree, updates, advanced, github, agents, agent profiles, command line tool, cli, agent skills, skills install, repo settings, appearance **Related:** [reference/settings-fields](../reference/settings-fields.md) · [custom-actions](custom-actions.md) · [updates](updates.md) · [notifications](notifications.md) @@ -34,7 +34,7 @@ and opens that section's root. | **Commands** | Global Custom Commands. Enabled commands appear in the window toolbar; each repo can independently hide a Global command. → [custom-actions](custom-actions.md) | | **Advanced** | Analytics, crash reports, restore terminal layout on launch (experimental) + clear saved layout. | | **Agents → Profiles** | Named launch presets for supported agent runtimes (model, effort, execution mode, tab/split placement, extra arguments, opt-in dedicated home for a separate account) with a live launch preview. List order is the recommendation fallback. → [agent-profiles](agent-profiles.md) | -| **Agents → Command Line Tool** | Install/status for the bundled `prowl` CLI and the local socket path it uses to reach the app. → [cli](cli.md) | +| **Agents → Command Line Tool** | Install/status for the bundled `prowl` CLI, the local socket path it uses to reach the app, and the **Agent Skills** section that links the bundled skills into your agents' skill folders. → [cli](cli.md) | | **Repositories / Repo Settings** | Per-repository: setup/archive/run scripts, **Custom Commands**, Global-command visibility, **Default Agent Profile**, default base ref & directory, copy-files overrides, open-with app, custom title, icon & color, PR merge strategy, line-diff & PR-state fetching. Reached from the sidebar context menu → "Repo Settings". → [custom-actions](custom-actions.md), [repositories-and-worktrees](repositories-and-worktrees.md) | ## Where settings live on disk @@ -53,6 +53,40 @@ Legacy `~/.supacode` is migrated to `~/.prowl` on first launch. (prompting for admin rights if needed). Also available via Command Palette → "Install Command Line Tool". See [cli](cli.md). +## Agent Skills + +**Agents → Command Line Tool → Agent Skills** lists the user-installable skills bundled in +the app (`Prowl.app/Contents/Resources/skills/`, today `prowl-cli`) and links them into +your agents' skill folders so every agent reads the version that matches the installed +app. It is the GUI for [`prowl skills`](cli.md#prowl-skills) in user scope and shows the +same status as `prowl skills list`. + +- **Rows** — one per bundled `user` skill: name, description, and **Reveal** (shows the + bundled skill folder in Finder). Workflow-only skills are not listed. +- **Chips** — one per *detected* target: `Claude Code` (`~/.claude/skills`), `Codex` + (`~/.codex/skills`), and `Shared agents directory` (`~/.agents/skills`). A target is + detected when its parent folder (`~/.claude`, `~/.codex`, `~/.agents`) exists; hovering + a chip shows the link path. With no detected target the section says so and points at + `prowl skills install --target `, which creates the folder. +- **Statuses and actions** — one explicit action per skill × target link: + + | Chip | Meaning | Button | + |---|---|---| + | Installed | Symlink → this app's bundled skill | **Remove** (deletes the link only) | + | Not installed | Nothing in the slot | **Install** (creates the skills folder if needed) | + | Linked elsewhere `→ path` | Symlink to another Prowl build (for example a Debug build) | **Replace** | + | Broken link `→ path` | Dangling symlink — the app moved or was removed | **Repair** | + | Real file or directory | Something that is not a symlink occupies the slot | none — Prowl never deletes it; remove it manually | + +- **Aliased targets** — if `~/.claude/skills` and `~/.codex/skills` are symlinks to one + synced folder, both chips describe the same link: installing or removing through one + updates the other immediately. +- Results show as a toast; a failure (for example a real directory in the way) also shows + an alert. Nothing is auto-linked after an update: a newly bundled skill simply appears + as Not installed. Project-scope links are CLI-only (`prowl skills install --scope project`). +- A Debug build that was not built with `make build-app` has no staged skills; the section + then reports that the bundled skills are unavailable. + ## Gotchas for agents - Many behaviors are **global with a per-repo override** (copy-files, base diff --git a/supacode/Features/Settings/Views/AgentSkillsSectionView.swift b/supacode/Features/Settings/Views/AgentSkillsSectionView.swift index fa10a372..0dff1347 100644 --- a/supacode/Features/Settings/Views/AgentSkillsSectionView.swift +++ b/supacode/Features/Settings/Views/AgentSkillsSectionView.swift @@ -184,7 +184,8 @@ struct AgentSkillsSectionView: View { switch status { case .installed: "Installed" case .notInstalled: "Not installed" - case .installedDifferentSource(_, let destination): destination == nil ? "Real file or directory" : "Linked elsewhere" + case .installedDifferentSource(_, let destination): + destination == nil ? "Real file or directory" : "Linked elsewhere" case .broken: "Broken link" } } diff --git a/supacodeTests/SkillInstallClientTests.swift b/supacodeTests/SkillInstallClientTests.swift index b05e0d0d..38419ddd 100644 --- a/supacodeTests/SkillInstallClientTests.swift +++ b/supacodeTests/SkillInstallClientTests.swift @@ -109,9 +109,11 @@ struct SkillInstallClientTests { for target in [".claude", ".agents"] { let linkPath = fixture.linkPath(target: target, skill: "prowl-cli") - #expect(try FileManager.default.destinationOfSymbolicLink(atPath: linkPath) == fixture.skillDirectory("prowl-cli")) + #expect( + try FileManager.default.destinationOfSymbolicLink(atPath: linkPath) == fixture.skillDirectory("prowl-cli")) } - #expect(FileManager.default.fileExists(atPath: other.path(percentEncoded: false)), "The other source is never removed") + #expect( + FileManager.default.fileExists(atPath: other.path(percentEncoded: false)), "The other source is never removed") } @Test func installRefusesARealDirectoryWithoutTouchingIt() async throws { @@ -145,8 +147,12 @@ struct SkillInstallClientTests { try await fixture.client.uninstall(skill, try fixture.target("claude")) try await fixture.client.uninstall(skill, try fixture.target("agents")) - #expect((try? FileManager.default.attributesOfItem(atPath: fixture.linkPath(target: ".claude", skill: "prowl-cli"))) == nil) - #expect((try? FileManager.default.attributesOfItem(atPath: fixture.linkPath(target: ".agents", skill: "prowl-cli"))) == nil) + #expect( + (try? FileManager.default.attributesOfItem(atPath: fixture.linkPath(target: ".claude", skill: "prowl-cli"))) + == nil) + #expect( + (try? FileManager.default.attributesOfItem(atPath: fixture.linkPath(target: ".agents", skill: "prowl-cli"))) + == nil) #expect(FileManager.default.fileExists(atPath: fixture.skillDirectory("prowl-cli")), "The bundled skill stays") do { diff --git a/supacodeTests/SkillInstallFixture.swift b/supacodeTests/SkillInstallFixture.swift index 75c2df8f..7412fa26 100644 --- a/supacodeTests/SkillInstallFixture.swift +++ b/supacodeTests/SkillInstallFixture.swift @@ -11,7 +11,8 @@ struct SkillInstallFixture { let client: SkillInstallClient init() throws { - root = FileManager.default.temporaryDirectory + root = + FileManager.default.temporaryDirectory .appending(path: "prowl-skill-install-\(UUID().uuidString)", directoryHint: .isDirectory) .standardizedFileURL home = root.appending(path: "home", directoryHint: .isDirectory) -- 2.51.2 From a523256bf0582a449c23facad0306e91c440da7a Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 28 Aug 2026 02:14:57 +0900 Subject: [PATCH 06/11] Record the K3 slice and close the 065 entry Claude-Session: https://claude.ai/code/session_01PpjWDXtmY9eFHWhwicxkvR --- docs-ai/063-agent-workflows/release-plan.md | 9 +- docs-ai/065-bundled-agent-skills/000-plan.md | 9 +- .../065-bundled-agent-skills/001-action.md | 70 ++++++++++++++ .../005-k3-settings-agent-skills.md | 94 +++++++++++++++++++ 4 files changed, 178 insertions(+), 4 deletions(-) create mode 100644 docs-ai/065-bundled-agent-skills/001-action.md create mode 100644 docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md diff --git a/docs-ai/063-agent-workflows/release-plan.md b/docs-ai/063-agent-workflows/release-plan.md index b7d774dd..a4ca4873 100644 --- a/docs-ai/063-agent-workflows/release-plan.md +++ b/docs-ai/063-agent-workflows/release-plan.md @@ -37,11 +37,12 @@ user-facing surface may merge before "their" release and stay dormant. Three rel | S3 wave 1 | Complete | Merged in #721/#723/#725/#728; S3c plan [064.010](../064-agent-completion-signals/010-s3c-plan.md), record [064.011](../064-agent-completion-signals/011-s3c-action.md) | | 065-S0/K1 | Merged | #729; [065.003](../065-bundled-agent-skills/003-k1-bundle-registry.md) | | 065-K2 | Merged | #730; [065.004](../065-bundled-agent-skills/004-k2-skill-installer-cli.md) | -| 065-K3 | In progress | `feat/bundled-skills-k3`; Agent Skills section on Settings › Command Line Tool; closes 065 | +| 065-K3 | In review | PR to fill in; [065.005](../065-bundled-agent-skills/005-k3-settings-agent-skills.md); closes 065 | A2 completes 063's R1 implementation work, and S1/S2/S3 wave 1 are on `main`. The remaining R1 work is 065 bundled skill distribution: S0, K1 (#729), and K2 (#730) are merged; K3, the -Settings section, is in progress and closes 065. +Settings section, is implemented and in review — once it merges, 065 and R1's implementation +work are complete. #### S3 wave 1 PR breakdown @@ -124,6 +125,10 @@ R3+: V2 / S5 rest; delete HANDOFF_RETIRED stubs ## Change log +- 2026-08-28 — 065-K3 implemented on `feat/bundled-skills-k3` and opened for review: the Agent + Skills section on Settings › Agents › Command Line Tool; 065's action log + ([065.001](../065-bundled-agent-skills/001-action.md)) summarizes K1–K3. Record: + [065.005](../065-bundled-agent-skills/005-k3-settings-agent-skills.md). - 2026-08-28 — 065-K2 merged in #730 after four review rounds and a real-environment check. K3 (Agent Skills section on Settings › Command Line Tool) started on `feat/bundled-skills-k3`; it is the last 065 slice and the last open R1 item. diff --git a/docs-ai/065-bundled-agent-skills/000-plan.md b/docs-ai/065-bundled-agent-skills/000-plan.md index 44612884..f0696efd 100644 --- a/docs-ai/065-bundled-agent-skills/000-plan.md +++ b/docs-ai/065-bundled-agent-skills/000-plan.md @@ -2,7 +2,7 @@ | | | | --- | --- | -| **Status** | In progress — S0, K1, and K2 complete (#729, #730 merged); K3 in progress | +| **Status** | Implemented — S0, K1 (#729), K2 (#730), and K3 complete; see [001-action.md](001-action.md) | | **Anchor date** | 2026-08-22 | | **Primary PRs** | #712 (plan); #729 (K1); #730 (K2); K3 to fill in | | **Related** | [063-agent-workflows](../063-agent-workflows/000-plan.md) (D1 `skill:` materialization, D1–D3 new skills), [060-prowl-cli-targeting-and-contract-governance](../060-prowl-cli-targeting-and-contract-governance/000-plan.md) (four-layer CLI rule), [013-prowl-cli](../013-prowl-cli/000-plan.md), `docs/components/cli.md`, `skills/prowl-cli/SKILL.md` | @@ -139,7 +139,7 @@ depends on it); K2 and K3 follow inside R1 so the R1 user can `prowl skills inst | **S0** spike | **Complete** — verified Codex per-directory links and `.codex/skills`, mapped installed `.agents/skills` readers, and confirmed dangling links do not block discovery. Copy mode stays out of K2. See [002-s0-skill-targets.md](002-s0-skill-targets.md). | — | | **K1** | **Complete (#729)** — `embed-skills`, `Resources/skills` folder reference, Foundation-only `ProwlSkills` registry + typed errors and frontmatter parser, CLI bundle resolution, tests. See [003-k1-bundle-registry.md](003-k1-bundle-registry.md). | — | | **K2** | **Complete (#730)** — shared `SymlinkInstaller` extracted from `CLIInstallClient`, declarative targets, `prowl skills list\|install\|uninstall\|path`, contract, schema, `cli.md`, `prowl-cli` skill line, unit + integration tests (temp dirs, `PROWL_SKILLS_DIR`). See [004-k2-skill-installer-cli.md](004-k2-skill-installer-cli.md). | S0, K1 | -| **K3** | Agent Skills section on the Command Line Tool page, `AgentSkillsFeature` + `SkillInstallClient`, reducer tests, `docs/components/settings.md`. | K2 | +| **K3** | **Complete** — `SkillInstallClient`, `AgentSkillsFeature`, and the Agent Skills section on the Command Line Tool page (skill × detected target chips with Install / Remove / Repair / Replace, Reveal, empty states), reducer and client tests over temporary roots, `docs/components/settings.md`. See [005-k3-settings-agent-skills.md](005-k3-settings-agent-skills.md). | K2 | ## Alternatives & decisions @@ -214,6 +214,11 @@ Decisions below were taken in the 2026-08-22 plan review (#712): ## Amendments +- Updated 2026-08-28: Implemented and verified K3: the Command Line Tool page gained the Agent + Skills section over a `SkillInstallClient` dependency and an `AgentSkillsFeature` child; every + action recomputes all chips so aliased targets stay consistent; success is a toast, failure an + alert. 065 is complete — see [005-k3-settings-agent-skills.md](005-k3-settings-agent-skills.md) + and [001-action.md](001-action.md). - Updated 2026-08-27: Implemented and verified K2: one shared symlink installer with a `broken` status now backs both the CLI installer and skill links; `prowl skills` is local-only with the four contract layers; project scope prints its Git-hygiene note once and diff --git a/docs-ai/065-bundled-agent-skills/001-action.md b/docs-ai/065-bundled-agent-skills/001-action.md new file mode 100644 index 00000000..8363217a --- /dev/null +++ b/docs-ai/065-bundled-agent-skills/001-action.md @@ -0,0 +1,70 @@ +# 065 — Bundled Agent Skills: Action Log + +## Timeline + +| Date | Change | Ref | +| --- | --- | --- | +| 2026-08-22 | Plan reviewed and accepted: bundle Prowl's own skills, direct bundle symlinks, skill × target granularity, `prowl skills`, Agent Skills section on the Command Line Tool page. | #712, [000-plan.md](000-plan.md) | +| 2026-08-27 | S0 verified the `claude` / `codex` / `agents` targets against installed runtimes in temporary homes: directory symlinks are followed, dangling links do not block discovery, copy mode stays deferred. | [002-s0-skill-targets.md](002-s0-skill-targets.md) | +| 2026-08-27 | K1 merged: `embed-skills` staging, `Resources/skills` folder reference, Foundation-only `ProwlSkills` registry with the frontmatter parser and `metadata.prowl-install` audience, CLI bundle resolution, typed errors. | #729, [003-k1-bundle-registry.md](003-k1-bundle-registry.md) | +| 2026-08-27 | K2 merged after four review rounds and an owner-authorized real-environment check: shared `SymlinkInstaller` (extracted from `CLIInstallClient`, adds `broken`), declarative `SkillInstallTarget`, `ProwlSkillInstaller`, local-only `prowl skills list\|install\|uninstall\|path` with contract, schema, manual, and skill line; aliased targets handled by re-reading each slot. | #730, [004-k2-skill-installer-cli.md](004-k2-skill-installer-cli.md) | +| 2026-08-28 | K3: `SkillInstallClient`, `AgentSkillsFeature`, and the Agent Skills section on Settings › Agents › Command Line Tool; `settings.md` / `cli.md` updated; 065 complete. | PR to fill in, [005-k3-settings-agent-skills.md](005-k3-settings-agent-skills.md) | + +## Outcome & current state (as of 2026-08-28) + +- **Bundle.** `make build-app` / `test` / `archive` stage `skills/` into `Resources/skills/` + (ignored, `rsync --delete`), which the Xcode project embeds as a folder reference, so the + shipped app carries `Contents/Resources/skills//SKILL.md`. Today the only bundled skill is + `prowl-cli` (`user` audience); 063's D1–D3 skills join by being added to `skills/` with the right + `metadata.prowl-install`. +- **Registry.** `ProwlSkills` (`supacode/CLIService/Shared/ProwlSkills.swift`, part of + `ProwlCLIShared` and compiled into the app) parses `name`, plain or `>-` `description`, and the + strictly nested `metadata.prowl-install` audience; `bundled(resourcesURL:)` serves the app, + `bundledForCLI(executableURL:environment:)` resolves the CLI's own bundle or `PROWL_SKILLS_DIR`, + and `skill(id:)` is the locator 063 uses. +- **Installer.** `SymlinkInstaller` (`SymlinkInstaller.swift`) owns one link slot with the + statuses `notInstalled` / `installed(path:)` / `installedDifferentSource(path:destination:)` / + `broken(path:destination:)`; it replaces live or dangling symlinks, refuses real files and + directories, and removes symlinks only. `CLIInstallClient` delegates to it for + `/usr/local/bin/prowl`. `SkillInstallTarget.all` (`SkillInstallTarget.swift`) declares + `claude`, `codex`, and `agents` with user and project directories and the parent-exists + detection rule; `ProwlSkillInstaller` composes skill × target × scope into a slot and enforces + the project-boundary rule. +- **CLI.** `prowl skills` (`ProwlCLI/Skills/`) is local-only, contract `prowl.cli.skills.v1` + (`docs-ai/013-prowl-cli/contracts/skills.md`, `cli-output-schema.json`, + `docs/components/cli.md`, one line in `skills/prowl-cli/SKILL.md`). +- **Settings.** `SkillInstallClient` (`supacode/Clients/SkillInstall/`), `AgentSkillsFeature` + (`supacode/Features/Settings/Reducer/`), and `AgentSkillsSectionView` (`.../Views/`) add the + Agent Skills section to `CommandLineToolSettingsView`. `AppFeature.setSelection(.commandLineTool)` + creates the child state; every action recomputes all chips; results surface as toasts and + failures as an alert. `docs/components/settings.md` documents the section. +- **Tests.** Registry, installer, target, executor, parser, schema, and integration tests under + `ProwlCLITests/` (temporary roots, `PROWL_SKILLS_DIR`, temporary `HOME`); `CLIInstallClientTests`, + `SkillInstallClientTests`, `AgentSkillsFeatureTests`, `AppFeatureSettingsSelectionTests`, and + `AppFeatureAgentSkillsTests` under `supacodeTests/` (temporary bundle and home via + `SkillInstallFixture`). No test reads or writes `~/.claude`, `~/.codex`, or `~/.agents`. + +## Deviations from plan + +- `SkillInstallTarget` has no `runtimes` field and `projectDirectory` is not optional: the + reader labels for `agents` live in the contract and manual rather than in code, and all three + targets have a project directory. +- `installedDifferentSource` and `broken` carry a `destination` (added in K2's review round 2) + so the CLI and Settings can name the other build or the vanished location. +- The CLI re-reads each slot immediately before acting on it (K2 real-environment fix for + aliased `~/.claude/skills` / `~/.codex/skills`), and Settings recomputes every chip after each + action for the same reason; neither was in the plan. +- Project scope follows repository-controlled symlinks only inside the repository + (`INSTALL_CONFLICT` otherwise), an explicit `--path` resolves to its Git root, and a bare + `install` with no detected target fails with `TARGET_NOT_FOUND` — all K2 review additions. +- Settings reports a successful link with a toast only (the chip changes state); the plan's + "mirroring the CLI install row" did not decide this, and a modal per link would be noise. +- The section shows detected targets only, as planned, but the "no target detected" state adds an + explicit pointer to `prowl skills install --target …` since Settings never creates folders. + +## Open questions + +- Copy mode (`--copy`) remains deferred until a supported runtime demonstrably refuses directory + symlinks. +- Whether 063-D1's Workflows "ask your agent" prompt should run `prowl skills install` first. +- Settings UI for project scope (per-repository section) — V2 if asked. diff --git a/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md b/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md new file mode 100644 index 00000000..8c144b4b --- /dev/null +++ b/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md @@ -0,0 +1,94 @@ +# 065.005 — Agent Skills Section on the Command Line Tool Page (K3) + +## Context + +K2 (#730) gave the `prowl` CLI status, install, and uninstall for every bundled skill × target +link, but a user who never opens a terminal still had no way to see that Prowl ships skills or +to link them. K3 is the last 065 slice: the Settings › Agents › Command Line Tool page gains an +**Agent Skills** section that exposes exactly the CLI's user-scope actions, backed by the same +shared installer so both surfaces always report the same status. + +## Change + +- `SkillInstallClient` (`supacode/Clients/SkillInstall/`) is the TCA dependency over the shared + installer, shaped like `CLIInstallClient`: `bundledSkills()`, `status(skill, target)`, + `install(skill, target)`, `uninstall(skill, target)`, `revealSkill(skill)`. `liveValue` reads + `Bundle.main.resourceURL` and the user's home; `live(resourcesURL:userRoot:)` builds the same + client over any roots so tests run against a temporary bundle and home, and `testValue` is an + inert stub. `SymlinkInstallError` maps to `SkillInstallError.message` the way `CLIInstallError` + does: a real file or directory is reported as occupying the slot and never deleted. +- `AgentSkillsFeature` (`supacode/Features/Settings/Reducer/`) is a child of `SettingsFeature` + (`agentSkills: State?`), created by `AppFeature.setSelection(.commandLineTool)` and cleared + on every other section, exactly like `agentProfiles` for `.profiles`. `.task` loads the + `user`-audience skills and one `SkillLink` per **detected** target (`SkillTargetStatus.detected`); + `installLink` covers Install, Repair, and Replace (all replace the slot with a link to this + bundle), `removeLink` removes a symlink only, `revealSkillButtonTapped` opens the bundled + folder in Finder. Every completion — success or failure — recomputes all rows and chips, because + aliased targets (`~/.claude/skills` and `~/.codex/skills` symlinked to one folder) share one + physical link. A failure sets an "Agent Skills Error" alert on the child and delegates a + warning toast; a success delegates a success toast (`AppFeature` maps + `agentSkills(.delegate(.linkChanged))` to `repositories.showToast`). +- `AgentSkillsSectionView` renders under Installation and Connection: one row per skill (name, + id when it differs, description limited to three lines with the full text as a tooltip, + Reveal), and one chip per detected target with the status wording of `prowl skills list` + (Installed / Not installed / Linked elsewhere `→ destination` / Real file or directory / Broken + link `→ destination`) and one action button (Remove / Install / Replace / — / Repair). A real + file or directory shows only "Prowl never deletes it; remove it manually to link here." Empty + states: the bundle could not be read (message from `ProwlSkillsError`), no installable skills, + and no detected target (points at `prowl skills install --target claude|codex|agents`). Every + button carries a tooltip; chips show their link path on hover. +- Docs: `docs/components/settings.md` gains an Agent Skills section (statuses, actions, aliased + targets, empty states) and `docs/components/cli.md` notes that Settings offers the same + user-scope actions with the same status as `prowl skills list`. + +## Decisions and boundaries + +- Success feedback is a toast plus the chip's own state change; only failures raise a modal + alert. The CLI-install row alerts on success as well, but a per-link modal for up to three + chips per skill would be noise, and the toast path is the same `AppFeature` mechanism. +- Detection and status come from the client's `SkillTargetStatus`, so the reducer never reads + the home directory itself and undetected targets are simply absent (the CLI's `--target` + creates them). Workflow-audience skills are filtered in the reducer, not the client, because + the audience rule belongs to this surface. +- Load and status refresh are synchronous in the reducer (a handful of `lstat` calls plus one + frontmatter parse), matching the CLI-install row's synchronous `installationStatus`; only + install/uninstall run as effects. +- Not in K3: project scope, copy mode, third-party skills, auto-linking after updates, new + targets, changes to the shared installer or the CLI contract. `ProwlCLIShared` is untouched. + +## Verification + +- TDD RED: 25 missing-symbol errors for `SkillInstallClientTests` before the client existed; + 21 for the `AppFeature` selection and toast tests before the wiring existed. GREEN: 11 client + tests (all four statuses, foreign-link destination, install/replace/repair, conflict refused + with the directory intact, uninstall of links only, aliased targets) and 10 `TestStore` tests + (`user`-only rows, detected-only chips, install/remove/repair/replace transitions with full + refresh, aliased refresh, conflict alert, bundle missing, reveal, unknown ids ignored), all + over temporary roots; the `AppFeature` selection tests cover `agentSkills` creation and + clearing and the toast mapping. +- `make check` passed (strict swift-format, SwiftLint, 44 script tests); `make build-app` + passed with zero warnings and the Debug app bundles `Contents/Resources/skills/prowl-cli/SKILL.md` + identical to the source; `make build-cli`, `make test-cli-unit` (129) and + `make test-cli-integration` (102) passed unchanged. +- `make test`: 2,643 main app tests passed; one deferred-Ghostty-surface test + (`WorktreeTerminalStateAgentProfileTests/deferredProfileAppliesFontSizeAdjustmentAfterSurfaceCreation`) + failed with `.surfaceCreationFailed` while the display was asleep (`pmset -g log`) and passed + when re-run with the display awake; the suite was re-run under `caffeinate` (see the PR for + the final count). +- Visual: a copy of the Debug app launched with `CFFIXED_USER_HOME` (Foundation ignores a bare + `HOME` override) and a dedicated `PROWL_CLI_SOCKET`, so its settings, home, and skill folders + lived in a temporary directory. Screenshots of Settings › Agents › Command Line Tool show the + section in every state: Not installed × 2 with `codex` undetected; Installed / Real file or + directory / Broken link `→ /Volumes/Old/…` with Remove / no button / Repair; Linked elsewhere + `→ …/DerivedData/…` with Replace; aliased `claude` + `codex` both Installed from one link; and + the no-target explanation. The Mac was locked during the run, so button clicks could not be + delivered to the isolated instance; the action path is covered by the `TestStore` tests over + the live client. The live `~/.claude/skills`, `~/.codex/skills`, and `~/.agents` were neither + read as inputs nor modified. + +## Refs + +- Slice: 065-K3 +- Branch: `feat/bundled-skills-k3` +- PR: to fill in +- Depends on: [004-k2-skill-installer-cli.md](004-k2-skill-installer-cli.md) -- 2.51.2 From 290b484a3ea60d85e5d79b3fa75bc376cef29a3b Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 28 Aug 2026 02:15:30 +0900 Subject: [PATCH 07/11] Record the K3 PR number and the clean test run Claude-Session: https://claude.ai/code/session_01PpjWDXtmY9eFHWhwicxkvR --- docs-ai/063-agent-workflows/release-plan.md | 4 ++-- docs-ai/065-bundled-agent-skills/000-plan.md | 2 +- docs-ai/065-bundled-agent-skills/001-action.md | 2 +- .../005-k3-settings-agent-skills.md | 6 +++--- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs-ai/063-agent-workflows/release-plan.md b/docs-ai/063-agent-workflows/release-plan.md index a4ca4873..b4351216 100644 --- a/docs-ai/063-agent-workflows/release-plan.md +++ b/docs-ai/063-agent-workflows/release-plan.md @@ -37,7 +37,7 @@ user-facing surface may merge before "their" release and stay dormant. Three rel | S3 wave 1 | Complete | Merged in #721/#723/#725/#728; S3c plan [064.010](../064-agent-completion-signals/010-s3c-plan.md), record [064.011](../064-agent-completion-signals/011-s3c-action.md) | | 065-S0/K1 | Merged | #729; [065.003](../065-bundled-agent-skills/003-k1-bundle-registry.md) | | 065-K2 | Merged | #730; [065.004](../065-bundled-agent-skills/004-k2-skill-installer-cli.md) | -| 065-K3 | In review | PR to fill in; [065.005](../065-bundled-agent-skills/005-k3-settings-agent-skills.md); closes 065 | +| 065-K3 | In review | #731; [065.005](../065-bundled-agent-skills/005-k3-settings-agent-skills.md); closes 065 | A2 completes 063's R1 implementation work, and S1/S2/S3 wave 1 are on `main`. The remaining R1 work is 065 bundled skill distribution: S0, K1 (#729), and K2 (#730) are merged; K3, the @@ -125,7 +125,7 @@ R3+: V2 / S5 rest; delete HANDOFF_RETIRED stubs ## Change log -- 2026-08-28 — 065-K3 implemented on `feat/bundled-skills-k3` and opened for review: the Agent +- 2026-08-28 — 065-K3 implemented on `feat/bundled-skills-k3` and opened as #731: the Agent Skills section on Settings › Agents › Command Line Tool; 065's action log ([065.001](../065-bundled-agent-skills/001-action.md)) summarizes K1–K3. Record: [065.005](../065-bundled-agent-skills/005-k3-settings-agent-skills.md). diff --git a/docs-ai/065-bundled-agent-skills/000-plan.md b/docs-ai/065-bundled-agent-skills/000-plan.md index f0696efd..21aedb62 100644 --- a/docs-ai/065-bundled-agent-skills/000-plan.md +++ b/docs-ai/065-bundled-agent-skills/000-plan.md @@ -4,7 +4,7 @@ | --- | --- | | **Status** | Implemented — S0, K1 (#729), K2 (#730), and K3 complete; see [001-action.md](001-action.md) | | **Anchor date** | 2026-08-22 | -| **Primary PRs** | #712 (plan); #729 (K1); #730 (K2); K3 to fill in | +| **Primary PRs** | #712 (plan); #729 (K1); #730 (K2); #731 (K3) | | **Related** | [063-agent-workflows](../063-agent-workflows/000-plan.md) (D1 `skill:` materialization, D1–D3 new skills), [060-prowl-cli-targeting-and-contract-governance](../060-prowl-cli-targeting-and-contract-governance/000-plan.md) (four-layer CLI rule), [013-prowl-cli](../013-prowl-cli/000-plan.md), `docs/components/cli.md`, `skills/prowl-cli/SKILL.md` | ## Background diff --git a/docs-ai/065-bundled-agent-skills/001-action.md b/docs-ai/065-bundled-agent-skills/001-action.md index 8363217a..a90cd0aa 100644 --- a/docs-ai/065-bundled-agent-skills/001-action.md +++ b/docs-ai/065-bundled-agent-skills/001-action.md @@ -8,7 +8,7 @@ | 2026-08-27 | S0 verified the `claude` / `codex` / `agents` targets against installed runtimes in temporary homes: directory symlinks are followed, dangling links do not block discovery, copy mode stays deferred. | [002-s0-skill-targets.md](002-s0-skill-targets.md) | | 2026-08-27 | K1 merged: `embed-skills` staging, `Resources/skills` folder reference, Foundation-only `ProwlSkills` registry with the frontmatter parser and `metadata.prowl-install` audience, CLI bundle resolution, typed errors. | #729, [003-k1-bundle-registry.md](003-k1-bundle-registry.md) | | 2026-08-27 | K2 merged after four review rounds and an owner-authorized real-environment check: shared `SymlinkInstaller` (extracted from `CLIInstallClient`, adds `broken`), declarative `SkillInstallTarget`, `ProwlSkillInstaller`, local-only `prowl skills list\|install\|uninstall\|path` with contract, schema, manual, and skill line; aliased targets handled by re-reading each slot. | #730, [004-k2-skill-installer-cli.md](004-k2-skill-installer-cli.md) | -| 2026-08-28 | K3: `SkillInstallClient`, `AgentSkillsFeature`, and the Agent Skills section on Settings › Agents › Command Line Tool; `settings.md` / `cli.md` updated; 065 complete. | PR to fill in, [005-k3-settings-agent-skills.md](005-k3-settings-agent-skills.md) | +| 2026-08-28 | K3: `SkillInstallClient`, `AgentSkillsFeature`, and the Agent Skills section on Settings › Agents › Command Line Tool; `settings.md` / `cli.md` updated; 065 complete. | #731, [005-k3-settings-agent-skills.md](005-k3-settings-agent-skills.md) | ## Outcome & current state (as of 2026-08-28) diff --git a/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md b/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md index 8c144b4b..4ba6cd70 100644 --- a/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md +++ b/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md @@ -73,8 +73,8 @@ shared installer so both surfaces always report the same status. - `make test`: 2,643 main app tests passed; one deferred-Ghostty-surface test (`WorktreeTerminalStateAgentProfileTests/deferredProfileAppliesFontSizeAdjustmentAfterSurfaceCreation`) failed with `.surfaceCreationFailed` while the display was asleep (`pmset -g log`) and passed - when re-run with the display awake; the suite was re-run under `caffeinate` (see the PR for - the final count). + when re-run with the display awake; a full re-run under `caffeinate` passed 2,644 main app + tests plus the 2 isolated shell-cancellation tests with zero failures. - Visual: a copy of the Debug app launched with `CFFIXED_USER_HOME` (Foundation ignores a bare `HOME` override) and a dedicated `PROWL_CLI_SOCKET`, so its settings, home, and skill folders lived in a temporary directory. Screenshots of Settings › Agents › Command Line Tool show the @@ -90,5 +90,5 @@ shared installer so both surfaces always report the same status. - Slice: 065-K3 - Branch: `feat/bundled-skills-k3` -- PR: to fill in +- PR: #731 - Depends on: [004-k2-skill-installer-cli.md](004-k2-skill-installer-cli.md) -- 2.51.2 From 5b5335cc98d67f1c510cf7ab94b1d7be5405fd9c Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 28 Aug 2026 02:32:40 +0900 Subject: [PATCH 08/11] Own the Agent Skills state in SettingsFeature so leaving the page cancels link effects Claude-Session: https://claude.ai/code/session_01PpjWDXtmY9eFHWhwicxkvR --- .../065-bundled-agent-skills/001-action.md | 11 +++-- .../005-k3-settings-agent-skills.md | 19 +++++++- .../Features/App/Reducer/AppFeature.swift | 12 +---- .../Settings/Reducer/SettingsFeature.swift | 12 ++++- .../AppFeatureAgentSkillsTests.swift | 48 +++++++++++++++++++ 5 files changed, 86 insertions(+), 16 deletions(-) diff --git a/docs-ai/065-bundled-agent-skills/001-action.md b/docs-ai/065-bundled-agent-skills/001-action.md index a90cd0aa..5ac4a024 100644 --- a/docs-ai/065-bundled-agent-skills/001-action.md +++ b/docs-ai/065-bundled-agent-skills/001-action.md @@ -35,9 +35,10 @@ `docs/components/cli.md`, one line in `skills/prowl-cli/SKILL.md`). - **Settings.** `SkillInstallClient` (`supacode/Clients/SkillInstall/`), `AgentSkillsFeature` (`supacode/Features/Settings/Reducer/`), and `AgentSkillsSectionView` (`.../Views/`) add the - Agent Skills section to `CommandLineToolSettingsView`. `AppFeature.setSelection(.commandLineTool)` - creates the child state; every action recomputes all chips; results surface as toasts and - failures as an alert. `docs/components/settings.md` documents the section. + Agent Skills section to `CommandLineToolSettingsView`. `SettingsFeature.setSelection` creates + the child state for `.commandLineTool` and clears it otherwise (so `ifLet` cancels in-flight + link effects); every action recomputes all chips; results surface as toasts and failures as an + alert. `docs/components/settings.md` documents the section. - **Tests.** Registry, installer, target, executor, parser, schema, and integration tests under `ProwlCLITests/` (temporary roots, `PROWL_SKILLS_DIR`, temporary `HOME`); `CLIInstallClientTests`, `SkillInstallClientTests`, `AgentSkillsFeatureTests`, `AppFeatureSettingsSelectionTests`, and @@ -59,6 +60,10 @@ `install` with no detected target fails with `TARGET_NOT_FOUND` — all K2 review additions. - Settings reports a successful link with a toast only (the chip changes state); the plan's "mirroring the CLI install row" did not decide this, and a modal per link would be noise. +- `agentSkills` is created and cleared by `SettingsFeature.setSelection`, not by + `AppFeature.setSelection` as the plan's `agentProfiles` analogy implied: a grandparent + mutation happens after the child `ifLet` has run, so it cannot cancel in-flight link effects + (K3 review round 1). - The section shows detected targets only, as planned, but the "no target detected" state adds an explicit pointer to `prowl skills install --target …` since Settings never creates folders. diff --git a/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md b/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md index 4ba6cd70..975a3185 100644 --- a/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md +++ b/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md @@ -18,8 +18,9 @@ shared installer so both surfaces always report the same status. inert stub. `SymlinkInstallError` maps to `SkillInstallError.message` the way `CLIInstallError` does: a real file or directory is reported as occupying the slot and never deleted. - `AgentSkillsFeature` (`supacode/Features/Settings/Reducer/`) is a child of `SettingsFeature` - (`agentSkills: State?`), created by `AppFeature.setSelection(.commandLineTool)` and cleared - on every other section, exactly like `agentProfiles` for `.profiles`. `.task` loads the + (`agentSkills: State?`), created when `SettingsFeature.setSelection` resolves to + `.commandLineTool` and cleared on every other section (unlike `agentProfiles`, which + `AppFeature` manages — see Review hardening for why). `.task` loads the `user`-audience skills and one `SkillLink` per **detected** target (`SkillTargetStatus.detected`); `installLink` covers Install, Repair, and Replace (all replace the slot with a link to this bundle), `removeLink` removes a symlink only, `revealSkillButtonTapped` opens the bundled @@ -56,6 +57,20 @@ shared installer so both surfaces always report the same status. - Not in K3: project scope, copy mode, third-party skills, auto-linking after updates, new targets, changes to the shared installer or the CLI contract. `ProwlCLIShared` is untouched. +## Review hardening + +Adversarial review round 1 (sibling reviewer, brief and findings kept outside the repository) +found no P0/P1 and one P2: the child state was created and cleared by `AppFeature.setSelection`, +the grandparent, which mutates `settings.agentSkills` *after* `SettingsFeature`'s `ifLet` has +already run for that action — so the `ifLet` never observed a non-nil → nil transition and an +in-flight install/uninstall effect survived the section switch; its delayed completion then hit +nil child state (a TCA runtime warning in Debug) and was dropped without the promised refresh, +toast, or failure alert. The reviewer reproduced it with a suspended-install probe. The lifecycle +now lives in `SettingsFeature.setSelection` (create on `.commandLineTool` when absent, clear +otherwise), so `ifLet` cancels the child's effects with the state; `AppFeature` no longer touches +`agentSkills`. Pinned by an `AppFeature` test that suspends install/uninstall on a `TestClock`, +switches to General, and requires every effect to be gone. + ## Verification - TDD RED: 25 missing-symbol errors for `SkillInstallClientTests` before the client existed; diff --git a/supacode/Features/App/Reducer/AppFeature.swift b/supacode/Features/App/Reducer/AppFeature.swift index 868e4f6f..3dd117e6 100644 --- a/supacode/Features/App/Reducer/AppFeature.swift +++ b/supacode/Features/App/Reducer/AppFeature.swift @@ -380,17 +380,10 @@ 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 @@ -414,12 +407,11 @@ struct AppFeature { state.settings.repositorySettings = repoSettingsState state.settings.globalCustomCommands = nil state.settings.agentProfiles = nil - state.settings.agentSkills = nil - case .general, .notifications, .shortcuts, .worktree, .updates, .advanced, .github: + case .general, .notifications, .shortcuts, .worktree, .updates, .advanced, .github, .commandLineTool: + // `settings.agentSkills` is owned by SettingsFeature.setSelection. state.settings.repositorySettings = nil state.settings.globalCustomCommands = nil state.settings.agentProfiles = nil - state.settings.agentSkills = nil } return .none diff --git a/supacode/Features/Settings/Reducer/SettingsFeature.swift b/supacode/Features/Settings/Reducer/SettingsFeature.swift index 2a499cba..7455ee3d 100644 --- a/supacode/Features/Settings/Reducer/SettingsFeature.swift +++ b/supacode/Features/Settings/Reducer/SettingsFeature.swift @@ -452,7 +452,17 @@ struct SettingsFeature { return .none case .setSelection(let selection): - state.selection = selection ?? .general + let resolvedSelection = selection ?? .general + state.selection = resolvedSelection + // Owned here rather than in AppFeature so `ifLet` observes the removal and cancels an + // in-flight link effect instead of letting its completion land on nil child state. + if resolvedSelection == .commandLineTool { + if state.agentSkills == nil { + state.agentSkills = .init() + } + } else { + state.agentSkills = nil + } return .none case .alert(.presented(.openSystemNotificationSettings)): diff --git a/supacodeTests/AppFeatureAgentSkillsTests.swift b/supacodeTests/AppFeatureAgentSkillsTests.swift index 1a583b7d..6ea9c863 100644 --- a/supacodeTests/AppFeatureAgentSkillsTests.swift +++ b/supacodeTests/AppFeatureAgentSkillsTests.swift @@ -34,4 +34,52 @@ struct AppFeatureAgentSkillsTests { $0.repositories.statusToast = .warning("Skill link failed: boom") } } + + /// Leaving the Command Line Tool page removes the child state; the in-flight link effect must be + /// cancelled with it, or its completion lands on nil state and the outcome is never reported. + @Test(.dependencies, arguments: [AgentSkillsFeatureTests.LinkAction.install, .remove]) + func switchingSectionsCancelsAnInFlightLinkEffect(action: AgentSkillsFeatureTests.LinkAction) async throws { + let fixture = try SkillInstallFixture() + defer { fixture.cleanup() } + let clock = TestClock() + var client = fixture.client + client.install = { _, _ in try await clock.sleep(for: .seconds(1)) } + client.uninstall = { _, _ in try await clock.sleep(for: .seconds(1)) } + let store = TestStore(initialState: AppFeature.State(settings: SettingsFeature.State())) { + AppFeature() + } withDependencies: { + $0.skillInstallClient = client + } + + await store.send(.settings(.setSelection(.commandLineTool))) { + $0.settings.selection = .commandLineTool + $0.settings.agentSkills = .init() + } + await store.send(.settings(.agentSkills(.task))) { + $0.settings.agentSkills?.skills = [ + try fixture.row("prowl-cli", links: [("claude", .notInstalled), ("agents", .notInstalled)]) + ] + } + switch action { + case .install: + await store.send(.settings(.agentSkills(.installLink(skillID: "prowl-cli", targetID: "claude")))) + case .remove: + await store.send(.settings(.agentSkills(.removeLink(skillID: "prowl-cli", targetID: "claude")))) + } + + await store.send(.settings(.setSelection(.general))) { + $0.settings.selection = .general + $0.settings.agentSkills = nil + } + // The suspended operation is cancelled with the child state; nothing may complete afterwards. + await clock.advance(by: .seconds(1)) + await store.finish() + } +} + +extension AgentSkillsFeatureTests { + enum LinkAction { + case install + case remove + } } -- 2.51.2 From 49a5fd558b37e7b1069c02994a412967940a3bdb Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 28 Aug 2026 02:42:30 +0900 Subject: [PATCH 09/11] Correct the reducer order in the K3 review record Claude-Session: https://claude.ai/code/session_01PpjWDXtmY9eFHWhwicxkvR --- docs-ai/065-bundled-agent-skills/001-action.md | 7 ++++--- .../005-k3-settings-agent-skills.md | 12 +++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs-ai/065-bundled-agent-skills/001-action.md b/docs-ai/065-bundled-agent-skills/001-action.md index 5ac4a024..50019e65 100644 --- a/docs-ai/065-bundled-agent-skills/001-action.md +++ b/docs-ai/065-bundled-agent-skills/001-action.md @@ -61,9 +61,10 @@ - Settings reports a successful link with a toast only (the chip changes state); the plan's "mirroring the CLI install row" did not decide this, and a modal per link would be noise. - `agentSkills` is created and cleared by `SettingsFeature.setSelection`, not by - `AppFeature.setSelection` as the plan's `agentProfiles` analogy implied: a grandparent - mutation happens after the child `ifLet` has run, so it cannot cancel in-flight link effects - (K3 review round 1). + `AppFeature.setSelection` as the plan's `agentProfiles` analogy implied: the grandparent's + `Reduce` runs before the `Scope` into `SettingsFeature`, so its mutation lies outside the + child `ifLet`'s transition boundary and cannot cancel in-flight link effects (K3 review + round 1, wording corrected in round 2). - The section shows detected targets only, as planned, but the "no target detected" state adds an explicit pointer to `prowl skills install --target …` since Settings never creates folders. diff --git a/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md b/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md index 975a3185..463e7a26 100644 --- a/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md +++ b/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md @@ -61,9 +61,10 @@ shared installer so both surfaces always report the same status. Adversarial review round 1 (sibling reviewer, brief and findings kept outside the repository) found no P0/P1 and one P2: the child state was created and cleared by `AppFeature.setSelection`, -the grandparent, which mutates `settings.agentSkills` *after* `SettingsFeature`'s `ifLet` has -already run for that action — so the `ifLet` never observed a non-nil → nil transition and an -in-flight install/uninstall effect survived the section switch; its delayed completion then hit +the grandparent, whose core `Reduce` runs before the `Scope` into `SettingsFeature` — so the +nested optional was already nil when `SettingsFeature`'s `ifLet` ran, the `ifLet` never observed +a non-nil → nil transition of its own, and an in-flight install/uninstall effect survived the +section switch; its delayed completion then hit nil child state (a TCA runtime warning in Debug) and was dropped without the promised refresh, toast, or failure alert. The reviewer reproduced it with a suspended-install probe. The lifecycle now lives in `SettingsFeature.setSelection` (create on `.commandLineTool` when absent, clear @@ -71,6 +72,11 @@ otherwise), so `ifLet` cancels the child's effects with the state; `AppFeature` `agentSkills`. Pinned by an `AppFeature` test that suspends install/uninstall on a `TestClock`, switches to General, and requires every effect to be gone. +Round 2 re-verified the fix (including re-selecting the same row, reopening Settings mid-action, +and creating a replacement child) and found no P0/P1/P2; its one P3 was that this record and the +action log had described the reducer order backwards ("after the `ifLet` had run"), corrected +above. The review loop closed there. + ## Verification - TDD RED: 25 missing-symbol errors for `SkillInstallClientTests` before the client existed; -- 2.51.2 From f95c15662c97dbc6b7f83c769efa81b99ec65b9b Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 28 Aug 2026 08:41:17 +0900 Subject: [PATCH 10/11] Add an optional prowl-summary to bundled skill frontmatter The agent-facing description carries trigger phrasing and runs to a paragraph; Settings needs a short human-facing summary instead. Claude-Session: https://claude.ai/code/session_01PpjWDXtmY9eFHWhwicxkvR --- ProwlCLITests/ProwlSkillsTests.swift | 61 ++++++++++++++++++ skills/prowl-cli/SKILL.md | 2 + supacode/CLIService/Shared/ProwlSkills.swift | 68 ++++++++++++-------- 3 files changed, 105 insertions(+), 26 deletions(-) diff --git a/ProwlCLITests/ProwlSkillsTests.swift b/ProwlCLITests/ProwlSkillsTests.swift index fd34b2e9..59bfd009 100644 --- a/ProwlCLITests/ProwlSkillsTests.swift +++ b/ProwlCLITests/ProwlSkillsTests.swift @@ -64,6 +64,67 @@ final class ProwlSkillsTests: XCTestCase { } } + func testBundledParsesOptionalSummaryMetadata() throws { + try withTemporaryDirectory { root in + let resources = root.appending(path: "Resources", directoryHint: .isDirectory) + _ = try writeSkill( + id: "summarized", + frontmatter: """ + --- + name: Summarized + description: The long agent-facing description with every trigger phrase. + metadata: + prowl-summary: Short display text: what it does and how to use it. + prowl-install: user + --- + """, + resourcesURL: resources + ) + _ = try writeSkill( + id: "plain", + frontmatter: """ + --- + name: Plain + description: No summary here. + --- + """, + resourcesURL: resources + ) + + let skills = try ProwlSkills.bundled(resourcesURL: resources) + + XCTAssertEqual(skills.map(\.summary), [nil, "Short display text: what it does and how to use it."]) + XCTAssertEqual(skills.map(\.audience), [.user, .user]) + } + } + + func testBundledRejectsEmptyOrDuplicateSummaryMetadata() throws { + for metadata in [" prowl-summary:", " prowl-summary: One\n prowl-summary: Two"] { + try withTemporaryDirectory { root in + let resources = root.appending(path: "Resources", directoryHint: .isDirectory) + _ = try writeSkill( + id: "bad", + frontmatter: """ + --- + name: Bad + description: Valid description. + metadata: + \(metadata) + --- + """, + resourcesURL: resources + ) + + XCTAssertThrowsError(try ProwlSkills.bundled(resourcesURL: resources)) { error in + guard case ProwlSkillsError.invalidFrontmatter(_, let reason) = error else { + return XCTFail("Expected invalidFrontmatter, got \(error)") + } + XCTAssertTrue(reason.contains("prowl-summary"), reason) + } + } + } + } + func testBundledParsesExplicitUserAndWorkflowAudiences() throws { try withTemporaryDirectory { root in let resources = root.appending(path: "Resources", directoryHint: .isDirectory) diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 140aacf2..02147a06 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -2,6 +2,8 @@ name: prowl-cli description: >- Use the Prowl CLI (`prowl`) to inspect or control a running Prowl GUI app and the agent sessions it hosts. Prowl runs several coding agents in parallel, each in its own pane/tab/worktree, so reach for this whenever the user wants to act on a pane other than the current one — check on, coordinate, read from, focus, send text or keys to, open, or close another pane, tab, worktree, split, window, or sibling/neighboring agent. Covers colloquial framings that never say "prowl": "check what the agent in my other window is doing", "are any of my agents running side by side still working or idle?", "tell the agent in my left split to rerun the tests", "send npm run build to the build tab and grab the output", "open ~/proj in a fresh tab", "close that scratch tab I left open". Not for ordinary editing or building inside the Prowl source repo, and not for how-to questions about Prowl's settings, preferences, or keybindings — only when the task is to actually drive panes in the live Prowl app. +metadata: + prowl-summary: Lets an agent drive the running Prowl app through the prowl command — list panes, read or message sibling agents, send text and keys, create or close tabs and panes, launch profiles, and wait for a task to finish. Link it into a runtime's skill folder so the agent knows when and how to use prowl. --- # Prowl CLI diff --git a/supacode/CLIService/Shared/ProwlSkills.swift b/supacode/CLIService/Shared/ProwlSkills.swift index b5948849..d133063d 100644 --- a/supacode/CLIService/Shared/ProwlSkills.swift +++ b/supacode/CLIService/Shared/ProwlSkills.swift @@ -8,7 +8,10 @@ nonisolated public enum ProwlSkillAudience: String, Codable, Equatable, Sendable nonisolated public struct BundledSkill: Equatable, Sendable { public let id: String public let name: String + /// The agent-facing description from the frontmatter, including trigger phrasing. public let description: String + /// `metadata.prowl-summary`: a short human-facing summary for UI surfaces; nil when absent. + public let summary: String? public let audience: ProwlSkillAudience public let directoryURL: URL @@ -17,11 +20,13 @@ nonisolated public struct BundledSkill: Equatable, Sendable { name: String, description: String, audience: ProwlSkillAudience, - directoryURL: URL + directoryURL: URL, + summary: String? = nil ) { self.id = id self.name = name self.description = description + self.summary = summary self.audience = audience self.directoryURL = directoryURL } @@ -160,7 +165,8 @@ nonisolated public enum ProwlSkills { name: frontmatter.name, description: frontmatter.description, audience: frontmatter.audience, - directoryURL: directoryURL + directoryURL: directoryURL, + summary: frontmatter.summary ) } @@ -180,8 +186,7 @@ nonisolated public enum ProwlSkills { let frontmatterLines = Array(lines[1.. ParsedAudienceMetadata { - var audience = currentAudience - var wasSet = audienceWasSet + ) throws -> Int { var directFieldIndent: Int? var index = startIndex @@ -339,18 +345,27 @@ nonisolated public enum ProwlSkills { guard let (key, value) = keyValue(in: line), !key.isEmpty else { throw invalidFrontmatter(path: path, reason: "metadata contains a malformed field") } - if key == "prowl-install" { - guard !wasSet, let parsedAudience = ProwlSkillAudience(rawValue: value) else { + switch key { + case "prowl-install": + guard !state.audienceWasSet, let parsedAudience = ProwlSkillAudience(rawValue: value) else { throw invalidFrontmatter( path: path, reason: "metadata.prowl-install must be user or workflow") } - audience = parsedAudience - wasSet = true + state.audience = parsedAudience + state.audienceWasSet = true + case "prowl-summary": + guard state.summary == nil, !value.isEmpty else { + throw invalidFrontmatter( + path: path, reason: "metadata.prowl-summary must be a single non-empty scalar") + } + state.summary = value + default: + break } index += 1 } - return ParsedAudienceMetadata(audience: audience, wasSet: wasSet, nextIndex: index) + return index } private static func validateTopLevelKey(_ key: String, path: String) throws { @@ -395,10 +410,11 @@ nonisolated private struct ParsedFrontmatter { let name: String let description: String let audience: ProwlSkillAudience + let summary: String? } -nonisolated private struct ParsedAudienceMetadata { - let audience: ProwlSkillAudience - let wasSet: Bool - let nextIndex: Int +nonisolated private struct ParsedMetadata { + var audience = ProwlSkillAudience.user + var audienceWasSet = false + var summary: String? } -- 2.51.2 From d36480ec3ec6985abe4b8c84f22063bca8e000ac Mon Sep 17 00:00:00 2001 From: onevcat Date: Fri, 28 Aug 2026 08:41:19 +0900 Subject: [PATCH 11/11] Lay out skill targets as an aligned table with their link folders Claude-Session: https://claude.ai/code/session_01PpjWDXtmY9eFHWhwicxkvR --- docs-ai/065-bundled-agent-skills/000-plan.md | 5 + .../065-bundled-agent-skills/001-action.md | 3 +- .../005-k3-settings-agent-skills.md | 31 ++++-- docs/components/settings.md | 19 ++-- .../Views/AgentSkillsSectionView.swift | 98 +++++++++++++------ 5 files changed, 107 insertions(+), 49 deletions(-) diff --git a/docs-ai/065-bundled-agent-skills/000-plan.md b/docs-ai/065-bundled-agent-skills/000-plan.md index 21aedb62..ae6ee5b2 100644 --- a/docs-ai/065-bundled-agent-skills/000-plan.md +++ b/docs-ai/065-bundled-agent-skills/000-plan.md @@ -214,6 +214,11 @@ Decisions below were taken in the 2026-08-22 plan review (#712): ## Amendments +- Updated 2026-08-28 (K3 review follow-up): `BundledSkill` gained an optional `summary` + (`metadata.prowl-summary`) so Settings can show human-facing text while the agent-facing + `description` keeps its trigger phrasing; the Settings target chips became an aligned + full-width table showing each link's folder — see + [005-k3-settings-agent-skills.md](005-k3-settings-agent-skills.md). - Updated 2026-08-28: Implemented and verified K3: the Command Line Tool page gained the Agent Skills section over a `SkillInstallClient` dependency and an `AgentSkillsFeature` child; every action recomputes all chips so aliased targets stay consistent; success is a toast, failure an diff --git a/docs-ai/065-bundled-agent-skills/001-action.md b/docs-ai/065-bundled-agent-skills/001-action.md index 50019e65..26a4abe7 100644 --- a/docs-ai/065-bundled-agent-skills/001-action.md +++ b/docs-ai/065-bundled-agent-skills/001-action.md @@ -19,7 +19,8 @@ `metadata.prowl-install`. - **Registry.** `ProwlSkills` (`supacode/CLIService/Shared/ProwlSkills.swift`, part of `ProwlCLIShared` and compiled into the app) parses `name`, plain or `>-` `description`, and the - strictly nested `metadata.prowl-install` audience; `bundled(resourcesURL:)` serves the app, + strictly nested `metadata.prowl-install` audience and optional `metadata.prowl-summary` + display text; `bundled(resourcesURL:)` serves the app, `bundledForCLI(executableURL:environment:)` resolves the CLI's own bundle or `PROWL_SKILLS_DIR`, and `skill(id:)` is the locator 063 uses. - **Installer.** `SymlinkInstaller` (`SymlinkInstaller.swift`) owns one link slot with the diff --git a/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md b/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md index 463e7a26..1f5e1b87 100644 --- a/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md +++ b/docs-ai/065-bundled-agent-skills/005-k3-settings-agent-skills.md @@ -30,21 +30,27 @@ shared installer so both surfaces always report the same status. warning toast; a success delegates a success toast (`AppFeature` maps `agentSkills(.delegate(.linkChanged))` to `repositories.showToast`). - `AgentSkillsSectionView` renders under Installation and Connection: one row per skill (name, - id when it differs, description limited to three lines with the full text as a tooltip, - Reveal), and one chip per detected target with the status wording of `prowl skills list` - (Installed / Not installed / Linked elsewhere `→ destination` / Real file or directory / Broken - link `→ destination`) and one action button (Remove / Install / Replace / — / Repair). A real - file or directory shows only "Prowl never deletes it; remove it manually to link here." Empty - states: the bundle could not be read (message from `ProwlSkillsError`), no installable skills, - and no detected target (points at `prowl skills install --target claude|codex|agents`). Every - button carries a tooltip; chips show their link path on hover. + id when it differs, the skill's `metadata.prowl-summary` — falling back to the agent-facing + `description` — and Reveal), then one full-width line per detected target laid out as a + `Grid` so the four columns align: status icon + target name, the link folder + (`~`-abbreviated, monospaced, middle-truncated; a second line carries `→ destination` for a + foreign or dangling link, or the "not a symlink" explanation), the status wording of + `prowl skills list` (Installed / Not installed / Linked elsewhere / Real file or directory / + Broken link), and one action button (Remove / Install / Replace / — / Repair). Empty states: + the bundle could not be read (message from `ProwlSkillsError`), no installable skills, and no + detected target (points at `prowl skills install --target claude|codex|agents`). Every button + carries a tooltip; the folder cell's tooltip holds the full paths. +- `ProwlSkills` gained the optional `metadata.prowl-summary` field (`BundledSkill.summary`): + a skill's `description` is agent-facing trigger text (`prowl-cli`'s runs to a paragraph of + colloquial phrasings), so Settings shows the summary instead. Absent → nil; empty or duplicated + → `INVALID_SKILL_FRONTMATTER`. The CLI payload and contract are unchanged. - Docs: `docs/components/settings.md` gains an Agent Skills section (statuses, actions, aliased targets, empty states) and `docs/components/cli.md` notes that Settings offers the same user-scope actions with the same status as `prowl skills list`. ## Decisions and boundaries -- Success feedback is a toast plus the chip's own state change; only failures raise a modal +- Success feedback is a toast plus the line's own state change; only failures raise a modal alert. The CLI-install row alerts on success as well, but a per-link modal for up to three chips per skill would be noise, and the toast path is the same `AppFeature` mechanism. - Detection and status come from the client's `SkillTargetStatus`, so the reducer never reads @@ -77,6 +83,13 @@ and creating a replacement child) and found no P0/P1/P2; its one P3 was that thi action log had described the reducer order backwards ("after the `ifLet` had run"), corrected above. The review loop closed there. +Owner feedback after the loop (2026-08-28): the agent-facing description was far too long for +the row and its hover tooltip misbehaved, and the content-sized capsules had unequal widths with +an empty gap before the buttons. The row now shows `prowl-summary`, and the per-target lines +became an aligned full-width `Grid` that also shows each link's folder (impeccable layout pass: +three equivalent targets are a table, not floating chips; no nested containers inside the +grouped Form card). + ## Verification - TDD RED: 25 missing-symbol errors for `SkillInstallClientTests` before the client existed; diff --git a/docs/components/settings.md b/docs/components/settings.md index 045737be..5067e774 100644 --- a/docs/components/settings.md +++ b/docs/components/settings.md @@ -61,16 +61,19 @@ your agents' skill folders so every agent reads the version that matches the ins app. It is the GUI for [`prowl skills`](cli.md#prowl-skills) in user scope and shows the same status as `prowl skills list`. -- **Rows** — one per bundled `user` skill: name, description, and **Reveal** (shows the - bundled skill folder in Finder). Workflow-only skills are not listed. -- **Chips** — one per *detected* target: `Claude Code` (`~/.claude/skills`), `Codex` - (`~/.codex/skills`), and `Shared agents directory` (`~/.agents/skills`). A target is - detected when its parent folder (`~/.claude`, `~/.codex`, `~/.agents`) exists; hovering - a chip shows the link path. With no detected target the section says so and points at +- **Rows** — one per bundled `user` skill: name, a short summary (the skill's + `metadata.prowl-summary`; the agent-facing `description` is shown only when a skill has no + summary), and **Reveal** (shows the bundled skill folder in Finder). Workflow-only skills are + not listed. +- **Target lines** — one per *detected* target under each skill: `Claude Code` + (`~/.claude/skills`), `Codex` (`~/.codex/skills`), and `Shared agents directory` + (`~/.agents/skills`), each showing the link's folder (`~/.claude/skills/prowl-cli`), its + status, and one action. A target is detected when its parent folder (`~/.claude`, `~/.codex`, + `~/.agents`) exists. With no detected target the section says so and points at `prowl skills install --target `, which creates the folder. - **Statuses and actions** — one explicit action per skill × target link: - | Chip | Meaning | Button | + | Status | Meaning | Button | |---|---|---| | Installed | Symlink → this app's bundled skill | **Remove** (deletes the link only) | | Not installed | Nothing in the slot | **Install** (creates the skills folder if needed) | @@ -79,7 +82,7 @@ same status as `prowl skills list`. | Real file or directory | Something that is not a symlink occupies the slot | none — Prowl never deletes it; remove it manually | - **Aliased targets** — if `~/.claude/skills` and `~/.codex/skills` are symlinks to one - synced folder, both chips describe the same link: installing or removing through one + synced folder, both lines describe the same link: installing or removing through one updates the other immediately. - Results show as a toast; a failure (for example a real directory in the way) also shows an alert. Nothing is auto-linked after an update: a newly bundled skill simply appears diff --git a/supacode/Features/Settings/Views/AgentSkillsSectionView.swift b/supacode/Features/Settings/Views/AgentSkillsSectionView.swift index 0dff1347..3910b078 100644 --- a/supacode/Features/Settings/Views/AgentSkillsSectionView.swift +++ b/supacode/Features/Settings/Views/AgentSkillsSectionView.swift @@ -2,8 +2,8 @@ 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. +/// skill, with one full-width line per detected target (status, link folder, action). +/// Link behavior stays in `AgentSkillsFeature`; this view only presents it. struct AgentSkillsSectionView: View { @Bindable var store: StoreOf @@ -65,7 +65,7 @@ struct AgentSkillsSectionView: View { } private func skillRow(_ row: AgentSkillsFeature.SkillRow) -> some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 10) { HStack(alignment: .firstTextBaseline, spacing: 8) { Text(row.skill.name) .font(.headline) @@ -82,43 +82,57 @@ struct AgentSkillsSectionView: View { .buttonStyle(.bordered) .controlSize(.small) } - Text(row.skill.description) + // The frontmatter description carries agent trigger phrasing; the summary is the + // human-facing text, so it wins whenever the skill provides one. + Text(row.skill.summary ?? row.skill.description) .foregroundStyle(.secondary) .font(.callout) - .lineLimit(3) - .help(row.skill.description) + .fixedSize(horizontal: false, vertical: true) + if !row.links.isEmpty { + linkTable(row) + } + } + } + + /// One line per detected target. A grid keeps the target, folder, status, and action columns + /// aligned across lines, so every line spans the row instead of sizing to its own text. + private func linkTable(_ row: AgentSkillsFeature.SkillRow) -> some View { + Grid(alignment: .leadingFirstTextBaseline, horizontalSpacing: 12, verticalSpacing: 8) { ForEach(row.links) { link in - linkRow(skill: row.skill, link: link) + if link.id != row.links.first?.id { + Divider() + } + linkLine(skill: row.skill, link: link) } } + .font(.callout) } - private func linkRow(skill: BundledSkill, link: AgentSkillsFeature.SkillLink) -> some View { - HStack(spacing: 8) { + private func linkLine(skill: BundledSkill, link: AgentSkillsFeature.SkillLink) -> some View { + GridRow { 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) + VStack(alignment: .leading, spacing: 2) { + pathText(abbreviated(link.linkPath)) + if let detail = detail(for: link.status) { + if detail.isPath { + pathText(detail.text) + } else { + Text(detail.text) + .fixedSize(horizontal: false, vertical: true) + } + } } - - Spacer() - + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .help(link.status.destination.map { "\(link.linkPath) → \($0)" } ?? link.linkPath) + Text(statusText(link.status)) + .foregroundStyle(.secondary) + .gridColumnAlignment(.trailing) actionButton(skill: skill, link: link) + .gridColumnAlignment(.trailing) } } @@ -154,10 +168,6 @@ struct AgentSkillsSectionView: View { .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) } } } @@ -189,4 +199,30 @@ struct AgentSkillsSectionView: View { case .broken: "Broken link" } } + + /// The second folder line: where a foreign or dangling link points, or why a real file or + /// directory gets no action. + private func detail(for status: SymlinkInstallStatus) -> (text: String, isPath: Bool)? { + switch status { + case .installed, .notInstalled: + nil + case .installedDifferentSource(_, let destination): + destination.map { ("→ \(abbreviated($0))", true) } + ?? ("Not a symlink — Prowl never deletes it. Remove it manually to link here.", false) + case .broken(_, let destination): + ("→ \(abbreviated(destination))", true) + } + } + + /// Paths keep one line and truncate in the middle so the skill folder name stays visible. + private func pathText(_ path: String) -> some View { + Text(path) + .font(.callout.monospaced()) + .lineLimit(1) + .truncationMode(.middle) + } + + private func abbreviated(_ path: String) -> String { + (path as NSString).abbreviatingWithTildeInPath + } } -- 2.51.2