diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6d49a2..72613e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,10 +1,7 @@ name: CI on: - push: - branches: [master, pi-sandbox-refactor] pull_request: - branches: [master] jobs: rust-tests: @@ -123,15 +120,26 @@ jobs: echo "$output" echo "$output" | grep -q "claude-code" - - name: Test --with rootfs creation + - name: Install bubblewrap + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq bubblewrap + if ! bwrap --ro-bind / / --dev /dev --proc /proc -- echo "bwrap works"; then + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 + fi + + - name: Linux end-to-end (create → exec → destroy) run: | output=$(NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox create \ - --with bash,coreutils --network off --name ci-smoke --json) + --with bash,coreutils --network off --name ci-linux-e2e --json) echo "$output" session_id=$(echo "$output" | python3 -c "import sys,json; print(json.load(sys.stdin)['sessionId'])") - echo "Session created: $session_id" + echo "Session: $session_id" + NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox exec "$session_id" -- echo "Hello from Linux sandbox" + NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox status "$session_id" --json | \ + python3 -c "import sys,json; d=json.load(sys.stdin); assert d['isolation']=='native', f'expected native, got {d[\"isolation\"]}'; print('Isolation: native ✓')" NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox destroy "$session_id" - echo "Session destroyed" + echo "Full Linux E2E passed" - name: Test mutual exclusivity run: | @@ -226,3 +234,103 @@ jobs: if [ -n "$SESSION_ID" ]; then NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox destroy "$SESSION_ID" 2>/dev/null || true fi + + macos-e2e: + name: macOS end-to-end (Docker builder) + # ARM macOS hosted runners do not support nested virtualization reliably. + # Use Intel macOS for Docker-in-VM setup. + runs-on: macos-15-intel + needs: [rust-tests] + steps: + - uses: actions/checkout@v6 + + - uses: cachix/install-nix-action@v30 + with: + extra_nix_config: | + experimental-features = nix-command flakes + extra-substituters = https://cache.numtide.com + extra-trusted-public-keys = niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g= + + - name: Set up Docker daemon + uses: docker/setup-docker-action@v5 + + - name: Verify Docker amd64 preflight + shell: bash + run: | + set -euo pipefail + docker info + arch="$(docker run --rm --platform linux/amd64 alpine:3.20 uname -m)" + test "$arch" = "x86_64" + + - name: Build nixosandbox CLI (native macOS) + run: nix build --accept-flake-config .#nixosandbox + + - name: Test catalog (nix eval — no Docker needed) + run: | + NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json | \ + python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{len(d[\"agents\"])} agents, {len(d[\"tools\"])} tools')" + + - name: Create sandbox via Docker builder + run: | + output=$(NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox create \ + --with bash,coreutils --network off --name ci-macos-e2e --json) + echo "$output" + session_id=$(echo "$output" | python3 -c "import sys,json; print(json.load(sys.stdin)['sessionId'])") + echo "SESSION_ID=$session_id" >> "$GITHUB_ENV" + + - name: Prepare privileged sidecar for CI + shell: bash + run: | + set -euo pipefail + data_dir="${NIXOSANDBOX_DATA_DIR:-$HOME/.local/share/nixosandbox}" + mkdir -p "$data_dir" + + docker rm -f nixosandbox-sidecar >/dev/null 2>&1 || true + docker build --platform linux/amd64 -t nixosandbox-sidecar:latest \ + -f "$PWD/docker/nixosandbox-sidecar.Dockerfile" "$PWD" + + docker run -d \ + --name nixosandbox-sidecar \ + --platform linux/amd64 \ + --privileged \ + --security-opt seccomp=unconfined \ + --security-opt apparmor=unconfined \ + -v "$data_dir:/nixosandbox/sessions" \ + -v "nixosandbox-nix:/nix:ro" \ + nixosandbox-sidecar:latest \ + sleep infinity + + # Preflight bwrap inside sidecar to fail early if mount permissions are still blocked. + docker exec nixosandbox-sidecar sh -lc 'bwrap --dev /dev --proc /proc --ro-bind / / -- true' + + - name: Execute command in sandbox via Docker sidecar + shell: bash + run: | + set -euo pipefail + if ! NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox exec "$SESSION_ID" -- echo "Hello from macOS sandbox"; then + echo "::group::docker diagnostics" + docker ps -a || true + docker inspect nixosandbox-sidecar || true + echo "::endgroup::" + + if docker ps -a --format '{{.Names}}' | grep -qx nixosandbox-sidecar; then + echo "::group::sidecar diagnostics" + docker exec nixosandbox-sidecar sh -lc 'id; uname -a; cat /proc/self/status | grep "^Cap"; bwrap --version' || true + docker exec nixosandbox-sidecar sh -lc 'bwrap --dev /dev --proc /proc --ro-bind / / -- true' || true + echo "::endgroup::" + fi + exit 1 + fi + + - name: Verify session status + run: | + NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox status "$SESSION_ID" --json | \ + python3 -c "import sys,json; d=json.load(sys.stdin); assert d['isolation']=='docker', f'expected docker, got {d[\"isolation\"]}'; print('Isolation: docker ✓')" + + - name: Cleanup + if: always() + run: | + if [ -n "$SESSION_ID" ]; then + NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox destroy "$SESSION_ID" 2>/dev/null || true + fi + docker rm -f nixosandbox-sidecar 2>/dev/null || true diff --git a/.gitignore b/.gitignore index 1bcc82f..d111417 100644 --- a/.gitignore +++ b/.gitignore @@ -119,3 +119,6 @@ crates/nixosandbox/target/ # Protocol tests tests/protocol/node_modules/ .pi/ + +# Local git worktrees +.worktrees/ diff --git a/crates/nixosandbox/src/docker.rs b/crates/nixosandbox/src/docker.rs index a000bf0..701bcd3 100644 --- a/crates/nixosandbox/src/docker.rs +++ b/crates/nixosandbox/src/docker.rs @@ -1,3 +1,4 @@ +use std::io::Write; use std::process::{Command, Stdio}; const SIDECAR_NAME: &str = "nixosandbox-sidecar"; @@ -7,6 +8,11 @@ const IMAGE_NAME: &str = "nixosandbox-sidecar:latest"; /// Sessions live at `/sessions//...` inside the container. const CONTAINER_DATA_MOUNT: &str = "/nixosandbox/sessions"; +const BUILDER_IMAGE_NAME: &str = "nixosandbox-builder:latest"; +/// Persistent Docker volume storing the Nix store for macOS builds. +/// Shared between the builder (writes) and the sidecar (reads). +const NIX_VOLUME_NAME: &str = "nixosandbox-nix"; + /// Information about a running Docker sidecar container. pub struct DockerSidecar { pub container_id: String, @@ -103,10 +109,16 @@ fn ensure_image() -> Result<(), String> { } eprintln!("nixosandbox: building Docker sidecar image (one-time setup)..."); + let flake_root = crate::nix::find_flake_root() + .unwrap_or_else(|_| ".".to_string()); + let dockerfile = format!("{}/docker/nixosandbox-sidecar.Dockerfile", flake_root); let output = Command::new("docker") .args([ - "build", "-t", IMAGE_NAME, - "-f", "docker/nixosandbox-sidecar.Dockerfile", ".", + "build", + "--platform", "linux/amd64", + "-t", IMAGE_NAME, + "-f", &dockerfile, + &flake_root, ]) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -122,17 +134,22 @@ fn ensure_image() -> Result<(), String> { } /// Create and start a new sidecar container. +/// +/// Mounts the shared `nixosandbox-nix` Docker volume at `/nix` so bwrap can +/// access rootfs derivations built by the Docker-based builder. fn create_sidecar(host_sessions_dir: &str) -> Result { let sessions_volume = format!("{host_sessions_dir}:{CONTAINER_DATA_MOUNT}"); + let nix_volume = format!("{NIX_VOLUME_NAME}:/nix:ro"); let output = Command::new("docker") .args([ "run", "-d", + "--platform", "linux/amd64", "--name", SIDECAR_NAME, "--cap-add", "SYS_ADMIN", "--cap-add", "NET_ADMIN", "--security-opt", "seccomp=unconfined", "-v", &sessions_volume, - "-v", "/nix/store:/nix/store:ro", + "-v", &nix_volume, IMAGE_NAME, "sleep", "infinity", ]) @@ -205,6 +222,157 @@ pub fn rewrite_path(path: &str, host_prefix: &str, container_prefix: &str) -> St } } +// --------------------------------------------------------------------------- +// Docker-based Nix builder (macOS) +// --------------------------------------------------------------------------- + +/// Build the builder Docker image if it doesn't already exist. +/// Uses an inline Dockerfile piped via stdin to avoid path-resolution issues. +fn ensure_builder_image() -> Result<(), String> { + let output = Command::new("docker") + .args(["images", BUILDER_IMAGE_NAME, "--format", "{{.ID}}"]) + .output() + .map_err(|e| format!("docker images check failed: {e}"))?; + + let id = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !id.is_empty() { + return Ok(()); + } + + eprintln!("nixosandbox: building Docker builder image (one-time setup)..."); + + // Inline Dockerfile — avoids needing to locate the file on disk. + let dockerfile = concat!( + "FROM nixos/nix:latest\n", + "RUN echo 'experimental-features = nix-command flakes' >> /etc/nix/nix.conf \\\n", + " && echo 'extra-substituters = https://cache.numtide.com' >> /etc/nix/nix.conf \\\n", + " && echo 'extra-trusted-public-keys = niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g=' >> /etc/nix/nix.conf \\\n", + " && echo 'filter-syscalls = false' >> /etc/nix/nix.conf\n", + ); + + let mut child = Command::new("docker") + .args([ + "build", + "--platform", "linux/amd64", + "-t", BUILDER_IMAGE_NAME, + "-f", "-", // read Dockerfile from stdin + "/tmp", // build context (unused — Dockerfile has no COPY) + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("docker build (builder): {e}"))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(dockerfile.as_bytes()) + .map_err(|e| format!("writing builder Dockerfile to stdin: {e}"))?; + } + + let output = child + .wait_with_output() + .map_err(|e| format!("docker build (builder) wait: {e}"))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Err(format!("docker build (builder) failed: {}", stderr.trim())) + } +} + +/// Run `nix build ` inside a Docker container. +/// +/// The builder uses a persistent Docker volume (`nixosandbox-nix`) for `/nix`, +/// so subsequent builds are incremental. The flake root is bind-mounted read-only. +pub fn nix_build_in_docker(flake_attr: &str, flake_root: &str) -> Result { + if !is_docker_available() { + return Err( + "Docker not available. On macOS, Docker Desktop is required to build Linux sandboxes.\n\ + Install Docker Desktop from https://www.docker.com/products/docker-desktop/".to_string() + ); + } + + ensure_builder_image()?; + + let flake_mount = format!("{}:{}:ro", flake_root, flake_root); + let nix_volume = format!("{}:/nix", NIX_VOLUME_NAME); + + let output = Command::new("docker") + .args([ + "run", "--rm", + "--platform", "linux/amd64", + "-v", &flake_mount, + "-v", &nix_volume, + BUILDER_IMAGE_NAME, + "nix", "build", flake_attr, + "--no-link", "--print-out-paths", + "--accept-flake-config", + "--extra-experimental-features", "nix-command flakes", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| format!("docker run (nix build): {e}"))?; + + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + Err("nix build in Docker produced no output".into()) + } else { + Ok(path) + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + Err(format!("nix build in Docker failed: {}", stderr)) + } +} + +/// Run `nix build --impure --expr ` inside a Docker container. +pub fn nix_build_expr_in_docker(expr: &str, flake_root: &str) -> Result { + if !is_docker_available() { + return Err( + "Docker not available. On macOS, Docker Desktop is required to build Linux sandboxes.\n\ + Install Docker Desktop from https://www.docker.com/products/docker-desktop/".to_string() + ); + } + + ensure_builder_image()?; + + let flake_mount = format!("{}:{}:ro", flake_root, flake_root); + let nix_volume = format!("{}:/nix", NIX_VOLUME_NAME); + + let output = Command::new("docker") + .args([ + "run", "--rm", + "--platform", "linux/amd64", + "-v", &flake_mount, + "-v", &nix_volume, + BUILDER_IMAGE_NAME, + "nix", "build", "--impure", "--expr", expr, + "--no-link", "--print-out-paths", + "--accept-flake-config", + "--extra-experimental-features", "nix-command flakes", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(|e| format!("docker run (nix build --expr): {e}"))?; + + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { + Err("nix build --expr in Docker produced no output".into()) + } else { + Ok(path) + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + Err(format!("nix build --expr in Docker failed: {}", stderr)) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/nixosandbox/src/nix.rs b/crates/nixosandbox/src/nix.rs index e37d5b0..4d700c0 100644 --- a/crates/nixosandbox/src/nix.rs +++ b/crates/nixosandbox/src/nix.rs @@ -28,7 +28,13 @@ pub fn find_flake_root() -> Result { /// Build a rootfs for a built-in profile. Returns the Nix store path. pub fn build_profile(profile_name: &str) -> Result { let flake_root = find_flake_root()?; - nix_build(&format!("{}#sandbox-{}", flake_root, profile_name)) + let flake_attr = format!("{}#sandbox-{}", flake_root, profile_name); + + if cfg!(not(target_os = "linux")) { + return crate::docker::nix_build_in_docker(&flake_attr, &flake_root); + } + + nix_build(&flake_attr) } /// Build a rootfs from a custom spec. Returns the Nix store path. @@ -40,12 +46,17 @@ pub fn build_spec(spec: &SandboxSpec) -> Result { r#"let pkgs = import (builtins.getFlake "{}").inputs.nixpkgs {{}}; mkSandboxRootfs = import {}/nix/mkSandboxRootfs.nix {{ inherit pkgs; }}; in mkSandboxRootfs {{ name = "{}"; packages = [ {} ]; env = {{ {} }}; }}"#, flake_root, flake_root, spec.name, packages_nix, env_nix ); + + if cfg!(not(target_os = "linux")) { + return crate::docker::nix_build_expr_in_docker(&expr, &flake_root); + } + nix_build_expr(&expr) } fn nix_build(flake_attr: &str) -> Result { let output = Command::new("nix") - .args(["build", flake_attr, "--no-link", "--print-out-paths"]) + .args(["build", flake_attr, "--no-link", "--print-out-paths", "--accept-flake-config"]) .stdout(Stdio::piped()).stderr(Stdio::piped()) .output().map_err(|e| format!("nix build: {e}"))?; if output.status.success() { @@ -58,7 +69,7 @@ fn nix_build(flake_attr: &str) -> Result { fn nix_build_expr(expr: &str) -> Result { let output = Command::new("nix") - .args(["build", "--impure", "--expr", expr, "--no-link", "--print-out-paths"]) + .args(["build", "--impure", "--expr", expr, "--no-link", "--print-out-paths", "--accept-flake-config"]) .stdout(Stdio::piped()).stderr(Stdio::piped()) .output().map_err(|e| format!("nix build --expr: {e}"))?; if output.status.success() { @@ -70,7 +81,19 @@ fn nix_build_expr(expr: &str) -> Result { } /// Check if a rootfs path looks valid. +/// +/// On non-Linux platforms, the rootfs lives inside a Docker volume (not the host +/// filesystem), so we only validate the path format rather than checking existence. pub fn validate_rootfs(rootfs_path: &str) -> Result<(), String> { + if cfg!(not(target_os = "linux")) { + // On macOS, rootfs is in the Docker volume — can't stat from the host. + // Validate that it looks like a Nix store path. + if !rootfs_path.starts_with("/nix/store/") { + return Err(format!("rootfs path doesn't look like a Nix store path: {rootfs_path}")); + } + return Ok(()); + } + let root = Path::new(rootfs_path); if !root.exists() { return Err(format!("rootfs not found: {rootfs_path}")); } if !root.join("bin").exists() { return Err(format!("rootfs missing /bin: {rootfs_path}")); } @@ -123,6 +146,11 @@ pub fn build_with_catalog(names: &[String], _network: &str) -> Result> /etc/nix/nix.conf && \ + echo 'extra-substituters = https://cache.numtide.com' >> /etc/nix/nix.conf && \ + echo 'extra-trusted-public-keys = niks3.numtide.com-1:DTx8wZduET09hRmMtKdQDxNNthLQETkc/yaX7M4qK0g=' >> /etc/nix/nix.conf && \ + echo 'filter-syscalls = false' >> /etc/nix/nix.conf diff --git a/docs/superpowers/plans/2026-04-10-nixo-homebrew-catalog-skill.md b/docs/superpowers/plans/2026-04-10-nixo-homebrew-catalog-skill.md new file mode 100644 index 0000000..cfc3c21 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-nixo-homebrew-catalog-skill.md @@ -0,0 +1,629 @@ +# Nixo Homebrew, Catalog Grouping, and Cross-Agent Skill Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a Homebrew-first install flow with `nixo` as the primary CLI name, preserve `nixosandbox` compatibility, improve catalog readability with grouped output, and add a cross-agent `.agents/skills` skill for runtime portability. + +**Architecture:** Keep runtime behavior and JSON compatibility stable while layering UX improvements. Introduce a catalog presentation module for deterministic grouping, dual binary names for migration safety, a release workflow that emits `nixo` artifacts, and a repository-local Agent Skills package under `.agents/skills/nixo-cli`. + +**Tech Stack:** Rust (`clap`, `serde_json`), Nix, GitHub Actions, Homebrew formula conventions, Agent Skills (`SKILL.md` frontmatter + progressive disclosure resources). + +--- + +## File Structure + +- `crates/nixosandbox/src/catalog.rs` (new): Catalog data model, grouping logic, text/JSON rendering helpers, category mapping. +- `crates/nixosandbox/src/main.rs` (modify): Route `cmd_catalog` through catalog module, add `--grouped` support. +- `crates/nixosandbox/src/cli.rs` (modify): Add `--grouped` to `catalog`, set command branding to `nixo`. +- `crates/nixosandbox/Cargo.toml` (modify): Produce both `nixo` and `nixosandbox` binaries from `src/main.rs`. +- `.github/workflows/release.yml` (new): Tag-driven release artifacts + checksums. +- `packaging/homebrew/nixo.rb` (new): Formula template for tap repository use. +- `README.md` (modify): Homebrew-first install, `nixo` examples, alias notes, grouped catalog docs. +- `CLAUDE.md` (modify): Command examples and architecture guidance updated to `nixo`/alias language and grouped catalog behavior. +- `AGENTS.md` (modify): Agent-facing project instructions updated to `nixo`/alias language and grouped catalog behavior. +- `docs/superpowers/specs/2026-04-10-nixo-homebrew-and-cross-agent-skill-design.md` (modify): Add any implementation-time clarifications discovered during execution. +- `.agents/skills/nixo-cli/SKILL.md` (new): Cross-agent skill instructions. +- `.agents/skills/nixo-cli/references/quick-reference.md` (new): Command recipes. +- `.agents/skills/nixo-cli/references/troubleshooting.md` (new): Error triage paths. +- `.github/workflows/ci.yml` (modify): Add catalog grouped output check and alias parity smoke checks. + +### Task 1: Add Catalog Grouping Domain Module + +**Files:** +- Create: `crates/nixosandbox/src/catalog.rs` +- Modify: `crates/nixosandbox/src/main.rs` +- Test: `crates/nixosandbox/src/catalog.rs` (unit tests in module) + +- [ ] **Step 1: Write failing tests for grouping and rendering** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn groups_known_agents_into_expected_sections() { + let raw = serde_json::json!({ + "agents": { + "claude-code": { "description": "Anthropic CLI" }, + "localgpt": { "description": "assistant" }, + "coderabbit-cli": { "description": "review" } + }, + "tools": { "git": { "description": "git scm" } } + }); + let grouped = GroupedCatalog::from_catalog_json(&raw).unwrap(); + assert!(grouped.agent_categories.contains_key("AI Coding Agents")); + assert!(grouped.agent_categories.contains_key("AI Assistants")); + assert!(grouped.agent_categories.contains_key("Code Review")); + } + + #[test] + fn preserves_flat_json_shape_for_default_json_mode() { + let raw = serde_json::json!({ + "agents": { "claude-code": { "description": "Anthropic CLI" } }, + "tools": { "git": { "description": "git scm" } } + }); + let flat = CatalogView::from_json(&raw).unwrap().to_flat_json(); + assert!(flat.get("agents").is_some()); + assert!(flat.get("tools").is_some()); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd crates/nixosandbox && cargo test catalog::tests -- --nocapture` +Expected: FAIL because `catalog.rs` does not exist and tests are not wired. + +- [ ] **Step 3: Implement catalog module with grouping + stable sort** + +```rust +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map, Value}; + +#[derive(Clone, Debug)] +pub struct CatalogEntry { + pub name: String, + pub description: String, +} + +#[derive(Clone, Debug)] +pub struct CatalogView { + pub agents: Vec, + pub tools: Vec, +} + +#[derive(Clone, Debug)] +pub struct GroupedCatalog { + pub agent_categories: BTreeMap>, + pub tools: Vec, +} + +impl CatalogView { + pub fn from_json(raw: &Value) -> Result { + let agents = parse_entries(raw, "agents")?; + let tools = parse_entries(raw, "tools")?; + Ok(Self { agents, tools }) + } + + pub fn to_flat_json(&self) -> Value { + serde_json::json!({ + "agents": entries_to_object(&self.agents), + "tools": entries_to_object(&self.tools), + }) + } + + pub fn to_grouped(&self) -> GroupedCatalog { + let mut grouped: BTreeMap> = BTreeMap::new(); + for entry in &self.agents { + let category = agent_category(&entry.name).to_string(); + grouped.entry(category).or_default().push(entry.clone()); + } + for entries in grouped.values_mut() { + entries.sort_by(|a, b| a.name.cmp(&b.name)); + } + let mut tools = self.tools.clone(); + tools.sort_by(|a, b| a.name.cmp(&b.name)); + GroupedCatalog { + agent_categories: grouped, + tools, + } + } +} + +impl GroupedCatalog { + pub fn from_catalog_json(raw: &Value) -> Result { + let view = CatalogView::from_json(raw)?; + Ok(view.to_grouped()) + } +} + +fn parse_entries(raw: &Value, section: &str) -> Result, String> { + let map = raw + .get(section) + .and_then(|v| v.as_object()) + .ok_or_else(|| format!("missing section: {section}"))?; + let mut names: BTreeSet = BTreeSet::new(); + names.extend(map.keys().cloned()); + Ok(names + .into_iter() + .map(|name| CatalogEntry { + description: map + .get(&name) + .and_then(|v| v.get("description")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(), + name, + }) + .collect()) +} + +fn entries_to_object(entries: &[CatalogEntry]) -> Map { + entries + .iter() + .map(|e| { + ( + e.name.clone(), + serde_json::json!({ "description": e.description }), + ) + }) + .collect() +} + +fn agent_category(name: &str) -> &'static str { + match name { + "localgpt" | "hermes-agent" | "openclaw" => "AI Assistants", + "coderabbit-cli" | "tuicr" => "Code Review", + _ => "AI Coding Agents", + } +} +``` + +- [ ] **Step 4: Wire module into main** + +```rust +mod catalog; +``` + +- [ ] **Step 5: Run tests to verify pass** + +Run: `cd crates/nixosandbox && cargo test catalog::tests -- --nocapture` +Expected: PASS with grouping and flat-shape assertions. + +- [ ] **Step 6: Commit** + +```bash +git add crates/nixosandbox/src/catalog.rs crates/nixosandbox/src/main.rs +git commit -m "feat: add catalog grouping domain module" +``` + +### Task 2: Add `catalog --grouped` CLI UX + +**Files:** +- Modify: `crates/nixosandbox/src/cli.rs` +- Modify: `crates/nixosandbox/src/main.rs` +- Test: `crates/nixosandbox/src/catalog.rs` (renderer/filter tests) + +- [ ] **Step 1: Write failing test for grouped JSON mode** + +```rust +#[test] +fn grouped_json_contains_agent_categories() { + let raw = serde_json::json!({ + "agents": { "claude-code": { "description": "Anthropic CLI" } }, + "tools": { "git": { "description": "git scm" } } + }); + let grouped = CatalogView::from_json(&raw).unwrap().to_grouped_json(); + assert!(grouped.get("agentCategories").is_some()); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd crates/nixosandbox && cargo test grouped_json_contains_agent_categories -- --nocapture` +Expected: FAIL because `to_grouped_json()` is not implemented. + +- [ ] **Step 3: Add `--grouped` flag and command handling** + +```rust +// cli.rs +Catalog { + #[arg(long)] + json: bool, + #[arg(long)] + grouped: bool, + #[arg(long)] + filter: Option, +} + +// main.rs match arm +Commands::Catalog { json, grouped, filter } => { + cmd_catalog(json, grouped, filter); +} +``` + +- [ ] **Step 4: Implement output modes in `cmd_catalog`** + +```rust +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); + }); + let raw: 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 view = catalog::CatalogView::from_json(&raw).unwrap_or_else(|e| { + eprintln!("error: invalid catalog shape: {e}"); + std::process::exit(1); + }); + if json && !grouped { + println!("{}", serde_json::to_string_pretty(&view.to_flat_json()).unwrap()); + return; + } + if json && grouped { + println!("{}", serde_json::to_string_pretty(&view.to_grouped_json(filter.as_deref())).unwrap()); + return; + } + println!("{}", view.to_grouped_text(filter.as_deref())); +} +``` + +- [ ] **Step 5: Run tests and smoke command checks** + +Run: `cd crates/nixosandbox && cargo test catalog::tests -- --nocapture` +Expected: PASS + +Run: `NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json --grouped | python3 -m json.tool > /dev/null` +Expected: command succeeds and JSON parses. + +- [ ] **Step 6: Commit** + +```bash +git add crates/nixosandbox/src/cli.rs crates/nixosandbox/src/main.rs crates/nixosandbox/src/catalog.rs +git commit -m "feat: add grouped catalog output and grouped json mode" +``` + +### Task 3: Make `nixo` Primary CLI with `nixosandbox` Compatibility + +**Files:** +- Modify: `crates/nixosandbox/Cargo.toml` +- Modify: `crates/nixosandbox/src/cli.rs` +- Modify: `README.md` +- Test: `crates/nixosandbox` build outputs + +- [ ] **Step 1: Write failing test/verification command for dual binaries** + +```bash +cd crates/nixosandbox +cargo build --release +test -x target/release/nixo +test -x target/release/nixosandbox +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: the command block above +Expected: FAIL because `target/release/nixo` does not exist. + +- [ ] **Step 3: Add dual binary entries and update clap branding** + +```toml +[[bin]] +name = "nixo" +path = "src/main.rs" + +[[bin]] +name = "nixosandbox" +path = "src/main.rs" +``` + +```rust +#[derive(Parser)] +#[command(name = "nixo", about = "Reproducible, isolated sandbox environments")] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} +``` + +- [ ] **Step 4: Verify both binaries work** + +Run: `cd crates/nixosandbox && cargo build --release && ./target/release/nixo --help && ./target/release/nixosandbox --help` +Expected: both succeed, help text shows `nixo`. + +- [ ] **Step 5: Commit** + +```bash +git add crates/nixosandbox/Cargo.toml crates/nixosandbox/src/cli.rs README.md +git commit -m "feat: make nixo primary binary and keep nixosandbox alias" +``` + +### Task 4: Add Release Workflow and Homebrew Formula Template + +**Files:** +- Create: `.github/workflows/release.yml` +- Create: `packaging/homebrew/nixo.rb` +- Modify: `README.md` +- Test: workflow lint + formula syntax check + +- [ ] **Step 1: Write failing verification for release workflow presence** + +Run: `test -f .github/workflows/release.yml` +Expected: FAIL because file does not exist. + +- [ ] **Step 2: Add release workflow** + +```yaml +name: Release + +on: + push: + tags: + - "v*" + +jobs: + build: + strategy: + matrix: + include: + - runner: macos-15 + target: aarch64-apple-darwin + - runner: macos-15-intel + target: x86_64-apple-darwin + - runner: ubuntu-latest + target: x86_64-unknown-linux-gnu + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v6 + - uses: dtolnay/rust-toolchain@stable + - run: cargo build --release --locked --target ${{ matrix.target }} + working-directory: crates/nixosandbox + - run: | + mkdir -p dist + cp crates/nixosandbox/target/${{ matrix.target }}/release/nixo dist/nixo-${{ matrix.target }} + shasum -a 256 dist/nixo-${{ matrix.target }} > dist/nixo-${{ matrix.target }}.sha256 + - uses: softprops/action-gh-release@v2 + with: + files: dist/* +``` + +- [ ] **Step 3: Add Homebrew formula template for tap repo** + +```bash +TAG="v0.1.0" +ARM64_SHA="$(cut -d' ' -f1 dist/nixo-aarch64-apple-darwin.sha256)" +AMD64_SHA="$(cut -d' ' -f1 dist/nixo-x86_64-apple-darwin.sha256)" +LINUX_SHA="$(cut -d' ' -f1 dist/nixo-x86_64-unknown-linux-gnu.sha256)" + +cat > packaging/homebrew/nixo.rb < "nixo" + bin.install_symlink "nixo" => "nixosandbox" + end +end +EOF +``` + +- [ ] **Step 4: Verify files exist and basic syntax checks** + +Run: `test -f .github/workflows/release.yml && test -f packaging/homebrew/nixo.rb` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add .github/workflows/release.yml packaging/homebrew/nixo.rb README.md +git commit -m "ci: add nixo release workflow and homebrew formula template" +``` + +### Task 5: Add Cross-Agent Skill in `.agents/skills` + +**Files:** +- Create: `.agents/skills/nixo-cli/SKILL.md` +- Create: `.agents/skills/nixo-cli/references/quick-reference.md` +- Create: `.agents/skills/nixo-cli/references/troubleshooting.md` +- Test: frontmatter parse + file layout checks + +- [ ] **Step 1: Write failing validation command** + +Run: `test -f .agents/skills/nixo-cli/SKILL.md` +Expected: FAIL because skill does not exist. + +- [ ] **Step 2: Create Agent Skills-compliant `SKILL.md`** + +```markdown +--- +name: nixo-cli +description: Use when creating, managing, and troubleshooting nixo sandbox sessions from any agent runtime, including package catalog discovery and command execution in isolated environments. +--- + +# Nixo CLI + +Load this skill when an agent needs to operate `nixo` sessions end-to-end. + +Prefer `nixo` commands. `nixosandbox` is a compatibility alias. + +## Core workflow + +1. Run `nixo catalog` or `nixo catalog --json` to choose packages. +2. Create sandbox with `nixo create --with ... --network off --json` unless downloads are required. +3. Execute commands via `nixo exec -- ...`. +4. Inspect state with `nixo status --json`. +5. Destroy sessions when done with `nixo destroy `. + +## References + +- `references/quick-reference.md` +- `references/troubleshooting.md` +``` + +- [ ] **Step 3: Add reference docs** + +```markdown +# Quick Reference + +- Catalog: `nixo catalog --json` +- Create: `nixo create --with claude-code,bash --network off --json` +- Exec: `nixo exec -- echo hello` +- Status: `nixo status --json` +- Destroy: `nixo destroy ` +``` + +```markdown +# Troubleshooting + +## bwrap unavailable + +- Linux: ensure `bwrap` is installed and user namespaces are available. +- macOS: ensure Docker Desktop is running unless `NIXOSANDBOX_NO_DOCKER=1` is intentional. + +## Flake root errors + +- Set `NIXOSANDBOX_FLAKE_ROOT` to repository root containing `flake.nix`. + +## Create argument conflicts + +- Do not combine `--with`, `--profile`, and `--spec` in the same `create` command. +``` + +- [ ] **Step 4: Validate skill shape** + +Run: `test -f .agents/skills/nixo-cli/SKILL.md && rg -n "^name:|^description:" .agents/skills/nixo-cli/SKILL.md` +Expected: PASS with exactly one `name` and one `description` in frontmatter. + +- [ ] **Step 5: Commit** + +```bash +git add .agents/skills/nixo-cli/SKILL.md .agents/skills/nixo-cli/references/quick-reference.md .agents/skills/nixo-cli/references/troubleshooting.md +git commit -m "feat: add cross-agent nixo cli skill package" +``` + +### Task 6: Update CI and README for New UX + +**Files:** +- Modify: `.github/workflows/ci.yml` +- Modify: `README.md` +- Modify: `CLAUDE.md` +- Modify: `AGENTS.md` +- Modify: `docs/superpowers/specs/2026-04-10-nixo-homebrew-and-cross-agent-skill-design.md` (only if implementation details changed) +- Test: CI-equivalent local checks + +- [ ] **Step 1: Write failing checks for grouped and alias coverage** + +Run: `rg -n "catalog --json --grouped|nixo --help|nixosandbox --help" .github/workflows/ci.yml` +Expected: FAIL because checks are missing. + +- [ ] **Step 2: Add CI smoke checks** + +```yaml +- name: Catalog grouped JSON shape + run: | + NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo catalog --json --grouped | python3 -m json.tool > /dev/null + +- name: Alias parity smoke check + run: | + ./result/bin/nixo --help > /tmp/nixo-help.txt + ./result/bin/nixosandbox --help > /tmp/nixosandbox-help.txt + grep -q "nixo" /tmp/nixo-help.txt + grep -q "nixo" /tmp/nixosandbox-help.txt +``` + +- [ ] **Step 3: Update README command examples and install sections** + +```markdown +## Install + +### Homebrew (recommended) + +```bash +brew tap HashWarlock/homebrew-nixo +brew install nixo +``` + +`nixosandbox` remains available as a compatibility alias. +``` + +- [ ] **Step 4: Update `CLAUDE.md` and `AGENTS.md` command guidance** + +```markdown +### CLI smoke test (Linux only, requires bwrap) +NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo catalog --json +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/nixo destroy + +Compatibility note: `nixosandbox` remains an alias for `nixo`. +``` + +- [ ] **Step 5: Reconcile spec doc if implementation changed details** + +Run: `rg -n "nixo|catalog --json --grouped|homebrew" docs/superpowers/specs/2026-04-10-nixo-homebrew-and-cross-agent-skill-design.md` +Expected: PASS and aligned with final implementation behavior. + +- [ ] **Step 6: Run verification commands** + +Run: `cd crates/nixosandbox && cargo test && cargo build --release` +Expected: PASS + +Run: `NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo catalog --json --grouped | python3 -m json.tool > /dev/null` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add .github/workflows/ci.yml README.md CLAUDE.md AGENTS.md docs/superpowers/specs/2026-04-10-nixo-homebrew-and-cross-agent-skill-design.md +git commit -m "docs(ci): align nixo homebrew guidance across project docs" +``` + +## Final Verification Checklist + +- [ ] `cd crates/nixosandbox && cargo test` +- [ ] `cd crates/nixosandbox && cargo build --release` +- [ ] `./crates/nixosandbox/target/release/nixo --help` +- [ ] `./crates/nixosandbox/target/release/nixosandbox --help` +- [ ] `NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo catalog` +- [ ] `NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo catalog --json` +- [ ] `NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo catalog --json --grouped` +- [ ] `test -f .agents/skills/nixo-cli/SKILL.md` + +## Self-Review + +### Spec coverage check + +- Homebrew-first install path: Task 4 + README updates in Task 6. +- `nixo` primary with `nixosandbox` alias: Task 3 + CI checks in Task 6. +- Catalog grouping UX: Task 1 and Task 2 + CI grouped checks. +- Cross-agent skill package: Task 5. + +### Placeholder scan + +- No `TBD`, `TODO`, or unresolved placeholder tokens remain. + +### Type/signature consistency + +- `cmd_catalog(json, grouped, filter)` signature is introduced consistently in `cli.rs` and `main.rs`. +- `CatalogView`/`GroupedCatalog` naming is consistent across module tests and command routing. diff --git a/docs/superpowers/specs/2026-04-10-nixo-homebrew-and-cross-agent-skill-design.md b/docs/superpowers/specs/2026-04-10-nixo-homebrew-and-cross-agent-skill-design.md new file mode 100644 index 0000000..1effdee --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-nixo-homebrew-and-cross-agent-skill-design.md @@ -0,0 +1,227 @@ +--- +title: nixo Homebrew Packaging, CLI Naming, and Cross-Agent Skill +date: 2026-04-10 +status: proposed +--- + +# Context + +The current install path emphasizes Nix builds from source: + +- `nix build github:HashWarlock/nixosandbox` +- Run binary from `./result/bin/nixosandbox` + +The goal is to make install and usage easier for mainstream users and agent runtimes: + +1. Install through Homebrew without requiring users to build the whole project with Nix. +2. Use `nixo` as the primary CLI command. +3. Keep `nixosandbox` as a compatibility alias. +4. Provide a cross-agent skill using the Agent Skills standard so any compliant runtime can use the CLI reliably. + +# Decisions + +## Chosen approach + +Use a release-artifact + Homebrew tap model: + +- Publish prebuilt binaries for supported targets from GitHub Releases. +- Homebrew formula installs `nixo` as the primary executable. +- Homebrew formula installs `nixosandbox` as a symlink alias to preserve compatibility. + +## Naming policy + +- Canonical CLI name: `nixo` +- Compatibility alias: `nixosandbox` +- Documentation should gradually shift examples to `nixo`, while acknowledging alias compatibility. + +## Skill policy + +- Create project-level skill at `.agents/skills/nixo-cli/`. +- Follow Agent Skills conventions for discovery and progressive disclosure. +- Keep instructions runtime-agnostic (no Codex/Claude/Gemini-specific tool assumptions). + +# Design + +## 1) Homebrew packaging architecture + +### Release artifacts + +Introduce release artifacts for: + +- macOS arm64 +- macOS x86_64 +- Linux x86_64 (and optionally Linux arm64 when release process is stable) + +Artifacts should include: + +- binary named `nixo` +- checksum files per target + +### Homebrew tap formula + +Create or use a tap repository with a formula (e.g. `nixo.rb`) that: + +- downloads the correct target artifact +- installs the binary as `nixo` +- creates compatibility symlink: + - `bin.install_symlink "nixo" => "nixosandbox"` + +### Versioning and update flow + +- Tag release in this repo (semantic versioning recommended). +- CI publishes release artifacts + checksums. +- Tap formula is updated to new version/checksum. + +## 2) CLI naming integration + +### Runtime behavior + +Support both invocation names: + +- `nixo` (primary) +- `nixosandbox` (alias compatibility) + +Implementation intent: + +- Set clap command display name/help to `nixo`. +- Keep behavior identical regardless of invoked binary name. +- Preserve existing flags/subcommands exactly. + +### Backward compatibility + +- Existing scripts using `nixosandbox ...` continue to work via alias/symlink. +- Existing metadata/env vars remain unchanged for now (`NIXOSANDBOX_*`) to avoid breakage. +- Optional future phase can add mirrored `NIXO_*` env vars if needed. + +## 3) Cross-agent skill design + +Skill location: + +- `.agents/skills/nixo-cli/SKILL.md` + +Optional resources: + +- `.agents/skills/nixo-cli/references/quick-reference.md` +- `.agents/skills/nixo-cli/references/troubleshooting.md` + +Frontmatter requirements: + +- `name`: `nixo-cli` +- `description`: trigger-focused, starts with "Use when..." + +Skill content scope: + +- command workflows: + - discover packages (`catalog`) + - create session (`create --with ... --network ... --json`) + - execute command (`exec -- ...`) + - inspect (`status`, `list`) + - cleanup (`destroy`) +- safe defaults: + - favor `--network off` unless task needs downloads + - use `--json` for machine-readable flows +- compatibility note: + - prefer `nixo`, mention `nixosandbox` alias +- failure triage: + - bwrap unavailable + - flake root resolution issues + - invalid package names or mixed `--with/--profile/--spec` + +## 4) Documentation updates + +Update README install and examples: + +- add Homebrew as primary install path +- keep Nix-from-source as developer/advanced path +- switch command examples to `nixo` +- mention compatibility alias explicitly + +## 5) Catalog readability and grouping + +The current catalog output is functionally correct but hard to scan as agent count grows. + +### UX goals + +- Make `nixo catalog` easier for humans to scan quickly. +- Preserve existing machine consumers that parse current `--json` output. +- Offer richer grouped JSON for new consumers without forced migration. + +### Output behavior + +- `nixo catalog` (plain text): + - grouped by category by default (readability-first). +- `nixo catalog --json`: + - keep current flat compatibility shape: + - top-level `agents` map + - top-level `tools` map +- `nixo catalog --json --grouped`: + - return grouped JSON view for clients that want category structure. + +### Category model + +Use category labels aligned with `llm-agents.nix` README conventions where possible (for example `AI Coding Agents`, `AI Assistants`, `Code Review`, `Utilities`). + +Implementation note: + +- Category metadata is not currently exposed in the catalog query path. +- Add a lightweight category mapping layer in `nixosandbox` (or derive from upstream metadata if later exposed) so grouping remains deterministic and stable. + +# Data flow and operations + +1. User runs `brew install /nixo`. +2. Homebrew installs `nixo` binary and `nixosandbox` symlink. +3. User executes `nixo create ...`. +4. CLI behavior remains identical to current `nixosandbox` command handling. +5. Agent runtimes discover `.agents/skills/nixo-cli/SKILL.md` and apply standard skill activation patterns. +6. `nixo catalog` presents grouped human-readable sections while `--json` keeps flat compatibility by default. + +# Error handling and edge cases + +- Missing release artifact for platform: + - formula should fail clearly; CI should validate supported platform matrix before release. +- Alias drift: + - test should assert `nixosandbox --help` works and matches `nixo --help`. +- Existing users pinned to old command: + - preserved through symlink alias. +- Skill parsing issues across clients: + - keep YAML simple; avoid malformed frontmatter; keep required fields present. + +# Testing strategy + +## Packaging tests + +- Validate tarball checksums in release workflow. +- Install via Homebrew in CI on macOS runners and run: + - `nixo --help` + - `nixosandbox --help` + - one non-destructive command (e.g., `catalog --json`) + +## CLI compatibility tests + +- Add tests confirming both executable names function. +- Ensure output parity for key commands. + +## Catalog UX tests + +- `nixo catalog` renders grouped sections with stable ordering. +- `nixo catalog --json` remains backward-compatible with current shape. +- `nixo catalog --json --grouped` returns grouped schema with deterministic category names. + +## Skill validation tests + +- Validate skill frontmatter and file layout against Agent Skills expectations. +- Smoke test by loading skill from `.agents/skills/` in at least one runtime that supports Agent Skills discovery. + +# Non-goals + +- Replacing Nix internals with non-Nix runtime composition. +- Renaming all internal `NIXOSANDBOX_*` env vars in this phase. +- Building a client-specific skill format; this stays Agent Skills standard. + +# Rollout plan + +1. Add release artifact workflow for `nixo`. +2. Create/maintain Homebrew tap formula with alias symlink. +3. Update CLI branding/help and docs to `nixo`. +4. Add `.agents/skills/nixo-cli` skill and references. +5. Validate install + alias + skill discovery in CI/manual smoke tests.