Introducing Ferry

Sync your coding-agent skills, instructions, plugins, and MCP server declarations to a remote Linux box. An introduction to Ferry, with setup instructions and terminal examples.

Ferry syncs your coding-agent setup from your laptop to a remote Linux machine. It carries skills, instructions, Claude plugins and settings, and remote MCP server declarations through a private git repository. You log in to each tool on the remote machine.

I built it for the gap between configuring an agent and using that agent somewhere else. A skill you added on your laptop should be available on the box. A change to your global instructions should reach both. Keeping those files in sync by hand becomes another job every time you improve your setup.

Copying all of ~/.claude would also copy credentials and session data. Ferry selects the configuration to share and leaves authentication on each machine. Your setup travels. Your logins stay on the machine that created them.

The sections below cover setup and daily use, followed by the details of what Ferry syncs.

How it works

Ferry has three parts:

  • The operator machine. This is your laptop, macOS or Linux. Ferry runs here, and here is where you edit your skills and settings.
  • A private snapshot repository. A git repository that you create, for example git@github.com:you/ferry-snapshot.git. It holds the skills, the global instruction file, the Claude subagents and commands, an allowlist of Claude settings keys, and the remote MCP server declarations.
  • The box. A Linux machine that you reach over Tailscale or SSH. The box does not run Ferry. Ferry runs commands on it over SSH.

Both machines clone the snapshot repository into ~/.ferry/store. Ferry links managed paths into that clone. Individual skills link to the store; Claude subagent and command directories link as whole directories. On your laptop you keep editing files where you always did. ferry sync commits and pushes the store, then connects to the box, pulls the new commit, and links everything into place.

Sync runs from your laptop to the box. Make shared configuration changes on your laptop. If you remove a carried settings key on your laptop, sync removes it on the box too.

Authentication happens separately on each machine. When the box needs a login, Ferry starts the vendor login on the box and prints the URL. You finish the login in a browser on your laptop, and the token stays on the box.

Requirements

  • An operator machine with macOS or Linux. Windows is not supported.
  • A Linux box that you can reach with SSH. ferry install and ferry update use apt for gh, so Debian or Ubuntu is the tested target.
  • Tailscale on both machines, or one OpenSSH destination such as user@box.example.
  • An empty private git repository for the snapshot. Ferry does not create it.
  • For an SSH snapshot URL, an SSH agent on the operator machine with a loaded key that can read and push the snapshot repository.
  • Bun 1.4 or later, if you install from source.

Install

The quickest way is the install script:

sh
curl -fsSL https://raw.githubusercontent.com/dlhck/ferry/main/install.sh | sh

The script downloads the executable for your platform from the latest GitHub release, checks it against the release's SHA256SUMS file, and installs it to ~/.local/bin/ferry. It supports macOS and Linux on arm64 and x64, and it needs curl or wget plus sha256sum or shasum. If ~/.local/bin is not on your PATH, the script prints the line to add to your shell profile.

Run the remaining commands on your laptop. Ferry runs commands on the box over SSH; you do not install Ferry there.

To update Ferry, run the same command again. Three variables change what the script does:

  • FERRY_VERSION installs a specific release, for example curl -fsSL https://raw.githubusercontent.com/dlhck/ferry/main/install.sh | FERRY_VERSION=v0.2.0 sh.
  • FERRY_INSTALL_DIR installs to a different directory.
  • FERRY_SKIP_CHECKSUM=1 skips the checksum check. You need it only for v0.1.1 or earlier, which have no SHA256SUMS file.

If you run ferry watch as a service, it keeps the old executable until it restarts. Restart it after an update with launchctl kickstart -k gui/$(id -u)/dev.ferry.watch on macOS or systemctl --user restart ferry-watch.service on Linux.

Ferry is also on npm:

sh
npm i -g @dlhck/ferry

The package runs the same prebuilt executable and does not need Bun. npm installs only the executable for your platform, from the optional dependency @dlhck/ferry-<os>-<arch>, so do not install with --omit=optional. Update with npm i -g @dlhck/ferry@latest. ferry update updates the agent tools, not Ferry itself.

To install from source with Bun 1.4 or later:

sh
git clone https://github.com/dlhck/ferry.git
cd ferry
bun install
bun link

