From 299fd3d3cb33c2fa5b7b860c705b267cb0704e1d Mon Sep 17 00:00:00 2001 From: arzafran Date: Wed, 4 Nov 2026 18:29:23 -0300 Subject: [PATCH] refactor: split CLI command descriptors into CLI/Commands and build the table once (INC-494) --- CLI/CLI+CodexConfig.swift | 1001 +++++++ CLI/CLI+Hooks.swift | 995 ------- CLI/Commands/LocalCommands.swift | 145 ++ CLI/Commands/NotificationCommands.swift | 154 ++ CLI/Commands/PaneCommands.swift | 808 ++++++ CLI/Commands/SidebarCommands.swift | 316 +++ CLI/Commands/SystemCommands.swift | 202 ++ CLI/Commands/TerminalIOCommands.swift | 593 +++++ CLI/Commands/WindowCommands.swift | 195 ++ CLI/Commands/WorkspaceCommands.swift | 327 +++ CLI/Commands/WorktreeCommands.swift | 315 +++ CLI/programa.swift | 3150 +---------------------- GhosttyTabs.xcodeproj/project.pbxproj | 40 + scripts/check-structural-budgets.py | 6 +- 14 files changed, 4189 insertions(+), 4058 deletions(-) create mode 100644 CLI/CLI+CodexConfig.swift create mode 100644 CLI/Commands/LocalCommands.swift create mode 100644 CLI/Commands/NotificationCommands.swift create mode 100644 CLI/Commands/PaneCommands.swift create mode 100644 CLI/Commands/SidebarCommands.swift create mode 100644 CLI/Commands/SystemCommands.swift create mode 100644 CLI/Commands/TerminalIOCommands.swift create mode 100644 CLI/Commands/WindowCommands.swift create mode 100644 CLI/Commands/WorkspaceCommands.swift create mode 100644 CLI/Commands/WorktreeCommands.swift diff --git a/CLI/CLI+CodexConfig.swift b/CLI/CLI+CodexConfig.swift new file mode 100644 index 000000000..c55dc1611 --- /dev/null +++ b/CLI/CLI+CodexConfig.swift @@ -0,0 +1,1001 @@ +import Foundation +import CryptoKit +import Darwin +import TOML + +extension ProgramaCLI { + // MARK: - Codex hooks + + static let codexMaximumFileBytes = 16 * 1024 * 1024 + /// Everything Programa writes to config.toml lives between these two lines and is + /// rewritten whole. Its content is tables only, so it is valid wherever it lands. + private static let codexBlockBegin = "# BEGIN programa (managed by Programa, do not edit)" + private static let codexBlockEnd = "# END programa" + private static let codexTrustBlockStart = "# >>> programa managed codex hook trust v1 >>>" + private static let codexTrustBlockEnd = "# <<< programa managed codex hook trust v1 <<<" + private static let codexLegacyTrustBlockStart = "# >>> programa codex hook trust >>>" + private static let codexLegacyTrustBlockEnd = "# <<< programa codex hook trust <<<" + + private struct CodexHookSpec { + let event: String + let label: String + let commandEvent: String + let timeout: Int + } + + private static let codexHookSpecs = [ + CodexHookSpec(event: "SessionStart", label: "session_start", commandEvent: "session-start", timeout: 10), + CodexHookSpec(event: "UserPromptSubmit", label: "user_prompt_submit", commandEvent: "prompt-submit", timeout: 10), + CodexHookSpec(event: "Stop", label: "stop", commandEvent: "stop", timeout: 10), + CodexHookSpec(event: "PermissionRequest", label: "permission_request", commandEvent: "notification", timeout: 10), + CodexHookSpec(event: "SessionEnd", label: "session_end", commandEvent: "session-end", timeout: 1), + ] + private static let codexOwnedCommandEventByHookEvent: [String: String] = { + var result = Dictionary(uniqueKeysWithValues: codexHookSpecs.map { ($0.event, $0.commandEvent) }) + result["Notification"] = "notification" + return result + }() + + struct CodexFileSnapshot: Equatable { + let data: Data + let mode: mode_t + } + + struct CodexPaths { + let home: String + let lockHome: String + let hooks: String + let configLink: String + let configTarget: String + } + + struct CodexTrustEdit { + let configPath: String + let configLinkPath: String + let hooksKeyPrefix: String + let desired: [String: String] + /// Trust hashes of the Programa handlers already in hooks.json, by trust key. + let previousOwned: [String: String] + let ownedHashes: Set + } + + struct CodexPreparedConfig { + let edit: CodexTrustEdit + let snapshot: CodexFileSnapshot? + let rendered: Data + } + + private struct CodexRenderedConfig { + let text: String + let warnings: [String] + } + + private enum CodexTOMLStringMode { + case none + case basic + case literal + case multilineBasic + case multilineLiteral + } + + private struct CodexTOMLLine { + let range: Range + let text: String + let startsInString: Bool + } + + private indirect enum CodexTOMLValue: Decodable, Equatable { + case string(String) + case integer(Int64) + case float(Double) + case boolean(Bool) + case offsetDateTime(Date) + case localDateTime(LocalDateTime) + case localDate(LocalDate) + case localTime(LocalTime) + case array([CodexTOMLValue]) + case table([String: CodexTOMLValue]) + case null + + private struct Key: CodingKey { + let stringValue: String + let intValue: Int? = nil + init?(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } + } + + init(from decoder: Decoder) throws { + if let container = try? decoder.container(keyedBy: Key.self) { + var result: [String: CodexTOMLValue] = [:] + for key in container.allKeys { + result[key.stringValue] = try container.decode(CodexTOMLValue.self, forKey: key) + } + self = .table(result) + return + } + if var container = try? decoder.unkeyedContainer() { + var result: [CodexTOMLValue] = [] + while !container.isAtEnd { + result.append(try container.decode(CodexTOMLValue.self)) + } + self = .array(result) + return + } + let container = try decoder.singleValueContainer() + if container.decodeNil() { self = .null; return } + if let value = try? container.decode(Bool.self) { self = .boolean(value); return } + if let value = try? container.decode(Int64.self) { self = .integer(value); return } + if let value = try? container.decode(Double.self) { self = .float(value); return } + if let value = try? container.decode(String.self) { self = .string(value); return } + if let value = try? container.decode(Date.self) { self = .offsetDateTime(value); return } + if let value = try? container.decode(LocalDateTime.self) { self = .localDateTime(value); return } + if let value = try? container.decode(LocalDate.self) { self = .localDate(value); return } + if let value = try? container.decode(LocalTime.self) { self = .localTime(value); return } + throw DecodingError.typeMismatch( + CodexTOMLValue.self, + .init(codingPath: decoder.codingPath, debugDescription: "Unsupported TOML value") + ) + } + + var tableValue: [String: CodexTOMLValue]? { + guard case .table(let value) = self else { return nil } + return value + } + + var stringValue: String? { + guard case .string(let value) = self else { return nil } + return value + } + } + + struct CodexHooksError: LocalizedError { + let message: String + var errorDescription: String? { message } + } + + /// Each installed hook gracefully no-ops outside a programa surface. + private static func codexHookCommand(_ event: String) -> String { + "[ -n \"$PROGRAMA_SURFACE_ID\" ] && command -v programa >/dev/null 2>&1 && programa codex-hook \(event) || echo '{}'" + } + + func codexPaths() throws -> CodexPaths { + let environment = ProcessInfo.processInfo.environment + let override = environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines) + let explicit = override?.isEmpty == false + let rawHome = explicit ? override! : "~/.codex" + let expanded = Self.integrationExpandTilde(rawHome) + let absolute = expanded.hasPrefix("/") + ? URL(fileURLWithPath: expanded).standardizedFileURL.path + : URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(expanded).standardizedFileURL.path + try FileManager.default.createDirectory(atPath: absolute, withIntermediateDirectories: true, attributes: nil) + guard let resolvedHome = absolute.withCString({ realpath($0, nil) }) else { + throw codexPOSIXError("resolve", path: absolute) + } + defer { free(resolvedHome) } + let canonicalHome = String(cString: resolvedHome) + let home = explicit ? canonicalHome : absolute + let hooks = (home as NSString).appendingPathComponent("hooks.json") + let configLink = (home as NSString).appendingPathComponent("config.toml") + let configTarget = try codexResolveConfigTarget(configLink) + return CodexPaths(home: home, lockHome: canonicalHome, hooks: hooks, configLink: configLink, configTarget: configTarget) + } + + func codexResolveConfigTarget(_ path: String) throws -> String { + var info = stat() + let result = path.withCString { Darwin.lstat($0, &info) } + if result != 0 { + if errno == ENOENT { return path } + throw codexPOSIXError("inspect", path: path) + } + guard (info.st_mode & S_IFMT) == S_IFLNK else { return path } + guard let resolved = path.withCString({ realpath($0, nil) }) else { + throw CodexHooksError(message: "\(path) is a dangling symlink; refusing to replace it") + } + defer { free(resolved) } + return String(cString: resolved) + } + + func withCodexHooksLock(at home: String, _ body: () throws -> T) throws -> T { + let path = (home as NSString).appendingPathComponent(".programa-hooks.lock") + var existing = stat() + let createdLock = path.withCString { Darwin.lstat($0, &existing) } != 0 + let descriptor = path.withCString { Darwin.open($0, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0o600) } + guard descriptor >= 0 else { throw codexPOSIXError("open lock", path: path) } + defer { Darwin.close(descriptor) } + var succeeded = false + // A declined or failed run leaves no new file behind in the user's Codex home. + defer { if createdLock && !succeeded { _ = path.withCString { unlink($0) } } } + var info = stat() + guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else { + throw CodexHooksError(message: "\(path) is not a regular lock file") + } + guard flock(descriptor, LOCK_EX) == 0 else { throw codexPOSIXError("lock", path: path) } + defer { flock(descriptor, LOCK_UN) } + let result = try body() + succeeded = true + return result + } + + func codexReadRegularFile(_ path: String, limit: Int, refuseSymlink: Bool) throws -> CodexFileSnapshot? { + if refuseSymlink { + var linkInfo = stat() + let result = path.withCString { Darwin.lstat($0, &linkInfo) } + if result == 0 { + if (linkInfo.st_mode & S_IFMT) == S_IFLNK { + throw CodexHooksError(message: "refusing to read symlink \(path)") + } + } else if errno != ENOENT { + throw codexPOSIXError("inspect", path: path) + } + } + let descriptor = path.withCString { Darwin.open($0, O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW) } + guard descriptor >= 0 else { + if errno == ENOENT { return nil } + throw codexPOSIXError("open", path: path) + } + defer { Darwin.close(descriptor) } + var info = stat() + guard fstat(descriptor, &info) == 0 else { throw codexPOSIXError("inspect", path: path) } + guard (info.st_mode & S_IFMT) == S_IFREG else { + throw CodexHooksError(message: "\(path) is not a regular file; refusing to read it") + } + guard info.st_size >= 0, info.st_size <= Int64(limit) else { + throw CodexHooksError(message: "\(path) exceeds 16 MiB") + } + var data = Data() + data.reserveCapacity(Int(info.st_size)) + var buffer = [UInt8](repeating: 0, count: 8192) + while true { + let count = Darwin.read(descriptor, &buffer, buffer.count) + if count == 0 { break } + if count < 0 { + if errno == EINTR { continue } + throw codexPOSIXError("read", path: path) + } + data.append(buffer, count: count) + guard data.count <= limit else { throw CodexHooksError(message: "\(path) exceeds 16 MiB") } + } + return CodexFileSnapshot(data: data, mode: info.st_mode & 0o777) + } + + func codexHooksRoot(from data: Data?, path: String) throws -> [String: Any] { + guard let data else { return [:] } + let value: Any + do { value = try JSONSerialization.jsonObject(with: data) } + catch { throw CodexHooksError(message: "\(path) is not valid JSON: \(error.localizedDescription)") } + guard let root = value as? [String: Any] else { + throw CodexHooksError(message: "\(path) must contain a JSON object") + } + return root + } + + func codexRewriteHooks(_ source: [String: Any], install: Bool) throws -> [String: Any] { + var root = source + let rawHooks = root["hooks"] + guard rawHooks == nil || rawHooks is [String: Any] else { + throw CodexHooksError(message: "hooks.json hooks must be an object") + } + if rawHooks == nil, !install { return root } + var hooks = rawHooks as? [String: Any] ?? [:] + for event in Array(hooks.keys) { + guard let rawGroups = hooks[event] as? [Any] else { + throw CodexHooksError(message: "hooks.json event \(event) must be an array") + } + // Codex trust keys are positional. On install the first owned handler of + // a spec event is rewritten where it stands so foreign handlers that + // follow it keep their (group, handler) positions. + let spec = install ? Self.codexHookSpecs.first { $0.event == event } : nil + var updatedInPlace = false + var rewritten: [[String: Any]] = [] + for (groupIndex, rawGroup) in rawGroups.enumerated() { + guard var group = rawGroup as? [String: Any] else { + throw CodexHooksError(message: "hooks.json \(event) group \(groupIndex) must be an object") + } + guard let rawHandlers = group["hooks"] as? [Any] else { + throw CodexHooksError(message: "hooks.json \(event) group \(groupIndex).hooks must be an array") + } + var handlers: [[String: Any]] = [] + var ownedOnlyGroup = false + for (handlerIndex, rawHandler) in rawHandlers.enumerated() { + guard let handler = rawHandler as? [String: Any] else { + throw CodexHooksError(message: "hooks.json \(event) handler \(handlerIndex) must be an object") + } + if let command = handler["command"], !(command is String) { + throw CodexHooksError(message: "hooks.json \(event) handler \(handlerIndex).command must be a string") + } + if !codexIsOwnedHandler(handler, event: event) { + handlers.append(handler) + } else if let spec, !updatedInPlace { + updatedInPlace = true + handlers.append(Self.codexCanonicalHandler(spec)) + ownedOnlyGroup = rawHandlers.count == 1 + } + } + if ownedOnlyGroup { + // A group holding only Programa's handler is replaced whole so a + // stale matcher cannot narrow it. + rewritten.append(["hooks": handlers]) + } else if !handlers.isEmpty { + group["hooks"] = handlers + rewritten.append(group) + } + } + if rewritten.isEmpty { hooks.removeValue(forKey: event) } + else { hooks[event] = rewritten } + } + if install { + for spec in Self.codexHookSpecs { + var groups = hooks[spec.event] as? [[String: Any]] ?? [] + let hasOwned = groups.contains { group in + (group["hooks"] as? [[String: Any]] ?? []).contains { codexIsOwnedHandler($0, event: spec.event) } + } + if !hasOwned { + groups.append(["hooks": [Self.codexCanonicalHandler(spec)]]) + } + hooks[spec.event] = groups + } + } + root["hooks"] = hooks + try codexValidateForeignHandlerPositions(before: source, after: root) + return root + } + + private static func codexCanonicalHandler(_ spec: CodexHookSpec) -> [String: Any] { + [ + "type": "command", + "command": codexHookCommand(spec.commandEvent), + "timeout": spec.timeout, + ] + } + + private func codexValidateForeignHandlerPositions( + before: [String: Any], + after: [String: Any] + ) throws { + for spec in Self.codexHookSpecs { + let oldPositions = codexForeignHandlerPositions(root: before, event: spec.event) + let newPositions = codexForeignHandlerPositions(root: after, event: spec.event) + guard oldPositions.count == newPositions.count else { + throw CodexHooksError(message: "rewriting \(spec.event) would change the foreign handler set; refusing to reuse Codex trust positions") + } + for (index, pair) in zip(oldPositions, newPositions).enumerated() where pair.0 != pair.1 { + throw CodexHooksError( + message: "rewriting \(spec.event) would move foreign handler \(index) from \(pair.0.0):\(pair.0.1) to \(pair.1.0):\(pair.1.1); refusing to reuse Codex trust positions" + ) + } + } + } + + private func codexForeignHandlerPositions(root: [String: Any], event: String) -> [(Int, Int)] { + guard let hooks = root["hooks"] as? [String: Any], + let groups = hooks[event] as? [[String: Any]] else { return [] } + var positions: [(Int, Int)] = [] + for (groupIndex, group) in groups.enumerated() { + guard let handlers = group["hooks"] as? [[String: Any]] else { continue } + for (handlerIndex, handler) in handlers.enumerated() where !codexIsOwnedHandler(handler, event: event) { + positions.append((groupIndex, handlerIndex)) + } + } + return positions + } + + private func codexIsOwnedHandler(_ handler: [String: Any], event: String) -> Bool { + guard let commandEvent = Self.codexOwnedCommandEventByHookEvent[event], + let command = handler["command"] as? String else { return false } + return command == Self.codexHookCommand(commandEvent) + || command == "programa codex-hook \(commandEvent)" + } + + func codexEncodeHooks(_ root: [String: Any]) throws -> Data { + guard JSONSerialization.isValidJSONObject(root) else { + throw CodexHooksError(message: "merged hooks.json is not serializable") + } + var data = try JSONSerialization.data( + withJSONObject: root, + options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + ) + data.append(0x0A) + return data + } + + func codexExpectedTrustEntries(hooksPath: String, root: [String: Any]) throws -> [String: String] { + var result: [String: String] = [:] + for spec in Self.codexHookSpecs { + let entry = try codexOwnedHookEntry(root: root, spec: spec) + let key = "\(hooksPath):\(spec.label):\(entry.groupIndex):\(entry.handlerIndex)" + result[key] = try codexTrustHash(label: spec.label, group: entry.group, handler: entry.handler) + } + return result + } + + private func codexOwnedHookEntry( + root: [String: Any], + spec: CodexHookSpec + ) throws -> (groupIndex: Int, handlerIndex: Int, group: [String: Any], handler: [String: Any]) { + guard let hooks = root["hooks"] as? [String: Any], + let groups = hooks[spec.event] as? [[String: Any]] else { + throw CodexHooksError(message: "installed Codex hook for \(spec.event) is missing") + } + for (groupIndex, group) in groups.enumerated() { + guard let handlers = group["hooks"] as? [[String: Any]] else { continue } + for (handlerIndex, handler) in handlers.enumerated() where codexIsOwnedHandler(handler, event: spec.event) { + return (groupIndex, handlerIndex, group, handler) + } + } + throw CodexHooksError(message: "installed Codex hook for \(spec.event) is missing") + } + + func codexOwnedTrustEntries(hooksPath: String, root: [String: Any]) -> [String: String] { + guard let hooks = root["hooks"] as? [String: Any] else { return [:] } + let labels = Dictionary(uniqueKeysWithValues: Self.codexHookSpecs.map { ($0.event, $0.label) }) + var entries: [String: String] = [:] + for (event, rawGroups) in hooks { + guard let label = labels[event], let groups = rawGroups as? [[String: Any]] else { continue } + for (groupIndex, group) in groups.enumerated() { + guard let handlers = group["hooks"] as? [[String: Any]] else { continue } + for (handlerIndex, handler) in handlers.enumerated() where codexIsOwnedHandler(handler, event: event) { + entries["\(hooksPath):\(label):\(groupIndex):\(handlerIndex)"] = + try? codexTrustHash(label: label, group: group, handler: handler) + } + } + } + return entries + } + + func codexOwnedTrustHashes() throws -> Set { + var hashes: Set = [] + for spec in Self.codexHookSpecs { + let group: [String: Any] = [:] + let handler: [String: Any] = [ + "type": "command", + "command": Self.codexHookCommand(spec.commandEvent), + "timeout": spec.timeout, + ] + hashes.insert(try codexTrustHash(label: spec.label, group: group, handler: handler)) + } + return hashes + } + + private func codexTrustHash(label: String, group: [String: Any], handler: [String: Any]) throws -> String { + guard (handler["type"] as? String) == "command", + let command = handler["command"] as? String else { + throw CodexHooksError(message: "Codex hook \(label) must be a command handler") + } + let asynchronous: Bool + if let value = handler["async"] { + guard let value = value as? Bool else { throw CodexHooksError(message: "Codex hook async must be a boolean") } + asynchronous = value + } else { + asynchronous = false + } + var normalized: [String: Any] = [ + "async": asynchronous, + "command": command, + "timeout": try codexNormalizedTimeout(label: label, value: handler["timeout"]), + "type": "command", + ] + if let status = handler["statusMessage"] { + guard let status = status as? String else { throw CodexHooksError(message: "Codex hook statusMessage must be a string") } + normalized["statusMessage"] = status + } + if let rawLimit = handler["additionalContextLimit"] { + let limit = try codexInteger(rawLimit, field: "additionalContextLimit") + if ["pre_tool_use", "post_tool_use", "session_start", "user_prompt_submit", "subagent_start"].contains(label), + limit != 2_500 { + normalized["additionalContextLimit"] = limit + } + } + var identity: [String: Any] = ["event_name": label, "hooks": [normalized]] + if label != "user_prompt_submit", label != "stop", let matcher = group["matcher"] { + guard let matcher = matcher as? String else { throw CodexHooksError(message: "Codex hook matcher must be a string") } + identity["matcher"] = matcher + } + let data = try JSONSerialization.data(withJSONObject: identity, options: [.sortedKeys, .withoutEscapingSlashes]) + return "sha256:" + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + private func codexNormalizedTimeout(label: String, value: Any?) throws -> Int { + let fallback = label == "session_end" ? 1 : 600 + let timeout = value == nil ? fallback : try codexInteger(value!, field: "timeout") + return label == "session_end" ? min(max(timeout, 1), 3) : max(timeout, 1) + } + + private func codexInteger(_ value: Any, field: String) throws -> Int { + guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { + throw CodexHooksError(message: "Codex hook \(field) must be an integer") + } + let double = number.doubleValue + guard double.rounded() == double, double >= Double(Int.min), double <= Double(Int.max) else { + throw CodexHooksError(message: "Codex hook \(field) must be an integer") + } + return number.intValue + } + + func codexPrepareConfig(_ edit: CodexTrustEdit) throws -> CodexPreparedConfig { + let snapshot = try codexReadRegularFile(edit.configPath, limit: Self.codexMaximumFileBytes, refuseSymlink: true) + let original = try codexUTF8(snapshot?.data ?? Data(), path: edit.configPath) + let rendered = try codexRenderConfig(original, edit: edit) + try codexProbeWritableDirectory((edit.configPath as NSString).deletingLastPathComponent) + for warning in rendered.warnings { FileHandle.standardError.write(Data((warning + "\n").utf8)) } + return CodexPreparedConfig(edit: edit, snapshot: snapshot, rendered: Data(rendered.text.utf8)) + } + + func codexCommitConfig(_ prepared: CodexPreparedConfig) throws { + let resolvedTarget = try codexResolveConfigTarget(prepared.edit.configLinkPath) + guard resolvedTarget == prepared.edit.configPath else { + throw CodexHooksError(message: "\(prepared.edit.configLinkPath) changed targets during installation; retry the command") + } + let current = try codexReadRegularFile(prepared.edit.configPath, limit: Self.codexMaximumFileBytes, refuseSymlink: true) + let rendered: Data + if current == prepared.snapshot { + rendered = prepared.rendered + } else { + let fresh = try codexUTF8(current?.data ?? Data(), path: prepared.edit.configPath) + rendered = Data(try codexRenderConfig(fresh, edit: prepared.edit).text.utf8) + } + guard (current?.data ?? Data()) != rendered else { return } + try codexAtomicWrite(rendered, to: prepared.edit.configPath, mode: current?.mode ?? 0o600) + } + + private func codexRenderConfig(_ source: String, edit: CodexTrustEdit) throws -> CodexRenderedConfig { + let originalTree = try codexParseTOML(source, path: edit.configPath) + try codexValidateTrustShape(originalTree, path: edit.configPath) + let newline = codexNewline(in: source) + + var working = source + for (begin, end) in [ + (Self.codexTrustBlockStart, Self.codexTrustBlockEnd), + (Self.codexLegacyTrustBlockStart, Self.codexLegacyTrustBlockEnd), + ] { + let removal = try codexRemovingBlock(working, begin: begin, end: end) + if let interior = removal.interior { try codexValidateLegacyBlock(interior, edit: edit) } + working = removal.text + } + + // The current block is rewritten whole, in place, so its contents never need + // validating. `head` and `tail` are the text before and after it. + let lines = codexTOMLLines(working) + let block = try codexBlockRange(lines, begin: Self.codexBlockBegin, end: Self.codexBlockEnd) + let hadBlock = block != nil + var head = working + var tail = "" + var blockKeys = Set() + if let block { + let interior = lines[(block.begin + 1).. 0 { blockText += newline } + blockText += "[hooks.state.\(try codexTOMLQuoted(entry.key))]\(newline)" + blockText += "trusted_hash = \(try codexTOMLQuoted(entry.hash))\(newline)" + } + blockText += Self.codexBlockEnd + newline + } + // An existing block is replaced where it stands; a new one goes at the end + // after one blank line. The block holds full table headers only, so it does + // not matter which table the file ends in. + let separator = blockText.isEmpty ? "" : newline + if hadBlock { + working = head + (head.isEmpty ? "" : separator) + blockText + tail + } else if !blockText.isEmpty { + if !working.isEmpty { + if working.utf8.last != 0x0A { working += newline } + working += newline + } + working += blockText + } + + let renderedTree: CodexTOMLValue + do { renderedTree = try codexParseTOML(working, path: edit.configPath) } + catch { + throw CodexHooksError(message: "cannot add Programa trust entries to \(edit.configPath): hooks is defined in a form that cannot be extended (\(error.localizedDescription))") + } + let renderedState = renderedTree.tableValue?["hooks"]?.tableValue?["state"]?.tableValue ?? [:] + for entry in entries { + guard renderedState[entry.key]?.tableValue?["trusted_hash"]?.stringValue == entry.hash else { + throw CodexHooksError(message: "Codex trust entry \(entry.key) did not render correctly") + } + } + guard Self.codexStripped(renderedTree, stateKeys: Set(entries.map(\.key)), featureFlag: false) + == Self.codexStripped(originalTree, stateKeys: removedKeys, featureFlag: removedFeatureFlag) else { + throw CodexHooksError(message: "Codex config changed outside the Programa block; refusing to write") + } + return CodexRenderedConfig(text: working, warnings: warnings) + } + + private func codexValidateTrustShape(_ root: CodexTOMLValue, path: String) throws { + guard let table = root.tableValue else { throw CodexHooksError(message: "\(path) must contain a TOML table") } + if let hooks = table["hooks"] { + guard let hooksTable = hooks.tableValue else { + throw CodexHooksError(message: "hooks in \(path) is not a table, so Codex cannot read trust state") + } + if let state = hooksTable["state"], state.tableValue == nil { + throw CodexHooksError(message: "hooks.state in \(path) is not a table") + } + } + } + + /// Drops the named trust entries and feature flag, then every empty table, so two + /// trees compare equal when they differ only by Programa-owned or now-empty tables. + private static func codexStripped(_ tree: CodexTOMLValue, stateKeys: Set, featureFlag: Bool) -> CodexTOMLValue? { + var root = tree.tableValue ?? [:] + if featureFlag, var features = root["features"]?.tableValue { + features.removeValue(forKey: "codex_hooks") + root["features"] = .table(features) + } + if var hooks = root["hooks"]?.tableValue, var state = hooks["state"]?.tableValue { + for key in stateKeys { state.removeValue(forKey: key) } + hooks["state"] = .table(state) + root["hooks"] = .table(hooks) + } + func pruned(_ value: CodexTOMLValue) -> CodexTOMLValue? { + guard case .table(let table) = value else { return value } + let kept = table.compactMapValues(pruned) + return kept.isEmpty ? nil : .table(kept) + } + return pruned(.table(root)) + } + + private func codexParseTOML(_ source: String, path: String) throws -> CodexTOMLValue { + let decoder = TOMLDecoder() + decoder.limits = .init( + maxInputSize: Self.codexMaximumFileBytes, + maxDepth: 128, + maxTableKeys: 100_000, + maxArrayLength: 100_000, + maxStringLength: Self.codexMaximumFileBytes + ) + do { return .table(try decoder.decode([String: CodexTOMLValue].self, from: source)) } + catch { throw CodexHooksError(message: "\(path) is not valid TOML: \(error.localizedDescription)") } + } + + /// Locates a marker-delimited block. The range starts at the blank line the + /// installer put before the block, when there is one. + private func codexBlockRange( + _ lines: [CodexTOMLLine], + begin: String, + end: String + ) throws -> (first: Int, begin: Int, end: Int)? { + let starts = lines.indices.filter { !lines[$0].startsInString && lines[$0].text == begin } + let ends = lines.indices.filter { !lines[$0].startsInString && lines[$0].text == end } + guard starts.count <= 1, ends.count <= 1, starts.count == ends.count else { + throw CodexHooksError(message: "Codex config contains duplicate or unmatched Programa markers") + } + guard let start = starts.first, let finish = ends.first else { return nil } + guard start < finish else { + throw CodexHooksError(message: "Codex config contains mismatched Programa markers") + } + // An assignment after the end marker belongs to the block's last table; removing + // or rewriting the block would silently move it into another table. + for index in (finish + 1).. 0 && !lines[start - 1].startsInString && lines[start - 1].text.isEmpty + return (blankBefore ? start - 1 : start, start, finish) + } + + private func codexRemovingBlock( + _ source: String, + begin: String, + end: String + ) throws -> (text: String, interior: String?) { + let lines = codexTOMLLines(source) + guard let block = try codexBlockRange(lines, begin: begin, end: end) else { return (source, nil) } + let interior = lines[(block.begin + 1)..) -> String { + var result = "" + for (index, line) in lines.enumerated() where !removing.contains(index) { + result += String(source.unicodeScalars[line.range]) + } + return result + } + + /// Older installers wrote trust tables between their own markers. The block must + /// hold only trust tables for this hooks file; anything else is the user's. + private func codexValidateLegacyBlock(_ interior: String, edit: CodexTrustEdit) throws { + let tree = try codexParseTOML(interior, path: edit.configPath) + let state = tree.tableValue?["hooks"]?.tableValue?["state"]?.tableValue ?? [:] + let programaHashes = edit.ownedHashes.union(edit.desired.values) + var expectedLines = Set() + for (key, value) in state { + guard key.hasPrefix(edit.hooksKeyPrefix), + let hash = value.tableValue?["trusted_hash"]?.stringValue, + programaHashes.contains(hash) || edit.previousOwned[key] == hash else { + throw CodexHooksError(message: "Programa trust block contains an entry Programa does not own: \(key)") + } + expectedLines.insert("[hooks.state.\(try codexTOMLQuoted(key))]") + expectedLines.insert("trusted_hash=\(try codexTOMLQuoted(hash))") + } + var contentLines = 0 + for line in codexTOMLLines(interior) { + let text = line.text.trimmingCharacters(in: .whitespaces) + if text.isEmpty { continue } + contentLines += 1 + guard expectedLines.contains(text.hasPrefix("[") ? text : text.filter({ !$0.isWhitespace })) else { + throw CodexHooksError(message: "Programa trust block contains comments or unrecognized content") + } + } + guard !state.isEmpty, contentLines == state.count * 2 else { + throw CodexHooksError(message: "Programa trust block contains comments or unrecognized content") + } + } + + /// Removes exactly what the older installers wrote outside any block: a + /// `codex_hooks = true` line under `[features]`, and `[hooks.state.""]` tables + /// that hold a single `trusted_hash` equal to a hash Programa produces. + private func codexMigratingLegacyLines( + _ source: String, + edit: CodexTrustEdit + ) throws -> (text: String, removedKeys: Set, removedFeatureFlag: Bool) { + let lines = codexTOMLLines(source) + let tree = try codexParseTOML(source, path: edit.configPath) + let state = tree.tableValue?["hooks"]?.tableValue?["state"]?.tableValue ?? [:] + let programaHashes = edit.ownedHashes.union(edit.desired.values) + let headers = lines.indices.compactMap { index -> (index: Int, text: String)? in + let text = lines[index].text.trimmingCharacters(in: .whitespaces) + return !lines[index].startsInString && text.hasPrefix("[") ? (index, text) : nil + } + func sectionEnd(_ position: Int) -> Int { position + 1 < headers.count ? headers[position + 1].index : lines.count } + + var removal = Set() + var removedFeatureFlag = false + for (position, header) in headers.enumerated() where header.text == "[features]" { + for index in (header.index + 1)..() + for (key, value) in state where key.hasPrefix(edit.hooksKeyPrefix) { + guard let hash = value.tableValue?["trusted_hash"]?.stringValue, + programaHashes.contains(hash) || edit.previousOwned[key] == hash else { continue } + let header = "[hooks.state.\(try codexTOMLQuoted(key))]" + let matches = headers.indices.filter { headers[$0].text == header } + guard matches.count == 1 else { + throw CodexHooksError(message: "Programa trust entry \(key) in \(edit.configPath) is not a plain table and cannot be removed safely") + } + let position = matches[0] + let content = ((headers[position].index + 1).. [CodexTOMLLine] { + var result: [CodexTOMLLine] = [] + var mode = CodexTOMLStringMode.none + let scalars = source.unicodeScalars + var cursor = scalars.startIndex + while cursor < scalars.endIndex { + let newline = scalars[cursor...].firstIndex(of: "\n") + let lineEnd = newline.map { scalars.index(after: $0) } ?? scalars.endIndex + var contentEnd = newline ?? scalars.endIndex + if contentEnd > cursor, scalars[scalars.index(before: contentEnd)] == "\r" { + contentEnd = scalars.index(before: contentEnd) + } + let startsInString = mode != .none + let text = String(scalars[cursor.. CodexTOMLStringMode { + var mode = initial + var cursor = line.startIndex + while cursor < line.endIndex { + let suffix = line[cursor...] + switch mode { + case .none: + if line[cursor] == "#" { return .none } + if suffix.hasPrefix("\"\"\"") { + mode = .multilineBasic + cursor = line.index(cursor, offsetBy: 3) + } else if suffix.hasPrefix("'''") { + mode = .multilineLiteral + cursor = line.index(cursor, offsetBy: 3) + } else if line[cursor] == "\"" { + mode = .basic + cursor = line.index(after: cursor) + } else if line[cursor] == "'" { + mode = .literal + cursor = line.index(after: cursor) + } else { + cursor = line.index(after: cursor) + } + case .basic: + if line[cursor] == "\\" { + cursor = line.index(after: cursor) + if cursor < line.endIndex { cursor = line.index(after: cursor) } + } else { + if line[cursor] == "\"" { mode = .none } + cursor = line.index(after: cursor) + } + case .literal: + if line[cursor] == "'" { mode = .none } + cursor = line.index(after: cursor) + case .multilineBasic: + if suffix.hasPrefix("\"\"\"") { + mode = .none + cursor = line.index(cursor, offsetBy: 3) + } else if line[cursor] == "\\" { + cursor = line.index(after: cursor) + if cursor < line.endIndex { cursor = line.index(after: cursor) } + } else { + cursor = line.index(after: cursor) + } + case .multilineLiteral: + if suffix.hasPrefix("'''") { + mode = .none + cursor = line.index(cursor, offsetBy: 3) + } else { + cursor = line.index(after: cursor) + } + } + } + return mode + } + + private func codexNewline(in source: String) -> String { + let scalars = source.unicodeScalars + guard let newline = scalars.firstIndex(of: "\n") else { return "\n" } + return newline > scalars.startIndex && scalars[scalars.index(before: newline)] == "\r" ? "\r\n" : "\n" + } + + private func codexTOMLQuoted(_ value: String) throws -> String { + let data = try JSONSerialization.data(withJSONObject: [value], options: [.withoutEscapingSlashes]) + let encoded = String(decoding: data, as: UTF8.self) + return String(encoded.dropFirst().dropLast()) + } + + private func codexUTF8(_ data: Data, path: String) throws -> String { + guard let value = String(data: data, encoding: .utf8) else { + throw CodexHooksError(message: "\(path) is not valid UTF-8") + } + return value + } + + private func codexProbeWritableDirectory(_ path: String) throws { + try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) + let probe = (path as NSString).appendingPathComponent(".programa-preflight-\(UUID().uuidString)") + let descriptor = probe.withCString { Darwin.open($0, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0o600) } + guard descriptor >= 0 else { throw codexPOSIXError("preflight write access to", path: path) } + Darwin.close(descriptor) + guard unlink(probe) == 0 else { throw codexPOSIXError("remove preflight", path: probe) } + } + + func codexAtomicWrite(_ data: Data, to path: String, mode: mode_t) throws { + var targetInfo = stat() + if path.withCString({ Darwin.lstat($0, &targetInfo) }) == 0 { + guard (targetInfo.st_mode & S_IFMT) == S_IFREG else { + throw CodexHooksError(message: "refusing to replace non-regular file \(path)") + } + } else if errno != ENOENT { + throw codexPOSIXError("inspect", path: path) + } + let parent = (path as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true, attributes: nil) + let directoryDescriptor = try codexOpenDirectory(parent) + defer { Darwin.close(directoryDescriptor) } + let name = (path as NSString).lastPathComponent + let temporary = (parent as NSString).appendingPathComponent(".\(name).programa-\(UUID().uuidString)") + let descriptor = temporary.withCString { Darwin.open($0, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, mode) } + guard descriptor >= 0 else { throw codexPOSIXError("create", path: temporary) } + var shouldRemove = true + var descriptorIsOpen = true + defer { + if descriptorIsOpen { Darwin.close(descriptor) } + if shouldRemove { unlink(temporary) } + } + try data.withUnsafeBytes { bytes in + var offset = 0 + while offset < bytes.count { + let count = Darwin.write(descriptor, bytes.baseAddress!.advanced(by: offset), bytes.count - offset) + if count < 0 { + if errno == EINTR { continue } + throw codexPOSIXError("write", path: temporary) + } + offset += count + } + } + guard fchmod(descriptor, mode) == 0 else { throw codexPOSIXError("set mode", path: temporary) } + guard fsync(descriptor) == 0 else { throw codexPOSIXError("sync", path: temporary) } + guard Darwin.close(descriptor) == 0 else { throw codexPOSIXError("close", path: temporary) } + descriptorIsOpen = false + var replacementInfo = stat() + if path.withCString({ Darwin.lstat($0, &replacementInfo) }) == 0, + (replacementInfo.st_mode & S_IFMT) != S_IFREG { + throw CodexHooksError(message: "refusing to replace non-regular file \(path)") + } + guard rename(temporary, path) == 0 else { throw codexPOSIXError("replace", path: path) } + shouldRemove = false + codexSyncDirectoryAfterCommit(directoryDescriptor, path: parent) + } + + func codexRestoreFile(_ snapshot: CodexFileSnapshot?, at path: String) throws { + if let snapshot { + try codexAtomicWrite(snapshot.data, to: path, mode: snapshot.mode) + return + } + let parent = (path as NSString).deletingLastPathComponent + let directoryDescriptor = try codexOpenDirectory(parent) + defer { Darwin.close(directoryDescriptor) } + if unlink(path) != 0 { + if errno == ENOENT { return } + throw codexPOSIXError("remove", path: path) + } + codexSyncDirectoryAfterCommit(directoryDescriptor, path: parent) + } + + private func codexOpenDirectory(_ path: String) throws -> Int32 { + let descriptor = path.withCString { Darwin.open($0, O_RDONLY | O_DIRECTORY | O_CLOEXEC) } + guard descriptor >= 0 else { throw codexPOSIXError("open directory", path: path) } + return descriptor + } + + private func codexSyncDirectoryAfterCommit(_ descriptor: Int32, path: String) { + guard fsync(descriptor) != 0 else { return } + let message = "warning: committed Codex hook change, but syncing directory \(path) failed: \(String(cString: strerror(errno)))\n" + FileHandle.standardError.write(Data(message.utf8)) + } + + private func codexPOSIXError(_ action: String, path: String) -> Error { + let code = errno + return CodexHooksError(message: "\(action) \(path): \(String(cString: strerror(code)))") + } +} diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index a6381e7d1..bebf188b4 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -1765,1001 +1765,6 @@ extension ProgramaCLI { return filtered.joined(separator: " ") } - // MARK: - Codex hooks - - static let codexMaximumFileBytes = 16 * 1024 * 1024 - /// Everything Programa writes to config.toml lives between these two lines and is - /// rewritten whole. Its content is tables only, so it is valid wherever it lands. - private static let codexBlockBegin = "# BEGIN programa (managed by Programa, do not edit)" - private static let codexBlockEnd = "# END programa" - private static let codexTrustBlockStart = "# >>> programa managed codex hook trust v1 >>>" - private static let codexTrustBlockEnd = "# <<< programa managed codex hook trust v1 <<<" - private static let codexLegacyTrustBlockStart = "# >>> programa codex hook trust >>>" - private static let codexLegacyTrustBlockEnd = "# <<< programa codex hook trust <<<" - - private struct CodexHookSpec { - let event: String - let label: String - let commandEvent: String - let timeout: Int - } - - private static let codexHookSpecs = [ - CodexHookSpec(event: "SessionStart", label: "session_start", commandEvent: "session-start", timeout: 10), - CodexHookSpec(event: "UserPromptSubmit", label: "user_prompt_submit", commandEvent: "prompt-submit", timeout: 10), - CodexHookSpec(event: "Stop", label: "stop", commandEvent: "stop", timeout: 10), - CodexHookSpec(event: "PermissionRequest", label: "permission_request", commandEvent: "notification", timeout: 10), - CodexHookSpec(event: "SessionEnd", label: "session_end", commandEvent: "session-end", timeout: 1), - ] - private static let codexOwnedCommandEventByHookEvent: [String: String] = { - var result = Dictionary(uniqueKeysWithValues: codexHookSpecs.map { ($0.event, $0.commandEvent) }) - result["Notification"] = "notification" - return result - }() - - struct CodexFileSnapshot: Equatable { - let data: Data - let mode: mode_t - } - - struct CodexPaths { - let home: String - let lockHome: String - let hooks: String - let configLink: String - let configTarget: String - } - - struct CodexTrustEdit { - let configPath: String - let configLinkPath: String - let hooksKeyPrefix: String - let desired: [String: String] - /// Trust hashes of the Programa handlers already in hooks.json, by trust key. - let previousOwned: [String: String] - let ownedHashes: Set - } - - struct CodexPreparedConfig { - let edit: CodexTrustEdit - let snapshot: CodexFileSnapshot? - let rendered: Data - } - - private struct CodexRenderedConfig { - let text: String - let warnings: [String] - } - - private enum CodexTOMLStringMode { - case none - case basic - case literal - case multilineBasic - case multilineLiteral - } - - private struct CodexTOMLLine { - let range: Range - let text: String - let startsInString: Bool - } - - private indirect enum CodexTOMLValue: Decodable, Equatable { - case string(String) - case integer(Int64) - case float(Double) - case boolean(Bool) - case offsetDateTime(Date) - case localDateTime(LocalDateTime) - case localDate(LocalDate) - case localTime(LocalTime) - case array([CodexTOMLValue]) - case table([String: CodexTOMLValue]) - case null - - private struct Key: CodingKey { - let stringValue: String - let intValue: Int? = nil - init?(stringValue: String) { self.stringValue = stringValue } - init?(intValue: Int) { return nil } - } - - init(from decoder: Decoder) throws { - if let container = try? decoder.container(keyedBy: Key.self) { - var result: [String: CodexTOMLValue] = [:] - for key in container.allKeys { - result[key.stringValue] = try container.decode(CodexTOMLValue.self, forKey: key) - } - self = .table(result) - return - } - if var container = try? decoder.unkeyedContainer() { - var result: [CodexTOMLValue] = [] - while !container.isAtEnd { - result.append(try container.decode(CodexTOMLValue.self)) - } - self = .array(result) - return - } - let container = try decoder.singleValueContainer() - if container.decodeNil() { self = .null; return } - if let value = try? container.decode(Bool.self) { self = .boolean(value); return } - if let value = try? container.decode(Int64.self) { self = .integer(value); return } - if let value = try? container.decode(Double.self) { self = .float(value); return } - if let value = try? container.decode(String.self) { self = .string(value); return } - if let value = try? container.decode(Date.self) { self = .offsetDateTime(value); return } - if let value = try? container.decode(LocalDateTime.self) { self = .localDateTime(value); return } - if let value = try? container.decode(LocalDate.self) { self = .localDate(value); return } - if let value = try? container.decode(LocalTime.self) { self = .localTime(value); return } - throw DecodingError.typeMismatch( - CodexTOMLValue.self, - .init(codingPath: decoder.codingPath, debugDescription: "Unsupported TOML value") - ) - } - - var tableValue: [String: CodexTOMLValue]? { - guard case .table(let value) = self else { return nil } - return value - } - - var stringValue: String? { - guard case .string(let value) = self else { return nil } - return value - } - } - - struct CodexHooksError: LocalizedError { - let message: String - var errorDescription: String? { message } - } - - /// Each installed hook gracefully no-ops outside a programa surface. - private static func codexHookCommand(_ event: String) -> String { - "[ -n \"$PROGRAMA_SURFACE_ID\" ] && command -v programa >/dev/null 2>&1 && programa codex-hook \(event) || echo '{}'" - } - - func codexPaths() throws -> CodexPaths { - let environment = ProcessInfo.processInfo.environment - let override = environment["CODEX_HOME"]?.trimmingCharacters(in: .whitespacesAndNewlines) - let explicit = override?.isEmpty == false - let rawHome = explicit ? override! : "~/.codex" - let expanded = Self.integrationExpandTilde(rawHome) - let absolute = expanded.hasPrefix("/") - ? URL(fileURLWithPath: expanded).standardizedFileURL.path - : URL(fileURLWithPath: FileManager.default.currentDirectoryPath).appendingPathComponent(expanded).standardizedFileURL.path - try FileManager.default.createDirectory(atPath: absolute, withIntermediateDirectories: true, attributes: nil) - guard let resolvedHome = absolute.withCString({ realpath($0, nil) }) else { - throw codexPOSIXError("resolve", path: absolute) - } - defer { free(resolvedHome) } - let canonicalHome = String(cString: resolvedHome) - let home = explicit ? canonicalHome : absolute - let hooks = (home as NSString).appendingPathComponent("hooks.json") - let configLink = (home as NSString).appendingPathComponent("config.toml") - let configTarget = try codexResolveConfigTarget(configLink) - return CodexPaths(home: home, lockHome: canonicalHome, hooks: hooks, configLink: configLink, configTarget: configTarget) - } - - func codexResolveConfigTarget(_ path: String) throws -> String { - var info = stat() - let result = path.withCString { Darwin.lstat($0, &info) } - if result != 0 { - if errno == ENOENT { return path } - throw codexPOSIXError("inspect", path: path) - } - guard (info.st_mode & S_IFMT) == S_IFLNK else { return path } - guard let resolved = path.withCString({ realpath($0, nil) }) else { - throw CodexHooksError(message: "\(path) is a dangling symlink; refusing to replace it") - } - defer { free(resolved) } - return String(cString: resolved) - } - - func withCodexHooksLock(at home: String, _ body: () throws -> T) throws -> T { - let path = (home as NSString).appendingPathComponent(".programa-hooks.lock") - var existing = stat() - let createdLock = path.withCString { Darwin.lstat($0, &existing) } != 0 - let descriptor = path.withCString { Darwin.open($0, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0o600) } - guard descriptor >= 0 else { throw codexPOSIXError("open lock", path: path) } - defer { Darwin.close(descriptor) } - var succeeded = false - // A declined or failed run leaves no new file behind in the user's Codex home. - defer { if createdLock && !succeeded { _ = path.withCString { unlink($0) } } } - var info = stat() - guard fstat(descriptor, &info) == 0, (info.st_mode & S_IFMT) == S_IFREG else { - throw CodexHooksError(message: "\(path) is not a regular lock file") - } - guard flock(descriptor, LOCK_EX) == 0 else { throw codexPOSIXError("lock", path: path) } - defer { flock(descriptor, LOCK_UN) } - let result = try body() - succeeded = true - return result - } - - func codexReadRegularFile(_ path: String, limit: Int, refuseSymlink: Bool) throws -> CodexFileSnapshot? { - if refuseSymlink { - var linkInfo = stat() - let result = path.withCString { Darwin.lstat($0, &linkInfo) } - if result == 0 { - if (linkInfo.st_mode & S_IFMT) == S_IFLNK { - throw CodexHooksError(message: "refusing to read symlink \(path)") - } - } else if errno != ENOENT { - throw codexPOSIXError("inspect", path: path) - } - } - let descriptor = path.withCString { Darwin.open($0, O_RDONLY | O_NONBLOCK | O_CLOEXEC | O_NOFOLLOW) } - guard descriptor >= 0 else { - if errno == ENOENT { return nil } - throw codexPOSIXError("open", path: path) - } - defer { Darwin.close(descriptor) } - var info = stat() - guard fstat(descriptor, &info) == 0 else { throw codexPOSIXError("inspect", path: path) } - guard (info.st_mode & S_IFMT) == S_IFREG else { - throw CodexHooksError(message: "\(path) is not a regular file; refusing to read it") - } - guard info.st_size >= 0, info.st_size <= Int64(limit) else { - throw CodexHooksError(message: "\(path) exceeds 16 MiB") - } - var data = Data() - data.reserveCapacity(Int(info.st_size)) - var buffer = [UInt8](repeating: 0, count: 8192) - while true { - let count = Darwin.read(descriptor, &buffer, buffer.count) - if count == 0 { break } - if count < 0 { - if errno == EINTR { continue } - throw codexPOSIXError("read", path: path) - } - data.append(buffer, count: count) - guard data.count <= limit else { throw CodexHooksError(message: "\(path) exceeds 16 MiB") } - } - return CodexFileSnapshot(data: data, mode: info.st_mode & 0o777) - } - - func codexHooksRoot(from data: Data?, path: String) throws -> [String: Any] { - guard let data else { return [:] } - let value: Any - do { value = try JSONSerialization.jsonObject(with: data) } - catch { throw CodexHooksError(message: "\(path) is not valid JSON: \(error.localizedDescription)") } - guard let root = value as? [String: Any] else { - throw CodexHooksError(message: "\(path) must contain a JSON object") - } - return root - } - - func codexRewriteHooks(_ source: [String: Any], install: Bool) throws -> [String: Any] { - var root = source - let rawHooks = root["hooks"] - guard rawHooks == nil || rawHooks is [String: Any] else { - throw CodexHooksError(message: "hooks.json hooks must be an object") - } - if rawHooks == nil, !install { return root } - var hooks = rawHooks as? [String: Any] ?? [:] - for event in Array(hooks.keys) { - guard let rawGroups = hooks[event] as? [Any] else { - throw CodexHooksError(message: "hooks.json event \(event) must be an array") - } - // Codex trust keys are positional. On install the first owned handler of - // a spec event is rewritten where it stands so foreign handlers that - // follow it keep their (group, handler) positions. - let spec = install ? Self.codexHookSpecs.first { $0.event == event } : nil - var updatedInPlace = false - var rewritten: [[String: Any]] = [] - for (groupIndex, rawGroup) in rawGroups.enumerated() { - guard var group = rawGroup as? [String: Any] else { - throw CodexHooksError(message: "hooks.json \(event) group \(groupIndex) must be an object") - } - guard let rawHandlers = group["hooks"] as? [Any] else { - throw CodexHooksError(message: "hooks.json \(event) group \(groupIndex).hooks must be an array") - } - var handlers: [[String: Any]] = [] - var ownedOnlyGroup = false - for (handlerIndex, rawHandler) in rawHandlers.enumerated() { - guard let handler = rawHandler as? [String: Any] else { - throw CodexHooksError(message: "hooks.json \(event) handler \(handlerIndex) must be an object") - } - if let command = handler["command"], !(command is String) { - throw CodexHooksError(message: "hooks.json \(event) handler \(handlerIndex).command must be a string") - } - if !codexIsOwnedHandler(handler, event: event) { - handlers.append(handler) - } else if let spec, !updatedInPlace { - updatedInPlace = true - handlers.append(Self.codexCanonicalHandler(spec)) - ownedOnlyGroup = rawHandlers.count == 1 - } - } - if ownedOnlyGroup { - // A group holding only Programa's handler is replaced whole so a - // stale matcher cannot narrow it. - rewritten.append(["hooks": handlers]) - } else if !handlers.isEmpty { - group["hooks"] = handlers - rewritten.append(group) - } - } - if rewritten.isEmpty { hooks.removeValue(forKey: event) } - else { hooks[event] = rewritten } - } - if install { - for spec in Self.codexHookSpecs { - var groups = hooks[spec.event] as? [[String: Any]] ?? [] - let hasOwned = groups.contains { group in - (group["hooks"] as? [[String: Any]] ?? []).contains { codexIsOwnedHandler($0, event: spec.event) } - } - if !hasOwned { - groups.append(["hooks": [Self.codexCanonicalHandler(spec)]]) - } - hooks[spec.event] = groups - } - } - root["hooks"] = hooks - try codexValidateForeignHandlerPositions(before: source, after: root) - return root - } - - private static func codexCanonicalHandler(_ spec: CodexHookSpec) -> [String: Any] { - [ - "type": "command", - "command": codexHookCommand(spec.commandEvent), - "timeout": spec.timeout, - ] - } - - private func codexValidateForeignHandlerPositions( - before: [String: Any], - after: [String: Any] - ) throws { - for spec in Self.codexHookSpecs { - let oldPositions = codexForeignHandlerPositions(root: before, event: spec.event) - let newPositions = codexForeignHandlerPositions(root: after, event: spec.event) - guard oldPositions.count == newPositions.count else { - throw CodexHooksError(message: "rewriting \(spec.event) would change the foreign handler set; refusing to reuse Codex trust positions") - } - for (index, pair) in zip(oldPositions, newPositions).enumerated() where pair.0 != pair.1 { - throw CodexHooksError( - message: "rewriting \(spec.event) would move foreign handler \(index) from \(pair.0.0):\(pair.0.1) to \(pair.1.0):\(pair.1.1); refusing to reuse Codex trust positions" - ) - } - } - } - - private func codexForeignHandlerPositions(root: [String: Any], event: String) -> [(Int, Int)] { - guard let hooks = root["hooks"] as? [String: Any], - let groups = hooks[event] as? [[String: Any]] else { return [] } - var positions: [(Int, Int)] = [] - for (groupIndex, group) in groups.enumerated() { - guard let handlers = group["hooks"] as? [[String: Any]] else { continue } - for (handlerIndex, handler) in handlers.enumerated() where !codexIsOwnedHandler(handler, event: event) { - positions.append((groupIndex, handlerIndex)) - } - } - return positions - } - - private func codexIsOwnedHandler(_ handler: [String: Any], event: String) -> Bool { - guard let commandEvent = Self.codexOwnedCommandEventByHookEvent[event], - let command = handler["command"] as? String else { return false } - return command == Self.codexHookCommand(commandEvent) - || command == "programa codex-hook \(commandEvent)" - } - - func codexEncodeHooks(_ root: [String: Any]) throws -> Data { - guard JSONSerialization.isValidJSONObject(root) else { - throw CodexHooksError(message: "merged hooks.json is not serializable") - } - var data = try JSONSerialization.data( - withJSONObject: root, - options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] - ) - data.append(0x0A) - return data - } - - func codexExpectedTrustEntries(hooksPath: String, root: [String: Any]) throws -> [String: String] { - var result: [String: String] = [:] - for spec in Self.codexHookSpecs { - let entry = try codexOwnedHookEntry(root: root, spec: spec) - let key = "\(hooksPath):\(spec.label):\(entry.groupIndex):\(entry.handlerIndex)" - result[key] = try codexTrustHash(label: spec.label, group: entry.group, handler: entry.handler) - } - return result - } - - private func codexOwnedHookEntry( - root: [String: Any], - spec: CodexHookSpec - ) throws -> (groupIndex: Int, handlerIndex: Int, group: [String: Any], handler: [String: Any]) { - guard let hooks = root["hooks"] as? [String: Any], - let groups = hooks[spec.event] as? [[String: Any]] else { - throw CodexHooksError(message: "installed Codex hook for \(spec.event) is missing") - } - for (groupIndex, group) in groups.enumerated() { - guard let handlers = group["hooks"] as? [[String: Any]] else { continue } - for (handlerIndex, handler) in handlers.enumerated() where codexIsOwnedHandler(handler, event: spec.event) { - return (groupIndex, handlerIndex, group, handler) - } - } - throw CodexHooksError(message: "installed Codex hook for \(spec.event) is missing") - } - - func codexOwnedTrustEntries(hooksPath: String, root: [String: Any]) -> [String: String] { - guard let hooks = root["hooks"] as? [String: Any] else { return [:] } - let labels = Dictionary(uniqueKeysWithValues: Self.codexHookSpecs.map { ($0.event, $0.label) }) - var entries: [String: String] = [:] - for (event, rawGroups) in hooks { - guard let label = labels[event], let groups = rawGroups as? [[String: Any]] else { continue } - for (groupIndex, group) in groups.enumerated() { - guard let handlers = group["hooks"] as? [[String: Any]] else { continue } - for (handlerIndex, handler) in handlers.enumerated() where codexIsOwnedHandler(handler, event: event) { - entries["\(hooksPath):\(label):\(groupIndex):\(handlerIndex)"] = - try? codexTrustHash(label: label, group: group, handler: handler) - } - } - } - return entries - } - - func codexOwnedTrustHashes() throws -> Set { - var hashes: Set = [] - for spec in Self.codexHookSpecs { - let group: [String: Any] = [:] - let handler: [String: Any] = [ - "type": "command", - "command": Self.codexHookCommand(spec.commandEvent), - "timeout": spec.timeout, - ] - hashes.insert(try codexTrustHash(label: spec.label, group: group, handler: handler)) - } - return hashes - } - - private func codexTrustHash(label: String, group: [String: Any], handler: [String: Any]) throws -> String { - guard (handler["type"] as? String) == "command", - let command = handler["command"] as? String else { - throw CodexHooksError(message: "Codex hook \(label) must be a command handler") - } - let asynchronous: Bool - if let value = handler["async"] { - guard let value = value as? Bool else { throw CodexHooksError(message: "Codex hook async must be a boolean") } - asynchronous = value - } else { - asynchronous = false - } - var normalized: [String: Any] = [ - "async": asynchronous, - "command": command, - "timeout": try codexNormalizedTimeout(label: label, value: handler["timeout"]), - "type": "command", - ] - if let status = handler["statusMessage"] { - guard let status = status as? String else { throw CodexHooksError(message: "Codex hook statusMessage must be a string") } - normalized["statusMessage"] = status - } - if let rawLimit = handler["additionalContextLimit"] { - let limit = try codexInteger(rawLimit, field: "additionalContextLimit") - if ["pre_tool_use", "post_tool_use", "session_start", "user_prompt_submit", "subagent_start"].contains(label), - limit != 2_500 { - normalized["additionalContextLimit"] = limit - } - } - var identity: [String: Any] = ["event_name": label, "hooks": [normalized]] - if label != "user_prompt_submit", label != "stop", let matcher = group["matcher"] { - guard let matcher = matcher as? String else { throw CodexHooksError(message: "Codex hook matcher must be a string") } - identity["matcher"] = matcher - } - let data = try JSONSerialization.data(withJSONObject: identity, options: [.sortedKeys, .withoutEscapingSlashes]) - return "sha256:" + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() - } - - private func codexNormalizedTimeout(label: String, value: Any?) throws -> Int { - let fallback = label == "session_end" ? 1 : 600 - let timeout = value == nil ? fallback : try codexInteger(value!, field: "timeout") - return label == "session_end" ? min(max(timeout, 1), 3) : max(timeout, 1) - } - - private func codexInteger(_ value: Any, field: String) throws -> Int { - guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { - throw CodexHooksError(message: "Codex hook \(field) must be an integer") - } - let double = number.doubleValue - guard double.rounded() == double, double >= Double(Int.min), double <= Double(Int.max) else { - throw CodexHooksError(message: "Codex hook \(field) must be an integer") - } - return number.intValue - } - - func codexPrepareConfig(_ edit: CodexTrustEdit) throws -> CodexPreparedConfig { - let snapshot = try codexReadRegularFile(edit.configPath, limit: Self.codexMaximumFileBytes, refuseSymlink: true) - let original = try codexUTF8(snapshot?.data ?? Data(), path: edit.configPath) - let rendered = try codexRenderConfig(original, edit: edit) - try codexProbeWritableDirectory((edit.configPath as NSString).deletingLastPathComponent) - for warning in rendered.warnings { FileHandle.standardError.write(Data((warning + "\n").utf8)) } - return CodexPreparedConfig(edit: edit, snapshot: snapshot, rendered: Data(rendered.text.utf8)) - } - - func codexCommitConfig(_ prepared: CodexPreparedConfig) throws { - let resolvedTarget = try codexResolveConfigTarget(prepared.edit.configLinkPath) - guard resolvedTarget == prepared.edit.configPath else { - throw CodexHooksError(message: "\(prepared.edit.configLinkPath) changed targets during installation; retry the command") - } - let current = try codexReadRegularFile(prepared.edit.configPath, limit: Self.codexMaximumFileBytes, refuseSymlink: true) - let rendered: Data - if current == prepared.snapshot { - rendered = prepared.rendered - } else { - let fresh = try codexUTF8(current?.data ?? Data(), path: prepared.edit.configPath) - rendered = Data(try codexRenderConfig(fresh, edit: prepared.edit).text.utf8) - } - guard (current?.data ?? Data()) != rendered else { return } - try codexAtomicWrite(rendered, to: prepared.edit.configPath, mode: current?.mode ?? 0o600) - } - - private func codexRenderConfig(_ source: String, edit: CodexTrustEdit) throws -> CodexRenderedConfig { - let originalTree = try codexParseTOML(source, path: edit.configPath) - try codexValidateTrustShape(originalTree, path: edit.configPath) - let newline = codexNewline(in: source) - - var working = source - for (begin, end) in [ - (Self.codexTrustBlockStart, Self.codexTrustBlockEnd), - (Self.codexLegacyTrustBlockStart, Self.codexLegacyTrustBlockEnd), - ] { - let removal = try codexRemovingBlock(working, begin: begin, end: end) - if let interior = removal.interior { try codexValidateLegacyBlock(interior, edit: edit) } - working = removal.text - } - - // The current block is rewritten whole, in place, so its contents never need - // validating. `head` and `tail` are the text before and after it. - let lines = codexTOMLLines(working) - let block = try codexBlockRange(lines, begin: Self.codexBlockBegin, end: Self.codexBlockEnd) - let hadBlock = block != nil - var head = working - var tail = "" - var blockKeys = Set() - if let block { - let interior = lines[(block.begin + 1).. 0 { blockText += newline } - blockText += "[hooks.state.\(try codexTOMLQuoted(entry.key))]\(newline)" - blockText += "trusted_hash = \(try codexTOMLQuoted(entry.hash))\(newline)" - } - blockText += Self.codexBlockEnd + newline - } - // An existing block is replaced where it stands; a new one goes at the end - // after one blank line. The block holds full table headers only, so it does - // not matter which table the file ends in. - let separator = blockText.isEmpty ? "" : newline - if hadBlock { - working = head + (head.isEmpty ? "" : separator) + blockText + tail - } else if !blockText.isEmpty { - if !working.isEmpty { - if working.utf8.last != 0x0A { working += newline } - working += newline - } - working += blockText - } - - let renderedTree: CodexTOMLValue - do { renderedTree = try codexParseTOML(working, path: edit.configPath) } - catch { - throw CodexHooksError(message: "cannot add Programa trust entries to \(edit.configPath): hooks is defined in a form that cannot be extended (\(error.localizedDescription))") - } - let renderedState = renderedTree.tableValue?["hooks"]?.tableValue?["state"]?.tableValue ?? [:] - for entry in entries { - guard renderedState[entry.key]?.tableValue?["trusted_hash"]?.stringValue == entry.hash else { - throw CodexHooksError(message: "Codex trust entry \(entry.key) did not render correctly") - } - } - guard Self.codexStripped(renderedTree, stateKeys: Set(entries.map(\.key)), featureFlag: false) - == Self.codexStripped(originalTree, stateKeys: removedKeys, featureFlag: removedFeatureFlag) else { - throw CodexHooksError(message: "Codex config changed outside the Programa block; refusing to write") - } - return CodexRenderedConfig(text: working, warnings: warnings) - } - - private func codexValidateTrustShape(_ root: CodexTOMLValue, path: String) throws { - guard let table = root.tableValue else { throw CodexHooksError(message: "\(path) must contain a TOML table") } - if let hooks = table["hooks"] { - guard let hooksTable = hooks.tableValue else { - throw CodexHooksError(message: "hooks in \(path) is not a table, so Codex cannot read trust state") - } - if let state = hooksTable["state"], state.tableValue == nil { - throw CodexHooksError(message: "hooks.state in \(path) is not a table") - } - } - } - - /// Drops the named trust entries and feature flag, then every empty table, so two - /// trees compare equal when they differ only by Programa-owned or now-empty tables. - private static func codexStripped(_ tree: CodexTOMLValue, stateKeys: Set, featureFlag: Bool) -> CodexTOMLValue? { - var root = tree.tableValue ?? [:] - if featureFlag, var features = root["features"]?.tableValue { - features.removeValue(forKey: "codex_hooks") - root["features"] = .table(features) - } - if var hooks = root["hooks"]?.tableValue, var state = hooks["state"]?.tableValue { - for key in stateKeys { state.removeValue(forKey: key) } - hooks["state"] = .table(state) - root["hooks"] = .table(hooks) - } - func pruned(_ value: CodexTOMLValue) -> CodexTOMLValue? { - guard case .table(let table) = value else { return value } - let kept = table.compactMapValues(pruned) - return kept.isEmpty ? nil : .table(kept) - } - return pruned(.table(root)) - } - - private func codexParseTOML(_ source: String, path: String) throws -> CodexTOMLValue { - let decoder = TOMLDecoder() - decoder.limits = .init( - maxInputSize: Self.codexMaximumFileBytes, - maxDepth: 128, - maxTableKeys: 100_000, - maxArrayLength: 100_000, - maxStringLength: Self.codexMaximumFileBytes - ) - do { return .table(try decoder.decode([String: CodexTOMLValue].self, from: source)) } - catch { throw CodexHooksError(message: "\(path) is not valid TOML: \(error.localizedDescription)") } - } - - /// Locates a marker-delimited block. The range starts at the blank line the - /// installer put before the block, when there is one. - private func codexBlockRange( - _ lines: [CodexTOMLLine], - begin: String, - end: String - ) throws -> (first: Int, begin: Int, end: Int)? { - let starts = lines.indices.filter { !lines[$0].startsInString && lines[$0].text == begin } - let ends = lines.indices.filter { !lines[$0].startsInString && lines[$0].text == end } - guard starts.count <= 1, ends.count <= 1, starts.count == ends.count else { - throw CodexHooksError(message: "Codex config contains duplicate or unmatched Programa markers") - } - guard let start = starts.first, let finish = ends.first else { return nil } - guard start < finish else { - throw CodexHooksError(message: "Codex config contains mismatched Programa markers") - } - // An assignment after the end marker belongs to the block's last table; removing - // or rewriting the block would silently move it into another table. - for index in (finish + 1).. 0 && !lines[start - 1].startsInString && lines[start - 1].text.isEmpty - return (blankBefore ? start - 1 : start, start, finish) - } - - private func codexRemovingBlock( - _ source: String, - begin: String, - end: String - ) throws -> (text: String, interior: String?) { - let lines = codexTOMLLines(source) - guard let block = try codexBlockRange(lines, begin: begin, end: end) else { return (source, nil) } - let interior = lines[(block.begin + 1)..) -> String { - var result = "" - for (index, line) in lines.enumerated() where !removing.contains(index) { - result += String(source.unicodeScalars[line.range]) - } - return result - } - - /// Older installers wrote trust tables between their own markers. The block must - /// hold only trust tables for this hooks file; anything else is the user's. - private func codexValidateLegacyBlock(_ interior: String, edit: CodexTrustEdit) throws { - let tree = try codexParseTOML(interior, path: edit.configPath) - let state = tree.tableValue?["hooks"]?.tableValue?["state"]?.tableValue ?? [:] - let programaHashes = edit.ownedHashes.union(edit.desired.values) - var expectedLines = Set() - for (key, value) in state { - guard key.hasPrefix(edit.hooksKeyPrefix), - let hash = value.tableValue?["trusted_hash"]?.stringValue, - programaHashes.contains(hash) || edit.previousOwned[key] == hash else { - throw CodexHooksError(message: "Programa trust block contains an entry Programa does not own: \(key)") - } - expectedLines.insert("[hooks.state.\(try codexTOMLQuoted(key))]") - expectedLines.insert("trusted_hash=\(try codexTOMLQuoted(hash))") - } - var contentLines = 0 - for line in codexTOMLLines(interior) { - let text = line.text.trimmingCharacters(in: .whitespaces) - if text.isEmpty { continue } - contentLines += 1 - guard expectedLines.contains(text.hasPrefix("[") ? text : text.filter({ !$0.isWhitespace })) else { - throw CodexHooksError(message: "Programa trust block contains comments or unrecognized content") - } - } - guard !state.isEmpty, contentLines == state.count * 2 else { - throw CodexHooksError(message: "Programa trust block contains comments or unrecognized content") - } - } - - /// Removes exactly what the older installers wrote outside any block: a - /// `codex_hooks = true` line under `[features]`, and `[hooks.state.""]` tables - /// that hold a single `trusted_hash` equal to a hash Programa produces. - private func codexMigratingLegacyLines( - _ source: String, - edit: CodexTrustEdit - ) throws -> (text: String, removedKeys: Set, removedFeatureFlag: Bool) { - let lines = codexTOMLLines(source) - let tree = try codexParseTOML(source, path: edit.configPath) - let state = tree.tableValue?["hooks"]?.tableValue?["state"]?.tableValue ?? [:] - let programaHashes = edit.ownedHashes.union(edit.desired.values) - let headers = lines.indices.compactMap { index -> (index: Int, text: String)? in - let text = lines[index].text.trimmingCharacters(in: .whitespaces) - return !lines[index].startsInString && text.hasPrefix("[") ? (index, text) : nil - } - func sectionEnd(_ position: Int) -> Int { position + 1 < headers.count ? headers[position + 1].index : lines.count } - - var removal = Set() - var removedFeatureFlag = false - for (position, header) in headers.enumerated() where header.text == "[features]" { - for index in (header.index + 1)..() - for (key, value) in state where key.hasPrefix(edit.hooksKeyPrefix) { - guard let hash = value.tableValue?["trusted_hash"]?.stringValue, - programaHashes.contains(hash) || edit.previousOwned[key] == hash else { continue } - let header = "[hooks.state.\(try codexTOMLQuoted(key))]" - let matches = headers.indices.filter { headers[$0].text == header } - guard matches.count == 1 else { - throw CodexHooksError(message: "Programa trust entry \(key) in \(edit.configPath) is not a plain table and cannot be removed safely") - } - let position = matches[0] - let content = ((headers[position].index + 1).. [CodexTOMLLine] { - var result: [CodexTOMLLine] = [] - var mode = CodexTOMLStringMode.none - let scalars = source.unicodeScalars - var cursor = scalars.startIndex - while cursor < scalars.endIndex { - let newline = scalars[cursor...].firstIndex(of: "\n") - let lineEnd = newline.map { scalars.index(after: $0) } ?? scalars.endIndex - var contentEnd = newline ?? scalars.endIndex - if contentEnd > cursor, scalars[scalars.index(before: contentEnd)] == "\r" { - contentEnd = scalars.index(before: contentEnd) - } - let startsInString = mode != .none - let text = String(scalars[cursor.. CodexTOMLStringMode { - var mode = initial - var cursor = line.startIndex - while cursor < line.endIndex { - let suffix = line[cursor...] - switch mode { - case .none: - if line[cursor] == "#" { return .none } - if suffix.hasPrefix("\"\"\"") { - mode = .multilineBasic - cursor = line.index(cursor, offsetBy: 3) - } else if suffix.hasPrefix("'''") { - mode = .multilineLiteral - cursor = line.index(cursor, offsetBy: 3) - } else if line[cursor] == "\"" { - mode = .basic - cursor = line.index(after: cursor) - } else if line[cursor] == "'" { - mode = .literal - cursor = line.index(after: cursor) - } else { - cursor = line.index(after: cursor) - } - case .basic: - if line[cursor] == "\\" { - cursor = line.index(after: cursor) - if cursor < line.endIndex { cursor = line.index(after: cursor) } - } else { - if line[cursor] == "\"" { mode = .none } - cursor = line.index(after: cursor) - } - case .literal: - if line[cursor] == "'" { mode = .none } - cursor = line.index(after: cursor) - case .multilineBasic: - if suffix.hasPrefix("\"\"\"") { - mode = .none - cursor = line.index(cursor, offsetBy: 3) - } else if line[cursor] == "\\" { - cursor = line.index(after: cursor) - if cursor < line.endIndex { cursor = line.index(after: cursor) } - } else { - cursor = line.index(after: cursor) - } - case .multilineLiteral: - if suffix.hasPrefix("'''") { - mode = .none - cursor = line.index(cursor, offsetBy: 3) - } else { - cursor = line.index(after: cursor) - } - } - } - return mode - } - - private func codexNewline(in source: String) -> String { - let scalars = source.unicodeScalars - guard let newline = scalars.firstIndex(of: "\n") else { return "\n" } - return newline > scalars.startIndex && scalars[scalars.index(before: newline)] == "\r" ? "\r\n" : "\n" - } - - private func codexTOMLQuoted(_ value: String) throws -> String { - let data = try JSONSerialization.data(withJSONObject: [value], options: [.withoutEscapingSlashes]) - let encoded = String(decoding: data, as: UTF8.self) - return String(encoded.dropFirst().dropLast()) - } - - private func codexUTF8(_ data: Data, path: String) throws -> String { - guard let value = String(data: data, encoding: .utf8) else { - throw CodexHooksError(message: "\(path) is not valid UTF-8") - } - return value - } - - private func codexProbeWritableDirectory(_ path: String) throws { - try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true, attributes: nil) - let probe = (path as NSString).appendingPathComponent(".programa-preflight-\(UUID().uuidString)") - let descriptor = probe.withCString { Darwin.open($0, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0o600) } - guard descriptor >= 0 else { throw codexPOSIXError("preflight write access to", path: path) } - Darwin.close(descriptor) - guard unlink(probe) == 0 else { throw codexPOSIXError("remove preflight", path: probe) } - } - - func codexAtomicWrite(_ data: Data, to path: String, mode: mode_t) throws { - var targetInfo = stat() - if path.withCString({ Darwin.lstat($0, &targetInfo) }) == 0 { - guard (targetInfo.st_mode & S_IFMT) == S_IFREG else { - throw CodexHooksError(message: "refusing to replace non-regular file \(path)") - } - } else if errno != ENOENT { - throw codexPOSIXError("inspect", path: path) - } - let parent = (path as NSString).deletingLastPathComponent - try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true, attributes: nil) - let directoryDescriptor = try codexOpenDirectory(parent) - defer { Darwin.close(directoryDescriptor) } - let name = (path as NSString).lastPathComponent - let temporary = (parent as NSString).appendingPathComponent(".\(name).programa-\(UUID().uuidString)") - let descriptor = temporary.withCString { Darwin.open($0, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, mode) } - guard descriptor >= 0 else { throw codexPOSIXError("create", path: temporary) } - var shouldRemove = true - var descriptorIsOpen = true - defer { - if descriptorIsOpen { Darwin.close(descriptor) } - if shouldRemove { unlink(temporary) } - } - try data.withUnsafeBytes { bytes in - var offset = 0 - while offset < bytes.count { - let count = Darwin.write(descriptor, bytes.baseAddress!.advanced(by: offset), bytes.count - offset) - if count < 0 { - if errno == EINTR { continue } - throw codexPOSIXError("write", path: temporary) - } - offset += count - } - } - guard fchmod(descriptor, mode) == 0 else { throw codexPOSIXError("set mode", path: temporary) } - guard fsync(descriptor) == 0 else { throw codexPOSIXError("sync", path: temporary) } - guard Darwin.close(descriptor) == 0 else { throw codexPOSIXError("close", path: temporary) } - descriptorIsOpen = false - var replacementInfo = stat() - if path.withCString({ Darwin.lstat($0, &replacementInfo) }) == 0, - (replacementInfo.st_mode & S_IFMT) != S_IFREG { - throw CodexHooksError(message: "refusing to replace non-regular file \(path)") - } - guard rename(temporary, path) == 0 else { throw codexPOSIXError("replace", path: path) } - shouldRemove = false - codexSyncDirectoryAfterCommit(directoryDescriptor, path: parent) - } - - func codexRestoreFile(_ snapshot: CodexFileSnapshot?, at path: String) throws { - if let snapshot { - try codexAtomicWrite(snapshot.data, to: path, mode: snapshot.mode) - return - } - let parent = (path as NSString).deletingLastPathComponent - let directoryDescriptor = try codexOpenDirectory(parent) - defer { Darwin.close(directoryDescriptor) } - if unlink(path) != 0 { - if errno == ENOENT { return } - throw codexPOSIXError("remove", path: path) - } - codexSyncDirectoryAfterCommit(directoryDescriptor, path: parent) - } - - private func codexOpenDirectory(_ path: String) throws -> Int32 { - let descriptor = path.withCString { Darwin.open($0, O_RDONLY | O_DIRECTORY | O_CLOEXEC) } - guard descriptor >= 0 else { throw codexPOSIXError("open directory", path: path) } - return descriptor - } - - private func codexSyncDirectoryAfterCommit(_ descriptor: Int32, path: String) { - guard fsync(descriptor) != 0 else { return } - let message = "warning: committed Codex hook change, but syncing directory \(path) failed: \(String(cString: strerror(errno)))\n" - FileHandle.standardError.write(Data(message.utf8)) - } - - private func codexPOSIXError(_ action: String, path: String) -> Error { - let code = errno - return CodexHooksError(message: "\(action) \(path): \(String(cString: strerror(code)))") - } - // MARK: - Agent skill (SKILL.md) /// The `programa` agent skill: teaches an agent running inside programa diff --git a/CLI/Commands/LocalCommands.swift b/CLI/Commands/LocalCommands.swift new file mode 100644 index 000000000..a87c3fa7f --- /dev/null +++ b/CLI/Commands/LocalCommands.swift @@ -0,0 +1,145 @@ +import Foundation + +extension ProgramaCLI { + /// `codex`, `claude` and `opencode`: one subcommand plus `--yes`. `-y` is a + /// single-dash token, so it arrives as a positional after the subcommand. + private var integrationInstallGrammar: CLIArgumentGrammar { + CLIArgumentGrammar(booleanOptions: ["yes", "y"], minPositionals: 1, maxPositionals: 2, shortFlags: ["-y"], validate: { command, parsed in + guard parsed.positional.count == 1 || parsed.positional[1] == "-y" else { + throw CLIError(message: "\(command): unexpected arguments: \(parsed.positional[1])") + } + }) + } + + /// Commands dispatched before any socket work (`CLICommandDispatcher.run()`), + /// listed here so `usage()` has one source for their help text. + func localCommandDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["welcome"], + helpLines: ["welcome"], + connectionPolicy: .local, + detailedUsage: """ + Usage: programa welcome + + Show a welcome screen with the programa logo and useful shortcuts. + Auto-runs once on first launch. + """, + grammar: CLIArgumentGrammar(), + execute: nil + ), + CommandDescriptor( + names: ["shortcuts"], + helpLines: ["shortcuts"], + connectionPolicy: .local, + argumentContract: .handlerParsed, + detailedUsage: """ + Usage: programa shortcuts + + Open the Settings window to Keyboard Shortcuts. + """, + execute: nil + ), + CommandDescriptor( + names: ["feedback"], + helpLines: ["feedback [--email --body [--image ...]] (opens GitHub issues; direct submission disabled)"], + connectionPolicy: .local, + argumentContract: .handlerParsed, + detailedUsage: """ + Usage: programa feedback + programa feedback --email --body [--image ...] + + Without args, opens the GitHub issues page (https://github.com/darkroomengineering/programa/issues) in your browser. + + Direct feedback submission is disabled; --email/--body/--image are accepted but the app will + return an error telling you to report the issue on GitHub instead. + + Flags: + --email Contact email for follow-up (submission disabled) + --body Feedback body (submission disabled) + --image Attach an image file, repeat for multiple images (submission disabled) + """, + execute: nil + ), + CommandDescriptor(names: ["themes"], helpLines: ["themes [list|set|clear]"], connectionPolicy: .local, argumentContract: .handlerParsed, execute: nil), + CommandDescriptor(names: ["claude-teams"], helpLines: ["claude-teams [claude-args...]"], connectionPolicy: .local, helpPolicy: .passthrough, argumentContract: .handlerParsed, execute: nil), + CommandDescriptor( + names: ["codex"], + helpLines: ["codex [--yes]"], + connectionPolicy: .local, + detailedUsage: """ + Usage: programa codex [--yes] + + Shows the changes and asks before applying them. --yes (-y) applies + without asking; without it a non-interactive stdin exits 1 unchanged. + + Install or remove Programa's Codex notification hooks in + ~/.codex/hooks.json (or $CODEX_HOME/hooks.json), the + `programa` agent skill (SKILL.md) into ~/.agents/skills/programa/, + and the bundled programa-mcp server (via `codex mcp add programa`). + """, + grammar: integrationInstallGrammar, + execute: nil + ), + CommandDescriptor( + names: ["claude"], + helpLines: ["claude [--yes]"], + connectionPolicy: .local, + detailedUsage: """ + Usage: programa claude [--yes] + + Install or remove Programa's persistent Claude Code hooks in + ~/.claude/settings.json (or $CLAUDE_CONFIG_DIR/settings.json), + and the `programa` agent skill (SKILL.md) into + ~/.claude/skills/programa/. + Unlike the runtime wrapper, this makes the integration work from + any terminal, not just programa's. + --yes (-y) applies without asking; without it a non-interactive + stdin exits 1 unchanged. + """, + grammar: integrationInstallGrammar, + execute: nil + ), + CommandDescriptor( + names: ["opencode"], + helpLines: ["opencode [--yes]"], + connectionPolicy: .local, + detailedUsage: """ + Usage: programa opencode [--yes] + + Install or remove Programa's OpenCode plugin in + ~/.config/opencode/plugins/programa.js (or $OPENCODE_CONFIG_DIR/plugins/programa.js), + and the `programa` agent skill (SKILL.md) into + ~/.config/opencode/skills/programa/ (or $OPENCODE_CONFIG_DIR/skills/programa/), + and the bundled programa-mcp server as `mcp.programa` in + ~/.config/opencode/opencode.json (or $OPENCODE_CONFIG_DIR/opencode.json). + A JSONC config is never rewritten; the command prints the entry to add. + OpenCode auto-loads local plugin files, so no npm install is needed. + The MCP registration is untested against a live OpenCode. + --yes (-y) applies without asking; without it a non-interactive + stdin exits 1 unchanged. + """, + grammar: integrationInstallGrammar, + execute: nil + ), + CommandDescriptor( + names: ["aside"], + helpLines: ["aside [--with-devtools] [--yes]"], + connectionPolicy: .local, + detailedUsage: """ + Usage: programa aside [--with-devtools] [--yes] + + Detect the Aside agent browser CLI and register its MCP server with + Claude Code and Codex. `status` reports the detected aside binary, + the DevTools endpoint if Aside is running, and each client's + registration state. `install-mcp` runs `claude mcp add` / `codex mcp add` + for each detected client; `--with-devtools` also registers a + chrome-devtools-mcp server pointed at Aside's DevTools port. + `uninstall-mcp` removes both. `--yes`/`-y` skips the confirmation prompt. + """, + grammar: CLIArgumentGrammar(booleanOptions: ["yes", "y", "with-devtools"], minPositionals: 1, maxPositionals: 1), + execute: nil + ), + ] + } +} diff --git a/CLI/Commands/NotificationCommands.swift b/CLI/Commands/NotificationCommands.swift new file mode 100644 index 000000000..9d48f93d7 --- /dev/null +++ b/CLI/Commands/NotificationCommands.swift @@ -0,0 +1,154 @@ +import Foundation + +extension ProgramaCLI { + func notificationDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["notify"], + helpLines: ["notify --title [--subtitle ] [--body ] [--workspace ] [--surface ]"], + detailedUsage: """ + Usage: programa notify [flags] + + Send a notification to a workspace/surface. + + Flags: + --title Notification title (default: "Notification") + --subtitle Notification subtitle + --body Notification body + --workspace Target workspace (default: $PROGRAMA_WORKSPACE_ID) + --surface Target surface (default: $PROGRAMA_SURFACE_ID) + + Example: + programa notify --title "Build done" --body "All tests passed" + programa notify --title "Error" --subtitle "test.swift" --body "Line 42: syntax error" + """, + grammar: CLIArgumentGrammar(valueOptions: ["title", "subtitle", "body", "workspace", "surface"]), + execute: { ctx in + let title = self.optionValue(ctx.commandArgs, name: "--title") ?? "Notification" + let subtitle = self.optionValue(ctx.commandArgs, name: "--subtitle") ?? "" + let body = self.optionValue(ctx.commandArgs, name: "--body") ?? "" + + let explicitWorkspaceArg = self.optionValue(ctx.commandArgs, name: "--workspace") + let preferTTYFallback = ctx.windowId == nil && ProcessInfo.processInfo.environment["TMUX"] != nil + let callerWorkspaceArg = preferTTYFallback + ? nil + : (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg + let explicitSurfaceArg = self.optionValue(ctx.commandArgs, name: "--surface") + let envWorkspaceArg = ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] + let callerSurfaceArg = explicitSurfaceArg == nil + && preferTTYFallback == false + && ctx.windowId == nil + && (explicitWorkspaceArg == nil || explicitWorkspaceArg == envWorkspaceArg) + ? ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] + : nil + let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg + + let targetWorkspace = try { + if explicitWorkspaceArg != nil { + return try self.resolveWorkspaceId(workspaceArg, client: ctx.client) + } + return try self.resolveWorkspaceIdAllowingFallback(workspaceArg, client: ctx.client) + }() + let targetSurface = try { + if explicitSurfaceArg != nil { + return try self.resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: ctx.client) + } + return try self.resolveSurfaceIdAllowingFallback( + surfaceArg, + workspaceId: targetWorkspace, + client: ctx.client + ) + }() + + let payload = try ctx.client.sendV2(method: V2MethodNames.notificationCreateForTarget, params: [ + "workspace_id": targetWorkspace, + "surface_id": targetSurface, + "title": title, + "subtitle": subtitle, + "body": body, + ]) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: "OK") + } + ), + + CommandDescriptor( + names: ["list-notifications"], + helpLines: ["list-notifications"], + detailedUsage: """ + Usage: programa list-notifications + + List queued notifications. + """, + grammar: CLIArgumentGrammar(), + execute: { ctx in + let listed = try ctx.client.sendV2(method: V2MethodNames.notificationList) + let notifications = listed["notifications"] as? [[String: Any]] ?? [] + if ctx.jsonOutput { + let payload = notifications.enumerated().map { _, item -> [String: Any] in + var dict: [String: Any] = [ + "id": (item["id"] as? String) ?? "", + "workspace_id": (item["workspace_id"] as? String) ?? "", + "is_read": (item["is_read"] as? Bool) ?? false, + "title": (item["title"] as? String) ?? "", + "subtitle": (item["subtitle"] as? String) ?? "", + "body": (item["body"] as? String) ?? "", + ] + dict["surface_id"] = item["surface_id"] as? String ?? NSNull() + return dict + } + print(self.jsonString(["notifications": payload])) + } else if notifications.isEmpty { + print("No notifications") + } else { + let lines = notifications.enumerated().map { index, item -> String in + let surfaceText = (item["surface_id"] as? String) ?? "none" + let readText = ((item["is_read"] as? Bool) ?? false) ? "read" : "unread" + let id = (item["id"] as? String) ?? "" + let workspaceId = (item["workspace_id"] as? String) ?? "" + let title = (item["title"] as? String) ?? "" + let subtitle = (item["subtitle"] as? String) ?? "" + let body = (item["body"] as? String) ?? "" + return "\(index):\(id)|\(workspaceId)|\(surfaceText)|\(readText)|\(title)|\(subtitle)|\(body)" + } + print(lines.joined(separator: "\n")) + } + } + ), + + CommandDescriptor( + names: ["clear-notifications"], + helpLines: ["clear-notifications [--workspace ]"], + detailedUsage: """ + Usage: programa clear-notifications [--workspace ] + + Clear queued notifications. Run from a Programa terminal, it clears only + that terminal's workspace ($PROGRAMA_WORKSPACE_ID). Run anywhere else, or + with the global --window option, it clears every notification. + + Flags: + --workspace Clear only this workspace's notifications + + Example: + programa clear-notifications + programa clear-notifications --workspace workspace:2 + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), + execute: { ctx in + let response: [String: Any] + if let wsFlag = self.optionValue(ctx.commandArgs, name: "--workspace") { + let wsId = try self.resolveWorkspaceId(wsFlag, client: ctx.client) + response = try ctx.client.sendV2(method: V2MethodNames.notificationClear, params: ["workspace_id": wsId]) + } else if ctx.windowId == nil, + let envWs = ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"], + let wsId = try? self.resolveWorkspaceId(envWs, client: ctx.client) { + response = try ctx.client.sendV2(method: V2MethodNames.notificationClear, params: ["workspace_id": wsId]) + } else { + response = try ctx.client.sendV2(method: V2MethodNames.notificationClear) + } + self.printV2Payload(response.merging(["ok": true]) { $1 }, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: "OK") + } + ), + ] + } +} diff --git a/CLI/Commands/PaneCommands.swift b/CLI/Commands/PaneCommands.swift new file mode 100644 index 000000000..75e516325 --- /dev/null +++ b/CLI/Commands/PaneCommands.swift @@ -0,0 +1,808 @@ +import Foundation + +extension ProgramaCLI { + func paneListDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["new-split"], + helpLines: ["new-split [--workspace ] [--surface ] [--panel ]"], + detailedUsage: """ + Usage: programa new-split [flags] + + Split the current pane in the given direction. + + Flags: + --workspace Target workspace (default: $PROGRAMA_WORKSPACE_ID) + --surface Surface to split from (default: $PROGRAMA_SURFACE_ID) + --panel Alias for --surface + + Example: + programa new-split right + programa new-split down --workspace workspace:1 + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "surface", "panel"], minPositionals: 1, maxPositionals: 1, validate: { _, parsed in + guard ["left", "right", "up", "down"].contains(parsed.positional[0].lowercased()) else { + throw CLIError(message: "new-split: direction must be left, right, up, or down") + } + }), + execute: { ctx in + let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") + let (panelArg, rem1) = self.parseOption(rem0, name: "--panel") + let (sfArg, rem2) = self.parseOption(rem1, name: "--surface") + let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + guard let direction = rem2.first else { + throw CLIError(message: "new-split requires a direction") + } + var params: [String: Any] = ["direction": direction] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try self.resolveCommandSurface( + explicitSurface: sfArg ?? panelArg, explicitWorkspace: wsArg, + windowId: ctx.windowId, workspaceHandle: wsId, client: ctx.client + ) + if let sfId { params["surface_id"] = sfId } + let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceSplit, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) + } + ), + + CommandDescriptor( + names: ["list-panes"], + helpLines: ["list-panes [--workspace ]"], + detailedUsage: """ + Usage: programa list-panes [--workspace ] + + List panes in a workspace. + + Flags: + --workspace Workspace context (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa list-panes + programa list-panes --workspace workspace:2 + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), + execute: { ctx in + let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) + var params: [String: Any] = [:] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let payload = try ctx.client.sendV2(method: V2MethodNames.paneList, params: params) + if ctx.jsonOutput { + print(self.jsonString(self.formatIDs(payload, mode: ctx.idFormat))) + } else { + let panes = payload["panes"] as? [[String: Any]] ?? [] + if panes.isEmpty { + print("No panes") + } else { + for pane in panes { + let focused = (pane["focused"] as? Bool) == true + let handle = self.textHandle(pane, idFormat: ctx.idFormat) + let count = pane["surface_count"] as? Int ?? 0 + let prefix = focused ? "* " : " " + let focusTag = focused ? " [focused]" : "" + print("\(prefix)\(handle) [\(count) surface\(count == 1 ? "" : "s")]\(focusTag)") + } + } + } + } + ), + + CommandDescriptor( + names: ["list-pane-surfaces"], + helpLines: ["list-pane-surfaces [--workspace ] [--pane ]"], + detailedUsage: """ + Usage: programa list-pane-surfaces [--workspace ] [--pane ] + + List surfaces in a pane. + + Flags: + --workspace Workspace context (default: $PROGRAMA_WORKSPACE_ID) + --pane Restrict to a specific pane (default: focused pane) + + Example: + programa list-pane-surfaces + programa list-pane-surfaces --workspace workspace:2 --pane pane:1 + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "pane"]), + execute: { ctx in + let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) + let paneRaw = self.optionValue(ctx.commandArgs, name: "--pane") + var params: [String: Any] = [:] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let paneId = try self.normalizePaneHandle(paneRaw, client: ctx.client, workspaceHandle: wsId) + if let paneId { params["pane_id"] = paneId } + let payload = try ctx.client.sendV2(method: V2MethodNames.paneSurfaces, params: params) + if ctx.jsonOutput { + print(self.jsonString(self.formatIDs(payload, mode: ctx.idFormat))) + } else { + let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] + if surfaces.isEmpty { + print("No surfaces in pane") + } else { + for surface in surfaces { + let selected = (surface["selected"] as? Bool) == true + let handle = self.textHandle(surface, idFormat: ctx.idFormat) + let title = (surface["title"] as? String) ?? "" + let prefix = selected ? "* " : " " + let selTag = selected ? " [selected]" : "" + print("\(prefix)\(handle) \(title)\(selTag)") + } + } + } + } + ), + ] + } + + func paneSurfaceDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["focus-pane"], + helpLines: ["focus-pane --pane [--workspace ]"], + detailedUsage: """ + Usage: programa focus-pane [--pane | ] [flags] + + Focus the specified pane. + + Flags: + --pane Pane to focus (required unless passed positionally) + --workspace Workspace context (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa focus-pane --pane pane:2 + programa focus-pane pane:1 + programa focus-pane --pane pane:1 --workspace workspace:2 + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "pane"], maxPositionals: 1, validate: { _, parsed in + guard parsed.options["pane"] != nil || parsed.positional.count == 1 else { + throw CLIError(message: "focus-pane requires --pane ") + } + guard !(parsed.options["pane"] != nil && !parsed.positional.isEmpty) else { + throw CLIError(message: "focus-pane: provide the pane once") + } + }), + execute: { ctx in + let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) + guard let paneRaw = self.optionOrFirstPositional(ctx.commandArgs, name: "--pane") else { + throw CLIError(message: "focus-pane requires --pane ") + } + var params: [String: Any] = [:] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let paneId = try self.normalizePaneHandle(paneRaw, client: ctx.client, workspaceHandle: wsId) + if let paneId { params["pane_id"] = paneId } + let payload = try ctx.client.sendV2(method: V2MethodNames.paneFocus, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat, kinds: ["pane", "workspace"])) + } + ), + + CommandDescriptor( + names: ["new-pane"], + helpLines: ["new-pane [--type ] [--direction ] [--workspace ]"], + detailedUsage: """ + Usage: programa new-pane [flags] + + Create a new pane in the workspace. + + Flags: + --type Pane type (default: terminal) + --direction Split direction (default: right) + --workspace Target workspace (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa new-pane + programa new-pane --direction down + """, + grammar: CLIArgumentGrammar(valueOptions: ["type", "direction", "workspace"], validate: { _, parsed in + if let type = parsed.options["type"], type.lowercased() != "terminal" { + throw CLIError(message: "new-pane: --type must be terminal") + } + if let direction = parsed.options["direction"], !["left", "right", "up", "down"].contains(direction.lowercased()) { + throw CLIError(message: "new-pane: invalid direction") + } + }), + execute: { ctx in + let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) + let type = self.optionValue(ctx.commandArgs, name: "--type") + let direction = self.optionValue(ctx.commandArgs, name: "--direction") ?? "right" + var params: [String: Any] = ["direction": direction] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + if let type { params["type"] = type } + let payload = try ctx.client.sendV2(method: V2MethodNames.paneCreate, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat, kinds: ["surface", "pane", "workspace"])) + } + ), + + CommandDescriptor( + names: ["new-surface"], + helpLines: ["new-surface [--type ] [--pane ] [--workspace ]"], + detailedUsage: """ + Usage: programa new-surface [flags] + + Create a new surface (tab) in a pane. + + Flags: + --type Surface type (default: terminal) + --pane Target pane + --workspace Target workspace (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa new-surface + programa new-surface --pane pane:1 + """, + grammar: CLIArgumentGrammar(valueOptions: ["type", "pane", "workspace"], validate: { _, parsed in + if let type = parsed.options["type"], type.lowercased() != "terminal" { + throw CLIError(message: "new-surface: --type must be terminal") + } + }), + execute: { ctx in + let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) + let type = self.optionValue(ctx.commandArgs, name: "--type") + let paneRaw = self.optionValue(ctx.commandArgs, name: "--pane") + var params: [String: Any] = [:] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let paneId = try self.normalizePaneHandle(paneRaw, client: ctx.client, workspaceHandle: wsId) + if let paneId { params["pane_id"] = paneId } + if let type { params["type"] = type } + let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceCreate, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat, kinds: ["surface", "pane", "workspace"])) + } + ), + + CommandDescriptor( + names: ["close-surface"], + helpLines: ["close-surface [--surface ] [--workspace ]"], + detailedUsage: """ + Usage: programa close-surface [flags] + + Close a surface. Defaults to the focused surface if none specified. + + Flags: + --surface Surface to close (default: $PROGRAMA_SURFACE_ID) + --panel Alias for --surface + --workspace Workspace context (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa close-surface + programa close-surface --surface surface:3 + """, + grammar: CLIArgumentGrammar(valueOptions: ["surface", "panel", "workspace"]), + execute: { ctx in + let csWsFlag = self.optionValue(ctx.commandArgs, name: "--workspace") + let workspaceArg = csWsFlag ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + let surfaceRaw = self.optionValue(ctx.commandArgs, name: "--surface") ?? self.optionValue(ctx.commandArgs, name: "--panel") + var params: [String: Any] = [:] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try self.resolveCommandSurface( + explicitSurface: surfaceRaw, explicitWorkspace: csWsFlag, + windowId: ctx.windowId, workspaceHandle: wsId, client: ctx.client + ) + if let sfId { params["surface_id"] = sfId } + let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceClose, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) + } + ), + + CommandDescriptor( + names: ["move-surface"], + helpLines: ["move-surface --surface [--pane ] [--workspace ] [--window ] [--before ] [--after ] [--index ] [--focus ]"], + detailedUsage: """ + Usage: programa move-surface [--surface | ] [flags] + + Move a surface to a different pane, workspace, or window. + + Flags: + --surface Surface to move (required unless passed positionally) + --pane Target pane + --workspace Target workspace + --window Target window + --before Place before this surface + --before-surface + Alias for --before + --after Place after this surface + --after-surface + Alias for --after + --index Place at this index + --focus Focus the surface after moving + + Example: + programa move-surface --surface surface:1 --workspace workspace:2 + programa move-surface surface:1 --pane pane:2 --index 0 + """, + grammar: CLIArgumentGrammar(valueOptions: ["surface", "pane", "workspace", "window", "before", "before-surface", "after", "after-surface", "index", "focus"], maxPositionals: 1, validate: { _, parsed in + guard parsed.options["surface"] != nil || parsed.positional.count == 1 else { + throw CLIError(message: "move-surface requires --surface ") + } + guard !(parsed.options["surface"] != nil && !parsed.positional.isEmpty) else { + throw CLIError(message: "move-surface: provide the surface once") + } + if let raw = parsed.options["index"], (Int(raw) ?? -1) < 0 { + throw CLIError(message: "move-surface: --index must be a nonnegative integer") + } + if let raw = parsed.options["focus"], !["true", "false", "1", "0", "yes", "no", "on", "off"].contains(raw.lowercased()) { + throw CLIError(message: "move-surface: --focus must be true or false") + } + let anchors = ["index", "before", "before-surface", "after", "after-surface"].filter { parsed.options[$0] != nil } + guard anchors.count <= 1 else { + throw CLIError(message: "move-surface accepts only one of --index, --before, or --after") + } + }), + execute: { ctx in + try self.runMoveSurface(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) + } + ), + + CommandDescriptor( + names: ["reorder-surface"], + helpLines: ["reorder-surface --surface (--index | --before | --after )"], + detailedUsage: """ + Usage: programa reorder-surface [--surface | ] [flags] + + Reorder a surface within its pane. + + Flags: + --surface Surface to reorder (required unless passed positionally) + --workspace Workspace context + --before Place before this surface + --before-surface + Alias for --before + --after Place after this surface + --after-surface + Alias for --after + --index Place at this index + + Example: + programa reorder-surface --surface surface:1 --index 0 + programa reorder-surface --surface surface:3 --after surface:1 + """, + grammar: CLIArgumentGrammar(valueOptions: ["surface", "workspace", "index", "before", "before-surface", "after", "after-surface"], maxPositionals: 1, validate: { _, parsed in + guard parsed.options["surface"] != nil || parsed.positional.count == 1 else { + throw CLIError(message: "reorder-surface requires --surface ") + } + guard !(parsed.options["surface"] != nil && !parsed.positional.isEmpty) else { + throw CLIError(message: "reorder-surface: provide the surface once") + } + let anchors = ["index", "before", "before-surface", "after", "after-surface"].filter { parsed.options[$0] != nil } + guard anchors.count == 1 else { + throw CLIError(message: "reorder-surface requires exactly one of --index, --before, or --after") + } + if let raw = parsed.options["index"], (Int(raw) ?? -1) < 0 { + throw CLIError(message: "reorder-surface: --index must be a nonnegative integer") + } + }), + execute: { ctx in + try self.runReorderSurface(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) + } + ), + + CommandDescriptor( + names: ["tab-action"], + helpLines: ["tab-action --action [--tab ] [--surface ] [--workspace ] [--title ]"], + detailedUsage: """ + Usage: programa tab-action --action [flags] + + Perform horizontal tab context-menu actions from CLI/socket. + + Actions: + rename | clear-name + close-left | close-right | close-others + new-terminal-right + reload | duplicate + pin | unpin + mark-unread + + Flags: + --action Action name (required if not positional) + --tab Target tab (accepts tab: or surface:; default: $PROGRAMA_TAB_ID, then $PROGRAMA_SURFACE_ID, then focused tab) + --surface Alias for --tab (backward compatibility) + --workspace Workspace context (default: current/$PROGRAMA_WORKSPACE_ID) + --title Title for rename (or pass trailing title text) + + Example: + programa tab-action --tab tab:3 --action pin + programa tab-action --action close-right + programa tab-action --tab tab:2 --action rename --title "build logs" + """, + grammar: CLIArgumentGrammar(valueOptions: ["action", "tab", "surface", "workspace", "title"], maxPositionals: nil, validate: { _, parsed in + guard parsed.options["action"] != nil || !parsed.positional.isEmpty else { + throw CLIError(message: "tab-action requires --action ") + } + }), + execute: { ctx in + try self.runTabAction(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, windowOverride: ctx.windowId) + } + ), + + CommandDescriptor( + names: ["rename-tab"], + helpLines: ["rename-tab [--workspace ] [--tab ] [--surface ] "], + detailedUsage: """ + Usage: programa rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] [--] <title> + + Compatibility alias for tab-action rename. + + Resolution order for target tab: + 1) --tab + 2) --surface + 3) $PROGRAMA_TAB_ID / $PROGRAMA_SURFACE_ID + 4) currently focused tab (optionally within --workspace) + + Flags: + --workspace <id|ref> Workspace context (default: current/$PROGRAMA_WORKSPACE_ID) + --tab <id|ref> Tab target (supports tab:<n> or surface:<n>) + --surface <id|ref> Alias for --tab + --title <text> Explicit title (or use trailing positional title) + + Examples: + programa rename-tab "build logs" + programa rename-tab --tab tab:3 "staging server" + programa rename-tab --workspace workspace:2 --surface surface:5 --title "agent run" + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "tab", "surface", "title"], maxPositionals: nil, validate: { _, parsed in + guard parsed.options["title"] != nil || !parsed.positional.isEmpty else { + throw CLIError(message: "rename-tab requires a title") + } + }), + execute: { ctx in + try self.runRenameTab(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, windowOverride: ctx.windowId) + } + ), + + CommandDescriptor( + names: ["drag-surface-to-split"], + helpLines: ["drag-surface-to-split --surface <id|ref> <left|right|up|down>"], + detailedUsage: """ + Usage: programa drag-surface-to-split --surface <id|ref> <left|right|up|down> + + Drag a surface into a new split in the given direction. + + Flags: + --surface <id|ref> Surface to drag (required) + --panel <id|ref> Alias for --surface + + Example: + programa drag-surface-to-split --surface surface:1 right + programa drag-surface-to-split --panel surface:2 down + """, + grammar: CLIArgumentGrammar(valueOptions: ["surface", "panel"], minPositionals: 1, maxPositionals: 1, validate: { _, parsed in + guard (parsed.options["surface"] != nil) != (parsed.options["panel"] != nil) else { + throw CLIError(message: "drag-surface-to-split requires exactly one of --surface or --panel") + } + guard ["left", "right", "up", "down"].contains(parsed.positional[0].lowercased()) else { + throw CLIError(message: "drag-surface-to-split: invalid direction") + } + }), + execute: { ctx in + let (surfaceArg, rem0) = self.parseOption(ctx.commandArgs, name: "--surface") + let (panelArg, rem1) = self.parseOption(rem0, name: "--panel") + let surface = surfaceArg ?? panelArg + guard let surface else { + throw CLIError(message: "drag-surface-to-split requires --surface <id|ref>") + } + guard let direction = rem1.first else { + throw CLIError(message: "drag-surface-to-split requires a direction") + } + // No --workspace support: leave workspace_id unset so v2 targets the current selection. + let surfaceIdForDrag = try self.normalizeSurfaceHandle(surface, client: ctx.client, workspaceHandle: nil) + var dragParams: [String: Any] = ["direction": direction] + if let surfaceIdForDrag { dragParams["surface_id"] = surfaceIdForDrag } + let dragPayload = try ctx.client.sendV2(method: V2MethodNames.surfaceDragToSplit, params: dragParams) + print("OK \((dragPayload["pane_id"] as? String) ?? "")") + } + ), + ] + } + + func surfaceDiagnosticsDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["refresh-surfaces"], + helpLines: ["refresh-surfaces"], + detailedUsage: """ + Usage: programa refresh-surfaces + + Refresh surface snapshots for the focused workspace. + """, + grammar: CLIArgumentGrammar(), + execute: { ctx in + // No --workspace support: leave workspace_id unset so v2 targets the current selection. + let refreshPayload = try ctx.client.sendV2(method: V2MethodNames.surfaceRefresh, params: [:]) + print("OK Refreshed \(self.intFromAny(refreshPayload["refreshed"]) ?? 0) surfaces") + } + ), + + CommandDescriptor( + names: ["reload-config"], + helpLines: ["reload-config"], + detailedUsage: """ + Usage: programa reload-config + + Run the same configuration reload as the Reload Configuration shortcut. + This reloads Ghostty config, re-reads ~/.config/programa/settings.json, and refreshes terminals. + + Example: + programa reload-config + """, + grammar: CLIArgumentGrammar(), + execute: { ctx in + if let unexpected = ctx.commandArgs.first { + throw CLIError(message: "reload-config does not accept arguments. Unexpected argument '\(unexpected)'") + } + _ = try ctx.client.sendV2(method: V2MethodNames.appReloadConfig) + print("OK Reloaded config") + } + ), + + CommandDescriptor( + names: ["surface-health"], + helpLines: ["surface-health [--workspace <id|ref>]"], + detailedUsage: """ + Usage: programa surface-health [--workspace <id|ref>] + + List health details for surfaces in a workspace. + + Flags: + --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa surface-health + programa surface-health --workspace workspace:2 + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), + execute: { ctx in + let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) + var params: [String: Any] = [:] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceHealth, params: params) + if ctx.jsonOutput { + print(self.jsonString(self.formatIDs(payload, mode: ctx.idFormat))) + } else { + let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] + if surfaces.isEmpty { + print("No surfaces") + } else { + for surface in surfaces { + let handle = self.textHandle(surface, idFormat: ctx.idFormat) + let sType = (surface["type"] as? String) ?? "" + let inWindow = surface["in_window"] + let inWindowStr: String + if let b = inWindow as? Bool { + inWindowStr = " in_window=\(b)" + } else { + inWindowStr = "" + } + print("\(handle) type=\(sType)\(inWindowStr)") + } + } + } + } + ), + + CommandDescriptor( + names: ["debug-terminals"], + helpLines: [], + detailedUsage: """ + Usage: programa debug-terminals + + Print live Ghostty terminal runtime metadata across all windows and workspaces. + Intended for debugging stray or detached terminal views. + """, + grammar: CLIArgumentGrammar(), + execute: { ctx in + let unexpected = ctx.commandArgs.filter { $0 != "--" } + if let extra = unexpected.first { + throw CLIError(message: "debug-terminals: unexpected argument '\(extra)'") + } + let payload = try ctx.client.sendV2(method: V2MethodNames.debugTerminals) + if ctx.jsonOutput { + print(self.jsonString(self.formatIDs(payload, mode: ctx.idFormat))) + } else { + print(self.formatDebugTerminalsPayload(payload, idFormat: ctx.idFormat)) + } + } + ), + + CommandDescriptor( + names: ["trigger-flash"], + helpLines: ["trigger-flash [--workspace <id|ref>] [--surface <id|ref>]"], + detailedUsage: """ + Usage: programa trigger-flash [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] + + Trigger the unread flash indicator for a surface. + + Flags: + --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) + --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) + --panel <id|ref> Alias for --surface + + Example: + programa trigger-flash + programa trigger-flash --workspace workspace:2 --surface surface:3 + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "surface", "panel"]), + execute: { ctx in + let tfWsFlag = self.optionValue(ctx.commandArgs, name: "--workspace") + let explicitWorkspaceArg = tfWsFlag + let preferTTYFallback = ctx.windowId == nil && ProcessInfo.processInfo.environment["TMUX"] != nil + let callerWorkspaceArg = preferTTYFallback + ? nil + : (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg + let explicitSurfaceArg = self.optionValue(ctx.commandArgs, name: "--surface") ?? self.optionValue(ctx.commandArgs, name: "--panel") + let callerSurfaceArg = explicitSurfaceArg == nil && preferTTYFallback == false && ctx.windowId == nil + ? ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] + : nil + let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg + var params: [String: Any] = [:] + let wsId = try { + if explicitWorkspaceArg != nil { + return try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + } + return try self.resolveWorkspaceIdAllowingFallback(workspaceArg, client: ctx.client) + }() + if let wsId { params["workspace_id"] = wsId } + let sfId = try { + if explicitSurfaceArg != nil { + return try self.normalizeSurfaceHandle(surfaceArg, client: ctx.client, workspaceHandle: wsId) + } + guard let wsId else { return nil } + return try self.resolveSurfaceIdAllowingFallback( + surfaceArg, + workspaceId: wsId, + client: ctx.client + ) + }() + if let sfId { params["surface_id"] = sfId } + let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceTriggerFlash, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) + } + ), + ] + } + + func panelDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["list-panels"], + helpLines: ["list-panels [--workspace <id|ref>]"], + detailedUsage: """ + Usage: programa list-panels [--workspace <id|ref>] + + List surfaces (panels) in a workspace. + + Flags: + --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa list-panels + programa list-panels --workspace workspace:2 + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), + execute: { ctx in + let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) + var params: [String: Any] = [:] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceList, params: params) + if ctx.jsonOutput { + print(self.jsonString(self.formatIDs(payload, mode: ctx.idFormat))) + } else { + let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] + if surfaces.isEmpty { + print("No surfaces") + } else { + for surface in surfaces { + let focused = (surface["focused"] as? Bool) == true + let handle = self.textHandle(surface, idFormat: ctx.idFormat) + let sType = (surface["type"] as? String) ?? "" + let title = (surface["title"] as? String) ?? "" + let prefix = focused ? "* " : " " + let focusTag = focused ? " [focused]" : "" + let titlePart = title.isEmpty ? "" : " \"\(title)\"" + print("\(prefix)\(handle) \(sType)\(focusTag)\(titlePart)") + } + } + } + } + ), + + CommandDescriptor( + names: ["focus-panel"], + helpLines: ["focus-panel --panel <id|ref> [--workspace <id|ref>]"], + argumentContract: .focusPanel, + detailedUsage: """ + Usage: programa focus-panel --panel <id|ref> [--workspace <id|ref>] + + Focus a specific panel (surface). + + Flags: + --panel <id|ref> Panel/surface to focus (required) + --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa focus-panel --panel surface:2 + programa focus-panel --panel surface:5 --workspace workspace:2 + """, + execute: { ctx in + let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) + guard let panelRaw = self.optionValue(ctx.commandArgs, name: "--panel") else { + throw CLIError(message: "focus-panel requires --panel") + } + var params: [String: Any] = [:] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try self.normalizeSurfaceHandle(panelRaw, client: ctx.client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceFocus, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) + } + ), + ] + } + + func viewerPanelDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["markdown"], + helpLines: ["markdown [open] <path> (open markdown file in formatted viewer panel with live reload)"], + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "window", "surface", "direction"], minPositionals: 1, maxPositionals: 2, validate: { _, parsed in + if parsed.positional.count == 2, parsed.positional[0].lowercased() != "open" { + throw CLIError(message: "markdown: unexpected subcommand \(parsed.positional[0])") + } + }), + execute: { ctx in + try self.runMarkdownCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) + } + ), + + CommandDescriptor( + names: ["review"], + helpLines: ["review open|refresh|comment|send (agent diff review panel: worktree/branch diff, line comments)"], + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "window", "surface", "direction", "mode", "base-branch", "preamble"], minPositionals: 1, maxPositionals: nil, validate: { _, parsed in + let subcommand = parsed.positional[0].lowercased() + guard ["open", "refresh", "comment", "send"].contains(subcommand) else { + throw CLIError(message: "review: unknown subcommand \(parsed.positional[0])") + } + if subcommand == "comment" { + guard parsed.positional.count >= 2 else { + throw CLIError(message: "review comment: missing subcommand (add|remove|list)") + } + guard ["add", "remove", "list"].contains(parsed.positional[1].lowercased()) else { + throw CLIError(message: "review comment: unknown subcommand \(parsed.positional[1])") + } + } + }), + execute: { ctx in + try self.runReviewCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) + } + ), + + CommandDescriptor( + names: ["recap"], + helpLines: ["recap open <slug>|list (open/list saved recaps in .programa/recaps/)"], + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "window"], minPositionals: 1, maxPositionals: 2, validate: { _, parsed in + let subcommand = parsed.positional[0].lowercased() + guard ["open", "list"].contains(subcommand) else { + throw CLIError(message: "recap: unknown subcommand \(parsed.positional[0])") + } + if subcommand == "open" { + guard parsed.positional.count == 2 else { + throw CLIError(message: "recap open requires <slug>") + } + } else if subcommand == "list", parsed.positional.count != 1 { + throw CLIError(message: "recap list: unexpected argument \(parsed.positional[1])") + } + }), + execute: { ctx in + try self.runRecapCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) + } + ), + ] + } +} diff --git a/CLI/Commands/SidebarCommands.swift b/CLI/Commands/SidebarCommands.swift new file mode 100644 index 000000000..2c40a1a7c --- /dev/null +++ b/CLI/Commands/SidebarCommands.swift @@ -0,0 +1,316 @@ +import Foundation + +extension ProgramaCLI { + func sidebarMetadataDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["set-status"], + helpLines: ["set-status <key> <value> [--icon <name>] [--color <#hex>] [--url <url>] [--priority <n>] [--format <plain|markdown>] [--workspace <id|ref>]"], + detailedUsage: """ + Usage: programa set-status <key> <value> [flags] + + Set a sidebar status entry for a workspace. Status entries appear as + pills in the sidebar tab row. Use a unique key so different tools + (e.g. "claude_code", "build") can manage their own entries. + + Flags: + --icon <name> Icon name (e.g. "sparkle", "hammer") + --color <#hex> Pill color (e.g. "#ff9500") + --url <url> Link attached to the pill (alias: --link) + --priority <n> Sort order, higher first (-9999 to 9999) + --format <plain|markdown> How the value is rendered (default: plain) + --pid <pid> Process behind this key (its listening ports are tracked) + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa set-status build "compiling" --icon hammer --color "#ff9500" + programa set-status deploy "v1.2.3" --workspace workspace:2 + """, + grammar: CLIArgumentGrammar(valueOptions: ["icon", "color", "url", "link", "priority", "format", "pid", "workspace"], minPositionals: 2, maxPositionals: nil, validate: { _, parsed in + if let priority = parsed.options["priority"], Int(priority) == nil { + throw CLIError(message: "set-status: --priority must be an integer") + } + if let format = parsed.options["format"], !["plain", "markdown", "md"].contains(format.lowercased()) { + throw CLIError(message: "set-status: --format must be plain or markdown") + } + if let pid = parsed.options["pid"], (Int(pid) ?? 0) <= 0 { + throw CLIError(message: "set-status: --pid must be a positive integer") + } + }), + execute: { ctx in + let parsed = self.parseFlagArgs(ctx.commandArgs, stopAtDashDash: false) + guard parsed.positional.count >= 2 else { + throw CLIError(message: "ERROR: Missing status key or value — usage: programa set-status <key> <value> [--icon <name>] [--color <#hex>] [--url <url>] [--priority <n>] [--format plain|markdown] [--pid <pid>] [--workspace <id|ref>]") + } + var params: [String: Any] = [ + "key": parsed.positional[0], + "value": parsed.positional[1...].joined(separator: " "), + ] + if let icon = self.normalizedFlagValue(parsed.options["icon"]) { params["icon"] = icon } + if let color = self.normalizedFlagValue(parsed.options["color"]) { params["color"] = color } + if let url = self.normalizedFlagValue(parsed.options["url"] ?? parsed.options["link"]) { params["url"] = url } + if let priorityRaw = self.normalizedFlagValue(parsed.options["priority"]) { + guard let priority = Int(priorityRaw) else { + throw CLIError(message: "ERROR: Invalid metadata priority '\(priorityRaw)' — must be an integer") + } + params["priority"] = max(-9999, min(9999, priority)) + } + if let formatRaw = self.normalizedFlagValue(parsed.options["format"]) { + guard ["plain", "markdown", "md"].contains(formatRaw.lowercased()) else { + throw CLIError(message: "ERROR: Invalid metadata format '\(formatRaw)' — use: plain, markdown") + } + params["format"] = formatRaw.lowercased() == "md" ? "markdown" : formatRaw.lowercased() + } + if let pidRaw = self.normalizedFlagValue(parsed.options["pid"]), let pid = Int(pidRaw), pid > 0 { + params["pid"] = pid + } + params["workspace_id"] = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) + _ = try ctx.client.sendV2(method: V2MethodNames.workspaceSetStatus, params: params) + print("OK") + } + ), + + CommandDescriptor( + names: ["clear-status"], + helpLines: ["clear-status <key> [--workspace <id|ref>]"], + detailedUsage: """ + Usage: programa clear-status <key> [flags] + + Remove a sidebar status entry by key. + + Flags: + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa clear-status build + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace"], minPositionals: 1, maxPositionals: 1), + execute: { ctx in + let parsed = self.parseFlagArgs(ctx.commandArgs) + guard let key = parsed.positional.first, parsed.positional.count == 1 else { + throw CLIError(message: "ERROR: Missing metadata key — usage: programa clear-status <key> [--workspace <id|ref>]") + } + let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) + _ = try ctx.client.sendV2(method: V2MethodNames.workspaceClearStatus, params: ["workspace_id": workspaceId, "key": key]) + print("OK") + } + ), + + CommandDescriptor( + names: ["list-status"], + helpLines: ["list-status [--workspace <id|ref>]"], + detailedUsage: """ + Usage: programa list-status [flags] + + List all sidebar status entries for a workspace. + + Flags: + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa list-status + programa list-status --workspace workspace:2 + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), + execute: { ctx in + let parsed = self.parseFlagArgs(ctx.commandArgs) + let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) + let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceListStatus, params: ["workspace_id": workspaceId]) + let entries = payload["entries"] as? [[String: Any]] ?? [] + if entries.isEmpty { + print("No status entries") + } else { + print(entries.map(self.sidebarMetadataLineText).joined(separator: "\n")) + } + } + ), + + CommandDescriptor( + names: ["set-progress"], + helpLines: ["set-progress <0.0-1.0> [--label <text>] [--workspace <id|ref>]"], + argumentContract: .setProgress, + detailedUsage: """ + Usage: programa set-progress <0.0-1.0> [flags] + + Set a progress bar in the sidebar for a workspace. + + Flags: + --label <text> Label shown next to the progress bar + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa set-progress 0.5 --label "Building..." + programa set-progress 1.0 --label "Done" + """, + execute: { ctx in + let parsed = self.parseFlagArgs(ctx.commandArgs) + guard let first = parsed.positional.first else { + throw CLIError(message: "ERROR: Missing progress value — usage: programa set-progress <0.0-1.0> [--label <text>] [--workspace <id|ref>]") + } + guard let value = Double(first), value.isFinite else { + throw CLIError(message: "ERROR: Invalid progress value '\(first)' — must be 0.0 to 1.0") + } + var params: [String: Any] = ["value": min(1.0, max(0.0, value))] + if let label = self.normalizedFlagValue(parsed.options["label"]) { params["label"] = label } + params["workspace_id"] = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) + _ = try ctx.client.sendV2(method: V2MethodNames.workspaceSetProgress, params: params) + print("OK") + } + ), + + CommandDescriptor( + names: ["clear-progress"], + helpLines: ["clear-progress [--workspace <id|ref>]"], + detailedUsage: """ + Usage: programa clear-progress [flags] + + Clear the sidebar progress bar for a workspace. + + Flags: + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa clear-progress + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), + execute: { ctx in + let parsed = self.parseFlagArgs(ctx.commandArgs) + let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) + _ = try ctx.client.sendV2(method: V2MethodNames.workspaceClearProgress, params: ["workspace_id": workspaceId]) + print("OK") + } + ), + + CommandDescriptor( + names: ["log"], + helpLines: ["log [--level <level>] [--source <name>] [--workspace <id|ref>] [--] <message>"], + detailedUsage: """ + Usage: programa log [flags] [--] <message> + + Append a log entry to the sidebar for a workspace. + + Flags: + --level <level> Log level: info, progress, success, warning, error (default: info) + --source <name> Source label (e.g. "build", "test") + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa log "Build started" + programa log --level error --source build "Compilation failed" + programa log --level success -- "All 42 tests passed" + """, + grammar: CLIArgumentGrammar(valueOptions: ["level", "source", "workspace"], minPositionals: 1, maxPositionals: nil, validate: { _, parsed in + if let level = parsed.options["level"], !["info", "progress", "success", "warning", "error"].contains(level) { + throw CLIError(message: "log: invalid --level value") + } + }), + execute: { ctx in + let parsed = self.parseFlagArgs(ctx.commandArgs) + guard !parsed.positional.isEmpty else { + throw CLIError(message: "ERROR: Missing message — usage: programa log [--level <level>] [--source <name>] [--workspace <id|ref>] [--] <message>") + } + let levelStr = parsed.options["level"] ?? "info" + guard ["info", "progress", "success", "warning", "error"].contains(levelStr) else { + throw CLIError(message: "ERROR: Unknown log level '\(levelStr)' — use: info, progress, success, warning, error") + } + var params: [String: Any] = [ + "message": parsed.positional.joined(separator: " "), + "level": levelStr, + ] + if let source = self.normalizedFlagValue(parsed.options["source"]) { params["source"] = source } + params["workspace_id"] = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) + _ = try ctx.client.sendV2(method: V2MethodNames.workspaceLog, params: params) + print("OK") + } + ), + + CommandDescriptor( + names: ["clear-log"], + helpLines: ["clear-log [--workspace <id|ref>]"], + detailedUsage: """ + Usage: programa clear-log [flags] + + Clear all sidebar log entries for a workspace. + + Flags: + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa clear-log + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), + execute: { ctx in + let parsed = self.parseFlagArgs(ctx.commandArgs) + let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) + _ = try ctx.client.sendV2(method: V2MethodNames.workspaceClearLog, params: ["workspace_id": workspaceId]) + print("OK") + } + ), + + CommandDescriptor( + names: ["list-log"], + helpLines: ["list-log [--limit <n>] [--workspace <id|ref>]"], + argumentContract: .listLog, + detailedUsage: """ + Usage: programa list-log [flags] + + List sidebar log entries for a workspace. + + Flags: + --limit <n> Show only the last N entries + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa list-log + programa list-log --limit 5 + """, + execute: { ctx in + let parsed = self.parseFlagArgs(ctx.commandArgs) + var params: [String: Any] = [:] + if let limitStr = parsed.options["limit"] { + guard !limitStr.isEmpty else { + throw CLIError(message: "ERROR: Missing limit value — usage: programa list-log [--limit <n>] [--workspace <id|ref>]") + } + guard let limit = Int(limitStr), limit >= 0 else { + throw CLIError(message: "ERROR: Invalid limit '\(limitStr)' — must be >= 0") + } + params["limit"] = limit + } + params["workspace_id"] = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) + let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceListLog, params: params) + let entries = payload["entries"] as? [[String: Any]] ?? [] + if entries.isEmpty { + print("No log entries") + } else { + print(entries.map(self.sidebarLogLineText).joined(separator: "\n")) + } + } + ), + + CommandDescriptor( + names: ["sidebar-state"], + helpLines: ["sidebar-state [--workspace <id|ref>]"], + detailedUsage: """ + Usage: programa sidebar-state [flags] + + Dump all sidebar metadata for a workspace (cwd, git branch, ports, + status entries, progress, log entries). + + Flags: + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa sidebar-state + programa sidebar-state --workspace workspace:2 + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), + execute: { ctx in + let parsed = self.parseFlagArgs(ctx.commandArgs) + let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) + let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceSidebarState, params: ["workspace_id": workspaceId]) + print(self.sidebarStateText(payload)) + } + ), + ] + } +} diff --git a/CLI/Commands/SystemCommands.swift b/CLI/Commands/SystemCommands.swift new file mode 100644 index 000000000..1f4c07f7a --- /dev/null +++ b/CLI/Commands/SystemCommands.swift @@ -0,0 +1,202 @@ +import Foundation + +extension ProgramaCLI { + func systemCommandDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["ping"], + helpLines: ["ping"], + argumentContract: .noArguments, + detailedUsage: """ + Usage: programa ping + + Check connectivity to the programa socket server. + """, + execute: { ctx in + let payload = try ctx.client.sendV2(method: V2MethodNames.systemPing) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: "PONG") + } + ), + + CommandDescriptor(names: ["version"], helpLines: ["version"], connectionPolicy: .local, grammar: CLIArgumentGrammar(), execute: nil), + + CommandDescriptor( + names: ["capabilities"], + helpLines: ["capabilities"], + detailedUsage: """ + Usage: programa capabilities + + Print server capabilities as JSON. + """, + grammar: CLIArgumentGrammar(), + execute: { ctx in + let response = try ctx.client.sendV2(method: V2MethodNames.systemCapabilities) + print(self.jsonString(self.formatIDs(response, mode: ctx.idFormat))) + } + ), + + CommandDescriptor( + names: ["rpc"], + helpLines: ["rpc <method> [json-params]"], + detailedUsage: """ + Usage: programa rpc <method> [json-params] + + Call a raw v2 method with an optional JSON object for params. + Example: programa rpc surface.report_tty '{"workspace_id":"...","surface_id":"...","tty_name":"ttys001"}' + """, + grammar: CLIArgumentGrammar(minPositionals: 1, maxPositionals: 2, validate: { _, parsed in + _ = try self.parseRPCParams(Array(parsed.positional.dropFirst())) + }), + execute: { ctx in + guard let method = ctx.commandArgs.first?.trimmingCharacters(in: .whitespacesAndNewlines), + !method.isEmpty else { + throw CLIError(message: "Usage: programa rpc <method> [json-params]") + } + let params = try self.parseRPCParams(Array(ctx.commandArgs.dropFirst())) + let response = try ctx.client.sendV2(method: method, params: params) + let output: Any = ctx.idFormatArgProvided ? self.formatIDs(response, mode: ctx.idFormat) : response + print(self.jsonString(output)) + } + ), + + CommandDescriptor( + names: ["identify"], + helpLines: ["identify [--workspace <id|ref>] [--surface <id|ref>] [--no-caller]"], + detailedUsage: """ + Usage: programa identify [--workspace <id|ref>] [--surface <id|ref>] [--no-caller] + + Print server identity and caller context details. + + Flags: + --workspace <id|ref> Caller workspace context (default: $PROGRAMA_WORKSPACE_ID) + --surface <id|ref> Caller surface context (default: $PROGRAMA_SURFACE_ID) + --no-caller Omit caller context from the request + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "surface"], booleanOptions: ["no-caller"]), + execute: { ctx in + var params: [String: Any] = [:] + let includeCaller = !self.hasFlag(ctx.commandArgs, name: "--no-caller") + if includeCaller { + let idWsFlag = self.optionValue(ctx.commandArgs, name: "--workspace") + let workspaceArg = idWsFlag ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + let surfaceArg = self.optionValue(ctx.commandArgs, name: "--surface") ?? (idWsFlag == nil && ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] : nil) + if workspaceArg != nil || surfaceArg != nil { + let workspaceId = try self.normalizeWorkspaceHandle( + workspaceArg, + client: ctx.client, + allowCurrent: surfaceArg != nil + ) + var caller: [String: Any] = [:] + if let workspaceId { + caller["workspace_id"] = workspaceId + } + if surfaceArg != nil { + guard let surfaceId = try self.normalizeSurfaceHandle( + surfaceArg, + client: ctx.client, + workspaceHandle: workspaceId + ) else { + throw CLIError(message: "Invalid surface handle") + } + caller["surface_id"] = surfaceId + } + if !caller.isEmpty { + params["caller"] = caller + } + } + } + let response = try ctx.client.sendV2(method: V2MethodNames.systemIdentify, params: params) + print(self.jsonString(self.formatIDs(response, mode: ctx.idFormat))) + } + ), + ] + } + + /// Test-only focus overrides and the `claude-teams` tmux shim entrypoint. + func internalCommandDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["set-app-focus"], + helpLines: ["set-app-focus <active|inactive|clear>"], + detailedUsage: """ + Usage: programa set-app-focus <active|inactive|clear> + + Override app focus state for notification routing tests. + + Example: + programa set-app-focus inactive + programa set-app-focus clear + """, + grammar: CLIArgumentGrammar(minPositionals: 1, maxPositionals: 1, validate: { _, parsed in + guard ["active", "inactive", "clear", "1", "0", "true", "false", "none"].contains(parsed.positional[0].lowercased()) else { + throw CLIError(message: "set-app-focus: invalid state") + } + }), + execute: { ctx in + guard let value = ctx.commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } + let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let state: String + switch normalized { + case "active", "1", "true": state = "active" + case "inactive", "0", "false": state = "inactive" + case "clear", "none", "": state = "clear" + default: + throw CLIError(message: "ERROR: Expected active, inactive, or clear") + } + _ = try ctx.client.sendV2(method: V2MethodNames.appFocusOverrideSet, params: ["state": state]) + print("OK") + } + ), + + CommandDescriptor( + names: ["simulate-app-active"], + helpLines: ["simulate-app-active"], + detailedUsage: """ + Usage: programa simulate-app-active + + Trigger the app-active handler used by notification focus tests. + """, + grammar: CLIArgumentGrammar(), + execute: { ctx in + _ = try ctx.client.sendV2(method: V2MethodNames.appSimulateActive) + print("OK") + } + ), + + CommandDescriptor( + names: ["__tmux-compat"], + helpLines: [], + helpPolicy: .passthrough, + argumentContract: .handlerParsed, + execute: { ctx in + try self.runClaudeTeamsTmuxCompat( + commandArgs: ctx.commandArgs, + client: ctx.client, + jsonOutput: ctx.jsonOutput, + idFormat: ctx.idFormat, + windowOverride: ctx.windowId + ) + } + ), + ] + } + + func helpCommandDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["help"], + helpLines: ["help"], + connectionPolicy: .local, + detailedUsage: """ + Usage: programa help + + Show top-level CLI usage and command list. + """, + grammar: CLIArgumentGrammar(), + execute: { ctx in + print(self.usage()) + } + ), + ] + } +} diff --git a/CLI/Commands/TerminalIOCommands.swift b/CLI/Commands/TerminalIOCommands.swift new file mode 100644 index 000000000..b0661d120 --- /dev/null +++ b/CLI/Commands/TerminalIOCommands.swift @@ -0,0 +1,593 @@ +import Foundation + +extension ProgramaCLI { + func terminalIODescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["read-screen"], + helpLines: ["read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]"], + argumentContract: .readScreen, + detailedUsage: """ + Usage: programa read-screen [flags] + + Read terminal text from a surface as plain text. + + Flags: + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) + --scrollback Include scrollback (not just visible viewport) + --lines <n> Limit to the last n lines (implies --scrollback) + + Example: + programa read-screen + programa read-screen --surface surface:2 --scrollback --lines 200 + """, + execute: { ctx in + let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") + let (sfArg, rem1) = self.parseOption(rem0, name: "--surface") + let (linesArg, rem2) = self.parseOption(rem1, name: "--lines") + let trailing = rem2.filter { $0 != "--scrollback" } + if !trailing.isEmpty { + throw CLIError(message: "read-screen: unexpected arguments: \(trailing.joined(separator: " "))") + } + + let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + + var params: [String: Any] = [:] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try self.resolveCommandSurface( + explicitSurface: sfArg, explicitWorkspace: wsArg, + windowId: ctx.windowId, workspaceHandle: wsId, client: ctx.client + ) + if let sfId { params["surface_id"] = sfId } + + let includeScrollback = rem2.contains("--scrollback") + if includeScrollback { + params["scrollback"] = true + } + if let linesArg { + guard let lineCount = Int(linesArg), lineCount > 0 else { + throw CLIError(message: "--lines must be greater than 0") + } + params["lines"] = lineCount + params["scrollback"] = true + } + + let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceReadText, params: params) + if ctx.jsonOutput { + print(self.jsonString(payload)) + } else { + print((payload["text"] as? String) ?? "") + } + } + ), + + CommandDescriptor( + names: ["wait-surface"], + helpLines: ["wait-surface [--workspace <id|ref>] [--surface <id|ref>] (--pattern <regex> | --exit | --agent-state <state>) [--timeout <seconds>] [--lines <n>]"], + argumentContract: .waitSurface, + detailedUsage: """ + Usage: programa wait-surface [flags] + + Block until a surface hits a condition, in one server-owned request -- no + polling loop needed. Answers as soon as the condition is met (or is already + true when the call arrives) or the timeout elapses. + + Flags: + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) + --pattern <regex> Wait until output (screen + scrollback) matches this regex + --exit Wait until the surface's child process exits + --agent-state <state> Wait until the agent's reported state is idle, working, + blocked, or any_change (lifecycle-hook agents) + --timeout <seconds> Give up after this long (default: 30) + --lines <n> Cap how much scrollback --pattern rereads per check (default: 2000) + + Exactly one of --pattern / --exit / --agent-state is required. A marker already + present when the call arrives (or a process that has already exited, or an agent + already in the state; a surface with no reported state counts as idle) resolves + immediately -- + `waited: false` in JSON output distinguishes that from an actual wait. + + Example: + programa wait-surface --pattern 'BUILD (SUCCEEDED|FAILED)' --timeout 120 + programa wait-surface --surface surface:2 --exit --timeout 10 + programa wait-surface --surface surface:2 --agent-state idle --timeout 300 + """, + execute: { ctx in + let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") + let (sfArg, rem1) = self.parseOption(rem0, name: "--surface") + let (patternArg, rem2) = self.parseOption(rem1, name: "--pattern") + let (timeoutArg, rem3) = self.parseOption(rem2, name: "--timeout") + let (linesArg, rem4) = self.parseOption(rem3, name: "--lines") + let (agentStateArg, rem5) = self.parseOption(rem4, name: "--agent-state") + let exitFlag = rem5.contains("--exit") + let trailing = rem5.filter { $0 != "--exit" } + if !trailing.isEmpty { + throw CLIError(message: "wait-surface: unexpected arguments: \(trailing.joined(separator: " "))") + } + guard [patternArg != nil, exitFlag, agentStateArg != nil].filter({ $0 }).count == 1 else { + throw CLIError(message: "wait-surface requires exactly one of --pattern <regex>, --exit, or --agent-state <idle|working|blocked|any_change>") + } + + let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + + var params: [String: Any] = [:] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try self.resolveCommandSurface( + explicitSurface: sfArg, explicitWorkspace: wsArg, + windowId: ctx.windowId, workspaceHandle: wsId, client: ctx.client + ) + if let sfId { params["surface_id"] = sfId } + + if let patternArg { + params["pattern"] = patternArg + } else if let agentStateArg { + params["agent_state"] = agentStateArg + } else { + params["exit"] = true + } + + var timeoutSeconds = 30.0 + if let timeoutArg { + guard let parsed = Double(timeoutArg), parsed.isFinite, parsed > 0 else { + throw CLIError(message: "wait-surface: --timeout must be a positive number of seconds") + } + timeoutSeconds = parsed + params["timeout_ms"] = Int((parsed * 1000).rounded()) + } + if let linesArg { + guard let lineCount = Int(linesArg), lineCount > 0 else { + throw CLIError(message: "wait-surface: --lines must be greater than 0") + } + params["lines"] = lineCount + } + + // The server may legitimately hold this connection open for the full + // --timeout; give the client-side socket read at least that long (plus a + // buffer for the response round trip) rather than the default 15s. + let payload = try ctx.client.sendV2( + method: V2MethodNames.surfaceWait, + params: params, + minimumReceiveTimeout: timeoutSeconds + 5.0 + ) + if ctx.jsonOutput { + print(self.jsonString(payload)) + } else if let match = payload["match"] as? String { + print(match) + } else if exitFlag { + print("exited") + } else if agentStateArg != nil { + print((payload["state"] as? String) ?? "ok") + } else { + print("ok") + } + } + ), + + CommandDescriptor( + names: ["prompt-agent"], + helpLines: ["prompt-agent [--workspace <id|ref>] [--surface <id|ref>] [--timeout <seconds>] [--working-grace <seconds>] <text>"], + detailedUsage: """ + Usage: programa prompt-agent [flags] [--] <text> + + Submit a prompt to an agent surface and wait for it to finish, in one + request -- built on surface.wait's agent_state condition (#166). Sends + <text> (+ Enter) the same way `send` does, waits up to --working-grace for + the agent to report it started working (capped by the remaining --timeout), + then waits up to the remaining --timeout for it to report idle again. + + If the agent never reports "working" before that capped grace expires -- + including when the overall deadline arrives first -- the call resolves + immediately (working_observed: false in JSON output), rather than returning + a timeout error. If the surface never reported any agent_state at all, JSON + output carries a `warning` noting hooks may not be installed. + + Flags: + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) + --timeout <seconds> Overall budget for the agent to finish (default: 120) + --working-grace <seconds> How long to wait for a "working" report before + giving up on observing it, capped by the remaining + --timeout budget (default: 3) + + Example: + programa prompt-agent "review this diff for bugs" + programa prompt-agent --surface surface:2 --timeout 300 "run the test suite" + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "surface", "timeout", "working-grace"], minPositionals: 1, maxPositionals: nil), + execute: { ctx in + let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") + let (sfArg, rem1) = self.parseOption(rem0, name: "--surface") + let (timeoutArg, rem2) = self.parseOption(rem1, name: "--timeout") + let (graceArg, rem3) = self.parseOption(rem2, name: "--working-grace") + let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + let rawText = rem3.dropFirst(rem3.first == "--" ? 1 : 0).joined(separator: " ") + guard !rawText.isEmpty else { throw CLIError(message: "prompt-agent requires text") } + + var params: [String: Any] = ["text": rawText] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try self.resolveCommandSurface( + explicitSurface: sfArg, explicitWorkspace: wsArg, + windowId: ctx.windowId, workspaceHandle: wsId, client: ctx.client + ) + if let sfId { params["surface_id"] = sfId } + + var timeoutSeconds = 120.0 + if let timeoutArg { + guard let parsed = Double(timeoutArg), parsed.isFinite, parsed > 0 else { + throw CLIError(message: "prompt-agent: --timeout must be a positive number of seconds") + } + timeoutSeconds = parsed + params["timeout_ms"] = Int((parsed * 1000).rounded()) + } + if let graceArg { + guard let parsedGrace = Double(graceArg), parsedGrace.isFinite, parsedGrace > 0 else { + throw CLIError(message: "prompt-agent: --working-grace must be a positive number of seconds") + } + params["working_grace_ms"] = Int((parsedGrace * 1000).rounded()) + } + + // Mirrors wait-surface: the server may legitimately hold this connection + // open for close to the full --timeout. + let payload = try ctx.client.sendV2( + method: V2MethodNames.agentPrompt, + params: params, + minimumReceiveTimeout: timeoutSeconds + 5.0 + ) + if ctx.jsonOutput { + print(self.jsonString(payload)) + } else if let warning = payload["warning"] as? String { + print(warning) + } else { + print((payload["final_state"] as? String) ?? "ok") + } + } + ), + + CommandDescriptor( + names: ["watch-events"], + helpLines: ["watch-events [--agent-state] [--workspace-lifecycle] [--needs-input] [--output <surface_id[,surface_id...]>] [--no-reconnect]"], + argumentContract: .watchEvents, + detailedUsage: """ + Usage: programa watch-events [flags] + + Subscribe to a long-lived stream of pushed events (#167) -- for consumers + that need many events over time (dashboards, orchestrators, a menu bar on + another machine). Casual "wait for one thing" callers should use + `wait-surface`/`prompt-agent` instead; this command holds the connection + open indefinitely and prints one JSON event per line until interrupted + (Ctrl-C) or the process gives up reconnecting. + + Flags (at least one of --agent-state/--workspace-lifecycle/--needs-input/--output required): + --agent-state Agent working/blocked/idle transitions + --workspace-lifecycle Workspace created/closed/renamed + --needs-input Agent prompts waiting for the user, with the + prompt text (needs_input events) + --output <surface_id[,surface_id...]> + Coalesced output for specific surfaces + (not available for "all surfaces" -- see docs) + --no-reconnect Exit on disconnect instead of the default + auto-reconnect (for scripts that want to + observe and handle drops themselves) + + By default, if the connection drops (app restart, network hiccup, or the + 1-hour idle read timeout) the command prints a notice to stderr and + automatically reconnects and resubscribes with capped exponential backoff, + rather than exiting. Pass --no-reconnect to restore the old exit-on-drop + behavior. Stdout only ever contains event lines (or JSON, in --json mode); + reconnect notices always go to stderr. + + If the per-connection event queue overflows (a slow/disconnected reader), + a synthetic {"event":"dropped","count":N} line is printed -- re-sync with + `list-workspaces`/`surface list` after seeing one. + + Example: + programa watch-events --agent-state + programa watch-events --output surface:2,surface:3 + programa watch-events --agent-state --no-reconnect + programa watch-events --needs-input + """, + execute: { ctx in + let agentState = self.hasFlag(ctx.commandArgs, name: "--agent-state") + let workspaceLifecycle = self.hasFlag(ctx.commandArgs, name: "--workspace-lifecycle") + let needsInput = self.hasFlag(ctx.commandArgs, name: "--needs-input") + let noReconnect = self.hasFlag(ctx.commandArgs, name: "--no-reconnect") + let (outputArg, _) = self.parseOption(ctx.commandArgs, name: "--output") + + var classes: [String] = [] + if agentState { classes.append("agent_state") } + if workspaceLifecycle { classes.append("workspace_lifecycle") } + if needsInput { classes.append("needs_input") } + + var params: [String: Any] = [:] + if let outputArg { + classes.append("output") + let rawIds = outputArg.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + var resolvedIds: [String] = [] + for rawId in rawIds where !rawId.isEmpty { + guard let resolved = try self.normalizeSurfaceHandle(rawId, client: ctx.client) else { + throw CLIError(message: "watch-events: could not resolve surface id: \(rawId)") + } + resolvedIds.append(resolved) + } + guard !resolvedIds.isEmpty else { + throw CLIError(message: "watch-events: --output requires at least one surface id") + } + params["surface_ids"] = resolvedIds + } + params["classes"] = classes + + // Sends the subscribe request and reads its ack. Shared by the initial + // subscribe and every reconnect below, so a resubscribe after a dropped + // connection goes through the exact same path as first connect: the ack is + // parsed and checked for `ok: true` before we consider the (re)subscribe + // successful, and the raw ack line is never written to stdout -- stdout is + // events-only, both to preserve that contract on first connect and to avoid + // a stray non-event JSON object landing mid-stream on a later resubscribe. + func subscribeAndAck() throws { + try ctx.client.sendV2RequestOnly(method: V2MethodNames.subscribe, params: params) + let ackLine = try ctx.client.readEventLine(timeout: 10) + if ackLine.hasPrefix("ERROR:") { + throw CLIError(message: ackLine) + } + guard let ackData = ackLine.data(using: .utf8), + let ack = try? JSONSerialization.jsonObject(with: ackData) as? [String: Any] else { + throw CLIError(message: "Invalid subscribe ack: \(ackLine)") + } + guard (ack["ok"] as? Bool) == true else { + if let error = ack["error"] as? [String: Any] { + let code = (error["code"] as? String) ?? "error" + let message = (error["message"] as? String) ?? "Subscribe rejected" + throw CLIError(message: "\(code): \(message)") + } + throw CLIError(message: "Subscribe rejected: \(ackLine)") + } + FileHandle.standardError.write("Subscribed: \(classes.joined(separator: ", "))\n".data(using: .utf8)!) + } + + // Reconnect failures that won't resolve themselves on retry: wrong + // password / unconfigured password (auth.login's auth_failed/ + // auth_required/auth_unconfigured), the programa-ancestry "unsafe socket" + // rejection (raw "ERROR: Access denied ..." preamble), and an explicit + // subscribe-ack rejection (invalid_params -- retrying with the same + // params will never succeed). Everything else (ECONNREFUSED/ENOENT while + // the app restarts, EOF, read timeouts) is transient and keeps retrying + // indefinitely as designed. + func isPermanentReconnectFailure(_ error: Error) -> Bool { + let description = String(describing: error) + if description.hasPrefix("ERROR: Access denied") { return true } + let permanentCodes = ["auth_failed:", "auth_required:", "auth_unconfigured:", "invalid_params:"] + return permanentCodes.contains { description.hasPrefix($0) } + } + + func printEvent(_ line: String) { + if ctx.jsonOutput { + print(line) + return + } + guard let data = line.data(using: .utf8), + let frame = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { + print(line) + return + } + let event = (frame["event"] as? String) ?? "event" + let rest = frame.filter { $0.key != "event" } + .sorted { $0.key < $1.key } + .map { "\($0.key)=\($0.value)" } + .joined(separator: " ") + print("[\(event)] \(rest)") + } + + var connectedAt = Date() + try subscribeAndAck() + + // Long-lived: reads and prints one pushed event frame per line until the + // connection closes or the process is interrupted (Ctrl-C). Each frame is + // already a complete JSON object (see SocketEventBroadcaster.encodeFrame), + // so JSON mode just echoes the raw line. + // + // Any disconnect (EOF, a transport error, or the 3600s idle read timeout + // firing after an hour of silence) lands here. By default we don't let any + // of those kill the process: print one stderr notice, reconnect+resubscribe + // with capped exponential backoff, and keep streaming. --no-reconnect + // exits on the first drop instead, for scripts that handle drops themselves. + while true { + do { + let line = try ctx.client.readEventLine(timeout: 3600) + guard !line.isEmpty else { continue } + printEvent(line) + } catch { + if noReconnect { throw error } + + let elapsedSeconds = Int(Date().timeIntervalSince(connectedAt)) + let reason = String(describing: error) + FileHandle.standardError.write( + "programa: event stream disconnected (\(reason)) after \(elapsedSeconds)s; reconnecting…\n".data(using: .utf8)! + ) + + ctx.client.close() + var backoffSeconds = 0.5 + var consecutivePermanentFailures = 0 + while true { + do { + try ctx.client.connectWithTransientRetry() + try self.authenticateClientIfNeeded( + ctx.client, + explicitPassword: ctx.socketPasswordArg, + socketPath: ctx.client.socketPath + ) + connectedAt = Date() + try subscribeAndAck() + break + } catch { + ctx.client.close() + let permanent = isPermanentReconnectFailure(error) + consecutivePermanentFailures = permanent ? consecutivePermanentFailures + 1 : 0 + FileHandle.standardError.write( + "programa: reconnect attempt failed (\(error))\(permanent ? " [not retryable]" : "")\n".data(using: .utf8)! + ) + if permanent && consecutivePermanentFailures >= 3 { + throw error + } + Thread.sleep(forTimeInterval: backoffSeconds + Double.random(in: 0...0.1)) + backoffSeconds = min(backoffSeconds * 2, 5.0) + } + } + FileHandle.standardError.write("programa: event stream reconnected\n".data(using: .utf8)!) + } + } + } + ), + + CommandDescriptor( + names: ["send"], + helpLines: ["send [--workspace <id|ref>] [--surface <id|ref>] <text>"], + detailedUsage: """ + Usage: programa send [flags] [--] <text> + + Send text to a terminal surface. Escape sequences: \\n and \\r send Enter, \\t sends Tab. + + Flags: + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) + + Example: + programa send "echo hello" + programa send --surface surface:2 "ls -la\\n" + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "surface"], minPositionals: 1, maxPositionals: nil), + execute: { ctx in + let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") + let (sfArg, rem1) = self.parseOption(rem0, name: "--surface") + let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + let rawText = rem1.dropFirst(rem1.first == "--" ? 1 : 0).joined(separator: " ") + guard !rawText.isEmpty else { throw CLIError(message: "send requires text") } + let text = self.unescapeSendText(rawText) + var params: [String: Any] = ["text": text] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try self.resolveCommandSurface( + explicitSurface: sfArg, explicitWorkspace: wsArg, + windowId: ctx.windowId, workspaceHandle: wsId, client: ctx.client + ) + if let sfId { params["surface_id"] = sfId } + let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceSendText, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) + } + ), + + CommandDescriptor( + names: ["send-key"], + helpLines: ["send-key [--workspace <id|ref>] [--surface <id|ref>] <key>"], + detailedUsage: """ + Usage: programa send-key [flags] [--] <key> + + Send a key event to a terminal surface. + + Flags: + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) + + Example: + programa send-key enter + programa send-key --surface surface:2 ctrl+c + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "surface"], minPositionals: 1, maxPositionals: 1), + execute: { ctx in + let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") + let (sfArg, rem1) = self.parseOption(rem0, name: "--surface") + let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + let keyArgs = rem1.first == "--" ? Array(rem1.dropFirst()) : rem1 + guard let key = keyArgs.first else { throw CLIError(message: "send-key requires a key") } + var params: [String: Any] = ["key": key] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try self.resolveCommandSurface( + explicitSurface: sfArg, explicitWorkspace: wsArg, + windowId: ctx.windowId, workspaceHandle: wsId, client: ctx.client + ) + if let sfId { params["surface_id"] = sfId } + let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceSendKey, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) + } + ), + + CommandDescriptor( + names: ["send-panel"], + helpLines: ["send-panel --panel <id|ref> [--workspace <id|ref>] <text>"], + detailedUsage: """ + Usage: programa send-panel --panel <id|ref> [flags] [--] <text> + + Send text to a specific panel (surface). Escape sequences: \\n and \\r send Enter, \\t sends Tab. + + Flags: + --panel <id|ref> Target panel (required) + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa send-panel --panel surface:2 "echo hello\\n" + """, + grammar: CLIArgumentGrammar(valueOptions: ["panel", "workspace"], requiredOptions: ["panel"], minPositionals: 1, maxPositionals: nil), + execute: { ctx in + let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") + let (panelArg, rem1) = self.parseOption(rem0, name: "--panel") + let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + guard let panelArg else { + throw CLIError(message: "send-panel requires --panel") + } + let rawText = rem1.dropFirst(rem1.first == "--" ? 1 : 0).joined(separator: " ") + guard !rawText.isEmpty else { throw CLIError(message: "send-panel requires text") } + let text = self.unescapeSendText(rawText) + var params: [String: Any] = ["text": text] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try self.normalizeSurfaceHandle(panelArg, client: ctx.client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceSendText, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) + } + ), + + CommandDescriptor( + names: ["send-key-panel"], + helpLines: ["send-key-panel --panel <id|ref> [--workspace <id|ref>] <key>"], + detailedUsage: """ + Usage: programa send-key-panel --panel <id|ref> [flags] [--] <key> + + Send a key event to a specific panel (surface). + + Flags: + --panel <id|ref> Target panel (required) + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + + Example: + programa send-key-panel --panel surface:2 enter + programa send-key-panel --panel surface:2 ctrl+c + """, + grammar: CLIArgumentGrammar(valueOptions: ["panel", "workspace"], requiredOptions: ["panel"], minPositionals: 1, maxPositionals: 1), + execute: { ctx in + let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") + let (panelArg, rem1) = self.parseOption(rem0, name: "--panel") + let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + guard let panelArg else { + throw CLIError(message: "send-key-panel requires --panel") + } + let skpArgs = rem1.first == "--" ? Array(rem1.dropFirst()) : rem1 + let key = skpArgs.first ?? "" + guard !key.isEmpty else { throw CLIError(message: "send-key-panel requires a key") } + var params: [String: Any] = ["key": key] + let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let sfId = try self.normalizeSurfaceHandle(panelArg, client: ctx.client, workspaceHandle: wsId) + if let sfId { params["surface_id"] = sfId } + let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceSendKey, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) + } + ), + ] + } +} diff --git a/CLI/Commands/WindowCommands.swift b/CLI/Commands/WindowCommands.swift new file mode 100644 index 000000000..52f6d5805 --- /dev/null +++ b/CLI/Commands/WindowCommands.swift @@ -0,0 +1,195 @@ +import Foundation + +extension ProgramaCLI { + /// `focus-window` and `close-window`: a required `--window` that must be a UUID or ref. + private var windowTargetGrammar: CLIArgumentGrammar { + CLIArgumentGrammar(valueOptions: ["window"], requiredOptions: ["window"], validate: { command, parsed in + guard let rawWindow = parsed.options["window"]?.trimmingCharacters(in: .whitespacesAndNewlines), + self.isUUID(rawWindow) || self.isHandleRef(rawWindow) else { + throw CLIError(message: "\(command): invalid window id") + } + }) + } + + func windowCommandDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["list-windows"], + helpLines: ["list-windows"], + detailedUsage: """ + Usage: programa list-windows + + List open windows. + """, + grammar: CLIArgumentGrammar(), + execute: { ctx in + let listed = try ctx.client.sendV2(method: V2MethodNames.windowList) + let windows = listed["windows"] as? [[String: Any]] ?? [] + if ctx.jsonOutput { + let payload = windows.map { item -> [String: Any] in + var dict: [String: Any] = [ + "index": self.intFromAny(item["index"]) ?? 0, + "id": (item["id"] as? String) ?? "", + "key": (item["key"] as? Bool) ?? false, + "workspace_count": self.intFromAny(item["workspace_count"]) ?? 0, + ] + dict["selected_workspace_id"] = item["selected_workspace_id"] as? String ?? NSNull() + return dict + } + print(self.jsonString(payload)) + } else if windows.isEmpty { + print("No windows") + } else { + let lines = windows.map { item -> String in + let selected = ((item["key"] as? Bool) ?? false) ? "*" : " " + let idx = self.intFromAny(item["index"]) ?? 0 + let id = (item["id"] as? String) ?? "" + let selectedWs = (item["selected_workspace_id"] as? String) ?? "none" + let workspaceCount = self.intFromAny(item["workspace_count"]) ?? 0 + return "\(selected) \(idx): \(id) selected_workspace=\(selectedWs) workspaces=\(workspaceCount)" + } + print(lines.joined(separator: "\n")) + } + } + ), + + CommandDescriptor( + names: ["current-window"], + helpLines: ["current-window"], + detailedUsage: """ + Usage: programa current-window + + Print the currently selected window ID. + """, + grammar: CLIArgumentGrammar(), + execute: { ctx in + let response = try ctx.client.sendV2(method: V2MethodNames.windowCurrent) + let windowId = (response["window_id"] as? String) ?? "" + if ctx.jsonOutput { + print(self.jsonString(["window_id": windowId])) + } else { + print(windowId) + } + } + ), + + CommandDescriptor( + names: ["new-window"], + helpLines: ["new-window"], + detailedUsage: """ + Usage: programa new-window + + Create a new window. + + Example: + programa new-window + """, + grammar: CLIArgumentGrammar(), + execute: { ctx in + let response = try ctx.client.sendV2(method: V2MethodNames.windowCreate) + self.printV2Payload(response, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(response, idFormat: ctx.idFormat, kinds: ["window"])) + } + ), + + CommandDescriptor( + names: ["focus-window"], + helpLines: ["focus-window --window <id>"], + detailedUsage: """ + Usage: programa focus-window --window <id|ref> + + Focus (bring to front) the specified window. + + Flags: + --window <id|ref> Window to focus (required) + + Example: + programa focus-window --window <window-uuid> + programa focus-window --window window:1 + """, + grammar: windowTargetGrammar, + execute: { ctx in + guard let target = self.optionValue(ctx.commandArgs, name: "--window") else { + throw CLIError(message: "focus-window requires --window") + } + // A UUID or a short ref (new-window prints a ref); bare indexes are rejected. + guard self.isUUID(target.trimmingCharacters(in: .whitespacesAndNewlines)) + || self.isHandleRef(target.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw CLIError(message: "ERROR: Invalid window id") + } + do { + _ = try ctx.client.sendV2(method: V2MethodNames.windowFocus, params: ["window_id": target]) + print("OK") + } catch let error as CLIError where error.message.hasPrefix("not_found:") { + throw CLIError(message: "not_found: Window not found") + } + } + ), + + CommandDescriptor( + names: ["close-window"], + helpLines: ["close-window --window <id>"], + detailedUsage: """ + Usage: programa close-window --window <id|ref> + + Close the specified window. + + Flags: + --window <id|ref> Window to close (required) + + Example: + programa close-window --window <window-uuid> + programa close-window --window window:1 + """, + grammar: windowTargetGrammar, + execute: { ctx in + guard let target = self.optionValue(ctx.commandArgs, name: "--window") else { + throw CLIError(message: "close-window requires --window") + } + guard self.isUUID(target.trimmingCharacters(in: .whitespacesAndNewlines)) + || self.isHandleRef(target.trimmingCharacters(in: .whitespacesAndNewlines)) else { + throw CLIError(message: "ERROR: Invalid window id") + } + do { + _ = try ctx.client.sendV2(method: V2MethodNames.windowClose, params: ["window_id": target]) + print("OK") + } catch let error as CLIError where error.message.hasPrefix("not_found:") { + throw CLIError(message: "not_found: Window not found") + } + } + ), + + CommandDescriptor( + names: ["move-workspace-to-window"], + helpLines: ["move-workspace-to-window --workspace <id|ref> --window <id|ref>"], + detailedUsage: """ + Usage: programa move-workspace-to-window --workspace <id|ref> --window <id|ref> + + Move a workspace to a different window. + + Flags: + --workspace <id|ref> Workspace to move (required) + --window <id|ref> Target window (required) + + Example: + programa move-workspace-to-window --workspace workspace:2 --window window:1 + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "window"], requiredOptions: ["workspace", "window"]), + execute: { ctx in + guard let workspaceRaw = self.optionValue(ctx.commandArgs, name: "--workspace") else { + throw CLIError(message: "move-workspace-to-window requires --workspace") + } + guard let windowRaw = self.optionValue(ctx.commandArgs, name: "--window") else { + throw CLIError(message: "move-workspace-to-window requires --window") + } + var params: [String: Any] = [:] + let wsId = try self.normalizeWorkspaceHandle(workspaceRaw, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let winId = try self.normalizeWindowHandle(windowRaw, client: ctx.client) + if let winId { params["window_id"] = winId } + let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceMoveToWindow, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat, kinds: ["workspace", "window"])) + } + ), + ] + } +} diff --git a/CLI/Commands/WorkspaceCommands.swift b/CLI/Commands/WorkspaceCommands.swift new file mode 100644 index 000000000..dc9df6a3c --- /dev/null +++ b/CLI/Commands/WorkspaceCommands.swift @@ -0,0 +1,327 @@ +import Foundation + +extension ProgramaCLI { + /// `close-workspace` and `select-workspace` never fall back to the current workspace. + private var explicitWorkspaceGrammar: CLIArgumentGrammar { + CLIArgumentGrammar(valueOptions: ["workspace"], validate: { command, parsed in + if parsed.options["workspace"] == nil { + throw CLIError( + message: "\(command): --workspace <id|ref> is required (UUID or short ref like workspace:2). Refusing to target the current workspace implicitly." + ) + } + }) + } + + func workspaceOrderingDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["reorder-workspace"], + helpLines: ["reorder-workspace --workspace <id|ref> (--index <n> | --before <id|ref> | --after <id|ref>) [--window <id|ref>]"], + detailedUsage: """ + Usage: programa reorder-workspace [--workspace <id|ref> | <id|ref>] [flags] + + Reorder a workspace within its window. + + Flags: + --workspace <id|ref> Workspace to reorder (required unless passed positionally) + --index <n> Place at this index + --before <id|ref> Place before this workspace + --before-workspace <id|ref> + Alias for --before + --after <id|ref> Place after this workspace + --after-workspace <id|ref> + Alias for --after + --window <id|ref> Window context + + Example: + programa reorder-workspace --workspace workspace:2 --index 0 + programa reorder-workspace --workspace workspace:3 --after workspace:1 + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "index", "before", "before-workspace", "after", "after-workspace", "window"], maxPositionals: 1, validate: { _, parsed in + guard (parsed.options["workspace"] != nil) != (parsed.positional.count == 1) else { + throw CLIError(message: "reorder-workspace requires --workspace <id|ref> or one positional <id|ref>") + } + let anchors = ["index", "before", "before-workspace", "after", "after-workspace"].filter { parsed.options[$0] != nil } + guard anchors.count == 1 else { + throw CLIError(message: "reorder-workspace requires exactly one of --index, --before, or --after") + } + if let raw = parsed.options["index"], (Int(raw) ?? -1) < 0 { + throw CLIError(message: "reorder-workspace: --index must be a nonnegative integer") + } + }), + execute: { ctx in + try self.runReorderWorkspace(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) + } + ), + + CommandDescriptor( + names: ["workspace-action"], + helpLines: ["workspace-action --action <name> [--workspace <id|ref>] [--title <text>] [--color <name|#hex>] [--description <text>]"], + detailedUsage: """ + Usage: programa workspace-action --action <name> [flags] + + Perform workspace context-menu actions from CLI/socket. + + Actions: + pin | unpin + rename | clear-name + set-description | clear-description + move-up | move-down | move-top + close-others | close-above | close-below + mark-read | mark-unread + set-color | clear-color + + Flags: + --action <name> Action name (required if not positional) + --workspace <id|ref> Target workspace (default: current/$PROGRAMA_WORKSPACE_ID) + --title <text> Title for rename + --color <name|#hex> Color for set-color (name or #RRGGBB hex) + --description <text> Description for set-description + + Named colors: + Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua, + Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal + + Example: + programa workspace-action --workspace workspace:2 --action pin + programa workspace-action --action rename --title "infra" + programa workspace-action close-others + programa workspace-action --action set-color --color blue + programa workspace-action --action set-color --color "#C0392B" + programa workspace-action set-color Amber + programa workspace-action --action set-description --description "Ship checklist" + programa workspace-action --action set-description $'Ship checklist\n- verify build\n- post notes' + programa workspace-action clear-color + """, + grammar: CLIArgumentGrammar(valueOptions: ["action", "workspace", "title", "color", "description"], maxPositionals: nil, validate: { _, parsed in + guard let rawAction = parsed.options["action"] ?? parsed.positional.first else { + throw CLIError(message: "workspace-action requires --action <name>") + } + let trailing = parsed.options["action"] == nil ? Array(parsed.positional.dropFirst()) : parsed.positional + let action = rawAction.lowercased().replacingOccurrences(of: "-", with: "_") + if action == "rename", parsed.options["title"] == nil, trailing.isEmpty { + throw CLIError(message: "workspace-action rename requires a title") + } + if action == "set_color", parsed.options["color"] == nil, trailing.isEmpty { + throw CLIError(message: "workspace-action set-color requires a color") + } + if action == "set_description", parsed.options["description"] == nil, trailing.isEmpty { + throw CLIError(message: "workspace-action set-description requires a description") + } + }), + execute: { ctx in + try self.runWorkspaceAction(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, windowOverride: ctx.windowId) + } + ), + ] + } + + func workspaceListDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["list-workspaces"], + helpLines: ["list-workspaces"], + detailedUsage: """ + Usage: programa list-workspaces + + List workspaces in the current window. + + Example: + programa list-workspaces + """, + grammar: CLIArgumentGrammar(), + execute: { ctx in + let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceList) + if ctx.jsonOutput { + print(self.jsonString(self.formatIDs(payload, mode: ctx.idFormat))) + } else { + let workspaces = payload["workspaces"] as? [[String: Any]] ?? [] + if workspaces.isEmpty { + print("No workspaces") + } else { + for ws in workspaces { + let selected = (ws["selected"] as? Bool) == true + let handle = self.textHandle(ws, idFormat: ctx.idFormat) + let title = (ws["title"] as? String) ?? "" + let prefix = selected ? "* " : " " + let selTag = selected ? " [selected]" : "" + let titlePart = title.isEmpty ? "" : " \(title)" + print("\(prefix)\(handle)\(titlePart)\(selTag)") + } + } + } + } + ), + + CommandDescriptor( + names: ["new-workspace"], + helpLines: ["new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>]"], + detailedUsage: """ + Usage: programa new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] + + Create a new workspace in the current window. + + Flags: + --name <title> Set a custom name for the new workspace + --description <text> Set a custom description for the new workspace + --cwd <path> Set the working directory for the new workspace + --command <text> Send text+Enter to the new workspace after creation + + Example: + programa new-workspace + programa new-workspace --name "Build Server" + programa new-workspace --name "Launch" --description "Ship checklist" + programa new-workspace --cwd ~/projects/myapp + programa new-workspace --cwd . --command "npm test" + """, + grammar: CLIArgumentGrammar(valueOptions: ["name", "description", "cwd", "command"]), + execute: { ctx in + let (commandOpt, rem0) = self.parseOption(ctx.commandArgs, name: "--command") + let (cwdOpt, rem1) = self.parseOption(rem0, name: "--cwd") + let (nameOpt, rem2) = self.parseOption(rem1, name: "--name") + let (descriptionOpt, remaining) = self.parseOption(rem2, name: "--description") + if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { + throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --name <title>, --description <text>, --command <text>, --cwd <path>") + } + var params: [String: Any] = [:] + if let cwdOpt { + let resolved = self.resolvePath(cwdOpt) + params["cwd"] = resolved + } + if let nameOpt { + params["title"] = nameOpt + } + if let descriptionOpt { + params["description"] = descriptionOpt + } + let response = try ctx.client.sendV2(method: V2MethodNames.workspaceCreate, params: params) + let wsId = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" + self.printV2Payload(response, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(response, idFormat: ctx.idFormat, kinds: ["workspace"])) + if let commandText = commandOpt, !wsId.isEmpty { + let text = self.unescapeSendText(commandText + "\\n") + let sendParams: [String: Any] = ["text": text, "workspace_id": wsId] + _ = try ctx.client.sendV2(method: V2MethodNames.surfaceSendText, params: sendParams) + } + } + ), + ] + } + + func workspaceSelectionDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["close-workspace"], + helpLines: ["close-workspace --workspace <id|ref>"], + detailedUsage: """ + Usage: programa close-workspace --workspace <id|ref> + + Close the specified workspace. + + Flags: + --workspace <id|ref> Workspace to close (required) + + Example: + programa close-workspace --workspace workspace:2 + """, + grammar: explicitWorkspaceGrammar, + execute: { ctx in + guard let workspaceRaw = self.optionValue(ctx.commandArgs, name: "--workspace") else { + throw CLIError( + message: "close-workspace: --workspace <id|ref> is required (UUID or short ref like workspace:2). Refusing to target the current workspace implicitly." + ) + } + var params: [String: Any] = [:] + let wsId = try self.normalizeWorkspaceHandle(workspaceRaw, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceClose, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat, kinds: ["workspace"])) + } + ), + + CommandDescriptor( + names: ["select-workspace"], + helpLines: ["select-workspace --workspace <id|ref>"], + detailedUsage: """ + Usage: programa select-workspace --workspace <id|ref> + + Select (switch to) the specified workspace. + + Flags: + --workspace <id|ref> Workspace to select (required) + + Example: + programa select-workspace --workspace workspace:2 + programa select-workspace --workspace 0 + """, + grammar: explicitWorkspaceGrammar, + execute: { ctx in + guard let workspaceRaw = self.optionValue(ctx.commandArgs, name: "--workspace") else { + throw CLIError(message: "select-workspace requires --workspace") + } + var params: [String: Any] = [:] + let wsId = try self.normalizeWorkspaceHandle(workspaceRaw, client: ctx.client) + if let wsId { params["workspace_id"] = wsId } + let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceSelect, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat, kinds: ["workspace"])) + } + ), + + CommandDescriptor( + names: ["rename-workspace", "rename-window"], + helpLines: [ + "rename-workspace [--workspace <id|ref>] <title>", + "rename-window [--workspace <id|ref>] <title>", + ], + detailedUsage: """ + Usage: programa rename-workspace [--workspace <id|ref>] [--] <title> + + Rename a workspace. Defaults to the current workspace. + tmux-compatible alias: rename-window + + Flags: + --workspace <id|ref> Workspace to rename (default: current/$PROGRAMA_WORKSPACE_ID) + + Example: + programa rename-workspace "backend logs" + programa rename-window --workspace workspace:2 "agent run" + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace"], minPositionals: 1, maxPositionals: nil), + execute: { ctx in + let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") + let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) + let titleArgs = rem0.dropFirst(rem0.first == "--" ? 1 : 0) + let title = titleArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + throw CLIError(message: "\(ctx.command) requires a title") + } + let wsId = try self.resolveWorkspaceId(workspaceArg, client: ctx.client) + let params: [String: Any] = ["title": title, "workspace_id": wsId] + let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceRename, params: params) + self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat, kinds: ["workspace"])) + } + ), + + CommandDescriptor( + names: ["current-workspace"], + helpLines: ["current-workspace"], + detailedUsage: """ + Usage: programa current-workspace + + Print the currently selected workspace ID. + """, + grammar: CLIArgumentGrammar(), + execute: { ctx in + let response = try ctx.client.sendV2(method: V2MethodNames.workspaceCurrent) + if ctx.jsonOutput { + print(self.jsonString(self.formatIDs(response, mode: ctx.idFormat))) + } else { + let handle = self.formatHandle(response, kind: "workspace", idFormat: ctx.idFormat) + ?? (response["workspace_id"] as? String) + ?? "" + print(handle) + } + } + ), + ] + } +} diff --git a/CLI/Commands/WorktreeCommands.swift b/CLI/Commands/WorktreeCommands.swift new file mode 100644 index 000000000..1489c8223 --- /dev/null +++ b/CLI/Commands/WorktreeCommands.swift @@ -0,0 +1,315 @@ +import Foundation + +extension ProgramaCLI { + func worktreeAndLayoutDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["worktree"], + helpLines: ["worktree <create|open|remove|list> ..."], + detailedUsage: """ + Usage: programa worktree <subcommand> [flags] + + Native git worktree workflow: create/open a worktree as its own workspace, + grouped next to its parent repo's workspace in the sidebar. + + Subcommands: + create <branch> [--base <ref>] [--path <dir>] [--repo <dir>] [--layout <name>] [--focus] + Create (or check out) a worktree for <branch> and open it as a workspace. + If <branch> already exists locally, it is checked out; otherwise it is + created from --base (default HEAD). Default --path is + <worktrees.directory>/<repo-name>/<branch-slug>. --layout applies a saved + layout (see 'programa layout') into the new workspace. --focus opts in to + focusing the new workspace (default: does not steal focus). + + create [<branch>] --from-issue <number> [--agent <name>] [create flags] + Read GitHub issue <number> with 'gh issue view' (run in the repo, so gh + must be installed and logged in) and create the worktree on branch + issue-<number>-<title-slug>. An explicit <branch> wins over the derived + name. --agent (claude, opencode, or codex) then starts that agent in the + new workspace, pointed at a temp file holding the issue title, link and + body; the issue text is never typed into the shell. Prompt files are + kept in $TMPDIR/programa-issue-prompts for 7 days. + + open <path-or-branch> [--repo <dir>] [--focus] + open --all [--repo <dir>] + Open an existing worktree (by path or branch name) as a workspace. + Idempotent: returns the existing workspace if already open. --all opens + every worktree of the repo at once instead of one <path-or-branch>; + mutually exclusive with a positional target and with --focus. + + remove <path-or-branch> [--repo <dir>] [--force] + Remove a worktree (never deletes the branch). Closes its workspace if + open. Requires --force if the worktree has uncommitted changes. + + list [--repo <dir>] [--json] + List worktrees for the resolved repo, noting which are open as workspaces. + + Flags: + --repo <dir> Repo to operate on. Default: resolved from the current + directory via 'git rev-parse --show-toplevel'. + --base <ref> Base ref for a new branch (create only). Default: HEAD. + --path <dir> Worktree directory (create only). Default: computed from + the worktrees.directory setting. + --layout <name> Apply a saved layout (see 'programa layout') after creating + the workspace (create only). + --from-issue <n> Name the branch after GitHub issue <n> (create only). + --agent <name> Start claude, opencode, or codex on the issue (needs + --from-issue). + --focus Focus the workspace after create/open (default: off). + --all Open every worktree of the repo (open only). + --force Allow removing a worktree with uncommitted changes. + + Example: + programa worktree create feature-x + programa worktree create feature-x --base main --layout fullstack-dev + programa worktree create --from-issue 42 --agent claude + programa worktree list --repo ~/code/programa + programa worktree open --all + programa worktree remove feature-x + """, + grammar: CLIArgumentGrammar(valueOptions: ["repo", "base", "path", "layout", "from-issue", "agent"], booleanOptions: ["focus", "force", "json", "all"], minPositionals: 1, maxPositionals: nil, validate: { _, parsed in + guard ["create", "open", "remove", "list"].contains(parsed.positional[0].lowercased()) else { + throw CLIError(message: "worktree: unknown subcommand \(parsed.positional[0])") + } + if parsed.options["from-issue"] != nil || parsed.options["agent"] != nil { + guard parsed.positional[0].lowercased() == "create" else { + throw CLIError(message: "worktree: --from-issue and --agent are only valid for create") + } + let issueNumber = try parsed.options["from-issue"].map(self.parseWorktreeIssueNumber) + if let agent = parsed.options["agent"] { + _ = try self.validateWorktreeIssueAgent(agent, issueNumber: issueNumber) + } + } + if parsed.options["all"] != nil { + guard parsed.positional[0].lowercased() == "open" else { + throw CLIError(message: "worktree: --all is only valid for open") + } + guard parsed.options["focus"] == nil else { + throw CLIError(message: "worktree open: --focus cannot be combined with --all") + } + guard parsed.positional.count == 1 else { + throw CLIError(message: "worktree open --all does not take a <path-or-branch> argument") + } + if let unsupported = parsed.options.keys.first(where: { !["all", "repo"].contains($0) }) { + throw CLIError(message: "worktree open --all: unexpected option --\(unsupported)") + } + } + }), + execute: { ctx in + try self.runWorktreeCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) + } + ), + + CommandDescriptor( + names: ["agent-detection"], + helpLines: ["agent-detection <list|scaffold|test> ..."], + detailedUsage: """ + Usage: programa agent-detection <subcommand> [flags] + + Screen-manifest agent detection is a best-effort tier: regex patterns matched + against visible terminal text to guess whether a coding agent is + working/blocked/idle, used only when an agent doesn't report its own lifecycle + via hooks. Programa ships manifests for a handful of agents; this command lets + you author your own for anything else, at + ~/.config/programa/agent-detection/<agent-id>.json -- a user override there + fully replaces any bundled manifest with the same agent id. + + Subcommands: + list [--json] + List every loaded manifest (bundled + user overrides): agent id, display + name, source, and recognized process names. + + scaffold <agent-id> [--surface <id|ref>] [--workspace <id|ref>] [--force] + Capture the target surface's current screen and write a starter manifest + to ~/.config/programa/agent-detection/<agent-id>.json, with the captured + screen embedded as reference text in each state's notes. Refuses to + overwrite an existing file unless --force. Also refuses <agent-id>s that + match one of the seven bundled agents (claude-code, codex, gemini-cli, + opencode, copilot-cli, cursor-agent, aider) unless --force, since an + override fully replaces the bundled manifest and a fresh scaffold starts + with empty patterns -- without --force this would silently disable working + detection for that agent. --force seeds the new file from the bundled + manifest's own patterns instead of starting blank. + + test [<agent-id>] [--surface <id|ref>] [--workspace <id|ref>] + Classify the target surface's current screen through the real + recognition + classification path. With <agent-id>, tests that manifest's + state patterns directly (bypasses recognition -- useful right after + scaffolding, before recognize.screen_patterns is filled in). Without it, + reports whichever loaded manifest's recognize.screen_patterns first + matches the screen, the same way live detection would. + + Flags: + --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) + --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) + --force Overwrite an existing scaffolded manifest, and/or + confirm scaffolding one of the bundled agent ids + (scaffold only) + + Example: + programa agent-detection list + programa agent-detection scaffold my-agent + programa agent-detection test my-agent + programa agent-detection test + """, + grammar: CLIArgumentGrammar(valueOptions: ["surface", "workspace"], booleanOptions: ["force", "json"], minPositionals: 1, maxPositionals: 2, validate: { _, parsed in + let subcommand = parsed.positional[0].lowercased() + guard ["list", "scaffold", "test"].contains(subcommand) else { + throw CLIError(message: "agent-detection: unknown subcommand \(parsed.positional[0])") + } + if subcommand == "scaffold", parsed.positional.count != 2 { + throw CLIError(message: "agent-detection scaffold requires <agent-id>") + } + }), + execute: { ctx in + try self.runAgentDetectionCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, windowId: ctx.windowId) + } + ), + + CommandDescriptor( + names: ["race"], + helpLines: ["race <prompt> [--n <count>] [--agent <name>] [--base <ref>] [--prefix <slug>] [--layout <name>]"], + detailedUsage: """ + Usage: programa race <prompt> [flags] + + Fan one prompt across N agents, each in its own isolated git worktree, so you + can compare their approaches and pick a winner. v1: spawns the fleet only -- + comparison/merge is manual (open each workspace, review its diff, merge the one + you like via 'programa worktree remove' for the rest). + + For each index 1...N: creates a worktree + workspace on branch <prefix>/<index> + (via 'programa worktree create', never stealing focus), then types the agent's + launch command with your prompt into that workspace's focused terminal. + + Flags: + --n <count> Number of parallel agents. Default 3. Must be 1-8. + --agent <name> Agent to launch: claude, opencode, or codex. Default claude. + Only these three report state via lifecycle hooks, which + racing needs for a trustworthy idle signal. + --base <ref> Git ref to branch from. Default: HEAD (see 'worktree create'). + --prefix <slug> Branch-name prefix. Default 'race'. Branches are + <prefix>/<n>, continuing after any that already + exist, so racing twice gives you race/1, race/2 + then race/3, race/4 rather than a collision. + --layout <name> Saved layout to apply into each new workspace (see + 'programa layout'). + + If a branch or worktree path already exists for one index, that index is + reported as failed and the rest of the fleet still spawns; the command exits + non-zero if any index failed. + + Example: + programa race "fix the failing test" + programa race "add dark mode" --n 5 --agent codex + programa race "refactor the parser" --n 2 --base main --prefix spike + """, + grammar: CLIArgumentGrammar(valueOptions: ["n", "agent", "base", "prefix", "layout"], minPositionals: 1, maxPositionals: nil, validate: { _, parsed in + if let rawN = parsed.options["n"] { + guard let n = Int(rawN), n >= 1, n <= 8 else { + throw CLIError(message: "race: --n must be between 1 and 8") + } + } + if let rawAgent = parsed.options["agent"] { + guard ["claude", "opencode", "codex"].contains(rawAgent.lowercased()) else { + throw CLIError(message: "race: --agent must be one of claude, opencode, codex") + } + } + }), + execute: { ctx in + try self.runRaceCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) + } + ), + + CommandDescriptor( + names: ["layout"], + helpLines: ["layout <save|apply|list> ..."], + detailedUsage: """ + Usage: programa layout <subcommand> [flags] + + Named layout configs: save the current workspace's pane/split layout under a + name, and re-apply it later (from the CLI or the command palette). + + Subcommands: + save <name> [--force] + Capture the current workspace's pane/split layout and cwds + into ~/.config/programa/layouts/<name>.json. Does NOT capture what + command is currently running in each pane -- only geometry and cwd are saved. + + apply <name> [--workspace <id|ref>] [--cwd <dir>] + Apply a saved layout. If --workspace is omitted, creates a new (unfocused) + workspace first, with cwd = --cwd (or the current directory), then applies + the layout into it -- relative cwds in the saved layout resolve against + that new workspace's root. + + list [--json] + List saved layout names. + + Flags: + --force Overwrite an existing layout with the same name (save only). + --workspace <id|ref> + Target an existing workspace (apply only). + --cwd <dir> Base directory for a newly created workspace (apply only). + + Example: + programa layout save fullstack-dev + programa layout apply fullstack-dev + programa layout list + """, + grammar: CLIArgumentGrammar(valueOptions: ["workspace", "cwd"], booleanOptions: ["force", "json"], minPositionals: 1, maxPositionals: nil, validate: { _, parsed in + guard ["save", "apply", "list"].contains(parsed.positional[0].lowercased()) else { + throw CLIError(message: "layout: unknown subcommand \(parsed.positional[0])") + } + }), + execute: { ctx in + try self.runLayoutCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) + } + ), + + CommandDescriptor( + names: ["snapshot"], + helpLines: ["snapshot <list|restore> ..."], + detailedUsage: """ + Usage: programa snapshot <subcommand> [flags] + + Session-restore history: every launch archives the previous session snapshot + into ~/Library/Application Support/programa/session-history/ before it can be + overwritten, so a crash, a forced shutdown, or a launch that skips restore + never loses your last window layout for good. + + Subcommands: + list [--json] + List archived snapshots, newest first: id, saved-at, clean/unclean + shutdown, and window/workspace/panel counts. + + restore [<id>|latest] + Restore an archived snapshot by opening a new window for every window + it contains. Never closes or changes a window that is already open. + Defaults to the newest archived snapshot when <id> is omitted or + "latest". + + Example: + programa snapshot list + programa snapshot restore + programa snapshot restore 20260731-153000-com.darkroom.programa + """, + grammar: CLIArgumentGrammar(booleanOptions: ["json"], minPositionals: 1, maxPositionals: 2, validate: { _, parsed in + switch parsed.positional[0].lowercased() { + case "list": + guard parsed.positional.count == 1 else { + throw CLIError(message: "snapshot list does not take a target") + } + case "restore": + guard parsed.options["json"] == nil else { + throw CLIError(message: "snapshot restore: unknown flag '--json'") + } + default: + throw CLIError(message: "snapshot: unknown subcommand \(parsed.positional[0])") + } + }), + execute: { ctx in + try self.runSnapshotCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) + } + ), + ] + } +} diff --git a/CLI/programa.swift b/CLI/programa.swift index a27f8c390..5240e5f88 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -217,6 +217,9 @@ enum CLICommandHelpPolicy { /// cases retain additional semantic validation where the schema is richer. enum CLICommandArgumentContract { case registered + /// The handler parses its own (often foreign or passthrough) arguments, so + /// preflight leaves them untouched. + case handlerParsed case noArguments case focusPanel case readScreen @@ -229,12 +232,12 @@ enum CLICommandArgumentContract { /// The argument grammar a `.registered` command accepts, declared once on its /// descriptor so validation is generated from the same source that documents /// it (`detailedUsage`), instead of being restated by hand as a switch arm in -/// `validateRegisteredArguments`. Field vocabulary matches that switch's -/// nested `parse`/`require` helpers so a grammar and a switch arm are -/// interchangeable line-for-line. +/// `validateRegisteredArguments`. Field vocabulary matches that function's +/// nested `parse`/`require` helpers. /// /// A command with a `nil` grammar (the default) falls through to the switch -/// unchanged. +/// in `validateRegisteredArguments`, which covers only descriptors declared +/// outside `CLI/Commands/`. struct CLIArgumentGrammar { /// Flags that take a value, e.g. `--name <value>`. var valueOptions: Set<String> = [] @@ -247,6 +250,12 @@ struct CLIArgumentGrammar { var minPositionals: Int = 0 /// `nil` means unbounded. var maxPositionals: Int? = 0 + /// Single-dash tokens accepted as positionals, e.g. `-y`. + var shortFlags: Set<String> = [] + /// Semantic checks the flag schema cannot state (allowed values, numeric + /// ranges, mutually exclusive flags). Runs after parsing and + /// `requiredOptions`; receives the command name for error messages. + var validate: ((_ command: String, _ parsed: (positional: [String], options: [String: String])) throws -> Void)? } /// Single source of truth for a CLI command's name(s), its one-line entry in @@ -317,8 +326,25 @@ struct CommandDescriptor { } } +/// Memoizes `ProgramaCLI.commandDescriptors()`. The table's closures capture +/// the CLI value that built it, so it lives for the rest of the process. +final class CommandDescriptorCache { + private let lock = NSLock() + private var cached: [CommandDescriptor]? + + func descriptors(_ build: () -> [CommandDescriptor]) -> [CommandDescriptor] { + lock.lock() + defer { lock.unlock() } + if let cached { return cached } + let built = build() + cached = built + return built + } +} + struct ProgramaCLI { let args: [String] + let descriptorCache = CommandDescriptorCache() func run() throws { try CLICommandDispatcher(cli: self).run() @@ -328,2671 +354,38 @@ struct ProgramaCLI { /// /// Order matches the `Commands:` help block, since that order /// is externally visible; dispatch itself is by name lookup and does not - /// depend on array order. + /// depend on array order. Built once per process: dispatch, argument + /// validation and help all look commands up here. func commandDescriptors() -> [CommandDescriptor] { - // Built imperatively (rather than as one large `[...] + f() + [...]` - // expression) because the Swift type-checker times out trying to - // infer a single expression spanning this many array-literal + - // function-call concatenations. - var descriptors: [CommandDescriptor] = [ - // MARK: - Pre-connection specials (dispatched earlier in run(); - // documented here only so usage() has one source for help text). - CommandDescriptor( - names: ["welcome"], - helpLines: ["welcome"], - connectionPolicy: .local, - detailedUsage: """ - Usage: programa welcome - - Show a welcome screen with the programa logo and useful shortcuts. - Auto-runs once on first launch. - """, - grammar: CLIArgumentGrammar(), - execute: nil - ), - CommandDescriptor( - names: ["shortcuts"], - helpLines: ["shortcuts"], - connectionPolicy: .local, - detailedUsage: """ - Usage: programa shortcuts - - Open the Settings window to Keyboard Shortcuts. - """, - execute: nil - ), - CommandDescriptor( - names: ["feedback"], - helpLines: ["feedback [--email <email> --body <text> [--image <path> ...]] (opens GitHub issues; direct submission disabled)"], - connectionPolicy: .local, - detailedUsage: """ - Usage: programa feedback - programa feedback --email <email> --body <text> [--image <path> ...] - - Without args, opens the GitHub issues page (https://github.com/darkroomengineering/programa/issues) in your browser. - - Direct feedback submission is disabled; --email/--body/--image are accepted but the app will - return an error telling you to report the issue on GitHub instead. - - Flags: - --email <email> Contact email for follow-up (submission disabled) - --body <text> Feedback body (submission disabled) - --image <path> Attach an image file, repeat for multiple images (submission disabled) - """, - execute: nil - ), - CommandDescriptor(names: ["themes"], helpLines: ["themes [list|set|clear]"], connectionPolicy: .local, execute: nil), - CommandDescriptor(names: ["claude-teams"], helpLines: ["claude-teams [claude-args...]"], connectionPolicy: .local, helpPolicy: .passthrough, execute: nil), - CommandDescriptor( - names: ["codex"], - helpLines: ["codex <install-integration|uninstall-integration> [--yes]"], - connectionPolicy: .local, - detailedUsage: """ - Usage: programa codex <install-integration|uninstall-integration> [--yes] - - Shows the changes and asks before applying them. --yes (-y) applies - without asking; without it a non-interactive stdin exits 1 unchanged. - - Install or remove Programa's Codex notification hooks in - ~/.codex/hooks.json (or $CODEX_HOME/hooks.json), the - `programa` agent skill (SKILL.md) into ~/.agents/skills/programa/, - and the bundled programa-mcp server (via `codex mcp add programa`). - """, - execute: nil - ), - CommandDescriptor( - names: ["claude"], - helpLines: ["claude <install-integration|uninstall-integration> [--yes]"], - connectionPolicy: .local, - detailedUsage: """ - Usage: programa claude <install-integration|uninstall-integration> [--yes] - - Install or remove Programa's persistent Claude Code hooks in - ~/.claude/settings.json (or $CLAUDE_CONFIG_DIR/settings.json), - and the `programa` agent skill (SKILL.md) into - ~/.claude/skills/programa/. - Unlike the runtime wrapper, this makes the integration work from - any terminal, not just programa's. - --yes (-y) applies without asking; without it a non-interactive - stdin exits 1 unchanged. - """, - execute: nil - ), - CommandDescriptor( - names: ["opencode"], - helpLines: ["opencode <install-integration|uninstall-integration> [--yes]"], - connectionPolicy: .local, - detailedUsage: """ - Usage: programa opencode <install-integration|uninstall-integration> [--yes] - - Install or remove Programa's OpenCode plugin in - ~/.config/opencode/plugins/programa.js (or $OPENCODE_CONFIG_DIR/plugins/programa.js), - and the `programa` agent skill (SKILL.md) into - ~/.config/opencode/skills/programa/ (or $OPENCODE_CONFIG_DIR/skills/programa/), - and the bundled programa-mcp server as `mcp.programa` in - ~/.config/opencode/opencode.json (or $OPENCODE_CONFIG_DIR/opencode.json). - A JSONC config is never rewritten; the command prints the entry to add. - OpenCode auto-loads local plugin files, so no npm install is needed. - The MCP registration is untested against a live OpenCode. - --yes (-y) applies without asking; without it a non-interactive - stdin exits 1 unchanged. - """, - execute: nil - ), - CommandDescriptor( - names: ["aside"], - helpLines: ["aside <status|install-mcp|uninstall-mcp> [--with-devtools] [--yes]"], - connectionPolicy: .local, - detailedUsage: """ - Usage: programa aside <status|install-mcp|uninstall-mcp> [--with-devtools] [--yes] - - Detect the Aside agent browser CLI and register its MCP server with - Claude Code and Codex. `status` reports the detected aside binary, - the DevTools endpoint if Aside is running, and each client's - registration state. `install-mcp` runs `claude mcp add` / `codex mcp add` - for each detected client; `--with-devtools` also registers a - chrome-devtools-mcp server pointed at Aside's DevTools port. - `uninstall-mcp` removes both. `--yes`/`-y` skips the confirmation prompt. - """, - execute: nil - ), - - CommandDescriptor( - names: ["ping"], - helpLines: ["ping"], - argumentContract: .noArguments, - detailedUsage: """ - Usage: programa ping - - Check connectivity to the programa socket server. - """, - execute: { ctx in - let payload = try ctx.client.sendV2(method: V2MethodNames.systemPing) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: "PONG") - } - ), - - CommandDescriptor(names: ["version"], helpLines: ["version"], connectionPolicy: .local, grammar: CLIArgumentGrammar(), execute: nil), - - CommandDescriptor( - names: ["capabilities"], - helpLines: ["capabilities"], - detailedUsage: """ - Usage: programa capabilities - - Print server capabilities as JSON. - """, - grammar: CLIArgumentGrammar(), - execute: { ctx in - let response = try ctx.client.sendV2(method: V2MethodNames.systemCapabilities) - print(self.jsonString(self.formatIDs(response, mode: ctx.idFormat))) - } - ), - - CommandDescriptor( - names: ["rpc"], - helpLines: ["rpc <method> [json-params]"], - detailedUsage: """ - Usage: programa rpc <method> [json-params] - - Call a raw v2 method with an optional JSON object for params. - Example: programa rpc surface.report_tty '{"workspace_id":"...","surface_id":"...","tty_name":"ttys001"}' - """, - execute: { ctx in - guard let method = ctx.commandArgs.first?.trimmingCharacters(in: .whitespacesAndNewlines), - !method.isEmpty else { - throw CLIError(message: "Usage: programa rpc <method> [json-params]") - } - let params = try self.parseRPCParams(Array(ctx.commandArgs.dropFirst())) - let response = try ctx.client.sendV2(method: method, params: params) - let output: Any = ctx.idFormatArgProvided ? self.formatIDs(response, mode: ctx.idFormat) : response - print(self.jsonString(output)) - } - ), - - CommandDescriptor( - names: ["identify"], - helpLines: ["identify [--workspace <id|ref>] [--surface <id|ref>] [--no-caller]"], - detailedUsage: """ - Usage: programa identify [--workspace <id|ref>] [--surface <id|ref>] [--no-caller] - - Print server identity and caller context details. - - Flags: - --workspace <id|ref> Caller workspace context (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Caller surface context (default: $PROGRAMA_SURFACE_ID) - --no-caller Omit caller context from the request - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace", "surface"], booleanOptions: ["no-caller"]), - execute: { ctx in - var params: [String: Any] = [:] - let includeCaller = !self.hasFlag(ctx.commandArgs, name: "--no-caller") - if includeCaller { - let idWsFlag = self.optionValue(ctx.commandArgs, name: "--workspace") - let workspaceArg = idWsFlag ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - let surfaceArg = self.optionValue(ctx.commandArgs, name: "--surface") ?? (idWsFlag == nil && ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] : nil) - if workspaceArg != nil || surfaceArg != nil { - let workspaceId = try self.normalizeWorkspaceHandle( - workspaceArg, - client: ctx.client, - allowCurrent: surfaceArg != nil - ) - var caller: [String: Any] = [:] - if let workspaceId { - caller["workspace_id"] = workspaceId - } - if surfaceArg != nil { - guard let surfaceId = try self.normalizeSurfaceHandle( - surfaceArg, - client: ctx.client, - workspaceHandle: workspaceId - ) else { - throw CLIError(message: "Invalid surface handle") - } - caller["surface_id"] = surfaceId - } - if !caller.isEmpty { - params["caller"] = caller - } - } - } - let response = try ctx.client.sendV2(method: V2MethodNames.systemIdentify, params: params) - print(self.jsonString(self.formatIDs(response, mode: ctx.idFormat))) - } - ), - - CommandDescriptor( - names: ["list-windows"], - helpLines: ["list-windows"], - detailedUsage: """ - Usage: programa list-windows - - List open windows. - """, - grammar: CLIArgumentGrammar(), - execute: { ctx in - let listed = try ctx.client.sendV2(method: V2MethodNames.windowList) - let windows = listed["windows"] as? [[String: Any]] ?? [] - if ctx.jsonOutput { - let payload = windows.map { item -> [String: Any] in - var dict: [String: Any] = [ - "index": self.intFromAny(item["index"]) ?? 0, - "id": (item["id"] as? String) ?? "", - "key": (item["key"] as? Bool) ?? false, - "workspace_count": self.intFromAny(item["workspace_count"]) ?? 0, - ] - dict["selected_workspace_id"] = item["selected_workspace_id"] as? String ?? NSNull() - return dict - } - print(self.jsonString(payload)) - } else if windows.isEmpty { - print("No windows") - } else { - let lines = windows.map { item -> String in - let selected = ((item["key"] as? Bool) ?? false) ? "*" : " " - let idx = self.intFromAny(item["index"]) ?? 0 - let id = (item["id"] as? String) ?? "" - let selectedWs = (item["selected_workspace_id"] as? String) ?? "none" - let workspaceCount = self.intFromAny(item["workspace_count"]) ?? 0 - return "\(selected) \(idx): \(id) selected_workspace=\(selectedWs) workspaces=\(workspaceCount)" - } - print(lines.joined(separator: "\n")) - } - } - ), - - CommandDescriptor( - names: ["current-window"], - helpLines: ["current-window"], - detailedUsage: """ - Usage: programa current-window - - Print the currently selected window ID. - """, - grammar: CLIArgumentGrammar(), - execute: { ctx in - let response = try ctx.client.sendV2(method: V2MethodNames.windowCurrent) - let windowId = (response["window_id"] as? String) ?? "" - if ctx.jsonOutput { - print(self.jsonString(["window_id": windowId])) - } else { - print(windowId) - } - } - ), - - CommandDescriptor( - names: ["new-window"], - helpLines: ["new-window"], - detailedUsage: """ - Usage: programa new-window - - Create a new window. - - Example: - programa new-window - """, - grammar: CLIArgumentGrammar(), - execute: { ctx in - let response = try ctx.client.sendV2(method: V2MethodNames.windowCreate) - self.printV2Payload(response, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(response, idFormat: ctx.idFormat, kinds: ["window"])) - } - ), - - CommandDescriptor( - names: ["focus-window"], - helpLines: ["focus-window --window <id>"], - detailedUsage: """ - Usage: programa focus-window --window <id|ref> - - Focus (bring to front) the specified window. - - Flags: - --window <id|ref> Window to focus (required) - - Example: - programa focus-window --window <window-uuid> - programa focus-window --window window:1 - """, - execute: { ctx in - guard let target = self.optionValue(ctx.commandArgs, name: "--window") else { - throw CLIError(message: "focus-window requires --window") - } - // A UUID or a short ref (new-window prints a ref); bare indexes are rejected. - guard self.isUUID(target.trimmingCharacters(in: .whitespacesAndNewlines)) - || self.isHandleRef(target.trimmingCharacters(in: .whitespacesAndNewlines)) else { - throw CLIError(message: "ERROR: Invalid window id") - } - do { - _ = try ctx.client.sendV2(method: V2MethodNames.windowFocus, params: ["window_id": target]) - print("OK") - } catch let error as CLIError where error.message.hasPrefix("not_found:") { - throw CLIError(message: "not_found: Window not found") - } - } - ), - - CommandDescriptor( - names: ["close-window"], - helpLines: ["close-window --window <id>"], - detailedUsage: """ - Usage: programa close-window --window <id|ref> - - Close the specified window. - - Flags: - --window <id|ref> Window to close (required) - - Example: - programa close-window --window <window-uuid> - programa close-window --window window:1 - """, - execute: { ctx in - guard let target = self.optionValue(ctx.commandArgs, name: "--window") else { - throw CLIError(message: "close-window requires --window") - } - guard self.isUUID(target.trimmingCharacters(in: .whitespacesAndNewlines)) - || self.isHandleRef(target.trimmingCharacters(in: .whitespacesAndNewlines)) else { - throw CLIError(message: "ERROR: Invalid window id") - } - do { - _ = try ctx.client.sendV2(method: V2MethodNames.windowClose, params: ["window_id": target]) - print("OK") - } catch let error as CLIError where error.message.hasPrefix("not_found:") { - throw CLIError(message: "not_found: Window not found") - } - } - ), - - CommandDescriptor( - names: ["move-workspace-to-window"], - helpLines: ["move-workspace-to-window --workspace <id|ref> --window <id|ref>"], - detailedUsage: """ - Usage: programa move-workspace-to-window --workspace <id|ref> --window <id|ref> - - Move a workspace to a different window. - - Flags: - --workspace <id|ref> Workspace to move (required) - --window <id|ref> Target window (required) - - Example: - programa move-workspace-to-window --workspace workspace:2 --window window:1 - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace", "window"], requiredOptions: ["workspace", "window"]), - execute: { ctx in - guard let workspaceRaw = self.optionValue(ctx.commandArgs, name: "--workspace") else { - throw CLIError(message: "move-workspace-to-window requires --workspace") - } - guard let windowRaw = self.optionValue(ctx.commandArgs, name: "--window") else { - throw CLIError(message: "move-workspace-to-window requires --window") - } - var params: [String: Any] = [:] - let wsId = try self.normalizeWorkspaceHandle(workspaceRaw, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let winId = try self.normalizeWindowHandle(windowRaw, client: ctx.client) - if let winId { params["window_id"] = winId } - let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceMoveToWindow, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat, kinds: ["workspace", "window"])) - } - ), - - CommandDescriptor( - names: ["reorder-workspace"], - helpLines: ["reorder-workspace --workspace <id|ref> (--index <n> | --before <id|ref> | --after <id|ref>) [--window <id|ref>]"], - detailedUsage: """ - Usage: programa reorder-workspace [--workspace <id|ref> | <id|ref>] [flags] - - Reorder a workspace within its window. - - Flags: - --workspace <id|ref> Workspace to reorder (required unless passed positionally) - --index <n> Place at this index - --before <id|ref> Place before this workspace - --before-workspace <id|ref> - Alias for --before - --after <id|ref> Place after this workspace - --after-workspace <id|ref> - Alias for --after - --window <id|ref> Window context - - Example: - programa reorder-workspace --workspace workspace:2 --index 0 - programa reorder-workspace --workspace workspace:3 --after workspace:1 - """, - execute: { ctx in - try self.runReorderWorkspace(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) - } - ), - - CommandDescriptor( - names: ["workspace-action"], - helpLines: ["workspace-action --action <name> [--workspace <id|ref>] [--title <text>] [--color <name|#hex>] [--description <text>]"], - detailedUsage: """ - Usage: programa workspace-action --action <name> [flags] - - Perform workspace context-menu actions from CLI/socket. - - Actions: - pin | unpin - rename | clear-name - set-description | clear-description - move-up | move-down | move-top - close-others | close-above | close-below - mark-read | mark-unread - set-color | clear-color - - Flags: - --action <name> Action name (required if not positional) - --workspace <id|ref> Target workspace (default: current/$PROGRAMA_WORKSPACE_ID) - --title <text> Title for rename - --color <name|#hex> Color for set-color (name or #RRGGBB hex) - --description <text> Description for set-description - - Named colors: - Red, Crimson, Orange, Amber, Olive, Green, Teal, Aqua, - Blue, Navy, Indigo, Purple, Magenta, Rose, Brown, Charcoal - - Example: - programa workspace-action --workspace workspace:2 --action pin - programa workspace-action --action rename --title "infra" - programa workspace-action close-others - programa workspace-action --action set-color --color blue - programa workspace-action --action set-color --color "#C0392B" - programa workspace-action set-color Amber - programa workspace-action --action set-description --description "Ship checklist" - programa workspace-action --action set-description $'Ship checklist\n- verify build\n- post notes' - programa workspace-action clear-color - """, - execute: { ctx in - try self.runWorkspaceAction(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, windowOverride: ctx.windowId) - } - ), - - CommandDescriptor( - names: ["worktree"], - helpLines: ["worktree <create|open|remove|list> ..."], - detailedUsage: """ - Usage: programa worktree <subcommand> [flags] - - Native git worktree workflow: create/open a worktree as its own workspace, - grouped next to its parent repo's workspace in the sidebar. - - Subcommands: - create <branch> [--base <ref>] [--path <dir>] [--repo <dir>] [--layout <name>] [--focus] - Create (or check out) a worktree for <branch> and open it as a workspace. - If <branch> already exists locally, it is checked out; otherwise it is - created from --base (default HEAD). Default --path is - <worktrees.directory>/<repo-name>/<branch-slug>. --layout applies a saved - layout (see 'programa layout') into the new workspace. --focus opts in to - focusing the new workspace (default: does not steal focus). - - create [<branch>] --from-issue <number> [--agent <name>] [create flags] - Read GitHub issue <number> with 'gh issue view' (run in the repo, so gh - must be installed and logged in) and create the worktree on branch - issue-<number>-<title-slug>. An explicit <branch> wins over the derived - name. --agent (claude, opencode, or codex) then starts that agent in the - new workspace, pointed at a temp file holding the issue title, link and - body; the issue text is never typed into the shell. Prompt files are - kept in $TMPDIR/programa-issue-prompts for 7 days. - - open <path-or-branch> [--repo <dir>] [--focus] - open --all [--repo <dir>] - Open an existing worktree (by path or branch name) as a workspace. - Idempotent: returns the existing workspace if already open. --all opens - every worktree of the repo at once instead of one <path-or-branch>; - mutually exclusive with a positional target and with --focus. - - remove <path-or-branch> [--repo <dir>] [--force] - Remove a worktree (never deletes the branch). Closes its workspace if - open. Requires --force if the worktree has uncommitted changes. - - list [--repo <dir>] [--json] - List worktrees for the resolved repo, noting which are open as workspaces. - - Flags: - --repo <dir> Repo to operate on. Default: resolved from the current - directory via 'git rev-parse --show-toplevel'. - --base <ref> Base ref for a new branch (create only). Default: HEAD. - --path <dir> Worktree directory (create only). Default: computed from - the worktrees.directory setting. - --layout <name> Apply a saved layout (see 'programa layout') after creating - the workspace (create only). - --from-issue <n> Name the branch after GitHub issue <n> (create only). - --agent <name> Start claude, opencode, or codex on the issue (needs - --from-issue). - --focus Focus the workspace after create/open (default: off). - --all Open every worktree of the repo (open only). - --force Allow removing a worktree with uncommitted changes. - - Example: - programa worktree create feature-x - programa worktree create feature-x --base main --layout fullstack-dev - programa worktree create --from-issue 42 --agent claude - programa worktree list --repo ~/code/programa - programa worktree open --all - programa worktree remove feature-x - """, - execute: { ctx in - try self.runWorktreeCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) - } - ), - - CommandDescriptor( - names: ["agent-detection"], - helpLines: ["agent-detection <list|scaffold|test> ..."], - detailedUsage: """ - Usage: programa agent-detection <subcommand> [flags] - - Screen-manifest agent detection is a best-effort tier: regex patterns matched - against visible terminal text to guess whether a coding agent is - working/blocked/idle, used only when an agent doesn't report its own lifecycle - via hooks. Programa ships manifests for a handful of agents; this command lets - you author your own for anything else, at - ~/.config/programa/agent-detection/<agent-id>.json -- a user override there - fully replaces any bundled manifest with the same agent id. - - Subcommands: - list [--json] - List every loaded manifest (bundled + user overrides): agent id, display - name, source, and recognized process names. - - scaffold <agent-id> [--surface <id|ref>] [--workspace <id|ref>] [--force] - Capture the target surface's current screen and write a starter manifest - to ~/.config/programa/agent-detection/<agent-id>.json, with the captured - screen embedded as reference text in each state's notes. Refuses to - overwrite an existing file unless --force. Also refuses <agent-id>s that - match one of the seven bundled agents (claude-code, codex, gemini-cli, - opencode, copilot-cli, cursor-agent, aider) unless --force, since an - override fully replaces the bundled manifest and a fresh scaffold starts - with empty patterns -- without --force this would silently disable working - detection for that agent. --force seeds the new file from the bundled - manifest's own patterns instead of starting blank. - - test [<agent-id>] [--surface <id|ref>] [--workspace <id|ref>] - Classify the target surface's current screen through the real - recognition + classification path. With <agent-id>, tests that manifest's - state patterns directly (bypasses recognition -- useful right after - scaffolding, before recognize.screen_patterns is filled in). Without it, - reports whichever loaded manifest's recognize.screen_patterns first - matches the screen, the same way live detection would. - - Flags: - --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - --force Overwrite an existing scaffolded manifest, and/or - confirm scaffolding one of the bundled agent ids - (scaffold only) - - Example: - programa agent-detection list - programa agent-detection scaffold my-agent - programa agent-detection test my-agent - programa agent-detection test - """, - execute: { ctx in - try self.runAgentDetectionCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, windowId: ctx.windowId) - } - ), - - CommandDescriptor( - names: ["race"], - helpLines: ["race <prompt> [--n <count>] [--agent <name>] [--base <ref>] [--prefix <slug>] [--layout <name>]"], - detailedUsage: """ - Usage: programa race <prompt> [flags] - - Fan one prompt across N agents, each in its own isolated git worktree, so you - can compare their approaches and pick a winner. v1: spawns the fleet only -- - comparison/merge is manual (open each workspace, review its diff, merge the one - you like via 'programa worktree remove' for the rest). - - For each index 1...N: creates a worktree + workspace on branch <prefix>/<index> - (via 'programa worktree create', never stealing focus), then types the agent's - launch command with your prompt into that workspace's focused terminal. - - Flags: - --n <count> Number of parallel agents. Default 3. Must be 1-8. - --agent <name> Agent to launch: claude, opencode, or codex. Default claude. - Only these three report state via lifecycle hooks, which - racing needs for a trustworthy idle signal. - --base <ref> Git ref to branch from. Default: HEAD (see 'worktree create'). - --prefix <slug> Branch-name prefix. Default 'race'. Branches are - <prefix>/<n>, continuing after any that already - exist, so racing twice gives you race/1, race/2 - then race/3, race/4 rather than a collision. - --layout <name> Saved layout to apply into each new workspace (see - 'programa layout'). - - If a branch or worktree path already exists for one index, that index is - reported as failed and the rest of the fleet still spawns; the command exits - non-zero if any index failed. - - Example: - programa race "fix the failing test" - programa race "add dark mode" --n 5 --agent codex - programa race "refactor the parser" --n 2 --base main --prefix spike - """, - execute: { ctx in - try self.runRaceCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) - } - ), - - CommandDescriptor( - names: ["layout"], - helpLines: ["layout <save|apply|list> ..."], - detailedUsage: """ - Usage: programa layout <subcommand> [flags] - - Named layout configs: save the current workspace's pane/split layout under a - name, and re-apply it later (from the CLI or the command palette). - - Subcommands: - save <name> [--force] - Capture the current workspace's pane/split layout and cwds - into ~/.config/programa/layouts/<name>.json. Does NOT capture what - command is currently running in each pane -- only geometry and cwd are saved. - - apply <name> [--workspace <id|ref>] [--cwd <dir>] - Apply a saved layout. If --workspace is omitted, creates a new (unfocused) - workspace first, with cwd = --cwd (or the current directory), then applies - the layout into it -- relative cwds in the saved layout resolve against - that new workspace's root. - - list [--json] - List saved layout names. - - Flags: - --force Overwrite an existing layout with the same name (save only). - --workspace <id|ref> - Target an existing workspace (apply only). - --cwd <dir> Base directory for a newly created workspace (apply only). - - Example: - programa layout save fullstack-dev - programa layout apply fullstack-dev - programa layout list - """, - execute: { ctx in - try self.runLayoutCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) - } - ), - - CommandDescriptor( - names: ["snapshot"], - helpLines: ["snapshot <list|restore> ..."], - detailedUsage: """ - Usage: programa snapshot <subcommand> [flags] - - Session-restore history: every launch archives the previous session snapshot - into ~/Library/Application Support/programa/session-history/ before it can be - overwritten, so a crash, a forced shutdown, or a launch that skips restore - never loses your last window layout for good. - - Subcommands: - list [--json] - List archived snapshots, newest first: id, saved-at, clean/unclean - shutdown, and window/workspace/panel counts. - - restore [<id>|latest] - Restore an archived snapshot by opening a new window for every window - it contains. Never closes or changes a window that is already open. - Defaults to the newest archived snapshot when <id> is omitted or - "latest". - - Example: - programa snapshot list - programa snapshot restore - programa snapshot restore 20260731-153000-com.darkroom.programa - """, - execute: { ctx in - try self.runSnapshotCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) - } - ), - - CommandDescriptor( - names: ["list-workspaces"], - helpLines: ["list-workspaces"], - detailedUsage: """ - Usage: programa list-workspaces - - List workspaces in the current window. - - Example: - programa list-workspaces - """, - grammar: CLIArgumentGrammar(), - execute: { ctx in - let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceList) - if ctx.jsonOutput { - print(self.jsonString(self.formatIDs(payload, mode: ctx.idFormat))) - } else { - let workspaces = payload["workspaces"] as? [[String: Any]] ?? [] - if workspaces.isEmpty { - print("No workspaces") - } else { - for ws in workspaces { - let selected = (ws["selected"] as? Bool) == true - let handle = self.textHandle(ws, idFormat: ctx.idFormat) - let title = (ws["title"] as? String) ?? "" - let prefix = selected ? "* " : " " - let selTag = selected ? " [selected]" : "" - let titlePart = title.isEmpty ? "" : " \(title)" - print("\(prefix)\(handle)\(titlePart)\(selTag)") - } - } - } - } - ), - - CommandDescriptor( - names: ["new-workspace"], - helpLines: ["new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>]"], - detailedUsage: """ - Usage: programa new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] - - Create a new workspace in the current window. - - Flags: - --name <title> Set a custom name for the new workspace - --description <text> Set a custom description for the new workspace - --cwd <path> Set the working directory for the new workspace - --command <text> Send text+Enter to the new workspace after creation - - Example: - programa new-workspace - programa new-workspace --name "Build Server" - programa new-workspace --name "Launch" --description "Ship checklist" - programa new-workspace --cwd ~/projects/myapp - programa new-workspace --cwd . --command "npm test" - """, - grammar: CLIArgumentGrammar(valueOptions: ["name", "description", "cwd", "command"]), - execute: { ctx in - let (commandOpt, rem0) = self.parseOption(ctx.commandArgs, name: "--command") - let (cwdOpt, rem1) = self.parseOption(rem0, name: "--cwd") - let (nameOpt, rem2) = self.parseOption(rem1, name: "--name") - let (descriptionOpt, remaining) = self.parseOption(rem2, name: "--description") - if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { - throw CLIError(message: "new-workspace: unknown flag '\(unknown)'. Known flags: --name <title>, --description <text>, --command <text>, --cwd <path>") - } - var params: [String: Any] = [:] - if let cwdOpt { - let resolved = self.resolvePath(cwdOpt) - params["cwd"] = resolved - } - if let nameOpt { - params["title"] = nameOpt - } - if let descriptionOpt { - params["description"] = descriptionOpt - } - let response = try ctx.client.sendV2(method: V2MethodNames.workspaceCreate, params: params) - let wsId = (response["workspace_ref"] as? String) ?? (response["workspace_id"] as? String) ?? "" - self.printV2Payload(response, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(response, idFormat: ctx.idFormat, kinds: ["workspace"])) - if let commandText = commandOpt, !wsId.isEmpty { - let text = self.unescapeSendText(commandText + "\\n") - let sendParams: [String: Any] = ["text": text, "workspace_id": wsId] - _ = try ctx.client.sendV2(method: V2MethodNames.surfaceSendText, params: sendParams) - } - } - ), - - ] - descriptors += [ - - CommandDescriptor( - names: ["new-split"], - helpLines: ["new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>]"], - detailedUsage: """ - Usage: programa new-split <left|right|up|down> [flags] - - Split the current pane in the given direction. - - Flags: - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Surface to split from (default: $PROGRAMA_SURFACE_ID) - --panel <id|ref> Alias for --surface - - Example: - programa new-split right - programa new-split down --workspace workspace:1 - """, - execute: { ctx in - let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") - let (panelArg, rem1) = self.parseOption(rem0, name: "--panel") - let (sfArg, rem2) = self.parseOption(rem1, name: "--surface") - let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - guard let direction = rem2.first else { - throw CLIError(message: "new-split requires a direction") - } - var params: [String: Any] = ["direction": direction] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let sfId = try self.resolveCommandSurface( - explicitSurface: sfArg ?? panelArg, explicitWorkspace: wsArg, - windowId: ctx.windowId, workspaceHandle: wsId, client: ctx.client - ) - if let sfId { params["surface_id"] = sfId } - let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceSplit, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) - } - ), - - CommandDescriptor( - names: ["list-panes"], - helpLines: ["list-panes [--workspace <id|ref>]"], - detailedUsage: """ - Usage: programa list-panes [--workspace <id|ref>] - - List panes in a workspace. - - Flags: - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa list-panes - programa list-panes --workspace workspace:2 - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), - execute: { ctx in - let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) - var params: [String: Any] = [:] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let payload = try ctx.client.sendV2(method: V2MethodNames.paneList, params: params) - if ctx.jsonOutput { - print(self.jsonString(self.formatIDs(payload, mode: ctx.idFormat))) - } else { - let panes = payload["panes"] as? [[String: Any]] ?? [] - if panes.isEmpty { - print("No panes") - } else { - for pane in panes { - let focused = (pane["focused"] as? Bool) == true - let handle = self.textHandle(pane, idFormat: ctx.idFormat) - let count = pane["surface_count"] as? Int ?? 0 - let prefix = focused ? "* " : " " - let focusTag = focused ? " [focused]" : "" - print("\(prefix)\(handle) [\(count) surface\(count == 1 ? "" : "s")]\(focusTag)") - } - } - } - } - ), - - CommandDescriptor( - names: ["list-pane-surfaces"], - helpLines: ["list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>]"], - detailedUsage: """ - Usage: programa list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>] - - List surfaces in a pane. - - Flags: - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - --pane <id|ref> Restrict to a specific pane (default: focused pane) - - Example: - programa list-pane-surfaces - programa list-pane-surfaces --workspace workspace:2 --pane pane:1 - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace", "pane"]), - execute: { ctx in - let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) - let paneRaw = self.optionValue(ctx.commandArgs, name: "--pane") - var params: [String: Any] = [:] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let paneId = try self.normalizePaneHandle(paneRaw, client: ctx.client, workspaceHandle: wsId) - if let paneId { params["pane_id"] = paneId } - let payload = try ctx.client.sendV2(method: V2MethodNames.paneSurfaces, params: params) - if ctx.jsonOutput { - print(self.jsonString(self.formatIDs(payload, mode: ctx.idFormat))) - } else { - let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] - if surfaces.isEmpty { - print("No surfaces in pane") - } else { - for surface in surfaces { - let selected = (surface["selected"] as? Bool) == true - let handle = self.textHandle(surface, idFormat: ctx.idFormat) - let title = (surface["title"] as? String) ?? "" - let prefix = selected ? "* " : " " - let selTag = selected ? " [selected]" : "" - print("\(prefix)\(handle) \(title)\(selTag)") - } - } - } - } - ), - - ] + descriptorCache.descriptors { buildCommandDescriptors() } + } + + private func buildCommandDescriptors() -> [CommandDescriptor] { + // Each group is its own array literal in CLI/Commands/: one expression + // spanning every descriptor times out the Swift type-checker. + var descriptors = localCommandDescriptors() + descriptors += systemCommandDescriptors() + descriptors += windowCommandDescriptors() + descriptors += workspaceOrderingDescriptors() + descriptors += worktreeAndLayoutDescriptors() + descriptors += workspaceListDescriptors() + descriptors += paneListDescriptors() descriptors += self.treeDescriptors() descriptors += self.portsDescriptors() + self.resumeDescriptors() + self.usageDescriptors() + self.storageDescriptors() descriptors += self.handoffDescriptors() - descriptors += [ - - CommandDescriptor( - names: ["focus-pane"], - helpLines: ["focus-pane --pane <id|ref> [--workspace <id|ref>]"], - detailedUsage: """ - Usage: programa focus-pane [--pane <id|ref> | <id|ref>] [flags] - - Focus the specified pane. - - Flags: - --pane <id|ref> Pane to focus (required unless passed positionally) - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa focus-pane --pane pane:2 - programa focus-pane pane:1 - programa focus-pane --pane pane:1 --workspace workspace:2 - """, - execute: { ctx in - let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) - guard let paneRaw = self.optionOrFirstPositional(ctx.commandArgs, name: "--pane") else { - throw CLIError(message: "focus-pane requires --pane <id|ref>") - } - var params: [String: Any] = [:] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let paneId = try self.normalizePaneHandle(paneRaw, client: ctx.client, workspaceHandle: wsId) - if let paneId { params["pane_id"] = paneId } - let payload = try ctx.client.sendV2(method: V2MethodNames.paneFocus, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat, kinds: ["pane", "workspace"])) - } - ), - - CommandDescriptor( - names: ["new-pane"], - helpLines: ["new-pane [--type <terminal>] [--direction <left|right|up|down>] [--workspace <id|ref>]"], - detailedUsage: """ - Usage: programa new-pane [flags] - - Create a new pane in the workspace. - - Flags: - --type <terminal> Pane type (default: terminal) - --direction <left|right|up|down> Split direction (default: right) - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa new-pane - programa new-pane --direction down - """, - execute: { ctx in - let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) - let type = self.optionValue(ctx.commandArgs, name: "--type") - let direction = self.optionValue(ctx.commandArgs, name: "--direction") ?? "right" - var params: [String: Any] = ["direction": direction] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - if let type { params["type"] = type } - let payload = try ctx.client.sendV2(method: V2MethodNames.paneCreate, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat, kinds: ["surface", "pane", "workspace"])) - } - ), - - CommandDescriptor( - names: ["new-surface"], - helpLines: ["new-surface [--type <terminal>] [--pane <id|ref>] [--workspace <id|ref>]"], - detailedUsage: """ - Usage: programa new-surface [flags] - - Create a new surface (tab) in a pane. - - Flags: - --type <terminal> Surface type (default: terminal) - --pane <id|ref> Target pane - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa new-surface - programa new-surface --pane pane:1 - """, - execute: { ctx in - let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) - let type = self.optionValue(ctx.commandArgs, name: "--type") - let paneRaw = self.optionValue(ctx.commandArgs, name: "--pane") - var params: [String: Any] = [:] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let paneId = try self.normalizePaneHandle(paneRaw, client: ctx.client, workspaceHandle: wsId) - if let paneId { params["pane_id"] = paneId } - if let type { params["type"] = type } - let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceCreate, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat, kinds: ["surface", "pane", "workspace"])) - } - ), - - CommandDescriptor( - names: ["close-surface"], - helpLines: ["close-surface [--surface <id|ref>] [--workspace <id|ref>]"], - detailedUsage: """ - Usage: programa close-surface [flags] - - Close a surface. Defaults to the focused surface if none specified. - - Flags: - --surface <id|ref> Surface to close (default: $PROGRAMA_SURFACE_ID) - --panel <id|ref> Alias for --surface - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa close-surface - programa close-surface --surface surface:3 - """, - grammar: CLIArgumentGrammar(valueOptions: ["surface", "panel", "workspace"]), - execute: { ctx in - let csWsFlag = self.optionValue(ctx.commandArgs, name: "--workspace") - let workspaceArg = csWsFlag ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - let surfaceRaw = self.optionValue(ctx.commandArgs, name: "--surface") ?? self.optionValue(ctx.commandArgs, name: "--panel") - var params: [String: Any] = [:] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let sfId = try self.resolveCommandSurface( - explicitSurface: surfaceRaw, explicitWorkspace: csWsFlag, - windowId: ctx.windowId, workspaceHandle: wsId, client: ctx.client - ) - if let sfId { params["surface_id"] = sfId } - let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceClose, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) - } - ), - - CommandDescriptor( - names: ["move-surface"], - helpLines: ["move-surface --surface <id|ref> [--pane <id|ref>] [--workspace <id|ref>] [--window <id|ref>] [--before <id|ref>] [--after <id|ref>] [--index <n>] [--focus <true|false>]"], - detailedUsage: """ - Usage: programa move-surface [--surface <id|ref> | <id|ref>] [flags] - - Move a surface to a different pane, workspace, or window. - - Flags: - --surface <id|ref> Surface to move (required unless passed positionally) - --pane <id|ref> Target pane - --workspace <id|ref> Target workspace - --window <id|ref> Target window - --before <id|ref> Place before this surface - --before-surface <id|ref> - Alias for --before - --after <id|ref> Place after this surface - --after-surface <id|ref> - Alias for --after - --index <n> Place at this index - --focus <true|false> Focus the surface after moving - - Example: - programa move-surface --surface surface:1 --workspace workspace:2 - programa move-surface surface:1 --pane pane:2 --index 0 - """, - execute: { ctx in - try self.runMoveSurface(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) - } - ), - - CommandDescriptor( - names: ["reorder-surface"], - helpLines: ["reorder-surface --surface <id|ref> (--index <n> | --before <id|ref> | --after <id|ref>)"], - detailedUsage: """ - Usage: programa reorder-surface [--surface <id|ref> | <id|ref>] [flags] - - Reorder a surface within its pane. - - Flags: - --surface <id|ref> Surface to reorder (required unless passed positionally) - --workspace <id|ref> Workspace context - --before <id|ref> Place before this surface - --before-surface <id|ref> - Alias for --before - --after <id|ref> Place after this surface - --after-surface <id|ref> - Alias for --after - --index <n> Place at this index - - Example: - programa reorder-surface --surface surface:1 --index 0 - programa reorder-surface --surface surface:3 --after surface:1 - """, - execute: { ctx in - try self.runReorderSurface(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) - } - ), - - CommandDescriptor( - names: ["tab-action"], - helpLines: ["tab-action --action <name> [--tab <id|ref>] [--surface <id|ref>] [--workspace <id|ref>] [--title <text>]"], - detailedUsage: """ - Usage: programa tab-action --action <name> [flags] - - Perform horizontal tab context-menu actions from CLI/socket. - - Actions: - rename | clear-name - close-left | close-right | close-others - new-terminal-right - reload | duplicate - pin | unpin - mark-unread - - Flags: - --action <name> Action name (required if not positional) - --tab <id|ref> Target tab (accepts tab:<n> or surface:<n>; default: $PROGRAMA_TAB_ID, then $PROGRAMA_SURFACE_ID, then focused tab) - --surface <id|ref> Alias for --tab (backward compatibility) - --workspace <id|ref> Workspace context (default: current/$PROGRAMA_WORKSPACE_ID) - --title <text> Title for rename (or pass trailing title text) - - Example: - programa tab-action --tab tab:3 --action pin - programa tab-action --action close-right - programa tab-action --tab tab:2 --action rename --title "build logs" - """, - execute: { ctx in - try self.runTabAction(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, windowOverride: ctx.windowId) - } - ), - - CommandDescriptor( - names: ["rename-tab"], - helpLines: ["rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] <title>"], - detailedUsage: """ - Usage: programa rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] [--] <title> - - Compatibility alias for tab-action rename. - - Resolution order for target tab: - 1) --tab - 2) --surface - 3) $PROGRAMA_TAB_ID / $PROGRAMA_SURFACE_ID - 4) currently focused tab (optionally within --workspace) - - Flags: - --workspace <id|ref> Workspace context (default: current/$PROGRAMA_WORKSPACE_ID) - --tab <id|ref> Tab target (supports tab:<n> or surface:<n>) - --surface <id|ref> Alias for --tab - --title <text> Explicit title (or use trailing positional title) - - Examples: - programa rename-tab "build logs" - programa rename-tab --tab tab:3 "staging server" - programa rename-tab --workspace workspace:2 --surface surface:5 --title "agent run" - """, - execute: { ctx in - try self.runRenameTab(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, windowOverride: ctx.windowId) - } - ), - - CommandDescriptor( - names: ["drag-surface-to-split"], - helpLines: ["drag-surface-to-split --surface <id|ref> <left|right|up|down>"], - detailedUsage: """ - Usage: programa drag-surface-to-split --surface <id|ref> <left|right|up|down> - - Drag a surface into a new split in the given direction. - - Flags: - --surface <id|ref> Surface to drag (required) - --panel <id|ref> Alias for --surface - - Example: - programa drag-surface-to-split --surface surface:1 right - programa drag-surface-to-split --panel surface:2 down - """, - execute: { ctx in - let (surfaceArg, rem0) = self.parseOption(ctx.commandArgs, name: "--surface") - let (panelArg, rem1) = self.parseOption(rem0, name: "--panel") - let surface = surfaceArg ?? panelArg - guard let surface else { - throw CLIError(message: "drag-surface-to-split requires --surface <id|ref>") - } - guard let direction = rem1.first else { - throw CLIError(message: "drag-surface-to-split requires a direction") - } - // No --workspace support: leave workspace_id unset so v2 targets the current selection. - let surfaceIdForDrag = try self.normalizeSurfaceHandle(surface, client: ctx.client, workspaceHandle: nil) - var dragParams: [String: Any] = ["direction": direction] - if let surfaceIdForDrag { dragParams["surface_id"] = surfaceIdForDrag } - let dragPayload = try ctx.client.sendV2(method: V2MethodNames.surfaceDragToSplit, params: dragParams) - print("OK \((dragPayload["pane_id"] as? String) ?? "")") - } - ), - - CommandDescriptor( - names: ["refresh-surfaces"], - helpLines: ["refresh-surfaces"], - detailedUsage: """ - Usage: programa refresh-surfaces - - Refresh surface snapshots for the focused workspace. - """, - grammar: CLIArgumentGrammar(), - execute: { ctx in - // No --workspace support: leave workspace_id unset so v2 targets the current selection. - let refreshPayload = try ctx.client.sendV2(method: V2MethodNames.surfaceRefresh, params: [:]) - print("OK Refreshed \(self.intFromAny(refreshPayload["refreshed"]) ?? 0) surfaces") - } - ), - - CommandDescriptor( - names: ["reload-config"], - helpLines: ["reload-config"], - detailedUsage: """ - Usage: programa reload-config - - Run the same configuration reload as the Reload Configuration shortcut. - This reloads Ghostty config, re-reads ~/.config/programa/settings.json, and refreshes terminals. - - Example: - programa reload-config - """, - grammar: CLIArgumentGrammar(), - execute: { ctx in - if let unexpected = ctx.commandArgs.first { - throw CLIError(message: "reload-config does not accept arguments. Unexpected argument '\(unexpected)'") - } - _ = try ctx.client.sendV2(method: V2MethodNames.appReloadConfig) - print("OK Reloaded config") - } - ), - - CommandDescriptor( - names: ["surface-health"], - helpLines: ["surface-health [--workspace <id|ref>]"], - detailedUsage: """ - Usage: programa surface-health [--workspace <id|ref>] - - List health details for surfaces in a workspace. - - Flags: - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa surface-health - programa surface-health --workspace workspace:2 - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), - execute: { ctx in - let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) - var params: [String: Any] = [:] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceHealth, params: params) - if ctx.jsonOutput { - print(self.jsonString(self.formatIDs(payload, mode: ctx.idFormat))) - } else { - let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] - if surfaces.isEmpty { - print("No surfaces") - } else { - for surface in surfaces { - let handle = self.textHandle(surface, idFormat: ctx.idFormat) - let sType = (surface["type"] as? String) ?? "" - let inWindow = surface["in_window"] - let inWindowStr: String - if let b = inWindow as? Bool { - inWindowStr = " in_window=\(b)" - } else { - inWindowStr = "" - } - print("\(handle) type=\(sType)\(inWindowStr)") - } - } - } - } - ), - - CommandDescriptor( - names: ["debug-terminals"], - helpLines: [], - detailedUsage: """ - Usage: programa debug-terminals - - Print live Ghostty terminal runtime metadata across all windows and workspaces. - Intended for debugging stray or detached terminal views. - """, - grammar: CLIArgumentGrammar(), - execute: { ctx in - let unexpected = ctx.commandArgs.filter { $0 != "--" } - if let extra = unexpected.first { - throw CLIError(message: "debug-terminals: unexpected argument '\(extra)'") - } - let payload = try ctx.client.sendV2(method: V2MethodNames.debugTerminals) - if ctx.jsonOutput { - print(self.jsonString(self.formatIDs(payload, mode: ctx.idFormat))) - } else { - print(self.formatDebugTerminalsPayload(payload, idFormat: ctx.idFormat)) - } - } - ), - - CommandDescriptor( - names: ["trigger-flash"], - helpLines: ["trigger-flash [--workspace <id|ref>] [--surface <id|ref>]"], - detailedUsage: """ - Usage: programa trigger-flash [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] - - Trigger the unread flash indicator for a surface. - - Flags: - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) - --panel <id|ref> Alias for --surface - - Example: - programa trigger-flash - programa trigger-flash --workspace workspace:2 --surface surface:3 - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace", "surface", "panel"]), - execute: { ctx in - let tfWsFlag = self.optionValue(ctx.commandArgs, name: "--workspace") - let explicitWorkspaceArg = tfWsFlag - let preferTTYFallback = ctx.windowId == nil && ProcessInfo.processInfo.environment["TMUX"] != nil - let callerWorkspaceArg = preferTTYFallback - ? nil - : (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg - let explicitSurfaceArg = self.optionValue(ctx.commandArgs, name: "--surface") ?? self.optionValue(ctx.commandArgs, name: "--panel") - let callerSurfaceArg = explicitSurfaceArg == nil && preferTTYFallback == false && ctx.windowId == nil - ? ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] - : nil - let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg - var params: [String: Any] = [:] - let wsId = try { - if explicitWorkspaceArg != nil { - return try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - } - return try self.resolveWorkspaceIdAllowingFallback(workspaceArg, client: ctx.client) - }() - if let wsId { params["workspace_id"] = wsId } - let sfId = try { - if explicitSurfaceArg != nil { - return try self.normalizeSurfaceHandle(surfaceArg, client: ctx.client, workspaceHandle: wsId) - } - guard let wsId else { return nil } - return try self.resolveSurfaceIdAllowingFallback( - surfaceArg, - workspaceId: wsId, - client: ctx.client - ) - }() - if let sfId { params["surface_id"] = sfId } - let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceTriggerFlash, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) - } - ), - - CommandDescriptor( - names: ["list-panels"], - helpLines: ["list-panels [--workspace <id|ref>]"], - detailedUsage: """ - Usage: programa list-panels [--workspace <id|ref>] - - List surfaces (panels) in a workspace. - - Flags: - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa list-panels - programa list-panels --workspace workspace:2 - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), - execute: { ctx in - let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) - var params: [String: Any] = [:] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceList, params: params) - if ctx.jsonOutput { - print(self.jsonString(self.formatIDs(payload, mode: ctx.idFormat))) - } else { - let surfaces = payload["surfaces"] as? [[String: Any]] ?? [] - if surfaces.isEmpty { - print("No surfaces") - } else { - for surface in surfaces { - let focused = (surface["focused"] as? Bool) == true - let handle = self.textHandle(surface, idFormat: ctx.idFormat) - let sType = (surface["type"] as? String) ?? "" - let title = (surface["title"] as? String) ?? "" - let prefix = focused ? "* " : " " - let focusTag = focused ? " [focused]" : "" - let titlePart = title.isEmpty ? "" : " \"\(title)\"" - print("\(prefix)\(handle) \(sType)\(focusTag)\(titlePart)") - } - } - } - } - ), - - CommandDescriptor( - names: ["focus-panel"], - helpLines: ["focus-panel --panel <id|ref> [--workspace <id|ref>]"], - argumentContract: .focusPanel, - detailedUsage: """ - Usage: programa focus-panel --panel <id|ref> [--workspace <id|ref>] - - Focus a specific panel (surface). - - Flags: - --panel <id|ref> Panel/surface to focus (required) - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa focus-panel --panel surface:2 - programa focus-panel --panel surface:5 --workspace workspace:2 - """, - execute: { ctx in - let workspaceArg = self.workspaceFromArgsOrEnv(ctx.commandArgs, windowOverride: ctx.windowId) - guard let panelRaw = self.optionValue(ctx.commandArgs, name: "--panel") else { - throw CLIError(message: "focus-panel requires --panel") - } - var params: [String: Any] = [:] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let sfId = try self.normalizeSurfaceHandle(panelRaw, client: ctx.client, workspaceHandle: wsId) - if let sfId { params["surface_id"] = sfId } - let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceFocus, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) - } - ), - - CommandDescriptor( - names: ["close-workspace"], - helpLines: ["close-workspace --workspace <id|ref>"], - detailedUsage: """ - Usage: programa close-workspace --workspace <id|ref> - - Close the specified workspace. - - Flags: - --workspace <id|ref> Workspace to close (required) - - Example: - programa close-workspace --workspace workspace:2 - """, - execute: { ctx in - guard let workspaceRaw = self.optionValue(ctx.commandArgs, name: "--workspace") else { - throw CLIError( - message: "close-workspace: --workspace <id|ref> is required (UUID or short ref like workspace:2). Refusing to target the current workspace implicitly." - ) - } - var params: [String: Any] = [:] - let wsId = try self.normalizeWorkspaceHandle(workspaceRaw, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceClose, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat, kinds: ["workspace"])) - } - ), - - CommandDescriptor( - names: ["select-workspace"], - helpLines: ["select-workspace --workspace <id|ref>"], - detailedUsage: """ - Usage: programa select-workspace --workspace <id|ref> - - Select (switch to) the specified workspace. - - Flags: - --workspace <id|ref> Workspace to select (required) - - Example: - programa select-workspace --workspace workspace:2 - programa select-workspace --workspace 0 - """, - execute: { ctx in - guard let workspaceRaw = self.optionValue(ctx.commandArgs, name: "--workspace") else { - throw CLIError(message: "select-workspace requires --workspace") - } - var params: [String: Any] = [:] - let wsId = try self.normalizeWorkspaceHandle(workspaceRaw, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceSelect, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat, kinds: ["workspace"])) - } - ), - - CommandDescriptor( - names: ["rename-workspace", "rename-window"], - helpLines: [ - "rename-workspace [--workspace <id|ref>] <title>", - "rename-window [--workspace <id|ref>] <title>", - ], - detailedUsage: """ - Usage: programa rename-workspace [--workspace <id|ref>] [--] <title> - - Rename a workspace. Defaults to the current workspace. - tmux-compatible alias: rename-window - - Flags: - --workspace <id|ref> Workspace to rename (default: current/$PROGRAMA_WORKSPACE_ID) - - Example: - programa rename-workspace "backend logs" - programa rename-window --workspace workspace:2 "agent run" - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"], minPositionals: 1, maxPositionals: nil), - execute: { ctx in - let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") - let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - let titleArgs = rem0.dropFirst(rem0.first == "--" ? 1 : 0) - let title = titleArgs.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) - guard !title.isEmpty else { - throw CLIError(message: "\(ctx.command) requires a title") - } - let wsId = try self.resolveWorkspaceId(workspaceArg, client: ctx.client) - let params: [String: Any] = ["title": title, "workspace_id": wsId] - let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceRename, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat, kinds: ["workspace"])) - } - ), - - CommandDescriptor( - names: ["current-workspace"], - helpLines: ["current-workspace"], - detailedUsage: """ - Usage: programa current-workspace - - Print the currently selected workspace ID. - """, - grammar: CLIArgumentGrammar(), - execute: { ctx in - let response = try ctx.client.sendV2(method: V2MethodNames.workspaceCurrent) - if ctx.jsonOutput { - print(self.jsonString(self.formatIDs(response, mode: ctx.idFormat))) - } else { - let handle = self.formatHandle(response, kind: "workspace", idFormat: ctx.idFormat) - ?? (response["workspace_id"] as? String) - ?? "" - print(handle) - } - } - ), - - CommandDescriptor( - names: ["read-screen"], - helpLines: ["read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>]"], - argumentContract: .readScreen, - detailedUsage: """ - Usage: programa read-screen [flags] - - Read terminal text from a surface as plain text. - - Flags: - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) - --scrollback Include scrollback (not just visible viewport) - --lines <n> Limit to the last n lines (implies --scrollback) - - Example: - programa read-screen - programa read-screen --surface surface:2 --scrollback --lines 200 - """, - execute: { ctx in - let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") - let (sfArg, rem1) = self.parseOption(rem0, name: "--surface") - let (linesArg, rem2) = self.parseOption(rem1, name: "--lines") - let trailing = rem2.filter { $0 != "--scrollback" } - if !trailing.isEmpty { - throw CLIError(message: "read-screen: unexpected arguments: \(trailing.joined(separator: " "))") - } - - let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - - var params: [String: Any] = [:] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let sfId = try self.resolveCommandSurface( - explicitSurface: sfArg, explicitWorkspace: wsArg, - windowId: ctx.windowId, workspaceHandle: wsId, client: ctx.client - ) - if let sfId { params["surface_id"] = sfId } - - let includeScrollback = rem2.contains("--scrollback") - if includeScrollback { - params["scrollback"] = true - } - if let linesArg { - guard let lineCount = Int(linesArg), lineCount > 0 else { - throw CLIError(message: "--lines must be greater than 0") - } - params["lines"] = lineCount - params["scrollback"] = true - } - - let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceReadText, params: params) - if ctx.jsonOutput { - print(self.jsonString(payload)) - } else { - print((payload["text"] as? String) ?? "") - } - } - ), - - CommandDescriptor( - names: ["wait-surface"], - helpLines: ["wait-surface [--workspace <id|ref>] [--surface <id|ref>] (--pattern <regex> | --exit | --agent-state <state>) [--timeout <seconds>] [--lines <n>]"], - argumentContract: .waitSurface, - detailedUsage: """ - Usage: programa wait-surface [flags] - - Block until a surface hits a condition, in one server-owned request -- no - polling loop needed. Answers as soon as the condition is met (or is already - true when the call arrives) or the timeout elapses. - - Flags: - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) - --pattern <regex> Wait until output (screen + scrollback) matches this regex - --exit Wait until the surface's child process exits - --agent-state <state> Wait until the agent's reported state is idle, working, - blocked, or any_change (lifecycle-hook agents) - --timeout <seconds> Give up after this long (default: 30) - --lines <n> Cap how much scrollback --pattern rereads per check (default: 2000) - - Exactly one of --pattern / --exit / --agent-state is required. A marker already - present when the call arrives (or a process that has already exited, or an agent - already in the state; a surface with no reported state counts as idle) resolves - immediately -- - `waited: false` in JSON output distinguishes that from an actual wait. - - Example: - programa wait-surface --pattern 'BUILD (SUCCEEDED|FAILED)' --timeout 120 - programa wait-surface --surface surface:2 --exit --timeout 10 - programa wait-surface --surface surface:2 --agent-state idle --timeout 300 - """, - execute: { ctx in - let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") - let (sfArg, rem1) = self.parseOption(rem0, name: "--surface") - let (patternArg, rem2) = self.parseOption(rem1, name: "--pattern") - let (timeoutArg, rem3) = self.parseOption(rem2, name: "--timeout") - let (linesArg, rem4) = self.parseOption(rem3, name: "--lines") - let (agentStateArg, rem5) = self.parseOption(rem4, name: "--agent-state") - let exitFlag = rem5.contains("--exit") - let trailing = rem5.filter { $0 != "--exit" } - if !trailing.isEmpty { - throw CLIError(message: "wait-surface: unexpected arguments: \(trailing.joined(separator: " "))") - } - guard [patternArg != nil, exitFlag, agentStateArg != nil].filter({ $0 }).count == 1 else { - throw CLIError(message: "wait-surface requires exactly one of --pattern <regex>, --exit, or --agent-state <idle|working|blocked|any_change>") - } - - let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - - var params: [String: Any] = [:] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let sfId = try self.resolveCommandSurface( - explicitSurface: sfArg, explicitWorkspace: wsArg, - windowId: ctx.windowId, workspaceHandle: wsId, client: ctx.client - ) - if let sfId { params["surface_id"] = sfId } - - if let patternArg { - params["pattern"] = patternArg - } else if let agentStateArg { - params["agent_state"] = agentStateArg - } else { - params["exit"] = true - } - - var timeoutSeconds = 30.0 - if let timeoutArg { - guard let parsed = Double(timeoutArg), parsed.isFinite, parsed > 0 else { - throw CLIError(message: "wait-surface: --timeout must be a positive number of seconds") - } - timeoutSeconds = parsed - params["timeout_ms"] = Int((parsed * 1000).rounded()) - } - if let linesArg { - guard let lineCount = Int(linesArg), lineCount > 0 else { - throw CLIError(message: "wait-surface: --lines must be greater than 0") - } - params["lines"] = lineCount - } - - // The server may legitimately hold this connection open for the full - // --timeout; give the client-side socket read at least that long (plus a - // buffer for the response round trip) rather than the default 15s. - let payload = try ctx.client.sendV2( - method: V2MethodNames.surfaceWait, - params: params, - minimumReceiveTimeout: timeoutSeconds + 5.0 - ) - if ctx.jsonOutput { - print(self.jsonString(payload)) - } else if let match = payload["match"] as? String { - print(match) - } else if exitFlag { - print("exited") - } else if agentStateArg != nil { - print((payload["state"] as? String) ?? "ok") - } else { - print("ok") - } - } - ), - - CommandDescriptor( - names: ["prompt-agent"], - helpLines: ["prompt-agent [--workspace <id|ref>] [--surface <id|ref>] [--timeout <seconds>] [--working-grace <seconds>] <text>"], - detailedUsage: """ - Usage: programa prompt-agent [flags] [--] <text> - - Submit a prompt to an agent surface and wait for it to finish, in one - request -- built on surface.wait's agent_state condition (#166). Sends - <text> (+ Enter) the same way `send` does, waits up to --working-grace for - the agent to report it started working (capped by the remaining --timeout), - then waits up to the remaining --timeout for it to report idle again. - - If the agent never reports "working" before that capped grace expires -- - including when the overall deadline arrives first -- the call resolves - immediately (working_observed: false in JSON output), rather than returning - a timeout error. If the surface never reported any agent_state at all, JSON - output carries a `warning` noting hooks may not be installed. - - Flags: - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) - --timeout <seconds> Overall budget for the agent to finish (default: 120) - --working-grace <seconds> How long to wait for a "working" report before - giving up on observing it, capped by the remaining - --timeout budget (default: 3) - - Example: - programa prompt-agent "review this diff for bugs" - programa prompt-agent --surface surface:2 --timeout 300 "run the test suite" - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace", "surface", "timeout", "working-grace"], minPositionals: 1, maxPositionals: nil), - execute: { ctx in - let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") - let (sfArg, rem1) = self.parseOption(rem0, name: "--surface") - let (timeoutArg, rem2) = self.parseOption(rem1, name: "--timeout") - let (graceArg, rem3) = self.parseOption(rem2, name: "--working-grace") - let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - let rawText = rem3.dropFirst(rem3.first == "--" ? 1 : 0).joined(separator: " ") - guard !rawText.isEmpty else { throw CLIError(message: "prompt-agent requires text") } - - var params: [String: Any] = ["text": rawText] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let sfId = try self.resolveCommandSurface( - explicitSurface: sfArg, explicitWorkspace: wsArg, - windowId: ctx.windowId, workspaceHandle: wsId, client: ctx.client - ) - if let sfId { params["surface_id"] = sfId } - - var timeoutSeconds = 120.0 - if let timeoutArg { - guard let parsed = Double(timeoutArg), parsed.isFinite, parsed > 0 else { - throw CLIError(message: "prompt-agent: --timeout must be a positive number of seconds") - } - timeoutSeconds = parsed - params["timeout_ms"] = Int((parsed * 1000).rounded()) - } - if let graceArg { - guard let parsedGrace = Double(graceArg), parsedGrace.isFinite, parsedGrace > 0 else { - throw CLIError(message: "prompt-agent: --working-grace must be a positive number of seconds") - } - params["working_grace_ms"] = Int((parsedGrace * 1000).rounded()) - } - - // Mirrors wait-surface: the server may legitimately hold this connection - // open for close to the full --timeout. - let payload = try ctx.client.sendV2( - method: V2MethodNames.agentPrompt, - params: params, - minimumReceiveTimeout: timeoutSeconds + 5.0 - ) - if ctx.jsonOutput { - print(self.jsonString(payload)) - } else if let warning = payload["warning"] as? String { - print(warning) - } else { - print((payload["final_state"] as? String) ?? "ok") - } - } - ), - - CommandDescriptor( - names: ["watch-events"], - helpLines: ["watch-events [--agent-state] [--workspace-lifecycle] [--needs-input] [--output <surface_id[,surface_id...]>] [--no-reconnect]"], - argumentContract: .watchEvents, - detailedUsage: """ - Usage: programa watch-events [flags] - - Subscribe to a long-lived stream of pushed events (#167) -- for consumers - that need many events over time (dashboards, orchestrators, a menu bar on - another machine). Casual "wait for one thing" callers should use - `wait-surface`/`prompt-agent` instead; this command holds the connection - open indefinitely and prints one JSON event per line until interrupted - (Ctrl-C) or the process gives up reconnecting. - - Flags (at least one of --agent-state/--workspace-lifecycle/--needs-input/--output required): - --agent-state Agent working/blocked/idle transitions - --workspace-lifecycle Workspace created/closed/renamed - --needs-input Agent prompts waiting for the user, with the - prompt text (needs_input events) - --output <surface_id[,surface_id...]> - Coalesced output for specific surfaces - (not available for "all surfaces" -- see docs) - --no-reconnect Exit on disconnect instead of the default - auto-reconnect (for scripts that want to - observe and handle drops themselves) - - By default, if the connection drops (app restart, network hiccup, or the - 1-hour idle read timeout) the command prints a notice to stderr and - automatically reconnects and resubscribes with capped exponential backoff, - rather than exiting. Pass --no-reconnect to restore the old exit-on-drop - behavior. Stdout only ever contains event lines (or JSON, in --json mode); - reconnect notices always go to stderr. - - If the per-connection event queue overflows (a slow/disconnected reader), - a synthetic {"event":"dropped","count":N} line is printed -- re-sync with - `list-workspaces`/`surface list` after seeing one. - - Example: - programa watch-events --agent-state - programa watch-events --output surface:2,surface:3 - programa watch-events --agent-state --no-reconnect - programa watch-events --needs-input - """, - execute: { ctx in - let agentState = self.hasFlag(ctx.commandArgs, name: "--agent-state") - let workspaceLifecycle = self.hasFlag(ctx.commandArgs, name: "--workspace-lifecycle") - let needsInput = self.hasFlag(ctx.commandArgs, name: "--needs-input") - let noReconnect = self.hasFlag(ctx.commandArgs, name: "--no-reconnect") - let (outputArg, _) = self.parseOption(ctx.commandArgs, name: "--output") - - var classes: [String] = [] - if agentState { classes.append("agent_state") } - if workspaceLifecycle { classes.append("workspace_lifecycle") } - if needsInput { classes.append("needs_input") } - - var params: [String: Any] = [:] - if let outputArg { - classes.append("output") - let rawIds = outputArg.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } - var resolvedIds: [String] = [] - for rawId in rawIds where !rawId.isEmpty { - guard let resolved = try self.normalizeSurfaceHandle(rawId, client: ctx.client) else { - throw CLIError(message: "watch-events: could not resolve surface id: \(rawId)") - } - resolvedIds.append(resolved) - } - guard !resolvedIds.isEmpty else { - throw CLIError(message: "watch-events: --output requires at least one surface id") - } - params["surface_ids"] = resolvedIds - } - params["classes"] = classes - - // Sends the subscribe request and reads its ack. Shared by the initial - // subscribe and every reconnect below, so a resubscribe after a dropped - // connection goes through the exact same path as first connect: the ack is - // parsed and checked for `ok: true` before we consider the (re)subscribe - // successful, and the raw ack line is never written to stdout -- stdout is - // events-only, both to preserve that contract on first connect and to avoid - // a stray non-event JSON object landing mid-stream on a later resubscribe. - func subscribeAndAck() throws { - try ctx.client.sendV2RequestOnly(method: V2MethodNames.subscribe, params: params) - let ackLine = try ctx.client.readEventLine(timeout: 10) - if ackLine.hasPrefix("ERROR:") { - throw CLIError(message: ackLine) - } - guard let ackData = ackLine.data(using: .utf8), - let ack = try? JSONSerialization.jsonObject(with: ackData) as? [String: Any] else { - throw CLIError(message: "Invalid subscribe ack: \(ackLine)") - } - guard (ack["ok"] as? Bool) == true else { - if let error = ack["error"] as? [String: Any] { - let code = (error["code"] as? String) ?? "error" - let message = (error["message"] as? String) ?? "Subscribe rejected" - throw CLIError(message: "\(code): \(message)") - } - throw CLIError(message: "Subscribe rejected: \(ackLine)") - } - FileHandle.standardError.write("Subscribed: \(classes.joined(separator: ", "))\n".data(using: .utf8)!) - } - - // Reconnect failures that won't resolve themselves on retry: wrong - // password / unconfigured password (auth.login's auth_failed/ - // auth_required/auth_unconfigured), the programa-ancestry "unsafe socket" - // rejection (raw "ERROR: Access denied ..." preamble), and an explicit - // subscribe-ack rejection (invalid_params -- retrying with the same - // params will never succeed). Everything else (ECONNREFUSED/ENOENT while - // the app restarts, EOF, read timeouts) is transient and keeps retrying - // indefinitely as designed. - func isPermanentReconnectFailure(_ error: Error) -> Bool { - let description = String(describing: error) - if description.hasPrefix("ERROR: Access denied") { return true } - let permanentCodes = ["auth_failed:", "auth_required:", "auth_unconfigured:", "invalid_params:"] - return permanentCodes.contains { description.hasPrefix($0) } - } - - func printEvent(_ line: String) { - if ctx.jsonOutput { - print(line) - return - } - guard let data = line.data(using: .utf8), - let frame = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - print(line) - return - } - let event = (frame["event"] as? String) ?? "event" - let rest = frame.filter { $0.key != "event" } - .sorted { $0.key < $1.key } - .map { "\($0.key)=\($0.value)" } - .joined(separator: " ") - print("[\(event)] \(rest)") - } - - var connectedAt = Date() - try subscribeAndAck() - - // Long-lived: reads and prints one pushed event frame per line until the - // connection closes or the process is interrupted (Ctrl-C). Each frame is - // already a complete JSON object (see SocketEventBroadcaster.encodeFrame), - // so JSON mode just echoes the raw line. - // - // Any disconnect (EOF, a transport error, or the 3600s idle read timeout - // firing after an hour of silence) lands here. By default we don't let any - // of those kill the process: print one stderr notice, reconnect+resubscribe - // with capped exponential backoff, and keep streaming. --no-reconnect - // exits on the first drop instead, for scripts that handle drops themselves. - while true { - do { - let line = try ctx.client.readEventLine(timeout: 3600) - guard !line.isEmpty else { continue } - printEvent(line) - } catch { - if noReconnect { throw error } - - let elapsedSeconds = Int(Date().timeIntervalSince(connectedAt)) - let reason = String(describing: error) - FileHandle.standardError.write( - "programa: event stream disconnected (\(reason)) after \(elapsedSeconds)s; reconnecting…\n".data(using: .utf8)! - ) - - ctx.client.close() - var backoffSeconds = 0.5 - var consecutivePermanentFailures = 0 - while true { - do { - try ctx.client.connectWithTransientRetry() - try self.authenticateClientIfNeeded( - ctx.client, - explicitPassword: ctx.socketPasswordArg, - socketPath: ctx.client.socketPath - ) - connectedAt = Date() - try subscribeAndAck() - break - } catch { - ctx.client.close() - let permanent = isPermanentReconnectFailure(error) - consecutivePermanentFailures = permanent ? consecutivePermanentFailures + 1 : 0 - FileHandle.standardError.write( - "programa: reconnect attempt failed (\(error))\(permanent ? " [not retryable]" : "")\n".data(using: .utf8)! - ) - if permanent && consecutivePermanentFailures >= 3 { - throw error - } - Thread.sleep(forTimeInterval: backoffSeconds + Double.random(in: 0...0.1)) - backoffSeconds = min(backoffSeconds * 2, 5.0) - } - } - FileHandle.standardError.write("programa: event stream reconnected\n".data(using: .utf8)!) - } - } - } - ), - - CommandDescriptor( - names: ["send"], - helpLines: ["send [--workspace <id|ref>] [--surface <id|ref>] <text>"], - detailedUsage: """ - Usage: programa send [flags] [--] <text> - - Send text to a terminal surface. Escape sequences: \\n and \\r send Enter, \\t sends Tab. - - Flags: - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) - - Example: - programa send "echo hello" - programa send --surface surface:2 "ls -la\\n" - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace", "surface"], minPositionals: 1, maxPositionals: nil), - execute: { ctx in - let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") - let (sfArg, rem1) = self.parseOption(rem0, name: "--surface") - let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - let rawText = rem1.dropFirst(rem1.first == "--" ? 1 : 0).joined(separator: " ") - guard !rawText.isEmpty else { throw CLIError(message: "send requires text") } - let text = self.unescapeSendText(rawText) - var params: [String: Any] = ["text": text] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let sfId = try self.resolveCommandSurface( - explicitSurface: sfArg, explicitWorkspace: wsArg, - windowId: ctx.windowId, workspaceHandle: wsId, client: ctx.client - ) - if let sfId { params["surface_id"] = sfId } - let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceSendText, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) - } - ), - - CommandDescriptor( - names: ["send-key"], - helpLines: ["send-key [--workspace <id|ref>] [--surface <id|ref>] <key>"], - detailedUsage: """ - Usage: programa send-key [flags] [--] <key> - - Send a key event to a terminal surface. - - Flags: - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) - - Example: - programa send-key enter - programa send-key --surface surface:2 ctrl+c - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace", "surface"], minPositionals: 1, maxPositionals: 1), - execute: { ctx in - let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") - let (sfArg, rem1) = self.parseOption(rem0, name: "--surface") - let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - let keyArgs = rem1.first == "--" ? Array(rem1.dropFirst()) : rem1 - guard let key = keyArgs.first else { throw CLIError(message: "send-key requires a key") } - var params: [String: Any] = ["key": key] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let sfId = try self.resolveCommandSurface( - explicitSurface: sfArg, explicitWorkspace: wsArg, - windowId: ctx.windowId, workspaceHandle: wsId, client: ctx.client - ) - if let sfId { params["surface_id"] = sfId } - let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceSendKey, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) - } - ), - - CommandDescriptor( - names: ["send-panel"], - helpLines: ["send-panel --panel <id|ref> [--workspace <id|ref>] <text>"], - detailedUsage: """ - Usage: programa send-panel --panel <id|ref> [flags] [--] <text> - - Send text to a specific panel (surface). Escape sequences: \\n and \\r send Enter, \\t sends Tab. - - Flags: - --panel <id|ref> Target panel (required) - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa send-panel --panel surface:2 "echo hello\\n" - """, - grammar: CLIArgumentGrammar(valueOptions: ["panel", "workspace"], requiredOptions: ["panel"], minPositionals: 1, maxPositionals: nil), - execute: { ctx in - let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") - let (panelArg, rem1) = self.parseOption(rem0, name: "--panel") - let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - guard let panelArg else { - throw CLIError(message: "send-panel requires --panel") - } - let rawText = rem1.dropFirst(rem1.first == "--" ? 1 : 0).joined(separator: " ") - guard !rawText.isEmpty else { throw CLIError(message: "send-panel requires text") } - let text = self.unescapeSendText(rawText) - var params: [String: Any] = ["text": text] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let sfId = try self.normalizeSurfaceHandle(panelArg, client: ctx.client, workspaceHandle: wsId) - if let sfId { params["surface_id"] = sfId } - let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceSendText, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) - } - ), - - CommandDescriptor( - names: ["send-key-panel"], - helpLines: ["send-key-panel --panel <id|ref> [--workspace <id|ref>] <key>"], - detailedUsage: """ - Usage: programa send-key-panel --panel <id|ref> [flags] [--] <key> - - Send a key event to a specific panel (surface). - - Flags: - --panel <id|ref> Target panel (required) - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa send-key-panel --panel surface:2 enter - programa send-key-panel --panel surface:2 ctrl+c - """, - grammar: CLIArgumentGrammar(valueOptions: ["panel", "workspace"], requiredOptions: ["panel"], minPositionals: 1, maxPositionals: 1), - execute: { ctx in - let (wsArg, rem0) = self.parseOption(ctx.commandArgs, name: "--workspace") - let (panelArg, rem1) = self.parseOption(rem0, name: "--panel") - let workspaceArg = wsArg ?? (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - guard let panelArg else { - throw CLIError(message: "send-key-panel requires --panel") - } - let skpArgs = rem1.first == "--" ? Array(rem1.dropFirst()) : rem1 - let key = skpArgs.first ?? "" - guard !key.isEmpty else { throw CLIError(message: "send-key-panel requires a key") } - var params: [String: Any] = ["key": key] - let wsId = try self.normalizeWorkspaceHandle(workspaceArg, client: ctx.client) - if let wsId { params["workspace_id"] = wsId } - let sfId = try self.normalizeSurfaceHandle(panelArg, client: ctx.client, workspaceHandle: wsId) - if let sfId { params["surface_id"] = sfId } - let payload = try ctx.client.sendV2(method: V2MethodNames.surfaceSendKey, params: params) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: self.v2OKSummary(payload, idFormat: ctx.idFormat)) - } - ), - - CommandDescriptor( - names: ["notify"], - helpLines: ["notify --title <text> [--subtitle <text>] [--body <text>] [--workspace <id|ref>] [--surface <id|ref>]"], - detailedUsage: """ - Usage: programa notify [flags] - - Send a notification to a workspace/surface. - - Flags: - --title <text> Notification title (default: "Notification") - --subtitle <text> Notification subtitle - --body <text> Notification body - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) - - Example: - programa notify --title "Build done" --body "All tests passed" - programa notify --title "Error" --subtitle "test.swift" --body "Line 42: syntax error" - """, - grammar: CLIArgumentGrammar(valueOptions: ["title", "subtitle", "body", "workspace", "surface"]), - execute: { ctx in - let title = self.optionValue(ctx.commandArgs, name: "--title") ?? "Notification" - let subtitle = self.optionValue(ctx.commandArgs, name: "--subtitle") ?? "" - let body = self.optionValue(ctx.commandArgs, name: "--body") ?? "" - - let explicitWorkspaceArg = self.optionValue(ctx.commandArgs, name: "--workspace") - let preferTTYFallback = ctx.windowId == nil && ProcessInfo.processInfo.environment["TMUX"] != nil - let callerWorkspaceArg = preferTTYFallback - ? nil - : (ctx.windowId == nil ? ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] : nil) - let workspaceArg = explicitWorkspaceArg ?? callerWorkspaceArg - let explicitSurfaceArg = self.optionValue(ctx.commandArgs, name: "--surface") - let envWorkspaceArg = ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] - let callerSurfaceArg = explicitSurfaceArg == nil - && preferTTYFallback == false - && ctx.windowId == nil - && (explicitWorkspaceArg == nil || explicitWorkspaceArg == envWorkspaceArg) - ? ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] - : nil - let surfaceArg = explicitSurfaceArg ?? callerSurfaceArg - - let targetWorkspace = try { - if explicitWorkspaceArg != nil { - return try self.resolveWorkspaceId(workspaceArg, client: ctx.client) - } - return try self.resolveWorkspaceIdAllowingFallback(workspaceArg, client: ctx.client) - }() - let targetSurface = try { - if explicitSurfaceArg != nil { - return try self.resolveSurfaceId(surfaceArg, workspaceId: targetWorkspace, client: ctx.client) - } - return try self.resolveSurfaceIdAllowingFallback( - surfaceArg, - workspaceId: targetWorkspace, - client: ctx.client - ) - }() - - let payload = try ctx.client.sendV2(method: V2MethodNames.notificationCreateForTarget, params: [ - "workspace_id": targetWorkspace, - "surface_id": targetSurface, - "title": title, - "subtitle": subtitle, - "body": body, - ]) - self.printV2Payload(payload, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: "OK") - } - ), - - CommandDescriptor( - names: ["list-notifications"], - helpLines: ["list-notifications"], - detailedUsage: """ - Usage: programa list-notifications - - List queued notifications. - """, - grammar: CLIArgumentGrammar(), - execute: { ctx in - let listed = try ctx.client.sendV2(method: V2MethodNames.notificationList) - let notifications = listed["notifications"] as? [[String: Any]] ?? [] - if ctx.jsonOutput { - let payload = notifications.enumerated().map { _, item -> [String: Any] in - var dict: [String: Any] = [ - "id": (item["id"] as? String) ?? "", - "workspace_id": (item["workspace_id"] as? String) ?? "", - "is_read": (item["is_read"] as? Bool) ?? false, - "title": (item["title"] as? String) ?? "", - "subtitle": (item["subtitle"] as? String) ?? "", - "body": (item["body"] as? String) ?? "", - ] - dict["surface_id"] = item["surface_id"] as? String ?? NSNull() - return dict - } - print(self.jsonString(["notifications": payload])) - } else if notifications.isEmpty { - print("No notifications") - } else { - let lines = notifications.enumerated().map { index, item -> String in - let surfaceText = (item["surface_id"] as? String) ?? "none" - let readText = ((item["is_read"] as? Bool) ?? false) ? "read" : "unread" - let id = (item["id"] as? String) ?? "" - let workspaceId = (item["workspace_id"] as? String) ?? "" - let title = (item["title"] as? String) ?? "" - let subtitle = (item["subtitle"] as? String) ?? "" - let body = (item["body"] as? String) ?? "" - return "\(index):\(id)|\(workspaceId)|\(surfaceText)|\(readText)|\(title)|\(subtitle)|\(body)" - } - print(lines.joined(separator: "\n")) - } - } - ), - - CommandDescriptor( - names: ["clear-notifications"], - helpLines: ["clear-notifications"], - detailedUsage: """ - Usage: programa clear-notifications - - Clear all queued notifications. - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), - execute: { ctx in - let response: [String: Any] - if let wsFlag = self.optionValue(ctx.commandArgs, name: "--workspace") { - let wsId = try self.resolveWorkspaceId(wsFlag, client: ctx.client) - response = try ctx.client.sendV2(method: V2MethodNames.notificationClear, params: ["workspace_id": wsId]) - } else if ctx.windowId == nil, - let envWs = ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"], - let wsId = try? self.resolveWorkspaceId(envWs, client: ctx.client) { - response = try ctx.client.sendV2(method: V2MethodNames.notificationClear, params: ["workspace_id": wsId]) - } else { - response = try ctx.client.sendV2(method: V2MethodNames.notificationClear) - } - self.printV2Payload(response.merging(["ok": true]) { $1 }, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat, fallbackText: "OK") - } - ), - - CommandDescriptor( - names: ["set-status"], - helpLines: ["set-status <key> <value> [--icon <name>] [--color <#hex>] [--url <url>] [--priority <n>] [--format <plain|markdown>] [--workspace <id|ref>]"], - detailedUsage: """ - Usage: programa set-status <key> <value> [flags] - - Set a sidebar status entry for a workspace. Status entries appear as - pills in the sidebar tab row. Use a unique key so different tools - (e.g. "claude_code", "build") can manage their own entries. - - Flags: - --icon <name> Icon name (e.g. "sparkle", "hammer") - --color <#hex> Pill color (e.g. "#ff9500") - --url <url> Link attached to the pill (alias: --link) - --priority <n> Sort order, higher first (-9999 to 9999) - --format <plain|markdown> How the value is rendered (default: plain) - --pid <pid> Process behind this key (its listening ports are tracked) - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa set-status build "compiling" --icon hammer --color "#ff9500" - programa set-status deploy "v1.2.3" --workspace workspace:2 - """, - execute: { ctx in - let parsed = self.parseFlagArgs(ctx.commandArgs, stopAtDashDash: false) - guard parsed.positional.count >= 2 else { - throw CLIError(message: "ERROR: Missing status key or value — usage: programa set-status <key> <value> [--icon <name>] [--color <#hex>] [--url <url>] [--priority <n>] [--format plain|markdown] [--pid <pid>] [--workspace <id|ref>]") - } - var params: [String: Any] = [ - "key": parsed.positional[0], - "value": parsed.positional[1...].joined(separator: " "), - ] - if let icon = self.normalizedFlagValue(parsed.options["icon"]) { params["icon"] = icon } - if let color = self.normalizedFlagValue(parsed.options["color"]) { params["color"] = color } - if let url = self.normalizedFlagValue(parsed.options["url"] ?? parsed.options["link"]) { params["url"] = url } - if let priorityRaw = self.normalizedFlagValue(parsed.options["priority"]) { - guard let priority = Int(priorityRaw) else { - throw CLIError(message: "ERROR: Invalid metadata priority '\(priorityRaw)' — must be an integer") - } - params["priority"] = max(-9999, min(9999, priority)) - } - if let formatRaw = self.normalizedFlagValue(parsed.options["format"]) { - guard ["plain", "markdown", "md"].contains(formatRaw.lowercased()) else { - throw CLIError(message: "ERROR: Invalid metadata format '\(formatRaw)' — use: plain, markdown") - } - params["format"] = formatRaw.lowercased() == "md" ? "markdown" : formatRaw.lowercased() - } - if let pidRaw = self.normalizedFlagValue(parsed.options["pid"]), let pid = Int(pidRaw), pid > 0 { - params["pid"] = pid - } - params["workspace_id"] = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) - _ = try ctx.client.sendV2(method: V2MethodNames.workspaceSetStatus, params: params) - print("OK") - } - ), - - CommandDescriptor( - names: ["clear-status"], - helpLines: ["clear-status <key> [--workspace <id|ref>]"], - detailedUsage: """ - Usage: programa clear-status <key> [flags] - - Remove a sidebar status entry by key. - - Flags: - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa clear-status build - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"], minPositionals: 1, maxPositionals: 1), - execute: { ctx in - let parsed = self.parseFlagArgs(ctx.commandArgs) - guard let key = parsed.positional.first, parsed.positional.count == 1 else { - throw CLIError(message: "ERROR: Missing metadata key — usage: programa clear-status <key> [--workspace <id|ref>]") - } - let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) - _ = try ctx.client.sendV2(method: V2MethodNames.workspaceClearStatus, params: ["workspace_id": workspaceId, "key": key]) - print("OK") - } - ), - - CommandDescriptor( - names: ["list-status"], - helpLines: ["list-status [--workspace <id|ref>]"], - detailedUsage: """ - Usage: programa list-status [flags] - - List all sidebar status entries for a workspace. - - Flags: - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa list-status - programa list-status --workspace workspace:2 - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), - execute: { ctx in - let parsed = self.parseFlagArgs(ctx.commandArgs) - let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) - let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceListStatus, params: ["workspace_id": workspaceId]) - let entries = payload["entries"] as? [[String: Any]] ?? [] - if entries.isEmpty { - print("No status entries") - } else { - print(entries.map(self.sidebarMetadataLineText).joined(separator: "\n")) - } - } - ), - - CommandDescriptor( - names: ["set-progress"], - helpLines: ["set-progress <0.0-1.0> [--label <text>] [--workspace <id|ref>]"], - argumentContract: .setProgress, - detailedUsage: """ - Usage: programa set-progress <0.0-1.0> [flags] - - Set a progress bar in the sidebar for a workspace. - - Flags: - --label <text> Label shown next to the progress bar - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa set-progress 0.5 --label "Building..." - programa set-progress 1.0 --label "Done" - """, - execute: { ctx in - let parsed = self.parseFlagArgs(ctx.commandArgs) - guard let first = parsed.positional.first else { - throw CLIError(message: "ERROR: Missing progress value — usage: programa set-progress <0.0-1.0> [--label <text>] [--workspace <id|ref>]") - } - guard let value = Double(first), value.isFinite else { - throw CLIError(message: "ERROR: Invalid progress value '\(first)' — must be 0.0 to 1.0") - } - var params: [String: Any] = ["value": min(1.0, max(0.0, value))] - if let label = self.normalizedFlagValue(parsed.options["label"]) { params["label"] = label } - params["workspace_id"] = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) - _ = try ctx.client.sendV2(method: V2MethodNames.workspaceSetProgress, params: params) - print("OK") - } - ), - - CommandDescriptor( - names: ["clear-progress"], - helpLines: ["clear-progress [--workspace <id|ref>]"], - detailedUsage: """ - Usage: programa clear-progress [flags] - - Clear the sidebar progress bar for a workspace. - - Flags: - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa clear-progress - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), - execute: { ctx in - let parsed = self.parseFlagArgs(ctx.commandArgs) - let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) - _ = try ctx.client.sendV2(method: V2MethodNames.workspaceClearProgress, params: ["workspace_id": workspaceId]) - print("OK") - } - ), - - CommandDescriptor( - names: ["log"], - helpLines: ["log [--level <level>] [--source <name>] [--workspace <id|ref>] [--] <message>"], - detailedUsage: """ - Usage: programa log [flags] [--] <message> - - Append a log entry to the sidebar for a workspace. - - Flags: - --level <level> Log level: info, progress, success, warning, error (default: info) - --source <name> Source label (e.g. "build", "test") - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa log "Build started" - programa log --level error --source build "Compilation failed" - programa log --level success -- "All 42 tests passed" - """, - execute: { ctx in - let parsed = self.parseFlagArgs(ctx.commandArgs) - guard !parsed.positional.isEmpty else { - throw CLIError(message: "ERROR: Missing message — usage: programa log [--level <level>] [--source <name>] [--workspace <id|ref>] [--] <message>") - } - let levelStr = parsed.options["level"] ?? "info" - guard ["info", "progress", "success", "warning", "error"].contains(levelStr) else { - throw CLIError(message: "ERROR: Unknown log level '\(levelStr)' — use: info, progress, success, warning, error") - } - var params: [String: Any] = [ - "message": parsed.positional.joined(separator: " "), - "level": levelStr, - ] - if let source = self.normalizedFlagValue(parsed.options["source"]) { params["source"] = source } - params["workspace_id"] = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) - _ = try ctx.client.sendV2(method: V2MethodNames.workspaceLog, params: params) - print("OK") - } - ), - - CommandDescriptor( - names: ["clear-log"], - helpLines: ["clear-log [--workspace <id|ref>]"], - detailedUsage: """ - Usage: programa clear-log [flags] - - Clear all sidebar log entries for a workspace. - - Flags: - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa clear-log - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), - execute: { ctx in - let parsed = self.parseFlagArgs(ctx.commandArgs) - let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) - _ = try ctx.client.sendV2(method: V2MethodNames.workspaceClearLog, params: ["workspace_id": workspaceId]) - print("OK") - } - ), - - CommandDescriptor( - names: ["list-log"], - helpLines: ["list-log [--limit <n>] [--workspace <id|ref>]"], - argumentContract: .listLog, - detailedUsage: """ - Usage: programa list-log [flags] - - List sidebar log entries for a workspace. - - Flags: - --limit <n> Show only the last N entries - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa list-log - programa list-log --limit 5 - """, - execute: { ctx in - let parsed = self.parseFlagArgs(ctx.commandArgs) - var params: [String: Any] = [:] - if let limitStr = parsed.options["limit"] { - guard !limitStr.isEmpty else { - throw CLIError(message: "ERROR: Missing limit value — usage: programa list-log [--limit <n>] [--workspace <id|ref>]") - } - guard let limit = Int(limitStr), limit >= 0 else { - throw CLIError(message: "ERROR: Invalid limit '\(limitStr)' — must be >= 0") - } - params["limit"] = limit - } - params["workspace_id"] = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) - let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceListLog, params: params) - let entries = payload["entries"] as? [[String: Any]] ?? [] - if entries.isEmpty { - print("No log entries") - } else { - print(entries.map(self.sidebarLogLineText).joined(separator: "\n")) - } - } - ), - - CommandDescriptor( - names: ["sidebar-state"], - helpLines: ["sidebar-state [--workspace <id|ref>]"], - detailedUsage: """ - Usage: programa sidebar-state [flags] - - Dump all sidebar metadata for a workspace (cwd, git branch, ports, - status entries, progress, log entries). - - Flags: - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - - Example: - programa sidebar-state - programa sidebar-state --workspace workspace:2 - """, - grammar: CLIArgumentGrammar(valueOptions: ["workspace"]), - execute: { ctx in - let parsed = self.parseFlagArgs(ctx.commandArgs) - let workspaceId = try self.resolveSidebarWorkspaceId(options: parsed.options, windowOverride: ctx.windowId, client: ctx.client) - let payload = try ctx.client.sendV2(method: V2MethodNames.workspaceSidebarState, params: ["workspace_id": workspaceId]) - print(self.sidebarStateText(payload)) - } - ), - - ] + descriptors += paneSurfaceDescriptors() + descriptors += surfaceDiagnosticsDescriptors() + descriptors += panelDescriptors() + descriptors += workspaceSelectionDescriptors() + descriptors += terminalIODescriptors() + descriptors += notificationDescriptors() + descriptors += sidebarMetadataDescriptors() descriptors += self.hooksDescriptors() - descriptors += [ - - CommandDescriptor( - names: ["set-app-focus"], - helpLines: ["set-app-focus <active|inactive|clear>"], - detailedUsage: """ - Usage: programa set-app-focus <active|inactive|clear> - - Override app focus state for notification routing tests. - - Example: - programa set-app-focus inactive - programa set-app-focus clear - """, - execute: { ctx in - guard let value = ctx.commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") } - let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - let state: String - switch normalized { - case "active", "1", "true": state = "active" - case "inactive", "0", "false": state = "inactive" - case "clear", "none", "": state = "clear" - default: - throw CLIError(message: "ERROR: Expected active, inactive, or clear") - } - _ = try ctx.client.sendV2(method: V2MethodNames.appFocusOverrideSet, params: ["state": state]) - print("OK") - } - ), - - CommandDescriptor( - names: ["simulate-app-active"], - helpLines: ["simulate-app-active"], - detailedUsage: """ - Usage: programa simulate-app-active - - Trigger the app-active handler used by notification focus tests. - """, - grammar: CLIArgumentGrammar(), - execute: { ctx in - _ = try ctx.client.sendV2(method: V2MethodNames.appSimulateActive) - print("OK") - } - ), - - CommandDescriptor( - names: ["__tmux-compat"], - helpLines: [], - helpPolicy: .passthrough, - execute: { ctx in - try self.runClaudeTeamsTmuxCompat( - commandArgs: ctx.commandArgs, - client: ctx.client, - jsonOutput: ctx.jsonOutput, - idFormat: ctx.idFormat, - windowOverride: ctx.windowId - ) - } - ), - - // MARK: - tmux compatibility commands (all share one bespoke - // handler; help text preserves the original grouped layout, - // including the two pipe-separated combo lines). - CommandDescriptor(names: [], helpLines: ["", "# tmux compatibility commands"], execute: nil), - ] + descriptors += internalCommandDescriptors() + // tmux compatibility commands all share one bespoke handler; help text + // preserves the original grouped layout, including the two + // pipe-separated combo lines. + descriptors.append(CommandDescriptor(names: [], helpLines: ["", "# tmux compatibility commands"], execute: nil)) descriptors += Self.tmuxCompatDescriptors(runTmuxCompatCommand: { ctx in try self.runTmuxCompatCommand( command: ctx.command, @@ -3003,50 +396,10 @@ struct ProgramaCLI { windowOverride: ctx.windowId ) }) - descriptors += [ - CommandDescriptor(names: [], helpLines: [""], execute: nil), - - CommandDescriptor( - names: ["markdown"], - helpLines: ["markdown [open] <path> (open markdown file in formatted viewer panel with live reload)"], - execute: { ctx in - try self.runMarkdownCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) - } - ), - - CommandDescriptor( - names: ["review"], - helpLines: ["review open|refresh|comment|send (agent diff review panel: worktree/branch diff, line comments)"], - execute: { ctx in - try self.runReviewCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) - } - ), - - CommandDescriptor( - names: ["recap"], - helpLines: ["recap open <slug>|list (open/list saved recaps in .programa/recaps/)"], - execute: { ctx in - try self.runRecapCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) - } - ), - - CommandDescriptor(names: [], helpLines: [""], execute: nil), - - CommandDescriptor( - names: ["help"], - helpLines: ["help"], - connectionPolicy: .local, - detailedUsage: """ - Usage: programa help - - Show top-level CLI usage and command list. - """, - grammar: CLIArgumentGrammar(), - execute: { ctx in - print(self.usage()) - } - ), - ] + descriptors.append(CommandDescriptor(names: [], helpLines: [""], execute: nil)) + descriptors += viewerPanelDescriptors() + descriptors.append(CommandDescriptor(names: [], helpLines: [""], execute: nil)) + descriptors += helpCommandDescriptors() return descriptors } @@ -3546,7 +899,7 @@ struct ProgramaCLI { formatHandle(id: payload["\(kind)_id"] as? String, ref: payload["\(kind)_ref"] as? String, idFormat: idFormat) } - private func formatHandle(id: String?, ref: String?, idFormat: CLIIDFormat) -> String? { + func formatHandle(id: String?, ref: String?, idFormat: CLIIDFormat) -> String? { switch idFormat { case .refs: return ref ?? id @@ -3650,7 +1003,7 @@ struct ProgramaCLI { return String(format: "%.3fs", seconds) } - private func formatDebugTerminalsPayload(_ payload: [String: Any], idFormat: CLIIDFormat) -> String { + func formatDebugTerminalsPayload(_ payload: [String: Any], idFormat: CLIIDFormat) -> String { let terminals = payload["terminals"] as? [[String: Any]] ?? [] guard !terminals.isEmpty else { return "No terminal surfaces" } @@ -3739,7 +1092,7 @@ struct ProgramaCLI { .joined(separator: "\n") } - private func runMoveSurface( + func runMoveSurface( commandArgs: [String], client: SocketClient, jsonOutput: Bool, @@ -3789,7 +1142,7 @@ struct ProgramaCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary) } - private func runReorderSurface( + func runReorderSurface( commandArgs: [String], client: SocketClient, jsonOutput: Bool, @@ -3825,7 +1178,7 @@ struct ProgramaCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary) } - private func runReorderWorkspace( + func runReorderWorkspace( commandArgs: [String], client: SocketClient, jsonOutput: Bool, @@ -3864,7 +1217,7 @@ struct ProgramaCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summary) } - private func runWorkspaceAction( + func runWorkspaceAction( commandArgs: [String], client: SocketClient, jsonOutput: Bool, @@ -3954,7 +1307,7 @@ struct ProgramaCLI { // MARK: - Worktree commands (docs/plans/worktree-and-layouts.md) - private func runWorktreeCommand( + func runWorktreeCommand( commandArgs: [String], client: SocketClient, jsonOutput: Bool, @@ -4282,7 +1635,7 @@ struct ProgramaCLI { "'" + raw.replacingOccurrences(of: "'", with: "'\\''") + "'" } - private func runRaceCommand( + func runRaceCommand( commandArgs: [String], client: SocketClient, jsonOutput: Bool, @@ -4383,7 +1736,7 @@ struct ProgramaCLI { // MARK: - Layout commands (docs/plans/worktree-and-layouts.md) - private func runLayoutCommand( + func runLayoutCommand( commandArgs: [String], client: SocketClient, jsonOutput: Bool, @@ -4496,7 +1849,7 @@ struct ProgramaCLI { // MARK: - Snapshot history commands (docs/plans/snapshot-restore.md) - private func runSnapshotCommand( + func runSnapshotCommand( commandArgs: [String], client: SocketClient, jsonOutput: Bool, @@ -4586,7 +1939,7 @@ struct ProgramaCLI { ) } - private func runTabAction( + func runTabAction( commandArgs: [String], client: SocketClient, jsonOutput: Bool, @@ -4667,7 +2020,7 @@ struct ProgramaCLI { printV2Payload(payload, jsonOutput: jsonOutput, idFormat: idFormat, fallbackText: summaryParts.joined(separator: " ")) } - private func runRenameTab( + func runRenameTab( commandArgs: [String], client: SocketClient, jsonOutput: Bool, @@ -4795,7 +2148,7 @@ struct ProgramaCLI { /// Unescape CLI escape sequences for send behavior. /// \n and \r → carriage return (Enter), \t → tab. - private func unescapeSendText(_ text: String) -> String { + func unescapeSendText(_ text: String) -> String { return text .replacingOccurrences(of: "\\n", with: "\r") .replacingOccurrences(of: "\\r", with: "\r") @@ -4809,9 +2162,9 @@ struct ProgramaCLI { return ProcessInfo.processInfo.environment["PROGRAMA_WORKSPACE_ID"] } - /// Exhaustive grammar table for commands that use the shared registry - /// contract. Keeping this switch total makes a newly registered command - /// fail closed before socket acquisition until its grammar is declared. + /// Validates a `.registered` command from its descriptor grammar. A command + /// with neither a grammar nor a switch arm fails closed before socket + /// acquisition until its grammar is declared. private func validateRegisteredArguments(_ args: [String], for command: String) throws { func parse( values: Set<String> = [], @@ -4850,327 +2203,26 @@ struct ProgramaCLI { let parsed = try parse( values: grammar.valueOptions, booleans: grammar.booleanOptions, + shortFlags: grammar.shortFlags, minPositionals: grammar.minPositionals, maxPositionals: grammar.maxPositionals, repeatableValues: grammar.repeatableValueOptions ) try require(grammar.requiredOptions, in: parsed.options) + try grammar.validate?(command, parsed) return } + // Descriptors declared outside CLI/Commands/ (tree, agent hooks, tmux + // compatibility) still carry their semantic checks here. switch command { - case "rpc": - let parsed = try parse(minPositionals: 1, maxPositionals: 2) - _ = try parseRPCParams(Array(parsed.positional.dropFirst())) - - case "focus-window", "close-window": - let parsed = try parse(values: ["window"]) - try require(["window"], in: parsed.options) - guard let rawWindow = parsed.options["window"]?.trimmingCharacters(in: .whitespacesAndNewlines), - isUUID(rawWindow) || isHandleRef(rawWindow) else { - throw CLIError(message: "\(command): invalid window id") - } - - case "reorder-workspace": - let parsed = try parse(values: ["workspace", "index", "before", "before-workspace", "after", "after-workspace", "window"], maxPositionals: 1) - guard (parsed.options["workspace"] != nil) != (parsed.positional.count == 1) else { - throw CLIError(message: "reorder-workspace requires --workspace <id|ref> or one positional <id|ref>") - } - let anchors = ["index", "before", "before-workspace", "after", "after-workspace"].filter { parsed.options[$0] != nil } - guard anchors.count == 1 else { - throw CLIError(message: "reorder-workspace requires exactly one of --index, --before, or --after") - } - if let raw = parsed.options["index"], (Int(raw) ?? -1) < 0 { - throw CLIError(message: "reorder-workspace: --index must be a nonnegative integer") - } - - case "workspace-action": - let parsed = try parse(values: ["action", "workspace", "title", "color", "description"], maxPositionals: nil) - guard let rawAction = parsed.options["action"] ?? parsed.positional.first else { - throw CLIError(message: "workspace-action requires --action <name>") - } - let trailing = parsed.options["action"] == nil ? Array(parsed.positional.dropFirst()) : parsed.positional - let action = rawAction.lowercased().replacingOccurrences(of: "-", with: "_") - if action == "rename", parsed.options["title"] == nil, trailing.isEmpty { - throw CLIError(message: "workspace-action rename requires a title") - } - if action == "set_color", parsed.options["color"] == nil, trailing.isEmpty { - throw CLIError(message: "workspace-action set-color requires a color") - } - if action == "set_description", parsed.options["description"] == nil, trailing.isEmpty { - throw CLIError(message: "workspace-action set-description requires a description") - } - - case "new-split": - let parsed = try parse(values: ["workspace", "surface", "panel"], minPositionals: 1, maxPositionals: 1) - guard ["left", "right", "up", "down"].contains(parsed.positional[0].lowercased()) else { - throw CLIError(message: "new-split: direction must be left, right, up, or down") - } - - case "focus-pane": - let parsed = try parse(values: ["workspace", "pane"], maxPositionals: 1) - guard parsed.options["pane"] != nil || parsed.positional.count == 1 else { - throw CLIError(message: "focus-pane requires --pane <id|ref>") - } - guard !(parsed.options["pane"] != nil && !parsed.positional.isEmpty) else { - throw CLIError(message: "focus-pane: provide the pane once") - } - - case "new-pane": - let parsed = try parse(values: ["type", "direction", "workspace"]) - if let type = parsed.options["type"], type.lowercased() != "terminal" { - throw CLIError(message: "new-pane: --type must be terminal") - } - if let direction = parsed.options["direction"], !["left", "right", "up", "down"].contains(direction.lowercased()) { - throw CLIError(message: "new-pane: invalid direction") - } - - case "new-surface": - let parsed = try parse(values: ["type", "pane", "workspace"]) - if let type = parsed.options["type"], type.lowercased() != "terminal" { - throw CLIError(message: "new-surface: --type must be terminal") - } - - case "move-surface": - let parsed = try parse(values: ["surface", "pane", "workspace", "window", "before", "before-surface", "after", "after-surface", "index", "focus"], maxPositionals: 1) - guard parsed.options["surface"] != nil || parsed.positional.count == 1 else { - throw CLIError(message: "move-surface requires --surface <id|ref>") - } - guard !(parsed.options["surface"] != nil && !parsed.positional.isEmpty) else { - throw CLIError(message: "move-surface: provide the surface once") - } - if let raw = parsed.options["index"], (Int(raw) ?? -1) < 0 { - throw CLIError(message: "move-surface: --index must be a nonnegative integer") - } - if let raw = parsed.options["focus"], !["true", "false", "1", "0", "yes", "no", "on", "off"].contains(raw.lowercased()) { - throw CLIError(message: "move-surface: --focus must be true or false") - } - let anchors = ["index", "before", "before-surface", "after", "after-surface"].filter { parsed.options[$0] != nil } - guard anchors.count <= 1 else { - throw CLIError(message: "move-surface accepts only one of --index, --before, or --after") - } - - case "reorder-surface": - let parsed = try parse(values: ["surface", "workspace", "index", "before", "before-surface", "after", "after-surface"], maxPositionals: 1) - guard parsed.options["surface"] != nil || parsed.positional.count == 1 else { - throw CLIError(message: "reorder-surface requires --surface <id|ref>") - } - guard !(parsed.options["surface"] != nil && !parsed.positional.isEmpty) else { - throw CLIError(message: "reorder-surface: provide the surface once") - } - let anchors = ["index", "before", "before-surface", "after", "after-surface"].filter { parsed.options[$0] != nil } - guard anchors.count == 1 else { - throw CLIError(message: "reorder-surface requires exactly one of --index, --before, or --after") - } - if let raw = parsed.options["index"], (Int(raw) ?? -1) < 0 { - throw CLIError(message: "reorder-surface: --index must be a nonnegative integer") - } - - case "tab-action": - let parsed = try parse(values: ["action", "tab", "surface", "workspace", "title"], maxPositionals: nil) - guard parsed.options["action"] != nil || !parsed.positional.isEmpty else { - throw CLIError(message: "tab-action requires --action <name>") - } - - case "rename-tab": - let parsed = try parse(values: ["workspace", "tab", "surface", "title"], maxPositionals: nil) - guard parsed.options["title"] != nil || !parsed.positional.isEmpty else { - throw CLIError(message: "rename-tab requires a title") - } - - case "drag-surface-to-split": - let parsed = try parse(values: ["surface", "panel"], minPositionals: 1, maxPositionals: 1) - guard (parsed.options["surface"] != nil) != (parsed.options["panel"] != nil) else { - throw CLIError(message: "drag-surface-to-split requires exactly one of --surface or --panel") - } - guard ["left", "right", "up", "down"].contains(parsed.positional[0].lowercased()) else { - throw CLIError(message: "drag-surface-to-split: invalid direction") - } - - case "close-workspace", "select-workspace": - let parsed = try parse(values: ["workspace"]) - if parsed.options["workspace"] == nil { - throw CLIError( - message: "\(command): --workspace <id|ref> is required (UUID or short ref like workspace:2). Refusing to target the current workspace implicitly." - ) - } - - case "set-status": - let parsed = try parse( - values: ["icon", "color", "url", "link", "priority", "format", "pid", "workspace"], - minPositionals: 2, - maxPositionals: nil - ) - if let priority = parsed.options["priority"], Int(priority) == nil { - throw CLIError(message: "set-status: --priority must be an integer") - } - if let format = parsed.options["format"], !["plain", "markdown", "md"].contains(format.lowercased()) { - throw CLIError(message: "set-status: --format must be plain or markdown") - } - if let pid = parsed.options["pid"], (Int(pid) ?? 0) <= 0 { - throw CLIError(message: "set-status: --pid must be a positive integer") - } - - case "log": - let parsed = try parse(values: ["level", "source", "workspace"], minPositionals: 1, maxPositionals: nil) - if let level = parsed.options["level"], !["info", "progress", "success", "warning", "error"].contains(level) { - throw CLIError(message: "log: invalid --level value") - } - - case "set-app-focus": - let parsed = try parse(minPositionals: 1, maxPositionals: 1) - guard ["active", "inactive", "clear", "1", "0", "true", "false", "none"].contains(parsed.positional[0].lowercased()) else { - throw CLIError(message: "set-app-focus: invalid state") - } - case "tree": _ = try parse(values: ["workspace"], booleans: ["all"]) - - case "markdown": - let parsed = try parse( - values: ["workspace", "window", "surface", "direction"], - minPositionals: 1, - maxPositionals: 2 - ) - if parsed.positional.count == 2, parsed.positional[0].lowercased() != "open" { - throw CLIError(message: "markdown: unexpected subcommand \(parsed.positional[0])") - } - - case "review": - let parsed = try parse( - values: ["workspace", "window", "surface", "direction", "mode", "base-branch", "preamble"], - minPositionals: 1, - maxPositionals: nil - ) - let subcommand = parsed.positional[0].lowercased() - guard ["open", "refresh", "comment", "send"].contains(subcommand) else { - throw CLIError(message: "review: unknown subcommand \(parsed.positional[0])") - } - if subcommand == "comment" { - guard parsed.positional.count >= 2 else { - throw CLIError(message: "review comment: missing subcommand (add|remove|list)") - } - guard ["add", "remove", "list"].contains(parsed.positional[1].lowercased()) else { - throw CLIError(message: "review comment: unknown subcommand \(parsed.positional[1])") - } - } - - case "recap": - let parsed = try parse( - values: ["workspace", "window"], - minPositionals: 1, - maxPositionals: 2 - ) - let subcommand = parsed.positional[0].lowercased() - guard ["open", "list"].contains(subcommand) else { - throw CLIError(message: "recap: unknown subcommand \(parsed.positional[0])") - } - if subcommand == "open" { - guard parsed.positional.count == 2 else { - throw CLIError(message: "recap open requires <slug>") - } - } else if subcommand == "list", parsed.positional.count != 1 { - throw CLIError(message: "recap list: unexpected argument \(parsed.positional[1])") - } - - case "snapshot": - let parsed = try parse(booleans: ["json"], minPositionals: 1, maxPositionals: 2) - switch parsed.positional[0].lowercased() { - case "list": - guard parsed.positional.count == 1 else { - throw CLIError(message: "snapshot list does not take a target") - } - case "restore": - guard parsed.options["json"] == nil else { - throw CLIError(message: "snapshot restore: unknown flag '--json'") - } - default: - throw CLIError(message: "snapshot: unknown subcommand \(parsed.positional[0])") - } - - case "worktree": - let parsed = try parse( - values: ["repo", "base", "path", "layout", "from-issue", "agent"], - booleans: ["focus", "force", "json", "all"], - minPositionals: 1, - maxPositionals: nil - ) - guard ["create", "open", "remove", "list"].contains(parsed.positional[0].lowercased()) else { - throw CLIError(message: "worktree: unknown subcommand \(parsed.positional[0])") - } - if parsed.options["from-issue"] != nil || parsed.options["agent"] != nil { - guard parsed.positional[0].lowercased() == "create" else { - throw CLIError(message: "worktree: --from-issue and --agent are only valid for create") - } - let issueNumber = try parsed.options["from-issue"].map(parseWorktreeIssueNumber) - if let agent = parsed.options["agent"] { - _ = try validateWorktreeIssueAgent(agent, issueNumber: issueNumber) - } - } - if parsed.options["all"] != nil { - guard parsed.positional[0].lowercased() == "open" else { - throw CLIError(message: "worktree: --all is only valid for open") - } - guard parsed.options["focus"] == nil else { - throw CLIError(message: "worktree open: --focus cannot be combined with --all") - } - guard parsed.positional.count == 1 else { - throw CLIError(message: "worktree open --all does not take a <path-or-branch> argument") - } - if let unsupported = parsed.options.keys.first(where: { !["all", "repo"].contains($0) }) { - throw CLIError(message: "worktree open --all: unexpected option --\(unsupported)") - } - } - - case "agent-detection": - let parsed = try parse( - values: ["surface", "workspace"], - booleans: ["force", "json"], - minPositionals: 1, - maxPositionals: 2 - ) - let subcommand = parsed.positional[0].lowercased() - guard ["list", "scaffold", "test"].contains(subcommand) else { - throw CLIError(message: "agent-detection: unknown subcommand \(parsed.positional[0])") - } - if subcommand == "scaffold", parsed.positional.count != 2 { - throw CLIError(message: "agent-detection scaffold requires <agent-id>") - } - - case "race": - let parsed = try parse( - values: ["n", "agent", "base", "prefix", "layout"], - minPositionals: 1, - maxPositionals: nil - ) - if let rawN = parsed.options["n"] { - guard let n = Int(rawN), n >= 1, n <= 8 else { - throw CLIError(message: "race: --n must be between 1 and 8") - } - } - if let rawAgent = parsed.options["agent"] { - guard ["claude", "opencode", "codex"].contains(rawAgent.lowercased()) else { - throw CLIError(message: "race: --agent must be one of claude, opencode, codex") - } - } - - case "layout": - let parsed = try parse( - values: ["workspace", "cwd"], - booleans: ["force", "json"], - minPositionals: 1, - maxPositionals: nil - ) - guard ["save", "apply", "list"].contains(parsed.positional[0].lowercased()) else { - throw CLIError(message: "layout: unknown subcommand \(parsed.positional[0])") - } - case "claude-hook": let parsed = try parse(values: ["workspace", "surface"], maxPositionals: 1) if let subcommand = parsed.positional.first?.lowercased(), !["session-start", "active", "stop", "idle", "prompt-submit", "notification", "notify", "session-end", "pre-tool-use", "subagent-start", "subagent-stop", "help", "--help", "-h"].contains(subcommand) { throw CLIError(message: "claude-hook: unknown event \(subcommand)") } - case "codex-hook": guard ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] != nil else { return } let parsed = try parse(values: ["workspace", "surface"], maxPositionals: 1) @@ -5178,7 +2230,6 @@ struct ProgramaCLI { !["session-start", "prompt-submit", "stop", "notification", "notify", "session-end", "help", "--help", "-h"].contains(subcommand) { throw CLIError(message: "codex-hook: unknown event \(subcommand)") } - case "opencode-hook": guard ProcessInfo.processInfo.environment["PROGRAMA_SURFACE_ID"] != nil else { return } let parsed = try parse(values: ["workspace", "surface", "cwd", "session"], maxPositionals: 1) @@ -5186,7 +2237,6 @@ struct ProgramaCLI { !["session-start", "prompt-submit", "stop", "notification", "notify", "session-end", "help", "--help", "-h"].contains(subcommand) { throw CLIError(message: "opencode-hook: unknown event \(subcommand)") } - case "capture-pane": let parsed = try parse(values: ["workspace", "surface", "lines"], booleans: ["scrollback"]) if let lines = parsed.options["lines"], (Int(lines) ?? 0) <= 0 { @@ -5225,29 +2275,6 @@ struct ProgramaCLI { guard parsed.positional.filter({ $0 != "-p" }).isEmpty == false else { throw CLIError(message: "display-message requires text") } - - // These commands own nested or foreign grammars. Their handlers do - // full parsing; flags must remain byte-for-byte passthrough here. - case "__tmux-compat": - return - - // Local commands are registered for unified lookup/help, but do not - // acquire the app socket through the generic dispatcher. - case "shortcuts", "feedback", "themes", "claude-teams": - return - case "codex", "claude", "opencode": - // `-y` is a single-dash token, so it arrives as a positional after the subcommand. - let parsed = try parse(booleans: ["yes", "y"], shortFlags: ["-y"], minPositionals: 1, maxPositionals: 2) - guard parsed.positional.count == 1 || parsed.positional[1] == "-y" else { - throw CLIError(message: "\(command): unexpected arguments: \(parsed.positional[1])") - } - case "aside": - _ = try parse(booleans: ["yes", "y", "with-devtools"], minPositionals: 1, maxPositionals: 1) - // Commands with richer bespoke contracts are validated by their - // dedicated cases in `validateArguments`. - case "ping", "focus-panel", "read-screen", "wait-surface", "set-progress", "list-log", "watch-events": - return - default: throw CLIError(message: "Internal CLI registry error: no argument contract for \(command)") } @@ -5262,6 +2289,9 @@ struct ProgramaCLI { case .registered: try validateRegisteredArguments(args, for: command) + case .handlerParsed: + return + case .noArguments: guard args.isEmpty else { throw CLIError(message: "\(command): unexpected arguments: \(args.joined(separator: " "))") @@ -5372,7 +2402,7 @@ struct ProgramaCLI { } } - private func normalizedFlagValue(_ value: String?) -> String? { + func normalizedFlagValue(_ value: String?) -> String? { guard let value else { return nil } let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed @@ -5382,7 +2412,7 @@ struct ProgramaCLI { /// flag, else `$PROGRAMA_WORKSPACE_ID` (unless a `--window` override is active), else the /// server's currently-selected workspace (mirrors v1's `resolveTabForReport`/ /// `parseSidebarMutationTabTarget` fallback to `tabManager.selectedTabId`). - private func resolveSidebarWorkspaceId( + func resolveSidebarWorkspaceId( options: [String: String], windowOverride: String?, client: SocketClient @@ -5393,7 +2423,7 @@ struct ProgramaCLI { /// Rebuilds v1's `key=value icon=X color=Y url=Z priority=N format=X` status-entry line /// from a `workspace.set_status`/`list_status`/`sidebar_state` v2 entry dict. - private func sidebarMetadataLineText(_ entry: [String: Any]) -> String { + func sidebarMetadataLineText(_ entry: [String: Any]) -> String { let key = (entry["key"] as? String) ?? "" let value = (entry["value"] as? String) ?? "" var line = "\(key)=\(value)" @@ -5417,7 +2447,7 @@ struct ProgramaCLI { /// Rebuilds v1's `[level] message` (optionally `[source] [level] message`) log line from /// a `workspace.list_log` v2 entry dict. - private func sidebarLogLineText(_ entry: [String: Any]) -> String { + func sidebarLogLineText(_ entry: [String: Any]) -> String { let level = (entry["level"] as? String) ?? "info" let message = (entry["message"] as? String) ?? "" var line = "[\(level)] \(message)" @@ -5429,7 +2459,7 @@ struct ProgramaCLI { /// Rebuilds v1's `sidebar_state` multi-line text dump from a `workspace.sidebar_state` /// v2 payload. Field-for-field mirror of `TerminalController.sidebarState(_:)`. - private func sidebarStateText(_ payload: [String: Any]) -> String { + func sidebarStateText(_ payload: [String: Any]) -> String { var lines: [String] = [] lines.append("tab=\((payload["workspace_id"] as? String) ?? "")") lines.append("color=\((payload["color"] as? String) ?? "none")") @@ -5536,7 +2566,7 @@ struct ProgramaCLI { return output } - private func parseRPCParams(_ args: [String]) throws -> [String: Any] { + func parseRPCParams(_ args: [String]) throws -> [String: Any] { guard !args.isEmpty else { return [:] } let raw = args.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) guard !raw.isEmpty else { return [:] } diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 5da6fb8a6..49408240e 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -291,6 +291,16 @@ B900003DA1B2C3D4E5F60719 /* CLI+AgentWrappers.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900003CA1B2C3D4E5F60719 /* CLI+AgentWrappers.swift */; }; E9C500000000000000000001 /* CLI+WorktreeIssue.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9C500000000000000000002 /* CLI+WorktreeIssue.swift */; }; B900003FA1B2C3D4E5F60719 /* CLI+Hooks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B900003EA1B2C3D4E5F60719 /* CLI+Hooks.swift */; }; + CC0F00000000000000000003 /* LocalCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0F00000000000000000004 /* LocalCommands.swift */; }; + CC0F00000000000000000005 /* SystemCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0F00000000000000000006 /* SystemCommands.swift */; }; + CC0F00000000000000000007 /* WindowCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0F00000000000000000008 /* WindowCommands.swift */; }; + CC0F00000000000000000009 /* WorkspaceCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0F0000000000000000000A /* WorkspaceCommands.swift */; }; + CC0F0000000000000000000B /* WorktreeCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0F0000000000000000000C /* WorktreeCommands.swift */; }; + CC0F0000000000000000000D /* PaneCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0F0000000000000000000E /* PaneCommands.swift */; }; + CC0F0000000000000000000F /* TerminalIOCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0F00000000000000000010 /* TerminalIOCommands.swift */; }; + CC0F00000000000000000011 /* NotificationCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0F00000000000000000012 /* NotificationCommands.swift */; }; + CC0F00000000000000000013 /* SidebarCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0F00000000000000000014 /* SidebarCommands.swift */; }; + CC0F00000000000000000001 /* CLI+CodexConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0F00000000000000000002 /* CLI+CodexConfig.swift */; }; C7A3000005 /* CLI+AgentEventAdapters.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A3000006 /* CLI+AgentEventAdapters.swift */; }; 7501C0000000000000000001 /* CLI+ProgramStatusEmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7501C0000000000000000002 /* CLI+ProgramStatusEmitter.swift */; }; H1HC0002 /* CLI+HookCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = H1HC0001 /* CLI+HookCommands.swift */; }; @@ -741,6 +751,16 @@ B900003CA1B2C3D4E5F60719 /* CLI+AgentWrappers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+AgentWrappers.swift"; sourceTree = "<group>"; }; E9C500000000000000000002 /* CLI+WorktreeIssue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+WorktreeIssue.swift"; sourceTree = "<group>"; }; B900003EA1B2C3D4E5F60719 /* CLI+Hooks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+Hooks.swift"; sourceTree = "<group>"; }; + CC0F00000000000000000004 /* LocalCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Commands/LocalCommands.swift"; sourceTree = "<group>"; }; + CC0F00000000000000000006 /* SystemCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Commands/SystemCommands.swift"; sourceTree = "<group>"; }; + CC0F00000000000000000008 /* WindowCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Commands/WindowCommands.swift"; sourceTree = "<group>"; }; + CC0F0000000000000000000A /* WorkspaceCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Commands/WorkspaceCommands.swift"; sourceTree = "<group>"; }; + CC0F0000000000000000000C /* WorktreeCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Commands/WorktreeCommands.swift"; sourceTree = "<group>"; }; + CC0F0000000000000000000E /* PaneCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Commands/PaneCommands.swift"; sourceTree = "<group>"; }; + CC0F00000000000000000010 /* TerminalIOCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Commands/TerminalIOCommands.swift"; sourceTree = "<group>"; }; + CC0F00000000000000000012 /* NotificationCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Commands/NotificationCommands.swift"; sourceTree = "<group>"; }; + CC0F00000000000000000014 /* SidebarCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Commands/SidebarCommands.swift"; sourceTree = "<group>"; }; + CC0F00000000000000000002 /* CLI+CodexConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+CodexConfig.swift"; sourceTree = "<group>"; }; C7A3000006 /* CLI+AgentEventAdapters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+AgentEventAdapters.swift"; sourceTree = "<group>"; }; 7501C0000000000000000002 /* CLI+ProgramStatusEmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+ProgramStatusEmitter.swift"; sourceTree = "<group>"; }; H1HC0001 /* CLI+HookCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CLI+HookCommands.swift"; sourceTree = "<group>"; }; @@ -1198,6 +1218,16 @@ B900003CA1B2C3D4E5F60719 /* CLI+AgentWrappers.swift */, E9C500000000000000000002 /* CLI+WorktreeIssue.swift */, B900003EA1B2C3D4E5F60719 /* CLI+Hooks.swift */, + CC0F00000000000000000004 /* LocalCommands.swift */, + CC0F00000000000000000006 /* SystemCommands.swift */, + CC0F00000000000000000008 /* WindowCommands.swift */, + CC0F0000000000000000000A /* WorkspaceCommands.swift */, + CC0F0000000000000000000C /* WorktreeCommands.swift */, + CC0F0000000000000000000E /* PaneCommands.swift */, + CC0F00000000000000000010 /* TerminalIOCommands.swift */, + CC0F00000000000000000012 /* NotificationCommands.swift */, + CC0F00000000000000000014 /* SidebarCommands.swift */, + CC0F00000000000000000002 /* CLI+CodexConfig.swift */, C7A3000006 /* CLI+AgentEventAdapters.swift */, 7501C0000000000000000002 /* CLI+ProgramStatusEmitter.swift */, H1HC0001 /* CLI+HookCommands.swift */, @@ -1896,6 +1926,16 @@ B900003DA1B2C3D4E5F60719 /* CLI+AgentWrappers.swift in Sources */, E9C500000000000000000001 /* CLI+WorktreeIssue.swift in Sources */, B900003FA1B2C3D4E5F60719 /* CLI+Hooks.swift in Sources */, + CC0F00000000000000000003 /* LocalCommands.swift in Sources */, + CC0F00000000000000000005 /* SystemCommands.swift in Sources */, + CC0F00000000000000000007 /* WindowCommands.swift in Sources */, + CC0F00000000000000000009 /* WorkspaceCommands.swift in Sources */, + CC0F0000000000000000000B /* WorktreeCommands.swift in Sources */, + CC0F0000000000000000000D /* PaneCommands.swift in Sources */, + CC0F0000000000000000000F /* TerminalIOCommands.swift in Sources */, + CC0F00000000000000000011 /* NotificationCommands.swift in Sources */, + CC0F00000000000000000013 /* SidebarCommands.swift in Sources */, + CC0F00000000000000000001 /* CLI+CodexConfig.swift in Sources */, C7A3000005 /* CLI+AgentEventAdapters.swift in Sources */, 7501C0000000000000000001 /* CLI+ProgramStatusEmitter.swift in Sources */, H1HC0002 /* CLI+HookCommands.swift in Sources */, diff --git a/scripts/check-structural-budgets.py b/scripts/check-structural-budgets.py index cda04950e..5e9e20c87 100755 --- a/scripts/check-structural-budgets.py +++ b/scripts/check-structural-budgets.py @@ -33,9 +33,9 @@ class FamilyBudget: FileBudget("Sources/AppLifecycleCoordinator.swift", 0, 114, "AppLifecycleCoordinator"), FileBudget("Sources/ContentView.swift", 5_949, 1_300, "root window view"), FileBudget("Sources/CommandPaletteController.swift", 71, 283, "CommandPaletteController"), - FileBudget("CLI/programa.swift", 7_886, 6_864, "ProgramaCLI entrypoint"), + FileBudget("CLI/programa.swift", 7_886, 3_200, "ProgramaCLI entrypoint"), FileBudget("CLI/CLICommandDispatcher.swift", 0, 255, "CLICommandDispatcher"), - FileBudget("CLI/CLI+Hooks.swift", 4_381, 3610, "hook provider primitives"), + FileBudget("CLI/CLI+Hooks.swift", 4_381, 2_600, "hook provider primitives"), FileBudget("CLI/HookInstallationCoordinator.swift", 0, 168, "HookInstallationCoordinator"), FileBudget("Sources/TerminalController.swift", 3_149, 3_014, "terminal RPC adapter"), ) @@ -62,7 +62,7 @@ class FamilyBudget: "CLI/HookInstallationCoordinator.swift", ), 12_267, - 11_093, + 6_200, "CLICommandDispatcher + HookInstallationCoordinator", ), )