From adb1ff8b2d3c3a04f807fc2e5268b2d0447813d5 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:11:04 -0300 Subject: [PATCH 1/4] refactor: collapse duplicated handle normalizers and option scanners normalizeWindowHandle/normalizeWorkspaceHandle/normalizePaneHandle/ normalizeSurfaceHandle shared one algorithm (trim -> UUID/ref passthrough -> index lookup via .list) copy-pasted 4x with only the RPC method name, list key, filter param key, and current/focused fallback varying. Collapsed into a single private normalizeHandle(kind:filterParam:fallback:) with the four public wrappers now thin call sites. parseOption/parseRepeatedOption were the same terminator-aware scan loop duplicated twice, differing only in whether the last or all matched values were kept. Collapsed into a shared scanOption(_:name:) returning all matches; parseOption now takes .last, parseRepeatedOption returns the full list. Behavior-identical: same RPC methods, same list/filter keys, same error message text, same terminator (--) handling. Refs #93. --- CLI/programa.swift | 164 +++++++++++++++++---------------------------- 1 file changed, 63 insertions(+), 101 deletions(-) diff --git a/CLI/programa.swift b/CLI/programa.swift index 8fe2698fc47..58048ad4b76 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -3364,11 +3364,19 @@ struct CMUXCLI { return Int(String(pieces[1])) != nil } - func normalizeWindowHandle(_ raw: String?, client: SocketClient, allowCurrent: Bool = false) throws -> String? { + /// Generic handle normalizer shared by window/workspace/pane/surface lookups. + /// Resolves a raw CLI argument (UUID, handle ref, or list index) to a canonical + /// handle ref/id, optionally scoped to a parent handle and falling back to a + /// caller-supplied "current"/"focused" resolver when `raw` is nil. + private func normalizeHandle( + _ raw: String?, + client: SocketClient, + kind: String, + filterParam: (key: String, value: String)? = nil, + fallback: () throws -> String? + ) throws -> String? { guard let raw else { - if !allowCurrent { return nil } - let current = try client.sendV2(method: "window.current") - return (current["window_ref"] as? String) ?? (current["window_id"] as? String) + return try fallback() } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) @@ -3377,15 +3385,27 @@ struct CMUXCLI { return trimmed } guard let wantedIndex = Int(trimmed) else { - throw CLIError(message: "Invalid window handle: \(trimmed) (expected UUID, ref like window:1, or index)") + throw CLIError(message: "Invalid \(kind) handle: \(trimmed) (expected UUID, ref like \(kind):1, or index)") } - let listed = try client.sendV2(method: "window.list") - let windows = listed["windows"] as? [[String: Any]] ?? [] - for item in windows where intFromAny(item["index"]) == wantedIndex { + var params: [String: Any] = [:] + if let filterParam { + params[filterParam.key] = filterParam.value + } + let listed = try client.sendV2(method: "\(kind).list", params: params) + let items = listed["\(kind)s"] as? [[String: Any]] ?? [] + for item in items where intFromAny(item["index"]) == wantedIndex { return (item["ref"] as? String) ?? (item["id"] as? String) } - throw CLIError(message: "Window index not found") + throw CLIError(message: "\(kind.capitalized) index not found") + } + + func normalizeWindowHandle(_ raw: String?, client: SocketClient, allowCurrent: Bool = false) throws -> String? { + try normalizeHandle(raw, client: client, kind: "window") { + guard allowCurrent else { return nil } + let current = try client.sendV2(method: "window.current") + return (current["window_ref"] as? String) ?? (current["window_id"] as? String) + } } func normalizeWorkspaceHandle( @@ -3394,31 +3414,16 @@ struct CMUXCLI { windowHandle: String? = nil, allowCurrent: Bool = false ) throws -> String? { - guard let raw else { - if !allowCurrent { return nil } + try normalizeHandle( + raw, + client: client, + kind: "workspace", + filterParam: windowHandle.map { ("window_id", $0) } + ) { + guard allowCurrent else { return nil } let current = try client.sendV2(method: "workspace.current") return (current["workspace_ref"] as? String) ?? (current["workspace_id"] as? String) } - - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - if isUUID(trimmed) || isHandleRef(trimmed) { - return trimmed - } - guard let wantedIndex = Int(trimmed) else { - throw CLIError(message: "Invalid workspace handle: \(trimmed) (expected UUID, ref like workspace:1, or index)") - } - - var params: [String: Any] = [:] - if let windowHandle { - params["window_id"] = windowHandle - } - let listed = try client.sendV2(method: "workspace.list", params: params) - let items = listed["workspaces"] as? [[String: Any]] ?? [] - for item in items where intFromAny(item["index"]) == wantedIndex { - return (item["ref"] as? String) ?? (item["id"] as? String) - } - throw CLIError(message: "Workspace index not found") } func normalizePaneHandle( @@ -3427,32 +3432,17 @@ struct CMUXCLI { workspaceHandle: String? = nil, allowFocused: Bool = false ) throws -> String? { - guard let raw else { - if !allowFocused { return nil } + try normalizeHandle( + raw, + client: client, + kind: "pane", + filterParam: workspaceHandle.map { ("workspace_id", $0) } + ) { + guard allowFocused else { return nil } let ident = try client.sendV2(method: "system.identify") let focused = ident["focused"] as? [String: Any] ?? [:] return (focused["pane_ref"] as? String) ?? (focused["pane_id"] as? String) } - - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - if isUUID(trimmed) || isHandleRef(trimmed) { - return trimmed - } - guard let wantedIndex = Int(trimmed) else { - throw CLIError(message: "Invalid pane handle: \(trimmed) (expected UUID, ref like pane:1, or index)") - } - - var params: [String: Any] = [:] - if let workspaceHandle { - params["workspace_id"] = workspaceHandle - } - let listed = try client.sendV2(method: "pane.list", params: params) - let items = listed["panes"] as? [[String: Any]] ?? [] - for item in items where intFromAny(item["index"]) == wantedIndex { - return (item["ref"] as? String) ?? (item["id"] as? String) - } - throw CLIError(message: "Pane index not found") } func normalizeSurfaceHandle( @@ -3461,32 +3451,17 @@ struct CMUXCLI { workspaceHandle: String? = nil, allowFocused: Bool = false ) throws -> String? { - guard let raw else { - if !allowFocused { return nil } + try normalizeHandle( + raw, + client: client, + kind: "surface", + filterParam: workspaceHandle.map { ("workspace_id", $0) } + ) { + guard allowFocused else { return nil } let ident = try client.sendV2(method: "system.identify") let focused = ident["focused"] as? [String: Any] ?? [:] return (focused["surface_ref"] as? String) ?? (focused["surface_id"] as? String) } - - let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.isEmpty { return nil } - if isUUID(trimmed) || isHandleRef(trimmed) { - return trimmed - } - guard let wantedIndex = Int(trimmed) else { - throw CLIError(message: "Invalid surface handle: \(trimmed) (expected UUID, ref like surface:1, or index)") - } - - var params: [String: Any] = [:] - if let workspaceHandle { - params["workspace_id"] = workspaceHandle - } - let listed = try client.sendV2(method: "surface.list", params: params) - let items = listed["surfaces"] as? [[String: Any]] ?? [] - for item in items where intFromAny(item["index"]) == wantedIndex { - return (item["ref"] as? String) ?? (item["id"] as? String) - } - throw CLIError(message: "Surface index not found") } private func canonicalSurfaceHandleFromTabInput(_ value: String) -> String { @@ -5526,32 +5501,10 @@ struct CMUXCLI { .replacingOccurrences(of: "\r", with: "\\r") return "\"\(escaped)\"" } - func parseOption(_ args: [String], name: String) -> (String?, [String]) { - var remaining: [String] = [] - var value: String? - var skipNext = false - var pastTerminator = false - for (idx, arg) in args.enumerated() { - if skipNext { - skipNext = false - continue - } - if arg == "--" { - pastTerminator = true - remaining.append(arg) - continue - } - if !pastTerminator, arg == name, idx + 1 < args.count { - value = args[idx + 1] - skipNext = true - continue - } - remaining.append(arg) - } - return (value, remaining) - } - - private func parseRepeatedOption(_ args: [String], name: String) -> ([String], [String]) { + /// Shared scan loop for `parseOption`/`parseRepeatedOption`: walks `args`, + /// collecting every value that follows `name` (honoring a `--` terminator) + /// and returning the leftover args with those option/value pairs removed. + private func scanOption(_ args: [String], name: String) -> ([String], [String]) { var remaining: [String] = [] var values: [String] = [] var skipNext = false @@ -5576,6 +5529,15 @@ struct CMUXCLI { return (values, remaining) } + func parseOption(_ args: [String], name: String) -> (String?, [String]) { + let (values, remaining) = scanOption(args, name: name) + return (values.last, remaining) + } + + private func parseRepeatedOption(_ args: [String], name: String) -> ([String], [String]) { + scanOption(args, name: name) + } + func optionValue(_ args: [String], name: String) -> String? { guard let index = args.firstIndex(of: name), index + 1 < args.count else { return nil } return args[index + 1] From 3df15ce40890042792fd01a7b75a2956d18fdaa5 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 9 Jul 2026 14:20:37 -0300 Subject: [PATCH 2/4] refactor: move per-domain subcommand help text and descriptors into domain files programa.swift's subcommandUsage() switch (~1300 lines) documented help text for every CLI domain in one place, even though the tmux-compat, SSH, tree, hooks, browser, markdown, themes, and agent-wrapper domains already have their own CLI+*.swift files housing the implementation. Split the domain-specific case blocks out into a SubcommandUsage helper per file (mirroring the existing tmuxCompatDescriptors pattern), and turned the central subcommandUsage() into a delegation chain that falls through to the remaining core (window/workspace/pane/surface/ notify/status/log) cases. Also moved the ssh/ssh-session-end/remote-daemon-status, tree, and claude-hook/codex-hook CommandDescriptor entries out of the central commandDescriptors() array into sshDescriptors()/treeDescriptors()/ hooksDescriptors() in their respective domain files, next to the implementations they call. The execute:nil pre-connection specials (themes, claude-teams, omo/omx/omc, codex) stay centralized per the existing comment ('documented here only so usage() has one source for help text'). commandDescriptors() itself had to be restructured from one large array concatenation expression into imperative descriptors += statements: the Swift type-checker timed out trying to infer the combined expression once a fourth concatenation was added. Behavior-identical: same command names, same help text, same dispatch. Refs #101. --- CLI/CLI+AgentWrappers.swift | 100 +++++++ CLI/CLI+Browser.swift | 90 ++++++ CLI/CLI+Hooks.swift | 81 +++++ CLI/CLI+Markdown.swift | 31 ++ CLI/CLI+SSH.swift | 63 ++++ CLI/CLI+Themes.swift | 39 +++ CLI/CLI+TmuxCompat.swift | 197 ++++++++++++ CLI/CLI+Tree.swift | 51 ++++ CLI/programa.swift | 580 ++---------------------------------- 9 files changed, 679 insertions(+), 553 deletions(-) diff --git a/CLI/CLI+AgentWrappers.swift b/CLI/CLI+AgentWrappers.swift index 86d2d417ae8..e4406a68601 100644 --- a/CLI/CLI+AgentWrappers.swift +++ b/CLI/CLI+AgentWrappers.swift @@ -890,4 +890,104 @@ extension CMUXCLI { ) ) } + + /// Subcommand help text for AgentWrappers commands, split out of the + /// central `subcommandUsage` switch (programa.swift) so each domain's + /// help text lives next to its command descriptors. Refs #101. + func agentWrapperSubcommandUsage(_ command: String) -> String? { + switch command { + case "claude-teams": + return String(localized: "cli.claude-teams.usage", defaultValue: """ + Usage: programa claude-teams [claude-args...] + + Launch Claude Code with agent teams enabled. + + This command: + - defaults Claude teammate mode to auto + - sets a tmux-like environment so Claude auto mode uses programa splits + - sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 + - prepends a private tmux shim to PATH + - forwards all remaining arguments to claude + + The tmux shim translates supported tmux window/pane commands into programa + workspace and split operations in the current programa session. + + Examples: + programa claude-teams + programa claude-teams --continue + programa claude-teams --model sonnet + """) + case "omo": + return String(localized: "cli.omo.usage", defaultValue: """ + Usage: programa omo [opencode-args...] + + Launch OpenCode with oh-my-openagent in a programa-aware environment. + + oh-my-openagent orchestrates multiple AI models as specialized agents in + parallel. This command sets up a tmux shim so agent panes become native + programa splits with sidebar metadata and notifications. + + This command: + - sets a tmux-like environment so oh-my-openagent uses programa splits + - prepends a private tmux shim to PATH + - forwards all remaining arguments to opencode + + The tmux shim translates tmux window/pane commands into programa workspace + and split operations in the current programa session. + + Examples: + programa omo + programa omo --continue + programa omo --model claude-sonnet-4-6 + """) + case "omx": + return String(localized: "cli.omx.usage", defaultValue: """ + Usage: programa omx [omx-args...] + + Launch Oh My Codex (OMX) with native programa pane integration. + + OMX is a multi-agent orchestration layer for OpenAI Codex CLI. This + command sets up a tmux shim so OMX team mode, HUD, and agent panes + become native programa splits. + + This command: + - sets a tmux-like environment so OMX uses programa splits + - prepends a private tmux shim to PATH + - forwards all remaining arguments to omx + + Install: npm install -g oh-my-codex + + Examples: + programa omx + programa omx --madmax --high + programa omx team + """) + case "omc": + return String(localized: "cli.omc.usage", defaultValue: """ + Usage: programa omc [omc-args...] + + Launch Oh My Claude Code (OMC) with native programa pane integration. + + OMC is a multi-agent orchestration system for Claude Code with + specialized agents, smart model routing, and team pipelines. This + command sets up a tmux shim so OMC team mode and agent panes become + native programa splits. + + This command: + - sets a tmux-like environment so OMC uses programa splits + - prepends a private tmux shim to PATH + - injects NODE_OPTIONS restore module for Claude compatibility + - forwards all remaining arguments to omc + + Install: npm install -g oh-my-claude-sisyphus + + Examples: + programa omc + programa omc team 3:claude "implement feature" + programa omc --watch + """) + default: + return nil + } + } } diff --git a/CLI/CLI+Browser.swift b/CLI/CLI+Browser.swift index 5f41411727c..b467213a0a0 100644 --- a/CLI/CLI+Browser.swift +++ b/CLI/CLI+Browser.swift @@ -1307,4 +1307,94 @@ extension CMUXCLI { throw CLIError(message: "Unsupported browser subcommand: \(subcommand)") } + + /// Subcommand help text for Browser commands, split out of the + /// central `subcommandUsage` switch (programa.swift) so each domain's + /// help text lives next to its command descriptors. Refs #101. + func browserSubcommandUsage(_ command: String) -> String? { + switch command { + case "browser": + return """ + Usage: programa browser [--surface | ] [args] + + Browser automation commands. Most subcommands require a surface handle. + A surface can be passed as `--surface ` or as the first positional token. + `open`/`open-split`/`new`/`identify` can run without an explicit surface. + + Subcommands: + open|open-split|new [url] [--workspace ] [--window ] + open/open-split/new default to $PROGRAMA_WORKSPACE_ID when --workspace is omitted and --window is not set + goto|navigate [--snapshot-after] + back|forward|reload [--snapshot-after] + url|get-url + focus-webview | is-webview-focused + snapshot [--interactive|-i] [--cursor] [--compact] [--max-depth ] [--selector ] + eval [--script | ] + wait [--selector ] [--text ] [--url-contains |--url ] [--load-state ] [--function ] [--timeout-ms |--timeout ] + click|dblclick|hover|focus|check|uncheck|scroll-into-view [--selector | ] [--snapshot-after] + type|fill [--selector | ] [--text | ] [--snapshot-after] + press|key|keydown|keyup [--key | ] [--snapshot-after] + select [--selector | ] [--value | ] [--snapshot-after] + scroll [--selector ] [--dx ] [--dy ] [--snapshot-after] + screenshot [--out ] + get [...] + text|html|value|count|box|styles|attr: [--selector | ] + attr: [--attr | ] + styles: [--property ] + is [--selector | ] + find [...] + role: [--name ] [--exact] + text|label|placeholder|alt|title|testid: [--exact] + first|last: [--selector | ] + nth: [--index | ] [--selector | ] + frame [--selector ] + dialog [text] + download [wait] [--path ] [--timeout-ms |--timeout ] + cookies [--name ] [--value ] [--url ] [--domain ] [--path ] [--expires ] [--secure] [--all] + storage [...] + tab > [...] + console + errors + highlight [--selector | ] + state + addinitscript|addscript [--script | ] + addstyle [--css | ] + viewport + geolocation|geo + offline + trace [path] + network ... + route [--abort] [--body ] + unroute + screencast + input [args...] + input_mouse | input_keyboard | input_touch + identify [--surface ] + + Example: + programa browser open https://example.com + programa browser surface:1 navigate https://google.com + programa browser --surface surface:1 snapshot --interactive + """ + // Legacy browser aliases — point users to `programa browser --help` + case "open-browser": + return "Legacy alias for 'programa browser open'. Run 'programa browser --help' for details." + case "navigate": + return "Legacy alias for 'programa browser navigate'. Run 'programa browser --help' for details." + case "browser-back": + return "Legacy alias for 'programa browser back'. Run 'programa browser --help' for details." + case "browser-forward": + return "Legacy alias for 'programa browser forward'. Run 'programa browser --help' for details." + case "browser-reload": + return "Legacy alias for 'programa browser reload'. Run 'programa browser --help' for details." + case "get-url": + return "Legacy alias for 'programa browser get-url'. Run 'programa browser --help' for details." + case "focus-webview": + return "Legacy alias for 'programa browser focus-webview'. Run 'programa browser --help' for details." + case "is-webview-focused": + return "Legacy alias for 'programa browser is-webview-focused'. Run 'programa browser --help' for details." + default: + return nil + } + } } diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index f813e572a14..af439a336b4 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -1657,4 +1657,85 @@ extension CMUXCLI { } return URL(fileURLWithPath: output).lastPathComponent.lowercased() } + + /// Subcommand help text for Hooks commands, split out of the + /// central `subcommandUsage` switch (programa.swift) so each domain's + /// help text lives next to its command descriptors. Refs #101. + func hooksSubcommandUsage(_ command: String) -> String? { + switch command { + case "claude-hook": + return """ + Usage: programa claude-hook [flags] + + Hook for Claude Code integration. Reads JSON from stdin. + + Subcommands: + session-start Signal that a Claude session has started + active Alias for session-start + stop Signal that a Claude session has stopped + idle Alias for stop + notification Forward a Claude notification + notify Alias for notification + prompt-submit Clear notification and set Running on user prompt + + Flags: + --workspace Target workspace (default: $PROGRAMA_WORKSPACE_ID) + --surface Target surface (default: $PROGRAMA_SURFACE_ID) + + Example: + echo '{"session_id":"abc"}' | programa claude-hook session-start + echo '{}' | programa claude-hook stop + """ + case "codex": + return """ + Usage: programa codex + + Manage Codex CLI hooks integration. + + Subcommands: + install-hooks Install programa hooks into ~/.codex/hooks.json + uninstall-hooks Remove programa hooks from ~/.codex/hooks.json + """ + case "codex-hook": + return """ + Usage: programa codex-hook [flags] + + Hook for Codex CLI integration. Reads JSON from stdin. + Gracefully no-ops when not running inside programa. + + Subcommands: + session-start Register a Codex session + prompt-submit Set Running status on user prompt + stop Send completion notification, set Idle + + Flags: + --workspace Target workspace (default: $PROGRAMA_WORKSPACE_ID) + --surface Target surface (default: $PROGRAMA_SURFACE_ID) + """ + default: + return nil + } + } + + /// Hook command descriptors, split out of the central + /// `commandDescriptors()` array (programa.swift) so they live next to + /// their implementation. Refs #101. + func hooksDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["claude-hook"], + helpLines: ["claude-hook [--workspace ] [--surface ]"], + execute: { ctx in + try self.runClaudeHook(commandArgs: ctx.commandArgs, client: ctx.client) + } + ), + CommandDescriptor( + names: ["codex-hook"], + helpLines: [], + execute: { ctx in + try self.runCodexHook(commandArgs: ctx.commandArgs, client: ctx.client) + } + ), + ] + } } diff --git a/CLI/CLI+Markdown.swift b/CLI/CLI+Markdown.swift index 07982bc4559..400fe103603 100644 --- a/CLI/CLI+Markdown.swift +++ b/CLI/CLI+Markdown.swift @@ -100,4 +100,35 @@ extension CMUXCLI { print("OK surface=\(surfaceText) pane=\(paneText) path=\(filePath)") } } + + /// Subcommand help text for Markdown commands, split out of the + /// central `subcommandUsage` switch (programa.swift) so each domain's + /// help text lives next to its command descriptors. Refs #101. + func markdownSubcommandUsage(_ command: String) -> String? { + switch command { + case "markdown": + return """ + Usage: programa markdown open [options] + programa markdown (shorthand for 'open') + + Open a markdown file in a formatted viewer panel with live file watching. + The file is rendered with rich formatting (headings, code blocks, tables, + lists, blockquotes) and automatically updates when the file changes on disk. + + Options: + --workspace Target workspace (default: $PROGRAMA_WORKSPACE_ID) + --surface Source surface to split from (default: focused surface) + --window Target window + --direction Split direction (default: right) + + Examples: + programa markdown open plan.md + programa markdown ~/project/CHANGELOG.md + programa markdown open ./docs/design.md --workspace 0 + programa markdown open plan.md --direction down + """ + default: + return nil + } + } } diff --git a/CLI/CLI+SSH.swift b/CLI/CLI+SSH.swift index 0ac3e6bafa5..af59451468e 100644 --- a/CLI/CLI+SSH.swift +++ b/CLI/CLI+SSH.swift @@ -1326,4 +1326,67 @@ extension CMUXCLI { ) return (result.status, result.stdout, result.stderr) } + + /// Subcommand help text for SSH commands, split out of the + /// central `subcommandUsage` switch (programa.swift) so each domain's + /// help text lives next to its command descriptors. Refs #101. + func sshSubcommandUsage(_ command: String) -> String? { + switch command { + case "ssh": + return """ + Usage: programa ssh [flags] [-- ] + + Create a new workspace, mark it as remote-SSH, and start an SSH session in that workspace. + programa will also establish a local SSH proxy endpoint so browser traffic can egress from the remote host. + + Flags: + --name Optional workspace title + --port <n> SSH port + --identity <path> SSH identity file path + --ssh-option <opt> Extra SSH -o option (repeatable) + --no-focus Create workspace without switching to it + + Example: + programa ssh dev@my-host + programa ssh dev@my-host --name "gpu-box" --port 2222 --identity ~/.ssh/id_ed25519 + programa ssh dev@my-host --ssh-option UserKnownHostsFile=/dev/null --ssh-option StrictHostKeyChecking=no + """ + case "remote-daemon-status": + return """ + Usage: programa remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] + + Show the embedded programad-remote release manifest, local cache status, checksum verification state, + and the GitHub attestation verification command for a target platform. + + Example: + programa remote-daemon-status + programa remote-daemon-status --os linux --arch arm64 + """ + default: + return nil + } + } + + /// SSH-related command descriptors, split out of the central + /// `commandDescriptors()` array (programa.swift) so they live next to + /// their implementation. Refs #101. + func sshDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["ssh"], + helpLines: ["ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [--no-focus] [-- <remote-command-args>]"], + execute: { ctx in + try self.runSSH(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) + } + ), + CommandDescriptor( + names: ["ssh-session-end"], + helpLines: [], + execute: { ctx in + try self.runSSHSessionEnd(commandArgs: ctx.commandArgs, client: ctx.client) + } + ), + CommandDescriptor(names: ["remote-daemon-status"], helpLines: ["remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>]"], execute: nil), + ] + } } diff --git a/CLI/CLI+Themes.swift b/CLI/CLI+Themes.swift index 562f24bef2b..2ce96f3189a 100644 --- a/CLI/CLI+Themes.swift +++ b/CLI/CLI+Themes.swift @@ -724,4 +724,43 @@ extension CMUXCLI { return nil } + + /// Subcommand help text for Themes commands, split out of the + /// central `subcommandUsage` switch (programa.swift) so each domain's + /// help text lives next to its command descriptors. Refs #101. + func themesSubcommandUsage(_ command: String) -> String? { + switch command { + case "themes": + return """ + Usage: programa themes + programa themes list + programa themes set <theme> + programa themes set --light <theme> [--dark <theme>] + programa themes set --dark <theme> [--light <theme>] + programa themes clear + + When run in a TTY, `programa themes` opens an interactive theme picker with + live app preview. Use `programa themes list` for a plain listing. + + The picker previews the selected theme across the running programa app and + lets you apply it to the light theme, dark theme, or both defaults. + + Commands: + list List available themes and mark the current light/dark defaults + set <theme> Set the same theme for both light and dark appearance + set --light <theme> Set the light appearance theme + set --dark <theme> Set the dark appearance theme + clear Remove the programa theme override and fall back to other config + + Examples: + programa themes + programa themes list + programa themes set "Catppuccin Mocha" + programa themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha" + programa themes clear + """ + default: + return nil + } + } } diff --git a/CLI/CLI+TmuxCompat.swift b/CLI/CLI+TmuxCompat.swift index e4fb103a2de..59ab8388309 100644 --- a/CLI/CLI+TmuxCompat.swift +++ b/CLI/CLI+TmuxCompat.swift @@ -1924,4 +1924,201 @@ extension CMUXCLI { throw CLIError(message: "Unsupported tmux compatibility command: \(command)") } } + + /// Subcommand help text for TmuxCompat commands, split out of the + /// central `subcommandUsage` switch (programa.swift) so each domain's + /// help text lives next to its command descriptors. Refs #101. + func tmuxCompatSubcommandUsage(_ command: String) -> String? { + switch command { + case "capture-pane": + return """ + Usage: programa capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] + + tmux-compatible alias for reading terminal text from a pane. + + Flags: + --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) + --surface <id|ref> Surface context (default: $PROGRAMA_SURFACE_ID) + --scrollback Include scrollback + --lines <n> Return only the last N lines (implies --scrollback) + + Example: + programa capture-pane --workspace workspace:2 --surface surface:1 --scrollback --lines 200 + """ + case "resize-pane": + return """ + Usage: programa resize-pane [--pane <id|ref>] [--workspace <id|ref>] [-L|-R|-U|-D] [--amount <n>] + + tmux-compatible pane resize command. + + Flags: + --pane <id|ref> Pane to resize (default: focused pane) + --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) + -L|-R|-U|-D Direction (default: -R) + --amount <n> Resize amount (default: 1) + """ + case "pipe-pane": + return """ + Usage: programa pipe-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <shell-command> | <shell-command>] + + Capture pane text and pipe it to a shell command via stdin. + + Flags: + --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) + --surface <id|ref> Surface context (default: focused surface) + --command <command> Shell command to run (or pass as trailing text) + """ + case "wait-for": + return """ + Usage: programa wait-for [-S|--signal] <name> [--timeout <seconds>] + + Wait for or signal a named synchronization token. + + Flags: + -S, --signal Signal the token instead of waiting + --timeout <seconds> Wait timeout (default: 30) + """ + case "swap-pane": + return """ + Usage: programa swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>] + + Swap two panes. + + Flags: + --pane <id|ref> Source pane (required) + --target-pane <id|ref> Target pane (required) + --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) + """ + case "break-pane": + return """ + Usage: programa break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] + + Move a pane/surface out into its own pane context. + + Flags: + --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) + --pane <id|ref> Source pane + --surface <id|ref> Source surface + --no-focus Do not focus the result + """ + case "join-pane": + return """ + Usage: programa join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] + + Join a pane/surface into another pane. + + Flags: + --target-pane <id|ref> Target pane (required) + --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) + --pane <id|ref> Source pane + --surface <id|ref> Source surface + --no-focus Do not focus the result + """ + case "next-window", "previous-window", "last-window": + return """ + Usage: programa \(command) + + Switch workspace selection (next/previous/last) in the current window. + """ + case "last-pane": + return """ + Usage: programa last-pane [--workspace <id|ref>] + + Focus the previously focused pane in a workspace. + + Flags: + --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) + """ + case "find-window": + return """ + Usage: programa find-window [--content] [--select] [query] + + Find workspaces by title (and optionally terminal content). + + Flags: + --content Search terminal content in addition to workspace titles + --select Select the first match + """ + case "clear-history": + return """ + Usage: programa clear-history [--workspace <id|ref>] [--surface <id|ref>] + + Clear terminal scrollback history. + + Flags: + --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) + --surface <id|ref> Surface context (default: focused surface) + """ + case "set-hook": + return """ + Usage: programa set-hook [--list] [--unset <event>] | <event> <command> + + Manage tmux-compat hook definitions. + + Flags: + --list List configured hooks + --unset <event> Remove a hook by event name + """ + case "popup": + return """ + Usage: programa popup + + tmux compatibility placeholder. This command is currently not supported. + """ + case "bind-key", "unbind-key", "copy-mode": + return """ + Usage: programa \(command) + + tmux compatibility placeholder. This command is currently not supported. + """ + case "set-buffer": + return """ + Usage: programa set-buffer [--name <name>] [--] <text> + + Save text into a named tmux-compat buffer. + + Flags: + --name <name> Buffer name (default: default) + """ + case "paste-buffer": + return """ + Usage: programa paste-buffer [--name <name>] [--workspace <id|ref>] [--surface <id|ref>] + + Paste a named tmux-compat buffer into a surface. + + Flags: + --name <name> Buffer name (default: default) + --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) + --surface <id|ref> Surface context (default: focused surface) + """ + case "list-buffers": + return """ + Usage: programa list-buffers + + List tmux-compat buffers. + """ + case "respawn-pane": + return """ + Usage: programa respawn-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <cmd> | <cmd>] + + Send a command (or default shell restart command) to a surface. + + Flags: + --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) + --surface <id|ref> Surface context (default: focused surface) + --command <cmd> Command text (or pass trailing command text) + """ + case "display-message": + return """ + Usage: programa display-message [-p|--print] <text> + + Print text (or show it via notification bridge in parity mode). + + Flags: + -p, --print Print to stdout only + """ + default: + return nil + } + } } diff --git a/CLI/CLI+Tree.swift b/CLI/CLI+Tree.swift index 9e2ec5be8d9..5acf5ff7f0d 100644 --- a/CLI/CLI+Tree.swift +++ b/CLI/CLI+Tree.swift @@ -541,4 +541,55 @@ extension CMUXCLI { } return parts.joined(separator: " ") } + + /// Subcommand help text for Tree commands, split out of the + /// central `subcommandUsage` switch (programa.swift) so each domain's + /// help text lives next to its command descriptors. Refs #101. + func treeSubcommandUsage(_ command: String) -> String? { + switch command { + case "tree": + return """ + Usage: programa tree [flags] + + Print the hierarchy of windows, workspaces, panes, and surfaces. + + Flags: + --all Include all windows (default: current window only) + --workspace <id|ref|index> Show only one workspace + --json Structured JSON output + + Output: + Text mode prints a box-drawing tree with markers: + - ◀ active (true focused window/workspace/pane/surface path) + - ◀ here (caller surface where `programa tree` was invoked) + - workspace [selected] + - pane [focused] + - surface [selected] + Browser surfaces also include their current URL. + + Example: + programa tree + programa tree --all + programa tree --workspace workspace:2 + programa --json tree --all + """ + default: + return nil + } + } + + /// Tree command descriptors, split out of the central + /// `commandDescriptors()` array (programa.swift) so they live next to + /// their implementation. Refs #101. + func treeDescriptors() -> [CommandDescriptor] { + [ + CommandDescriptor( + names: ["tree"], + helpLines: ["tree [--all] [--workspace <id|ref|index>]"], + execute: { ctx in + try self.runTreeCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) + } + ), + ] + } } diff --git a/CLI/programa.swift b/CLI/programa.swift index 58048ad4b76..63ae82e5a10 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -1631,7 +1631,11 @@ struct CMUXCLI { /// is externally visible; dispatch itself is by name lookup and does not /// depend on array order. private 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"], execute: nil), @@ -1923,22 +1927,9 @@ struct CMUXCLI { } ), - CommandDescriptor( - names: ["ssh"], - helpLines: ["ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [--no-focus] [-- <remote-command-args>]"], - execute: { ctx in - try self.runSSH(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) - } - ), - CommandDescriptor( - names: ["ssh-session-end"], - helpLines: [], - execute: { ctx in - try self.runSSHSessionEnd(commandArgs: ctx.commandArgs, client: ctx.client) - } - ), - - CommandDescriptor(names: ["remote-daemon-status"], helpLines: ["remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>]"], execute: nil), + ] + descriptors += self.sshDescriptors() + descriptors += [ CommandDescriptor( names: ["new-split"], @@ -2023,13 +2014,9 @@ struct CMUXCLI { } ), - CommandDescriptor( - names: ["tree"], - helpLines: ["tree [--all] [--workspace <id|ref|index>]"], - execute: { ctx in - try self.runTreeCommand(commandArgs: ctx.commandArgs, client: ctx.client, jsonOutput: ctx.jsonOutput, idFormat: ctx.idFormat) - } - ), + ] + descriptors += self.treeDescriptors() + descriptors += [ CommandDescriptor( names: ["focus-pane"], @@ -2799,21 +2786,9 @@ struct CMUXCLI { } ), - CommandDescriptor( - names: ["claude-hook"], - helpLines: ["claude-hook <session-start|stop|notification> [--workspace <id|ref>] [--surface <id|ref>]"], - execute: { ctx in - try self.runClaudeHook(commandArgs: ctx.commandArgs, client: ctx.client) - } - ), - - CommandDescriptor( - names: ["codex-hook"], - helpLines: [], - execute: { ctx in - try self.runCodexHook(commandArgs: ctx.commandArgs, client: ctx.client) - } - ), + ] + descriptors += self.hooksDescriptors() + descriptors += [ CommandDescriptor( names: ["set-app-focus"], @@ -2861,7 +2836,8 @@ struct CMUXCLI { // handler; help text preserves the original grouped layout, // including the two pipe-separated combo lines). CommandDescriptor(names: [], helpLines: ["", "# tmux compatibility commands"], execute: nil), - ] + Self.tmuxCompatDescriptors(runTmuxCompatCommand: { ctx in + ] + descriptors += Self.tmuxCompatDescriptors(runTmuxCompatCommand: { ctx in try self.runTmuxCompatCommand( command: ctx.command, commandArgs: ctx.commandArgs, @@ -2870,7 +2846,8 @@ struct CMUXCLI { idFormat: ctx.idFormat, windowOverride: ctx.windowId ) - }) + [ + }) + descriptors += [ CommandDescriptor(names: [], helpLines: [""], execute: nil), CommandDescriptor( @@ -2999,6 +2976,7 @@ struct CMUXCLI { } ), ] + return descriptors } /// Looks up the descriptor whose `names` contains `command`, if any. @@ -4174,6 +4152,14 @@ struct CMUXCLI { /// Return the help/usage text for a subcommand, or nil if the command is unknown. private func subcommandUsage(_ command: String) -> String? { + if let text = tmuxCompatSubcommandUsage(command) { return text } + if let text = sshSubcommandUsage(command) { return text } + if let text = treeSubcommandUsage(command) { return text } + if let text = hooksSubcommandUsage(command) { return text } + if let text = browserSubcommandUsage(command) { return text } + if let text = markdownSubcommandUsage(command) { return text } + if let text = themesSubcommandUsage(command) { return text } + if let text = agentWrapperSubcommandUsage(command) { return text } switch command { case "ping": return """ @@ -4228,125 +4214,6 @@ struct CMUXCLI { --body <text> Feedback body (submission disabled) --image <path> Attach an image file, repeat for multiple images (submission disabled) """ - case "themes": - return """ - Usage: programa themes - programa themes list - programa themes set <theme> - programa themes set --light <theme> [--dark <theme>] - programa themes set --dark <theme> [--light <theme>] - programa themes clear - - When run in a TTY, `programa themes` opens an interactive theme picker with - live app preview. Use `programa themes list` for a plain listing. - - The picker previews the selected theme across the running programa app and - lets you apply it to the light theme, dark theme, or both defaults. - - Commands: - list List available themes and mark the current light/dark defaults - set <theme> Set the same theme for both light and dark appearance - set --light <theme> Set the light appearance theme - set --dark <theme> Set the dark appearance theme - clear Remove the programa theme override and fall back to other config - - Examples: - programa themes - programa themes list - programa themes set "Catppuccin Mocha" - programa themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha" - programa themes clear - """ - case "claude-teams": - return String(localized: "cli.claude-teams.usage", defaultValue: """ - Usage: programa claude-teams [claude-args...] - - Launch Claude Code with agent teams enabled. - - This command: - - defaults Claude teammate mode to auto - - sets a tmux-like environment so Claude auto mode uses programa splits - - sets CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 - - prepends a private tmux shim to PATH - - forwards all remaining arguments to claude - - The tmux shim translates supported tmux window/pane commands into programa - workspace and split operations in the current programa session. - - Examples: - programa claude-teams - programa claude-teams --continue - programa claude-teams --model sonnet - """) - case "omo": - return String(localized: "cli.omo.usage", defaultValue: """ - Usage: programa omo [opencode-args...] - - Launch OpenCode with oh-my-openagent in a programa-aware environment. - - oh-my-openagent orchestrates multiple AI models as specialized agents in - parallel. This command sets up a tmux shim so agent panes become native - programa splits with sidebar metadata and notifications. - - This command: - - sets a tmux-like environment so oh-my-openagent uses programa splits - - prepends a private tmux shim to PATH - - forwards all remaining arguments to opencode - - The tmux shim translates tmux window/pane commands into programa workspace - and split operations in the current programa session. - - Examples: - programa omo - programa omo --continue - programa omo --model claude-sonnet-4-6 - """) - case "omx": - return String(localized: "cli.omx.usage", defaultValue: """ - Usage: programa omx [omx-args...] - - Launch Oh My Codex (OMX) with native programa pane integration. - - OMX is a multi-agent orchestration layer for OpenAI Codex CLI. This - command sets up a tmux shim so OMX team mode, HUD, and agent panes - become native programa splits. - - This command: - - sets a tmux-like environment so OMX uses programa splits - - prepends a private tmux shim to PATH - - forwards all remaining arguments to omx - - Install: npm install -g oh-my-codex - - Examples: - programa omx - programa omx --madmax --high - programa omx team - """) - case "omc": - return String(localized: "cli.omc.usage", defaultValue: """ - Usage: programa omc [omc-args...] - - Launch Oh My Claude Code (OMC) with native programa pane integration. - - OMC is a multi-agent orchestration system for Claude Code with - specialized agents, smart model routing, and team pipelines. This - command sets up a tmux shim so OMC team mode and agent panes become - native programa splits. - - This command: - - sets a tmux-like environment so OMC uses programa splits - - prepends a private tmux shim to PATH - - injects NODE_OPTIONS restore module for Claude compatibility - - forwards all remaining arguments to omc - - Install: npm install -g oh-my-claude-sisyphus - - Examples: - programa omc - programa omc team 3:claude "implement feature" - programa omc --watch - """) case "identify": return """ Usage: programa identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] @@ -4599,36 +4466,6 @@ struct CMUXCLI { Example: programa list-workspaces """ - case "ssh": - return """ - Usage: programa ssh <destination> [flags] [-- <remote-command-args>] - - Create a new workspace, mark it as remote-SSH, and start an SSH session in that workspace. - programa will also establish a local SSH proxy endpoint so browser traffic can egress from the remote host. - - Flags: - --name <title> Optional workspace title - --port <n> SSH port - --identity <path> SSH identity file path - --ssh-option <opt> Extra SSH -o option (repeatable) - --no-focus Create workspace without switching to it - - Example: - programa ssh dev@my-host - programa ssh dev@my-host --name "gpu-box" --port 2222 --identity ~/.ssh/id_ed25519 - programa ssh dev@my-host --ssh-option UserKnownHostsFile=/dev/null --ssh-option StrictHostKeyChecking=no - """ - case "remote-daemon-status": - return """ - Usage: programa remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] - - Show the embedded programad-remote release manifest, local cache status, checksum verification state, - and the GitHub attestation verification command for a target platform. - - Example: - programa remote-daemon-status - programa remote-daemon-status --os linux --arch arm64 - """ case "new-split": return """ Usage: programa new-split <left|right|up|down> [flags] @@ -4671,32 +4508,6 @@ struct CMUXCLI { programa list-pane-surfaces programa list-pane-surfaces --workspace workspace:2 --pane pane:1 """ - case "tree": - return """ - Usage: programa tree [flags] - - Print the hierarchy of windows, workspaces, panes, and surfaces. - - Flags: - --all Include all windows (default: current window only) - --workspace <id|ref|index> Show only one workspace - --json Structured JSON output - - Output: - Text mode prints a box-drawing tree with markers: - - ◀ active (true focused window/workspace/pane/surface path) - - ◀ here (caller surface where `programa tree` was invoked) - - workspace [selected] - - pane [focused] - - surface [selected] - Browser surfaces also include their current URL. - - Example: - programa tree - programa tree --all - programa tree --workspace workspace:2 - programa --json tree --all - """ case "focus-pane": return """ Usage: programa focus-pane [--pane <id|ref> | <id|ref>] [flags] @@ -4896,193 +4707,6 @@ struct CMUXCLI { Print the currently selected workspace ID. """ - case "capture-pane": - return """ - Usage: programa capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] - - tmux-compatible alias for reading terminal text from a pane. - - Flags: - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Surface context (default: $PROGRAMA_SURFACE_ID) - --scrollback Include scrollback - --lines <n> Return only the last N lines (implies --scrollback) - - Example: - programa capture-pane --workspace workspace:2 --surface surface:1 --scrollback --lines 200 - """ - case "resize-pane": - return """ - Usage: programa resize-pane [--pane <id|ref>] [--workspace <id|ref>] [-L|-R|-U|-D] [--amount <n>] - - tmux-compatible pane resize command. - - Flags: - --pane <id|ref> Pane to resize (default: focused pane) - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - -L|-R|-U|-D Direction (default: -R) - --amount <n> Resize amount (default: 1) - """ - case "pipe-pane": - return """ - Usage: programa pipe-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <shell-command> | <shell-command>] - - Capture pane text and pipe it to a shell command via stdin. - - Flags: - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Surface context (default: focused surface) - --command <command> Shell command to run (or pass as trailing text) - """ - case "wait-for": - return """ - Usage: programa wait-for [-S|--signal] <name> [--timeout <seconds>] - - Wait for or signal a named synchronization token. - - Flags: - -S, --signal Signal the token instead of waiting - --timeout <seconds> Wait timeout (default: 30) - """ - case "swap-pane": - return """ - Usage: programa swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>] - - Swap two panes. - - Flags: - --pane <id|ref> Source pane (required) - --target-pane <id|ref> Target pane (required) - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - """ - case "break-pane": - return """ - Usage: programa break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] - - Move a pane/surface out into its own pane context. - - Flags: - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - --pane <id|ref> Source pane - --surface <id|ref> Source surface - --no-focus Do not focus the result - """ - case "join-pane": - return """ - Usage: programa join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--no-focus] - - Join a pane/surface into another pane. - - Flags: - --target-pane <id|ref> Target pane (required) - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - --pane <id|ref> Source pane - --surface <id|ref> Source surface - --no-focus Do not focus the result - """ - case "next-window", "previous-window", "last-window": - return """ - Usage: programa \(command) - - Switch workspace selection (next/previous/last) in the current window. - """ - case "last-pane": - return """ - Usage: programa last-pane [--workspace <id|ref>] - - Focus the previously focused pane in a workspace. - - Flags: - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - """ - case "find-window": - return """ - Usage: programa find-window [--content] [--select] [query] - - Find workspaces by title (and optionally terminal content). - - Flags: - --content Search terminal content in addition to workspace titles - --select Select the first match - """ - case "clear-history": - return """ - Usage: programa clear-history [--workspace <id|ref>] [--surface <id|ref>] - - Clear terminal scrollback history. - - Flags: - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Surface context (default: focused surface) - """ - case "set-hook": - return """ - Usage: programa set-hook [--list] [--unset <event>] | <event> <command> - - Manage tmux-compat hook definitions. - - Flags: - --list List configured hooks - --unset <event> Remove a hook by event name - """ - case "popup": - return """ - Usage: programa popup - - tmux compatibility placeholder. This command is currently not supported. - """ - case "bind-key", "unbind-key", "copy-mode": - return """ - Usage: programa \(command) - - tmux compatibility placeholder. This command is currently not supported. - """ - case "set-buffer": - return """ - Usage: programa set-buffer [--name <name>] [--] <text> - - Save text into a named tmux-compat buffer. - - Flags: - --name <name> Buffer name (default: default) - """ - case "paste-buffer": - return """ - Usage: programa paste-buffer [--name <name>] [--workspace <id|ref>] [--surface <id|ref>] - - Paste a named tmux-compat buffer into a surface. - - Flags: - --name <name> Buffer name (default: default) - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Surface context (default: focused surface) - """ - case "list-buffers": - return """ - Usage: programa list-buffers - - List tmux-compat buffers. - """ - case "respawn-pane": - return """ - Usage: programa respawn-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <cmd> | <cmd>] - - Send a command (or default shell restart command) to a surface. - - Flags: - --workspace <id|ref> Workspace context (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Surface context (default: focused surface) - --command <cmd> Command text (or pass trailing command text) - """ - case "display-message": - return """ - Usage: programa display-message [-p|--print] <text> - - Print text (or show it via notification bridge in parity mode). - - Flags: - -p, --print Print to stdout only - """ case "read-screen": return """ Usage: programa read-screen [flags] @@ -5323,156 +4947,6 @@ struct CMUXCLI { Trigger the app-active handler used by notification focus tests. """ - case "claude-hook": - return """ - Usage: programa claude-hook <session-start|active|stop|idle|notification|notify|prompt-submit> [flags] - - Hook for Claude Code integration. Reads JSON from stdin. - - Subcommands: - session-start Signal that a Claude session has started - active Alias for session-start - stop Signal that a Claude session has stopped - idle Alias for stop - notification Forward a Claude notification - notify Alias for notification - prompt-submit Clear notification and set Running on user prompt - - Flags: - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) - - Example: - echo '{"session_id":"abc"}' | programa claude-hook session-start - echo '{}' | programa claude-hook stop - """ - case "codex": - return """ - Usage: programa codex <install-hooks|uninstall-hooks> - - Manage Codex CLI hooks integration. - - Subcommands: - install-hooks Install programa hooks into ~/.codex/hooks.json - uninstall-hooks Remove programa hooks from ~/.codex/hooks.json - """ - case "codex-hook": - return """ - Usage: programa codex-hook <session-start|prompt-submit|stop> [flags] - - Hook for Codex CLI integration. Reads JSON from stdin. - Gracefully no-ops when not running inside programa. - - Subcommands: - session-start Register a Codex session - prompt-submit Set Running status on user prompt - stop Send completion notification, set Idle - - Flags: - --workspace <id|ref> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref> Target surface (default: $PROGRAMA_SURFACE_ID) - """ - case "browser": - return """ - Usage: programa browser [--surface <id|ref|index> | <surface>] <subcommand> [args] - - Browser automation commands. Most subcommands require a surface handle. - A surface can be passed as `--surface <handle>` or as the first positional token. - `open`/`open-split`/`new`/`identify` can run without an explicit surface. - - Subcommands: - open|open-split|new [url] [--workspace <id|ref|index>] [--window <id|ref|index>] - open/open-split/new default to $PROGRAMA_WORKSPACE_ID when --workspace is omitted and --window is not set - goto|navigate <url> [--snapshot-after] - back|forward|reload [--snapshot-after] - url|get-url - focus-webview | is-webview-focused - snapshot [--interactive|-i] [--cursor] [--compact] [--max-depth <n>] [--selector <css>] - eval [--script <js> | <js>] - wait [--selector <css>] [--text <text>] [--url-contains <text>|--url <text>] [--load-state <interactive|complete>] [--function <js>] [--timeout-ms <ms>|--timeout <seconds>] - click|dblclick|hover|focus|check|uncheck|scroll-into-view [--selector <css> | <css>] [--snapshot-after] - type|fill [--selector <css> | <css>] [--text <text> | <text>] [--snapshot-after] - press|key|keydown|keyup [--key <key> | <key>] [--snapshot-after] - select [--selector <css> | <css>] [--value <value> | <value>] [--snapshot-after] - scroll [--selector <css>] [--dx <n>] [--dy <n>] [--snapshot-after] - screenshot [--out <path>] - get <url|title|text|html|value|attr|count|box|styles> [...] - text|html|value|count|box|styles|attr: [--selector <css> | <css>] - attr: [--attr <name> | <name>] - styles: [--property <name>] - is <visible|enabled|checked> [--selector <css> | <css>] - find <role|text|label|placeholder|alt|title|testid|first|last|nth> [...] - role: [--name <text>] [--exact] <role> - text|label|placeholder|alt|title|testid: [--exact] <text> - first|last: [--selector <css> | <css>] - nth: [--index <n> | <n>] [--selector <css> | <css>] - frame <main|selector> [--selector <css>] - dialog <accept|dismiss> [text] - download [wait] [--path <path>] [--timeout-ms <ms>|--timeout <seconds>] - cookies <get|set|clear> [--name <name>] [--value <value>] [--url <url>] [--domain <domain>] [--path <path>] [--expires <unix>] [--secure] [--all] - storage <local|session> <get|set|clear> [...] - tab <new|list|switch|close|<index>> [...] - console <list|clear> - errors <list|clear> - highlight [--selector <css> | <css>] - state <save|load> <path> - addinitscript|addscript [--script <js> | <js>] - addstyle [--css <css> | <css>] - viewport <width> <height> - geolocation|geo <latitude> <longitude> - offline <true|false> - trace <start|stop> [path] - network <route|unroute|requests> ... - route <pattern> [--abort] [--body <text>] - unroute <pattern> - screencast <start|stop> - input <mouse|keyboard|touch> [args...] - input_mouse | input_keyboard | input_touch - identify [--surface <id|ref|index>] - - Example: - programa browser open https://example.com - programa browser surface:1 navigate https://google.com - programa browser --surface surface:1 snapshot --interactive - """ - // Legacy browser aliases — point users to `programa browser --help` - case "open-browser": - return "Legacy alias for 'programa browser open'. Run 'programa browser --help' for details." - case "navigate": - return "Legacy alias for 'programa browser navigate'. Run 'programa browser --help' for details." - case "browser-back": - return "Legacy alias for 'programa browser back'. Run 'programa browser --help' for details." - case "browser-forward": - return "Legacy alias for 'programa browser forward'. Run 'programa browser --help' for details." - case "browser-reload": - return "Legacy alias for 'programa browser reload'. Run 'programa browser --help' for details." - case "get-url": - return "Legacy alias for 'programa browser get-url'. Run 'programa browser --help' for details." - case "focus-webview": - return "Legacy alias for 'programa browser focus-webview'. Run 'programa browser --help' for details." - case "is-webview-focused": - return "Legacy alias for 'programa browser is-webview-focused'. Run 'programa browser --help' for details." - case "markdown": - return """ - Usage: programa markdown open <path> [options] - programa markdown <path> (shorthand for 'open') - - Open a markdown file in a formatted viewer panel with live file watching. - The file is rendered with rich formatting (headings, code blocks, tables, - lists, blockquotes) and automatically updates when the file changes on disk. - - Options: - --workspace <id|ref|index> Target workspace (default: $PROGRAMA_WORKSPACE_ID) - --surface <id|ref|index> Source surface to split from (default: focused surface) - --window <id|ref|index> Target window - --direction <left|right|up|down> Split direction (default: right) - - Examples: - programa markdown open plan.md - programa markdown ~/project/CHANGELOG.md - programa markdown open ./docs/design.md --workspace 0 - programa markdown open plan.md --direction down - """ default: return nil } From 54009d3109f9e8b6f0d3748e2d89d9fd5db2b54d Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Thu, 9 Jul 2026 14:22:53 -0300 Subject: [PATCH 3/4] refactor: move ClaudeHookSessionStore and hook types to CLI+Hooks.swift ClaudeHookParsedInput, ClaudeHookSessionRecord, ClaudeHookSessionStoreFile, ClaudeHookSessionStore, and codexHookWrapperProcessNames were declared at the top of programa.swift (210 lines) but only ever used from CLI+Hooks.swift. Moved the whole block verbatim, ahead of the existing `extension CMUXCLI` in that file. No access-level or behavior changes. Refs #101. --- CLI/CLI+Hooks.swift | 211 ++++++++++++++++++++++++++++++++++++++++++++ CLI/programa.swift | 211 -------------------------------------------- 2 files changed, 211 insertions(+), 211 deletions(-) diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index af439a336b4..2b94d6a214b 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -8,6 +8,217 @@ import LocalAuthentication import Security #endif +struct ClaudeHookParsedInput { + let object: [String: Any]? + let rawFallback: String? + let sessionId: String? + let cwd: String? + let transcriptPath: String? +} + +struct ClaudeHookSessionRecord: Codable { + var sessionId: String + var workspaceId: String + var surfaceId: String + var cwd: String? + var pid: Int? + var lastSubtitle: String? + var lastBody: String? + var startedAt: TimeInterval + var updatedAt: TimeInterval +} + +private struct ClaudeHookSessionStoreFile: Codable { + var version: Int = 1 + var sessions: [String: ClaudeHookSessionRecord] = [:] +} + +final class ClaudeHookSessionStore { + private static let defaultStatePath = "~/.programa/claude-hook-sessions.json" + private static let maxStateAgeSeconds: TimeInterval = 60 * 60 * 24 * 7 + + private let statePath: String + private let fileManager: FileManager + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + + init( + processEnv: [String: String] = ProcessInfo.processInfo.environment, + fileManager: FileManager = .default + ) { + if let overridePath = processEnv["PROGRAMA_CLAUDE_HOOK_STATE_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !overridePath.isEmpty { + self.statePath = NSString(string: overridePath).expandingTildeInPath + } else { + self.statePath = NSString(string: Self.defaultStatePath).expandingTildeInPath + } + self.fileManager = fileManager + self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + } + + func lookup(sessionId: String) throws -> ClaudeHookSessionRecord? { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return nil } + return try withLockedState { state in + state.sessions[normalized] + } + } + + func upsert( + sessionId: String, + workspaceId: String, + surfaceId: String, + cwd: String?, + pid: Int? = nil, + lastSubtitle: String? = nil, + lastBody: String? = nil + ) throws { + let normalized = normalizeSessionId(sessionId) + guard !normalized.isEmpty else { return } + try withLockedState { state in + let now = Date().timeIntervalSince1970 + var record = state.sessions[normalized] ?? ClaudeHookSessionRecord( + sessionId: normalized, + workspaceId: workspaceId, + surfaceId: surfaceId, + cwd: nil, + pid: nil, + lastSubtitle: nil, + lastBody: nil, + startedAt: now, + updatedAt: now + ) + record.workspaceId = workspaceId + if !surfaceId.isEmpty { + record.surfaceId = surfaceId + } + if let cwd = normalizeOptional(cwd) { + record.cwd = cwd + } + if let pid { + record.pid = pid + } + if let subtitle = normalizeOptional(lastSubtitle) { + record.lastSubtitle = subtitle + } + if let body = normalizeOptional(lastBody) { + record.lastBody = body + } + record.updatedAt = now + state.sessions[normalized] = record + } + } + + func consume( + sessionId: String?, + workspaceId: String?, + surfaceId: String? + ) throws -> ClaudeHookSessionRecord? { + let normalizedSessionId = normalizeOptional(sessionId) + let normalizedWorkspace = normalizeOptional(workspaceId) + let normalizedSurface = normalizeOptional(surfaceId) + return try withLockedState { state in + if let normalizedSessionId, + let removed = state.sessions.removeValue(forKey: normalizedSessionId) { + return removed + } + + guard let fallback = fallbackRecord( + sessions: Array(state.sessions.values), + workspaceId: normalizedWorkspace, + surfaceId: normalizedSurface + ) else { + return nil + } + state.sessions.removeValue(forKey: fallback.sessionId) + return fallback + } + } + + private func fallbackRecord( + sessions: [ClaudeHookSessionRecord], + workspaceId: String?, + surfaceId: String? + ) -> ClaudeHookSessionRecord? { + if let surfaceId { + let matches = sessions.filter { $0.surfaceId == surfaceId } + return matches.max(by: { $0.updatedAt < $1.updatedAt }) + } + if let workspaceId { + let matches = sessions.filter { $0.workspaceId == workspaceId } + if matches.count == 1 { + return matches[0] + } + } + return nil + } + + private func withLockedState<T>(_ body: (inout ClaudeHookSessionStoreFile) throws -> T) throws -> T { + let lockPath = statePath + ".lock" + let fd = open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR)) + if fd < 0 { + throw CLIError(message: "Failed to open Claude hook state lock: \(lockPath)") + } + defer { Darwin.close(fd) } + + if flock(fd, LOCK_EX) != 0 { + throw CLIError(message: "Failed to lock Claude hook state: \(lockPath)") + } + defer { _ = flock(fd, LOCK_UN) } + + var state = loadUnlocked() + pruneExpired(&state) + let result = try body(&state) + try saveUnlocked(state) + return result + } + + private func loadUnlocked() -> ClaudeHookSessionStoreFile { + guard fileManager.fileExists(atPath: statePath) else { + return ClaudeHookSessionStoreFile() + } + guard let data = try? Data(contentsOf: URL(fileURLWithPath: statePath)), + let decoded = try? decoder.decode(ClaudeHookSessionStoreFile.self, from: data) else { + return ClaudeHookSessionStoreFile() + } + return decoded + } + + private func saveUnlocked(_ state: ClaudeHookSessionStoreFile) throws { + let stateURL = URL(fileURLWithPath: statePath) + let parentURL = stateURL.deletingLastPathComponent() + try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true, attributes: nil) + let data = try encoder.encode(state) + try data.write(to: stateURL, options: .atomic) + } + + private func pruneExpired(_ state: inout ClaudeHookSessionStoreFile) { + let now = Date().timeIntervalSince1970 + let cutoff = now - Self.maxStateAgeSeconds + state.sessions = state.sessions.filter { _, record in + record.updatedAt >= cutoff + } + } + + private func normalizeSessionId(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func normalizeOptional(_ value: String?) -> String? { + guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } +} + +let codexHookWrapperProcessNames: Set<String> = [ + "sh", + "bash", + "zsh", + "env" +] + extension CMUXCLI { func runClaudeHook( commandArgs: [String], diff --git a/CLI/programa.swift b/CLI/programa.swift index 63ae82e5a10..3b14779bf4b 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -14,217 +14,6 @@ struct CLIError: Error, CustomStringConvertible { var description: String { message } } -struct ClaudeHookParsedInput { - let object: [String: Any]? - let rawFallback: String? - let sessionId: String? - let cwd: String? - let transcriptPath: String? -} - -struct ClaudeHookSessionRecord: Codable { - var sessionId: String - var workspaceId: String - var surfaceId: String - var cwd: String? - var pid: Int? - var lastSubtitle: String? - var lastBody: String? - var startedAt: TimeInterval - var updatedAt: TimeInterval -} - -private struct ClaudeHookSessionStoreFile: Codable { - var version: Int = 1 - var sessions: [String: ClaudeHookSessionRecord] = [:] -} - -final class ClaudeHookSessionStore { - private static let defaultStatePath = "~/.programa/claude-hook-sessions.json" - private static let maxStateAgeSeconds: TimeInterval = 60 * 60 * 24 * 7 - - private let statePath: String - private let fileManager: FileManager - private let decoder = JSONDecoder() - private let encoder = JSONEncoder() - - init( - processEnv: [String: String] = ProcessInfo.processInfo.environment, - fileManager: FileManager = .default - ) { - if let overridePath = processEnv["PROGRAMA_CLAUDE_HOOK_STATE_PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), - !overridePath.isEmpty { - self.statePath = NSString(string: overridePath).expandingTildeInPath - } else { - self.statePath = NSString(string: Self.defaultStatePath).expandingTildeInPath - } - self.fileManager = fileManager - self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - } - - func lookup(sessionId: String) throws -> ClaudeHookSessionRecord? { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { return nil } - return try withLockedState { state in - state.sessions[normalized] - } - } - - func upsert( - sessionId: String, - workspaceId: String, - surfaceId: String, - cwd: String?, - pid: Int? = nil, - lastSubtitle: String? = nil, - lastBody: String? = nil - ) throws { - let normalized = normalizeSessionId(sessionId) - guard !normalized.isEmpty else { return } - try withLockedState { state in - let now = Date().timeIntervalSince1970 - var record = state.sessions[normalized] ?? ClaudeHookSessionRecord( - sessionId: normalized, - workspaceId: workspaceId, - surfaceId: surfaceId, - cwd: nil, - pid: nil, - lastSubtitle: nil, - lastBody: nil, - startedAt: now, - updatedAt: now - ) - record.workspaceId = workspaceId - if !surfaceId.isEmpty { - record.surfaceId = surfaceId - } - if let cwd = normalizeOptional(cwd) { - record.cwd = cwd - } - if let pid { - record.pid = pid - } - if let subtitle = normalizeOptional(lastSubtitle) { - record.lastSubtitle = subtitle - } - if let body = normalizeOptional(lastBody) { - record.lastBody = body - } - record.updatedAt = now - state.sessions[normalized] = record - } - } - - func consume( - sessionId: String?, - workspaceId: String?, - surfaceId: String? - ) throws -> ClaudeHookSessionRecord? { - let normalizedSessionId = normalizeOptional(sessionId) - let normalizedWorkspace = normalizeOptional(workspaceId) - let normalizedSurface = normalizeOptional(surfaceId) - return try withLockedState { state in - if let normalizedSessionId, - let removed = state.sessions.removeValue(forKey: normalizedSessionId) { - return removed - } - - guard let fallback = fallbackRecord( - sessions: Array(state.sessions.values), - workspaceId: normalizedWorkspace, - surfaceId: normalizedSurface - ) else { - return nil - } - state.sessions.removeValue(forKey: fallback.sessionId) - return fallback - } - } - - private func fallbackRecord( - sessions: [ClaudeHookSessionRecord], - workspaceId: String?, - surfaceId: String? - ) -> ClaudeHookSessionRecord? { - if let surfaceId { - let matches = sessions.filter { $0.surfaceId == surfaceId } - return matches.max(by: { $0.updatedAt < $1.updatedAt }) - } - if let workspaceId { - let matches = sessions.filter { $0.workspaceId == workspaceId } - if matches.count == 1 { - return matches[0] - } - } - return nil - } - - private func withLockedState<T>(_ body: (inout ClaudeHookSessionStoreFile) throws -> T) throws -> T { - let lockPath = statePath + ".lock" - let fd = open(lockPath, O_CREAT | O_RDWR, mode_t(S_IRUSR | S_IWUSR)) - if fd < 0 { - throw CLIError(message: "Failed to open Claude hook state lock: \(lockPath)") - } - defer { Darwin.close(fd) } - - if flock(fd, LOCK_EX) != 0 { - throw CLIError(message: "Failed to lock Claude hook state: \(lockPath)") - } - defer { _ = flock(fd, LOCK_UN) } - - var state = loadUnlocked() - pruneExpired(&state) - let result = try body(&state) - try saveUnlocked(state) - return result - } - - private func loadUnlocked() -> ClaudeHookSessionStoreFile { - guard fileManager.fileExists(atPath: statePath) else { - return ClaudeHookSessionStoreFile() - } - guard let data = try? Data(contentsOf: URL(fileURLWithPath: statePath)), - let decoded = try? decoder.decode(ClaudeHookSessionStoreFile.self, from: data) else { - return ClaudeHookSessionStoreFile() - } - return decoded - } - - private func saveUnlocked(_ state: ClaudeHookSessionStoreFile) throws { - let stateURL = URL(fileURLWithPath: statePath) - let parentURL = stateURL.deletingLastPathComponent() - try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true, attributes: nil) - let data = try encoder.encode(state) - try data.write(to: stateURL, options: .atomic) - } - - private func pruneExpired(_ state: inout ClaudeHookSessionStoreFile) { - let now = Date().timeIntervalSince1970 - let cutoff = now - Self.maxStateAgeSeconds - state.sessions = state.sessions.filter { _, record in - record.updatedAt >= cutoff - } - } - - private func normalizeSessionId(_ value: String) -> String { - value.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func normalizeOptional(_ value: String?) -> String? { - guard let value = value?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { - return nil - } - return value - } -} - -let codexHookWrapperProcessNames: Set<String> = [ - "sh", - "bash", - "zsh", - "env" -] - enum CLIIDFormat: String { case refs case uuids From 03795ecba9d100144cb85c3b443cf5061dbf7005 Mon Sep 17 00:00:00 2001 From: arzafran <franco@darkroom.engineering> Date: Thu, 9 Jul 2026 14:24:00 -0300 Subject: [PATCH 4/4] refactor: rename CMUXCLI to ProgramaCLI Mechanical rename across all CLI/*.swift files (the struct declaration in programa.swift, its instantiation in main(), and every `extension CMUXCLI` in the CLI+*.swift domain files). No behavior change. Refs #101. --- CLI/CLI+AgentWrappers.swift | 2 +- CLI/CLI+Browser.swift | 2 +- CLI/CLI+Hooks.swift | 2 +- CLI/CLI+Markdown.swift | 2 +- CLI/CLI+SSH.swift | 2 +- CLI/CLI+Themes.swift | 2 +- CLI/CLI+TmuxCompat.swift | 2 +- CLI/CLI+Tree.swift | 2 +- CLI/programa.swift | 4 ++-- 9 files changed, 10 insertions(+), 10 deletions(-) diff --git a/CLI/CLI+AgentWrappers.swift b/CLI/CLI+AgentWrappers.swift index e4406a68601..9fc76c5e694 100644 --- a/CLI/CLI+AgentWrappers.swift +++ b/CLI/CLI+AgentWrappers.swift @@ -8,7 +8,7 @@ import LocalAuthentication import Security #endif -extension CMUXCLI { +extension ProgramaCLI { private func isProgramaClaudeWrapper(at path: String) -> Bool { guard let data = FileManager.default.contents(atPath: path) else { return false } let prefixData = data.prefix(512) diff --git a/CLI/CLI+Browser.swift b/CLI/CLI+Browser.swift index b467213a0a0..a2cf0dc643f 100644 --- a/CLI/CLI+Browser.swift +++ b/CLI/CLI+Browser.swift @@ -8,7 +8,7 @@ import LocalAuthentication import Security #endif -extension CMUXCLI { +extension ProgramaCLI { func runBrowserCommand( commandArgs: [String], client: SocketClient, diff --git a/CLI/CLI+Hooks.swift b/CLI/CLI+Hooks.swift index 2b94d6a214b..ba96cf324e5 100644 --- a/CLI/CLI+Hooks.swift +++ b/CLI/CLI+Hooks.swift @@ -219,7 +219,7 @@ let codexHookWrapperProcessNames: Set<String> = [ "env" ] -extension CMUXCLI { +extension ProgramaCLI { func runClaudeHook( commandArgs: [String], client: SocketClient diff --git a/CLI/CLI+Markdown.swift b/CLI/CLI+Markdown.swift index 400fe103603..93244e8ef3f 100644 --- a/CLI/CLI+Markdown.swift +++ b/CLI/CLI+Markdown.swift @@ -8,7 +8,7 @@ import LocalAuthentication import Security #endif -extension CMUXCLI { +extension ProgramaCLI { // MARK: - Markdown Commands func runMarkdownCommand( diff --git a/CLI/CLI+SSH.swift b/CLI/CLI+SSH.swift index af59451468e..77133e26085 100644 --- a/CLI/CLI+SSH.swift +++ b/CLI/CLI+SSH.swift @@ -8,7 +8,7 @@ import LocalAuthentication import Security #endif -extension CMUXCLI { +extension ProgramaCLI { private func generateRemoteRelayPort() -> Int { // Random port in the ephemeral range (49152-65535) Int.random(in: 49152...65535) diff --git a/CLI/CLI+Themes.swift b/CLI/CLI+Themes.swift index 2ce96f3189a..e6cdb9080e8 100644 --- a/CLI/CLI+Themes.swift +++ b/CLI/CLI+Themes.swift @@ -8,7 +8,7 @@ import LocalAuthentication import Security #endif -extension CMUXCLI { +extension ProgramaCLI { private static let programaThemeOverrideBundleIdentifier = "com.darkroom.programa" private static let programaThemesBlockStart = "# programa themes start" private static let programaThemesBlockEnd = "# programa themes end" diff --git a/CLI/CLI+TmuxCompat.swift b/CLI/CLI+TmuxCompat.swift index 59ab8388309..712d75bcc29 100644 --- a/CLI/CLI+TmuxCompat.swift +++ b/CLI/CLI+TmuxCompat.swift @@ -8,7 +8,7 @@ import LocalAuthentication import Security #endif -extension CMUXCLI { +extension ProgramaCLI { /// The 23 tmux-emulation command names, all dispatched through the same /// `runTmuxCompatCommand`. Help text preserves the original grouped /// layout, including the two pipe-separated combo lines diff --git a/CLI/CLI+Tree.swift b/CLI/CLI+Tree.swift index 5acf5ff7f0d..44794201c93 100644 --- a/CLI/CLI+Tree.swift +++ b/CLI/CLI+Tree.swift @@ -8,7 +8,7 @@ import LocalAuthentication import Security #endif -extension CMUXCLI { +extension ProgramaCLI { private struct TreeCommandOptions { let includeAllWindows: Bool let workspaceHandle: String? diff --git a/CLI/programa.swift b/CLI/programa.swift index 3b14779bf4b..6fbf630c9e7 100644 --- a/CLI/programa.swift +++ b/CLI/programa.swift @@ -1120,7 +1120,7 @@ struct CommandDescriptor { let execute: ((CommandContext) throws -> Void)? } -struct CMUXCLI { +struct ProgramaCLI { let args: [String] private static let debugLastSocketHintPath = "/tmp/programa-last-socket-path" @@ -5473,7 +5473,7 @@ struct CMUXTermMain { static func main() { // CLI tools should ignore SIGPIPE so closed stdout pipes do not terminate the process. _ = signal(SIGPIPE, SIG_IGN) - let cli = CMUXCLI(args: CommandLine.arguments) + let cli = ProgramaCLI(args: CommandLine.arguments) do { try cli.run() } catch {