bun link puts ferry on your PATH and runs src/cli.ts with Bun.

Quick start

sh
ferry init          # record the box and the snapshot URL, seed the snapshot, link this machine
ferry install       # install gh, Claude Code, Codex, Pi, and Cursor Agent on the box
ferry sync          # publish the snapshot and apply it on the box
ferry auth gh       # start a login on the box, finish it in a browser here
ferry auth claude
ferry status        # check the link, the snapshot, the managed paths, and the box logins

ferry install shows each install command and asks before it runs it on the box. Repeat ferry auth for each tool you use. Add --dry-run to init, sync, or update to see the changes before Ferry makes them.

After setup, edit skills and settings on your laptop and run ferry sync, or use ferry watch.

Connecting to the box

ferry init asks whether to use a Tailscale host or an SSH-only destination. You can also pass the answer as flags.

A Tailscale peer:

sh
ferry init \
  --host build-box \
  --ssh-user ferry \
  --snapshot-url git@github.com:you/ferry-snapshot.git

An explicit OpenSSH destination:

sh
ferry init \
  --ssh-destination user@box.example \
  --snapshot-url git@github.com:you/ferry-snapshot.git

Ferry stores the transport in ~/.ferry/config.toml. It never falls back from Tailscale to direct SSH, and SSH-only mode never runs Tailscale.

With an SSH snapshot URL, Ferry checks that your SSH agent has a loaded identity, forwards the agent to the box, and tests read access to the snapshot repository. If the git host is not in the box's known_hosts, Ferry prints the host-key fingerprints and asks before it adds them. Your private key stays on your machine.

Logging in on the box

Run ferry auth <tool> for gh, claude, codex, or cursor.

Ferry first checks whether the tool is already logged in on the box. If it isn't, Ferry starts the vendor login on the box, detached from the SSH session, and prints the URL and code as soon as the login shows them. You open the URL on your machine and finish there. Ferry checks the box every 5 seconds and reports when the login is complete. The login on the box stops after 15 minutes.

  • gh prints a one-time code for https://github.com/login/device.
  • claude prints the login URL. After the login, the browser shows a code. Paste it at the Ferry prompt, and Ferry hands it to the login on the box.
  • codex prints a one-time code for https://auth.openai.com/codex/device. If the device login gives no code, Ferry uses the Codex callback login. It prints the URL and forwards local port 1455 to the box for 120 seconds. Press Ctrl-C after the browser reports success.
  • cursor prints the login URL.

Pi has no remote login that Ferry can start. SSH to the box, run pi, and use /login.

ferry auth without a tool shows which logins Ferry can start:

shellsession
$ ferry auth
gh: startable
claude: startable
codex: startable
cursor: startable
pi: manual SSH flow

Examples

These illustrative transcripts follow Ferry's output messages; they are not recordings of live sessions. Codes and the build-box hostname are examples, and authorization URLs are shortened. Use the URL and code printed by your own command.

A first gh login. After the login, Ferry sets up GitHub over SSH and prints one line per step:

shellsession
$ ferry auth gh
│
◇  Starting the gh login on the box
URL: https://github.com/login/device
Code: 7F3K-92QD
Open the URL in a browser on this machine and enter the code.
│
◇  Waiting for you to finish the login in the browser (up to 15 min)
gh: logged in
SSH key: added to GitHub as build-box (ferry)
known_hosts: added github.com
git protocol: ssh
ssh -T git@github.com: authenticated

The same command when gh is already logged in:

shellsession
$ ferry auth gh
│
◇  Starting the gh login on the box
│
◇  Checking the gh setup on the box
gh: already authenticated
SSH key: already on GitHub
git protocol: ssh
ssh -T git@github.com: authenticated

A Claude login. The browser shows a code at the end, and you paste it at the prompt. Ferry masks it:

shellsession
$ ferry auth claude
│
◇  Starting the claude login on the box
URL: https://claude.ai/oauth/authorize?code=true&client_id=...
Open the URL in a browser on this machine. After the login, paste the code that the browser shows.
│
◇  Code from the browser
│  ▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪▪
│
◇  Waiting for you to finish the login in the browser (up to 15 min)
claude: logged in

A Codex device login:

