devlaunch
A streamlined CLI for devpod with intuitive autocomplete and fzf fuzzy selection.
Continuous Integration Status
Installation
Pixi (Recommended)
pixi global install --channel conda-forge --channel https://prefix.dev/blooop devlaunch
This installs devlaunch along with devpod and all dependencies automatically.
Pip
pip install devlaunch
Note: When using pip, you must install devpod separately.
If devpod is not on PATH, every command that needs it prints a single install hint on stderr and exits 127
(the shell's "command not found" code). dl --help and dl --version keep working without it.
A devpod that is installed but cannot answer is a different failure and gets a different exit code. If
devpod list exits non-zero, or prints something that is not a --output json workspace listing, dl quotes
what devpod said on stderr and exits 1 rather than reporting that you have no workspaces — so dl --purge
stops instead of deleting caches it never checked. Shell completion is the deliberate exception: dl --install,
dl --refresh and dl --completion-data log the failure and carry on with the repos and branches they can
still discover on local disk, so an unreachable devpod costs you workspace-name completion and nothing more.
Shell Completions
After installation, set up shell completions for dl and aid:
dl --install
source ~/.bashrc # or restart your terminal
Usage
dl # Interactive workspace selector (fzf)
dl <user/repo> # Start workspace and attach shell
dl <user/repo> <cmd> # Run workspace command (stop, code, etc.)
dl <user/repo> -- <command> # Run shell command in workspace
Commands that need a terminal
dl <ws> -- <command> gives the command a terminal whenever dl itself has one,
so interactive programs — a coding agent, htop, git rebase -i, a REPL — start
and stay up instead of exiting immediately. Redirect the output and the terminal
goes away again, so dl <ws> -- ls > files.txt stays free of escape sequences.
This needs the ssh host alias devpod up writes to ~/.ssh/config. If a
workspace has none, dl says so and falls back to the plain devpod ssh
transport, which has no terminal; dl <ws> restart republishes the alias. Set
DEVLAUNCH_NO_TTY=1 to force the fallback everywhere.
aid: start a coding agent in a workspace
aid is dl with a coding agent started for you:
aid <user/repo>[@branch] [prompt...] # Open the workspace, start the agent
It is a shortcut, not a second launcher. aid rewrites its command line into a
dl one and hands it to dl itself, so
aid blooop/devlaunch@fix/42 fix the flaky test
is exactly
dl blooop/devlaunch@fix/42 -- IS_SANDBOX=1 claude --dangerously-skip-permissions 'fix the flaky test'
That means an aid workspace is the dl workspace: same clone, same workspace
id, same container — started if stopped, attached to if already running, and never
rebuilt just because aid asked for it. Anything dl learns, aid gets.
claude is started with --dangerously-skip-permissions. The agent is already
inside a disposable container holding only this repo, so the per-tool prompts it
would ask on the host protect nothing here and would stall an unattended run.
IS_SANDBOX=1 rides along because claude otherwise refuses that flag outright
under uid 0, and devcontainers that run as root are ordinary. The variable is
scoped to the agent process, not exported into your shell.
The trade is worth stating plainly: an agent started this way edits, runs and
deletes inside the container without asking. It cannot reach your host, but it can
rewrite the checkout it is in, so review an aid workspace before pushing rather
than treating it as a sandbox that will stop it for you. --codex and --gemini
are unaffected, and dl <ws> -- claude still runs exactly what you typed.
| Option | Description |
|---|---|
--claude, --codex, --gemini |
Pick the agent (default: claude) |
--devcontainer <variant|path> |
Passed through to dl |
DEVLAUNCH_AID_AGENT=<agent> |
Change the default agent |
Everything after the workspace is the prompt, flags and all, so it never needs
quoting to survive aid's own parsing. Managing workspaces — listing, stopping,
deleting, VS Code — stays with dl.
The agent's CLI has to be installed in the container; aid runs it there, it does
not install it.
Workspace Sources
dl myproject # Existing workspace by name
dl user/repo # Create from GitHub repo
dl user/repo@branch # Create from specific branch
dl ./path # Create from local path
Workspace IDs
dl user/repo@branch derives one id that names both the devpod workspace (what you
see in dl --ls) and the clone directory under ~/.cache/devlaunch/repos/:
<repo-slug>-<branch-slug>-<syllables> at most 38 characters
blooop/devlaunch@main -> devlaunch-main-zovomobo
blooop/devlaunch@feature/auth -> devlaunch-feature-auth-poliseno
blooop/devlaunch@feature-auth -> devlaunch-feature-auth-nesatabe
blooop/test_renv@nb4 -> test-renv-nb4-polenita
kinisi-robotics/kinisi_ros@ags-devcontainer-tooling-support
-> kinisi-ros-ags-devcontainer-t-lenevere
blooop/devlaunch@dependabot/github_actions/codecov/codecov-action-6
-> devlaunch-dependabot-codecov-sifivasa
The eight-character syllable suffix is a hash of the full (owner, repo, branch) triple.
It is what makes the id unique: the readable part is shortened to fit the length limit,
and shortening it does not affect whether two branches share an id. Long branch names
drop whole /-separated middle segments before losing characters, so the part that
identifies the branch survives. Note the third and fourth lines above: feature/auth and
feature-auth read the same once slugged but are different branches, and they get
different ids.
Owner and repo are matched case-insensitively, the way GitHub treats them, so
dl NVIDIA/cuda-samples@main and dl nvidia/cuda-samples@main are the same workspace.
Branch names are case-sensitive, because git refs are.
URL specs (dl github.com/owner/repo) get an id in the same shape, with the suffix
hashed over the URL.
The id is also the container hostname, so it stays well inside the 38-character budget to leave room for tools that add their own prefixes.
Branch names must be safe as both git refs and directory names — a name with a space or a leading dash is rejected rather than quietly rewritten.
Upgrading from an older devlaunch
This id format is new, and the directories and containers on your machine were named by
the previous scheme. The first dl user/repo… command after upgrading migrates the cache
once and prints what it did. dl --help, dl --version, dl --ls and opening an existing
workspace by name do not trigger it.
Your clone directories are renamed. What was
~/.cache/devlaunch/repos/blooop/devlaunch/main becomes
~/.cache/devlaunch/repos/blooop/devlaunch/devlaunch-main-zovomobo. A workspace is a git
clone whose origin points at the .bare cache next to it, and .bare does not move, so
this is a plain rename: branches, history and uncommitted changes all survive — only
the folder name changes. metadata.json is updated in the same pass, so nothing is left
pointing at the old name.
Your existing devpod containers keep their old ids and are orphaned. The next
dl user/repo@branch builds a fresh container under the new id. dl does not delete
containers for you — deleting by id is how a running sidecar got destroyed the last time
something tried (kinisi_ros#9766) —
so it prints a one-line notice with the count and writes the old ids to
~/.cache/devlaunch/orphaned-workspaces.txt. Remove them when you are ready:
xargs -r -n1 devpod delete < ~/.cache/devlaunch/orphaned-workspaces.txt
A clone directory with no metadata record is left alone. Nothing records which branch
it was cloned for, and the old directory name cannot be turned back into one — feature/auth
and feature-auth both became feature-auth — so a guessed name would be worse than no
rename. Those directories stay exactly where they are and are listed in
~/.cache/devlaunch/unmigrated-clones.txt.
Running dl again changes nothing: the migration is keyed on the version field in
metadata.json, not on directory names, so a branch that happens to look like a new-scheme
id is never mistaken for one. If a migration is interrupted, the next run finishes it — the
version is written last, in the same atomic save as the new paths, so it never claims more
than the filesystem has actually done.
Workspace Commands
| Command | Description |
|---|---|
dl <user/repo> stop |
Stop the workspace |
dl <user/repo> rm, prune |
Delete the workspace |
dl <user/repo> code |
Open in VS Code |
dl <user/repo> restart |
Stop and start (no rebuild) |
dl <user/repo> recreate |
Recreate container |
dl <user/repo> reset |
Clean slate (remove all, recreate) |
dl <user/repo> -- <command> |
Run shell command in workspace (with a terminal, when dl has one) |
Options
| Option | Description |
|---|---|
--devcontainer <variant|path> |
Use a non-default devcontainer.json. A bare name means .devcontainer/<name>/devcontainer.json. Stored with the workspace, so pass it once. |
DEVLAUNCH_NO_TTY=1 |
Never give a workspace command a terminal; always use the plain devpod ssh transport. |
Projects with demanding devcontainers — several variants, compose sidecars, or a
host-side initializeCommand that has to tell branch workspaces apart — are
covered in docs/devcontainer-projects.md.
GitHub Authentication
Every workspace dl opens inherits the host's GitHub login, so gh is already
authenticated inside the container and the devcontainer.json does not have to
arrange anything for it. devpod forwards the ssh agent and git credentials on its
own, but nothing else carries gh.
devlaunch takes the token from GH_TOKEN, GITHUB_TOKEN, or gh auth token,
whichever answers first, and hands it to the container as GH_TOKEN. That reaches
any image and any container user, unlike a bind-mount of ~/.config/gh, and it
works whether the host keeps its token in hosts.yml or in a keyring. The token
is passed to devpod through a private file and through devpod's own environment,
never on a command line, so it does not appear in ps. dl installs gh itself
(see Tools in every workspace), so the login has
something to be spent on whatever the image ships. Check a workspace with:
dl <workspace> -- gh auth status
If no token can be found, dl warns on stderr and opens the workspace anyway rather
than failing — and the warning names the config directory gh consulted, because the
usual cause is a shell that scoped XDG_CONFIG_HOME somewhere gh has no login,
not a host that is actually logged out.
Who gets the token
Everything running in the container does — including a postCreateCommand from a
repo you did not write. dl someone/repo builds and runs that project's
devcontainer with your GitHub token in its environment, and a gh auth login token
usually carries repo, workflow, gist and read:org scopes. devpod already
forwards the ssh agent to every workspace, so this is not a new trust boundary, but
it is a wider one. Skip it for a repo you have not read:
DEVLAUNCH_NO_GH_TOKEN=1 dl someone/repo
| Variable | Description |
|---|---|
DEVLAUNCH_NO_GH_TOKEN=1 |
Do not forward the host's GitHub login into workspaces |
When the token changes
dl refreshes the token on every start, so rotating it on the host is enough for
any workspace that gets started or restarted afterwards. Attaching to a workspace
that is already running skips that step, and the token it was given at startup
stays in place — including one it was given before you set
DEVLAUNCH_NO_GH_TOKEN. Run dl <workspace> restart to replace it.
Tools in every workspace
gh and claude are available in every workspace dl opens, in every kind of
session — an interactive dl <workspace>, a one-shot dl <workspace> -- <command>,
and aid. The repo's devcontainer.json does not have to provide them, and most
do not: dl launches arbitrary repos, so a guarantee that depended on the image
would not be a guarantee.
They are installed with pixi global on devpod up, and put on the PATH of a
login shell through whichever of ~/.bash_profile, ~/.bash_login or ~/.profile
bash actually reads — it sources only the first of those that exists, so an image
shipping a ~/.bash_profile never reads ~/.profile. A workspace that already has both is left alone —
the check runs first, so the cost after the first launch is one round-trip and no
network. If pixi is missing from the image, dl installs that too.
An install that fails costs the workspace its tools, not its launch: dl logs a
warning and hands you the session anyway.
DEVLAUNCH_NO_TOOLS=1 dl someone/repo
| Variable | Description |
|---|---|
DEVLAUNCH_NO_TOOLS=1 |
Do not install gh or claude into workspaces |
Attaching to a workspace that is already running skips devpod up, and so skips
this too. A workspace started by something other than dl — or created before this
existed — picks the tools up on its next dl <workspace> restart.
Global Commands
| Command | Description |
|---|---|
dl --ls |
List all workspaces |
dl --install |
Install shell completions |
dl --purge [-y] |
Remove all devlaunch data — the workspaces devlaunch created, and its caches |
dl --prune-worktrees [days] |
Remove unused worktrees (default: 30 days) |
dl --refresh |
Refresh completion cache |
dl --help, -h |
Show this help |
dl --version |
Show version (an editable install also names the tree it runs from) |
A released install prints the version and nothing else. An install made in editable mode says so and names the checkout it resolves to, so two builds of the same version are told apart at a glance:
$ dl --version
dl 0.0.9
$ dl-next --version # editable install of a working tree
dl 0.0.9 (dev, editable from /path/to/your/devlaunch)
aid --version reports the same thing under its own name. The provenance comes
from the installed package's own PEP 610 metadata; an install that records none
just prints the bare version.
What purge deletes
devpod's workspace list is shared. A workspace you made with devpod up, or that
another tool made, sits in the same list as the ones dl made, and dl --purge
has no business destroying it. So it deletes only the workspaces devlaunch
created — the clones it made under its own cache directory ($XDG_CACHE_HOME or
~/.cache, then devlaunch/repos/<owner>/<repo>/<id>), which is exactly the
directory the purge is about to remove anyway. Everything else keeps working
afterwards, because nothing a purge touches backs it.
Anything it is leaving is named before it asks:
$ dl --purge
This will remove all devlaunch data:
- 4 DevPod workspace(s)
- /home/you/.cache/devlaunch/ (workspace clones, repo caches, completions)
Leaving 2 workspace(s) devlaunch did not create:
- pythontemplate
- my-hand-made-workspace
Are you sure? [y/N]
Three things dl does create are in that second list rather than the first.
dl ./some/path and dl <git-url> open a source dl did not clone, so it
cannot tell them from a workspace you made by hand — and a config.toml that
points repos_dir outside the cache puts the clones somewhere --purge does not
remove either, so those are left too. Delete any of them with dl <workspace> rm.
Erring this way is deliberate — a purge that skips one of your own workspaces
costs you a command, and the other kind of mistake costs you work you cannot get
back.
Examples
dl # Select workspace with fzf
dl devpod # Open existing workspace
dl loft-sh/devpod # Create from GitHub
dl blooop/devlaunch@main # Create from specific branch
dl ./my-project # Create from local folder
dl blooop/devlaunch code # Open in VS Code
dl blooop/devlaunch -- make test # Run command in workspace
dl blooop/devlaunch stop # Stop workspace
Features
- Fuzzy Selection: When called without arguments, uses fzf for interactive workspace selection
- Smart Completion: Tab completion for workspaces, GitHub repos (owner/repo format), and paths
- GitHub Shorthand: Use
owner/repoinstead of full URLs - automatically expands togithub.com/owner/repo - Branch Support: Specify branches with
owner/repo@branchsyntax - Fast Autocomplete: Completion cache for ~3ms response time (vs ~700ms without cache)
- One Round-Trip Per Question: every
devpodcall costs ~0.45s, far more thandlitself, so a command reads the workspace list at most once — anddl <ws> -- <cmd>skips the extra round-trip that names an interactive prompt, since a one-shot command has none
Worktree Backend
For git repositories, devlaunch uses an efficient worktree backend by default:
- Efficient Storage: Repos are cloned once to
~/.cache/devlaunch/repos/owner/repo/, then git worktrees are created for each branch - Shared Git Objects: All branches share git objects, saving disk space
- Lazy Fetch: Remote updates are only fetched if the configured interval has elapsed (default: 1 hour)
Container Sharing Mode
Use --shared to share a single container across multiple branches of the same repo:
dl --shared owner/repo@branch1 # Creates container "owner-repo"
dl --shared owner/repo@branch2 # Reuses "owner-repo" container
Pre-warming
Use --warm to prepare a workspace without attaching a shell:
dl --warm owner/repo@branch # Creates container in background
Shell Completion
After running dl --install, you get intelligent tab completion:
- Workspace names from your devpod list
- Known GitHub owners and repositories from your workspaces
- File/directory paths when starting with
./,/, or~ - All global flags (
--ls,--install, etc.) and workspace commands
How the completion cache stays current
The data behind completions lives in ~/.cache/devlaunch/completions.json, and
building it means a git ls-remote per known repo — seconds of work. So it is
rebuilt in the background at most once an hour (the same interval the worktree
backend uses for lazy fetches), and at most once per dl invocation. Commands
that change your workspaces (starting, stopping or deleting one) rebuild it as
soon as they finish, regardless of when it was last built. Commands with no use
for it — dl --help, dl --version — do not touch it at all.
A branch created on a remote in the last hour may therefore not be offered yet.
dl --refresh rebuilds the cache immediately and ignores the interval.
Development
This project uses pixi for environment management.
# Run tests
pixi run test
# Run the e2e suite: real devpod, real containers
pixi run test-e2e
# Run full CI suite
pixi run ci
# Format and lint
pixi run style
pixi run test skips the e2e tests, which need devpod and a Docker daemon and
build real containers. CI runs pixi run test-e2e in a job of its own, outside
the Python matrix, on a throwaway runner — on every push to main and on every
pull request, whatever branch that pull request targets. Stacked chains, where
each link targets its predecessor rather than main, get the same CI as anything
else.
Alongside the matrix and e2e there is a gate job that does nothing but fail
unless every other job in that workflow succeeded. It exists so that a branch
ruleset has one stable name to require rather than a list: requiring the jobs one
by one means literal strings in a repository setting, which nobody reviews and
which goes stale the moment a job is added or renamed — and a required check that
no longer exists does not turn a merge red, it stops gating it. Adding a job
means adding it to gate's needs, in the same pull request, where it can be
seen. It reaches only as far as its own workflow file, so the prek lint job is
not behind it and has to be required alongside it.
Running it yourself is a different proposition. This repo's devcontainer carries
a Docker daemon of its own, through the docker-in-docker feature, and pins the
same devpod a host installs, so pixi run test-e2e from inside it builds its
containers in there rather than on your Docker. You can also run it on a machine
you do not mind it writing to — an ephemeral CI runner, say. It is skipped by
default rather than gated on a container, because what it needs is a daemon, not
nesting. Either way the suite exercises dl --purge, so it gives itself a
private devpod namespace before collection begins — but the containers it builds
are real ones, and it wants several minutes and a 1.25 GB image pull the first
time.
Its skips mean one thing only. A test that opts out does so through
fixtures.e2e_guard.opt_out, and any other skip is reported as a failure,
because a run that could not reach a registry used to be indistinguishable from
a healthy one. Every run also prints what it actually built:
--------------------------------- e2e session ---------------------------------
22 e2e tests attempted, 5 workspaces created: e2e-test-create, e2e-test-lifecycle, e2e-test-git, e2e-purge-devlaunchs, e2e-purge-hand-made
A run whose workspace-building tests built nothing does not pass: the shortfall
is counted into the last line of the run, so 4 passed, 18 skipped becomes
1 failed, 4 passed, 18 skipped. A run with no workspace-building tests in it —
pytest -m e2e test/e2e/test_interactive_session.py, say — has nothing to
answer for and says so instead.
DEVLAUNCH_E2E_WORKSPACE=<id> opts in to the interactive-session tests, which
attach to a workspace you already have running rather than building one.
The nested daemon is also why the devcontainer does not join the host's network
namespace: a nested daemon needs a namespace of its own, or it co-manages the
host's docker0 bridge and writes its NAT rules into the host's netfilter
tables.
Disk cost of the dev container
Opening a devcontainer for a branch costs about 2 GB on the host before you do
anything in it: ~600 MB of image layers unique to this image, a ~680 MB container
writable layer, and a ~520 MB <workspace>-pixi volume.
The container carries its own Docker daemon, and that daemon's /var/lib/docker
lives on a second named volume. One pixi run test-e2e plus a couple of nested
workspaces puts ~2.3 GB in there, and nothing garbage-collects it — the inner
daemon reports ~45% of its images reclaimable with no reclaimer. Nested daemons
share no layers with the host or with each other, so this is paid once per branch.
Budget ~4 GB per branch you are actively developing and e2e-testing — about 12 GB for three concurrent branches.
The time cost is cold pulls in a fresh nested daemon: the first devpod up inside a
new container takes ~25s, ~16s of which is pulling a base image the host already has.
Workspaces after that reuse it and take ~8s.
These volumes are not reclaimed automatically. devpod delete removes the
container with docker rm and never touches volumes, and Docker never
garbage-collects a named volume — so <workspace>-pixi and
dind-var-lib-docker-* outlive the workspace that created them. To see what has
piled up:
docker system df -v # under Local Volumes, LINKS 0 means no container uses it
Cross-check a name against devpod list before removing it with docker volume rm:
a volume belonging to a live workspace shows LINKS 1.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file devlaunch-0.0.19.tar.gz.
File metadata
- Download URL: devlaunch-0.0.19.tar.gz
- Upload date:
- Size: 89.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
cd9904cb4bba26cfa0ce0df8f73ea36a59d0884a4811916b599a4a64f67946cf
|
|
| MD5 |
b24bd09e5a1759feb07eda2eda46371d
|
|
| BLAKE2b-256 |
c6dc21b7ecf905ca7a466b3afda3372b38f500b5880033a9aca344ecd6bae82e
|
File details
Details for the file devlaunch-0.0.19-py2.py3-none-any.whl.
File metadata
- Download URL: devlaunch-0.0.19-py2.py3-none-any.whl
- Upload date:
- Size: 89.2 kB
- Tags: Python 2, Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
twine/7.0.0 CPython/3.12.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
07e1df6e1490353f57843889e4c2377d42febd10f4718903a2228c280845e204
|
|
| MD5 |
6de1e58e54461a6f9418effd70d9b98a
|
|
| BLAKE2b-256 |
2946b2eec0e496a4d58297eb93fa88647d973b2a40a6df9a75d9305a3798b5bb
|