Repository navigation
Improve dev build incrementality - #4367
lawrencecchen wants to merge 13 commits into
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughRefactors a monolithic SocketClient into three files with Unix/relay transport, v2 JSON-RPC and streaming, filesystem-wait utilities; wires sources into Xcode, adds Ghostty resource/source filelists, and updates build scripts for conditional builds and ranlib caching. ChangesSocket Client Library Extraction and Build Infrastructure
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Caution Pre-merge checks failedPlease resolve all errors before merging. Addressing warnings is optional.
❌ Failed checks (4 errors, 2 warnings)
✅ Passed checks (11 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Greptile SummaryThis PR improves dev build incrementality by splitting
Confidence Score: 3/5The Swift extraction is mechanically safe but the relay wait path in SocketClient+Wait.swift has outstanding concerns (blocking semaphore wait, swallowed permanent errors) from earlier review rounds; merge should wait for those to be resolved. The new tagged-launch socket readiness check in reload.sh adds a sleep 0.1 poll loop (up to 8 s) instead of a filesystem event signal for socket creation. Combined with the pre-existing open issues in SocketClient+Wait.swift — the relay branch silently discards permanent errors like missing auth credentials, causing the full timeout to elapse before the caller sees a generic message, and DispatchSemaphore.wait blocks the calling thread for the entire wait duration — there are multiple active reliability concerns in the new socket lifecycle code. CLI/SocketClient+Wait.swift (relay error handling and thread blocking in the wait path) and scripts/reload.sh (new sleep-based socket readiness polling in the tagged launch path) Important Files Changed
Sequence DiagramsequenceDiagram
participant SH as reload.sh
participant XC as xcodebuild
participant SC as SocketClient
participant App as Tagged App
SH->>XC: "build (COMPILER_INDEX_STORE_ENABLE=NO)"
XC-->>SH: app bundle (DerivedData)
SH->>SH: stamp_app_commit (PlistBuddy CMUXCommit)
SH->>SH: rsync TAG_APP_PATH, patch Info.plist
SH->>SH: stamp_app_commit (TAG_APP_PATH)
SH->>SH: codesign --force --sign -
SH->>App: nohup launch (tagged)
SH->>SH: sleep 0.2 then pgrep check
loop poll 80x100ms
SH->>SH: test -S CMUX_SOCKET_PATH_VALUE
end
Note over SC: waitForConnectableSocket (relay)
SC->>SC: DispatchSource.makeTimerSource 50ms
SC->>SC: DispatchSemaphore.wait(timeout:)
SC-->>App: connect() then close()
Note over SC: streamV2 (relay-backed)
SC->>SC: connect()
SC->>SC: defer close() if isRelayBacked
SC->>App: writeAll request line
loop while true
App-->>SC: readStreamLine()
SC->>SC: onLine(line)
end
|
| buildActionMask = 2147483647; | ||
| files = ( | ||
| ); | ||
| inputFileListPaths = ( | ||
| ); | ||
| inputPaths = ( | ||
| "$(SRCROOT)/scripts/compress-markdown-viewer-assets.sh", | ||
| "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/markdown-viewer", | ||
| "$(SRCROOT)/Resources/markdown-viewer/highlight.min.js", | ||
| "$(SRCROOT)/Resources/markdown-viewer/marked.min.js", | ||
| "$(SRCROOT)/Resources/markdown-viewer/mermaid.min.js", | ||
| "$(SRCROOT)/Resources/markdown-viewer/vega-embed.min.js", | ||
| "$(SRCROOT)/Resources/markdown-viewer/vega-lite.min.js", | ||
| "$(SRCROOT)/Resources/markdown-viewer/vega.min.js", | ||
| ); |
There was a problem hiding this comment.
Directory-level input paths may cause false-skips in the ghostty helper phase
Three of the five input paths for the "Build Ghostty CLI Helper" script phase are directories ($(SRCROOT)/Resources/ghostty, $(SRCROOT)/Resources/shell-integration, $(SRCROOT)/Resources/terminfo-overlay). Xcode's incremental build logic compares modification timestamps: for a directory, the mtime only updates when entries are directly added or removed, not when a file inside the directory is modified in-place. If a ghostty resource or shell-integration file is edited without changing the directory structure, Xcode will see the directory as unchanged, conclude its outputs are up-to-date, and skip the phase entirely — leaving a stale helper in the app bundle.
# Conflicts: # CLI/cmux.swift
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
CLI/CMUXCLI+Usage.swift (1)
1-1847:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftFile exceeds maximum size for new Swift files.
This new file is 1847 lines, which exceeds the 800-line limit for production Swift files with a single coherent responsibility. While the file does have a clear, single purpose (CLI help text generation), it should be refactored to comply with the size guidelines.
Consider one of these approaches:
Split by command category: Break
subcommandUsage(_:)into multiple smaller extensions organized by command domain (workspace commands, surface/tab commands, browser automation, notifications, tmux compatibility, agent integrations, etc.).Move to string catalogs: Migrate all help text into the localized string catalog (
.xcstrings), keeping only the lookup logic in Swift. This would also resolve the i18n inconsistency flagged separately.Hybrid approach: Keep frequently-changed or programmatically-generated help in Swift, move stable help text to catalogs, and split remaining Swift logic by category.
As per coding guidelines, new production Swift files should not exceed 400 lines without clear single responsibility, or 800 lines even when mostly coherent.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@CLI/CMUXCLI`+Usage.swift around lines 1 - 1847, This file is too large (1847 lines) and must be split/refactored; extract the giant subcommandUsage(_:) switch and large help literals into smaller pieces or string catalogs. Split responsibilities by creating multiple partial extensions (e.g., CMUXCLI+UsageBrowser, CMUXCLI+UsageWorkspace, CMUXCLI+UsageNotifications) each implementing a focused helper like subcommandUsageForBrowser(_:), subcommandUsageForWorkspace(_:), etc., then make subcommandUsage(_:) delegate to those smaller helpers; alternatively move stable large help texts into localized string resources (.xcstrings) and replace inline triple-quoted literals with localized lookup calls while keeping the dispatch logic in Swift (functions: subcommandUsage(_:), dispatchSubcommandHelp(command:commandArgs:), usage()). Ensure each new Swift file stays under the 800-line (preferably 400-line) guideline and keep public API/behavior unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CLI/CMUXCLI`+Usage.swift:
- Around line 5-1645: The subcommandUsage(_:) function contains many user-facing
hardcoded help strings; replace each plain multiline return with the localized
form String(localized: "cli.<command>.usage", defaultValue: """...""") following
the existing examples (e.g., claude-teams, right-sidebar) so every command
(ping, capabilities, events, auth, vm/cloud, browser, workspace/surface
commands, etc.) uses a unique "cli.<command>.usage" key; update the app string
catalog with matching entries for supported locales and ensure the defaultValue
contains the current English text exactly as before to preserve behavior.
In `@CLI/SocketClient.swift`:
- Around line 719-724: The guard that throws CLIError(message: "Invalid v2
response: \(raw)") exposes the full raw server response; update the error to
avoid leaking upstream content by either emitting a generic message (e.g.,
"Invalid v2 response from server") or a sanitized/truncated snippet (e.g., first
N characters with non-printables removed) instead; change the throw in
SocketClient.swift where JSONSerialization fails (the guard that binds response)
to use CLIError without the verbatim raw string (or a safely truncated/sanitized
version) and ensure any helper used for truncation is applied consistently.
In `@cmux.xcodeproj/project.pbxproj`:
- Around line 1908-1913: The build phase currently uses directory inputs
("$(SRCROOT)/Resources/ghostty", "$(SRCROOT)/Resources/shell-integration",
"$(SRCROOT)/Resources/terminfo-overlay") which prevents reliable dependency
tracking; update the shell script build phase that references
build-ghostty-cli-helper.sh so it either points to a new .xcfilelist containing
every file under those three resource directories or replace each directory
entry with explicit file paths for the files you copy (e.g., list individual
files under Resources/ghostty, Resources/shell-integration,
Resources/terminfo-overlay) so Xcode can detect changes and re-run the phase
correctly.
- Around line 1917-1921: The build phase that uses PlistBuddy to set CMUXCommit
is missing the Info.plist from outputPaths and the git ref from inputPaths, so
Xcode's dependency analysis can skip it; add
"$(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)" to the phase's outputPaths and add the
repository HEAD (e.g. "$(SRCROOT)/.git/HEAD") to the phase's inputPaths to force
rerun when the commit changes, or alternatively move the stamping into a new
build phase with runOnlyForDeploymentPostprocessing = 1 so it's always executed
during deployment.
- Around line 2245-2246: CMUXCLI+Usage.swift contains localized CLI help but
isn't being included in string extraction because the cmux-cli target doesn't
have SWIFT_EMIT_LOC_STRINGS enabled; fix by either enabling the build setting
SWIFT_EMIT_LOC_STRINGS = YES for the cmux-cli target or moving/adding
CMUXCLI+Usage.swift to a target that already participates in localization
extraction (e.g., the main app/library target) so its strings are harvested into
Localizable.xcstrings; update the project.pbxproj target settings or target
membership accordingly and verify extraction picks up the strings.
---
Outside diff comments:
In `@CLI/CMUXCLI`+Usage.swift:
- Around line 1-1847: This file is too large (1847 lines) and must be
split/refactored; extract the giant subcommandUsage(_:) switch and large help
literals into smaller pieces or string catalogs. Split responsibilities by
creating multiple partial extensions (e.g., CMUXCLI+UsageBrowser,
CMUXCLI+UsageWorkspace, CMUXCLI+UsageNotifications) each implementing a focused
helper like subcommandUsageForBrowser(_:), subcommandUsageForWorkspace(_:),
etc., then make subcommandUsage(_:) delegate to those smaller helpers;
alternatively move stable large help texts into localized string resources
(.xcstrings) and replace inline triple-quoted literals with localized lookup
calls while keeping the dispatch logic in Swift (functions: subcommandUsage(_:),
dispatchSubcommandHelp(command:commandArgs:), usage()). Ensure each new Swift
file stays under the 800-line (preferably 400-line) guideline and keep public
API/behavior unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: ae871e48-2ce6-4ced-9f4f-b1e1909e9b6a
📒 Files selected for processing (6)
CLI/CMUXCLI+Usage.swiftCLI/SocketClient.swiftCLI/cmux.swiftcmux.xcodeproj/project.pbxprojscripts/ensure-ghosttykit.shscripts/reload.sh
| func subcommandUsage(_ command: String) -> String? { | ||
| switch command { | ||
| case "ping": | ||
| return """ | ||
| Usage: cmux ping | ||
|
|
||
| Check connectivity to the cmux socket server. | ||
| """ | ||
| case "capabilities": | ||
| return """ | ||
| Usage: cmux capabilities | ||
|
|
||
| Print server capabilities as JSON. | ||
| """ | ||
| case "events": | ||
| return """ | ||
| Usage: cmux events [options] | ||
|
|
||
| Stream cmux events as newline-delimited JSON. | ||
|
|
||
| Options: | ||
| --after <seq> Replay retained events after this sequence | ||
| --cursor-file <path> Read the starting sequence from a file and update it after each event | ||
| --name <event> Filter by event name, repeatable | ||
| --category <name> Filter by category, repeatable | ||
| --reconnect Reconnect forever and resume from the last received sequence | ||
| --limit <n> Exit after printing n event frames | ||
| --no-ack Do not print the subscription ack frame | ||
| --no-heartbeat Do not print heartbeat frames | ||
|
|
||
| Examples: | ||
| cmux events --category notification | ||
| cmux events --cursor-file ~/.cache/cmux/events.seq --reconnect | ||
| cmux events --after 42 --name feed.item.received | ||
| """ | ||
| case "auth": | ||
| return """ | ||
| Usage: cmux auth <status|login|logout> | ||
|
|
||
| status Print whether the user is signed in (add `cmux --json` for JSON). | ||
| login Open the sign-in popup on the cmux web app and wait for it to finish. | ||
| logout Clear the current session. | ||
| """ | ||
| case "login": | ||
| return """ | ||
| Usage: cmux login | ||
|
|
||
| Alias for `cmux auth login`. | ||
| """ | ||
| case "logout": | ||
| return """ | ||
| Usage: cmux logout | ||
|
|
||
| Alias for `cmux auth logout`. | ||
| """ | ||
| case "vm", "cloud": | ||
| return """ | ||
| Usage: cmux \(command) <new|ls|rm|exec|shell|attach|ssh|ssh-info> [args...] | ||
|
|
||
| Manage cloud VMs. `cloud` is an alias for `vm`. Requires `cmux auth login`. | ||
|
|
||
| Subcommands: | ||
| ls List your cloud VMs. | ||
| new [--image <template>] [--provider <provider>] [--detach|-d] | ||
| Create a new VM. By default drops you into a shell on | ||
| the VM (like `docker run -it`). Pass --detach/-d to | ||
| just print the id and exit (scripting primitive). | ||
| shell <id> Drop into an interactive shell on an existing VM. | ||
| Alias: `attach <id>`. | ||
| ssh <id> Drop into a cmux-managed SSH workspace for an existing | ||
| VM, using the same session path as `cmux ssh`. | ||
| ssh-info <id> Print SSH connection details when the Cloud VM | ||
| exposes SSH. | ||
| rm <id> Destroy a VM. | ||
| exec <id> -- <command...> Run a shell command inside the VM and print stdout. | ||
|
|
||
| Env: | ||
| CMUX_VM_API_BASE_URL Override the backend origin (default: the cmux website). | ||
| `bun run dev` derives this from CMUX_PORT/PORT for | ||
| local testing from the web worktree. | ||
|
|
||
| Example: | ||
| cmux vm new | ||
| cmux vm ls | ||
| cmux cloud exec <id> -- echo hello | ||
| cmux vm rm <id> | ||
| """ | ||
| case "rpc": | ||
| return """ | ||
| Usage: cmux rpc <method> [json-params] | ||
|
|
||
| Call a raw v2 method with an optional JSON object for params. | ||
| Example: cmux rpc surface.report_tty '{"workspace_id":"...","surface_id":"...","tty_name":"ttys001"}' | ||
| """ | ||
| case "help": | ||
| return """ | ||
| Usage: cmux help | ||
|
|
||
| Show top-level CLI usage and command list. | ||
| Also works without a running cmux app or socket. | ||
| """ | ||
| case "docs": | ||
| return docsUsage() | ||
| case "settings": | ||
| return settingsUsage() | ||
| case "config": | ||
| return configUsage() | ||
| case "welcome": | ||
| return """ | ||
| Usage: cmux welcome | ||
|
|
||
| Show a welcome screen with the cmux logo and useful shortcuts. | ||
| Auto-runs once on first launch. | ||
| """ | ||
| case "shortcuts": | ||
| return """ | ||
| Usage: cmux shortcuts | ||
|
|
||
| Open the Settings window to Keyboard Shortcuts. | ||
| """ | ||
| case "disable-browser": | ||
| return """ | ||
| Usage: cmux disable-browser [--json] | ||
|
|
||
| Disable cmux browser creation and link interception. This overrides | ||
| browser settings from cmux.json until re-enabled. | ||
| """ | ||
| case "enable-browser": | ||
| return """ | ||
| Usage: cmux enable-browser [--json] | ||
|
|
||
| Re-enable cmux browser creation and link interception. | ||
| """ | ||
| case "browser-status": | ||
| return """ | ||
| Usage: cmux browser-status [--json] | ||
|
|
||
| Print whether cmux browser creation and link interception are enabled. | ||
| """ | ||
| case "restore-session": | ||
| return """ | ||
| Usage: cmux restore-session | ||
|
|
||
| Reopen the previous saved cmux session. | ||
|
|
||
| If the app is already running, this restores the last saved session into the current app. | ||
| If the app is not running, this launches cmux and lets startup restore reopen the saved session. | ||
| """ | ||
| case "feedback": | ||
| return """ | ||
| Usage: cmux feedback | ||
| cmux feedback --email <email> --body <text> [--image <path> ...] | ||
|
|
||
| Without args, open the Send Feedback modal in the running app. | ||
|
|
||
| With args, submit feedback through the app using the same feedback pipeline as the modal. | ||
|
|
||
| Flags: | ||
| --email <email> Contact email for follow-up | ||
| --body <text> Feedback body | ||
| --image <path> Attach an image file, repeat for multiple images | ||
|
|
||
| Coding agents: | ||
| Double check with the end user before sending anything. Review the message and attachments for secrets, | ||
| private code, credentials, tokens, and other sensitive information first. | ||
| """ | ||
| case "feed": | ||
| return """ | ||
| Usage: cmux feed tui [--opentui|--legacy] | ||
| cmux feed clear [--yes|-y] | ||
|
|
||
| Open the keyboard-first Feed TUI or manage persisted Feed workstream history. | ||
|
|
||
| TUI options: | ||
| --opentui Force the OpenTUI implementation and fail if unavailable | ||
| --legacy Force the older built-in Swift TUI | ||
| """ | ||
| case "hooks": | ||
| return """ | ||
| Usage: cmux hooks setup [agent] [--agent <name>] [--yes|-y] | ||
| cmux hooks uninstall [agent] [--agent <name>] [--yes|-y] | ||
| cmux hooks <agent> install [--yes|-y] (opencode supports --project) | ||
| cmux hooks <agent> uninstall [--yes|-y] (opencode supports --project) | ||
| cmux hooks <agent> <event> [flags] | ||
| cmux hooks feed --source <agent> [--event <event>] | ||
|
|
||
| Manage and run cmux agent hooks without adding one top-level command per | ||
| agent. Claude Code hooks are injected automatically by the cmux Claude wrapper. | ||
|
|
||
| Agents: | ||
| codex, opencode, pi, amp, cursor, gemini, rovodev (alias: rovo), hermes-agent, copilot, codebuddy, factory, qoder | ||
|
|
||
| Hook targets: | ||
| setup Install hooks for all supported agents on PATH | ||
| uninstall Remove hooks for all supported agents | ||
| <agent> install Install one agent integration | ||
| <agent> uninstall Remove one agent integration | ||
| <agent> <event> Internal hook entrypoint used by generated configs | ||
| feed Internal Feed decision bridge | ||
|
|
||
| Generated files: | ||
| ~/.config/opencode/plugins/cmux-session.js | ||
| ~/.config/opencode/plugins/cmux-feed.js | ||
| ~/.pi/agent/extensions/cmux-session.ts | ||
| ~/.config/amp/plugins/cmux-session.ts | ||
| See docs/agent-hooks.md for the full integration matrix. | ||
|
|
||
| Examples: | ||
| cmux hooks setup | ||
| cmux hooks setup --agent codex | ||
| cmux hooks setup rovo | ||
| cmux hooks uninstall rovo | ||
| cmux hooks codex install | ||
| cmux hooks opencode install --project | ||
| cmux hooks uninstall | ||
| """ | ||
| case "themes": | ||
| return """ | ||
| Usage: cmux themes | ||
| cmux themes list | ||
| cmux themes set <theme> | ||
| cmux themes set --light <theme> [--dark <theme>] | ||
| cmux themes set --dark <theme> [--light <theme>] | ||
| cmux themes clear | ||
|
|
||
| When run in a TTY, `cmux themes` opens an interactive theme picker with | ||
| live app preview. Use `cmux themes list` for a plain listing. | ||
|
|
||
| The picker previews the selected theme across the running cmux 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 cmux theme override and fall back to other config | ||
|
|
||
| Examples: | ||
| cmux themes | ||
| cmux themes list | ||
| cmux themes set "Catppuccin Mocha" | ||
| cmux themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha" | ||
| cmux themes clear | ||
| """ | ||
| case "claude-teams": | ||
| return String(localized: "cli.claude-teams.usage", defaultValue: """ | ||
| Usage: cmux 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 cmux 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 cmux | ||
| workspace and split operations in the current cmux session. | ||
|
|
||
| Examples: | ||
| cmux claude-teams | ||
| cmux claude-teams --continue | ||
| cmux claude-teams --model sonnet | ||
| """) | ||
| case "codex-teams": | ||
| return String(localized: "cli.codex-teams.usage", defaultValue: """ | ||
| Usage: cmux codex-teams [codex-args...] | ||
|
|
||
| Launch Codex with cmux-managed subagent panes. | ||
|
|
||
| This command: | ||
| - starts a private Codex app-server on localhost | ||
| - launches the root Codex TUI against that app-server | ||
| - watches live Codex thread-spawn subagents | ||
| - opens subagents up to depth 2 as native cmux splits | ||
| - forwards all remaining arguments to codex | ||
|
|
||
| Examples: | ||
| cmux codex-teams | ||
| cmux codex-teams --model gpt-5.4 | ||
| cmux codex-teams resume --last | ||
| """) | ||
| case "omo": | ||
| return String(localized: "cli.omo.usage", defaultValue: """ | ||
| Usage: cmux omo [opencode-args...] | ||
|
|
||
| Launch OpenCode with oh-my-openagent in a cmux-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 | ||
| cmux splits with sidebar metadata and notifications. | ||
|
|
||
| This command: | ||
| - sets a tmux-like environment so oh-my-openagent uses cmux splits | ||
| - prepends a private tmux shim to PATH | ||
| - forwards all remaining arguments to opencode | ||
|
|
||
| The tmux shim translates tmux window/pane commands into cmux workspace | ||
| and split operations in the current cmux session. | ||
|
|
||
| Examples: | ||
| cmux omo | ||
| cmux omo --continue | ||
| cmux omo --model claude-sonnet-4-6 | ||
| """) | ||
| case "omx": | ||
| return String(localized: "cli.omx.usage", defaultValue: """ | ||
| Usage: cmux omx [omx-args...] | ||
|
|
||
| Launch Oh My Codex (OMX) with native cmux 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 cmux splits. | ||
|
|
||
| This command: | ||
| - sets a tmux-like environment so OMX uses cmux splits | ||
| - prepends a private tmux shim to PATH | ||
| - forwards all remaining arguments to omx | ||
|
|
||
| Install: npm install -g oh-my-codex | ||
|
|
||
| Examples: | ||
| cmux omx | ||
| cmux omx --madmax --high | ||
| cmux omx team | ||
| """) | ||
| case "omc": | ||
| return String(localized: "cli.omc.usage", defaultValue: """ | ||
| Usage: cmux omc [omc-args...] | ||
|
|
||
| Launch Oh My Claude Code (OMC) with native cmux 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 cmux splits. | ||
|
|
||
| This command: | ||
| - sets a tmux-like environment so OMC uses cmux 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: | ||
| cmux omc | ||
| cmux omc team 3:claude "implement feature" | ||
| cmux omc --watch | ||
| """) | ||
| case "identify": | ||
| return """ | ||
| Usage: cmux identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] | ||
|
|
||
| Print server identity and caller context details. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref|index> Caller workspace context (default: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref|index> Caller surface context (default: $CMUX_SURFACE_ID) | ||
| --no-caller Omit caller context from the request | ||
| """ | ||
| case "list-windows": | ||
| return """ | ||
| Usage: cmux list-windows | ||
|
|
||
| List open windows. | ||
| """ | ||
| case "current-window": | ||
| return """ | ||
| Usage: cmux current-window | ||
|
|
||
| Print the currently selected window ID. | ||
| """ | ||
| case "new-window": | ||
| return """ | ||
| Usage: cmux new-window | ||
|
|
||
| Create a new window. | ||
|
|
||
| Example: | ||
| cmux new-window | ||
| """ | ||
| case "focus-window": | ||
| return """ | ||
| Usage: cmux focus-window --window <id|ref|index> | ||
|
|
||
| Focus (bring to front) the specified window. | ||
|
|
||
| Flags: | ||
| --window <id|ref|index> Window to focus (required) | ||
|
|
||
| Example: | ||
| cmux focus-window --window 0 | ||
| cmux focus-window --window window:1 | ||
| """ | ||
| case "close-window": | ||
| return """ | ||
| Usage: cmux close-window --window <id|ref|index> | ||
|
|
||
| Close the specified window. | ||
|
|
||
| Flags: | ||
| --window <id|ref|index> Window to close (required) | ||
|
|
||
| Example: | ||
| cmux close-window --window 0 | ||
| cmux close-window --window window:1 | ||
| """ | ||
| case "move-workspace-to-window": | ||
| return """ | ||
| Usage: cmux move-workspace-to-window --workspace <id|ref|index> --window <id|ref|index> | ||
|
|
||
| Move a workspace to a different window. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref|index> Workspace to move (required) | ||
| --window <id|ref|index> Target window (required) | ||
|
|
||
| Example: | ||
| cmux move-workspace-to-window --workspace workspace:2 --window window:1 | ||
| """ | ||
| case "move-surface": | ||
| return """ | ||
| Usage: cmux move-surface [--surface <id|ref|index> | <id|ref|index>] [flags] | ||
|
|
||
| Move a surface to a different pane, workspace, or window. | ||
|
|
||
| Flags: | ||
| --surface <id|ref|index> Surface to move (required unless passed positionally) | ||
| --pane <id|ref|index> Target pane | ||
| --workspace <id|ref|index> Target workspace | ||
| --window <id|ref|index> Target window | ||
| --before <id|ref|index> Place before this surface | ||
| --before-surface <id|ref|index> | ||
| Alias for --before | ||
| --after <id|ref|index> Place after this surface | ||
| --after-surface <id|ref|index> | ||
| Alias for --after | ||
| --index <n> Place at this index | ||
| --focus <true|false> Focus the surface after moving | ||
|
|
||
| Example: | ||
| cmux move-surface --surface surface:1 --workspace workspace:2 | ||
| cmux move-surface surface:1 --pane pane:2 --index 0 | ||
| """ | ||
| case "reorder-surface": | ||
| return """ | ||
| Usage: cmux reorder-surface [--surface <id|ref|index> | <id|ref|index>] [flags] | ||
|
|
||
| Reorder a surface within its pane. | ||
|
|
||
| Flags: | ||
| --surface <id|ref|index> Surface to reorder (required unless passed positionally) | ||
| --workspace <id|ref|index> Workspace context | ||
| --before <id|ref|index> Place before this surface | ||
| --before-surface <id|ref|index> | ||
| Alias for --before | ||
| --after <id|ref|index> Place after this surface | ||
| --after-surface <id|ref|index> | ||
| Alias for --after | ||
| --index <n> Place at this index | ||
| --focus <true|false> Focus the surface after reordering | ||
|
|
||
| Example: | ||
| cmux reorder-surface --surface surface:1 --index 0 | ||
| cmux reorder-surface --surface surface:3 --after surface:1 | ||
| """ | ||
| case "reorder-workspace": | ||
| return """ | ||
| Usage: cmux reorder-workspace [--workspace <id|ref|index> | <id|ref|index>] [flags] | ||
|
|
||
| Reorder a workspace within its window. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref|index> Workspace to reorder (required unless passed positionally) | ||
| --index <n> Place at this index | ||
| --before <id|ref|index> Place before this workspace | ||
| --before-workspace <id|ref|index> | ||
| Alias for --before | ||
| --after <id|ref|index> Place after this workspace | ||
| --after-workspace <id|ref|index> | ||
| Alias for --after | ||
| --window <id|ref|index> Window context | ||
|
|
||
| Example: | ||
| cmux reorder-workspace --workspace workspace:2 --index 0 | ||
| cmux reorder-workspace --workspace workspace:3 --after workspace:1 | ||
| """ | ||
| case "workspace-action": | ||
| return """ | ||
| Usage: cmux 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|index> Target workspace (default: current/$CMUX_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: | ||
| cmux workspace-action --workspace workspace:2 --action pin | ||
| cmux workspace-action --action rename --title "infra" | ||
| cmux workspace-action close-others | ||
| cmux workspace-action --action set-color --color blue | ||
| cmux workspace-action --action set-color --color "#C0392B" | ||
| cmux workspace-action set-color Amber | ||
| cmux workspace-action --action set-description --description "Ship checklist" | ||
| cmux workspace-action --action set-description $'Ship checklist\n- verify build\n- post notes' | ||
| cmux workspace-action clear-color | ||
| """ | ||
| case "tab-action": | ||
| return """ | ||
| Usage: cmux 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 | new-browser-right | ||
| move-to-new-workspace | ||
| reload | duplicate | ||
| pin | unpin | ||
| mark-unread | ||
|
|
||
| Flags: | ||
| --action <name> Action name (required if not positional) | ||
| --tab <id|ref|index> Target tab (accepts tab:<n> or surface:<n>; default: $CMUX_TAB_ID, then $CMUX_SURFACE_ID, then focused tab) | ||
| --surface <id|ref|index> Alias for --tab (backward compatibility) | ||
| --workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID) | ||
| --title <text> Title for rename (or pass trailing title text) | ||
| --url <url> Optional URL for new-browser-right | ||
| --focus <true|false> Focus the destination when supported (default: false for move-to-new-workspace) | ||
|
|
||
| Example: | ||
| cmux tab-action --tab tab:3 --action pin | ||
| cmux tab-action --action close-right | ||
| cmux tab-action --tab tab:2 --action move-to-new-workspace | ||
| cmux tab-action --tab tab:2 --action rename --title "build logs" | ||
| """ | ||
| case "move-tab-to-new-workspace", "detach-tab": | ||
| return Self.moveTabToNewWorkspaceCommandHelp | ||
| case "rename-tab": | ||
| return """ | ||
| Usage: cmux 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) $CMUX_TAB_ID / $CMUX_SURFACE_ID | ||
| 4) currently focused tab (optionally within --workspace) | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: current/$CMUX_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: | ||
| cmux rename-tab "build logs" | ||
| cmux rename-tab --tab tab:3 "staging server" | ||
| cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run" | ||
| """ | ||
| case "new-workspace": | ||
| return """ | ||
| Usage: cmux new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] | ||
|
|
||
| Create a new workspace in the caller's 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 | ||
| --layout <json> Create workspace with a predefined split layout (inline JSON). | ||
| Uses the same schema as cmux.json layout definitions. | ||
| When provided, --command is ignored (layout surfaces define their own commands). | ||
| --window <id|ref|index> | ||
| Target window (default: caller's window from $CMUX_WORKSPACE_ID/$CMUX_SURFACE_ID) | ||
| --focus <true|false> Focus the new workspace (default: false) | ||
|
|
||
| Example: | ||
| cmux new-workspace | ||
| cmux new-workspace --name "Build Server" | ||
| cmux new-workspace --name "Launch" --description "Ship checklist" | ||
| cmux new-workspace --cwd ~/projects/myapp | ||
| cmux new-workspace --cwd . --command "npm test" | ||
| cmux new-workspace --name "Dev" --layout '{"direction":"horizontal","split":0.5,"children":[{"pane":{"surfaces":[{"type":"terminal","command":"vim"}]}},{"pane":{"surfaces":[{"type":"terminal","command":"npm run start"}]}}]}' | ||
| """ | ||
| case "list-workspaces": | ||
| return """ | ||
| Usage: cmux list-workspaces | ||
|
|
||
| List workspaces in the current window. | ||
|
|
||
| Example: | ||
| cmux list-workspaces | ||
| """ | ||
| case "ssh": | ||
| return """ | ||
| Usage: cmux ssh <destination> [flags] [-- <remote-command-args>] | ||
|
|
||
| Create a new workspace, mark it as remote-SSH, and start an SSH session in that workspace. | ||
| cmux 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: | ||
| cmux ssh dev@my-host | ||
| cmux ssh dev@my-host --name "gpu-box" --port 2222 --identity ~/.ssh/id_ed25519 | ||
| cmux ssh dev@my-host --ssh-option UserKnownHostsFile=/dev/null --ssh-option StrictHostKeyChecking=no | ||
| """ | ||
| case "remote-daemon-status": | ||
| return """ | ||
| Usage: cmux remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] | ||
|
|
||
| Show the embedded cmuxd-remote release manifest, local cache status, checksum verification state, | ||
| and the GitHub attestation verification command for a target platform. | ||
|
|
||
| Example: | ||
| cmux remote-daemon-status | ||
| cmux remote-daemon-status --os linux --arch arm64 | ||
| """ | ||
| case "new-split": | ||
| return """ | ||
| Usage: cmux new-split <left|right|up|down> [flags] | ||
|
|
||
| Split the current pane in the given direction. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Surface to split from (default: $CMUX_SURFACE_ID) | ||
| --panel <id|ref> Alias for --surface | ||
| --focus <true|false> Focus the new split (default: false) | ||
|
|
||
| Example: | ||
| cmux new-split right | ||
| cmux new-split down --workspace workspace:1 | ||
| """ | ||
| case "list-panes": | ||
| return """ | ||
| Usage: cmux list-panes [--workspace <id|ref>] | ||
|
|
||
| List panes in a workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux list-panes | ||
| cmux list-panes --workspace workspace:2 | ||
| """ | ||
| case "list-pane-surfaces": | ||
| return """ | ||
| Usage: cmux list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>] | ||
|
|
||
| List surfaces in a pane. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
| --pane <id|ref> Restrict to a specific pane (default: focused pane) | ||
|
|
||
| Example: | ||
| cmux list-pane-surfaces | ||
| cmux list-pane-surfaces --workspace workspace:2 --pane pane:1 | ||
| """ | ||
| case "tree": | ||
| return """ | ||
| Usage: cmux 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 `cmux tree` was invoked) | ||
| - workspace [selected] | ||
| - pane [focused] | ||
| - surface [selected] | ||
| Browser surfaces also include their current URL. | ||
|
|
||
| Example: | ||
| cmux tree | ||
| cmux tree --all | ||
| cmux tree --workspace workspace:2 | ||
| cmux --json tree --all | ||
| """ | ||
| case "top": | ||
| return """ | ||
| Usage: cmux top [flags] | ||
|
|
||
| Print CPU and RAM usage by cmux window, workspace, pane, surface, status tag, and browser webview. | ||
|
|
||
| Flags: | ||
| --all Include all windows (default: current window only) | ||
| --workspace <id|ref|index> Show only one workspace | ||
| --processes Include process trees under windows, surfaces, webviews, and tags | ||
| --sort <cpu|mem|proc> Sort sibling rows by CPU, memory, or process count | ||
| --flat Print independent rows for shell sorting | ||
| --format <tree|tsv> Text output format (tsv implies --flat) | ||
| --json Structured JSON output | ||
|
|
||
| Output: | ||
| CPU comes from macOS process accounting and can exceed 100% across cores. | ||
| Memory is summed from macOS physical footprint across the unique process IDs attributed to each tree node. | ||
| Browser webviews are attributed through their WebKit content process PID. | ||
| TSV columns are: cpu_percent, memory_bytes, process_count, kind, ref, parent_ref, title. | ||
|
|
||
| Example: | ||
| cmux top | ||
| cmux top --all | ||
| cmux top --sort cpu | ||
| cmux top --format tsv | sort -t $'\\t' -nrk1,1 | ||
| cmux top --workspace workspace:2 --processes | ||
| cmux --json top --all | ||
| """ | ||
| case "focus-pane": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux focus-pane --pane pane:2 | ||
| cmux focus-pane pane:1 | ||
| cmux focus-pane --pane pane:1 --workspace workspace:2 | ||
| """ | ||
| case "new-pane": | ||
| return """ | ||
| Usage: cmux new-pane [flags] | ||
|
|
||
| Create a new pane in the workspace. | ||
|
|
||
| Flags: | ||
| --type <terminal|browser> Pane type (default: terminal) | ||
| --direction <left|right|up|down> Split direction (default: right) | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
| --url <url> URL for browser panes | ||
| --focus <true|false> Focus the new pane (default: false) | ||
|
|
||
| Example: | ||
| cmux new-pane | ||
| cmux new-pane --type browser --direction down --url https://example.com | ||
| """ | ||
| case "new-surface": | ||
| return """ | ||
| Usage: cmux new-surface [flags] | ||
|
|
||
| Create a new surface (tab) in a pane. | ||
|
|
||
| Flags: | ||
| --type <terminal|browser> Surface type (default: terminal) | ||
| --pane <id|ref> Target pane | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
| --url <url> URL for browser surfaces | ||
| --focus <true|false> Focus the new surface (default: false) | ||
|
|
||
| Example: | ||
| cmux new-surface | ||
| cmux new-surface --type browser --pane pane:1 --url https://example.com | ||
| """ | ||
| case "close-surface": | ||
| return """ | ||
| Usage: cmux close-surface [flags] | ||
|
|
||
| Close a surface. Defaults to the focused surface if none specified. | ||
|
|
||
| Flags: | ||
| --surface <id|ref> Surface to close (default: $CMUX_SURFACE_ID) | ||
| --panel <id|ref> Alias for --surface | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux close-surface | ||
| cmux close-surface --surface surface:3 | ||
| """ | ||
| case "drag-surface-to-split": | ||
| return """ | ||
| Usage: cmux drag-surface-to-split --surface <id|ref|index> <left|right|up|down> [flags] | ||
|
|
||
| Drag a surface into a new split in the given direction. | ||
|
|
||
| Flags: | ||
| --surface <id|ref|index> Surface to drag (required) | ||
| --panel <id|ref|index> Alias for --surface | ||
| --workspace <id|ref|index> Workspace context for ref/index resolution | ||
| --focus <true|false> Focus the split-off surface (default: false) | ||
|
|
||
| Example: | ||
| cmux drag-surface-to-split --surface surface:1 right | ||
| cmux drag-surface-to-split --panel surface:2 down | ||
| """ | ||
| case "split-off": | ||
| return """ | ||
| Usage: cmux split-off --surface <id|ref|index> <left|right|up|down> [flags] | ||
|
|
||
| Move an existing surface into a new split without changing focus by default. | ||
|
|
||
| Flags: | ||
| --surface <id|ref|index> Surface to move (required) | ||
| --panel <id|ref|index> Alias for --surface | ||
| --workspace <id|ref|index> Workspace context for ref/index resolution | ||
| --focus <true|false> Focus the split-off surface (default: false) | ||
|
|
||
| Example: | ||
| cmux split-off --surface surface:1 right | ||
| cmux split-off --workspace workspace:2 --surface surface:4 down | ||
| """ | ||
| case "refresh-surfaces": | ||
| return """ | ||
| Usage: cmux refresh-surfaces | ||
|
|
||
| Refresh surface snapshots for the focused workspace. | ||
| """ | ||
| case "reload-config": | ||
| return """ | ||
| Usage: cmux reload-config | ||
|
|
||
| Run the same configuration reload as the Reload Configuration shortcut. | ||
| This reloads Ghostty config, re-reads ~/.config/cmux/cmux.json, and refreshes terminals. | ||
|
|
||
| Example: | ||
| cmux reload-config | ||
| """ | ||
| case "surface-health": | ||
| return """ | ||
| Usage: cmux surface-health [--workspace <id|ref>] | ||
|
|
||
| List health details for surfaces in a workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux surface-health | ||
| cmux surface-health --workspace workspace:2 | ||
| """ | ||
| case "surface", "surface-resume": | ||
| return """ | ||
| Usage: cmux surface resume set [flags] -- <argv...> | ||
| cmux surface resume set [flags] --shell <command> | ||
| cmux surface resume show [--json] [flags] | ||
| cmux surface resume get [--json] [flags] | ||
| cmux surface resume clear [flags] | ||
|
|
||
| Attach restart command metadata to a terminal surface. | ||
| Public CLI bindings are stored for inspection and manual restore. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Surface context (default: $CMUX_SURFACE_ID) | ||
| --cwd <path> Working directory for restore (default: $PWD) | ||
| --name <name> Display name for the binding | ||
| --kind <kind> Binding kind, for example agent or tmux | ||
| --checkpoint <id> Provider checkpoint or session id | ||
| --checkpoint-id <id> Same as --checkpoint and takes precedence | ||
| --source <source> Binding source label | ||
|
|
||
| Examples: | ||
| cmux surface resume set --kind tmux --shell "tmux attach -t work" | ||
| cmux surface resume set --kind opencode --checkpoint ses_123 -- opencode --session ses_123 | ||
| cmux surface resume show --json | ||
| """ | ||
| case "debug-terminals": | ||
| return """ | ||
| Usage: cmux debug-terminals | ||
|
|
||
| Print live Ghostty terminal runtime metadata across all windows and workspaces. | ||
| Intended for debugging stray or detached terminal views. | ||
| """ | ||
| case "trigger-flash": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) | ||
| --panel <id|ref> Alias for --surface | ||
|
|
||
| Example: | ||
| cmux trigger-flash | ||
| cmux trigger-flash --workspace workspace:2 --surface surface:3 | ||
| """ | ||
| case "list-panels": | ||
| return """ | ||
| Usage: cmux list-panels [--workspace <id|ref>] | ||
|
|
||
| List surfaces (panels) in a workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux list-panels | ||
| cmux list-panels --workspace workspace:2 | ||
| """ | ||
| case "focus-panel": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux focus-panel --panel surface:2 | ||
| cmux focus-panel --panel surface:5 --workspace workspace:2 | ||
| """ | ||
| case "close-workspace": | ||
| return """ | ||
| Usage: cmux close-workspace --workspace <id|ref|index> | ||
|
|
||
| Close the specified workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref|index> Workspace to close (required) | ||
|
|
||
| Example: | ||
| cmux close-workspace --workspace workspace:2 | ||
| """ | ||
| case "select-workspace": | ||
| return """ | ||
| Usage: cmux select-workspace --workspace <id|ref|index> | ||
|
|
||
| Select (switch to) the specified workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref|index> Workspace to select (required) | ||
|
|
||
| Example: | ||
| cmux select-workspace --workspace workspace:2 | ||
| cmux select-workspace --workspace 0 | ||
| """ | ||
| case "rename-workspace", "rename-window": | ||
| return """ | ||
| Usage: cmux rename-workspace [--workspace <id|ref|index>] [--] <title> | ||
|
|
||
| Rename a workspace. Defaults to the current workspace. | ||
| tmux-compatible alias: rename-window | ||
|
|
||
| Flags: | ||
| --workspace <id|ref|index> Workspace to rename (default: current/$CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux rename-workspace "backend logs" | ||
| cmux rename-window --workspace workspace:2 "agent run" | ||
| """ | ||
| case "current-workspace": | ||
| return """ | ||
| Usage: cmux current-workspace | ||
|
|
||
| Print the currently selected workspace ID. | ||
| """ | ||
| case "capture-pane": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Surface context (default: $CMUX_SURFACE_ID) | ||
| --scrollback Include scrollback | ||
| --lines <n> Return only the last N lines (implies --scrollback) | ||
|
|
||
| Example: | ||
| cmux capture-pane --workspace workspace:2 --surface surface:1 --scrollback --lines 200 | ||
| """ | ||
| case "resize-pane": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
| -L|-R|-U|-D Direction (default: -R) | ||
| --amount <n> Resize amount (default: 1) | ||
| """ | ||
| case "pipe-pane": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_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: cmux 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: cmux swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>] [--focus <true|false>] | ||
|
|
||
| Swap two panes. | ||
|
|
||
| Flags: | ||
| --pane <id|ref> Source pane (required) | ||
| --target-pane <id|ref> Target pane (required) | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
| --focus <true|false> Focus the target pane after swapping (default: false) | ||
| """ | ||
| case "break-pane": | ||
| return """ | ||
| Usage: cmux break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--focus <true|false>] [--no-focus] | ||
|
|
||
| Move a pane/surface out into its own pane context. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
| --pane <id|ref> Source pane | ||
| --surface <id|ref> Source surface | ||
| --focus <true|false> Focus the result (default: false) | ||
| --no-focus Compatibility alias for --focus false | ||
| """ | ||
| case "join-pane": | ||
| return """ | ||
| Usage: cmux join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--focus <true|false>] [--no-focus] | ||
|
|
||
| Join a pane/surface into another pane. | ||
|
|
||
| Flags: | ||
| --target-pane <id|ref> Target pane (required) | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
| --pane <id|ref> Source pane | ||
| --surface <id|ref> Source surface | ||
| --focus <true|false> Focus the result (default: false) | ||
| --no-focus Compatibility alias for --focus false | ||
| """ | ||
| case "next-window", "previous-window", "last-window": | ||
| return """ | ||
| Usage: cmux \(command) | ||
|
|
||
| Switch workspace selection (next/previous/last) in the current window. | ||
| """ | ||
| case "last-pane": | ||
| return """ | ||
| Usage: cmux last-pane [--workspace <id|ref>] | ||
|
|
||
| Focus the previously focused pane in a workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
| """ | ||
| case "find-window": | ||
| return """ | ||
| Usage: cmux 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: cmux clear-history [--workspace <id|ref>] [--surface <id|ref>] | ||
|
|
||
| Clear terminal scrollback history. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Surface context (default: focused surface) | ||
| """ | ||
| case "set-hook": | ||
| return """ | ||
| Usage: cmux 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: cmux popup | ||
|
|
||
| tmux compatibility placeholder. This command is currently not supported. | ||
| """ | ||
| case "bind-key", "unbind-key", "copy-mode": | ||
| return """ | ||
| Usage: cmux \(command) | ||
|
|
||
| tmux compatibility placeholder. This command is currently not supported. | ||
| """ | ||
| case "set-buffer": | ||
| return """ | ||
| Usage: cmux 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: cmux 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: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Surface context (default: focused surface) | ||
| """ | ||
| case "list-buffers": | ||
| return """ | ||
| Usage: cmux list-buffers | ||
|
|
||
| List tmux-compat buffers. | ||
| """ | ||
| case "respawn-pane": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Surface context (default: focused surface) | ||
| --command <cmd> Command text (or pass trailing command text) | ||
| """ | ||
| case "display-message": | ||
| return """ | ||
| Usage: cmux 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: cmux read-screen [flags] | ||
|
|
||
| Read terminal text from a surface as plain text. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) | ||
| --scrollback Include scrollback (not just visible viewport) | ||
| --lines <n> Limit to the last n lines (implies --scrollback) | ||
|
|
||
| Example: | ||
| cmux read-screen | ||
| cmux read-screen --surface surface:2 --scrollback --lines 200 | ||
| """ | ||
| case "send": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) | ||
|
|
||
| Example: | ||
| cmux send "echo hello" | ||
| cmux send --surface surface:2 "ls -la\\n" | ||
| """ | ||
| case "send-key": | ||
| return """ | ||
| Usage: cmux send-key [flags] [--] <key> | ||
|
|
||
| Send a key event to a terminal surface. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) | ||
|
|
||
| Example: | ||
| cmux send-key enter | ||
| cmux send-key --surface surface:2 ctrl+c | ||
| """ | ||
| case "send-panel": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux send-panel --panel surface:2 "echo hello\\n" | ||
| """ | ||
| case "send-key-panel": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux send-key-panel --panel surface:2 enter | ||
| cmux send-key-panel --panel surface:2 ctrl+c | ||
| """ | ||
| case "notify": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) | ||
|
|
||
| Example: | ||
| cmux notify --title "Build done" --body "All tests passed" | ||
| cmux notify --title "Error" --subtitle "test.swift" --body "Line 42: syntax error" | ||
| """ | ||
| case "list-notifications": | ||
| return """ | ||
| Usage: cmux list-notifications | ||
|
|
||
| List queued notifications. | ||
| """ | ||
| case "dismiss-notification": | ||
| return String(localized: "cli.help.dismissNotification", defaultValue: """ | ||
| Usage: cmux dismiss-notification (--id <uuid> | --all-read) | ||
|
|
||
| Remove one notification, or remove every already-read notification. | ||
|
|
||
| Flags: | ||
| --id <uuid> Notification id to remove | ||
| --all-read Remove every already-read notification | ||
| --json Print JSON | ||
| --id-format <mode> refs, uuids, or both | ||
| """) | ||
| case "mark-notification-read": | ||
| return String(localized: "cli.help.markNotificationRead", defaultValue: """ | ||
| Usage: cmux mark-notification-read (--id <uuid> | --workspace <id|ref> [--surface <id|ref>] | --all) | ||
|
|
||
| Mark notifications read without opening them. Exactly one selector is required. | ||
|
|
||
| Flags: | ||
| --id <uuid> Mark one notification read | ||
| --workspace <id|ref> Mark notifications for a workspace | ||
| --surface <id|ref> Narrow --workspace to one surface | ||
| --all Mark every notification read | ||
| --json Print JSON | ||
| --id-format <mode> refs, uuids, or both | ||
| """) | ||
| case "open-notification": | ||
| return String(localized: "cli.help.openNotification", defaultValue: """ | ||
| Usage: cmux open-notification --id <uuid> | ||
|
|
||
| Focus the notification's workspace and surface, then mark the row read. | ||
|
|
||
| Flags: | ||
| --id <uuid> Notification id to open | ||
| --json Print JSON | ||
| --id-format <mode> refs, uuids, or both | ||
| """) | ||
| case "jump-to-unread": | ||
| return String(localized: "cli.help.jumpToUnread", defaultValue: """ | ||
| Usage: cmux jump-to-unread | ||
|
|
||
| Focus the latest unread notification, matching the Notifications page action. | ||
|
|
||
| Flags: | ||
| --json Print JSON | ||
| --id-format <mode> refs, uuids, or both | ||
| """) | ||
| case "clear-notifications": | ||
| return """ | ||
| Usage: cmux clear-notifications | ||
|
|
||
| Clear all queued notifications. | ||
| """ | ||
| case "set-status": | ||
| return String(localized: "cli.help.setStatus", defaultValue: """ | ||
| Usage: cmux 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") | ||
| --priority <n> Sort priority; higher appears first (default: 0) | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux set-status build "compiling" --icon hammer --color "#ff9500" --priority 80 | ||
| cmux set-status deploy "v1.2.3" --workspace workspace:2 | ||
| """) | ||
| case "clear-status": | ||
| return """ | ||
| Usage: cmux clear-status <key> [flags] | ||
|
|
||
| Remove a sidebar status entry by key. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux clear-status build | ||
| """ | ||
| case "list-status": | ||
| return """ | ||
| Usage: cmux list-status [flags] | ||
|
|
||
| List all sidebar status entries for a workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux list-status | ||
| cmux list-status --workspace workspace:2 | ||
| """ | ||
| case "set-progress": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux set-progress 0.5 --label "Building..." | ||
| cmux set-progress 1.0 --label "Done" | ||
| """ | ||
| case "clear-progress": | ||
| return """ | ||
| Usage: cmux clear-progress [flags] | ||
|
|
||
| Clear the sidebar progress bar for a workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux clear-progress | ||
| """ | ||
| case "log": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux log "Build started" | ||
| cmux log --level error --source build "Compilation failed" | ||
| cmux log --level success -- "All 42 tests passed" | ||
| """ | ||
| case "clear-log": | ||
| return """ | ||
| Usage: cmux clear-log [flags] | ||
|
|
||
| Clear all sidebar log entries for a workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux clear-log | ||
| """ | ||
| case "list-log": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux list-log | ||
| cmux list-log --limit 5 | ||
| """ | ||
| case "sidebar-state": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux sidebar-state | ||
| cmux sidebar-state --workspace workspace:2 | ||
| """ | ||
| case "right-sidebar": | ||
| return String(localized: "cli.rightSidebar.usage", defaultValue: """ | ||
| Usage: cmux right-sidebar <command> [flags] | ||
|
|
||
| Control the right sidebar from the CLI. | ||
|
|
||
| Commands: | ||
| toggle Toggle right sidebar visibility | ||
| show Show the right sidebar | ||
| hide Hide the right sidebar | ||
| focus Focus the current right sidebar mode | ||
| set <files|find|vault|sessions|feed|dock> | ||
| Show, switch mode, and focus | ||
| mode Print {"visible":bool,"mode":string} | ||
| files|find|vault|sessions|feed|dock | ||
| Alias for show + set + focus | ||
|
|
||
| Flags: | ||
| --workspace <id|ref|index> Target the window containing a workspace | ||
| --window <id|ref|index> Target a window | ||
| --no-focus With set, switch mode without moving focus | ||
|
|
||
| Examples: | ||
| cmux right-sidebar toggle | ||
| cmux right-sidebar set find | ||
| cmux right-sidebar set vault --no-focus | ||
| cmux right-sidebar mode | ||
| """) | ||
| case "set-app-focus": | ||
| return """ | ||
| Usage: cmux set-app-focus <active|inactive|clear> | ||
|
|
||
| Override app focus state for notification routing tests. | ||
|
|
||
| Example: | ||
| cmux set-app-focus inactive | ||
| cmux set-app-focus clear | ||
| """ | ||
| case "simulate-app-active": | ||
| return """ | ||
| Usage: cmux simulate-app-active | ||
|
|
||
| Trigger the app-active handler used by notification focus tests. | ||
| """ | ||
| case "claude-hook": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) | ||
|
|
||
| Example: | ||
| echo '{"session_id":"abc"}' | cmux claude-hook session-start | ||
| echo '{}' | cmux claude-hook stop | ||
| """ | ||
| case "codex": | ||
| return """ | ||
| Usage: cmux codex <install-hooks|uninstall-hooks> | ||
|
|
||
| Manage Codex CLI hooks integration. | ||
|
|
||
| Subcommands: | ||
| install-hooks Install cmux hooks into ~/.codex/hooks.json | ||
| uninstall-hooks Remove cmux hooks from ~/.codex/hooks.json | ||
| """ | ||
| case "browser": | ||
| return """ | ||
| Usage: cmux 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>] [--focus <true|false>] | ||
| open/open-split/new default to $CMUX_WORKSPACE_ID when --workspace is omitted and --window is not set | ||
| --focus defaults to false | ||
| disable | enable | status | ||
| 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>] | ||
| profiles <list|add|rename|clear|delete> [...] | ||
| import [--interactive|--non-interactive|-y|--yes] [--from <browser>] [--profile <name>] [--all-profiles] [--to-profile <name|uuid>] [--create-profile] [--domain <domain>] | ||
| 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: | ||
| cmux browser open https://example.com | ||
| cmux browser surface:1 navigate https://google.com | ||
| cmux browser --surface surface:1 snapshot --interactive | ||
| """ | ||
| // Legacy browser aliases — point users to `cmux browser --help` | ||
| case "open-browser": | ||
| return "Legacy alias for 'cmux browser open'. Run 'cmux browser --help' for details." | ||
| case "navigate": | ||
| return "Legacy alias for 'cmux browser navigate'. Run 'cmux browser --help' for details." | ||
| case "browser-back": | ||
| return "Legacy alias for 'cmux browser back'. Run 'cmux browser --help' for details." | ||
| case "browser-forward": | ||
| return "Legacy alias for 'cmux browser forward'. Run 'cmux browser --help' for details." | ||
| case "browser-reload": | ||
| return "Legacy alias for 'cmux browser reload'. Run 'cmux browser --help' for details." | ||
| case "get-url": | ||
| return "Legacy alias for 'cmux browser get-url'. Run 'cmux browser --help' for details." | ||
| case "focus-webview": | ||
| return "Legacy alias for 'cmux browser focus-webview'. Run 'cmux browser --help' for details." | ||
| case "is-webview-focused": | ||
| return "Legacy alias for 'cmux browser is-webview-focused'. Run 'cmux browser --help' for details." | ||
| case "open": return openSubcommandUsage() | ||
| case "markdown": | ||
| return """ | ||
| Usage: cmux markdown open <path> [options] | ||
| cmux 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: $CMUX_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) | ||
| --focus <true|false> Focus the markdown panel (default: false) | ||
|
|
||
| Examples: | ||
| cmux markdown open plan.md | ||
| cmux markdown ~/project/CHANGELOG.md | ||
| cmux markdown open ./docs/design.md --workspace 0 | ||
| cmux markdown open plan.md --direction down | ||
| """ | ||
| default: | ||
| return nil | ||
| } | ||
| } |
There was a problem hiding this comment.
Inconsistent localization of user-facing help text.
The subcommandUsage(_:) method returns user-facing CLI help text, but only ~10 out of 100+ commands use String(localized:defaultValue:) for proper internationalization. Most commands return plain multi-line strings that cannot be translated.
Commands with localization (lines 251, 272, 290, 313, 335, 1281, 1293, 1307, 1318, 1334, 1459):
- claude-teams, codex-teams, omo, omx, omc
- dismiss-notification, mark-notification-read, open-notification, jump-to-unread
- set-status, right-sidebar
Commands without localization (90%+ of the file):
- ping, capabilities, events, auth, vm, rpc, help, docs, settings, config, welcome, shortcuts, browser commands, workspace commands, surface commands, tmux compatibility commands, and many more
This violates the full internationalization guideline: user-facing Swift text must use String(localized:defaultValue:) or an equivalent localized API with matching translated string-catalog entries. CLI help text is user-facing and must be fully localized.
Required changes:
- Wrap all help text in
String(localized: "cli.<command>.usage", defaultValue: """...""") - Add corresponding entries to the app's string catalog for all supported locales
- Follow the pattern already established for commands like
claude-teams(line 251) andright-sidebar(line 1459)
As per coding guidelines, for production changes, fail when the diff violates full-internationalization.md: user-facing Swift text must use localized APIs with matching translated string-catalog entries.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@CLI/CMUXCLI`+Usage.swift around lines 5 - 1645, The subcommandUsage(_:)
function contains many user-facing hardcoded help strings; replace each plain
multiline return with the localized form String(localized:
"cli.<command>.usage", defaultValue: """...""") following the existing examples
(e.g., claude-teams, right-sidebar) so every command (ping, capabilities,
events, auth, vm/cloud, browser, workspace/surface commands, etc.) uses a unique
"cli.<command>.usage" key; update the app string catalog with matching entries
for supported locales and ensure the defaultValue contains the current English
text exactly as before to preserve behavior.
| "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/bin/ghostty", | ||
| "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/ghostty", | ||
| "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/shell-integration", | ||
| "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/terminfo", | ||
| ); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's locate and examine the project file structure
cd cmux.xcodeproj && wc -l project.pbxprojRepository: manaflow-ai/cmux
Length of output: 81
🏁 Script executed:
# Let's look at the lines around 1917-1921 in context
sed -n '1900,1950p' cmux.xcodeproj/project.pbxproj | cat -nRepository: manaflow-ai/cmux
Length of output: 5576
🏁 Script executed:
# Search for PlistBuddy references which is mentioned as writing Info.plist
rg -n "PlistBuddy" cmux.xcodeproj/project.pbxprojRepository: manaflow-ai/cmux
Length of output: 3496
🏁 Script executed:
# Search for build phases that might be related to commit stamping or Info.plist
rg -n "Info\.plist|INFOPLIST_PATH" cmux.xcodeproj/project.pbxproj | head -20Repository: manaflow-ai/cmux
Length of output: 3592
🏁 Script executed:
# Search for CMUXCommit to understand the context
rg -n "CMUXCommit" cmux.xcodeproj/project.pbxproj | head -20Repository: manaflow-ai/cmux
Length of output: 3496
🏁 Script executed:
# Look for outputPaths declarations in the file
rg -n "outputPaths" cmux.xcodeproj/project.pbxproj | head -20Repository: manaflow-ai/cmux
Length of output: 132
Add Info.plist to outputPaths and track git ref as input to ensure CMUXCommit is always stamped.
This build phase modifies $(TARGET_BUILD_DIR)/$(INFOPLIST_PATH) via PlistBuddy to set the CMUXCommit value, but the file is not declared in outputPaths and the git ref dependency is not tracked in inputPaths. With dependency analysis enabled, Xcode may skip this phase when only source code changes, causing the app to retain a stale CMUXCommit stamp. Either add the Info.plist file to outputPaths and track the git HEAD as an input, or move commit stamping to a separate phase marked runOnlyForDeploymentPostprocessing = 1.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cmux.xcodeproj/project.pbxproj` around lines 1917 - 1921, The build phase
that uses PlistBuddy to set CMUXCommit is missing the Info.plist from
outputPaths and the git ref from inputPaths, so Xcode's dependency analysis can
skip it; add "$(TARGET_BUILD_DIR)/$(INFOPLIST_PATH)" to the phase's outputPaths
and add the repository HEAD (e.g. "$(SRCROOT)/.git/HEAD") to the phase's
inputPaths to force rerun when the commit changes, or alternatively move the
stamping into a new build phase with runOnlyForDeploymentPostprocessing = 1 so
it's always executed during deployment.
| C510C1E10000000000000002 /* SocketClient.swift in Sources */, | ||
| C510C1E20000000000000002 /* CMUXCLI+Usage.swift in Sources */, |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, check the file structure and locate the project
find . -name "project.pbxproj" -type f | head -5Repository: manaflow-ai/cmux
Length of output: 93
🏁 Script executed:
# Read the relevant section around lines 2245-2246
sed -n '2240,2250p' cmux.xcodeproj/project.pbxprojRepository: manaflow-ai/cmux
Length of output: 763
🏁 Script executed:
# Search for SWIFT_EMIT_LOC_STRINGS in the project file
rg "SWIFT_EMIT_LOC_STRINGS" cmux.xcodeproj/project.pbxproj -A 1 -B 1Repository: manaflow-ai/cmux
Length of output: 395
🏁 Script executed:
# Search for CMUXCLI+Usage.swift references in the project
rg "CMUXCLI\+Usage" cmux.xcodeproj/project.pbxprojRepository: manaflow-ai/cmux
Length of output: 511
🏁 Script executed:
# Find the cmux-cli and cmux target build settings sections
rg "cmux-cli|cmux" cmux.xcodeproj/project.pbxproj | grep -i "target\|settings" | head -20Repository: manaflow-ai/cmux
Length of output: 4624
🏁 Script executed:
# Get more context around the SWIFT_EMIT_LOC_STRINGS lines to identify which target
rg "SWIFT_EMIT_LOC_STRINGS" cmux.xcodeproj/project.pbxproj -B 10 -A 2 | head -50Repository: manaflow-ai/cmux
Length of output: 851
🏁 Script executed:
# Search for cmux-cli build configuration section to check its settings
rg "B9000008A1B2C3D4E5F60719|B9000009A1B2C3D4E5F60719" cmux.xcodeproj/project.pbxproj -A 20 | head -100Repository: manaflow-ai/cmux
Length of output: 1985
🏁 Script executed:
# Find which targets include CMUXCLI+Usage.swift by searching build phases
rg -B 5 "CMUXCLI\+Usage.*in Sources" cmux.xcodeproj/project.pbxprojRepository: manaflow-ai/cmux
Length of output: 1397
Wire CMUXCLI+Usage.swift into string-catalog extraction.
This file owns localized CLI help text but is compiled only into cmux-cli, which lacks SWIFT_EMIT_LOC_STRINGS = YES. These strings will not be harvested into Localizable.xcstrings, causing non-English help to silently fall back. Enable SWIFT_EMIT_LOC_STRINGS for cmux-cli or compile the usage helpers in a target that already participates in extraction, as required by the project's localization expectations.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cmux.xcodeproj/project.pbxproj` around lines 2245 - 2246, CMUXCLI+Usage.swift
contains localized CLI help but isn't being included in string extraction
because the cmux-cli target doesn't have SWIFT_EMIT_LOC_STRINGS enabled; fix by
either enabling the build setting SWIFT_EMIT_LOC_STRINGS = YES for the cmux-cli
target or moving/adding CMUXCLI+Usage.swift to a target that already
participates in localization extraction (e.g., the main app/library target) so
its strings are harvested into Localizable.xcstrings; update the project.pbxproj
target settings or target membership accordingly and verify extraction picks up
the strings.
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CLI/CMUXCLI`+Usage.swift:
- Around line 3-1846: The file exceeds the 800-line guideline; split the large
CMUXCLI extension into category-specific extensions and have subcommandUsage(_:)
delegate to them: extract related switch cases into new files implementing
helper methods (e.g., CMUXCLI+Usage+Core.swift, CMUXCLI+Usage+Workspace.swift,
CMUXCLI+Usage+Browser.swift, CMUXCLI+Usage+Tmux.swift,
CMUXCLI+Usage+Notifications.swift) each providing a categorySubcommandUsage(_:)
used by subcommandUsage(_:), keep dispatchSubcommandHelp(command:commandArgs:)
and usage() intact but routing to the new helpers; ensure symbol names
(subcommandUsage(_:), dispatchSubcommandHelp(command:commandArgs:), usage())
remain and update visibility/imports so compilation is unchanged.
In `@CLI/SocketClient.swift`:
- Around line 167-263: The send(command:responseTimeout:) method (and other
CLI-facing error/heading literals in this file) uses raw English strings like
"Not connected", "Command timed out", "Socket read error", "Socket closed before
reply", "Socket closed before complete reply", "Invalid UTF-8 response" (and
similar literals in the other ranges) — replace each user-visible literal with
the repo's localization API (e.g. String(localized:defaultValue:)) and pass the
localized string into CLIError initializers and any headings/messages returned
to the user; add matching entries to the translation catalog with clear keys and
context, and ensure you update all occurrences in send(command:), and the other
code blocks indicated (lines ~274-285, 401-477, 499-599, 611-699, 734-925) to
use the same localized API.
- Around line 601-650: The function waitForConnectableSocket currently gives up
and tries file-system watching when the initial client.connect() fails, which
breaks relay paths (host:port) because existingWatchDirectory(forPath:) returns
nil; instead detect relay endpoints (client.relayEndpoint != nil or path
contains host:port) and, when present, start a retry loop/timer that repeatedly
calls attemptConnect() until timeout rather than falling back to
existingWatchDirectory; specifically, change the logic after the first failed
connect so that if client.relayEndpoint != nil you skip the
existingWatchDirectory guard and set up a DispatchSource timer or
DispatchQueue.asyncAfter loop that calls attemptConnect() until semaphore
signals or timeout elapses, then cancel/close like the current file-watch path
does.
- Around line 6-927: The file is too large and mixes responsibilities (socket
transport, relay auth, JSON-RPC v2 formatting/streaming, telemetry, filesystem
watching); split it into smaller, single-responsibility Swift files. Extract
core transport/socket lifecycle into SocketTransport (retain SocketClient or
rename but move connect/connectOnce, socketFD management, read/write helpers
like writeAll, configureSocketWriteSafety, configureReceiveTimeout,
connectionAppearsOpen, close, waitForConnectableSocket, waitForFilesystemPath,
existingWatchDirectory), move relay-specific code into SocketRelay
(RelayEndpoint/RelayCredentials, parseRelayEndpoint, relayCredentials,
connectToRelay, authenticateRelay, readLine), move JSON-RPC v2 logic into
SocketClientV2 (sendV2, streamV2, readStreamLine, formatV2Error, safeV2Details,
trimmedNonEmptyV2Text, indentV2ErrorLines), and move telemetry/state types
(CLISocketOperationTelemetry.State and recordOperation usage) into its own
telemetry file; also move hex helpers (hexData/hexString) into a small util
file. Update visibility (private/internal) and imports so each file compiles
independently and adjust references to the moved symbols (e.g., connectToRelay,
authenticateRelay, relayCredentials, sendV2, streamV2, writeAll) across files.
- Around line 870-897: streamV2() currently errors immediately if socketFD < 0,
preventing relay-backed clients from being connected; change the start of
streamV2() to attempt to establish a real connection by invoking the existing
connect() behavior (same path send() uses) when socketFD < 0, then re-check
socketFD and only throw CLIError if it still < 0; specifically, replace the
initial guard that throws with a conditional that calls try connect() (or the
appropriate connect method used elsewhere), then proceed to create and send the
request via writeAll() and read lines via readStreamLine() as before.
In `@scripts/reload.sh`:
- Around line 623-626: When updating the same-tag bundle, stop the running
same-tag app before mutating its bundle: detect the running instance for the
current TAG/APP_NAME (compare TAG and SEARCH_APP_NAME as in the existing check),
terminate or gracefully stop that process (so the live app isn't operating on a
partially rsynced TAG_APP_PATH) and only then run the rsync into TAG_APP_PATH;
alternatively, perform the rsync into a temporary directory (e.g.
TAG_APP_PATH.tmp) and, after a successful sync, atomically swap or move the temp
dir into TAG_APP_PATH to avoid in-place mutation while the process is live.
Ensure you reference TAG, APP_NAME, SEARCH_APP_NAME, TAG_APP_PATH and the rsync
step when applying the change.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 1787a259-9bda-4ee4-af53-459dce680f08
📒 Files selected for processing (4)
CLI/CMUXCLI+Usage.swiftCLI/SocketClient.swiftCLI/cmux.swiftscripts/reload.sh
| extension CMUXCLI { | ||
| /// Return the help/usage text for a subcommand, or nil if the command is unknown. | ||
| func subcommandUsage(_ command: String) -> String? { | ||
| switch command { | ||
| case "ping": | ||
| return """ | ||
| Usage: cmux ping | ||
|
|
||
| Check connectivity to the cmux socket server. | ||
| """ | ||
| case "capabilities": | ||
| return """ | ||
| Usage: cmux capabilities | ||
|
|
||
| Print server capabilities as JSON. | ||
| """ | ||
| case "events": | ||
| return """ | ||
| Usage: cmux events [options] | ||
|
|
||
| Stream cmux events as newline-delimited JSON. | ||
|
|
||
| Options: | ||
| --after <seq> Replay retained events after this sequence | ||
| --cursor-file <path> Read the starting sequence from a file and update it after each event | ||
| --name <event> Filter by event name, repeatable | ||
| --category <name> Filter by category, repeatable | ||
| --reconnect Reconnect forever and resume from the last received sequence | ||
| --limit <n> Exit after printing n event frames | ||
| --no-ack Do not print the subscription ack frame | ||
| --no-heartbeat Do not print heartbeat frames | ||
|
|
||
| Examples: | ||
| cmux events --category notification | ||
| cmux events --cursor-file ~/.cache/cmux/events.seq --reconnect | ||
| cmux events --after 42 --name feed.item.received | ||
| """ | ||
| case "auth": | ||
| return """ | ||
| Usage: cmux auth <status|login|logout> | ||
|
|
||
| status Print whether the user is signed in (add `cmux --json` for JSON). | ||
| login Open the sign-in popup on the cmux web app and wait for it to finish. | ||
| logout Clear the current session. | ||
| """ | ||
| case "login": | ||
| return """ | ||
| Usage: cmux login | ||
|
|
||
| Alias for `cmux auth login`. | ||
| """ | ||
| case "logout": | ||
| return """ | ||
| Usage: cmux logout | ||
|
|
||
| Alias for `cmux auth logout`. | ||
| """ | ||
| case "vm", "cloud": | ||
| return """ | ||
| Usage: cmux \(command) <new|ls|rm|exec|shell|attach|ssh|ssh-info> [args...] | ||
|
|
||
| Manage cloud VMs. `cloud` is an alias for `vm`. Requires `cmux auth login`. | ||
|
|
||
| Subcommands: | ||
| ls List your cloud VMs. | ||
| new [--image <template>] [--provider <provider>] [--detach|-d] | ||
| Create a new VM. By default drops you into a shell on | ||
| the VM (like `docker run -it`). Pass --detach/-d to | ||
| just print the id and exit (scripting primitive). | ||
| shell <id> Drop into an interactive shell on an existing VM. | ||
| Alias: `attach <id>`. | ||
| ssh <id> Drop into a cmux-managed SSH workspace for an existing | ||
| VM, using the same session path as `cmux ssh`. | ||
| ssh-info <id> Print SSH connection details when the Cloud VM | ||
| exposes SSH. | ||
| rm <id> Destroy a VM. | ||
| exec <id> -- <command...> Run a shell command inside the VM and print stdout. | ||
|
|
||
| Env: | ||
| CMUX_VM_API_BASE_URL Override the backend origin (default: the cmux website). | ||
| `bun run dev` derives this from CMUX_PORT/PORT for | ||
| local testing from the web worktree. | ||
|
|
||
| Example: | ||
| cmux vm new | ||
| cmux vm ls | ||
| cmux cloud exec <id> -- echo hello | ||
| cmux vm rm <id> | ||
| """ | ||
| case "rpc": | ||
| return """ | ||
| Usage: cmux rpc <method> [json-params] | ||
|
|
||
| Call a raw v2 method with an optional JSON object for params. | ||
| Example: cmux rpc surface.report_tty '{"workspace_id":"...","surface_id":"...","tty_name":"ttys001"}' | ||
| """ | ||
| case "help": | ||
| return """ | ||
| Usage: cmux help | ||
|
|
||
| Show top-level CLI usage and command list. | ||
| Also works without a running cmux app or socket. | ||
| """ | ||
| case "docs": | ||
| return docsUsage() | ||
| case "settings": | ||
| return settingsUsage() | ||
| case "config": | ||
| return configUsage() | ||
| case "welcome": | ||
| return """ | ||
| Usage: cmux welcome | ||
|
|
||
| Show a welcome screen with the cmux logo and useful shortcuts. | ||
| Auto-runs once on first launch. | ||
| """ | ||
| case "shortcuts": | ||
| return """ | ||
| Usage: cmux shortcuts | ||
|
|
||
| Open the Settings window to Keyboard Shortcuts. | ||
| """ | ||
| case "disable-browser": | ||
| return """ | ||
| Usage: cmux disable-browser [--json] | ||
|
|
||
| Disable cmux browser creation and link interception. This overrides | ||
| browser settings from cmux.json until re-enabled. | ||
| """ | ||
| case "enable-browser": | ||
| return """ | ||
| Usage: cmux enable-browser [--json] | ||
|
|
||
| Re-enable cmux browser creation and link interception. | ||
| """ | ||
| case "browser-status": | ||
| return """ | ||
| Usage: cmux browser-status [--json] | ||
|
|
||
| Print whether cmux browser creation and link interception are enabled. | ||
| """ | ||
| case "restore-session": | ||
| return """ | ||
| Usage: cmux restore-session | ||
|
|
||
| Reopen the previous saved cmux session. | ||
|
|
||
| If the app is already running, this restores the last saved session into the current app. | ||
| If the app is not running, this launches cmux and lets startup restore reopen the saved session. | ||
| """ | ||
| case "feedback": | ||
| return """ | ||
| Usage: cmux feedback | ||
| cmux feedback --email <email> --body <text> [--image <path> ...] | ||
|
|
||
| Without args, open the Send Feedback modal in the running app. | ||
|
|
||
| With args, submit feedback through the app using the same feedback pipeline as the modal. | ||
|
|
||
| Flags: | ||
| --email <email> Contact email for follow-up | ||
| --body <text> Feedback body | ||
| --image <path> Attach an image file, repeat for multiple images | ||
|
|
||
| Coding agents: | ||
| Double check with the end user before sending anything. Review the message and attachments for secrets, | ||
| private code, credentials, tokens, and other sensitive information first. | ||
| """ | ||
| case "feed": | ||
| return """ | ||
| Usage: cmux feed tui [--opentui|--legacy] | ||
| cmux feed clear [--yes|-y] | ||
|
|
||
| Open the keyboard-first Feed TUI or manage persisted Feed workstream history. | ||
|
|
||
| TUI options: | ||
| --opentui Force the OpenTUI implementation and fail if unavailable | ||
| --legacy Force the older built-in Swift TUI | ||
| """ | ||
| case "hooks": | ||
| return """ | ||
| Usage: cmux hooks setup [agent] [--agent <name>] [--yes|-y] | ||
| cmux hooks uninstall [agent] [--agent <name>] [--yes|-y] | ||
| cmux hooks <agent> install [--yes|-y] (opencode supports --project) | ||
| cmux hooks <agent> uninstall [--yes|-y] (opencode supports --project) | ||
| cmux hooks <agent> <event> [flags] | ||
| cmux hooks feed --source <agent> [--event <event>] | ||
|
|
||
| Manage and run cmux agent hooks without adding one top-level command per | ||
| agent. Claude Code hooks are injected automatically by the cmux Claude wrapper. | ||
|
|
||
| Agents: | ||
| codex, grok, opencode, pi, amp, cursor, gemini, rovodev (alias: rovo), hermes-agent, copilot, codebuddy, factory, qoder | ||
|
|
||
| Hook targets: | ||
| setup Install hooks for all supported agents on PATH | ||
| uninstall Remove hooks for all supported agents | ||
| <agent> install Install one agent integration | ||
| <agent> uninstall Remove one agent integration | ||
| <agent> <event> Internal hook entrypoint used by generated configs | ||
| feed Internal Feed decision bridge | ||
|
|
||
| Generated files: | ||
| ~/.config/opencode/plugins/cmux-session.js | ||
| ~/.config/opencode/plugins/cmux-feed.js | ||
| ~/.pi/agent/extensions/cmux-session.ts | ||
| ~/.config/amp/plugins/cmux-session.ts | ||
| See docs/agent-hooks.md for the full integration matrix. | ||
|
|
||
| Examples: | ||
| cmux hooks setup | ||
| cmux hooks setup --agent codex | ||
| cmux hooks setup rovo | ||
| cmux hooks uninstall rovo | ||
| cmux hooks codex install | ||
| cmux hooks opencode install --project | ||
| cmux hooks uninstall | ||
| """ | ||
| case "themes": | ||
| return """ | ||
| Usage: cmux themes | ||
| cmux themes list | ||
| cmux themes set <theme> | ||
| cmux themes set --light <theme> [--dark <theme>] | ||
| cmux themes set --dark <theme> [--light <theme>] | ||
| cmux themes clear | ||
|
|
||
| When run in a TTY, `cmux themes` opens an interactive theme picker with | ||
| live app preview. Use `cmux themes list` for a plain listing. | ||
|
|
||
| The picker previews the selected theme across the running cmux 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 cmux theme override and fall back to other config | ||
|
|
||
| Examples: | ||
| cmux themes | ||
| cmux themes list | ||
| cmux themes set "Catppuccin Mocha" | ||
| cmux themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha" | ||
| cmux themes clear | ||
| """ | ||
| case "claude-teams": | ||
| return String(localized: "cli.claude-teams.usage", defaultValue: """ | ||
| Usage: cmux 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 cmux 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 cmux | ||
| workspace and split operations in the current cmux session. | ||
|
|
||
| Examples: | ||
| cmux claude-teams | ||
| cmux claude-teams --continue | ||
| cmux claude-teams --model sonnet | ||
| """) | ||
| case "codex-teams": | ||
| return String(localized: "cli.codex-teams.usage", defaultValue: """ | ||
| Usage: cmux codex-teams [codex-args...] | ||
|
|
||
| Launch Codex with cmux-managed subagent panes. | ||
|
|
||
| This command: | ||
| - starts a private Codex app-server on localhost | ||
| - launches the root Codex TUI against that app-server | ||
| - watches live Codex thread-spawn subagents | ||
| - opens subagents up to depth 2 as native cmux splits | ||
| - forwards all remaining arguments to codex | ||
|
|
||
| Examples: | ||
| cmux codex-teams | ||
| cmux codex-teams --model gpt-5.4 | ||
| cmux codex-teams resume --last | ||
| """) | ||
| case "omo": | ||
| return String(localized: "cli.omo.usage", defaultValue: """ | ||
| Usage: cmux omo [opencode-args...] | ||
|
|
||
| Launch OpenCode with oh-my-openagent in a cmux-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 | ||
| cmux splits with sidebar metadata and notifications. | ||
|
|
||
| This command: | ||
| - sets a tmux-like environment so oh-my-openagent uses cmux splits | ||
| - prepends a private tmux shim to PATH | ||
| - forwards all remaining arguments to opencode | ||
|
|
||
| The tmux shim translates tmux window/pane commands into cmux workspace | ||
| and split operations in the current cmux session. | ||
|
|
||
| Examples: | ||
| cmux omo | ||
| cmux omo --continue | ||
| cmux omo --model claude-sonnet-4-6 | ||
| """) | ||
| case "omx": | ||
| return String(localized: "cli.omx.usage", defaultValue: """ | ||
| Usage: cmux omx [omx-args...] | ||
|
|
||
| Launch Oh My Codex (OMX) with native cmux 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 cmux splits. | ||
|
|
||
| This command: | ||
| - sets a tmux-like environment so OMX uses cmux splits | ||
| - prepends a private tmux shim to PATH | ||
| - forwards all remaining arguments to omx | ||
|
|
||
| Install: npm install -g oh-my-codex | ||
|
|
||
| Examples: | ||
| cmux omx | ||
| cmux omx --madmax --high | ||
| cmux omx team | ||
| """) | ||
| case "omc": | ||
| return String(localized: "cli.omc.usage", defaultValue: """ | ||
| Usage: cmux omc [omc-args...] | ||
|
|
||
| Launch Oh My Claude Code (OMC) with native cmux 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 cmux splits. | ||
|
|
||
| This command: | ||
| - sets a tmux-like environment so OMC uses cmux 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: | ||
| cmux omc | ||
| cmux omc team 3:claude "implement feature" | ||
| cmux omc --watch | ||
| """) | ||
| case "identify": | ||
| return """ | ||
| Usage: cmux identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] | ||
|
|
||
| Print server identity and caller context details. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref|index> Caller workspace context (default: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref|index> Caller surface context (default: $CMUX_SURFACE_ID) | ||
| --no-caller Omit caller context from the request | ||
| """ | ||
| case "list-windows": | ||
| return """ | ||
| Usage: cmux list-windows | ||
|
|
||
| List open windows. | ||
| """ | ||
| case "current-window": | ||
| return """ | ||
| Usage: cmux current-window | ||
|
|
||
| Print the currently selected window ID. | ||
| """ | ||
| case "new-window": | ||
| return """ | ||
| Usage: cmux new-window | ||
|
|
||
| Create a new window. | ||
|
|
||
| Example: | ||
| cmux new-window | ||
| """ | ||
| case "focus-window": | ||
| return """ | ||
| Usage: cmux focus-window --window <id|ref|index> | ||
|
|
||
| Focus (bring to front) the specified window. | ||
|
|
||
| Flags: | ||
| --window <id|ref|index> Window to focus (required) | ||
|
|
||
| Example: | ||
| cmux focus-window --window 0 | ||
| cmux focus-window --window window:1 | ||
| """ | ||
| case "close-window": | ||
| return """ | ||
| Usage: cmux close-window --window <id|ref|index> | ||
|
|
||
| Close the specified window. | ||
|
|
||
| Flags: | ||
| --window <id|ref|index> Window to close (required) | ||
|
|
||
| Example: | ||
| cmux close-window --window 0 | ||
| cmux close-window --window window:1 | ||
| """ | ||
| case "move-workspace-to-window": | ||
| return """ | ||
| Usage: cmux move-workspace-to-window --workspace <id|ref|index> --window <id|ref|index> | ||
|
|
||
| Move a workspace to a different window. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref|index> Workspace to move (required) | ||
| --window <id|ref|index> Target window (required) | ||
|
|
||
| Example: | ||
| cmux move-workspace-to-window --workspace workspace:2 --window window:1 | ||
| """ | ||
| case "move-surface": | ||
| return """ | ||
| Usage: cmux move-surface [--surface <id|ref|index> | <id|ref|index>] [flags] | ||
|
|
||
| Move a surface to a different pane, workspace, or window. | ||
|
|
||
| Flags: | ||
| --surface <id|ref|index> Surface to move (required unless passed positionally) | ||
| --pane <id|ref|index> Target pane | ||
| --workspace <id|ref|index> Target workspace | ||
| --window <id|ref|index> Target window | ||
| --before <id|ref|index> Place before this surface | ||
| --before-surface <id|ref|index> | ||
| Alias for --before | ||
| --after <id|ref|index> Place after this surface | ||
| --after-surface <id|ref|index> | ||
| Alias for --after | ||
| --index <n> Place at this index | ||
| --focus <true|false> Focus the surface after moving | ||
|
|
||
| Example: | ||
| cmux move-surface --surface surface:1 --workspace workspace:2 | ||
| cmux move-surface surface:1 --pane pane:2 --index 0 | ||
| """ | ||
| case "reorder-surface": | ||
| return """ | ||
| Usage: cmux reorder-surface [--surface <id|ref|index> | <id|ref|index>] [flags] | ||
|
|
||
| Reorder a surface within its pane. | ||
|
|
||
| Flags: | ||
| --surface <id|ref|index> Surface to reorder (required unless passed positionally) | ||
| --workspace <id|ref|index> Workspace context | ||
| --before <id|ref|index> Place before this surface | ||
| --before-surface <id|ref|index> | ||
| Alias for --before | ||
| --after <id|ref|index> Place after this surface | ||
| --after-surface <id|ref|index> | ||
| Alias for --after | ||
| --index <n> Place at this index | ||
| --focus <true|false> Focus the surface after reordering | ||
|
|
||
| Example: | ||
| cmux reorder-surface --surface surface:1 --index 0 | ||
| cmux reorder-surface --surface surface:3 --after surface:1 | ||
| """ | ||
| case "reorder-workspace": | ||
| return """ | ||
| Usage: cmux reorder-workspace [--workspace <id|ref|index> | <id|ref|index>] [flags] | ||
|
|
||
| Reorder a workspace within its window. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref|index> Workspace to reorder (required unless passed positionally) | ||
| --index <n> Place at this index | ||
| --before <id|ref|index> Place before this workspace | ||
| --before-workspace <id|ref|index> | ||
| Alias for --before | ||
| --after <id|ref|index> Place after this workspace | ||
| --after-workspace <id|ref|index> | ||
| Alias for --after | ||
| --window <id|ref|index> Window context | ||
|
|
||
| Example: | ||
| cmux reorder-workspace --workspace workspace:2 --index 0 | ||
| cmux reorder-workspace --workspace workspace:3 --after workspace:1 | ||
| """ | ||
| case "workspace-action": | ||
| return """ | ||
| Usage: cmux 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|index> Target workspace (default: current/$CMUX_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: | ||
| cmux workspace-action --workspace workspace:2 --action pin | ||
| cmux workspace-action --action rename --title "infra" | ||
| cmux workspace-action close-others | ||
| cmux workspace-action --action set-color --color blue | ||
| cmux workspace-action --action set-color --color "#C0392B" | ||
| cmux workspace-action set-color Amber | ||
| cmux workspace-action --action set-description --description "Ship checklist" | ||
| cmux workspace-action --action set-description $'Ship checklist\n- verify build\n- post notes' | ||
| cmux workspace-action clear-color | ||
| """ | ||
| case "tab-action": | ||
| return """ | ||
| Usage: cmux 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 | new-browser-right | ||
| move-to-new-workspace | ||
| reload | duplicate | ||
| pin | unpin | ||
| mark-unread | ||
|
|
||
| Flags: | ||
| --action <name> Action name (required if not positional) | ||
| --tab <id|ref|index> Target tab (accepts tab:<n> or surface:<n>; default: $CMUX_TAB_ID, then $CMUX_SURFACE_ID, then focused tab) | ||
| --surface <id|ref|index> Alias for --tab (backward compatibility) | ||
| --workspace <id|ref|index> Workspace context (default: current/$CMUX_WORKSPACE_ID) | ||
| --title <text> Title for rename (or pass trailing title text) | ||
| --url <url> Optional URL for new-browser-right | ||
| --focus <true|false> Focus the destination when supported (default: false for move-to-new-workspace) | ||
|
|
||
| Example: | ||
| cmux tab-action --tab tab:3 --action pin | ||
| cmux tab-action --action close-right | ||
| cmux tab-action --tab tab:2 --action move-to-new-workspace | ||
| cmux tab-action --tab tab:2 --action rename --title "build logs" | ||
| """ | ||
| case "move-tab-to-new-workspace", "detach-tab": | ||
| return Self.moveTabToNewWorkspaceCommandHelp | ||
| case "rename-tab": | ||
| return """ | ||
| Usage: cmux 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) $CMUX_TAB_ID / $CMUX_SURFACE_ID | ||
| 4) currently focused tab (optionally within --workspace) | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: current/$CMUX_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: | ||
| cmux rename-tab "build logs" | ||
| cmux rename-tab --tab tab:3 "staging server" | ||
| cmux rename-tab --workspace workspace:2 --surface surface:5 --title "agent run" | ||
| """ | ||
| case "new-workspace": | ||
| return """ | ||
| Usage: cmux new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] | ||
|
|
||
| Create a new workspace in the caller's 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 | ||
| --layout <json> Create workspace with a predefined split layout (inline JSON). | ||
| Uses the same schema as cmux.json layout definitions. | ||
| When provided, --command is ignored (layout surfaces define their own commands). | ||
| --window <id|ref|index> | ||
| Target window (default: caller's window from $CMUX_WORKSPACE_ID/$CMUX_SURFACE_ID) | ||
| --focus <true|false> Focus the new workspace (default: false) | ||
|
|
||
| Example: | ||
| cmux new-workspace | ||
| cmux new-workspace --name "Build Server" | ||
| cmux new-workspace --name "Launch" --description "Ship checklist" | ||
| cmux new-workspace --cwd ~/projects/myapp | ||
| cmux new-workspace --cwd . --command "npm test" | ||
| cmux new-workspace --name "Dev" --layout '{"direction":"horizontal","split":0.5,"children":[{"pane":{"surfaces":[{"type":"terminal","command":"vim"}]}},{"pane":{"surfaces":[{"type":"terminal","command":"npm run start"}]}}]}' | ||
| """ | ||
| case "list-workspaces": | ||
| return """ | ||
| Usage: cmux list-workspaces | ||
|
|
||
| List workspaces in the current window. | ||
|
|
||
| Example: | ||
| cmux list-workspaces | ||
| """ | ||
| case "ssh": | ||
| return """ | ||
| Usage: cmux ssh <destination> [flags] [-- <remote-command-args>] | ||
|
|
||
| Create a new workspace, mark it as remote-SSH, and start an SSH session in that workspace. | ||
| cmux 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: | ||
| cmux ssh dev@my-host | ||
| cmux ssh dev@my-host --name "gpu-box" --port 2222 --identity ~/.ssh/id_ed25519 | ||
| cmux ssh dev@my-host --ssh-option UserKnownHostsFile=/dev/null --ssh-option StrictHostKeyChecking=no | ||
| """ | ||
| case "remote-daemon-status": | ||
| return """ | ||
| Usage: cmux remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] | ||
|
|
||
| Show the embedded cmuxd-remote release manifest, local cache status, checksum verification state, | ||
| and the GitHub attestation verification command for a target platform. | ||
|
|
||
| Example: | ||
| cmux remote-daemon-status | ||
| cmux remote-daemon-status --os linux --arch arm64 | ||
| """ | ||
| case "new-split": | ||
| return """ | ||
| Usage: cmux new-split <left|right|up|down> [flags] | ||
|
|
||
| Split the current pane in the given direction. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Surface to split from (default: $CMUX_SURFACE_ID) | ||
| --panel <id|ref> Alias for --surface | ||
| --focus <true|false> Focus the new split (default: false) | ||
|
|
||
| Example: | ||
| cmux new-split right | ||
| cmux new-split down --workspace workspace:1 | ||
| """ | ||
| case "list-panes": | ||
| return """ | ||
| Usage: cmux list-panes [--workspace <id|ref>] | ||
|
|
||
| List panes in a workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux list-panes | ||
| cmux list-panes --workspace workspace:2 | ||
| """ | ||
| case "list-pane-surfaces": | ||
| return """ | ||
| Usage: cmux list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>] | ||
|
|
||
| List surfaces in a pane. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
| --pane <id|ref> Restrict to a specific pane (default: focused pane) | ||
|
|
||
| Example: | ||
| cmux list-pane-surfaces | ||
| cmux list-pane-surfaces --workspace workspace:2 --pane pane:1 | ||
| """ | ||
| case "tree": | ||
| return """ | ||
| Usage: cmux 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 `cmux tree` was invoked) | ||
| - workspace [selected] | ||
| - pane [focused] | ||
| - surface [selected] | ||
| Browser surfaces also include their current URL. | ||
|
|
||
| Example: | ||
| cmux tree | ||
| cmux tree --all | ||
| cmux tree --workspace workspace:2 | ||
| cmux --json tree --all | ||
| """ | ||
| case "top": | ||
| return """ | ||
| Usage: cmux top [flags] | ||
|
|
||
| Print CPU and RAM usage by cmux window, workspace, pane, surface, status tag, and browser webview. | ||
|
|
||
| Flags: | ||
| --all Include all windows (default: current window only) | ||
| --workspace <id|ref|index> Show only one workspace | ||
| --processes Include process trees under windows, surfaces, webviews, and tags | ||
| --sort <cpu|mem|proc> Sort sibling rows by CPU, memory, or process count | ||
| --flat Print independent rows for shell sorting | ||
| --format <tree|tsv> Text output format (tsv implies --flat) | ||
| --json Structured JSON output | ||
|
|
||
| Output: | ||
| CPU comes from macOS process accounting and can exceed 100% across cores. | ||
| Memory is summed from macOS physical footprint across the unique process IDs attributed to each tree node. | ||
| Browser webviews are attributed through their WebKit content process PID. | ||
| TSV columns are: cpu_percent, memory_bytes, process_count, kind, ref, parent_ref, title. | ||
|
|
||
| Example: | ||
| cmux top | ||
| cmux top --all | ||
| cmux top --sort cpu | ||
| cmux top --format tsv | sort -t $'\\t' -nrk1,1 | ||
| cmux top --workspace workspace:2 --processes | ||
| cmux --json top --all | ||
| """ | ||
| case "focus-pane": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux focus-pane --pane pane:2 | ||
| cmux focus-pane pane:1 | ||
| cmux focus-pane --pane pane:1 --workspace workspace:2 | ||
| """ | ||
| case "new-pane": | ||
| return """ | ||
| Usage: cmux new-pane [flags] | ||
|
|
||
| Create a new pane in the workspace. | ||
|
|
||
| Flags: | ||
| --type <terminal|browser> Pane type (default: terminal) | ||
| --direction <left|right|up|down> Split direction (default: right) | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
| --url <url> URL for browser panes | ||
| --focus <true|false> Focus the new pane (default: false) | ||
|
|
||
| Example: | ||
| cmux new-pane | ||
| cmux new-pane --type browser --direction down --url https://example.com | ||
| """ | ||
| case "new-surface": | ||
| return """ | ||
| Usage: cmux new-surface [flags] | ||
|
|
||
| Create a new surface (tab) in a pane. | ||
|
|
||
| Flags: | ||
| --type <terminal|browser> Surface type (default: terminal) | ||
| --pane <id|ref> Target pane | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
| --url <url> URL for browser surfaces | ||
| --focus <true|false> Focus the new surface (default: false) | ||
|
|
||
| Example: | ||
| cmux new-surface | ||
| cmux new-surface --type browser --pane pane:1 --url https://example.com | ||
| """ | ||
| case "close-surface": | ||
| return """ | ||
| Usage: cmux close-surface [flags] | ||
|
|
||
| Close a surface. Defaults to the focused surface if none specified. | ||
|
|
||
| Flags: | ||
| --surface <id|ref> Surface to close (default: $CMUX_SURFACE_ID) | ||
| --panel <id|ref> Alias for --surface | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux close-surface | ||
| cmux close-surface --surface surface:3 | ||
| """ | ||
| case "drag-surface-to-split": | ||
| return """ | ||
| Usage: cmux drag-surface-to-split --surface <id|ref|index> <left|right|up|down> [flags] | ||
|
|
||
| Drag a surface into a new split in the given direction. | ||
|
|
||
| Flags: | ||
| --surface <id|ref|index> Surface to drag (required) | ||
| --panel <id|ref|index> Alias for --surface | ||
| --workspace <id|ref|index> Workspace context for ref/index resolution | ||
| --focus <true|false> Focus the split-off surface (default: false) | ||
|
|
||
| Example: | ||
| cmux drag-surface-to-split --surface surface:1 right | ||
| cmux drag-surface-to-split --panel surface:2 down | ||
| """ | ||
| case "split-off": | ||
| return """ | ||
| Usage: cmux split-off --surface <id|ref|index> <left|right|up|down> [flags] | ||
|
|
||
| Move an existing surface into a new split without changing focus by default. | ||
|
|
||
| Flags: | ||
| --surface <id|ref|index> Surface to move (required) | ||
| --panel <id|ref|index> Alias for --surface | ||
| --workspace <id|ref|index> Workspace context for ref/index resolution | ||
| --focus <true|false> Focus the split-off surface (default: false) | ||
|
|
||
| Example: | ||
| cmux split-off --surface surface:1 right | ||
| cmux split-off --workspace workspace:2 --surface surface:4 down | ||
| """ | ||
| case "refresh-surfaces": | ||
| return """ | ||
| Usage: cmux refresh-surfaces | ||
|
|
||
| Refresh surface snapshots for the focused workspace. | ||
| """ | ||
| case "reload-config": | ||
| return """ | ||
| Usage: cmux reload-config | ||
|
|
||
| Run the same configuration reload as the Reload Configuration shortcut. | ||
| This reloads Ghostty config, re-reads ~/.config/cmux/cmux.json, and refreshes terminals. | ||
|
|
||
| Example: | ||
| cmux reload-config | ||
| """ | ||
| case "surface-health": | ||
| return """ | ||
| Usage: cmux surface-health [--workspace <id|ref>] | ||
|
|
||
| List health details for surfaces in a workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux surface-health | ||
| cmux surface-health --workspace workspace:2 | ||
| """ | ||
| case "surface", "surface-resume": | ||
| return """ | ||
| Usage: cmux surface resume set [flags] -- <argv...> | ||
| cmux surface resume set [flags] --shell <command> | ||
| cmux surface resume show [--json] [flags] | ||
| cmux surface resume get [--json] [flags] | ||
| cmux surface resume clear [flags] | ||
|
|
||
| Attach restart command metadata to a terminal surface. | ||
| Public CLI bindings are stored for inspection and manual restore. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Surface context (default: $CMUX_SURFACE_ID) | ||
| --cwd <path> Working directory for restore (default: $PWD) | ||
| --name <name> Display name for the binding | ||
| --kind <kind> Binding kind, for example agent or tmux | ||
| --checkpoint <id> Provider checkpoint or session id | ||
| --checkpoint-id <id> Same as --checkpoint and takes precedence | ||
| --source <source> Binding source label | ||
|
|
||
| Examples: | ||
| cmux surface resume set --kind tmux --shell "tmux attach -t work" | ||
| cmux surface resume set --kind opencode --checkpoint ses_123 -- opencode --session ses_123 | ||
| cmux surface resume show --json | ||
| """ | ||
| case "debug-terminals": | ||
| return """ | ||
| Usage: cmux debug-terminals | ||
|
|
||
| Print live Ghostty terminal runtime metadata across all windows and workspaces. | ||
| Intended for debugging stray or detached terminal views. | ||
| """ | ||
| case "trigger-flash": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) | ||
| --panel <id|ref> Alias for --surface | ||
|
|
||
| Example: | ||
| cmux trigger-flash | ||
| cmux trigger-flash --workspace workspace:2 --surface surface:3 | ||
| """ | ||
| case "list-panels": | ||
| return """ | ||
| Usage: cmux list-panels [--workspace <id|ref>] | ||
|
|
||
| List surfaces (panels) in a workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux list-panels | ||
| cmux list-panels --workspace workspace:2 | ||
| """ | ||
| case "focus-panel": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux focus-panel --panel surface:2 | ||
| cmux focus-panel --panel surface:5 --workspace workspace:2 | ||
| """ | ||
| case "close-workspace": | ||
| return """ | ||
| Usage: cmux close-workspace --workspace <id|ref|index> | ||
|
|
||
| Close the specified workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref|index> Workspace to close (required) | ||
|
|
||
| Example: | ||
| cmux close-workspace --workspace workspace:2 | ||
| """ | ||
| case "select-workspace": | ||
| return """ | ||
| Usage: cmux select-workspace --workspace <id|ref|index> | ||
|
|
||
| Select (switch to) the specified workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref|index> Workspace to select (required) | ||
|
|
||
| Example: | ||
| cmux select-workspace --workspace workspace:2 | ||
| cmux select-workspace --workspace 0 | ||
| """ | ||
| case "rename-workspace", "rename-window": | ||
| return """ | ||
| Usage: cmux rename-workspace [--workspace <id|ref|index>] [--] <title> | ||
|
|
||
| Rename a workspace. Defaults to the current workspace. | ||
| tmux-compatible alias: rename-window | ||
|
|
||
| Flags: | ||
| --workspace <id|ref|index> Workspace to rename (default: current/$CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux rename-workspace "backend logs" | ||
| cmux rename-window --workspace workspace:2 "agent run" | ||
| """ | ||
| case "current-workspace": | ||
| return """ | ||
| Usage: cmux current-workspace | ||
|
|
||
| Print the currently selected workspace ID. | ||
| """ | ||
| case "capture-pane": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Surface context (default: $CMUX_SURFACE_ID) | ||
| --scrollback Include scrollback | ||
| --lines <n> Return only the last N lines (implies --scrollback) | ||
|
|
||
| Example: | ||
| cmux capture-pane --workspace workspace:2 --surface surface:1 --scrollback --lines 200 | ||
| """ | ||
| case "resize-pane": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
| -L|-R|-U|-D Direction (default: -R) | ||
| --amount <n> Resize amount (default: 1) | ||
| """ | ||
| case "pipe-pane": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_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: cmux 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: cmux swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>] [--focus <true|false>] | ||
|
|
||
| Swap two panes. | ||
|
|
||
| Flags: | ||
| --pane <id|ref> Source pane (required) | ||
| --target-pane <id|ref> Target pane (required) | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
| --focus <true|false> Focus the target pane after swapping (default: false) | ||
| """ | ||
| case "break-pane": | ||
| return """ | ||
| Usage: cmux break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--focus <true|false>] [--no-focus] | ||
|
|
||
| Move a pane/surface out into its own pane context. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
| --pane <id|ref> Source pane | ||
| --surface <id|ref> Source surface | ||
| --focus <true|false> Focus the result (default: false) | ||
| --no-focus Compatibility alias for --focus false | ||
| """ | ||
| case "join-pane": | ||
| return """ | ||
| Usage: cmux join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--focus <true|false>] [--no-focus] | ||
|
|
||
| Join a pane/surface into another pane. | ||
|
|
||
| Flags: | ||
| --target-pane <id|ref> Target pane (required) | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
| --pane <id|ref> Source pane | ||
| --surface <id|ref> Source surface | ||
| --focus <true|false> Focus the result (default: false) | ||
| --no-focus Compatibility alias for --focus false | ||
| """ | ||
| case "next-window", "previous-window", "last-window": | ||
| return """ | ||
| Usage: cmux \(command) | ||
|
|
||
| Switch workspace selection (next/previous/last) in the current window. | ||
| """ | ||
| case "last-pane": | ||
| return """ | ||
| Usage: cmux last-pane [--workspace <id|ref>] | ||
|
|
||
| Focus the previously focused pane in a workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
| """ | ||
| case "find-window": | ||
| return """ | ||
| Usage: cmux 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: cmux clear-history [--workspace <id|ref>] [--surface <id|ref>] | ||
|
|
||
| Clear terminal scrollback history. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Workspace context (default: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Surface context (default: focused surface) | ||
| """ | ||
| case "set-hook": | ||
| return """ | ||
| Usage: cmux 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: cmux popup | ||
|
|
||
| tmux compatibility placeholder. This command is currently not supported. | ||
| """ | ||
| case "bind-key", "unbind-key", "copy-mode": | ||
| return """ | ||
| Usage: cmux \(command) | ||
|
|
||
| tmux compatibility placeholder. This command is currently not supported. | ||
| """ | ||
| case "set-buffer": | ||
| return """ | ||
| Usage: cmux 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: cmux 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: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Surface context (default: focused surface) | ||
| """ | ||
| case "list-buffers": | ||
| return """ | ||
| Usage: cmux list-buffers | ||
|
|
||
| List tmux-compat buffers. | ||
| """ | ||
| case "respawn-pane": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Surface context (default: focused surface) | ||
| --command <cmd> Command text (or pass trailing command text) | ||
| """ | ||
| case "display-message": | ||
| return """ | ||
| Usage: cmux 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: cmux read-screen [flags] | ||
|
|
||
| Read terminal text from a surface as plain text. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) | ||
| --scrollback Include scrollback (not just visible viewport) | ||
| --lines <n> Limit to the last n lines (implies --scrollback) | ||
|
|
||
| Example: | ||
| cmux read-screen | ||
| cmux read-screen --surface surface:2 --scrollback --lines 200 | ||
| """ | ||
| case "send": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) | ||
|
|
||
| Example: | ||
| cmux send "echo hello" | ||
| cmux send --surface surface:2 "ls -la\\n" | ||
| """ | ||
| case "send-key": | ||
| return """ | ||
| Usage: cmux send-key [flags] [--] <key> | ||
|
|
||
| Send a key event to a terminal surface. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) | ||
|
|
||
| Example: | ||
| cmux send-key enter | ||
| cmux send-key --surface surface:2 ctrl+c | ||
| """ | ||
| case "send-panel": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux send-panel --panel surface:2 "echo hello\\n" | ||
| """ | ||
| case "send-key-panel": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux send-key-panel --panel surface:2 enter | ||
| cmux send-key-panel --panel surface:2 ctrl+c | ||
| """ | ||
| case "notify": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) | ||
|
|
||
| Example: | ||
| cmux notify --title "Build done" --body "All tests passed" | ||
| cmux notify --title "Error" --subtitle "test.swift" --body "Line 42: syntax error" | ||
| """ | ||
| case "list-notifications": | ||
| return """ | ||
| Usage: cmux list-notifications | ||
|
|
||
| List queued notifications. | ||
| """ | ||
| case "dismiss-notification": | ||
| return String(localized: "cli.help.dismissNotification", defaultValue: """ | ||
| Usage: cmux dismiss-notification (--id <uuid> | --all-read) | ||
|
|
||
| Remove one notification, or remove every already-read notification. | ||
|
|
||
| Flags: | ||
| --id <uuid> Notification id to remove | ||
| --all-read Remove every already-read notification | ||
| --json Print JSON | ||
| --id-format <mode> refs, uuids, or both | ||
| """) | ||
| case "mark-notification-read": | ||
| return String(localized: "cli.help.markNotificationRead", defaultValue: """ | ||
| Usage: cmux mark-notification-read (--id <uuid> | --workspace <id|ref> [--surface <id|ref>] | --all) | ||
|
|
||
| Mark notifications read without opening them. Exactly one selector is required. | ||
|
|
||
| Flags: | ||
| --id <uuid> Mark one notification read | ||
| --workspace <id|ref> Mark notifications for a workspace | ||
| --surface <id|ref> Narrow --workspace to one surface | ||
| --all Mark every notification read | ||
| --json Print JSON | ||
| --id-format <mode> refs, uuids, or both | ||
| """) | ||
| case "open-notification": | ||
| return String(localized: "cli.help.openNotification", defaultValue: """ | ||
| Usage: cmux open-notification --id <uuid> | ||
|
|
||
| Focus the notification's workspace and surface, then mark the row read. | ||
|
|
||
| Flags: | ||
| --id <uuid> Notification id to open | ||
| --json Print JSON | ||
| --id-format <mode> refs, uuids, or both | ||
| """) | ||
| case "jump-to-unread": | ||
| return String(localized: "cli.help.jumpToUnread", defaultValue: """ | ||
| Usage: cmux jump-to-unread | ||
|
|
||
| Focus the latest unread notification, matching the Notifications page action. | ||
|
|
||
| Flags: | ||
| --json Print JSON | ||
| --id-format <mode> refs, uuids, or both | ||
| """) | ||
| case "clear-notifications": | ||
| return """ | ||
| Usage: cmux clear-notifications | ||
|
|
||
| Clear all queued notifications. | ||
| """ | ||
| case "set-status": | ||
| return String(localized: "cli.help.setStatus", defaultValue: """ | ||
| Usage: cmux 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") | ||
| --priority <n> Sort priority; higher appears first (default: 0) | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux set-status build "compiling" --icon hammer --color "#ff9500" --priority 80 | ||
| cmux set-status deploy "v1.2.3" --workspace workspace:2 | ||
| """) | ||
| case "clear-status": | ||
| return """ | ||
| Usage: cmux clear-status <key> [flags] | ||
|
|
||
| Remove a sidebar status entry by key. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux clear-status build | ||
| """ | ||
| case "list-status": | ||
| return """ | ||
| Usage: cmux list-status [flags] | ||
|
|
||
| List all sidebar status entries for a workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux list-status | ||
| cmux list-status --workspace workspace:2 | ||
| """ | ||
| case "set-progress": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux set-progress 0.5 --label "Building..." | ||
| cmux set-progress 1.0 --label "Done" | ||
| """ | ||
| case "clear-progress": | ||
| return """ | ||
| Usage: cmux clear-progress [flags] | ||
|
|
||
| Clear the sidebar progress bar for a workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux clear-progress | ||
| """ | ||
| case "log": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux log "Build started" | ||
| cmux log --level error --source build "Compilation failed" | ||
| cmux log --level success -- "All 42 tests passed" | ||
| """ | ||
| case "clear-log": | ||
| return """ | ||
| Usage: cmux clear-log [flags] | ||
|
|
||
| Clear all sidebar log entries for a workspace. | ||
|
|
||
| Flags: | ||
| --workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux clear-log | ||
| """ | ||
| case "list-log": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux list-log | ||
| cmux list-log --limit 5 | ||
| """ | ||
| case "sidebar-state": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
|
|
||
| Example: | ||
| cmux sidebar-state | ||
| cmux sidebar-state --workspace workspace:2 | ||
| """ | ||
| case "right-sidebar": | ||
| return String(localized: "cli.rightSidebar.usage", defaultValue: """ | ||
| Usage: cmux right-sidebar <command> [flags] | ||
|
|
||
| Control the right sidebar from the CLI. | ||
|
|
||
| Commands: | ||
| toggle Toggle right sidebar visibility | ||
| show Show the right sidebar | ||
| hide Hide the right sidebar | ||
| focus Focus the current right sidebar mode | ||
| set <files|find|vault|sessions|feed|dock> | ||
| Show, switch mode, and focus | ||
| mode Print {"visible":bool,"mode":string} | ||
| files|find|vault|sessions|feed|dock | ||
| Alias for show + set + focus | ||
|
|
||
| Flags: | ||
| --workspace <id|ref|index> Target the window containing a workspace | ||
| --window <id|ref|index> Target a window | ||
| --no-focus With set, switch mode without moving focus | ||
|
|
||
| Examples: | ||
| cmux right-sidebar toggle | ||
| cmux right-sidebar set find | ||
| cmux right-sidebar set vault --no-focus | ||
| cmux right-sidebar mode | ||
| """) | ||
| case "set-app-focus": | ||
| return """ | ||
| Usage: cmux set-app-focus <active|inactive|clear> | ||
|
|
||
| Override app focus state for notification routing tests. | ||
|
|
||
| Example: | ||
| cmux set-app-focus inactive | ||
| cmux set-app-focus clear | ||
| """ | ||
| case "simulate-app-active": | ||
| return """ | ||
| Usage: cmux simulate-app-active | ||
|
|
||
| Trigger the app-active handler used by notification focus tests. | ||
| """ | ||
| case "claude-hook": | ||
| return """ | ||
| Usage: cmux 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: $CMUX_WORKSPACE_ID) | ||
| --surface <id|ref> Target surface (default: $CMUX_SURFACE_ID) | ||
|
|
||
| Example: | ||
| echo '{"session_id":"abc"}' | cmux claude-hook session-start | ||
| echo '{}' | cmux claude-hook stop | ||
| """ | ||
| case "codex": | ||
| return """ | ||
| Usage: cmux codex <install-hooks|uninstall-hooks> | ||
|
|
||
| Manage Codex CLI hooks integration. | ||
|
|
||
| Subcommands: | ||
| install-hooks Install cmux hooks into ~/.codex/hooks.json | ||
| uninstall-hooks Remove cmux hooks from ~/.codex/hooks.json | ||
| """ | ||
| case "browser": | ||
| return """ | ||
| Usage: cmux 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>] [--focus <true|false>] | ||
| open/open-split/new default to $CMUX_WORKSPACE_ID when --workspace is omitted and --window is not set | ||
| --focus defaults to false | ||
| disable | enable | status | ||
| 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>] | ||
| profiles <list|add|rename|clear|delete> [...] | ||
| import [--interactive|--non-interactive|-y|--yes] [--from <browser>] [--profile <name>] [--all-profiles] [--to-profile <name|uuid>] [--create-profile] [--domain <domain>] | ||
| 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: | ||
| cmux browser open https://example.com | ||
| cmux browser surface:1 navigate https://google.com | ||
| cmux browser --surface surface:1 snapshot --interactive | ||
| """ | ||
| // Legacy browser aliases — point users to `cmux browser --help` | ||
| case "open-browser": | ||
| return "Legacy alias for 'cmux browser open'. Run 'cmux browser --help' for details." | ||
| case "navigate": | ||
| return "Legacy alias for 'cmux browser navigate'. Run 'cmux browser --help' for details." | ||
| case "browser-back": | ||
| return "Legacy alias for 'cmux browser back'. Run 'cmux browser --help' for details." | ||
| case "browser-forward": | ||
| return "Legacy alias for 'cmux browser forward'. Run 'cmux browser --help' for details." | ||
| case "browser-reload": | ||
| return "Legacy alias for 'cmux browser reload'. Run 'cmux browser --help' for details." | ||
| case "get-url": | ||
| return "Legacy alias for 'cmux browser get-url'. Run 'cmux browser --help' for details." | ||
| case "focus-webview": | ||
| return "Legacy alias for 'cmux browser focus-webview'. Run 'cmux browser --help' for details." | ||
| case "is-webview-focused": | ||
| return "Legacy alias for 'cmux browser is-webview-focused'. Run 'cmux browser --help' for details." | ||
| case "open": return openSubcommandUsage() | ||
| case "markdown": | ||
| return """ | ||
| Usage: cmux markdown open <path> [options] | ||
| cmux 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: $CMUX_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) | ||
| --focus <true|false> Focus the markdown panel (default: false) | ||
|
|
||
| Examples: | ||
| cmux markdown open plan.md | ||
| cmux markdown ~/project/CHANGELOG.md | ||
| cmux markdown open ./docs/design.md --workspace 0 | ||
| cmux markdown open plan.md --direction down | ||
| """ | ||
| default: | ||
| return nil | ||
| } | ||
| } | ||
|
|
||
| /// Dispatch help for a subcommand. Returns true if help was printed. | ||
| func dispatchSubcommandHelp(command: String, commandArgs: [String]) -> Bool { | ||
| guard commandArgs.contains("--help") || commandArgs.contains("-h") else { return false } | ||
| guard let text = subcommandUsage(command) else { return false } | ||
| print("cmux \(command)") | ||
| print("") | ||
| print(text) | ||
| return true | ||
| } | ||
|
|
||
| func usage() -> String { | ||
| return """ | ||
| cmux - control cmux via Unix socket | ||
|
|
||
| Usage: | ||
| cmux <path> Open a directory in a new workspace (launches cmux if needed) | ||
| cmux [global-options] <command> [options] | ||
|
|
||
| Handle Inputs: | ||
| Use UUIDs, short refs (window:1/workspace:2/pane:3/surface:4), or indexes where commands accept window, workspace, pane, or surface inputs. | ||
| `tab-action` also accepts `tab:<n>` in addition to `surface:<n>`. | ||
| Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs. | ||
|
|
||
| Socket Auth: | ||
| --password takes precedence, then CMUX_SOCKET_PASSWORD env var, then password saved in Settings. | ||
|
|
||
| Agent Help: | ||
| To change cmux settings, run `cmux docs settings` and `cmux settings path`; to add Dock controls, run `cmux docs dock`. | ||
| Back up any existing cmux.json file to a timestamped .bak copy before editing. | ||
| Use printed curl commands to fetch the latest docs/schema, and prefer Ghostty config for terminal behavior Ghostty already supports. | ||
| Ghostty config lives at ~/.config/ghostty/config (controls terminal transparency, blur, font, theme, keybinds, etc.). | ||
| `cmux reload-config` reloads BOTH Ghostty config and ~/.config/cmux/cmux.json and refreshes terminals in place. No app restart needed. | ||
|
|
||
| Commands: | ||
| welcome | ||
| docs [settings|shortcuts|api|browser|agents|dock] | ||
| settings [open [target]|path|docs|<target>] | ||
| config <doctor|check|validate|path|paths|docs|documentation|reload> | ||
| shortcuts | ||
| disable-browser | enable-browser | browser-status | ||
| restore-session | ||
| open <path-or-url>... [--workspace <id|ref|index>] [--surface <id|ref|index>] [--pane <id|ref|index>] [--window <id|ref|index>] [--focus <true|false>] [--no-focus] | ||
| feedback [--email <email> --body <text> [--image <path> ...]] | ||
| feed tui|clear | ||
| themes [list|set|clear] | ||
| claude-teams [claude-args...] | ||
| codex-teams [codex-args...] | ||
| omo [opencode-args...] | ||
| omx [omx-args...] | ||
| omc [omc-args...] | ||
| hooks setup|uninstall [--agent <name>] | ||
| hooks <agent> <install|uninstall|event> [options; opencode supports --project] | ||
| hooks feed --source <agent> [--event <event>] | ||
| ping | ||
| version | ||
| capabilities | ||
| events [--after <seq>] [--cursor-file <path>] [--name <event>] [--category <category>] [--reconnect] [--limit <n>] [--no-ack] [--no-heartbeat] | ||
| auth <status|login|logout> | ||
| login | logout (aliases for auth login/logout) | ||
| vm <new|ls|rm|exec|shell|ssh> [args...] (alias: cloud) | ||
| rpc <method> [json-params] | ||
| identify [--workspace <id|ref|index>] [--surface <id|ref|index>] [--no-caller] | ||
| list-windows | ||
| current-window | ||
| new-window | ||
| focus-window --window <id> | ||
| close-window --window <id> | ||
| move-workspace-to-window --workspace <id|ref> --window <id|ref> | ||
| reorder-workspace --workspace <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) [--window <id|ref|index>] | ||
| workspace-action --action <name> [--workspace <id|ref|index>] [--title <text>] [--color <name|#hex>] [--description <text>] | ||
| move-tab-to-new-workspace [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--focus <true|false>] | ||
| list-workspaces | ||
| new-workspace [--name <title>] [--description <text>] [--cwd <path>] [--command <text>] [--layout <json>] [--window <id|ref|index>] [--focus <true|false>] | ||
| ssh <destination> [--name <title>] [--port <n>] [--identity <path>] [--ssh-option <opt>] [--no-focus] [-- <remote-command-args>] | ||
| remote-daemon-status [--os <darwin|linux>] [--arch <arm64|amd64>] | ||
| new-split <left|right|up|down> [--workspace <id|ref>] [--surface <id|ref>] [--panel <id|ref>] [--focus <true|false>] | ||
| list-panes [--workspace <id|ref>] | ||
| list-pane-surfaces [--workspace <id|ref>] [--pane <id|ref>] | ||
| tree [--all] [--workspace <id|ref|index>] | ||
| top [--all] [--workspace <id|ref|index>] [--processes] [--sort <cpu|mem|proc>] [--flat] [--format <tree|tsv>] | ||
| focus-pane --pane <id|ref> [--workspace <id|ref>] | ||
| new-pane [--type <terminal|browser>] [--direction <left|right|up|down>] [--workspace <id|ref>] [--url <url>] [--focus <true|false>] | ||
| new-surface [--type <terminal|browser>] [--pane <id|ref>] [--workspace <id|ref>] [--url <url>] [--focus <true|false>] | ||
| close-surface [--surface <id|ref>] [--workspace <id|ref>] | ||
| move-surface --surface <id|ref|index> [--pane <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--before <id|ref|index>] [--after <id|ref|index>] [--index <n>] [--focus <true|false>] | ||
| split-off --surface <id|ref|index> <left|right|up|down> [--workspace <id|ref|index>] [--focus <true|false>] | ||
| reorder-surface --surface <id|ref|index> (--index <n> | --before <id|ref|index> | --after <id|ref|index>) [--focus <true|false>] | ||
| tab-action --action <name> [--tab <id|ref|index>] [--surface <id|ref|index>] [--workspace <id|ref|index>] [--title <text>] [--url <url>] [--focus <true|false>] | ||
| surface resume <set|show|get|clear> [--workspace <id|ref>] [--surface <id|ref>] | ||
| rename-tab [--workspace <id|ref>] [--tab <id|ref>] [--surface <id|ref>] <title> | ||
| drag-surface-to-split --surface <id|ref|index> <left|right|up|down> [--workspace <id|ref|index>] [--focus <true|false>] | ||
| refresh-surfaces | ||
| reload-config | ||
| surface-health [--workspace <id|ref>] | ||
| debug-terminals | ||
| trigger-flash [--workspace <id|ref>] [--surface <id|ref>] | ||
| list-panels [--workspace <id|ref>] | ||
| focus-panel --panel <id|ref> [--workspace <id|ref>] | ||
| close-workspace --workspace <id|ref> | ||
| select-workspace --workspace <id|ref> | ||
| rename-workspace [--workspace <id|ref>] <title> | ||
| rename-window [--workspace <id|ref>] <title> | ||
| current-workspace | ||
| read-screen [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] | ||
| send [--workspace <id|ref>] [--surface <id|ref>] <text> | ||
| send-key [--workspace <id|ref>] [--surface <id|ref>] <key> | ||
| send-panel --panel <id|ref> [--workspace <id|ref>] <text> | ||
| send-key-panel --panel <id|ref> [--workspace <id|ref>] <key> | ||
| notify --title <text> [--subtitle <text>] [--body <text>] [--workspace <id|ref>] [--surface <id|ref>] | ||
| list-notifications | ||
| dismiss-notification (--id <uuid> | --all-read) | ||
| mark-notification-read (--id <uuid> | --workspace <id|ref> [--surface <id|ref>] | --all) | ||
| open-notification --id <uuid> | ||
| jump-to-unread | ||
| clear-notifications | ||
| right-sidebar <toggle|show|hide|focus|set|mode|files|find|vault|sessions|feed|dock> [--workspace <id|ref|index>] [--window <id|ref|index>] [--no-focus] | ||
| set-status <key> <value> [--workspace <id|ref>] [--icon <name>] [--color <#hex>] [--priority <n>] | ||
| clear-status <key> [--workspace <id|ref>] | ||
| list-status [--workspace <id|ref>] | ||
| set-progress <0.0-1.0> [--label <text>] [--workspace <id|ref>] | ||
| clear-progress [--workspace <id|ref>] | ||
| log [--level <level>] [--source <name>] [--workspace <id|ref>] <message> | ||
| clear-log [--workspace <id|ref>] | ||
| list-log [--workspace <id|ref>] [--limit <n>] | ||
| sidebar-state [--workspace <id|ref>] | ||
| set-app-focus <active|inactive|clear> | ||
| simulate-app-active | ||
|
|
||
| # tmux compatibility commands | ||
| capture-pane [--workspace <id|ref>] [--surface <id|ref>] [--scrollback] [--lines <n>] | ||
| resize-pane --pane <id|ref> [--workspace <id|ref>] (-L|-R|-U|-D) [--amount <n>] | ||
| pipe-pane --command <shell-command> [--workspace <id|ref>] [--surface <id|ref>] | ||
| wait-for [-S|--signal] <name> [--timeout <seconds>] | ||
| swap-pane --pane <id|ref> --target-pane <id|ref> [--workspace <id|ref>] [--focus <true|false>] | ||
| break-pane [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--focus <true|false>] [--no-focus] | ||
| join-pane --target-pane <id|ref> [--workspace <id|ref>] [--pane <id|ref>] [--surface <id|ref>] [--focus <true|false>] [--no-focus] | ||
| next-window | previous-window | last-window | ||
| last-pane [--workspace <id|ref>] | ||
| find-window [--content] [--select] <query> | ||
| clear-history [--workspace <id|ref>] [--surface <id|ref>] | ||
| set-hook [--list] [--unset <event>] | <event> <command> | ||
| popup | ||
| bind-key | unbind-key | copy-mode | ||
| set-buffer [--name <name>] <text> | ||
| list-buffers | ||
| paste-buffer [--name <name>] [--workspace <id|ref>] [--surface <id|ref>] | ||
| respawn-pane [--workspace <id|ref>] [--surface <id|ref>] [--command <cmd>] | ||
| display-message [-p|--print] <text> | ||
|
|
||
| markdown [open] <path> [--focus <true|false>] (open markdown file in formatted viewer panel with live reload) | ||
|
|
||
| browser [--surface <id|ref|index> | <surface>] <subcommand> ... | ||
| browser disable | enable | status | ||
| browser open [url] [--focus <true|false>] (create browser split in caller's workspace; if surface supplied, behaves like navigate) | ||
| browser open-split [url] | ||
| browser goto|navigate <url> [--snapshot-after] | ||
| browser back|forward|reload [--snapshot-after] | ||
| browser url|get-url | ||
| browser snapshot [--interactive|-i] [--cursor] [--compact] [--max-depth <n>] [--selector <css>] | ||
| browser eval <script> | ||
| browser wait [--selector <css>] [--text <text>] [--url-contains <text>] [--load-state <interactive|complete>] [--function <js>] [--timeout-ms <ms>] | ||
| browser click|dblclick|hover|focus|check|uncheck|scroll-into-view <selector> [--snapshot-after] | ||
| browser type <selector> <text> [--snapshot-after] | ||
| browser fill <selector> [text] [--snapshot-after] (empty text clears input) | ||
| browser press|keydown|keyup <key> [--snapshot-after] | ||
| browser select <selector> <value> [--snapshot-after] | ||
| browser scroll [--selector <css>] [--dx <n>] [--dy <n>] [--snapshot-after] | ||
| browser screenshot [--out <path>] [--json] | ||
| browser get <url|title|text|html|value|attr|count|box|styles> [...] | ||
| browser is <visible|enabled|checked> <selector> | ||
| browser find <role|text|label|placeholder|alt|title|testid|first|last|nth> ... | ||
| browser frame <selector|main> | ||
| browser dialog <accept|dismiss> [text] | ||
| browser download [wait] [--path <path>] [--timeout-ms <ms>] | ||
| browser profiles <list|add|rename|clear|delete> [...] | ||
| browser profiles clear <profile|--all> [--force] | ||
| browser import [...] | ||
| browser cookies <get|set|clear> [...] | ||
| browser storage <local|session> <get|set|clear> [...] | ||
| browser tab <new|list|switch|close|<index>> [...] | ||
| browser console <list|clear> | ||
| browser errors <list|clear> | ||
| browser highlight <selector> | ||
| browser state <save|load> <path> | ||
| browser addinitscript <script> | ||
| browser addscript <script> | ||
| browser addstyle <css> | ||
| browser identify [--surface <id|ref|index>] | ||
| help | ||
|
|
||
| Environment: | ||
| CMUX_WORKSPACE_ID Auto-set in cmux terminals. Used as default --workspace for | ||
| ALL commands (send, list-panels, new-split, notify, etc.). | ||
| CMUX_TAB_ID Optional alias used by `tab-action`/`rename-tab` as default --tab. | ||
| CMUX_SURFACE_ID Auto-set in cmux terminals. Used as default --surface. | ||
| CMUX_SOCKET_PATH Override the Unix socket path. Without this, the CLI defaults | ||
| to ~/Library/Application Support/cmux/cmux.sock and auto-discovers tagged/debug sockets. | ||
| """ | ||
| } | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | ⚖️ Poor tradeoff
Consider splitting this file to comply with the 800-line guideline.
This file is 1847 lines, which exceeds the project guideline: "A new production Swift file must not exceed 400 lines without a clear single responsibility, or 800 lines even when the responsibility is mostly coherent." While the file has a clear single responsibility (CLI help text), it's 2.3× over the 800-line limit.
Suggested approach: Split by command category into separate extension files:
CMUXCLI+Usage+Core.swift- ping, capabilities, events, auth, vm, rpc, help, docs, settings, config (~300 lines)CMUXCLI+Usage+Workspace.swift- workspace, window, pane, surface, split commands (~600 lines)CMUXCLI+Usage+Browser.swift- browser commands and legacy aliases (~200 lines)CMUXCLI+Usage+Tmux.swift- tmux compatibility commands (~400 lines)CMUXCLI+Usage+Notifications.swift- notifications, status, log, sidebar commands (~350 lines)
Each subcommandUsage(_:) fragment would handle its category's switch cases, and the main method would delegate to category-specific helpers. This improves navigability and reduces incremental rebuild cost when editing help text for one category.
As per coding guidelines, new production Swift files should not exceed 800 lines.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@CLI/CMUXCLI`+Usage.swift around lines 3 - 1846, The file exceeds the 800-line
guideline; split the large CMUXCLI extension into category-specific extensions
and have subcommandUsage(_:) delegate to them: extract related switch cases into
new files implementing helper methods (e.g., CMUXCLI+Usage+Core.swift,
CMUXCLI+Usage+Workspace.swift, CMUXCLI+Usage+Browser.swift,
CMUXCLI+Usage+Tmux.swift, CMUXCLI+Usage+Notifications.swift) each providing a
categorySubcommandUsage(_:) used by subcommandUsage(_:), keep
dispatchSubcommandHelp(command:commandArgs:) and usage() intact but routing to
the new helpers; ensure symbol names (subcommandUsage(_:),
dispatchSubcommandHelp(command:commandArgs:), usage()) remain and update
visibility/imports so compilation is unchanged.
| final class SocketClient { | ||
| private struct RelayEndpoint { | ||
| let host: String | ||
| let port: UInt16 | ||
| } | ||
|
|
||
| private struct SocketConnectError: Error, CustomStringConvertible { | ||
| let path: String | ||
| let errnoValue: Int32 | ||
|
|
||
| var description: String { | ||
| "Failed to connect to socket at \(path) (\(String(cString: strerror(errnoValue))), errno \(errnoValue))" | ||
| } | ||
| } | ||
|
|
||
| private struct RelayCredentials { | ||
| let relayID: String | ||
| let relayToken: Data | ||
| } | ||
|
|
||
| private let path: String | ||
| private var socketFD: Int32 = -1 | ||
| private var lastConfiguredReceiveTimeout: TimeInterval? | ||
| private var lastOperationTelemetry: CLISocketOperationTelemetry.State? | ||
| private static let defaultResponseTimeoutSeconds: TimeInterval = 15.0 | ||
| private static let multilineResponseIdleTimeoutSeconds: TimeInterval = 0.12 | ||
| private static let maxSocketTimeoutSeconds: TimeInterval = 9_007_199_254_740_991 | ||
| private static let connectRetryDeadline: TimeInterval = 0.35 | ||
| private static let connectRetryIntervalMicros: useconds_t = 25_000 | ||
| private static let responseTimeoutSeconds: TimeInterval = { | ||
| let env = ProcessInfo.processInfo.environment | ||
| if let raw = env["CMUXTERM_CLI_RESPONSE_TIMEOUT_SEC"], | ||
| let seconds = Double(raw), | ||
| seconds.isFinite, | ||
| seconds > 0 { | ||
| return seconds | ||
| } | ||
| return defaultResponseTimeoutSeconds | ||
| }() | ||
|
|
||
| private static func isCompleteSingleLineResponse(_ data: Data) -> Bool { | ||
| guard data.contains(UInt8(0x0A)), | ||
| let response = String(data: data, encoding: .utf8) else { | ||
| return false | ||
| } | ||
| let normalized = response.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard !normalized.isEmpty, !normalized.contains("\n") else { | ||
| return false | ||
| } | ||
|
|
||
| if normalized == "OK" || | ||
| normalized == "PONG" || | ||
| normalized.hasPrefix("OK ") || | ||
| normalized.hasPrefix("ERROR:") { | ||
| return true | ||
| } | ||
|
|
||
| if let jsonData = normalized.data(using: .utf8), (try? JSONSerialization.jsonObject(with: jsonData, options: [.fragmentsAllowed])) != nil { | ||
| return true | ||
| } | ||
|
|
||
| return false | ||
| } | ||
|
|
||
| init(path: String) { | ||
| self.path = path | ||
| } | ||
|
|
||
| var socketPath: String { | ||
| path | ||
| } | ||
|
|
||
| var isRelayBacked: Bool { | ||
| relayEndpoint != nil | ||
| } | ||
|
|
||
| func connectionAppearsOpen() -> Bool { | ||
| if relayEndpoint != nil, socketFD < 0 { | ||
| do { | ||
| try connect() | ||
| } catch { | ||
| return false | ||
| } | ||
| } | ||
| guard socketFD >= 0 else { return false } | ||
| while true { | ||
| var descriptor = pollfd( | ||
| fd: socketFD, | ||
| events: Int16(POLLIN | POLLHUP | POLLERR), | ||
| revents: 0 | ||
| ) | ||
| let ready = Darwin.poll(&descriptor, 1, 0) | ||
| if ready < 0 { | ||
| if errno == EINTR { continue } | ||
| return false | ||
| } | ||
| let terminalEvents = Int16(POLLHUP | POLLERR | POLLNVAL) | ||
| return descriptor.revents & terminalEvents == 0 | ||
| } | ||
| } | ||
|
|
||
| func operationTelemetryContext() -> [String: Any] { | ||
| lastOperationTelemetry?.context() ?? [:] | ||
| } | ||
|
|
||
| func hasUnfinishedOperationTelemetry() -> Bool { lastOperationTelemetry.map { $0.phase != .completed } ?? false } | ||
|
|
||
| private var relayEndpoint: RelayEndpoint? { | ||
| Self.parseRelayEndpoint(path) | ||
| } | ||
|
|
||
| private static func trimmedEnvValue(_ value: String?) -> String? { | ||
| guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), | ||
| !trimmed.isEmpty else { | ||
| return nil | ||
| } | ||
| return trimmed | ||
| } | ||
|
|
||
| private static func socketTimeval(for timeout: TimeInterval) -> timeval { | ||
| let sanitizedTimeout = timeout.isFinite ? timeout : defaultResponseTimeoutSeconds | ||
| let clampedTimeout = min(max(sanitizedTimeout, 0.01), maxSocketTimeoutSeconds) | ||
| let seconds = floor(clampedTimeout) | ||
| let microseconds = min( | ||
| max(Int((clampedTimeout - seconds) * 1_000_000), 0), | ||
| 999_999 | ||
| ) | ||
| return timeval( | ||
| tv_sec: Int(seconds), | ||
| tv_usec: __darwin_suseconds_t(microseconds) | ||
| ) | ||
| } | ||
|
|
||
| private func recordOperation(_ operation: CLISocketOperationTelemetry.State) { | ||
| lastOperationTelemetry = operation | ||
| } | ||
|
|
||
| func connect() throws { | ||
| if socketFD >= 0 { return } | ||
| let deadline = Date().addingTimeInterval(Self.connectRetryDeadline) | ||
| while true { | ||
| do { | ||
| try connectOnce() | ||
| return | ||
| } catch { | ||
| guard Self.shouldRetryConnect(error), Date() < deadline else { | ||
| throw error | ||
| } | ||
| usleep(Self.connectRetryIntervalMicros) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func close() { | ||
| if socketFD >= 0 { | ||
| Darwin.close(socketFD) | ||
| socketFD = -1 | ||
| } | ||
| lastConfiguredReceiveTimeout = nil | ||
| } | ||
|
|
||
| func send(command: String, responseTimeout: TimeInterval? = nil) throws -> String { | ||
| if relayEndpoint != nil, socketFD < 0 { | ||
| try connect() | ||
| } | ||
| guard socketFD >= 0 else { throw CLIError(message: "Not connected") } | ||
| let shouldCloseAfterSend = relayEndpoint != nil | ||
| defer { | ||
| if shouldCloseAfterSend { | ||
| close() | ||
| } | ||
| } | ||
|
|
||
| let initialResponseTimeout = responseTimeout ?? Self.responseTimeoutSeconds | ||
| if lastConfiguredReceiveTimeout != initialResponseTimeout { | ||
| try configureReceiveTimeout(initialResponseTimeout) | ||
| } | ||
| var operation = CLISocketOperationTelemetry.State( | ||
| name: CLISocketOperationTelemetry.operationName(for: command), | ||
| timeout: initialResponseTimeout, | ||
| startedAt: Date(), | ||
| phase: .writeRequest | ||
| ) | ||
| recordOperation(operation) | ||
|
|
||
| let payload = command + "\n" | ||
| try writeAll( | ||
| Data(payload.utf8), | ||
| timeoutMessage: "Command timed out", | ||
| failureMessage: "Failed to write to socket" | ||
| ) | ||
|
|
||
| var data = Data() | ||
| var sawNewline = false | ||
| var receivedCompleteResponse = false | ||
|
|
||
| while true { | ||
| let currentTimeout = sawNewline ? Self.multilineResponseIdleTimeoutSeconds : initialResponseTimeout | ||
| operation.phase = sawNewline ? .readMultilineResponse : .waitForResponse | ||
| operation.sawNewline = sawNewline | ||
| operation.timeout = currentTimeout | ||
| recordOperation(operation) | ||
| if lastConfiguredReceiveTimeout != currentTimeout { | ||
| try configureReceiveTimeout(currentTimeout) | ||
| } | ||
|
|
||
| var buffer = [UInt8](repeating: 0, count: 8192) | ||
| let count = Darwin.read(socketFD, &buffer, buffer.count) | ||
| if count < 0 { | ||
| if errno == EINTR { | ||
| continue | ||
| } | ||
| if errno == EAGAIN || errno == EWOULDBLOCK { | ||
| if sawNewline { | ||
| receivedCompleteResponse = true | ||
| break | ||
| } | ||
| throw CLIError(message: "Command timed out") | ||
| } | ||
| throw CLIError(message: "Socket read error") | ||
| } | ||
| if count == 0 { | ||
| operation.sawNewline = sawNewline | ||
| recordOperation(operation) | ||
| if data.isEmpty { | ||
| throw CLIError(message: "Socket closed before reply") | ||
| } | ||
| if !sawNewline { | ||
| throw CLIError(message: "Socket closed before complete reply") | ||
| } | ||
| receivedCompleteResponse = true | ||
| break | ||
| } | ||
| data.append(buffer, count: count) | ||
| operation.bytesRead += count | ||
| if data.contains(UInt8(0x0A)) { | ||
| sawNewline = true | ||
| if Self.isCompleteSingleLineResponse(data) { | ||
| receivedCompleteResponse = true | ||
| break | ||
| } | ||
| } | ||
| } | ||
|
|
||
| operation.sawNewline = sawNewline | ||
| if receivedCompleteResponse { | ||
| operation.phase = .completed | ||
| } | ||
| recordOperation(operation) | ||
|
|
||
| guard var response = String(data: data, encoding: .utf8) else { | ||
| throw CLIError(message: "Invalid UTF-8 response") | ||
| } | ||
| if response.hasSuffix("\n") { | ||
| response.removeLast() | ||
| } | ||
| return response | ||
| } | ||
|
|
||
| private func connectOnce() throws { | ||
| if let relayEndpoint { | ||
| try connectToRelay(endpoint: relayEndpoint) | ||
| return | ||
| } | ||
|
|
||
| // Verify socket is owned by the current user to prevent fake-socket attacks. | ||
| var st = stat() | ||
| guard stat(path, &st) == 0 else { | ||
| throw CLIError(message: "Socket not found at \(path)") | ||
| } | ||
| guard (st.st_mode & mode_t(S_IFMT)) == mode_t(S_IFSOCK) else { | ||
| throw CLIError(message: "Path exists at \(path) but is not a Unix socket") | ||
| } | ||
| guard st.st_uid == getuid() else { | ||
| throw CLIError(message: "Socket at \(path) is not owned by the current user — refusing to connect") | ||
| } | ||
|
|
||
| socketFD = socket(AF_UNIX, SOCK_STREAM, 0) | ||
| if socketFD < 0 { | ||
| throw CLIError(message: "Failed to create socket") | ||
| } | ||
| do { | ||
| try configureSocketWriteSafety(Self.responseTimeoutSeconds) | ||
| try configureReceiveTimeout(Self.responseTimeoutSeconds) | ||
| } catch { | ||
| close() | ||
| throw error | ||
| } | ||
|
|
||
| var addr = sockaddr_un() | ||
| addr.sun_family = sa_family_t(AF_UNIX) | ||
| let maxLength = MemoryLayout.size(ofValue: addr.sun_path) | ||
| path.withCString { ptr in | ||
| withUnsafeMutablePointer(to: &addr.sun_path) { pathPtr in | ||
| let buf = UnsafeMutableRawPointer(pathPtr).assumingMemoryBound(to: CChar.self) | ||
| strncpy(buf, ptr, maxLength - 1) | ||
| } | ||
| } | ||
|
|
||
| let result = withUnsafePointer(to: &addr) { ptr in | ||
| ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in | ||
| Darwin.connect(socketFD, sockaddrPtr, socklen_t(MemoryLayout<sockaddr_un>.size)) | ||
| } | ||
| } | ||
| if result == 0 { | ||
| return | ||
| } | ||
|
|
||
| let connectErrno = errno | ||
| Darwin.close(socketFD) | ||
| socketFD = -1 | ||
| throw SocketConnectError(path: path, errnoValue: connectErrno) | ||
| } | ||
|
|
||
| private static func shouldRetryConnect(_ error: Error) -> Bool { | ||
| guard let error = error as? SocketConnectError else { | ||
| return false | ||
| } | ||
| switch error.errnoValue { | ||
| case ECONNREFUSED, EAGAIN, EWOULDBLOCK: | ||
| return true | ||
| default: | ||
| return false | ||
| } | ||
| } | ||
|
|
||
| private static func parseRelayEndpoint(_ raw: String) -> RelayEndpoint? { | ||
| let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard !trimmed.isEmpty, | ||
| !trimmed.hasPrefix("/") else { | ||
| return nil | ||
| } | ||
| let components = trimmed.split(separator: ":", omittingEmptySubsequences: false) | ||
| guard components.count == 2, | ||
| let port = UInt16(components[1]), | ||
| port > 0 else { | ||
| return nil | ||
| } | ||
| let host = String(components[0]).lowercased() | ||
| guard host == "127.0.0.1" || host == "localhost" else { | ||
| return nil | ||
| } | ||
| return RelayEndpoint(host: host == "localhost" ? "127.0.0.1" : host, port: port) | ||
| } | ||
|
|
||
| private static func relayCredentials(for endpoint: RelayEndpoint) throws -> RelayCredentials { | ||
| let environment = ProcessInfo.processInfo.environment | ||
| if let relayID = trimmedEnvValue(environment["CMUX_RELAY_ID"]), | ||
| let relayTokenHex = trimmedEnvValue(environment["CMUX_RELAY_TOKEN"]), | ||
| let relayToken = hexData(from: relayTokenHex) { | ||
| return RelayCredentials(relayID: relayID, relayToken: relayToken) | ||
| } | ||
|
|
||
| let authURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) | ||
| .appendingPathComponent(".cmux/relay/\(endpoint.port).auth", isDirectory: false) | ||
| guard let authData = try? Data(contentsOf: authURL), | ||
| let authObject = try? JSONSerialization.jsonObject(with: authData) as? [String: Any], | ||
| let relayID = trimmedEnvValue(authObject["relay_id"] as? String), | ||
| let relayTokenHex = trimmedEnvValue(authObject["relay_token"] as? String), | ||
| let relayToken = hexData(from: relayTokenHex) else { | ||
| throw CLIError(message: "Missing relay auth metadata for \(endpoint.host):\(endpoint.port)") | ||
| } | ||
|
|
||
| return RelayCredentials(relayID: relayID, relayToken: relayToken) | ||
| } | ||
|
|
||
| private static func hexData(from string: String) -> Data? { | ||
| let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard !normalized.isEmpty, | ||
| normalized.count.isMultiple(of: 2) else { | ||
| return nil | ||
| } | ||
|
|
||
| var data = Data(capacity: normalized.count / 2) | ||
| var cursor = normalized.startIndex | ||
| while cursor < normalized.endIndex { | ||
| let next = normalized.index(cursor, offsetBy: 2) | ||
| guard let byte = UInt8(normalized[cursor..<next], radix: 16) else { | ||
| return nil | ||
| } | ||
| data.append(byte) | ||
| cursor = next | ||
| } | ||
| return data | ||
| } | ||
|
|
||
| private static func hexString(from data: Data) -> String { | ||
| data.map { String(format: "%02x", $0) }.joined() | ||
| } | ||
|
|
||
| private func connectToRelay(endpoint: RelayEndpoint) throws { | ||
| let credentials = try Self.relayCredentials(for: endpoint) | ||
|
|
||
| socketFD = socket(AF_INET, SOCK_STREAM, 0) | ||
| guard socketFD >= 0 else { | ||
| throw CLIError(message: "Failed to create relay socket") | ||
| } | ||
| do { | ||
| try configureSocketWriteSafety(Self.responseTimeoutSeconds) | ||
| try configureReceiveTimeout(Self.responseTimeoutSeconds) | ||
| } catch { | ||
| close() | ||
| throw error | ||
| } | ||
|
|
||
| var address = sockaddr_in() | ||
| address.sin_len = UInt8(MemoryLayout<sockaddr_in>.stride) | ||
| address.sin_family = sa_family_t(AF_INET) | ||
| address.sin_port = endpoint.port.bigEndian | ||
| let parsedAddress = withUnsafeMutablePointer(to: &address.sin_addr) { pointer in | ||
| endpoint.host.withCString { hostPointer in | ||
| inet_pton(AF_INET, hostPointer, pointer) | ||
| } | ||
| } | ||
| guard parsedAddress == 1 else { | ||
| close() | ||
| throw CLIError(message: "Invalid relay endpoint \(endpoint.host):\(endpoint.port)") | ||
| } | ||
|
|
||
| let result = withUnsafePointer(to: &address) { pointer in | ||
| pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPointer in | ||
| Darwin.connect(socketFD, sockaddrPointer, socklen_t(MemoryLayout<sockaddr_in>.stride)) | ||
| } | ||
| } | ||
| if result != 0 { | ||
| let connectErrno = errno | ||
| close() | ||
| throw CLIError( | ||
| message: "Failed to connect to relay at \(endpoint.host):\(endpoint.port) (\(String(cString: strerror(connectErrno))), errno \(connectErrno))" | ||
| ) | ||
| } | ||
|
|
||
| do { | ||
| try authenticateRelay(credentials: credentials) | ||
| } catch { | ||
| close() | ||
| throw error | ||
| } | ||
| } | ||
|
|
||
| private func authenticateRelay(credentials: RelayCredentials) throws { | ||
| let challengeLine = try readLine() | ||
| guard let challengeData = challengeLine.data(using: .utf8), | ||
| let challenge = try JSONSerialization.jsonObject(with: challengeData) as? [String: Any], | ||
| (challenge["protocol"] as? String) == "cmux-relay-auth", | ||
| let version = challenge["version"] as? Int, | ||
| let relayID = challenge["relay_id"] as? String, | ||
| relayID == credentials.relayID, | ||
| let nonce = challenge["nonce"] as? String, | ||
| !nonce.isEmpty else { | ||
| throw CLIError(message: "Invalid relay authentication challenge") | ||
| } | ||
|
|
||
| let authMessage = Data("relay_id=\(relayID)\nnonce=\(nonce)\nversion=\(version)".utf8) | ||
| let key = SymmetricKey(data: credentials.relayToken) | ||
| let mac = Data(HMAC<SHA256>.authenticationCode(for: authMessage, using: key)) | ||
| let authPayload = try JSONSerialization.data(withJSONObject: [ | ||
| "relay_id": relayID, | ||
| "mac": Self.hexString(from: mac), | ||
| ]) | ||
| try writeAll( | ||
| authPayload + Data([0x0A]), | ||
| timeoutMessage: "Relay command timed out", | ||
| failureMessage: "Failed to write to relay socket" | ||
| ) | ||
|
|
||
| let authResponseLine = try readLine() | ||
| guard let authResponseData = authResponseLine.data(using: .utf8), | ||
| let authResponse = try JSONSerialization.jsonObject(with: authResponseData) as? [String: Any], | ||
| (authResponse["ok"] as? Bool) == true else { | ||
| throw CLIError(message: "Relay authentication failed") | ||
| } | ||
| } | ||
|
|
||
| private func writeAll( | ||
| _ data: Data, | ||
| timeoutMessage: String, | ||
| failureMessage: String | ||
| ) throws { | ||
| try data.withUnsafeBytes { rawBuffer in | ||
| guard let baseAddress = rawBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else { | ||
| return | ||
| } | ||
| var offset = 0 | ||
| while offset < data.count { | ||
| let written = Darwin.write(socketFD, baseAddress.advanced(by: offset), data.count - offset) | ||
| if written < 0 { | ||
| let errorCode = errno | ||
| if errorCode == EINTR { | ||
| continue | ||
| } | ||
| close() | ||
| if errorCode == EAGAIN || errorCode == EWOULDBLOCK || errorCode == ETIMEDOUT { | ||
| throw CLIError(message: timeoutMessage) | ||
| } | ||
| let reason = String(cString: strerror(errorCode)) | ||
| throw CLIError( | ||
| message: "\(failureMessage) (\(reason), errno \(errorCode))" | ||
| ) | ||
| } | ||
| if written == 0 { | ||
| close() | ||
| throw CLIError(message: failureMessage) | ||
| } | ||
| offset += written | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private func configureSocketWriteSafety(_ timeout: TimeInterval) throws { | ||
| var interval = Self.socketTimeval(for: timeout) | ||
| let sendTimeoutResult = withUnsafePointer(to: &interval) { ptr in | ||
| setsockopt( | ||
| socketFD, | ||
| SOL_SOCKET, | ||
| SO_SNDTIMEO, | ||
| ptr, | ||
| socklen_t(MemoryLayout<timeval>.size) | ||
| ) | ||
| } | ||
| guard sendTimeoutResult == 0 else { | ||
| throw CLIError(message: "Failed to configure socket write timeout") | ||
| } | ||
|
|
||
| #if os(macOS) | ||
| var noSigPipe: Int32 = 1 | ||
| let noSigPipeResult = withUnsafePointer(to: &noSigPipe) { ptr in | ||
| setsockopt( | ||
| socketFD, | ||
| SOL_SOCKET, | ||
| SO_NOSIGPIPE, | ||
| ptr, | ||
| socklen_t(MemoryLayout<Int32>.size) | ||
| ) | ||
| } | ||
| guard noSigPipeResult == 0 else { | ||
| throw CLIError(message: "Failed to disable SIGPIPE on socket") | ||
| } | ||
| #endif | ||
| } | ||
|
|
||
| private func readLine(maxBytes: Int = 16 * 1024) throws -> String { | ||
| var data = Data() | ||
|
|
||
| while data.count < maxBytes { | ||
| try configureReceiveTimeout(Self.responseTimeoutSeconds) | ||
|
|
||
| var byte: UInt8 = 0 | ||
| let count = Darwin.read(socketFD, &byte, 1) | ||
| if count < 0 { | ||
| if errno == EINTR { | ||
| continue | ||
| } | ||
| if errno == EAGAIN || errno == EWOULDBLOCK { | ||
| throw CLIError(message: "Relay command timed out") | ||
| } | ||
| throw CLIError(message: "Relay socket read error") | ||
| } | ||
| if count == 0 { | ||
| break | ||
| } | ||
| if byte == 0x0A { | ||
| break | ||
| } | ||
| data.append(byte) | ||
| } | ||
|
|
||
| guard !data.isEmpty else { | ||
| throw CLIError(message: "Unexpected EOF from relay") | ||
| } | ||
| guard let line = String(data: data, encoding: .utf8) else { | ||
| throw CLIError(message: "Invalid UTF-8 relay response") | ||
| } | ||
| return line.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| } | ||
|
|
||
| private func configureReceiveTimeout(_ timeout: TimeInterval) throws { | ||
| var interval = Self.socketTimeval(for: timeout) | ||
| let result = withUnsafePointer(to: &interval) { ptr in | ||
| setsockopt( | ||
| socketFD, | ||
| SOL_SOCKET, | ||
| SO_RCVTIMEO, | ||
| ptr, | ||
| socklen_t(MemoryLayout<timeval>.size) | ||
| ) | ||
| } | ||
| guard result == 0 else { | ||
| let errorCode = errno | ||
| let reason = String(cString: strerror(errorCode)) | ||
| throw CLIError(message: "Failed to configure socket receive timeout (\(reason), errno \(errorCode))") | ||
| } | ||
| lastConfiguredReceiveTimeout = timeout | ||
| } | ||
|
|
||
| static func waitForConnectableSocket(path: String, timeout: TimeInterval) throws -> SocketClient { | ||
| let client = SocketClient(path: path) | ||
| if (try? client.connect()) != nil { | ||
| if client.relayEndpoint != nil { | ||
| client.close() | ||
| } | ||
| return client | ||
| } | ||
|
|
||
| guard let watchDirectory = existingWatchDirectory(forPath: path) else { | ||
| throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") | ||
| } | ||
| let watchFD = open(watchDirectory, O_EVTONLY) | ||
| guard watchFD >= 0 else { | ||
| throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") | ||
| } | ||
|
|
||
| let queue = DispatchQueue(label: "com.cmux.cli.socket-watch.\(UUID().uuidString)") | ||
| let semaphore = DispatchSemaphore(value: 0) | ||
| var connected = false | ||
| let source = DispatchSource.makeFileSystemObjectSource( | ||
| fileDescriptor: watchFD, | ||
| eventMask: [.write, .rename, .delete, .attrib, .extend, .link], | ||
| queue: queue | ||
| ) | ||
|
|
||
| func attemptConnect() { | ||
| guard !connected else { return } | ||
| if (try? client.connect()) != nil { | ||
| connected = true | ||
| semaphore.signal() | ||
| } | ||
| } | ||
|
|
||
| source.setEventHandler { | ||
| attemptConnect() | ||
| } | ||
| source.setCancelHandler { | ||
| Darwin.close(watchFD) | ||
| } | ||
| source.resume() | ||
| queue.async { | ||
| attemptConnect() | ||
| } | ||
|
|
||
| guard semaphore.wait(timeout: .now() + timeout) == .success else { | ||
| source.cancel() | ||
| client.close() | ||
| throw CLIError(message: "cmux app did not start in time (socket not found at \(path))") | ||
| } | ||
|
|
||
| source.cancel() | ||
| return client | ||
| } | ||
|
|
||
| static func waitForFilesystemPath(_ path: String, timeout: TimeInterval) throws { | ||
| if FileManager.default.fileExists(atPath: path) { | ||
| return | ||
| } | ||
|
|
||
| guard let watchDirectory = existingWatchDirectory(forPath: path) else { | ||
| throw CLIError(message: "Timed out waiting for \(path)") | ||
| } | ||
| let watchFD = open(watchDirectory, O_EVTONLY) | ||
| guard watchFD >= 0 else { | ||
| throw CLIError(message: "Timed out waiting for \(path)") | ||
| } | ||
|
|
||
| let queue = DispatchQueue(label: "com.cmux.cli.path-watch.\(UUID().uuidString)") | ||
| let semaphore = DispatchSemaphore(value: 0) | ||
| var found = false | ||
| let source = DispatchSource.makeFileSystemObjectSource( | ||
| fileDescriptor: watchFD, | ||
| eventMask: [.write, .rename, .delete, .attrib, .extend, .link], | ||
| queue: queue | ||
| ) | ||
|
|
||
| func checkPath() { | ||
| guard !found else { return } | ||
| if FileManager.default.fileExists(atPath: path) { | ||
| found = true | ||
| semaphore.signal() | ||
| } | ||
| } | ||
|
|
||
| source.setEventHandler { | ||
| checkPath() | ||
| } | ||
| source.setCancelHandler { | ||
| Darwin.close(watchFD) | ||
| } | ||
| source.resume() | ||
| queue.async { | ||
| checkPath() | ||
| } | ||
|
|
||
| guard semaphore.wait(timeout: .now() + timeout) == .success else { | ||
| source.cancel() | ||
| throw CLIError(message: "Timed out waiting for \(path)") | ||
| } | ||
|
|
||
| source.cancel() | ||
| } | ||
|
|
||
| private static func existingWatchDirectory(forPath path: String) -> String? { | ||
| let fileManager = FileManager.default | ||
| var candidate = URL(fileURLWithPath: (path as NSString).deletingLastPathComponent, isDirectory: true) | ||
|
|
||
| while !candidate.path.isEmpty { | ||
| var isDirectory: ObjCBool = false | ||
| if fileManager.fileExists(atPath: candidate.path, isDirectory: &isDirectory), isDirectory.boolValue { | ||
| return candidate.path | ||
| } | ||
| let parent = candidate.deletingLastPathComponent() | ||
| if parent.path == candidate.path { | ||
| break | ||
| } | ||
| candidate = parent | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func sendV2( | ||
| method: String, | ||
| params: [String: Any] = [:], | ||
| responseTimeout: TimeInterval? = nil | ||
| ) throws -> [String: Any] { | ||
| let request: [String: Any] = [ | ||
| "id": UUID().uuidString, | ||
| "method": method, | ||
| "params": params | ||
| ] | ||
| guard JSONSerialization.isValidJSONObject(request) else { | ||
| throw CLIError(message: "Failed to encode v2 request") | ||
| } | ||
|
|
||
| let requestData = try JSONSerialization.data(withJSONObject: request, options: []) | ||
| guard let requestLine = String(data: requestData, encoding: .utf8) else { | ||
| throw CLIError(message: "Failed to encode v2 request") | ||
| } | ||
|
|
||
| let raw = try send(command: requestLine, responseTimeout: responseTimeout) | ||
|
|
||
| // The server may return plain-text errors (e.g., "ERROR: Access denied ...") | ||
| // before the JSON protocol starts. Surface these directly instead of letting | ||
| // JSONSerialization throw a confusing parse error. | ||
| if raw.hasPrefix("ERROR:") { | ||
| throw CLIError(message: raw) | ||
| } | ||
|
|
||
| guard let responseData = raw.data(using: .utf8) else { | ||
| throw CLIError(message: "Invalid UTF-8 v2 response") | ||
| } | ||
| guard let response = try JSONSerialization.jsonObject(with: responseData, options: []) as? [String: Any] else { | ||
| throw CLIError(message: "Invalid v2 response: \(raw)") | ||
| } | ||
|
|
||
| if let ok = response["ok"] as? Bool, ok { | ||
| return (response["result"] as? [String: Any]) ?? [:] | ||
| } | ||
|
|
||
| if let error = response["error"] as? [String: Any] { | ||
| let code = (error["code"] as? String) ?? "error" | ||
| let message = (error["message"] as? String) ?? "Unknown v2 error" | ||
| let action = error["action"] as? String | ||
| let reason = error["reason"] as? String | ||
| throw CLIError( | ||
| message: formatV2Error( | ||
| code: code, | ||
| message: message, | ||
| action: action, | ||
| reason: reason, | ||
| details: safeV2Details(error["details"]) | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| throw CLIError(message: "v2 request failed") | ||
| } | ||
|
|
||
| private func formatV2Error( | ||
| code: String, | ||
| message: String, | ||
| action: String? = nil, | ||
| reason: String? = nil, | ||
| details: String? = nil | ||
| ) -> String { | ||
| let header: String | ||
| if code == "vm_error" { | ||
| header = message | ||
| } else if message.contains("\n") { | ||
| header = "\(code):\n\(message)" | ||
| } else { | ||
| header = "\(code): \(message)" | ||
| } | ||
| var sections = [header] | ||
| if let reason = trimmedNonEmptyV2Text(reason) { | ||
| sections.append("Reason:\n\(indentV2ErrorLines(reason))") | ||
| } | ||
| if let action = trimmedNonEmptyV2Text(action) { | ||
| sections.append("What to do:\n\(indentV2ErrorLines(action))") | ||
| } | ||
| if let details = trimmedNonEmptyV2Text(details) { | ||
| sections.append("Details:\n\(indentV2ErrorLines(details))") | ||
| } | ||
| return sections.joined(separator: "\n\n") | ||
| } | ||
|
|
||
| private func safeV2Details(_ value: Any?) -> String? { | ||
| guard let value else { return nil } | ||
| if let string = value as? String { | ||
| return trimmedNonEmptyV2Text(string) | ||
| } | ||
| if let dictionary = value as? [String: Any] { | ||
| let allowedKeys = Set([ | ||
| "amount", | ||
| "code", | ||
| "duration", | ||
| "durationMs", | ||
| "field", | ||
| "idempotencyKeySet", | ||
| "imageRequested", | ||
| "limit", | ||
| "operation", | ||
| "retryable", | ||
| "status", | ||
| "type", | ||
| "vmId", | ||
| ]) | ||
| let lines = dictionary.keys.sorted().compactMap { key -> String? in | ||
| guard allowedKeys.contains(key), let value = dictionary[key], !(value is NSNull) else { return nil } | ||
| return "\(key): \(safeV2DetailValue(value))" | ||
| } | ||
| return lines.isEmpty ? nil : lines.joined(separator: "\n") | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| private func safeV2DetailValue(_ value: Any) -> String { | ||
| if let string = value as? String { | ||
| return string.replacingOccurrences(of: "\n", with: "\\n") | ||
| .replacingOccurrences(of: "\r", with: "\\r") | ||
| } | ||
| if let number = value as? NSNumber { | ||
| if CFGetTypeID(number) == CFBooleanGetTypeID() { | ||
| return number.boolValue ? "true" : "false" | ||
| } | ||
| return "\(number)" | ||
| } | ||
| if value is [String: Any] || value is [Any] { | ||
| return "available" | ||
| } | ||
| return String(describing: value) | ||
| .replacingOccurrences(of: "\n", with: "\\n") | ||
| .replacingOccurrences(of: "\r", with: "\\r") | ||
| } | ||
|
|
||
| private func trimmedNonEmptyV2Text(_ value: String?) -> String? { | ||
| let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| return trimmed?.isEmpty == false ? trimmed : nil | ||
| } | ||
|
|
||
| private func indentV2ErrorLines(_ value: String) -> String { | ||
| value | ||
| .split(separator: "\n", omittingEmptySubsequences: false) | ||
| .map { " \($0)" } | ||
| .joined(separator: "\n") | ||
| } | ||
|
|
||
| func streamV2( | ||
| method: String, | ||
| params: [String: Any] = [:], | ||
| onLine: (String) throws -> Void | ||
| ) throws { | ||
| guard socketFD >= 0 else { throw CLIError(message: "Not connected") } | ||
| let request: [String: Any] = [ | ||
| "id": UUID().uuidString, | ||
| "method": method, | ||
| "params": params | ||
| ] | ||
| guard JSONSerialization.isValidJSONObject(request), | ||
| let requestData = try? JSONSerialization.data(withJSONObject: request, options: []), | ||
| let requestLine = String(data: requestData, encoding: .utf8) else { | ||
| throw CLIError(message: "Failed to encode v2 stream request") | ||
| } | ||
|
|
||
| try writeAll( | ||
| Data((requestLine + "\n").utf8), | ||
| timeoutMessage: "Stream request timed out", | ||
| failureMessage: "Failed to write stream request" | ||
| ) | ||
|
|
||
| while true { | ||
| let line = try readStreamLine() | ||
| try onLine(line) | ||
| } | ||
| } | ||
|
|
||
| private func readStreamLine(maxBytes: Int = 4 * 1024 * 1024) throws -> String { | ||
| var data = Data() | ||
| try configureReceiveTimeout(45) | ||
| while data.count < maxBytes { | ||
| var byte: UInt8 = 0 | ||
| let count = Darwin.read(socketFD, &byte, 1) | ||
| if count < 0 { | ||
| if errno == EINTR { | ||
| continue | ||
| } | ||
| if errno == EAGAIN || errno == EWOULDBLOCK { | ||
| throw CLIError(message: "Timed out waiting for event stream frame") | ||
| } | ||
| throw CLIError(message: "Event stream socket read error") | ||
| } | ||
| if count == 0 { | ||
| throw CLIError(message: "Event stream closed") | ||
| } | ||
| if byte == 0x0A { | ||
| guard let line = String(data: data, encoding: .utf8) else { | ||
| throw CLIError(message: "Invalid UTF-8 event stream frame") | ||
| } | ||
| return line.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| } | ||
| data.append(byte) | ||
| } | ||
| throw CLIError(message: "Event stream frame exceeded \(maxBytes) bytes") | ||
| } | ||
| } |
There was a problem hiding this comment.
Split SocketClient.swift before merge.
This new production file lands at 927 lines and bundles transport lifecycle, relay auth, JSON-RPC formatting, stream framing, telemetry, and filesystem watching in one place. That is over the repo limit for a new Swift file and mixes responsibilities that should compile independently.
As per coding guidelines, A new production Swift file must not exceed 400 lines without a clear single responsibility, or 800 lines even when the responsibility is mostly coherent and Do not mix ... networking, parsing, subprocess/socket protocol, and platform bridge code in one Swift file.
🧰 Tools
🪛 SwiftLint (0.63.2)
[Warning] 6-6: Classes should have an explicit deinit method
(required_deinit)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@CLI/SocketClient.swift` around lines 6 - 927, The file is too large and mixes
responsibilities (socket transport, relay auth, JSON-RPC v2
formatting/streaming, telemetry, filesystem watching); split it into smaller,
single-responsibility Swift files. Extract core transport/socket lifecycle into
SocketTransport (retain SocketClient or rename but move connect/connectOnce,
socketFD management, read/write helpers like writeAll,
configureSocketWriteSafety, configureReceiveTimeout, connectionAppearsOpen,
close, waitForConnectableSocket, waitForFilesystemPath, existingWatchDirectory),
move relay-specific code into SocketRelay (RelayEndpoint/RelayCredentials,
parseRelayEndpoint, relayCredentials, connectToRelay, authenticateRelay,
readLine), move JSON-RPC v2 logic into SocketClientV2 (sendV2, streamV2,
readStreamLine, formatV2Error, safeV2Details, trimmedNonEmptyV2Text,
indentV2ErrorLines), and move telemetry/state types
(CLISocketOperationTelemetry.State and recordOperation usage) into its own
telemetry file; also move hex helpers (hexData/hexString) into a small util
file. Update visibility (private/internal) and imports so each file compiles
independently and adjust references to the moved symbols (e.g., connectToRelay,
authenticateRelay, relayCredentials, sendV2, streamV2, writeAll) across files.
| func send(command: String, responseTimeout: TimeInterval? = nil) throws -> String { | ||
| if relayEndpoint != nil, socketFD < 0 { | ||
| try connect() | ||
| } | ||
| guard socketFD >= 0 else { throw CLIError(message: "Not connected") } | ||
| let shouldCloseAfterSend = relayEndpoint != nil | ||
| defer { | ||
| if shouldCloseAfterSend { | ||
| close() | ||
| } | ||
| } | ||
|
|
||
| let initialResponseTimeout = responseTimeout ?? Self.responseTimeoutSeconds | ||
| if lastConfiguredReceiveTimeout != initialResponseTimeout { | ||
| try configureReceiveTimeout(initialResponseTimeout) | ||
| } | ||
| var operation = CLISocketOperationTelemetry.State( | ||
| name: CLISocketOperationTelemetry.operationName(for: command), | ||
| timeout: initialResponseTimeout, | ||
| startedAt: Date(), | ||
| phase: .writeRequest | ||
| ) | ||
| recordOperation(operation) | ||
|
|
||
| let payload = command + "\n" | ||
| try writeAll( | ||
| Data(payload.utf8), | ||
| timeoutMessage: "Command timed out", | ||
| failureMessage: "Failed to write to socket" | ||
| ) | ||
|
|
||
| var data = Data() | ||
| var sawNewline = false | ||
| var receivedCompleteResponse = false | ||
|
|
||
| while true { | ||
| let currentTimeout = sawNewline ? Self.multilineResponseIdleTimeoutSeconds : initialResponseTimeout | ||
| operation.phase = sawNewline ? .readMultilineResponse : .waitForResponse | ||
| operation.sawNewline = sawNewline | ||
| operation.timeout = currentTimeout | ||
| recordOperation(operation) | ||
| if lastConfiguredReceiveTimeout != currentTimeout { | ||
| try configureReceiveTimeout(currentTimeout) | ||
| } | ||
|
|
||
| var buffer = [UInt8](repeating: 0, count: 8192) | ||
| let count = Darwin.read(socketFD, &buffer, buffer.count) | ||
| if count < 0 { | ||
| if errno == EINTR { | ||
| continue | ||
| } | ||
| if errno == EAGAIN || errno == EWOULDBLOCK { | ||
| if sawNewline { | ||
| receivedCompleteResponse = true | ||
| break | ||
| } | ||
| throw CLIError(message: "Command timed out") | ||
| } | ||
| throw CLIError(message: "Socket read error") | ||
| } | ||
| if count == 0 { | ||
| operation.sawNewline = sawNewline | ||
| recordOperation(operation) | ||
| if data.isEmpty { | ||
| throw CLIError(message: "Socket closed before reply") | ||
| } | ||
| if !sawNewline { | ||
| throw CLIError(message: "Socket closed before complete reply") | ||
| } | ||
| receivedCompleteResponse = true | ||
| break | ||
| } | ||
| data.append(buffer, count: count) | ||
| operation.bytesRead += count | ||
| if data.contains(UInt8(0x0A)) { | ||
| sawNewline = true | ||
| if Self.isCompleteSingleLineResponse(data) { | ||
| receivedCompleteResponse = true | ||
| break | ||
| } | ||
| } | ||
| } | ||
|
|
||
| operation.sawNewline = sawNewline | ||
| if receivedCompleteResponse { | ||
| operation.phase = .completed | ||
| } | ||
| recordOperation(operation) | ||
|
|
||
| guard var response = String(data: data, encoding: .utf8) else { | ||
| throw CLIError(message: "Invalid UTF-8 response") | ||
| } | ||
| if response.hasSuffix("\n") { | ||
| response.removeLast() | ||
| } | ||
| return response | ||
| } |
There was a problem hiding this comment.
Localize the new CLI-facing strings.
This file introduces user-visible errors/headings as raw English literals ("Not connected", "Command timed out", "Reason:", "What to do:", etc.) instead of the localized string APIs this repo requires for production Swift text.
As per coding guidelines, user-facing Swift text must use String(localized:defaultValue:) or an equivalent localized API with a matching translated string-catalog entry.
Also applies to: 274-285, 401-477, 499-599, 611-699, 734-925
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@CLI/SocketClient.swift` around lines 167 - 263, The
send(command:responseTimeout:) method (and other CLI-facing error/heading
literals in this file) uses raw English strings like "Not connected", "Command
timed out", "Socket read error", "Socket closed before reply", "Socket closed
before complete reply", "Invalid UTF-8 response" (and similar literals in the
other ranges) — replace each user-visible literal with the repo's localization
API (e.g. String(localized:defaultValue:)) and pass the localized string into
CLIError initializers and any headings/messages returned to the user; add
matching entries to the translation catalog with clear keys and context, and
ensure you update all occurrences in send(command:), and the other code blocks
indicated (lines ~274-285, 401-477, 499-599, 611-699, 734-925) to use the same
localized API.
| func streamV2( | ||
| method: String, | ||
| params: [String: Any] = [:], | ||
| onLine: (String) throws -> Void | ||
| ) throws { | ||
| guard socketFD >= 0 else { throw CLIError(message: "Not connected") } | ||
| let request: [String: Any] = [ | ||
| "id": UUID().uuidString, | ||
| "method": method, | ||
| "params": params | ||
| ] | ||
| guard JSONSerialization.isValidJSONObject(request), | ||
| let requestData = try? JSONSerialization.data(withJSONObject: request, options: []), | ||
| let requestLine = String(data: requestData, encoding: .utf8) else { | ||
| throw CLIError(message: "Failed to encode v2 stream request") | ||
| } | ||
|
|
||
| try writeAll( | ||
| Data((requestLine + "\n").utf8), | ||
| timeoutMessage: "Stream request timed out", | ||
| failureMessage: "Failed to write stream request" | ||
| ) | ||
|
|
||
| while true { | ||
| let line = try readStreamLine() | ||
| try onLine(line) | ||
| } | ||
| } |
There was a problem hiding this comment.
streamV2() never connects relay-backed clients.
send() lazily calls connect() for relay endpoints, but streamV2() immediately throws at Line 875 when socketFD < 0. That breaks the client returned by waitForConnectableSocket() for relays, because Line 605 closes the probe connection before returning it.
Suggested fix
func streamV2(
method: String,
params: [String: Any] = [:],
onLine: (String) throws -> Void
) throws {
+ if relayEndpoint != nil, socketFD < 0 {
+ try connect()
+ }
guard socketFD >= 0 else { throw CLIError(message: "Not connected") }
let request: [String: Any] = [
"id": UUID().uuidString,
"method": method,
"params": params📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| func streamV2( | |
| method: String, | |
| params: [String: Any] = [:], | |
| onLine: (String) throws -> Void | |
| ) throws { | |
| guard socketFD >= 0 else { throw CLIError(message: "Not connected") } | |
| let request: [String: Any] = [ | |
| "id": UUID().uuidString, | |
| "method": method, | |
| "params": params | |
| ] | |
| guard JSONSerialization.isValidJSONObject(request), | |
| let requestData = try? JSONSerialization.data(withJSONObject: request, options: []), | |
| let requestLine = String(data: requestData, encoding: .utf8) else { | |
| throw CLIError(message: "Failed to encode v2 stream request") | |
| } | |
| try writeAll( | |
| Data((requestLine + "\n").utf8), | |
| timeoutMessage: "Stream request timed out", | |
| failureMessage: "Failed to write stream request" | |
| ) | |
| while true { | |
| let line = try readStreamLine() | |
| try onLine(line) | |
| } | |
| } | |
| func streamV2( | |
| method: String, | |
| params: [String: Any] = [:], | |
| onLine: (String) throws -> Void | |
| ) throws { | |
| if relayEndpoint != nil, socketFD < 0 { | |
| try connect() | |
| } | |
| guard socketFD >= 0 else { throw CLIError(message: "Not connected") } | |
| let request: [String: Any] = [ | |
| "id": UUID().uuidString, | |
| "method": method, | |
| "params": params | |
| ] | |
| guard JSONSerialization.isValidJSONObject(request), | |
| let requestData = try? JSONSerialization.data(withJSONObject: request, options: []), | |
| let requestLine = String(data: requestData, encoding: .utf8) else { | |
| throw CLIError(message: "Failed to encode v2 stream request") | |
| } | |
| try writeAll( | |
| Data((requestLine + "\n").utf8), | |
| timeoutMessage: "Stream request timed out", | |
| failureMessage: "Failed to write stream request" | |
| ) | |
| while true { | |
| let line = try readStreamLine() | |
| try onLine(line) | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@CLI/SocketClient.swift` around lines 870 - 897, streamV2() currently errors
immediately if socketFD < 0, preventing relay-backed clients from being
connected; change the start of streamV2() to attempt to establish a real
connection by invoking the existing connect() behavior (same path send() uses)
when socketFD < 0, then re-check socketFD and only throw CLIError if it still <
0; specifically, replace the initial guard that throws with a conditional that
calls try connect() (or the appropriate connect method used elsewhere), then
proceed to create and send the request via writeAll() and read lines via
readStreamLine() as before.
| } | ||
| guard let response = try JSONSerialization.jsonObject(with: responseData, options: []) as? [String: Any] else { | ||
| throw CLIError(message: "Invalid v2 response from server") | ||
| } |
There was a problem hiding this comment.
Diagnostic info removed from error message during refactor
Low Severity
During the file split refactor, the sendV2 error message for an unparseable server response changed from "Invalid v2 response: \(raw)" (which included the actual response content) to the generic "Invalid v2 response from server". This drops valuable diagnostic context when the server returns valid JSON that isn't a [String: Any] dictionary, making production debugging harder.
Reviewed by Cursor Bugbot for commit 2c91074. Configure here.
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CLI/SocketClient.swift`:
- Line 27: Make socketFD read-only outside this file by changing its declaration
to private(set) (or fileprivate(set) if necessary for same-file access), so
external code can't mutate the descriptor and bypass lifecycle logic; update the
declaration of socketFD (and keep references to lastConfiguredReceiveTimeout and
close()) to ensure only the owning file can set socketFD while other files can
still read it, preventing desynchronization between socketFD and
lastConfiguredReceiveTimeout.
In `@CLI/SocketClient`+V2.swift:
- Around line 41-42: When ok == true the code currently hides a missing "result"
by returning [:]; instead require and validate the "result" field so missing
results aren't silently masked: update the ok branch that checks response["ok"]
to use a guard/if-let for response["result"] as? [String: Any] (rather than
falling back to [:]) and either throw/log an explicit error or return an
optional to surface the absence; update the surrounding method (the response
handler in SocketClient+V2.swift that reads response["ok"] and
response["result"]) to reflect this explicit error/optional handling.
- Around line 72-78: The conditional branch that treats the "vm_error" code
specially (the block that assigns to header when code == "vm_error", using the
variables code, message, and header) lacks an explanation; add a concise comment
immediately above that if/else block documenting why "vm_error" drops the "code:
" prefix (or, if there is no intentional reason, change the branch to format the
header the same as other codes, e.g. "\(code): \(message)" or
"\(code):\n\(message)" depending on newline presence). Ensure the comment
references "vm_error", the header variable, and the code/message formatting so
future readers understand the decision.
- Around line 158-161: The guard checking socketFD after calling connect() is
redundant—remove the guard block and rely on the existing if socketFD < 0 { try
connect() } flow (since connect() should throw on failure); update the method in
SocketClient+V2.swift to delete the guard that throws CLIError(message: "Not
connected") and let connect() propagate errors (or, if you want an explicit
sanity check, replace the guard with a precondition or an assertion referencing
socketFD to document the invariant).
- Around line 185-187: The readStreamLine method uses magic numbers for the max
frame size and timeout; extract them as named constants (e.g.,
MAX_FRAME_SIZE_BYTES and READ_TIMEOUT_SECONDS) at the top of the extension and
replace the literals in readStreamLine (the default parameter value 4 * 1024 *
1024 and the configureReceiveTimeout(45) call) with those constants to improve
readability and maintainability.
In `@CLI/SocketClient`+Wait.swift:
- Line 23: The thrown CLIError in SocketClient+Wait.swift currently exposes
internal transport names ("cmux" and "relay") in the message; change the
user-facing message passed to CLIError(message: ...) to a generic,
non-implementation-specific text (e.g., "app did not start in time" or "failed
to start within timeout") and remove any mention of "relay" or "cmux"; if the
path variable is useful for debugging, emit it to a debug or error log
separately (using the same scope that constructs CLIError) rather than including
it in the user-facing CLIError message.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: ffae5a9e-86ad-4bca-8c83-9db974c01de2
📒 Files selected for processing (7)
CLI/SocketClient+V2.swiftCLI/SocketClient+Wait.swiftCLI/SocketClient.swiftCLI/cmux.swiftcmux.xcodeproj/BuildGhosttyCLIHelperInputs.xcfilelistcmux.xcodeproj/project.pbxprojscripts/reload.sh
💤 Files with no reviewable changes (4)
- cmux.xcodeproj/BuildGhosttyCLIHelperInputs.xcfilelist
- CLI/cmux.swift
- cmux.xcodeproj/project.pbxproj
- scripts/reload.sh
|
|
||
| if let error = response["error"] as? [String: Any] { | ||
| let code = (error["code"] as? String) ?? "error" | ||
| let message = (error["message"] as? String) ?? "Unknown v2 error" | ||
| let action = error["action"] as? String | ||
| let reason = error["reason"] as? String |
There was a problem hiding this comment.
sendV2 now throws when ok: true but result is absent
The old implementation returned (response["result"] as? [String: Any]) ?? [:] — callers received an empty dict when the server sent {"ok": true} without a result field and could proceed silently. The new code instead throws "Invalid v2 response from server". Any server command that legitimately responds with ok: true and no result (e.g., fire-and-forget mutations) will now surface as a user-visible error. This needs a server-side contract check to confirm result is always present on success paths before this tightening is safe to ship.
| } | ||
| } | ||
|
|
||
| private func readStreamLine(maxBytes: Int = SocketClient.streamMaxFrameBytes) throws -> String { | ||
| var data = Data() | ||
| try configureReceiveTimeout(Self.streamReadTimeoutSeconds) | ||
| while data.count < maxBytes { | ||
| var byte: UInt8 = 0 | ||
| let count = Darwin.read(socketFD, &byte, 1) | ||
| if count < 0 { | ||
| if errno == EINTR { | ||
| continue | ||
| } | ||
| if errno == EAGAIN || errno == EWOULDBLOCK { | ||
| throw CLIError(message: "Timed out waiting for event stream frame") | ||
| } | ||
| throw CLIError(message: "Event stream socket read error") | ||
| } | ||
| if count == 0 { | ||
| throw CLIError(message: "Event stream closed") | ||
| } | ||
| if byte == 0x0A { | ||
| guard let line = String(data: data, encoding: .utf8) else { | ||
| throw CLIError(message: "Invalid UTF-8 event stream frame") | ||
| } | ||
| return line.trimmingCharacters(in: .whitespacesAndNewlines) | ||
| } |
There was a problem hiding this comment.
Relay socket leaks on every stream error or caller throw
streamV2 now auto-connects for relay-backed clients (try connect() at line 196), but unlike send() — which uses defer { if shouldCloseAfterSend { close() } } — there is no corresponding cleanup. When readStreamLine() throws (timeout, read error, EOF) or when onLine throws to break the stream, the relay socket stays open with socketFD >= 0. Since streamV2 is the only exit path for the infinite while true loop, every error leaves a leaked file descriptor on relay-backed streams. The callers didn't need to close previously (the old code required a pre-connected socket), so they are unlikely to call close() after catching the throw.
# Conflicts: # scripts/reload.sh
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 3 total unresolved issues (including 2 from previous reviews).
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit b88d86f. Configure here.
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
CLI/SocketClient.swift (1)
547-551: 🧹 Nitpick | 🔵 Trivial | 💤 Low valueRedundant
setsockoptcalls inreadLine()loop.
configureReceiveTimeout()is called on every iteration of the byte-reading loop without checking if the timeout is already configured. Unlikesend()which checkslastConfiguredReceiveTimeout != currentTimeout, this issues a syscall per byte read.Move the timeout configuration before the loop since the timeout value is constant.
♻️ Proposed fix
private func readLine(maxBytes: Int = 16 * 1024) throws -> String { var data = Data() + if lastConfiguredReceiveTimeout != Self.responseTimeoutSeconds { + try configureReceiveTimeout(Self.responseTimeoutSeconds) + } while data.count < maxBytes { - try configureReceiveTimeout(Self.responseTimeoutSeconds) - var byte: UInt8 = 0 let count = Darwin.read(socketFD, &byte, 1)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@CLI/SocketClient.swift` around lines 547 - 551, The readLine() method currently calls configureReceiveTimeout(Self.responseTimeoutSeconds) inside its byte-reading loop, causing redundant setsockopt syscalls; move the timeout configuration out of the while loop and call configureReceiveTimeout(Self.responseTimeoutSeconds) once before entering the loop (or reuse the existing lastConfiguredReceiveTimeout check logic as used in send()) so the receive timeout is only configured when needed; update readLine() to remove the in-loop call to configureReceiveTimeout and ensure behavior matches send()’s timeout handling.CLI/SocketClient+V2.swift (2)
87-95: 🧹 Nitpick | 🔵 Trivial | ⚡ Quick winError section labels should be localized.
"Reason:", "What to do:", and "Details:" are user-facing labels that should use localized strings for full internationalization compliance.
♻️ Example localization
if let reason = trimmedNonEmptyV2Text(reason) { - sections.append("Reason:\n\(indentV2ErrorLines(reason))") + sections.append("\(String(localized: "cli.error.reason", defaultValue: "Reason")):\n\(indentV2ErrorLines(reason))") }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@CLI/SocketClient`+V2.swift around lines 87 - 95, The three hard-coded user-facing labels in the error formatting block ("Reason:", "What to do:", "Details:") should be replaced with localized strings: update the block that uses trimmedNonEmptyV2Text and indentV2ErrorLines (the sections.append calls) to call NSLocalizedString (or your app's localization helper) for each label (e.g. NSLocalizedString("Error.Reason", comment: ""), NSLocalizedString("Error.Action", comment: ""), NSLocalizedString("Error.Details", comment: "")) and concatenate the localized label with the indented text as before; also add corresponding keys and translations to your Localizable.strings files so the labels are localized.
19-26: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy liftError messages should use localized strings for internationalization compliance.
Per coding guidelines, user-facing Swift text must use
String(localized:defaultValue:)with matching catalog entries. The codebase already follows this pattern (e.g.,String(localized: "cli.top.error.processDiagnosticsUnsupported", ...)).Error messages on lines 20, 25, 38, 41, and 66 use hardcoded English strings.
♻️ Suggested pattern for localization
guard JSONSerialization.isValidJSONObject(request) else { - throw CLIError(message: "Failed to encode v2 request") + throw CLIError(message: String(localized: "cli.socket.v2.encodeError", defaultValue: "Failed to encode v2 request")) }Apply similar changes to other error messages with appropriate localization keys.
As per coding guidelines: "Swift text must use localized APIs with matching translated string-catalog entries."
Also applies to: 37-42, 66-66
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@CLI/SocketClient`+V2.swift around lines 19 - 26, Replace hardcoded English error strings in SocketClient+V2.swift with localized strings using the project's String(localized:defaultValue:) pattern and matching catalog keys; e.g., change throw CLIError(message: "Failed to encode v2 request") to throw CLIError(message: String(localized: "cli.socket.v2.failedToEncodeRequest", defaultValue: "Failed to encode v2 request")), and apply the same pattern for the other hardcoded messages referenced around the JSON encoding/decoding logic (the guards that produce the request/requestLine errors and the other error throws at lines noted in the review). Ensure you add corresponding keys to the string catalog for each new key (use descriptive keys like cli.socket.v2.invalidJSONObject, cli.socket.v2.unableToCreateRequestLine, etc.) so CLIError(message:) receives localized text consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@CLI/SocketClient`+V2.swift:
- Around line 165-176: Add a brief comment explaining the intentional asymmetric
connection handling: when socketFD < 0 the code throws CLIError("Not connected")
for non-relay clients (they must be pre-connected) but calls connect() for
relay-backed clients (relay connections are transient and auto-opened/closed);
place this comment near the socketFD check referencing isRelayBacked, connect(),
close(), and the shouldCloseAfterStream/defer block so future readers understand
why non-relay paths do not auto-connect while relay paths do and why the socket
is closed after streaming.
---
Outside diff comments:
In `@CLI/SocketClient.swift`:
- Around line 547-551: The readLine() method currently calls
configureReceiveTimeout(Self.responseTimeoutSeconds) inside its byte-reading
loop, causing redundant setsockopt syscalls; move the timeout configuration out
of the while loop and call configureReceiveTimeout(Self.responseTimeoutSeconds)
once before entering the loop (or reuse the existing
lastConfiguredReceiveTimeout check logic as used in send()) so the receive
timeout is only configured when needed; update readLine() to remove the in-loop
call to configureReceiveTimeout and ensure behavior matches send()’s timeout
handling.
In `@CLI/SocketClient`+V2.swift:
- Around line 87-95: The three hard-coded user-facing labels in the error
formatting block ("Reason:", "What to do:", "Details:") should be replaced with
localized strings: update the block that uses trimmedNonEmptyV2Text and
indentV2ErrorLines (the sections.append calls) to call NSLocalizedString (or
your app's localization helper) for each label (e.g.
NSLocalizedString("Error.Reason", comment: ""),
NSLocalizedString("Error.Action", comment: ""),
NSLocalizedString("Error.Details", comment: "")) and concatenate the localized
label with the indented text as before; also add corresponding keys and
translations to your Localizable.strings files so the labels are localized.
- Around line 19-26: Replace hardcoded English error strings in
SocketClient+V2.swift with localized strings using the project's
String(localized:defaultValue:) pattern and matching catalog keys; e.g., change
throw CLIError(message: "Failed to encode v2 request") to throw
CLIError(message: String(localized: "cli.socket.v2.failedToEncodeRequest",
defaultValue: "Failed to encode v2 request")), and apply the same pattern for
the other hardcoded messages referenced around the JSON encoding/decoding logic
(the guards that produce the request/requestLine errors and the other error
throws at lines noted in the review). Ensure you add corresponding keys to the
string catalog for each new key (use descriptive keys like
cli.socket.v2.invalidJSONObject, cli.socket.v2.unableToCreateRequestLine, etc.)
so CLIError(message:) receives localized text consistently.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: fa66df3c-b0b6-4324-92af-68c5714294f1
📒 Files selected for processing (10)
.github/workflows/release.ymlCLI/SocketClient+V2.swiftCLI/SocketClient+Wait.swiftCLI/SocketClient.swiftcmux.xcodeproj/BuildGhosttyCLIHelperSourceInputs.xcfilelistcmux.xcodeproj/project.pbxprojscripts/build-sign-upload.shscripts/reload.shscripts/reloadp.shscripts/reloads.sh
| if socketFD < 0 { | ||
| guard isRelayBacked else { | ||
| throw CLIError(message: "Not connected") | ||
| } | ||
| try connect() | ||
| } | ||
| let shouldCloseAfterStream = isRelayBacked | ||
| defer { | ||
| if shouldCloseAfterStream { | ||
| close() | ||
| } | ||
| } |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | 💤 Low value
Connection handling differs between relay and non-relay modes.
The asymmetric behavior (auto-connect for relay, throw for non-relay) is intentional but worth documenting. Non-relay clients are expected to be pre-connected, while relay connections are transient. Consider adding a brief comment explaining this design choice.
📝 Optional documentation
if socketFD < 0 {
+ // Relay connections are transient; connect on-demand. Non-relay clients
+ // should already be connected before calling streamV2.
guard isRelayBacked else {
throw CLIError(message: "Not connected")
}
try connect()
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@CLI/SocketClient`+V2.swift around lines 165 - 176, Add a brief comment
explaining the intentional asymmetric connection handling: when socketFD < 0 the
code throws CLIError("Not connected") for non-relay clients (they must be
pre-connected) but calls connect() for relay-backed clients (relay connections
are transient and auto-opened/closed); place this comment near the socketFD
check referencing isRelayBacked, connect(), close(), and the
shouldCloseAfterStream/defer block so future readers understand why non-relay
paths do not auto-connect while relay paths do and why the socket is closed
after streaming.


Summary:
Measurements:
Verification:
Note
Low Risk
Low risk script-only change, but it touches the
codesigninvocation; incorrect entitlement extraction could still break local app launching/reload workflows.Overview
Improves
scripts/reloadp.shre-signing by extracting the built app’s existing entitlements and reusing them during the ad-hoccodesignstep after updatingCMUXCommitinInfo.plist.This replaces the fixed
codesigncall with a constructed argument list that conditionally adds--entitlementswhen extraction succeeds, and cleans up the temporary entitlements plist viatrap.Reviewed by Cursor Bugbot for commit 3170c69. Bugbot is set up for automated code reviews on this repo. Configure here.
Summary by cubic
Speeds up dev reloads by making Swift builds more incremental and warming reload paths. Socket-only CLI edits recompile fewer files, helper rebuilds skip unchanged sources/resources, and release/dev reloads stamp the commit and re‑sign the app while preserving entitlements.
Refactors
SocketClientintoSocketClient.swift,SocketClient+V2.swift, andSocketClient+Wait.swift; tightened errors/timeouts, improved relay-backed waits, and validated v2 JSON streaming with size/time caps.BuildGhosttyCLIHelperInputs.xcfilelistandBuildGhosttyCLIHelperSourceInputs.xcfilelist; fixed resource/output stamps so the Ghostty helper invalidates correctly and unchanged resources/markdown compression are skipped.CMUXCommitintoInfo.plistin CI (release.yml), build/upload, and reload scripts; ad‑hoc re‑sign the app after plist changes and preserve entitlements inreloadp.sh.scripts/ensure-ghosttykit.shto refreshlibghostty.a’s ranlib index only when the archive changes.scripts/reload.sh: prefer writable shim targets and use a persisted source‑manifest stamp to skipcmuxdrebuilds when up to date.Performance
Written for commit 3170c69. Summary will update on new commits. Review in cubic
Summary by CodeRabbit
New Features
Refactor
Bug Fixes
Chores