Skip to content

CLI Machines Remote

github-actions[bot] edited this page Aug 8, 2026 · 5 revisions

Running commands on a machine

ed machines exec runs one command on a configured machine over the SSH connection Edith already holds, hands you the machine's stdout and stderr on your own stdout and stderr, and exits with the status the remote command exited with. ed <machine> <command...> is the same thing with the ceremony removed: name a machine as the first word and the rest of the line runs there.

This is the escape hatch under every other ed machines verb. docker, files, power and services exist because a parsed, --json answer is worth having for the things you script; everything else on the machine is reachable by typing it. Nothing here needs the Edith app to be running, because the transport is /usr/bin/ssh and a ControlMaster socket on disk rather than a request to the app.

At a glance

Command What it does
ed machines exec <machine> <command...> Runs the command over the shared connection with no pty, streaming both output channels and propagating the remote exit code.
ed machines exec --tty <machine> <command...> The same, on a real terminal, which is what vim, top, a sudo password prompt and docker exec -it need.
ed machines run <machine> <command...> Alias of ed machines exec.
ed <machine> <command...> Shorthand that rewrites to ed machines exec <machine> -- <command...>.
ed <machine> A bare machine name with nothing runnable after it is ed machines show <machine>.
ed <machine> cd [<directory>] Sets the working directory the later commands on that machine run in, per terminal.
ed machines <machine> <command...> The machine-first spelling: a word after machines that is not a subcommand is moved to wherever that subcommand wants it.
ed __complete Hidden. Emits completion candidates, and hands over to the machine for anything after a machine name.

Commands

ed machines exec

Runs one command on one machine and gets out of the way. Both output streams stay separate, the remote exit code becomes yours, and the only byte ed adds is the newline it puts back on a last line that had none.

ed machines exec [--tty] <machine> [--] <command...>
ed machines run  [--tty] <machine> [--] <command...>

Arguments

Name Type / values Default What it does
<machine> machine name, ssh config alias, id, or an unambiguous prefix of a name or alias none, required Which machine to run on. Matched case-insensitively: exact name first, then ssh alias, then UUID, then a unique prefix.
<command...> one or more words none, required The command to run. A single leading -- is dropped before anything is sent.

Options

Name Type / values Default What it does
--tty, -t flag off Allocate a terminal for the command with ssh -tt. Needed by anything interactive; wrong for scripting.
--help, -h flag off Print the help for this command on stdout and exit 0.
--version flag off Print the CLI version, 1.0.0, and exit 0. Generated by the parser and accepted here as well as at the root.

Every one of these has to come before the machine name. The command array is captured for passthrough, so the parser stops reading options at the first positional word: in ed machines exec tuf --tty top the --tty is part of the command and is sent to the machine, where it is not a program:

$ ed machines exec tuf --tty top
bash: line 1: --tty: command not found
$ echo $?
127

There is no --json. ed machines exec is one of the handful of commands that do not offer it, because the bytes on stdout belong to the remote process and wrapping them would break every pipe. --json before the machine name is an unknown option and exits 2.

Examples

ed machines exec tuf uptime
ed machines exec tuf -- ls -la /etc
ed machines exec --tty tuf top
ed machines run tuf systemctl status nginx

Behaviour notes

Nothing local is mutated except the remembered working directory, which only cd writes, and what opening the connection leaves behind: the ControlMaster socket and the host key pinned beside it. The machine is resolved from Edith's own machine list, so a host is never retyped, and the connection is opened before the command runs: if a ControlMaster socket for that machine is already alive, from the app or from an earlier ed, the command is one round trip on it, and if there is not, ed opens one that outlives the process by ControlPersist=10m.

How the words become a command line depends on how many there are. One word is sent verbatim, which is the one-shot escape hatch for anything the remote shell has to interpret. Several words are quoted individually and joined, so an argument with spaces survives and a shell metacharacter does not:

$ ed tuf echo 'a;pwd'
a;pwd

