A git-like CLI to safely sync local folders with a remote JupyterHub — zero dependencies, pure Python.
jp keeps a local folder in sync with a directory on a JupyterHub server —
the way git keeps you in sync with a remote. You edit notebooks and scripts on
your laptop, jp push to send them up, run your training on the server's GPUs,
and jp pull the results back down.
It talks to the JupyterHub REST API directly, has zero third-party dependencies (pure Python standard library), and runs anywhere Python 3.9+ runs — macOS, Windows, Linux.
- Git-like workflow —
jp clone,jp status,jp push,jp pull. Same muscle memory. - Or skip the copy —
jp live <url>mounts the remote folder as a local folder you edit in place, andjp terminaldrops you into a shell on the server. No SSH, no FUSE, no server-side install. - Safe by default — on a shared research machine, jp never deletes remote files unless you explicitly turn that on, and even then it asks you file-by-file. Conflicts are never silently overwritten.
- Zero dependencies — one install, no dependency hell; ships as a wheel, a single
.pyz, or a standalone binary. - Cross-platform — macOS, Windows, Linux; Python 3.9 → 3.13.
Recommended — pipx:
pipx install jpsyncOr with uv:
uv tool install jpsyncBoth install the jp command in an isolated environment and put it on your
PATH. Then check it works:
jp --versionIf
jp: command not found, runpipx ensurepath(oruv tool update-shell) and reopen your terminal.
Other ways to install
No Python required — standalone binary (macOS / Linux):
curl -fsSL https://raw.githubusercontent.com/pehqge/jpsync/main/scripts/install.sh | shNo Python required — standalone binary (Windows, PowerShell):
powershell -ExecutionPolicy ByPass -c "irm https://raw.githubusercontent.com/pehqge/jpsync/main/scripts/install.ps1 | iex"Single file — grab jp.pyz from the
latest release and run it
with any Python 3.9+:
python jp.pyz --helpFrom source (latest main):
pipx install "git+https://github.com/pehqge/jpsync"jp authenticates with a personal API token from your JupyterHub.
- Open your JupyterHub in a browser and log in (e.g.
https://jupyter.example.com). - Go to the Token page — usually the Token link in the top bar, or
visit
https://<your-hub>/hub/tokendirectly. - Type a note (e.g.
jp), leave the scopes blank (full access to what you can already do), and click Request new API token. - Copy the token now — JupyterHub shows it only once.
Security: the token is like a password for your account.
jpstores only the path to a token file, never the token value, and never logs or commits it.
Run jp login and follow the prompts. It first asks for your Jupyter URL (so
the credential is linked to that server), suggests a name from it, opens the
token page in your browser, then you paste the token you generate (your input
stays hidden). Credentials are saved globally by default (usable from
anywhere); pass --local to keep it only in the current workspace:
$ jp login
Paste your Jupyter URL (to link this credential to its server), or leave blank: https://jupyter.example.com/user/me/lab/tree/x
Name this server/credential [me-jupyter.example.com]:
Opening the token page to create an API token: https://jupyter.example.com/hub/token
Open it in your browser now? [Y/n]: y
Paste your API token (input hidden):
✓ saved global credential 'me-jupyter.example.com'The URL is optional (leave it blank to skip the link); pass --url <URL> to
supply it non-interactively, or --no-browser to not open the token page. The
site (the server's scheme://host) is stored alongside the name so that,
when you have several credentials, jp offers only the ones for the server
you're working with.
Everything stays on your machine. jp writes the token to a private file
(permissions 600) under ~/.config/jp/ (or the workspace's .jp/ for a local
credential) and records only the credential's name in config — never the token
value. The token is never printed, logged, committed, or sent anywhere except as
the Authorization header to your own hub.
Run jp login again any time to add another server — keep as many credentials as
you like and pick one when you clone. See Credentials.
jp talks to your single-user server, so it must be started: open JupyterHub
and, if needed, click Start My Server. (jp doctor will tell you if it's
stopped.)
Copy the URL of the folder from your browser's address bar — the lab/tree/...
URL works directly:
jp clone https://jupyter.example.com/user/<you>/lab/tree/your-folder
cd your-folderThat creates a your-folder/ folder with a .jp/ workspace inside (like .git/)
and downloads the remote tree. jp looks at the URL's server and offers only the
credentials saved for it: with a single match it just uses it; with several it
shows a picker (press a to see all servers, s to link a site to a credential).
The choice is remembered in the workspace (jp clone … --credential <name> to
skip the prompt).
jp status # what changed, locally vs the server
jp push # send local changes up
jp pull # bring remote changes (e.g. training output) downThat's it. From any subdirectory of the workspace, jp finds its root
automatically (it walks up looking for .jp/, stopping at your home folder).
Need to actually run something on the remote box — install a package, kick off
training, poke around? Run jp terminal from your workspace and your terminal
becomes the remote machine's shell, right in the mapped folder. No SSH, no setup:
jp terminal # you're now in a remote shell, in this folderIt only opens an ephemeral terminal session (it never touches your files), and the session is cleaned up when you exit. See the command reference for the details.
Don't want a local copy at all — just open the remote folder and edit it in
place? jp live <url> mounts it as a normal folder on your machine over the same
kernel transport. Reads and (by default) writes travel straight to the server.
jp live https://jupyter.example.com/user/<you>/lab/tree/your-folder
# → confirms writable vs read-only, then mounts it under ./your-folder/
# edit with any tool; Ctrl-C (or `jp live unmount`) to stop.
jp live <url> --code # open it in VS Code with a remote shell wired up
jp live <url> --read-only # browse without any risk of writingIt mounts under one auto-managed handle (a folder on macOS/Linux, a drive letter on Windows) using the OS's built-in WebDAV client — no FUSE, no third-party deps. The mount is loopback-only and gated behind a per-session secret. Full guide and the cross-OS notes: docs/jp-live.md.
Editing a script locally and want to run it remotely without jp push on every
change? jp run <file> ships the source through an ephemeral terminal and runs
it on the remote, in the same mapped folder you're standing in — so it behaves
exactly like running it locally (relative open(), sibling import, __file__
all resolve against that folder). The exit code is propagated.
jp run train.py --epochs 5 # from the workspace root -> runs in <prefix>
jp run analyze.py # from a subfolder -> runs in <prefix>/<subfolder>
jp run cleanup.sh # shebang/extension picks the interpreter
jp run --as python3 script # force the interpreter (e.g. extensionless file)
jp run --dry-run train.py # preview target folder + source, run nothingThe interpreter is chosen from the file's shebang, else its extension (.py,
.sh, .js, .rb, .R, …), or --as. The script source is written to a temp
file (__temp__.<name>.<random>.<ext>) in that folder, executed, then removed —
only that one file is ever created or deleted, so concurrent jp runs never clash
and your files are never overwritten. For Python, sys.argv[0]/__file__/
tracebacks show the real name, not the temp file. You see only the program's
output, streamed live — no remote shell prompt, and input() works just like
locally. Any data files the script opens must already exist on the remote
(jp push those first). Works on macOS, Linux and Windows (the interactive raw
proxy uses the same jp.pty backend as jp terminal).
| Command | What it does |
|---|---|
jp clone <url> [dir] |
Clone a remote Jupyter folder into a new local directory. Accepts a lab/tree URL or --base-url/--prefix. |
jp init <url> |
Turn the current folder into a jp workspace (no download). |
jp login |
Save a named API-token credential linked to its server (paste the Jupyter URL, name it, paste the token; defaults to global; use --local for workspace-only). |
jp credentials |
List, edit, or delete saved credentials. No args opens an interactive manager (delete, set site, rename); --list, --rm NAME, --rename OLD NEW, --set-site NAME URL for scripting. |
jp status |
Show local vs. remote differences. Read-only. |
jp push |
Upload local changes. Additive by default. |
jp pull |
Download remote changes. Additive by default. |
jp diff [path] |
Show file-level differences. |
jp ls [remote-path] |
List a remote directory (no local writes). |
jp live <url> |
Mount a remote folder as a local folder over the kernel websocket — edit it in your own tools; changes travel to the server. Writable by default (confirmed); --read-only opts out. --code opens it in VS Code with a remote shell. jp live unmount (from inside) stops it; jp live --defaults sets the defaults. (guide) |
jp open |
Open this workspace's folder (or the subfolder you're in) in the Jupyter web UI. Confirms first, shows the URL, and can copy instead of opening. Refuses folders jp never syncs (.jp/, hidden dot-names, .jpignore matches) since they aren't on the remote. Press r in the prompt to remember your choice (skip it next time); jp open --ask forgets it. Token-free URL; no network. |
jp config |
Interactive settings editor (see below). Also config get/set/list. |
jp ignore [pattern] |
Manage .jpignore patterns. |
jp rm <path> |
Delete on the remote — gated, dry-run + typed confirmation. The only deleter. |
jp kernel |
Set up a VS Code remote kernel to run notebooks in the right directory (guide). |
jp terminal [url] |
Open the remote machine's shell in your terminal, in the workspace folder. With a <url> it works standalone (no workspace needed). Creates/deletes only an ephemeral terminal session; touches no files. |
jp run <file> [args…] |
Run a local script on the remote in the current mapped folder, without pushing it. Interpreter from shebang/extension or --as; --dry-run to preview. Propagates the exit code. macOS/Linux/Windows. |
jp doctor |
Diagnose token, connectivity, server status. |
jp update |
Update jp to the latest version. |
jp changelog |
Show release notes (newer releases, a specific version, or --all). |
jp version |
Print the version (--changelog also shows release notes; jp --version). |
Global flags: -q/--quiet, --no-color. Every command has --help.
Run jp config with no arguments in a terminal for a settings screen:
Mirror mode (allow deletes) false
> Dotfile policy skip
Colored output auto
Network timeout (s) 30.0
Up/Down move · Space change · i info · / search · Enter save · Esc cancel
- ↑/↓ move · Space cycle the value · i show help for the selected setting · / search · Enter save · Esc cancel.
For scripts, the classic forms still work: jp config list,
jp config get <key>, jp config set <key> <value>.
By default jp push/jp pull are additive — they never delete. If you want
true mirroring (delete on the remote when you delete locally, and vice-versa),
turn on mirror mode:
jp config set mirror true # persist it, or use --mirror for one run
jp push --mirror # one-offWith mirror on, after the normal sync jp finds files that exist on one side but not the other and — always, before deleting anything — shows you the list and lets you choose, with the arrow keys, which to keep and which to delete:
Mirror mode: 2 file(s) exist on remote but not on the other side.
Choose which to DELETE on remote. Default is KEEP.
> [keep] old_experiment.py
[keep] scratch.ipynb
Up/Down move · Space toggle · a delete-all · n keep-all · Enter confirm · Esc cancel
Nothing is deleted unless you mark it. In a non-interactive shell, mirror
deletions are refused unless you pass --yes. Conflicts (both sides changed) are
never deleted or overwritten.
jp login is how you give jp your JupyterHub API token. It is fully
interactive and everything happens locally — the token never leaves your
machine and is never printed:
- It first asks for your Jupyter URL and links the credential to that
server's site (
scheme://host); the URL is optional (--urlto supply it, blank to skip). - You give the credential a name — prefilled from the URL (e.g.
me-jupyter.example.com), editable. - It opens the server's token page in your browser (
--no-browserto skip), then prompts you to paste the token with the input hidden (no echo). - The scope defaults to global (stored in
~/.config/jp/, usable from any directory). Pass--localto store the credential only in the current workspace's.jp/. - The token value goes into a private
600file; only its name is recorded in the workspace config (credentialkey). - A local credential lives in the workspace's
.jp/, andjpdrops a.jp/.gitignore(*) so that — even if the workspace is also a git repo — git ignores the whole.jp/directory and the token can never be committed.
Save as many as you like — run jp login once per server:
jp login # interactive: name, paste token; saves globally
jp login --name myserver --global # scriptable form
jp login --token-stdin --name lab-gpu --local < token.txtWhen you jp clone / jp init, jp reads the credentials available globally
and locally and matches them against the URL's server: with one match it's used
automatically, with several you pick (or pass --credential <name>). In the
picker, each credential shows its site; press a to toggle between this-server
and all credentials, and s to link a site to one that has none. The choice is
saved in the workspace so later push/pull just work.
Manage saved credentials with jp credentials — run it with no arguments for
an interactive manager (move with arrows; d delete, s set/replace site, r
rename, q quit), or use flags for scripting:
jp credentials --list # name, scope, site (no tokens)
jp credentials --set-site myserver https://host/user/me # link/replace its site
jp credentials --rename old new # rename
jp credentials --rm myserver --force # delete (no prompt)Legacy credentials saved before sites existed keep working — they have no site and match any server until you link one.
At sync time the token is resolved, in order: $JP_TOKEN (a value, for CI) →
$JP_TOKEN_FILE (a path) → the workspace's saved credential → legacy
token_path / ~/.config/jp/token. jp warns if any token file is readable by
other users.
jp update # detects pipx / uv / pip and upgrades in place
jp update --check # just check; don't installFor a standalone binary install, jp update prints the one-line reinstall
command for your OS.
jp also checks for a newer release at most once a day, in the background, and
shows a one-line notice next time you run a command — only in an interactive
terminal, never in scripts, pipes, or CI:
jp 1.1.1 → 1.2.0 (update available)
run `jp changelog` to see what's new · `jp update` to upgrade
- Turn the notice off:
export JP_NO_UPDATE_NOTIFIER=1orjp config set update_notifier false. - Opt in to automatic updates:
jp config set auto_update true. When on,jpupdates itself in the background between commands and tells you on the next run. It never updates the running command mid-flight, and never in CI or dev installs. - See what changed:
jp changelog(releases newer than yours),jp changelog 1.2.0(a specific version),jp changelog --all, orjp version --changelog.
Each workspace stores its settings in .jp/config.json (JSON, never the token
value). Keys: base_url, prefix, credential, token_path, mirror,
dotfiles, color, timeout. See docs/commands.md and
docs/architecture.md.
jp is built for a shared machine where a mistake can destroy someone
else's research. The guarantees:
push/pullnever delete unless you opt into mirror mode — and even then jp asks you, file by file, defaulting to keep.- Conflicts are never silently overwritten. If both sides changed since the last sync, jp aborts that file and tells you.
- Path-jailing. Every remote operation is confined to your workspace's
prefix. The server root and shared spaces (
compartilhado,lapix,shared, …) are refused outright. - Untrusted server on download. File names from the server are sanitized before anything is written locally (anti path-traversal / Zip-Slip), and writes are atomic and never follow a symlink.
- Your token never leaks — stored by path only, sent in the
Authorizationheader (never a URL), redacted from all output, never committed.
Found a vulnerability? See SECURITY.md — please don't open a public issue.
Is jp related to git? No — it borrows git's workflow, not its internals.
There's no remote version history on a JupyterHub.
Does it need Jupyter installed locally? No. Just Python 3.9+; it talks to the Hub over HTTPS.
Why won't my .gitignore (or any dotfile) upload? Most JupyterHub servers
run with allow_hidden=False, which rejects creating hidden files (names
starting with .). jp detects this and skips dotfiles on push, reporting
them instead of failing — your .git/, .gitignore, .env etc. simply stay
local (which is usually what you want). A nice side effect: secrets in dotfiles
never get pushed by accident.
Will it overwrite my work? Never silently. A conflict aborts that file; remote deletes are opt-in (mirror mode) and confirmed file-by-file.
Can I edit notebooks locally in VS Code but run them on the remote GPUs? Yes —
that's a core workflow. Sync with jp, then connect VS Code to your remote kernel.
One catch: a remote kernel starts in the server's home, not your notebook's
folder, so relative paths fail. Run jp kernel once to fix it. The full
walkthrough (connecting the kernel + the cwd fix) is in
docs/vscode-remote-cwd.md.
jp: command not found— runpipx ensurepath/uv tool update-shell, reopen the terminal.your JupyterHub server appears to be stopped— open the Hub UI and click Start My Server.authentication failed/ HTTP 403 — your token expired; create a new one andjp loginagain (or update the token file).- A big upload times out — raise the timeout:
jp config set timeout 120. FileNotFoundError/ relative paths fail in VS Code with a remote kernel — the kernel starts in the server's home, not your notebook's folder. Runjp kernel(see the VS Code remote-kernel guide).
Run jp doctor for a guided check.
Contributions welcome — see CONTRIBUTING.md and the Code of Conduct. The project is standard-library only; please keep it dependency-free.
jpsync is free and open source. If it saves you time on your JupyterHub
workflow, you can support its development — thank you! ☕
Apache 2.0 © Pedro Henrique Gimenez

