native macOS codings agent orchestrator
6
fork

Configure Feed

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

Merge pull request #150 from onevcat/feature/cli-auto-target

feat(cli): add auto-target resolution for selector commands

authored by

Wei Wang and committed by
GitHub
d6766836 292f0247

+266 -41
+4 -1
ProwlCLI/Commands/FocusCommand.swift
··· 12 12 @OptionGroup var selector: SelectorOptions 13 13 @OptionGroup var options: GlobalOptions 14 14 15 + @Argument(help: "Target pane/tab UUID or worktree id/name/path (auto-resolved).") 16 + var target: String? 17 + 15 18 mutating func run() throws { 16 19 try CLIExecution.run(command: "focus", output: options.outputMode, colorEnabled: options.colorEnabled) { 17 - let sel = try selector.resolve() 20 + let sel = try selector.resolve(positionalTarget: target) 18 21 let envelope = CommandEnvelope( 19 22 output: options.outputMode, 20 23 command: .focus(FocusInput(selector: sel))
+32 -6
ProwlCLI/Commands/KeyCommand.swift
··· 7 7 struct KeyCommand: ParsableCommand { 8 8 static let configuration = CommandConfiguration( 9 9 commandName: "key", 10 - abstract: "Send a key event to a terminal pane." 10 + abstract: "Send a key event to a terminal pane.", 11 + discussion: """ 12 + With one positional argument, the key is sent to the current pane. 13 + With two positional arguments, the first is the target (auto-resolved) and 14 + the second is the key token. 15 + """ 11 16 ) 12 17 13 18 @OptionGroup var selector: SelectorOptions ··· 16 21 @Option(name: .long, help: "Number of times to repeat the key (1-100).") 17 22 var `repeat`: Int = 1 18 23 19 - @Argument(help: "Key token (e.g. enter, esc, tab, ctrl-c, up, down).") 20 - var token: String 24 + @Argument( 25 + help: """ 26 + Key token, or target followed by key token. \ 27 + One argument: key token sent to current pane. \ 28 + Two arguments: first is target (auto-resolved), second is key token. 29 + """ 30 + ) 31 + var args: [String] = [] 21 32 22 33 mutating func run() throws { 23 34 try CLIExecution.run(command: "key", output: options.outputMode, colorEnabled: options.colorEnabled) { 24 - let sel = try selector.resolve() 35 + // Parse positional args: 1 = token, 2 = target + token 36 + let positionalTarget: String? 37 + let rawToken: String 38 + switch args.count { 39 + case 1: 40 + positionalTarget = nil 41 + rawToken = args[0].trimmingCharacters(in: .whitespaces) 42 + case 2: 43 + positionalTarget = args[0] 44 + rawToken = args[1].trimmingCharacters(in: .whitespaces) 45 + default: 46 + throw ExitError( 47 + code: CLIErrorCode.invalidArgument, 48 + message: "Expected 1 or 2 positional arguments (optional target and key token), got \(args.count)." 49 + ) 50 + } 51 + 52 + let sel = try selector.resolve(positionalTarget: positionalTarget) 25 53 26 54 guard (1...100).contains(self.repeat) else { 27 55 throw ExitError( ··· 29 57 message: "Repeat count must be between 1 and 100, got \(self.repeat)." 30 58 ) 31 59 } 32 - 33 - let rawToken = token.trimmingCharacters(in: .whitespaces) 34 60 35 61 guard let normalized = KeyTokens.normalize(rawToken) else { 36 62 throw ExitError(
+4 -1
ProwlCLI/Commands/ReadCommand.swift
··· 15 15 @Option(name: .long, help: "Number of recent lines to read (omit for snapshot).") 16 16 var last: Int? 17 17 18 + @Argument(help: "Target pane/tab UUID or worktree id/name/path (auto-resolved).") 19 + var target: String? 20 + 18 21 mutating func run() throws { 19 22 try CLIExecution.run(command: "read", output: options.outputMode, colorEnabled: options.colorEnabled) { 20 - let sel = try selector.resolve() 23 + let sel = try selector.resolve(positionalTarget: target) 21 24 22 25 if let n = last, n < 1 { 23 26 throw ExitError(
+16 -2
ProwlCLI/Commands/SelectorOptions.swift
··· 5 5 import ProwlCLIShared 6 6 7 7 struct SelectorOptions: ParsableArguments { 8 + @Option(name: .shortAndLong, help: "Auto-resolve target by pane/tab UUID or worktree id/name/path.") 9 + var target: String? 10 + 8 11 @Option(name: .long, help: "Target worktree by id, name, or path.") 9 12 var worktree: String? 10 13 ··· 16 19 17 20 /// Validate mutual exclusivity and return typed selector. 18 21 func resolve() throws -> TargetSelector { 19 - let provided = [worktree, tab, pane].compactMap { $0 } 22 + let provided = [target, worktree, tab, pane].compactMap { $0 } 20 23 guard provided.count <= 1 else { 21 24 throw ExitError( 22 25 code: CLIErrorCode.invalidArgument, 23 - message: "At most one target selector (--worktree, --tab, --pane) is allowed." 26 + message: "At most one target selector (--target, --worktree, --tab, --pane) is allowed." 24 27 ) 25 28 } 29 + if let a = target { return .auto(a) } 26 30 if let w = worktree { return .worktree(w) } 27 31 if let t = tab { return .tab(t) } 28 32 if let p = pane { return .pane(p) } 29 33 return .none 34 + } 35 + 36 + /// Resolve with an additional auto-target value (from positional argument). 37 + /// The positional target takes effect only when no flag selectors are specified. 38 + func resolve(positionalTarget: String?) throws -> TargetSelector { 39 + let flagSelector = try resolve() 40 + if case .none = flagSelector, let positional = positionalTarget { 41 + return .auto(positional) 42 + } 43 + return flagSelector 30 44 } 31 45 }
+36 -5
ProwlCLI/Commands/SendCommand.swift
··· 18 18 19 19 static let configuration = CommandConfiguration( 20 20 commandName: "send", 21 - abstract: "Send text input to a terminal pane." 21 + abstract: "Send text input to a terminal pane.", 22 + discussion: """ 23 + With one positional argument, it is treated as text sent to the current pane. 24 + With two positional arguments, the first is the target (auto-resolved) and 25 + the second is the text. 26 + """ 22 27 ) 23 28 24 29 @OptionGroup var selector: SelectorOptions ··· 36 41 @Option(name: .long, help: "Maximum seconds to wait for completion (1–300, default: 30).") 37 42 var timeout: Int? 38 43 39 - @Argument(help: "Text to send. Alternatively pipe via stdin.") 40 - var text: String? 44 + @Argument( 45 + help: """ 46 + Text to send, or target followed by text. \ 47 + One argument: text sent to current pane. \ 48 + Two arguments: first is target (auto-resolved), second is text. 49 + """ 50 + ) 51 + var args: [String] = [] 41 52 42 53 mutating func run() throws { 43 54 try CLIExecution.run(command: "send", output: options.outputMode, colorEnabled: options.colorEnabled) { 44 - let sel = try selector.resolve() 55 + // Parse positional args: 0 = stdin, 1 = text, 2 = target + text 56 + let positionalTarget: String? 57 + let positionalText: String? 58 + switch args.count { 59 + case 0: 60 + positionalTarget = nil 61 + positionalText = nil 62 + case 1: 63 + positionalTarget = nil 64 + positionalText = args[0] 65 + case 2: 66 + positionalTarget = args[0] 67 + positionalText = args[1] 68 + default: 69 + throw ExitError( 70 + code: CLIErrorCode.invalidArgument, 71 + message: "Expected at most 2 positional arguments (target and text), got \(args.count)." 72 + ) 73 + } 74 + 75 + let sel = try selector.resolve(positionalTarget: positionalTarget) 45 76 46 77 if let timeout, (timeout < 1 || timeout > 300) { 47 78 throw ExitError( ··· 68 99 let stdinIsPiped = isatty(fileno(stdin)) == 0 && Self.stdinHasData() 69 100 let inputText: String 70 101 let source: InputSource 71 - if let argText = text { 102 + if let argText = positionalText { 72 103 if stdinIsPiped { 73 104 throw ExitError( 74 105 code: CLIErrorCode.invalidArgument,
+62 -22
doc-onevcat/contracts/cli/input.md
··· 77 77 78 78 ### 3.1 Selector flags 79 79 80 - - `--worktree <id|name|path>` 81 - - `--tab <id>` 82 - - `--pane <id>` 80 + - `-t <value>` / `--target <value>` — auto-resolve: try pane UUID → tab UUID → worktree id/name/path. 81 + - `--worktree <id|name|path>` — explicit worktree selector. 82 + - `--tab <id>` — explicit tab UUID selector. 83 + - `--pane <id>` — explicit pane UUID selector. 84 + 85 + ### 3.2 Positional target shorthand 83 86 84 - ### 3.2 Mutual exclusivity (hard rule) 87 + `focus` and `read` accept an optional positional argument as auto-target: 88 + 89 + ```bash 90 + prowl focus <target> 91 + prowl read <target> --last 50 92 + ``` 93 + 94 + `send` and `key` use argument count to disambiguate: 95 + 96 + - `prowl send "text"` — 1 arg → text to current pane. 97 + - `prowl send <target> "text"` — 2 args → auto-target + text. 98 + - `prowl key enter` — 1 arg → key token to current pane. 99 + - `prowl key <target> enter` — 2 args → auto-target + key token. 100 + 101 + Positional targets are ignored when flag selectors (`-t`, `--worktree`, `--tab`, `--pane`) are present. 102 + 103 + ### 3.3 Mutual exclusivity (hard rule) 85 104 86 105 Exactly **zero or one** selector is allowed. 87 106 ··· 91 110 92 111 This is preferred over implicit precedence because it is easier to reason about in scripts. 93 112 94 - ### 3.3 Resolution rules 113 + ### 3.4 Resolution rules 95 114 96 115 - `--pane`: exact pane. 97 116 - `--tab`: current focused pane of target tab. 98 117 - `--worktree`: selected tab + focused pane in target worktree. 118 + - `-t` / `--target` / positional: auto-resolve in order pane → tab → worktree. 99 119 - none: currently focused pane in current context. 100 120 101 121 If required context does not exist: ··· 166 186 ### Grammar 167 187 168 188 ```bash 169 - prowl focus [--worktree <...> | --tab <...> | --pane <...>] [--json] 189 + prowl focus [<target>] [--json] 190 + prowl focus [-t <...> | --worktree <...> | --tab <...> | --pane <...>] [--json] 170 191 ``` 171 192 172 193 ### Rules 173 194 174 - - Selectors are optional; no selector means “focus current target and bring app front”. 195 + - Optional positional `<target>` is auto-resolved (pane → tab → worktree). 196 + - Flag selectors override positional target. 197 + - No selector means “focus current target and bring app front”. 175 198 - More than one selector is invalid. 176 199 177 200 ## 5.4 `send` ··· 179 202 ### Grammar 180 203 181 204 ```bash 182 - prowl send [selector] [--no-enter] [--no-wait] [--timeout <seconds>] [--json] [<text>] 183 - # or 184 - printf '...' | prowl send [selector] [--no-enter] [--no-wait] [--timeout <seconds>] [--json] 205 + prowl send [flags] <text> 206 + prowl send [flags] <target> <text> 207 + printf '...' | prowl send [flags] 208 + printf '...' | prowl send [flags] -t <target> 185 209 ``` 186 210 211 + Where `[flags]` includes `[--no-enter] [--no-wait] [--capture] [--timeout <seconds>] [--json]` and optional selector flags (`-t`, `--worktree`, `--tab`, `--pane`). 212 + 187 213 ### Rules 188 214 189 - - Input source is exactly one of: 190 - - positional `<text>` (`argv`) 191 - - stdin (`stdin`) 192 - - Both provided simultaneously: `INVALID_ARGUMENT`. 193 - - Neither provided (or empty stdin): `EMPTY_INPUT`. 215 + - Positional argument count determines interpretation: 216 + - 0 args: read text from stdin, send to current pane. 217 + - 1 arg: text to current pane. 218 + - 2 args: first is auto-resolved target, second is text. 219 + - Flag selector (`-t`, `--worktree`, `--tab`, `--pane`) overrides positional target. 220 + - Input source is exactly one of positional text or stdin. Both: `INVALID_ARGUMENT`. Neither: `EMPTY_INPUT`. 194 221 - Default sends trailing Enter; `--no-enter` disables it. 195 222 - Default waits for command completion (requires shell integration); `--no-wait` disables it and returns immediately after delivery. 196 223 - `--timeout <seconds>` sets the maximum wait duration (default: 30, range: 1–300). Ignored when `--no-wait` is used. ··· 201 228 ### Grammar 202 229 203 230 ```bash 204 - prowl key [selector] <token> [--repeat <n>] [--json] 231 + prowl key [flags] <token> 232 + prowl key [flags] <target> <token> 205 233 ``` 206 234 235 + Where `[flags]` includes `[--repeat <n>] [--json]` and optional selector flags (`-t`, `--worktree`, `--tab`, `--pane`). 236 + 207 237 ### Rules 208 238 209 - - Exactly one positional `<token>` required. 239 + - Positional argument count determines interpretation: 240 + - 1 arg: key token to current pane. 241 + - 2 args: first is auto-resolved target, second is key token. 242 + - Flag selector overrides positional target. 243 + - Exactly one key token required. 210 244 - Token parsing is case-insensitive; canonical output token is lowercase kebab-case. 211 245 - Alias normalization follows `key.md`. 212 246 - `--repeat` default is `1`, range `1...100`. ··· 217 251 ### Grammar 218 252 219 253 ```bash 220 - prowl read [selector] [--json] 221 - prowl read [selector] --last <n> [--json] 254 + prowl read [<target>] [--last <n>] [--json] 255 + prowl read [-t <...> | --worktree <...> | --tab <...> | --pane <...>] [--last <n>] [--json] 222 256 ``` 223 257 224 258 ### Rules ··· 279 313 ```bash 280 314 prowl . 281 315 prowl open ~/Projects/Prowl 282 - prowl focus --pane 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 --json 316 + prowl focus 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 # auto-resolve pane UUID 317 + prowl focus --pane 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 # explicit pane 318 + prowl focus main # auto-resolve worktree name 319 + prowl send "echo hello" # text to current pane 320 + prowl send 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 "echo hi" # target + text 283 321 printf 'git status' | prowl send --worktree Prowl --json 284 - prowl key --pane 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 return --repeat 2 --json 285 - prowl read --tab 2FC00CF0-3974-4E1B-BEF8-7A08A8E3B7C0 --last 200 --json 322 + prowl key enter # key to current pane 323 + prowl key 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 ctrl-c # target + key 324 + prowl read 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 --last 200 # positional target + flag 286 325 ``` 287 326 288 327 Invalid: 289 328 290 329 ```bash 291 330 prowl focus --pane <id> --tab <id> # multiple selectors 331 + prowl focus --pane <id> <positional> # flag + positional (flag wins, positional ignored) 292 332 prowl send "echo hi" < /tmp/input.txt # two input sources 293 333 prowl key --repeat 0 enter # repeat out of range 294 334 prowl list --pane <id> # list does not accept selector
+1 -1
doc-onevcat/contracts/cli/schema.md
··· 349 349 "properties": { 350 350 "selector": { 351 351 "type": "string", 352 - "enum": ["worktree", "tab", "pane", "current"] 352 + "enum": ["worktree", "tab", "pane", "auto", "current"] 353 353 }, 354 354 "value": { 355 355 "type": ["string", "null"]
+17 -3
supacode/CLIService/FocusCommandHandler.swift
··· 62 62 } 63 63 64 64 let requested = makeRequestedTarget(from: input.selector) 65 - let resolvedVia = makeResolvedVia(from: input.selector) 66 65 67 66 // Resolve requested selector. 68 67 let requestedTarget: FocusResolvedTarget ··· 99 98 100 99 let payload = FocusCommandPayload( 101 100 requested: requested, 102 - resolvedVia: resolvedVia, 101 + resolvedVia: makeResolvedVia(from: input.selector, requestedTarget: requestedTarget), 103 102 broughtToFront: true, 104 103 target: makePayloadTarget(from: finalTarget) 105 104 ) ··· 124 123 return FocusRequestedTarget(selector: .tab, value: value) 125 124 case .pane(let value): 126 125 return FocusRequestedTarget(selector: .pane, value: value) 126 + case .auto(let value): 127 + return FocusRequestedTarget(selector: .auto, value: value) 127 128 case .none: 128 129 return FocusRequestedTarget(selector: .current, value: nil) 129 130 } 130 131 } 131 132 132 - private func makeResolvedVia(from selector: TargetSelector) -> FocusResolvedVia { 133 + private func makeResolvedVia( 134 + from selector: TargetSelector, 135 + requestedTarget: FocusResolvedTarget 136 + ) -> FocusResolvedVia { 133 137 switch selector { 134 138 case .worktree: 135 139 return .worktree ··· 137 141 return .tab 138 142 case .pane, .none: 139 143 return .pane 144 + case .auto(let value): 145 + if let uuid = UUID(uuidString: value) { 146 + if uuid == requestedTarget.paneID { 147 + return .pane 148 + } 149 + if uuid == requestedTarget.tabID { 150 + return .tab 151 + } 152 + } 153 + return .worktree 140 154 } 141 155 } 142 156
+1
supacode/CLIService/Shared/FocusCommandPayload.swift
··· 43 43 case worktree 44 44 case tab 45 45 case pane 46 + case auto 46 47 case current 47 48 } 48 49
+1
supacode/CLIService/Shared/TargetSelector.swift
··· 10 10 case worktree(String) 11 11 case tab(String) 12 12 case pane(String) 13 + case auto(String) 13 14 }
+24
supacode/CLIService/TargetResolver.swift
··· 50 50 return resolveTab(value, snapshot) 51 51 case .pane(let value): 52 52 return resolvePane(value, snapshot) 53 + case .auto(let value): 54 + return resolveAuto(value, snapshot) 53 55 } 54 56 } 55 57 ··· 145 147 } 146 148 } 147 149 return .failure(.notFound("Pane '\(value)' not found.")) 150 + } 151 + 152 + // MARK: - .auto: try pane → tab → worktree 153 + 154 + private func resolveAuto( 155 + _ value: String, 156 + _ snapshot: TargetResolutionSnapshot 157 + ) -> Result<ResolvedTarget, TargetResolverError> { 158 + // Try as pane UUID first (most specific) 159 + if UUID(uuidString: value) != nil { 160 + if case .success(let target) = resolvePane(value, snapshot) { 161 + return .success(target) 162 + } 163 + if case .success(let target) = resolveTab(value, snapshot) { 164 + return .success(target) 165 + } 166 + } 167 + // Fall back to worktree (id / name / path) 168 + if case .success(let target) = resolveWorktree(value, snapshot) { 169 + return .success(target) 170 + } 171 + return .failure(.notFound("Target '\(value)' not found as pane, tab, or worktree.")) 148 172 } 149 173 150 174 // MARK: - Helpers
+68
supacodeTests/CLIFocusCommandHandlerTests.swift
··· 101 101 #expect(payload.resolvedVia == .pane) 102 102 } 103 103 104 + @Test func autoSelectorWithTabUUIDResolvesViaTab() throws { 105 + var resolveNoneCount = 0 106 + let handler = FocusCommandHandler( 107 + resolveProvider: { selector in 108 + switch selector { 109 + case .auto(let value): 110 + #expect(value == Self.tabID.uuidString) 111 + return .success(Self.makeTarget(tabSelected: true, paneFocused: true)) 112 + case .none: 113 + resolveNoneCount += 1 114 + return .success(Self.makeTarget(tabSelected: true, paneFocused: true)) 115 + default: 116 + return .failure(.notFound("Unexpected selector")) 117 + } 118 + }, 119 + focusPerformer: { _ in true }, 120 + bringToFront: { true } 121 + ) 122 + 123 + let response = handler.handle( 124 + envelope: CommandEnvelope( 125 + output: .json, 126 + command: .focus(FocusInput(selector: .auto(Self.tabID.uuidString))) 127 + ) 128 + ) 129 + 130 + #expect(response.ok) 131 + #expect(resolveNoneCount == 1) 132 + let payload = try #require(try response.data?.decode(as: FocusCommandPayload.self)) 133 + #expect(payload.requested.selector == .auto) 134 + #expect(payload.requested.value == Self.tabID.uuidString) 135 + #expect(payload.resolvedVia == .tab) 136 + } 137 + 138 + @Test func autoSelectorWithWorktreeNameResolvesViaWorktree() throws { 139 + var resolveNoneCount = 0 140 + let handler = FocusCommandHandler( 141 + resolveProvider: { selector in 142 + switch selector { 143 + case .auto(let value): 144 + #expect(value == "Prowl") 145 + return .success(Self.makeTarget(tabSelected: true, paneFocused: true)) 146 + case .none: 147 + resolveNoneCount += 1 148 + return .success(Self.makeTarget(tabSelected: true, paneFocused: true)) 149 + default: 150 + return .failure(.notFound("Unexpected selector")) 151 + } 152 + }, 153 + focusPerformer: { _ in true }, 154 + bringToFront: { true } 155 + ) 156 + 157 + let response = handler.handle( 158 + envelope: CommandEnvelope( 159 + output: .json, 160 + command: .focus(FocusInput(selector: .auto("Prowl"))) 161 + ) 162 + ) 163 + 164 + #expect(response.ok) 165 + #expect(resolveNoneCount == 1) 166 + let payload = try #require(try response.data?.decode(as: FocusCommandPayload.self)) 167 + #expect(payload.requested.selector == .auto) 168 + #expect(payload.requested.value == "Prowl") 169 + #expect(payload.resolvedVia == .worktree) 170 + } 171 + 104 172 @Test func targetNotFoundMapsToContractCode() { 105 173 let handler = FocusCommandHandler( 106 174 resolveProvider: { _ in .failure(.notFound("Pane missing")) },