Add sidebar metadata CLI subcommands and API docs (#305)

* Add sidebar metadata CLI subcommands and API docs

Expose set-status, clear-status, list-status, set-progress,
clear-progress, log, clear-log, list-log, and sidebar-state as
proper CLI subcommands with --help support and usage() listing.
Previously these only existed as raw socket commands.

Also adds a "Sidebar metadata commands" section to the docs site
API reference page.

* Quote multi-word values in socket command strings

Fix set-progress --label and log message forwarding to properly
quote values before sending to the socket tokenizer. Without
quoting, multi-word labels like "Build step one" would be split
into separate tokens. Also quote --source values for consistency.

* Fix socket quoting: escape backslashes and quote status values

Add socketQuote() helper that escapes both backslashes and double
quotes before wrapping in quotes. Apply it to:
- set-status value (prevents --flags in values being parsed as options)
- set-status --icon and --color values
- set-progress --label
- log --source and message text

Fixes values like "pytest --maxfail=1" or "C:\new\build" being
mangled by the socket tokenizer.

* Escape newlines in socketQuote to prevent socket framing breakage

The socket protocol uses newline as message terminator, so embedded
newlines/carriage returns in values would truncate the command.

* Parse flags before positionals in set-status, clear-status, set-progress

Fixes flags-first invocation like `cmux set-status --workspace workspace:2
build compiling` which previously grabbed `--workspace` as the key.
Now all flags are extracted first, then positional args are validated.
This commit is contained in:
Lawrence Chen 2026-02-22 16:06:32 -08:00 committed by GitHub
parent 6d64ca938e
commit f7457055f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 322 additions and 1 deletions

View file

@ -1105,6 +1105,109 @@ struct CMUXCLI {
case "claude-hook":
try runClaudeHook(commandArgs: commandArgs, client: client)
case "set-status":
let (icon, r1) = parseOption(commandArgs, name: "--icon")
let (color, r2) = parseOption(r1, name: "--color")
let (wsFlag, r3) = parseOption(r2, name: "--workspace")
guard r3.count >= 2 else {
throw CLIError(message: "set-status requires <key> and <value>")
}
let key = r3[0]
let value = r3.dropFirst().joined(separator: " ")
guard !value.isEmpty else {
throw CLIError(message: "set-status requires a non-empty value")
}
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
var socketCmd = "set_status \(key) \(socketQuote(value))"
if let icon { socketCmd += " --icon=\(socketQuote(icon))" }
if let color { socketCmd += " --color=\(socketQuote(color))" }
socketCmd += " --tab=\(wsId)"
let response = try sendV1Command(socketCmd, client: client)
print(response)
case "clear-status":
let (wsFlag, csRemaining) = parseOption(commandArgs, name: "--workspace")
guard let key = csRemaining.first else {
throw CLIError(message: "clear-status requires a <key>")
}
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
let response = try sendV1Command("clear_status \(key) --tab=\(wsId)", client: client)
print(response)
case "list-status":
let (wsFlag, _) = parseOption(commandArgs, name: "--workspace")
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
let response = try sendV1Command("list_status --tab=\(wsId)", client: client)
print(response)
case "set-progress":
let (label, spR1) = parseOption(commandArgs, name: "--label")
let (wsFlag, spR2) = parseOption(spR1, name: "--workspace")
guard let valueStr = spR2.first else {
throw CLIError(message: "set-progress requires a progress value (0.0-1.0)")
}
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
var socketCmd = "set_progress \(valueStr)"
if let label { socketCmd += " --label=\(socketQuote(label))" }
socketCmd += " --tab=\(wsId)"
let response = try sendV1Command(socketCmd, client: client)
print(response)
case "clear-progress":
let (wsFlag, _) = parseOption(commandArgs, name: "--workspace")
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
let response = try sendV1Command("clear_progress --tab=\(wsId)", client: client)
print(response)
case "log":
let (level, r1) = parseOption(commandArgs, name: "--level")
let (source, r2) = parseOption(r1, name: "--source")
let (wsFlag, r3) = parseOption(r2, name: "--workspace")
// Strip leading "--" separator if present
let positional = r3.first == "--" ? Array(r3.dropFirst()) : r3
let message = positional.joined(separator: " ")
guard !message.isEmpty else {
throw CLIError(message: "log requires a message")
}
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
var socketCmd = "log"
if let level { socketCmd += " --level=\(level)" }
if let source { socketCmd += " --source=\(socketQuote(source))" }
socketCmd += " --tab=\(wsId) -- \(socketQuote(message))"
let response = try sendV1Command(socketCmd, client: client)
print(response)
case "clear-log":
let (wsFlag, _) = parseOption(commandArgs, name: "--workspace")
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
let response = try sendV1Command("clear_log --tab=\(wsId)", client: client)
print(response)
case "list-log":
let (limitStr, r1) = parseOption(commandArgs, name: "--limit")
let (wsFlag, _) = parseOption(r1, name: "--workspace")
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
var socketCmd = "list_log"
if let limitStr { socketCmd += " --limit=\(limitStr)" }
socketCmd += " --tab=\(wsId)"
let response = try sendV1Command(socketCmd, client: client)
print(response)
case "sidebar-state":
let (wsFlag, _) = parseOption(commandArgs, name: "--workspace")
let workspaceArg = wsFlag ?? (windowId == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let wsId = try resolveWorkspaceId(workspaceArg, client: client)
let response = try sendV1Command("sidebar_state --tab=\(wsId)", client: client)
print(response)
case "set-app-focus":
guard let value = commandArgs.first else { throw CLIError(message: "set-app-focus requires a value") }
let response = try sendV1Command("set_app_focus \(value)", client: client)
@ -3453,6 +3556,130 @@ struct CMUXCLI {
cmux notify --title "Build done" --body "All tests passed"
cmux notify --title "Error" --subtitle "test.swift" --body "Line 42: syntax error"
"""
case "set-status":
return """
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")
--workspace <id|ref> Target workspace (default: $CMUX_WORKSPACE_ID)
Example:
cmux set-status build "compiling" --icon hammer --color "#ff9500"
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 "claude-hook":
return """
Usage: cmux claude-hook <session-start|stop|notification> [flags]
@ -3515,6 +3742,20 @@ struct CMUXCLI {
return true
}
/// Escape and quote a string for safe embedding in a v1 socket command.
/// The socket tokenizer treats `\` and `"` as special inside quoted strings,
/// so both must be escaped before wrapping in double quotes. Newlines and
/// carriage returns must also be escaped since the socket protocol uses
/// newline as the message terminator.
private func socketQuote(_ s: String) -> String {
let escaped = s
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
.replacingOccurrences(of: "\n", with: "\\n")
.replacingOccurrences(of: "\r", with: "\\r")
return "\"\(escaped)\""
}
private func parseOption(_ args: [String], name: String) -> (String?, [String]) {
var remaining: [String] = []
var value: String?
@ -4721,6 +4962,18 @@ struct CMUXCLI {
list-notifications
clear-notifications
claude-hook <session-start|stop|notification> [--workspace <id|ref>] [--surface <id|ref>]
# sidebar metadata commands
set-status <key> <value> [--icon <name>] [--color <#hex>] [--workspace <id|ref>]
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 [--limit <n>] [--workspace <id|ref>]
sidebar-state [--workspace <id|ref>]
set-app-focus <active|inactive|clear>
simulate-app-active

View file

@ -5,7 +5,7 @@ import { Callout } from "../../components/callout";
export const metadata: Metadata = {
title: "API Reference",
description:
"cmux CLI and Unix socket API reference. Workspace management, split panes, input control, notifications, environment variables, and detection methods.",
"cmux CLI and Unix socket API reference. Workspace management, split panes, input control, notifications, sidebar metadata (status, progress, logs), environment variables, and detection methods.",
};
function Cmd({
@ -281,6 +281,74 @@ cmux list-notifications --json`}
socket={`{"id":"notif-clear","method":"notification.clear","params":{}}`}
/>
<h2>Sidebar metadata commands</h2>
<p>
Set status pills, progress bars, and log entries in the sidebar for any
workspace. Useful for build scripts, CI integrations, and AI coding
agents that want to surface state at a glance.
</p>
<Cmd
name="set-status"
desc="Set a sidebar status pill. Use a unique key so different tools can manage their own entries."
cli={`cmux set-status build "compiling" --icon hammer --color "#ff9500"
cmux set-status deploy "v1.2.3" --workspace workspace:2`}
socket={`set_status build compiling --icon=hammer --color=#ff9500 --tab=<workspace-uuid>`}
/>
<Cmd
name="clear-status"
desc="Remove a sidebar status entry by key."
cli={`cmux clear-status build`}
socket={`clear_status build --tab=<workspace-uuid>`}
/>
<Cmd
name="list-status"
desc="List all sidebar status entries for a workspace."
cli={`cmux list-status`}
socket={`list_status --tab=<workspace-uuid>`}
/>
<Cmd
name="set-progress"
desc="Set a progress bar in the sidebar (0.0 to 1.0)."
cli={`cmux set-progress 0.5 --label "Building..."
cmux set-progress 1.0 --label "Done"`}
socket={`set_progress 0.5 --label=Building... --tab=<workspace-uuid>`}
/>
<Cmd
name="clear-progress"
desc="Clear the sidebar progress bar."
cli={`cmux clear-progress`}
socket={`clear_progress --tab=<workspace-uuid>`}
/>
<Cmd
name="log"
desc="Append a log entry to the sidebar. Levels: info, progress, success, warning, error."
cli={`cmux log "Build started"
cmux log --level error --source build "Compilation failed"
cmux log --level success -- "All 42 tests passed"`}
socket={`log --level=error --source=build --tab=<workspace-uuid> -- Compilation failed`}
/>
<Cmd
name="clear-log"
desc="Clear all sidebar log entries."
cli={`cmux clear-log`}
socket={`clear_log --tab=<workspace-uuid>`}
/>
<Cmd
name="list-log"
desc="List sidebar log entries."
cli={`cmux list-log
cmux list-log --limit 5`}
socket={`list_log --limit=5 --tab=<workspace-uuid>`}
/>
<Cmd
name="sidebar-state"
desc="Dump all sidebar metadata (cwd, git branch, ports, status, progress, logs)."
cli={`cmux sidebar-state
cmux sidebar-state --workspace workspace:2`}
socket={`sidebar_state --tab=<workspace-uuid>`}
/>
<h2>Utility commands</h2>
<Cmd