shellsession
$ ferry auth codex
│
◇  Starting the codex login on the box
URL: https://auth.openai.com/codex/device
Code: Q8RZ4-TK27M
Open the URL in a browser on this machine and enter the code.
│
◇  Waiting for you to finish the login in the browser (up to 15 min)
codex: logged in

In a terminal, each ◇ line is a spinner until its step is done. When Ferry doesn't run in a terminal, for example in a script, it writes each step as one plain line, such as Starting the gh login on the box....

GitHub over SSH

Ferry forwards your SSH agent only for its own snapshot update, so agents on the box need their own key to push. ferry auth gh sets that up:

  1. Before the login, Ferry creates ~/.ssh/id_ed25519 on the box if it does not exist. The key has no passphrase and the comment <box user>@<box host> ferry. Ferry never replaces an existing key.
  2. The gh login asks for the admin:public_key scope, which gh needs to add the key.
  3. After the login, Ferry adds ~/.ssh/id_ed25519.pub to your GitHub account with the title <box host> (ferry), unless GitHub already has it.
  4. If the box ~/.ssh/known_hosts has no github.com entry, Ferry reads the ed25519 host key with ssh-keyscan. It adds the key only if the fingerprint matches the one the GitHub meta API publishes.
  5. Ferry runs gh config set -h github.com git_protocol ssh.
  6. Ferry runs ssh -T git@github.com and reports whether GitHub accepts the key.

If you don't finish the login, Ferry skips steps 3 to 6. If gh is already logged in, Ferry runs only steps 1 and 3 to 6. A gh login without the admin:public_key scope cannot add the key. Run gh auth refresh -h github.com -s admin:public_key on the box, then run ferry auth gh again.

To revoke the box key, delete the key titled <box host> (ferry) under GitHub Settings > SSH and GPG keys, or use gh ssh-key list and gh ssh-key delete <id>. To remove it from the box, delete ~/.ssh/id_ed25519 and ~/.ssh/id_ed25519.pub there.

Moving a project

Your setup is on both machines, but a project usually lives on one of them. ferry move moves a project to the other machine, so an agent on the box can pick up where you stopped, or you can take the box's work back to your laptop.

sh
ferry move ~/Developer/app                # this machine -> box
ferry move --from-box ~/Developer/app     # box -> this machine
ferry move ~/Developer/app --dry-run      # show what Ferry carries, refuses, and skips
ferry move ~/Developer/app --remove       # after verification, move the source copy to a trash directory
ferry move ~/Developer/app --include-env  # also carry .env files that pass the deny rules

The project must be inside your home directory, and it keeps the same path relative to home. ~/Developer/app on your laptop becomes ~/Developer/app on the box. Ferry doesn't copy the git history. The destination clones the project from origin, and Ferry carries only the files that git doesn't have. The steps:

  1. Preflight. Ferry reads the branches and tags of origin with git ls-remote. It refuses the move if a commit on a local branch is not on origin, unless git cherry shows an equivalent patch on the default branch of origin. Ferry does not fetch, so run git fetch first if the patch was merged recently. Ferry also refuses uncommitted changes to tracked files and a destination path that already exists. It reports stashes, because the clone does not get them.
  2. Clone. The destination clones the project from origin, at the current branch if origin has it. The box uses its own SSH key, so run ferry auth gh first.
  3. Carry. Ferry carries the untracked and ignored files. It skips node_modules, .next, .nuxt, .svelte-kit, .turbo, .cache, .parcel-cache, dist, build, coverage, target, __pycache__, .venv, venv, .gradle, .terraform, .idea, .vscode, .DS_Store, and ._* at any depth. Each carried file must pass the same deny rules as a sync. Ferry refuses symbolic links and nested git repositories.
  4. Environment files. Ferry refuses .env and .env.* files. With --include-env, it carries an environment file only if it has no token and no PASSWORD, SECRET, API_KEY, or similar key with a value.
  5. Verify. Ferry compares the SHA-256 checksum of each file on the destination with the checksum of the bytes it checked.
  6. Paseo. If the destination has the paseo CLI, Ferry runs paseo project create <path> there.
  7. Remove. With --remove, Ferry moves the source copy away after verification: to ~/.Trash/<name>-<timestamp> on macOS, and to ~/.ferry/trash/<name>-<timestamp> on the box and on Linux. Ferry never deletes the source copy. If Ferry refuses a local-only file, it refuses --remove before it changes anything.

