Metadata-Version: 2.5
Name: uv-stack
Version: 0.8.0
Summary: Formalized uv + micromamba Python environment management
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Operating System :: MacOS :: MacOS X
Classifier: Operating System :: POSIX
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Build Tools
Requires-Python: >=3.12
Requires-Dist: pydantic>=2
Requires-Dist: pyyaml>=6
Requires-Dist: rich-click>=1.7
Description-Content-Type: text/markdown

# uv-stack

Reusable package sets for shared Python environments and uv projects — powered by
[micromamba](https://mamba.readthedocs.io/en/latest/user_guide/micromamba.html)
and [uv](https://docs.astral.sh/uv/).

You *define* what you want in a few small files — *profiles* (named package
groups) and *bundles* (groups of profiles). Then you *install* those
definitions somewhere: a shared environment you activate (one `stack.txt` per
environment), or a uv project that carries its own `.venv`. The `stack`
command renders, locks, and installs everything else, splitting the work
between two tools:

| Tool | Owns |
| --- | --- |
| `micromamba` | The Python interpreter and conda-level binary packages (from `conda-forge`) |
| `uv` | Every pip-level package — compiled to a lock file, then synced exactly |

`micromamba` builds the room (the interpreter and any conda-level binaries);
`uv` furnishes it (fast, fully locked pip packages). The same profiles and
bundles feed both destinations. The box on the left is what you author —
inside `[tool.uv-stack]` that means the `stack` key, since the rest of that
table is uv-stack's own bookkeeping. `requirements.in` and `environment.yml`
are generated and carry a `# Generated by uv-stack — do not edit.` header,
and `uv` compiles `requirements.lock.txt` from `requirements.in`. On the
project path `stack` writes the resolved packages into your own
`pyproject.toml` and records what it applied back into `[tool.uv-stack]`,
leaving the lock and `.venv` to `uv`.

Why you might want this:

- **Define packages once, reuse everywhere.** The same profile feeds your
  shared notebook environment and your uv projects.
- **Shared environments are rebuildable.** A shared environment is a pure
  function of its source files. Delete it, `stack create env NAME`, and it
  comes back.
- **Real locks.** Every shared environment gets a fully pinned
  `requirements.lock.txt`, compiled and installed with `uv` (fast), and
  verified with `uv pip check`.

---

- [Installation](#installation)
- [Quickstart](#quickstart)
- [What you define](#what-you-define)
- [Where it goes](#where-it-goes)
- [Everyday commands](#everyday-commands)
- [Configuration reference](#configuration-reference)
- [Tips and gotchas](#tips-and-gotchas)

## Installation

Prerequisites (both must be installed and reachable):

- [`uv`](https://docs.astral.sh/uv/getting-started/installation/)
- [`micromamba`](https://mamba.readthedocs.io/en/latest/installation/micromamba-installation.html) —
  run `micromamba shell init` after installing so `$MAMBA_EXE` is set

Then install the CLI as a standalone tool (requires Python 3.12+):

```bash
uv tool install uv-stack
```

(`pip install uv-stack` works too.) This gives you one command: `stack`.

## Quickstart

Five minutes from nothing to a working, locked environment.

```bash
# One guided command sets up everything with prompts — a config tree, a
# commented 'starter' profile, and a 'main' environment (--yes accepts defaults):
stack init

# Prefer explicit steps? An equivalent setup by hand, with your own profile:
stack config init
stack create profile ds numpy pandas --description "Core data-science stack" --tag data
stack create env main ds --python 3.12

# Use it (stack prints this hint after every successful create env):
micromamba activate main
# ...or run things in it without activating:
micromamba run -n main python
```

What just happened: `stack` resolved the `ds` token to your profile, generated
`requirements.in` and `environment.yml` inside `envs/main/`, had `micromamba`
create the `main` environment (Python 3.12 by default), compiled a pinned
`requirements.lock.txt` with `uv pip compile`, installed it exactly with
`uv pip sync`, and verified consistency with `uv pip check`.

From now on, changing what's installed is the same two steps: edit a source
file (a profile, a bundle, or stack.txt), then run stack upgrade main. For a
freshly cloned config root, use stack sync to build the environments first.
Check what needs rebuilding at any time with stack status.

## What you define

### Profiles

A profile is a named list of pip packages, stored as
`profiles/<name>.yaml` in the config root:

```yaml
description: Core data-science stack   # optional
tags: [data, core]                     # optional, used by `stack list --tag`
includes:                              # required: literal pip requirements
  - numpy>=2
  - pandas
```

`includes` entries may reference a variable the config root declares, written
`${NAME}`. This is what lets one set of profiles describe editable checkouts
that live in different places on different machines:

```yaml
includes:
  - numpy>=2
  - -e ${DEV}/mytool
```

Three rules govern where a reference may appear:

1. **Every entry is a single line.** One requirement per line, always.
2. **The name must be declared** in `variables.txt`. A name the environment
   happens to export but the root does not declare is an error, because the
   declaration is what makes the reference part of the root's portable
   contract.
3. **A reference may appear only** in an editable (`-e ${DEV}/pkg`), in another
   option line (`--find-links ${WHEELS}`), or in an entry whose first token is
   a path (`${DEV}/pkg`) — and **never** in a `-r`/`--requirement` or
   `-c`/`--constraint` include.

Rule 3 is not stylistic. A reference in the distribution-name position
(`${PKG}>=2`) would make the same entry mean different packages on different
machines, which corrupts the dependency ownership a tracked project records in
its `pyproject.toml`. A reference inside an included file would pull in
dependencies the config root cannot see. `stack doctor` reports every entry
that breaks one of these rules, naming the file, the entry, and the rule.

### Bundles

A bundle is a recipe that composes profiles, other bundles, and literal
packages, stored as `bundles/<name>.yaml`. Same schema, but `includes` entries
are *stack tokens*:

```yaml
description: Everything for daily work
includes:
  - ds          # a profile
  - chem        # another profile
  - utils
```

Bundles can nest other bundles; duplicates are removed automatically.

### Stack tokens

Tokens are how you reference things in `stack.txt`, bundle `includes`, and on
the `stack create project` / `stack resolve` command line:

| Token | Meaning |
| --- | --- |
| `ds` (bare name) | Profile `ds` if it exists, else bundle `ds`, else a literal package |
| `profile:ds` | Profile `ds` (error if missing) |
| `@standard` or `bundle:standard` | Bundle `standard` (error if missing) |
| `pkg:numpy` or `package:numpy` | Always a literal package — the escape hatch when a name collides with a profile or bundle |
| `numpy>=2`, `-e ~/src/mytool`, archive paths | Literal pip requirement, passed through |

Use `stack resolve` to see how tokens are classified, and
`stack resolve --full` to expand them into a flat package list.

## Where it goes

A definition is inert until you install it somewhere, and there are two
destinations. The choice is *not* "an environment or a project" — either way
you end up with an interpreter and a set of installed packages, and a project
carries its own `.venv` for exactly that. The real difference is where the
packages live: in one central, named place you activate from anywhere, or
inside a single directory alongside the code that uses them.

| | Shared environment | Project |
| --- | --- | --- |
| Create with | `stack create env NAME TOKENS...` | `stack create project TOKENS...` |
| Lives in | `envs/<name>/` in your config root | the current directory — `pyproject.toml`, `uv.lock`, `.venv/` |
| Enter it with | `micromamba activate <name>` | `uv run ...` |
| Update with | `stack upgrade <name>` | `stack refresh` |
| Inspect with | `stack show env <name>`, `stack status` | `stack show project` |
| Remove with | `stack delete env <name>` | `stack delete project` |
| Reach for it when | notebooks, ad-hoc work, tooling spanning many repos | one codebase, dependencies versioned and shipped with the code |

Shared environments are registered centrally under the config root, so
`stack list env` and `stack status` can enumerate them all. A project is
identified by the `pyproject.toml` in the directory you are standing in: there
is no registry to enumerate, and **no parent-directory search** — run
`stack refresh` and `stack show project` from the project root. That is also
why there is deliberately no `stack list project`.

### Shared environments

A named *shared environment* lives in `envs/<name>/` under the config root. It
exists as soon as `stack.txt` does.

Files **you** write:

| File | Purpose |
| --- | --- |
| `stack.txt` | **Required.** One stack token per line; `#` comments allowed |
| `python.txt` | Interpreter version (defaults to `3.12`); applied at create and `--recreate` only |
| `micromamba.txt` | Extra conda packages (e.g. `graphviz`) |
| `channels.txt` | Extra conda channels (`conda-forge` is always first) |
| `requirements.local.in` | Machine-local pip additions, kept out of profiles |

Files `stack` generates (never edit these):

| File | Purpose |
| --- | --- |
| `environment.yml` | micromamba spec: `python=<version>`, `pip`, conda packages |
| `requirements.in` | Expanded, unpinned pip requirements |
| `requirements.lock.txt` | Fully pinned lock, produced by `uv pip compile` |

### Projects

For per-project work, `stack` can seed a standard `uv` project from the same
profiles and bundles:

```bash
mkdir myproject && cd myproject
stack create project @standard pkg:httpx
```

This resolves the tokens to a flat package list, then runs `uv init --bare`,
`uv add`, and `uv sync` — leaving you a normal `uv` project with a
`pyproject.toml`, `uv.lock`, and `.venv/`.

Choosing the project's interpreter with `--python`:

- A version (`3.12`), a path, or a spec like `cpython@3.12` is passed straight
  to `uv`.
- **Anything else is treated as a micromamba environment name** — the project
  uses that environment's interpreter. `stack create project ds --python main`
  builds the project on `main`'s Python.
- A botched version — `3.12.*`, `3.12.x` — is not a version either, so it lands
  in the case above. It is still refused, but the message names the typo rather
  than telling you to create an environment called `3.12.x`. `stack edit
  project` warns about one in the tracking table when the editor exits. (Note
  that `3.12.*` *is* legal in an environment's `python.txt`, which conda reads,
  not uv.)
- With no `--python`, the default comes from `$UV_STACK_PROJECT_PYTHON`, then
  `<config-root>/project-python.txt`, then `3.12`.

New projects are **tracked** by default: `stack create project` records
your tokens in a `[tool.uv-stack]` table inside `pyproject.toml`
(`--no-track` opts out for one-shot scaffolds). When your profiles or
bundles change later, re-resolve the project in place:

```bash
stack refresh              # apply profile/bundle changes to this project
stack refresh --dry-run    # see the add/remove delta first
```

To add tokens as well, use `stack sync project TOKENS...` (see Upgrading); it
extends the recorded stack rather than replacing it.

Refresh removes only packages recorded in the table's `applied` list — the
ones uv-stack itself added. Dependencies whose names uv-stack never applied
are never touched. One caveat: ownership is by package name, so if you
re-pin a stack-applied package yourself (say `uv add 'numpy<2'` after a
profile applied `numpy`), uv-stack still owns that name and a later refresh
may rewrite or remove it. After an interrupted run, recovery adopts leftover `pending` names that are still installed but no longer in the stack — it warns first and the next successful `stack refresh` removes them (the warning tells you how to keep one). Day to day, the project is still a normal `uv`
project — `uv add`, `uv sync`, and `uv run` all work as usual.

## Everyday commands

| Command | What it does |
| --- | --- |
| `stack init` | Guided first-run setup (config tree, starter profile, first env) |
| `stack create env NAME [TOKENS]...` | Scaffold (optional) and build a shared environment (`--recreate` wipes and rebuilds it: the lock is compiled first, and the conda layer is destroyed only if that succeeds) |
| `stack create profile NAME PKG...` | Write a new profile YAML (`--description`, `--tag`) |
| `stack create bundle NAME TOKEN...` | Write a new bundle YAML (`--description`, `--tag`) |
| `stack delete env NAME` | Remove a shared environment: its micromamba environment, then `envs/NAME/` |
| `stack delete profile NAME` / `stack delete bundle NAME` | Delete the YAML; refused while something still refers to it (`--force` overrides) |
| `stack delete project` | Withdraw uv-stack from the project in this directory: `uv remove` what it applied, drop `[tool.uv-stack]` |
| `stack edit KIND [NAME]` | Open a profile, bundle, env source, project file, or `remotes.yaml` in your editor and validate it when the editor exits |
| `stack upgrade [NAMES]...` | Re-render, re-lock, and sync shared environments |
| `stack sync` | Create, build, and recompile every environment the root declares (no prompt, keeps existing pins) |
| `stack sync env NAME...` | The same, for only the named environments |
| `stack sync project TOKENS...` | Add tokens to the tracked project in this directory and re-resolve it (the recorded stack is extended, never replaced) |
| `stack refresh` | Re-resolve a tracked project against current profiles/bundles |
| `stack status [NAMES]...` | Shared-env build state: drift, lock freshness, existence |
| `stack diff SOURCE SOURCE` | Compare two environments — interpreter, micromamba packages, channels, and pins — here or across machines (`--json`, `--exit-code`) |
| `stack list env\|profile\|bundle` | Tables of what exists (`--tag` filters, `--json` for scripts) |
| `stack show env\|profile\|bundle [NAME]` | Details for one item (`NAME` defaults to `main` for envs) |
| `stack show project` | The tracked project in this directory: tokens, applied packages, pending state |
| `stack resolve [--full] TOKENS...` | Classify tokens, or expand them to a flat package list |
| `stack doctor [--fix]` | Detect problems; `--fix` applies the safe repairs |
| `stack completion bash\|zsh\|fish` | Print the shell-completion script |
| `stack config init` | Create missing config directories (bare primitive) |
| `stack config portable` | Write the managed `.gitignore` block so the config root can be committed |
| `stack config remote list` | Per-host settings for `stack sync remote`, with defaults filled in (`--json` for the stored values) |
| `stack config remote set HOST` | Set a host's `--stack` command, `--root`, or both in `remotes.yaml` |
| `stack config remote remove HOST [stack\|root]...` | Remove a host's entry, or only the named fields |

### Editing configuration

`stack edit` opens a config file in your editor and checks it the moment the
editor exits. If the file does not validate, the error is shown and the editor
is offered again, with your changes left in place. Nothing is reverted: if you
decline the re-offer, the file stays exactly as you left it and the command
exits non-zero. When stdin is not a terminal there is no re-offer at all — the
error is printed and the command exits 1. Quitting the editor with a non-zero
status (`:cq` in vi) aborts without validating at all.

```bash
stack edit profile ds              # profiles/ds.yaml
stack edit bundle standard         # bundles/standard.yaml
stack edit env                     # envs/main/stack.txt
stack edit env chem --file python  # envs/chem/python.txt
stack edit project                 # ./pyproject.toml
stack edit remotes                 # remotes.yaml
```

On success the command names the file it validated and how to apply the change:
`stack upgrade NAME` for an environment, `stack refresh` for a tracked project,
for a profile or bundle a reminder that it takes effect on the next upgrade or
refresh, and for `remotes.yaml` a reminder that the next `stack sync remote`
uses it. A `remotes.yaml` that does not exist when the editor exits (you quit
without saving, or deleted it) is reported as such and the command exits 0: no
file means no remotes.

`--file` selects which environment source to open and accepts `stack`
(default), `python`, `micromamba`, `channels`, and `local`
(`requirements.local.in`). It applies to `env` only. An optional source that
does not exist yet is opened anyway, so this is how you add a
`channels.txt`. `edit` never creates a profile, bundle, or environment — use
`stack create` for that.

Symlinked config files work, so a profile can live in a dotfiles repo and be
linked into the config root. When the file you name is itself a symlink, the
success line reports the real file it resolves to rather than the link, so you
can see where the write landed. A link pointing at nothing is refused instead
of followed.

The editor is chosen from the first of these that is set:

1. `--editor`
2. `$UV_STACK_EDITOR`
3. `<config-root>/editor.txt`
4. `$VISUAL`
5. `$EDITOR`

The value is a command line, so `code -w` and `emacsclient -nw` both work; a
value that is spelled as a path — absolute, `~`-prefixed, or containing a `/` —
and names an existing file is used as a single argument, so a path containing
spaces needs no quoting, and a leading `~` is expanded to your home directory.
The edited file is appended as the final argument. An empty
environment variable or `editor.txt` is skipped rather than treated as a
choice, so an exported-but-empty `$VISUAL` does not shadow `$EDITOR`. If none
of the five is set, `stack edit` says so and stops — it will not drop you into
an editor you did not choose.

`editor.txt` uses the same line grammar as the other config-root text files:
the first non-blank line wins, and everything from a `#` onward is a comment.
An editor command containing `#` would therefore be truncated, so set one of
the environment variables instead if you need one.

```bash
echo 'code -w' > ~/.config/python-envs/editor.txt
```

**Use your editor's blocking flag.** `stack edit` validates the file at the
moment the editor process exits. A GUI editor that hands the file to an
already-running instance and returns immediately — `code` without `-w`,
`subl` without `-w`, `gvim` without `-f` — exits before you have typed
anything, so validation runs against the file as it was and reports success
prematurely. This cannot be fixed from uv-stack's side; the fix is the flag:

```bash
stack edit profile ds --editor 'code -w'
```

### Deleting

`stack delete` is the inverse of `stack create`. Every form asks before it acts;
`-y` answers yes, and a declined or unanswered prompt exits 1 with nothing
touched.

```bash
stack delete profile ds            # profiles/ds.yaml
stack delete bundle standard       # bundles/standard.yaml
stack delete env chem              # the micromamba env, then envs/chem/
stack delete project               # withdraw from ./pyproject.toml
```

A profile or bundle is refused while an environment's `stack.txt` or another
bundle still refers to it, and the refusal lists each place. The reason is the
same one `stack import` enforces: a bare token that named a profile would
silently start meaning the pip package of that name on the next upgrade, and a
qualified one (`profile:ds`, `@standard`) would stop resolving. Edit the
references out first, or pass `--force` to delete anyway; a forced delete
prints one warning per reference it leaves behind. A source the check cannot
read counts as a possible reference and is refused the same way.

`stack delete env` removes only an environment uv-stack manages — one with a
`stack.txt` under the config root. The micromamba environment goes first, so if
`micromamba remove` fails the sources are still there and the command can be
run again. If no micromamba environment is built, that step is skipped, and
the prompt says so.

`stack delete project` does not delete your project. It runs `uv remove` for
the packages uv-stack applied — the table's `applied` list, the same ownership
rule `stack refresh` uses — and drops the `[tool.uv-stack]` table, leaving a
plain `uv` project with only the dependencies you added yourself.
`pyproject.toml`, `uv.lock`, and `.venv/` stay. The prompt lists what will be
removed. A final plain `uv sync` prunes the removed packages from `.venv/`
(`--no-sync` skips it); it is not pinned to the recorded interpreter, since
that choice is no longer uv-stack's once the table is gone.

### Upgrading

```bash
stack upgrade main                      # one environment
stack upgrade main scratch              # several
stack upgrade                           # every environment (lists them, asks first)
stack upgrade --all -y                  # every environment, no prompt
stack upgrade --no-upgrade main         # re-lock without floating pins upward
stack upgrade --upgrade-package numpy main   # upgrade only numpy
stack upgrade --dry-run main            # print the command plan
```

By default `upgrade` recompiles the lock with `--upgrade` (everything floats
to the newest allowed versions), then syncs the environment to match the lock
exactly — packages you removed from a profile are uninstalled. A batch keeps
going past a failing environment and ends with a `✓`/`✗` summary
(`--stop-on-error` aborts at the first failure).

Under `--stop-on-error` the summary gains a third marker: `–` for an
environment the batch never reached. Those appear only in that mode — without
it every requested environment is attempted, so nothing is ever skipped.

To bring up a whole config root instead of named environments — creating the
missing ones, without a prompt — use `stack sync`:

```bash
stack sync                      # every environment the root declares
stack sync env main scratch     # only these
stack sync --upgrade            # force new pins (the default preserves them)
```

To add tokens to a tracked project and bring it up to date in one step, use
`stack sync project` from the project root:

```bash
stack sync project @qsar             # stack ["standard"] becomes ["standard", "@qsar"]
stack sync project @qsar --dry-run   # show the new stack and the delta first
```

It **adds** the tokens to the stack the project already records; it never
replaces that stack, so an existing token cannot be lost by forgetting to
repeat it. A token already recorded makes the run a plain re-resolve. The
tokens are ordinary stack tokens, and `--python`, `--strict`, `--no-sync`,
and `--dry-run` behave as they do for `stack refresh`, which remains the way
to re-resolve the recorded tokens without adding any.

> **Changed behavior.** `--no-upgrade` and `--upgrade-package` now genuinely
> preserve the pins the existing lock already holds. Previously both compiled
> into an empty file, so uv had no prior pins to keep and re-resolved
> everything. The first run after this change may therefore move pins that
> should have stayed put under the old behavior.

`stack upgrade` refuses a non-recreate upgrade when the running interpreter does
not match `python.txt` — use `stack create env NAME --recreate` to resolve
the mismatch first.

### Changing an environment's Python version

To upgrade or downgrade an existing environment's interpreter:

```bash
stack create env NAME --python 3.14 --recreate
```

`--recreate` requires `python.txt` to hold a plain dotted version such as `3.14`;
a conda match spec such as `3.12.*` is refused.

The command compiles the candidate lock before destroying the environment, so an
unsatisfiable resolve leaves the old environment intact. `micromamba.txt`,
`channels.txt`, and `python.txt` are applied at environment creation and
`--recreate` only; `stack upgrade` re-syncs the pip layer without rebuilding
the conda layer.

### Inspecting

```bash
$ stack resolve standard ds numpy
bundle:standard
profile:ds
package:numpy

$ stack resolve --full standard
numpy
pandas
rdkit
rich

$ stack list profile --tag data       # only profiles tagged 'data'
$ stack show env main                 # python, tokens, channels, resolved packages
```

### Comparing environments

```bash
stack diff dev prod                       # two environments here
stack diff dev ../laptop/envs/dev         # this machine vs another, all layers
stack diff dev ../from-laptop.lock.txt    # pins only, and it says so
stack diff dev prod --json
stack diff dev prod --exit-code           # exit 1 when they differ
```

Each source is an environment in this config root, the path to an
`envs/<name>/` directory from another machine, or a compiled
`requirements.lock.txt`. A directory is read where it sits, so keep it outside
this root's `envs/`, where it would become an environment of its own. An
environment or such a directory carries four layers — the interpreter from `python.txt`, the
micromamba packages, the effective channel order, and the compiled pins — and
`diff` compares all four. A bare lock file carries pins only, so against one
the other three are reported as not compared rather than as matching.

The interpreter, micromamba packages, and channels are declarations, not what
is installed: two machines that both declare `3.12` can run different patch
releases. So a match on those layers means the two environments are
configured the same way; only the pins, which are resolved, show that the
two resolved to the same Python distributions. `diff` reads what was
compiled, not what is installed.

The verdict is one of three values. `identical` means every layer matched;
`identical-where-comparable` means the pins matched but a bare lock hid the
other layers; `different` means at least one layer differs. `diff` exits 0 in
all three cases — a difference is a finding, not a failure — and exits 1 for
`different` only under `--exit-code`. A source that cannot be read exits 1
either way.

### Checking your setup

```bash
stack doctor
```

`doctor` never changes anything — it reports problems with a suggested `fix:`
line for each. It covers the shape of the tree (missing directories, legacy
file formats, environments in the wrong place, a name published as both a
profile and a bundle) and its portability: declared
variables with no value on this machine, references that are undeclared,
malformed, or in an entry that may not hold one, editable checkouts that are
absent (given as a path or a local `file:` URL; a relative path is looked up
from the directory `stack doctor` runs in, where uv resolves it), a `project-python.txt` value that will not travel,
and a missing or stale managed `.gitignore` block in a root that sits inside a
git repository.

### Watching for drift

```bash
stack status
```

Shows one row per shared environment: whether the micromamba env exists, whether a
lock is present, and whether your sources changed since the last build
(`sources changed` means "run `stack upgrade`"). When the running interpreter
does not match `python.txt`, the state becomes `python changed` and the Python
column shows both the configured and actual versions (e.g. `3.12 (env 3.14.0)`).
`--json` makes every inspection command (`list`, `show`, `resolve`, `status`,
`diff`, `doctor`) script-friendly.

### Typo protection

A bare token that matches no profile or bundle becomes a literal PyPI
package. If it looks like a near-miss of one of your names, `stack` warns
(`did you mean 'standard'?`). Add `--strict` to `upgrade`, `create env`,
`create project`, `create bundle`, or `resolve` to turn any unqualified
fallthrough into an error, and use the `pkg:` prefix to say "yes, really a
package."

### Shell completion

```bash
# zsh — add to ~/.zshrc:
eval "$(stack completion zsh)"
```

Tab-completes commands, options, and your environment/profile/bundle names.

## Configuration reference

### Config root

All state lives under one directory, resolved in this order:

1. `--root PATH` (global flag)
2. `$UV_STACK_ROOT`
3. `$UV_ENV_ROOT` (the historical spelling, still accepted)
4. `~/.config/python-envs` (default)

```text
~/.config/python-envs/
├── variables.txt             # optional: declares the names profiles may reference
├── variables.local.txt       # optional: this machine's values — do not commit
├── .gitignore                # written by `stack config portable`
├── project-python.txt        # optional: default --python for `create project`
├── editor.txt                # optional: editor command for `stack edit`
├── profiles/
│   └── <name>.yaml
├── bundles/
│   └── <name>.yaml
├── .locks/                   # internal lock files (safe to leave alone)
└── envs/
    └── <name>/
        ├── stack.txt             # required (defines the env)
        ├── python.txt            # optional
        ├── micromamba.txt        # optional
        ├── channels.txt          # optional
        ├── requirements.local.in # optional
        ├── requirements.in       # generated
        ├── environment.yml       # generated
        └── requirements.lock.txt # generated
```

The root, or a directory above it, can be a symbolic link, for example to a
checkout kept elsewhere. If the link's target is missing, commands that write
refuse rather than create an empty tree in its place. Restore the target (clone
or mount it), or run `stack config init` to create it; `stack init` offers to
create it as well.

### Shell environment variables

| Variable | Purpose |
| --- | --- |
| `UV_STACK_ROOT` | Config root (overridden by `--root`) |
| `UV_ENV_ROOT` | The historical spelling of `UV_STACK_ROOT`; used only when `UV_STACK_ROOT` is unset or empty |
| `UV_STACK_PROJECT_PYTHON` | Default interpreter spec for `stack create project` |
| `UV_STACK_EDITOR` | Editor command for `stack edit`; beats `editor.txt`, `$VISUAL`, and `$EDITOR` |
| `VISUAL` | Standard fallback editor for `stack edit`, below `editor.txt` |
| `EDITOR` | Standard fallback editor for `stack edit`, consulted last |
| `MAMBA_EXE` | Path to the micromamba binary; set by `micromamba shell init` and preferred over `PATH` lookup |

## Portable config roots

A config root is a directory of text files, so it can live in git and be
cloned onto another machine. Two things make that work: a managed `.gitignore`
block that keeps generated files out of the repository, and variables that let
one profile describe paths that differ per machine.

### What travels

| Travels | Does not travel |
| --- | --- |
| `profiles/`, `bundles/`, `envs/*/stack.txt`, `python.txt`, `micromamba.txt`, `channels.txt` | `requirements.in`, `environment.yml`, `requirements.lock.txt` (all generated) |
| `variables.txt` — the declared names | `variables.local.txt` — this machine's values |
| `project-python.txt`, when it holds a portable selector | `envs/*/requirements.local.in` — one machine's extra packages |
| | `editor.txt` — this machine's editor |
| | `.locks/`, `.DS_Store` |

**Compiled locks deliberately do not travel.** A lock is resolved for one
platform and one interpreter, and uv-stack compiles per machine rather than
committing a lock that is only correct where it was produced. The consequence
is worth stating plainly: two machines converged from the same sources will have
compatible environments, not identical pins. `stack status` is a single-machine
command; `stack diff` reports the difference between two machines once one of
them has the other's environment directory to read (see below).

### Bring-up on a new machine

```bash
git clone <url> ~/.config/python-envs
stack doctor                                    # names the variables with no value
$EDITOR ~/.config/python-envs/variables.local.txt
stack sync                                      # builds every environment
```

Step two is what makes this work on a machine you have never set up: doctor
reads the declared list and reports what is missing before anything tries to
build.

To check the result against a machine that is already set up, bring that
machine's `envs/<name>/` directory over by any means, including its
`requirements.lock.txt` — the clone deliberately left the lock behind. Put it
anywhere outside this root's `envs/`, where it would become an environment of
its own, and compare:

```bash
stack diff main ../other/envs/main
```

### Making a root committable

```bash
stack config portable
```

This writes a managed block into `<root>/.gitignore`, delimited by markers:

```text
# BEGIN uv-stack — managed block, do not edit by hand.
...
# END uv-stack
```

Edit the file freely **outside** the markers — your lines are preserved.
Anything you write **inside** them is replaced the next time the command runs.

A `.gitignore` that is a *symlink* is refused rather than written or replaced:
git commits the link itself, so the rules it points at would never reach the
machine that cloned the root.

A `.gitignore` that carries a second **hard link** is refused too. A hard link
can be created without permission to read what it points at, so reading through
one could copy a file you can read — and whoever created the link cannot — into
a file you are about to commit. The same refusal catches an innocent case: a
hardlink snapshot backup such as `cp -al` or `rsnapshot` leaves every file it
covers with a second link, so a `.gitignore` under one must be de-linked (copy
it to a new path and move it back) before the command will refresh it.

An empty `profiles/`, `bundles/`, or `envs/` also gets a `.gitkeep`. Git tracks
files and not directories, so without one the directory would simply be absent
from the clone, and the bring-up above would report it as a missing directory.
Only a directory that already exists and holds nothing gets a placeholder —
creating a directory is `stack config init`'s job, not this command's.

The command prints the git commands to follow, and for a root that is already
a repository those begin with an untracking step: files that are now ignored
stay tracked until `git rm --cached` removes them from the index. That applies
to any root that is a repository, including one whose `.gitignore` this command
is creating for the first time. A root that is a *subdirectory* of a repository
— `python-envs/` inside a dotfiles repo — counts as one: every command is
addressed to the config root with `-C`, and git finds the enclosing repository
by walking up from there.

That sequence stages only the `.gitignore` it just wrote, so nothing else
sitting in the root is committed on your behalf; a root whose own files have
never been committed needs a `git add` of your own. Its `commit` records
everything already staged in the repository, this root or not, which is worth
a `git status` before you paste it.

uv-stack never invokes git. It writes the ignore file and prints the commands;
you run them.

### Variables

`variables.txt` declares the names, one per line, comments allowed:

```text
# Root of the development checkouts this root's editables point into.
DEV
WHEELS
```

`variables.local.txt` supplies this machine's values and is not committed:

```text
DEV=~/src
WHEELS=/opt/wheels
```

An exported environment variable of the same name wins over the file, which is
what makes a CI runner work without writing a file at all. Only declared names
are read from the environment.

### Which `project-python.txt` values travel

| Form | Example | Travels |
| --- | --- | --- |
| Python version | `3.12` | yes |
| uv implementation form | `cpython@3.12`, `pypy-3.10` | yes |
| An environment this root declares | `main` | yes |
| An environment it does not declare | `scratch` | no |
| An interpreter path | `/opt/envs/x/bin/python` | no |

A path is the one form that reaches uv unchanged and still fails to travel,
which is why `stack doctor` warns about it separately. The same check applies
to `--python` values recorded in a project's `[tool.uv-stack]` table: uv-stack
records what you gave it, and says so when the value will not travel.

### The limit on tracked projects

uv-stack keeps its own project ledger portable — `[tool.uv-stack]` records
requirement strings unexpanded, so a committed `pyproject.toml` never holds
another machine's home directory.

It cannot keep uv's half. `uv add` writes the resolved absolute path of a
**local source** into the project's own `[tool.uv.sources]` table, and uv
exposes no flag to keep that symbolic. So a project whose stack resolves to
ordinary named requirements is portable, and a project whose stack pulls in any
local source — an editable or a plain path — is machine-bound in that table.
Closing that gap would mean rewriting uv's output behind its back.

## Moving environments between machines

### stack export

`stack export [ITEMS...]` writes the named environments, profiles, and bundles
as a single JSON document. Each ITEM is `env:NAME`, `profile:NAME`,
`bundle:NAME`, `@NAME`, or a bare NAME that names exactly one kind. With no
ITEMS, the whole config root is exported.

Everything the named items reach is included: all referenced profiles and
bundles, plus each environment's lock file as a seed. The document goes to
standard output by default; pass `-o FILE` to write it to a file instead.

When standard output is the destination, only the document is written there.
Warnings (such as the absolute-path notice below) go to standard error so they
do not corrupt the JSON stream.

**Absolute paths are not portable.** If a profile or environment includes an
editable install (`-e /src/foo`), a local path (`/wheels/bar.whl`), or a
`file://` URL, the export warns once per file: those paths must exist at the
same locations on the target machine for its environments to build. The import
writes the definitions either way. Its pre-flight refuses a missing editable
checkout, given as a path or a local `file:` URL, before anything is written
(unless `--no-build`), and uv reports any other missing path when the build
runs. Use `${NAME}` variable references to
make paths portable across machines.

**References are matched by spelling.** On a filesystem that ignores letter
case, such as a default macOS install, a stack file's `foo` reaches
`profiles/Foo.yaml`, and the export ships it as `profiles/foo.yaml`, the key
the reference names. An export that would ship that file under both spellings
is refused and names both; spell the reference as the file is named.

**Pull from another machine.** You can export and import in one command by
piping over ssh:

```bash
ssh HOST stack export ITEMS | stack import -
```

### stack import

`stack import FILE|-` installs the environments, profiles, and bundles from a
document written by `stack export`. Pass the document's file path, or `-` to
read from standard input.

Each file in the document is installed as **new**, left alone if **identical**
to this machine's copy, **replaced** when `--overwrite` is given and the
content differs, or **removed** (for environment files only) when the source
environment no longer has it and `--overwrite` is given.

**Conflicts refuse the import.** When a file differs from this machine's copy
and `--overwrite` was not given, the import stops before writing anything,
prints a unified diff showing the difference, and lists the environments that
use the file. Fix the conflict manually or re-run with `--overwrite`.

**Meaning changes are always refused.** An unqualified token in a stack file or
bundle means whichever profile, bundle, or package it resolves to, and the
import refuses to change that meaning in either direction, even with
`--overwrite`:

- A token in a shipped stack file or bundle must mean here what it meant on the
  source machine. A shipped environment whose stack names `utils` as a package
  is refused when this machine has a `utils` profile.
- A token in one of this machine's stack files or bundles that the import does
  not replace must mean the same after the import. Importing `profile:utils` is
  refused when an existing environment's stack names `utils` as a package.

Fix it by qualifying the token so it keeps its meaning (`pkg:utils` for the
package, `profile:utils`, or `@utils` for a bundle); the refusal's hint names
the form to write. Qualify this machine's own file in place; for a shipped
file, qualify it on the source machine and export again.

**Options:**

- `--overwrite`: Replace files that differ and remove environment files that
  the source environment no longer has. Without this flag, differing files stop
  the import with a conflict error.
- `--dry-run`: Report what would change without writing anything. Unless
  `--no-build` is given, the pre-flight below still runs, which can probe
  micromamba, and each build the import would run is listed as
  `build: NAME (ACTION)`, where ACTION is `create`, `sync`, or `recreate`.
- `--strict`: Refuse unqualified names in stack files and bundles that fall
  through to package literals rather than referencing a profile or bundle.
- `--no-build`: Install the definitions without building environments.
- `--recreate`: Wipe and rebuild each imported environment from the shipped
  pins. Only works with plain dotted Python versions (e.g., `3.12`); refused
  for ranges or constraints. Cannot be combined with `--no-build`.

**Variables.** Variable names the imported definitions reference but this
machine does not declare are appended to `variables.txt`, names only, and
listed on a `declare in variables.txt:` line. The document does not carry
their values: set each in `variables.local.txt` as `NAME=value`, or export it
in the environment.

**Paths.** Writing the definitions checks none of the paths they name; the
build needs them. A relative editable path resolves against the directory
`stack` runs in, which is where uv resolves it, not against the config root.
Under `stack sync remote` that directory is the remote user's home.

**Pre-flight refusals.** Unless `--no-build` is given, the import checks each
imported environment before writing anything, and refuses when it references a
variable with no value here, installs an editable checkout (a path or a local
`file:` URL) that is missing here, already runs a Python outside the plain version its imported
`python.txt` names (without `--recreate`; 3.12 when the document ships no
`python.txt`), or has a `python.txt` that is not a plain version (with
`--recreate`). Each refusal's hint also offers `--no-build`. uv reports any
other missing path during the build.

**Build phase.** After writing the definitions, each imported environment is
built: created if absent, recreated if `--recreate` is given, or synced
otherwise. The environment's shipped lock seeds its compile, so those pins act
as preferences and uv re-resolves them for this machine. For each environment
that shipped a lock and built, uv-stack then prints a pin report:

```text
main: 41 pins kept, 1 changed, 0 dropped, 1 added
  changed: numpy 1.26.4 -> 2.0.2
  added: typing-extensions 4.12.2
```

`kept` counts the shipped lock's version and URL pins that the new lock holds
unchanged. `changed`, `dropped`, and `added` count requirements whose pin
differs, that only the shipped lock has, or that only the new lock has. An
editable or unnamed path requirement in both locks is in none of the four, so
kept, changed, and dropped need not add up to the number of requirements in the
shipped lock. An environment exported without a lock prints no report.

**Environments not rebuilt.** Environments on this machine that the document
does not ship are never rebuilt. When the import replaces a profile or bundle
they use, or ships a new profile that captures a bare token in an unchanged
bundle they reach, a line names them:

```text
Not rebuilt, but using changed definitions: work. Rebuild them with 'stack sync env work'.
```

The line prints whenever such environments exist: after a successful or failed
build, with `--no-build`, and with `--dry-run`. It can print even without
`--overwrite`, since adding a new profile replaces nothing.

**Build failures.** If a build fails, the definitions stay written, the
remaining environments still build, and the import exits 1 after naming only
the failed ones: `Re-run the failed build(s) once the cause is fixed: stack
sync env NAME`. That command syncs; it does not recreate. After a failed
`--recreate` build, re-run the same import with `--recreate` instead, which is
safe: the files already written are reported as identical, and each imported
environment is recreated again, including those that built.

### stack sync remote

`stack sync remote [ITEMS...] DEST` exports the named items (or the whole root
if no ITEMS are given) and imports them on a remote machine, running:

```bash
ssh DEST <stack> [--root ROOT] import - [FLAGS]
```

The document is sent to ssh's stdin; ssh's stdout and stderr stream to the local
terminal as they arrive; the local exit status is the remote's. Everything the
import does—pre-flight checks, building environments, pin reports—happens on the
remote and prints locally.

**Printed commands are for the remote.** The `stack sync env ...` commands
the import prints refer to the remote machine: the re-run command for
environments whose build failed, and the "Not rebuilt, but using changed
definitions" line naming the remote's other environments that use a changed
profile or bundle. The second line can appear even when every build
succeeds, and with `--no-build` or `--dry-run`. Run these commands on the
remote (for example over `ssh DEST`), not locally. The printed commands use
bare `stack` and the remote's default root; when the stack command or root
was customized, run them with the same command and root the import used—for
example, `ssh gpu-box 'PATH=$HOME/.local/bin:$PATH stack --root /data/python-envs sync env NAME'`
for the `remotes.yaml` entry below.

**Customizing the remote command.** `--remote-stack CMD` sets the stack command
to run on the remote. `--remote-root PATH` sets the remote's config root. Both
can be specified per-host in `remotes.yaml` (see below), with the command-line
flags taking precedence.

**The remote's PATH.** ssh runs the command in a non-interactive shell, whose
PATH often lacks `~/.local/bin` (where `uv tool install` and the micromamba
installer put their commands) or Homebrew's `/opt/homebrew/bin`, because the
startup files that add them are skipped or return early for such shells.
Finding `stack` is not enough: the import runs `uv` from PATH, and micromamba
from `$MAMBA_EXE` when that is set, otherwise from PATH. The stack command is
inserted into the remote command line as written, so a `PATH=` prefix in it
reaches stack and everything stack runs:

```yaml
gpu-box:
  stack: PATH=$HOME/.local/bin:$PATH stack
```

List every directory that holds stack, uv, or micromamba on that host, for
example `PATH=$HOME/.local/bin:/opt/homebrew/bin:$PATH stack`, or set
`MAMBA_EXE=/path/to/micromamba` in the same way. A prefix needs a remote login
shell that accepts `NAME=value command`, as sh, bash, and zsh do.

**Exit status hints:**

- Exit 255 is ssh's own failure: the connection failed, or it dropped during
  the import. The hint says that re-running the same command is safe: files
  already written are reported as identical and the rest are completed. If the
  dropped import is still running on the remote, a re-run waits a few seconds
  for its lock and then refuses.
- Exit 127 means the remote shell did not find the stack command. The hint
  suggests `uv tool install uv-stack` on the remote and, for an installed
  uv-stack that the ssh shell cannot find, a `remotes.yaml` entry for DEST
  whose `stack` is the PATH recipe above.
- Exit 2 with "No such command 'import'" in the stderr means the remote's
  uv-stack predates `import`; the hint says to run `uv tool upgrade uv-stack`
  there.

**Options forwarded to the remote:**

- `--overwrite`: Replace files that differ and remove environment files the
  source no longer has.
- `--no-build`: Install definitions without building environments.
- `--recreate`: Wipe and rebuild each environment from the shipped pins.
- `--dry-run`: Report what the remote would change without writing anything.
  Unless `--no-build` is given, this can still probe the remote's micromamba
  installation.
- `--strict`: Refuse unqualified names that fall through to package literals.

**Using ssh prompts.** Password and passphrase prompts still work—ssh reads them
from the terminal, not stdin.

**Without a remote uv-stack install.** For a remote that has uv but not
uv-stack, run it through `uvx`. The stack it starts runs `uv` too, so give the
command the same PATH prefix:

```yaml
gpu-box:
  stack: PATH=$HOME/.local/bin:$PATH uvx --from uv-stack stack
```

**Pull from another machine.** To pull items from a remote into the local
machine, reverse the direction by piping the remote export into a local import:

```bash
ssh HOST stack export ITEMS | stack import -
```

### remotes.yaml

A root-level file mapping host names to optional settings:

```yaml
gpu-box:
  stack: PATH=$HOME/.local/bin:$PATH stack
  root: /data/python-envs
```

Each entry allows only `stack` (the command to run on the remote) and `root`
(the remote's config root), both optional non-empty strings. Command-line flags
(`--remote-stack`, `--remote-root`) take precedence over the file's values.

- **DEST must match the key exactly as typed.** `stack sync remote gpu-box`
  uses the entry above; `stack sync remote user@gpu-box` does not.
- **`root` is passed literally.** It reaches the remote quoted, and only a
  leading `~` is expanded there. `$HOME/envs` is not expanded: it names a
  directory literally called `$HOME`, relative to the remote home. `stack`, by
  contrast, is inserted unquoted, so the remote shell expands `~` and `$HOME`
  in it.
- **Quote host names that YAML reads as something else.** A key such as `yes`,
  `no`, `null`, or `1` is a boolean, null, or number to YAML, and
  `stack sync remote` refuses the file until it is quoted (`"yes":`).
  `stack config remote set` quotes such names itself.
- **List each host once.** YAML would silently keep only the last of two
  entries for one host, so every command that reads the file refuses it and
  names both lines. `"gpu-box"` and `gpu-box` are the same host.
- **Write each setting once per entry.** YAML uses only the last of two
  `root:` lines in one entry, so commands that read the file warn and name
  both lines. `stack config remote set` and `remove` keep the used one when
  they rewrite the file.

Two commands change the file. `stack edit remotes` opens it in your editor and
validates it on exit (see [Editing configuration](#editing-configuration)).
`stack config remote set HOST --stack CMD --root PATH` merges the given fields
into HOST's entry, creating it if needed. `stack config remote remove HOST`
deletes the entry, and `remove HOST stack` or `remove HOST root` clears only
that field, leaving `HOST: {}` when none remain. `stack config remote list`
prints each entry with an unset field shown as its default. `set` and `remove`
rewrite the whole file, normalizing its quoting and indentation, so they refuse
a file that holds comments, which the rewrite would drop; edit a commented file
with `stack edit remotes` instead.

The file is portable across machines—the remote's paths are properties of the
remote, not of the machine that pushes. `stack doctor` reports an unreadable or
invalid `remotes.yaml` file.

## Tips and gotchas

- **Edit sources, not generated files.** `requirements.in`,
  `environment.yml`, and `requirements.lock.txt` are overwritten on every
  upgrade. For env-specific additions use `requirements.local.in`; for
  anything reusable, a profile.
- **`--dry-run` is almost dry.** For `stack upgrade`, `stack sync` and
  `stack sync env`, `--dry-run` runs no uv commands and never touches the lock
  or the environment, but it re-renders `requirements.in` and `environment.yml`
  and can still ask micromamba for the environment's Python version. `stack
  refresh --dry-run` and `stack sync project --dry-run` change nothing. `stack
  import --dry-run` and `stack sync remote --dry-run` write nothing, but can
  still run micromamba probes (on the remote, over ssh, for `stack sync
  remote`).
- **Import's letter-case checks assume matching filesystems.** `stack import`
  stages its view of the result in the system temporary directory, and its
  checks of whether two names that differ only in letter case or Unicode
  normalization are one file see that directory's filesystem. They are exact
  only when it folds names the same way as the config root's filesystem, as a
  default macOS install or a plain Linux system does. On a target that folds
  case, a shipped case variant of an existing definition replaces it, and the
  environments that used it are reported under the shipped name.
- **Your lock is safe from failed compiles.** The lock is compiled to a
  temporary file and atomically swapped in, so a failed `uv pip compile`
  never corrupts an existing `requirements.lock.txt`. If the later *sync*
  step fails (e.g. a network blip), just run `stack upgrade NAME` again.
- **Activation is micromamba's job.** `stack` builds shared environments; you enter
  them with `micromamba activate <name>` or run one-offs with
  `micromamba run -n <name> <command>`.
- **Name collisions.** A bare token prefers a profile over a bundle over a
  literal package. When a package name collides with one of your profile or
  bundle names, force the package with `pkg:<name>`. A name that is *both* a
  profile and a bundle still works — bare reaches the profile, `@<name>` the
  bundle — but `stack doctor` warns about it, because otherwise you only hear
  about it on the runs that happen to reference the name.
- **`doctor --fix` is conservative.** It only applies safe repairs (creating
  missing directories, renaming legacy files, converting `.in`/`.bundle`
  files to YAML with a `.bak` backup). Anything destructive stays a printed
  suggestion.
- **Migrating old configs.** If you previously used `.in` profiles,
  `.bundle` files, or a `profiles.txt`, `stack doctor` will point at each
  leftover and tell you what to rename or convert.
- **The `[tool.uv-stack]` table is tool-owned.** `stack refresh` rewrites
  it wholesale; comments inside that one table are not preserved. uv-stack's
  own writes leave every other byte of `pyproject.toml` alone — but refresh
  also runs `uv remove`/`uv add` under the hood, which edit
  `[project.dependencies]` just as they would if you ran them yourself.
  An interrupted refresh or tracked create may leave a `pending` key in the table; the next successful `stack refresh` (or re-running the tracked create with `--force`) cleans it up.
- **A value in `variables.local.txt` cannot contain `#`.** The file grammar
  strips comments, so everything from the first `#` is discarded. An exported
  environment variable has no such restriction.
- **A value holding `${...}` is refused, not passed through.** Substitution is
  single-pass, so a `${OTHER}` inside a value is never itself expanded — and an
  entry that still holds `${` afterwards is rejected rather than written out,
  because uv expands environment variables in a requirements file and would
  resolve the leftover reference behind uv-stack's back. `DEV=/a/${OTHER}/b`
  turns every entry that references `DEV` into an error: `stack doctor` reports
  it and `stack sync` refuses.
- **No value may contain whitespace.** That rules out a path containing a
  space, and it is what keeps expansion from splitting an already-validated
  entry into a new token. Use a symlink or move the checkout.
