Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions CLI/CLICommandDispatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ struct CLICommandDispatcher {
var idFormatArg: String? = nil
var windowId: String? = nil
var socketPasswordArg: String? = nil
var passwordFromStdin = false

var index = 1
while index < args.count {
Expand Down Expand Up @@ -58,6 +59,11 @@ struct CLICommandDispatcher {
index += width
continue
}
if arg == "--password-stdin" {
passwordFromStdin = true
index += 1
continue
}
if arg == "-v" || arg == "--version" {
cli.printVersion(jsonOutput: jsonOutput)
return
Expand All @@ -77,6 +83,11 @@ struct CLICommandDispatcher {
throw CLIError(message: "Missing command")
}

if passwordFromStdin {
guard socketPasswordArg == nil else { throw CLIError(message: "--password and --password-stdin cannot be used together") }
socketPasswordArg = try readPasswordLine()
}

let command = args[index]
let commandArgs = Array(args[(index + 1)...])

Expand Down Expand Up @@ -200,10 +211,11 @@ struct CLICommandDispatcher {
return
}

// If the user explicitly targets a window, focus it first so commands route correctly.
// `--window` scopes requests to that window without focusing it (socket focus policy).
if let windowId {
let normalizedWindow = try cli.normalizeWindowHandle(windowId, client: client) ?? windowId
_ = try client.sendV2(method: V2MethodNames.windowFocus, params: ["window_id": normalizedWindow])
try requireWindowExists(normalizedWindow, client: client)
client.routingWindowId = normalizedWindow
}

let ctx = CommandContext(
Expand All @@ -219,4 +231,20 @@ struct CLICommandDispatcher {
try execute(ctx)
}

/// Rejects an unknown `--window` through a read-only query; methods without `window_id` ignore it.
private func requireWindowExists(_ handle: String, client: SocketClient) throws {
let windows = (try client.sendV2(method: V2MethodNames.windowList)["windows"] as? [[String: Any]]) ?? []
guard windows.contains(where: { ($0["ref"] as? String) == handle
|| ($0["id"] as? String)?.caseInsensitiveCompare(handle) == .orderedSame }) else {
throw CLIError(message: "not_found: Window not found")
}
}

/// Reads one stdin line byte by byte, so later input stays unread for commands that read stdin.
private func readPasswordLine() throws -> String {
var bytes: [UInt8] = [], byte: UInt8 = 0
while Darwin.read(STDIN_FILENO, &byte, 1) == 1, byte != UInt8(ascii: "\n") { bytes.append(byte) }
guard !bytes.isEmpty else { throw CLIError(message: "--password-stdin read an empty password") }
return String(decoding: bytes, as: UTF8.self)
}
}
27 changes: 25 additions & 2 deletions CLI/SocketClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,27 @@ final class SocketClient {
return defaultResponseTimeoutSeconds
}()

/// The global `--window` target. Window-scoped requests that name no window,
/// workspace, surface or tab are routed to it, so `--window` never focuses the window.
var routingWindowId: String?

init(path: String) {
self.path = path
}

/// Adds `window_id` to a window-scoped request that carries none of the selectors the
/// server resolves a target window from (`v2ResolveTabManager`). A request that already
/// names its target keeps it: an explicit workspace in another window must not be
/// overridden by the routing window.
static func routedParams(_ params: [String: Any], method: String, windowId: String?) -> [String: Any] {
guard let windowId, V2MethodNames.windowScoped.contains(method) else { return params }
let selectors = ["window_id", "workspace_id", "surface_id", "tab_id"]
guard !selectors.contains(where: { params[$0] != nil && !(params[$0] is NSNull) }) else { return params }
var routed = params
routed["window_id"] = windowId
return routed
}

var socketPath: String {
path
}
Expand Down Expand Up @@ -413,7 +430,10 @@ final class SocketClient {
}

func sendV2(method: String, params: [String: Any] = [:], minimumReceiveTimeout: TimeInterval? = nil) throws -> [String: Any] {
let requestLine = try Self.encodeV2RequestLine(method: method, params: params)
let requestLine = try Self.encodeV2RequestLine(
method: method,
params: Self.routedParams(params, method: method, windowId: routingWindowId)
)
let raw = try send(command: requestLine, minimumReceiveTimeout: minimumReceiveTimeout)

// The server may return plain-text errors (e.g., "ERROR: Access denied ...")
Expand Down Expand Up @@ -449,7 +469,10 @@ final class SocketClient {
/// frames for a connection that keeps receiving events indefinitely.
func sendV2RequestOnly(method: String, params: [String: Any] = [:]) throws {
guard socketFD >= 0 else { throw CLIError(message: "Not connected") }
let requestLine = try Self.encodeV2RequestLine(method: method, params: params)
let requestLine = try Self.encodeV2RequestLine(
method: method,
params: Self.routedParams(params, method: method, windowId: routingWindowId)
)
try writeAll(
Data((requestLine + "\n").utf8),
timeoutMessage: "Command timed out",
Expand Down
82 changes: 82 additions & 0 deletions CLI/V2MethodNames.swift
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,86 @@ enum V2MethodNames {
static let worktreeList = "worktree.list"
static let worktreeOpen = "worktree.open"
static let worktreeRemove = "worktree.remove"

/// Methods whose params accept `window_id`. The global `--window` option routes
/// these to its window instead of focusing that window first.
static let windowScoped: Set<String> = [
"agent.detection.classify",
"agent.prompt",
"debug.command_palette.rename_input.delete_backward",
"debug.command_palette.rename_input.interact",
"debug.command_palette.rename_input.selection",
"debug.command_palette.rename_tab.open",
"debug.command_palette.results",
"debug.command_palette.selection",
"debug.command_palette.toggle",
"debug.command_palette.visible",
"debug.sidebar.visible",
"feedback.open",
"layout.apply",
"layout.save",
"markdown.open",
"notification.create",
"notification.create_for_surface",
"notification.create_for_target",
"pane.break",
"pane.create",
"pane.focus",
"pane.last",
"pane.list",
"pane.resize",
"pane.surfaces",
"review.comment.add",
"review.comment.list",
"review.comment.remove",
"review.open",
"review.refresh",
"review.send_comments",
"surface.action",
"surface.clear_history",
"surface.close",
"surface.create",
"surface.current",
"surface.drag_to_split",
"surface.focus",
"surface.health",
"surface.list",
"surface.move",
"surface.read_text",
"surface.refresh",
"surface.send_if_unchanged",
"surface.send_key",
"surface.send_paste",
"surface.send_text",
"surface.split",
"surface.trigger_flash",
"surface.wait",
"system.identify",
"tab.action",
"window.close",
"window.focus",
"workspace.action",
"workspace.clear_meta_block",
"workspace.close",
"workspace.create",
"workspace.current",
"workspace.equalize_splits",
"workspace.last",
"workspace.list",
"workspace.list_log",
"workspace.list_meta_blocks",
"workspace.list_status",
"workspace.move_to_window",
"workspace.next",
"workspace.previous",
"workspace.rename",
"workspace.reorder",
"workspace.reset_sidebar",
"workspace.select",
"workspace.sidebar_state",
"worktree.create",
"worktree.list",
"worktree.open",
"worktree.remove",
]
}
7 changes: 4 additions & 3 deletions CLI/programa.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5958,8 +5958,9 @@ struct ProgramaCLI {
--socket <path> Socket to connect to (default: see PROGRAMA_SOCKET_PATH below)
--json JSON output; errors print {"ok":false,"error":{...}} on stdout
--id-format <refs|uuids|both> Handle format in output (default: refs)
--window <id|ref> Target this window (focuses it first)
--password <value> Socket password
--window <id|ref> Run the command in this window without focusing it
--password <value> Socket password (visible in ps; prefer --password-stdin or PROGRAMA_SOCKET_PASSWORD)
--password-stdin Read the socket password from the first line of stdin
-v, --version Print the version and exit
-h, --help Print this help and exit

Expand All @@ -5969,7 +5970,7 @@ struct ProgramaCLI {
Output defaults to refs; pass --id-format uuids or --id-format both to include UUIDs.

Socket Auth:
--password takes precedence, then PROGRAMA_SOCKET_PASSWORD env var, then password saved in Settings.
--password or --password-stdin takes precedence, then PROGRAMA_SOCKET_PASSWORD env var, then password saved in Settings.

Commands:
\(commandsHelpBlock())
Expand Down
2 changes: 1 addition & 1 deletion docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ Read by the app, the CLI and `programa-mcp`. Modes and discovery are described i
|---|---|---|
| `PROGRAMA_SOCKET_PATH` | CLI, MCP, app | Socket path to use. The app honors it for Debug and Staging builds, or when `PROGRAMA_ALLOW_SOCKET_OVERRIDE` is truthy. |
| `PROGRAMA_SOCKET` | CLI, MCP | Second choice for the socket path. `PROGRAMA_SOCKET_PATH` wins when both are set. The app never reads it. |
| `PROGRAMA_SOCKET_PASSWORD` | CLI, MCP, app | Socket password for `password` mode. The CLI checks `--password` first. The app checks it before the saved password file. |
| `PROGRAMA_SOCKET_PASSWORD` | CLI, MCP, app | Socket password for `password` mode. The CLI checks `--password` and `--password-stdin` first. The app checks it before the saved password file. |
| `PROGRAMA_SOCKET_MODE` | app | Socket mode for this launch: `off`, `programaOnly`, `automation`, `password` or `allowAll`. Overrides the setting. |
| `PROGRAMA_SOCKET_ENABLE` | app | `1`/`true`/`yes`/`on` or `0`/`false`/`no`/`off`. `0` turns the socket off regardless of the mode. |
| `PROGRAMA_ALLOW_SOCKET_OVERRIDE` | app | Truthy value lets `PROGRAMA_SOCKET_PATH` move the socket in a Release build or a tagged Debug build. |
Expand Down
12 changes: 10 additions & 2 deletions docs/socket-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,16 @@ script run there talks to that app. To drive a different instance, such as a tag
2. the file `~/Library/Application Support/programa/socket-control-password` (mode `0600`,
written by **Settings > Automation**; `automation.socketPassword` in `settings.json`)

The CLI takes `--password <value>` first, then `PROGRAMA_SOCKET_PASSWORD`, then the saved
password. `programa-mcp` takes `PROGRAMA_SOCKET_PASSWORD`, then the saved password.
The CLI takes `--password <value>` or `--password-stdin` first, then `PROGRAMA_SOCKET_PASSWORD`,
then the saved password. `--password` puts the password in the process arguments, where other
processes can read it with `ps`. Prefer `PROGRAMA_SOCKET_PASSWORD`, the saved password, or
`--password-stdin`, which reads the password from the first line of stdin:

```bash
printf '%s\n' "$SECRET" | programa --password-stdin ping
```

`programa-mcp` takes `PROGRAMA_SOCKET_PASSWORD`, then the saved password.

A client authenticates once per connection:

Expand Down
5 changes: 3 additions & 2 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ Common symptoms:
- `Access denied — only processes started inside Programa can connect`: the socket is in the
default `programaOnly` mode and the caller was not started from a Programa terminal. Change
`automation.socketControlMode` or run the command from inside Programa.
- `auth_required` or `auth_failed`: the socket is in `password` mode. Pass `--password`, or set
`PROGRAMA_SOCKET_PASSWORD`.
- `auth_required` or `auth_failed`: the socket is in `password` mode. Set
`PROGRAMA_SOCKET_PASSWORD`, or pipe the password to `--password-stdin`. `--password <value>`
also works but leaves the password visible in `ps`.
- Connection refused or no such file: Programa is not running, or the socket mode is `off`.

## High memory use
Expand Down
14 changes: 13 additions & 1 deletion scripts/gen-v2-contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

Outputs (all overwritten in place, each carrying a "GENERATED, do not edit" header):
- Sources/V2CommandCatalog.swift (base/debug method-name arrays)
- CLI/V2MethodNames.swift (one named constant per v2 method)
- CLI/V2MethodNames.swift (one named constant per v2 method, plus the set of
methods whose params accept `window_id`)

Handlers change only after the contract does (see docs/socket-api.md "Contract").
Run scripts/check-v2-contract.sh to verify the checked-in files match this contract.
Expand Down Expand Up @@ -110,6 +111,17 @@ def gen_v2_method_names(contract) -> str:
continue
seen.add(name)
body += f' static let {name} = "{m}"\n'
scoped = [
m for m in methods
if "window_id" in contract["methods"][m].get("params", {}).get("properties", {})
]
body += "\n"
body += " /// Methods whose params accept `window_id`. The global `--window` option routes\n"
body += " /// these to its window instead of focusing that window first.\n"
body += " static let windowScoped: Set<String> = [\n"
for m in scoped:
body += f' "{m}",\n'
body += " ]\n"
body += "}\n"
return body

Expand Down
Loading
Loading