use std::process::ExitCode;
use anyhow::{Context, Result};
use astrid_core::PrincipalId;
use astrid_core::kernel_api::{AdminRequestKind, AdminResponseBody, AgentSummary};
use clap::{Args, Subcommand};
use colored::Colorize;
use serde::Serialize;
use crate::admin_client::{AdminClient, into_result};
use crate::commands::stub::{self, ISSUE_DELEGATION, ISSUE_REMOTE_AUTH};
use crate::context;
use crate::theme::Theme;
use crate::value_formatter::{ValueFormat, emit_structured};
#[derive(Subcommand, Debug, Clone)]
#[allow(
clippy::large_enum_variant,
reason = "clap subcommand enum, constructed once per process"
)]
pub(crate) enum AgentCommand {
Create(CreateArgs),
List(ListArgs),
Current,
Switch(SwitchArgs),
Show(ShowArgs),
Delete(DeleteArgs),
Enable(EnableArgs),
Disable(DisableArgs),
Modify(ModifyArgs),
Link(LinkArgs),
Unlink(UnlinkArgs),
Discover(StubArgs),
Add(StubArgs),
Card(StubArgs),
Export(StubArgs),
Import(StubArgs),
Delegate(StubArgs),
}
#[derive(Args, Debug, Clone)]
pub(crate) struct CreateArgs {
pub name: String,
#[arg(short, long, default_value = "astralis")]
pub distro: String,
#[arg(long)]
pub bare: bool,
#[arg(long = "group", value_name = "NAME")]
pub groups: Vec<String>,
#[arg(long, value_name = "DOMAINS")]
pub egress: Option<String>,
#[arg(long = "process-allow", value_name = "CMDS")]
pub process_allow: Option<String>,
#[arg(long = "link", value_name = "PLATFORM:ID")]
pub link: Option<String>,
#[arg(long, value_name = "SIZE")]
pub memory: Option<String>,
#[arg(long, value_name = "DURATION")]
pub timeout: Option<String>,
#[arg(long, value_name = "SIZE")]
pub storage: Option<String>,
#[arg(long, value_name = "N")]
pub processes: Option<u32>,
#[arg(short = 'y', long)]
pub yes: bool,
#[arg(long = "spawned-by", value_name = "AGENT", hide = true)]
pub spawned_by: Option<String>,
#[arg(long = "budget-voucher", value_name = "AMOUNT", hide = true)]
pub budget_voucher: Option<String>,
#[arg(long = "grant-access", value_name = "PATTERN", hide = true)]
pub grant_access: Option<String>,
#[arg(long = "expires", value_name = "DURATION", hide = true)]
pub expires: Option<String>,
#[arg(long = "budget", value_name = "AMOUNT", hide = true)]
pub budget: Option<String>,
#[arg(long = "period", value_name = "monthly|weekly|none", hide = true)]
pub period: Option<String>,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct ListArgs {
#[arg(long, hide = true)]
pub remote: bool,
#[arg(long = "group", value_name = "NAME")]
pub group: Option<String>,
#[arg(long, hide = true)]
pub tree: bool,
#[arg(long, default_value = "pretty")]
pub format: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct SwitchArgs {
pub name: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct ShowArgs {
pub name: Option<String>,
#[arg(long, default_value = "pretty")]
pub format: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct DeleteArgs {
pub name: String,
#[arg(short = 'y', long)]
pub yes: bool,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct EnableArgs {
pub name: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct DisableArgs {
pub name: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct ModifyArgs {
pub name: String,
#[arg(long = "add-group", value_name = "NAME")]
pub add_group: Vec<String>,
#[arg(long = "remove-group", value_name = "NAME")]
pub remove_group: Vec<String>,
#[arg(long, value_name = "NEW-NAME", hide = true)]
pub rename: Option<String>,
#[arg(long, value_name = "DOMAINS", hide = true)]
pub egress: Option<String>,
#[arg(long = "process-allow", value_name = "CMDS", hide = true)]
pub process_allow: Option<String>,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct LinkArgs {
pub name: String,
pub binding: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct UnlinkArgs {
pub name: String,
pub binding: String,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct StubArgs {
#[arg(allow_hyphen_values = true, trailing_var_arg = true)]
pub args: Vec<String>,
}
pub(crate) async fn run(cmd: AgentCommand) -> Result<ExitCode> {
match cmd {
AgentCommand::Create(args) => run_create(args).await,
AgentCommand::List(args) => run_list(args).await,
AgentCommand::Current => run_current(),
AgentCommand::Switch(args) => run_switch(args).await,
AgentCommand::Show(args) => run_show(args).await,
AgentCommand::Delete(args) => run_delete(args).await,
AgentCommand::Enable(args) => run_enable(args).await,
AgentCommand::Disable(args) => run_disable(args).await,
AgentCommand::Modify(args) => run_modify(args).await,
AgentCommand::Link(args) => Ok(run_link(args)),
AgentCommand::Unlink(args) => Ok(run_unlink(args)),
AgentCommand::Discover(_)
| AgentCommand::Add(_)
| AgentCommand::Card(_)
| AgentCommand::Export(_)
| AgentCommand::Import(_) => Ok(stub::deferred(
"remote agent / Agent Card management",
&[ISSUE_DELEGATION, ISSUE_REMOTE_AUTH],
)),
AgentCommand::Delegate(_) => Ok(stub::deferred("agent delegation", &[ISSUE_DELEGATION])),
}
}
fn create_uses_deferred_flags(args: &CreateArgs) -> bool {
args.spawned_by.is_some()
|| args.budget_voucher.is_some()
|| args.grant_access.is_some()
|| args.expires.is_some()
|| args.budget.is_some()
|| args.period.is_some()
}
async fn run_create(mut args: CreateArgs) -> Result<ExitCode> {
if create_uses_deferred_flags(&args) {
return Ok(stub::deferred(
"agent create with delegation/budget flags",
&[ISSUE_DELEGATION],
));
}
if let Some(exit) = check_unshipped_provisioning_flags(&args) {
return Ok(exit);
}
let principal = PrincipalId::new(&args.name).context("invalid agent name")?;
let quota_updates = parse_quota_flags(&args)?;
let caps_to_grant = build_caps_to_grant(&args)?;
let groups = std::mem::take(&mut args.groups);
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client
.request(AdminRequestKind::AgentCreate {
name: args.name.clone(),
groups,
grants: caps_to_grant,
})
.await?;
let _ = into_result(body)?;
println!(
"{}",
Theme::success(&format!("Created agent '{}'", args.name))
);
if !quota_updates.is_empty() {
apply_initial_quotas(&mut client, &principal, "a_updates)
.await
.with_context(|| {
format!(
"agent '{}' created, but failed to apply quotas — re-run `astrid quota set -a {} ...` to retry",
args.name, args.name
)
})?;
}
Ok(ExitCode::SUCCESS)
}
fn check_unshipped_provisioning_flags(args: &CreateArgs) -> Option<ExitCode> {
if args.bare {
eprintln!(
"astrid: --bare needs a distro management IPC that has not shipped. Track in #657."
);
return Some(ExitCode::from(2));
}
if args.distro != "astralis" {
eprintln!(
"astrid: per-agent --distro pinning needs distro management IPC that has not \
shipped. Track in #657."
);
return Some(ExitCode::from(2));
}
if args.link.is_some() {
eprintln!(
"astrid: --link needs an admin.agent.link IPC that has not shipped. Track in #657."
);
return Some(ExitCode::from(2));
}
None
}
fn parse_quota_flags(args: &CreateArgs) -> Result<Vec<QuotaField>> {
let mut updates: Vec<QuotaField> = Vec::new();
if let Some(s) = args.memory.as_deref() {
let bytes = crate::commands::quota::parse_bytes(s).context("invalid --memory")?;
updates.push(QuotaField::Memory(bytes));
}
if let Some(s) = args.timeout.as_deref() {
let secs = crate::commands::quota::parse_duration(s)
.context("invalid --timeout")?
.as_secs()
.max(1);
updates.push(QuotaField::Timeout(secs));
}
if let Some(s) = args.storage.as_deref() {
let bytes = crate::commands::quota::parse_bytes(s).context("invalid --storage")?;
updates.push(QuotaField::Storage(bytes));
}
if let Some(n) = args.processes {
updates.push(QuotaField::Processes(n));
}
Ok(updates)
}
fn build_caps_to_grant(args: &CreateArgs) -> Result<Vec<String>> {
let mut caps: Vec<String> = Vec::new();
if let Some(domains) = args.egress.as_deref() {
for entry in domains.split(',').map(str::trim).filter(|s| !s.is_empty()) {
let cap = format!("network:egress:{entry}");
astrid_core::capability_grammar::validate_capability(&cap)
.map_err(|e| anyhow::anyhow!("invalid --egress entry {entry:?}: {e}"))?;
caps.push(cap);
}
}
if let Some(cmds) = args.process_allow.as_deref() {
for entry in cmds.split(',').map(str::trim).filter(|s| !s.is_empty()) {
let cap = format!("process:spawn:{entry}");
astrid_core::capability_grammar::validate_capability(&cap)
.map_err(|e| anyhow::anyhow!("invalid --process-allow entry {entry:?}: {e}"))?;
caps.push(cap);
}
}
Ok(caps)
}
async fn apply_initial_quotas(
client: &mut AdminClient,
principal: &PrincipalId,
updates: &[QuotaField],
) -> Result<()> {
let body = client
.request(AdminRequestKind::QuotaGet {
principal: principal.clone(),
})
.await?;
let body = into_result(body)?;
let mut quotas = match body {
AdminResponseBody::Quotas(q) => q,
other => anyhow::bail!("unexpected response from kernel: {other:?}"),
};
for field in updates {
match field {
QuotaField::Memory(b) => quotas.max_memory_bytes = *b,
QuotaField::Timeout(s) => quotas.max_timeout_secs = *s,
QuotaField::Storage(b) => quotas.max_storage_bytes = *b,
QuotaField::Processes(n) => quotas.max_background_processes = *n,
}
}
let body = client
.request(AdminRequestKind::QuotaSet {
principal: principal.clone(),
quotas,
})
.await?;
let _ = into_result(body)?;
println!(
" {}",
Theme::info(&format!(
"Quotas set ({} field{})",
updates.len(),
if updates.len() == 1 { "" } else { "s" }
))
);
Ok(())
}
enum QuotaField {
Memory(u64),
Timeout(u64),
Storage(u64),
Processes(u32),
}
async fn run_list(args: ListArgs) -> Result<ExitCode> {
if args.remote {
return Ok(stub::deferred(
"agent list --remote",
&[ISSUE_DELEGATION, ISSUE_REMOTE_AUTH],
));
}
if args.tree {
return Ok(stub::deferred(
"agent list --tree (delegation hierarchy)",
&[ISSUE_DELEGATION],
));
}
let format = ValueFormat::parse(&args.format);
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client.request(AdminRequestKind::AgentList).await?;
let body = into_result(body)?;
let mut agents = match body {
AdminResponseBody::AgentList(list) => list,
other => anyhow::bail!("unexpected response from kernel: {other:?}"),
};
agents.sort_by(|a, b| a.principal.as_str().cmp(b.principal.as_str()));
if let Some(group) = args.group.as_deref() {
agents.retain(|a| a.groups.iter().any(|g| g == group));
}
if !format.is_pretty() {
emit_structured(&agents, format)?;
return Ok(ExitCode::SUCCESS);
}
print_agent_table(&agents);
Ok(ExitCode::SUCCESS)
}
fn print_agent_table(agents: &[AgentSummary]) {
if agents.is_empty() {
println!("{}", Theme::info("No agents."));
return;
}
println!(
"{:<24} {:<10} {}",
"AGENT".bold(),
"STATE".bold(),
"GROUPS".bold()
);
for agent in agents {
let state = if agent.enabled {
"enabled".green()
} else {
"disabled".yellow()
};
let groups = if agent.groups.is_empty() {
"—".to_string()
} else {
agent.groups.join(",")
};
println!(
"{:<24} {:<10} {}",
agent.principal.as_str(),
state,
groups
);
}
}
fn run_current() -> Result<ExitCode> {
let agent = context::active_agent()?;
println!("{}", agent.as_str());
Ok(ExitCode::SUCCESS)
}
async fn run_switch(args: SwitchArgs) -> Result<ExitCode> {
let principal = PrincipalId::new(&args.name).context("invalid agent name")?;
if let Ok(mut client) = crate::admin_client::connect_as_active_agent().await
&& let Ok(body) = client.request(AdminRequestKind::AgentList).await
&& let AdminResponseBody::AgentList(list) = body
&& !list.iter().any(|a| a.principal == principal)
{
eprintln!(
"{}",
Theme::warning(&format!(
"agent '{}' not found on this host (context still set)",
args.name
))
);
}
context::set_active_agent(&principal)?;
println!(
"{}",
Theme::success(&format!("Active agent set to '{principal}'"))
);
Ok(ExitCode::SUCCESS)
}
async fn run_show(args: ShowArgs) -> Result<ExitCode> {
let target = context::resolve_agent(args.name.as_deref())?;
let format = ValueFormat::parse(&args.format);
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client.request(AdminRequestKind::AgentList).await?;
let body = into_result(body)?;
let agents = match body {
AdminResponseBody::AgentList(list) => list,
other => anyhow::bail!("unexpected response from kernel: {other:?}"),
};
let Some(agent) = agents.into_iter().find(|a| a.principal == target) else {
eprintln!("{}", Theme::error(&format!("agent '{target}' not found")));
return Ok(ExitCode::from(1));
};
if !format.is_pretty() {
emit_structured(&agent, format)?;
return Ok(ExitCode::SUCCESS);
}
print_agent_detail(&agent);
Ok(ExitCode::SUCCESS)
}
fn print_agent_detail(agent: &AgentSummary) {
println!("{}", "Agent".bold());
println!(" Principal: {}", agent.principal.as_str());
println!(
" Enabled: {}",
if agent.enabled {
"yes".green()
} else {
"no".yellow()
}
);
println!(
" Groups: {}",
if agent.groups.is_empty() {
"(none)".dimmed().to_string()
} else {
agent.groups.join(", ")
}
);
if !agent.grants.is_empty() {
println!(" Grants:");
for cap in &agent.grants {
println!(" + {cap}");
}
}
if !agent.revokes.is_empty() {
println!(" Revokes:");
for cap in &agent.revokes {
println!(" - {cap}");
}
}
}
async fn run_delete(args: DeleteArgs) -> Result<ExitCode> {
let principal = PrincipalId::new(&args.name).context("invalid agent name")?;
if !args.yes {
eprint!("Delete agent '{principal}' (home directory is NOT removed) [y/N]? ");
std::io::Write::flush(&mut std::io::stderr()).ok();
let mut buf = String::new();
std::io::stdin().read_line(&mut buf).ok();
if !matches!(buf.trim().to_ascii_lowercase().as_str(), "y" | "yes") {
eprintln!("aborted.");
return Ok(ExitCode::from(1));
}
}
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client
.request(AdminRequestKind::AgentDelete { principal })
.await?;
let _ = into_result(body)?;
println!(
"{}",
Theme::success(&format!("Deleted agent '{}'", args.name))
);
Ok(ExitCode::SUCCESS)
}
async fn run_enable(args: EnableArgs) -> Result<ExitCode> {
let principal = PrincipalId::new(&args.name).context("invalid agent name")?;
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client
.request(AdminRequestKind::AgentEnable { principal })
.await?;
let _ = into_result(body)?;
println!(
"{}",
Theme::success(&format!("Enabled agent '{}'", args.name))
);
Ok(ExitCode::SUCCESS)
}
async fn run_disable(args: DisableArgs) -> Result<ExitCode> {
let principal = PrincipalId::new(&args.name).context("invalid agent name")?;
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client
.request(AdminRequestKind::AgentDisable { principal })
.await?;
let _ = into_result(body)?;
println!(
"{}",
Theme::success(&format!("Disabled agent '{}'", args.name))
);
Ok(ExitCode::SUCCESS)
}
async fn run_modify(args: ModifyArgs) -> Result<ExitCode> {
if args.rename.is_some() || args.egress.is_some() || args.process_allow.is_some() {
eprintln!(
"astrid: --rename, --egress, --process-allow on `agent modify` need kernel-side IPC that has not shipped."
);
eprintln!(" Use `astrid caps grant <agent> network:egress:<domain>` for egress changes.");
eprintln!(" Tracking issue #657 (CLI redesign) coordinates the rollout.");
return Ok(ExitCode::from(2));
}
let principal = PrincipalId::new(&args.name).context("invalid agent name")?;
if args.add_group.is_empty() && args.remove_group.is_empty() {
eprintln!("astrid: nothing to do (specify --add-group or --remove-group)");
return Ok(ExitCode::from(1));
}
let mut client = crate::admin_client::connect_as_active_agent().await?;
let body = client
.request(AdminRequestKind::AgentModify {
principal: principal.clone(),
add_groups: args.add_group.clone(),
remove_groups: args.remove_group.clone(),
})
.await?;
let body = into_result(body)?;
let value = match body {
AdminResponseBody::Success(v) => v,
other => anyhow::bail!("unexpected response from kernel: {other:?}"),
};
let groups: Vec<String> = value
.get("groups")
.and_then(|g| g.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let changed = value
.get("changed")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if changed {
println!(
"{}",
Theme::success(&format!(
"Updated agent '{principal}' groups: [{}]",
groups.join(", ")
))
);
} else {
println!(
"{}",
Theme::info(&format!(
"agent '{principal}' already in the requested groups (no change)"
))
);
}
Ok(ExitCode::SUCCESS)
}
fn run_link(_args: LinkArgs) -> ExitCode {
eprintln!(
"astrid: agent identity linking needs `admin.agent.link` IPC that has not shipped yet."
);
eprintln!(
" Tracking issue #657 — CLI redesign followup. The identity store API exists; only the admin topic is missing."
);
ExitCode::from(2)
}
fn run_unlink(_args: UnlinkArgs) -> ExitCode {
eprintln!(
"astrid: agent identity unlinking needs `admin.agent.unlink` IPC that has not shipped yet."
);
eprintln!(" Tracking issue #657 — CLI redesign followup.");
ExitCode::from(2)
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct AgentRecord {
pub principal: String,
pub enabled: bool,
pub groups: Vec<String>,
}
impl From<AgentSummary> for AgentRecord {
fn from(s: AgentSummary) -> Self {
Self {
principal: s.principal.to_string(),
enabled: s.enabled,
groups: s.groups,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn create_with_spawned_by_is_deferred() {
let args = CreateArgs {
name: "x".into(),
distro: "astralis".into(),
bare: false,
groups: vec![],
egress: None,
process_allow: None,
link: None,
memory: None,
timeout: None,
storage: None,
processes: None,
yes: true,
spawned_by: Some("parent".into()),
budget_voucher: None,
grant_access: None,
expires: None,
budget: None,
period: None,
};
assert!(create_uses_deferred_flags(&args));
}
#[test]
fn vanilla_create_is_not_deferred() {
let args = CreateArgs {
name: "x".into(),
distro: "astralis".into(),
bare: false,
groups: vec![],
egress: None,
process_allow: None,
link: None,
memory: None,
timeout: None,
storage: None,
processes: None,
yes: true,
spawned_by: None,
budget_voucher: None,
grant_access: None,
expires: None,
budget: None,
period: None,
};
assert!(!create_uses_deferred_flags(&args));
}
#[test]
fn agent_record_roundtrips_through_json() {
let summary = AgentSummary {
principal: PrincipalId::new("alice").unwrap(),
enabled: true,
groups: vec!["agent".into()],
grants: vec![],
revokes: vec![],
};
let rec: AgentRecord = summary.into();
let json = serde_json::to_string(&rec).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["principal"], "alice");
assert_eq!(parsed["enabled"], true);
}
}