A folder without git has no clone. Ferry copies all its files with the same skip list and deny rules.

--dry-run prints the plan: the clone, and each file that Ferry carries, refuses, or skips. It writes nothing on either machine. With --from-box, Ferry reads the box files into a temporary directory on your laptop for the deny checks and removes that directory after the run.

Warning

If a step fails after the clone, the destination copy is incomplete. Ferry does not remove it. Move it away before you try again.

Supported harnesses

Ferry supports the following tools and their global configuration directories. Ferry calls each configuration a harness:

HarnessSkill rootInstruction fileMCP servers
Shared agents~/.agents/skills~/AGENTS.mdNone
Claude Code~/.claude/skills~/.claude/CLAUDE.md~/.claude.json
Codex~/.codex/skills~/.codex/AGENTS.md~/.codex/config.toml
Pi~/.pi/agent/skills~/.pi/agent/AGENTS.mdNone
Cursor Agent~/.cursor/skillsNone~/.cursor/mcp.json

~/AGENTS.md is the shared global instruction file. Ferry links it as the instruction file of each harness, so every agent reads the same instructions. You can add your own harness. See Custom harnesses.

Security model

Ferry carries skills, ~/AGENTS.md, the Claude subagents and commands, an allowlist of Claude settings keys, and the name and HTTPS URL of each remote MCP server. What Ferry carries has the details.

Ferry excludes:

  • credential files, OAuth sessions, or Keychain items
  • tokens, API keys, MCP tokens, or request headers
  • settings keys that can hold secrets, such as env and apiKeyHelper
  • local MCP servers, which have a command, args, or env
  • session history, caches, databases, or whole settings files

Ferry checks carried files against deny rules for credential filenames, known token patterns, and secret fields. A match stops the sync. The error names the file without printing the secret value.

Ferry opens no public listening port. The Codex callback login forwards local port 1455 to the box for 120 seconds. Ferry never turns off SSH host-key checks. It forwards your SSH agent to the box only for the snapshot update.

ferry auth gh creates an SSH key without a passphrase on the box and adds it to your GitHub account, so agents on the box can push. See GitHub over SSH.

Warning

Ferry runs commands on the box as your SSH user. Use a box and an SSH user that you trust with the agents that run there.

What Ferry carries

  • Skills from every global harness skill root, such as ~/.agents/skills and ~/.claude/skills. Ferry skips the .system directory, because Codex manages it and installs its own copy on each machine.
  • ~/AGENTS.md, linked as the instruction file of each harness.
  • Claude subagents and commands in ~/.claude/agents and ~/.claude/commands. Ferry links each directory whole into the store. On the box, a live directory at one of these paths stops the sync. ferry sync --force moves it to ~/.ferry/backups and then links it.
  • Four keys from ~/.claude/settings.json: enabledPlugins, extraKnownMarketplaces, permissions, and hooks. The snapshot holds them in settings/claude.json. No other key and no plugin cache.
  • Remote MCP servers from Claude, Codex, and Cursor Agent. Only the name, the transport, and the HTTPS URL. See Remote MCP servers.

It does not carry project-local skills.

Deny rules

The deny rules apply to every carried directory. Any of these stops the sync:

  • A file such as .env or credentials.json, or content that matches a known token pattern.
  • A JSON, YAML, or TOML file with a password, passwd, secret, client_secret, private_key, or api_key key that has a string value, at any depth. Ferry compares keys without regard to case, -, or _, so clientSecret matches too. If the file does not parse, Ferry checks each key: value or key = value line.
  • An ELF, Mach-O, or PE executable. A script with a shebang and a Java class file are fine.
  • A carried settings key that holds a token or request headers.

Empty values and recognized placeholders pass. For token examples in skill documentation, use a body made only of x, only of X, or only of 0. Ferry also accepts x and X with the token format's separators, such as xoxb-xxxx-xxxx-xxxx. Arbitrary fake values can still trigger the deny rules.

Hooks

A hook that points at a file in your home directory breaks on the box if that file isn't there. Ferry checks each hook command before it carries it.