$ ed tuf 'ls /etc | head -3'
adduser.conf
alsa
alternatives

A word is left unquoted only when every character in it is a letter, a digit or one of . _ - + / = : @ % ,. Everything else is wrapped in single quotes, which covers spaces, ~, *, $, |, >, ;, & and quotes themselves. So ed tuf ls '*.log' does not glob on the machine, and ed tuf ls '~/Desktop' does not expand the tilde; quote the whole line instead when you want either.

--tty builds its command line differently: the words are joined with single spaces and nothing is quoted, because the point of a terminal session is to let the remote shell parse the line. The same input therefore behaves differently under the two paths:

$ ed machines exec --tty tuf echo 'a;pwd'
a
/home/pulkit
Shared connection to 192.168.1.12 closed.

That trailing line is ssh's own, on stderr, and appears on every --tty run.

Stdin is not forwarded on the plain path. The remote process is given /dev/null, so printf 'x\n' | ed tuf cat prints nothing and ed tuf wc -l < file counts zero. With --tty your terminal is handed straight to ssh and typing works, but a redirected file or a pipe still will not end the command: the remote side sees a terminal rather than a closed pipe, so a cat with nothing more to read waits instead of exiting. Feed data to a machine with ed machines files put or by quoting a line that reads the file on the far side.

Output is read line by line off two pipes and re-emitted, stdout to stdout and stderr to stderr, so a partial last line with no newline is still printed and the exact interleaving of the two streams is not guaranteed to match what the machine produced. There is no timeout: ed tuf tail -f /var/log/syslog streams until you interrupt it.

The exit code is the remote command's, verbatim and unclamped, which is the one documented exception to ed's 0 to 4 contract:

$ ed tuf exit 42
$ echo $?
42

Naming no command at all fails before anything is dialled, and the error names the machine you gave:

$ ed machines exec tuf
error: name a command to run, for example `ed tuf uptime`

The command runs in whatever directory ed <machine> cd last set for this terminal, on both the plain and the --tty path. That is a cd prefix on the line ssh is given, cd '<directory>' 2>/dev/null || cd;, so a directory that has since been deleted quietly falls back to the home directory rather than failing every command after it.

ed <machine> <command...>

Names a machine as the first word of the line and runs the rest there. It is pure argument rewriting: ed reshapes argv before the parser ever sees it, then the ordinary ed machines exec runs.

ed <machine> <command...>
ed <machine>

The rewrite happens in this order, and stops at the first rule that applies:

When the first word The line becomes
is missing, or starts with - unchanged
is machines reordered by the machine-first rule below
is a top level command name or alias, or help, or __complete unchanged
does not match a configured machine name or ssh alias unchanged
matches a machine, and something after it does not start with - machines exec <machine> -- <rest>
matches a machine, and the rest is empty or all flags machines show <machine> <rest>

The match against your machines is by whole name or whole ssh alias, case-insensitively. It is deliberately stricter than the resolver ed machines exec then uses: a prefix or a UUID is not enough to trigger the shorthand, so ed tu uptime is not rewritten and fails as an unknown command with exit 2, while ed tuf uptime and ed 'asus tuf 7' uptime both run.

The reserved list that wins over a machine name is every top level command and alias: guide, schema, version, completions, install, uninstall, config, app, extensions, permissions, usage, system, music, nowplaying, np, calendar, tools, apps, download, downloads, dl, clipboard, color, colour, shelf, cleaner and machines, plus help and __complete. A machine called usage is unreachable by the shorthand and has to be named explicitly, as ed machines exec usage -- ... or ed machines show usage.

Because the rewrite inserts -- after the machine name, every flag after it belongs to the machine, never to ed. That is what lets ed tuf ls -la and ed tuf docker compose up -d work without ceremony, and it is why --tty has no shorthand: write ed machines exec --tty tuf top.

