A local-first remote development platform. Work with remote files and commands as if they were local.
Note: This project is still in alpha development. Breaking changes may be made. Issues and contributions are welcome!
| Local (client) | Remote (target) | |
|---|---|---|
| macOS | yes | |
| Linux | yes | yes |
curl --proto '=https' --tlsv1.2 -sSf https://graft.run/install.sh | shOptions: --install-dir <dir>, --version <tag>
1. Activate shell integration (add to your shell rc file):
# bash: ~/.bashrc, zsh: ~/.zshrc
eval "$(graft activate zsh)"This lets graft track your working directory so commands like run, shell, sync, and forward can automatically detect which connection to use.
2. Start the daemon:
graft daemon service install # auto-start on loginThe daemon runs in the background and manages your remote connections.
3. Connect to a remote machine:
graft connect . user@host:~/project --syncThis connects your current directory (.) to ~/project on the remote host, with --sync to enable bidirectional file synchronization. You can also use graft init to save connection settings to a graft.yaml file for repeated use.
4. Use it from within the connected directory:
graft run make build # run a command remotely
graft shell # open a remote shell
graft sync # sync files to the remote
graft forward go make # forward commands to the remoteAll of these commands detect the connection from your current directory. You can also specify a connection explicitly with --to <connection>, or pin a connection to your shell session with graft use <connection>.
Interactive commands and shells persist on the remote if your terminal or connection dies, and they can be picked back up:
graft run -d npm run dev # start a command detached; prints its id
graft ps # list commands the remote daemons manage
graft attach <id> # re-attach, replaying recent output
graft detach <id> # disconnect its client; the command keeps running
graft kill <id> # terminate a command's process groupIf the network drops mid-command (laptop sleep, SSH blip), attached commands resume automatically once the connection reconnects, and disconnected commands are held for up to an hour awaiting re-attach. Interactive (pty) sessions replay the most recent 1MiB per stream on resume, dropping older history tmux-style; piped commands get TCP-like semantics instead, no output is ever dropped, and the command blocks in write once the buffer fills until its consumer returns.
Signals sent to graft during a run or attach (Ctrl-C, SIGTERM, SIGQUIT, SIGUSR1/2) are forwarded to the remote command, like a local foreground process. Closing the terminal (SIGHUP) detaches instead of forwarding, so kept commands survive it; use graft detach <id> from another terminal to detach deliberately.
If you use Claude Code, install the graft plugin so Claude knows how to use graft for remote development. It covers running commands, syncing files, forwarding ports, and diagnosing connection issues:
/plugin marketplace add edaniels/graft
/plugin install graft@graft
The plugin auto-triggers when you're working in a graft-managed directory, so you can keep using Claude Code normally; it will reach for graft run, graft sync, and graft status instead of raw ssh/scp where appropriate. It also installs a SessionStart hook that automatically tells Claude which connection, shimmed commands, and port forwards apply to the directory it started in, so it doesn't have to guess or ask.
| Command | Description |
|---|---|
connect |
Connect to a remote machine (SSH or Docker) |
disconnect |
Disconnect from a remote connection |
run |
Run a command on the remote |
shell |
Open a remote shell |
sync |
Sync files to the remote |
forward |
Forward local commands, ports, or the SSH agent to the remote (details) |
env |
Manage environment variable forwarding (details) |
use |
Pin a connection to the current shell session |
status |
Show connection status |
doctor |
Check environment setup and diagnose issues |
lsp |
Proxy a language server running on the remote (details) |
init |
Generate a graft.yaml configuration file for future graft connects |
When you run a command like graft run or graft shell, graft needs to know which connection to use. It follows this hierarchy:
- Explicit -
--to <connection>on the command line - Session pin - set with
graft use <connection>, applies to the current shell - CWD-based - automatically detected from your working directory based on each connection's local root
graft use is useful when you have multiple connections and want to lock your shell to a specific one:
graft use labos # pin this shell to the "labos" connection
graft shell # opens a shell on labos, regardless of cwd
graft use --clear # resume CWD-based auto-selectionBy default graft does not sync .git, so git commands on the remote fail with
fatal: not a git repository. Opt in to a one-way replica of your local
.git with:
graft sync --git # or: graft connect --sync --sync-gitor syncGit: true on a synchronization in the daemon config (and
syncGit: true on a destination in graft.yaml).
The replica makes the remote's git read-only: git status, log,
diff, blame, and rev-parse all work, but anything the remote writes to
.git is reverted on the next flush. In particular:
- Remote
git commit/branch/tag/fetchappear to succeed, then silently evaporate seconds later. Commit locally instead. - Never run
git stash,git checkout <branch>, orgit reset --hardon the remote. Their working-tree changes flow back to your local machine through the two-way file sync while the git metadata reverts;git stashin particular reverts your edits on both sides and then loses the stash. - Git's transient files (
*.lock, temp objects,gc.pid) are never replicated, so a localindex.lockcan't wedge remote git.
The initial sync transfers your entire .git (which can be large), and a
local git gc/repack re-transfers the rewritten packfiles.
By default graft derives its sync ignores from your .gitignore, so anything
git ignores is also skipped by sync. That is usually what you want, but not
for generated files you deliberately keep out of git yet still need on both
ends, like generated protobufs for example, that a build on the remote produces
and you want mirrored back locally.
Use --include-ignored (repeatable) to sync a gitignore-style pattern even
though .gitignore excludes it:
graft sync --include-ignored '**/*_pb2.py' --include-ignored '**/*.pb.go'
# or: graft connect --sync --sync-include-ignored '**/*_pb2.py'or syncInclude on a synchronization in the daemon config (and on a
destination in graft.yaml):
destinations:
myconn:
host: myhost
user: ubuntu
syncTo: ~/mydir
sync: true
syncInclude:
- "**/*_pb2.py"
- "**/*.pb.go"One caveat, inherited from how git and the sync engine both work: you cannot
re-include a file whose parent directory is ignored outright. Ignore a
directory's contents (gen/**), not the directory itself (gen/), or the
scan prunes the directory before the re-include is ever consulted. graft warns
you (right in the graft sync output, and in the daemon log for config-driven
syncs) when an include pattern is shadowed this way, so it never fails
silently.
Forward your local SSH agent to a connection so remote commands (e.g. git push
over SSH) can use your local keys, with no manual setup:
graft forward --agent --to myconn # start forwarding
graft forward remove --agent --to myconn # stop forwarding--agent is a flag, not a subcommand, so it can never be confused with
forwarding a real command or port literally named "agent" (e.g. some
*-agent daemon).
This is a persistent, per-connection setting, not a one-off action: once enabled, graft keeps the forward alive for as long as the connection is up, automatically re-establishing it after reconnects or daemon restarts. It's opt-in per connection; nothing is forwarded by default.
Stop retyping FOO_API_KEY=$FOO_API_KEY FOO_APP_KEY=$FOO_APP_KEY graft run ... for
vars that rarely change. graft env forward persists an allowlist of variable
names (glob patterns like FOO_* are supported) that graft run resolves from
your live shell environment on every invocation:
graft env forward FOO_API_KEY FOO_APP_KEY --to myconn # add to the forward list
graft env forward list --to myconn # see what's forwarded
graft env forward remove FOO_API_KEY --to myconn # stop forwarding a nameOnly the names/patterns are persisted, never values - each graft run re-reads
them from whatever is currently in your shell. The one-off inline form
(VAR=val graft run ...) still works and takes precedence over the persisted
list when both name the same variable.
Point your editor's LSP client at graft instead of the language server binary:
graft lsp <server> is a stdio LSP proxy. It starts <server> on the
connection matching your working directory (falling back to a local <server>
if the remote does not have one) and rewrites file:// URIs between the local
and remote sides using the connection's path remappings, so the server sees
remote paths while your editor sees local ones.
Definitions often land in files outside the synced tree that only exist on the
remote: the cargo registry, rust std sources, a Go module cache. The proxy
rewrites those to graft://<connection>/<remote-path> URIs and serves their
content read-only through the LSP 3.18 workspace/textDocumentContent
request, so goto-definition opens the exact bytes the server analyzed, with no
local copy of the toolchain required. Hover and further navigation keep
working from inside those buffers; editing and saving them does not (they are
read-only views).
This requires an LSP client that supports workspace/textDocumentContent
(proposed in LSP 3.18). Tested with Sublime Text's LSP package (>= 2.13.0).
In Preferences > Package Settings > LSP > Settings:
{
"clients": {
"rust-remote": {
"enabled": true,
"selector": "source.rust",
"command": ["graft", "lsp", "rust-analyzer"],
// Attach to graft:// buffers so goto-def and hover keep working from
// inside remote-only sources (the default is ["file"] only).
"schemes": ["file", "graft"],
// Syntax highlighting for those read-only buffers.
"syntax_map": {
"graft": "Packages/Rust/Rust.sublime-syntax"
}
}
}
}Editors title these buffers with the last segment of the URI, so graft marks
the file name with the connection it came from, keeping the extension intact
for editors that infer the language from it: goto-definition into the cargo
registry opens a tab named context@myconn.rs rather than a bare
context.rs that looks local. The marker is stripped before any path reaches
the language server or the remote filesystem.
Instead of passing flags to graft connect every time, you can save connection settings in a graft.yaml file.
A project config lives in a project directory and defines how to connect:
graft init . ubuntu@myhost:~/mydir --name myconn --sync --forward makeThis creates a graft.yaml:
version: v1
forward:
- make
destinations:
myconn:
host: myhost
user: ubuntu
syncTo: ~/mydir
sync: trueThen graft connect with no arguments from that directory reads the config automatically.
A workspace groups multiple projects under a shared root. Create one with:
cd ~/work
graft init --workspaceThis creates a graft.yaml with workspace: true. When you run graft connect from a project directory inside the workspace, graft walks up the directory tree looking for the workspace root. If found and syncWorkspace is enabled, the entire workspace directory is synced rather than just the project subdirectory.
~/work/ <- workspace root (graft.yaml with workspace: true)
infra/
projectA/ <- project (graft.yaml with destinations)
projectB/ <- project (graft.yaml with destinations)
Connections created with --background are excluded from CWD-based auto-selection. This is useful for auxiliary connections (e.g. a shared build server) that you only want to use explicitly via --to or graft use:
graft connect . user@build-server --background --name build
graft use build # explicitly switch to itSee docs/architecture.md for how graft works internally.
See docs/architecture.md#security-model.
See CONTRIBUTING.md for guidelines, including our AI usage policy. This project uses AI tools responsibly - all code is human-reviewed before merging, and all contributions must disclose AI usage.
All build/test/lint commands use just:
just graft-dev # build and install for local dev
just test # run tests
just lint # run all lintersSee the justfile for the full list of recipes.