use std::process::ExitCode;
use anyhow::Result;
use astrid_core::kernel_api::{AdminRequestKind, AdminResponseBody, GroupSummary};
use clap::{Args, Subcommand};
use colored::Colorize;
use serde::Serialize;
use crate::admin_client::into_result;
use crate::theme::Theme;
use crate::value_formatter::{ValueFormat, emit_structured};
#[derive(Subcommand, Debug, Clone)]
pub(crate) enum GroupCommand {
Create(CreateArgs),
Show(ShowArgs),
List(ListArgs),
Delete(DeleteArgs),
Modify(ModifyArgs),
}
#[derive(Args, Debug, Clone)]
pub(crate) struct CreateArgs {
pub name: String,
#[arg(long = "caps", value_name = "CAP1,CAP2", value_delimiter = ',')]
pub caps: Vec<String>,
#[arg(long)]
pub description: Option<String>,
#[arg(long = "unsafe-admin")]
pub unsafe_admin: bool,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct ShowArgs {
pub name: String,
#[arg(long, default_value = "pretty")]
pub format: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct ListArgs {
#[arg(long, default_value = "pretty")]
pub format: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct DeleteArgs {
pub name: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct ModifyArgs {
pub name: String,
#[arg(long = "add-caps", value_name = "CAPS", value_delimiter = ',')]
pub add_caps: Vec<String>,
#[arg(long = "remove-caps", value_name = "CAPS", value_delimiter = ',')]
pub remove_caps: Vec<String>,
#[arg(long)]
pub description: Option<String>,
#[arg(long, hide = true)]
pub rename: Option<String>,
}
pub(crate) async fn run(cmd: GroupCommand) -> Result<ExitCode> {
match cmd {
GroupCommand::Create(args) => run_create(args).await,
GroupCommand::Show(args) => run_show(args).await,
GroupCommand::List(args) => run_list(args).await,
GroupCommand::Delete(args) => run_delete(args).await,
GroupCommand::Modify(args) => run_modify(args).await,
}
}
async fn fetch_groups() -> Result<Vec<GroupSummary>> {
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client.request(AdminRequestKind::GroupList).await?;
let body = into_result(body)?;
match body {
AdminResponseBody::GroupList(list) => Ok(list),
other => anyhow::bail!("unexpected response from kernel: {other:?}"),
}
}
async fn run_create(args: CreateArgs) -> Result<ExitCode> {
if args.caps.is_empty() {
eprintln!("astrid: --caps is required (use comma-separated values)");
return Ok(ExitCode::from(1));
}
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client
.request(AdminRequestKind::GroupCreate {
name: args.name.clone(),
capabilities: args.caps,
description: args.description,
unsafe_admin: args.unsafe_admin,
})
.await?;
let _ = into_result(body)?;
println!(
"{}",
Theme::success(&format!("Created group '{}'", args.name))
);
Ok(ExitCode::SUCCESS)
}
async fn run_show(args: ShowArgs) -> Result<ExitCode> {
let format = ValueFormat::parse(&args.format);
let groups = fetch_groups().await?;
let Some(group) = groups.into_iter().find(|g| g.name == args.name) else {
eprintln!(
"{}",
Theme::error(&format!("group '{}' not found", args.name))
);
return Ok(ExitCode::from(1));
};
if !format.is_pretty() {
emit_structured(&GroupRecord::from(group), format)?;
return Ok(ExitCode::SUCCESS);
}
print_group_detail(&group);
Ok(ExitCode::SUCCESS)
}
async fn run_list(args: ListArgs) -> Result<ExitCode> {
let format = ValueFormat::parse(&args.format);
let mut groups = fetch_groups().await?;
groups.sort_by(|a, b| a.name.cmp(&b.name));
if !format.is_pretty() {
let records: Vec<GroupRecord> = groups.into_iter().map(GroupRecord::from).collect();
emit_structured(&records, format)?;
return Ok(ExitCode::SUCCESS);
}
print_group_table(&groups);
Ok(ExitCode::SUCCESS)
}
async fn run_delete(args: DeleteArgs) -> Result<ExitCode> {
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client
.request(AdminRequestKind::GroupDelete {
name: args.name.clone(),
})
.await?;
let _ = into_result(body)?;
println!(
"{}",
Theme::success(&format!("Deleted group '{}'", args.name))
);
Ok(ExitCode::SUCCESS)
}
async fn run_modify(args: ModifyArgs) -> Result<ExitCode> {
if args.rename.is_some() {
eprintln!("astrid: --rename on `group modify` is deferred (Layer 6 has no rename).");
return Ok(ExitCode::from(2));
}
if args.add_caps.is_empty() && args.remove_caps.is_empty() && args.description.is_none() {
eprintln!("astrid: nothing to do (specify --add-caps, --remove-caps, or --description)");
return Ok(ExitCode::from(1));
}
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client.request(AdminRequestKind::GroupList).await?;
let body = into_result(body)?;
let groups = match body {
AdminResponseBody::GroupList(list) => list,
other => anyhow::bail!("unexpected response from kernel: {other:?}"),
};
let Some(group) = groups.into_iter().find(|g| g.name == args.name) else {
eprintln!(
"{}",
Theme::error(&format!("group '{}' not found", args.name))
);
return Ok(ExitCode::from(1));
};
let mut caps = group.capabilities.clone();
for c in &args.remove_caps {
caps.retain(|x| x != c);
}
for c in &args.add_caps {
if !caps.contains(c) {
caps.push(c.clone());
}
}
let description_change = args
.description
.map(|d| if d.is_empty() { None } else { Some(d) });
let body = client
.request(AdminRequestKind::GroupModify {
name: args.name.clone(),
capabilities: Some(caps),
description: description_change,
unsafe_admin: None,
})
.await?;
let _ = into_result(body)?;
println!(
"{}",
Theme::success(&format!("Modified group '{}'", args.name))
);
Ok(ExitCode::SUCCESS)
}
fn print_group_table(groups: &[GroupSummary]) {
if groups.is_empty() {
println!("{}", Theme::info("No groups."));
return;
}
println!(
"{:<16} {:<10} {}",
"GROUP".bold(),
"TYPE".bold(),
"CAPABILITIES".bold()
);
for g in groups {
let kind = if g.builtin {
"built-in".dimmed()
} else {
"custom".green()
};
let caps_summary = if g.capabilities.is_empty() {
"(none)".dimmed().to_string()
} else if g.capabilities.len() <= 3 {
g.capabilities.join(",")
} else {
format!(
"{}, ...({} total)",
g.capabilities[..3].join(","),
g.capabilities.len()
)
};
println!("{:<16} {kind:<10} {caps_summary}", g.name);
}
}
fn print_group_detail(group: &GroupSummary) {
println!(
"{} {} {}",
"Group".bold(),
group.name.cyan(),
if group.builtin {
"(built-in)".dimmed().to_string()
} else {
"(custom)".green().to_string()
}
);
if let Some(desc) = group.description.as_deref() {
println!(" Description: {desc}");
}
if group.unsafe_admin {
println!(" {}", "Holds the universal `*` capability".yellow().bold());
}
println!(" Capabilities:");
for cap in &group.capabilities {
println!(" - {cap}");
}
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct GroupRecord {
pub name: String,
pub capabilities: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub unsafe_admin: bool,
pub builtin: bool,
}
impl From<GroupSummary> for GroupRecord {
fn from(s: GroupSummary) -> Self {
Self {
name: s.name,
capabilities: s.capabilities,
description: s.description,
unsafe_admin: s.unsafe_admin,
builtin: s.builtin,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn record_round_trips_to_json() {
let summary = GroupSummary {
name: "ops".into(),
capabilities: vec!["agent:create".into(), "agent:delete".into()],
description: Some("Operators".into()),
unsafe_admin: false,
builtin: false,
};
let rec: GroupRecord = summary.into();
let json = serde_json::to_string(&rec).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["name"], "ops");
assert_eq!(parsed["builtin"], false);
assert_eq!(parsed["capabilities"][1], "agent:delete");
}
}