Introduction
scuv is a centralized Python virtual environment manager powered by uv.
One scoop, endless envs — pyenv-style workflow with uv’s blazing speed.
What is scuv?
Think of it like running an ice cream parlor:
- The Freezer (
~/.scuv/) keeps all your flavors fresh - Flavors are your virtualenvs — mix once, serve anywhere
- One scoop is all you need to get the right env
| The Old Way | The scuv Way |
|---|---|
.venv scattered across projects | ~/.scuv/virtualenvs/ centralized |
Manual source .venv/bin/activate | Auto-activate on directory entry |
| pyenv-virtualenv is slow | uv-powered, 100x+ faster |
| Which Python? Which venv? Chaos. | scuv doctor checks everything |
Quick Example
# Install Python
scuv install 3.12
# Create a virtualenv
scuv create myproject 3.12
# Use it (auto-activates!)
scuv use myproject
(myproject) $ uv pip install -r requirements.txt
# Check what's available
scuv list
Features
- Fast — Powered by uv, virtualenv creation is nearly instant
- Centralized — All environments live in
~/.scuv/virtualenvs/ - Auto-activation — Enter a directory, environment activates automatically
- Shell integration — Works with bash, zsh, fish, and PowerShell
- IDE friendly —
scuv use --linkcreates.venvsymlink for IDE discovery - Health checks —
scuv doctordiagnoses your setup
Getting Started
Ready to scoop? Head to the Installation guide to get started.
Links
- GitHub Repository
- API Reference (docs.rs)
- Crates.io
- llms.txt — AI/LLM-friendly project reference
- llms-full.txt — Full API reference for AI tools
Installation
Prerequisites
| Dependency | Version | Install Command |
|---|---|---|
| uv | 0.5.19 or newer | curl -LsSf https://astral.sh/uv/install.sh | sh |
| Rust | 1.89+ | curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh |
Install via Cargo
cargo install scoop-uv
The binary is installed to ~/.cargo/bin/scuv.
Upgrade
To upgrade scuv to the latest version:
cargo install scoop-uv
This overwrites the existing binary in ~/.cargo/bin/scuv. Your virtual environments in ~/.scuv/ are preserved.
Verify the upgrade:
scuv --version
Upgrading from scoop (≤ 0.14.x)
The CLI command was renamed in v0.15.0 (scoop → scuv). One-time migration:
scoop self update # installs the new `scuv` binary
# (the "could not locate the freshly installed
# `scoop` binary" warning is expected)
rm -f ~/.cargo/bin/scoop # remove the old binary if cargo left one behind
mv ~/.scoop ~/.scuv # move your environments
Then update your shell rc file — replace eval "$(scoop init <shell>)" with
eval "$(scuv init <shell>)" (fish: scuv init fish | source), restart your
shell, and run scuv doctor to confirm nothing scoop-era is left over.
Since v0.16.0 the legacy SCOOP_* env vars and .scoop-version /
.scoop.toml files are no longer read, so rename them as part of the
migration. Don’t skip the rm step: a leftover old binary keeps running
0.14.x silently.
Verify Installation
scuv --version
# scuv 0.17.0
Troubleshooting
scuv: command not found
Ensure ~/.cargo/bin is in your PATH:
# Add to ~/.zshrc or ~/.bashrc
export PATH="$HOME/.cargo/bin:$PATH"
Then restart your terminal or run:
source ~/.zshrc # or ~/.bashrc
uv not found
scuv requires uv to be installed and available in PATH. Verify:
uv --version
If not installed, run:
curl -LsSf https://astral.sh/uv/install.sh | sh
Next Steps
After installation, set up Shell Integration to enable auto-activation and tab completion.
Quick Start
This guide walks you through the basic scuv workflow.
1. Set Up Shell Integration
Zsh (macOS default):
echo 'eval "$(scuv init zsh)"' >> ~/.zshrc
source ~/.zshrc
Bash:
echo 'eval "$(scuv init bash)"' >> ~/.bashrc
source ~/.bashrc
Fish:
echo 'scuv init fish | source' >> ~/.config/fish/config.fish
source ~/.config/fish/config.fish
PowerShell:
Add-Content -Path $PROFILE -Value 'Invoke-Expression (& scuv init powershell | Out-String)'
. $PROFILE
2. Install Python
# Install latest Python
scuv install 3.12
# Verify installation
scuv list --pythons
3. Create a Virtual Environment
scuv create myproject 3.12
This creates a virtual environment at ~/.scuv/virtualenvs/myproject/.
If Python 3.12 isn’t installed yet, add --install-python to install it on demand:
scuv create myproject 3.12 --install-python
4. Use the Environment
cd ~/projects/myproject
scuv use myproject
This:
- Creates
.scuv-versionfile in the current directory - Activates the environment (prompt shows
(myproject))
5. Work With Your Environment
(myproject) $ uv pip install -r requirements.txt
# If the file is in a different location:
(myproject) $ uv pip install -r path/to/requirements.txt
# Verify installed packages
(myproject) $ uv pip list
Environments that scuv create makes have no pip of their own, so a bare
pip would reach some other Python’s pip. uv pip installs into the active
environment (it reads VIRTUAL_ENV).
6. Auto-Activation
Once configured, entering a directory with .scuv-version automatically activates the environment:
cd ~/projects/myproject
# (myproject) appears in prompt automatically
Common Commands
| Task | Command |
|---|---|
| List environments | scuv list |
| List Python versions | scuv list --pythons |
| Show environment info | scuv info myproject |
| Remove environment | scuv remove myproject |
| Check installation | scuv doctor |
IDE Integration
Create a .venv symlink for IDE compatibility:
scuv use myproject --link
This creates .venv pointing to the scuv environment, recognized by VS Code, PyCharm, etc.
Next Steps
- See Shell Integration for advanced configuration
- See Commands for full command reference
Shell Integration
scuv uses a shell wrapper pattern (like pyenv) where the CLI outputs shell code that gets evaluated by the shell.
How It Works
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ User runs │ --> │ CLI outputs │ --> │ Shell evals │
│ scuv use │ │ export ... │ │ the output │
└─────────────┘ └─────────────┘ └─────────────┘
The scuv shell function wraps the CLI binary (simplified; the real one also
passes help flags through and respects an explicit --shell):
scuv() {
case "$1" in
use)
command scuv "$@" || return
local name=""
shift
for arg in "$@"; do
case "$arg" in
-*) ;;
*) name="$arg"; break ;;
esac
done
if [[ "$name" == [Ss][Yy][Ss][Tt][Ee][Mm] ]]; then
eval "$(command scuv deactivate --shell bash)"
elif [[ -n "$name" ]]; then
eval "$(command scuv activate --shell bash "$name")"
fi
;;
activate|deactivate|shell)
# Capture first, so a failed call keeps its exit status
local script
script="$(command scuv "$1" --shell bash "${@:2}")" || return
eval "$script"
;;
*)
command scuv "$@"
;;
esac
}
Setup
Zsh
echo 'eval "$(scuv init zsh)"' >> ~/.zshrc
source ~/.zshrc
Bash
echo 'eval "$(scuv init bash)"' >> ~/.bashrc
source ~/.bashrc
Fish
echo 'scuv init fish | source' >> ~/.config/fish/config.fish
source ~/.config/fish/config.fish
PowerShell
# Add to $PROFILE
Add-Content $PROFILE 'Invoke-Expression (& scuv init powershell | Out-String)'
# Restart PowerShell
Keep the | Out-String: scuv init powershell prints many lines, and
Invoke-Expression takes one string, not the array of lines & returns.
A profile line without it never defines the scuv function; if yours
lacks it, update the line.
Auto-Activation
When enabled, scuv automatically activates environments based on version files.
Zsh: Uses chpwd hook (runs on directory change)
autoload -Uz add-zsh-hook
add-zsh-hook chpwd _scuv_hook
Bash: Uses PROMPT_COMMAND
PROMPT_COMMAND="_scuv_hook;$PROMPT_COMMAND"
Fish: Uses --on-variable PWD event handler
function _scuv_hook --on-variable PWD
# Check for version file and activate/deactivate
end
The hook checks for version files and activates/deactivates accordingly.
Version Resolution Priority
scuv checks these sources in order (first match wins):
| Priority | Source | Set by |
|---|---|---|
| 1 | SCUV_VERSION env var | scuv shell |
| 2 | .scuv-version file | scuv use (walks parent directories) |
| 3 | ~/.scuv/version file | scuv use --global |
The “system” Value
When any source contains the value system, scuv deactivates the current virtual environment and uses the system Python.
scuv use system # Write "system" to .scuv-version
scuv shell system # Set SCUV_VERSION=system (this terminal only)
Environment Variables
| Variable | Description | Default |
|---|---|---|
SCUV_HOME | Base directory | ~/.scuv |
SCUV_VERSION | Override version (highest priority) | (unset) |
SCUV_NO_AUTO | Disable auto-activation | (unset) |
SCUV_ACTIVE | Currently active environment | (set by scuv) |
SCUV_RESOLVE_MAX_DEPTH | Limit parent directory traversal | (unlimited) |
Disable Auto-Activation
export SCUV_NO_AUTO=1
Temporary and Project-Scoped Control
Disable only in the current shell session (does not affect global settings):
export SCUV_NO_AUTO=1
# ...work without auto-activation...
unset SCUV_NO_AUTO
For one project directory, use local version files instead of global settings:
cd ~/project
# Keep auto-activation, but force system Python in this project only
scuv use system
# Or pin a specific environment for this project only
scuv use myproject
For temporary per-terminal overrides without changing files:
scuv shell system # this terminal only
# ...test...
scuv shell --unset # return to file-based behavior
Custom Home Directory
export SCUV_HOME=/custom/path
Network Filesystem Optimization
For slow network filesystems (NFS, SSHFS), limit directory traversal depth:
# Only check current directory and up to 3 parents
export SCUV_RESOLVE_MAX_DEPTH=3
# Only check current directory (fastest)
export SCUV_RESOLVE_MAX_DEPTH=0
Using with pyenv
Add scuv after pyenv in your shell config:
# ~/.zshrc
eval "$(pyenv init -)" # 1. pyenv first
eval "$(scuv init zsh)" # 2. scuv second (takes precedence)
Tab Completion
Shell integration includes completion for:
- Commands and subcommands
- Environment names
- Python versions
- Command options
Completion is automatically enabled by scuv init.
Supported Shells
| Shell | Status |
|---|---|
| Zsh | Full support (auto-activation, completion) |
| Bash | Full support (auto-activation, completion) |
| Fish | Full support (auto-activation, completion) |
| PowerShell | Full support (auto-activation, completion) |
Python Management
scuv delegates all Python installation and discovery to uv. This page explains how Python versions are found, installed, and used with scuv.
How Python Discovery Works
When you run scuv create myenv 3.12, scuv asks uv to create a virtual environment with Python 3.12. uv searches for a matching Python in this order:
- uv-managed installations in
~/.local/share/uv/python/(installed viascuv installoruv python install) - System Python on PATH — executables named
python,python3, orpython3.x - Platform-specific locations — Windows registry, Microsoft Store (Windows only)
Key behavior: For managed Pythons, uv prefers the newest matching version. For system Pythons, uv uses the first compatible version found on PATH.
Installing Python Versions
Via scuv (recommended)
# Install latest Python
scuv install
# Install specific minor version (latest patch)
scuv install 3.12
# Install exact version
scuv install 3.12.3
# List installed versions
scuv list --pythons
Behind the scenes
scuv install 3.12 runs uv python install 3.12 internally. uv downloads a standalone Python build from the python-build-standalone project and stores it in ~/.local/share/uv/python/.
Using System Python
scuv can use Python versions already installed on your system (via Homebrew, apt, the OS, etc.) — no scuv install needed.
# Check what Python versions uv can find on your system
uv python list
# Example output:
# cpython-3.13.1 /opt/homebrew/bin/python3.13 (system)
# cpython-3.12.8 ~/.local/share/uv/python/... (managed)
# cpython-3.12.0 /usr/bin/python3 (system)
# cpython-3.11.5 /usr/bin/python3.11 (system)
# Create environment using system Python 3.13
# (uv finds it automatically — no scuv install needed)
scuv create myenv 3.13
If the version you request matches a system Python, uv will use it. You only need scuv install if the version is not already available on your system.
Using Custom Python Installations
If you have a custom-built Python or an alternative interpreter (PyPy, GraalPy) in a non-standard location, you can point scuv directly to the executable.
When the required version is not in default sources
If scuv install <version> and normal uv discovery do not provide the interpreter you need,
integrate your own Python using one of these patterns:
- Direct path (recommended):
scuv create <env> --python-path /path/to/python - PATH-based discovery: add your Python to
PATH, then runscuv create <env> <version>
Use –python-path (recommended)
The simplest approach is to pass the Python executable path directly:
# Custom Python built from source
scuv create debug-env --python-path /opt/python-debug/bin/python3
# PyPy interpreter
scuv create pypy-env --python-path /opt/pypy/bin/pypy3
# GraalPy
scuv create graal-env --python-path /opt/graalpy/bin/graalpy
scuv validates the path, auto-detects the version, and stores the custom path in metadata.
# Verify what was integrated (shows the detected version)
scuv info debug-env
# Name: debug-env
# Python: 3.13.0
# Path: ~/.scuv/virtualenvs/debug-env
Metadata is stored in ~/.scuv/virtualenvs/<name>/.scoop-metadata.json (python_path field).
See create command for details.
Alternative: Add custom Python to PATH
You can also add the Python to your PATH so uv discovers it automatically:
# Example: custom Python built from source in /opt/python-debug/
export PATH="/opt/python-debug/bin:$PATH"
# Verify uv can find it
uv python list | grep python
# cpython-3.13.0 /opt/python-debug/bin/python3.13
# Now scuv can use it
scuv create debug-env 3.13
Use UV_PYTHON_INSTALL_DIR
For managed Python installations in a custom location:
# Store uv-managed Pythons in a custom directory
export UV_PYTHON_INSTALL_DIR=/opt/shared-pythons
# Install Python to the custom location
scuv install 3.12
# All team members can share the same Python installations
Python preference settings
Control whether uv prefers managed or system Python:
# Use only uv-managed Python (ignore system Python)
UV_PYTHON_PREFERENCE=only-managed scuv create myenv 3.12
# Use only system Python (ignore uv-managed)
UV_PYTHON_PREFERENCE=only-system scuv create myenv 3.12
# Prefer system Python over managed (default: managed first)
UV_PYTHON_PREFERENCE=system scuv create myenv 3.12
Migrating from Other Tools
If you have existing virtual environments in pyenv, conda, or virtualenvwrapper, scuv can migrate them:
# See what can be migrated
scuv migrate list
# Example output:
# • Scanning all sources for environments...
# ✓ Found 2 environment(s):
#
# [virtualenvwrapper]
# ✓ myproject Python 3.12 - MB
# ✓ webapp Python 3.11 - MB
# ...
# Migrate a specific environment
scuv migrate @env myproject
# Migrate everything at once
scuv migrate all
The migration process:
- Discovers environments from pyenv (
~/.pyenv/versions/), conda (conda info --envs), or virtualenvwrapper ($WORKON_HOME) - Creates a new scuv environment with the same Python version
- Reinstalls packages using uv for improved performance
- Preserves originals by default (use
--delete-sourceto remove source envs after success)
scuv migrate all runs migrations in parallel across CPU cores via rayon — typically 4-8× faster than sequential. Single-env (migrate @env) and --dry-run stay sequential for predictable output.
See migrate command for details.
Troubleshooting
Python version not found
$ scuv create myenv 3.14
# Error: Python 3.14 not found
# Solution 1: Install it via scuv
scuv install 3.14
# Solution 2: Check what's available
uv python list
scuv list --pythons
Invalid custom Python path
# Example custom path flow
scuv create myenv --python-path /opt/custom/python3
# Verify the binary exists and runs
/opt/custom/python3 --version
If the path is invalid or not executable, provide a valid Python binary path and retry.
Verify custom integration end-to-end
# 1) Confirm uv can see your interpreter (PATH-based flow)
uv python list
# 2) Confirm scuv recorded the interpreter path
scuv info myenv
# 3) Diagnose broken links or metadata issues
scuv doctor -v
Using a different Python than expected
# Check which Python uv would select for a version
uv python find 3.12
# /opt/homebrew/bin/python3.12
# Check all available 3.12 installations
uv python list | grep 3.12
# cpython-3.12.8 /opt/homebrew/bin/python3.12 (system)
# cpython-3.12.7 ~/.local/share/uv/python/... (managed)
Verify environment’s Python
# Check what Python an environment uses
scuv info myenv
# Name: myenv
# Python: 3.12.8
# Path: ~/.scuv/virtualenvs/myenv
Removing Python Versions
Quick: Cascade removal (recommended)
Use --cascade to automatically remove all environments using a Python version:
# Remove Python 3.12 and all environments using it
scuv uninstall 3.12 --cascade
# Skip confirmation prompt
scuv uninstall 3.12 --cascade --force
Preview affected environments
Before uninstalling, you can check which environments would be affected:
# Filter environments by Python version
scuv list --python-version 3.12
# myproject 3.12 ~/.scuv/virtualenvs/myproject
# webapp 3.12 ~/.scuv/virtualenvs/webapp
Manual workflow
If you prefer manual control (without --cascade):
# 1. Identify environments using the target Python version
scuv list --python-version 3.12
# myproject 3.12 ~/.scuv/virtualenvs/myproject
# webapp 3.12 ~/.scuv/virtualenvs/webapp
# 2. Remove or recreate affected environments
scuv remove myproject --force
scuv remove webapp --force
# Or recreate with a different version:
# scuv remove myproject --force && scuv create myproject 3.13
# 3. Uninstall the Python version
scuv uninstall 3.12
# 4. Verify everything is clean
scuv list --pythons # Confirm Python removed
scuv doctor # Check for broken environments
Recovery from accidental uninstall
If you uninstalled Python without cleaning up environments:
# Detect broken environments
scuv doctor -v
# ✗ broken virtualenv: 'myproject' is corrupted
# → scuv remove myproject && scuv create myproject <python-version>
# ✗ broken symlink: Python symlink in 'myproject' is broken
# → scuv remove myproject && scuv create myproject <python-version>
# Fix by reinstalling the Python version
scuv install 3.12
scuv doctor --fix
# Or remove the broken environments and start fresh
scuv remove myproject --force
See uninstall command and doctor command for details.
Summary
| Scenario | What to do |
|---|---|
| Standard Python version | scuv install 3.12 then scuv create myenv 3.12 |
| System Python (Homebrew, apt) | Just scuv create myenv 3.12 — uv finds it automatically |
| Custom Python executable | scuv create myenv --python-path /path/to/python |
| Custom Python in non-standard path | Add to PATH, then scuv create myenv <version> |
| PyPy or alternative interpreter | scuv create myenv --python-path /opt/pypy/bin/pypy3 |
| Existing pyenv/conda environments | scuv migrate all |
| Shared Python installations | Set UV_PYTHON_INSTALL_DIR |
| Force system-only Python | Set UV_PYTHON_PREFERENCE=only-system |
| Uninstall Python + cleanup envs | scuv uninstall 3.12 --cascade (or manual workflow) |
| Find envs using a Python version | scuv list --python-version 3.12 |
| Fix broken environments | scuv doctor --fix (after reinstalling the Python version) |
Frequently Asked Questions
What’s the difference between scuv and pyenv?
While both tools help you manage Python, they focus on different parts of the workflow:
pyenv is primarily a version manager. It focuses on:
- Installing multiple versions of the Python interpreter (e.g., 3.9.0, 3.12.1)
- Switching between them globally or per folder
scuv is an environment and workflow manager powered by uv. It focuses on:
- Creating and managing isolated virtual environments
- Fast project-specific environment workflows
Summary: You might use pyenv to install Python 3.11 on your machine, but you use scuv to actually build and run your application within a lightning-fast virtual environment using that Python version.
How is scuv different from uv’s centralized-project-envs preview?
They solve different problems, and scuv is built on top of uv — it’s a complement, not a fork or a competitor.
uv 0.11.25 added a preview feature (centralized-project-envs) that relocates a project’s
.venv into uv’s cache directory. The environment is still bound to that one project: its
identity is a cache key derived from the workspace path and interpreter (e.g.
my-project-cp3.12.4-0123abcd), it cannot be shared between projects, there is no activation
workflow, and uv cache clean / uv cache prune delete it unconditionally — by design it is a
disposable cache entry that gets transparently recreated.
scuv environments are the opposite in every one of those dimensions: named, durable, and project-independent.
uv centralized-project-envs | scuv | |
|---|---|---|
| Environment identity | hash cache key (not user-controlled) | a name you choose (scuv create ml 3.12) |
| Shared across projects | no (key includes workspace path) | yes — any project with a .scuv-version file |
| Activation workflow | none (uv run-centric; .venv link for IDEs) | shell auto-activation, scuv use, 4 shells |
| Lifecycle | wiped by uv cache clean/prune, auto-recreated | durable; gc (dry-run first), verify, metadata (last_used) |
| Extras | — | clone, diff, export/import, .scuv.toml sync, migration from pyenv / conda / virtualenvwrapper |
The uv team has stated there are “no current plans to support standalone environments not tied to a specific project”. That standalone, named, pyenv-virtualenv-style workflow is exactly what scuv provides — with uv doing the fast parts underneath.
Is scuv related to Scoop, the Windows package manager?
No. scuv — “a scoop of uv” 🍨 — is a centralized Python virtual environment manager and
is unrelated to Scoop, the Windows package manager. The project was
originally command-named scoop; we renamed the command to scuv in v0.15.0 precisely so both
tools can coexist cleanly on Windows. Installing scuv does not shadow or conflict with scoop
in any shell, including PowerShell. (The repository and crate keep the historical name
scoop-uv.)
How do I set Python 3.11.0 as the global default for all new shells and environments?
Use this workflow:
# 1) Install Python 3.11.0 (skip if already available on your system)
scuv install 3.11.0
# 2) Create an environment that uses 3.11.0
scuv create py311 3.11.0
# 3) Make that environment the global default
scuv use py311 --global
Important details:
--globalstores an environment name in~/.scuv/version, not a raw version like3.11.0.- This global default is applied in new shells and directories without a local
.scuv-version. - Priority is:
SCUV_VERSIONenv var > local.scuv-version> global~/.scuv/version.
To remove the global default later:
scuv use --unset --global
How do I create a new virtual environment for a project, explicitly specifying Python 3.9.5?
Use this end-to-end workflow:
# 1) Install Python 3.9.5 (skip if already available on your system)
scuv install 3.9.5
# 2) Create a new project environment with that exact version
scuv create myproject 3.9.5
# 3) Verify which Python the environment uses
scuv info myproject
If creation fails because 3.9.5 is not found, run:
uv python list
scuv list --pythons
Then install the exact version and retry:
scuv install 3.9.5
scuv create myproject 3.9.5
How do I uninstall a specific Python version and all its associated virtual environments managed by scuv?
Use --cascade to remove both the Python version and every environment that depends on it:
# 1) Optional: preview affected environments
scuv list --python-version 3.12
# 2) Remove Python 3.12 and all associated environments
scuv uninstall 3.12 --cascade
# 3) Verify cleanup
scuv list --pythons
scuv doctor
Useful variants:
- Non-interactive mode:
scuv uninstall 3.12 --cascade --force - JSON output for automation:
scuv uninstall 3.12 --cascade --json
Important detail:
- Without
--cascade, environments are not removed and can become broken.
Given scuv’s auto-activation feature, how would a developer temporarily disable or customize its behavior for a specific project or directory without affecting global settings?
Use one of these local or temporary patterns:
# Option 1) Disable auto-activation only in the current shell session
export SCUV_NO_AUTO=1
# ...work here...
unset SCUV_NO_AUTO
# Option 2) For one project directory, force system Python locally
cd ~/project
scuv use system
# Option 3) For one project directory, pin a specific environment locally
scuv use myproject
# Option 4) Temporary override in this terminal only (no file changes)
scuv shell system
# ...test...
scuv shell --unset
Notes:
- These approaches avoid
--global, so global defaults are unchanged. .scuv-versionchanges fromscuv use ...are local to the project directory (and inherited by subdirectories).scuv shell ...affects only the current terminal session.
Once a scuv environment is active, how would you install project dependencies from a requirements.txt file into it?
Run uv pip inside the active environment. Environments that scuv create makes have no pip of their own; uv pip installs into the active one (it reads VIRTUAL_ENV):
# Prompt shows active environment, e.g. (myproject)
uv pip install -r requirements.txt
Useful variants:
- Different file location:
uv pip install -r path/to/requirements.txt - Verify installed dependencies:
uv pip list
If requirements.txt is in the project root, run the command from that directory.
How can a developer list all Python versions and their associated virtual environments currently managed by scuv?
Use this sequence:
# 1) Show all managed Python versions
scuv list --pythons
# 2) Show all environments and their Python versions
scuv list
# 3) Show environments for one specific Python version
scuv list --python-version 3.12
For automation:
- Use
--jsonfor machine-readable output. - Use
--barefor name-only output in shell scripts.
Example script to iterate each Python version and print associated environments
(an env may record only the minor version, such as 3.12, so cut the installed
versions down to major.minor first; a 3.12 filter also matches 3.12.x):
for v in $(scuv list --pythons --bare | cut -d. -f1,2 | sort -u); do
echo "== Python $v =="
scuv list --python-version "$v" --bare
done
If no versions or environments exist yet, these commands simply return empty results.
If a project requires a Python version not directly available through scuv’s default sources, how could a developer integrate a custom or pre-existing Python installation into scuv’s management system?
Use one of these two approaches:
# Option 1) Recommended: point directly to a Python executable
scuv create myenv --python-path /opt/python-debug/bin/python3
# Option 2) Add custom Python to PATH, then use normal version selection
export PATH="/opt/python-debug/bin:$PATH"
scuv create myenv 3.13
Validation and diagnostics:
uv python list # confirm interpreter discovery
scuv info myenv # confirm selected Python + Python Path
scuv doctor -v # detect broken links/metadata issues
Where scuv stores this integration:
- Environment metadata file:
~/.scuv/virtualenvs/myenv/.scoop-metadata.json - Custom interpreter path is recorded in the
python_pathfield.
Can I use scuv with conda environments?
Not directly. They serve different purposes and operate independently:
conda is a package and environment manager. It handles:
- Its own binaries and non-Python dependencies
- Heavy data science libraries (MKL, CUDA, cuDNN, etc.)
scuv is a lightweight environment manager powered by uv. It:
- Leverages your existing Python installations
- Creates fast, portable virtual environments
When to use what: For heavy data science requiring non-Python libraries → conda. For almost everything else → scuv (significantly faster and more portable).
How do I uninstall scuv completely?
To remove scuv from your system:
1. Delete the data folder
rm -rf ~/.scuv
2. Remove the shell hook
Edit your shell config file and remove the scuv init line:
| Shell | Config File | Line to Remove |
|---|---|---|
| Bash | ~/.bashrc | eval "$(scuv init bash)" |
| Zsh | ~/.zshrc | eval "$(scuv init zsh)" |
| Fish | ~/.config/fish/config.fish | scuv init fish | source |
| PowerShell | $PROFILE | Invoke-Expression (& scuv init powershell | Out-String) |
3. (Optional) Remove config
rm -f ~/.scuv/config.json
4. Restart your terminal
Does scuv work on Windows?
scuv supports PowerShell on Windows (both PowerShell Core 7.x+ and Windows PowerShell 5.1+). Shell integration including auto-activation and tab completion works fully.
# Add to $PROFILE
Invoke-Expression (& scuv init powershell | Out-String)
Note: Command Prompt (cmd.exe) is not supported. Use PowerShell for the full scuv experience.
Can I use a custom or pre-existing Python with scuv?
Yes, in two ways:
Option 1: Use –python-path (recommended for custom builds)
Point directly to any Python executable:
# Custom-built Python
scuv create debug-env --python-path /opt/python-debug/bin/python3
# PyPy interpreter
scuv create pypy-env --python-path /opt/pypy/bin/pypy3
# GraalPy
scuv create graal-env --python-path /opt/graalpy/bin/graalpy
scuv validates the path, auto-detects the version, and stores it in metadata.
Option 2: System Python via uv discovery
scuv uses uv for Python discovery, which automatically finds Python installations on your system:
# Check what Python versions uv can discover
uv python list
# Example output:
# cpython-3.13.1 /opt/homebrew/bin/python3.13 (system)
# cpython-3.12.8 ~/.local/share/uv/python/... (managed)
# cpython-3.11.5 /usr/bin/python3.11 (system)
# Use a system-installed Python directly (no scuv install needed)
scuv create myenv 3.13
For a custom Python in a non-standard location, add it to your PATH:
export PATH="/opt/python-debug/bin:$PATH"
scuv create debug-env 3.13
See also: Python Management for the full guide on Python discovery, system Python, custom interpreters, and environment variables.
Can I migrate environments from pyenv or conda?
Yes. scuv can discover and migrate existing environments from pyenv-virtualenv, conda, and virtualenvwrapper:
# See what can be migrated
scuv migrate list
# • Scanning all sources for environments...
# ✓ Found 2 environment(s):
#
# [virtualenvwrapper]
# ✓ myproject Python 3.12 - MB
# ✓ webapp Python 3.11 - MB
# ...
# Migrate a specific environment
scuv migrate @env myproject
# Migrate everything at once
scuv migrate all
The original environments are preserved by default. Use --delete-source to remove source envs after successful migration. See migrate command for details.
Command Reference
Complete reference for all scuv commands.
Commands Overview
| Command | Aliases | Description |
|---|---|---|
scuv list | ls | List virtualenvs or Python versions |
scuv create | - | Create virtualenv |
scuv use | - | Set + activate environment |
scuv remove | rm, delete | Remove virtualenv |
scuv install | - | Install Python version |
scuv uninstall | - | Uninstall Python version |
scuv doctor | - | Diagnose installation |
scuv info | - | Show virtualenv details |
scuv status | - | Summarise the currently active env |
scuv which | - | Resolve an executable inside an env |
scuv run | - | Run a command inside an env without activating |
scuv sync | - | Apply .scuv.toml declaratively |
scuv export | - | Write a portable JSON snapshot of an env |
scuv import | - | Recreate an env from an export file (or stdin) |
scuv clone | - | Duplicate an env (with or without packages) |
scuv migrate | - | Migrate from pyenv/conda/venvwrapper |
scuv gc | - | Garbage-collect orphan virtualenvs |
scuv prune | - | Prune the uv cache |
scuv verify | - | Per-env health diagnosis (6 checks) |
scuv lang | - | Get/set display language |
scuv shell | - | Set shell-specific env (temporary) |
scuv init | - | Shell init script |
scuv completions | - | Completion script |
scuv man | - | Generate man pages (for distro packagers) |
scuv self update | - | Reinstall scuv from crates.io (update or pin version) |
Global Options
Available for all commands:
| Option | Description |
|---|---|
-q, --quiet | Suppress all output |
--color <WHEN> | When to use color: auto (default; on a terminal unless NO_COLOR is set), always, never |
--no-color | Same as --color never |
-h, --help | Show help message |
-V, --version | Show version |
Environment Variables
| Variable | Description | Default |
|---|---|---|
SCUV_HOME | Base directory for scuv | ~/.scuv |
SCUV_NO_AUTO | Disable auto-activation | (unset) |
SCUV_LANG | Display language (en, ko, ja, pt-BR, es) | System locale |
NO_COLOR | Disable colored output when set to any non-empty value (an explicit --color always still colors) | (unset) |
SCUV_VERSION | Shell-session override; highest-priority version selector (set by scuv shell) | (unset) |
SCUV_ACTIVE | Name of the currently active environment (set by the activation script; read by status/which/run) | (unset) |
SCUV_RESOLVE_MAX_DEPTH | Caps the parent-directory walk when resolving .scuv-version (0 = current dir only; unset = unlimited) | (unset) |
Directory Layout
| Location | Purpose |
|---|---|
~/.scuv/virtualenvs/ | Virtual environments storage |
~/.scuv/version | Global default environment |
.scuv-version | Local environment preference |
.venv | Symlink to active environment (with --link) |
list
List all virtual environments or installed Python versions.
Aliases: ls
Usage
scuv list [options]
Options
| Option | Description |
|---|---|
--pythons | Show Python versions instead of virtualenvs |
--python-version <VERSION> | Filter environments by Python version (e.g., 3.12) |
--sort <MODE> | Sort order: name (default), created, last-used |
--bare | Output names only (for scripting); hidden from --help |
--json | Output as JSON |
Sort
--sort reorders the output without changing what’s shown:
| Mode | Order | Tie-break |
|---|---|---|
name | Alphabetical (default, back-compat) | — |
created | Newest created_at first | Name (asc) |
last-used | Most recently activated first | Name (asc) |
Envs missing the relevant timestamp (created_at / last_used) sort
to the end of the list, with name-order tie-break — so legacy or
never-activated envs don’t bury the interesting ones. last_used
populates when an env is actually activated: scuv activate,
shell-hook auto-activation triggered by scuv use, scuv run, or
scuv shell. scuv use on its own only writes the version file
and does not touch metadata; the touch fires when the shell wrapper
sources the activate script afterwards.
--sort is mutually exclusive with --pythons (which lists Python
installations, not environments).
Examples
scuv list # List all virtualenvs
scuv list --pythons # List installed Python versions
scuv list --bare # Names only, one per line
scuv list --json # JSON output
# Filter by Python version
scuv list --python-version 3.12 # Show only 3.12.x environments
scuv list --python-version 3 # Show all Python 3.x environments
scuv list --python-version 3.12.1 # Matches only envs that recorded 3.12.1
# Sort
scuv list --sort created # Newest envs first
scuv list --sort last-used # Recently active envs first
Each row shows the name, the Python version the env recorded, and its
path. The last row is the system Python found on PATH; it appears
in --bare and --json output too, and an active env is marked *:
$ scuv list
myproject 3.12 ~/.scuv/virtualenvs/myproject
other 3.11 ~/.scuv/virtualenvs/other
webapp 3.12 ~/.scuv/virtualenvs/webapp
webapp-mirror 3.11 ~/.scuv/virtualenvs/webapp-mirror
system 3.13.1 /usr/bin/python3 (system)
$ scuv list --bare
myproject
other
webapp
webapp-mirror
system
Envs created by current uv record the minor version (3.12), because
that is what uv writes to pyvenv.cfg.
List Python Versions with Associated Environments
Use this workflow to see both sides of the mapping:
# 1) List installed Python versions managed by scuv/uv
scuv list --pythons
# 2) List all virtual environments with their Python versions
scuv list
# 3) Show environments associated with a specific Python version
scuv list --python-version 3.12
For scripting, combine --bare with per-version filtering. --pythons --bare prints full versions (3.12.14), while an env may record only the
minor version (current uv writes 3.12), so cut each version down to
major.minor first; a 3.12 filter also matches envs that record a patch:
for v in $(scuv list --pythons --bare | cut -d. -f1,2 | sort -u); do
echo "== Python $v =="
scuv list --python-version "$v" --bare
done
The system row matches the filter for its own version, so it shows up
under that version’s heading.
You can also use --json for machine-readable output:
scuv list --pythons --json
scuv list --json
Version Filtering
The --python-version option uses prefix matching to filter environments:
scuv list --python-version 3.12
# Output:
# myproject 3.12 ~/.scuv/virtualenvs/myproject
# webapp 3.12 ~/.scuv/virtualenvs/webapp
# (environments using 3.11 or 3.13 are not shown)
This is useful for identifying environments before uninstalling a Python version:
# See which environments will be affected
scuv list --python-version 3.12
# Then uninstall with cascade
scuv uninstall 3.12 --cascade
Note:
--python-versioncannot be combined with--pythons(which lists Python installations, not environments).
Empty Results
- If no Python versions are installed,
scuv list --pythonsshows no entries. - If no environments exist,
scuv liststill shows thesystemrow when a Python is onPATH. Only when there is none either does it printNo environments yetand ascuv createhint (to stderr). - If no environments match a filter,
scuv list --python-version <VERSION>prints only thesystemrow when the system Python matches, and otherwiseNo environments using Python <VERSION>(to stderr;--bareprints nothing).
create
Create a new virtual environment.
Usage
scuv create <name> [python-version]
Arguments
| Argument | Required | Default | Description |
|---|---|---|---|
name | Yes | - | Name for the new virtualenv |
python-version | No | 3 (latest) | Python version (e.g., 3.12, 3.11.8) |
Options
| Option | Description |
|---|---|
--force, -f | Overwrite existing virtualenv |
--python-path <PATH> | Use a specific Python executable instead of version discovery |
--install-python | Install the requested Python version first if it’s not already available (conflicts with --python-path) |
--json | Output result as JSON |
Examples
scuv create myproject 3.12 # Create with Python 3.12
scuv create webapp # Create with latest Python
scuv create myenv 3.11 --force # Overwrite if exists
# Auto-install Python first if the version is missing
scuv create myenv 3.13 --install-python
# Use a specific Python executable
scuv create myenv --python-path /opt/python-debug/bin/python3
scuv create graal --python-path /opt/graalpy/bin/graalpy
Create a Project Environment with Python 3.9.5
# Install exact Python version (skip if already available)
scuv install 3.9.5
# Create a new project environment using that exact version
scuv create myproject 3.9.5
# Verify the environment uses Python 3.9.5
scuv info myproject
If 3.9.5 is not available, install it first with scuv install 3.9.5, then check discovery with
uv python list and scuv list --pythons.
Python Version Resolution
scuv delegates Python discovery to uv. The python-version argument is passed to uv venv --python, which searches for a match in:
- uv-managed Python installations
- System Python on
PATH(Homebrew, apt, pyenv, etc.) - Platform-specific locations (Windows only)
# Uses uv-managed Python 3.12 (if installed via scuv install)
scuv create myenv 3.12
# Also works with system Python — no scuv install needed
# (e.g., if Homebrew has python@3.13)
scuv create myenv 3.13
# Check what Python versions are available
uv python list
scuv list --pythons
Tip: If the version isn’t found, install it first with
scuv install 3.12. See Python Management for custom Python paths.
Custom Python Executable
Use --python-path to create a virtualenv with a specific Python binary. This is useful for:
- Custom-built Python (debug builds, optimized builds)
- Alternative interpreters (PyPy, GraalPy)
- Python installations in non-standard locations
# Debug build from source
scuv create debug-env --python-path /opt/python-debug/bin/python3
# PyPy interpreter
scuv create pypy-env --python-path /opt/pypy/bin/pypy3
# GraalPy
scuv create graal-env --python-path /opt/graalpy/bin/graalpy
The path must point to a valid, executable Python binary. scuv will:
- Validate the path (exists, is a file, is executable)
- Auto-detect the Python version from the binary
- Store the custom path in the environment’s metadata
scuv info shows the version detected from that binary; the path itself
is stored as python_path in the env’s .scoop-metadata.json:
scuv info debug-env
# Name: debug-env
# Python: 3.13.0
# Path: ~/.scuv/virtualenvs/debug-env
# ...
use
Set a virtual environment for the current directory and activate it.
Usage
scuv use <name> [options]
scuv use system [options]
scuv use --unset [options]
Arguments
| Argument | Required | Description |
|---|---|---|
name | No | Name of the virtualenv, or system for system Python |
Options
| Option | Description |
|---|---|
--unset | Remove version file (local or global) |
--global, -g | Set as global default |
--link | Create .venv symlink for IDE compatibility |
--no-link | Do not create .venv symlink (default) |
--json | Output result as JSON |
Behavior
- Creates
.scuv-versionfile in current directory - Immediately activates the environment (if shell hook installed)
- With
--global: writes to~/.scuv/version - With
--link: creates.venv -> ~/.scuv/virtualenvs/<name>
Special Value: system
Using system as the name tells scuv to use the system Python:
scuv use system # Use system Python in this directory
scuv use system --global # Use system Python as global default
This writes the literal string system to the version file, which the shell hook interprets as “deactivate any virtual environment.”
The --unset Flag
Removes the version file entirely:
scuv use --unset # Delete .scuv-version in current directory
scuv use --unset --global # Delete ~/.scuv/version
After unsetting, scuv falls back to the next priority level in version resolution.
Examples
# Use a virtual environment in this directory
scuv use myproject
# Also create .venv symlink (for IDE support)
scuv use myproject --link
# Set global default environment
scuv use myproject --global
# Use system Python in this directory
scuv use system
# Use system Python globally
scuv use system --global
# Remove local version setting
scuv use --unset
# Remove global version setting
scuv use --unset --global
Set Python 3.11.0 as Global Default
--global stores an environment name, not a raw Python version string.
Create an environment with Python 3.11.0, then set that environment globally:
scuv install 3.11.0
scuv create py311 3.11.0
scuv use py311 --global
This writes py311 to ~/.scuv/version, which is used in new shell sessions and
directories that do not have a local .scuv-version.
If a local .scuv-version file or SCUV_VERSION environment variable is present,
it takes precedence over the global setting.
Version File Format
The .scuv-version file contains a single line with either:
- An environment name (e.g.,
myproject) - The literal string
system
$ cat .scuv-version
myproject
remove
Remove a virtual environment.
Aliases: rm, delete
Usage
scuv remove <name> [options]
Arguments
| Argument | Required | Description |
|---|---|---|
name | Yes | Name of the virtualenv to remove |
Options
| Option | Description |
|---|---|
--force, -f | Skip confirmation prompt |
--json | Output result as JSON |
Examples
scuv remove myproject # Remove with confirmation
scuv remove myproject --force # Remove without asking
scuv rm old-env -f # Using alias
Project .venv Link
If the current directory has a .venv symlink to the environment being
removed (made by scuv use <name> --link), remove deletes that link too,
so uv and editors do not trip over a dangling .venv. A real .venv
directory, or a link to anything else, is left alone. Under --json the
removed link is reported as unlinked. If the link cannot be removed (for
example, the directory is read-only), the environment is still removed, a
warning is printed, and --json reports the reason as unlink_error.
Only the current directory is checked. If you remove the environment from
somewhere else, scuv doctor in the project reports the dangling link and
scuv doctor --fix removes it.
Check Before Removing
To see details about an environment before removing it:
# Show environment details (Python version, path, packages)
scuv info myproject
# Output (excerpt):
# Name: myproject
# Python: 3.12
# Path: ~/.scuv/virtualenvs/myproject
Removing All Environments for a Python Version
To remove all environments that use a specific Python version:
# List environments to identify which use Python 3.12
scuv list
# ml-env 3.11 ~/.scuv/virtualenvs/ml-env
# myproject 3.12 ~/.scuv/virtualenvs/myproject
# webapp 3.12 ~/.scuv/virtualenvs/webapp
# system 3.13.1 /usr/bin/python3 (system)
# Remove each one
scuv remove myproject --force
scuv remove webapp --force
# Then optionally uninstall the Python version itself
scuv uninstall 3.12
See also: uninstall command for the complete workflow to uninstall a Python version and clean up associated environments.
install
Install a Python version.
Usage
scuv install [version] [options]
Arguments
| Argument | Required | Default | Description |
|---|---|---|---|
version | No | latest | Python version (e.g., 3.12, 3.11.8) |
Options
| Option | Description |
|---|---|
--latest | Install latest stable Python (default) |
--stable | Install oldest fully-supported Python (3.10) |
--json | Output result as JSON |
What gets installed where
The interpreter itself lands in uv’s own directory
(~/.local/share/uv/python/...); scuv records nothing about it beyond what
uv python list reports.
Since uv 0.8.0 there is a second effect worth knowing about: uv python install also links a versioned executable onto your PATH — typically
~/.local/bin/python3.13 — so the interpreter is reachable without going
through a virtual environment at all.
$ ls -l ~/.local/bin/python3.14
~/.local/bin/python3.14 -> ~/.local/share/uv/python/cpython-3.14.../bin/python3.14
That executable points at the bare interpreter: standard library only, and you
cannot install packages into it. Use a scuv environment for anything past a
quick python3.14 -c '...'.
uv itself accepts --no-bin to skip the link, but scuv install does not
currently forward flags to uv, so there is no way to opt out through scuv. Run
uv python install --no-bin <version> directly if you need that.
Version Resolution
- No argument or
--latest: installs latest Python 3.x --stable: installs Python 3.10 (oldest with active security support)3.12: installs latest 3.12.x patch3.12.3: installs exact version
Examples
scuv install # Install latest
scuv install --latest # Same as above
scuv install --stable # Install Python 3.10
scuv install 3.12 # Install latest 3.12.x
scuv install 3.12.3 # Install exact 3.12.3
Note: Python versions are managed by uv.
Python Discovery
You don’t always need scuv install. When you run scuv create, uv searches for a matching Python in this order:
- uv-managed — installed via
scuv installoruv python install - System PATH — Homebrew, apt, pyenv, or any Python on your
PATH - Platform-specific — Windows registry, Microsoft Store
# See all Python versions uv can find
uv python list
# Example output:
# cpython-3.13.1 /opt/homebrew/bin/python3.13 (system)
# cpython-3.12.8 ~/.local/share/uv/python/... (managed)
# cpython-3.11.5 /usr/bin/python3.11 (system)
# Use system Python directly — no scuv install needed
scuv create myenv 3.13
If the requested version isn’t found anywhere, scuv create will fail with an error. Use scuv install <version> to download it first.
See also: Python Management for custom Python paths, environment variables, and migration.
uninstall
Remove an installed Python version.
Usage
scuv uninstall <version>
Arguments
| Argument | Required | Description |
|---|---|---|
version | Yes | Python version to remove |
Options
| Option | Description |
|---|---|
--cascade | Also remove all virtual environments using this Python version |
--force, -f | Skip confirmation for cascade removal (requires --cascade) |
--json | Output result as JSON |
Examples
scuv uninstall 3.12 # Remove Python 3.12
scuv uninstall 3.11.8 # Remove specific version
# Remove Python and all environments using it
scuv uninstall 3.12 --cascade
# Remove without confirmation prompt
scuv uninstall 3.12 --cascade --force
Uninstall a Python Version and All Associated Environments
Recommended workflow for a full cleanup:
# 1) Optional: preview which environments would be removed
scuv list --python-version 3.12
# 2) Remove Python 3.12 and all environments using it
scuv uninstall 3.12 --cascade
# 3) Verify cleanup
scuv list --pythons
scuv doctor
For non-interactive scripts, skip the confirmation prompt:
scuv uninstall 3.12 --cascade --force
If the target version is not installed, check available versions first:
scuv list --pythons
Cascade Removal
The --cascade flag automatically removes all virtual environments that use the target Python version before uninstalling it. This replaces the manual multi-step workflow.
scuv uninstall 3.12 --cascade
# • Found 2 environment(s) using Python 3.12:
# • - myproject
# • - webapp
# Remove these environments and uninstall Python 3.12? [y/N]
# • Removing 'myproject'...
# • Removing 'webapp'...
# • Removed 2 environment(s)
# • Uninstalling Python 3.12...
# ✓ Python 3.12 uninstalled
With --force, the confirmation prompt is skipped:
scuv uninstall 3.12 --cascade --force
With --json, the output includes the list of removed environments:
scuv uninstall 3.12 --cascade --json
# {
# "status": "success",
# "command": "uninstall",
# "data": {
# "version": "3.12",
# "removed_envs": ["myproject", "webapp"]
# }
# }
Note: Without
--cascade, uninstalling a Python version does not remove virtual environments that were created with it. Those environments will become broken. Use--cascadeto handle this automatically, or follow the manual workflow below.
Manual Uninstall Workflow
If you prefer manual control (without --cascade):
Step 1: Identify affected environments
# List environments filtered by Python version
scuv list --python-version 3.12
# Output:
# myproject 3.12 ~/.scuv/virtualenvs/myproject
# webapp 3.12 ~/.scuv/virtualenvs/webapp
# Or use JSON for scripting
scuv list --json
Step 2: Handle affected environments
# Option A: Remove the environment entirely
scuv remove myproject --force
# Option B: Recreate with a different Python version
scuv remove myproject --force
scuv create myproject 3.13
# Option C: Keep it (will be broken until you reinstall that Python)
# Do nothing — scuv doctor can detect and help fix it later
Step 3: Uninstall the Python version
scuv uninstall 3.12
Step 4: Verify
# Confirm Python is removed
scuv list --pythons
# Check for broken environments
scuv doctor
# If any issues found:
scuv doctor --fix
Recovery
If you uninstalled a Python version without cleaning up environments first:
# Detect broken environments
scuv doctor -v
# Output (excerpt):
# ✗ broken virtualenv: 'myproject' is corrupted
# → scuv remove myproject && scuv create myproject <python-version>
# ✗ broken symlink: Python symlink in 'myproject' is broken
# → scuv remove myproject && scuv create myproject <python-version>
# Option 1: Reinstall the Python version
scuv install 3.12
scuv doctor --fix
# Option 2: Recreate affected environments with a new version
scuv remove myproject --force
scuv create myproject 3.13
doctor
Check scuv installation health and diagnose issues.
Usage
scuv doctor [options]
Options
| Option | Description |
|---|---|
-v, --verbose | Show more details (can repeat: -vv) |
--json | Output diagnostics as JSON |
--fix | Auto-fix issues where possible |
Checks Performed
| Check | What it verifies |
|---|---|
| uv installation | uv is installed and meets the minimum version (0.5.19) |
| SCUV_HOME directory | ~/.scuv/ exists and is writable |
| virtual environments | Every environment has a bin/python and a pyvenv.cfg |
| symbolic links | Python symlinks inside each environment still resolve |
| shell configuration | The shell hook is present in your rc file |
| version files | .scuv-version entries reference environments that exist |
| project .venv link | A .venv symlink in the current directory still resolves. A dangling link into ~/.scuv/virtualenvs/ (made by scuv use --link) is an error that --fix removes; a dangling link elsewhere is only a warning |
| legacy scoop remnants | Leftover SCOOP_* vars, an orphaned ~/.scoop, or .scoop-version / .scoop.toml in the current directory — none of them read since v0.16.0, so the check only warns |
Examples
scuv doctor # Quick health check
scuv doctor -v # Verbose diagnostics
scuv doctor --fix # Fix what can be fixed
scuv doctor --json # JSON output for scripting
Environment Integrity
The doctor checks each virtual environment for:
- Python symlink — Does the
pythonbinary in the environment point to a valid Python installation? - pyvenv.cfg — Does the environment’s configuration file exist (and its Python binary)?
Environments can become broken when their underlying Python version is uninstalled. Use scuv doctor to detect these issues:
# After accidentally uninstalling Python 3.12:
scuv doctor -v
# Output:
#
# Checking installation...
#
# ✓ uv installation
# uv 0.x.y (<commit> <date> <target>)
# ✓ SCUV_HOME directory
# ~/.scuv
# ✗ broken virtualenv: 'myproject' is corrupted
# → scuv remove myproject && scuv create myproject <python-version>
# ✗ broken virtualenv: 'webapp' is corrupted
# → scuv remove webapp && scuv create webapp <python-version>
# ✗ broken symlink: Python symlink in 'myproject' is broken
# → scuv remove myproject && scuv create myproject <python-version>
# ✗ broken symlink: Python symlink in 'webapp' is broken
# → scuv remove webapp && scuv create webapp <python-version>
# ✓ shell configuration
# found in ~/.zshrc
# ✓ version files
# no version files configured
# ✓ legacy scoop remnants
#
# ──────────────────────────────────
# Found 4 error(s).
# Auto-fix by recreating symlinks (requires Python to be reinstalled)
scuv install 3.12
scuv doctor --fix
# Output (excerpt):
# ✗ broken virtualenv: 'myproject' is corrupted
# → scuv remove myproject && scuv create myproject <python-version>
# ✗ broken virtualenv: 'webapp' is corrupted
# → scuv remove webapp && scuv create webapp <python-version>
# • Attempting to fix symlink for 'myproject'...
# • Found Python version: 3.12
# ✓ Fixed symlink for 'myproject'
# ✓ broken symlink
# • Attempting to fix symlink for 'webapp'...
# • Found Python version: 3.12
# ✓ Fixed symlink for 'webapp'
# ✓ broken symlink
# ...
# Found 2 error(s).
✓ marks a passing check, ⚠ a warning and ✗ an error; → lines
suggest a fix. The report goes to stderr. doctor exits 2 when any
check errors, 1 when the worst finding is a warning, and 0 when every
check passes (All checks passed!).
The broken virtualenv check runs before the symlink fix, so --fix
still reports those errors in the same run. Run scuv doctor again to
confirm the repair:
scuv doctor
# Output (excerpt):
# ✓ virtual environments
# ✓ symbolic links
# ...
# All checks passed!
Tip: Run
scuv doctorperiodically or after uninstalling Python versions to catch broken environments early. See uninstall command for the safe uninstall workflow.
info
Show detailed information about a virtual environment — heavier
sibling of scuv status. Reads metadata, walks the
directory for size, and runs uv pip list against the env for a
package list.
Usage
scuv info <name>
Arguments
| Argument | Required | Description |
|---|---|---|
name | Yes | Name of the virtualenv |
Options
| Option | Description |
|---|---|
--all-packages | Show the full installed-package list (default: top 5) |
--no-size | Skip the directory-size walk |
--json | Output as JSON |
Human Output
Name: myproject
Python: 3.12
Path: ~/.scuv/virtualenvs/myproject
Active: no
Created: 2026-05-29 12:34:56
Last used: 9 minutes ago
Size: 8 MB
Packages: 10
certifi==2026.7.22
charset-normalizer==3.5.2
idna==3.20
markdown-it-py==4.2.0
mdurl==0.1.2
... (5 more)
Packages are listed in name order. ... (N more) closes a truncated list;
--all-packages prints every package instead.
The Last used: row reads never for envs whose metadata exists but
have never been activated (scuv activate / scuv run /
scuv shell is what touches it), and is omitted entirely when there
is no on-disk metadata at all.
JSON Output
scuv info myproject --json
{
"status": "success",
"command": "info",
"data": {
"name": "myproject",
"python": "3.12",
"path": "/Users/me/.scuv/virtualenvs/myproject",
"active": false,
"created_at": "2026-05-29T12:34:56.375271+00:00",
"last_used": "2026-06-02T09:00:00.746201+00:00",
"size_bytes": 8575712,
"size_display": "8 MB",
"packages": {
"total": 10,
"items": [
{ "name": "certifi", "version": "2026.7.22" },
{ "name": "charset-normalizer", "version": "3.5.2" },
{ "name": "idna", "version": "3.20" },
{ "name": "markdown-it-py", "version": "4.2.0" },
{ "name": "mdurl", "version": "0.1.2" }
],
"truncated": true
}
}
}
last_used (RFC 3339) is omitted when the env has never been
activated. size_bytes / size_display are omitted under --no-size.
Examples
scuv info myproject # Default top-5 packages
scuv info myproject --all-packages
scuv info myproject --no-size # Skip directory-size walk
scuv info myproject --json
status
Summarise the current environment in one shot. It counts packages but does
not list them or walk the directory for its size. Use scuv info
for the heavier per-env view.
Usage
scuv status [--json]
States
status resolves to one of four states:
| State | Trigger |
|---|---|
active | $SCUV_ACTIVE is set (shell-activated) |
configured | A .scuv-version file or ~/.scuv/version selects an env |
system | The configured env is the literal name system |
none | Nothing resolved |
$SCUV_ACTIVE wins over version files because it reflects what the shell
actually activated.
Source
source names where the environment came from, which is not always a file:
| Value | Meaning |
|---|---|
scuv_active_env | $SCUV_ACTIVE — what the shell activated |
env_var | SCUV_VERSION |
version_file | .scuv-version (local or a parent) or ~/.scuv/version |
Resolution order is $SCUV_ACTIVE → SCUV_VERSION → version files, so
env_var outranks every file. Before 0.15.3 an environment selected by
SCUV_VERSION was also reported as version_file, which could mislead a
script trying to work out what to change.
Human Output
For a real env (active / configured):
Name: myenv
Source: scuv_active_env
Python: 3.12
Path: ~/.scuv/virtualenvs/myenv
Created: 2026-05-29 12:34:56
Last used:3 hours ago
Packages: 1
These rows go to stdout. Packages: is the number of packages uv pip list
reports for the env; it reads 0 when that listing fails (a broken
interpreter), not only when nothing is installed.
The Last used: row reads never for envs that have metadata but
have not yet been activated (fresh scuv create, or envs whose
metadata predates the field). It’s omitted entirely when there’s no
metadata at all — that way “we don’t know” doesn’t get conflated with
“definitely never used”.
For system, one line on stderr:
• Using system Python (no virtualenv active)
For none, a hint on stderr:
• No environment configured
• → Activate one: scuv use <name>
JSON Output
{
"status": "success",
"command": "status",
"data": {
"state": "active",
"name": "myenv",
"source": "scuv_active_env",
"path": "/Users/me/.scuv/virtualenvs/myenv",
"python": "3.12",
"created_at": "2026-05-29T12:34:56.375271+00:00",
"last_used": "2026-06-02T09:00:00.746201+00:00",
"packages": 1
}
}
Fields are omitted (skip_serializing_if) when not applicable to the
state. last_used is RFC 3339 and absent in two distinct cases:
- No metadata at all (legacy env / metadata file removed) —
the timestamp is unknown. Human output omits the
Last used:row entirely. - Metadata present but never activated since the field landed —
the timestamp is known to be never. Human output renders
Last used: never.
JSON consumers therefore should NOT collapse “missing” to “never”;
combine the absence of last_used with the presence of created_at
to tell the two cases apart.
Examples
scuv status # human-readable
scuv status --json # machine-readable
which
Print the full path to an executable inside a scuv environment, the same way
pyenv which resolves binaries.
Usage
scuv which <exe> [--env <name>] [--json]
Arguments
| Argument | Required | Description |
|---|---|---|
exe | Yes | Executable name to locate (e.g. python, pip, pytest) |
Options
| Option | Description |
|---|---|
--env <name> | Look in this environment instead of the active one |
--json | Output as JSON |
Resolution Order
--env <name>if provided$SCUV_ACTIVE(set byscuv activate/scuv shell).scuv-version(local → parents → global)
If none of those resolve to a real virtualenv (e.g. system Python or no
configuration), the command fails with No active environment.
On Windows, the lookup also probes .exe, .bat, and .cmd extensions.
Examples
scuv which python # active env's python
scuv which pytest --env myenv # explicit env
scuv which python --json # JSON: { exe, env, path }
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Path printed to stdout |
| 1 | No active env / env missing / executable not in env’s bin/ |
run
Run a command inside a virtualenv without activating it in the parent shell — useful for CI, one-shot scripts, and editor integrations.
Usage
scuv run <env> [--] <command> [args...]
The -- separator is optional but recommended when <command> accepts flags
that might collide with scuv’s own flags.
Arguments
| Argument | Required | Description |
|---|---|---|
env | Yes | Name of the virtualenv |
command | Yes | Program (and arguments) to execute |
Environment Wiring
The spawned child sees the same vars that scuv activate would set:
| Variable | Value |
|---|---|
VIRTUAL_ENV | Absolute path of the env |
SCUV_ACTIVE | <env> |
PATH | Env’s bin/ prepended to inherited PATH |
PYTHONHOME | Removed |
A bare program name (no / or \) is looked up inside the env’s bin/
first — so scuv run env -- python always picks the env’s interpreter, not
a system one. An explicit path (/usr/bin/python3) is used verbatim.
Exit Codes
scuv run exits with the child’s exit code. On Unix, a child killed by a
signal exits as 128 + signum (matching what bash exposes via $?).
Examples
scuv run myenv -- python script.py
scuv run myenv -- uv pip install requests
scuv run myenv -- pytest -vv tests/
scuv run myenv -- which python # absolute path inside myenv
sync
Apply a project’s declarative .scuv.toml — create the env if needed (auto-
installing Python if missing) and reconcile its packages via uv pip.
Usage
scuv sync [--with <GROUP>]... [--dry-run] [--json]
Options
| Option | Description |
|---|---|
--with <GROUP> | Install an extra package group on top of default (repeatable) |
--dry-run | Print the resolved plan without creating env or installing packages |
--json | Output as JSON |
Manifest Resolution
scuv sync walks from the current directory up to the filesystem root looking
for .scuv.toml — the same model as .scuv-version. The first manifest it
finds wins. If none is found, the command fails with MANIFEST_NOT_FOUND and
points you at this doc.
.scuv.toml Format
[environment]
name = "myproject" # required — must pass `is_valid_env_name`
python = "3.12" # required — same specifier syntax as `scuv create`
[packages]
default = ["pytest", "black", "mypy"] # always installed
dev = ["ipython", "debugpy"] # opt-in via `--with dev`
docs = ["mkdocs"] # opt-in via `--with docs`
Field rules:
[environment].namefollows the same validation asscuv create <NAME>(letter-leading,[a-zA-Z][a-zA-Z0-9_-]*, not a reserved subcommand name).[environment].pythonis forwarded touv venv --pythonas-is, so any specifier uv accepts works (3.12,3.12.7,cpython@3.12,pypy@3.10).[packages]is optional;default = []is valid.- Any other key inside
[packages]becomes a named group selectable with--with <name>. - Top-level keys other than
[environment]and[packages]are rejected (deny_unknown_fields) so typo’d sections fail loudly instead of silently doing nothing.
Behaviour
| State | What scuv sync does |
|---|---|
| Env missing | Auto-installs the requested Python (if uv doesn’t have it), creates the env, then installs packages |
| Env exists, Python matches | Just installs packages (idempotent — pip resolves and skips already-satisfied entries) |
| Env exists, Python mismatch | Warns and proceeds. Recreating an env on a version change is destructive, so it stays explicit: scuv remove <name> then scuv sync |
Unknown --with <group> | Fails fast before any env work, lists available groups |
scuv sync does not uninstall packages that are present in the env but
missing from the manifest. That’s a deliberate scoping decision for v1 — full
lockstep reconciliation is left to a future --prune flag.
Examples
# In a project directory with .scuv.toml
scuv sync # default group only
scuv sync --with dev # default + dev
scuv sync --with dev --with docs # multiple groups
scuv sync --dry-run # preview, no side effects
scuv sync --with dev --dry-run --json # machine-readable plan
Sample dry-run output
• Dry-run plan:
manifest: /path/to/project/.scuv.toml
environment: myproject (Python 3.12)
groups: default, dev
action: create env + install packages
packages: 5 total
- pytest
- black
- mypy
- ipython
- debugpy
Sample JSON output
{
"status": "success",
"command": "sync",
"data": {
"manifest_path": "/path/to/.scuv.toml",
"environment": "myproject",
"python": "3.12",
"groups": ["default", "dev"],
"packages": ["pytest", "black", "mypy", "ipython", "debugpy"],
"env_created": true,
"dry_run": false
}
}
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Sync succeeded (or dry-run produced a plan) |
| 1 | Manifest not found, unknown group, parse error, or pip install failed |
Not Yet Supported (v1)
These fields are intentionally not parsed in this version:
[hooks](post-create,post-activate, …) — needs a separate threat model before scuv will execute arbitrary shell from a checked-in file.python_path— per-env custom interpreter overrides (parallel to the existing--python-pathflag onscuv create).- Lock file format.
The parser rejects unknown top-level keys, so a manifest using these will fail cleanly today and stop working when they ship in a future release without a silent behaviour change.
export
Write a portable JSON snapshot of an environment so another machine (or
another teammate) can recreate it with scuv import.
Usage
scuv export <name> [-o <PATH>]
Arguments
| Argument | Required | Description |
|---|---|---|
name | Yes | Name of the environment to export |
Options
| Option | Description |
|---|---|
-o, --output <PATH> | Write to this file instead of stdout |
When -o is omitted, the JSON document is written to stdout and status
messages stay on stderr. That keeps the command pipe-friendly:
scuv export myenv > myenv.json
scuv export myenv | jq '.packages | length'
Schema
The exported file is versioned (scoop_export_version) so a future format
change is detected cleanly rather than silently mis-parsed.
{
"scoop_export_version": "1",
"environment": {
"name": "myproject",
"python": "3.12",
"created_at": "2026-05-29T12:34:56.762998+00:00"
},
"packages": [
{ "name": "pytest", "version": "8.0.0" },
{ "name": "black", "version": "24.1.0" }
]
}
Field notes:
environment.pythonis the version recorded in the env’s metadata, which comes fromversion_infoin the env’spyvenv.cfg. Current uv writes the minor version there (3.12); envs made with--python-pathrecord the interpreter’s full version (e.g.3.14.8).environment.created_atis RFC 3339 and may be absent for hand-authored or pre-metadata exports.packagesis whatuv pip listreports for the env — versions are pinned exactly so imports are reproducible.
Note: The export schema is still v1 and intentionally does not include the new
last_usedtimestamp.last_usedis local usage telemetry — it describes how you’ve been using this env, not what an importer needs to recreate it elsewhere. Imported envs start fresh with nolast_usedand the field populates the first time the new env is activated locally.
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Export written successfully (or printed to stdout) |
| 1 | Env not found, or writing the destination file failed |
See Also
scuv import— reverse operationscuv sync— declarative manifest with looser pinning
import
Recreate an environment from a scuv export JSON file.
Usage
scuv import <PATH> [--name <NEW_NAME>] [--force] [--json]
scuv import - [--name <NEW_NAME>] # read from stdin
Arguments
| Argument | Required | Description |
|---|---|---|
path | Yes | Path to the export JSON, or - to read from stdin |
Options
| Option | Description |
|---|---|
--name <NAME> | Override the env name from the file (validated like scuv create) |
-f, --force | Overwrite an existing environment with the same name |
--json | Output as JSON |
Behaviour
- Reads and validates the export schema. Mismatched
scoop_export_versionproduces a clearEXPORT_UNSUPPORTED_VERSIONerror pointing at upgrade guidance instead of trying to limp on. - Applies
--nameoverride (if any) and validates the resulting name. - If the target env already exists: errors out unless
--forceis set, in which case the existing env is removed first. - Auto-installs the requested Python if it isn’t already available via uv
(matches the ergonomics of
scuv sync). - Creates the env, then
uv pip installs every pinned package (name==version) in one shot.
Examples
# Plain file -> recreates with the schema's original name
scuv import myenv.json
# Pipe from a sibling machine
ssh other 'scuv export myenv' | scuv import -
# Rename on the fly + overwrite if it already exists
scuv import myenv.json --name myenv-2 --force
# Machine-readable summary for CI
scuv import myenv.json --json
JSON Output
{
"status": "success",
"command": "import",
"data": {
"name": "myenv",
"python": "3.12",
"packages_installed": 42,
"source": "myenv.json"
}
}
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Imported successfully |
| 1 | Invalid file, unsupported schema version, invalid name, or env existed without --force |
See Also
scuv export— produce the input filescuv sync— for declarative.scuv.toml-driven workflows
clone
Duplicate an environment — same Python version, same packages by default — without going through an export/import roundtrip.
Usage
scuv clone <SRC> <DST> [--no-packages] [--force] [--json]
Arguments
| Argument | Required | Description |
|---|---|---|
src | Yes | Name of the source environment |
dst | Yes | Name for the new environment |
Options
| Option | Description |
|---|---|
--no-packages | Skip package copy — create an empty env at the same Python version |
-f, --force | Overwrite the destination if it already exists |
--json | Output as JSON |
Behaviour
- Validates
<DST>(rejects reserved names likelist,clone, …). - Refuses a self-clone (
src == dst). - Resolves
<SRC>’s recorded Python version from its metadata; surfaces a clearCorruptedEnvironmenterror when metadata is missing so you know recreate-from-scratch is the right next step. - Creates
<DST>at the same Python version. - Unless
--no-packages, lists the source’s installed packages withuv pip listand re-installs them pinned (name==version) into the destination.
Examples
# Full copy
scuv clone myenv myenv-experiment
# Just the shell, no packages
scuv clone myenv myenv-clean --no-packages
# Replace an existing clone
scuv clone myenv myenv-experiment --force
JSON Output
{
"status": "success",
"command": "clone",
"data": {
"src": "myenv",
"dst": "myenv-experiment",
"python": "3.12",
"path": "/Users/me/.scuv/virtualenvs/myenv-experiment",
"packages_copied": 12,
"packages_skipped": false
}
}
Exit Codes
| Code | Meaning |
|---|---|
| 0 | Cloned successfully |
| 1 | Invalid dst name, self-clone, src missing, dst exists without --force, or src corrupted |
See Also
scuv export/scuv import— portable JSON for cross-machine duplicationscuv create --force— recreate from scratch with a specific Python version
migrate
Migrate virtual environments from other tools (pyenv-virtualenv, virtualenvwrapper, conda).
Usage
# List migratable environments
scuv migrate list
# Migrate a single environment
scuv migrate @env <name>
# Migrate all environments
scuv migrate all
Subcommands
| Subcommand | Description |
|---|---|
list | List environments available for migration |
@env <name> | Migrate a single environment by name |
all | Migrate all discovered environments |
Supported Sources
| Source | Detection |
|---|---|
| pyenv-virtualenv | ~/.pyenv/versions/ (non-system virtualenvs) |
| virtualenvwrapper | $WORKON_HOME or ~/.virtualenvs/ |
| conda | conda info --envs |
Options
| Option | Subcommand | Description |
|---|---|---|
--source <pyenv|virtualenvwrapper|conda> | all subcommands | Restrict to a single source tool |
--json | all subcommands | Machine-readable output (see JSON Output) |
--dry-run | @env, all | Preview without making changes |
--force | @env, all | Overwrite existing scuv env with the same name; bypass EOL Python guard |
--yes | @env, all | Skip the interactive confirmation prompt |
--strict | @env, all | Fail on the first package install error inside an env (default: keep going) |
--delete-source | @env, all | Remove the source env after successful migration |
--rename <new-name> | @env | Migrate under a different name |
--auto-rename | @env | On name conflict, migrate under <name>-pyenv automatically (conflicts with --force) |
Global flags (--quiet, --color, --no-color) apply to all subcommands.
Exit codes
scuv migrate follows the layered exit-code contract. The mapping differs slightly between migrate all (which partitions envs into buckets before iterating) and the single-env paths (@env, list).
migrate all
| Code | Returned when |
|---|---|
0 | (a) all envs migrated, (b) no envs found but source tools are installed, or (c) only non-conflict skips occurred (EOL / corrupted envs in the skipped bucket, no preflight name conflicts, no per-env failures) |
2 | At least one per-env failure or at least one preflight name conflict without --force. Returned via MigrationBatchFailed |
3 | No source tool (pyenv / virtualenvwrapper / conda) is detected on the system. Returned via MigrationSourcesNotFound |
migrate @env <name>
| Code | Returned when |
|---|---|
0 | The env migrated successfully (or the user chose Skip at the interactive conflict prompt) |
2 | MigrationNameConflict (env exists in scuv home, --force not set, and --yes or --json skips the prompt) or MigrationFailed (e.g. requested env’s Python is EOL and --force not set) |
3 | The named source env was not found in the requested source (PyenvEnvNotFound, VenvWrapperEnvNotFound, CondaEnvNotFound) or the source env is CorruptedEnvironment |
migrate list
Exit 0 is informational. list only fails when a discovery I/O error occurs (exit 1, via the catchall).
Notes:
- Before v0.14,
migrate allalways exited0regardless of per-env outcome. CI gates need0.14+to distinguish success from a batch where some envs failed. - When
migrate allreturnsMigrationBatchFailed, the human summary and JSON envelope are already on stdout/stderr;main.rssuppresses the globalerror:prefix to avoid duplicate noise.
–force vs –auto-rename (single env, @env)
The two flags are mutually exclusive (clap-enforced via
conflicts_with = "force"). For migrate all only --force is
available; conflicts without --force count toward the exit-2
contract.
--force (recommended for guaranteed resolution)
| Status of source env | With --force |
|---|---|
| Ready | Migrated normally |
| Name conflict with an existing scuv env | The existing scuv env is overwritten in place |
| EOL Python version (e.g. 2.7) | Migrated anyway (the EOL guard is intentionally bypassed) |
| Corrupted source env | Not bypassed — still returns CorruptedEnvironment (exit 3) |
--auto-rename (name-conflict only)
--auto-rename is a convenience for the pure name-conflict case:
when a scuv env with the same name already exists, the migration
proceeds under an auto-generated name. It does not override any
other status (EOL / corrupted), and it does not delete or overwrite
the existing scuv env.
$ scuv migrate @env myproject --auto-rename --yes
• Source: myproject (virtualenvwrapper, Python 3.12)
• Source: ~/.virtualenvs/myproject
• Auto-renaming to 'myproject-pyenv'
• Migrating...
✓ Migrated 'myproject-pyenv'
• Path: ~/.scuv/virtualenvs/myproject-pyenv
• Python: 3.12
• Packages: 1
•
• → Activate: scuv use myproject-pyenv
The generated name is <name>-pyenv for every source, including
virtualenvwrapper and conda. When that name is taken as well, scuv
picks a numbered name such as myproject-2.
The EOL guard still applies to a renamed env. When the source env both
conflicts by name and runs an end-of-life Python, --auto-rename stops
with Python <version> is end-of-life. Use --force to migrate anyway.
(exit 2).
Examples
List Migratable Environments
$ scuv migrate list
• Scanning all sources for environments...
✓ Found 2 environment(s):
[virtualenvwrapper]
✓ myproject Python 3.12 - MB
✓ webapp Python 3.11 - MB
• To migrate: scuv migrate @env <name>
• To preview: scuv migrate @env <name> --dry-run
Each env line starts with ✓ when it is ready to migrate, ⚠ for a name
conflict or an end-of-life Python (followed by the reason, such as
(Python 3.7.17 is EOL) or (conflicts with <path>)), and ✗ when the
env is corrupted. The grouped env lines go to stdout; the •,
✓ Found lines go to stderr.
Migrate Single Environment
$ scuv migrate @env myproject --yes
• Source: myproject (virtualenvwrapper, Python 3.12)
• Source: ~/.virtualenvs/myproject
• Migrating...
✓ Migrated 'myproject'
• Path: ~/.scuv/virtualenvs/myproject
• Python: 3.12
• Packages: 1
•
• → Activate: scuv use myproject
Migrate All
$ scuv migrate all --yes
• Scanning all sources for environments...
• Found 2 environment(s) to migrate:
• - myproject (Python 3.12)
• - webapp (Python 3.11)
•
• Starting batch migration...
•
• ────────────────────────────────────────
✓ Migration complete: 2/2 succeeded
When some envs fail or conflict, the summary names them and the command exits 2:
$ scuv migrate all --yes
• Scanning all sources for environments...
• Found 1 environment(s) to migrate:
• - brokenpip (Python 3.12)
⚠ 1 environment(s) will be skipped (see summary)
•
• Starting batch migration...
•
• ────────────────────────────────────────
✓ Migration complete: 0/1 succeeded
⚠ Failed environments: brokenpip
⚠ 1 name conflict(s) skipped (use --force):
⚠ - myproject (virtualenvwrapper) conflicts with /home/u/.scuv/virtualenvs/myproject
• → Pass --force to overwrite existing scuv environments
CI gate (fail the build on batch failure)
# Exits 2 if any env failed or a name conflict was skipped.
# Exits 3 if no source tool is installed on the runner.
scuv migrate all --yes
JSON Output
All three subcommands accept --json. The envelope follows scuv’s standard
shape: {status, command, data} on success; {status: "error", command, error: { code, message, ... }, data} on failure paths that already
rendered structured data.
migrate list --json
data carries the requested source filter string ("pyenv",
"virtualenvwrapper", "conda", or "all"), the full environments
array, and a summary bucketed by status.
{
"status": "success",
"command": "migrate list",
"data": {
"source": "all",
"environments": [
{
"name": "myproject",
"python_version": "3.12",
"path": "/home/u/.virtualenvs/myproject",
"source_type": "virtualenv_wrapper",
"size_bytes": null,
"status": { "status": "ready" }
},
{
"name": "oldenv",
"python_version": "3.7.17",
"path": "/home/u/.virtualenvs/oldenv",
"source_type": "virtualenv_wrapper",
"size_bytes": null,
"status": { "status": "python_eol", "version": "3.7.17" }
}
],
"summary": { "total": 2, "ready": 1, "conflict": 0, "eol": 1, "corrupted": 0 }
}
}
The status field is an object whose own status key names the state:
{"status": "ready"}, {"status": "name_conflict", "existing": "<path>"},
{"status": "python_eol", "version": "<version>"}, or
{"status": "corrupted", "reason": "<reason>"}. source_type is
"pyenv", "virtualenv_wrapper" or "conda". size_bytes is lazily
computed and may be null if not yet requested.
migrate all --json — success path
MigrateAllData carries five top-level data keys. conflicts[] (new in
0.14) is additive — name-conflict envs continue to appear in
skipped[] for backward compatibility, and summary.total == migrated.len() + failed.len() + skipped.len() still holds. conflicts[]
is a structured view so consumers can branch on the failure class
without parsing the localized reason string in skipped[].
{
"status": "success",
"command": "migrate all",
"data": {
"migrated": [
{
"name": "myproject",
"python_version": "3.12",
"packages_migrated": 1,
"packages_failed": [],
"dry_run": false,
"path": "/home/u/.scuv/virtualenvs/myproject",
"source_deleted": false,
"actual_python_version": "3.12"
}
],
"failed": [],
"skipped": [],
"conflicts": [],
"summary": { "total": 1, "success": 1, "failed": 0, "skipped": 0 }
}
}
actual_python_version currently repeats the source env’s
python_version. source_deleted reflects whether --delete-source was
honored for this env. dry_run mirrors the flag the command was
invoked with.
migrate all --json — failure path (exit 2)
Returned when at least one per-env failure occurred, or at least one
preflight name conflict was detected without --force. The envelope
embeds the full data view so consumers don’t lose detail on the
failure side either.
{
"status": "error",
"command": "migrate all",
"error": {
"code": "MIGRATE_BATCH_FAILED",
"message": "Migration finished with 1 failure(s) and 1 name conflict(s)",
"failed_count": 1,
"conflict_count": 1
},
"data": {
"migrated": [],
"failed": [
{
"name": "brokenpip",
"source_type": "virtualenv_wrapper",
"error_code": "MIGRATE_EXTRACTION_FAILED",
"error": "Couldn't extract packages: pip not found at /home/u/.virtualenvs/brokenpip/bin/pip"
}
],
"skipped": [
{ "name": "myproject", "reason": "name conflict (use --force)" }
],
"conflicts": [
{
"name": "myproject",
"source_type": "virtualenv_wrapper",
"existing": "/home/u/.scuv/virtualenvs/myproject"
}
],
"summary": { "total": 2, "success": 0, "failed": 1, "skipped": 1 }
}
}
Per-env failure objects carry two additive fields:
source_type("pyenv","virtualenv_wrapper","conda") — origin tool.error_code— the stableScoopError::code()constant (e.g."MIGRATE_EXTRACTION_FAILED","MIGRATE_NAME_CONFLICT","UV_COMMAND_FAILED"). Scripts branch on this instead of parsingerror(which is localized).
Exit-3 paths — no JSON envelope on stdout
Two distinct exit-3 cases exist; neither emits a JSON envelope on
stdout, even under --json. The localized error message and
install/lookup suggestion are written to stderr as plain text; stdout
stays empty. Detect via the exit code.
| Command | Error variant | Trigger |
|---|---|---|
migrate all | MigrationSourcesNotFound | No source tool detected at all (pyenv / virtualenvwrapper / conda) |
migrate @env <name> | PyenvEnvNotFound / VenvWrapperEnvNotFound / CondaEnvNotFound | The named env isn’t present in the requested (or any) source |
migrate @env <name> | CorruptedEnvironment | The named env exists but its layout is broken (missing python, broken pyvenv.cfg, etc) |
Script template:
scuv migrate all --json > out.json
case $? in
0) ;;
2) echo "batch failure — read out.json for detail" ;;
3) echo "no source tool installed" ;;
esac
The exit-2 path (batch failure) is the only migrate all failure path
that emits a structured envelope on stdout. Bridging the exit-3
asymmetry would require batch/ (and single.rs) to emit a JSON error
envelope before returning Err; tracked for a follow-up.
Migration Process
- Discovery: Scans configured source paths for virtual environments
- Extraction: Identifies Python version and installed packages
- Recreation: Creates new scuv environment with same Python version
- Package Install: Reinstalls packages using
uv pip install - Cleanup: Originals are preserved by default;
--delete-sourceremoves them after successful migration
Notes
- Original environments are preserved by default; use
--delete-sourceto remove sources after migration - Package versions are preserved where possible
- Migration creates fresh environments using
uvfor improved performance
Performance
scuv migrate all fans out across all CPU cores via rayon when migrating
more than one environment. The dominant cost (uv venv + pip install per env)
is I/O-bound on subprocesses, so wall-clock time scales close to linearly
with core count.
--dry-run stays sequential — preview output is more useful when ordered.
Progress lines may interleave when multiple envs finish close together. In
the JSON summary, the migrated[] and failed[] arrays are sorted
alphabetically by env name (so worker thread scheduling doesn’t leak into
the output). skipped[] and conflicts[] preserve the scan / partition
order — which itself is deterministic (source-type then name; see
scan_all_environments).
gc
Garbage-collect orphan virtual environments — directories under ~/.scuv/virtualenvs/ that no longer look like working environments, plus (optionally) environments that haven’t been activated in a while.
Usage
scuv gc # Preview orphans only (default)
scuv gc --yes # Actually remove orphans
scuv gc --aggressive # Also flag unused Python versions
scuv gc --aggressive --yes # Remove orphans + unused Pythons
scuv gc --older-than 30d # Also preview envs idle >30 days
scuv gc --older-than 6w --yes # Remove orphans + stale envs (≥6 weeks idle)
What counts as an orphan?
An environment directory is considered an orphan if either:
- It has no
.scoop-metadata.json(it wasn’t created by scuv, or the metadata was deleted), or - Its Python interpreter is missing (
bin/pythonon Unix /Scripts/python.exeon Windows) — typically because the Python version was uninstalled out from under it
Healthy environments are left untouched.
--aggressive
With --aggressive, gc also reports uv-managed Python versions that no surviving environment references. Pair with --yes to uninstall them via uv python uninstall.
An environment records the version it was created for. uv links environments to a minor version, so one recorded as 3.12 uses whichever 3.12.x is installed, and keeps every installed 3.12.x; one recorded as 3.12.1 keeps only 3.12.1. Pythons that uv does not manage (Homebrew, /usr/bin/python3) are never reported.
Without --aggressive, Python versions are never touched — even ones that look unused — because manually installed interpreters might be intentionally kept around for ad-hoc use.
When Pythons are left alone
gc reports a Python as unused only when it can tell which Python every remaining environment uses — every environment it lists that is not itself being cleaned up. When it cannot, it skips Python cleanup instead of guessing:
- The environment directory cannot be read — if
~/.scuv/virtualenvsis unreadable from the start,gcstops with an error before touching anything. If it becomes unreadable later, during the Python scan, a warning is printed and no Python is reported. - A remaining environment has unreadable metadata — its Python version is unknown, so a warning is printed and no Python is reported. A cleanup candidate with unreadable metadata does not count: it is going away.
Warnings are not printed under --json or --quiet; the JSON output then has an empty pythons array.
With --yes, the Python scan runs again right before uninstalling. Every environment it lists at that moment protects the Python it uses, including a candidate that gc decided to keep (see TOCTOU guard) or failed to remove. If this second scan cannot tell which Pythons are in use, nothing is uninstalled, and each Python left alone gets a warning and the JSON outcome skipped_in_use. If uv itself can no longer be found at that point, nothing is uninstalled either; those Pythons get the outcome skipped_no_uv.
--older-than <DURATION>
Flag environments whose last_used timestamp is older than the given duration. Accepts <n>d (days), <n>w (weeks = 7d), and <n>y (years = 365d). Examples: 30d, 2w, 1y.
Months are deliberately rejected — m is ambiguous between “minute” and “month”, and calendar months would require timezone-aware arithmetic for a marginal gain in accuracy on a stale-env heuristic. Use 30d or 1y instead.
The maximum allowed value is 200 years (200y); larger values are rejected to keep the resulting cutoff inside chrono’s representable range.
Note on system clock: the cutoff is
Utc::now() - <duration>, so the threshold moves with the system clock. A host whose clock is wrong (NTP compromise, manualdateset, or hibernated VM that woke up with a stale time) can shift which envsgc --older-thanconsiders stale. This is a best-effort heuristic, not a security boundary — pair it with--yesonly when you trust the clock.
Conservative rules
Two cases are never flagged as stale, by design:
last_used = None— fresh envs that have never been activated since the field landed, and envs whose metadata predates the field. Either way we have no positive evidence the env is unused.- Corrupt metadata — if we can’t read the metadata, we don’t pretend to know its age.
If you want to clean up un-activated envs anyway, surface them with
scuv list --sort last-used — envs missing last_used always sort to
the bottom — and remove individual ones with scuv remove <name>.
For scripted enumeration:
scuv list --json | jq -r '.data.virtualenvs[] | select(.last_used == null) | .name'
(Note: scuv verify checks per-env health — metadata / interpreter /
manifest drift — and intentionally does NOT flag a healthy env just
because it has never been activated.)
TOCTOU guard
Between the --older-than scan and the actual delete, an env may be activated. Each candidate is re-checked just before removal:
SkippedRecentlyUsed— the env was touched after the scan;last_usedis now at-or-newer than the original cutoff, so it is no longer stale.SkippedNoData— metadata became unreadable or missing between scan and remove. We refuse to delete envs we can no longer reason about.
Both surface in the JSON envelope as outcome values so scripts can distinguish them from Removed / Failed.
Options
| Option | Description |
|---|---|
-y, --yes | Actually remove the candidates (default: preview only) |
--aggressive | Also remove uv-managed Python versions that no environment uses |
--older-than <DURATION> | Also flag envs idle past the cutoff (30d / 2w / 1y) |
--json | Output as JSON |
Examples
# See what would be removed
scuv gc
# Sample output:
# • Orphan virtualenvs (2):
# - broken-env (Python interpreter missing) ~/.scuv/virtualenvs/broken-env
# - rogue-dir (no .scoop-metadata.json) ~/.scuv/virtualenvs/rogue-dir
# • (dry run — pass `--yes` to actually remove)
# Stale envs join the same list, in name order
scuv gc --older-than 30d
# Sample output:
# • Orphan virtualenvs (3):
# - broken-env (Python interpreter missing) ~/.scuv/virtualenvs/broken-env
# - old-poc (stale (62 days idle)) ~/.scuv/virtualenvs/old-poc
# - rogue-dir (no .scoop-metadata.json) ~/.scuv/virtualenvs/rogue-dir
# • (dry run — pass `--yes` to actually remove)
# Unused uv-managed Pythons get their own list
scuv gc --aggressive
# Sample output:
# • Orphan virtualenvs (2):
# - broken-env (Python interpreter missing) ~/.scuv/virtualenvs/broken-env
# - rogue-dir (no .scoop-metadata.json) ~/.scuv/virtualenvs/rogue-dir
# • Unused Python versions (2):
# - Python 3.13.1
# - Python 3.11.9
# • (dry run — pass `--yes` to actually remove)
# Actually clean up
scuv gc --yes
The - item lines go to stdout; the • header and footer lines go to
stderr, so scuv gc 2>/dev/null prints only the candidates. With nothing
to remove, gc prints ✓ Nothing to clean up — all environments look healthy (stderr).
JSON output
scuv gc --json
scuv gc --older-than 30d --json
{
"status": "success",
"command": "gc",
"data": {
"dry_run": true,
"envs": [
{ "name": "broken-env", "path": "/Users/x/.scuv/virtualenvs/broken-env", "reason": "broken_python", "outcome": "pending" },
{ "name": "old-poc", "path": "/Users/x/.scuv/virtualenvs/old-poc", "reason": "stale", "age_days": 62, "outcome": "pending" },
{ "name": "rogue-dir", "path": "/Users/x/.scuv/virtualenvs/rogue-dir", "reason": "missing_metadata", "outcome": "pending" }
],
"pythons": []
}
}
reason stays a flat string for all variants — orphans use the
existing "missing_metadata" / "broken_python" values; stale
records add "stale" plus a sibling age_days integer. Old consumers
that match on reason keep working unchanged; the only additive
change is the new outcome values skipped_recently_used and
skipped_no_data.
See also
prune— clean the uv cachedoctor— diagnose without removingremove— delete a specific environment by name
prune
Prune the uv cache — deletes unused download archives, wheels, and source artifacts that uv has cached but no longer needs.
Usage
scuv prune
This is a thin wrapper around uv cache prune. uv decides what’s safe to delete; scuv just forwards the result so you don’t have to remember the exact invocation.
When to use
- After uninstalling Python versions you no longer need
- When disk space on
~/.cache/uv/is filling up - As part of regular cleanup, paired with
scuv gcfor orphan virtualenvs
Options
| Option | Description |
|---|---|
--json | Output the result as JSON |
Examples
# Standard cleanup
scuv prune
# Capture freed-bytes for a script
scuv prune --json | jq -r '.data.output'
See also
verify
Verify the health of one or all virtual environments. Where doctor checks system-wide setup (uv installed, shell wrapper wired up, etc.), verify looks inside each env directory and answers: “does Python actually work in here?”
Usage
scuv verify # Check every environment
scuv verify <NAME> # Check just one environment
scuv verify --json # Machine-readable output
scuv verify --strict # Exit 1 if any check has Fail status (default: always 0)
What gets checked
Six checks run per environment, in order:
| Check | Status | What it means |
|---|---|---|
metadata | Fail | .scoop-metadata.json is missing or unreadable |
python_binary | Fail | bin/python (Scripts/python.exe on Windows) is missing |
pyvenv_cfg | Fail | pyvenv.cfg venv marker is missing |
activate_script | Fail | bin/activate (Scripts/Activate.ps1 on Windows) is missing |
python_executes | Fail | python --version fails to run (Skip if the binary is already missing) |
manifest_match | Warn | env’s Python doesn’t match .scuv.toml if a manifest exists in the cwd hierarchy (Skip otherwise) |
A check returns one of:
- Pass — everything looks right
- Skip — irrelevant for this env (e.g. no
.scuv.toml, or the prerequisite already failed) - Warn — soft issue; env may still work
- Fail — hard breakage; env likely unusable
An env is considered healthy when every check is Pass or Skip.
Exit codes
By default, verify always exits 0 — even when checks fail. This matches doctor’s philosophy: surfacing information shouldn’t break CI just because someone wanted to look at the report. Pass --strict to opt into exit 1 when any env has at least one Fail check (Warn alone does not trigger the non-zero exit).
verify vs doctor vs gc
| Command | Scope | Action |
|---|---|---|
doctor | System (uv install, shell wrapper, paths) | Diagnose + optional --fix |
verify | Per-env (file presence, exec, manifest) | Diagnose only |
gc | All envs (orphan detection) | Diagnose + optional removal |
verify is the “what’s wrong with this specific env?” tool. gc is the “which envs are so broken they should just be removed?” tool. They share territory but differ in granularity and intent.
Examples
# Quick health check on every env
scuv verify
# Specific env, JSON for scripting
scuv verify myproject --json | jq '.data.envs[0].healthy'
# CI gate: fail the build if any env is broken
scuv verify --strict
JSON output
{
"status": "success",
"command": "verify",
"data": {
"envs": [
{
"name": "myenv",
"healthy": true,
"python": "3.12",
"checks": [
{ "name": "metadata", "status": "pass" },
{ "name": "python_binary", "status": "pass" },
{ "name": "pyvenv_cfg", "status": "pass" },
{ "name": "activate_script", "status": "pass" },
{ "name": "python_executes", "status": "pass" },
{ "name": "manifest_match", "status": "skip" }
]
}
],
"summary": { "total": 1, "healthy": 1, "warnings": 0, "issues": 0 }
}
}
summary puts each env in one bucket: healthy (every check Pass or
Skip), warnings (at least one Warn, no Fail) or issues (at least one
Fail). Only issues makes --strict exit 1.
The human report puts the per-env lines on stdout and the closing summary on stderr:
✓ myproject (Python 3.12)
• 1 env(s) checked: 1 healthy, 0 with issues
See also
doctor— system-level diagnosticsgc— remove broken envs detected hereinfo— detailed view of a single env (without health checks)
diff
Compare two virtualenvs across Python version, installed packages, and metadata. Useful for spotting drift between teammates’ envs, between a dev env and an export, or between a baseline and a working env when something breaks.
Usage
scuv diff <env-a> <env-b> # human table, exit 0
scuv diff <env-a> <env-b> --json # machine-readable
scuv diff <env-a> <env-b> --strict # exit 1 if any diff
scuv diff <env-a> <env-b> --packages-only # skip metadata section
scuv diff <env-a> <env-b> --metadata-only # skip package enumeration
What gets compared
Three independent sections, all enabled by default:
| Section | Fields |
|---|---|
| Python | python_version from each env’s metadata |
| Packages | name==version set via uv pip list --format=json on each env |
| Metadata | python_version, created_at, last_used, uv_version |
Package matching uses PEP 503
canonical names (lowercase, -/_/. collapsed to single -), so
Requests vs requests and flask_sqlalchemy vs Flask-SQLAlchemy
are treated as the same package.
Options
| Option | Description |
|---|---|
--json | Output as JSON (see JSON Output) |
--strict | Exit 1 if any differences are detected (default: always exit 0) |
--packages-only | Skip the metadata section; package enumeration still runs |
--metadata-only | Skip package enumeration (no uv subprocess); metadata section only |
--packages-only and --metadata-only are mutually exclusive
(clap-enforced).
Global flags (--quiet, --color, --no-color) apply.
Exit codes
scuv diff follows the layered exit-code contract:
| Code | Returned when |
|---|---|
0 | Default — diff reported (even if envs differ) |
1 | --strict was set AND at least one difference was detected (DiffMismatch). Same precedent as verify --strict. |
Operational failures (env not found, uv missing, corrupt metadata)
return the appropriate underlying ScoopError variant via the
catchall exit 1 path; --strict is not required for those.
Examples
Identical envs
$ scuv diff webapp webapp-mirror
• Environments are identical
Mixed differences
$ scuv diff webapp webapp-mirror
• webapp vs webapp-mirror
•
• Python
• ~ python a: 3.12 b: 3.11
•
• Packages (3 differences)
• - six==1.17.0
• + certifi==2026.7.22
• ~ idna: 3.6 → 3.7
•
• Metadata
• ~ python_version a: 3.12 b: 3.11
• ~ created_at a: 2026-01-10T09:12:44.398746+00:00 b: 2026-03-22T14:05:31.266187+00:00
• last_used a: - b: -
• uv_version a: uv 0.x.y (<commit> <date> <target>) b: uv 0.x.y (<commit> <date> <target>)
The ~ marker flags changed scalar fields; -/+ flag removed/added
packages; absent metadata values render as -.
The human report goes to stderr, one • -prefixed line each; stdout
stays empty unless --json is set.
CI gate (fail the build on drift)
scuv diff baseline production --strict
# exits 1 if baseline and production diverge
JSON output
--json emits one envelope to stdout. Success and failure envelopes
share the same data shape; only the top-level wrapper differs.
Success envelope (default exit 0)
{
"status": "success",
"command": "diff",
"data": {
"env_a": "webapp",
"env_b": "webapp-mirror",
"identical": false,
"python": { "a": "3.12", "b": "3.11", "changed": true },
"packages": {
"added": [{"name": "certifi", "version": "2026.7.22", "display_name": "certifi"}],
"removed": [{"name": "six", "version": "1.17.0", "display_name": "six"}],
"changed": [{"name": "idna", "version_a": "3.6", "version_b": "3.7"}]
},
"metadata": {
"python_version": { "a": "3.12", "b": "3.11", "changed": true },
"created_at": { "a": "2026-01-10T09:12:44.398746+00:00", "b": "2026-03-22T14:05:31.266187+00:00", "changed": true },
"last_used": { "a": null, "b": null, "changed": false },
"uv_version": { "a": "uv 0.x.y (<commit> <date> <target>)", "b": "uv 0.x.y (<commit> <date> <target>)", "changed": false }
},
"summary": {
"differences": 5,
"python_changed": true,
"packages_added": 1,
"packages_removed": 1,
"packages_changed": 1,
"metadata_fields_changed": 1
}
}
}
metadata_fields_changed counts created_at, last_used and
uv_version only. metadata.python_version repeats data.python and
is left out, so a Python mismatch adds 1 to differences, not 2.
Strict failure envelope (--strict with differences, exit 1)
{
"status": "error",
"command": "diff",
"error": {
"code": "DIFF_MISMATCH",
"message": "'webapp' and 'webapp-mirror' differ (5 difference(s))",
"env_a": "webapp",
"env_b": "webapp-mirror",
"differences": 5
},
"data": { "...": "same shape as success" }
}
Scalar diff fields — 2-state contract
Each metadata field in data.metadata is a ScalarDiff carrying both
sides as Option<T>:
a | b | changed |
|---|---|---|
"x" | "x" | false |
"x" | "y" | true |
"x" | null | true |
null | null | false |
The null side means the value is not observable on that side —
regardless of whether the metadata file was missing, the field was
absent, or the field was present-as-null in the JSON. Diff intentionally
does not surface the cause; if you need to know why, run scuv info
on the env directly. (The data.packages shape uses non-nullable
inner objects because pip never emits a “package present but no
version” state.)
Mode-suppressed sections
--packages-only→data.metadataisnull.--metadata-only→data.packagesisnull; nouvsubprocess is spawned.
data.python and data.summary are always present.
See also
verify— per-env health checks (different scope: one env at a time, not pairwise)info— detailed view of a single envlist— enumerate all envsapi.md— full process exit-code contract
lang
Get or set the display language for scuv CLI messages.
Usage
# Show current language
scuv lang
# Set language
scuv lang <code>
# List supported languages
scuv lang --list
# Reset to system default
scuv lang --reset
Arguments
| Argument | Description |
|---|---|
<code> | Language code to set (e.g., en, ko) |
Options
| Option | Description |
|---|---|
--list | List all supported languages |
--reset | Reset to system default language |
--json | Output as JSON |
Supported Languages
| Code | Language |
|---|---|
en | English (default) |
ko | 한국어 (Korean) |
ja | 日本語 (Japanese) |
pt-BR | Português (Brazilian Portuguese) |
es | Español (Spanish) |
Language Detection Priority
SCUV_LANGenvironment variable~/.scuv/config.jsonsetting- System locale (via
sys-locale) - Default:
en
Examples
Show Current Language
$ scuv lang
Current: en (English)
Set Korean
$ scuv lang ko
✓ 언어가 ko(으)로 설정됨
The confirmation is printed in the language you just selected.
List Languages
$ scuv lang --list
Supported languages:
* en English
ko 한국어
pt-BR Português (Brasil)
ja 日本語
es Español
The * marks the current language.
Reset to System Default
$ scuv lang --reset
✓ Reset to system default: en
JSON Output
$ scuv lang --json
{
"status": "success",
"command": "lang",
"data": {
"current": "ko",
"name": "한국어"
}
}
Configuration
Language preference is stored in:
~/.scuv/config.json
{"lang": "ko"}
Environment Variable Override
# Temporarily use English regardless of config
SCUV_LANG=en scuv list
# Set for current session
export SCUV_LANG=ko
Notes
- CLI help text (
--help) remains in English (industry standard) - JSON output keys remain in English (machine-readable)
- Error messages, success messages, and prompts are translated
self update
Reinstall scuv from crates.io to update (or pin) the installed version.
Usage
scuv self update [OPTIONS]
Options
| Option | Description |
|---|---|
--force | Reinstall even if already on the latest version |
--version <VERSION> | Install a specific version instead of the latest |
--no-verify | Skip the post-update scuv doctor verification |
--json | Output result as JSON |
Behavior
- Queries crates.io (via
cargo search) for the latestscoop-uvversion. - Runs
cargo install --force --locked scoop-uv --version <target>. - Unless
--no-verifyis set, runsscuv doctorwith the freshly installed binary and reports the outcome.
Use --force to reinstall the current version (useful for repairing a broken install). Use --version to pin to a specific release.
Examples
# Update to the latest release
scuv self update
# Pin to a specific version
scuv self update --version 0.14.0
# Force reinstall of the current version
scuv self update --force
# Machine-readable output
scuv self update --json
JSON Output
{
"status": "success",
"command": "self update",
"data": {
"from": "0.14.0",
"to": "0.14.1",
"skipped": false,
"verify": { "status": "passed" }
}
}
The verify.status field is one of skipped, passed, warned, errored, or launch_failed.
See also
scuv doctor— diagnose the current installation
init
Output shell initialization script.
Usage
scuv init <shell>
Arguments
| Argument | Required | Description |
|---|---|---|
shell | Yes | Shell type: bash, zsh, fish, powershell (alias: pwsh) |
Setup
Add to your shell configuration:
# Bash (~/.bashrc)
eval "$(scuv init bash)"
# Zsh (~/.zshrc)
eval "$(scuv init zsh)"
# Fish (~/.config/fish/config.fish)
scuv init fish | source
# PowerShell ($PROFILE)
Invoke-Expression (& scuv init powershell | Out-String)
Features Enabled
- Auto-activation when entering directories with
.scuv-version - Tab completion for commands, environments, and options
- Wrapper function for
activate/deactivate/use
Examples
scuv init bash # Output bash init script
scuv init zsh # Output zsh init script
scuv init fish # Output fish init script
scuv init powershell # Output PowerShell init script
shell
Set shell-specific environment (current shell session only).
Unlike scuv use which writes to a file, scuv shell sets the SCUV_VERSION environment variable for the current shell session only.
Usage
eval "$(scuv shell --shell bash <name>)" # Bash (Zsh: --shell zsh)
scuv shell --shell fish <name> | source # Fish (fish does not export FISH_VERSION, so name the shell)
Note: If you have shell integration set up (
scuv init), theevalis automatic:scuv shell myenv # Works directly
Arguments
| Argument | Required | Description |
|---|---|---|
name | No | Environment name or system |
Options
| Option | Description |
|---|---|
--unset | Clear shell-specific environment |
--shell <SHELL> | Target shell type (auto-detected if not specified) |
Behavior
- Sets
SCUV_VERSIONenvironment variable - If
nameis an environment: also outputs activation script - If
nameissystem: also outputs deactivation script --unset: outputsunset SCUV_VERSION
Priority
SCUV_VERSION has the highest priority in version resolution:
1. SCUV_VERSION env var <- scuv shell (highest)
2. .scuv-version file <- scuv use
3. ~/.scuv/version <- scuv use --global
This means scuv shell overrides any file-based settings until:
- You run
scuv shell --unset - You close the terminal
Examples
# Use a specific environment in this terminal
scuv shell myproject
# Use system Python in this terminal
scuv shell system
# Clear the shell setting (return to file-based resolution)
scuv shell --unset
# Explicit shell type
scuv shell --shell fish myenv
Use Cases
Temporary Testing
# Currently using myproject
scuv shell testenv # Switch to testenv temporarily
python test.py # Test something
scuv shell myproject # Switch back
Override Project Settings
cd ~/project # Has .scuv-version = projectenv
scuv shell system # Use system Python anyway
python --version # System Python
scuv shell --unset # Back to projectenv
completions
Generate shell completion script.
Usage
scuv completions <shell>
Arguments
| Argument | Required | Description |
|---|---|---|
shell | Yes | Shell type: bash, zsh, fish, powershell (alias: pwsh) |
Examples
scuv completions bash # Output bash completions
scuv completions zsh # Output zsh completions
scuv completions fish # Output fish completions
scuv completions powershell # Output PowerShell completions
# PowerShell — add to $PROFILE
scuv completions powershell | Out-String | Invoke-Expression
Tip: Usually you don’t need this separately -
scuv initincludes completions.
man
Generate Unix man pages from scuv’s clap::Command tree. Because they’re rendered from the live CLI definition, the man pages always reflect the actual --help text — no separate documentation to keep in sync.
Usage
# Print the top-level scuv.1 to stdout
scuv man
# Preview with `man -l`
scuv man | man -l -
# Write scuv.1 + scuv-<sub>.1 (one per subcommand) into a directory
scuv man /tmp/scuv-man
Arguments
| Argument | Description |
|---|---|
[DIR] | Write scuv.1 + scuv-<sub>.1 files into this directory. Omit to print the top-level page to stdout. |
Options
| Option | Description |
|---|---|
--json | Output as JSON; requires DIR (without it, clap rejects the call with exit 2) |
Packager usage
Distro packagers can wire this into their build recipe:
# In your build script
mkdir -p $PKG_DIR/usr/share/man/man1
./scuv man $PKG_DIR/usr/share/man/man1
gzip -9 $PKG_DIR/usr/share/man/man1/*.1
Hidden subcommands (activate, deactivate, resolve — internal to the shell wrapper) are intentionally not rendered: they’re not user-facing.
See also
completions— shell completion scripts (also generated fromclap::Command)
Contributing
Guide for contributing to scuv development.
Prerequisites
Setup
# Clone the repository
git clone https://github.com/ai-screams/scoop-uv.git
cd scoop-uv
# Install prek (Rust-native pre-commit alternative)
uv tool install prek
# or: cargo install prek
# Install git hooks
prek install
# Build
cargo build
# Run tests
cargo test
Project Structure
src/
├── main.rs # Entry point
├── lib.rs # Library root
├── error/ # Error types (ScoopError)
│ └── exit.rs # Exit code policy
├── paths.rs # Path utilities
├── validate.rs # Name/version validation
├── uv/ # uv client wrapper
│ ├── mod.rs
│ └── client.rs
├── core/ # Business logic
│ ├── mod.rs
│ ├── virtualenv/ # VirtualenvService (mod.rs + tests.rs)
│ ├── version.rs # VersionService
│ ├── metadata.rs # Metadata structs
│ ├── manifest.rs # Sync manifest (.scuv.toml)
│ ├── export_schema.rs # Export/import schema
│ └── doctor/ # Health diagnostics (engine, types, checks/)
├── cli/ # CLI layer
│ ├── mod.rs # Cli struct, Commands enum
│ └── commands/ # Command handlers
│ ├── mod.rs
│ ├── list.rs
│ ├── create.rs
│ ├── use_env/ # Use command (normal, system, unset, symlink)
│ ├── remove.rs
│ ├── install.rs
│ ├── doctor.rs
│ ├── migrate/ # Migration subcommands
│ └── ...
├── shell/ # Shell integration
│ ├── mod.rs
│ ├── common.rs # Shared utilities (version check macros)
│ ├── bash.rs
│ ├── zsh.rs
│ ├── fish.rs
│ └── powershell.rs
└── output/ # Output formatting
├── mod.rs
├── color.rs # --color decision
└── time.rs # last_used fuzzy-age formatter
docs/ # Public documentation
.docs/ # Internal technical docs
tests/ # Integration tests
Common Commands
Build and Run
cargo build # Debug build
cargo build --release # Release build (optimized)
cargo run -- --help # Show help
cargo run -- list # Run commands
cargo run -- doctor # Check setup health
Quick Quality Check
# All checks at once (recommended before commit)
cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings && cargo test
For detailed guides, see:
- Testing - Comprehensive testing guide
- Code Quality - Formatting, linting, pre-commit hooks
Architecture
Key Services
VirtualenvService (src/core/virtualenv/)
- Manages virtualenvs in
~/.scuv/virtualenvs/ - Wraps uv commands for venv creation
VersionService (src/core/version.rs)
- Manages
.scuv-versionfiles - Resolves current directory to active environment
Doctor (src/core/doctor/)
- Health diagnostics for scuv setup
- Checks uv, shell integration, paths, environments
UvClient (src/uv/client.rs)
- Wrapper for
uvCLI commands - Python version management
Shell Integration
Shell scripts are embedded in Rust code:
src/shell/bash.rs- Bash init scriptsrc/shell/zsh.rs- Zsh init script
Key components:
- Wrapper function - Intercepts
use/activate/deactivate - Hook function - Auto-activation on directory change
- Completion function - Tab completion
Adding a New Command
- Define command in
src/cli/mod.rs:
#![allow(unused)]
fn main() {
#[derive(Subcommand)]
pub enum Commands {
// ...
MyCommand {
#[arg(short, long)]
option: bool,
},
}
}
- Create handler in
src/cli/commands/my_command.rs:
#![allow(unused)]
fn main() {
pub fn execute(output: &Output, option: bool) -> Result<()> {
// Implementation
Ok(())
}
}
-
Export in
src/cli/commands/mod.rs -
Wire up in
src/main.rs -
Add shell completion in
src/shell/{bash,zsh}.rs
Testing
Unit Tests
Located within source files:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_something() {
// ...
}
}
}
Integration Tests
Located in tests/:
#![allow(unused)]
fn main() {
use assert_cmd::Command;
#[test]
fn test_cli_command() {
Command::cargo_bin("scuv")
.unwrap()
.args(["list"])
.assert()
.success();
}
}
Release Process
Releases are automated via release-plz:
- Create PR with changes
- Merge to main
- release-plz creates release PR
- Merge release PR to publish to crates.io
Internal Documentation
See .docs/ for internal technical references:
adr/- Architecture decision recordsspec/- Naming and CLI option specsdev/- Code quality, documentation and testing strategy notesplan/- Feature scope and roadmapref/,design/,research/,wip/- Working notesbrand/brand.md- Brand guidelines
Code Style
- Follow Rust conventions
- Run
cargo fmtbefore committing - Keep functions small and focused
- Document public APIs with
///comments - Use
thiserrorfor error types - Translated error messages with solutions (en, ko, ja, pt-BR, es)
Translation Guide
This document provides guidelines for contributing translations to scuv.
Current Status
For the latest translation status, see:
- Issue #42: i18n Translation Tracking
- Run
scuv lang --listto see currently supported languages
Contribution Process
Step 1: Fork and Clone
git clone https://github.com/YOUR_USERNAME/scoop-uv.git
cd scoop-uv
Step 2: Add Translations
Edit locales/app.yml and add your language to every key:
create.success:
en: "Created '%{name}' environment"
ko: "'%{name}' 환경 생성됨"
pt-BR: "Ambiente '%{name}' criado"
ja: "'%{name}' 環境を作成しました"
es: "Entorno '%{name}' creado"
{ lang }: "Your translation here" # Add your language code and translation
Important:
- Add translations to all 221 keys
- Keep placeholder syntax exactly:
%{name},%{version}, etc. - Preserve special characters:
→, quotes, backticks
The key count grows between releases. For the current number:
grep -c '^[a-z_][a-zA-Z0-9_.]*:$' locales/app.yml
Step 3: Register Language
Edit src/i18n.rs and add your language to SUPPORTED_LANGS:
#![allow(unused)]
fn main() {
pub const SUPPORTED_LANGS: &[(&str, &str)] = &[
("en", "English"),
("ko", "한국어"),
("pt-BR", "Português (Brasil)"),
("ja", "日本語"),
("es", "Español"),
("{lang}", "Your Language Name"), // Add your language
];
}
Language Code Format:
- Use BCP 47 format
- Simple languages:
ja,fr,es,de,it - Regional variants:
pt-BR,zh-CN,zh-TW,es-MX
Three more places list the supported locales. The first one matters most.
1. tests/i18n_completeness.rs — add your code to the LOCALES const:
#![allow(unused)]
fn main() {
const LOCALES: &[&str] = &["en", "ko", "ja", "pt-BR", "es", "{lang}"];
}
This is the CI gate that checks every key exists in every locale. If your language is missing from this list, CI passes while your translation goes completely unverified — nothing tells you it was skipped.
2. Shell completions — the scuv lang candidate lists are hand-written
in all four shells:
src/shell/fish.rs— thecomplete -c scuv ... from langlinessrc/shell/zsh.rs— thelangs=(...)arraysrc/shell/bash.rs— thecompgen -W "en ko ja pt-BR es"list underlang)src/shell/powershell.rs— the@('en', 'ko', 'ja', 'pt-BR', 'es')array
Each shell module has a test (lang_completion_list_matches_supported_langs)
that compares its list with SUPPORTED_LANGS, so a shell you miss fails
cargo test instead of surfacing when a user presses Tab.
3. Locale loops in tests (optional) — src/error/tests.rs and
src/error/suggestion.rs iterate the supported locales. Adding yours gives
your translation unit-level coverage. Skip it if you would rather not touch
Rust, and a maintainer can add it during review.
Step 4: Test Locally
rust-i18n’s proc macro is not tracked by cargo, so editing locales/app.yml
on its own does not trigger a rebuild. cargo test reuses the stale binary and
reports a false pass. Always touch src/lib.rs first.
# Build and test (the touch is required — see the note above)
touch src/lib.rs
cargo build
cargo test
# Test your language (replace {lang} with your language code)
SCUV_LANG={lang} ./target/debug/scuv --help
SCUV_LANG={lang} ./target/debug/scuv lang
Step 5: Create Pull Request
Required files in PR:
-
locales/app.yml- All 221 keys translated -
src/i18n.rs- Language registered in SUPPORTED_LANGS -
tests/i18n_completeness.rs- Language added to LOCALES -
src/shell/bash.rs,src/shell/zsh.rs,src/shell/fish.rs,src/shell/powershell.rs- Completion lists updated
PR Title Format:
feat(i18n): add {Language Name} translation
feat, not docs: a new language is a user-visible feature, and the
changelog generator files it under “Added” only for feat commits.
Style Guidelines
Philosophy: Your Language, Your Style
We trust translators. You know your language and community best.
- Word choice is yours — Pick terms that feel natural to native speakers
- Creativity welcome — Witty expressions are fine if they’re clear and widely understood
- Casual over formal — scuv is a friendly CLI tool, not enterprise software
General Principles
- Concise: CLI messages should be short and clear
- Natural: Use natural phrasing, not word-for-word translation
- Casual: Friendly, approachable tone — like talking to a colleague
- Clear: Wit is great, but clarity comes first
Tone Examples
# Too formal (avoid)
"The environment has been successfully created."
# Too robotic (avoid)
"Environment creation: complete."
# Good - casual and clear
"Created 'myenv' — ready to go!"
"'myenv' is ready"
Message Types
| Type | English Example | Guidance |
|---|---|---|
| Progress | “Installing…” | Use progressive/ongoing form |
| Success | “Created ‘myenv’” | Completion — feel free to add flair |
| Error | “Can’t find ‘myenv’” | Clear and actionable |
| Hint | “→ Create: scuv create…” | Helpful, not lecturing |
Translator’s Discretion
These decisions are up to you:
- Vocabulary: Choose words that resonate with your community
- Idioms: Use local expressions if they fit naturally
- Humor: Light wit is welcome (e.g., ice cream puns if appropriate)
- Formality level: Lean casual, but match your culture’s CLI norms
Only requirement: The meaning must be clear to users.
Technical Terms
For technical vocabulary:
- Check your community — What do Python developers in your language use?
- Consistency — Pick one term and stick with it throughout
- Loanwords OK — If your community uses English terms (e.g., “install”), that’s fine
Tip: Study existing translations in locales/app.yml for reference, but don’t feel bound by them.
Glossary
Do NOT Translate
These terms should remain in English in all languages:
| Term | Reason |
|---|---|
scuv | Brand name |
uv | Tool name |
pyenv | Tool name |
conda | Tool name |
virtualenv | Technical term |
virtualenvwrapper | Tool name |
Python | Language name |
shell | Technical term (bash, zsh, fish) |
JSON | Format name |
PATH | Environment variable |
pip | Tool name |
Commands - Never Translate
All commands and code examples must stay in English:
# WRONG - Command translated
hint: "→ Create: {translated_command} myenv 3.12"
# CORRECT - Only description translated
hint: "→ {translated_word}: scuv create myenv 3.12"
Common Terms to Translate
These are core concepts you’ll need to translate. Reference existing translations for consistency:
| English | What to look for |
|---|---|
| environment | Your language’s term for “environment” |
| create | Common verb for “make/create” |
| remove/delete | Common verb for “delete/remove” |
| install | Standard software installation term |
| uninstall | Standard software removal term |
| activate | Term for “enable/turn on” |
| deactivate | Term for “disable/turn off” |
| migrate | IT term for migration (often kept as loanword) |
| version | Your language’s term for “version” |
| path | Your language’s term for file path |
| error | Your language’s term for “error” |
| success | Your language’s term for “success” |
Tip: Check how these terms are translated in existing translations for reference.
Ice Cream Metaphor (README only)
scuv uses ice cream metaphors in documentation:
| Term | Meaning | Guidance |
|---|---|---|
| scuv | The tool | Always keep as “scuv” |
| flavor | virtualenv | Translate if the metaphor works in your language |
| freezer | ~/.scuv/ directory | Translate if the metaphor works |
Note: The metaphor is mainly in README.md, not in CLI messages (locales/app.yml).
File Structure
locales/app.yml
# Categories in order:
# 1. lang.* - Language command messages
# 2. create.* - Create command messages
# 3. remove.* - Remove command messages
# 4. list.* - List command messages
# 5. use.* - Use command messages
# 6. install.* - Install command messages
# 7. uninstall.* - Uninstall command messages
# 8. migrate.* - Migrate command messages
# 9. error.* - Error messages
# 10. suggestion.* - Suggestion/hint messages
src/i18n.rs
#![allow(unused)]
fn main() {
// Language detection priority:
// 1. SCUV_LANG environment variable
// 2. Config file (~/.scuv/config.json)
// 3. System locale
// 4. Default: "en"
pub const SUPPORTED_LANGS: &[(&str, &str)] = &[
("en", "English"),
// ... existing languages
// Add new languages here
];
}
Common Mistakes
1. Missing SUPPORTED_LANGS Registration
Symptom: Translation exists but scuv lang {code} doesn’t work
Fix: Add language to src/i18n.rs SUPPORTED_LANGS
2. Broken Placeholders
# WRONG - Missing placeholder
error: "Cannot find environment"
# CORRECT - Placeholder preserved
error: "Cannot find '%{name}' environment"
3. Translating Commands
# WRONG - Command translated
hint: "→ List: {translated} list"
# CORRECT - Only label translated
hint: "→ {Translated Label}: scuv list"
4. Inconsistent Key Coverage
All languages must have ALL keys. Missing keys fall back to English.
5. Missing LOCALES Registration
Symptom: CI is green, but nothing ever checked your locale
Fix: Add your code to the LOCALES const in tests/i18n_completeness.rs
6. Stale i18n Cache
Symptom: cargo test passes, but your new strings never show up
Fix: Run touch src/lib.rs before cargo test
Testing Checklist
Before submitting PR:
- All 221 keys translated
- All placeholders preserved (
%{name},%{version}, etc.) - Language registered in SUPPORTED_LANGS
- Language added to LOCALES in
tests/i18n_completeness.rs - Shell completion lists updated (bash, zsh, fish, PowerShell)
-
cargo buildsucceeds -
touch src/lib.rsrun, thencargo testpasses -
SCUV_LANG={code} scuv langshows your language - Messages display correctly in terminal
Questions?
- Open an issue: GitHub Issues
- See existing translations for reference:
locales/app.yml
Documentation Translation (mdBook)
For translating CLI strings (the 221 keys in
locales/app.ymlconsumed by the Rust binary), see translation instead. This page covers user-documentation translation only.
scuv’s user documentation lives in docs/src/*.md and is the
single English source of truth. Translations are layered on top
via gettext .po files
under docs/po/, processed by the
mdbook-i18n-helpers
preprocessor at render time. Untranslated strings automatically
fall back to English, so a partial translation is always
deployable.
URL layout
- English (canonical):
https://ai-scream.ai/scoop-uv/ - Korean:
https://ai-scream.ai/scoop-uv/ko/
The locale switcher in the top-right of every page jumps between the same page on each side.
Prerequisites
# macOS
brew install gettext # msginit / msgmerge / msgfmt
cargo install mdbook --version 0.5.3 --locked
cargo install mdbook-i18n-helpers --version 0.4.0 --locked
# Linux
sudo apt-get install -y gettext
# Then the same `cargo install` lines as above.
The cargo install step needs Rust 1.88 or newer (helpers’
upstream MSRV). The project’s rust-toolchain.toml pins 1.89, so the
toolchain already in the repo is new enough — no +stable
override needed.
Workflow: updating an existing translation
When you edit a page in docs/src/, the existing translations
need to learn about the new / changed strings.
cd docs
# 1. Re-extract template (overwrites docs/po/messages.pot).
MDBOOK_OUTPUT='{"xgettext": {}}' mdbook build -d po
# 2. Merge the new template into your locale's .po file.
# --backup=none avoids leaving a stray ko.po~ around.
msgmerge --update --backup=none po/ko.po po/messages.pot
# 3. Open po/ko.po in your editor. Look for:
# - new empty `msgstr ""` entries → add translations
# - "#, fuzzy" markers → review and remove flag
docs/po/messages.pot is in .gitignore — it’s regenerated on
every CI run, committing it would create churn. The locale .po
files (docs/po/ko.po, etc.) ARE committed; they’re the
translation memory.
Workflow: previewing a translated build
cd docs
# English (root)
mdbook build -d book
open book/index.html
# Korean (book/ko subdir)
MDBOOK_BOOK__LANGUAGE=ko mdbook build -d book/ko
open book/ko/index.html
mdbook serve works too if you want live reload:
MDBOOK_BOOK__LANGUAGE=ko mdbook serve -d book/ko
Workflow: adding a brand-new locale (e.g. ja)
cd docs
# Initialise an empty translation file.
msginit -i po/messages.pot -l ja -o po/ja.po --no-translator
# Translate msgids in po/ja.po (untranslated ones fall back to English).
# Add a CI build step for the new locale in
# .github/workflows/docs.yml, e.g.:
#
# - name: Build Japanese (book/ja)
# working-directory: docs
# env:
# MDBOOK_BOOK__LANGUAGE: ja
# run: mdbook build -d book/ja
#
# Then expand the locale switcher in docs/theme/head.hbs to
# include the new locale link.
CI guard: stale translations
The Verify ko translations are in sync step in
.github/workflows/docs.yml regenerates the pot template from
the current English source, runs msgmerge --update against
the committed ko.po, and fails the build if the result
differs. This means English edits land with their corresponding
.po updates in the same PR, or they don’t land at all.
To fix a CI failure on this step:
cd docs
MDBOOK_OUTPUT='{"xgettext": {}}' mdbook build -d po
msgmerge --update --backup=none po/ko.po po/messages.pot
git add po/ko.po
git commit --amend # or as a separate fixup commit
Style guidelines
The same casual-tone guidelines from translation
apply here. Don’t try to translate code samples or CLI commands
literally — only the prose around them. Code-block content is
shown verbatim regardless of locale; mdbook-i18n-helpers
intentionally does not interpolate translations inside fenced
code blocks for unmarked code, though it does extract code
comments (# Install Python) as separate msgids so you can
translate those if it helps readability.
Known limitations
- Search index is built per-locale. Korean search results only hit Korean pages, English only hits English. This is the intended mdBook behaviour.
- The locale switcher uses a JS-injected DOM element; users with
JS disabled won’t see it. They can navigate via direct URL
(
/scoop-uv/ko/...) or browser bookmarks. - mdbook-i18n-helpers’
xgettextdoesn’t extract HTML tables’ cell-by-cell content as separate msgids when the table is written in pipe-syntax — it extracts the whole table as a single block. For now this is acceptable; for fine-grained table translation we’d need to switch to per-row HTML tables in the source.
Architecture
scuv is built in Rust using a modular architecture.
Module Structure
src/
├── cli/ # Command-line interface
│ ├── mod.rs # Cli struct, Commands enum, ShellType
│ └── commands/ # Individual command handlers
├── core/ # Domain logic
│ ├── metadata.rs # Virtualenv metadata (JSON)
│ ├── version.rs # Version file resolution
│ ├── virtualenv/ # Virtualenv entity (mod.rs + tests.rs)
│ ├── doctor/ # Health check system (engine, types, checks/)
│ ├── manifest.rs # Sync manifest (.scuv.toml, backs `scuv sync`)
│ ├── export_schema.rs # Export/import schema (backs `scuv export`/`import`)
│ └── migrate/ # Migration from pyenv/conda/virtualenvwrapper
│ ├── mod.rs
│ ├── discovery.rs # Source detection
│ ├── migrator.rs # Migration orchestrator
│ └── ...
├── shell/ # Shell integration
│ ├── mod.rs # Shell module exports & detection
│ ├── common.rs # Shared shell utilities & macros
│ ├── bash.rs # Bash init script
│ ├── zsh.rs # Zsh init script
│ ├── fish.rs # Fish init script
│ └── powershell.rs # PowerShell init script
├── output/ # Terminal UI and JSON output
│ └── time.rs # last_used fuzzy-age formatter
├── uv/ # uv CLI wrapper
├── error/ # ScoopError module
│ └── exit.rs # Exit code policy
├── paths.rs # Path utilities
├── validate.rs # Input validation
├── i18n.rs # Internationalization
└── config.rs # Configuration management
Module Dependency Graph
graph TB
CLI[cli/] --> Core[core/]
CLI --> Shell[shell/]
CLI --> Output[output/]
CLI --> I18N[i18n]
CLI --> Config[config]
Core --> UV[uv/]
Core --> Paths[paths]
Core --> Error[error]
Core --> Validate[validate]
Core --> Config
Shell --> Paths
Output --> Error
Output --> I18N
UV --> Error
Config --> Paths
Config --> Error
style CLI fill:#e1f5ff
style Core fill:#fff3e0
style Shell fill:#f3e5f5
style Output fill:#e8f5e9
Key Components
CLI Layer (cli/)
- Uses clap for argument parsing
Clistruct defines global optionsCommandsenum defines subcommands- Each command has an
executefunction incommands/
Core Layer (core/)
| Module | Purpose |
|---|---|
doctor | Health check system with Check trait |
metadata | JSON metadata for virtualenvs |
version | Version file discovery and parsing |
virtualenv | Virtualenv entity and operations |
Shell Layer (shell/)
Generates shell scripts for integration:
init_script()- Returns shell initialization code- Wrapper function for
scuvcommand - Auto-activation hooks
- Tab completion definitions
Output Layer (output/)
Handles terminal output formatting:
- Colored output using owo-colors
- JSON output for scripting
- Progress indicators with indicatif
UV Layer (uv/)
Wraps the uv CLI for Python/virtualenv operations:
- Python installation
- Virtualenv creation
- Version listing
Design Patterns
Shell Eval Pattern
The CLI outputs shell code to stdout, which the shell evaluates:
# User runs
scuv activate myenv
# CLI outputs
export VIRTUAL_ENV="/Users/x/.scuv/virtualenvs/myenv"
export PATH="/Users/x/.scuv/virtualenvs/myenv/bin:$PATH"
export SCUV_ACTIVE="myenv"
# Shell wrapper evaluates this output
eval "$(command scuv activate --shell bash myenv)"
This pattern is used by pyenv, rbenv, and other version managers.
Error Handling
Uses thiserror for error types:
#![allow(unused)]
fn main() {
// Uses thiserror for Error trait derive
// Display impl is manual (not #[error] attributes) for i18n support
#[derive(Debug, Error)]
pub enum ScoopError {
VirtualenvNotFound { name: String },
VirtualenvExists { name: String },
// ...
}
// Manual Display implementation using rust-i18n
impl std::fmt::Display for ScoopError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Self::VirtualenvNotFound { name } => {
write!(f, "{}", t!("error.virtualenv_not_found", name = name))
}
// ...
}
}
}
}
Path Management
Centralizes path logic in paths.rs:
scoop_home()- ReturnsSCUV_HOMEor~/.scuvvirtualenvs_dir()- Returns virtualenvs directoryglobal_version_file()- Returns global version file path (~/.scuv/version)local_version_file(dir)- Returns local version file path in directory
Data Flow
Command Execution Flow
sequenceDiagram
participant User
participant CLI as CLI Parser
participant Cmd as Command Handler
participant Core as Core Logic
participant UV as UV Wrapper
participant Output as Output Formatter
User->>CLI: scuv create myenv 3.12
CLI->>Cmd: parse & dispatch
Cmd->>Core: VirtualenvService::create()
Core->>UV: uv venv create
UV-->>Core: success/error
Core-->>Cmd: Result<()>
Cmd->>Output: format response
Output-->>User: stdout/stderr
Shell Integration Flow
sequenceDiagram
participant User
participant Shell as Shell Wrapper
participant CLI as scuv CLI
participant Core as Core Logic
User->>Shell: scuv use myenv
Shell->>CLI: command scuv use myenv
CLI->>Core: resolve version & path
Core-->>CLI: env vars to export
CLI-->>Shell: echo shell script
Shell->>Shell: eval output
Shell-->>User: (myenv) $
Note over User: Environment activated
Version Resolution Flow
graph LR
Start([User runs command]) --> Env{SCUV_VERSION<br/>env set?<br/><small>shell hook</small>}
Env -->|Yes| Use[Use env value]
Env -->|No| Local{.scuv-version<br/>in current/parent<br/>dirs?}
Local -->|Yes| Use
Local -->|No| Global{~/.scuv/version<br/>exists?}
Global -->|Yes| Use
Global -->|No| None[No version<br/>system Python]
style Use fill:#c8e6c9
style None fill:#fff9c4
Note:
.python-versionis not supported. Version resolution walks up parent directories to find.scuv-version.
Health Check Flow
flowchart TD
Start([scuv doctor]) --> Init[Initialize Doctor]
Init --> Run[Run all checks]
Run --> UV{UV Check}
UV -->|Pass| Home{Home Check}
UV -->|Fail| Fix1[Suggest: install uv]
Home -->|Pass| Venv{Venv Check}
Home -->|Fail| Fix2[Auto-fix: mkdir]
Venv -->|Pass| Link{Symlink Check}
Venv -->|Warn| Warn1[Warn: corrupted env]
Link -->|Pass| Shell{Shell Check}
Link -->|Fail| Fix3[Auto-fix: remove broken links]
Shell -->|Pass| Ver{Version Check}
Shell -->|Warn| Warn2[Warn: not initialized]
Ver -->|Pass| Legacy{Legacy Check}
Ver -->|Warn| Warn3[Warn: invalid version file]
Legacy -->|Pass| Done([All checks passed])
Legacy -->|Warn| Warn4[Warn: leftover scoop state]
Fix1 --> Report[Generate Report]
Fix2 --> Report
Fix3 --> Report
Warn1 --> Report
Warn2 --> Report
Warn3 --> Report
Done --> Report
style Done fill:#c8e6c9
style Fix1 fill:#ffcdd2
style Fix2 fill:#ffcdd2
style Fix3 fill:#ffcdd2
style Warn1 fill:#fff9c4
style Warn2 fill:#fff9c4
style Warn3 fill:#fff9c4
Migration Architecture
scuv supports migrating environments from pyenv-virtualenv, virtualenvwrapper, and conda.
graph TD
Start([scuv migrate]) --> Detect[Detect Sources]
Detect --> Pyenv{pyenv-virtualenv}
Detect --> Venv{virtualenvwrapper}
Detect --> Conda{conda}
Pyenv -->|Found| P1[List ~/.pyenv/versions]
Venv -->|Found| V1[List $WORKON_HOME]
Conda -->|Found| C1[conda env list]
P1 --> Parse[Parse metadata]
V1 --> Parse
C1 --> Parse
Parse --> Create[Create in scuv]
Create --> Copy[Copy packages]
Copy --> Meta[Write metadata]
Meta --> Done([Migration complete])
Pyenv -->|Not found| Skip1[Skip]
Venv -->|Not found| Skip2[Skip]
Conda -->|Not found| Skip3[Skip]
Skip1 --> Check{Any source found?}
Skip2 --> Check
Skip3 --> Check
Check -->|Yes| Done
Check -->|No| Error[Error: No sources]
style Done fill:#c8e6c9
style Error fill:#ffcdd2
Dependencies
| Crate | Purpose |
|---|---|
| clap | Argument parsing & completion |
| clap_complete | Shell completion generation |
| serde | JSON serialization |
| serde_json | Metadata persistence |
| thiserror | Error type definitions |
| owo-colors | Terminal colors |
| indicatif | Progress bars |
| dialoguer | Interactive prompts |
| dirs | Home directory resolution |
| which | Binary lookup (uv, python) |
| regex | Version parsing & validation |
| walkdir | Directory traversal |
| rust-i18n | Internationalization (en, ko, ja, pt-BR, es) |
| sys-locale | System locale detection |
| chrono | Timestamp generation |
| rayon | Parallel scuv migrate all |
| toml | .scuv.toml manifest parsing (scuv sync) |
| clap_mangen | Man page generation (scuv man) |
Extension Points
Adding a New Shell
- Create
shell/myshell.rs:
#![allow(unused)]
fn main() {
pub fn init_script() -> &'static str {
// Return shell-specific initialization code as static string
r#"
Shell initialization code here
scuv() { ... }
"#
}
}
Note:
scuv completions <shell>is generated by clap viaclap_complete, but the completions built into the init scripts are hand-written per shell (_scuv_complete()inbash.rs,complete -c scuvlines infish.rs, thelangsarray inzsh.rs) — those need updating by hand when a subcommand or option changes.
- Add to
ShellTypeenum incli/mod.rs:
#![allow(unused)]
fn main() {
pub enum ShellType {
// ... existing
MyShell,
}
}
- Add detection to
shell::detect_shell()insrc/shell/mod.rs:
#![allow(unused)]
fn main() {
pub fn detect_shell() -> ShellType {
if env::var("MYSHELL_VERSION").is_ok() {
return ShellType::MyShell;
}
// ... existing checks
}
}
Adding a New Migration Source
- Create
core/migrate/mysource.rs:
#![allow(unused)]
fn main() {
pub struct MySource;
impl MySource {
pub fn detect() -> bool {
// Check if source is available
}
pub fn list_envs() -> Result<Vec<MigrationCandidate>> {
// Return list of environments
}
pub fn migrate_env(name: &str) -> Result<()> {
// Perform migration
}
}
}
- Register in
cli/commands/migrate/(itsmod.rs):
#![allow(unused)]
fn main() {
let sources = vec![
// ... existing
Box::new(MySource),
];
}
Adding a New Doctor Check
See API Reference - Adding a New Health Check for details.
Performance Characteristics
| Operation | Time Complexity | Notes |
|---|---|---|
| List envs | O(n) | n = number of virtualenvs |
| Create env | O(1)* | *Depends on uv performance |
| Delete env | O(1) | Simple directory removal |
| Version resolution | O(d) | d = directory depth (walks parents) |
| Doctor checks | O(n) | n = number of checks (fixed) |
Thread Safety
scuv is a single-threaded CLI application. No concurrent operations are performed.
File locking: Not implemented. Assumes single user on single machine. Concurrent operations (e.g., two terminals creating the same env) may result in race conditions.
Security Considerations
- Path Traversal: All user-provided names are validated via regex before use in filesystem operations.
- Command Injection: uv commands are constructed using typed arguments, not string concatenation.
- Symlink Safety: Doctor checks detect and warn about broken symlinks.
- Metadata Integrity: JSON parsing errors are gracefully handled without panics.
Related Documentation
- API Reference - Detailed API documentation
- Testing - Testing strategies
- Contributing - Development guide
API Reference
This document provides a reference for scuv’s public API, primarily intended for:
- AI/LLM tools analyzing or modifying the codebase
- Contributors extending scuv’s functionality
- Advanced users integrating scuv into custom tooling
Note: This is an internal API reference. For CLI usage, see Commands.
Core Types
VirtualEnv Module (core/virtualenv/)
VirtualenvInfo
Represents basic information about a virtual environment. Marked
#[non_exhaustive] since 0.14.0 — external Rust consumers can
no longer construct it with struct-literal syntax. Obtain instances
from VirtualenvService::list() (or any future read API) instead.
#![allow(unused)]
fn main() {
#[non_exhaustive]
pub struct VirtualenvInfo {
pub name: String,
pub path: PathBuf,
pub python_version: Option<String>,
pub created_at: Option<DateTime<Utc>>,
pub last_used: Option<DateTime<Utc>>,
}
}
Fields:
name- Environment name (e.g.,"myproject")path- Absolute path to virtualenv directorypython_version- Python version string if metadata exists (e.g.,Some("3.12.1"))created_at- Creation timestamp from metadata, if present (since 0.13.0)last_used- Most recentscuv activate/run/shelltouch timestamp, if present (since 0.13.0).Nonefor legacy envs that pre-date the field and fresh envs that have never been activated.
Example (consume-only, struct-literal construction is no longer permitted):
#![allow(unused)]
fn main() {
let service = VirtualenvService::auto()?;
for info in service.list()? {
println!("{} (last used: {:?})", info.name, info.last_used);
}
}
VirtualenvService
Primary service for virtualenv operations.
#![allow(unused)]
fn main() {
pub struct VirtualenvService {
uv: UvClient,
}
impl VirtualenvService {
/// Creates a new service with custom uv wrapper
pub fn new(uv: UvClient) -> Self
/// Creates a service using system's uv installation
pub fn auto() -> Result<Self>
/// Lists all virtualenvs
pub fn list(&self) -> Result<Vec<VirtualenvInfo>>
/// Creates a new virtualenv
pub fn create(&self, name: &str, python_version: &str) -> Result<PathBuf>
/// Creates a new virtualenv with a specific Python executable
pub fn create_with_python_path(&self, name: &str, python_version: &str, python_path: &Path) -> Result<PathBuf>
/// Deletes a virtualenv
pub fn delete(&self, name: &str) -> Result<()>
/// Checks if a virtualenv exists
pub fn exists(&self, name: &str) -> Result<bool>
/// Gets the path to a virtualenv
pub fn get_path(&self, name: &str) -> Result<PathBuf>
/// Reads metadata for a virtualenv (best-effort; collapses
/// missing and corrupt into `None`).
pub fn read_metadata(&self, path: &Path) -> Option<Metadata>
/// Reads metadata distinguishing missing (`Ok(None)`) from
/// corrupt (`Err(_)`). Touch / gc use this so they can refuse
/// to overwrite garbage. (since 0.14.0)
pub fn read_metadata_result(&self, path: &Path) -> Result<Option<Metadata>>
/// Writes metadata atomically via tempfile + rename. (since 0.14.0)
pub fn write_metadata_atomic(&self, path: &Path, m: &Metadata) -> Result<()>
/// Best-effort update to `last_used`. Never returns an error;
/// logs `warn!` on failure. (since 0.14.0)
pub fn touch_metadata_best_effort(&self, env_name: &str)
}
}
Common Usage Pattern:
#![allow(unused)]
fn main() {
// Initialize service
let service = VirtualenvService::auto()?;
// Check if environment exists
if !service.exists("myenv")? {
// Create with Python 3.12
service.create("myenv", "3.12")?;
}
// Get environment path
let path = service.get_path("myenv")?;
println!("Environment at: {}", path.display());
}
Metadata Module (core/metadata.rs)
Metadata
Stores JSON metadata for each virtualenv.
#![allow(unused)]
fn main() {
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
pub struct Metadata {
pub name: String,
pub python_version: String,
pub created_at: DateTime<Utc>, // Timestamp (ISO 8601 when serialized)
pub created_by: String, // "scuv X.Y.Z" format
pub uv_version: Option<String>, // uv version used
pub python_path: Option<String>, // Custom Python executable path (if --python-path was used)
pub last_used: Option<DateTime<Utc>>, // Last activation timestamp (since 0.13.0)
}
impl Metadata {
/// Creates new metadata
pub fn new(name: String, python_version: String, uv_version: Option<String>) -> Self
}
}
Storage Location: ~/.scuv/virtualenvs/<name>/.scoop-metadata.json
Example JSON:
{
"name": "myproject",
"python_version": "3.12",
"created_at": "2024-01-15T10:30:00.845598Z",
"created_by": "scuv <version>",
"uv_version": "uv 0.x.y (<commit> <date> <target>)"
}
Note:
created_atisDateTime<Utc>in Rust but serializes to ISO 8601 string in JSON.
Doctor Module (core/doctor/)
Check Trait
Interface for health checks.
#![allow(unused)]
fn main() {
pub trait Check: Send + Sync {
fn id(&self) -> &'static str;
fn name(&self) -> &'static str;
fn run(&self) -> Vec<CheckResult>; // Returns Vec - a check can produce multiple results
}
}
Implementations:
UvCheck- Verifies uv is installedHomeCheck- ChecksSCUV_HOMEdirectoryVirtualenvCheck- Validates virtualenvs directorySymlinkCheck- Checks for broken virtualenv Python symlinks (e.g.,<env>/bin/python)ShellCheck- Verifies shell integrationVersionCheck- Validates version filesVenvLinkCheck- Checks for a dangling.venvsymlink in the current directoryLegacyCheck- Warns about leftoverSCOOP_*vars, an orphaned~/.scoop, or.scoop-version/.scoop.toml(not read since v0.16.0)
CheckStatus
Result status for health checks.
#![allow(unused)]
fn main() {
pub enum CheckStatus {
Ok, // ✅ Check passed
Warning(String), // ⚠️ Issue found, but not critical
Error(String), // ❌ Critical issue
}
}
CheckResult
Detailed result from a health check.
#![allow(unused)]
fn main() {
pub struct CheckResult {
pub id: &'static str,
pub name: &'static str,
pub status: CheckStatus,
pub suggestion: Option<String>,
pub details: Option<String>,
}
impl CheckResult {
/// Creates an OK result
pub fn ok(id: &'static str, name: &'static str) -> Self
/// Creates a warning result
pub fn warn(id: &'static str, name: &'static str, message: impl Into<String>) -> Self
/// Creates an error result
pub fn error(id: &'static str, name: &'static str, message: impl Into<String>) -> Self
/// Adds a suggestion for fixing the issue
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self
/// Adds detailed information
pub fn with_details(mut self, details: impl Into<String>) -> Self
// Status checks
pub fn is_ok(&self) -> bool
pub fn is_warning(&self) -> bool
pub fn is_error(&self) -> bool
}
}
Example - Implementing a Custom Check:
#![allow(unused)]
fn main() {
struct MyCustomCheck;
impl Check for MyCustomCheck {
fn id(&self) -> &'static str {
"my_check"
}
fn name(&self) -> &'static str {
"My Custom Check"
}
fn run(&self) -> Vec<CheckResult> {
if some_condition() {
vec![CheckResult::ok(self.id(), self.name())]
} else {
vec![CheckResult::error(self.id(), self.name(), "Error message here")
.with_suggestion("Run: scuv fix-it")
.with_details("Expected X, found Y")]
}
}
}
}
Doctor
Orchestrates all health checks.
#![allow(unused)]
fn main() {
pub struct Doctor {
checks: Vec<Box<dyn Check>>,
}
impl Doctor {
/// Creates a doctor with default checks
pub fn new() -> Self
/// Runs all checks without fixing
pub fn run_all(&self) -> Vec<CheckResult>
/// Runs checks and attempts to fix issues
pub fn run_and_fix(&self, output: &crate::output::Output) -> Vec<CheckResult>
}
impl Default for Doctor {
fn default() -> Self {
Self::new()
}
}
}
Usage:
#![allow(unused)]
fn main() {
let doctor = Doctor::new();
// Run diagnostics
let results = doctor.run_all();
for result in results {
match &result.status {
CheckStatus::Error(msg) => eprintln!("❌ {}: {}", result.name, msg),
CheckStatus::Warning(msg) => println!("⚠️ {}: {}", result.name, msg),
CheckStatus::Ok => println!("✅ {}", result.name),
}
}
// Auto-fix issues (requires Output for progress display)
use scoop_uv::output::{Colors, Output};
let output = Output::new(0, false, Colors::NONE, false);
let fixed_results = doctor.run_and_fix(&output);
}
Error Handling
ScoopError (error/ module)
Primary error type for all scuv operations.
#![allow(unused)]
fn main() {
#[derive(Error, Debug)]
pub enum ScoopError {
// Virtualenv errors
VirtualenvNotFound { name: String },
VirtualenvExists { name: String },
InvalidEnvName { name: String, reason: String },
// Python errors
PythonNotInstalled { version: String },
PythonInstallFailed { version: String, message: String },
PythonUninstallFailed { version: String, message: String },
InvalidPythonVersion { version: String },
NoPythonVersions { pattern: String },
// uv errors
UvNotFound,
UvCommandFailed { command: String, message: String },
// Path/IO errors
PathError(String), // Tuple variant
HomeNotFound,
Io(#[from] std::io::Error), // Tuple variant with From
Json(#[from] serde_json::Error), // Tuple variant with From
// Config errors
VersionFileNotFound { path: PathBuf },
UnsupportedShell { shell: String },
// CLI errors
InvalidArgument { message: String },
// Migration errors
PyenvNotFound,
PyenvEnvNotFound { name: String },
VenvWrapperEnvNotFound { name: String },
CondaEnvNotFound { name: String },
CorruptedEnvironment { name: String, reason: String },
PackageExtractionFailed { reason: String },
MigrationFailed { reason: String },
MigrationNameConflict { name: String, existing: PathBuf },
// Python path errors
InvalidPythonPath { path: PathBuf, reason: String },
// Cascade errors
CascadeAborted,
// Abridged: 38 variants in total. See src/error/mod.rs for the full set,
// including SelfUpdateFailed, NoActiveEnvironment, ExecutableNotFound,
// ManifestNotFound, InvalidExportFile, UnsupportedExportVersion,
// VerifyFailed, SitePackagesNotFound, MigrationSourcesNotFound,
// MigrationBatchFailed and DiffMismatch.
}
impl ScoopError {
/// Returns error code string (e.g., "ENV_NOT_FOUND", "UV_COMMAND_FAILED")
pub fn code(&self) -> &'static str
/// Localized message in an explicit locale (bypasses the process-global
/// current locale). `Display` delegates to this with the current locale.
pub fn message_in(&self, locale: &str) -> String
/// Returns user-friendly suggestion in the current locale (if available)
pub fn suggestion(&self) -> Option<String>
/// Locale-explicit sibling of `suggestion()` — useful for deterministic,
/// parallel tests that must not depend on the global locale.
pub fn suggestion_in(&self, locale: &str) -> Option<String>
/// Returns migration-specific exit code
pub fn migration_exit_code(&self) -> MigrationExitCode
}
}
The
*_in(locale)accessors passlocale =to rust-i18n’st!, which bypasses the process-global current locale — this lets tests assert messages deterministically without#[serial].
Error Code String Prefixes:
ENV_*- Environment errors (e.g.,ENV_NOT_FOUND,ENV_ALREADY_EXISTS)PYTHON_*- Python version errors (e.g.,PYTHON_NOT_INSTALLED)UV_*- uv errors (e.g.,UV_NOT_INSTALLED,UV_COMMAND_FAILED)IO_*- Path/IO errors (e.g.,IO_ERROR,PATH_ERROR)CONFIG_*- Config errors (e.g.,CONFIG_VERSION_FILE_NOT_FOUND)SHELL_*- Shell errors (e.g.,SHELL_NOT_SUPPORTED)ARG_*- CLI argument errors (e.g.,ARG_INVALID)SOURCE_*- Migration source errors (e.g.,SOURCE_PYENV_NOT_FOUND)MIGRATE_*- Migration process errors (e.g.,MIGRATE_FAILED)UNINSTALL_*- Uninstall errors (e.g.,UNINSTALL_CASCADE_ABORTED)- Plus
SELF_*,NO_ACTIVE_ENV,EXE_*,MANIFEST_*,EXPORT_*,VERIFY_*andDIFF_*— seesrc/error/code.rsfor the authoritative list
Example Error Handling:
#![allow(unused)]
fn main() {
use crate::error::{ScoopError, Result};
fn my_function(name: &str) -> Result<()> {
if !validate_name(name) {
return Err(ScoopError::InvalidEnvName {
name: name.to_string(),
reason: "Must start with a letter".to_string(),
});
}
// ... operation
Ok(())
}
// Usage
match my_function("123invalid") {
Ok(_) => println!("Success"),
Err(e) => {
eprintln!("Error: {}", e);
if let Some(suggestion) = e.suggestion() {
eprintln!("Suggestion: {}", suggestion);
}
eprintln!("Error code: {}", e.code());
std::process::exit(1); // Non-zero exit for error
}
}
}
Shell Integration
Shell Types (cli/mod.rs)
#![allow(unused)]
fn main() {
pub enum ShellType {
Bash,
Zsh,
Fish,
Powershell,
}
// Note: ShellType is a plain enum without methods
// Shell operations are handled by module-level functions:
// - shell::detect_shell() -> ShellType (in shell/mod.rs)
// - shell::bash::init_script() -> &'static str
// - shell::zsh::init_script() -> &'static str
// - shell::fish::init_script() -> &'static str
// - shell::powershell::init_script() -> &'static str
}
Auto-detection Priority:
FISH_VERSION→ FishPSModulePath→ PowerShellZSH_VERSION→ Zsh- Default → Bash
Path Utilities (paths.rs)
#![allow(unused)]
fn main() {
/// Returns scuv home directory (SCUV_HOME or ~/.scuv)
pub fn scoop_home() -> Result<PathBuf>
/// Returns virtualenvs directory
pub fn virtualenvs_dir() -> Result<PathBuf>
/// Returns global version file path (~/.scuv/version)
pub fn global_version_file() -> Result<PathBuf>
/// Returns local version file path in the given directory
pub fn local_version_file(dir: &std::path::Path) -> PathBuf
}
Version Resolution (core/version.rs)
VersionService
Service for managing version files.
#![allow(unused)]
fn main() {
pub struct VersionService;
impl VersionService {
/// Set the local version for a directory
pub fn set_local(dir: &Path, env_name: &str) -> Result<()>
/// Set the global version
pub fn set_global(env_name: &str) -> Result<()>
/// Get the local version for a directory
pub fn get_local(dir: &Path) -> Option<String>
/// Get the global version
pub fn get_global() -> Option<String>
/// Resolve the version for a directory (local -> parent walk -> global)
pub fn resolve(dir: &Path) -> Option<String>
/// Resolve from current directory
pub fn resolve_current() -> Option<String>
/// Unset local version (removes .scuv-version)
pub fn unset_local(dir: &Path) -> Result<()>
/// Unset global version (removes ~/.scuv/version)
pub fn unset_global() -> Result<()>
}
}
CLI Equivalent (User Workflow):
VersionService::set_global() is exposed by the scuv use <name> --global command.
To set Python 3.11.0 as the global default in practice:
scuv install 3.11.0
scuv create py311 3.11.0
scuv use py311 --global
This writes py311 to ~/.scuv/version. The global value is used when no local
.scuv-version or SCUV_VERSION override is present.
Resolution Priority Order:
SCUV_VERSIONenvironment variable (checked at shell hook level, not in VersionService).scuv-versionin current directory.scuv-versionin parent directories (walks up)~/.scuv/version(global default)
Note:
.python-versionis not supported.
Testing Patterns
Property-Based Testing
scuv uses proptest for property-based testing of critical logic:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use proptest::prelude::*;
proptest! {
#[test]
fn env_name_validation_is_consistent(name in "[a-zA-Z][a-zA-Z0-9_-]*") {
assert!(validate_name(&name).is_ok());
}
}
}
}
Integration Testing
Test files follow the pattern:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup_test_env() -> TempDir {
// Test setup
}
#[test]
fn test_something() {
let temp = setup_test_env();
// Test logic
}
}
}
Extending scuv
Adding a New Command
- Define command in
cli/mod.rs:
#![allow(unused)]
fn main() {
#[derive(Subcommand)]
pub enum Commands {
// ... existing commands
MyCommand {
#[arg(help = "Argument description")]
arg: String,
},
}
}
- Create handler in
cli/commands/:
#![allow(unused)]
fn main() {
// cli/commands/my_command.rs
use crate::error::Result;
pub fn execute(arg: &str) -> Result<()> {
// Implementation
Ok(())
}
}
- Wire up in
main.rs:
#![allow(unused)]
fn main() {
Commands::MyCommand { arg } => {
commands::my_command::execute(&arg)?
}
}
Adding a New Health Check
#![allow(unused)]
fn main() {
// In core/doctor/checks/
struct MyCheck;
impl Check for MyCheck {
fn id(&self) -> &'static str {
"my_check"
}
fn name(&self) -> &'static str {
"My Custom Check"
}
fn run(&self) -> Vec<CheckResult> {
// Check logic
vec![CheckResult::ok(self.id(), self.name())]
}
}
// Register in Doctor::new()
impl Doctor {
pub fn new() -> Self {
Self {
checks: vec![
// ... existing checks
Box::new(MyCheck),
],
}
}
}
}
Related Documentation
- Architecture - System design and patterns
- Commands - CLI reference
- Contributing - Development guide
- Testing - Testing strategies
For AI/LLM Tools
When analyzing or modifying this codebase:
-
Use symbolic tools for precise navigation:
find_symbolto locate specific functions/typesfind_referencing_symbolsto understand usageget_symbols_overviewfor module structure
-
Follow existing patterns:
- Error handling: Always return
Result<T>withScoopError - Shell output: CLI outputs shell code, wrapper evals it
- Testing: Unit tests + integration tests + property tests
- i18n: Use
t!()macro for all user-facing strings
- Error handling: Always return
-
Preserve conventions:
- Error codes are string constants (e.g., “ENV_NOT_FOUND”, “UV_COMMAND_FAILED”)
- Process exit codes follow a layered contract (see Process exit codes below)
- Path handling via
paths.rsutilities (cross-platform — Unixbin/vs WindowsScripts/) - Shell detection via
shell::detect_shell()function
Process exit codes
scuv centralises exit-code policy in src/error/exit.rs via
ScoopError::exit_code(). Commands that already render their own
report (e.g. verify) opt into ErrorRenderPolicy::Quiet so main.rs
does not append a duplicate error: line.
| Code | Meaning |
|---|---|
0 | Success |
1 | Generic failure or semantic finding (verify failed, generic operational error) |
2 | Migration failure / MigrationNameConflict (and reserved for future probe/tool failures) |
3 | Migration source-discovery error (pyenv/conda/venvwrapper missing or corrupted) |
Per-command exit code table:
| Command | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
verify | always (informational) | — | — | — |
verify --strict | clean | any Fail check | — | — |
migrate @env / migrate all | success | — | failure / conflict | source tool not found |
doctor | clean | warning | error | — |
| Other commands | success | failure | — | — |
- Documentation requirements:
- All
pub fnmust have doc comments - Examples in doctests where applicable
- Error conditions documented
- Shell integration changes require cross-shell testing
- All
scuv Version: 0.17.0
Testing
Comprehensive guide for testing scuv.
Quick Reference
cargo test # Run all tests
cargo test json # Run tests containing "json"
cargo test -- --nocapture # Show println! output
cargo clippy -- -D warnings # Lint check
Test Structure
tests/
├── cli/ # CLI integration tests (one binary)
│ ├── main.rs # declares the topic modules
│ ├── support.rs # TestFixture, scoop_cmd, shared helpers
│ └── <topic>.rs # general, list, remove, color, shell, ...
└── i18n_completeness.rs # locale parity
src/
├── error/ # Unit tests for error types
├── validate.rs # Unit tests for validation
├── paths.rs # Unit tests for path utilities
├── output/
│ └── json/tests.rs # Unit tests for JSON output
├── core/
│ ├── virtualenv/ # virtualenv service (mod.rs + tests.rs)
│ ├── version.rs # Unit tests for version service
│ ├── metadata.rs # Unit tests for metadata
│ └── doctor/ # Unit tests for doctor
├── shell/
│ ├── bash.rs # Shell script tests
│ └── zsh.rs # Shell script tests
└── uv/
└── client.rs # Unit tests for uv client
Running Tests
All Tests
# Run all tests
cargo test
# Run with all features enabled
cargo test --all-features
# Run in release mode (faster execution)
cargo test --release
Filtered Tests
# By name pattern
cargo test json # Tests containing "json"
cargo test error # Tests containing "error"
cargo test virtualenv # Tests containing "virtualenv"
# By module path
cargo test output::json # Tests in output/json/tests.rs
cargo test error::tests # Tests in error.rs
cargo test core::version # Tests in core/version.rs
cargo test cli::commands # Tests in cli/commands/
# Single test
cargo test test_json_response_success_creates_correct_status
Test Output
# Show stdout/stderr (println!, dbg!, etc.)
cargo test -- --nocapture
# Show test names as they run
cargo test -- --nocapture --test-threads=1
# Only show failed tests
cargo test -- --quiet
Debugging
# Run single-threaded (easier to debug)
cargo test -- --test-threads=1
# Run ignored tests
cargo test -- --ignored
# Run specific test with output
cargo test test_name -- --nocapture --test-threads=1
Test Categories
Unit Tests
The bulk of the suite (957 tests, ~93% of the total 1029) lives within source files using #[cfg(test)]:
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_something() {
assert_eq!(1 + 1, 2);
}
}
}
Key test modules:
| Module | Tests | Coverage |
|---|---|---|
error::tests | 92 | Error types, codes, suggestions |
output::json::tests | 44 | JSON serialization, edge cases |
validate::tests | 56 | Name/version validation |
core::version::tests | 35 | Version file resolution |
core::virtualenv::tests | 26 | Virtualenv service |
paths::tests | 48 | Path utilities |
shell::*::tests | 50 | Shell scripts (shellcheck) |
Integration Tests (tests/cli/ + tests/i18n_completeness.rs)
tests/cli/ is one test binary (main.rs) with a module per topic:
general, list, remove, color, shell, dispatch, errors,
output_format, requires_uv, and shared fixtures (including a fake uv)
in support.
# Run only integration tests
cargo test --test cli
Categories:
- Error cases - Invalid inputs, missing arguments
- Output format - Help, version, JSON output
- Command behavior - list, create, use, remove
- Real shells - the
shellmodule sourcesscuv initin real bash, zsh, fish and PowerShell (pwsh) and drives the wrapper, hook and completion through them. Each test skips when its shell is not installed. The CI Test and MSRV jobs install fish and zsh (bash andpwshcome with the runner) and setSCUV_REQUIRE_FISH,SCUV_REQUIRE_ZSHandSCUV_REQUIRE_PWSH, so a missing fish, zsh orpwshfails there instead.
Some tests are marked #[ignore] because they require uv installed. The
Docker integration jobs run them with cargo test -- --include-ignored, as
their images carry uv and Python 3.12:
# Run ignored tests (requires uv)
cargo test -- --ignored
Doc Tests (25 tests)
Examples in documentation comments:
#![allow(unused)]
fn main() {
/// Validates environment name.
///
/// # Examples
///
/// ```
/// use scoop_uv::validate::is_valid_env_name;
/// assert!(is_valid_env_name("myenv"));
/// assert!(!is_valid_env_name("123bad"));
/// ```
pub fn is_valid_env_name(name: &str) -> bool { ... }
}
# Run only doc tests
cargo test --doc
Property Tests
Using proptest for randomized testing:
#![allow(unused)]
fn main() {
use proptest::prelude::*;
proptest! {
#[test]
fn prop_valid_names_accepted(name in "[a-zA-Z][a-zA-Z0-9_-]{0,49}") {
assert!(is_valid_env_name(&name));
}
}
}
Located in src/validate.rs.
Parameterized Tests
Using rstest #[case] tables for input/output matrices (e.g. version parsing,
env-name validation) so each case reports independently:
#![allow(unused)]
fn main() {
use rstest::rstest;
#[rstest]
#[case::simple("myenv", true)]
#[case::digit_start("123", false)]
#[case::reserved("activate", false)]
fn is_valid_env_name_cases(#[case] input: &str, #[case] expected: bool) {
assert_eq!(is_valid_env_name(input), expected);
}
}
Mutation Testing
cargo-mutants verifies the suite actually catches bugs (not just that lines
run). Config in .cargo/mutants.toml; CI runs it on changed lines per PR
(--in-diff) and a full pass weekly.
cargo install cargo-mutants
cargo mutants # full (scoped via mutants.toml)
git diff origin/main.. | cargo mutants --in-diff /dev/stdin # changed lines
Fuzz Testing
cargo-fuzz (libFuzzer) fuzzes the untrusted-input parsers. It lives in an
isolated fuzz/ workspace pinned to nightly, so it never affects the
MSRV-1.89 build; CI runs the targets on a weekly schedule.
cargo install cargo-fuzz
cargo +nightly fuzz run fuzz_env_name -- -max_total_time=60
Writing Tests
Unit Test Template
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
// ========================================
// Test Group Name
// ========================================
#[test]
fn test_function_name_expected_behavior() {
// Arrange
let input = "test input";
// Act
let result = function_under_test(input);
// Assert
assert_eq!(result, expected_value);
}
#[test]
fn test_function_name_edge_case() {
let result = function_under_test("");
assert!(result.is_err());
}
}
}
Integration Test Template
#![allow(unused)]
fn main() {
// tests/cli/<topic>.rs (declare it in tests/cli/main.rs)
use crate::support::*;
use assert_cmd::Command;
use predicates::prelude::*;
#[test]
fn test_command_success() {
Command::cargo_bin("scuv")
.unwrap()
.args(["list"])
.assert()
.success()
.stdout(predicate::str::contains("expected output"));
}
#[test]
fn test_command_failure() {
Command::cargo_bin("scuv")
.unwrap()
.args(["use", "nonexistent"])
.assert()
.failure()
.stderr(predicate::str::contains("not found"));
}
}
JSON Output Testing
#![allow(unused)]
fn main() {
#[test]
fn test_json_serialization() {
let data = MyData { field: "value".into() };
let json = serde_json::to_string(&data).unwrap();
// Check JSON structure
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["field"], "value");
}
#[test]
fn test_optional_field_omitted() {
let data = MyData { optional: None, .. };
let json = serde_json::to_string(&data).unwrap();
// skip_serializing_if = "Option::is_none"
assert!(!json.contains("optional"));
}
}
Test Utilities
Located in src/test_utils.rs:
#![allow(unused)]
fn main() {
use scoop_uv::test_utils::*;
#[test]
fn test_with_temp_environment() {
with_temp_scoop_home(|temp_dir| {
// SCUV_HOME is set to temp_dir
// Cleanup happens automatically
});
}
#[test]
fn test_with_mock_venv() {
with_temp_scoop_home(|temp_dir| {
create_mock_venv("myenv", Some("3.12"));
// Virtual environment created at temp_dir/virtualenvs/myenv
});
}
}
Coverage
Using cargo-tarpaulin
# Install
cargo install cargo-tarpaulin
# Run with HTML report
cargo tarpaulin --out Html --output-dir coverage
# Run with specific target
cargo tarpaulin --out Html --output-dir coverage --packages scoop-uv
# View report
open coverage/tarpaulin-report.html
Using cargo-llvm-cov
# Install
cargo install cargo-llvm-cov
# Run with HTML report
cargo llvm-cov --html
# View report
open target/llvm-cov/html/index.html
CI/CD Testing
Tests run automatically on:
- Every push to any branch
- Every pull request
GitHub Actions workflow (.github/workflows/ci.yml):
- name: Run tests
run: cargo test --all-features
- name: Run clippy
run: cargo clippy --all-targets -- -D warnings
Troubleshooting
Test Hangs
# Run single-threaded to identify hanging test
cargo test -- --test-threads=1
Flaky Tests
# Run specific test multiple times
for i in {1..10}; do cargo test test_name || break; done
Environment Issues
# Clear test artifacts
cargo clean
# Rebuild and test
cargo test
Shell Tests Fail
The real-shell tests in tests/cli/shell.rs skip a shell that is not
installed; install bash, zsh, fish and pwsh to run them all locally, or set
SCUV_REQUIRE_<SHELL> to make a missing one fail.
ShellCheck must be installed for shell script tests:
# macOS
brew install shellcheck
# Linux
apt install shellcheck
Best Practices
- Test naming:
test_<function>_<scenario>_<expected> - Arrange-Act-Assert: Clear test structure
- One assertion per test: When practical
- Test edge cases: Empty, unicode, special chars, boundaries
- No test interdependencies: Each test should be isolated
- Fast tests: Mock external dependencies
Codespaces / Devcontainer
.devcontainer/devcontainer.json boots a Rust 1.89 dev environment on
mcr.microsoft.com/devcontainers/rust:1-bookworm that matches the local
toolchain pinned by rust-toolchain.toml. Open the repo in VS Code
(“Reopen in Container”) or create a Codespace — both follow the same
lifecycle:
| Hook | Runs | Used for |
|---|---|---|
onCreateCommand | once (in Codespace prebuild) | install nextest / llvm-cov / mutants / uv, warm cargo build |
updateContentCommand | on every prebuild refresh | cargo fetch to keep registry warm |
postCreateCommand | when user creates the Codespace | prek install |
Cargo registry + git caches persist as named Docker volumes scoped per
project so two scoop-uv worktrees don’t share caches. target/ is NOT
volumed — it’s architecture-specific and warmed in the prebuild layer.
Enabling Codespace prebuilds
In repo Settings → Codespaces → “Set up prebuild”, configure for the
main branch on the “configuration change” trigger. This keeps Actions
minutes low (only rebuilds when .devcontainer/** or Dockerfile
changes) while still keeping the cargo registry warm via volume
persistence.
Multi-source Integration (Docker matrix)
The Dockerfile builds three per-source leaf stages — pyenv-test,
conda-test, venvwrapper-test — on top of a shared scuv-test-base.
Each carries only the source-tool it migrates from, so CI can
matrix-build just one variant.
# Local (sequential)
make test-integration-pyenv
make test-integration-conda
make test-integration-venvwrapper
# Local (all three sequentially)
make test-integration-all
# Drop into a single variant for ad-hoc debugging
make docker-shell-conda
CI runs all three in parallel via
.github/workflows/integration-test.yml’s strategy.matrix with
per-source BuildKit cache scoping (cache-from/to=type=gha,scope=<src>).
Benchmarks (Criterion)
Three bench binaries live in benches/:
| Binary | Targets |
|---|---|
parsing | clap parse, TOML manifest, JSON uv python list |
validation | is_valid_env_name across 6 representative inputs |
path_lookup | find_executable_in hit + miss |
Local workflow
# Run all benches once (no baseline diffing)
cargo bench
# Save current results as a named baseline (default: "main")
make bench-save # saves as "main"
make bench-save BENCH_BASELINE=before-X # saves as "before-X"
# Compare current results against a saved baseline
make bench-compare # vs "main"
make bench-compare BENCH_BASELINE=before-X
# Run all benches inside Docker (reproducible)
make bench
Criterion writes HTML reports to target/criterion/report/index.html
for visual diffing.
CI regression gate
.github/workflows/bench.yml runs every PR and push:
- On
main: results are pushed to thegh-pagesbranch as the new baseline. - On PRs: results are compared against that baseline.
- alert at 130% — leaves a PR comment, doesn’t block merge
- fail at 150% — fails the workflow, blocks merge
Thresholds account for GitHub-hosted runner variance (10-30% per-bench noise is normal on shared CPU). Tighten by running on a self-hosted runner with pinned hardware if needed.
Code Quality
Comprehensive guide for maintaining code quality in scuv.
Quick Reference
# Format code
cargo fmt
# Lint check
cargo clippy --all-targets --all-features -- -D warnings
# All checks (pre-commit style)
cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings && cargo test
# Pre-commit hooks
prek run --all-files
Formatting (rustfmt)
Configuration
Located in rustfmt.toml:
edition = "2024"
max_width = 100
tab_spaces = 4
use_field_init_shorthand = true
use_try_shorthand = true
Commands
# Auto-format all files
cargo fmt
# Check formatting (CI mode, no changes)
cargo fmt --check
# Format specific file
rustfmt src/main.rs
# Show diff instead of applying
cargo fmt -- --check --diff
IDE Integration
VS Code (rust-analyzer):
{
"[rust]": {
"editor.formatOnSave": true
}
}
JetBrains (RustRover/CLion):
- Settings → Languages → Rust → Rustfmt → Run on save
Linting (Clippy)
Basic Usage
# Standard lint check
cargo clippy
# Treat warnings as errors (CI mode)
cargo clippy -- -D warnings
# All targets (including tests, examples)
cargo clippy --all-targets
# All features enabled
cargo clippy --all-features
# Full CI check
cargo clippy --all-targets --all-features -- -D warnings
Lint Categories
# Enable specific lint category
cargo clippy -- -W clippy::pedantic
# Deny specific lint
cargo clippy -- -D clippy::unwrap_used
# Allow specific lint
cargo clippy -- -A clippy::too_many_arguments
Common Lints
| Lint | Severity | Description |
|---|---|---|
clippy::unwrap_used | Warn | Use ? or expect() instead |
clippy::panic | Warn | Avoid panic in library code |
clippy::todo | Warn | Remove before release |
clippy::dbg_macro | Warn | Remove debug macros |
clippy::print_stdout | Warn | Use logging instead |
Fixing Lints
# Auto-fix where possible
cargo clippy --fix
# Allow fixes that change behavior
cargo clippy --fix --allow-dirty --allow-staged
Suppressing Lints
#![allow(unused)]
fn main() {
// Single line
#[allow(clippy::too_many_arguments)]
fn complex_function(...) {}
// Entire module
#![allow(clippy::module_inception)]
// With explanation
#[allow(clippy::unwrap_used)] // Safe: validated in parse()
fn get_value() {}
}
Pre-commit Hooks (prek)
Setup
# Install prek
cargo install prek
# or
uv tool install prek
# Install hooks in repository
prek install
Configuration
Located in .pre-commit-config.yaml:
repos:
- repo: local
hooks:
- id: cargo-fmt
name: cargo fmt
entry: cargo fmt --all --
language: system
types: [ rust ]
pass_filenames: false
- id: cargo-clippy
name: cargo clippy
entry: cargo clippy --all-targets --all-features -- -D warnings
language: system
types: [ rust ]
pass_filenames: false
- id: cargo-check
name: cargo check
entry: cargo check --all-targets
language: system
types: [ rust ]
pass_filenames: false
Usage
# Run all hooks on staged files
prek run
# Run all hooks on all files
prek run --all-files
# Run specific hook
prek run cargo-fmt
prek run cargo-clippy
# Run multiple specific hooks
prek run cargo-fmt cargo-clippy
# Skip hooks (emergency only!)
git commit --no-verify
Available Hooks
| Hook | Description | When |
|---|---|---|
cargo-fmt | Code formatting | Pre-commit |
cargo-clippy | Linting | Pre-commit |
cargo-check | Type checking | Pre-commit |
trailing-whitespace | Remove trailing spaces | Pre-commit |
end-of-file-fixer | Ensure newline at EOF | Pre-commit |
check-toml | Validate TOML files | Pre-commit |
check-yaml | Validate YAML files | Pre-commit |
cargo-test | Test suite | Pre-commit |
mixed-line-ending | Normalise line endings | Pre-commit |
check-added-large-files | Block large blobs | Pre-commit |
check-merge-conflict | Catch conflict markers | Pre-commit |
check-case-conflict | Catch case collisions | Pre-commit |
CI Pipeline
GitHub Actions
Located in .github/workflows/ci.yml:
name: CI
on: [ push, pull_request ]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Format check
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Test
run: cargo test --all-features
Local CI Simulation
# Run exactly what CI runs
cargo fmt --check && \
cargo clippy --all-targets --all-features -- -D warnings && \
cargo test --all-features
Code Style Guidelines
Naming Conventions
| Item | Convention | Example |
|---|---|---|
| Modules | snake_case | version_file |
| Functions | snake_case | get_version() |
| Types | PascalCase | VirtualenvService |
| Constants | SCREAMING_SNAKE | MAX_NAME_LENGTH |
| Lifetimes | short lowercase | 'a, 'src |
Documentation
/// Creates a new virtual environment.
///
/// # Arguments
///
/// * `name` - Environment name (must be valid)
/// * `python` - Python version (e.g., "3.12")
///
/// # Returns
///
/// Path to the created environment.
///
/// # Errors
///
/// Returns [`ScoopError::InvalidEnvName`] if name is invalid.
///
/// # Examples
///
/// ```
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let path = create_env("myenv", "3.12")?;
/// # Ok(())
/// # }
/// ```
pub fn create_env(name: &str, python: &str) -> Result<PathBuf> {
// ...
}
Error Handling
#![allow(unused)]
fn main() {
// Good: Use ? operator
fn process() -> Result<()> {
let file = File::open(path)?;
let data = read_data(&file)?;
Ok(())
}
// Good: Contextual errors
fn process() -> Result<()> {
let file = File::open(path)
.map_err(|e| ScoopError::Io(e))?;
Ok(())
}
// Avoid: unwrap() in library code
fn bad() {
let file = File::open(path).unwrap(); // Bad
}
// OK: expect() with explanation
fn acceptable() {
let home = dirs::home_dir()
.expect("home directory must exist");
}
}
Import Organization
#![allow(unused)]
fn main() {
// 1. Standard library
use std::collections::HashMap;
use std::path::PathBuf;
// 2. External crates
use clap::Parser;
use serde::Serialize;
use thiserror::Error;
// 3. Local modules
use crate::error::Result;
use crate::paths;
}
Security Considerations
Dependency Auditing
# Install cargo-audit
cargo install cargo-audit
# Run audit
cargo audit
# Fix vulnerabilities
cargo audit fix
MSRV (Minimum Supported Rust Version)
- Current MSRV: 1.89
- Defined in
Cargo.toml:[package] rust-version = "1.89"
Unsafe Code
- Avoid
unsafeunless absolutely necessary - Document safety invariants
- Use
#![forbid(unsafe_code)]in library crates
Performance
Profiling
# Build with debug info for release
cargo build --release
# Use flamegraph
cargo install flamegraph
cargo flamegraph --bin scuv -- list
Benchmarks
# Run benchmarks (if defined)
cargo bench
# Using criterion
cargo bench --bench my_benchmark
Continuous Improvement
Regular Checks
# Weekly dependency update check
cargo outdated
# Security audit
cargo audit
# MSRV check
cargo msrv verify
Upgrade Dependencies
# Update Cargo.lock
cargo update
# Upgrade to latest compatible versions
cargo upgrade # requires cargo-edit
Troubleshooting
Clippy False Positives
#![allow(unused)]
fn main() {
// Silence with explanation
#[allow(clippy::needless_return)]
fn explicit_return() -> i32 {
return 42; // Intentional for readability
}
}
Format Conflicts
#![allow(unused)]
fn main() {
// Skip formatting for specific block
#[rustfmt::skip]
const MATRIX: [[i32; 3]; 3] = [
[1, 0, 0],
[0, 1, 0],
[0, 0, 1],
];
}
CI vs Local Differences
# Ensure same toolchain as CI
rustup update stable
rustup default stable
# Check Rust version
rustc --version
Summary Checklist
Before committing:
-
cargo fmt- Code formatted -
cargo clippy -- -D warnings- No lint warnings -
cargo test- All tests pass -
cargo doc- Documentation builds - No
todo!()ordbg!()left in code - Public APIs documented
- Error messages are helpful
CI/CD
Thirteen workflows guard this repository. This page explains what each one protects, the decisions behind how they are wired, and the failure modes that shaped them.
Pipeline at a glance
| Workflow | Trigger | Guards against |
|---|---|---|
ci.yml | PR, main | Unformatted code, clippy warnings, failing tests (including the shell integration in real bash, zsh, fish and pwsh), MSRV drift, broken shell scripts, stale reference docs, malformed .po, files missing from the published crate |
integration-test.yml | PR, main | Migration breaking for pyenv / conda / virtualenvwrapper users |
coverage.yml | PR, main | Untested code paths going unnoticed |
bench.yml | PR, main | Performance regressions in parsing and validation |
mutants.yml | PR (diff), weekly (full) | Tests that execute code without asserting on it |
security.yml | PR, main, weekly | Vulnerable dependencies, disallowed licences, untrusted sources |
msrv-check.yml | Cargo.toml/Cargo.lock changes | A declared MSRV that no longer compiles |
docker-build.yml | docker/** changes, weekly | Broken published images, vulnerable image contents |
fuzz.yml | Weekly | Parser crashes on hostile input |
docs-check.yml | PR touching docs/src/**, docs/po/**, docs/book.toml, docs/theme/** | A docs edit that breaks the mdBook build or leaves ko.po stale reaching a release tag |
docs.yml | v* tags | Broken documentation site |
release-plz.yml | main | Manual release mistakes |
cache-cleanup.yml | PR closed, weekly | Closed PRs’ cache copies and superseded main rust caches eating the 10 GB allowance and forcing evictions mid-export |
Cross-cutting decisions
Cancel PR runs, never cancel main
Every workflow that runs on both uses the same concurrency block:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
Pushing a fixup to a PR should abandon the previous run — nobody needs
results for a commit that no longer exists. A run on main is different:
it produces state that later runs depend on. bench.yml writes the
benchmark baseline to gh-pages, and a cancelled main run leaves the
next PR comparing against a stale baseline.
release-plz.yml uses the same block with cancel-in-progress: false: a
release must never be cancelled, but two releases must never run at once
either, and a group delivers both.
The MSRV is verified twice, deliberately
ci.yml has an msrv job that builds and tests on 1.89. msrv-check.yml
separately runs cargo msrv verify. These answer different questions:
ci.yml— does the code work on the version we claim?msrv-check.yml— is the version we claim still the lowest one that works?
The second catches the case where a dependency bump silently raises the
real floor while Cargo.toml still advertises the old one. It only runs
when Cargo.toml or Cargo.lock changes, because that is the only way
the answer can change.
cargo-msrv itself is pinned to ^0.18: 0.19 requires rustc 1.91, which
this project’s own toolchain pin cannot satisfy.
Gate what is stable, track what is noisy
Not every measurement can be a gate. bench.yml splits its benchmarks by
how reproducible they are on shared runners:
| Group | Benches | Observed spread | Behaviour |
|---|---|---|---|
| CPU | parsing, validation | 1.71x-2.37x | Fails the build past 250% |
| Filesystem | path_lookup | ~3.4x | Recorded, never fails |
find_executable_in calls stat. Across 38 runs of unchanged code on
main it produced anywhere from 574 to 1931 ns depending on which runner
the job landed on. No threshold both tolerates that and catches a real
regression, so the group is tracked on gh-pages for trend visibility
and fail-on-alert: false keeps it out of the gate.
This matters because a noisy gate is worse than no gate: it blocks unrelated work and trains reviewers to ignore red.
The CPU row was written as “~1.4x, fails past 150%”, and the numbers did not
hold. Measured across the 60 runs on gh-pages, the group swings 1.71x to
2.37x — its noise floor was above its own threshold, so it could not tell a
regression from a runner. The way it failed is worth remembering, because a
re-run does not clear it: the action compares against the single previous
data point, and a run that lands on a fast runner measures low across every
bench at once. Commit 9a9241da came in ~1.7x fast, and every PR afterwards
compared against that and failed until the next commit reached main and
replaced the baseline. 250% clears the measured ceiling. It is a coarse gate
on purpose — the regressions worth catching here are algorithmic, and those
land far beyond 2.5x.
Mutation testing runs at two scopes
cargo test proves a line executed. It does not prove a test would
notice if that line were wrong. cargo-mutants injects deliberate
defects — flipping > to >=, replacing a return value — and reports
any that the suite fails to catch.
- On PRs (
--in-diff): only the lines this PR changed. Fast enough to gate on. - Weekly (full): every candidate in scope. Tracked rather than gated for now — the step tolerates exit 2 (mutants missed) and nothing else, so a broken baseline or a timeout still fails loudly while the backlog in Known gaps is worked through.
.cargo/mutants.toml carries the exclusions, each with a written
rationale. Most exclude code whose mutations can only be killed by
spawning a real uv or python — a gap we accept rather than a test
hole. Add exclusions there with the reason, not silently.
exclude_re matches the whole mutant description, not the function name.
A bare "foo" therefore drops every mutant in foo, including the ones
existing tests already kill — the exclusion outlives the reason given for
it, and --in-diff never gates that code again. Exclude the specific
description instead ("delete match arm \[major\] in foo"), and confirm
what actually left the gate by listing both configs and diffing them:
cargo mutants --config <before>.toml --list --file '<glob>' | sort > before.txt
cargo mutants --list --file '<glob>' | sort > after.txt
comm -23 before.txt after.txt
Some mutants cannot be separated that way. Two delete ! in one function
produce a byte-identical description, so a whole-function exclusion is the
only option available — say so in the comment when that is the reason,
instead of letting it read as an equivalence claim.
New Check trait implementations and thin wrappers need direct dispatch
tests, or the PR gate reports them as MISSED.
One cache per toolchain, one writer per cache
Swatinem/rust-cache keys on shared-key, so jobs sharing a key share a
cache:
shared-key | Jobs |
|---|---|
stable-ci | ci.yml lint / test / shellcheck, coverage.yml, mutants.yml |
msrv | ci.yml msrv |
msrv-verify | msrv-check.yml |
bench | bench.yml |
MSRV artifacts are kept separate on purpose: a different compiler produces incompatible output, and sharing would mean both jobs permanently invalidating each other.
Within a shared key there is one writer. ci.yml marks lint and
shellcheck save-if: false so only the test job — which produces the
richest artifacts, including test binaries — populates the cache; in
mutants.yml the incremental job writes and the weekly full job reads.
Note that rust-cache folds workflow-level env into its key hash. Two
jobs can declare the same shared-key and still land on different
caches if their workflows set different environment variables.
Failure modes worth remembering
These cost real debugging time. They are recorded so the next person recognises them faster.
Criterion errors corrupt the benchmark parser output
rust-cache restores target/ with the criterion tree present but its
sample.json baselines pruned out. Criterion then fails to load a
baseline it can see should exist, and writes the error to stdout
mid-line:
test clap_parse_create ... Criterion.rs ERROR: error: Failed to access file
".../base/sample.json": No such file or directory
bench: 41,347 ns/iter (+/- 2,364)
benchmark-action’s parser needs test NAME ... bench: N ns/iter on one
line. Split in two, it reports “no benchmark result” even though every
benchmark ran. A cold cache passes precisely because there is no tree to
half-load.
Each bench step therefore starts with rm -rf target/criterion. The gate
compares against gh-pages history and never against criterion’s local
baseline, so removing it costs nothing.
Two benchmark-action invocations collide on gh-pages
The first invocation fetches gh-pages into a local branch and commits
its entry onto it — even on PRs, where it simply never pushes. A second
invocation’s identical fetch is then a non-fast-forward update and git
rejects it, failing the step before any comparison happens.
skip-fetch-gh-pages: true on the second step reuses what the first
fetched.
cargo bench | tee swallows failures
Without set -o pipefail the step exits with tee’s status, so a
genuinely failing cargo bench reports success and resurfaces one step
later as a confusing parse error.
Docs guards run on PRs only for docs paths
docs.yml — the mdBook build, the ko.po staleness round-trip and the
Pages deploy — triggers only on v* tags. docs-check.yml runs the same
build and the same round-trip on pull requests, but only when the PR
touches docs/src/**, docs/po/**, docs/book.toml or docs/theme/**.
It is a separate workflow rather than a trigger on docs.yml so a PR run
needs neither pages: write nor id-token: write and never enters the
pages concurrency group. Two checks in the ci.yml Lint job cover the
facts that live outside those paths:
scripts/check-doc-references.pycompares facts copied intoREADME.md,CONTRIBUTING.md,llms.txt,llms-full.txtand the docs against the code that owns them — version and MSRV fromCargo.toml, reserved names fromsrc/validate.rs, key count fromlocales/app.yml. Every check is doc-against-code; comparing the threellmsfiles to each other would pass with all three wrong.msgfmt --checkondocs/po/*.pocatches structural damage such as amsgstrwhose trailing newline no longer matches itsmsgid.
Editing any page under docs/src/ still requires regenerating ko.po in
the same commit. See Docs Translation.
Secrets
| Secret | Used by | Purpose |
|---|---|---|
RELEASE_PLZ_TOKEN | release-plz.yml | Push release PRs and tags |
CARGO_REGISTRY_TOKEN | release-plz.yml | Publish to crates.io |
CODECOV_TOKEN | coverage.yml | Upload coverage reports |
GITHUB_TOKEN | bench, docker | Push gh-pages, publish to ghcr.io |
A workflow triggered by Dependabot reads the Dependabot secret store, not
the Actions one. A secret both need has to be registered twice —
gh secret set CODECOV_TOKEN --app dependabot — or the job fails on every
Dependabot PR while passing everywhere else.
Default workflow permissions are read. Workflows that need more declare
it explicitly — bench.yml needs contents: write for gh-pages,
docker-build.yml needs packages: write for the registry.
Releases
release-plz reads Conventional Commits on main and prepares the
release; release-plz.toml holds the policy:
release_commits = "^(feat|fix|perf|refactor|revert)"— documentation and chore commits do not trigger a release on their own.features_always_increment_minor = true— afeatbumps the minor version even in0.x, where cargo’s default would only bump the patch.- Changelog generation goes through
git-cliff(cliff.toml).
release-plz only rewrites Cargo.toml, Cargo.lock and CHANGELOG.md. The
scuv <version> samples in README.md, CLAUDE.md, installation.md and
api.md are not its business, so every release PR used to fail the Lint job on
check-doc-references.py until someone hand-synced those four files. There is
no hook that runs while release-plz builds the PR — the hooks it does have live
on the publish path, which is too late — so release-plz.yml carries a step
that runs check-doc-references.py --fix and commits the result onto the
release branch. A release PR therefore arrives with an extra
docs: sync version samples to <version> commit; that is the automation
working, not drift. The step pushes with RELEASE_PLZ_TOKEN because the
default GITHUB_TOKEN does not re-trigger workflows, and re-runs the full
guard afterwards so a reference --fix does not cover still fails the job.
Merging the release PR is what publishes: it creates the tag, the GitHub
release, and the crates.io upload — and the v* tag is also what deploys
the documentation site.
Known gaps
Accurate as of the last revision of this page. Verify before relying on any of these being fixed.
codecov/projecthas never posted.codecov.ymlstates a target for it, and the config is live — settingcomment.require_changeschanged comment behaviour on the next PR. The status itself has never appeared, though, on any PR back through #165, which predates that config by months. The cause is on the Codecov side, in account or organisation settings this repository cannot read, so the relative check (“did this PR drop overall coverage”) is not being enforced. An absolute floor stands in for it incoverage.yml(cargo llvm-cov report --fail-under-lines 80), which catches a collapse but not a slow slide. Requiring the status before it is known to post would leave every PR waiting on a check that never arrives.- Unreviewed mutation escapes outside
migrate/. The last full-run artifact listed 55 mutants no test kills, 46 of them undersrc/core/migrate/**. That module is now closed — 111 mutants, 0 missed — so what remains is the return-value backlog elsewhere (UvClient::list_pythons -> Ok(vec![])and similar). Those may need a realuvorcondato observe, in which case they belong in.cargo/mutants.tomlwith a rationale, but each needs checking rather than assuming. The next completed weekly run supersedes that artifact; until the triage happens the job tolerates exit 2. - The coverage floor is absolute, not relative.
--fail-under-lines 80is measured against llvm-cov, which reads 80.97% where Codecov reads 78.5%; the two count different things, so a number taken from the Codecov dashboard would be wrong in the workflow. Coverage can still drift from 81% to 80.1% without tripping it — closing that needscodecov/project, see the gap above. - The cache hit its limit. 10.49 GB against the 10 GB allowance on
2026-09-26: 139 caches for
main(about 2 GB of themv0-rust-*keyed to the Cargo.lock hash from before the 0.16.0cargo update) and 118 copies left behind by merged PRs. LRU eviction then ran while a BuildKit export was writing layers and failed a green Docker Integration build witherror writing layer blob: not_found(#198). Three changes: every saving step (Swatinem/rust-cachesave-if, BuildKitcache-to) now writes only frommain— PR runs restoremain’s entries and leave no copy; BuildKit exports areignore-error=true, so a failed cache write is a warning rather than a failed build; andcache-cleanup.ymldeletes whatever a PR still leaves when it closes. The one-time deletion of the PR copies and the stale-lockfile rust caches brought usage to 6.4 GB.mainstill kept every lockfile generation of each rust cache (up to four per job, about 4.4 GB, by 2026-10-04); a weekly job incache-cleanup.ymlnow keeps only the newest entry under each restore key (the cache key minus its lockfile hash), the one a later run with that toolchain and environment falls back to.
Recently closed
Left here because the reasoning is worth keeping, not because anything is outstanding.
- Docs were only verified on release tags: the MSRV 1.89 bump edited pages
under
docs/src/, its PR went green, and thev0.15.4tag then failed at theko.poround-trip. The crate published and the tag was fine — only the Pages deploy stopped, which is the quiet half of the failure and the reason it went unnoticed until someone opened the site. Closed bydocs-check.yml, which runs the build and the round-trip on PRs that touch the docs paths. - Coverage uploads were rejected for months with
Token required because branch is protectedwhile the job reported success, becausefail_ci_if_error: falsehid it. The org allows tokenless uploads, but that path only covers fork PRs. Fixed by wiringCODECOV_TOKENand letting a rejected upload fail the job. That fix was half of it: Dependabot-triggered runs read a different secret store, so every Dependabot PR kept failing the same way until the token was registered there too. - Every release PR failed the Lint job because release-plz bumps
Cargo.tomlwithout touching the version samples in prose. Hand-fixed at v0.15.3, hit again at v0.15.4, and now handled by the sync step described under Releases — the direction the v0.15.3 fix asked for: teach the release to update them rather than loosen the check. - Dependabot does not read
rust-version, so it raisedrust-i18npast the MSRV and every job died at dependency resolution before a line compiled. The group could not land without the MSRV moving to 1.89, which it did..github/dependabot.ymlnow carries anignorefor the next known case (serial_test4.x needs rustc 1.93.1), marked as debt to drop when the MSRV catches up. - The weekly full mutation run had never once completed — 60 minutes killed it every time, and GitHub reports a timed-out job as cancelled, which reads as benign. Measured at 385/446 mutants in 60 minutes, so the timeout is now 90.
aquasecurity/trivy-action@masterandastral-sh/setup-uv@v7are now pinned to release tags. Dependabot could not follow either: it cannot bump a branch ref, and setup-uv stopped publishing moving major tags at v8.stable-cihad four writers and, becauserust-cachehashes workflow-levelenv, was never actually shared withcoverage.ymlormutants.ymlat all. Each now names the key it really uses.release-plz.ymlhad no concurrency group. It has one now, withcancel-in-progress: false— which delivers the “must always complete” intent that omitting the block only half-achieved.
LLM Reference
This page provides a concise reference for AI/LLM tools working with scuv.
Tip: The raw text versions are available at
llms.txt(concise) andllms-full.txt(full API reference).
Overview
scuv is a centralized Python virtual environment manager — pyenv-style workflow powered by uv. Written in Rust.
All virtualenvs are stored in ~/.scuv/virtualenvs/. Override with SCUV_HOME env var.
Commands
| Command | Description |
|---|---|
scuv list | List virtualenvs (aliases: ls) |
scuv list --pythons | List installed Python versions |
scuv list --sort <name|created|last-used> | Sort order; envs missing the timestamp sort last with name tie-break (0.13.0) |
scuv create <name> [version] | Create virtualenv (default: latest Python) |
scuv create <name> <ver> --install-python | Create env; install Python on demand if missing (v0.11.0) |
scuv use <name> | Set + activate environment |
scuv use <name> --global | Set as global default |
scuv use <name> --link | Also create .venv symlink for IDE |
scuv use system | Deactivate, use system Python |
scuv use --unset | Remove version file |
scuv remove <name> | Delete virtualenv (aliases: rm, delete) |
scuv clone <src> <dst> | Duplicate an env in-place (--no-packages for skeleton) (v0.11.0) |
scuv install [version] | Install Python version |
scuv uninstall <version> | Remove Python version |
scuv info <name> | Show virtualenv details (includes Last used: row since 0.13.0) |
scuv status | Summarise current state (Active/Configured/System/None) (v0.11.0); includes Last used: since 0.13.0 |
scuv which <exe> | Resolve an executable inside the active env (v0.11.0) |
scuv run <env> -- <cmd> | Run a command inside an env without activating (v0.11.0) |
scuv sync | Reconcile the active env with .scuv.toml manifest (v0.11.0) |
scuv export <name> | Snapshot an env as JSON (schema v1) (v0.11.0) |
scuv import <file> | Restore an env from a JSON snapshot (v0.11.0) |
scuv doctor | Health check |
scuv doctor --fix | Auto-fix issues |
scuv shell <name> | Set shell-specific env (temporary) |
scuv shell --unset | Clear shell-specific setting |
scuv init <shell> | Output shell init script |
scuv completions <shell> | Generate completion script |
scuv lang [code] | Get/set language (en, ko, ja, pt-BR, es) |
scuv migrate list | List migratable envs (pyenv, conda, virtualenvwrapper) |
scuv migrate @env <name> | Migrate single environment |
scuv migrate all | Migrate all environments (parallel via rayon since v0.11.0) |
scuv gc | Garbage-collect orphan virtualenvs (--yes to actually remove, --aggressive also for unused Pythons, --older-than <n>d/w/y flags stale envs by last_used; envs with no last_used are never matched) |
scuv prune | Prune the uv cache (uv cache prune wrapper) |
scuv verify [NAME] | Per-env health diagnosis — 6 checks (metadata, python binary, pyvenv.cfg, activate, exec, manifest drift); --strict exits 1 on issues |
scuv man [DIR] | Generate man pages (stdout or one file per subcommand in DIR) |
scuv diff <a> <b> | Compare two environments: Python, packages, metadata |
scuv self update | Update scuv itself from crates.io |
Most commands support --json for machine-readable output.
Global options: --quiet, --color <auto|always|never>, --no-color
Key Concepts
Set a Specific Python Version as Global Default
To set Python 3.11.0 as the global default for new shells:
scuv install 3.11.0
scuv create py311 3.11.0
scuv use py311 --global
Important: --global stores an environment name (py311) in ~/.scuv/version,
not the raw Python version string. Local .scuv-version and SCUV_VERSION
override the global default.
Create a Project Environment with Python 3.9.5
scuv install 3.9.5
scuv create myproject 3.9.5
scuv info myproject
If 3.9.5 is not found, check discovery with uv python list and
scuv list --pythons, then install and retry.
Uninstall Python and Associated Environments
# Optional preview
scuv list --python-version 3.12
# Remove Python 3.12 and all environments that use it
scuv uninstall 3.12 --cascade
# Verify cleanup
scuv list --pythons
scuv doctor
For automation, use scuv uninstall 3.12 --cascade --force.
Without --cascade, dependent environments are not removed and may become broken.
Temporarily Disable or Customize Auto-Activation (Project-Scoped)
# Current shell only (temporary disable)
export SCUV_NO_AUTO=1
unset SCUV_NO_AUTO
# Project-local behavior (writes .scuv-version in current dir)
scuv use system
scuv use myproject
# Terminal-only override (no file changes)
scuv shell system
scuv shell --unset
Use these without --global to avoid changing global settings.
Install Dependencies from requirements.txt in Active Environment
# environment already active (prompt shows: (myproject))
uv pip install -r requirements.txt
Use uv pip install -r path/to/requirements.txt for non-root files.
Verify with uv pip list. scuv envs have no pip of their own; uv pip targets the active env.
List Python Versions and Associated Environments
scuv list --pythons
scuv list
scuv list --python-version 3.12
Use --json for automation and --bare for script-friendly output.
For full mapping in shell scripts, iterate versions from scuv list --pythons --bare,
cut each to major.minor (an env may record 3.12 rather than 3.12.14), and query each with
scuv list --python-version <VERSION> --bare.
Integrate Custom or Pre-Existing Python
# Preferred: explicit interpreter path
scuv create myenv --python-path /opt/python-debug/bin/python3
# Alternative: make interpreter discoverable via PATH
export PATH="/opt/python-debug/bin:$PATH"
scuv create myenv 3.13
Verify with uv python list, scuv info myenv, and scuv doctor -v.
Custom interpreter path is stored in ~/.scuv/virtualenvs/<name>/.scoop-metadata.json
(python_path field).
Version Files
Priority (first match wins):
SCUV_VERSIONenv var (shell session override, set byscuv shell).scuv-versionin current directory (local, walks parent directories)~/.scuv/version(global default)
Shell Integration
scuv outputs shell code to stdout; the shell wrapper evals it (pyenv pattern).
Auto-activation triggers on directory change when .scuv-version is present.
Supported shells: bash, zsh, fish, PowerShell (Core 7.x+ and Windows PowerShell 5.1+)
Disable auto-activation: export SCUV_NO_AUTO=1
Environment Name Rules
- Pattern:
^[a-zA-Z][a-zA-Z0-9_-]*$(max 64 chars) - Must start with a letter
- Reserved words: activate, base, clone, completions, create, deactivate, default, delete, export, global, help, import, init, install, list, local, remove, resolve, root, run, status, sync, system, uninstall, use, version, versions, which
Migration Sources
Import environments from pyenv-virtualenv, virtualenvwrapper, and conda.
Internationalization
Supported languages: English (en), Korean (ko), Japanese (ja), Portuguese-BR (pt-BR), Spanish (es)
Priority: SCUV_LANG env > ~/.scuv/config.json > system locale > en
Configuration
- Config file:
~/.scuv/config.json - Home directory:
~/.scuv/(override:SCUV_HOME) - Metadata:
~/.scuv/virtualenvs/<name>/.scoop-metadata.json
Examples
Explore real-world usage examples in the examples/ directory on GitHub.
| Example | Description |
|---|---|
| Basic Workflow | Create, use, and remove environments |
| Migration from pyenv | Import pyenv-virtualenv environments |
| Multi-Project Setup | Manage multiple project environments |
| GitHub Actions CI | Use scuv in CI pipelines |