diff --git a/Package.resolved b/Package.resolved index 6b684d44..77f579ad 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "6a83eb824bb8cc817831017cf72468c234a2d99470f15d658e3a30b8bddccd4c", + "originHash" : "f754db8301c9da1c2526e397dc4b099bee8a678f1e853a4f2b121df3ef3fd4b3", "pins" : [ { "identity" : "rainbow", @@ -18,6 +18,33 @@ "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", "version" : "1.7.1" } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-json-schema", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ajevans99/swift-json-schema", + "state" : { + "revision" : "f299eb1cce78b2dd736d9a390ec0779d28678416", + "version" : "0.13.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 68750949..2f282580 100644 --- a/Package.swift +++ b/Package.swift @@ -19,6 +19,7 @@ let package = Package( ], dependencies: [ .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), + .package(url: "https://github.com/ajevans99/swift-json-schema", from: "0.13.1"), .package(url: "https://github.com/onevcat/Rainbow", from: "4.0.0"), ], targets: [ @@ -35,11 +36,18 @@ let package = Package( ], path: "ProwlCLI" ), + .target( + name: "ProwlCLIContracts", + path: "ProwlCLIContracts", + resources: [.process("Resources")] + ), .testTarget( name: "ProwlCLITests", dependencies: [ + "ProwlCLIContracts", "ProwlCLIShared", "prowl", + .product(name: "JSONSchema", package: "swift-json-schema"), ], path: "ProwlCLITests" ), diff --git a/ProwlCLI/Commands/CloseCommand.swift b/ProwlCLI/Commands/CloseCommand.swift new file mode 100644 index 00000000..71d3f4e5 --- /dev/null +++ b/ProwlCLI/Commands/CloseCommand.swift @@ -0,0 +1,30 @@ +// ProwlCLI/Commands/CloseCommand.swift + +import ArgumentParser +import ProwlCLIShared + +struct CloseCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "close", + abstract: "Close a terminal pane or tab." + ) + + @Argument(help: "Pane/tab UUID or prefixed handle (pN or tN).") + var target: String? + + @OptionGroup var selector: LifecycleSelectorOptions + @OptionGroup var options: GlobalOptions + + @Flag(name: .long, help: "Close without prompting for protected panes.") + var force = false + + mutating func run() throws { + try CLIExecution.run(command: "close", output: options.outputMode, colorEnabled: options.colorEnabled) { + let envelope = CommandEnvelope( + output: options.outputMode, + command: .close(CloseInput(selector: try selector.resolveTerminalTarget(positionalTarget: target), force: force)) + ) + try CLIRunner.execute(envelope) + } + } +} diff --git a/ProwlCLI/Commands/CreateCommand.swift b/ProwlCLI/Commands/CreateCommand.swift new file mode 100644 index 00000000..4eecab4c --- /dev/null +++ b/ProwlCLI/Commands/CreateCommand.swift @@ -0,0 +1,65 @@ +// ProwlCLI/Commands/CreateCommand.swift + +import ArgumentParser +import Foundation +import ProwlCLIShared + +struct CreateCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a terminal resource.", + subcommands: [ + CreateTabCommand.self, + ] + ) +} + +struct CreateTabCommand: ParsableCommand { + static let configuration = CommandConfiguration( + commandName: "tab", + abstract: "Create a new terminal tab." + ) + + @Argument(help: "Worktree id, name, or path.") + var worktree: String? + + @OptionGroup var selector: LifecycleSelectorOptions + @OptionGroup var options: GlobalOptions + + @Option(name: .long, help: "Working directory for the new tab.") + var path: String? + + mutating func run() throws { + try CLIExecution.run(command: "create", output: options.outputMode, colorEnabled: options.colorEnabled) { + let envelope = CommandEnvelope( + output: options.outputMode, + command: .create( + CreateInput( + resource: .tab, + selector: try selector.resolveWorktree(positionalTarget: worktree), + path: normalizedPath() + ) + ) + ) + try CLIRunner.execute(envelope) + } + } + + private func normalizedPath() -> String? { + guard let path else { return nil } + return URL(fileURLWithPath: path, isDirectory: true) + .standardizedFileURL + .path(percentEncoded: false) + .trimmingTrailingSlash() + } +} + +private extension String { + func trimmingTrailingSlash() -> String { + var value = self + while value.count > 1, value.hasSuffix("/") { + value.removeLast() + } + return value + } +} diff --git a/ProwlCLI/Commands/DeprecationWarning.swift b/ProwlCLI/Commands/DeprecationWarning.swift new file mode 100644 index 00000000..d3170730 --- /dev/null +++ b/ProwlCLI/Commands/DeprecationWarning.swift @@ -0,0 +1,8 @@ +// ProwlCLI/Commands/DeprecationWarning.swift + +import Foundation + +func emitDeprecationWarning(command: String, replacement: String) { + let message = "warning: `prowl \(command)` is deprecated; use `prowl \(replacement)`.\n" + FileHandle.standardError.write(Data(message.utf8)) +} diff --git a/ProwlCLI/Commands/LifecycleSelectorOptions.swift b/ProwlCLI/Commands/LifecycleSelectorOptions.swift new file mode 100644 index 00000000..0fcd55e8 --- /dev/null +++ b/ProwlCLI/Commands/LifecycleSelectorOptions.swift @@ -0,0 +1,118 @@ +// ProwlCLI/Commands/LifecycleSelectorOptions.swift +// Typed target selectors for action-first lifecycle commands. + +import ArgumentParser +import Foundation +import ProwlCLIShared + +struct LifecycleSelectorOptions: ParsableArguments { + @Option(name: .long, help: "Target worktree by id, name, or path.") + var worktree: String? + + @Option(name: .long, help: "Target tab by UUID or short handle (for example, t4).") + var tab: String? + + @Option(name: .long, help: "Target pane by UUID or short handle (for example, p3).") + var pane: String? + + func resolveWorktree(positionalTarget: String?) throws -> TargetSelector { + try resolve(positionalTarget: positionalTarget, acceptedSelector: .worktree) + } + + func resolveTerminalTarget(positionalTarget: String?) throws -> TargetSelector { + let provided = [worktree, tab, pane].compactMap { $0 } + guard provided.count <= 1 else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "At most one target selector (--worktree, --tab, --pane) is allowed." + ) + } + + if let positionalTarget { + guard provided.isEmpty else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "Use either a positional target or one selector flag (--tab, --pane)." + ) + } + guard isTerminalReference(positionalTarget) else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "close requires a pane/tab UUID or prefixed handle (pN or tN)." + ) + } + return .auto(positionalTarget) + } + + if worktree != nil { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "close accepts only --tab or --pane, not --worktree." + ) + } + if let tab { return .tab(tab) } + if let pane { return .pane(pane) } + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "close requires an explicit pane or tab target." + ) + } + + private enum AcceptedSelector { + case worktree + } + + private func resolve(positionalTarget: String?, acceptedSelector: AcceptedSelector) throws -> TargetSelector { + let provided = [worktree, tab, pane].compactMap { $0 } + guard provided.count <= 1 else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "At most one target selector (--worktree, --tab, --pane) is allowed." + ) + } + + if let positionalTarget { + guard provided.isEmpty else { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "Use either a positional target or one selector flag (--worktree)." + ) + } + return .worktree(positionalTarget) + } + + switch acceptedSelector { + case .worktree: + if let worktree { return .worktree(worktree) } + if tab != nil || pane != nil { + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "create tab accepts only a worktree target." + ) + } + throw ExitError( + code: CLIErrorCode.invalidArgument, + message: "create tab requires an explicit worktree target." + ) + } + } + + private func isTerminalReference(_ value: String) -> Bool { + UUID(uuidString: value) != nil || isPrefixedHandle(value, prefix: "p") || isPrefixedHandle(value, prefix: "t") + } + + private func isPrefixedHandle(_ value: String, prefix: Character) -> Bool { + let normalized = value.lowercased() + guard normalized.first == prefix else { return false } + let digits = normalized.dropFirst() + guard + !digits.isEmpty, + digits.allSatisfy({ $0.isASCII && $0.isNumber }), + let handle = Int(digits), + handle > 0 + else { + return false + } + return true + } +} diff --git a/ProwlCLI/Commands/PaneCommand.swift b/ProwlCLI/Commands/PaneCommand.swift index 4fe0a065..2b540ef5 100644 --- a/ProwlCLI/Commands/PaneCommand.swift +++ b/ProwlCLI/Commands/PaneCommand.swift @@ -6,7 +6,7 @@ import ProwlCLIShared struct PaneCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "pane", - abstract: "Manage terminal panes.", + abstract: "[Deprecated] Manage terminal panes. Use `prowl close`.", subcommands: [ PaneCloseCommand.self, ] @@ -16,7 +16,7 @@ struct PaneCommand: ParsableCommand { struct PaneCloseCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "close", - abstract: "Close a terminal pane." + abstract: "[Deprecated] Close a terminal pane. Use `prowl close `." ) @OptionGroup var selector: SelectorOptions @@ -26,6 +26,7 @@ struct PaneCloseCommand: ParsableCommand { var force = false mutating func run() throws { + emitDeprecationWarning(command: "pane close", replacement: "close ") try CLIExecution.run(command: "pane", output: options.outputMode, colorEnabled: options.colorEnabled) { let resolvedSelector = try selector.resolve() guard !resolvedSelector.isNone else { diff --git a/ProwlCLI/Commands/ProwlCommand.swift b/ProwlCLI/Commands/ProwlCommand.swift index 26350a13..eab5ad4a 100644 --- a/ProwlCLI/Commands/ProwlCommand.swift +++ b/ProwlCLI/Commands/ProwlCommand.swift @@ -18,6 +18,8 @@ struct ProwlCommand: ParsableCommand { SendCommand.self, KeyCommand.self, ReadCommand.self, + CreateCommand.self, + CloseCommand.self, TabCommand.self, PaneCommand.self, HandoffCommand.self, diff --git a/ProwlCLI/Commands/TabCommand.swift b/ProwlCLI/Commands/TabCommand.swift index 01344502..ab9a4a70 100644 --- a/ProwlCLI/Commands/TabCommand.swift +++ b/ProwlCLI/Commands/TabCommand.swift @@ -7,7 +7,7 @@ import ProwlCLIShared struct TabCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "tab", - abstract: "Create or close terminal tabs.", + abstract: "[Deprecated] Create or close terminal tabs. Use `prowl create tab` or `prowl close`.", subcommands: [ TabCreateCommand.self, TabCloseCommand.self, @@ -18,7 +18,7 @@ struct TabCommand: ParsableCommand { struct TabCreateCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "create", - abstract: "Create a new terminal tab." + abstract: "[Deprecated] Create a new terminal tab. Use `prowl create tab`." ) @OptionGroup var selector: SelectorOptions @@ -28,6 +28,7 @@ struct TabCreateCommand: ParsableCommand { var path: String? mutating func run() throws { + emitDeprecationWarning(command: "tab create", replacement: "create tab ") try CLIExecution.run(command: "tab", output: options.outputMode, colorEnabled: options.colorEnabled) { let envelope = CommandEnvelope( output: options.outputMode, @@ -49,7 +50,7 @@ struct TabCreateCommand: ParsableCommand { struct TabCloseCommand: ParsableCommand { static let configuration = CommandConfiguration( commandName: "close", - abstract: "Close a terminal tab." + abstract: "[Deprecated] Close a terminal tab. Use `prowl close `." ) @OptionGroup var selector: SelectorOptions @@ -59,6 +60,7 @@ struct TabCloseCommand: ParsableCommand { var force = false mutating func run() throws { + emitDeprecationWarning(command: "tab close", replacement: "close ") try CLIExecution.run(command: "tab", output: options.outputMode, colorEnabled: options.colorEnabled) { let resolvedSelector = try selector.resolve() guard !resolvedSelector.isNone else { diff --git a/ProwlCLI/Output/OutputRenderer.swift b/ProwlCLI/Output/OutputRenderer.swift index 54a958bf..ed1a1800 100644 --- a/ProwlCLI/Output/OutputRenderer.swift +++ b/ProwlCLI/Output/OutputRenderer.swift @@ -100,6 +100,14 @@ enum OutputRenderer { return } + if response.command == "create" || response.command == "close", + let data = response.data, + let payload = try? data.decode(as: LifecycleCommandPayload.self) + { + print(renderLifecycle(payload, command: response.command)) + return + } + if response.command == "tab", let data = response.data, let payload = try? data.decode(as: TabCommandPayload.self) @@ -338,6 +346,23 @@ enum OutputRenderer { return lines.joined(separator: "\n") } + private static func renderLifecycle(_ payload: LifecycleCommandPayload, command: String) -> String { + let wt = payload.target.worktree + let tab = payload.target.tab + let pane = payload.target.pane + let projectName = projectName(from: wt.path) + let verb = command == "create" ? "Created" : "Closed" + + switch payload.resource { + case .tab: + return "\(verb) tab \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(tab.title.yellow)" + + " \(tab.id.dim)\n \("pane:".dim) \(pane.title.green) \(pane.id.dim)" + case .pane: + return "\(verb) pane \(projectName.cyan.bold)\(":".dim)\(wt.name) → \(pane.title.green)" + + " \(pane.id.dim)" + } + } + private static func renderHandoff(_ payload: HandoffCommandPayload) -> String { var lines: [String] = [] diff --git a/ProwlCLIContracts/ContractBundle.swift b/ProwlCLIContracts/ContractBundle.swift new file mode 100644 index 00000000..5750e5f3 --- /dev/null +++ b/ProwlCLIContracts/ContractBundle.swift @@ -0,0 +1,14 @@ +import Foundation + +public enum ProwlCLIContractBundle { + public static let schemaData: Data = { + guard let url = Bundle.module.url(forResource: "cli-output-schema", withExtension: "json") else { + preconditionFailure("Missing Prowl CLI JSON Schema bundle resource.") + } + do { + return try Data(contentsOf: url) + } catch { + preconditionFailure("Failed to load Prowl CLI JSON Schema bundle: \(error)") + } + }() +} diff --git a/ProwlCLIContracts/Resources/cli-output-schema.json b/ProwlCLIContracts/Resources/cli-output-schema.json new file mode 100644 index 00000000..eb8b3c2f --- /dev/null +++ b/ProwlCLIContracts/Resources/cli-output-schema.json @@ -0,0 +1,2032 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://prowl.onev.cat/contracts/cli/v1/schema-bundle.json", + "title": "Prowl CLI Output Contract Schemas", + "description": "Versioned JSON schemas for every Prowl CLI wire response.", + "oneOf": [ + { + "$ref": "#/$defs/openResponse" + }, + { + "$ref": "#/$defs/listResponse" + }, + { + "$ref": "#/$defs/agentsResponse" + }, + { + "$ref": "#/$defs/agentsReadResponse" + }, + { + "$ref": "#/$defs/focusResponse" + }, + { + "$ref": "#/$defs/sendResponse" + }, + { + "$ref": "#/$defs/keyResponse" + }, + { + "$ref": "#/$defs/readResponse" + }, + { + "$ref": "#/$defs/createResponse" + }, + { + "$ref": "#/$defs/closeResponse" + }, + { + "$ref": "#/$defs/tabResponse" + }, + { + "$ref": "#/$defs/paneResponse" + }, + { + "$ref": "#/$defs/handoffResponse" + } + ], + "$defs": { + "error": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "string", + "minLength": 1 + }, + "message": { + "type": "string", + "minLength": 1 + } + } + }, + "worktree": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "name", + "path", + "root_path", + "kind" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "minLength": 1 + }, + "path": { + "type": "string", + "minLength": 1 + }, + "root_path": { + "type": "string", + "minLength": 1 + }, + "kind": { + "enum": [ + "git", + "plain", + "workspace" + ] + } + } + }, + "tab": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "selected" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string" + }, + "selected": { + "type": "boolean" + } + } + }, + "pane": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "cwd", + "focused" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "focused": { + "type": "boolean" + } + } + }, + "target": { + "type": "object", + "additionalProperties": false, + "required": [ + "worktree", + "tab", + "pane" + ], + "properties": { + "worktree": { + "$ref": "#/$defs/worktree" + }, + "tab": { + "$ref": "#/$defs/tab" + }, + "pane": { + "$ref": "#/$defs/pane" + } + } + }, + "lifecycleData": { + "type": "object", + "additionalProperties": false, + "required": [ + "resource", + "target" + ], + "properties": { + "resource": { + "enum": [ + "tab", + "pane" + ] + }, + "target": { + "$ref": "#/$defs/target" + } + } + }, + "tabData": { + "type": "object", + "additionalProperties": false, + "required": [ + "action", + "target" + ], + "properties": { + "action": { + "enum": [ + "create", + "close" + ] + }, + "target": { + "$ref": "#/$defs/target" + } + } + }, + "paneData": { + "type": "object", + "additionalProperties": false, + "required": [ + "action", + "target" + ], + "properties": { + "action": { + "const": "close" + }, + "target": { + "$ref": "#/$defs/target" + } + } + }, + "listTab": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "selected" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "handle": { + "type": "integer", + "minimum": 1 + }, + "title": { + "type": "string" + }, + "selected": { + "type": "boolean" + } + } + }, + "listPane": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "cwd", + "focused" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "handle": { + "type": "integer", + "minimum": 1 + }, + "title": { + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "focused": { + "type": "boolean" + }, + "agent": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + } + } + }, + "listItem": { + "type": "object", + "additionalProperties": false, + "required": [ + "worktree", + "tab", + "pane", + "task" + ], + "properties": { + "worktree": { + "$ref": "#/$defs/worktree" + }, + "tab": { + "$ref": "#/$defs/listTab" + }, + "pane": { + "$ref": "#/$defs/listPane" + }, + "task": { + "type": "object", + "additionalProperties": false, + "required": [ + "status" + ], + "properties": { + "status": { + "anyOf": [ + { + "enum": [ + "running", + "idle" + ] + }, + { + "type": "null" + } + ] + } + } + } + } + }, + "listData": { + "type": "object", + "additionalProperties": false, + "required": [ + "count", + "items" + ], + "properties": { + "count": { + "type": "integer", + "minimum": 0 + }, + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/listItem" + } + } + } + }, + "agentsSession": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "path", + "confidence", + "source" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "path": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "confidence": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string", + "minLength": 1 + } + } + }, + "agentsPane": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "index", + "title", + "cwd", + "focused" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "handle": { + "type": "integer", + "minimum": 1 + }, + "index": { + "type": "integer", + "minimum": 0 + }, + "title": { + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "focused": { + "type": "boolean" + } + } + }, + "agent": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "type", + "name", + "status", + "raw_state", + "last_changed_at", + "project", + "worktree", + "tab", + "pane" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "type": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "status": { + "enum": [ + "blocked", + "working", + "done", + "idle" + ] + }, + "raw_state": { + "type": "string", + "minLength": 1 + }, + "detection_reason": { + "type": "string" + }, + "last_changed_at": { + "type": "string", + "minLength": 1 + }, + "project": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "branch", + "path" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "branch": { + "type": "string" + }, + "path": { + "type": "string", + "minLength": 1 + } + } + }, + "worktree": { + "$ref": "#/$defs/worktree" + }, + "tab": { + "$ref": "#/$defs/tab" + }, + "pane": { + "$ref": "#/$defs/agentsPane" + }, + "session": { + "$ref": "#/$defs/agentsSession" + } + } + }, + "agentsData": { + "type": "object", + "additionalProperties": false, + "required": [ + "count", + "agents" + ], + "properties": { + "count": { + "type": "integer", + "minimum": 0 + }, + "agents": { + "type": "array", + "items": { + "$ref": "#/$defs/agent" + } + } + } + }, + "agentReadSession": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "confidence", + "source" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "confidence": { + "type": "string", + "minLength": 1 + }, + "source": { + "type": "string", + "minLength": 1 + } + } + }, + "agentReadData": { + "type": "object", + "additionalProperties": false, + "required": [ + "output_mode", + "target", + "agent", + "result" + ], + "properties": { + "output_mode": { + "enum": [ + "snapshot", + "result_only" + ] + }, + "target": { + "$ref": "#/$defs/target" + }, + "agent": { + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "status", + "raw_state", + "last_changed_at" + ], + "properties": { + "type": { + "type": "string", + "minLength": 1 + }, + "status": { + "enum": [ + "blocked", + "working", + "done", + "idle" + ] + }, + "raw_state": { + "type": "string", + "minLength": 1 + }, + "detection_reason": { + "type": "string" + }, + "last_changed_at": { + "type": "string", + "minLength": 1 + }, + "session": { + "$ref": "#/$defs/agentReadSession" + } + } + }, + "blocker": { + "type": "object", + "additionalProperties": false, + "required": [ + "text" + ], + "properties": { + "text": { + "type": "string", + "minLength": 1 + } + } + }, + "result": { + "type": "object", + "additionalProperties": false, + "required": [ + "state" + ], + "properties": { + "state": { + "enum": [ + "complete", + "pending", + "unavailable", + "missing", + "incomplete", + "too_large" + ] + }, + "text": { + "type": "string" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + } + }, + "focusData": { + "type": "object", + "additionalProperties": false, + "required": [ + "requested", + "resolved_via", + "brought_to_front", + "target" + ], + "properties": { + "requested": { + "type": "object", + "additionalProperties": false, + "required": [ + "selector", + "value" + ], + "properties": { + "selector": { + "enum": [ + "worktree", + "tab", + "pane", + "auto", + "current" + ] + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + }, + "resolved_via": { + "enum": [ + "worktree", + "tab", + "pane" + ] + }, + "brought_to_front": { + "type": "boolean" + }, + "target": { + "$ref": "#/$defs/target" + } + } + }, + "sendData": { + "type": "object", + "additionalProperties": false, + "required": [ + "target", + "input", + "created_tab" + ], + "properties": { + "target": { + "$ref": "#/$defs/target" + }, + "input": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "characters", + "bytes", + "trailing_enter_sent" + ], + "properties": { + "source": { + "enum": [ + "argv", + "stdin" + ] + }, + "characters": { + "type": "integer", + "minimum": 0 + }, + "bytes": { + "type": "integer", + "minimum": 0 + }, + "trailing_enter_sent": { + "type": "boolean" + } + } + }, + "created_tab": { + "type": "boolean" + }, + "wait": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "exit_code", + "duration_ms" + ], + "properties": { + "exit_code": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "duration_ms": { + "type": "integer", + "minimum": 0 + } + } + }, + { + "type": "null" + } + ] + }, + "capture": { + "type": "object", + "additionalProperties": false, + "required": [ + "text", + "line_count", + "source", + "truncated" + ], + "properties": { + "text": { + "type": "string" + }, + "line_count": { + "type": "integer", + "minimum": 0 + }, + "source": { + "const": "screen_diff" + }, + "truncated": { + "type": "boolean" + } + } + } + } + }, + "keyData": { + "type": "object", + "additionalProperties": false, + "required": [ + "requested", + "key", + "delivery", + "target" + ], + "properties": { + "requested": { + "type": "object", + "additionalProperties": false, + "required": [ + "token", + "repeat" + ], + "properties": { + "token": { + "type": "string", + "minLength": 1 + }, + "repeat": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + } + }, + "key": { + "type": "object", + "additionalProperties": false, + "required": [ + "normalized", + "category" + ], + "properties": { + "normalized": { + "type": "string", + "minLength": 1 + }, + "category": { + "enum": [ + "navigation", + "editing", + "control", + "shortcut", + "function" + ] + } + } + }, + "delivery": { + "type": "object", + "additionalProperties": false, + "required": [ + "attempted", + "delivered", + "mode" + ], + "properties": { + "attempted": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "delivered": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "mode": { + "const": "keyDownUp" + } + } + }, + "target": { + "$ref": "#/$defs/target" + } + } + }, + "readData": { + "type": "object", + "additionalProperties": false, + "required": [ + "target", + "mode", + "source", + "truncated", + "line_count", + "text" + ], + "properties": { + "target": { + "$ref": "#/$defs/target" + }, + "mode": { + "enum": [ + "snapshot", + "last" + ] + }, + "last": { + "anyOf": [ + { + "type": "integer", + "minimum": 1 + }, + { + "type": "null" + } + ] + }, + "source": { + "enum": [ + "screen", + "scrollback", + "mixed", + "detection" + ] + }, + "truncated": { + "type": "boolean" + }, + "line_count": { + "type": "integer", + "minimum": 0 + }, + "text": { + "type": "string" + }, + "stabilized": { + "type": "boolean" + }, + "waited_ms": { + "type": "integer", + "minimum": 0 + }, + "samples": { + "type": "integer", + "minimum": 1 + } + } + }, + "handoffRepo": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "branch", + "is_git", + "changed_file_count", + "insertions", + "deletions" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "is_git": { + "type": "boolean" + }, + "changed_file_count": { + "type": "integer", + "minimum": 0 + }, + "insertions": { + "type": "integer", + "minimum": 0 + }, + "deletions": { + "type": "integer", + "minimum": 0 + } + } + }, + "handoffSession": { + "type": "object", + "additionalProperties": false, + "required": [ + "agent", + "pane_id", + "pane_title", + "source", + "confidence", + "excerpt_path", + "transcript_path" + ], + "properties": { + "agent": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "session_id": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "pane_id": { + "type": "string", + "minLength": 1 + }, + "pane_title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "source": { + "type": "string", + "minLength": 1 + }, + "confidence": { + "type": "string", + "minLength": 1 + }, + "excerpt_path": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "transcript_path": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + } + } + }, + "handoffPane": { + "type": "object", + "additionalProperties": false, + "required": [ + "worktree_id", + "worktree_name", + "tab_id", + "pane_id", + "pane_title" + ], + "properties": { + "worktree_id": { + "type": "string", + "minLength": 1 + }, + "worktree_name": { + "type": "string", + "minLength": 1 + }, + "tab_id": { + "type": "string", + "minLength": 1 + }, + "pane_id": { + "type": "string", + "minLength": 1 + }, + "pane_title": { + "type": "string" + } + } + }, + "handoffData": { + "type": "object", + "additionalProperties": false, + "required": [ + "action", + "artifact_path", + "repos", + "changed_file_count", + "has_briefing" + ], + "properties": { + "action": { + "enum": [ + "save", + "to" + ] + }, + "artifact_path": { + "type": "string", + "minLength": 1 + }, + "outgoing_agent": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "to_agent": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "repos": { + "type": "array", + "items": { + "$ref": "#/$defs/handoffRepo" + } + }, + "changed_file_count": { + "type": "integer", + "minimum": 0 + }, + "archived_path": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "session_context": { + "$ref": "#/$defs/handoffSession" + }, + "briefing": { + "anyOf": [ + { + "enum": [ + "inline", + "fork", + "none", + "failed" + ] + }, + { + "type": "null" + } + ] + }, + "has_briefing": { + "type": "boolean" + }, + "launched_pane": { + "$ref": "#/$defs/handoffPane" + } + } + }, + "openTarget": { + "type": "object", + "additionalProperties": false, + "required": [ + "worktree", + "tab", + "pane" + ], + "properties": { + "worktree": { + "$ref": "#/$defs/worktree" + }, + "tab": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "cwd" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + } + } + }, + "pane": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "title", + "cwd" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "title": { + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + } + } + } + } + }, + "openData": { + "type": "object", + "additionalProperties": false, + "required": [ + "invocation", + "requested_path", + "resolved_path", + "resolution", + "app_launched", + "brought_to_front", + "created_tab", + "target" + ], + "properties": { + "invocation": { + "enum": [ + "bare", + "implicit-open", + "open-subcommand" + ] + }, + "requested_path": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "resolved_path": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "resolution": { + "enum": [ + "no-argument", + "exact-root", + "inside-root", + "new-root" + ] + }, + "app_launched": { + "type": "boolean" + }, + "brought_to_front": { + "type": "boolean" + }, + "created_tab": { + "type": "boolean" + }, + "target": { + "anyOf": [ + { + "$ref": "#/$defs/openTarget" + }, + { + "type": "null" + } + ] + } + } + }, + "openResponse": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "data" + ], + "properties": { + "ok": { + "const": true + }, + "command": { + "const": "open" + }, + "schema_version": { + "const": "prowl.cli.open.v1" + }, + "data": { + "$ref": "#/$defs/openData" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "error" + ], + "properties": { + "ok": { + "const": false + }, + "command": { + "const": "open" + }, + "schema_version": { + "const": "prowl.cli.open.v1" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + ] + }, + "listResponse": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "data" + ], + "properties": { + "ok": { + "const": true + }, + "command": { + "const": "list" + }, + "schema_version": { + "const": "prowl.cli.list.v1" + }, + "data": { + "$ref": "#/$defs/listData" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "error" + ], + "properties": { + "ok": { + "const": false + }, + "command": { + "const": "list" + }, + "schema_version": { + "const": "prowl.cli.list.v1" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + ] + }, + "agentsResponse": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "data" + ], + "properties": { + "ok": { + "const": true + }, + "command": { + "const": "agents" + }, + "schema_version": { + "const": "prowl.cli.agents.v1" + }, + "data": { + "$ref": "#/$defs/agentsData" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "error" + ], + "properties": { + "ok": { + "const": false + }, + "command": { + "const": "agents" + }, + "schema_version": { + "const": "prowl.cli.agents.v1" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + ] + }, + "agentsReadResponse": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "data" + ], + "properties": { + "ok": { + "const": true + }, + "command": { + "const": "agents.read" + }, + "schema_version": { + "const": "prowl.cli.agents.read.v1" + }, + "data": { + "$ref": "#/$defs/agentReadData" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "error" + ], + "properties": { + "ok": { + "const": false + }, + "command": { + "const": "agents.read" + }, + "schema_version": { + "const": "prowl.cli.agents.read.v1" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + ] + }, + "focusResponse": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "data" + ], + "properties": { + "ok": { + "const": true + }, + "command": { + "const": "focus" + }, + "schema_version": { + "const": "prowl.cli.focus.v1" + }, + "data": { + "$ref": "#/$defs/focusData" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "error" + ], + "properties": { + "ok": { + "const": false + }, + "command": { + "const": "focus" + }, + "schema_version": { + "const": "prowl.cli.focus.v1" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + ] + }, + "sendResponse": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "data" + ], + "properties": { + "ok": { + "const": true + }, + "command": { + "const": "send" + }, + "schema_version": { + "const": "prowl.cli.send.v1" + }, + "data": { + "$ref": "#/$defs/sendData" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "error" + ], + "properties": { + "ok": { + "const": false + }, + "command": { + "const": "send" + }, + "schema_version": { + "const": "prowl.cli.send.v1" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + ] + }, + "keyResponse": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "data" + ], + "properties": { + "ok": { + "const": true + }, + "command": { + "const": "key" + }, + "schema_version": { + "const": "prowl.cli.key.v1" + }, + "data": { + "$ref": "#/$defs/keyData" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "error" + ], + "properties": { + "ok": { + "const": false + }, + "command": { + "const": "key" + }, + "schema_version": { + "const": "prowl.cli.key.v1" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + ] + }, + "readResponse": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "data" + ], + "properties": { + "ok": { + "const": true + }, + "command": { + "const": "read" + }, + "schema_version": { + "const": "prowl.cli.read.v1" + }, + "data": { + "$ref": "#/$defs/readData" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "error" + ], + "properties": { + "ok": { + "const": false + }, + "command": { + "const": "read" + }, + "schema_version": { + "const": "prowl.cli.read.v1" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + ] + }, + "createResponse": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "data" + ], + "properties": { + "ok": { + "const": true + }, + "command": { + "const": "create" + }, + "schema_version": { + "const": "prowl.cli.create.v1" + }, + "data": { + "$ref": "#/$defs/lifecycleData" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "error" + ], + "properties": { + "ok": { + "const": false + }, + "command": { + "const": "create" + }, + "schema_version": { + "const": "prowl.cli.create.v1" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + ] + }, + "closeResponse": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "data" + ], + "properties": { + "ok": { + "const": true + }, + "command": { + "const": "close" + }, + "schema_version": { + "const": "prowl.cli.close.v1" + }, + "data": { + "$ref": "#/$defs/lifecycleData" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "error" + ], + "properties": { + "ok": { + "const": false + }, + "command": { + "const": "close" + }, + "schema_version": { + "const": "prowl.cli.close.v1" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + ] + }, + "tabResponse": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "data" + ], + "properties": { + "ok": { + "const": true + }, + "command": { + "const": "tab" + }, + "schema_version": { + "const": "prowl.cli.tab.v1" + }, + "data": { + "$ref": "#/$defs/tabData" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "error" + ], + "properties": { + "ok": { + "const": false + }, + "command": { + "const": "tab" + }, + "schema_version": { + "const": "prowl.cli.tab.v1" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + ] + }, + "paneResponse": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "data" + ], + "properties": { + "ok": { + "const": true + }, + "command": { + "const": "pane" + }, + "schema_version": { + "const": "prowl.cli.pane.v1" + }, + "data": { + "$ref": "#/$defs/paneData" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "error" + ], + "properties": { + "ok": { + "const": false + }, + "command": { + "const": "pane" + }, + "schema_version": { + "const": "prowl.cli.pane.v1" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + ] + }, + "handoffResponse": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "data" + ], + "properties": { + "ok": { + "const": true + }, + "command": { + "const": "handoff" + }, + "schema_version": { + "const": "prowl.cli.handoff.v2" + }, + "data": { + "$ref": "#/$defs/handoffData" + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "ok", + "command", + "schema_version", + "error" + ], + "properties": { + "ok": { + "const": false + }, + "command": { + "const": "handoff" + }, + "schema_version": { + "const": "prowl.cli.handoff.v2" + }, + "error": { + "$ref": "#/$defs/error" + } + } + } + ] + } + } +} diff --git a/ProwlCLITests/ProwlCLIIntegrationTests.swift b/ProwlCLITests/ProwlCLIIntegrationTests.swift index 4a31e55d..46d31fe4 100644 --- a/ProwlCLITests/ProwlCLIIntegrationTests.swift +++ b/ProwlCLITests/ProwlCLIIntegrationTests.swift @@ -4,6 +4,8 @@ import Darwin import Glibc #endif import Foundation +import JSONSchema +import ProwlCLIContracts import ProwlCLIShared import XCTest @@ -37,6 +39,18 @@ final class ProwlCLIIntegrationTests: XCTestCase { let help = try runProwl(args: ["--help"]) XCTAssertEqual(help.exitCode, 0) XCTAssertTrue(help.stdout.contains("USAGE:")) + XCTAssertTrue(help.stdout.contains("create")) + XCTAssertTrue(help.stdout.contains("close")) + } + + func testLegacyLifecycleHelpIsMarkedDeprecated() throws { + let tabHelp = try runProwl(args: ["tab", "--help"]) + XCTAssertEqual(tabHelp.exitCode, 0) + XCTAssertTrue(tabHelp.stdout.contains("[Deprecated]")) + + let paneHelp = try runProwl(args: ["pane", "--help"]) + XCTAssertEqual(paneHelp.exitCode, 0) + XCTAssertTrue(paneHelp.stdout.contains("[Deprecated]")) } func testListReturnsAppNotRunningWhenSocketUnavailable() throws { @@ -330,6 +344,172 @@ final class ProwlCLIIntegrationTests: XCTestCase { } } + func testReadPositionalPrefixedPaneHandleRoundTripsOverSocket() throws { + let socketPath = temporarySocketPath(suffix: "read-pane-handle") + let response = CommandResponse( + ok: true, + command: "read", + schemaVersion: "prowl.cli.read.v1" + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["read", "p12", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .read(let input) = envelope.command { + XCTAssertEqual(input.selector, .auto("p12")) + } else { + XCTFail("Expected read command envelope") + } + } + + func testCreateTabCommandRoundTripsOverSocket() throws { + let socketPath = temporarySocketPath(suffix: "create-tab") + let response = try CommandResponse( + ok: true, + command: "create", + schemaVersion: "prowl.cli.create.v1", + data: RawJSON(encoding: makeLifecyclePayload(resource: .tab)) + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["create", "tab", "App", "--path", "/Projects/App", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .create(let input) = envelope.command { + XCTAssertEqual(input.resource, .tab) + XCTAssertEqual(input.selector, .worktree("App")) + XCTAssertEqual(input.path, "/Projects/App") + } else { + XCTFail("Expected create command envelope") + } + } + + func testCloseCommandRoundTripsOverSocket() throws { + let socketPath = temporarySocketPath(suffix: "close-pane") + let response = try CommandResponse( + ok: true, + command: "close", + schemaVersion: "prowl.cli.close.v1", + data: RawJSON(encoding: makeLifecyclePayload(resource: .pane)) + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["close", "p12", "--force", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .close(let input) = envelope.command { + XCTAssertEqual(input.selector, .auto("p12")) + XCTAssertTrue(input.force) + } else { + XCTFail("Expected close command envelope") + } + } + + func testCloseAcceptsTypedTabSelector() throws { + let socketPath = temporarySocketPath(suffix: "close-tab") + let response = try CommandResponse( + ok: true, + command: "close", + schemaVersion: "prowl.cli.close.v1", + data: RawJSON(encoding: makeLifecyclePayload(resource: .tab)) + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["close", "--tab", "t6", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .close(let input) = envelope.command { + XCTAssertEqual(input.selector, .tab("t6")) + } else { + XCTFail("Expected close command envelope") + } + } + + func testCloseRejectsMissingTargetBeforeTransport() throws { + let result = try runProwl(args: ["close", "--json"]) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + XCTAssertEqual(payload["command"] as? String, "close") + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) + } + + func testCloseRejectsWorktreeBeforeTransport() throws { + let result = try runProwl(args: ["close", "App", "--json"]) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + XCTAssertEqual(payload["command"] as? String, "close") + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) + } + + func testCloseRejectsWorktreeSelectorBeforeTransport() throws { + let result = try runProwl(args: ["close", "--worktree", "App", "--json"]) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + XCTAssertEqual(payload["command"] as? String, "close") + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) + } + + func testCreateTabRejectsPaneSelectorBeforeTransport() throws { + let result = try runProwl(args: ["create", "tab", "--pane", "p12", "--json"]) + + XCTAssertNotEqual(result.exitCode, 0) + let payload = try jsonObject(from: result.stdout) + XCTAssertEqual(payload["ok"] as? Bool, false) + XCTAssertEqual(payload["command"] as? String, "create") + let error = try XCTUnwrap(payload["error"] as? [String: Any]) + XCTAssertEqual(error["code"] as? String, CLIErrorCode.invalidArgument) + } + + func testDeprecatedTabCreateWarnsWithoutChangingLegacyEnvelope() throws { + let socketPath = temporarySocketPath(suffix: "deprecated-tab-create") + let response = CommandResponse( + ok: true, + command: "tab", + schemaVersion: "prowl.cli.tab.v1" + ) + + let (requestData, result) = try runWithMockServer( + socketPath: socketPath, + response: response, + args: ["tab", "create", "--worktree", "App", "--json"] + ) + + XCTAssertEqual(result.exitCode, 0) + XCTAssertTrue(result.stderr.contains("deprecated")) + let envelope = try JSONDecoder().decode(CommandEnvelope.self, from: requestData) + if case .tab = envelope.command { + // Expected legacy transport contract during the deprecation window. + } else { + XCTFail("Expected tab command envelope") + } + } + func testTabCreateCommandRoundTripsOverSocket() throws { let socketPath = temporarySocketPath(suffix: "tab-create") let response = try CommandResponse( @@ -1992,6 +2172,7 @@ final class ProwlCLIIntegrationTests: XCTestCase { responseData: Data, args: [String] ) throws -> (Data, CommandResult) { + try assertResponseMatchesSchema(responseData) let server = try MockSocketServer(socketPath: socketPath, responseData: responseData) defer { server.stop() } try server.start() @@ -2005,6 +2186,24 @@ final class ProwlCLIIntegrationTests: XCTestCase { return (requestData, result) } + private func assertResponseMatchesSchema(_ responseData: Data) throws { + let response = try JSONDecoder().decode(CommandResponse.self, from: responseData) + guard response.data != nil || response.ok == false else { return } + + let schemaText = try XCTUnwrap(String(data: ProwlCLIContractBundle.schemaData, encoding: .utf8)) + let responseText = try XCTUnwrap(String(data: responseData, encoding: .utf8)) + let schema = try Schema(instance: schemaText) + let result = try schema.validate(instance: responseText) + XCTAssertTrue( + result.isValid, + "Socket response violates JSON Schema for \(response.command) \(response.schemaVersion)." + ) + } + + private func makeLifecyclePayload(resource: LifecycleResource) -> LifecycleCommandPayload { + LifecycleCommandPayload(resource: resource, target: makeTabTarget()) + } + private func makeTabPayload(action: TabAction) -> TabCommandPayload { TabCommandPayload(action: action, target: makeTabTarget()) } @@ -2197,9 +2396,43 @@ private struct OpenResponseData: Encodable { case resolution case appLaunched = "app_launched" case broughtToFront = "brought_to_front" + case createdTab = "created_tab" + case target } let broughtToFront: Bool + let createdTab = false + let target: OpenResponseTarget? = nil + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(invocation, forKey: .invocation) + try container.encode(requestedPath, forKey: .requestedPath) + try container.encode(resolvedPath, forKey: .resolvedPath) + try container.encode(resolution, forKey: .resolution) + try container.encode(appLaunched, forKey: .appLaunched) + try container.encode(broughtToFront, forKey: .broughtToFront) + try container.encode(createdTab, forKey: .createdTab) + try container.encode(target, forKey: .target) + } +} + +private struct OpenResponseTarget: Encodable { + let worktree: ListWorktree + let tab: OpenResponseTab + let pane: OpenResponsePane +} + +private struct OpenResponseTab: Encodable { + let id: String + let title: String + let cwd: String? +} + +private struct OpenResponsePane: Encodable { + let id: String + let title: String + let cwd: String? } @@ -2252,13 +2485,22 @@ private struct ListPane: Encodable { let title: String let cwd: String? let focused: Bool + let agent: String? - init(id: String, handle: Int? = nil, title: String, cwd: String?, focused: Bool) { + init( + id: String, + handle: Int? = nil, + title: String, + cwd: String?, + focused: Bool, + agent: String? = nil + ) { self.id = id self.handle = handle self.title = title self.cwd = cwd self.focused = focused + self.agent = agent } } diff --git a/docs-ai/013-prowl-cli/contracts/agents.md b/docs-ai/013-prowl-cli/contracts/agents.md new file mode 100644 index 00000000..20444307 --- /dev/null +++ b/docs-ai/013-prowl-cli/contracts/agents.md @@ -0,0 +1,18 @@ +# `prowl agents` Contract + +Current version: `prowl.cli.agents.v1`. + +```bash +prowl agents [--json] +``` + +The command is global discovery and accepts no target selector. It returns +`count` and an `agents` array. Each entry has its canonical pane `id`, detected +agent `type`/`name`, `status`, `raw_state`, optional `detection_reason`, +`last_changed_at`, project/worktree/tab/pane metadata, and optional session +attribution. Text output additionally shows a current-process `pN` handle. + +Use `prowl agents read ` for a semantic agent snapshot; that command +has its own [contract](agents-read.md). The complete roster response schema is +`#/$defs/agentsResponse` in +[`schema-bundle.json`](../../../ProwlCLIContracts/Resources/cli-output-schema.json). diff --git a/docs-ai/013-prowl-cli/contracts/architecture.md b/docs-ai/013-prowl-cli/contracts/architecture.md index 4f0be0e8..ea2d933f 100644 --- a/docs-ai/013-prowl-cli/contracts/architecture.md +++ b/docs-ai/013-prowl-cli/contracts/architecture.md @@ -83,6 +83,9 @@ Phase-1 commands are **remote-control actions on running app state**. - `SendCommandHandler` - `KeyCommandHandler` - `ReadCommandHandler` + - `LifecycleCommandHandler` (`create`, `close`) + - legacy `TabCommandHandler` / `PaneCommandHandler` during deprecation + - `AgentsCommandHandler`, `AgentReadCommandHandler`, and `HandoffCommandHandler` - Shared services - `TargetResolver` - `TerminalCommandBridge` @@ -140,18 +143,14 @@ Resolution belongs to app runtime (state-aware), with CLI only enforcing selecto ## 7) Mapping to existing contracts -- Input normalization rules: `input.md` -- Output contracts: - - `open.md` - - `list.md` - - `focus.md` - - `send.md` - - `key.md` - - `read.md` -- JSON schema validation source: - - `schema.md` +- Input normalization rules: `input.md` and `targeting.md` +- Output contracts: one document per wire command, including `create.md`, `close.md`, + deprecated `tab.md` / `pane.md`, `agents.md`, and `handoff.md`. +- JSON schema validation source: the machine-readable bundle linked by `schema.md`. -Implementation MUST be validated against `schema.md` for `--json` mode. +Every payload-bearing mock socket response is validated against that Draft 2020-12 +bundle in `ProwlCLIIntegrationTests`; typed model tests are supplementary, not a +replacement for schema validation. --- @@ -181,7 +180,7 @@ Implementation MUST be validated against `schema.md` for `--json` mode. ## M4 — test and harden - parser unit tests (argv matrix) -- contract tests (`--json` payload schema validation) +- contract tests (Draft 2020-12 validation of raw socket-response bytes) - integration tests for `list->focus->send/key->read` loops --- @@ -194,7 +193,7 @@ Implementation MUST be validated against `schema.md` for `--json` mode. - stdin/argv source rules for `send` - `--last` and `--repeat` constraints - Contract tests: - - validate JSON against `schema.md` refs per subcommand + - validate every payload-bearing socket response against the executable schema bundle - Runtime integration tests: - open exact-root / inside-root / new-root - key alias normalization and repeat delivery counters diff --git a/docs-ai/013-prowl-cli/contracts/close.md b/docs-ai/013-prowl-cli/contracts/close.md new file mode 100644 index 00000000..5d8b75fc --- /dev/null +++ b/docs-ai/013-prowl-cli/contracts/close.md @@ -0,0 +1,50 @@ +# `prowl close` Contract + +## Status + +Current version: `prowl.cli.close.v1`. + +`close` is the action-first destructive lifecycle command. Its target spelling +selects the resource to close; it never projects a worktree to a focused tab/pane. + +## Input + +```bash +prowl close [--force] [--json] +prowl close --pane [--force] [--json] +prowl close --tab [--force] [--json] +``` + +The positional form accepts only a UUID, `pN`, or `tN`. A prefixed handle routes to +that resource type; an unprefixed UUID resolves pane first, then tab. `--pane` and +`--tab` retain typed bare-number handles. A target is mandatory. + +Reject `--target`, `--worktree`, bare-number positionals, cross-selector mixing, +and positional-plus-selector mixing with `INVALID_ARGUMENT` before transport. + +Without `--force`, protected agent work or a running command may trigger the same +GUI confirmation policy as an app-originated close. `--force` skips that policy and +must only be used after identifying the target. + +## Success + +```json +{ + "ok": true, + "command": "close", + "schema_version": "prowl.cli.close.v1", + "data": { + "resource": "pane", + "target": { "worktree": { "id": "…" }, "tab": { "id": "…" }, "pane": { "id": "…" } } + } +} +``` + +`resource` is `pane` or `tab`; `target` describes the resource immediately before +it was closed. + +## Errors + +`INVALID_ARGUMENT`, `TARGET_NOT_FOUND`, `TARGET_NOT_UNIQUE`, and `CLOSE_FAILED` +use the common response envelope in +[`schema-bundle.json`](../../../ProwlCLIContracts/Resources/cli-output-schema.json). diff --git a/docs-ai/013-prowl-cli/contracts/create.md b/docs-ai/013-prowl-cli/contracts/create.md new file mode 100644 index 00000000..372d27b4 --- /dev/null +++ b/docs-ai/013-prowl-cli/contracts/create.md @@ -0,0 +1,49 @@ +# `prowl create` Contract + +## Status + +Current version: `prowl.cli.create.v1`. + +`create` is the action-first lifecycle namespace. V1 exposes only `create tab`; +`create pane` is reserved for [#699](https://github.com/onevcat/Prowl/issues/699). + +## Input + +```bash +prowl create tab [--path ] [--json] +prowl create tab --worktree [--path ] [--json] +``` + +Exactly one positional worktree reference or `--worktree` is required. `--pane`, +`--tab`, `--target`, and a positional-plus-flag combination fail before transport +with `INVALID_ARGUMENT`. + +`--path` is normalized by the CLI and must be the resolved worktree root or a +subdirectory of it. A path outside the worktree fails with `PATH_NOT_ALLOWED`. + +## Success + +```json +{ + "ok": true, + "command": "create", + "schema_version": "prowl.cli.create.v1", + "data": { + "resource": "tab", + "target": { + "worktree": { "id": "…", "name": "App", "path": "/…", "root_path": "/…", "kind": "git" }, + "tab": { "id": "…", "title": "zsh", "selected": true }, + "pane": { "id": "…", "title": "zsh", "cwd": "/…", "focused": true } + } + } +} +``` + +`target` identifies the newly created tab and its initial pane. Its UUID fields +are the automation-safe output of this command. + +## Errors + +`INVALID_ARGUMENT`, `TARGET_NOT_FOUND`, `TARGET_NOT_UNIQUE`, `PATH_NOT_ALLOWED`, +and `CREATE_FAILED` use the common error envelope in +[`schema-bundle.json`](../../../ProwlCLIContracts/Resources/cli-output-schema.json). diff --git a/docs-ai/013-prowl-cli/contracts/focus.md b/docs-ai/013-prowl-cli/contracts/focus.md index 309d1b75..ad38450e 100644 --- a/docs-ai/013-prowl-cli/contracts/focus.md +++ b/docs-ai/013-prowl-cli/contracts/focus.md @@ -16,10 +16,10 @@ This file defines the **JSON output contract** for: ## Supported selectors -- `--worktree ` -- `--tab ` -- `--pane ` -- no selector, meaning “focus current target” +`focus` uses [targeting.md](targeting.md): `--pane` / `--tab` accept typed handles, +and `--target` or the optional positional target accept UUIDs, worktree references, +and prefixed `pN` / `tN` handles. A positional target plus selector flag is invalid; +no selector means “focus current target”. ## Success payload @@ -70,7 +70,7 @@ This file defines the **JSON output contract** for: ### `requested` -- `selector`: `"worktree"` | `"tab"` | `"pane"` | `"current"` +- `selector`: `"worktree"` | `"tab"` | `"pane"` | `"auto"` | `"current"` - `value`: string or `null` - `null` only when `selector == "current"`. diff --git a/docs-ai/013-prowl-cli/contracts/handoff.md b/docs-ai/013-prowl-cli/contracts/handoff.md new file mode 100644 index 00000000..222379e1 --- /dev/null +++ b/docs-ai/013-prowl-cli/contracts/handoff.md @@ -0,0 +1,19 @@ +# `prowl handoff` Contract + +Current version: `prowl.cli.handoff.v2`. + +```bash +prowl handoff save [source] [--brief -|--no-brief] [--note ] [--json] +prowl handoff to [source] [--brief -|--no-brief] [--note ] [--no-launch] [--json] +``` + +An explicit generic target follows [targeting.md](targeting.md). With no source, +handoff resolves the pane that spawned the CLI process; it never uses unstable UI +focus. `--brief -` reads a validated briefing from stdin, while `--no-brief` is the +explicit context-only mode. + +The success payload reports `action`, `artifact_path`, source/destination agents, +repository summary, change count, briefing state, optional archived path/session +context, and optional launched pane. The complete response contract, including the +v2 schema discriminator, is `#/$defs/handoffResponse` in +[`schema-bundle.json`](../../../ProwlCLIContracts/Resources/cli-output-schema.json). diff --git a/docs-ai/013-prowl-cli/contracts/input.md b/docs-ai/013-prowl-cli/contracts/input.md index 502fab35..895aa8ae 100644 --- a/docs-ai/013-prowl-cli/contracts/input.md +++ b/docs-ai/013-prowl-cli/contracts/input.md @@ -1,379 +1,67 @@ -# CLI Input Contract: `prowl` (v1) +# Prowl CLI Input Contract -> Living normative contract of entry 013. Migrated from `doc-onevcat/contracts/cli/input.md` on 2026-07-12; update in place. +This document owns argv/stdin grammar. Shared target semantics live in +[targeting.md](targeting.md); JSON response contracts live in the command documents +and the executable [schema bundle](schema.md). -Status: draft truth source for `#70` implementation. +## Root grammar -This file defines **input-side** rules for the phase-1 CLI commands: - -- `open` -- `list` -- `agents` (including `agents read`) -- `focus` -- `send` -- `key` -- `read` - -It complements output contracts under `docs-ai/013-prowl-cli/contracts/{open,list,focus,send,key,read,agents-read}.md`. - ---- - -## 1) Design goals - -- One stable command grammar for both humans and agents. -- No hidden priority chains that make scripts nondeterministic. -- Parse once in CLI layer; app layer should receive already-normalized typed requests. -- Keep command behavior composable: `list -> focus/send/key/read`. - ---- - -## 2) Global command model - -### 2.1 Canonical form - -```bash -prowl [target-selector] [command-args] [output-options] -``` - -### 2.2 Supported subcommands (v1) - -- `open` -- `list` -- `agents` -- `focus` -- `send` -- `key` -- `read` - -Global options (not subcommands): - -- `--help` -- `--version` - -### 2.3 Bare path entry - -These are equivalent to `open` entry: - -- `prowl` -- `prowl ` -- `prowl open ` - -Path-like first arg (v1): - -- `/...` -- `./...` -- `../...` -- `~/...` -- `file://...` -- `.` -- `..` - -### 2.4 `--` handling - -`--` stops option parsing and forces following token parsing as positional arguments. - -- `prowl -- ./focus` MUST be treated as path entry (`open`), not subcommand `focus`. -- `prowl open -- --weird-dir` MUST treat `--weird-dir` as path. - ---- - -## 3) Target selector contract (shared) - -### 3.1 Selector flags - -- `-t ` / `--target ` — auto-resolve: try pane UUID → tab UUID → worktree id/name/path. -- `--worktree ` — explicit worktree selector. -- `--tab ` — explicit tab UUID or current short handle (`tN` or bare `N`). -- `--pane ` — explicit pane UUID or current short handle (`pN` or bare `N`). - -Short handles are process-scoped, globally monotonic, and never reused after a -target closes. They are intentionally unsupported for `--target` and positional -auto-targeting, where a bare number can be a worktree name. JSON `id` fields -remain canonical UUIDs. - -### 3.2 Positional target shorthand - -`focus` and `read` accept an optional positional argument as auto-target: - -```bash -prowl focus -prowl read --last 50 -``` - -`send` and `key` use argument count to disambiguate: - -- `prowl send "text"` — 1 arg → text to current pane. -- `prowl send "text"` — 2 args → auto-target + text. -- `prowl key enter` — 1 arg → key token to current pane. -- `prowl key enter` — 2 args → auto-target + key token. - -Positional targets are ignored when flag selectors (`-t`, `--worktree`, `--tab`, `--pane`) are present. - -### 3.3 Mutual exclusivity (hard rule) - -Exactly **zero or one** selector is allowed. - -- `0 selector`: operate on current focused target (where command allows it). -- `1 selector`: resolve with that selector. -- `>1 selector`: error `INVALID_ARGUMENT`. - -This is preferred over implicit precedence because it is easier to reason about in scripts. - -### 3.4 Resolution rules - -- `--pane`: exact pane. -- `--tab`: current focused pane of target tab. -- `--worktree`: selected tab + focused pane in target worktree. -- `-t` / `--target` / positional: auto-resolve in order pane → tab → worktree. -- none: currently focused pane in current context. - -If required context does not exist: - -- return command-specific not-found / no-active-pane error. - ---- - -## 4) Common output flags - -### 4.1 `--json` - -All phase-1 commands MUST support `--json`. - -- With `--json`, output MUST match corresponding schema in `schema.md`. -- Without `--json`, output is human-readable text. - -### 4.2 Exit behavior - -- Success: exit code `0` -- Failure: non-zero -- Error payload shape in JSON mode MUST follow command contract (`error.code`, `error.message`, optional `error.details`). - -(Exact numeric non-zero codes can be refined later; error `code` string is the machine contract.) - ---- - -## 5) Per-command input rules - -## 5.1 `open` - -### Grammar - -```bash -prowl -prowl -prowl open +```text +prowl [path] +prowl open [path] +prowl list | agents | focus | read | send | key | handoff | create | close ``` -### Rules +Bare path forms (`/`, `./`, `../`, `~/`, `file://`, `.`, `..`) enter `open`. +`--` stops option parsing. `--json` and `--no-color` are leaf-command output +options; JSON stdout always contains exactly one response envelope when parsing +succeeds. -- `prowl` without path is valid and means “open app / bring to front”. -- `prowl ` is first-class, not shorthand hack. -- `prowl open ` is explicit equivalent for scripts. -- For all open-entry forms, if app is not running, CLI MUST launch Prowl and complete the open/focus flow. -- Path MUST be normalized by CLI: - - expand `~` - - resolve relative path to absolute path - - resolve `file://` - - normalize `.` / `..` -- If provided path does not exist or is not a directory: error (`PATH_NOT_FOUND` / `PATH_NOT_DIRECTORY`). +## Shared target rules -## 5.2 `list` +- Generic target positions use `GenericTarget` from [targeting.md](targeting.md). + Prefixed `pN`/`tN` handles work in `--target` and positional auto-targets. +- Typed selectors are mutually exclusive. A positional target plus selector flag is + `INVALID_ARGUMENT`; no selector silently overrides another. +- `send` and `key` retain count-sensitive positional grammar: -### Grammar +| Command | 0 args | 1 arg | 2 args | +| --- | --- | --- | --- | +| `send` | stdin → focused pane | text → focused pane | target + text | +| `key` | invalid | token → focused pane | target + token | -```bash -prowl list [--json] -``` - -### Rules - -- `list` MUST NOT accept target selectors in v1 (it is global discovery). -- Extra positional args: `INVALID_ARGUMENT`. - -## 5.2.1 `agents` - -### Grammar - -```bash -prowl agents [--json] -prowl agents read [--max-bytes <1...4194304>] [--result-only] [--json] -``` - -### Rules - -- Plain `agents` remains global roster discovery and accepts no target selector. -- `agents read` requires exactly one positional pane handle (`pN`) or pane UUID. It - intentionally accepts neither worktree/tab/focus targeting nor selector flags. -- `pN` is the current-process handle from text `agents`; JSON callers use the canonical - `.data.agents[].pane.id` UUID. -- `agents read` is an immediate snapshot: it has no timeout, wait flag, or polling mode. -- `--max-bytes` defaults to 1,048,576 and is bounded by 1...4,194,304. -- `--result-only` is text-only and cannot combine with `--json`. - -## 5.3 `focus` - -### Grammar - -```bash -prowl focus [] [--json] -prowl focus [-t <...> | --worktree <...> | --tab <...> | --pane <...>] [--json] -``` - -### Rules - -- Optional positional `` is auto-resolved (pane → tab → worktree). -- Flag selectors override positional target. -- No selector means “focus current target and bring app front”. -- More than one selector is invalid. - -## 5.4 `send` - -### Grammar - -```bash -prowl send [flags] -prowl send [flags] -printf '...' | prowl send [flags] -printf '...' | prowl send [flags] -t -``` - -Where `[flags]` includes `[--no-enter] [--no-wait] [--capture] [--timeout ] [--json]` and optional selector flags (`-t`, `--worktree`, `--tab`, `--pane`). - -### Rules - -- Positional argument count determines interpretation: - - 0 args: read text from stdin, send to current pane. - - 1 arg: text to current pane. - - 2 args: first is auto-resolved target, second is text. -- Flag selector (`-t`, `--worktree`, `--tab`, `--pane`) overrides positional target. -- Input source is exactly one of positional text or stdin. Both: `INVALID_ARGUMENT`. Neither: `EMPTY_INPUT`. -- Default sends trailing Enter; `--no-enter` disables it. -- Default waits for command completion (requires shell integration); `--no-wait` disables it and returns immediately after delivery. -- `--timeout ` sets the maximum wait duration (default: 30, range: 1–300). Ignored when `--no-wait` is used. -- If the wait times out: `WAIT_TIMEOUT`. - -## 5.5 `key` - -### Grammar - -```bash -prowl key [flags] -prowl key [flags] -``` - -Where `[flags]` includes `[--repeat ] [--json]` and optional selector flags (`-t`, `--worktree`, `--tab`, `--pane`). - -### Rules +`send p12` remains text to the focused pane. Use `send p12 'text'`, or stdin with +`--target p12`, for a target-first send. -- Positional argument count determines interpretation: - - 1 arg: key token to current pane. - - 2 args: first is auto-resolved target, second is key token. -- Flag selector overrides positional target. -- Exactly one key token required. -- Token parsing is case-insensitive; canonical output token is lowercase kebab-case. -- Alias normalization follows `key.md`. -- `--repeat` default is `1`, range `1...100`. -- `--repeat` out of range: `INVALID_REPEAT`. - -## 5.6 `read` - -### Grammar +## Lifecycle grammar ```bash -prowl read [] [--last ] [--json] -prowl read [-t <...> | --worktree <...> | --tab <...> | --pane <...>] [--last ] [--json] +prowl create tab [--path ] +prowl create tab --worktree [--path ] +prowl close [--force] +prowl close --pane [--force] +prowl close --tab [--force] ``` -### Rules - -- `--last` optional; if omitted, mode is `snapshot`. -- `--last ` requires integer `n >= 1`; otherwise `INVALID_ARGUMENT`. -- At most one `--last` value. - ---- - -## 6) Reserved command tokens (v1) - -These tokens are reserved as first command token: - -- `open` -- `list` -- `focus` -- `send` -- `key` -- `read` -- `agents` - -If first token matches a reserved command, CLI MUST parse as subcommand unless forced by `--` path form. - -`--help` / `--version` are handled as global options, not subcommands. - ---- - -## 7) Normalized request model (input -> typed request) +`create tab` requires a worktree-only target. `close` requires a pane-or-tab-only +target and rejects `--target`, `--worktree`, bare-number positions, and focus +fallback. See [create.md](create.md) and [close.md](close.md). -CLI parser MUST produce one normalized typed request before transport. +`tab create`, `tab close`, and `pane close` remain deprecated aliases for one +shipped release. They keep their legacy parser/transport behavior while emitting a +stderr warning; new automation must use the lifecycle grammar above. -Example shape: - -```swift -struct CommandEnvelope { - var output: OutputMode // text | json - var command: Command -} - -enum Command { - case open(OpenInput) - case list(ListInput) - case agents(AgentsInput) - case agentsRead(AgentReadInput) - case focus(FocusInput) - case send(SendInput) - case key(KeyInput) - case read(ReadInput) -} -``` - -This model is the handoff contract to app/transport layer. - ---- - -## 8) Examples (valid / invalid) - -Valid: - -```bash -prowl . -prowl open ~/Projects/Prowl -prowl focus 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 # auto-resolve pane UUID -prowl focus --pane 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 # explicit pane -prowl read --pane p3 --last 200 # explicit pane handle -prowl tab close --tab t4 --force # explicit tab handle -prowl focus main # auto-resolve worktree name -prowl agents read p7 --json # immediate semantic agent snapshot -prowl send "echo hello" # text to current pane -prowl send 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 "echo hi" # target + text -printf 'git status' | prowl send --worktree Prowl --json -prowl key enter # key to current pane -prowl key 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 ctrl-c # target + key -prowl read 6E1A2A10-D99F-4E3F-920C-D93AA3C05764 --last 200 # positional target + flag -``` - -Invalid: - -```bash -prowl focus --pane --tab # multiple selectors -prowl focus --pane # flag + positional (flag wins, positional ignored) -prowl send "echo hi" < /tmp/input.txt # two input sources -prowl key --repeat 0 enter # repeat out of range -prowl list --pane # list does not accept selector -``` +## Command-specific exceptions ---- +- `agents read ` is a pane-only semantic snapshot, no selectors or + focus fallback. +- `handoff` defaults to the calling pane, not UI focus. +- `list` and `agents` are global discovery commands with no target selector. +- `open` consumes a path rather than a target. -## 9) Non-goals (v1) +## Transport request model -- No complex selector query language (`--where ...`). -- No streaming mode for `read`. -- No macro system for `key`. -- No dual parser implementations in v1. +The CLI sends one typed `CommandEnvelope` over the local socket. Command spelling is +owned by `ProwlCLI` ArgumentParser declarations; target state resolution remains +app-side. Parser and handler both enforce destructive lifecycle constraints so a +malformed direct socket request cannot gain a focus fallback. diff --git a/docs-ai/013-prowl-cli/contracts/key.md b/docs-ai/013-prowl-cli/contracts/key.md index 47021afb..0c575598 100644 --- a/docs-ai/013-prowl-cli/contracts/key.md +++ b/docs-ai/013-prowl-cli/contracts/key.md @@ -45,10 +45,9 @@ Compared with the initial draft, this version makes `key` broader and more scrip ## Supported targeting -- `--worktree ` -- `--tab ` -- `--pane ` -- no selector, meaning current focused pane +`key` uses [targeting.md](targeting.md). Its two-positional form accepts a generic +target first, so `key p12 enter` targets that pane; a single token still acts on the +current pane for interactive use. ## Input contract (v1) diff --git a/docs-ai/013-prowl-cli/contracts/list.md b/docs-ai/013-prowl-cli/contracts/list.md index 2e53ee9b..6d1c9cf9 100644 --- a/docs-ai/013-prowl-cli/contracts/list.md +++ b/docs-ai/013-prowl-cli/contracts/list.md @@ -99,20 +99,23 @@ Every item in `data.items` represents exactly one actionable pane. - `name`: string - `path`: string, absolute path to the worktree or plain folder shown in the UI - `root_path`: string, absolute repository root or plain-folder root -- `kind`: `"git"` | `"plain"` +- `kind`: `"git"` | `"plain"` | `"workspace"` ### `tab` - `id`: string, UUID text form +- `handle`: optional current-process integer handle - `title`: string - `selected`: boolean ### `pane` - `id`: string, UUID text form +- `handle`: optional current-process integer handle - `title`: string - `cwd`: string or `null` - `focused`: boolean +- `agent`: optional detected-agent machine token when available ### `task` diff --git a/docs-ai/013-prowl-cli/contracts/pane.md b/docs-ai/013-prowl-cli/contracts/pane.md new file mode 100644 index 00000000..5a5457c1 --- /dev/null +++ b/docs-ai/013-prowl-cli/contracts/pane.md @@ -0,0 +1,16 @@ +# Deprecated `prowl pane` Contract + +`prowl pane close` is a deprecated compatibility alias for `prowl close `. +It is retained for one shipped release only. + +- Help labels the group and leaf command `[Deprecated]`. +- Every invocation writes a migration warning to stderr without contaminating JSON + stdout. +- During the window it preserves its legacy parser, selector projection, wire command + (`pane`), response schema (`prowl.cli.pane.v1`), and output payload. +- New docs and automation must use `prowl close pN`, `prowl close --pane `, + or the equivalent tab form. + +The legacy success payload remains `{ "action": "close", "target": … }`; the +complete schema is `#/$defs/paneResponse` in +[`schema-bundle.json`](../../../ProwlCLIContracts/Resources/cli-output-schema.json). diff --git a/docs-ai/013-prowl-cli/contracts/read.md b/docs-ai/013-prowl-cli/contracts/read.md index 854feae9..7975d389 100644 --- a/docs-ai/013-prowl-cli/contracts/read.md +++ b/docs-ai/013-prowl-cli/contracts/read.md @@ -17,10 +17,9 @@ This file defines the **JSON output contract** for: ## Supported targeting -- `--worktree ` -- `--tab ` -- `--pane ` -- no selector, meaning current focused pane +`read` uses the shared generic target grammar in [targeting.md](targeting.md), so +`read p12` and `read --target t6` resolve the displayed current-process handles. +No selector retains the interactive current-focus fallback. ## Success payload @@ -95,10 +94,10 @@ This file defines the **JSON output contract** for: - `mode`: `"snapshot"` | `"last"` - `"snapshot"` for plain `prowl read` - `"last"` for `prowl read --last N` -- `last`: integer or `null` - - required as an integer when `mode == "last"` - - must be `null` when `mode == "snapshot"` -- `source`: `"screen"` | `"scrollback"` | `"mixed"` +- `last`: optional integer + - present as an integer when `mode == "last"` + - omitted for a snapshot +- `source`: `"screen"` | `"scrollback"` | `"mixed"` | `"detection"` - `"screen"`: visible screen snapshot only - `"scrollback"`: satisfied from scrollback/history - `"mixed"`: combined view when the runtime had to stitch sources together @@ -118,6 +117,15 @@ This file defines the **JSON output contract** for: - UTF-8 text payload - may be empty if the target pane currently has no readable text +## Stable wait and detection extensions + +- `--source detection` returns the exact active-screen buffer used by agent + detection; an incompatible older app fails rather than substituting viewport text. +- `--wait-stable` samples until output stabilizes or the timeout is reached. + `stabilized`, `waited_ms`, and `samples` are present only when waiting was + requested. +- `--stable-interval`, `--stable-period`, and `--wait-timeout` tune that wait. + ## Output invariants - `text` is always present on success, even when empty. diff --git a/docs-ai/013-prowl-cli/contracts/schema.md b/docs-ai/013-prowl-cli/contracts/schema.md index d60a6c83..3296016a 100644 --- a/docs-ai/013-prowl-cli/contracts/schema.md +++ b/docs-ai/013-prowl-cli/contracts/schema.md @@ -1,849 +1,41 @@ -# Prowl CLI JSON Schema Definitions (v1) - -> Living normative contract of entry 013. Migrated from `doc-onevcat/contracts/cli/schema.md` on 2026-07-12; update in place. - -Status: draft truth source for #96. - -This file provides machine-validatable JSON Schema definitions for the v1 CLI output contracts described in: - -- `open.md` -- `list.md` -- `focus.md` -- `send.md` -- `key.md` -- `read.md` -- `agents-read.md` - -## Scope - -- JSON Schema dialect: **Draft 2020-12** -- Commands covered: `open`, `list`, `focus`, `send`, `key`, `read`, `agents.read` -- Each command schema is represented as `oneOf(success, error)` -- Shared objects are centralized in `$defs` and reused by command schemas - -## Schema bundle - -```json -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://prowl.onev.cat/contracts/cli/v1/schema-bundle.json", - "title": "Prowl CLI Output Contract Schemas (v1)", - "description": "Bundle of output JSON schemas for prowl open/list/focus/send/key/read v1", - "type": "object", - "additionalProperties": false, - "$defs": { - "uuid": { - "type": "string", - "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" - }, - "absolutePath": { - "type": "string", - "minLength": 1, - "pattern": "^/.*" - }, - "worktreeKind": { - "type": "string", - "enum": ["git", "plain"] - }, - "worktree": { - "type": "object", - "additionalProperties": false, - "required": ["id", "name", "path", "root_path", "kind"], - "properties": { - "id": { "type": "string", "minLength": 1 }, - "name": { "type": "string", "minLength": 1 }, - "path": { "$ref": "#/$defs/absolutePath" }, - "root_path": { "$ref": "#/$defs/absolutePath" }, - "kind": { "$ref": "#/$defs/worktreeKind" } - } - }, - "tabBasic": { - "type": "object", - "additionalProperties": false, - "required": ["id", "title"], - "properties": { - "id": { "$ref": "#/$defs/uuid" }, - "title": { "type": "string" } - } - }, - "tabWithCwd": { - "allOf": [ - { "$ref": "#/$defs/tabBasic" }, - { - "type": "object", - "additionalProperties": false, - "required": ["cwd"], - "properties": { - "cwd": { - "type": ["string", "null"], - "pattern": "^/.*" - } - } - } - ] - }, - "paneBasic": { - "type": "object", - "additionalProperties": false, - "required": ["id", "title", "cwd"], - "properties": { - "id": { "$ref": "#/$defs/uuid" }, - "title": { "type": "string" }, - "cwd": { - "type": ["string", "null"], - "pattern": "^/.*" - } - } - }, - "tabSelected": { - "allOf": [ - { "$ref": "#/$defs/tabBasic" }, - { - "type": "object", - "additionalProperties": false, - "required": ["selected"], - "properties": { - "selected": { "type": "boolean" } - } - } - ] - }, - "paneFocused": { - "allOf": [ - { "$ref": "#/$defs/paneBasic" }, - { - "type": "object", - "additionalProperties": false, - "required": ["focused"], - "properties": { - "focused": { "type": "boolean" } - } - } - ] - }, - "openTarget": { - "type": "object", - "additionalProperties": false, - "required": ["worktree", "tab", "pane"], - "properties": { - "worktree": { "$ref": "#/$defs/worktree" }, - "tab": { "$ref": "#/$defs/tabWithCwd" }, - "pane": { "$ref": "#/$defs/paneBasic" } - } - }, - "resolvedTarget": { - "type": "object", - "additionalProperties": false, - "required": ["worktree", "tab", "pane"], - "properties": { - "worktree": { "$ref": "#/$defs/worktree" }, - "tab": { "$ref": "#/$defs/tabSelected" }, - "pane": { "$ref": "#/$defs/paneFocused" } - } - }, - "errorDetails": { - "type": "object", - "additionalProperties": true - }, - - "openSuccess": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "command", "schema_version", "data"], - "properties": { - "ok": { "const": true }, - "command": { "const": "open" }, - "schema_version": { "const": "prowl.cli.open.v1" }, - "data": { - "type": "object", - "additionalProperties": false, - "required": [ - "invocation", - "requested_path", - "resolved_path", - "resolution", - "app_launched", - "brought_to_front", - "created_tab", - "target" - ], - "properties": { - "invocation": { - "type": "string", - "enum": ["bare", "implicit-open", "open-subcommand"] - }, - "requested_path": { - "anyOf": [ - { "$ref": "#/$defs/absolutePath" }, - { "type": "null" } - ] - }, - "resolved_path": { - "anyOf": [ - { "$ref": "#/$defs/absolutePath" }, - { "type": "null" } - ] - }, - "resolution": { - "type": "string", - "enum": ["no-argument", "exact-root", "inside-root", "new-root"] - }, - "app_launched": { "type": "boolean" }, - "brought_to_front": { "type": "boolean" }, - "created_tab": { "type": "boolean" }, - "target": { "$ref": "#/$defs/openTarget" } - }, - "allOf": [ - { - "if": { - "properties": { "requested_path": { "type": "null" } }, - "required": ["requested_path"] - }, - "then": { - "properties": { - "resolved_path": { "type": "null" }, - "resolution": { "const": "no-argument" } - } - } - }, - { - "if": { - "properties": { - "invocation": { "const": "bare" } - }, - "required": ["invocation"] - }, - "then": { - "properties": { - "requested_path": { "type": "null" } - } - } - } - ] - } - } - }, - "openError": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "command", "schema_version", "error"], - "properties": { - "ok": { "const": false }, - "command": { "const": "open" }, - "schema_version": { "const": "prowl.cli.open.v1" }, - "error": { - "type": "object", - "additionalProperties": false, - "required": ["code", "message"], - "properties": { - "code": { - "type": "string", - "enum": [ - "INVALID_ARGUMENT", - "PATH_NOT_FOUND", - "PATH_NOT_DIRECTORY", - "PATH_NOT_ALLOWED", - "LAUNCH_FAILED", - "OPEN_FAILED" - ] - }, - "message": { "type": "string", "minLength": 1 }, - "details": { "$ref": "#/$defs/errorDetails" } - } - } - } - }, - "openResponse": { - "oneOf": [ - { "$ref": "#/$defs/openSuccess" }, - { "$ref": "#/$defs/openError" } - ] - }, - - "listSuccess": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "command", "schema_version", "data"], - "properties": { - "ok": { "const": true }, - "command": { "const": "list" }, - "schema_version": { "const": "prowl.cli.list.v1" }, - "data": { - "type": "object", - "additionalProperties": false, - "required": ["count", "items"], - "properties": { - "count": { "type": "integer", "minimum": 0 }, - "items": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["worktree", "tab", "pane", "task"], - "properties": { - "worktree": { "$ref": "#/$defs/worktree" }, - "tab": { "$ref": "#/$defs/tabSelected" }, - "pane": { "$ref": "#/$defs/paneFocused" }, - "task": { - "type": "object", - "additionalProperties": false, - "required": ["status"], - "properties": { - "status": { - "type": ["string", "null"], - "enum": ["idle", "running", null] - } - } - } - } - } - } - } - } - } - }, - "listError": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "command", "schema_version", "error"], - "properties": { - "ok": { "const": false }, - "command": { "const": "list" }, - "schema_version": { "const": "prowl.cli.list.v1" }, - "error": { - "type": "object", - "additionalProperties": false, - "required": ["code", "message"], - "properties": { - "code": { - "type": "string", - "enum": ["APP_NOT_RUNNING", "LIST_FAILED"] - }, - "message": { "type": "string", "minLength": 1 }, - "details": { "$ref": "#/$defs/errorDetails" } - } - } - } - }, - "listResponse": { - "oneOf": [ - { "$ref": "#/$defs/listSuccess" }, - { "$ref": "#/$defs/listError" } - ] - }, - - "focusSuccess": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "command", "schema_version", "data"], - "properties": { - "ok": { "const": true }, - "command": { "const": "focus" }, - "schema_version": { "const": "prowl.cli.focus.v1" }, - "data": { - "type": "object", - "additionalProperties": false, - "required": ["requested", "resolved_via", "brought_to_front", "target"], - "properties": { - "requested": { - "type": "object", - "additionalProperties": false, - "required": ["selector", "value"], - "properties": { - "selector": { - "type": "string", - "enum": ["worktree", "tab", "pane", "auto", "current"] - }, - "value": { - "type": ["string", "null"] - } - }, - "allOf": [ - { - "if": { - "properties": { "selector": { "const": "current" } }, - "required": ["selector"] - }, - "then": { - "properties": { - "value": { "type": "null" } - } - } - } - ] - }, - "resolved_via": { - "type": "string", - "enum": ["worktree", "tab", "pane"] - }, - "brought_to_front": { "type": "boolean" }, - "target": { "$ref": "#/$defs/resolvedTarget" } - } - } - } - }, - "focusError": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "command", "schema_version", "error"], - "properties": { - "ok": { "const": false }, - "command": { "const": "focus" }, - "schema_version": { "const": "prowl.cli.focus.v1" }, - "error": { - "type": "object", - "additionalProperties": false, - "required": ["code", "message"], - "properties": { - "code": { - "type": "string", - "enum": [ - "APP_NOT_RUNNING", - "INVALID_ARGUMENT", - "TARGET_NOT_FOUND", - "TARGET_NOT_UNIQUE", - "FOCUS_FAILED" - ] - }, - "message": { "type": "string", "minLength": 1 }, - "details": { "$ref": "#/$defs/errorDetails" } - } - } - } - }, - "focusResponse": { - "oneOf": [ - { "$ref": "#/$defs/focusSuccess" }, - { "$ref": "#/$defs/focusError" } - ] - }, - - "sendSuccess": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "command", "schema_version", "data"], - "properties": { - "ok": { "const": true }, - "command": { "const": "send" }, - "schema_version": { "const": "prowl.cli.send.v1" }, - "data": { - "type": "object", - "additionalProperties": false, - "required": ["target", "input", "created_tab", "wait"], - "properties": { - "target": { "$ref": "#/$defs/resolvedTarget" }, - "input": { - "type": "object", - "additionalProperties": false, - "required": ["source", "characters", "bytes", "trailing_enter_sent"], - "properties": { - "source": { - "type": "string", - "enum": ["argv", "stdin"] - }, - "characters": { - "type": "integer", - "minimum": 1 - }, - "bytes": { - "type": "integer", - "minimum": 1 - }, - "trailing_enter_sent": { "type": "boolean" } - } - }, - "created_tab": { "type": "boolean" }, - "wait": { - "oneOf": [ - { "type": "null" }, - { - "type": "object", - "additionalProperties": false, - "required": ["exit_code", "duration_ms"], - "properties": { - "exit_code": { - "type": ["integer", "null"] - }, - "duration_ms": { - "type": "integer", - "minimum": 0 - } - } - } - ] - } - } - } - } - }, - "sendError": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "command", "schema_version", "error"], - "properties": { - "ok": { "const": false }, - "command": { "const": "send" }, - "schema_version": { "const": "prowl.cli.send.v1" }, - "error": { - "type": "object", - "additionalProperties": false, - "required": ["code", "message"], - "properties": { - "code": { - "type": "string", - "enum": [ - "APP_NOT_RUNNING", - "INVALID_ARGUMENT", - "TARGET_NOT_FOUND", - "TARGET_NOT_UNIQUE", - "EMPTY_INPUT", - "SEND_FAILED", - "WAIT_TIMEOUT" - ] - }, - "message": { "type": "string", "minLength": 1 }, - "details": { "$ref": "#/$defs/errorDetails" } - } - } - } - }, - "sendResponse": { - "oneOf": [ - { "$ref": "#/$defs/sendSuccess" }, - { "$ref": "#/$defs/sendError" } - ] - }, - - "keySuccess": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "command", "schema_version", "data"], - "properties": { - "ok": { "const": true }, - "command": { "const": "key" }, - "schema_version": { "const": "prowl.cli.key.v1" }, - "data": { - "type": "object", - "additionalProperties": false, - "required": ["requested", "key", "delivery", "target"], - "properties": { - "requested": { - "type": "object", - "additionalProperties": false, - "required": ["token", "repeat"], - "properties": { - "token": { "type": "string", "minLength": 1 }, - "repeat": { "type": "integer", "minimum": 1, "maximum": 100 } - } - }, - "key": { - "type": "object", - "additionalProperties": false, - "required": ["normalized", "category"], - "properties": { - "normalized": { - "type": "string", - "enum": [ - "enter", - "esc", - "tab", - "backspace", - "up", - "down", - "left", - "right", - "pageup", - "pagedown", - "home", - "end", - "ctrl-c", - "ctrl-d", - "ctrl-l" - ] - }, - "category": { - "type": "string", - "enum": ["navigation", "editing", "control"] - } - } - }, - "delivery": { - "type": "object", - "additionalProperties": false, - "required": ["attempted", "delivered", "mode"], - "properties": { - "attempted": { "type": "integer", "minimum": 1, "maximum": 100 }, - "delivered": { "type": "integer", "minimum": 1, "maximum": 100 }, - "mode": { "const": "keyDownUp" } - } - }, - "target": { "$ref": "#/$defs/resolvedTarget" } - } - } - } - }, - "keyError": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "command", "schema_version", "error"], - "properties": { - "ok": { "const": false }, - "command": { "const": "key" }, - "schema_version": { "const": "prowl.cli.key.v1" }, - "error": { - "type": "object", - "additionalProperties": false, - "required": ["code", "message"], - "properties": { - "code": { - "type": "string", - "enum": [ - "APP_NOT_RUNNING", - "INVALID_ARGUMENT", - "INVALID_REPEAT", - "TARGET_NOT_FOUND", - "TARGET_NOT_UNIQUE", - "NO_ACTIVE_PANE", - "UNSUPPORTED_KEY", - "KEY_DELIVERY_FAILED" - ] - }, - "message": { "type": "string", "minLength": 1 }, - "details": { "$ref": "#/$defs/errorDetails" } - } - } - } - }, - "keyResponse": { - "oneOf": [ - { "$ref": "#/$defs/keySuccess" }, - { "$ref": "#/$defs/keyError" } - ] - }, - - "readSuccess": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "command", "schema_version", "data"], - "properties": { - "ok": { "const": true }, - "command": { "const": "read" }, - "schema_version": { "const": "prowl.cli.read.v1" }, - "data": { - "type": "object", - "additionalProperties": false, - "required": [ - "target", - "mode", - "last", - "source", - "truncated", - "line_count", - "text" - ], - "properties": { - "target": { "$ref": "#/$defs/resolvedTarget" }, - "mode": { - "type": "string", - "enum": ["snapshot", "last"] - }, - "last": { - "type": ["integer", "null"], - "minimum": 1 - }, - "source": { - "type": "string", - "enum": ["screen", "scrollback", "mixed"] - }, - "truncated": { "type": "boolean" }, - "line_count": { "type": "integer", "minimum": 0 }, - "text": { "type": "string" } - }, - "allOf": [ - { - "if": { - "properties": { "mode": { "const": "snapshot" } }, - "required": ["mode"] - }, - "then": { - "properties": { - "last": { "type": "null" } - } - } - }, - { - "if": { - "properties": { "mode": { "const": "last" } }, - "required": ["mode"] - }, - "then": { - "properties": { - "last": { "type": "integer", "minimum": 1 } - } - } - } - ] - } - } - }, - "readError": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "command", "schema_version", "error"], - "properties": { - "ok": { "const": false }, - "command": { "const": "read" }, - "schema_version": { "const": "prowl.cli.read.v1" }, - "error": { - "type": "object", - "additionalProperties": false, - "required": ["code", "message"], - "properties": { - "code": { - "type": "string", - "enum": [ - "APP_NOT_RUNNING", - "INVALID_ARGUMENT", - "TARGET_NOT_FOUND", - "TARGET_NOT_UNIQUE", - "READ_FAILED" - ] - }, - "message": { "type": "string", "minLength": 1 }, - "details": { "$ref": "#/$defs/errorDetails" } - } - } - } - }, - "readResponse": { - "oneOf": [ - { "$ref": "#/$defs/readSuccess" }, - { "$ref": "#/$defs/readError" } - ] - }, - - "agentsReadSuccess": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "command", "schema_version", "data"], - "properties": { - "ok": { "const": true }, - "command": { "const": "agents.read" }, - "schema_version": { "const": "prowl.cli.agents.read.v1" }, - "data": { - "type": "object", - "additionalProperties": false, - "required": ["output_mode", "target", "agent", "result"], - "properties": { - "output_mode": { "const": "snapshot" }, - "target": { "$ref": "#/$defs/resolvedTarget" }, - "agent": { - "type": "object", - "additionalProperties": false, - "required": ["type", "status", "raw_state", "last_changed_at"], - "properties": { - "type": { "type": "string", "enum": ["claude", "codex"] }, - "status": { "type": "string", "enum": ["blocked", "working", "done", "idle"] }, - "raw_state": { "type": "string", "enum": ["blocked", "working", "idle", "unknown"] }, - "detection_reason": { "type": "string" }, - "last_changed_at": { "type": "string", "format": "date-time" }, - "session": { - "type": "object", - "additionalProperties": false, - "required": ["id", "confidence", "source"], - "properties": { - "id": { "type": "string", "minLength": 1 }, - "confidence": { "type": "string", "enum": ["exact", "high"] }, - "source": { "type": "string", "enum": ["open_file", "transcript_match", "recent_file"] } - } - } - } - }, - "blocker": { - "type": "object", - "additionalProperties": false, - "required": ["text"], - "properties": { - "text": { "type": "string", "minLength": 1 } - } - }, - "result": { - "type": "object", - "additionalProperties": false, - "required": ["state"], - "properties": { - "state": { - "type": "string", - "enum": ["complete", "pending", "unavailable", "missing", "incomplete", "too_large"] - }, - "text": { "type": "string" }, - "error": { - "type": "object", - "additionalProperties": false, - "required": ["code", "message"], - "properties": { - "code": { - "type": "string", - "enum": ["SESSION_UNRESOLVED", "RESULT_NOT_FOUND", "RESULT_INCOMPLETE", "RESULT_TOO_LARGE"] - }, - "message": { "type": "string", "minLength": 1 } - } - } - } - } - } - } - } - }, - "agentsReadError": { - "type": "object", - "additionalProperties": false, - "required": ["ok", "command", "schema_version", "error"], - "properties": { - "ok": { "const": false }, - "command": { "const": "agents.read" }, - "schema_version": { "const": "prowl.cli.agents.read.v1" }, - "error": { - "type": "object", - "additionalProperties": false, - "required": ["code", "message"], - "properties": { - "code": { - "type": "string", - "enum": [ - "APP_NOT_RUNNING", "INVALID_ARGUMENT", "TARGET_NOT_FOUND", "AGENT_NOT_FOUND", - "AGENT_UNSUPPORTED", "AGENT_READ_FAILED", "BLOCKER_UNREADABLE", "SESSION_UNRESOLVED", - "RESULT_NOT_FOUND", "RESULT_INCOMPLETE", "RESULT_TOO_LARGE" - ] - }, - "message": { "type": "string", "minLength": 1 }, - "details": { "$ref": "#/$defs/errorDetails" } - } - } - } - }, - "agentsReadResponse": { - "oneOf": [ - { "$ref": "#/$defs/agentsReadSuccess" }, - { "$ref": "#/$defs/agentsReadError" } - ] - } - } -} -``` - -## Usage notes - -- Validate `prowl open --json` output against `#/$defs/openResponse` -- Validate `prowl list --json` output against `#/$defs/listResponse` -- Validate `prowl focus --json` output against `#/$defs/focusResponse` -- Validate `prowl send --json` output against `#/$defs/sendResponse` -- Validate `prowl key --json` output against `#/$defs/keyResponse` -- Validate `prowl read --json` output against `#/$defs/readResponse` -- Validate `prowl agents read --json` output against `#/$defs/agentsReadResponse` - -## Non-goals (v1) - -- This file does **not** define CLI input argument parsing schemas. -- This file does **not** guarantee order-sensitive invariants that require runtime state checks across array items (for example, uniqueness of focused pane across all rows in `list`). -- This file does **not** attempt to encode transport-level details (stdout/stderr split, process exit code) beyond payload shape. +# Prowl CLI JSON Schema Bundle + +The normative machine-readable Draft 2020-12 bundle is +[`cli-output-schema.json`](../../../ProwlCLIContracts/Resources/cli-output-schema.json). +This document intentionally contains no copied JSON: a Markdown code block cannot +be executed and was the source of the previous schema drift. + +## Coverage + +The bundle has one versioned success-or-error response schema for every wire command: + +| Command | Schema definition | +| --- | --- | +| `open` | `#/$defs/openResponse` | +| `list` | `#/$defs/listResponse` | +| `agents` | `#/$defs/agentsResponse` | +| `agents.read` | `#/$defs/agentsReadResponse` | +| `focus` | `#/$defs/focusResponse` | +| `send` | `#/$defs/sendResponse` | +| `key` | `#/$defs/keyResponse` | +| `read` | `#/$defs/readResponse` | +| `create` | `#/$defs/createResponse` | +| `close` | `#/$defs/closeResponse` | +| deprecated `tab` | `#/$defs/tabResponse` | +| deprecated `pane` | `#/$defs/paneResponse` | +| `handoff` | `#/$defs/handoffResponse` | + +The bundle root is a `oneOf` across these responses and is valid for any complete +socket response. + +## Executable verification + +`ProwlCLITests/ProwlCLIIntegrationTests.swift` loads the bundle through the +`ProwlCLIContracts` SwiftPM target and validates every mock socket response that +contains a command payload or error with the Draft 2020-12 `JSONSchema` validator. +Those are raw socket bytes, not decoded model assertions. `make test-cli-integration` +is therefore the contract verification command. + +A public wire command is incomplete until its versioned response definition, +socket fixture, parser/handler test, user manual, and command contract change in the +same review. diff --git a/docs-ai/013-prowl-cli/contracts/send.md b/docs-ai/013-prowl-cli/contracts/send.md index 8fb4afc3..1017b8f4 100644 --- a/docs-ai/013-prowl-cli/contracts/send.md +++ b/docs-ai/013-prowl-cli/contracts/send.md @@ -16,16 +16,19 @@ This file defines the **JSON output contract** for: ## Supported targeting -- `--worktree ` -- `--tab ` -- `--pane ` -- no selector, meaning current focused pane +`send` uses the shared generic target grammar in [targeting.md](targeting.md), +including `pN` / `tN` in `--target` and target-first positional forms. With one +positional argument, `send p12` remains text for the focused pane; target-first +send requires `send p12 'text'`. ## Supported input forms - positional text argument - stdin text -- `--no-enter` +- `--no-enter`, `--no-wait`, `--capture`, and `--timeout <1...300>` + +`--capture` is shipped behavior. It requires shell integration, sends Enter, and +cannot combine with `--no-wait` or `--no-enter`. ## Wait behavior @@ -42,11 +45,11 @@ By default, `send` waits for the delivered command to finish before returning. T - `"argv"` - means the payload came from the positional text argument of `prowl send` - typical trigger: `prowl send "echo hello"` - - planned use: short, explicit, human-authored inline sends + - use for short, explicit, human-authored inline sends - `"stdin"` - means the payload came from process stdin, usually via a pipe or redirection - typical trigger: `printf 'echo hello\n' | prowl send` - - planned use: multiline text, generated text, file/pipe input, or payloads you do not want to expose inline in shell history + - use for multiline text, generated text, file/pipe input, or payloads you do not want to expose inline in shell history ## Success payload @@ -176,6 +179,7 @@ By default, `send` waits for the delivered command to finish before returning. T - `EMPTY_INPUT` - `SEND_FAILED` - `WAIT_TIMEOUT` +- `CAPTURE_UNSUPPORTED` ## Notes @@ -184,7 +188,9 @@ By default, `send` waits for the delivered command to finish before returning. T - A future implementation may add optional debug echo flags, but v1 default JSON must stay redaction-friendly. - Wait behavior depends on shell integration (OSC 133). Without it, `onCommandFinished` never fires and the wait will time out. The `WAIT_TIMEOUT` error message should hint at this possible cause. - `--no-wait` combined with `--no-enter` is the purest "paste text" mode — no Enter, no waiting. -- A future `--capture` flag may return the command's output text alongside `wait`, pending upstream support for reading semantic zone data via the Ghostty C API. +- `--capture` returns `capture` with `text`, `line_count`, `source: "screen_diff"`, + and `truncated`; it is omitted when capture was not requested. Capture requires + shell integration and fails with `CAPTURE_UNSUPPORTED` when unavailable. ## Example: stdin + `--no-enter` diff --git a/docs-ai/013-prowl-cli/contracts/tab.md b/docs-ai/013-prowl-cli/contracts/tab.md new file mode 100644 index 00000000..3ec23c06 --- /dev/null +++ b/docs-ai/013-prowl-cli/contracts/tab.md @@ -0,0 +1,15 @@ +# Deprecated `prowl tab` Contract + +`prowl tab create` and `prowl tab close` are deprecated compatibility aliases for +`prowl create tab` and `prowl close `. They are retained for one shipped +release only. + +- Help labels the group and leaf commands `[Deprecated]`. +- Every invocation writes a migration warning to stderr. JSON stdout remains valid. +- During the window, aliases retain their existing parser, selector projection, wire + command (`tab`), response schema (`prowl.cli.tab.v1`), and output payload exactly. +- New docs and automation must not use them. + +`tab` success payloads retain `{ "action": "create"|"close", "target": … }`. +The complete legacy response schema is `#/$defs/tabResponse` in +[`schema-bundle.json`](../../../ProwlCLIContracts/Resources/cli-output-schema.json). diff --git a/docs-ai/013-prowl-cli/contracts/targeting.md b/docs-ai/013-prowl-cli/contracts/targeting.md new file mode 100644 index 00000000..0a932c19 --- /dev/null +++ b/docs-ai/013-prowl-cli/contracts/targeting.md @@ -0,0 +1,68 @@ +# Prowl CLI Targeting Contract + +> Normative target-language contract. Command contracts link here rather than redefining resolution rules. + +## References + +```text +GenericTarget ::= PaneUUID | TabUUID | PaneHandle | TabHandle | WorktreeRef +PaneHandle ::= "p" PositiveInteger +TabHandle ::= "t" PositiveInteger +WorktreeRef ::= worktree id | name | path +``` + +- UUIDs are the canonical JSON and cross-process automation identity. +- `pN` / `tN` are current-process interaction handles. They are globally monotonic, + never reused by a running Prowl process, and invalid after restart or target close. +- Bare `N` is only a typed `--pane N` / `--tab N` handle. In generic positions it + remains a worktree reference. +- Generic `pN` resolves only as a pane and generic `tN` resolves only as a tab. A + stale prefixed handle fails with `TARGET_NOT_FOUND`; it never falls back to a + worktree named `pN` or `tN`. Use `--worktree p12` for that worktree. + +## Generic resolution + +Generic target positions are `--target` and the target-first positional forms of +`focus`, `read`, `send`, `key`, and `handoff`. + +1. `pN` resolves as a pane handle. +2. `tN` resolves as a tab handle. +3. A UUID resolves pane first, then tab. +4. Any other spelling resolves as a worktree id, name, or path. + +Typed selectors remain mutually exclusive: + +```text +--target +--worktree +--tab +--pane +``` + +A positional target and any selector flag are mutually exclusive and fail with +`INVALID_ARGUMENT`; flags never silently override a positional target. + +## No-target behavior + +- `focus`, `read`, `send`, `key`, and legacy `tab create` retain current UI-focus + fallback for interactive use. +- `handoff` defaults to the pane that spawned the CLI process, never UI focus. +- `agents read` always requires a pane argument. +- New `create tab` and `close` always require an explicit target. + +Automation should use UUIDs or an explicit same-session `pN`/`tN` target; it must +not rely on a focus fallback. + +## Typed lifecycle targeting + +Lifecycle commands intentionally do not use generic worktree projection: + +```text +create tab | --worktree +close | --pane | --tab +``` + +`close` rejects `--target`, `--worktree`, bare-number positionals, UI-focus +fallback, and mixed positional/flag targeting. `create pane` is reserved for +[#699](https://github.com/onevcat/Prowl/issues/699): it will require a pane-only +anchor and direction. diff --git a/docs-ai/060-prowl-cli-targeting-and-contract-governance/000-plan.md b/docs-ai/060-prowl-cli-targeting-and-contract-governance/000-plan.md index fab01e59..2d446cbb 100644 --- a/docs-ai/060-prowl-cli-targeting-and-contract-governance/000-plan.md +++ b/docs-ai/060-prowl-cli-targeting-and-contract-governance/000-plan.md @@ -2,7 +2,7 @@ | | | | --- | --- | -| **Status** | Aligned — implementation pending | +| **Status** | Implemented | | **Anchor date** | 2026-08-16 | | **Primary PRs** | TBD | | **Related** | [013 — Prowl CLI](../013-prowl-cli/000-plan.md), [046 — CLI Short Handles](../046-cli-short-handles/000-plan.md), [047 — Cross-Agent Handoff](../047-cross-agent-handoff/000-plan.md), [059 — Agent Transcript Snapshots](../059-agent-transcript-snapshots/000-plan.md), [#699 — CLI split-pane creation](https://github.com/onevcat/Prowl/issues/699), [`docs/components/cli.md`](../../docs/components/cli.md) | diff --git a/docs-ai/060-prowl-cli-targeting-and-contract-governance/001-action.md b/docs-ai/060-prowl-cli-targeting-and-contract-governance/001-action.md new file mode 100644 index 00000000..fa2c4cb3 --- /dev/null +++ b/docs-ai/060-prowl-cli-targeting-and-contract-governance/001-action.md @@ -0,0 +1,58 @@ +# 060 — Prowl CLI Targeting and Contract Governance: Action + +## Delivered + +- Unified generic target references: `pN` and `tN` now work in `--target` and + every existing positional auto-target form. Bare numbers remain worktree + references outside typed `--pane` / `--tab` selectors, and stale prefixed + handles never fall back to worktrees. +- Added action-first lifecycle commands: + + ```bash + prowl create tab [--path ] + prowl close [--force] + prowl close --pane [--force] + prowl close --tab [--force] + ``` + + `close` has no worktree or UI-focus fallback. Its resolver returns the typed + tab/pane resource, so `pN` and `tN` dispatch without cross-resource selector + projection. +- Kept `tab create`, `tab close`, and `pane close` as one-release deprecated + aliases. Help marks them `[Deprecated]`; each invocation emits a stderr + migration warning while preserving the legacy wire command and JSON stdout. +- Added versioned `create` and `close` wire inputs, handlers, payloads, output + rendering, error codes, schemas, parser tests, resolver tests, handler tests, + and socket integration coverage. +- Rebased CLI documentation around `targeting.md`, action-first lifecycle + grammar, per-command contracts, and a durable pane-creation boundary in + [#699](https://github.com/onevcat/Prowl/issues/699). +- Replaced the non-executable Markdown schema copy with the Draft 2020-12 + `ProwlCLIContracts` bundle. `ProwlCLIIntegrationTests` validates every mock + socket response with payload/error bytes before the CLI receives it. The + bundle covers all 13 shipped wire commands, including deprecated aliases. + +## Deliberate Boundary + +`prowl create pane` remains unimplemented. [#699](https://github.com/onevcat/Prowl/issues/699) +now owns the action-first `create pane --direction ` +contract and requires a direct target-surface split primitive rather than a +focus-dependent implementation. + +## Verification + +Executed successfully: + +```bash +make format-changed +make build-cli +make test-cli-smoke +make test-cli-integration +make check +make test +make build-app +``` + +Focused coverage also exercised prefixed generic resolution, stale-handle +non-fallback, lifecycle target routing, lifecycle handler behavior, parser +rejection, deprecated help/warnings, and raw socket JSON-schema validation. diff --git a/docs/components/cli.md b/docs/components/cli.md index d3d8b3cd..049ef0c8 100644 --- a/docs/components/cli.md +++ b/docs/components/cli.md @@ -4,7 +4,7 @@ > an agent) can list panes, read their screens, run commands and capture output, > send keystrokes, focus, and open/close tabs and panes programmatically. -**Keywords:** prowl cli, command line, prowl list, prowl agents, prowl agents read, prowl read, prowl send, prowl key, prowl focus, prowl tab, prowl pane, prowl open, prowl handoff, pane id, agent, automation, json, capture, socket +**Keywords:** prowl cli, command line, prowl list, prowl agents, prowl agents read, prowl read, prowl send, prowl key, prowl focus, prowl create, prowl close, prowl open, prowl handoff, pane id, agent, automation, json, capture, socket **Related:** [terminal](terminal.md) · [concepts](../concepts.md) · [active-agents](active-agents.md) · [agent-detection](agent-detection.md) · the bundled **`prowl-cli` skill** (`skills/prowl-cli/SKILL.md`) @@ -48,8 +48,8 @@ Most commands accept one selector (mutually exclusive): short handle shown in text output; bare `N` is accepted too. - `--worktree ` — a worktree (its selected/first tab → focused/first pane). -- `-t, --target ` — auto-resolve: tries pane UUID, then tab UUID, then - worktree id/name/path. +- `-t, --target ` — auto-resolve: `pN` as a pane, `tN` as a tab, then + pane UUID, tab UUID, or worktree id/name/path. - **No selector** → the *current* focus (focused worktree → selected tab → focused pane). Some commands (close) refuse this for safety. @@ -58,10 +58,11 @@ Most commands accept one selector (mutually exclusive): Text `list` and `agents` output exposes short, type-prefixed handles such as `p7` and `t6`. They are valid only for the current app process, are globally -monotonic, and are never reused after a tab or pane closes. Use them with an -explicit `--pane` or `--tab`; `--target` and positional targets deliberately do -not resolve handles because a bare number can be a worktree name. JSON always -keeps the canonical UUID in `id`; do not cache either form across an app restart. +monotonic, and are never reused after a tab or pane closes. They work in every +generic target position (`read p7`, `focus t6`, `send p7 '…'`); bare numbers remain +worktree references there. A stale prefixed handle fails rather than falling back to +a same-named worktree. JSON keeps canonical UUIDs in `id`; do not cache handles +across an app restart. > **Never target by tab title.** Titles are free-form and can lie. For scripts, > resolve a concrete UUID `pane.id` from `prowl list --json`; for an interactive @@ -93,8 +94,8 @@ with the corresponding explicit selector: ```bash prowl list -prowl read --pane p7 --last 120 --wait-stable -prowl tab close --tab t6 --force +prowl read p7 --last 120 --wait-stable +prowl close t6 --force ``` `task.status` is **running** when any pane in the worktree is busy — a terminal @@ -268,32 +269,33 @@ prowl focus --pane "$pane" --json prowl focus --worktree MyApp --json ``` -### `prowl tab create` -Create a new terminal tab (deterministic — unlike `open`). - -- `--path ` — working directory (must be inside the worktree root). -- Selectors choose the worktree (defaults to current). +### `prowl create tab` +Create a new terminal tab (deterministic — unlike `open`). A worktree is required, +either positionally or with `--worktree`; `--path` must remain inside it. ```bash -pane="$(prowl tab create --worktree "$wt" --json | jq -r '.data.target.pane.id')" +pane="$(prowl create tab "$wt" --json | jq -r '.data.target.pane.id')" ``` -### `prowl tab close` / `prowl pane close` -Close a tab or a pane. **Require an explicit selector** (`--tab`/`--pane`/ -`--worktree`/`--target`) — they intentionally do **not** default to the focused -pane. If the target has protected agent work or a long-running command, Prowl may -ask for GUI confirmation; `--force` skips it (use only after positively -identifying the target). +### `prowl close` +Close one explicit tab or pane. The positional form uses a UUID, `pN`, or `tN`; the +long forms are `--pane ` and `--tab `. `close` rejects +worktree targeting and has no focus fallback. Protected agent work or a long-running +command may trigger GUI confirmation; `--force` skips it only after positive +identification. ```bash -prowl pane close --pane "$pane" --json -prowl tab close --tab "$tab" --force --json +prowl close "$pane" --json +prowl close --tab "$tab" --force --json ``` +> `prowl tab create`, `prowl tab close`, and `prowl pane close` are deprecated +> compatibility aliases. They warn on stderr and will be removed after one release. + ### `prowl open [path]` (the default command) Navigate Prowl to a path (or bring it to front with no argument). It may focus an existing pane or create a tab — it is **not** a deterministic "new pane" command. -For a guaranteed fresh shell, use `tab create`. +For a guaranteed fresh shell, use `create tab`. ```bash prowl open ~/projects/app # open/focus that project @@ -409,7 +411,7 @@ artifacts and terminal excerpts do not appear in `git status`. | `CAPTURE_UNSUPPORTED` | Target lacks OSC 133 — drop `--capture`, use `read --wait-stable`. | | `WAIT_TIMEOUT` | Command didn't finish in time — raise `--timeout` or use `--no-wait`. | | `UNSUPPORTED_KEY` / `INVALID_REPEAT` | Check `prowl key --help`. | -| `PATH_NOT_FOUND` / `PATH_NOT_DIRECTORY` / `PATH_NOT_ALLOWED` | Fix the `open`/`tab create` path. | +| `PATH_NOT_FOUND` / `PATH_NOT_DIRECTORY` / `PATH_NOT_ALLOWED` | Fix the `open`/`create tab` path. | | `LAUNCH_FAILED` | App launch or socket wait failed; the message includes the last socket diagnostic when available. | | `TRANSPORT_FAILED` | Socket transport failed for a reason other than app availability or permission, such as `ENOTSOCK` or an invalid `PROWL_CLI_SOCKET` path. | | `*_FAILED` (`LIST_FAILED`, `AGENTS_FAILED`, `FOCUS_FAILED`, `SEND_FAILED`, `READ_FAILED`, `TAB_FAILED`, `PANE_FAILED`, `OPEN_FAILED`, `HANDOFF_FAILED`) | The action itself failed. | @@ -426,11 +428,11 @@ artifacts and terminal excerpts do not appear in `git status`. ```bash self_pane="$(prowl list --json | jq -r '.data.items[]|select(.pane.focused==true)|.pane.id')" -pane="$(prowl tab create --worktree MyApp --json | jq -r '.data.target.pane.id')" +pane="$(prowl create tab MyApp --json | jq -r '.data.target.pane.id')" test "$pane" != "$self_pane" prowl send --pane "$pane" 'swift build' --capture --timeout 300 --json prowl read --pane "$pane" --last 100 --wait-stable --json -prowl pane close --pane "$pane" --json +prowl close "$pane" --json ``` ## Gotchas for agents (quick list) @@ -442,6 +444,6 @@ prowl pane close --pane "$pane" --json when you need all panes, including ordinary shells. - `--capture` needs shell integration; otherwise `read --wait-stable` or file redirection. -- `open` is navigation, not a guaranteed new pane — use `tab create`. +- `open` is navigation, not a guaranteed new pane — use `create tab`. - In zsh, don't name a variable `status` (it's readonly). - Pass shell values into `jq` with `--arg`. diff --git a/docs/components/terminal.md b/docs/components/terminal.md index c32f00aa..159ae220 100644 --- a/docs/components/terminal.md +++ b/docs/components/terminal.md @@ -27,7 +27,7 @@ why Prowl is fully native and CJK-correct. | Operation | How | |-----------|-----| -| New tab | Terminal menu → **New Terminal** (Ghostty `new_tab`, typically `⌘T`); the **+** on a Shelf spine; the [`prowl tab create`](cli.md) CLI | +| New tab | Terminal menu → **New Terminal** (Ghostty `new_tab`, typically `⌘T`); the **+** on a Shelf spine; the [`prowl create tab`](cli.md) CLI | | Select tab 1–9 | `⌘1`–`⌘9` | | Previous / Next tab | `⌘⇧[` / `⌘⇧]` | | Close focused tab | Terminal menu → **Close Terminal Tab** (Ghostty `close_tab`) | diff --git a/skills/prowl-cli/SKILL.md b/skills/prowl-cli/SKILL.md index 4a2fc6b7..ef25ef53 100644 --- a/skills/prowl-cli/SKILL.md +++ b/skills/prowl-cli/SKILL.md @@ -10,7 +10,7 @@ Use `prowl` only when the task is to inspect or control the running Prowl GUI ap ## Safe Default Workflow -For automation, always resolve a concrete pane UUID before `read`, `send`, `key`, `focus`, or destructive close commands. Text `list` and `agents` output also exposes current-process `pN` / `tN` handles for concise same-session handoffs; use them only with explicit `--pane` / `--tab` flags. +For automation, always resolve a concrete pane UUID before `read`, `send`, `key`, `focus`, or destructive close commands. Text `list` and `agents` also expose current-process `pN` / `tN` handles for concise same-session targeting: `read p7`, `send p7 '…'`, `key p7 enter`, and `close t6` are valid. UUIDs remain the only cross-process identity. ```bash prowl list --json @@ -61,13 +61,13 @@ worktree="$(prowl list --json | jq -r --arg project "$project" ' | select((.worktree.path | rtrimstr("/")) == ($project | rtrimstr("/"))) | .worktree.id ' | head -n 1)" -pane="$(prowl tab create --worktree "$worktree" --json | jq -r '.data.target.pane.id')" +pane="$(prowl create tab "$worktree" --json | jq -r '.data.target.pane.id')" test "$pane" != "$self_pane" ``` Prefer a `worktree.id` or `worktree.name` returned by `prowl list` over a hand-typed path; list preserves normalization such as trailing slashes. Use `--path` only for the new tab's working directory inside the selected worktree. -`prowl open /path` opens or focuses a matching project/path and may create a tab when needed. It is not guaranteed to create a new pane. Use `prowl tab create` for deterministic new terminal sessions. +`prowl open /path` opens or focuses a matching project/path and may create a tab when needed. It is not guaranteed to create a new pane. Use `prowl create tab` for deterministic new terminal sessions. Run a command and capture its result: @@ -99,14 +99,14 @@ printf '%s\n' 'echo first' 'echo second' | prowl send --pane "$pane" --capture - Close a temporary tab/pane when done: ```bash -prowl pane close --pane "$pane" --json -prowl tab close --tab "$tab" --json +prowl close "$pane" --json +prowl close --tab "$tab" --json ``` -`tab close` and `pane close` require an explicit `--tab`, `--pane`, `--worktree`, or `--target`; they intentionally do not default to the currently focused pane. For automation-created tabs, prefer the `tab.id` or `pane.id` returned by `tab create`. If the target has protected agent work or a long-running command, Prowl may ask for GUI confirmation. Use `--force` only after you have positively identified the target: +`close` requires an explicit pane or tab target and intentionally has no focus or worktree fallback. For automation-created tabs, prefer the `tab.id` or `pane.id` returned by `create tab`. If the target has protected agent work or a long-running command, Prowl may ask for GUI confirmation. Use `--force` only after you have positively identified the target: ```bash -prowl pane close --pane "$pane" --force --json +prowl close "$pane" --force --json ``` ## Parsing JSON Output @@ -124,8 +124,8 @@ out="$(prowl send --pane "$pane" 'git status --short' --capture --timeout 30 --j printf '%s\n' "$out" | jq -r '.data.capture.text' printf '%s\n' "$out" | jq -r '.data.wait.exit_code' -# tab create / open: new pane and tab ids -created="$(prowl tab create --worktree "$worktree" --json)" +# create tab / open: new pane and tab ids +created="$(prowl create tab "$worktree" --json)" printf '%s\n' "$created" | jq -r '.data.target.pane.id' printf '%s\n' "$created" | jq -r '.data.target.tab.id' @@ -144,7 +144,7 @@ Key fields by command (see `docs/components/cli.md` for the full contract): - `send` → `.data.input` (source/characters/bytes/trailing_enter_sent); `.data.wait.exit_code` and `.data.wait.duration_ms` when waiting; `.data.capture.text` / `.data.capture.line_count` / `.data.capture.truncated` when `--capture`. - `list` / `agents` → `.data.items[]` / `.data.agents[]`, each with `.pane.id`, `.tab.id`, and `.worktree.{id,name,path}`. Agent entries also include `.status`, `.raw_state`, and optional `.detection_reason`; list entries include `.task.status`. - `agents read` → `.data.agent` (current status/reason), `.data.blocker.text` when blocked, and `.data.result`. Only `.data.result.state == "complete"` carries `.data.result.text`; `pending`, `unavailable`, `missing`, `incomplete`, and `too_large` deliberately carry no partial text. -- `tab create` / `open` → `.data.target.{pane,tab,worktree}`. +- `create tab` / `open` → `.data.target.{pane,tab,worktree}`. ## Reading Agent Output @@ -252,7 +252,7 @@ prowl agents read "$pane" --json When no agent is blocked, use the same pattern with `working`, `done`, or `idle` depending on the task. The JSON payload also includes `.project.name`, `.project.branch`, `.worktree.path`, `.tab.title`, and `.pane.focused`, so automation can filter by human project label while still targeting the concrete pane. -`-t/--target` can auto-resolve pane UUID, tab UUID, or worktree id/name/path, but not short handles. Explicit `--pane ` is safer for automation; explicit `--pane ` is useful for a same-session human or agent handoff. +`-t/--target` and positional generic targets resolve `pN` as a pane, `tN` as a tab, then UUIDs and worktree references. A stale prefixed handle fails rather than falling back to a worktree of the same name. Explicit UUID `--pane` remains safest for automation. ## Argument Rules @@ -288,7 +288,7 @@ Avoid outer double quotes around payloads containing `$PWD`, `$VAR`, backticks, - Never omit `--pane` for `send`, `key`, `read`, or `focus` in automation. - Use `prowl agents --json` for discovery and `prowl agents read --json` for a supported agent's current semantic snapshot; use `prowl list --json` for all panes and worktree-level `task.status`. - `open /path` is a project/path navigation command. It may refocus an existing pane and is not a deterministic create command. -- Use `tab create` when automation needs a fresh shell, and capture the returned `pane.id` before sending input. +- Use `create tab` when automation needs a fresh shell, and capture the returned `pane.id` before sending input. - Focused pane is not stable; `open` and `focus` change it. - `read --wait-stable` sees rendered screen only. It cannot recover content folded by a TUI. - `read` returning fewer lines than `--last` requested is normally `truncated: false` — the pane simply has less history and you already have it all, so do not retry for more. `truncated: true` flags a possibly-incomplete result (the full scrollback could not be read). @@ -354,4 +354,4 @@ Write the briefing from your current working knowledge — required sections are ## Command Set -Current commands: `list`, `agents`, `agents read`, `read`, `send`, `key`, `focus`, `tab create`, `tab close`, `pane close`, `handoff to`, `handoff save`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with explicit `tab close` / `pane close` targets. +Current commands: `list`, `agents`, `agents read`, `read`, `send`, `key`, `focus`, `create tab`, `close`, `handoff to`, `handoff save`, and `open` (default). There is no CLI `quit`; close temporary tabs or panes with an explicit `close` target. `tab create`, `tab close`, and `pane close` remain deprecated aliases for one release. diff --git a/supacode/App/supacodeApp.swift b/supacode/App/supacodeApp.swift index fd8ef05a..66836fb9 100644 --- a/supacode/App/supacodeApp.swift +++ b/supacode/App/supacodeApp.swift @@ -802,72 +802,84 @@ struct SupacodeApp: App { return KeyDeliveryResult(attempted: repeatCount, delivered: delivered) } ) - let tabHandler = TabCommandHandler( - resolveProvider: { selector in - let resolver = TargetResolver { - TargetResolutionSnapshotBuilder.makeSnapshot( - repositoriesState: appStore.state.repositories, - terminalManager: terminalManager - ) - } - return resolver.resolve(selector).map { TabResolvedTarget(from: $0) } - }, - createTab: { target, path in - let repositories = Array(appStore.state.repositories.repositories) - guard let worktree = resolveCLITerminalWorktree(id: target.worktreeID, repositories: repositories) else { - return nil - } - selectCLIWorktreeContext( - worktreeID: target.worktreeID, - appStore: appStore, + let resolveTabTarget: TabCommandHandler.ResolveProvider = { selector in + let resolver = TargetResolver { + TargetResolutionSnapshotBuilder.makeSnapshot( + repositoriesState: appStore.state.repositories, terminalManager: terminalManager ) - let state = terminalManager.state(for: worktree) - let directory = path.map { URL(fileURLWithPath: $0, isDirectory: true) } - guard let tabID = state.createTab(workingDirectoryOverride: directory) else { - return nil - } - let resolver = makeTargetResolver(appStore: appStore, terminalManager: terminalManager) - switch resolver.resolve(.tab(tabID.rawValue.uuidString)) { - case .success(let resolved): - return TabResolvedTarget(from: resolved) - case .failure: - return nil - } - }, - closeTab: { target, force in - guard let tabUUID = UUID(uuidString: target.tabID), - let state = terminalManager.stateIfExists(for: target.worktreeID) - else { - return false - } - return state.closeTab( - TerminalTabID(rawValue: tabUUID), - confirmation: force ? .skip : .prompt(.tab) + } + return resolver.resolve(selector).map { TabResolvedTarget(from: $0) } + } + let resolveLifecycleTarget: LifecycleCommandHandler.ResolveCloseTargetProvider = { selector in + let resolver = TargetResolver { + TargetResolutionSnapshotBuilder.makeSnapshot( + repositoriesState: appStore.state.repositories, + terminalManager: terminalManager ) } + return resolver.resolveLifecycleTarget(selector) + } + let createTab: TabCommandHandler.CreateTabProvider = { target, path in + let repositories = Array(appStore.state.repositories.repositories) + guard let worktree = resolveCLITerminalWorktree(id: target.worktreeID, repositories: repositories) else { + return nil + } + selectCLIWorktreeContext( + worktreeID: target.worktreeID, + appStore: appStore, + terminalManager: terminalManager + ) + let state = terminalManager.state(for: worktree) + let directory = path.map { URL(fileURLWithPath: $0, isDirectory: true) } + guard let tabID = state.createTab(workingDirectoryOverride: directory) else { + return nil + } + let resolver = makeTargetResolver(appStore: appStore, terminalManager: terminalManager) + switch resolver.resolve(.tab(tabID.rawValue.uuidString)) { + case .success(let resolved): + return TabResolvedTarget(from: resolved) + case .failure: + return nil + } + } + let closeTab: TabCommandHandler.CloseTabProvider = { target, force in + guard let tabUUID = UUID(uuidString: target.tabID), + let state = terminalManager.stateIfExists(for: target.worktreeID) + else { + return false + } + return state.closeTab( + TerminalTabID(rawValue: tabUUID), + confirmation: force ? .skip : .prompt(.tab) + ) + } + let closePane: PaneCommandHandler.ClosePaneProvider = { target, force in + guard let paneID = UUID(uuidString: target.paneID), + let state = terminalManager.stateIfExists(for: target.worktreeID) + else { + return false + } + return state.closeSurface( + id: paneID, + confirmation: force ? .skip : .prompt(.pane) + ) + } + let lifecycleHandler = LifecycleCommandHandler( + resolveCreateTarget: resolveTabTarget, + resolveCloseTarget: resolveLifecycleTarget, + createTab: createTab, + closeTab: closeTab, + closePane: closePane + ) + let tabHandler = TabCommandHandler( + resolveProvider: resolveTabTarget, + createTab: createTab, + closeTab: closeTab ) let paneHandler = PaneCommandHandler( - resolveProvider: { selector in - let resolver = TargetResolver { - TargetResolutionSnapshotBuilder.makeSnapshot( - repositoriesState: appStore.state.repositories, - terminalManager: terminalManager - ) - } - return resolver.resolve(selector).map { TabResolvedTarget(from: $0) } - }, - closePane: { target, force in - guard let paneID = UUID(uuidString: target.paneID), - let state = terminalManager.stateIfExists(for: target.worktreeID) - else { - return false - } - return state.closeSurface( - id: paneID, - confirmation: force ? .skip : .prompt(.pane) - ) - } + resolveProvider: resolveTabTarget, + closePane: closePane ) let handoffHandler = HandoffCommandHandler( resolveProvider: { selector, callerPID in @@ -948,6 +960,8 @@ struct SupacodeApp: App { sendHandler: sendHandler, keyHandler: keyHandler, readHandler: readHandler, + createHandler: lifecycleHandler, + closeHandler: lifecycleHandler, tabHandler: tabHandler, paneHandler: paneHandler, handoffHandler: handoffHandler diff --git a/supacode/CLIService/CLICommandRouter.swift b/supacode/CLIService/CLICommandRouter.swift index 18dd3350..8367ed8d 100644 --- a/supacode/CLIService/CLICommandRouter.swift +++ b/supacode/CLIService/CLICommandRouter.swift @@ -13,6 +13,8 @@ final class CLICommandRouter { private let sendHandler: any CommandHandler private let keyHandler: any CommandHandler private let readHandler: any CommandHandler + private let createHandler: any CommandHandler + private let closeHandler: any CommandHandler private let tabHandler: any CommandHandler private let paneHandler: any CommandHandler private let handoffHandler: any CommandHandler @@ -26,6 +28,8 @@ final class CLICommandRouter { sendHandler: any CommandHandler = StubCommandHandler(command: "send"), keyHandler: any CommandHandler = StubCommandHandler(command: "key"), readHandler: any CommandHandler = StubCommandHandler(command: "read"), + createHandler: any CommandHandler = StubCommandHandler(command: "create"), + closeHandler: any CommandHandler = StubCommandHandler(command: "close"), tabHandler: any CommandHandler = StubCommandHandler(command: "tab"), paneHandler: any CommandHandler = StubCommandHandler(command: "pane"), handoffHandler: any CommandHandler = StubCommandHandler(command: "handoff") @@ -38,6 +42,8 @@ final class CLICommandRouter { self.sendHandler = sendHandler self.keyHandler = keyHandler self.readHandler = readHandler + self.createHandler = createHandler + self.closeHandler = closeHandler self.tabHandler = tabHandler self.paneHandler = paneHandler self.handoffHandler = handoffHandler @@ -57,6 +63,8 @@ final class CLICommandRouter { case .send: handler = sendHandler case .key: handler = keyHandler case .read: handler = readHandler + case .create: handler = createHandler + case .close: handler = closeHandler case .tab: handler = tabHandler case .pane: handler = paneHandler case .handoff: handler = handoffHandler diff --git a/supacode/CLIService/LifecycleCommandHandler.swift b/supacode/CLIService/LifecycleCommandHandler.swift new file mode 100644 index 00000000..0cefa321 --- /dev/null +++ b/supacode/CLIService/LifecycleCommandHandler.swift @@ -0,0 +1,202 @@ +import Foundation + +struct LifecycleResolvedTarget: Sendable, Equatable { + let resource: LifecycleResource + let target: TabResolvedTarget +} + +@MainActor +final class LifecycleCommandHandler: CommandHandler { + typealias ResolveCreateTargetProvider = @MainActor (TargetSelector) -> Result + typealias ResolveCloseTargetProvider = + @MainActor (TargetSelector) -> Result + typealias CreateTabProvider = @MainActor (TabResolvedTarget, String?) -> TabResolvedTarget? + typealias CloseTabProvider = @MainActor (TabResolvedTarget, Bool) -> Bool + typealias ClosePaneProvider = @MainActor (TabResolvedTarget, Bool) -> Bool + + private let resolveCreateTarget: ResolveCreateTargetProvider + private let resolveCloseTarget: ResolveCloseTargetProvider + private let createTab: CreateTabProvider + private let closeTab: CloseTabProvider + private let closePane: ClosePaneProvider + + init( + resolveCreateTarget: @escaping ResolveCreateTargetProvider, + resolveCloseTarget: @escaping ResolveCloseTargetProvider, + createTab: @escaping CreateTabProvider, + closeTab: @escaping CloseTabProvider, + closePane: @escaping ClosePaneProvider + ) { + self.resolveCreateTarget = resolveCreateTarget + self.resolveCloseTarget = resolveCloseTarget + self.createTab = createTab + self.closeTab = closeTab + self.closePane = closePane + } + + // swiftlint:disable:next async_without_await + func handle(envelope: CommandEnvelope) async -> CommandResponse { + switch envelope.command { + case .create(let input): + return handleCreate(input) + case .close(let input): + return handleClose(input) + default: + return errorResponse( + command: envelope.command.name, code: CLIErrorCode.invalidArgument, message: "Invalid command.") + } + } + + private func handleCreate(_ input: CreateInput) -> CommandResponse { + guard input.resource == .tab else { + return errorResponse( + command: "create", + code: CLIErrorCode.invalidArgument, + message: "create pane is not available yet." + ) + } + guard case .worktree = input.selector else { + return errorResponse( + command: "create", + code: CLIErrorCode.invalidArgument, + message: "create tab requires a worktree target." + ) + } + + let target: TabResolvedTarget + switch resolveCreateTarget(input.selector) { + case .success(let resolved): + target = resolved + case .failure(let error): + return mapResolverError(command: "create", error: error) + } + + let path = normalizedAllowedPath(input.path, worktreePath: target.worktreePath) + guard input.path == nil || path != nil else { + return errorResponse( + command: "create", + code: CLIErrorCode.pathNotAllowed, + message: "Tab path must be inside the resolved worktree." + ) + } + guard let createdTarget = createTab(target, path) else { + return errorResponse(command: "create", code: CLIErrorCode.createFailed, message: "Failed to create tab.") + } + return success(command: "create", resource: .tab, target: createdTarget) + } + + private func handleClose(_ input: CloseInput) -> CommandResponse { + guard !input.selector.isNone else { + return errorResponse( + command: "close", + code: CLIErrorCode.invalidArgument, + message: "close requires an explicit pane or tab target." + ) + } + + let resolved: LifecycleResolvedTarget + switch resolveCloseTarget(input.selector) { + case .success(let target): + resolved = target + case .failure(let error): + return mapResolverError(command: "close", error: error) + } + + let didClose = + switch resolved.resource { + case .tab: + closeTab(resolved.target, input.force) + case .pane: + closePane(resolved.target, input.force) + } + guard didClose else { + return errorResponse( + command: "close", + code: CLIErrorCode.closeFailed, + message: "Failed to close \(resolved.resource.rawValue)." + ) + } + return success(command: "close", resource: resolved.resource, target: resolved.target) + } + + private func normalizedAllowedPath(_ path: String?, worktreePath: String) -> String? { + guard let path else { return nil } + let normalizedPath = normalize(path) + let normalizedWorktree = normalize(worktreePath) + guard normalizedPath == normalizedWorktree || normalizedPath.hasPrefix(normalizedWorktree + "/") else { + return nil + } + return normalizedPath + } + + private func normalize(_ path: String) -> String { + URL(fileURLWithPath: path, isDirectory: true) + .standardizedFileURL + .path(percentEncoded: false) + .trimmingTrailingSlash() + } + + private func success(command: String, resource: LifecycleResource, target: TabResolvedTarget) -> CommandResponse { + do { + return try CommandResponse( + ok: true, + command: command, + schemaVersion: "prowl.cli.\(command).v1", + data: RawJSON(encoding: LifecycleCommandPayload(resource: resource, target: makePayloadTarget(from: target))) + ) + } catch { + return errorResponse(command: command, code: CLIErrorCode.createFailed, message: "Failed to encode response.") + } + } + + private func makePayloadTarget(from target: TabResolvedTarget) -> TabTarget { + TabTarget( + worktree: TabTargetWorktree( + id: target.worktreeID, + name: target.worktreeName, + path: target.worktreePath, + rootPath: target.worktreeRootPath, + kind: target.worktreeKind + ), + tab: TabTargetTab( + id: target.tabID, + title: target.tabTitle, + selected: target.tabSelected + ), + pane: TabTargetPane( + id: target.paneID, + title: target.paneTitle, + cwd: target.paneCWD, + focused: target.paneFocused + ) + ) + } + + private func mapResolverError(command: String, error: TargetResolverError) -> CommandResponse { + switch error { + case .notFound(let message): + errorResponse(command: command, code: CLIErrorCode.targetNotFound, message: message) + case .notUnique(let message): + errorResponse(command: command, code: CLIErrorCode.targetNotUnique, message: message) + } + } + + private func errorResponse(command: String, code: String, message: String) -> CommandResponse { + CommandResponse( + ok: false, + command: command, + schemaVersion: "prowl.cli.\(command).v1", + error: CommandError(code: code, message: message) + ) + } +} + +extension String { + fileprivate func trimmingTrailingSlash() -> String { + var value = self + while value.count > 1, value.hasSuffix("/") { + value.removeLast() + } + return value + } +} diff --git a/supacode/CLIService/Shared/CommandEnvelope.swift b/supacode/CLIService/Shared/CommandEnvelope.swift index ad0f07c9..10cd9098 100644 --- a/supacode/CLIService/Shared/CommandEnvelope.swift +++ b/supacode/CLIService/Shared/CommandEnvelope.swift @@ -22,6 +22,8 @@ public enum Command: Codable, Sendable { case send(SendInput) case key(KeyInput) case read(ReadInput) + case create(CreateInput) + case close(CloseInput) case tab(TabInput) case pane(PaneInput) case handoff(HandoffInput) @@ -36,6 +38,8 @@ public enum Command: Codable, Sendable { case .send: "send" case .key: "key" case .read: "read" + case .create: "create" + case .close: "close" case .tab: "tab" case .pane: "pane" case .handoff: "handoff" diff --git a/supacode/CLIService/Shared/ErrorCodes.swift b/supacode/CLIService/Shared/ErrorCodes.swift index 6171d4b0..e28c1375 100644 --- a/supacode/CLIService/Shared/ErrorCodes.swift +++ b/supacode/CLIService/Shared/ErrorCodes.swift @@ -49,6 +49,10 @@ public enum CLIErrorCode { // Read public static let readFailed = "READ_FAILED" + // Lifecycle + public static let createFailed = "CREATE_FAILED" + public static let closeFailed = "CLOSE_FAILED" + // Tab public static let tabFailed = "TAB_FAILED" diff --git a/supacode/CLIService/Shared/InputModels.swift b/supacode/CLIService/Shared/InputModels.swift index b69457cf..d4c7ba01 100644 --- a/supacode/CLIService/Shared/InputModels.swift +++ b/supacode/CLIService/Shared/InputModels.swift @@ -200,6 +200,33 @@ public struct ReadInput: Codable, Sendable { } } +public enum LifecycleResource: String, Codable, Sendable, Equatable { + case tab + case pane +} + +public struct CreateInput: Codable, Sendable { + public let resource: LifecycleResource + public let selector: TargetSelector + public let path: String? + + public init(resource: LifecycleResource, selector: TargetSelector, path: String? = nil) { + self.resource = resource + self.selector = selector + self.path = path + } +} + +public struct CloseInput: Codable, Sendable { + public let selector: TargetSelector + public let force: Bool + + public init(selector: TargetSelector, force: Bool = false) { + self.selector = selector + self.force = force + } +} + public enum TabAction: String, Codable, Sendable { case create case close diff --git a/supacode/CLIService/Shared/LifecycleCommandPayload.swift b/supacode/CLIService/Shared/LifecycleCommandPayload.swift new file mode 100644 index 00000000..05e119e3 --- /dev/null +++ b/supacode/CLIService/Shared/LifecycleCommandPayload.swift @@ -0,0 +1,11 @@ +import Foundation + +public struct LifecycleCommandPayload: Codable, Sendable, Equatable { + public let resource: LifecycleResource + public let target: TabTarget + + public init(resource: LifecycleResource, target: TabTarget) { + self.resource = resource + self.target = target + } +} diff --git a/supacode/CLIService/TargetResolver.swift b/supacode/CLIService/TargetResolver.swift index 5adad53d..49fb654f 100644 --- a/supacode/CLIService/TargetResolver.swift +++ b/supacode/CLIService/TargetResolver.swift @@ -55,6 +55,44 @@ final class TargetResolver { } } + /// Resolves an explicit pane-or-tab lifecycle target without allowing worktree fallback. + func resolveLifecycleTarget(_ selector: TargetSelector) -> Result { + let snapshot = snapshotProvider() + switch selector { + case .pane(let value): + return resolvePane(value, snapshot).map { + LifecycleResolvedTarget(resource: .pane, target: TabResolvedTarget(from: $0)) + } + case .tab(let value): + return resolveTab(value, snapshot).map { + LifecycleResolvedTarget(resource: .tab, target: TabResolvedTarget(from: $0)) + } + case .auto(let value): + if isPrefixedShortHandle(value, prefix: "p") { + return resolvePane(value, snapshot).map { + LifecycleResolvedTarget(resource: .pane, target: TabResolvedTarget(from: $0)) + } + } + if isPrefixedShortHandle(value, prefix: "t") { + return resolveTab(value, snapshot).map { + LifecycleResolvedTarget(resource: .tab, target: TabResolvedTarget(from: $0)) + } + } + guard UUID(uuidString: value) != nil else { + return .failure(.notFound("Lifecycle target '\(value)' must be a pane/tab UUID or prefixed handle.")) + } + if case .success(let target) = resolvePane(value, snapshot) { + return .success(LifecycleResolvedTarget(resource: .pane, target: TabResolvedTarget(from: target))) + } + if case .success(let target) = resolveTab(value, snapshot) { + return .success(LifecycleResolvedTarget(resource: .tab, target: TabResolvedTarget(from: target))) + } + return .failure(.notFound("Pane or tab '\(value)' not found.")) + case .none, .worktree: + return .failure(.notFound("Lifecycle commands require an explicit pane or tab target.")) + } + } + // MARK: - .none: focused worktree → selected tab → focused pane private func resolveNone(_ snapshot: TargetResolutionSnapshot) -> Result { @@ -143,12 +181,19 @@ final class TargetResolver { return .failure(.notFound("Pane '\(value)' not found.")) } - // MARK: - .auto: try pane → tab → worktree + // MARK: - .auto: typed handle → UUID → worktree private func resolveAuto( _ value: String, _ snapshot: TargetResolutionSnapshot ) -> Result { + if isPrefixedShortHandle(value, prefix: "p") { + return resolvePane(value, snapshot) + } + if isPrefixedShortHandle(value, prefix: "t") { + return resolveTab(value, snapshot) + } + // Try as pane UUID first (most specific) if UUID(uuidString: value) != nil { if case .success(let target) = resolvePane(value, snapshot) { @@ -181,6 +226,10 @@ final class TargetResolver { return pane.handle == handle } + private func isPrefixedShortHandle(_ selector: String, prefix: Character) -> Bool { + selector.lowercased().first == prefix && shortHandle(in: selector, prefix: prefix) != nil + } + private func shortHandle(in selector: String, prefix: Character) -> Int? { let normalized = selector.lowercased() let digits: Substring diff --git a/supacodeTests/CLILifecycleCommandHandlerTests.swift b/supacodeTests/CLILifecycleCommandHandlerTests.swift new file mode 100644 index 00000000..58caaff5 --- /dev/null +++ b/supacodeTests/CLILifecycleCommandHandlerTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing + +@testable import supacode + +@MainActor +struct CLILifecycleCommandHandlerTests { + @Test func createTabResolvesWorktreeCreatesTabAndReturnsCreatePayload() async throws { + let base = makeTarget(tabID: "base-tab", paneID: "base-pane") + let created = makeTarget(tabID: "created-tab", paneID: "created-pane") + var resolvedSelector: TargetSelector? + var createPath: String? + let handler = LifecycleCommandHandler( + resolveCreateTarget: { selector in + resolvedSelector = selector + return .success(base) + }, + resolveCloseTarget: { _ in .success(LifecycleResolvedTarget(resource: .pane, target: base)) }, + createTab: { _, path in + createPath = path + return created + }, + closeTab: { _, _ in true }, + closePane: { _, _ in true } + ) + + let response = await handler.handle( + envelope: CommandEnvelope( + output: .json, + command: .create(CreateInput(resource: .tab, selector: .worktree("App"), path: "/Projects/App")) + ) + ) + + #expect(response.ok) + #expect(response.command == "create") + #expect(response.schemaVersion == "prowl.cli.create.v1") + #expect(resolvedSelector == .worktree("App")) + #expect(createPath == "/Projects/App") + let data = try #require(response.data) + let payload = try data.decode(as: LifecycleCommandPayload.self) + #expect(payload.resource == .tab) + #expect(payload.target.tab.id == "created-tab") + } + + @Test func closeUsesResolvedResourceAndReturnsClosePayload() async throws { + let target = makeTarget(tabID: "tab-to-close", paneID: "pane-to-close") + var closedPane: TabResolvedTarget? + let handler = LifecycleCommandHandler( + resolveCreateTarget: { _ in .success(target) }, + resolveCloseTarget: { selector in + #expect(selector == .auto("p12")) + return .success(LifecycleResolvedTarget(resource: .pane, target: target)) + }, + createTab: { _, _ in nil }, + closeTab: { _, _ in false }, + closePane: { target, force in + #expect(force) + closedPane = target + return true + } + ) + + let response = await handler.handle( + envelope: CommandEnvelope(output: .json, command: .close(CloseInput(selector: .auto("p12"), force: true))) + ) + + #expect(response.ok) + #expect(response.command == "close") + #expect(response.schemaVersion == "prowl.cli.close.v1") + #expect(closedPane == target) + let data = try #require(response.data) + let payload = try data.decode(as: LifecycleCommandPayload.self) + #expect(payload.resource == .pane) + #expect(payload.target.pane.id == "pane-to-close") + } + + private func makeTarget( + worktreeID: String = "App:/Projects/App", + worktreeName: String = "App", + worktreePath: String = "/Projects/App", + worktreeRootPath: String = "/Projects/App", + worktreeKind: String = "git", + tabID: String = "tab-1", + tabTitle: String = "App 1", + tabSelected: Bool = true, + paneID: String = "pane-1", + paneTitle: String = "zsh", + paneCWD: String? = "/Projects/App", + paneFocused: Bool = true + ) -> TabResolvedTarget { + TabResolvedTarget( + worktreeID: worktreeID, + worktreeName: worktreeName, + worktreePath: worktreePath, + worktreeRootPath: worktreeRootPath, + worktreeKind: worktreeKind, + tabID: tabID, + tabTitle: tabTitle, + tabSelected: tabSelected, + paneID: paneID, + paneTitle: paneTitle, + paneCWD: paneCWD, + paneFocused: paneFocused + ) + } +} diff --git a/supacodeTests/CLITargetResolverTests.swift b/supacodeTests/CLITargetResolverTests.swift index bd896595..d5230b5f 100644 --- a/supacodeTests/CLITargetResolverTests.swift +++ b/supacodeTests/CLITargetResolverTests.swift @@ -31,12 +31,14 @@ struct CLITargetResolverTests { } } - @Test func autoSelectorKeepsNumericValuesForWorktrees() throws { + @Test func autoSelectorResolvesPrefixedHandlesAndKeepsBareNumbersForWorktrees() throws { + let tabID = UUID() + let paneID = UUID() let paneSnapshot = makeSnapshot( worktreeID: "pane-worktree", worktreeName: "other", - tab: (id: UUID(), handle: 1), - panes: [(id: UUID(), handle: 3)], + tab: (id: tabID, handle: 1), + panes: [(id: paneID, handle: 3)], focusedPaneID: nil ) let numericWorktreeSnapshot = makeSnapshot( @@ -53,13 +55,69 @@ struct CLITargetResolverTests { ) } + let paneTarget = try resolvedTarget(from: resolver.resolve(.auto("p3"))) + #expect(paneTarget.paneID == paneID) + + let tabTarget = try resolvedTarget(from: resolver.resolve(.auto("t1"))) + #expect(tabTarget.tabID == tabID) + let numericTarget = try resolvedTarget(from: resolver.resolve(.auto("3"))) #expect(numericTarget.worktreeID == "numeric-worktree") + } + + @Test func lifecycleResolverRoutesOnlyTabsAndPanes() throws { + let tabID = UUID() + let paneID = UUID() + let snapshot = makeSnapshot( + worktreeID: "worktree", + worktreeName: "main", + tab: (id: tabID, handle: 6), + panes: [(id: paneID, handle: 12)], + focusedPaneID: paneID + ) + let resolver = TargetResolver { snapshot } - if case .failure(.notFound) = resolver.resolve(.auto("p3")) { - // Expected: short handles are explicit-selector-only. + let pane = try lifecycleTarget(from: resolver.resolveLifecycleTarget(.auto("p12"))) + #expect(pane.resource == .pane) + #expect(pane.target.paneID == paneID.uuidString) + + let tab = try lifecycleTarget(from: resolver.resolveLifecycleTarget(.auto("t6"))) + #expect(tab.resource == .tab) + #expect(tab.target.tabID == tabID.uuidString) + + if case .failure(.notFound) = resolver.resolveLifecycleTarget(.worktree("main")) { + // Expected: lifecycle operations never project a worktree to a tab or pane. } else { - Issue.record("--target must not resolve a short pane handle") + Issue.record("Lifecycle commands must reject worktree targets.") + } + } + + @Test func stalePrefixedHandleDoesNotFallBackToWorktree() { + let snapshot = makeSnapshot( + worktreeID: "p3-worktree", + worktreeName: "p3", + tab: (id: UUID(), handle: 1), + panes: [(id: UUID(), handle: 2)], + focusedPaneID: nil + ) + let resolver = TargetResolver { snapshot } + + guard case .failure(.notFound(let message)) = resolver.resolve(.auto("p3")) else { + Issue.record("A stale prefixed handle must not resolve as a worktree.") + return + } + #expect(message.contains("Pane 'p3' not found")) + } + + private func lifecycleTarget( + from result: Result + ) throws -> LifecycleResolvedTarget { + switch result { + case .success(let target): + return target + case .failure(let error): + Issue.record("Unexpected lifecycle resolution failure: \(error)") + throw error } }