native macOS codings agent orchestrator
6
fork

Configure Feed

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

fix(cli): align open handler with contract — add inside-root resolution, contract-aligned payload

- Replace OpenCommandPayload with OpenCommandData matching open.md contract
(invocation, requested_path, resolved_path, resolution, app_launched,
brought_to_front, created_tab, target with worktree/tab/pane)
- Add inside-root resolution: paths inside existing worktree/repo roots
now resolve to parent root instead of being treated as new-root
- Add exact-root vs inside-root vs new-root vs no-argument resolution enum
- Wire invocation field from CLI parser (bare/implicit-open/open-subcommand)
- Add terminal snapshot provider for tab/pane info in response target
- Update tests: 9 tests covering all resolution paths, invocation derivation,
contract payload shape, router integration, and error handling
- Rename fm to fileManager in OpenCommand.swift (lint)

Addresses review feedback from @jarvis-elevated on PR #133.

Friday d3f15e22 004e7c0d

+395 -120
+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)"
+194 -52
supacode/CLIService/OpenCommandHandler.swift
··· 1 1 // supacode/CLIService/OpenCommandHandler.swift 2 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. 3 4 4 5 import AppKit 5 6 import Foundation 6 7 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) 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" 15 20 } 16 21 17 - /// Payload returned on successful open. 18 - struct OpenCommandPayload: Codable { 22 + /// Internal result of resolving an open command against current app state. 23 + struct OpenResolverResult: Sendable { 24 + let resolution: OpenResolution 19 25 let worktreeID: String? 20 26 let worktreeName: String? 21 - let path: String? 22 - let repositoryRoot: 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 23 42 let broughtToFront: Bool 43 + let createdTab: Bool 44 + let target: OpenTarget? 24 45 25 46 enum CodingKeys: String, CodingKey { 26 - case worktreeID = "worktree_id" 27 - case worktreeName = "worktree_name" 28 - case path 29 - case repositoryRoot = "repository_root" 47 + case invocation 48 + case requestedPath = "requested_path" 49 + case resolvedPath = "resolved_path" 50 + case resolution 51 + case appLaunched = "app_launched" 30 52 case broughtToFront = "brought_to_front" 53 + case createdTab = "created_tab" 54 + case target 31 55 } 32 56 } 33 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 + 34 103 final class OpenCommandHandler: CommandHandler { 35 - typealias Resolver = @MainActor (String?) -> OpenResolution 104 + typealias Resolver = @MainActor (String?) -> OpenResolverResult 36 105 typealias SelectAction = @MainActor (String) -> Void 37 106 typealias AddAndOpenAction = @MainActor (URL) -> Void 107 + typealias TerminalSnapshotProvider = @MainActor (String) -> OpenTerminalSnapshot? 38 108 39 109 private let resolver: Resolver 40 110 private let selectWorktree: SelectAction 41 111 private let addAndOpen: AddAndOpenAction 112 + private let terminalSnapshot: TerminalSnapshotProvider 42 113 43 114 init( 44 115 resolver: @escaping Resolver, 45 116 selectWorktree: @escaping SelectAction, 46 - addAndOpen: @escaping AddAndOpenAction 117 + addAndOpen: @escaping AddAndOpenAction, 118 + terminalSnapshot: @escaping TerminalSnapshotProvider 47 119 ) { 48 120 self.resolver = resolver 49 121 self.selectWorktree = selectWorktree 50 122 self.addAndOpen = addAndOpen 123 + self.terminalSnapshot = terminalSnapshot 51 124 } 52 125 53 126 // swiftlint:disable:next async_without_await ··· 61 134 ) 62 135 } 63 136 64 - let resolution = resolver(input.path) 137 + let result = resolver(input.path) 138 + let invocation = deriveInvocation(input: input) 65 139 66 - switch resolution { 67 - case .bringToFront: 140 + switch result.resolution { 141 + case .noArgument: 68 142 bringAppToFront() 69 143 return makeSuccess( 70 - worktreeID: nil, 71 - worktreeName: nil, 72 - path: nil, 73 - repositoryRoot: nil, 74 - broughtToFront: true 144 + invocation: invocation, 145 + requestedPath: nil, 146 + resolvedPath: nil, 147 + resolution: .noArgument, 148 + createdTab: false, 149 + target: nil 75 150 ) 76 151 77 - case .worktree(let id, let name, let path, let repositoryRoot): 78 - selectWorktree(id) 152 + case .exactRoot: 153 + if let worktreeID = result.worktreeID { 154 + selectWorktree(worktreeID) 155 + } 79 156 bringAppToFront() 157 + let snapshot = result.worktreeID.flatMap { terminalSnapshot($0) } 80 158 return makeSuccess( 81 - worktreeID: id, 82 - worktreeName: name, 83 - path: path, 84 - repositoryRoot: repositoryRoot, 85 - broughtToFront: true 159 + invocation: invocation, 160 + requestedPath: input.path, 161 + resolvedPath: result.resolvedPath ?? input.path, 162 + resolution: .exactRoot, 163 + createdTab: false, 164 + target: makeTarget(result: result, snapshot: snapshot) 86 165 ) 87 166 88 - case .unknownPath(let path): 89 - let url = URL(fileURLWithPath: path, isDirectory: true) 90 - addAndOpen(url) 167 + case .insideRoot: 168 + if let worktreeID = result.worktreeID { 169 + selectWorktree(worktreeID) 170 + } 91 171 bringAppToFront() 172 + let snapshot = result.worktreeID.flatMap { terminalSnapshot($0) } 92 173 return makeSuccess( 93 - worktreeID: nil, 94 - worktreeName: nil, 95 - path: path, 96 - repositoryRoot: nil, 97 - broughtToFront: true 174 + invocation: invocation, 175 + requestedPath: input.path, 176 + resolvedPath: result.resolvedPath ?? input.path, 177 + resolution: .insideRoot, 178 + createdTab: true, 179 + target: makeTarget(result: result, snapshot: snapshot) 180 + ) 181 + 182 + case .newRoot: 183 + if let path = result.resolvedPath ?? input.path { 184 + let url = URL(fileURLWithPath: path, isDirectory: true) 185 + addAndOpen(url) 186 + } 187 + bringAppToFront() 188 + return makeSuccess( 189 + invocation: invocation, 190 + requestedPath: input.path, 191 + resolvedPath: result.resolvedPath ?? input.path, 192 + resolution: .newRoot, 193 + createdTab: true, 194 + target: nil 98 195 ) 99 196 } 100 197 } 101 198 102 199 // MARK: - Private 103 200 201 + private func deriveInvocation(input: OpenInput) -> String { 202 + if let inv = input.invocation { 203 + return inv 204 + } 205 + return input.path == nil ? "bare" : "open-subcommand" 206 + } 207 + 104 208 private func bringAppToFront() { 105 209 NSApplication.shared.activate(ignoringOtherApps: true) 106 210 if let window = NSApplication.shared.windows.first(where: { $0.identifier?.rawValue == "main" }) { ··· 111 215 } 112 216 } 113 217 218 + private func makeTarget( 219 + result: OpenResolverResult, 220 + snapshot: OpenTerminalSnapshot? 221 + ) -> OpenTarget? { 222 + guard let worktreeID = result.worktreeID, 223 + let worktreeName = result.worktreeName, 224 + let worktreePath = result.worktreePath, 225 + let rootPath = result.rootPath 226 + else { 227 + return nil 228 + } 229 + 230 + let worktreeTarget = OpenTargetWorktree( 231 + id: worktreeID, 232 + name: worktreeName, 233 + path: worktreePath, 234 + rootPath: rootPath, 235 + kind: result.worktreeKind ?? "git" 236 + ) 237 + 238 + let tabTarget: OpenTargetTab? = snapshot.flatMap { snap in 239 + guard let tabID = snap.tabID, let tabTitle = snap.tabTitle else { return nil } 240 + return OpenTargetTab(id: tabID, title: tabTitle, cwd: snap.tabCwd) 241 + } 242 + 243 + let paneTarget: OpenTargetPane? = snapshot.flatMap { snap in 244 + guard let paneID = snap.paneID, let paneTitle = snap.paneTitle else { return nil } 245 + return OpenTargetPane(id: paneID, title: paneTitle, cwd: snap.paneCwd) 246 + } 247 + 248 + return OpenTarget(worktree: worktreeTarget, tab: tabTarget, pane: paneTarget) 249 + } 250 + 251 + // swiftlint:disable:next function_parameter_count 114 252 private func makeSuccess( 115 - worktreeID: String?, 116 - worktreeName: String?, 117 - path: String?, 118 - repositoryRoot: String?, 119 - broughtToFront: Bool 253 + invocation: String, 254 + requestedPath: String?, 255 + resolvedPath: String?, 256 + resolution: OpenResolution, 257 + createdTab: Bool, 258 + target: OpenTarget? 120 259 ) -> CommandResponse { 121 - let payload = OpenCommandPayload( 122 - worktreeID: worktreeID, 123 - worktreeName: worktreeName, 124 - path: path, 125 - repositoryRoot: repositoryRoot, 126 - broughtToFront: broughtToFront 260 + let payload = OpenCommandData( 261 + invocation: invocation, 262 + requestedPath: requestedPath, 263 + resolvedPath: resolvedPath, 264 + resolution: resolution.rawValue, 265 + appLaunched: false, 266 + broughtToFront: true, 267 + createdTab: createdTab, 268 + target: target 127 269 ) 128 270 do { 129 271 return try CommandResponse(
+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
+183 -64
supacodeTests/OpenCommandHandlerTests.swift
··· 1 1 // supacodeTests/OpenCommandHandlerTests.swift 2 - // Unit tests for OpenCommandHandler. 2 + // Unit tests for OpenCommandHandler — contract-aligned. 3 3 4 4 import Foundation 5 5 import Testing ··· 8 8 9 9 struct OpenCommandHandlerTests { 10 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 + terminalSnapshot: @escaping OpenCommandHandler.TerminalSnapshotProvider = { _ in nil } 23 + ) -> OpenCommandHandler { 24 + OpenCommandHandler( 25 + resolver: resolver, 26 + selectWorktree: selectWorktree, 27 + addAndOpen: addAndOpen, 28 + terminalSnapshot: terminalSnapshot 29 + ) 30 + } 31 + 11 32 // MARK: - Bring to front (no path) 12 33 13 34 @MainActor 14 35 @Test func openWithNoPathReturnsBringToFront() async throws { 15 - var selectCalled = false 16 - var addCalled = false 36 + let handler = makeHandler() 37 + let envelope = CommandEnvelope(output: .json, command: .open(OpenInput())) 38 + let response = await handler.handle(envelope: envelope) 17 39 18 - let handler = OpenCommandHandler( 19 - resolver: { path in 20 - #expect(path == nil) 21 - return .bringToFront 40 + #expect(response.ok == true) 41 + #expect(response.command == "open") 42 + #expect(response.schemaVersion == "prowl.cli.open.v1") 43 + 44 + let data = try #require(response.data) 45 + let payload = try data.decode(as: OpenCommandData.self) 46 + #expect(payload.resolution == "no-argument") 47 + #expect(payload.invocation == "bare") 48 + #expect(payload.requestedPath == nil) 49 + #expect(payload.resolvedPath == nil) 50 + #expect(payload.broughtToFront == true) 51 + #expect(payload.appLaunched == false) 52 + #expect(payload.target == nil) 53 + } 54 + 55 + // MARK: - Exact root 56 + 57 + @MainActor 58 + @Test func openExactRootSelectsAndReturnsContractPayload() async throws { 59 + var selectedID: String? 60 + 61 + let handler = makeHandler( 62 + resolver: { _ in 63 + OpenResolverResult( 64 + resolution: .exactRoot, 65 + worktreeID: "Prowl:/Users/test/Projects/Prowl", 66 + worktreeName: "Prowl", 67 + worktreePath: "/Users/test/Projects/Prowl", 68 + rootPath: "/Users/test/Projects/Prowl", 69 + worktreeKind: "git", 70 + resolvedPath: "/Users/test/Projects/Prowl" 71 + ) 22 72 }, 23 - selectWorktree: { _ in selectCalled = true }, 24 - addAndOpen: { _ in addCalled = true } 73 + selectWorktree: { id in selectedID = id }, 74 + terminalSnapshot: { _ in 75 + OpenTerminalSnapshot( 76 + tabID: "0E2A7C03-9C01-4BC1-9327-6C1C7B629A52", 77 + tabTitle: "Prowl 1", 78 + tabCwd: "/Users/test/Projects/Prowl", 79 + paneID: "0FB4DDB4-A797-4315-A00E-8AAFB32BFC95", 80 + paneTitle: "Prowl", 81 + paneCwd: "/Users/test/Projects/Prowl" 82 + ) 83 + } 25 84 ) 26 85 27 - let envelope = CommandEnvelope(output: .json, command: .open(OpenInput())) 86 + let envelope = CommandEnvelope( 87 + output: .json, 88 + command: .open(OpenInput(path: "/Users/test/Projects/Prowl", invocation: "open-subcommand")) 89 + ) 28 90 let response = await handler.handle(envelope: envelope) 29 91 30 92 #expect(response.ok == true) 31 - #expect(response.command == "open") 32 - #expect(response.schemaVersion == "prowl.cli.open.v1") 33 - #expect(!selectCalled) 34 - #expect(!addCalled) 93 + #expect(selectedID == "Prowl:/Users/test/Projects/Prowl") 35 94 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 - } 95 + let data = try #require(response.data) 96 + let payload = try data.decode(as: OpenCommandData.self) 97 + #expect(payload.resolution == "exact-root") 98 + #expect(payload.invocation == "open-subcommand") 99 + #expect(payload.requestedPath == "/Users/test/Projects/Prowl") 100 + #expect(payload.resolvedPath == "/Users/test/Projects/Prowl") 101 + #expect(payload.createdTab == false) 102 + #expect(payload.broughtToFront == true) 103 + 104 + let target = try #require(payload.target) 105 + #expect(target.worktree.id == "Prowl:/Users/test/Projects/Prowl") 106 + #expect(target.worktree.name == "Prowl") 107 + #expect(target.worktree.kind == "git") 108 + #expect(target.tab?.id == "0E2A7C03-9C01-4BC1-9327-6C1C7B629A52") 109 + #expect(target.pane?.id == "0FB4DDB4-A797-4315-A00E-8AAFB32BFC95") 41 110 } 42 111 43 - // MARK: - Known worktree 112 + // MARK: - Inside root 44 113 45 114 @MainActor 46 - @Test func openKnownWorktreeSelectsAndReturnsPayload() async throws { 115 + @Test func openInsideRootResolvesToParentWorktree() async throws { 47 116 var selectedID: String? 48 117 49 - let handler = OpenCommandHandler( 118 + let handler = makeHandler( 50 119 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" 120 + OpenResolverResult( 121 + resolution: .insideRoot, 122 + worktreeID: "Prowl:/Users/test/Projects/Prowl", 123 + worktreeName: "Prowl", 124 + worktreePath: "/Users/test/Projects/Prowl", 125 + rootPath: "/Users/test/Projects/Prowl", 126 + worktreeKind: "git", 127 + resolvedPath: "/Users/test/Projects/Prowl/supacode" 56 128 ) 57 129 }, 58 - selectWorktree: { id in selectedID = id }, 59 - addAndOpen: { _ in } 130 + selectWorktree: { id in selectedID = id } 60 131 ) 61 132 62 133 let envelope = CommandEnvelope( 63 - output: .text, 64 - command: .open(OpenInput(path: "/Users/test/Projects/Prowl")) 134 + output: .json, 135 + command: .open(OpenInput(path: "/Users/test/Projects/Prowl/supacode", invocation: "implicit-open")) 65 136 ) 66 137 let response = await handler.handle(envelope: envelope) 67 138 68 139 #expect(response.ok == true) 69 140 #expect(selectedID == "Prowl:/Users/test/Projects/Prowl") 70 141 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 - } 142 + let data = try #require(response.data) 143 + let payload = try data.decode(as: OpenCommandData.self) 144 + #expect(payload.resolution == "inside-root") 145 + #expect(payload.invocation == "implicit-open") 146 + #expect(payload.requestedPath == "/Users/test/Projects/Prowl/supacode") 147 + #expect(payload.resolvedPath == "/Users/test/Projects/Prowl/supacode") 148 + #expect(payload.createdTab == true) 78 149 } 79 150 80 - // MARK: - Unknown path triggers addAndOpen 151 + // MARK: - New root 81 152 82 153 @MainActor 83 - @Test func openUnknownPathCallsAddAndOpen() async throws { 154 + @Test func openNewRootCallsAddAndOpen() async throws { 84 155 var addedURL: URL? 85 156 86 - let handler = OpenCommandHandler( 87 - resolver: { _ in .unknownPath("/Users/test/NewProject") }, 88 - selectWorktree: { _ in }, 157 + let handler = makeHandler( 158 + resolver: { _ in 159 + OpenResolverResult( 160 + resolution: .newRoot, 161 + worktreeID: nil, worktreeName: nil, 162 + worktreePath: nil, rootPath: nil, 163 + worktreeKind: nil, resolvedPath: "/Users/test/NewProject" 164 + ) 165 + }, 89 166 addAndOpen: { url in addedURL = url } 90 167 ) 91 168 ··· 98 175 #expect(response.ok == true) 99 176 #expect(addedURL?.path == "/Users/test/NewProject") 100 177 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 - } 178 + let data = try #require(response.data) 179 + let payload = try data.decode(as: OpenCommandData.self) 180 + #expect(payload.resolution == "new-root") 181 + #expect(payload.requestedPath == "/Users/test/NewProject") 182 + #expect(payload.createdTab == true) 183 + #expect(payload.target == nil) 107 184 } 108 185 109 - // MARK: - Router dispatches to injected open handler 186 + // MARK: - Router integration 110 187 111 188 @MainActor 112 - @Test func routerUsesInjectedOpenHandler() async { 113 - let handler = OpenCommandHandler( 114 - resolver: { _ in .bringToFront }, 115 - selectWorktree: { _ in }, 116 - addAndOpen: { _ in } 117 - ) 118 - 189 + @Test func routerUsesInjectedOpenHandler() async throws { 190 + let handler = makeHandler() 119 191 let router = CLICommandRouter(openHandler: handler) 120 192 let envelope = CommandEnvelope(output: .json, command: .open(OpenInput())) 121 193 let response = await router.route(envelope) ··· 129 201 130 202 @MainActor 131 203 @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) 204 + let handler = makeHandler() 139 205 let envelope = CommandEnvelope(output: .json, command: .list(ListInput())) 140 206 let response = await handler.handle(envelope: envelope) 141 207 142 208 #expect(response.ok == false) 143 209 #expect(response.error?.code == "INVALID_ARGUMENT") 210 + } 211 + 212 + // MARK: - Invocation derivation 213 + 214 + @MainActor 215 + @Test func defaultInvocationIsBareWhenNoPath() async throws { 216 + let handler = makeHandler() 217 + let envelope = CommandEnvelope(output: .json, command: .open(OpenInput())) 218 + let response = await handler.handle(envelope: envelope) 219 + let data = try #require(response.data) 220 + let payload = try data.decode(as: OpenCommandData.self) 221 + #expect(payload.invocation == "bare") 222 + } 223 + 224 + @MainActor 225 + @Test func defaultInvocationIsOpenSubcommandWhenPathPresent() async throws { 226 + let handler = makeHandler( 227 + resolver: { _ in 228 + OpenResolverResult( 229 + resolution: .newRoot, 230 + worktreeID: nil, worktreeName: nil, 231 + worktreePath: nil, rootPath: nil, 232 + worktreeKind: nil, resolvedPath: "/tmp/test" 233 + ) 234 + } 235 + ) 236 + let envelope = CommandEnvelope(output: .json, command: .open(OpenInput(path: "/tmp/test"))) 237 + let response = await handler.handle(envelope: envelope) 238 + let data = try #require(response.data) 239 + let payload = try data.decode(as: OpenCommandData.self) 240 + #expect(payload.invocation == "open-subcommand") 241 + } 242 + 243 + @MainActor 244 + @Test func explicitInvocationIsPreserved() async throws { 245 + let handler = makeHandler( 246 + resolver: { _ in 247 + OpenResolverResult( 248 + resolution: .newRoot, 249 + worktreeID: nil, worktreeName: nil, 250 + worktreePath: nil, rootPath: nil, 251 + worktreeKind: nil, resolvedPath: "/tmp/test" 252 + ) 253 + } 254 + ) 255 + let envelope = CommandEnvelope( 256 + output: .json, 257 + command: .open(OpenInput(path: "/tmp/test", invocation: "implicit-open")) 258 + ) 259 + let response = await handler.handle(envelope: envelope) 260 + let data = try #require(response.data) 261 + let payload = try data.decode(as: OpenCommandData.self) 262 + #expect(payload.invocation == "implicit-open") 144 263 } 145 264 }