Environment Management

Environment management in HORUS covers the packages and versions a project uses: recording what resolution picked, sharing that record with the team, and understanding the .horus/ directory HORUS maintains for you.

Overview

horus.lock is closest to a pip freeze output that is written but not yet read back:

  • Record the versions resolution picked, into horus.lock
  • Share that record with teammates by committing the lockfile
  • Review it in a diff, so a version change is visible
  • Track environment history in version control

Quick Start

ℹ️What `horus.lock` pins, and what it still does not

The [[package]] entries are applied, not just recorded. Every installer asks the lockfile for a version before resolving one, so checking out a committed horus.lock on a clean machine installs what the file names instead of whatever latest resolves to there that day.

An ordinary horus run or horus build may add a pin for a package that has none. It may not move one that is already there — only horus lock, which is an explicit request to re-resolve, and a horus.toml that names a version the pin cannot satisfy. That second case is the escape hatch: editing numpy = ">=2.0" beats a pin at 1.26.4, the same way a manifest edit beats Cargo.lock.

checksum carries a sha256: digest for the sources that publish one, and an artifact whose digest disagrees with the pin at the same version is refused:

  • registry — the digest the installer already verified against the server.
  • pypi — the digest pip reports for the artifact it chose (pip 22.2+). Older pip cannot report one, so the field stays empty rather than being filled with something weaker.
  • crates.iocargo install neither reports the .crate digest nor leaves it on disk, so this is always empty. An empty checksum means "no digest was available", never "verified".

Still recorded rather than enforced: [toolchain] and [[system]] are reported by horus doctor but nothing installs or switches to them, and features is a record of what was active at lock time, not a constraint on the next build.

Record Your Environment

# Write horus.lock with the versions resolution picked
horus lock

# Verify the lockfile is up to date (CI-friendly; does not rewrite it)
horus lock --check

Commit horus.lock alongside horus.toml. Today that gives you a reviewable record and a CI check that catches a manifest change nobody re-resolved; it does not yet give you the same versions on the other machine.

Understanding HORUS's Hidden Environment

HORUS automatically manages a hidden .horus/ environment in each project. Understanding the global vs local architecture helps you work efficiently across multiple projects.

Global Cache (Shared Storage)

Location: ~/.cache/horus/ — the XDG cache directory. $XDG_CACHE_HOME/horus when that is set, ~/Library/Caches/horus on macOS.

There is a second root. horus install writes the XDG cache above, but horus run's own dependency installer writes ~/.horus/cache for both PyPI and crates.io packages. Every reader searches both roots, so a package installed by either command is found by the other — but if you are clearing a cache by hand, or looking for where something landed, check both.

All packages are downloaded once and stored here:

~/.cache/horus/
├── horus_py@0.1.0/                # HORUS registry packages
│   └── lib/horus/
├── cratesio_serde@1.0.228/        # External crates (from crates.io)
│   ├── src/
│   ├── Cargo.toml                 # Cargo lives here, not your project!
│   └── lib/libserde.rlib          # Pre-compiled
└── pid-controller@1.2.0/          # More HORUS registry packages

Directory names carry the source: HORUS registry packages are <name>@<version>, crates.io packages cratesio_<name>@<version>, and PyPI packages pypi_<name>@<version>.

Benefits:

  • Download once - Use in all projects
  • Saves disk space - No duplication
  • Faster setup - Cached packages install instantly
  • Works offline - Already have what you need

Local Workspace (Project-Specific)

Location: .horus/ in your project

Each project gets its own isolated environment:

my_robot_project/
├── horus.toml              # Dependencies declared here
├── main.py / main.rs       # Your code
└── .horus/                 # Hidden automatic environment
    ├── packages/           # Symlinks to global cache
    │   ├── horus_py -> ~/.cache/horus/horus_py@0.1.0/
    │   └── serde -> ~/.cache/horus/cratesio_serde@1.0.228/
    ├── bin/                # Project binaries (auto-created)
    ├── lib/                # Project libraries (auto-created)
    ├── Cargo.toml          # Generated for Rust projects (auto-managed)
    ├── Cargo.lock          # Cargo lock file for Rust (auto-managed)
    └── target/             # Cargo build artifacts for Rust (auto-managed)