Ferry splits the command into words at spaces, quotes, ;, |, &, (, ), <, >, and =. A word that starts with ~/, $HOME/, or ${HOME}/ must point into a place Ferry fills on the box: a skill root, ~/.claude/agents, ~/.claude/commands, or a managed instruction file such as ~/AGENTS.md. An absolute path under your home, such as /Users/you/bin/check.sh, never passes, because the box home has a different path. Programs on PATH, such as jq, pass this check. So do $CLAUDE_PROJECT_DIR paths and absolute paths outside your home. You must make those programs and paths available on the box yourself.

Ferry leaves out each hook that fails the check and carries the rest. Ferry also removes matcher groups and events left empty by these exclusions. The sync continues, and ferry sync prints one Skipped hook: line per hook with its location, for example hooks.PreToolUse[0].hooks[1].command.

Scripts in ~/.claude/hooks are not carried. Move them into a carried directory, such as a skill, or onto PATH on both machines.

Claude plugins and settings

Claude does not install a plugin from settings alone. So on the box, ferry sync runs claude plugin marketplace add for each carried marketplace and claude plugin install for each enabled plugin. Then it writes the carried keys into the box ~/.claude/settings.json.

The box keeps all other keys, such as env and model. A carried key that your machine does not have is removed from the box. If you have no hooks key, the box ends up with no hooks key. A marketplace or plugin that the box cannot install gives a warning and does not stop the sync. A marketplace with a directory or file source lives on your machine only, so the box cannot add it.

ferry sync --dry-run lists the carried settings keys that will change, compared with the last publish in ~/.ferry/store/settings. It does not connect to the box, so it can't see changes someone made there.

Info

If a plugin in enabledPlugins comes from a marketplace that extraKnownMarketplaces does not list, run claude plugin marketplace add for it once on your machine. For example, claude plugin marketplace add anthropics/claude-plugins-official.

Adding skills

Use ferry skills add to install skills. It runs npx skills add and passes your arguments through:

sh
ferry skills add vercel-labs/agent-skills --skill frontend-design -a claude-code -y

Ferry adds -g and --copy if you don't pass them, so the command above runs npx skills add vercel-labs/agent-skills --skill frontend-design -a claude-code -y -g --copy. A global copy install puts each skill in a real directory in a global skill root, and the next sync moves it into the store. A plain npx skills add installs into the current project, which Ferry does not see.

Add --project to keep a project install. Ferry then does not add -g. Put arguments after -- to keep Ferry from reading them. If npx skills add fails, Ferry exits with the same code.

Installing a skill does not publish it. Run ferry sync, or keep ferry watch running.

The Ferry agent skill

Ferry has its own agent skill, in skills/ferry. It tells agents on both machines how to work with Ferry. The most important rule is for agents on the box: do not edit a Ferry-managed file there, because the next sync resets it. The skill also tells agents to run --dry-run before ferry sync and ferry move, to leave ferry auth and confirmation prompts to you, and never to work around a deny rule.

Install it on your laptop and sync it to the box like any other skill:

sh
ferry skills add dlhck/ferry --skill ferry
ferry sync

Some installers replace the store link of a skill with a real directory that holds a newer version, for example in ~/.agents/skills. Sync then updates the store copy from that directory, publishes it, and links the directory back to the store. It does this only when:

  • exactly one skill root has a real directory for the skill,
  • every other skill root that has the skill links to ~/.ferry/store/skills/<name>, directly or through a chain of links, and
  • the store copy has no uncommitted changes in ~/.ferry/store.

In every other case, sync stops with a clash and leaves the decision to you. ferry sync prints Updated store skill <name> from <path> for each update, and ferry sync --dry-run lists them under Store updates from a harness root:.

Remote MCP servers

Ferry supports remote MCP server declarations with a name and an HTTPS URL. Servers such as Linear use OAuth for authentication. Ferry declares these servers on the box. It never copies an MCP token, so you log in to each server on the box.

Ferry reads only the MCP key of each file: mcpServers in ~/.claude.json, [mcp_servers] in ~/.codex/config.toml, and mcpServers in ~/.cursor/mcp.json. It does not include the account data from ~/.claude.json in the snapshot.

