native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #133 from onevcat/friday/issue-106-cli-open-runtime

authored by

Wei Wang and committed by
GitHub
37eafb2b 38e1430d

+742 -4
+12 -3
ProwlCLI/Commands/OpenCommand.swift
··· 20 20 let resolvedPath: String? = try path.map { try normalizePath($0) } 21 21 let envelope = CommandEnvelope( 22 22 output: options.outputMode, 23 - command: .open(OpenInput(path: resolvedPath)) 23 + command: .open(OpenInput(path: resolvedPath, invocation: Self.deriveInvocation(path: resolvedPath))) 24 24 ) 25 25 try CLIRunner.execute(envelope) 26 26 } 27 + } 28 + 29 + private static func deriveInvocation(path: String?) -> String { 30 + guard path != nil else { return "bare" } 31 + let args = CommandLine.arguments.dropFirst() 32 + if args.first == "open" { 33 + return "open-subcommand" 34 + } 35 + return "implicit-open" 27 36 } 28 37 29 38 private func normalizePath(_ raw: String) throws -> String { ··· 46 55 } 47 56 } 48 57 49 - let fm = FileManager.default 58 + let fileManager = FileManager.default 50 59 var isDir: ObjCBool = false 51 - guard fm.fileExists(atPath: path, isDirectory: &isDir) else { 60 + guard fileManager.fileExists(atPath: path, isDirectory: &isDir) else { 52 61 throw ExitError( 53 62 code: CLIErrorCode.pathNotFound, 54 63 message: "Path not found: \(raw)"
+151
supacode/App/supacodeApp.swift
··· 267 267 bringMainWindowToFront() 268 268 } 269 269 ) 270 + let openHandler = Self.makeOpenHandler(appStore: appStore, terminalManager: terminalManager) 270 271 let readHandler = ReadCommandHandler( 271 272 resolveProvider: { selector in 272 273 let resolver = TargetResolver { ··· 311 312 } 312 313 ) 313 314 let cliRouter = CLICommandRouter( 315 + openHandler: openHandler, 314 316 listHandler: listHandler, 315 317 focusHandler: focusHandler, 316 318 sendHandler: sendHandler, ··· 326 328 logger.warning("Failed to start CLI socket server: \(String(describing: error))") 327 329 } 328 330 return cliServer 331 + } 332 + 333 + // MARK: - Open handler factory 334 + 335 + private static func makeOpenHandler( 336 + appStore: StoreOf<AppFeature>, 337 + terminalManager: WorktreeTerminalManager 338 + ) -> OpenCommandHandler { 339 + OpenCommandHandler( 340 + resolver: { path in 341 + resolveOpenPath(path, repositories: appStore.state.repositories) 342 + }, 343 + selectWorktree: { worktreeID in 344 + selectCLIWorktreeContext( 345 + worktreeID: worktreeID, 346 + appStore: appStore, 347 + terminalManager: terminalManager 348 + ) 349 + }, 350 + addAndOpen: { url in 351 + appStore.send(.repositories(.repositoryManagement(.openRepositories([url])))) 352 + }, 353 + createTabAtPath: { worktreeID, path in 354 + // Find the Worktree object by ID to create a tab cd'd to the subpath. 355 + let repositories = appStore.state.repositories 356 + for repository in repositories.repositories { 357 + if let worktree = repository.worktrees.first(where: { $0.id == worktreeID }) { 358 + let quotedPath = shellQuote(path) 359 + terminalManager.handleCommand( 360 + .createTabWithInput( 361 + worktree, 362 + input: "cd -- \(quotedPath)", 363 + runSetupScriptIfNew: false 364 + ) 365 + ) 366 + return 367 + } 368 + } 369 + }, 370 + terminalSnapshot: { worktreeID in 371 + guard let state = terminalManager.activeWorktreeStates.first( 372 + where: { $0.worktreeID == worktreeID } 373 + ) else { return nil } 374 + let snapshot = state.makeCLIListSnapshot() 375 + guard let selectedTab = snapshot.tabs.first(where: { $0.selected }) ?? snapshot.tabs.first 376 + else { return nil } 377 + let focusedPane = selectedTab.panes.first(where: { $0.id == selectedTab.focusedPaneID }) 378 + ?? selectedTab.panes.first 379 + return OpenTerminalSnapshot( 380 + tabID: selectedTab.id.uuidString, 381 + tabTitle: selectedTab.title, 382 + tabCwd: selectedTab.panes.first.flatMap { $0.cwd }, 383 + paneID: focusedPane?.id.uuidString, 384 + paneTitle: focusedPane?.title, 385 + paneCwd: focusedPane?.cwd 386 + ) 387 + } 388 + ) 389 + } 390 + 391 + // MARK: - Open path resolution 392 + 393 + private static func resolveOpenPath( 394 + _ path: String?, 395 + repositories: RepositoriesFeature.State 396 + ) -> OpenResolverResult { 397 + guard let path else { 398 + return OpenResolverResult( 399 + resolution: .noArgument, worktreeID: nil, worktreeName: nil, 400 + worktreePath: nil, rootPath: nil, worktreeKind: nil, resolvedPath: nil 401 + ) 402 + } 403 + let normalized = URL(fileURLWithPath: path, isDirectory: true) 404 + .standardizedFileURL.path(percentEncoded: false) 405 + // Exact match: worktree working directory or repository root 406 + for repository in repositories.repositories { 407 + let kind = repository.kind.rawValue 408 + for worktree in repository.worktrees { 409 + let wtPath = worktree.workingDirectory 410 + .standardizedFileURL.path(percentEncoded: false) 411 + if wtPath == normalized { 412 + return OpenResolverResult( 413 + resolution: .exactRoot, worktreeID: worktree.id, 414 + worktreeName: worktree.name, worktreePath: wtPath, 415 + rootPath: repository.rootURL.standardizedFileURL.path(percentEncoded: false), 416 + worktreeKind: kind, resolvedPath: normalized 417 + ) 418 + } 419 + } 420 + let repoRoot = repository.rootURL 421 + .standardizedFileURL.path(percentEncoded: false) 422 + if repoRoot == normalized, 423 + !repository.capabilities.supportsWorktrees, 424 + repository.capabilities.supportsRunnableFolderActions 425 + { 426 + return OpenResolverResult( 427 + resolution: .exactRoot, worktreeID: repository.id, 428 + worktreeName: repository.name, worktreePath: repoRoot, 429 + rootPath: repoRoot, worktreeKind: kind, resolvedPath: normalized 430 + ) 431 + } 432 + } 433 + // Inside-root: path is inside an existing worktree/repo root 434 + let normalizedSlash = normalized.hasSuffix("/") ? normalized : normalized + "/" 435 + for repository in repositories.repositories { 436 + let kind = repository.kind.rawValue 437 + for worktree in repository.worktrees { 438 + let wtPath = worktree.workingDirectory 439 + .standardizedFileURL.path(percentEncoded: false) 440 + let wtSlash = wtPath.hasSuffix("/") ? wtPath : wtPath + "/" 441 + if normalizedSlash.hasPrefix(wtSlash) { 442 + return OpenResolverResult( 443 + resolution: .insideRoot, worktreeID: worktree.id, 444 + worktreeName: worktree.name, worktreePath: wtPath, 445 + rootPath: repository.rootURL.standardizedFileURL.path(percentEncoded: false), 446 + worktreeKind: kind, resolvedPath: normalized 447 + ) 448 + } 449 + } 450 + if !repository.capabilities.supportsWorktrees, 451 + repository.capabilities.supportsRunnableFolderActions 452 + { 453 + let repoRoot = repository.rootURL 454 + .standardizedFileURL.path(percentEncoded: false) 455 + let repoSlash = repoRoot.hasSuffix("/") ? repoRoot : repoRoot + "/" 456 + if normalizedSlash.hasPrefix(repoSlash) { 457 + return OpenResolverResult( 458 + resolution: .insideRoot, worktreeID: repository.id, 459 + worktreeName: repository.name, worktreePath: repoRoot, 460 + rootPath: repoRoot, worktreeKind: kind, resolvedPath: normalized 461 + ) 462 + } 463 + } 464 + } 465 + // New root: unknown path 466 + return OpenResolverResult( 467 + resolution: .newRoot, worktreeID: nil, worktreeName: nil, 468 + worktreePath: nil, rootPath: nil, worktreeKind: nil, resolvedPath: normalized 469 + ) 470 + } 471 + 472 + private static func shellQuote(_ value: String) -> String { 473 + let needsQuoting = value.contains { character in 474 + character.isWhitespace || character == "\"" || character == "'" || character == "\\" 475 + } 476 + guard needsQuoting else { 477 + return value 478 + } 479 + return "'\(value.replacing("'", with: "'\"'\"'"))'" 329 480 } 330 481 331 482 private static func selectCLIWorktreeContext(
+299
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 + // Response payload follows doc-onevcat/contracts/cli/open.md contract. 4 + 5 + import AppKit 6 + import Foundation 7 + 8 + // MARK: - Resolution 9 + 10 + /// How the requested path was resolved against current app state. 11 + enum OpenResolution: String, Sendable, Codable { 12 + /// Bare `prowl` with no path. 13 + case noArgument = "no-argument" 14 + /// Path matched an already-open root exactly. 15 + case exactRoot = "exact-root" 16 + /// Path was inside an already-open root. 17 + case insideRoot = "inside-root" 18 + /// Path was not yet managed; Prowl opened it as a new root. 19 + case newRoot = "new-root" 20 + } 21 + 22 + /// Internal result of resolving an open command against current app state. 23 + struct OpenResolverResult: Sendable { 24 + let resolution: OpenResolution 25 + let worktreeID: String? 26 + let worktreeName: String? 27 + let worktreePath: String? 28 + let rootPath: String? 29 + let worktreeKind: String? 30 + let resolvedPath: String? 31 + } 32 + 33 + // MARK: - Contract-aligned payload 34 + 35 + /// Success payload per doc-onevcat/contracts/cli/open.md 36 + struct OpenCommandData: Codable { 37 + let invocation: String 38 + let requestedPath: String? 39 + let resolvedPath: String? 40 + let resolution: String 41 + let appLaunched: Bool 42 + let broughtToFront: Bool 43 + let createdTab: Bool 44 + let target: OpenTarget? 45 + 46 + enum CodingKeys: String, CodingKey { 47 + case invocation 48 + case requestedPath = "requested_path" 49 + case resolvedPath = "resolved_path" 50 + case resolution 51 + case appLaunched = "app_launched" 52 + case broughtToFront = "brought_to_front" 53 + case createdTab = "created_tab" 54 + case target 55 + } 56 + } 57 + 58 + struct OpenTarget: Codable { 59 + let worktree: OpenTargetWorktree 60 + let tab: OpenTargetTab? 61 + let pane: OpenTargetPane? 62 + } 63 + 64 + struct OpenTargetWorktree: Codable { 65 + let id: String 66 + let name: String 67 + let path: String 68 + let rootPath: String 69 + let kind: String 70 + 71 + enum CodingKeys: String, CodingKey { 72 + case id, name, path 73 + case rootPath = "root_path" 74 + case kind 75 + } 76 + } 77 + 78 + struct OpenTargetTab: Codable { 79 + let id: String 80 + let title: String 81 + let cwd: String? 82 + } 83 + 84 + struct OpenTargetPane: Codable { 85 + let id: String 86 + let title: String 87 + let cwd: String? 88 + } 89 + 90 + // MARK: - Terminal snapshot for open command 91 + 92 + struct OpenTerminalSnapshot: Sendable { 93 + let tabID: String? 94 + let tabTitle: String? 95 + let tabCwd: String? 96 + let paneID: String? 97 + let paneTitle: String? 98 + let paneCwd: String? 99 + } 100 + 101 + // MARK: - Handler 102 + 103 + final class OpenCommandHandler: CommandHandler { 104 + typealias Resolver = @MainActor (String?) -> OpenResolverResult 105 + typealias SelectAction = @MainActor (String) -> Void 106 + typealias AddAndOpenAction = @MainActor (URL) -> Void 107 + /// Creates a new tab in the given worktree and `cd`s to the specified path. 108 + /// Parameters: worktreeID, absolutePath. 109 + typealias CreateTabAtPathAction = @MainActor (String, String) -> Void 110 + typealias TerminalSnapshotProvider = @MainActor (String) -> OpenTerminalSnapshot? 111 + 112 + private let resolver: Resolver 113 + private let selectWorktree: SelectAction 114 + private let addAndOpen: AddAndOpenAction 115 + private let createTabAtPath: CreateTabAtPathAction 116 + private let terminalSnapshot: TerminalSnapshotProvider 117 + 118 + init( 119 + resolver: @escaping Resolver, 120 + selectWorktree: @escaping SelectAction, 121 + addAndOpen: @escaping AddAndOpenAction, 122 + createTabAtPath: @escaping CreateTabAtPathAction = { _, _ in }, 123 + terminalSnapshot: @escaping TerminalSnapshotProvider 124 + ) { 125 + self.resolver = resolver 126 + self.selectWorktree = selectWorktree 127 + self.addAndOpen = addAndOpen 128 + self.createTabAtPath = createTabAtPath 129 + self.terminalSnapshot = terminalSnapshot 130 + } 131 + 132 + // swiftlint:disable:next async_without_await 133 + func handle(envelope: CommandEnvelope) async -> CommandResponse { 134 + guard case .open(let input) = envelope.command else { 135 + return CommandResponse( 136 + ok: false, 137 + command: "open", 138 + schemaVersion: "prowl.cli.open.v1", 139 + error: CommandError(code: CLIErrorCode.invalidArgument, message: "Expected open command.") 140 + ) 141 + } 142 + 143 + let result = resolver(input.path) 144 + let invocation = deriveInvocation(input: input) 145 + 146 + switch result.resolution { 147 + case .noArgument: 148 + bringAppToFront() 149 + return makeSuccess( 150 + invocation: invocation, 151 + requestedPath: nil, 152 + resolvedPath: nil, 153 + resolution: .noArgument, 154 + createdTab: false, 155 + target: nil 156 + ) 157 + 158 + case .exactRoot: 159 + if let worktreeID = result.worktreeID { 160 + selectWorktree(worktreeID) 161 + } 162 + bringAppToFront() 163 + let snapshot = result.worktreeID.flatMap { terminalSnapshot($0) } 164 + return makeSuccess( 165 + invocation: invocation, 166 + requestedPath: input.path, 167 + resolvedPath: result.resolvedPath ?? input.path, 168 + resolution: .exactRoot, 169 + createdTab: false, 170 + target: makeTarget(result: result, snapshot: snapshot) 171 + ) 172 + 173 + case .insideRoot: 174 + if let worktreeID = result.worktreeID { 175 + selectWorktree(worktreeID) 176 + // Open a new tab cd'd to the exact requested subpath. 177 + if let subpath = result.resolvedPath ?? input.path { 178 + createTabAtPath(worktreeID, subpath) 179 + } 180 + } 181 + bringAppToFront() 182 + let snapshot = result.worktreeID.flatMap { terminalSnapshot($0) } 183 + return makeSuccess( 184 + invocation: invocation, 185 + requestedPath: input.path, 186 + resolvedPath: result.resolvedPath ?? input.path, 187 + resolution: .insideRoot, 188 + createdTab: true, 189 + target: makeTarget(result: result, snapshot: snapshot) 190 + ) 191 + 192 + case .newRoot: 193 + if let path = result.resolvedPath ?? input.path { 194 + let url = URL(fileURLWithPath: path, isDirectory: true) 195 + addAndOpen(url) 196 + } 197 + bringAppToFront() 198 + return makeSuccess( 199 + invocation: invocation, 200 + requestedPath: input.path, 201 + resolvedPath: result.resolvedPath ?? input.path, 202 + resolution: .newRoot, 203 + createdTab: true, 204 + target: nil 205 + ) 206 + } 207 + } 208 + 209 + // MARK: - Private 210 + 211 + private func deriveInvocation(input: OpenInput) -> String { 212 + if let inv = input.invocation { 213 + return inv 214 + } 215 + return input.path == nil ? "bare" : "open-subcommand" 216 + } 217 + 218 + private func bringAppToFront() { 219 + NSApplication.shared.activate(ignoringOtherApps: true) 220 + if let window = NSApplication.shared.windows.first(where: { $0.identifier?.rawValue == "main" }) { 221 + if window.isMiniaturized { 222 + window.deminiaturize(nil) 223 + } 224 + window.makeKeyAndOrderFront(nil) 225 + } 226 + } 227 + 228 + private func makeTarget( 229 + result: OpenResolverResult, 230 + snapshot: OpenTerminalSnapshot? 231 + ) -> OpenTarget? { 232 + guard let worktreeID = result.worktreeID, 233 + let worktreeName = result.worktreeName, 234 + let worktreePath = result.worktreePath, 235 + let rootPath = result.rootPath 236 + else { 237 + return nil 238 + } 239 + 240 + let worktreeTarget = OpenTargetWorktree( 241 + id: worktreeID, 242 + name: worktreeName, 243 + path: worktreePath, 244 + rootPath: rootPath, 245 + kind: result.worktreeKind ?? "git" 246 + ) 247 + 248 + let tabTarget: OpenTargetTab? = snapshot.flatMap { snap in 249 + guard let tabID = snap.tabID, let tabTitle = snap.tabTitle else { return nil } 250 + return OpenTargetTab(id: tabID, title: tabTitle, cwd: snap.tabCwd) 251 + } 252 + 253 + let paneTarget: OpenTargetPane? = snapshot.flatMap { snap in 254 + guard let paneID = snap.paneID, let paneTitle = snap.paneTitle else { return nil } 255 + return OpenTargetPane(id: paneID, title: paneTitle, cwd: snap.paneCwd) 256 + } 257 + 258 + return OpenTarget(worktree: worktreeTarget, tab: tabTarget, pane: paneTarget) 259 + } 260 + 261 + // swiftlint:disable:next function_parameter_count 262 + private func makeSuccess( 263 + invocation: String, 264 + requestedPath: String?, 265 + resolvedPath: String?, 266 + resolution: OpenResolution, 267 + createdTab: Bool, 268 + target: OpenTarget? 269 + ) -> CommandResponse { 270 + let payload = OpenCommandData( 271 + invocation: invocation, 272 + requestedPath: requestedPath, 273 + resolvedPath: resolvedPath, 274 + resolution: resolution.rawValue, 275 + appLaunched: false, 276 + broughtToFront: true, 277 + createdTab: createdTab, 278 + target: target 279 + ) 280 + do { 281 + return try CommandResponse( 282 + ok: true, 283 + command: "open", 284 + schemaVersion: "prowl.cli.open.v1", 285 + data: RawJSON(encoding: payload) 286 + ) 287 + } catch { 288 + return CommandResponse( 289 + ok: false, 290 + command: "open", 291 + schemaVersion: "prowl.cli.open.v1", 292 + error: CommandError( 293 + code: CLIErrorCode.openFailed, 294 + message: "Failed to encode response." 295 + ) 296 + ) 297 + } 298 + } 299 + }
+6 -1
supacode/CLIService/Shared/InputModels.swift
··· 7 7 /// Normalized absolute path, or nil for bare `prowl` (bring to front). 8 8 public let path: String? 9 9 10 - public init(path: String? = nil) { 10 + /// Invocation kind: "bare", "implicit-open", or "open-subcommand". 11 + /// Optional — handler derives a default if absent. 12 + public let invocation: String? 13 + 14 + public init(path: String? = nil, invocation: String? = nil) { 11 15 self.path = path 16 + self.invocation = invocation 12 17 } 13 18 } 14 19
+274
supacodeTests/OpenCommandHandlerTests.swift
··· 1 + // supacodeTests/OpenCommandHandlerTests.swift 2 + // Unit tests for OpenCommandHandler — contract-aligned. 3 + 4 + import Foundation 5 + import Testing 6 + 7 + @testable import supacode 8 + 9 + struct OpenCommandHandlerTests { 10 + 11 + // MARK: - Helpers 12 + 13 + private func makeHandler( 14 + resolver: @escaping OpenCommandHandler.Resolver = { _ in 15 + OpenResolverResult( 16 + resolution: .noArgument, worktreeID: nil, worktreeName: nil, 17 + worktreePath: nil, rootPath: nil, worktreeKind: nil, resolvedPath: nil 18 + ) 19 + }, 20 + selectWorktree: @escaping OpenCommandHandler.SelectAction = { _ in }, 21 + addAndOpen: @escaping OpenCommandHandler.AddAndOpenAction = { _ in }, 22 + createTabAtPath: @escaping OpenCommandHandler.CreateTabAtPathAction = { _, _ in }, 23 + terminalSnapshot: @escaping OpenCommandHandler.TerminalSnapshotProvider = { _ in nil } 24 + ) -> OpenCommandHandler { 25 + OpenCommandHandler( 26 + resolver: resolver, 27 + selectWorktree: selectWorktree, 28 + addAndOpen: addAndOpen, 29 + createTabAtPath: createTabAtPath, 30 + terminalSnapshot: terminalSnapshot 31 + ) 32 + } 33 + 34 + // MARK: - Bring to front (no path) 35 + 36 + @MainActor 37 + @Test func openWithNoPathReturnsBringToFront() async throws { 38 + let handler = makeHandler() 39 + let envelope = CommandEnvelope(output: .json, command: .open(OpenInput())) 40 + let response = await handler.handle(envelope: envelope) 41 + 42 + #expect(response.ok == true) 43 + #expect(response.command == "open") 44 + #expect(response.schemaVersion == "prowl.cli.open.v1") 45 + 46 + let data = try #require(response.data) 47 + let payload = try data.decode(as: OpenCommandData.self) 48 + #expect(payload.resolution == "no-argument") 49 + #expect(payload.invocation == "bare") 50 + #expect(payload.requestedPath == nil) 51 + #expect(payload.resolvedPath == nil) 52 + #expect(payload.broughtToFront == true) 53 + #expect(payload.appLaunched == false) 54 + #expect(payload.target == nil) 55 + } 56 + 57 + // MARK: - Exact root 58 + 59 + @MainActor 60 + @Test func openExactRootSelectsAndReturnsContractPayload() async throws { 61 + var selectedID: String? 62 + 63 + let handler = makeHandler( 64 + resolver: { _ in 65 + OpenResolverResult( 66 + resolution: .exactRoot, 67 + worktreeID: "Prowl:/Users/test/Projects/Prowl", 68 + worktreeName: "Prowl", 69 + worktreePath: "/Users/test/Projects/Prowl", 70 + rootPath: "/Users/test/Projects/Prowl", 71 + worktreeKind: "git", 72 + resolvedPath: "/Users/test/Projects/Prowl" 73 + ) 74 + }, 75 + selectWorktree: { id in selectedID = id }, 76 + terminalSnapshot: { _ in 77 + OpenTerminalSnapshot( 78 + tabID: "0E2A7C03-9C01-4BC1-9327-6C1C7B629A52", 79 + tabTitle: "Prowl 1", 80 + tabCwd: "/Users/test/Projects/Prowl", 81 + paneID: "0FB4DDB4-A797-4315-A00E-8AAFB32BFC95", 82 + paneTitle: "Prowl", 83 + paneCwd: "/Users/test/Projects/Prowl" 84 + ) 85 + } 86 + ) 87 + 88 + let envelope = CommandEnvelope( 89 + output: .json, 90 + command: .open(OpenInput(path: "/Users/test/Projects/Prowl", invocation: "open-subcommand")) 91 + ) 92 + let response = await handler.handle(envelope: envelope) 93 + 94 + #expect(response.ok == true) 95 + #expect(selectedID == "Prowl:/Users/test/Projects/Prowl") 96 + 97 + let data = try #require(response.data) 98 + let payload = try data.decode(as: OpenCommandData.self) 99 + #expect(payload.resolution == "exact-root") 100 + #expect(payload.invocation == "open-subcommand") 101 + #expect(payload.requestedPath == "/Users/test/Projects/Prowl") 102 + #expect(payload.resolvedPath == "/Users/test/Projects/Prowl") 103 + #expect(payload.createdTab == false) 104 + #expect(payload.broughtToFront == true) 105 + 106 + let target = try #require(payload.target) 107 + #expect(target.worktree.id == "Prowl:/Users/test/Projects/Prowl") 108 + #expect(target.worktree.name == "Prowl") 109 + #expect(target.worktree.kind == "git") 110 + #expect(target.tab?.id == "0E2A7C03-9C01-4BC1-9327-6C1C7B629A52") 111 + #expect(target.pane?.id == "0FB4DDB4-A797-4315-A00E-8AAFB32BFC95") 112 + } 113 + 114 + // MARK: - Inside root 115 + 116 + @MainActor 117 + @Test func openInsideRootSelectsWorktreeAndCreatesTab() async throws { 118 + var selectedID: String? 119 + var tabCreatedForWorktree: String? 120 + var tabCreatedAtPath: String? 121 + 122 + let handler = makeHandler( 123 + resolver: { _ in 124 + OpenResolverResult( 125 + resolution: .insideRoot, 126 + worktreeID: "Prowl:/Users/test/Projects/Prowl", 127 + worktreeName: "Prowl", 128 + worktreePath: "/Users/test/Projects/Prowl", 129 + rootPath: "/Users/test/Projects/Prowl", 130 + worktreeKind: "git", 131 + resolvedPath: "/Users/test/Projects/Prowl/supacode" 132 + ) 133 + }, 134 + selectWorktree: { id in selectedID = id }, 135 + createTabAtPath: { worktreeID, path in 136 + tabCreatedForWorktree = worktreeID 137 + tabCreatedAtPath = path 138 + } 139 + ) 140 + 141 + let envelope = CommandEnvelope( 142 + output: .json, 143 + command: .open(OpenInput(path: "/Users/test/Projects/Prowl/supacode", invocation: "implicit-open")) 144 + ) 145 + let response = await handler.handle(envelope: envelope) 146 + 147 + #expect(response.ok == true) 148 + #expect(selectedID == "Prowl:/Users/test/Projects/Prowl") 149 + #expect(tabCreatedForWorktree == "Prowl:/Users/test/Projects/Prowl") 150 + #expect(tabCreatedAtPath == "/Users/test/Projects/Prowl/supacode") 151 + 152 + let data = try #require(response.data) 153 + let payload = try data.decode(as: OpenCommandData.self) 154 + #expect(payload.resolution == "inside-root") 155 + #expect(payload.invocation == "implicit-open") 156 + #expect(payload.requestedPath == "/Users/test/Projects/Prowl/supacode") 157 + #expect(payload.resolvedPath == "/Users/test/Projects/Prowl/supacode") 158 + #expect(payload.createdTab == true) 159 + } 160 + 161 + // MARK: - New root 162 + 163 + @MainActor 164 + @Test func openNewRootCallsAddAndOpen() async throws { 165 + var addedURL: URL? 166 + 167 + let handler = makeHandler( 168 + resolver: { _ in 169 + OpenResolverResult( 170 + resolution: .newRoot, 171 + worktreeID: nil, worktreeName: nil, 172 + worktreePath: nil, rootPath: nil, 173 + worktreeKind: nil, resolvedPath: "/Users/test/NewProject" 174 + ) 175 + }, 176 + addAndOpen: { url in addedURL = url } 177 + ) 178 + 179 + let envelope = CommandEnvelope( 180 + output: .json, 181 + command: .open(OpenInput(path: "/Users/test/NewProject")) 182 + ) 183 + let response = await handler.handle(envelope: envelope) 184 + 185 + #expect(response.ok == true) 186 + #expect(addedURL?.path == "/Users/test/NewProject") 187 + 188 + let data = try #require(response.data) 189 + let payload = try data.decode(as: OpenCommandData.self) 190 + #expect(payload.resolution == "new-root") 191 + #expect(payload.requestedPath == "/Users/test/NewProject") 192 + #expect(payload.createdTab == true) 193 + #expect(payload.target == nil) 194 + } 195 + 196 + // MARK: - Router integration 197 + 198 + @MainActor 199 + @Test func routerUsesInjectedOpenHandler() async throws { 200 + let handler = makeHandler() 201 + let router = CLICommandRouter(openHandler: handler) 202 + let envelope = CommandEnvelope(output: .json, command: .open(OpenInput())) 203 + let response = await router.route(envelope) 204 + 205 + #expect(response.ok == true) 206 + #expect(response.command == "open") 207 + #expect(response.schemaVersion == "prowl.cli.open.v1") 208 + } 209 + 210 + // MARK: - Wrong command type 211 + 212 + @MainActor 213 + @Test func handlerRejectsNonOpenCommand() async { 214 + let handler = makeHandler() 215 + let envelope = CommandEnvelope(output: .json, command: .list(ListInput())) 216 + let response = await handler.handle(envelope: envelope) 217 + 218 + #expect(response.ok == false) 219 + #expect(response.error?.code == "INVALID_ARGUMENT") 220 + } 221 + 222 + // MARK: - Invocation derivation 223 + 224 + @MainActor 225 + @Test func defaultInvocationIsBareWhenNoPath() async throws { 226 + let handler = makeHandler() 227 + let envelope = CommandEnvelope(output: .json, command: .open(OpenInput())) 228 + let response = await handler.handle(envelope: envelope) 229 + let data = try #require(response.data) 230 + let payload = try data.decode(as: OpenCommandData.self) 231 + #expect(payload.invocation == "bare") 232 + } 233 + 234 + @MainActor 235 + @Test func defaultInvocationIsOpenSubcommandWhenPathPresent() async throws { 236 + let handler = makeHandler( 237 + resolver: { _ in 238 + OpenResolverResult( 239 + resolution: .newRoot, 240 + worktreeID: nil, worktreeName: nil, 241 + worktreePath: nil, rootPath: nil, 242 + worktreeKind: nil, resolvedPath: "/tmp/test" 243 + ) 244 + } 245 + ) 246 + let envelope = CommandEnvelope(output: .json, command: .open(OpenInput(path: "/tmp/test"))) 247 + let response = await handler.handle(envelope: envelope) 248 + let data = try #require(response.data) 249 + let payload = try data.decode(as: OpenCommandData.self) 250 + #expect(payload.invocation == "open-subcommand") 251 + } 252 + 253 + @MainActor 254 + @Test func explicitInvocationIsPreserved() async throws { 255 + let handler = makeHandler( 256 + resolver: { _ in 257 + OpenResolverResult( 258 + resolution: .newRoot, 259 + worktreeID: nil, worktreeName: nil, 260 + worktreePath: nil, rootPath: nil, 261 + worktreeKind: nil, resolvedPath: "/tmp/test" 262 + ) 263 + } 264 + ) 265 + let envelope = CommandEnvelope( 266 + output: .json, 267 + command: .open(OpenInput(path: "/tmp/test", invocation: "implicit-open")) 268 + ) 269 + let response = await handler.handle(envelope: envelope) 270 + let data = try #require(response.data) 271 + let payload = try data.decode(as: OpenCommandData.self) 272 + #expect(payload.invocation == "implicit-open") 273 + } 274 + }