Key Points:

  • Workspace marker - .horus/ identifies a HORUS project
  • Symlinks not copies - Points to global cache (lightweight!)
  • Isolated - Each project independent
  • Auto-managed - Created by horus run, not by you

Automatic Workflow

When you run horus run:

  1. Reads horus.toml dependencies
  2. Checks global cache - already downloaded?
  3. Downloads if missing:
    • HORUS registry first
    • crates.io fallback for external Rust crates
  4. Compiles external crates (in global cache with cargo)
  5. Symlinks to .horus/packages/ in your project
  6. For Rust projects: Generates .horus/Cargo.toml from horus.toml with path-based dependencies
  7. Runs your code with correct environment

You normally never touch .horus/ - it's managed for you. The one exception is overriding a broken global cache, covered below.

Why This Matters

Portable: horus.toml works on any machine

# Team member clones your project
git clone your-repo
cd your-repo
horus run  # Auto-installs dependencies and runs

Lightweight: Projects stay small

# Without HORUS (traditional)
project1/node_modules/  # 500 MB
project2/node_modules/  # 500 MB
project3/node_modules/  # 500 MB
# Total: 1.5 GB duplicated!

# With HORUS (global cache)
~/.cache/horus/         # 500 MB (shared)
project1/.horus/packages/  # Symlinks only (a few KB)
project2/.horus/packages/  # Symlinks only (a few KB)
project3/.horus/packages/  # Symlinks only (a few KB)
# Total: 500 MB globally, ~10 KB per project!

Isolated: Projects don't interfere

# Different versions supported
project_a/horus.toml:  serde@1.0.228
project_b/horus.toml:  serde@1.0.150
# Both work - isolated environments

Solving Dependency Hell

HORUS solves dependency hell with local-first resolution and smart fallback:

Resolution Order (how horus run finds packages):

  1. Check local first - .horus/packages/<package> exists? Use it immediately
  2. Check global cache - Only if not found locally
  3. Install from registry - Only if missing from both

Local Always Wins:

# Scenario: Global cache has broken package
~/.cache/horus/
└── cratesio_serde@1.0.228/  # Corrupted or incompatible

my_project/.horus/packages/
└── serde/                   # Working local version (never version-suffixed)

# When you run:
horus run
# -> Uses local .horus/packages/serde/
# -> NEVER checks global cache
# -> Broken global version ignored!

Where horus install Puts Things:

-t/--target decides which workspace the package is installed for:

# No target: installs into the global cache ~/.cache/horus/
horus install serde

# With a target: installs into that workspace's .horus/packages/
horus install serde -t my_robot_project
  • Default (no -t) - Downloads into the global cache ~/.cache/horus/, so every project can share it. This does not modify horus.toml.
  • With -t <workspace-name> - Installs into the named workspace's .horus/packages/ instead. The name is resolved through the workspace registry, so it is a workspace name, not a path.

Where the files physically land can still differ. For HORUS registry packages, -t unpacks into the global cache and symlinks the package into the workspace's .horus/packages/ when that package is already cached globally; only when nothing is cached do the files land in .horus/packages/ directly. Either way the workspace ends up with a .horus/packages/<name> entry pointing at the right version.

Override Broken Global Cache:

If global cache has corrupted or incompatible package:

# Option 1: Remove symlink and reinstall into the workspace
rm .horus/packages/serde  # Remove symlink to broken global
horus install serde -t my_robot_project  # Installs into that workspace's .horus/packages/

# Option 2: Install a different version
horus install serde@1.0.150  # Specific working version (into the global cache)

# Option 3: Clear the cache `horus run` fills and let it reinstall
rm -rf ~/.horus/cache/cratesio_serde@1.0.228/
horus run  # Auto-reinstalls into ~/.horus/cache