Ferry skips local servers, which have a command, and servers with a plain http:// URL. Sync prints one Skipped MCP server: line for each. Ferry stops the sync if a remote server has any of these:

  • request headers (headers, headersHelper, http_headers, env_http_headers, http_headers_helper)
  • a bearer_token_env_var, an env or env_vars value, or args
  • a URL with a user name, a password, a token, or a query parameter such as api_key or token
  • a name with characters other than letters, digits, ., _, and -, because the name goes into commands on the box

On the box, sync declares each server:

  • Claude: claude mcp add --transport http --scope user <name> <url>
  • Codex: codex mcp add <name> --url <url>. This command also starts a login and waits for it, so Ferry stops it after 20 seconds. Codex writes the declaration before the login starts.
  • Cursor Agent: Ferry writes the server into mcpServers in the box ~/.cursor/mcp.json, because Cursor Agent has no add command.

Pi has no MCP support of its own, so Ferry declares no Pi servers.

If the box already has a server with the same URL, Ferry leaves it alone and its login stays. If the URL changed, Ferry replaces the declaration and you log in again. Ferry never removes a server from the box. If you remove a server on your machine, remove it on the box yourself. A missing CLI or a rejected server gives a Box MCP: warning and does not stop the sync.

ferry status lists each box MCP server that needs a login, and the command to run:

shellsession
$ ferry status
...
MCP logins:
  claude/linear: LOGIN REQUIRED, run ferry auth claude --mcp linear
  codex/linear: LOGIN REQUIRED, run ferry auth codex --mcp linear
...

Ferry starts the tool's MCP login on the box under script, because Claude and Codex need a terminal, and prints the authorize URL. Open it on your machine. The browser then redirects to a localhost callback port, which Ferry forwards to the box for 300 seconds, so the tool on the box gets the callback and keeps the token. Press Ctrl-C when the browser reports success. The callback port must be free on your machine. Claude uses port 3118.

shellsession
$ ferry auth claude --mcp linear
│
◇  Starting the claude/linear MCP login on the box
URL: https://mcp.linear.app/authorize?response_type=code&client_id=...&redirect_uri=http%3A%2F%2Flocalhost%3A3118%2Fcallback&...
Open the URL in a browser on this machine.
Ferry forwards local port 3118 to the box for 300 s. Press Ctrl-C after the browser reports success.

Run ferry status again to verify the login. After a successful login, the server should no longer appear under MCP logins:.

Automatic sync

ferry watch runs in the foreground and watches every global skill root, ~/AGENTS.md, the Claude subagents and commands, and the carried Claude settings keys. When a change stays stable for one second, it runs a normal sync without --force. Network, SSH, and git failures retry with a backoff capped at 60 seconds. If the deny rules refuse a file, the watch names the path and waits for your next edit.

To run it as a user service:

sh
ferry watch install

The installer uses the absolute path of the running Ferry executable and records the current PATH and, if set, SSH_AUTH_SOCK. Run it again after you move Ferry or either value changes.

On macOS, Ferry writes ~/Library/LaunchAgents/dev.ferry.watch.plist, unloads an older Ferry job, and bootstraps the new one. The log goes to ~/Library/Logs/ferry-watch.log. launchd does not inherit your shell's PATH or SSH_AUTH_SOCK, so before you install, make sure PATH contains git, ssh, and, for a Tailscale host, tailscale. If the snapshot remote needs an SSH agent, point SSH_AUTH_SOCK at its stable socket.

On Linux, Ferry writes ~/.config/systemd/user/ferry-watch.service, reloads the user manager, and enables and starts the service. Read the logs with:

sh
journalctl --user -u ferry-watch.service -f

Warning

Do not put tokens or private keys in the plist or the unit file.

Keeping the agent tools up to date

ferry update updates the agent tools, not Ferry itself. To update a standalone Ferry installation, replace the executable with a newer release and restart the watch service if you use it.

The agent update commands are:

ToolUpdate commandOn your machine
ghsudo apt update && sudo apt install gh -ySkipped. Ferry does not guess your package manager.
Claude Codeclaude updateRuns if installed.
Codexcodex updateRuns if installed.
Pipi updateRuns if installed. Updates Pi only, not its packages.
Cursor Agentcursor-agent updateRuns if installed.

