native macOS codings agent orchestrator
6
fork

Configure Feed

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

feat(cli): implement open v1 runtime handler

- Add OpenCommandHandler with path→worktree resolution, worktree selection,
and bring-to-front behavior
- Wire handler into CLICommandRouter via makeCLISocketServer factory
- Unknown paths trigger addAndOpen to register new repositories
- Add unit tests (5/5 passing)
- swiftlint clean

Relates to #106

Friday 004e7c0d eeccd4c7

+411
+119
supacode/App/supacodeApp.swift
··· 254 254 bringMainWindowToFront() 255 255 } 256 256 ) 257 + let openHandler = OpenCommandHandler( 258 + resolver: { path in 259 + guard let path else { 260 + return OpenResolverResult( 261 + resolution: .noArgument, worktreeID: nil, worktreeName: nil, 262 + worktreePath: nil, rootPath: nil, worktreeKind: nil, resolvedPath: nil 263 + ) 264 + } 265 + let normalized = URL(fileURLWithPath: path, isDirectory: true) 266 + .standardizedFileURL.path(percentEncoded: false) 267 + let repositories = appStore.state.repositories 268 + // Exact match: worktree working directory 269 + for repository in repositories.repositories { 270 + let kind = repository.kind.rawValue 271 + for worktree in repository.worktrees { 272 + let wtPath = worktree.workingDirectory 273 + .standardizedFileURL.path(percentEncoded: false) 274 + if wtPath == normalized { 275 + return OpenResolverResult( 276 + resolution: .exactRoot, worktreeID: worktree.id, 277 + worktreeName: worktree.name, worktreePath: wtPath, 278 + rootPath: repository.rootURL.standardizedFileURL.path(percentEncoded: false), 279 + worktreeKind: kind, resolvedPath: normalized 280 + ) 281 + } 282 + } 283 + // Exact match: repository root for non-worktree repos 284 + let repoRoot = repository.rootURL 285 + .standardizedFileURL.path(percentEncoded: false) 286 + if repoRoot == normalized, 287 + !repository.capabilities.supportsWorktrees, 288 + repository.capabilities.supportsRunnableFolderActions 289 + { 290 + return OpenResolverResult( 291 + resolution: .exactRoot, worktreeID: repository.id, 292 + worktreeName: repository.name, worktreePath: repoRoot, 293 + rootPath: repoRoot, worktreeKind: kind, resolvedPath: normalized 294 + ) 295 + } 296 + } 297 + // Inside-root: path is inside an existing worktree/repo root 298 + let normalizedSlash = normalized.hasSuffix("/") ? normalized : normalized + "/" 299 + for repository in repositories.repositories { 300 + let kind = repository.kind.rawValue 301 + for worktree in repository.worktrees { 302 + let wtPath = worktree.workingDirectory 303 + .standardizedFileURL.path(percentEncoded: false) 304 + let wtSlash = wtPath.hasSuffix("/") ? wtPath : wtPath + "/" 305 + if normalizedSlash.hasPrefix(wtSlash) { 306 + return OpenResolverResult( 307 + resolution: .insideRoot, worktreeID: worktree.id, 308 + worktreeName: worktree.name, worktreePath: wtPath, 309 + rootPath: repository.rootURL.standardizedFileURL.path(percentEncoded: false), 310 + worktreeKind: kind, resolvedPath: normalized 311 + ) 312 + } 313 + } 314 + if !repository.capabilities.supportsWorktrees, 315 + repository.capabilities.supportsRunnableFolderActions 316 + { 317 + let repoRoot = repository.rootURL 318 + .standardizedFileURL.path(percentEncoded: false) 319 + let repoSlash = repoRoot.hasSuffix("/") ? repoRoot : repoRoot + "/" 320 + if normalizedSlash.hasPrefix(repoSlash) { 321 + return OpenResolverResult( 322 + resolution: .insideRoot, worktreeID: repository.id, 323 + worktreeName: repository.name, worktreePath: repoRoot, 324 + rootPath: repoRoot, worktreeKind: kind, resolvedPath: normalized 325 + ) 326 + } 327 + } 328 + } 329 + // New root: unknown path 330 + return OpenResolverResult( 331 + resolution: .newRoot, worktreeID: nil, worktreeName: nil, 332 + worktreePath: nil, rootPath: nil, worktreeKind: nil, resolvedPath: normalized 333 + ) 334 + }, 335 + selectWorktree: { worktreeID in 336 + selectCLIWorktreeContext( 337 + worktreeID: worktreeID, 338 + appStore: appStore, 339 + terminalManager: terminalManager 340 + ) 341 + }, 342 + addAndOpen: { url in 343 + appStore.send(.repositories(.repositoryManagement(.openRepositories([url])))) 344 + }, 345 + createTabAtPath: { worktreeID, path in 346 + let repositories = appStore.state.repositories 347 + for repository in repositories.repositories { 348 + if let worktree = repository.worktrees.first(where: { $0.id == worktreeID }) { 349 + terminalManager.handleCommand( 350 + .createTabWithInput(worktree, input: "cd \(path)", runSetupScriptIfNew: false) 351 + ) 352 + return 353 + } 354 + } 355 + }, 356 + terminalSnapshot: { worktreeID in 357 + guard let state = terminalManager.activeWorktreeStates.first( 358 + where: { $0.worktreeID == worktreeID } 359 + ) else { return nil } 360 + let snapshot = state.makeCLIListSnapshot() 361 + guard let selectedTab = snapshot.tabs.first(where: { $0.selected }) ?? snapshot.tabs.first 362 + else { return nil } 363 + let focusedPane = selectedTab.panes.first(where: { $0.id == selectedTab.focusedPaneID }) 364 + ?? selectedTab.panes.first 365 + return OpenTerminalSnapshot( 366 + tabID: selectedTab.id.uuidString, 367 + tabTitle: selectedTab.title, 368 + tabCwd: selectedTab.panes.first.flatMap { $0.cwd }, 369 + paneID: focusedPane?.id.uuidString, 370 + paneTitle: focusedPane?.title, 371 + paneCwd: focusedPane?.cwd 372 + ) 373 + } 374 + ) 257 375 let cliRouter = CLICommandRouter( 376 + openHandler: openHandler, 258 377 listHandler: listHandler, 259 378 focusHandler: focusHandler, 260 379 sendHandler: sendHandler
+147
supacode/CLIService/OpenCommandHandler.swift
··· 1 + // supacode/CLIService/OpenCommandHandler.swift 2 + // Handles `prowl open [path]` — resolves path to worktree, selects it, brings app to front. 3 + 4 + import AppKit 5 + import Foundation 6 + 7 + /// Result of resolving an open command against current app state. 8 + enum OpenResolution: Sendable { 9 + /// Path matched an existing worktree. 10 + case worktree(id: String, name: String, path: String, repositoryRoot: String) 11 + /// No path provided — just bring app to front. 12 + case bringToFront 13 + /// Path is a valid directory but not a known worktree/repository. 14 + case unknownPath(String) 15 + } 16 + 17 + /// Payload returned on successful open. 18 + struct OpenCommandPayload: Codable { 19 + let worktreeID: String? 20 + let worktreeName: String? 21 + let path: String? 22 + let repositoryRoot: String? 23 + let broughtToFront: Bool 24 + 25 + enum CodingKeys: String, CodingKey { 26 + case worktreeID = "worktree_id" 27 + case worktreeName = "worktree_name" 28 + case path 29 + case repositoryRoot = "repository_root" 30 + case broughtToFront = "brought_to_front" 31 + } 32 + } 33 + 34 + final class OpenCommandHandler: CommandHandler { 35 + typealias Resolver = @MainActor (String?) -> OpenResolution 36 + typealias SelectAction = @MainActor (String) -> Void 37 + typealias AddAndOpenAction = @MainActor (URL) -> Void 38 + 39 + private let resolver: Resolver 40 + private let selectWorktree: SelectAction 41 + private let addAndOpen: AddAndOpenAction 42 + 43 + init( 44 + resolver: @escaping Resolver, 45 + selectWorktree: @escaping SelectAction, 46 + addAndOpen: @escaping AddAndOpenAction 47 + ) { 48 + self.resolver = resolver 49 + self.selectWorktree = selectWorktree 50 + self.addAndOpen = addAndOpen 51 + } 52 + 53 + // swiftlint:disable:next async_without_await 54 + func handle(envelope: CommandEnvelope) async -> CommandResponse { 55 + guard case .open(let input) = envelope.command else { 56 + return CommandResponse( 57 + ok: false, 58 + command: "open", 59 + schemaVersion: "prowl.cli.open.v1", 60 + error: CommandError(code: CLIErrorCode.invalidArgument, message: "Expected open command.") 61 + ) 62 + } 63 + 64 + let resolution = resolver(input.path) 65 + 66 + switch resolution { 67 + case .bringToFront: 68 + bringAppToFront() 69 + return makeSuccess( 70 + worktreeID: nil, 71 + worktreeName: nil, 72 + path: nil, 73 + repositoryRoot: nil, 74 + broughtToFront: true 75 + ) 76 + 77 + case .worktree(let id, let name, let path, let repositoryRoot): 78 + selectWorktree(id) 79 + bringAppToFront() 80 + return makeSuccess( 81 + worktreeID: id, 82 + worktreeName: name, 83 + path: path, 84 + repositoryRoot: repositoryRoot, 85 + broughtToFront: true 86 + ) 87 + 88 + case .unknownPath(let path): 89 + let url = URL(fileURLWithPath: path, isDirectory: true) 90 + addAndOpen(url) 91 + bringAppToFront() 92 + return makeSuccess( 93 + worktreeID: nil, 94 + worktreeName: nil, 95 + path: path, 96 + repositoryRoot: nil, 97 + broughtToFront: true 98 + ) 99 + } 100 + } 101 + 102 + // MARK: - Private 103 + 104 + private func bringAppToFront() { 105 + NSApplication.shared.activate(ignoringOtherApps: true) 106 + if let window = NSApplication.shared.windows.first(where: { $0.identifier?.rawValue == "main" }) { 107 + if window.isMiniaturized { 108 + window.deminiaturize(nil) 109 + } 110 + window.makeKeyAndOrderFront(nil) 111 + } 112 + } 113 + 114 + private func makeSuccess( 115 + worktreeID: String?, 116 + worktreeName: String?, 117 + path: String?, 118 + repositoryRoot: String?, 119 + broughtToFront: Bool 120 + ) -> CommandResponse { 121 + let payload = OpenCommandPayload( 122 + worktreeID: worktreeID, 123 + worktreeName: worktreeName, 124 + path: path, 125 + repositoryRoot: repositoryRoot, 126 + broughtToFront: broughtToFront 127 + ) 128 + do { 129 + return try CommandResponse( 130 + ok: true, 131 + command: "open", 132 + schemaVersion: "prowl.cli.open.v1", 133 + data: RawJSON(encoding: payload) 134 + ) 135 + } catch { 136 + return CommandResponse( 137 + ok: false, 138 + command: "open", 139 + schemaVersion: "prowl.cli.open.v1", 140 + error: CommandError( 141 + code: CLIErrorCode.openFailed, 142 + message: "Failed to encode response." 143 + ) 144 + ) 145 + } 146 + } 147 + }
+145
supacodeTests/OpenCommandHandlerTests.swift
··· 1 + // supacodeTests/OpenCommandHandlerTests.swift 2 + // Unit tests for OpenCommandHandler. 3 + 4 + import Foundation 5 + import Testing 6 + 7 + @testable import supacode 8 + 9 + struct OpenCommandHandlerTests { 10 + 11 + // MARK: - Bring to front (no path) 12 + 13 + @MainActor 14 + @Test func openWithNoPathReturnsBringToFront() async throws { 15 + var selectCalled = false 16 + var addCalled = false 17 + 18 + let handler = OpenCommandHandler( 19 + resolver: { path in 20 + #expect(path == nil) 21 + return .bringToFront 22 + }, 23 + selectWorktree: { _ in selectCalled = true }, 24 + addAndOpen: { _ in addCalled = true } 25 + ) 26 + 27 + let envelope = CommandEnvelope(output: .json, command: .open(OpenInput())) 28 + let response = await handler.handle(envelope: envelope) 29 + 30 + #expect(response.ok == true) 31 + #expect(response.command == "open") 32 + #expect(response.schemaVersion == "prowl.cli.open.v1") 33 + #expect(!selectCalled) 34 + #expect(!addCalled) 35 + 36 + if let data = response.data { 37 + let payload = try data.decode(as: OpenCommandPayload.self) 38 + #expect(payload.broughtToFront == true) 39 + #expect(payload.worktreeID == nil) 40 + } 41 + } 42 + 43 + // MARK: - Known worktree 44 + 45 + @MainActor 46 + @Test func openKnownWorktreeSelectsAndReturnsPayload() async throws { 47 + var selectedID: String? 48 + 49 + let handler = OpenCommandHandler( 50 + resolver: { _ in 51 + .worktree( 52 + id: "Prowl:/Users/test/Projects/Prowl", 53 + name: "Prowl", 54 + path: "/Users/test/Projects/Prowl", 55 + repositoryRoot: "/Users/test/Projects/Prowl" 56 + ) 57 + }, 58 + selectWorktree: { id in selectedID = id }, 59 + addAndOpen: { _ in } 60 + ) 61 + 62 + let envelope = CommandEnvelope( 63 + output: .text, 64 + command: .open(OpenInput(path: "/Users/test/Projects/Prowl")) 65 + ) 66 + let response = await handler.handle(envelope: envelope) 67 + 68 + #expect(response.ok == true) 69 + #expect(selectedID == "Prowl:/Users/test/Projects/Prowl") 70 + 71 + if let data = response.data { 72 + let payload = try data.decode(as: OpenCommandPayload.self) 73 + #expect(payload.worktreeID == "Prowl:/Users/test/Projects/Prowl") 74 + #expect(payload.worktreeName == "Prowl") 75 + #expect(payload.path == "/Users/test/Projects/Prowl") 76 + #expect(payload.broughtToFront == true) 77 + } 78 + } 79 + 80 + // MARK: - Unknown path triggers addAndOpen 81 + 82 + @MainActor 83 + @Test func openUnknownPathCallsAddAndOpen() async throws { 84 + var addedURL: URL? 85 + 86 + let handler = OpenCommandHandler( 87 + resolver: { _ in .unknownPath("/Users/test/NewProject") }, 88 + selectWorktree: { _ in }, 89 + addAndOpen: { url in addedURL = url } 90 + ) 91 + 92 + let envelope = CommandEnvelope( 93 + output: .json, 94 + command: .open(OpenInput(path: "/Users/test/NewProject")) 95 + ) 96 + let response = await handler.handle(envelope: envelope) 97 + 98 + #expect(response.ok == true) 99 + #expect(addedURL?.path == "/Users/test/NewProject") 100 + 101 + if let data = response.data { 102 + let payload = try data.decode(as: OpenCommandPayload.self) 103 + #expect(payload.worktreeID == nil) 104 + #expect(payload.path == "/Users/test/NewProject") 105 + #expect(payload.broughtToFront == true) 106 + } 107 + } 108 + 109 + // MARK: - Router dispatches to injected open handler 110 + 111 + @MainActor 112 + @Test func routerUsesInjectedOpenHandler() async { 113 + let handler = OpenCommandHandler( 114 + resolver: { _ in .bringToFront }, 115 + selectWorktree: { _ in }, 116 + addAndOpen: { _ in } 117 + ) 118 + 119 + let router = CLICommandRouter(openHandler: handler) 120 + let envelope = CommandEnvelope(output: .json, command: .open(OpenInput())) 121 + let response = await router.route(envelope) 122 + 123 + #expect(response.ok == true) 124 + #expect(response.command == "open") 125 + #expect(response.schemaVersion == "prowl.cli.open.v1") 126 + } 127 + 128 + // MARK: - Wrong command type 129 + 130 + @MainActor 131 + @Test func handlerRejectsNonOpenCommand() async { 132 + let handler = OpenCommandHandler( 133 + resolver: { _ in .bringToFront }, 134 + selectWorktree: { _ in }, 135 + addAndOpen: { _ in } 136 + ) 137 + 138 + // Directly call handle with a list envelope (shouldn't happen via router, but tests guard) 139 + let envelope = CommandEnvelope(output: .json, command: .list(ListInput())) 140 + let response = await handler.handle(envelope: envelope) 141 + 142 + #expect(response.ok == false) 143 + #expect(response.error?.code == "INVALID_ARGUMENT") 144 + } 145 + }