Benefits:

  • True isolation - Local packages override global
  • No conflicts - Version-specific directories coexist
  • Disk efficient - Uses global cache when possible
  • Escape hatch - Can bypass broken global cache
  • Zero config - Works automatically

Comparison with other tools:

FeaturePython venvNode node_modulesHORUS
Isolated envsYes - full copiesYes - full copiesYes - symlinks plus local override
Disk efficientNo - duplicates everythingNo - duplicates everythingYes - global cache shared
Local overrideAlways local, no shared cache to fall back toAlways local, no shared cache to fall back toLocal-first, global fallback
Escape broken globalNot applicable - no shared globalNot applicable - no shared globalYes - a local copy overrides it
Multiple versionsOne per venvNested copies per dependencyVersion-specific dirs in one cache

Best of both worlds:

  • Disk efficiency of global cache (like Cargo, pip global)
  • Isolation of virtual environments (like venv, node_modules)
  • Smart automatic fallback

Best Practices

Version Control

Always commit the lockfile. It is the record of what resolved. horus lock --check in CI compares the pins against the packages actually installed under .horus/packages and exits non-zero on drift, naming each package. It does not read horus.toml, so it will not catch a manifest edit on its own — run horus lock after changing dependencies:

# Add to git
git add horus.toml horus.lock
git commit -m "Update environment: add sensor-fusion package"

# .gitignore (auto-generated by `horus new`)
.horus/packages/      # Don't commit symlinks/packages
.horus/bin/           # Don't commit compiled binaries
.horus/lib/           # Don't commit libraries
.horus/include/       # Don't commit headers
.horus/cache/         # Don't commit cache
.horus/target/        # Don't commit Cargo build artifacts
.horus/Cargo.toml     # Generated from horus.toml
.horus/Cargo.lock     # Generated by Cargo

Keep the Lockfile Current

Regenerate after changing dependencies, and verify in CI:

# After adding or updating a dependency
horus lock

# In CI, fail if the lockfile is out of date
horus lock --check

Environment Hygiene

Keep environments clean:

# Remove unused packages
horus list
horus remove unused-package

# Re-record
horus lock

Troubleshooting

Package Not Available

Error:

Error: Package 'legacy-driver@0.5.0' not found in registry

Causes:

  • Package was unpublished
  • Version no longer available
  • Registry connection issue

Solutions:

# Option 1: Update horus.toml
# Remove or replace the unavailable package

# Option 2: Install alternative
horus install modern-driver
horus lock  # Re-record

# Option 3: Confirm the version is still cached - `horus run` reuses it
ls ~/.horus/cache/legacy-driver@0.5.0/

Checksum Mismatch

Error:

Error: Checksum mismatch for 'pid-controller@1.2.0'
  Expected: sha256:a3b2c1...
  Got:      sha256:x9y8z7...

Causes:

  • Package was modified on registry
  • Corrupted download
  • Network issue

Solutions:

# Delete the corrupted copy from the global cache, then let horus run fetch it again
rm -rf ~/.cache/horus/pid-controller@1.2.0/
rm -f .horus/packages/pid-controller  # Drop the now-dangling symlink
horus run  # Re-downloads it from the registry (confirm the prompt)

Do not use horus remove here: it deletes the dependency from horus.toml, and horus install is a standalone install that does not put it back.

Version Conflicts

Error:

Error: Cannot satisfy version constraints
  motion-planner requires pathfinding-utils ^1.2
  sensor-fusion requires pathfinding-utils ^1.0

Solutions:

# Option 1: Install compatible version
horus install sensor-fusion@2.0.0

# Option 2: Edit horus.toml manually
# Change pathfinding-utils to a compatible version

# Option 3: Remove conflicting package
horus remove sensor-fusion
horus lock  # Re-record

Registry Unavailable

Error:

Error: Failed to fetch package from registry

Solutions:

# Check what is already in the global cache - `horus run` reuses
# cached packages instead of contacting the registry
ls ~/.cache/horus/

There Is No horus env list

