native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add native Pi coding agent support (#262)

* Add native Pi coding agent support in Developer Settings

- Add PiSettingsInstaller that manages a bundled TypeScript extension
at ~/.pi/agent/extensions/supacode/index.ts (uses ownership marker
to identify Supacode-managed installs)
- Add PiSettingsClient dependency for check/install/uninstall
- Add PiExtensionContent with the bundled extension source that reports
agent_start/agent_end/session_shutdown hooks via the Supacode socket
- Add .piHooks slot to AgentHookSlot and wire into SettingsFeature
- Add .pi case to SkillAgent with configDirectoryName '.pi/agent'
- Add piSkillMd to CLISkillContent for Pi CLI skill installation
- Add Pi section to DeveloperSettingsView with Hooks + CLI Skill rows
- Add pi-mark SVG asset for the section header icon
- Add PiSettingsInstallerTests and Pi-specific SettingsFeature tests
- Add identifier_name exclusions for 'id' and 'pi' in .swiftlint.yml

* Address Pi agent PR review feedback

- Collapse Pi Developer Settings section by default to match Kiro.
- Harden PiSettingsInstaller: log read failures instead of conflating
them with "not installed"; throw `.extensionNotManaged` from both
install and uninstall instead of silently flipping the UI; log stale
empty-dir cleanup.
- Drop redundant TypeScript banner comments and the narrate-what event
mapping (the top-of-file docstring is the single source of truth).
- Replace the project-wide `pi` identifier_name exclusion with a scoped
`swiftlint:disable:next` on the enum case.
- Update pi-mark.svg to the official Pi logo with an `original` template
intent so it renders like the Kiro and Claude marks.
- Expand test coverage: 7-slot + 4-skill startup checks, typed error
paths, partial/non-UTF8 marker cases, install overwrite against stale
bodies, and ownership-guard dir preservation.

---------

Co-authored-by: Stefano Bertagno <stefano@bertagno.com>

authored by

Johannes Ebeling
Stefano Bertagno
and committed by
GitHub
6fff0218 3b07571b

+908 -7
+4
.swiftlint.yml
··· 39 39 nesting: 40 40 type_level: 2 41 41 42 + identifier_name: 43 + excluded: 44 + - id 45 + 42 46 redundant_discardable_let: 43 47 ignore_swiftui_view_bodies: true 44 48
+16 -3
SupacodeSettingsFeature/Reducer/SettingsFeature.swift
··· 44 44 public var kiroProgressState = AgentHooksInstallState.checking 45 45 public var kiroNotificationsState = AgentHooksInstallState.checking 46 46 public var kiroSkillState = AgentHooksInstallState.checking 47 + public var piHooksState = AgentHooksInstallState.checking 48 + public var piSkillState = AgentHooksInstallState.checking 47 49 /// `nil` when the settings window is closed; non-nil selects the visible section. 48 50 public var selection: SettingsSection? 49 51 public var repositorySummaries: [SettingsRepositorySummary] = [] ··· 165 167 @Dependency(ClaudeSettingsClient.self) private var claudeSettingsClient 166 168 @Dependency(CodexSettingsClient.self) private var codexSettingsClient 167 169 @Dependency(KiroSettingsClient.self) private var kiroSettingsClient 170 + @Dependency(PiSettingsClient.self) private var piSettingsClient 168 171 @Dependency(ArchivedWorktreeDatesClient.self) private var archivedWorktreeDatesClient 169 172 @Dependency(SystemNotificationClient.self) private var systemNotificationClient 170 173 @Dependency(\.date.now) private var now ··· 188 191 async let claude = cliSkillClient.checkInstalled(.claude) 189 192 async let codex = cliSkillClient.checkInstalled(.codex) 190 193 async let kiro = cliSkillClient.checkInstalled(.kiro) 194 + async let piSkill = cliSkillClient.checkInstalled(.pi) 191 195 await send(.cliSkillChecked(agent: .claude, installed: await claude)) 192 196 await send(.cliSkillChecked(agent: .codex, installed: await codex)) 193 197 await send(.cliSkillChecked(agent: .kiro, installed: await kiro)) 198 + await send(.cliSkillChecked(agent: .pi, installed: await piSkill)) 194 199 }, 195 - .run { [claudeSettingsClient, codexSettingsClient, kiroSettingsClient] send in 200 + .run { [claudeSettingsClient, codexSettingsClient, kiroSettingsClient, piSettingsClient] send in 196 201 async let claudeProgressInstalled = claudeSettingsClient.checkInstalled(true) 197 202 async let claudeNotificationsInstalled = claudeSettingsClient.checkInstalled(false) 198 203 async let codexProgressInstalled = codexSettingsClient.checkInstalled(true) 199 204 async let codexNotificationsInstalled = codexSettingsClient.checkInstalled(false) 200 205 async let kiroProgressInstalled = kiroSettingsClient.checkInstalled(true) 201 206 async let kiroNotificationsInstalled = kiroSettingsClient.checkInstalled(false) 207 + async let piHooksInstalled = piSettingsClient.checkInstalled() 202 208 203 209 await send(.agentHookChecked(.claudeProgress, installed: await claudeProgressInstalled)) 204 210 await send( ··· 209 215 await send(.agentHookChecked(.kiroProgress, installed: await kiroProgressInstalled)) 210 216 await send( 211 217 .agentHookChecked(.kiroNotifications, installed: await kiroNotificationsInstalled)) 218 + await send(.agentHookChecked(.piHooks, installed: await piHooksInstalled)) 212 219 } 213 220 ) 214 221 ) ··· 383 390 case .agentHookInstallTapped(let slot): 384 391 guard !state[hookSlot: slot].isLoading else { return .none } 385 392 state[hookSlot: slot] = .installing 386 - return .run { [claudeSettingsClient, codexSettingsClient, kiroSettingsClient] send in 393 + return .run { [claudeSettingsClient, codexSettingsClient, kiroSettingsClient, piSettingsClient] send in 387 394 do { 388 395 switch slot { 389 396 case .claudeProgress: try await claudeSettingsClient.installProgress() ··· 392 399 case .codexNotifications: try await codexSettingsClient.installNotifications() 393 400 case .kiroProgress: try await kiroSettingsClient.installProgress() 394 401 case .kiroNotifications: try await kiroSettingsClient.installNotifications() 402 + case .piHooks: try await piSettingsClient.install() 395 403 } 396 404 await send(.agentHookActionCompleted(slot, .success(true))) 397 405 } catch { ··· 402 410 case .agentHookUninstallTapped(let slot): 403 411 guard !state[hookSlot: slot].isLoading else { return .none } 404 412 state[hookSlot: slot] = .uninstalling 405 - return .run { [claudeSettingsClient, codexSettingsClient, kiroSettingsClient] send in 413 + return .run { [claudeSettingsClient, codexSettingsClient, kiroSettingsClient, piSettingsClient] send in 406 414 do { 407 415 switch slot { 408 416 case .claudeProgress: try await claudeSettingsClient.uninstallProgress() ··· 411 419 case .codexNotifications: try await codexSettingsClient.uninstallNotifications() 412 420 case .kiroProgress: try await kiroSettingsClient.uninstallProgress() 413 421 case .kiroNotifications: try await kiroSettingsClient.uninstallNotifications() 422 + case .piHooks: try await piSettingsClient.uninstall() 414 423 } 415 424 await send(.agentHookActionCompleted(slot, .success(false))) 416 425 } catch { ··· 609 618 case .claude: claudeSkillState 610 619 case .codex: codexSkillState 611 620 case .kiro: kiroSkillState 621 + case .pi: piSkillState 612 622 } 613 623 } 614 624 set { ··· 616 626 case .claude: claudeSkillState = newValue 617 627 case .codex: codexSkillState = newValue 618 628 case .kiro: kiroSkillState = newValue 629 + case .pi: piSkillState = newValue 619 630 } 620 631 } 621 632 } ··· 629 640 case .codexNotifications: codexNotificationsState 630 641 case .kiroProgress: kiroProgressState 631 642 case .kiroNotifications: kiroNotificationsState 643 + case .piHooks: piHooksState 632 644 } 633 645 } 634 646 set { ··· 639 651 case .codexNotifications: codexNotificationsState = newValue 640 652 case .kiroProgress: kiroProgressState = newValue 641 653 case .kiroNotifications: kiroNotificationsState = newValue 654 + case .piHooks: piHooksState = newValue 642 655 } 643 656 } 644 657 }
+57
SupacodeSettingsShared/BusinessLogic/CLISkillContent.swift
··· 308 308 Flags: `-w` (worktree), `-t` (tab), `-s` (surface), `-r` (repo), `-c` (script UUID for `worktree run`/`stop`), `-i` (input), `-d` (direction), `-n` (new ID). 309 309 Env var defaults only target your own shell session. Pass explicit IDs for created resources. 310 310 """ 311 + 312 + // MARK: - Pi. 313 + 314 + // Pi uses SKILL.md with YAML frontmatter (same structure as Kiro). 315 + static let piSkillMd = """ 316 + --- 317 + name: \(skillName) 318 + description: \(description) 319 + --- 320 + 321 + # Supacode CLI 322 + 323 + Control Supacode from the terminal. The `supacode` command is available in all Supacode terminal sessions. 324 + 325 + ## CRITICAL: ID Tracking 326 + 327 + **NEVER call `supacode tab new` or `supacode surface split` without capturing 328 + the output.** They print the new UUID to stdout. Without it you cannot target 329 + the resource afterward. 330 + 331 + **NEVER omit `-t`/`-s` when targeting a created resource.** The env vars point 332 + to your own shell, not to anything you created. 333 + 334 + For new tabs, surface ID = tab ID. 335 + 336 + ### Correct: 337 + 338 + ```sh 339 + TAB_ID=$(supacode tab new -i "npm start") 340 + SPLIT_ID=$(supacode surface split -t "$TAB_ID" -s "$TAB_ID" -d v -i "npm test") 341 + supacode surface close -t "$TAB_ID" -s "$SPLIT_ID" 342 + supacode tab close -t "$TAB_ID" 343 + ``` 344 + 345 + ### WRONG: 346 + 347 + ```sh 348 + supacode tab new -i "npm start" # BAD: not captured 349 + supacode surface split -d v -i "test" # BAD: missing -t/-s, targets your shell 350 + ``` 351 + 352 + ## Commands 353 + 354 + - `supacode worktree [list [-f]|focus|run [-c]|stop [-c]|script list|archive|unarchive|delete|pin|unpin] [-w <id>]` 355 + - `supacode tab [list [-w] [-f]|focus|new|close] [-w <id>] [-t <id>] [-i <cmd>] [-n <uuid>]` 356 + - `supacode surface [list [-w] [-t] [-f]|focus|split|close] [-w <id>] [-t <id>] [-s <id>] [-i <cmd>] [-d h|v] [-n <uuid>]` 357 + - `supacode repo [list | open <path> | worktree-new [-r <id>] [--branch] [--base] [--fetch]]` 358 + - `supacode settings [<section>]` 359 + - `supacode socket` 360 + 361 + `list` outputs one ID per line (percent-encoded for worktrees/repos, UUIDs for tabs/surfaces). 362 + `worktree script list` outputs tab-separated `<uuid>\\t<kind>\\t<displayName>` rows; running scripts are ANSI-underlined. 363 + Use these IDs directly as `-w`, `-t`, `-s`, `-r`, `-c` flag values. 364 + 365 + Flags: `-w` (worktree), `-t` (tab), `-s` (surface), `-r` (repo), `-c` (script UUID for `worktree run`/`stop`), `-i` (input), `-d` (direction), `-n` (new ID). 366 + Env var defaults only target your own shell session. Pass explicit IDs for created resources. 367 + """ 311 368 }
+1
SupacodeSettingsShared/BusinessLogic/CLISkillInstaller.swift
··· 48 48 case .claude: CLISkillContent.claudeSkill 49 49 case .codex: CLISkillContent.codexSkillMd 50 50 case .kiro: CLISkillContent.kiroSkillMd 51 + case .pi: CLISkillContent.piSkillMd 51 52 } 52 53 } 53 54 }
+155
SupacodeSettingsShared/BusinessLogic/PiExtensionContent.swift
··· 1 + /// Bundled TypeScript extension that Supacode installs into 2 + /// `~/.pi/agent/extensions/supacode/index.ts` to report agent 3 + /// lifecycle hooks back to the Supacode macOS app. 4 + nonisolated enum PiExtensionContent { 5 + /// Directory name under `~/.pi/agent/extensions/`. 6 + static let extensionDirectoryName = "supacode" 7 + 8 + /// Marker comment used to identify Supacode-managed extensions. 9 + static let ownershipMarker = "/* supacode-managed-extension */" 10 + 11 + static let indexTs = """ 12 + \(ownershipMarker) 13 + /** 14 + * Supacode ↔ Pi integration extension. 15 + * 16 + * Reports agent lifecycle hooks back to Supacode via the Unix domain socket 17 + * it injects into every managed terminal session, matching the semantics of 18 + * the existing Claude and Codex hook integrations. 19 + * 20 + * Required env vars (injected automatically by Supacode): 21 + * SUPACODE_SOCKET_PATH — path to the Unix domain socket 22 + * SUPACODE_WORKTREE_ID — worktree identifier 23 + * SUPACODE_TAB_ID — tab UUID 24 + * SUPACODE_SURFACE_ID — terminal surface UUID 25 + * 26 + * Hook event mapping: 27 + * Pi agent_start → busy = 1 (UserPromptSubmit equivalent) 28 + * Pi agent_end → busy = 0 (Stop equivalent) 29 + * → notification with last_assistant_message 30 + * Pi session_shutdown → busy = 0 (SessionEnd equivalent) 31 + */ 32 + 33 + import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; 34 + import { createConnection } from "node:net"; 35 + 36 + interface SupacodeEnv { 37 + socketPath: string; 38 + worktreeId: string; 39 + tabId: string; 40 + surfaceId: string; 41 + } 42 + 43 + interface HookPayload { 44 + hook_event_name: string; 45 + title?: string; 46 + message?: string; 47 + last_assistant_message?: string; 48 + } 49 + 50 + function readSupacodeEnv(): SupacodeEnv | null { 51 + const socketPath = process.env["SUPACODE_SOCKET_PATH"]; 52 + const worktreeId = process.env["SUPACODE_WORKTREE_ID"]; 53 + const tabId = process.env["SUPACODE_TAB_ID"]; 54 + const surfaceId = process.env["SUPACODE_SURFACE_ID"]; 55 + 56 + if (!socketPath || !worktreeId || !tabId || !surfaceId) return null; 57 + return { socketPath, worktreeId, tabId, surfaceId }; 58 + } 59 + 60 + /** 61 + * Sends raw bytes to a Unix domain socket and closes the connection. 62 + * Times out after 1 s (Pi serializes hook callbacks, so a stalled 63 + * delivery would stall the agent) and swallows all errors — 64 + * hook delivery is best-effort. 65 + */ 66 + function sendToSocket(socketPath: string, data: Buffer): Promise<void> { 67 + return new Promise<void>((resolve) => { 68 + let settled = false; 69 + const done = () => { 70 + if (!settled) { 71 + settled = true; 72 + resolve(); 73 + } 74 + }; 75 + 76 + const client = createConnection({ path: socketPath }); 77 + const timer = setTimeout(() => { 78 + client.destroy(); 79 + done(); 80 + }, 1000); 81 + 82 + client.on("connect", () => { 83 + client.write(data, () => { 84 + client.end(); 85 + clearTimeout(timer); 86 + done(); 87 + }); 88 + }); 89 + 90 + client.on("error", () => { 91 + clearTimeout(timer); 92 + done(); 93 + }); 94 + }); 95 + } 96 + 97 + function sendBusy(env: SupacodeEnv, active: boolean): Promise<void> { 98 + const flag = active ? "1" : "0"; 99 + const message = `${env.worktreeId} ${env.tabId} ${env.surfaceId} ${flag}\\n`; 100 + return sendToSocket(env.socketPath, Buffer.from(message, "utf8")); 101 + } 102 + 103 + function sendNotification(env: SupacodeEnv, payload: HookPayload): Promise<void> { 104 + const header = `${env.worktreeId} ${env.tabId} ${env.surfaceId} pi\\n`; 105 + const body = JSON.stringify(payload) + "\\n"; 106 + return sendToSocket(env.socketPath, Buffer.from(header + body, "utf8")); 107 + } 108 + 109 + function lastAssistantText(ctx: { sessionManager: { getEntries(): any[] } }): string | undefined { 110 + const entries = ctx.sessionManager.getEntries(); 111 + for (let i = entries.length - 1; i >= 0; i--) { 112 + const entry = entries[i]; 113 + if (entry.type !== "message") continue; 114 + if (entry.message.role !== "assistant") continue; 115 + 116 + const content = entry.message.content; 117 + if (!Array.isArray(content)) continue; 118 + 119 + const text = content 120 + .filter((c: { type: string; text?: string }) => c.type === "text" && typeof c.text === "string") 121 + .map((c: { text: string }) => c.text) 122 + .join("") 123 + .trim(); 124 + 125 + if (text.length > 0) return text; 126 + } 127 + return undefined; 128 + } 129 + 130 + export default function (pi: ExtensionAPI) { 131 + const env = readSupacodeEnv(); 132 + 133 + // Not running under Supacode — skip lifecycle hooks. 134 + if (!env) return; 135 + 136 + pi.on("agent_start", async (_event, _ctx) => { 137 + await sendBusy(env, true); 138 + }); 139 + 140 + pi.on("agent_end", async (_event, ctx) => { 141 + await sendBusy(env, false); 142 + 143 + const lastMessage = lastAssistantText(ctx); 144 + await sendNotification(env, { 145 + hook_event_name: "Stop", 146 + last_assistant_message: lastMessage, 147 + }); 148 + }); 149 + 150 + pi.on("session_shutdown", async (_event, _ctx) => { 151 + await sendBusy(env, false); 152 + }); 153 + } 154 + """ 155 + }
+116
SupacodeSettingsShared/BusinessLogic/PiSettingsInstaller.swift
··· 1 + import Foundation 2 + 3 + private nonisolated let piInstallerLogger = SupaLogger("Settings") 4 + 5 + nonisolated struct PiSettingsInstaller { 6 + let homeDirectoryURL: URL 7 + let fileManager: FileManager 8 + 9 + init( 10 + homeDirectoryURL: URL = FileManager.default.homeDirectoryForCurrentUser, 11 + fileManager: FileManager = .default 12 + ) { 13 + self.homeDirectoryURL = homeDirectoryURL 14 + self.fileManager = fileManager 15 + } 16 + 17 + // MARK: - Check. 18 + 19 + func isInstalled() -> Bool { 20 + let indexURL = extensionIndexURL 21 + guard fileManager.fileExists(atPath: indexURL.path(percentEncoded: false)) else { 22 + return false 23 + } 24 + // Surface read failures (permissions, non-UTF8 contents) instead of 25 + // conflating them with "not installed" — the UI would otherwise offer 26 + // Install and fail only on the next write. 27 + do { 28 + let contents = try String(contentsOf: indexURL, encoding: .utf8) 29 + return contents.contains(PiExtensionContent.ownershipMarker) 30 + } catch { 31 + piInstallerLogger.warning( 32 + "Pi extension at \(indexURL.path(percentEncoded: false)) is unreadable: \(error)") 33 + return false 34 + } 35 + } 36 + 37 + // MARK: - Install. 38 + 39 + func install() throws { 40 + // Refuse to clobber a user-authored extension at the managed path so 41 + // Install is symmetric with Uninstall's ownership guard. 42 + let indexPath = extensionIndexURL.path(percentEncoded: false) 43 + if fileManager.fileExists(atPath: indexPath) { 44 + let contents: String 45 + do { 46 + contents = try String(contentsOf: extensionIndexURL, encoding: .utf8) 47 + } catch { 48 + // Surface the path so the reducer's generic localizedDescription 49 + // alone does not lose the file we were trying to probe. 50 + piInstallerLogger.warning( 51 + "Pi install pre-check: unable to read \(indexPath): \(error)") 52 + throw error 53 + } 54 + guard contents.contains(PiExtensionContent.ownershipMarker) else { 55 + throw PiSettingsInstallerError.extensionNotManaged 56 + } 57 + } 58 + let dirPath = extensionDirectoryURL.path(percentEncoded: false) 59 + try fileManager.createDirectory(atPath: dirPath, withIntermediateDirectories: true) 60 + try PiExtensionContent.indexTs.write( 61 + to: extensionIndexURL, 62 + atomically: true, 63 + encoding: .utf8 64 + ) 65 + piInstallerLogger.info("Installed Pi extension at \(extensionIndexURL.path(percentEncoded: false))") 66 + } 67 + 68 + // MARK: - Uninstall. 69 + 70 + func uninstall() throws { 71 + let dirPath = extensionDirectoryURL.path(percentEncoded: false) 72 + guard fileManager.fileExists(atPath: dirPath) else { return } 73 + let indexPath = extensionIndexURL.path(percentEncoded: false) 74 + guard fileManager.fileExists(atPath: indexPath) else { 75 + try fileManager.removeItem(atPath: dirPath) 76 + piInstallerLogger.info("Removed stale empty Pi extension directory at \(dirPath)") 77 + return 78 + } 79 + // Refuse to remove a user-authored extension at the managed path; 80 + // surface it as a typed error so the reducer can show `.failed(…)` 81 + // instead of silently flipping the UI to "not installed". 82 + let contents = try String(contentsOf: extensionIndexURL, encoding: .utf8) 83 + guard contents.contains(PiExtensionContent.ownershipMarker) else { 84 + throw PiSettingsInstallerError.extensionNotManaged 85 + } 86 + try fileManager.removeItem(atPath: dirPath) 87 + piInstallerLogger.info("Uninstalled Pi extension from \(dirPath)") 88 + } 89 + 90 + // MARK: - Paths. 91 + 92 + private var extensionDirectoryURL: URL { 93 + Self.extensionDirectoryURL(homeDirectoryURL: homeDirectoryURL) 94 + } 95 + 96 + private var extensionIndexURL: URL { 97 + extensionDirectoryURL.appending(path: "index.ts", directoryHint: .notDirectory) 98 + } 99 + 100 + static func extensionDirectoryURL(homeDirectoryURL: URL) -> URL { 101 + homeDirectoryURL 102 + .appending(path: ".pi/agent/extensions", directoryHint: .isDirectory) 103 + .appending(path: PiExtensionContent.extensionDirectoryName, directoryHint: .isDirectory) 104 + } 105 + } 106 + 107 + nonisolated enum PiSettingsInstallerError: Error, Equatable, LocalizedError { 108 + case extensionNotManaged 109 + 110 + var errorDescription: String? { 111 + switch self { 112 + case .extensionNotManaged: 113 + "The Pi extension at ~/.pi/agent/extensions/supacode is not managed by Supacode." 114 + } 115 + } 116 + }
+44
SupacodeSettingsShared/Clients/CodingAgents/PiSettingsClient.swift
··· 1 + import ComposableArchitecture 2 + import Foundation 3 + 4 + public nonisolated struct PiSettingsClient: Sendable { 5 + public var checkInstalled: @Sendable () async -> Bool 6 + public var install: @Sendable () async throws -> Void 7 + public var uninstall: @Sendable () async throws -> Void 8 + 9 + public init( 10 + checkInstalled: @escaping @Sendable () async -> Bool, 11 + install: @escaping @Sendable () async throws -> Void, 12 + uninstall: @escaping @Sendable () async throws -> Void 13 + ) { 14 + self.checkInstalled = checkInstalled 15 + self.install = install 16 + self.uninstall = uninstall 17 + } 18 + } 19 + 20 + extension PiSettingsClient: DependencyKey { 21 + public static let liveValue = Self( 22 + checkInstalled: { 23 + PiSettingsInstaller().isInstalled() 24 + }, 25 + install: { 26 + try PiSettingsInstaller().install() 27 + }, 28 + uninstall: { 29 + try PiSettingsInstaller().uninstall() 30 + } 31 + ) 32 + public static let testValue = Self( 33 + checkInstalled: { false }, 34 + install: {}, 35 + uninstall: {} 36 + ) 37 + } 38 + 39 + extension DependencyValues { 40 + public var piSettingsClient: PiSettingsClient { 41 + get { self[PiSettingsClient.self] } 42 + set { self[PiSettingsClient.self] = newValue } 43 + } 44 + }
+1
SupacodeSettingsShared/Models/AgentHooksInstallState.swift
··· 37 37 case codexNotifications 38 38 case kiroProgress 39 39 case kiroNotifications 40 + case piHooks 40 41 }
+5 -1
SupacodeSettingsShared/Models/SkillAgent.swift
··· 2 2 case claude 3 3 case codex 4 4 case kiro 5 + // swiftlint:disable:next identifier_name 6 + case pi 5 7 6 - /// The dot-directory name under the user's home (e.g. `.claude`, `.codex`, `.kiro`). 8 + /// Path under the user's home where the agent stores its config 9 + /// (e.g. `.claude`, `.codex`, `.kiro`, `.pi/agent`). 7 10 public var configDirectoryName: String { 8 11 switch self { 9 12 case .claude: ".claude" 10 13 case .codex: ".codex" 11 14 case .kiro: ".kiro" 15 + case .pi: ".pi/agent" 12 16 } 13 17 } 14 18 }
+16
supacode/Assets.xcassets/pi-mark.imageset/Contents.json
··· 1 + { 2 + "images" : [ 3 + { 4 + "filename" : "pi-mark.svg", 5 + "idiom" : "universal" 6 + } 7 + ], 8 + "info" : { 9 + "author" : "xcode", 10 + "version" : 1 11 + }, 12 + "properties" : { 13 + "preserves-vector-representation" : true, 14 + "template-rendering-intent" : "original" 15 + } 16 + }
+5
supacode/Assets.xcassets/pi-mark.imageset/pi-mark.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 800"> 2 + <rect width="800" height="800" rx="170" fill="#000"/> 3 + <path fill="#fff" fill-rule="evenodd" d="M165.29 165.29 H517.36 V400 H400 V517.36 H282.65 V634.72 H165.29 Z M282.65 282.65 V400 H400 V282.65 Z"/> 4 + <path fill="#fff" d="M517.36 400 H634.72 V634.72 H517.36 Z"/> 5 + </svg>
+28
supacode/Features/Settings/Views/DeveloperSettingsView.swift
··· 6 6 struct DeveloperSettingsView: View { 7 7 let store: StoreOf<SettingsFeature> 8 8 @State private var kiroExpanded = false 9 + @State private var piExpanded = false 9 10 10 11 var body: some View { 11 12 Form { ··· 117 118 Text("Kiro") 118 119 } icon: { 119 120 Image("kiro-mark") 121 + .resizable() 122 + .aspectRatio(contentMode: .fit) 123 + .frame(width: 18, height: 18) 124 + .accessibilityHidden(true) 125 + } 126 + .labelStyle(.titleTrailingIcon) 127 + } 128 + Section(isExpanded: $piExpanded) { 129 + AgentInstallRow( 130 + installAction: { store.send(.agentHookInstallTapped(.piHooks)) }, 131 + uninstallAction: { store.send(.agentHookUninstallTapped(.piHooks)) }, 132 + installState: store.piHooksState, 133 + title: "Hooks", 134 + subtitle: "Display agent activity in tab, sidebar, and forward notifications." 135 + ) 136 + AgentInstallRow( 137 + installAction: { store.send(.cliSkillInstallTapped(.pi)) }, 138 + uninstallAction: { store.send(.cliSkillUninstallTapped(.pi)) }, 139 + installState: store.piSkillState, 140 + title: "CLI Skill", 141 + subtitle: "Teach Pi how to use the Supacode CLI." 142 + ) 143 + } header: { 144 + Label { 145 + Text("Pi") 146 + } icon: { 147 + Image("pi-mark") 120 148 .resizable() 121 149 .aspectRatio(contentMode: .fit) 122 150 .frame(width: 18, height: 18)
+196
supacodeTests/PiSettingsInstallerTests.swift
··· 1 + import Foundation 2 + import Testing 3 + 4 + @testable import SupacodeSettingsShared 5 + 6 + struct PiSettingsInstallerTests { 7 + private func makeTempHome() throws -> URL { 8 + let tempDir = FileManager.default.temporaryDirectory 9 + .appending(path: "PiSettingsInstallerTests-\(UUID().uuidString)", directoryHint: .isDirectory) 10 + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) 11 + return tempDir 12 + } 13 + 14 + private func makeInstaller(homeDirectoryURL: URL) -> PiSettingsInstaller { 15 + PiSettingsInstaller(homeDirectoryURL: homeDirectoryURL) 16 + } 17 + 18 + private func extensionIndexURL(homeDirectoryURL: URL) -> URL { 19 + PiSettingsInstaller.extensionDirectoryURL(homeDirectoryURL: homeDirectoryURL) 20 + .appending(path: "index.ts", directoryHint: .notDirectory) 21 + } 22 + 23 + @Test func isInstalledReturnsFalseWhenNoFileExists() throws { 24 + let home = try makeTempHome() 25 + let installer = makeInstaller(homeDirectoryURL: home) 26 + #expect(!installer.isInstalled()) 27 + } 28 + 29 + @Test func isInstalledReturnsFalseWhenFileExistsWithoutMarker() throws { 30 + let home = try makeTempHome() 31 + let indexURL = extensionIndexURL(homeDirectoryURL: home) 32 + try FileManager.default.createDirectory( 33 + at: indexURL.deletingLastPathComponent(), 34 + withIntermediateDirectories: true 35 + ) 36 + try "// some other extension".write(to: indexURL, atomically: true, encoding: .utf8) 37 + 38 + let installer = makeInstaller(homeDirectoryURL: home) 39 + #expect(!installer.isInstalled()) 40 + } 41 + 42 + @Test func isInstalledReturnsFalseForPartialMarker() throws { 43 + let home = try makeTempHome() 44 + let indexURL = extensionIndexURL(homeDirectoryURL: home) 45 + try FileManager.default.createDirectory( 46 + at: indexURL.deletingLastPathComponent(), 47 + withIntermediateDirectories: true 48 + ) 49 + // Partial marker must not match — full-string containment is the contract. 50 + try "/* supacode-managed".write(to: indexURL, atomically: true, encoding: .utf8) 51 + 52 + let installer = makeInstaller(homeDirectoryURL: home) 53 + #expect(!installer.isInstalled()) 54 + } 55 + 56 + @Test func isInstalledReturnsFalseWhenFileIsUnreadableAsUTF8() throws { 57 + let home = try makeTempHome() 58 + let indexURL = extensionIndexURL(homeDirectoryURL: home) 59 + try FileManager.default.createDirectory( 60 + at: indexURL.deletingLastPathComponent(), 61 + withIntermediateDirectories: true 62 + ) 63 + // Lead bytes that are invalid UTF-8 — read should fail, not be confused 64 + // with an installed/uninstalled state. 65 + try Data([0xFF, 0xFE, 0xFD, 0x00]).write(to: indexURL) 66 + 67 + let installer = makeInstaller(homeDirectoryURL: home) 68 + #expect(!installer.isInstalled()) 69 + } 70 + 71 + @Test func isInstalledReturnsTrueWhenMarkerPresent() throws { 72 + let home = try makeTempHome() 73 + let installer = makeInstaller(homeDirectoryURL: home) 74 + try installer.install() 75 + #expect(installer.isInstalled()) 76 + } 77 + 78 + @Test func installCreatesExtensionFile() throws { 79 + let home = try makeTempHome() 80 + let installer = makeInstaller(homeDirectoryURL: home) 81 + try installer.install() 82 + 83 + let indexURL = extensionIndexURL(homeDirectoryURL: home) 84 + #expect(FileManager.default.fileExists(atPath: indexURL.path(percentEncoded: false))) 85 + 86 + let contents = try String(contentsOf: indexURL, encoding: .utf8) 87 + #expect(contents.contains(PiExtensionContent.ownershipMarker)) 88 + #expect(contents.contains("agent_start")) 89 + #expect(contents.contains("agent_end")) 90 + #expect(contents.contains("session_shutdown")) 91 + } 92 + 93 + @Test func uninstallRemovesManagedExtension() throws { 94 + let home = try makeTempHome() 95 + let installer = makeInstaller(homeDirectoryURL: home) 96 + try installer.install() 97 + #expect(installer.isInstalled()) 98 + 99 + try installer.uninstall() 100 + #expect(!installer.isInstalled()) 101 + 102 + let dirURL = PiSettingsInstaller.extensionDirectoryURL(homeDirectoryURL: home) 103 + #expect(!FileManager.default.fileExists(atPath: dirURL.path(percentEncoded: false))) 104 + } 105 + 106 + @Test func uninstallThrowsExtensionNotManagedWhenFileIsUserAuthored() throws { 107 + let home = try makeTempHome() 108 + let indexURL = extensionIndexURL(homeDirectoryURL: home) 109 + try FileManager.default.createDirectory( 110 + at: indexURL.deletingLastPathComponent(), 111 + withIntermediateDirectories: true 112 + ) 113 + try "// user's custom extension".write(to: indexURL, atomically: true, encoding: .utf8) 114 + 115 + let installer = makeInstaller(homeDirectoryURL: home) 116 + #expect(throws: PiSettingsInstallerError.extensionNotManaged) { 117 + try installer.uninstall() 118 + } 119 + // Neither the file nor the enclosing directory may be touched when 120 + // the guard fires — a user could be keeping siblings alongside it. 121 + #expect(FileManager.default.fileExists(atPath: indexURL.path(percentEncoded: false))) 122 + let dirURL = PiSettingsInstaller.extensionDirectoryURL(homeDirectoryURL: home) 123 + #expect(FileManager.default.fileExists(atPath: dirURL.path(percentEncoded: false))) 124 + } 125 + 126 + @Test func uninstallNoOpWhenDirectoryDoesNotExist() throws { 127 + let home = try makeTempHome() 128 + let installer = makeInstaller(homeDirectoryURL: home) 129 + // Already uninstalled — silent no-op. 130 + try installer.uninstall() 131 + } 132 + 133 + @Test func uninstallRemovesEmptyDirectoryWhenIndexMissing() throws { 134 + let home = try makeTempHome() 135 + let dirURL = PiSettingsInstaller.extensionDirectoryURL(homeDirectoryURL: home) 136 + try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) 137 + 138 + let installer = makeInstaller(homeDirectoryURL: home) 139 + try installer.uninstall() 140 + 141 + #expect(!FileManager.default.fileExists(atPath: dirURL.path(percentEncoded: false))) 142 + } 143 + 144 + @Test func installOverwritesExistingManagedExtension() throws { 145 + let home = try makeTempHome() 146 + let indexURL = extensionIndexURL(homeDirectoryURL: home) 147 + try FileManager.default.createDirectory( 148 + at: indexURL.deletingLastPathComponent(), 149 + withIntermediateDirectories: true 150 + ) 151 + // Seed a stale managed file — marker present but body drifted from 152 + // the current bundle. Install must rewrite the full canonical body. 153 + let staleBody = "\(PiExtensionContent.ownershipMarker)\n// stale body" 154 + try staleBody.write(to: indexURL, atomically: true, encoding: .utf8) 155 + 156 + let installer = makeInstaller(homeDirectoryURL: home) 157 + try installer.install() 158 + 159 + let contents = try String(contentsOf: indexURL, encoding: .utf8) 160 + #expect(contents == PiExtensionContent.indexTs) 161 + } 162 + 163 + @Test func installThrowsExtensionNotManagedWhenFileIsUserAuthored() throws { 164 + let home = try makeTempHome() 165 + let indexURL = extensionIndexURL(homeDirectoryURL: home) 166 + try FileManager.default.createDirectory( 167 + at: indexURL.deletingLastPathComponent(), 168 + withIntermediateDirectories: true 169 + ) 170 + try "// user's custom extension".write(to: indexURL, atomically: true, encoding: .utf8) 171 + 172 + let installer = makeInstaller(homeDirectoryURL: home) 173 + #expect(throws: PiSettingsInstallerError.extensionNotManaged) { 174 + try installer.install() 175 + } 176 + // User's file must be preserved byte-for-byte. 177 + let contents = try String(contentsOf: indexURL, encoding: .utf8) 178 + #expect(contents == "// user's custom extension") 179 + } 180 + 181 + @Test func installCreatesFullDirectoryChainWhenMissing() throws { 182 + let home = try makeTempHome() 183 + // No `.pi` directory exists at all — install must create the whole chain. 184 + let piDir = home.appending(path: ".pi", directoryHint: .isDirectory) 185 + #expect(!FileManager.default.fileExists(atPath: piDir.path(percentEncoded: false))) 186 + 187 + let installer = makeInstaller(homeDirectoryURL: home) 188 + try installer.install() 189 + 190 + // Lock the `.pi/agent/extensions/<name>` layout — a reshuffle of the 191 + // managed paths would otherwise slip past `isInstalled()`. 192 + let dirURL = PiSettingsInstaller.extensionDirectoryURL(homeDirectoryURL: home) 193 + #expect(FileManager.default.fileExists(atPath: dirURL.path(percentEncoded: false))) 194 + #expect(installer.isInstalled()) 195 + } 196 + }
+264 -3
supacodeTests/SettingsFeatureAgentHookTests.swift
··· 148 148 } 149 149 return progress 150 150 } 151 + $0[PiSettingsClient.self].checkInstalled = { 152 + _ = startedChecks.withValue { $0.insert("piHooks") } 153 + await withCheckedContinuation { continuation in 154 + continuations.withValue { $0.append(continuation) } 155 + } 156 + return false 157 + } 151 158 } 152 159 153 160 store.exhaustivity = .off(showSkippedAssertions: false) ··· 156 163 157 164 // CLI/skill/hook checks run in parallel via `.merge`. 158 165 // CLI/skill mocks return immediately; hook checks block on continuations. 159 - // Wait for all six hook checks to start. 166 + // Wait for all seven hook checks to start. 160 167 await eventually { 161 - startedChecks.value.count == 6 168 + startedChecks.value.count == 7 162 169 } 163 170 164 171 continuations.withValue { continuations in ··· 171 178 await store.skipReceivedActions() 172 179 } 173 180 174 - @Test(.dependencies) func taskChecksAllSixHookSlotsOnStartup() async { 181 + @Test(.dependencies) func taskChecksAllHookSlotsOnStartup() async { 175 182 let checkedSlots = LockIsolated<[String]>([]) 176 183 177 184 let store = TestStore(initialState: SettingsFeature.State()) { ··· 191 198 checkedSlots.withValue { $0.append(progress ? "kiroProgress" : "kiroNotifications") } 192 199 return progress 193 200 } 201 + $0[PiSettingsClient.self].checkInstalled = { 202 + checkedSlots.withValue { $0.append("piHooks") } 203 + return false 204 + } 194 205 } 195 206 196 207 store.exhaustivity = .off(showSkippedAssertions: false) ··· 206 217 "codexNotifications", 207 218 "kiroProgress", 208 219 "kiroNotifications", 220 + "piHooks", 209 221 ]) 222 + } 223 + 224 + @Test(.dependencies) func taskChecksAllSkillsOnStartup() async { 225 + let checkedSkills = LockIsolated<[String]>([]) 226 + 227 + let store = TestStore(initialState: SettingsFeature.State()) { 228 + SettingsFeature() 229 + } withDependencies: { 230 + $0[CLIInstallerClient.self].checkInstalled = { false } 231 + $0[ClaudeSettingsClient.self].checkInstalled = { _ in false } 232 + $0[CodexSettingsClient.self].checkInstalled = { _ in false } 233 + $0[KiroSettingsClient.self].checkInstalled = { _ in false } 234 + $0[PiSettingsClient.self].checkInstalled = { false } 235 + $0[CLISkillClient.self].checkInstalled = { agent in 236 + let key: String = 237 + switch agent { 238 + case .claude: "claude" 239 + case .codex: "codex" 240 + case .kiro: "kiro" 241 + case .pi: "pi" 242 + } 243 + checkedSkills.withValue { $0.append(key) } 244 + return false 245 + } 246 + } 247 + 248 + store.exhaustivity = .off(showSkippedAssertions: false) 249 + await store.send(.task) 250 + await store.receive(\.settingsLoaded) 251 + await store.skipReceivedActions() 252 + 253 + #expect(Set(checkedSkills.value) == ["claude", "codex", "kiro", "pi"]) 210 254 } 211 255 212 256 // MARK: - Kiro hook actions. ··· 558 602 } 559 603 560 604 await store.send(.cliSkillUninstallTapped(.claude)) 605 + } 606 + 607 + // MARK: - Pi hooks. 608 + 609 + @Test(.dependencies) func piHookCheckedSetsInstalled() async { 610 + var state = SettingsFeature.State() 611 + state.piHooksState = .checking 612 + 613 + let store = TestStore(initialState: state) { 614 + SettingsFeature() 615 + } 616 + 617 + await store.send(.agentHookChecked(.piHooks, installed: true)) { 618 + $0.piHooksState = .installed 619 + } 620 + } 621 + 622 + @Test(.dependencies) func piHookCheckedSetsNotInstalled() async { 623 + var state = SettingsFeature.State() 624 + state.piHooksState = .checking 625 + 626 + let store = TestStore(initialState: state) { 627 + SettingsFeature() 628 + } 629 + 630 + await store.send(.agentHookChecked(.piHooks, installed: false)) { 631 + $0.piHooksState = .notInstalled 632 + } 633 + } 634 + 635 + @Test(.dependencies) func piHookInstallTransitionsToInstalledOnSuccess() async { 636 + var state = SettingsFeature.State() 637 + state.piHooksState = .notInstalled 638 + 639 + let store = TestStore(initialState: state) { 640 + SettingsFeature() 641 + } withDependencies: { 642 + $0[PiSettingsClient.self].install = {} 643 + } 644 + 645 + await store.send(.agentHookInstallTapped(.piHooks)) { 646 + $0.piHooksState = .installing 647 + } 648 + await store.receive(\.agentHookActionCompleted) { 649 + $0.piHooksState = .installed 650 + } 651 + } 652 + 653 + @Test(.dependencies) func piHookInstallTransitionsToFailedOnError() async { 654 + var state = SettingsFeature.State() 655 + state.piHooksState = .notInstalled 656 + 657 + let store = TestStore(initialState: state) { 658 + SettingsFeature() 659 + } withDependencies: { 660 + $0[PiSettingsClient.self].install = { 661 + throw PiSettingsInstallerError.extensionNotManaged 662 + } 663 + } 664 + 665 + await store.send(.agentHookInstallTapped(.piHooks)) { 666 + $0.piHooksState = .installing 667 + } 668 + await store.receive(\.agentHookActionCompleted) { 669 + $0.piHooksState = .failed(PiSettingsInstallerError.extensionNotManaged.localizedDescription) 670 + } 671 + } 672 + 673 + @Test(.dependencies) func piHookInstallWhileLoadingIsNoOp() async { 674 + var state = SettingsFeature.State() 675 + state.piHooksState = .installing 676 + 677 + let store = TestStore(initialState: state) { 678 + SettingsFeature() 679 + } 680 + 681 + await store.send(.agentHookInstallTapped(.piHooks)) 682 + } 683 + 684 + @Test(.dependencies) func piHookUninstallTransitionsToNotInstalledOnSuccess() async { 685 + var state = SettingsFeature.State() 686 + state.piHooksState = .installed 687 + 688 + let store = TestStore(initialState: state) { 689 + SettingsFeature() 690 + } withDependencies: { 691 + $0[PiSettingsClient.self].uninstall = {} 692 + } 693 + 694 + await store.send(.agentHookUninstallTapped(.piHooks)) { 695 + $0.piHooksState = .uninstalling 696 + } 697 + await store.receive(\.agentHookActionCompleted) { 698 + $0.piHooksState = .notInstalled 699 + } 700 + } 701 + 702 + @Test(.dependencies) func piHookUninstallTransitionsToFailedOnError() async { 703 + var state = SettingsFeature.State() 704 + state.piHooksState = .installed 705 + 706 + let store = TestStore(initialState: state) { 707 + SettingsFeature() 708 + } withDependencies: { 709 + $0[PiSettingsClient.self].uninstall = { 710 + throw PiSettingsInstallerError.extensionNotManaged 711 + } 712 + } 713 + 714 + await store.send(.agentHookUninstallTapped(.piHooks)) { 715 + $0.piHooksState = .uninstalling 716 + } 717 + await store.receive(\.agentHookActionCompleted) { 718 + $0.piHooksState = .failed(PiSettingsInstallerError.extensionNotManaged.localizedDescription) 719 + } 720 + } 721 + 722 + @Test(.dependencies) func piHookUninstallWhileLoadingIsNoOp() async { 723 + var state = SettingsFeature.State() 724 + state.piHooksState = .uninstalling 725 + 726 + let store = TestStore(initialState: state) { 727 + SettingsFeature() 728 + } 729 + 730 + await store.send(.agentHookUninstallTapped(.piHooks)) 731 + } 732 + 733 + @Test(.dependencies) func piSkillCheckedSetsInstalled() async { 734 + var state = SettingsFeature.State() 735 + state.piSkillState = .checking 736 + 737 + let store = TestStore(initialState: state) { 738 + SettingsFeature() 739 + } 740 + 741 + await store.send(.cliSkillChecked(agent: .pi, installed: true)) { 742 + $0.piSkillState = .installed 743 + } 744 + } 745 + 746 + @Test(.dependencies) func piSkillInstallTransitionsToInstalledOnSuccess() async { 747 + var state = SettingsFeature.State() 748 + state.piSkillState = .notInstalled 749 + 750 + let store = TestStore(initialState: state) { 751 + SettingsFeature() 752 + } withDependencies: { 753 + $0[CLISkillClient.self].install = { _ in } 754 + } 755 + 756 + await store.send(.cliSkillInstallTapped(.pi)) { 757 + $0.piSkillState = .installing 758 + } 759 + await store.receive(\.cliSkillCompleted) { 760 + $0.piSkillState = .installed 761 + } 762 + } 763 + 764 + @Test(.dependencies) func piSkillInstallTransitionsToFailedOnError() async { 765 + var state = SettingsFeature.State() 766 + state.piSkillState = .notInstalled 767 + 768 + let store = TestStore(initialState: state) { 769 + SettingsFeature() 770 + } withDependencies: { 771 + $0[CLISkillClient.self].install = { _ in 772 + throw PiSettingsInstallerError.extensionNotManaged 773 + } 774 + } 775 + 776 + await store.send(.cliSkillInstallTapped(.pi)) { 777 + $0.piSkillState = .installing 778 + } 779 + await store.receive(\.cliSkillCompleted) { 780 + $0.piSkillState = .failed(PiSettingsInstallerError.extensionNotManaged.localizedDescription) 781 + } 782 + } 783 + 784 + @Test(.dependencies) func piSkillUninstallTransitionsToNotInstalledOnSuccess() async { 785 + var state = SettingsFeature.State() 786 + state.piSkillState = .installed 787 + 788 + let store = TestStore(initialState: state) { 789 + SettingsFeature() 790 + } withDependencies: { 791 + $0[CLISkillClient.self].uninstall = { _ in } 792 + } 793 + 794 + await store.send(.cliSkillUninstallTapped(.pi)) { 795 + $0.piSkillState = .uninstalling 796 + } 797 + await store.receive(\.cliSkillCompleted) { 798 + $0.piSkillState = .notInstalled 799 + } 800 + } 801 + 802 + @Test(.dependencies) func piSkillInstallWhileLoadingIsNoOp() async { 803 + var state = SettingsFeature.State() 804 + state.piSkillState = .installing 805 + 806 + let store = TestStore(initialState: state) { 807 + SettingsFeature() 808 + } 809 + 810 + await store.send(.cliSkillInstallTapped(.pi)) 811 + } 812 + 813 + @Test(.dependencies) func piSkillUninstallWhileLoadingIsNoOp() async { 814 + var state = SettingsFeature.State() 815 + state.piSkillState = .uninstalling 816 + 817 + let store = TestStore(initialState: state) { 818 + SettingsFeature() 819 + } 820 + 821 + await store.send(.cliSkillUninstallTapped(.pi)) 561 822 } 562 823 }