ed tuf docker ps runs the machine's own docker binary and prints its raw output. The parsed, --json version is ed machines docker ps tuf, which is a different command. Both are correct; the shorthand is always the raw one.

The machine-first spelling under ed machines

A word straight after machines that is not one of the group's subcommands is treated as the machine and moved to wherever the subcommand wants it. ed walks as far down the subcommand tree as the words allow, then inserts the machine after the last subcommand it consumed:

ed machines tuf uptime            ed machines exec tuf -- uptime
ed machines tuf docker ps         ed machines docker ps tuf
ed machines tuf files ls /etc     ed machines files ls tuf /etc
ed machines tuf run ls -la        ed machines run tuf -- ls -la
ed machines tuf                   ed machines show tuf

Unlike the top level shorthand this does not check your machine list at all, so a typo is still moved into the machine slot and the error names it rather than complaining about an unknown subcommand:

$ ed machines nosuchbox uptime
error: no machine named nosuchbox
hint: known machines: Asus TUF 7; machines subcommands: add, broadcast, connect, disconnect, docker, edit, exec, files, forward, forwards, kill, list, ls, metrics, power, remove, rm, run, services, show, snippet, snippets, workspace, workspaces

A subcommand name always wins. The names that cannot be used as a machine here are ls, list, show, add, edit, rm, remove, forwards, forward, snippets, snippet, power, workspace, workspaces, broadcast, kill, metrics, exec, run, files, docker, services, connect and disconnect. A machine literally called docker needs ed machines show docker.

Examples

ed tuf uptime
ed tuf systemctl status nginx
ed tuf 'ls -la /srv | head'
ed machines tuf docker compose ls

Behaviour notes

A bare machine name is a lookup, not a shell:

$ ed tuf
Asus TUF 7
  target   tuf · pulkit@192.168.1.12
  auth     SSH agent
  system   Linux 7.0.0-28-generic x86_64
  uptime   22:25:13 up  9:17,  5 users,  load average: 0.03, 0.08, 0.21
  session  pulkit on seat0 since 2026-08-08 13:08 (login screen)
  session  pulkit on tty2 since 2026-08-08 13:08 (tty2)

Flags alone count as nothing runnable, so ed tuf --json is ed machines show tuf --json rather than an attempt to run --json on the machine.

ed <machine> cd [<directory>]

Sets the directory the later commands on that machine run in. It is not sent to the machine as a command; ed intercepts it, asks the machine where that path resolves to, and records the answer.

ed <machine> cd <directory>
ed <machine> cd -
ed <machine> cd

Arguments

Name Type / values Default What it does
<directory> a path on the machine, absolute or relative to the current one, or - omitted Where to move to. Omitted means the home directory; - means back to the directory you were in before the last cd.

cd is only intercepted when it is the whole command and there are at most two words. ed tuf cd and ed tuf cd Desktop are interceptions; ed tuf cd a b and the quoted one-shot ed tuf 'cd /tmp && pwd' are ordinary commands that run and change nothing:

$ ed tuf 'cd /tmp && pwd'
/tmp
$ ed tuf pwd
/home/pulkit

Examples

ed tuf pwd                      /home/pulkit
ed tuf cd Desktop
ed tuf pwd                      /home/pulkit/Desktop
ed tuf ls                       lists Desktop
ed tuf cd -                     back to where you were before
ed tuf cd                       back to the home directory

Behaviour notes

A successful cd prints nothing and exits 0. Under the covers ed runs pwd; cd -- '<target>' && pwd on the machine, prefixed with a cd into the directory you were already in, and keeps two lines: the first pwd becomes the previous directory and the second becomes the current one. That is why - works, and why it toggles rather than walking a stack.