There is no command that lists or inspects environments. horus env only manages shell integration (--init / --uninstall) — it has no list, show, delete, or export subcommand, and there is no environment registry to query. Commit horus.lock and inspect it as an ordinary file instead.

Ignoring Files and Packages

The ignore Section

During development, you may have experimental code, debug files, or development-only packages that you don't want HORUS to process during horus run. The ignore section in horus.toml allows you to exclude these items.

Configuration

Add an [ignore] section to your horus.toml:

[package]
name = "my_robot_project"
version = "0.1.0"

[dependencies]
horus_py = "0.1.0"
numpy = { version = "*", source = "pypi" }

# Optional: Ignore files, directories, and packages
[ignore]
files = [
  "debug_*.py",         # Ignore root-level files starting with debug_
  "test_*.rs",          # Ignore root-level test files
  "**/experiments/",    # Ignore anything under an experiments/ directory
]
directories = [
  "old/",               # Ignore old/ directory
  "experiments/",       # Ignore experiments/ directory
]
packages = [
  "ipython",            # Don't auto-install ipython
  "jupyter",            # Don't auto-install jupyter
]

Pattern Matching

The ignore feature supports flexible pattern matching:

Wildcard (*):

  • debug_*.py - Matches debug_test.py, debug_node.py, etc.
  • test_* - Matches any path starting with test_

A pattern containing * is anchored at the start of the path, so it only matches files at the project root: debug_*.py does not ignore src/debug_node.py. To catch nested files, use a **/ pattern with no * after it (**/debug_) or a directories entry.

Recursive directory (**/):

  • **/test.py - Matches test.py at any depth
  • **/build/ - Matches any build/ directory at any depth

The text after **/ is matched literally, so a trailing ** — or any * after the **/ — never matches. Write **/experiments/, not **/experiments/**.

Substring / suffix match:

  • old/ (a directories entry) - Matches any path containing old, so pick specific names: old/ also ignores src/goldfish.py
  • debug.py (a wildcard-free files entry) - Matches any path that equals or ends with debug.py, including src/debug.py and mydebug.py

Use Cases

Development files:

[ignore]
files = ["debug_*.py", "scratch_*.rs", "**/temp/"]

Test and experimental code:

[ignore]
directories = ["tests/", "experiments/", "benchmarks/"]

Development-only packages:

[ignore]
packages = [
  "ipython",      # Interactive shell for debugging
  "jupyter",      # Notebook for visualization
  "pytest",       # Testing framework
]

Behavior

When files/directories are ignored:

  • File detection: Ignored files won't be detected as main files by horus run
  • Glob patterns: Ignored files are excluded from glob patterns like horus run "*.py"
  • Multi-file execution: Ignored files are skipped when running multiple files

When packages are ignored:

  • Dependency scanning: Ignored packages won't be auto-installed during horus run
  • Import detection: If your code imports an ignored package, HORUS won't try to resolve it
  • Manual installation: You can still manually install ignored packages with horus install

Example Workflow

1. Create a project with debug files:

horus new my_project --python
cd my_project

2. Add debug files during development:

# Create some debug/experimental files
touch debug_sensor_test.py
mkdir experiments
touch experiments/new_algorithm.py

3. Update horus.toml to ignore them:

[ignore]
files = ["debug_*.py"]
directories = ["experiments/"]
packages = ["ipython"]

4. Run the project:

horus run
# Only main.py runs, debug files and experiments are ignored

5. Explicitly run ignored files when needed:

horus run debug_sensor_test.py
# Ignored files can still be run explicitly

Best Practices

Keep it minimal: Only ignore what's necessary. Over-ignoring can make debugging harder.

Use version control: Commit your horus.toml with ignore patterns so team members have consistent behavior:

git add horus.toml
git commit -m "Add ignore patterns for debug files"

Document why: Add comments explaining why certain patterns are ignored:

[ignore]
# Legacy code being phased out
directories = ["old_controllers/"]

# Development tools not needed in production
packages = ["ipython", "jupyter"]

Next Steps