Ferry finds each tool with command -v and never installs a tool on your machine. It prints the plan and asks first. --yes skips the prompt, and --dry-run prints the plan only. A failed update does not stop the others. At the end, Ferry names each failure and exits non-zero.

The watch can also run ferry update --yes once a day. Add this to ~/.ferry/config.toml and restart the watch:

toml
[update]
watch = true

The update runs next to the sync loop, so it never delays a sync. Ferry records the last start time in ~/.ferry/update-state.json, so restarting the watch does not trigger an extra update.

Passwordless sudo for the gh update

The gh update needs sudo on the box, and the watch has no terminal to type a password. Membership in the sudo group is not enough, because the default rule on Ubuntu and Debian asks for a password. Add this rule on the box, with your SSH user in place of <ssh-user>:

plaintext
# /etc/sudoers.d/ferry  (edit with: sudo visudo -f /etc/sudoers.d/ferry)
<ssh-user> ALL=(root) NOPASSWD: /usr/bin/true, /usr/bin/apt update, /usr/bin/apt install gh -y

sudo matches the full path and all arguments, so the rule allows exactly these three commands. /usr/bin/true is there so ferry status can run sudo -n /usr/bin/true to test whether sudo asks for a password. Ferry never writes sudoers files itself.

ferry status shows Box sudo: PASSWORDLESS or Box sudo: PASSWORD REQUIRED, and --json has it in boxSudo. With [update] watch = true and a password prompt, status warns that the watch cannot update gh.

Custom harnesses

If you use a harness Ferry doesn't know, declare it in ~/.ferry/config.toml:

toml
[[harness]]
id = "opencode"
name = "OpenCode"
skill_root = ".config/opencode/skills"
instruction_file = ".config/opencode/AGENTS.md"

Every command uses these entries, and ferry init keeps them when it rewrites the file. A custom harness can't add extra directories or settings keys to the snapshot. Ferry refuses unknown keys or sections in the config and names them.

Commands

CommandWhat it does
ferry initRecords the box and the snapshot URL, seeds the snapshot, and links this machine.
ferry installInstalls gh, Claude Code, Codex, Pi, and Cursor Agent on the box.
ferry syncPublishes the snapshot and applies it on the box.
ferry statusReports the link, the snapshot, the managed paths, the box logins, and MCP servers that need a login.
ferry auth <tool>Starts a login on the box for gh, claude, codex, or cursor.
ferry updateRuns the update command of each agent tool on the box and on this machine.
ferry skills addRuns npx skills add with the flags Ferry needs.
ferry move <path>Moves a project to the box, or with --from-box back to this machine.
ferry watchSyncs automatically when a carried file changes.
ferry watch installInstalls ferry watch as a launchd or systemd user service.
ferry uninstallRemoves Ferry and restores the paths it replaced.
ferry --versionPrints the installed Ferry version.

The useful flags:

  • --dry-run on init, sync, update, and move prints the plan and changes nothing.
  • --from-box, --remove, and --include-env on move set the direction, move the source copy to a trash directory, and carry .env files that pass the deny rules.
  • --yes on install, update, and uninstall skips the confirmation prompt.
  • --force on sync moves live directories on the box to ~/.ferry/backups before it links them.
  • -m, --message on sync sets the snapshot commit message.
  • --json on status prints the report as JSON.
  • --mcp <server> on auth starts the login of a remote MCP server on the box.

init, sync, status, install, auth, and update show the current step on stderr, for example Publishing the snapshot or Installing Claude plugins (3/10). In a terminal you get a spinner. Otherwise Ferry writes one plain line per step. ferry status --json writes only the JSON report.

Uninstalling

ferry uninstall removes the local store and config, removes the Ferry symlinks, and restores the paths that existed before your first ferry init. It refuses to write if you replaced a managed symlink with local content. It asks for confirmation first, and --yes skips the prompt.

Try it

Ferry is MIT licensed. Run the install script, create a private snapshot repository, and start with ferry init --dry-run to inspect the planned setup.

The source and issue tracker are on GitHub. See CONTRIBUTING.md for contributions and SECURITY.md to report a vulnerability.

CEO & co-founder, Vendure. Writing about building software and companies.

← Index