The pair is stored in a file at ~/Library/Application Support/Edith/machines/cwd/<machine>/<session>, where <machine> is the first ten characters of the machine's id with the dashes removed and <session> is the name of the terminal your stdin is attached to, /dev/ stripped and anything that is not a letter or a digit turned into a dash. So /dev/ttys012 becomes ttys012, and two tabs on one machine never move each other, the same way cd behaves in a local shell. When stdin is not a terminal, which covers a pipe, a script and a cron job, the session is called shared and every such invocation uses the same slot. The directory is created mode 0700 and the file is written atomically.

A path that does not exist is reported with the machine's own message, exits 1, and leaves the stored directory alone:

$ ed tuf cd nosuchdir
error: cannot change to nosuchdir on Asus TUF 7
hint: bash: line 1: cd: nosuchdir: No such file or directory

cd - with nothing recorded for this terminal exits 1 with no previous directory for <machine> in this terminal, decided locally once the connection is open and without asking the machine anything. The round trip a cd does make is under the 60 second command timeout that ed puts on ordinary commands, unlike the command path, which has none.

--tty reads the remembered directory but never sets it. The terminal branch runs before the cd interception, so ed machines exec --tty tuf cd Desktop changes directory inside that one pty session and nothing survives it.

The remembered directory reaches exactly two things: ed machines exec, including the shorthand and the --tty form, and remote path completion. Everything else keeps its own defaults, so ed machines files ls tuf still lists the remote home directory after ed tuf cd Desktop, and the app's own terminal and Finder windows are unaffected.

ed __complete

The hidden command behind shell completion. The installed zsh, bash and fish scripts call it with the whole word list and the index of the word being completed, and it prints one candidate per line. You never type it, but what it does after a machine name is the interesting half of this page.

ed __complete --index <n> -- <words...>

Options

Name Type / values Default What it does
--index integer, zero based 0 Which word in <words...> is being completed. Word 0 is the program name.
<words...> the command line so far empty Captured for passthrough, so flags in it are data. A single leading -- is dropped.

Behaviour notes

When the first word after the program name is not a known command and does match a configured machine, ed stops consulting its own tree and asks the machine. What it asks depends on where the cursor is:

  • At the first word after the machine name it asks for command names, with compgen -c -- <prefix> | sort -u | head -2000. That completes against the remote PATH, including tools ed has never heard of.
  • After cd, pushd or rmdir it asks for directories only, with compgen -d, capped the same way.
  • Anywhere else it uploads a small bash harness that sources /usr/share/bash-completion/bash_completion or /etc/bash_completion, runs _completion_loader for the command being typed, finds that command's registered -F function with complete -p, calls it with COMP_WORDS, COMP_CWORD, COMP_LINE and COMP_POINT set the way bash would, and prints COMPREPLY. When the command has no completion function or produces nothing, it falls back to compgen -o default, which is filenames.

So ed tuf docker <TAB> runs docker's own completion on the machine rather than a list baked into ed:

$ ed __complete --index 3 -- ed tuf docker comp
compose

Two guards keep this from ever being slow. It runs only when a ControlMaster socket for that machine is already alive, checked with ssh -O check, so pressing TAB never dials a sleeping host; with no open connection you get no candidates and exit 0. And the round trip itself is capped at six seconds, after which the candidate list is empty rather than late.

The whole probe is prefixed with the same cd that commands get, so completion follows ed <machine> cd. Candidates are filtered by the prefix you have typed, case-sensitively, and deduplicated in the order the machine returned them.

The half-typed word is never interpolated into the remote line unquoted. The command-name probe shell-quotes it, and the directory probe passes it to bash -c as a positional parameter, so a prefix such as $(touch /tmp/pwned) is completed against rather than run.

Under ed machines <machine> ... completion behaves the other way round: the words are reordered the way the parser will see them and ed's own tree answers, so ed machines tuf <TAB> offers docker, files and the rest of the group's verbs rather than remote programs.

Exit codes

