From 246cfa6bfe20a5639aeaf14d2823f305863ba320 Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 12:16:56 -0500 Subject: [PATCH 01/17] feat: add catalog grouping domain module --- crates/nixosandbox/src/catalog.rs | 124 ++++++++++++++++++++++++++++++ crates/nixosandbox/src/main.rs | 57 ++++++++++---- 2 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 crates/nixosandbox/src/catalog.rs diff --git a/crates/nixosandbox/src/catalog.rs b/crates/nixosandbox/src/catalog.rs new file mode 100644 index 0000000..c2808be --- /dev/null +++ b/crates/nixosandbox/src/catalog.rs @@ -0,0 +1,124 @@ +use std::collections::BTreeMap; + +use serde_json::{Map, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum AgentSection { + AiCodingAgents, + AiAssistants, + CodeReview, + Other, +} + +impl AgentSection { + pub fn label(self) -> &'static str { + match self { + AgentSection::AiCodingAgents => "AI Coding Agents", + AgentSection::AiAssistants => "AI Assistants", + AgentSection::CodeReview => "Code Review", + AgentSection::Other => "Other Agents", + } + } +} + +pub fn classify_agent(name: &str) -> AgentSection { + let lower = name.to_ascii_lowercase(); + + if lower.contains("review") { + AgentSection::CodeReview + } else if matches!(lower.as_str(), "claude-code" | "codex" | "opencode" | "aider") { + AgentSection::AiCodingAgents + } else if matches!(lower.as_str(), "pi" | "gemini" | "assistant") { + AgentSection::AiAssistants + } else { + AgentSection::Other + } +} + +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 +} + +pub fn flat_catalog_json(catalog: &Value) -> Value { + catalog.clone() +} + +#[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"), + ("pi", "Pi assistant"), + ("codex", "Codex"), + ("review", "Review helper"), + ]); + + let grouped = grouped_agents(&agents); + + let coding = grouped.get(&AgentSection::AiCodingAgents).unwrap(); + assert_eq!( + coding.keys().cloned().collect::>(), + vec!["claude-code".to_string(), "codex".to_string()] + ); + + let assistants = grouped.get(&AgentSection::AiAssistants).unwrap(); + assert_eq!( + assistants.keys().cloned().collect::>(), + vec!["pi".to_string()] + ); + + let review = grouped.get(&AgentSection::CodeReview).unwrap(); + assert_eq!( + review.keys().cloned().collect::>(), + vec!["review".to_string()] + ); + } + + #[test] + fn preserves_flat_json_shape_for_default_json_mode() { + let catalog = json!({ + "agents": { + "claude-code": { "description": "Claude Code" }, + "pi": { "description": "Pi assistant" } + }, + "tools": { + "git": { "description": "Git" } + } + }); + + let flat = flat_catalog_json(&catalog); + 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); + } +} diff --git a/crates/nixosandbox/src/main.rs b/crates/nixosandbox/src/main.rs index e9b95b2..f5fec91 100644 --- a/crates/nixosandbox/src/main.rs +++ b/crates/nixosandbox/src/main.rs @@ -1,5 +1,6 @@ mod bubblewrap; mod cli; +mod catalog; mod docker; mod nix; mod plan_builder; @@ -535,26 +536,51 @@ fn cmd_catalog(json: bool, filter: Option) { } // 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; + if let Some(entries) = catalog.get("agents").and_then(|v| v.as_object()) { + let grouped = catalog::grouped_agents(entries); + for section in [ + catalog::AgentSection::AiCodingAgents, + catalog::AgentSection::AiAssistants, + catalog::AgentSection::CodeReview, + catalog::AgentSection::Other, + ] { + if let Some(entries) = grouped.get(§ion) { + println!("{}:", section.label()); + for (name, value) in entries { + let desc = value + .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!(" {:<20} {}", name, desc); + println!(); } - println!(); } } + + if let Some(entries) = catalog.get("tools").and_then(|v| v.as_object()) { + println!("Tools (from nixpkgs):"); + 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!(); + } } fn cmd_status(session_id: &str, json: bool) { @@ -632,4 +658,3 @@ fn cmd_status(session_id: &str, json: bool) { println!("╰{}╯", "─".repeat(w)); } } - From dae53ce7aecc28fdf75303281b0d1470d61f29ff Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 12:20:21 -0500 Subject: [PATCH 02/17] fix: align catalog grouping with task1 spec --- crates/nixosandbox/src/catalog.rs | 29 ++++++++++------------------- crates/nixosandbox/src/main.rs | 1 - 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/crates/nixosandbox/src/catalog.rs b/crates/nixosandbox/src/catalog.rs index c2808be..75c1029 100644 --- a/crates/nixosandbox/src/catalog.rs +++ b/crates/nixosandbox/src/catalog.rs @@ -7,7 +7,6 @@ pub enum AgentSection { AiCodingAgents, AiAssistants, CodeReview, - Other, } impl AgentSection { @@ -16,22 +15,15 @@ impl AgentSection { AgentSection::AiCodingAgents => "AI Coding Agents", AgentSection::AiAssistants => "AI Assistants", AgentSection::CodeReview => "Code Review", - AgentSection::Other => "Other Agents", } } } pub fn classify_agent(name: &str) -> AgentSection { - let lower = name.to_ascii_lowercase(); - - if lower.contains("review") { - AgentSection::CodeReview - } else if matches!(lower.as_str(), "claude-code" | "codex" | "opencode" | "aider") { - AgentSection::AiCodingAgents - } else if matches!(lower.as_str(), "pi" | "gemini" | "assistant") { - AgentSection::AiAssistants - } else { - AgentSection::Other + match name { + "localgpt" | "hermes-agent" | "openclaw" => AgentSection::AiAssistants, + "coderabbit-cli" | "tuicr" => AgentSection::CodeReview, + _ => AgentSection::AiCodingAgents, } } @@ -75,9 +67,8 @@ mod tests { fn groups_known_agents_into_expected_sections() { let agents = agent_map(&[ ("claude-code", "Claude Code"), - ("pi", "Pi assistant"), - ("codex", "Codex"), - ("review", "Review helper"), + ("localgpt", "Local GPT"), + ("coderabbit-cli", "CodeRabbit CLI"), ]); let grouped = grouped_agents(&agents); @@ -85,19 +76,19 @@ mod tests { let coding = grouped.get(&AgentSection::AiCodingAgents).unwrap(); assert_eq!( coding.keys().cloned().collect::>(), - vec!["claude-code".to_string(), "codex".to_string()] + vec!["claude-code".to_string()] ); let assistants = grouped.get(&AgentSection::AiAssistants).unwrap(); assert_eq!( assistants.keys().cloned().collect::>(), - vec!["pi".to_string()] + vec!["localgpt".to_string()] ); let review = grouped.get(&AgentSection::CodeReview).unwrap(); assert_eq!( review.keys().cloned().collect::>(), - vec!["review".to_string()] + vec!["coderabbit-cli".to_string()] ); } @@ -106,7 +97,7 @@ mod tests { let catalog = json!({ "agents": { "claude-code": { "description": "Claude Code" }, - "pi": { "description": "Pi assistant" } + "localgpt": { "description": "Local GPT" } }, "tools": { "git": { "description": "Git" } diff --git a/crates/nixosandbox/src/main.rs b/crates/nixosandbox/src/main.rs index f5fec91..88a2520 100644 --- a/crates/nixosandbox/src/main.rs +++ b/crates/nixosandbox/src/main.rs @@ -542,7 +542,6 @@ fn cmd_catalog(json: bool, filter: Option) { catalog::AgentSection::AiCodingAgents, catalog::AgentSection::AiAssistants, catalog::AgentSection::CodeReview, - catalog::AgentSection::Other, ] { if let Some(entries) = grouped.get(§ion) { println!("{}:", section.label()); From 2abcbf65032caf1553c5094e2e06547cdfbc1218 Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 12:29:29 -0500 Subject: [PATCH 03/17] feat: add grouped catalog output and grouped json mode --- crates/nixosandbox/src/catalog.rs | 213 +++++++++++++++++++- crates/nixosandbox/src/cli.rs | 10 +- crates/nixosandbox/src/main.rs | 309 ++++++++++++++++++------------ 3 files changed, 396 insertions(+), 136 deletions(-) diff --git a/crates/nixosandbox/src/catalog.rs b/crates/nixosandbox/src/catalog.rs index 75c1029..b131f9f 100644 --- a/crates/nixosandbox/src/catalog.rs +++ b/crates/nixosandbox/src/catalog.rs @@ -27,7 +27,9 @@ pub fn classify_agent(name: &str) -> AgentSection { } } -pub fn grouped_agents(agents: &Map) -> BTreeMap> { +pub fn grouped_agents( + agents: &Map, +) -> BTreeMap> { let mut grouped = BTreeMap::new(); for (name, value) in agents { @@ -41,8 +43,144 @@ pub fn grouped_agents(agents: &Map) -> BTreeMap Value { - catalog.clone() +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) { + lines.push(format!("{}:", section.label())); + for (name, value) in entries { + if !matches_filter(name, value, filter_lower.as_deref()) { + continue; + } + 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()) { + lines.push("Tools (from nixpkgs):".to_string()); + for (name, value) in filtered_entries(entries, filter_lower.as_deref()) { + let desc = value + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or(""); + lines.push(format!(" {:<20} {}", name, desc)); + } + lines.push(String::new()); + } + + while matches!(lines.last(), Some(line) if line.is_empty()) { + lines.pop(); + } + + lines.join("\n") } #[cfg(test)] @@ -54,12 +192,7 @@ mod tests { fn agent_map(entries: &[(&str, &str)]) -> Map { entries .iter() - .map(|(name, description)| { - ( - (*name).to_string(), - json!({ "description": description }), - ) - }) + .map(|(name, description)| ((*name).to_string(), json!({ "description": description }))) .collect() } @@ -104,7 +237,7 @@ mod tests { } }); - let flat = flat_catalog_json(&catalog); + let flat = flat_catalog_json(&catalog, None); assert_eq!(flat, catalog); let obj = flat.as_object().unwrap(); @@ -112,4 +245,64 @@ mod tests { 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 + ); + } } diff --git a/crates/nixosandbox/src/cli.rs b/crates/nixosandbox/src/cli.rs index 14db473..47dd0fa 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 = "nixosandbox", + 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/main.rs index 88a2520..c12827d 100644 --- a/crates/nixosandbox/src/main.rs +++ b/crates/nixosandbox/src/main.rs @@ -1,6 +1,6 @@ mod bubblewrap; -mod cli; mod catalog; +mod cli; mod docker; mod nix; mod plan_builder; @@ -15,10 +15,36 @@ fn main() { let cli = Cli::parse(); 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 } => { @@ -33,11 +59,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); } } } @@ -54,12 +88,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); @@ -135,7 +167,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); @@ -156,7 +192,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); }); @@ -193,8 +230,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) @@ -299,17 +361,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); @@ -422,7 +482,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) @@ -432,15 +497,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); @@ -466,9 +529,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 + ); } } } @@ -492,94 +561,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 - if let Some(entries) = catalog.get("agents").and_then(|v| v.as_object()) { - let grouped = catalog::grouped_agents(entries); - for section in [ - catalog::AgentSection::AiCodingAgents, - catalog::AgentSection::AiAssistants, - catalog::AgentSection::CodeReview, - ] { - if let Some(entries) = grouped.get(§ion) { - println!("{}:", section.label()); - for (name, value) in entries { - let desc = value - .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!(); - } - } - } - - if let Some(entries) = catalog.get("tools").and_then(|v| v.as_object()) { - println!("Tools (from nixpkgs):"); - 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) { @@ -642,18 +664,57 @@ fn cmd_status(session_id: &str, json: bool) { let w = 48; println!("╭{}╮", "─".repeat(w)); - println!("│ {: Date: Fri, 10 Apr 2026 12:39:30 -0500 Subject: [PATCH 04/17] feat: make nixo primary binary and keep nixosandbox alias --- README.md | 55 ++++++++++++++++++----------------- crates/nixosandbox/Cargo.toml | 5 ++++ crates/nixosandbox/src/cli.rs | 2 +- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 9bf95a5..ee13fa8 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,27 @@ # nixosandbox 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 @@ -36,7 +37,8 @@ nixosandbox CLI (Rust) ```bash nix build github:HashWarlock/nixosandbox -./result/bin/nixosandbox --help +./result/bin/nixo --help +# `./result/bin/nixosandbox --help` also works as a compatibility alias ``` ### Development shell @@ -51,50 +53,50 @@ 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' ``` ### 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 ``` ## CLI reference @@ -149,7 +151,7 @@ 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: @@ -168,7 +170,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 +181,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 +226,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 +308,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", }); } ``` diff --git a/crates/nixosandbox/Cargo.toml b/crates/nixosandbox/Cargo.toml index f172f29..022bed5 100644 --- a/crates/nixosandbox/Cargo.toml +++ b/crates/nixosandbox/Cargo.toml @@ -2,6 +2,11 @@ name = "nixosandbox" version = "0.1.0" edition = "2021" +default-run = "nixo" + +[[bin]] +name = "nixo" +path = "src/main.rs" [[bin]] name = "nixosandbox" diff --git a/crates/nixosandbox/src/cli.rs b/crates/nixosandbox/src/cli.rs index 47dd0fa..36eb26d 100644 --- a/crates/nixosandbox/src/cli.rs +++ b/crates/nixosandbox/src/cli.rs @@ -2,7 +2,7 @@ use clap::{Parser, Subcommand}; #[derive(Parser)] #[command( - name = "nixosandbox", + name = "nixo", about = "Reproducible, isolated sandbox environments" )] pub struct Cli { From 9335c61548ce5ea60ed9dc578c6aaa2cc1b181b9 Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 12:46:48 -0500 Subject: [PATCH 05/17] fix: align nixo branding and alias verification docs --- README.md | 2 +- crates/nixosandbox/src/main.rs | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ee13fa8..53cf4fb 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ nixo CLI (Rust) ```bash nix build github:HashWarlock/nixosandbox ./result/bin/nixo --help -# `./result/bin/nixosandbox --help` also works as a compatibility alias +./result/bin/nixosandbox --help ``` ### Development shell diff --git a/crates/nixosandbox/src/main.rs b/crates/nixosandbox/src/main.rs index c12827d..c161e8e 100644 --- a/crates/nixosandbox/src/main.rs +++ b/crates/nixosandbox/src/main.rs @@ -8,11 +8,12 @@ mod session; mod spec; mod timestamps; -use clap::Parser; +use clap::{CommandFactory, FromArgMatches}; use cli::{Cli, Commands}; fn main() { - let cli = Cli::parse(); + let matches = Cli::command().bin_name("nixo").get_matches(); + let cli = Cli::from_arg_matches(&matches).unwrap_or_else(|e| e.exit()); match cli.command { Commands::Create { From 74b3f465020696f0881880b5ac18ad06e2ceaa54 Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 13:07:36 -0500 Subject: [PATCH 06/17] fix: split nixo binaries into thin wrappers --- crates/nixosandbox/Cargo.toml | 5 +++-- crates/nixosandbox/src/bin/nixo.rs | 3 +++ crates/nixosandbox/src/bin/nixosandbox.rs | 3 +++ crates/nixosandbox/src/{main.rs => lib.rs} | 4 ++-- 4 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 crates/nixosandbox/src/bin/nixo.rs create mode 100644 crates/nixosandbox/src/bin/nixosandbox.rs rename crates/nixosandbox/src/{main.rs => lib.rs} (99%) diff --git a/crates/nixosandbox/Cargo.toml b/crates/nixosandbox/Cargo.toml index 022bed5..7b627ea 100644 --- a/crates/nixosandbox/Cargo.toml +++ b/crates/nixosandbox/Cargo.toml @@ -2,15 +2,16 @@ name = "nixosandbox" version = "0.1.0" edition = "2021" +autobins = false default-run = "nixo" [[bin]] name = "nixo" -path = "src/main.rs" +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/main.rs b/crates/nixosandbox/src/lib.rs similarity index 99% rename from crates/nixosandbox/src/main.rs rename to crates/nixosandbox/src/lib.rs index c161e8e..092a67f 100644 --- a/crates/nixosandbox/src/main.rs +++ b/crates/nixosandbox/src/lib.rs @@ -11,8 +11,8 @@ mod timestamps; use clap::{CommandFactory, FromArgMatches}; use cli::{Cli, Commands}; -fn main() { - let matches = Cli::command().bin_name("nixo").get_matches(); +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 { From 216e79044ecb8058b8632f5ffffd7a35d51e81a1 Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 13:15:39 -0500 Subject: [PATCH 07/17] ci: add nixo release workflow and homebrew formula template --- .github/workflows/release.yml | 77 +++++++++++++++++++++++++++++++++++ README.md | 10 +++++ packaging/homebrew/nixo.rb | 26 ++++++++++++ 3 files changed, 113 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 packaging/homebrew/nixo.rb diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5d74a30 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,77 @@ +name: Release + +on: + push: + tags: + - "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" + install -m 755 "crates/nixosandbox/target/${target}/release/nixo" "$package_dir/nixo" + install -m 755 "crates/nixosandbox/target/${target}/release/nixosandbox" "$package_dir/nixosandbox" + tar -C "$package_dir" -czf "dist/nixo-${target}.tar.gz" nixo nixosandbox + 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/README.md b/README.md index 53cf4fb..bcab6ad 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,16 @@ nixo CLI (Rust) ## Install +### Homebrew (recommended) + +```bash +brew install /nixo +nixo --help +nixosandbox --help # compatibility alias +``` + +If you maintain a tap, start from [`packaging/homebrew/nixo.rb`](packaging/homebrew/nixo.rb) and update the release URLs and sha256 values for each version you publish. + ### From source (requires Nix with flakes) ```bash diff --git a/packaging/homebrew/nixo.rb b/packaging/homebrew/nixo.rb new file mode 100644 index 0000000..8fda51b --- /dev/null +++ b/packaging/homebrew/nixo.rb @@ -0,0 +1,26 @@ +class Nixo < Formula + desc "Reproducible, isolated sandbox environments for AI coding agents" + homepage "https://github.com/HashWarlock/nixosandbox" + version "0.1.0" + + on_macos do + on_arm do + url "https://github.com/HashWarlock/nixosandbox/releases/download/v#{version}/nixo-aarch64-apple-darwin.tar.gz" + sha256 "REPLACE_WITH_AARCH64_SHA256" + end + + on_intel do + url "https://github.com/HashWarlock/nixosandbox/releases/download/v#{version}/nixo-x86_64-apple-darwin.tar.gz" + sha256 "REPLACE_WITH_X86_64_SHA256" + end + end + + def install + bin.install "nixo" + bin.install_symlink "nixo" => "nixosandbox" + end + + test do + assert_match "nixo", shell_output("#{bin}/nixo --help") + end +end From 1109daa905f0a4d7d3690d3887832dda8db7f0b0 Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 13:21:17 -0500 Subject: [PATCH 08/17] fix: harden release trigger and clarify homebrew template --- .github/workflows/release.yml | 3 ++- README.md | 4 ++-- packaging/homebrew/nixo.rb | 8 ++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d74a30..1e53e59 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,8 @@ name: Release on: push: tags: - - "v*" + - "v*.*.*" + - "v*.*.*-*" permissions: contents: write diff --git a/README.md b/README.md index bcab6ad..46cd807 100644 --- a/README.md +++ b/README.md @@ -33,7 +33,7 @@ nixo CLI (Rust) ## Install -### Homebrew (recommended) +### Homebrew tap template ```bash brew install /nixo @@ -41,7 +41,7 @@ nixo --help nixosandbox --help # compatibility alias ``` -If you maintain a tap, start from [`packaging/homebrew/nixo.rb`](packaging/homebrew/nixo.rb) and update the release URLs and sha256 values for each version you publish. +[packaging/homebrew/nixo.rb](packaging/homebrew/nixo.rb) is a tap bootstrap template, not a ready-to-publish formula. Replace the release URLs and placeholder sha256 values before publishing your tap. ### From source (requires Nix with flakes) diff --git a/packaging/homebrew/nixo.rb b/packaging/homebrew/nixo.rb index 8fda51b..0fc12ba 100644 --- a/packaging/homebrew/nixo.rb +++ b/packaging/homebrew/nixo.rb @@ -3,6 +3,7 @@ class Nixo < Formula homepage "https://github.com/HashWarlock/nixosandbox" version "0.1.0" + # Replace the placeholder sha256 values below with the published release checksums. on_macos do on_arm do url "https://github.com/HashWarlock/nixosandbox/releases/download/v#{version}/nixo-aarch64-apple-darwin.tar.gz" @@ -15,6 +16,13 @@ class Nixo < Formula end end + on_linux do + on_intel do + url "https://github.com/HashWarlock/nixosandbox/releases/download/v#{version}/nixo-x86_64-unknown-linux-gnu.tar.gz" + sha256 "REPLACE_WITH_LINUX_X86_64_SHA256" + end + end + def install bin.install "nixo" bin.install_symlink "nixo" => "nixosandbox" From 13fa9ba1615f8fcca573775aec9ca3917d42a9da Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 13:23:03 -0500 Subject: [PATCH 09/17] feat: add cross-agent nixo cli skill package --- .agents/skills/nixo-cli/SKILL.md | 35 +++++++++++++++++++ .../nixo-cli/references/quick-reference.md | 31 ++++++++++++++++ .../nixo-cli/references/troubleshooting.md | 30 ++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 .agents/skills/nixo-cli/SKILL.md create mode 100644 .agents/skills/nixo-cli/references/quick-reference.md create mode 100644 .agents/skills/nixo-cli/references/troubleshooting.md diff --git a/.agents/skills/nixo-cli/SKILL.md b/.agents/skills/nixo-cli/SKILL.md new file mode 100644 index 0000000..3327446 --- /dev/null +++ b/.agents/skills/nixo-cli/SKILL.md @@ -0,0 +1,35 @@ +--- +name: nixo-cli +description: Use when working with the nixo CLI to discover packages, create sandboxes, execute commands, inspect sessions, or troubleshoot compatibility. Prefer `nixo`; `nixosandbox` remains a compatibility alias. +--- + +# nixo CLI + +Use this skill for runtime-agnostic workflows around the `nixo` command line. + +## Naming + +- Prefer `nixo` in examples, prompts, and automation. +- Treat `nixosandbox` as a compatibility alias for older scripts and environments. +- Keep command behavior and flag usage identical regardless of which binary name is invoked. + +## 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. + +## 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..a2f5540 --- /dev/null +++ b/.agents/skills/nixo-cli/references/troubleshooting.md @@ -0,0 +1,30 @@ +# 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. + +## 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. From ae6b6a659f4deede497a8a006537025cabec2419 Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 13:29:44 -0500 Subject: [PATCH 10/17] docs(ci): align nixo homebrew guidance across project docs --- .github/workflows/ci.yml | 14 +++ AGENTS.md | 98 +++++++++++++++++++ CLAUDE.md | 14 +-- README.md | 16 ++- ...o-homebrew-and-cross-agent-skill-design.md | 3 +- 5 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 AGENTS.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89007fd..e08ca8c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,6 +95,20 @@ 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: | + NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json --grouped | python3 -m json.tool > /dev/null + 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/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..84a810d --- /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. **main.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 | +| `main.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 nixosandbox CLI as a subprocess, 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..0ff7273 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 ### 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()`. diff --git a/README.md b/README.md index 46cd807..c4ea9fa 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ nixo --help nixosandbox --help # compatibility alias ``` -[packaging/homebrew/nixo.rb](packaging/homebrew/nixo.rb) is a tap bootstrap template, not a ready-to-publish formula. Replace the release URLs and placeholder sha256 values before publishing your tap. +[packaging/homebrew/nixo.rb](packaging/homebrew/nixo.rb) is a tap bootstrap template, not a ready-to-publish formula. It is written for `nixo` as the primary executable and installs `nixosandbox` as a compatibility symlink. Replace the release URLs and placeholder sha256 values before publishing your tap. ### From source (requires Nix with flakes) @@ -66,6 +66,7 @@ cargo build nixo catalog nixo catalog --filter claude nixo catalog --json | jq '.agents | keys' +nixo catalog --json --grouped | jq '.agentCategories | keys' ``` ### 2. Create a sandbox @@ -138,6 +139,14 @@ nixo 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: @@ -167,6 +176,11 @@ The catalog merges two sources: `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 | 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 index 1effdee..505cd72 100644 --- 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 @@ -156,6 +156,7 @@ The current catalog output is functionally correct but hard to scan as agent cou - top-level `tools` map - `nixo catalog --json --grouped`: - return grouped JSON view for clients that want category structure. + - current implementation emits grouped agent data under `agentCategories`, with `tools` preserved as a top-level map. ### Category model @@ -198,7 +199,7 @@ Implementation note: ## CLI compatibility tests -- Add tests confirming both executable names function. +- Add tests confirming both executable names function and invoke the same help text. - Ensure output parity for key commands. ## Catalog UX tests From ae1cefa7f1798ed3ef1e072f99fa97f9f7023ed0 Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 13:36:48 -0500 Subject: [PATCH 11/17] fix: align docs module references and grouped catalog CI checks --- .github/workflows/ci.yml | 11 ++++++++++- AGENTS.md | 4 ++-- CLAUDE.md | 4 ++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e08ca8c..d1c55ee 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,7 +97,16 @@ jobs: - name: Test grouped catalog output run: | - NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixosandbox catalog --json --grouped | python3 -m json.tool > /dev/null + 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 diff --git a/AGENTS.md b/AGENTS.md index 84a810d..105c1e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo destroy 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. **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 @@ -65,7 +65,7 @@ NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo 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`) | diff --git a/CLAUDE.md b/CLAUDE.md index 0ff7273..e26144d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,7 @@ NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo destroy 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. **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 @@ -65,7 +65,7 @@ NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo 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`) | From 124cf720289ca3f5ce961441c6f41e346a5fd087 Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 14:18:27 -0500 Subject: [PATCH 12/17] fix: package flake runtime context and tighten grouped catalog output --- .github/workflows/release.yml | 11 ++++-- README.md | 2 +- crates/nixosandbox/src/catalog.rs | 64 +++++++++++++++++++++++++++---- packaging/homebrew/nixo.rb | 6 ++- 4 files changed, 70 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1e53e59..35bf670 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,10 +44,13 @@ jobs: set -euo pipefail target="${{ matrix.target }}" package_dir="dist/nixo-${target}" - mkdir -p "$package_dir" - install -m 755 "crates/nixosandbox/target/${target}/release/nixo" "$package_dir/nixo" - install -m 755 "crates/nixosandbox/target/${target}/release/nixosandbox" "$package_dir/nixosandbox" - tar -C "$package_dir" -czf "dist/nixo-${target}.tar.gz" nixo nixosandbox + 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 diff --git a/README.md b/README.md index c4ea9fa..5f067f1 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ nixo --help nixosandbox --help # compatibility alias ``` -[packaging/homebrew/nixo.rb](packaging/homebrew/nixo.rb) is a tap bootstrap template, not a ready-to-publish formula. It is written for `nixo` as the primary executable and installs `nixosandbox` as a compatibility symlink. Replace the release URLs and placeholder sha256 values before publishing your tap. +[packaging/homebrew/nixo.rb](packaging/homebrew/nixo.rb) is a tap bootstrap template, not a ready-to-publish formula. It is written for `nixo` as the primary executable and installs `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 release URLs and placeholder sha256 values before publishing your tap. ### From source (requires Nix with flakes) diff --git a/crates/nixosandbox/src/catalog.rs b/crates/nixosandbox/src/catalog.rs index b131f9f..e310bd4 100644 --- a/crates/nixosandbox/src/catalog.rs +++ b/crates/nixosandbox/src/catalog.rs @@ -148,11 +148,15 @@ pub fn grouped_catalog_text(catalog: &Value, filter: Option<&str>) -> String { 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 entries { - if !matches_filter(name, value, filter_lower.as_deref()) { - continue; - } + for (name, value) in filtered { let desc = value .get("description") .and_then(|d| d.as_str()) @@ -165,15 +169,21 @@ pub fn grouped_catalog_text(catalog: &Value, filter: Option<&str>) -> String { } if let Some(entries) = catalog.get("tools").and_then(|v| v.as_object()) { - lines.push("Tools (from nixpkgs):".to_string()); - for (name, value) in filtered_entries(entries, filter_lower.as_deref()) { + 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)); } - lines.push(String::new()); + if has_tools { + lines.push(String::new()); + } } while matches!(lines.last(), Some(line) if line.is_empty()) { @@ -305,4 +315,44 @@ mod tests { 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/packaging/homebrew/nixo.rb b/packaging/homebrew/nixo.rb index 0fc12ba..de95d1c 100644 --- a/packaging/homebrew/nixo.rb +++ b/packaging/homebrew/nixo.rb @@ -24,7 +24,11 @@ class Nixo < Formula end def install - bin.install "nixo" + 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 From dff56b401096acfef8162426a91053ed555f0fae Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 16:23:47 -0500 Subject: [PATCH 13/17] chore: harden homebrew formula template checks --- README.md | 7 +++++++ packaging/homebrew/nixo.rb | 4 ++++ 2 files changed, 11 insertions(+) diff --git a/README.md b/README.md index 5f067f1..f83eef8 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,13 @@ nixosandbox --help # compatibility alias [packaging/homebrew/nixo.rb](packaging/homebrew/nixo.rb) is a tap bootstrap template, not a ready-to-publish formula. It is written for `nixo` as the primary executable and installs `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 release URLs and placeholder sha256 values before publishing your tap. +Before opening your tap PR, run: + +```bash +brew audit --strict nixo +brew test nixo +``` + ### From source (requires Nix with flakes) ```bash diff --git a/packaging/homebrew/nixo.rb b/packaging/homebrew/nixo.rb index de95d1c..1cd7d56 100644 --- a/packaging/homebrew/nixo.rb +++ b/packaging/homebrew/nixo.rb @@ -2,6 +2,7 @@ class Nixo < Formula desc "Reproducible, isolated sandbox environments for AI coding agents" homepage "https://github.com/HashWarlock/nixosandbox" version "0.1.0" + depends_on "nix" # Replace the placeholder sha256 values below with the published release checksums. on_macos do @@ -33,6 +34,9 @@ def install 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 From 93cf3fba70146f25a3c7d13810f6653be7743bde Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 16:36:16 -0500 Subject: [PATCH 14/17] docs(skill): tighten nixo cli triggers and troubleshooting --- .agents/skills/nixo-cli/SKILL.md | 18 ++++++++++++++---- .../nixo-cli/references/troubleshooting.md | 11 +++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/.agents/skills/nixo-cli/SKILL.md b/.agents/skills/nixo-cli/SKILL.md index 3327446..10935ee 100644 --- a/.agents/skills/nixo-cli/SKILL.md +++ b/.agents/skills/nixo-cli/SKILL.md @@ -1,6 +1,6 @@ --- name: nixo-cli -description: Use when working with the nixo CLI to discover packages, create sandboxes, execute commands, inspect sessions, or troubleshoot compatibility. Prefer `nixo`; `nixosandbox` remains a compatibility alias. +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 @@ -9,9 +9,9 @@ Use this skill for runtime-agnostic workflows around the `nixo` command line. ## Naming -- Prefer `nixo` in examples, prompts, and automation. -- Treat `nixosandbox` as a compatibility alias for older scripts and environments. -- Keep command behavior and flag usage identical regardless of which binary name is invoked. +- 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 @@ -28,6 +28,16 @@ Use this skill for runtime-agnostic workflows around the `nixo` command line. - 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`. + +## 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 diff --git a/.agents/skills/nixo-cli/references/troubleshooting.md b/.agents/skills/nixo-cli/references/troubleshooting.md index a2f5540..edd6e1a 100644 --- a/.agents/skills/nixo-cli/references/troubleshooting.md +++ b/.agents/skills/nixo-cli/references/troubleshooting.md @@ -12,6 +12,17 @@ - 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/nixosandbox` +- 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. From ca8d8c46f60845eef8b98384a744ab5114021287 Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 16:55:15 -0500 Subject: [PATCH 15/17] docs: align repo rename and agent guidance --- .agents/skills/nixo-cli/references/troubleshooting.md | 2 +- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 9 +++++---- packaging/homebrew/nixo.rb | 8 ++++---- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.agents/skills/nixo-cli/references/troubleshooting.md b/.agents/skills/nixo-cli/references/troubleshooting.md index edd6e1a..e05ef4c 100644 --- a/.agents/skills/nixo-cli/references/troubleshooting.md +++ b/.agents/skills/nixo-cli/references/troubleshooting.md @@ -15,7 +15,7 @@ ## `Could not find flake.nix` - Set flake root explicitly: - - `export NIXOSANDBOX_FLAKE_ROOT=/path/to/nixosandbox` + - `export NIXOSANDBOX_FLAKE_ROOT=/path/to/nixo` - Verify the path: - `test -f "$NIXOSANDBOX_FLAKE_ROOT/flake.nix" && echo ok` - Retry: diff --git a/AGENTS.md b/AGENTS.md index 105c1e9..7641a0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,7 +85,7 @@ NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo 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/CLAUDE.md b/CLAUDE.md index e26144d..2198163 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,7 +85,7 @@ NIXOSANDBOX_FLAKE_ROOT=$PWD ./result/bin/nixo 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/README.md b/README.md index f83eef8..01260a5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# 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. @@ -36,7 +36,8 @@ nixo CLI (Rust) ### Homebrew tap template ```bash -brew install /nixo +brew tap HashWarlock/homebrew-nixo +brew install nixo nixo --help nixosandbox --help # compatibility alias ``` @@ -53,7 +54,7 @@ brew test 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 ``` @@ -369,7 +370,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/packaging/homebrew/nixo.rb b/packaging/homebrew/nixo.rb index 1cd7d56..51f38f8 100644 --- a/packaging/homebrew/nixo.rb +++ b/packaging/homebrew/nixo.rb @@ -1,25 +1,25 @@ class Nixo < Formula desc "Reproducible, isolated sandbox environments for AI coding agents" - homepage "https://github.com/HashWarlock/nixosandbox" + 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/nixosandbox/releases/download/v#{version}/nixo-aarch64-apple-darwin.tar.gz" + 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/nixosandbox/releases/download/v#{version}/nixo-x86_64-apple-darwin.tar.gz" + 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/nixosandbox/releases/download/v#{version}/nixo-x86_64-unknown-linux-gnu.tar.gz" + 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 From 489153580510364c5fc11df1d525f2dac072a6b6 Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 17:05:03 -0500 Subject: [PATCH 16/17] docs: move homebrew formula into tap layout --- {packaging/homebrew => Formula}/nixo.rb | 0 README.md | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) rename {packaging/homebrew => Formula}/nixo.rb (100%) diff --git a/packaging/homebrew/nixo.rb b/Formula/nixo.rb similarity index 100% rename from packaging/homebrew/nixo.rb rename to Formula/nixo.rb diff --git a/README.md b/README.md index 01260a5..4a3eb5b 100644 --- a/README.md +++ b/README.md @@ -33,16 +33,16 @@ nixo CLI (Rust) ## Install -### Homebrew tap template +### Homebrew ```bash -brew tap HashWarlock/homebrew-nixo +brew tap HashWarlock/nixo brew install nixo nixo --help nixosandbox --help # compatibility alias ``` -[packaging/homebrew/nixo.rb](packaging/homebrew/nixo.rb) is a tap bootstrap template, not a ready-to-publish formula. It is written for `nixo` as the primary executable and installs `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 release URLs and placeholder sha256 values before publishing your tap. +[`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 opening your tap PR, run: From ed62fd8dfabec95274869e17983c0b9e71f47f67 Mon Sep 17 00:00:00 2001 From: hashwarlock Date: Fri, 10 Apr 2026 17:10:05 -0500 Subject: [PATCH 17/17] docs: polish tap and skill guidance --- .agents/skills/nixo-cli/SKILL.md | 14 ++++++++++++++ README.md | 18 +++++++++++++++++- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/.agents/skills/nixo-cli/SKILL.md b/.agents/skills/nixo-cli/SKILL.md index 10935ee..5fb2e4e 100644 --- a/.agents/skills/nixo-cli/SKILL.md +++ b/.agents/skills/nixo-cli/SKILL.md @@ -7,6 +7,12 @@ description: Use when requests involve nixo or nixosandbox sandbox lifecycle ope 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. @@ -30,6 +36,14 @@ Use this skill for runtime-agnostic workflows around the `nixo` command line. - 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`: diff --git a/README.md b/README.md index 4a3eb5b..83bc31e 100644 --- a/README.md +++ b/README.md @@ -44,13 +44,21 @@ 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 opening your tap PR, run: +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 @@ -118,6 +126,14 @@ 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 |