Materialize any git ref of a configured project into an isolated, runnable local workspace with one command.
AI coding agents produce branches and PRs faster than they can be reviewed. uxd turns any ref — a PR number, a branch, a commit, a URL, or a local path — into a ready-to-run git worktree, so you can run the app, click through the UI, and poke at the change locally instead of reviewing blind.
uxd my-project 42 code # PR #42 → worktree → open in editor
uxd my-project 42 run dev # same workspace → install deps → start dev server
uxd my-project clean --merged # dispose of workspaces whose PRs merged
Every workspace command is the same pipeline:
resolve(ref) → materialize(workspace) → act(command)
- resolve — turn a ref (PR number, branch, commit, URL, or path) into a concrete git target.
- materialize — ensure a worktree exists for that target, with dependencies installed, seed files copied, and ports allocated. Idempotent and cheap when already materialized.
- act — open an editor, run a configured command, exec an arbitrary command, drop into a shell, diff, or sync.
checkout is the pipeline with a no-op act step; every other verb is a thin wrapper over it.
- Node.js ≥ 18
git≥ 2.38gh(optional — enables PR metadata inlist/info,gh pr diff, and fork push-back; everyghfeature degrades gracefully when it is missing)
uxd runs on stock Node — no Bun, Deno, or other runtime required. Any package manager (npm, pnpm, yarn) installs it.
uxd publishes to npm as @uxfront/uxd and installs a uxd binary onto your PATH.
npm i -g @uxfront/uxd # npm
pnpm add -g @uxfront/uxd # pnpm
yarn global add @uxfront/uxd # yarn (classic)Confirm the install:
uxd version # → uxd 1.1.0If uxd is not found, make sure your package manager's global bin directory is on your PATH — $(npm prefix -g)/bin (npm), $(pnpm bin -g) (pnpm), or $(yarn global bin) (yarn).
Upgrading? CHANGELOG.md lists what changed in each release. Note that 1.0.0 has no uxd setup command and reports its version as uxd 0.0.0, and that 1.1.0 removed the extends project key.
Working on uxd itself? uxd ships as compiled JavaScript, so a clone install has two steps — install dependencies, then build the dist/ output the CLI runs from — before you link it onto your PATH:
git clone git@github.com:uxfront-com/uxd.git
cd uxd
npm install
npm run build # emits dist/bin/uxd.js (the linked bin points here)
npm link # puts `uxd` on your PATH
uxd version # → uxd 1.1.0npm link points uxd at your clone's dist/. Because the CLI runs compiled output, re-run npm run build after a git pull to pick up new changes.
Prefer pnpm or yarn for the clone flow? Build the same way, then use that tool's global-link command:
pnpm install && pnpm run build && pnpm link --global # pnpm (run `pnpm setup` once first)
yarn install && yarn run build && yarn global add "file:$PWD" # yarn (classic)Skip the global step and call the built entrypoint from the clone:
npm install && npm run build
node dist/bin/uxd.js version # → uxd 1.1.0The examples below use uxd; substitute node dist/bin/uxd.js if you have not linked it globally.
-
Scaffold your first project in one command. On a fresh machine
uxd setupcreates the config dir (~/.uxdby default), sets a baseroot, and writes your first project file — prompting for each field on a terminal, or taking them as flags:uxd setup # interactive: asks for root, name, repo, default branch # or fully non-interactive: uxd setup --name my-project --repo git@github.com:my-org/my-project.git
This writes
~/.uxd/defaults.toml(root) and a minimal~/.uxd/my-project.toml. Seesetupfor the full prompt/flag reference. Flesh the project out later withuxd config edit my-project:repo = "git@github.com:my-org/my-project.git" editor = "code" [setup] run = "pnpm install --frozen-lockfile" cache_key = ["pnpm-lock.yaml"] [commands.dev] run = "pnpm dev"
(
repo_path/worktrees_pathdefault to{root}/{project}/…;rootcomes fromdefaults.toml— see Configuration.) -
Check your setup and validate the config:
uxd doctor uxd config validate
-
Materialize a PR and open it:
uxd my-project 42 code
-
Run its dev server in the same workspace, then clean up when the PR merges:
uxd my-project 42 run dev uxd my-project clean --merged
A ref is what you want to materialize. uxd parses the ref positional in this order (first match wins):
| Input | Meaning | Example |
|---|---|---|
<n> or #<n> |
pull request | uxd my-project 42, uxd my-project '#42' |
pr/<n> |
pull request (case-insensitive) | uxd my-project pr/42 |
/, ./, ../, or ~-prefixed |
adopt an existing working tree | uxd my-project ~/code/checkout shell |
| 40-char hex | commit SHA (detached worktree) | uxd my-project 1a2b3c…<40 chars> |
- |
the last-used ref for this project | uxd my-project - shell |
| anything else valid as a branch | branch name | uxd my-project feat/login code |
Short SHAs are deliberately not auto-detected (they collide with hex-ish branch names) — use the full 40 characters or force interpretation with a flag.
Force a ref's kind with a disambiguator (bypasses the table above):
uxd my-project --pr 42 code # treat 42 as a PR
uxd my-project --branch 42 code # treat 42 as a branch literally named "42"
uxd my-project --path ./here shell # adopt ./hereURL form — pass a clone or web URL in place of the project name; uxd matches it to the project whose repo has the same host/owner/repo (ssh ↔ https, .git optional). A /pull/<n> or /tree/<branch> segment is consumed as the ref:
uxd https://github.com/my-org/my-project/pull/42 code
uxd https://github.com/my-org/my-project/tree/feat/login diff
uxd git@github.com:my-org/my-project.git 42 code # ref given separatelyuxd [global flags] <project|url> <ref> [verb] [args] [-- passthrough] # workspace commands
uxd [global flags] <project> <project-verb> [args] # project commands
uxd [global flags] <top-level-verb> [args] # global commands
- Everything after the first bare
--is passed verbatim to the underlying command (run,exec,diff). - Omit the verb and
uxduses the project'sdefault_command(config;codeby default):uxd my-project 42≡uxd my-project 42 code. - A bare
uxd <project>lists the project's workspaces — or opens an interactive picker on a TTY. - If the verb slot names one of your
[commands.*]entries, it is sugar forrun <name>:uxd my-project 42 dev≡uxd my-project 42 run dev.
Require a project and a ref.
| Verb | Purpose |
|---|---|
checkout |
Materialize only; print the worktree path |
code |
Materialize; open the workspace in your editor |
run <name> |
Materialize + setup; run a configured command |
exec -- <argv…> |
Materialize + setup; run an arbitrary command |
shell |
Materialize + setup; open an interactive shell in the workspace |
sync |
Re-fetch and reset the workspace to its ref |
diff |
Show the workspace's diff vs. its base |
info |
Print one workspace's details |
rm |
Remove one workspace |
checkout — the scriptable primitive. Prints the absolute worktree path (one line, nothing else) so it composes:
cd "$(uxd my-project 42 checkout)"
uxd my-project 42 checkout --json # {"project":"my-project","slug":"pr-42",...}
uxd my-project 42 checkout --fetch # force a re-fetch before materializing
uxd my-project 42 checkout --setup # also run [setup] (off by default here)code — opens the workspace, materializing it first if needed:
uxd my-project 42 code
uxd my-project 42 code --editor vim # override the configured editor for this runrun <name> — materializes, runs [setup], then the named command in the worktree. Passthrough args are appended to the command; --env/--port override the environment for this invocation only:
uxd my-project 42 run dev
uxd my-project 42 run dev --port 6000
uxd my-project 42 run test -- --watch # args after -- go to the command
uxd my-project 42 run dev --env DEBUG=1 --no-setupFlags: --no-setup (skip [setup]), --reseed (re-copy seed files), --fetch (re-fetch first), --port <n>, --env K=V (repeatable).
exec -- <argv…> — like run, but for an ad-hoc command spawned directly (no shell):
uxd my-project 42 exec -- pnpm test
uxd my-project 42 exec --no-setup -- git log --oneline -5shell — an interactive shell in the workspace, with the full uxd environment ($UXD_PORT, $UXD_PATH, …) exported:
uxd my-project 42 shell
uxd my-project - shell # the last-used refsync — re-fetch the ref and hard-reset the worktree to it. Choose how to treat local changes:
uxd my-project 42 sync # refuses if the tree is dirty
uxd my-project 42 sync --stash # stash changes, then reset
uxd my-project 42 sync --discard # discard changes, then reset
uxd my-project 42 sync --fresh # remove and re-materialize from scratch--stash, --discard, and --fresh are mutually exclusive.
diff — for a PR workspace with gh available, renders gh pr diff against the real PR base; otherwise git diff <merge-base>...HEAD in the worktree:
uxd my-project 42 diff
uxd my-project 42 diff --stat
uxd my-project 42 diff --tool # use git difftool (always local)
uxd my-project 42 diff -- --color-words # extra args go to gh/gitinfo / rm:
uxd my-project 42 info
uxd my-project 42 info --du # include on-disk size
uxd my-project 42 info --json
uxd my-project 42 rm # remove one workspace (prompts unless --yes)
uxd my-project 42 rm --force --yes # remove even if dirty, no promptRequire a project, no ref.
list — the project's workspaces. For PR workspaces, STATUS shows the PR state plus a CI mark (✓ pass / ✗ fail), refreshed from gh stale-while-revalidate (bounded to ~2s; the table always renders):
uxd my-project list
uxd my-project list --du # add a disk-usage column
uxd my-project list --json # deterministic, offline (no gh refresh)SLUG KIND BRANCH STATUS PORT AGE LAST USED
pr-42 pr feat/login open ✓ 5700 5m just now
feat-ui branch feat/ui dirty 5701 2h 1h
clean — remove workspaces by explicit slug or by filter (explicit slugs and filters cannot be combined):
uxd my-project clean pr-42 # explicit slug(s)
uxd my-project clean --all
uxd my-project clean --merged # branch merged into the default branch (git-only)
uxd my-project clean --closed # PR closed or merged (prefers gh, falls back to git)
uxd my-project clean --older-than 7d # idle ≥ 7d (s/m/h/d/w units)
uxd my-project clean --prune-state # drop state entries whose worktree is gone
uxd my-project clean --merged --yes # skip the confirmation promptFilters intersect. Dirty worktrees are skipped unless --force; adopted workspaces are excluded unless --include-adopted. clean prints a plan and asks for confirmation (pass --yes for non-interactive use).
No project argument.
uxd setup # create the config dir and scaffold your first project
uxd projects # list configured projects
uxd doctor # diagnose environment & configs
uxd config path # print the config dir
uxd config edit [project] # edit defaults or a project file
uxd config add [project] # like `config edit`; seeds a starter template if the file is absent
uxd config validate [project] # validate all configs, or one
uxd completions <bash|zsh|fish> # print a completion script
uxd help # usage
uxd version # version stringsetup is the first-run onboarding path: it creates the config dir if missing, ensures a defaults.root so derived paths resolve, and scaffolds your first project file — reading the existing project schema, inventing nothing. It's the only command exempt from the config-dir existence check, since it's what creates the dir.
uxd setup [--name <name>] [--repo <url>] [--root <dir>] [--default-branch <branch>] [--force]
On a terminal it prompts for each field in order; every prompt is also a flag, so the same command runs non-interactively in CI. It asks only for what it needs — --root is skipped once defaults.root is set.
| Prompt | Flag | Written to | Notes |
|---|---|---|---|
Base directory for repos & worktrees [~/dev/uxd] |
--root |
defaults.toml → root |
Asked only when defaults.root is unset; makes derived paths resolve |
| Project name | --name |
filename <name>.toml |
Must match ^[a-z0-9][a-z0-9._-]*$; not defaults/seeds |
Repository URL (git@… or https://…) |
--repo |
<name>.toml → repo |
Required |
| Default branch (blank = auto-detect) | --default-branch |
<name>.toml → default_branch |
Omitted from the file when blank |
repo_path / worktrees_path are left implicit — derived from root (see Configuration). The freshly written file is validated with the normal loader before success is reported, so a bad combination fails here rather than on your next command.
Guards:
- Invalid or reserved name, or an empty repo →
E_USAGE, nothing written. - Project file already exists →
E_CONFIGunless you pass--force(which overwrites only that project file). - A required value missing on a non-TTY with no flag →
E_USAGEnaming the flag to pass. --dry-runprints the planned writes and changes nothing.
Headless / CI — pass every value as a flag (add -y/--yes to accept the --root default without a prompt):
uxd setup \
--config-dir ./ci-config \
--name acme-web \
--repo git@github.com:acme/acme-web.git \
--default-branch main \
--yes
# → created config dir ./ci-config
# → set defaults.root = ~/dev/uxd
# → scaffolded acme-web.toml
# → next: uxd acme-web maindoctor checks git version, gh presence/auth, config-dir readability, state-dir writability, stale locks, and — per project — schema validity, the bare repo and its fetch refspec, editor binary on PATH, commands that shadow built-in verbs, and worktree/state drift. It also warns when core.hooksPath is set (see Worktrees & hooks). Exits non-zero if any check fails.
completions bakes in the verb vocabulary and your current project names; workspace slugs are completed live via uxd <project> list --json. Install for the session:
# bash
eval "$(uxd completions bash)"
# zsh
eval "$(uxd completions zsh)"
# fish
uxd completions fish | sourceOn a TTY, a bare uxd <project> lists the workspaces and prompts you to pick one; the chosen workspace's path is printed to stdout, so it composes with cd:
cd "$(uxd my-project)"Non-interactive (no TTY) or --json falls back to a plain list. An empty answer cancels.
| Flag | Effect |
|---|---|
--config-dir <dir> |
Override the config directory |
--pr / --branch / --path <v> |
Force how the ref is interpreted |
--dry-run |
Print what would run; change nothing |
--json |
Machine-readable stdout (checkout, list, info, projects, doctor) |
-q, --quiet |
Suppress step logs |
-v, --verbose |
Print external commands (also enables debug traces) |
-y, --yes |
Assume "yes" for prompts |
--no-color |
Disable ANSI color |
--dry-run is available on every mutating command. For run/exec/shell it prints the computed environment as export lines followed by the shell-quoted command, so you can see exactly what would execute:
uxd my-project 42 run dev --dry-run
uxd my-project 42 sync --discard --dry-run
uxd my-project 42 diff --dry-runuxd reads per-project TOML files from the config dir, resolved as:
--config-dir → $UXD_CONFIG_DIR → ~/.uxd
<config-dir>/
defaults.toml # optional global defaults
my-project.toml # one file per project; filename = project name
seeds/
my-project/ # seed file tree for project "my-project"
.env.local
Project names must match ^[a-z0-9][a-z0-9._-]*$. defaults and seeds are reserved names.
root = "~/dev/uxd" # base dir for derived repo_path / worktrees_path
editor = "zed" # fallback editor preset or template
default_command = "code" # fallback default verbAll keys optional. env and commands are not allowed here (keep per-project config self-contained).
# ── Repository ────────────────────────────────────────────────
repo = "git@github.com:my-org/my-project.git" # required. ssh or https.
repo_path = "~/dev/uxd/my-project/repo" # optional. default: {root}/{project}/repo
worktrees_path = "~/dev/uxd/my-project/trees" # optional. default: {root}/{project}/trees
default_branch = "main" # optional. default: auto-detect from origin HEAD
# ── Behavior ──────────────────────────────────────────────────
editor = "zed" # optional. preset name or template
default_command = "code" # optional. verb or configured command name
ports = 1 # optional. contiguous ports per workspace, 1–10
base_port = 5700 # optional. default: derived per-project
# ── Setup (runs after checkout, before run/exec/shell) ────────
[setup]
run = "pnpm install --frozen-lockfile" # optional. skipped entirely when absent
cache_key = ["pnpm-lock.yaml", "**/package.json"] # globs; setup re-runs when they change
seed_files = [".env.local"] # relative paths seeded into the worktree
seed_from = "~/dev/my-project" # optional extra seed source
# ── Environment for run/exec/shell/hooks (templated) ──────────
[env]
PORT = "{port}"
VITE_BASE_URL = "http://localhost:{port}"
# ── Named commands ────────────────────────────────────────────
[commands.dev]
run = "pnpm dev" # required. templated
cwd = "packages/app" # optional, relative to the worktree root
[commands.dev.env]
NODE_OPTIONS = "--max-old-space-size=8192"
[commands.test]
run = "pnpm test"
# ── Hooks ─────────────────────────────────────────────────────
[hooks]
post_checkout = "echo 'workspace ready: {path}'"
# pre_run, post_sync, pre_clean are the other three hook namesPath values support leading ~ expansion only. Validation is aggregate — config validate reports every schema error in a file at once.
Interpolated (single pass, {name} syntax; {{ escapes a literal {) in [env] values, commands.*.run, commands.*.cwd, hooks.*, and editor templates:
| Variable | Value |
|---|---|
{path} |
Absolute worktree path |
{repo_path} |
Absolute primary-repo path |
{data_dir} |
Per-workspace data dir |
{project} |
Project name |
{ref} |
Original ref string as typed |
{branch} |
Resolved branch name (or short SHA when detached) |
{slug} |
Workspace slug |
{port} |
First allocated port |
{port+N} |
port + N, valid for N < ports (e.g. {port+1}) |
The same values are always exported to child processes as UXD_PATH, UXD_REPO_PATH, UXD_DATA_DIR, UXD_PROJECT, UXD_REF, UXD_BRANCH, UXD_SLUG, UXD_PORT, and UXD_PORT_1…UXD_PORT_{ports-1}, whether or not your config references them.
editor accepts a preset name — zed, code, cursor, windsurf, idea, webstorm, phpstorm, goland, vim, nvim, helix, hx, or terminal — or a custom template containing {path}. Append :wait to a template to keep uxd in the foreground until the editor exits:
editor = "emacsclient -n {path}"
editor = "kak {path}:wait"When gh is installed and authenticated, uxd enriches — but never depends on — the core git flow:
listandinfoshow PR state and CI status, refreshed stale-while-revalidate and bounded so the command never blocks.diffon a PR workspace rendersgh pr diffagainst the true PR base.clean --closedusesgh's authoritative PR state, falling back to cached state, then to the git-only merged heuristic.- Fork PRs get push-back wiring so your local commits can be pushed to the contributor's branch.
Without gh, all of the above degrade gracefully: PR checkout of refs/pull/<n>/head still works against GitHub and GitHub Enterprise, and diff/clean fall back to git. doctor reports gh's status.
uxd owns one bare, partial clone per project and creates one git worktree per ref. A few consequences are worth knowing:
.gitin a worktree is a file, not a directory — it points back at the primary repo. Tooling that assumes a.gitdirectory (some Docker volume setups, older scripts) can trip on this. Nothing for uxd to fix; just be aware.core.hooksPath/ husky. Repos that configure relative hook paths (as husky does) can resolve them incorrectly from a worktree, andpnpm installmay try to install hooks into the bare repo.doctorwarns whencore.hooksPathis set — it does not modify your repo.- Partial clones fetch lazily. The first checkout of a worktree, and a
diffacross not-yet-fetched trees, need network access. Offline use is degraded by design. - A branch already checked out in another worktree cannot be checked out again;
uxdpoints you at the existing workspace. - Manually
rm -rf-ing a worktree leaves git metadata behind.cleanrunsgit worktree prune, anddoctorflags the drift — reconcile withuxd <project> clean --prune-state.
| Code | Name | Meaning |
|---|---|---|
| 0 | success | — |
| 1 | internal | unexpected error |
| 2 | usage | bad flags or arguments |
| 3 | config | invalid config or unknown project |
| 4 | resolve | ref could not be resolved |
| 5 | git | git (or gh) plumbing failure |
| 6 | setup | [setup] command failed |
Errors print as error(E_CODE): message on stderr, with a hint: line when one applies.
npm install
npm test # unit + integration (vitest)
npm run typecheck # tsc --noEmit
npm run build # emit dist/
node dist/bin/uxd.js help # run the built CLIDESIGN.md is the source of truth for behavior and scope. See docs/adrs/ for architecture decision records.
Every change that reaches the published package needs a changeset — run pnpm changeset and commit the generated file with your PR. Releases are published to npm by .github/workflows/changesets.yml. See .changeset/README.md for the author workflow and docs/release-runbook.md for how a release runs and how to roll one back.