Code When
0 The remote command exited 0, or a cd landed. --help also exits 0, and so does a completion probe with nothing to offer.
1 No command word was given; a cd the machine refused; cd - with no previous directory recorded for this terminal; ssh could not be started at all. Also produced when the remote command itself exits 1.
2 The command line was wrong: an unknown option before the machine name, such as --json, or a missing machine argument. Also produced when the remote command itself exits 2.
3 The machine did not resolve: nothing is configured, the name is unknown, or a prefix matched more than one machine. The hint lists the candidates and the machines subcommands.
4 The connection could not be opened. The message is could not reach <machine>: <reason>, with the reason translated from ssh's own text: authentication failed, connection refused, timed out, could not resolve the host name, or the host key changed.
anything The remote command's own status, passed through unchanged, so 127 for a command the machine does not have, 130 for one you interrupted, and anything else a program chooses to return.

Codes 1, 2, 3 and 4 are also values a remote program can return, and ed cannot tell you which side produced one. When a script needs to know, look at stderr: ed's own failures always start with error: and never touch stdout.

Notes and gotchas

  • The exit code passthrough is the single documented hole in the CLI's 0 to 4 contract. ed machines exec, the shorthand, ed machines docker logs and ed machines docker compose logs are the only commands that do it.
  • --tty is the counterpart of the app's Machine terminal pane, and of the Docker window's shell button, which is ed machines exec --tty <machine> 'docker exec -it <container> sh'.
  • The plain path gives the remote process no terminal at all. Anything that checks isatty will disable colour and progress bars, which is usually what you want from a script and never what you want from top.
  • A single leading -- is stripped, once. ed machines exec tuf -- -- ls sends -- ls.
  • The shorthand and the machine-first spelling both leave a word alone when it starts with -, so ed --help and ed machines --help are never mistaken for machine names.
  • ed <machine> with only flags after it is ed machines show, which opens a connection and runs uname, uptime and who on the machine. It is not a free lookup.
  • The remembered working directory is per terminal, not per shell. Two panes in the same terminal emulator have different ttys names and so different directories; a subshell inside one pane shares its parent's.
  • Everything a pipe touches uses the shared session slot, so ed tuf cd /tmp typed at a prompt does not change where a cron job's ed tuf make runs, and two concurrent scripts do share one slot.
  • The ControlMaster socket lives at ~/Library/Application Support/Edith/machines/sockets/<hash>.sk, keyed by the same ten characters of the machine id that name the working directory folder. ed machines disconnect <machine> closes it, which also silences remote completion until something opens it again.
  • Host keys are pinned in Edith's own known_hosts beside that socket, with your ~/.ssh/known_hosts consulted as well and StrictHostKeyChecking set to accept-new. Do not shell out to ssh directly for a configured machine; you lose the shared connection and the pinning.
  • Neither the Edith app nor the menu bar helper has to be running for anything on this page, and no macOS permission is involved.
  • ed machines exec takes no --json, and neither does the shorthand. If you want structured output, run something on the machine that produces it and pipe the result into jq yourself.
  • ed machines broadcast is the many-machine version of the same idea: one line on every configured machine, output labelled per machine, and exit 1 if any of them failed rather than the remote status. It does not honour the remembered working directory.

Where to go next

  • ed machines for the machine list itself, connecting and disconnecting, and the saved forwards and snippets the terminal pane uses.
  • ed machines docker for the parsed, --json half of what ed <machine> docker ... reaches raw.
  • ed machines files for moving files, which is also how you get data onto a machine given that stdin does not travel.
  • ed machines power for reboot, wake, systemd units and killing a process by pid.
  • Getting started for installing the completion scripts that call ed __complete.
  • Conventions and contracts for the exit code table this page is the exception to.
  • The ed command line for the rest of the reference.

CLI reference

Getting Started
Config
App
Extensions
Permissions
Usage
System
Music
Calendar
Clipboard
Color
Download
Apps
Tools
Shelf
Cleaner
Machines
Machines Remote
Machines Docker
Machines Files
Machines Power
Machines Workspace
Companion
Herdr
Lid Awake
Machines Thermal

Guides

Clone this wiki locally