Skip to Content

One-shot Exec

cmdop connect exec runs a single shell command on a remote machine, collects its stdout, stderr, and exit code, and returns. There is no PTY, no picker, and no expectation of a TTY on either end. It is the workhorse verb for scripts, CI, agent tools, and quick one-liners.

Syntax

cmdop connect exec <hostname> -- <command...>

The -- separator is required: it tells the CLI to stop parsing flags and pass everything after it verbatim to the remote shell. Without it, flags meant for the remote command would be eaten by the local CLI.

# Quick examples. cmdop connect exec vps-audi -- uptime cmdop connect exec prod-api-1 -- systemctl status nginx cmdop connect exec mac-studio -- 'cd /tmp && ls -la' cmdop connect exec vps-bmw -- bash -c 'echo $HOSTNAME && id'

The implementation lives in go/cmd/cmdop/cmds/connect/connect_exec_command.go.

What you get back

By default, exec mirrors the remote command’s stdout and stderr to your local stdout/stderr and exits with the remote exit code:

$ cmdop connect exec prod-api-1 -- systemctl is-active nginx active $ echo $? 0

If the remote command returns a non-zero exit, so does the local CLI. This makes cmdop connect exec a drop-in for any place a script would otherwise call ssh host -- cmd.

JSON mode

Pass --json to get a structured envelope instead of raw streams:

$ cmdop connect exec --json prod-api-1 -- uptime { "machine_id": "8f23a4b0-...", "hostname": "prod-api-1", "command": ["uptime"], "exit_code": 0, "stdout": " 14:32:01 up 12 days, 4:11, 1 user, load average: 0.18, 0.21, 0.19\n", "stderr": "", "duration_ms": 142 }

--json implies --no-interactive, never prompts, and is the right choice when a script or agent tool consumes the output.

Timeouts

Exec is timeout-bounded. The default is 30 seconds; override with --timeout:

# Wait up to 5 minutes for a slow build script. cmdop connect exec --timeout 5m vps-bmw -- ./scripts/build.sh

The agent-tool surface (connecttool operation=exec) accepts timeout_ms as an integer parameter, with the same 30s default. Server side, the relay enforces an upper bound — anything beyond a few minutes should use remote-sessions instead, where you can poll progress and avoid hitting the unary RPC ceiling.

Exec is for commands that finish. For tailing logs, watching a file, or any open-ended work, open a persistent session — exec will time out and return what stdout has buffered so far.

Recovery for password-protected machines

When the remote machine has a password set, exec uses a two-step recovery path documented in internal/connect/client/CLAUDE.md §ExecOnce:

  1. The CLI tries the unary Execute RPC directly.
  2. The server returns ErrSessionTokenRequired because no x-session-token metadata was attached.
  3. The CLI opens a brief streaming auth (ConnectTerminal), answers the AuthChallenge, captures the session_token, and stores it in sessionauth.Default() (24h TTL, process-wide).
  4. The CLI retries the unary Execute — this time it carries the token and succeeds.

Non-password machines pay zero overhead — step 1 succeeds. Password machines pay one extra streaming open per process lifetime. Inside agent tools, the cache is shared, so the second cmdop connect exec against the same machine in the same daemon reuses the cached token.

Streaming output

Exec streams output as it arrives. For commands that print steadily (builds, tests, log dumps), you see progress live, not after the command finishes. The implementation reads from the remote stream and forwards to local stdout/stderr until either:

  • The command exits — exec captures the exit code and returns.
  • The timeout fires — exec cancels the call and returns whatever output arrived so far, with a non-zero exit code.

In --json mode, the structured response is emitted only after the command finishes (or times out); intermediate output is buffered into stdout / stderr strings.

Exec vs interactive attach

cmdop connect execcmdop connect
TTY allocatedNoYes (raw mode).
Interactive flowNoPicker → confirm → attach.
Best forScripts, CI, agent tools, quick one-liners.Debugging, exploration, full-screen tools.
Picks up Ctrl-CYes — cancels the local call.Yes — forwards to remote.
Default timeout30sNone (session lives until disconnect).
OutputStreamed; final exit code returned.Live PTY.

If you find yourself running cmdop connect exec ... -- bash, you probably want interactive attach instead.

Exec from agent prompts

The agent-facing surface is the connect tool with operation=exec:

{ "operation": "exec", "hostname": "prod-api-1", "command": "tail -n 50 /var/log/nginx/error.log", "timeout_ms": 30000 }

Returns core.Success with {exit_code, stdout, stderr, duration_ms} on a normal completion, or core.Error on resolve / dial / auth / timeout failures. See internal/agent/builtin/tools/connecttool/tool.go for the parameter union and dispatch.

The agent tool description steers the LLM correctly:

Run shell commands on CMDOP-registered machines — relay-based, no keys/IPs needed. Prefer over local shell when the user names another machine. For agent-to-agent conversation (ask/talk/greet the remote LLM) use ask_agent

That last clause matters: exec is for shell, not for “ask the remote agent for an opinion”. For agent-to-agent calls, see server-to-server.

Common patterns

Fan-out by hand

For a small number of hosts, a shell loop is simplest:

for host in vps-audi vps-bmw mac-studio; do echo "=== $host ===" cmdop connect exec --timeout 10s "$host" -- uptime done

For a larger set, prefer the ask_agents agent tool — it parallelizes, respects per-host and total deadlines, and returns a structured map. See server-to-server.

Capture and parse JSON

load=$(cmdop connect exec --json prod-api-1 -- uptime | jq -r '.stdout') echo "$load"

Pipe local data in

Exec inherits stdin. Pipe local input to the remote command:

cat local-config.yaml | cmdop connect exec vps-audi -- 'cat > /etc/myapp/config.yaml'

Errors and exit codes

Exit codeCause
0Remote command exited 0.
<n>Remote command exited n (verbatim passthrough).
124Local timeout fired before the remote command finished.
125Resolver / dial / auth error before the command ran.
130Local Ctrl-C cancelled the call.

The --json envelope’s exit_code field always reflects the remote exit code; client-side errors surface as a structured error object instead of an envelope.

When you want a real shell, not a one-shot.

Persistent multi-command sessions for long work.

Agent-to-agent calls and structured fan-out.

Full CLI surface, flags, and JSON output spec.

Last updated on