diff --git a/Cargo.lock b/Cargo.lock index 87adc5e2b..458266207 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3435,6 +3435,7 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", + "serde_yml", "tar", "temp-env", "tempfile", diff --git a/crates/openshell-cli/Cargo.toml b/crates/openshell-cli/Cargo.toml index 7dc0c0f22..543fed6c3 100644 --- a/crates/openshell-cli/Cargo.toml +++ b/crates/openshell-cli/Cargo.toml @@ -23,6 +23,7 @@ openshell-prover = { path = "../openshell-prover" } openshell-tui = { path = "../openshell-tui" } serde = { workspace = true } serde_json = { workspace = true } +serde_yml = { workspace = true } prost-types = { workspace = true } # Async runtime diff --git a/crates/openshell-cli/src/main.rs b/crates/openshell-cli/src/main.rs index ca242be32..62a7b0ec1 100644 --- a/crates/openshell-cli/src/main.rs +++ b/crates/openshell-cli/src/main.rs @@ -647,13 +647,13 @@ fn normalize_completion_script(output: Vec, executable: &std::path::Path) -> } #[derive(Clone, Debug, ValueEnum)] -enum ProviderProfileOutput { +enum OutputFormat { Table, Yaml, Json, } -impl ProviderProfileOutput { +impl OutputFormat { fn as_str(&self) -> &'static str { match self { Self::Table => "table", @@ -736,8 +736,8 @@ enum ProviderCommands { #[command(help_template = LEAF_HELP_TEMPLATE, next_help_heading = "FLAGS")] ListProfiles { /// Output format. - #[arg(short = 'o', long = "output", value_enum, default_value_t = ProviderProfileOutput::Table)] - output: ProviderProfileOutput, + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table)] + output: OutputFormat, }, /// Manage provider profiles. @@ -786,8 +786,8 @@ enum ProviderProfileCommands { id: String, /// Output format. - #[arg(short = 'o', long = "output", value_enum, default_value_t = ProviderProfileOutput::Yaml)] - output: ProviderProfileOutput, + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Yaml)] + output: OutputFormat, }, /// Import provider profiles from a file or directory. @@ -1167,16 +1167,20 @@ enum SandboxCommands { offset: u32, /// Print only sandbox ids (one per line). - #[arg(long, conflicts_with = "names")] + #[arg(long, conflicts_with_all = ["names", "output"])] ids: bool, /// Print only sandbox names (one per line). - #[arg(long, conflicts_with = "ids")] + #[arg(long, conflicts_with_all = ["ids", "output"])] names: bool, /// Filter sandboxes by label selector (key1=value1,key2=value2). #[arg(long)] selector: Option, + + /// Output format. + #[arg(short = 'o', long = "output", value_enum, default_value_t = OutputFormat::Table, conflicts_with_all = ["ids", "names"])] + output: OutputFormat, }, /// Delete a sandbox by name. @@ -2527,6 +2531,7 @@ async fn main() -> Result<()> { ids, names, selector, + output, } => { run::sandbox_list( endpoint, @@ -2535,6 +2540,7 @@ async fn main() -> Result<()> { ids, names, selector.as_deref(), + output.as_str(), &tls, ) .await?; @@ -3398,7 +3404,7 @@ mod tests { cli.command, Some(Commands::Provider { command: Some(ProviderCommands::ListProfiles { - output: ProviderProfileOutput::Table + output: OutputFormat::Table }) }) )); @@ -3413,7 +3419,7 @@ mod tests { cli.command, Some(Commands::Provider { command: Some(ProviderCommands::ListProfiles { - output: ProviderProfileOutput::Json + output: OutputFormat::Json }) }) )); @@ -3436,7 +3442,7 @@ mod tests { Some(Commands::Provider { command: Some(ProviderCommands::Profile(ProviderProfileCommands::Export { id, - output: ProviderProfileOutput::Yaml + output: OutputFormat::Yaml })) }) if id == "custom-api" )); @@ -3473,6 +3479,66 @@ mod tests { )); } + #[test] + fn sandbox_list_default_output_is_table() { + let cli = Cli::try_parse_from(["openshell", "sandbox", "list"]) + .expect("sandbox list should parse"); + + assert!(matches!( + cli.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::List { + output: OutputFormat::Table, + .. + }) + }) + )); + } + + #[test] + fn sandbox_list_accepts_output_json() { + let cli = Cli::try_parse_from(["openshell", "sandbox", "list", "-o", "json"]) + .expect("sandbox list -o json should parse"); + + assert!(matches!( + cli.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::List { + output: OutputFormat::Json, + .. + }) + }) + )); + } + + #[test] + fn sandbox_list_accepts_output_yaml() { + let cli = Cli::try_parse_from(["openshell", "sandbox", "list", "-o", "yaml"]) + .expect("sandbox list -o yaml should parse"); + + assert!(matches!( + cli.command, + Some(Commands::Sandbox { + command: Some(SandboxCommands::List { + output: OutputFormat::Yaml, + .. + }) + }) + )); + } + + #[test] + fn sandbox_list_json_conflicts_with_ids() { + let result = Cli::try_parse_from(["openshell", "sandbox", "list", "-o", "json", "--ids"]); + assert!(result.is_err(), "--ids and -o json should conflict"); + } + + #[test] + fn sandbox_list_json_conflicts_with_names() { + let result = Cli::try_parse_from(["openshell", "sandbox", "list", "-o", "json", "--names"]); + assert!(result.is_err(), "--names and -o json should conflict"); + } + #[test] fn provider_create_accepts_custom_profile_type_ids() { let cli = Cli::try_parse_from([ diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 198cb4b0a..30025a14a 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -3045,6 +3045,7 @@ fn print_sandbox_policy(policy: &SandboxPolicy) { } /// List sandboxes. +#[allow(clippy::too_many_arguments)] pub async fn sandbox_list( server: &str, limit: u32, @@ -3052,6 +3053,7 @@ pub async fn sandbox_list( ids_only: bool, names_only: bool, label_selector: Option<&str>, + output: &str, tls: &TlsOptions, ) -> Result<()> { let mut client = grpc_client(server, tls).await?; @@ -3066,6 +3068,25 @@ pub async fn sandbox_list( .into_diagnostic()?; let sandboxes = response.into_inner().sandboxes; + + match output { + "json" => { + let items: Vec = sandboxes.iter().map(sandbox_to_json).collect(); + println!( + "{}", + serde_json::to_string_pretty(&items).into_diagnostic()? + ); + return Ok(()); + } + "yaml" => { + let items: Vec = sandboxes.iter().map(sandbox_to_json).collect(); + print!("{}", serde_yml::to_string(&items).into_diagnostic()?); + return Ok(()); + } + "table" => {} + _ => return Err(miette!("unsupported output format: {output}")), + } + if sandboxes.is_empty() { if !ids_only && !names_only { println!("No sandboxes found."); @@ -3126,6 +3147,19 @@ pub async fn sandbox_list( Ok(()) } +fn sandbox_to_json(sandbox: &Sandbox) -> serde_json::Value { + let meta = sandbox.metadata.as_ref(); + let labels = meta.map_or_else(|| serde_json::json!({}), |m| serde_json::json!(m.labels)); + serde_json::json!({ + "id": sandbox.object_id(), + "name": sandbox.object_name(), + "labels": labels, + "created_at": format_epoch_ms(meta.map_or(0, |m| m.created_at_ms)), + "phase": phase_name(sandbox.phase), + "current_policy_version": sandbox.current_policy_version, + }) +} + pub async fn sandbox_provider_list(server: &str, name: &str, tls: &TlsOptions) -> Result<()> { let mut client = grpc_client(server, tls).await?; let response = client diff --git a/docs/sandboxes/manage-sandboxes.mdx b/docs/sandboxes/manage-sandboxes.mdx index d8752c261..a62f57bf5 100644 --- a/docs/sandboxes/manage-sandboxes.mdx +++ b/docs/sandboxes/manage-sandboxes.mdx @@ -211,6 +211,13 @@ Filter the list by labels when you want a narrower view: openshell sandbox list --selector team=platform ``` +Use `-o json` or `-o yaml` for machine-readable output: + +```shell +openshell sandbox list -o json +openshell sandbox list -o yaml +``` + Get detailed information about a specific sandbox. The output lists **Policy source** (`sandbox` or `global`), **Revision** (the active policy’s row version for that source), and the formatted active policy YAML: ```shell