diff --git a/.agents/skills/nixo-cli/SKILL.md b/.agents/skills/nixo-cli/SKILL.md new file mode 100644 index 0000000..5fb2e4e --- /dev/null +++ b/.agents/skills/nixo-cli/SKILL.md @@ -0,0 +1,59 @@ +--- +name: nixo-cli +description: Use when requests involve nixo or nixosandbox sandbox lifecycle operations, catalog queries, session-id workflows, or CLI errors such as 'Could not find flake.nix' and create flag conflicts. +--- + +# nixo CLI + +Use this skill for runtime-agnostic workflows around the `nixo` command line. + +## When not to use + +- Do not use this skill for Nix flake authoring or package derivation work unless the task explicitly centers on the `nixo` CLI workflow. +- Do not use this skill when the user only needs raw `nix build`, `nix develop`, or `bubblewrap` commands outside a sandbox session flow. +- Do not use this skill when another repo-local workflow overrides the CLI, such as project-specific wrapper scripts or extension-only APIs. + +## Naming + +- Use `nixo` as the default command in all new instructions, prompts, and automation. +- Use `nixosandbox` only when the user explicitly asks for the legacy name or when `nixo` is unavailable. +- Keep flags and behavior identical across both names. + +## Core workflow + +- Discover available packages with `catalog`. +- Create a sandbox with `create`, using `--with` for package lists or `--profile` for named presets. +- Use `--json` when the output will be parsed by another tool or agent. +- Run commands inside an existing sandbox with `exec -- `. +- Inspect session state with `list` and `status`. +- Remove sessions with `destroy` when they are no longer needed. + +## Safe defaults + +- Prefer `--network off` unless the task explicitly needs downloads or live network access. +- Prefer narrow package sets over broad sandboxes. +- Prefer machine-readable output when the result feeds another step. +- Use `list` before `destroy` if you need to confirm the active session set. +- For automation, capture `sessionId` from `create --json` output and reuse it for `exec`, `status`, and `destroy`. + +## Input and output expectations + +- Prefer plain-text `nixo` commands for human guidance and shell examples. +- Prefer `--json` for agent-to-agent handoff, scripts, or any step that will parse command output. +- Treat `create --json` as the canonical machine-readable entrypoint because it returns the `sessionId` needed for later steps. +- Keep JSON consumers on `nixo catalog --json` unless they explicitly need grouped categories, then use `nixo catalog --json --grouped`. +- Emit `nixosandbox` only as a compatibility fallback when `nixo` is unavailable or the user asks for the legacy name. + +## Common failure patterns + +- `Could not find flake.nix`: + - set `NIXOSANDBOX_FLAKE_ROOT` to a directory containing `flake.nix`, or run from the repo root. +- `create` option conflicts: + - never combine `--with`, `--profile`, and `--spec` in the same command. +- Invalid package names with `--with`: + - confirm package names through `catalog` first. + +## When to read more + +- [Quick reference](references/quick-reference.md) +- [Troubleshooting](references/troubleshooting.md) diff --git a/.agents/skills/nixo-cli/references/quick-reference.md b/.agents/skills/nixo-cli/references/quick-reference.md new file mode 100644 index 0000000..f0ac0b3 --- /dev/null +++ b/.agents/skills/nixo-cli/references/quick-reference.md @@ -0,0 +1,31 @@ +# Quick Reference + +## Common commands + +```bash +nixo catalog +nixo catalog --json +nixo create --with bash,coreutils --network off --json +nixo create --profile strict --json +nixo exec -- echo hello +nixo exec --json -- python3 -c "print('hello')" +nixo list +nixo status +nixo destroy +``` + +## Typical patterns + +- Package discovery: + - use `catalog` to find names before creating a sandbox +- New sandbox: + - `create --with --network off --json` +- Reusing a session: + - `exec -- ` +- Cleanup: + - `destroy ` after the work is finished + +## Alias note + +- `nixosandbox` is the compatibility alias. +- Prefer `nixo` in new instructions and examples. diff --git a/.agents/skills/nixo-cli/references/troubleshooting.md b/.agents/skills/nixo-cli/references/troubleshooting.md new file mode 100644 index 0000000..e05ef4c --- /dev/null +++ b/.agents/skills/nixo-cli/references/troubleshooting.md @@ -0,0 +1,41 @@ +# Troubleshooting + +## `nixo` is not found + +- Check whether the primary binary is installed. +- Try `nixosandbox` if you are on an older setup that still exposes the compatibility alias. +- Verify the PATH points to the directory that contains the installed binary. + +## `catalog` or `create` fails early + +- Confirm the command name is correct and the binary is the expected one. +- Re-run with `--json` only if you need structured output; it does not fix command errors. +- If using `create --with`, make sure package names are valid and comma-separated. + +## `Could not find flake.nix` + +- Set flake root explicitly: + - `export NIXOSANDBOX_FLAKE_ROOT=/path/to/nixo` +- Verify the path: + - `test -f "$NIXOSANDBOX_FLAKE_ROOT/flake.nix" && echo ok` +- Retry: + - `nixo catalog --json` +- Alternative: + - run commands from the repository root that contains `flake.nix`. + +## Sandbox creation problems + +- Prefer `--network off` unless the task needs network access. +- If the sandbox setup references a profile, confirm the profile exists and matches the intended package set. +- If the CLI reports a missing package, re-check the package name returned by `catalog`. + +## Session execution problems + +- Use `status ` to confirm the session still exists. +- Use `list` to verify the ID before calling `exec` or `destroy`. +- If a command fails inside the sandbox, distinguish between sandbox setup failure and command exit status. + +## Compatibility issues + +- If instructions mention `nixosandbox`, treat them as valid alias usage, not a different tool. +- When documenting or prompting new workflows, prefer `nixo` so the canonical name stays consistent. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89007fd..d1c55ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,6 +95,29 @@ jobs: NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json | python3 -m json.tool > /dev/null echo "Catalog JSON is valid" + - name: Test grouped catalog output + run: | + grouped_json=$(NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json --grouped) + echo "$grouped_json" | python3 -m json.tool > /dev/null + echo "$grouped_json" | python3 -c ' + import json, sys + data = json.load(sys.stdin) + missing = [key for key in ["agentCategories", "tools"] if key not in data] + if missing: + print(f"ERROR: grouped catalog JSON missing keys: {missing}", file=sys.stderr) + sys.exit(1) + ' + echo "Grouped catalog JSON is valid" + + - name: Verify CLI alias help parity + run: | + tmpdir=$(mktemp -d) + trap 'rm -rf "$tmpdir"' EXIT + NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo --help > "$tmpdir/nixo-help" + NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox --help > "$tmpdir/nixosandbox-help" + diff -u "$tmpdir/nixo-help" "$tmpdir/nixosandbox-help" + echo "nixo and nixosandbox help output match" + - name: Verify catalog agent count and new packages run: | catalog_json=$(NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..35bf670 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: Release + +on: + push: + tags: + - "v*.*.*" + - "v*.*.*-*" + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + runner: macos-14 + - target: x86_64-apple-darwin + runner: macos-13 + - target: x86_64-unknown-linux-gnu + runner: ubuntu-24.04 + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - uses: swatinem/rust-cache@v2 + with: + workspaces: crates/nixosandbox + + - name: Build release binaries + working-directory: crates/nixosandbox + run: cargo build --locked --release --target "${{ matrix.target }}" --bin nixo --bin nixosandbox + + - name: Package release artifact + shell: bash + run: | + set -euo pipefail + target="${{ matrix.target }}" + package_dir="dist/nixo-${target}" + mkdir -p "$package_dir/bin" "$package_dir/flake" + install -m 755 "crates/nixosandbox/target/${target}/release/nixo" "$package_dir/bin/nixo" + install -m 755 "crates/nixosandbox/target/${target}/release/nixosandbox" "$package_dir/bin/nixosandbox" + install -m 644 flake.nix "$package_dir/flake/flake.nix" + install -m 644 flake.lock "$package_dir/flake/flake.lock" + cp -R nix "$package_dir/flake/" + tar -C "$package_dir" -czf "dist/nixo-${target}.tar.gz" bin flake + if [[ "${RUNNER_OS}" == "macOS" ]]; then + shasum -a 256 "dist/nixo-${target}.tar.gz" > "dist/nixo-${target}.tar.gz.sha256" + else + sha256sum "dist/nixo-${target}.tar.gz" > "dist/nixo-${target}.tar.gz.sha256" + fi + + - uses: actions/upload-artifact@v4 + with: + name: nixo-${{ matrix.target }} + path: | + dist/nixo-${{ matrix.target }}.tar.gz + dist/nixo-${{ matrix.target }}.tar.gz.sha256 + if-no-files-found: error + + release: + name: Publish GitHub release + needs: build + runs-on: ubuntu-24.04 + steps: + - uses: actions/download-artifact@v4 + with: + pattern: nixo-* + path: release-assets + merge-multiple: true + + - uses: softprops/action-gh-release@v2 + with: + files: release-assets/* diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7641a0f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,98 @@ +# AGENTS.md + +This file provides guidance to coding agents when working in this repository. + +## Build and test commands + +### Rust CLI +```bash +cd crates/nixosandbox +cargo build # build +cargo test # run all tests +cargo test session::tests # run tests in one module +cargo test metadata_roundtrip # run a single test by name +``` + +### TypeScript extension +```bash +cd packages/pi-sandbox-extension +npm install +npx tsc --noEmit # typecheck only +npm run build # compile to dist/ +``` + +### Nix +```bash +nix flake check --accept-flake-config +nix build --accept-flake-config .#nixosandbox # build CLI as Nix package +nix eval --accept-flake-config .#catalog.agents --apply 'x: builtins.attrNames x' +nix eval --accept-flake-config .#catalog.tools --apply 'x: builtins.attrNames x' +``` + +### CLI smoke test (Linux only, requires bwrap) +```bash +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo catalog --json --grouped +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo create --with bash,coreutils --network off --json +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo exec -- echo hello +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox --help # compatibility alias +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo destroy +``` + +## Architecture + +### Core data flow + +1. **User runs** `nixo create --with claude-code,bash --network off` +2. **nix.rs** resolves package names via `build_with_catalog()` which generates a Nix expression calling `mkAgentSandbox` +3. **mkAgentSandbox.nix** resolves names from the catalog (agents first, then tools) and delegates to `mkSandboxRootfs` +4. **mkSandboxRootfs.nix** uses `pkgs.buildEnv` to merge packages, then creates a rootfs directory with symlinks into `/nix/store` +5. **session.rs** creates a session directory under `~/.local/share/nixosandbox/sessions//` with metadata, workspace, home, and cache dirs +6. **User runs** `nixo exec -- command` +7. **plan_builder.rs** constructs bwrap argv: `--ro-bind /`, `--ro-bind /nix/store /nix/store`, writable bind mounts for workspace/home/cache, namespace flags, env vars +8. **lib.rs** spawns bwrap (detected path from `bubblewrap::detect()`) with the constructed argv + +### Key design decisions + +- **Primary CLI name is nixo**: `src/bin/nixo.rs` is the canonical entrypoint, and `src/bin/nixosandbox.rs` invokes the same app for compatibility. +- **Profile field is overloaded**: `session.profile` stores either a built-in profile name (e.g., `"strict"`) which maps to `nix/profiles/.json`, or `"custom:,"` for `--with` sessions. Code must check `meta.profile.starts_with("custom:")` before calling `load_profile()`. +- **Rootfs symlinks require Nix store**: The rootfs contains absolute symlinks into `/nix/store`. bwrap must bind-mount `/nix/store` read-only, and the rootfs must have `/nix/store` as an empty mount point directory. +- **macOS support via Docker sidecar**: On non-Linux, `bubblewrap::detect()` tries a Docker sidecar container (`nixosandbox-sidecar`) with bwrap inside. Session paths are rewritten from host to container paths via `docker::rewrite_path()`. +- **Package name validation**: `nix::validate_package_name()` rejects names not matching `[a-zA-Z0-9_.-]+` to prevent Nix expression injection via `--with`. +- **Network mode storage**: For `--with` sessions, the network mode is stored in `session.network` (not derivable from a profile file). For built-in profiles, it's in the profile JSON. + +### Rust module responsibilities + +| Module | Owns | +|--------|------| +| `cli.rs` | clap argument parsing | +| `lib.rs` | command dispatch, `cmd_create`, `cmd_exec`, `cmd_catalog`, etc. | +| `session.rs` | session CRUD, metadata serialization, directory layout | +| `nix.rs` | `find_flake_root()`, `build_profile()`, `build_with_catalog()`, `query_catalog()` (filters non-derivation attrs via `filterDrvs` before reading `.meta.description`) | +| `plan_builder.rs` | bwrap argv construction (`--ro-bind`, `--bind`, `--unshare-*`, `--setenv`) | +| `bubblewrap.rs` | bwrap detection: `NIXOSANDBOX_BWRAP_PATH` env var, then `which bwrap`, Docker fallback on macOS | +| `docker.rs` | Docker sidecar lifecycle (find, start, create, image build) | +| `spec.rs` | profile/spec loading from JSON, validation | + +### Nix module responsibilities + +| File | Owns | +|------|------| +| `nix/catalog.nix` | Unified `{ agents, tools }` attrset — agents is a full dynamic passthrough of `llm-agents-pkgs` (no whitelist), tools from nixpkgs | +| `nix/mkSandboxRootfs.nix` | Builds rootfs directory tree from package list (symlinks, /etc, certs) | +| `nix/mkAgentSandbox.nix` | Resolves catalog names to packages, delegates to mkSandboxRootfs | +| `nix/profiles/*.json` | Built-in profile specs (strict, build-install, offline-review, debug-network) | + +### TypeScript extension (packages/pi-sandbox-extension) + +Provides Pi coding agent integration. Key files: +- `cli-client.ts` — spawns the `nixo` CLI as a subprocess (`nixosandbox` remains a compatibility alias), provides `createSession()`, `execCommand()`, `catalogPackages()` +- `extension.ts` — registers Pi tools: `sandboxRun`, `sandboxReadFile`, `sandboxWriteFile`, `sandboxListFiles`, `sandboxSessionInfo`, `sandboxBrowser`, `sandboxCatalog` +- `contract.ts` — TypeScript type definitions for the NDJSON protocol + +## Session tests use env var serialization + +Tests in `session.rs` mutate `NIXOSANDBOX_DATA_DIR` to use temp directories. They serialize via `static ENV_LOCK: Mutex<()>` acquired in `with_temp_data_dir()`. If adding session tests, always use this helper. + +## CI runs on nixos-25.11 + +The flake pins `nixpkgs` to `nixos-25.11`. CI uses `cachix/install-nix-action@v30` with the numtide binary cache. Agent smoke tests use system apt bwrap (setuid) because Nix-built bwrap lacks setuid and fails on Ubuntu 24.04's AppArmor user namespace restrictions. diff --git a/CLAUDE.md b/CLAUDE.md index 02f1c83..2198163 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,27 +31,29 @@ nix eval --accept-flake-config .#catalog.tools --apply 'x: builtins.attrNames x' ### CLI smoke test (Linux only, requires bwrap) ```bash -NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json -NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox create --with bash,coreutils --network off --json -NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox exec -- echo hello -NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox destroy +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo catalog --json --grouped +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo create --with bash,coreutils --network off --json +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo exec -- echo hello +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox --help # compatibility alias +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo destroy ``` ## Architecture ### Core data flow -1. **User runs** `nixosandbox create --with claude-code,bash --network off` +1. **User runs** `nixo create --with claude-code,bash --network off` 2. **nix.rs** resolves package names via `build_with_catalog()` which generates a Nix expression calling `mkAgentSandbox` 3. **mkAgentSandbox.nix** resolves names from the catalog (agents first, then tools) and delegates to `mkSandboxRootfs` 4. **mkSandboxRootfs.nix** uses `pkgs.buildEnv` to merge packages, then creates a rootfs directory with symlinks into `/nix/store` 5. **session.rs** creates a session directory under `~/.local/share/nixosandbox/sessions//` with metadata, workspace, home, and cache dirs -6. **User runs** `nixosandbox exec -- command` +6. **User runs** `nixo exec -- command` 7. **plan_builder.rs** constructs bwrap argv: `--ro-bind /`, `--ro-bind /nix/store /nix/store`, writable bind mounts for workspace/home/cache, namespace flags, env vars -8. **main.rs** spawns bwrap (detected path from `bubblewrap::detect()`) with the constructed argv +8. **lib.rs** spawns bwrap (detected path from `bubblewrap::detect()`) with the constructed argv ### Key design decisions +- **Primary CLI name is nixo**: `src/bin/nixo.rs` is the canonical entrypoint, and `src/bin/nixosandbox.rs` invokes the same app for compatibility. - **Profile field is overloaded**: `session.profile` stores either a built-in profile name (e.g., `"strict"`) which maps to `nix/profiles/.json`, or `"custom:,"` for `--with` sessions. Code must check `meta.profile.starts_with("custom:")` before calling `load_profile()`. - **Rootfs symlinks require Nix store**: The rootfs contains absolute symlinks into `/nix/store`. bwrap must bind-mount `/nix/store` read-only, and the rootfs must have `/nix/store` as an empty mount point directory. - **macOS support via Docker sidecar**: On non-Linux, `bubblewrap::detect()` tries a Docker sidecar container (`nixosandbox-sidecar`) with bwrap inside. Session paths are rewritten from host to container paths via `docker::rewrite_path()`. @@ -63,7 +65,7 @@ NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox destroy | Module | Owns | |--------|------| | `cli.rs` | clap argument parsing | -| `main.rs` | command dispatch, `cmd_create`, `cmd_exec`, `cmd_catalog`, etc. | +| `lib.rs` | command dispatch, `cmd_create`, `cmd_exec`, `cmd_catalog`, etc. | | `session.rs` | session CRUD, metadata serialization, directory layout | | `nix.rs` | `find_flake_root()`, `build_profile()`, `build_with_catalog()`, `query_catalog()` (filters non-derivation attrs via `filterDrvs` before reading `.meta.description`) | | `plan_builder.rs` | bwrap argv construction (`--ro-bind`, `--bind`, `--unshare-*`, `--setenv`) | @@ -83,7 +85,7 @@ NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox destroy ### TypeScript extension (packages/pi-sandbox-extension) Provides Pi coding agent integration. Key files: -- `cli-client.ts` — spawns the nixosandbox CLI as a subprocess, provides `createSession()`, `execCommand()`, `catalogPackages()` +- `cli-client.ts` — spawns the `nixo` CLI as a subprocess (`nixosandbox` remains a compatibility alias), provides `createSession()`, `execCommand()`, `catalogPackages()` - `extension.ts` — registers Pi tools: `sandboxRun`, `sandboxReadFile`, `sandboxWriteFile`, `sandboxListFiles`, `sandboxSessionInfo`, `sandboxBrowser`, `sandboxCatalog` - `contract.ts` — TypeScript type definitions for the NDJSON protocol diff --git a/Formula/nixo.rb b/Formula/nixo.rb new file mode 100644 index 0000000..51f38f8 --- /dev/null +++ b/Formula/nixo.rb @@ -0,0 +1,42 @@ +class Nixo < Formula + desc "Reproducible, isolated sandbox environments for AI coding agents" + homepage "https://github.com/HashWarlock/nixo" + version "0.1.0" + depends_on "nix" + + # Replace the placeholder sha256 values below with the published release checksums. + on_macos do + on_arm do + url "https://github.com/HashWarlock/nixo/releases/download/v#{version}/nixo-aarch64-apple-darwin.tar.gz" + sha256 "REPLACE_WITH_AARCH64_SHA256" + end + + on_intel do + url "https://github.com/HashWarlock/nixo/releases/download/v#{version}/nixo-x86_64-apple-darwin.tar.gz" + sha256 "REPLACE_WITH_X86_64_SHA256" + end + end + + on_linux do + on_intel do + url "https://github.com/HashWarlock/nixo/releases/download/v#{version}/nixo-x86_64-unknown-linux-gnu.tar.gz" + sha256 "REPLACE_WITH_LINUX_X86_64_SHA256" + end + end + + def install + libexec.install Dir["bin/*"] + pkgshare.install Dir["flake"] + + flake_root = pkgshare/"flake" + bin.write_env_script libexec/"nixo", "NIXOSANDBOX_FLAKE_ROOT" => flake_root + bin.install_symlink "nixo" => "nixosandbox" + end + + test do + assert_predicate pkgshare/"flake/flake.nix", :exist? + assert_predicate pkgshare/"flake/flake.lock", :exist? + assert_match "nixo", shell_output("#{bin}/nixo --help") + assert_match "nixo", shell_output("#{bin}/nixosandbox --help") + end +end diff --git a/README.md b/README.md index 9bf95a5..83bc31e 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,27 @@ -# nixosandbox +# nixo Reproducible, isolated sandbox environments for AI coding agents. Compose sandboxes from 88+ agents and 24+ tools using Nix, run them in Bubblewrap containers with configurable network and filesystem policies. +Primary CLI: `nixo`. The legacy `nixosandbox` binary remains available as a compatibility alias. ## What it does -nixosandbox creates lightweight Linux sandboxes from a catalog of Nix packages. You pick the agent and tools you need, and it builds an isolated rootfs with just those packages — no Docker images, no VMs, no manual setup. +nixo creates lightweight Linux sandboxes from a catalog of Nix packages. You pick the agent and tools you need, and it builds an isolated rootfs with just those packages — no Docker images, no VMs, no manual setup. ```bash # Create a sandbox with Claude Code + Git + Python -nixosandbox create --with claude-code,git,python312 --network off --json +nixo create --with claude-code,git,python312 --network off --json # Run a command inside it -nixosandbox exec -- claude --version +nixo exec -- claude --version # Or drop into an interactive shell -nixosandbox enter +nixo enter ``` ## Architecture ``` -nixosandbox CLI (Rust) +nixo CLI (Rust) ├── Nix: builds rootfs from catalog packages ├── Bubblewrap: creates isolated mount/pid/net namespaces ├── Session manager: tracks sandbox lifecycle @@ -32,10 +33,37 @@ nixosandbox CLI (Rust) ## Install +### Homebrew + +```bash +brew tap HashWarlock/nixo +brew install nixo +nixo --help +nixosandbox --help # compatibility alias +``` + +[`Formula/nixo.rb`](Formula/nixo.rb) is the in-repo Homebrew formula for the `HashWarlock/nixo` tap. It installs `nixo` as the primary executable and `nixosandbox` as a compatibility symlink. The packaged release ships `bin/` plus `flake/` assets, so the installed command does not require a checkout of this repository. You still need a working Nix runtime on the host because the CLI shells out to `nix` at runtime. Replace the placeholder sha256 values before publishing a release. + +Before publishing or updating the formula, run: + +```bash +brew audit --strict nixo +brew test nixo +``` + +To publish the first tap-backed release from this repo: + +1. Merge the formula and release workflow to `master`. +2. Create a version tag such as `v0.1.0`. +3. Wait for the release workflow to upload the tarballs. +4. Compute the real archive checksums and replace the placeholder `sha256` values in [`Formula/nixo.rb`](Formula/nixo.rb). +5. Commit that formula update on `master`, then users can install with `brew tap HashWarlock/nixo && brew install nixo`. + ### From source (requires Nix with flakes) ```bash -nix build github:HashWarlock/nixosandbox +nix build github:HashWarlock/nixo +./result/bin/nixo --help ./result/bin/nixosandbox --help ``` @@ -51,52 +79,61 @@ cargo build ### 1. Browse the catalog ```bash -nixosandbox catalog -nixosandbox catalog --filter claude -nixosandbox catalog --json | jq '.agents | keys' +nixo catalog +nixo catalog --filter claude +nixo catalog --json | jq '.agents | keys' +nixo catalog --json --grouped | jq '.agentCategories | keys' ``` ### 2. Create a sandbox ```bash # From catalog packages (compose what you need) -nixosandbox create --with claude-code,bash,git --network off --name my-sandbox --json +nixo create --with claude-code,bash,git --network off --name my-sandbox --json # From a built-in profile -nixosandbox create --profile strict --json +nixo create --profile strict --json # With a host workspace mounted -nixosandbox create --with opencode,bash --workspace ~/projects/myapp --json +nixo create --with opencode,bash --workspace ~/projects/myapp --json ``` ### 3. Execute commands ```bash # Run a single command -nixosandbox exec -- echo "Hello from sandbox" +nixo exec -- echo "Hello from sandbox" # Stream NDJSON events (for programmatic use) -nixosandbox exec --json -- python3 -c "print('hello')" +nixo exec --json -- python3 -c "print('hello')" # With extra environment variables -nixosandbox exec --env API_KEY=test -- node script.js +nixo exec --env API_KEY=test -- node script.js ``` ### 4. Interactive shell ```bash -nixosandbox enter +nixo enter ``` ### 5. Manage sessions ```bash -nixosandbox list # list all sessions -nixosandbox list --json # as JSON -nixosandbox status # detailed session info -nixosandbox destroy # clean up +nixo list # list all sessions +nixo list --json # as JSON +nixo status # detailed session info +nixo destroy # clean up ``` +## AgentSkills support + +This repo ships a reusable AgentSkills-compatible skill at [`.agents/skills/nixo-cli/SKILL.md`](.agents/skills/nixo-cli/SKILL.md). + +- Agents should prefer `nixo` in new instructions and treat `nixosandbox` as a compatibility alias. +- Use the bundled skill when the task involves sandbox lifecycle operations, catalog queries, session-id workflows, or common CLI errors. +- If your runtime supports the AgentSkills directory convention, copy the `nixo-cli` folder into that runtime's skills directory or install it from this repository using the runtime's skill-import flow. + ## CLI reference | Command | Description | @@ -126,6 +163,14 @@ nixosandbox destroy # clean up `--with`, `--profile`, and `--spec` are mutually exclusive. +### `catalog` flags + +| Flag | Description | +|------|-------------| +| `--json` | Output as JSON (flat compatibility by default) | +| `--grouped` | Group agent catalog JSON by category | +| `--filter ` | Filter package names and descriptions by substring | + ## Catalog The catalog merges two sources: @@ -149,12 +194,17 @@ The catalog merges two sources: | `opencode` | Open-source coding agent | | `pi` | Pi coding agent | | `qwen-code` | Alibaba's coding agent | -| ... | 88+ agents total — run `nixosandbox catalog` to see all | +| ... | 88+ agents total — run `nixo catalog` to see all | **Development tools** from nixpkgs: `python312` `nodejs_22` `rustc` `cargo` `go` `git` `coreutils` `bash` `findutils` `gnugrep` `gnused` `gawk` `gnumake` `gcc` `gnutar` `gzip` `curl` `cacert` `ripgrep` `fd` `jq` `less` `zsh` `nix` +Catalog output supports two JSON views: + +- `nixo catalog --json` keeps the current flat compatibility shape with top-level `agents` and `tools` +- `nixo catalog --json --grouped` returns grouped agent categories under `agentCategories` + ## Built-in profiles | Profile | Network | Packages | Use case | @@ -168,7 +218,7 @@ The catalog merges two sources: ### Rootfs composition -When you run `nixosandbox create --with claude-code,bash`, the CLI: +When you run `nixo create --with claude-code,bash`, the CLI: 1. Resolves `claude-code` and `bash` from the catalog (agents first, then tools) 2. Calls `mkAgentSandbox` which delegates to `mkSandboxRootfs` @@ -179,7 +229,7 @@ The rootfs contains: `/bin`, `/lib`, `/etc` (passwd, hosts, certs), `/usr/bin/en ### Sandbox execution -When you run `nixosandbox exec -- command`: +When you run `nixo exec -- command`: 1. Loads session metadata (rootfs path, network mode, profile) 2. Detects bubblewrap (native Linux or Docker sidecar on macOS) @@ -224,7 +274,7 @@ Create a JSON spec for full control: ``` ```bash -nixosandbox create --spec my-env.json --json +nixo create --spec my-env.json --json ``` ## Environment variables @@ -306,8 +356,9 @@ import sandboxExtension from "/packages/pi-sandbox-extension/dist/ export default function (pi: any) { sandboxExtension(pi, { - // Absolute path to the nixosandbox binary (cargo build --release) - binaryPath: "/crates/nixosandbox/target/release/nixosandbox", + // Absolute path to the nixo binary (cargo build --release) + // The nixosandbox alias remains available if your setup still expects it. + binaryPath: "/crates/nixosandbox/target/release/nixo", }); } ``` @@ -335,7 +386,7 @@ pi -e .pi/extensions/sandbox.ts Set `NIXOSANDBOX_FLAKE_ROOT` to the repo root if the binary can't find `flake.nix` automatically: ```bash -export NIXOSANDBOX_FLAKE_ROOT=/path/to/nixosandbox +export NIXOSANDBOX_FLAKE_ROOT=/path/to/nixo ``` ## Testing diff --git a/crates/nixosandbox/Cargo.toml b/crates/nixosandbox/Cargo.toml index f172f29..7b627ea 100644 --- a/crates/nixosandbox/Cargo.toml +++ b/crates/nixosandbox/Cargo.toml @@ -2,10 +2,16 @@ name = "nixosandbox" version = "0.1.0" edition = "2021" +autobins = false +default-run = "nixo" + +[[bin]] +name = "nixo" +path = "src/bin/nixo.rs" [[bin]] name = "nixosandbox" -path = "src/main.rs" +path = "src/bin/nixosandbox.rs" [dependencies] serde = { version = "1", features = ["derive"] } diff --git a/crates/nixosandbox/src/bin/nixo.rs b/crates/nixosandbox/src/bin/nixo.rs new file mode 100644 index 0000000..6615424 --- /dev/null +++ b/crates/nixosandbox/src/bin/nixo.rs @@ -0,0 +1,3 @@ +fn main() { + nixosandbox::run_with_bin_name("nixo"); +} diff --git a/crates/nixosandbox/src/bin/nixosandbox.rs b/crates/nixosandbox/src/bin/nixosandbox.rs new file mode 100644 index 0000000..6615424 --- /dev/null +++ b/crates/nixosandbox/src/bin/nixosandbox.rs @@ -0,0 +1,3 @@ +fn main() { + nixosandbox::run_with_bin_name("nixo"); +} diff --git a/crates/nixosandbox/src/catalog.rs b/crates/nixosandbox/src/catalog.rs new file mode 100644 index 0000000..e310bd4 --- /dev/null +++ b/crates/nixosandbox/src/catalog.rs @@ -0,0 +1,358 @@ +use std::collections::BTreeMap; + +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AgentSection { + AiCodingAgents, + AiAssistants, + CodeReview, +} + +impl AgentSection { + pub fn label(self) -> &'static str { + match self { + AgentSection::AiCodingAgents => "AI Coding Agents", + AgentSection::AiAssistants => "AI Assistants", + AgentSection::CodeReview => "Code Review", + } + } +} + +pub fn classify_agent(name: &str) -> AgentSection { + match name { + "localgpt" | "hermes-agent" | "openclaw" => AgentSection::AiAssistants, + "coderabbit-cli" | "tuicr" => AgentSection::CodeReview, + _ => AgentSection::AiCodingAgents, + } +} + +pub fn grouped_agents( + agents: &Map, +) -> BTreeMap> { + let mut grouped = BTreeMap::new(); + + for (name, value) in agents { + let section = classify_agent(name); + grouped + .entry(section) + .or_insert_with(BTreeMap::new) + .insert(name.clone(), value.clone()); + } + + grouped +} + +fn matches_filter(name: &str, value: &Value, filter_lower: Option<&str>) -> bool { + match filter_lower { + None => true, + Some(filter_lower) => { + name.to_lowercase().contains(filter_lower) + || value + .get("description") + .and_then(|d| d.as_str()) + .map(|d| d.to_lowercase().contains(filter_lower)) + .unwrap_or(false) + } + } +} + +fn filtered_entries( + entries: &Map, + filter_lower: Option<&str>, +) -> BTreeMap { + entries + .iter() + .filter(|(name, value)| matches_filter(name, value, filter_lower)) + .map(|(name, value)| (name.clone(), value.clone())) + .collect() +} + +pub fn flat_catalog_json(catalog: &Value, filter: Option<&str>) -> Value { + match filter { + None => catalog.clone(), + Some(filter) => { + let filter_lower = filter.to_lowercase(); + let mut filtered = Map::new(); + for section in ["agents", "tools"] { + if let Some(entries) = catalog.get(section).and_then(|v| v.as_object()) { + filtered.insert( + section.to_string(), + Value::Object( + filtered_entries(entries, Some(filter_lower.as_str())) + .into_iter() + .collect(), + ), + ); + } + } + Value::Object(filtered) + } + } +} + +pub fn grouped_catalog_json(catalog: &Value, filter: Option<&str>) -> Value { + let filter_lower = filter.map(|filter| filter.to_lowercase()); + let mut grouped = Map::new(); + + if let Some(entries) = catalog.get("agents").and_then(|v| v.as_object()) { + let grouped_agents = grouped_agents(entries); + let mut categories = Map::new(); + + for section in [ + AgentSection::AiCodingAgents, + AgentSection::AiAssistants, + AgentSection::CodeReview, + ] { + if let Some(entries) = grouped_agents.get(§ion) { + let filtered: BTreeMap = entries + .iter() + .filter(|(name, value)| matches_filter(name, value, filter_lower.as_deref())) + .map(|(name, value)| (name.clone(), value.clone())) + .collect(); + if !filtered.is_empty() { + categories.insert( + section.label().to_string(), + Value::Object(filtered.into_iter().collect()), + ); + } + } + } + + grouped.insert("agentCategories".to_string(), Value::Object(categories)); + } + + if let Some(entries) = catalog.get("tools").and_then(|v| v.as_object()) { + grouped.insert( + "tools".to_string(), + Value::Object( + filtered_entries(entries, filter_lower.as_deref()) + .into_iter() + .collect(), + ), + ); + } + + Value::Object(grouped) +} + +pub fn grouped_catalog_text(catalog: &Value, filter: Option<&str>) -> String { + let filter_lower = filter.map(|filter| filter.to_lowercase()); + let mut lines = Vec::new(); + + if let Some(entries) = catalog.get("agents").and_then(|v| v.as_object()) { + let grouped = grouped_agents(entries); + for section in [ + AgentSection::AiCodingAgents, + AgentSection::AiAssistants, + AgentSection::CodeReview, + ] { + if let Some(entries) = grouped.get(§ion) { + let filtered: Vec<_> = entries + .iter() + .filter(|(name, value)| matches_filter(name, value, filter_lower.as_deref())) + .collect(); + if filtered.is_empty() { + continue; + } + lines.push(format!("{}:", section.label())); + for (name, value) in filtered { + let desc = value + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or(""); + lines.push(format!(" {:<20} {}", name, desc)); + } + lines.push(String::new()); + } + } + } + + if let Some(entries) = catalog.get("tools").and_then(|v| v.as_object()) { + let filtered = filtered_entries(entries, filter_lower.as_deref()); + let has_tools = !filtered.is_empty(); + if has_tools { + lines.push("Tools (from nixpkgs):".to_string()); + } + for (name, value) in filtered { + let desc = value + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or(""); + lines.push(format!(" {:<20} {}", name, desc)); + } + if has_tools { + lines.push(String::new()); + } + } + + while matches!(lines.last(), Some(line) if line.is_empty()) { + lines.pop(); + } + + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + use serde_json::json; + + fn agent_map(entries: &[(&str, &str)]) -> Map { + entries + .iter() + .map(|(name, description)| ((*name).to_string(), json!({ "description": description }))) + .collect() + } + + #[test] + fn groups_known_agents_into_expected_sections() { + let agents = agent_map(&[ + ("claude-code", "Claude Code"), + ("localgpt", "Local GPT"), + ("coderabbit-cli", "CodeRabbit CLI"), + ]); + + let grouped = grouped_agents(&agents); + + let coding = grouped.get(&AgentSection::AiCodingAgents).unwrap(); + assert_eq!( + coding.keys().cloned().collect::>(), + vec!["claude-code".to_string()] + ); + + let assistants = grouped.get(&AgentSection::AiAssistants).unwrap(); + assert_eq!( + assistants.keys().cloned().collect::>(), + vec!["localgpt".to_string()] + ); + + let review = grouped.get(&AgentSection::CodeReview).unwrap(); + assert_eq!( + review.keys().cloned().collect::>(), + vec!["coderabbit-cli".to_string()] + ); + } + + #[test] + fn preserves_flat_json_shape_for_default_json_mode() { + let catalog = json!({ + "agents": { + "claude-code": { "description": "Claude Code" }, + "localgpt": { "description": "Local GPT" } + }, + "tools": { + "git": { "description": "Git" } + } + }); + + let flat = flat_catalog_json(&catalog, None); + assert_eq!(flat, catalog); + + let obj = flat.as_object().unwrap(); + assert!(obj.contains_key("agents")); + assert!(obj.contains_key("tools")); + assert_eq!(obj.len(), 2); + } + + #[test] + fn grouped_json_contains_agent_categories() { + let catalog = json!({ + "agents": { + "claude-code": { "description": "Claude Code" }, + "localgpt": { "description": "Local GPT" }, + "coderabbit-cli": { "description": "CodeRabbit CLI" } + }, + "tools": { + "git": { "description": "Git" } + } + }); + + let grouped = grouped_catalog_json(&catalog, None); + let obj = grouped.as_object().unwrap(); + assert!(obj.contains_key("agentCategories")); + + let categories = obj + .get("agentCategories") + .and_then(|value| value.as_object()) + .unwrap(); + assert!(categories.contains_key("AI Coding Agents")); + assert!(categories.contains_key("AI Assistants")); + assert!(categories.contains_key("Code Review")); + + assert_eq!( + categories + .get("AI Coding Agents") + .and_then(|value| value.as_object()) + .unwrap() + .contains_key("claude-code"), + true + ); + assert_eq!( + categories + .get("AI Assistants") + .and_then(|value| value.as_object()) + .unwrap() + .contains_key("localgpt"), + true + ); + assert_eq!( + categories + .get("Code Review") + .and_then(|value| value.as_object()) + .unwrap() + .contains_key("coderabbit-cli"), + true + ); + + assert!(obj.contains_key("tools")); + assert_eq!( + obj.get("tools") + .and_then(|value| value.as_object()) + .unwrap() + .contains_key("git"), + true + ); + } + + #[test] + fn grouped_text_omits_empty_agent_headers_when_filter_matches_tools_only() { + let catalog = json!({ + "agents": { + "claude-code": { "description": "Claude Code" }, + "localgpt": { "description": "Local GPT" } + }, + "tools": { + "git": { "description": "Git" } + } + }); + + let output = grouped_catalog_text(&catalog, Some("git")); + + assert_eq!(output, "Tools (from nixpkgs):\n git Git"); + assert!(!output.contains("AI Coding Agents:")); + assert!(!output.contains("AI Assistants:")); + assert!(!output.contains("Code Review:")); + } + + #[test] + fn grouped_text_omits_empty_tools_header_when_filter_matches_agents_only() { + let catalog = json!({ + "agents": { + "claude-code": { "description": "Claude Code" }, + "localgpt": { "description": "Local GPT" } + }, + "tools": { + "git": { "description": "Git" } + } + }); + + let output = grouped_catalog_text(&catalog, Some("claude")); + + assert_eq!(output, "AI Coding Agents:\n claude-code Claude Code"); + assert!(!output.contains("AI Assistants:")); + assert!(!output.contains("Code Review:")); + assert!(!output.contains("Tools (from nixpkgs):")); + } +} diff --git a/crates/nixosandbox/src/cli.rs b/crates/nixosandbox/src/cli.rs index 14db473..36eb26d 100644 --- a/crates/nixosandbox/src/cli.rs +++ b/crates/nixosandbox/src/cli.rs @@ -1,7 +1,10 @@ use clap::{Parser, Subcommand}; #[derive(Parser)] -#[command(name = "nixosandbox", about = "Reproducible, isolated sandbox environments")] +#[command( + name = "nixo", + about = "Reproducible, isolated sandbox environments" +)] pub struct Cli { #[command(subcommand)] pub command: Commands, @@ -120,9 +123,12 @@ pub enum Commands { #[arg(long)] json: bool, + /// Group agent output by category + #[arg(long)] + grouped: bool, + /// Filter by name substring #[arg(long)] filter: Option, }, - } diff --git a/crates/nixosandbox/src/main.rs b/crates/nixosandbox/src/lib.rs similarity index 76% rename from crates/nixosandbox/src/main.rs rename to crates/nixosandbox/src/lib.rs index e9b95b2..092a67f 100644 --- a/crates/nixosandbox/src/main.rs +++ b/crates/nixosandbox/src/lib.rs @@ -1,4 +1,5 @@ mod bubblewrap; +mod catalog; mod cli; mod docker; mod nix; @@ -7,17 +8,44 @@ mod session; mod spec; mod timestamps; -use clap::Parser; +use clap::{CommandFactory, FromArgMatches}; use cli::{Cli, Commands}; -fn main() { - let cli = Cli::parse(); +pub fn run_with_bin_name(bin_name: &str) { + let matches = Cli::command().bin_name(bin_name).get_matches(); + let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit()); match cli.command { - Commands::Create { profile, spec: spec_file, with, network, workspace, name, agent, description, json } => { - cmd_create(profile, spec_file, with, network, workspace, name, agent, description, json); + Commands::Create { + profile, + spec: spec_file, + with, + network, + workspace, + name, + agent, + description, + json, + } => { + cmd_create( + profile, + spec_file, + with, + network, + workspace, + name, + agent, + description, + json, + ); } - Commands::Exec { session_id, json, timeout: _timeout, extra_env, command } => { + Commands::Exec { + session_id, + json, + timeout: _timeout, + extra_env, + command, + } => { cmd_exec(&session_id, json, extra_env, command); } Commands::Enter { session_id } => { @@ -32,11 +60,19 @@ fn main() { Commands::Status { session_id, json } => { cmd_status(&session_id, json); } - Commands::Build { profile, spec: spec_file, json } => { + Commands::Build { + profile, + spec: spec_file, + json, + } => { cmd_build(profile, spec_file, json); } - Commands::Catalog { json, filter } => { - cmd_catalog(json, filter); + Commands::Catalog { + json, + grouped, + filter, + } => { + cmd_catalog(json, grouped, filter); } } } @@ -53,12 +89,10 @@ fn resolve_spec(profile: Option, spec_file: Option) -> spec::San std::process::exit(1); }) } - (None, Some(s)) => { - spec::load_spec(&s).unwrap_or_else(|e| { - eprintln!("error: {e}"); - std::process::exit(1); - }) - } + (None, Some(s)) => spec::load_spec(&s).unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(1); + }), (Some(_), Some(_)) => { eprintln!("error: specify --profile or --spec, not both"); std::process::exit(1); @@ -134,7 +168,11 @@ fn cmd_create( eprintln!("rootfs validation failed: {e}"); std::process::exit(1); }); - (rootfs, format!("custom:{}", packages.join(",")), Some(network.clone())) + ( + rootfs, + format!("custom:{}", packages.join(",")), + Some(network.clone()), + ) } else { // Profile or spec-based let sandbox_spec = resolve_spec(profile.clone(), spec_file); @@ -155,7 +193,8 @@ fn cmd_create( agent.as_deref(), description.as_deref(), session_network.as_deref(), - ).unwrap_or_else(|e| { + ) + .unwrap_or_else(|e| { eprintln!("session creation failed: {e}"); std::process::exit(1); }); @@ -192,8 +231,18 @@ fn cmd_exec(session_id: &str, json: bool, extra_env: Vec, command: Vec, command: Vec, command: Vec, command: Vec { - let mut cmd_args = vec!["exec".to_string(), "-i".to_string(), container_id.clone(), "bwrap".to_string()]; + let mut cmd_args = vec![ + "exec".to_string(), + "-i".to_string(), + container_id.clone(), + "bwrap".to_string(), + ]; cmd_args.extend(bwrap_argv); Command::new("docker") .args(&cmd_args) @@ -298,17 +362,15 @@ fn cmd_exec(session_id: &str, json: bool, extra_env: Vec, command: Vec { - Command::new(path) - .args(&bwrap_argv) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap_or_else(|e| { - eprintln!("error: failed to spawn bwrap at {}: {e}", path.display()); - std::process::exit(1); - }) - } + bubblewrap::BwrapAvailability::Available { path } => Command::new(path) + .args(&bwrap_argv) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|e| { + eprintln!("error: failed to spawn bwrap at {}: {e}", path.display()); + std::process::exit(1); + }), bubblewrap::BwrapAvailability::Unavailable { reason } => { eprintln!("error: bwrap is not available: {reason}"); std::process::exit(1); @@ -421,7 +483,12 @@ fn cmd_exec(session_id: &str, json: bool, extra_env: Vec, command: Vec { - let mut cmd_args = vec!["exec".to_string(), "-i".to_string(), container_id.clone(), "bwrap".to_string()]; + let mut cmd_args = vec![ + "exec".to_string(), + "-i".to_string(), + container_id.clone(), + "bwrap".to_string(), + ]; cmd_args.extend(bwrap_argv); Command::new("docker") .args(&cmd_args) @@ -431,15 +498,13 @@ fn cmd_exec(session_id: &str, json: bool, extra_env: Vec, command: Vec { - Command::new(path) - .args(&bwrap_argv) - .status() - .unwrap_or_else(|e| { - eprintln!("error: failed to run bwrap at {}: {e}", path.display()); - std::process::exit(1); - }) - } + bubblewrap::BwrapAvailability::Available { path } => Command::new(path) + .args(&bwrap_argv) + .status() + .unwrap_or_else(|e| { + eprintln!("error: failed to run bwrap at {}: {e}", path.display()); + std::process::exit(1); + }), bubblewrap::BwrapAvailability::Unavailable { reason } => { eprintln!("error: bwrap is not available: {reason}"); std::process::exit(1); @@ -465,9 +530,15 @@ fn cmd_list(json: bool) { println!("No active sessions."); return; } - println!("{:<12} {:<20} {:<16} {}", "SESSION", "NAME", "PROFILE", "CREATED"); + println!( + "{:<12} {:<20} {:<16} {}", + "SESSION", "NAME", "PROFILE", "CREATED" + ); for s in &sessions { - println!("{:<12} {:<20} {:<16} {}", s.session_id, s.name, s.profile, s.created_at); + println!( + "{:<12} {:<20} {:<16} {}", + s.session_id, s.name, s.profile, s.created_at + ); } } } @@ -491,70 +562,47 @@ fn cmd_build(profile: Option, spec_file: Option, json: bool) { } } -fn cmd_catalog(json: bool, filter: Option) { +fn cmd_catalog(json: bool, grouped: bool, filter: Option) { let catalog_json = nix::query_catalog().unwrap_or_else(|e| { eprintln!("error: {e}"); std::process::exit(1); }); - if json && filter.is_none() { - println!("{}", catalog_json); - return; - } - // Parse for display or filtering let catalog: serde_json::Value = serde_json::from_str(&catalog_json).unwrap_or_else(|e| { eprintln!("error: failed to parse catalog: {e}"); std::process::exit(1); }); - let filter_lower = filter.as_ref().map(|f| f.to_lowercase()); - if json { - // Filtered JSON output - let mut filtered = serde_json::json!({ "agents": {}, "tools": {} }); - for section in ["agents", "tools"] { - if let Some(entries) = catalog.get(section).and_then(|v| v.as_object()) { - let filt = filter_lower.as_ref().unwrap(); - let matched: serde_json::Map = entries - .iter() - .filter(|(k, v)| { - k.to_lowercase().contains(filt) - || v.get("description") - .and_then(|d| d.as_str()) - .map(|d| d.to_lowercase().contains(filt)) - .unwrap_or(false) - }) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - filtered[section] = serde_json::Value::Object(matched); - } + if grouped { + println!( + "{}", + serde_json::to_string_pretty(&catalog::grouped_catalog_json( + &catalog, + filter.as_deref(), + )) + .unwrap() + ); + } else if filter.is_none() { + println!("{}", catalog_json); + } else { + println!( + "{}", + serde_json::to_string_pretty(&catalog::flat_catalog_json( + &catalog, + filter.as_deref(), + )) + .unwrap() + ); } - println!("{}", serde_json::to_string_pretty(&filtered).unwrap()); return; } - // Human-readable output - for (section, label) in [("agents", "Agents (from llm-agents.nix)"), ("tools", "Tools (from nixpkgs)")] { - if let Some(entries) = catalog.get(section).and_then(|v| v.as_object()) { - println!("{}:", label); - let mut names: Vec<&String> = entries.keys().collect(); - names.sort(); - for name in names { - let desc = entries[name] - .get("description") - .and_then(|d| d.as_str()) - .unwrap_or(""); - if let Some(ref filt) = filter_lower { - if !name.to_lowercase().contains(filt) && !desc.to_lowercase().contains(filt) { - continue; - } - } - println!(" {:<20} {}", name, desc); - } - println!(); - } - } + print!( + "{}", + catalog::grouped_catalog_text(&catalog, filter.as_deref()) + ); } fn cmd_status(session_id: &str, json: bool) { @@ -617,19 +665,57 @@ fn cmd_status(session_id: &str, json: bool) { let w = 48; println!("╭{}╮", "─".repeat(w)); - println!("│ {: