use std::process::ExitCode;
use std::time::Duration;
use anyhow::Result;
use uuid::Uuid;
use astrid_core::kernel_api::{CommandInfo, CommandKind};
use crate::commands::daemon;
use crate::socket_client::SocketClient;
use crate::theme::Theme;
const RESULT_TIMEOUT_SECS: u64 = 70;
const RESULT_TIMEOUT: Duration = Duration::from_secs(RESULT_TIMEOUT_SECS);
const CAPSULES_LOADED_TOPIC: &str = "astrid.v1.capsules_loaded";
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum VerbMatch {
One {
provider: String,
description: String,
},
None,
Ambiguous {
providers: Vec<String>,
},
}
pub(crate) fn match_verb(verb: &str, commands: &[CommandInfo]) -> VerbMatch {
let providers: Vec<&CommandInfo> = commands
.iter()
.filter(|c| c.kind == CommandKind::Cli && c.name == verb)
.collect();
match providers.as_slice() {
[] => VerbMatch::None,
[only] => VerbMatch::One {
provider: only.provider_capsule.clone(),
description: only.description.clone(),
},
many => VerbMatch::Ambiguous {
providers: many.iter().map(|c| c.provider_capsule.clone()).collect(),
},
}
}
pub(crate) async fn run_external(tokens: Vec<String>) -> Result<ExitCode> {
let mut it = tokens.into_iter();
let Some(verb) = it.next() else {
eprintln!("{}", Theme::error("No capsule verb given."));
return Ok(ExitCode::from(1));
};
let args: Vec<String> = it.collect();
let commands = match resolve_commands().await {
Ok(c) => c,
Err(e) => {
eprintln!("{}", Theme::error(&format!("{e}")));
return Ok(ExitCode::from(1));
},
};
match match_verb(&verb, &commands) {
VerbMatch::One { provider, .. } => execute(&provider, &verb, &args).await,
VerbMatch::None => {
eprintln!(
"{}",
Theme::error(&format!("Unknown capsule command: '{verb}'"))
);
print_available(&commands);
Ok(ExitCode::from(1))
},
VerbMatch::Ambiguous { providers } => {
eprintln!(
"{}",
Theme::error(&format!(
"Multiple capsules provide '{verb}': {}",
providers.join(", ")
))
);
eprintln!("Disambiguate with one of:");
for p in &providers {
eprintln!(" astrid capsule run {p} {verb}");
}
Ok(ExitCode::from(1))
},
}
}
pub(crate) async fn run_explicit(
provider: String,
verb: String,
args: Vec<String>,
) -> Result<ExitCode> {
let commands = match resolve_commands().await {
Ok(c) => c,
Err(e) => {
eprintln!("{}", Theme::error(&format!("{e}")));
return Ok(ExitCode::from(1));
},
};
let exists = commands
.iter()
.any(|c| c.kind == CommandKind::Cli && c.name == verb && c.provider_capsule == provider);
if !exists {
eprintln!(
"{}",
Theme::error(&format!(
"Capsule '{provider}' does not provide CLI verb '{verb}'."
))
);
print_available(&commands);
return Ok(ExitCode::from(1));
}
execute(&provider, &verb, &args).await
}
async fn resolve_commands() -> Result<Vec<CommandInfo>> {
daemon::ensure_daemon("capsule").await?;
let session = astrid_core::SessionId::from_uuid(Uuid::new_v4());
let source_id = session.0;
let caller = crate::principal::current();
let mut client = crate::socket_client::connect_for_workspace(session, caller.clone(), None)
.await
.map_err(|e| anyhow::anyhow!("Failed to connect to daemon: {e}"))?;
let req = astrid_core::kernel_api::KernelRequest::GetCommands;
let val = serde_json::to_value(req)?;
let msg = astrid_types::ipc::IpcMessage::new(
astrid_types::Topic::kernel_request("get_commands"),
astrid_types::ipc::IpcPayload::RawJson(val),
source_id,
)
.with_principal(caller.to_string());
client.send_message(msg).await?;
let raw = client
.read_until_topic(
astrid_types::Topic::kernel_response("get_commands").as_str(),
Duration::from_secs(10),
)
.await?;
match SocketClient::extract_kernel_response(&raw) {
Some(astrid_core::kernel_api::KernelResponse::Commands(cmds)) => Ok(cmds),
Some(astrid_core::kernel_api::KernelResponse::Error(err)) => {
anyhow::bail!("Daemon error: {err}")
},
_ => anyhow::bail!("Daemon returned an unexpected response to GetCommands"),
}
}
async fn execute(provider: &str, verb: &str, args: &[String]) -> Result<ExitCode> {
let session = astrid_core::SessionId::from_uuid(Uuid::new_v4());
let source_id = session.0;
let caller = crate::principal::current();
let mut client =
match crate::socket_client::connect_for_workspace(session, caller.clone(), None).await {
Ok(c) => c,
Err(e) => {
eprintln!(
"{}",
Theme::error(&format!("Failed to connect to daemon: {e}"))
);
return Ok(ExitCode::from(1));
},
};
let req_id = Uuid::new_v4().simple().to_string();
let body = serde_json::json!({
"req_id": req_id,
"command": verb,
"args": args,
});
let run_topic = astrid_types::Topic::cli_command_run(provider);
let result_topic = astrid_types::Topic::cli_command_result(&req_id);
let msg = astrid_types::ipc::IpcMessage::new(
run_topic,
astrid_types::ipc::IpcPayload::RawJson(body),
source_id,
)
.with_principal(caller.to_string());
if let Err(e) = client.send_message(msg).await {
eprintln!(
"{}",
Theme::error(&format!("Failed to send command to '{provider}': {e}"))
);
return Ok(ExitCode::from(1));
}
match wait_for_command_result(
&mut client,
result_topic.as_str(),
provider,
caller.as_str(),
RESULT_TIMEOUT,
)
.await
{
Ok(CommandWait::Result(raw)) => Ok(render_result(provider, &raw)),
Ok(CommandWait::ProviderUnloaded) => {
eprintln!(
"{}",
Theme::error(&format!(
"Capsule '{provider}' unloaded before command completed; command cancelled."
))
);
Ok(ExitCode::from(1))
},
Err(_) => {
eprintln!(
"{}",
Theme::error(&format!(
"Capsule '{provider}' did not respond within {RESULT_TIMEOUT_SECS}s."
))
);
Ok(ExitCode::from(1))
},
}
}
enum CommandWait {
Result(serde_json::Value),
ProviderUnloaded,
}
async fn wait_for_command_result(
client: &mut SocketClient,
result_topic: &str,
provider: &str,
principal: &str,
timeout: Duration,
) -> Result<CommandWait> {
let deadline = tokio::time::Instant::now()
.checked_add(timeout)
.unwrap_or_else(tokio::time::Instant::now);
loop {
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
if remaining.is_zero() {
anyhow::bail!("timed out waiting for capsule command result");
}
let read = tokio::time::timeout(remaining, client.read_raw_frame()).await;
let frame = match read {
Ok(Ok(Some(bytes))) => bytes,
Ok(Ok(None)) => anyhow::bail!("daemon connection closed before command result"),
Ok(Err(err)) => return Err(err),
Err(_) => anyhow::bail!("timed out waiting for capsule command result"),
};
let Ok(raw) = serde_json::from_slice::<serde_json::Value>(&frame) else {
continue;
};
let topic = raw.get("topic").and_then(serde_json::Value::as_str);
if topic == Some(result_topic) {
return Ok(CommandWait::Result(raw));
}
if topic == Some(CAPSULES_LOADED_TOPIC)
&& capsules_loaded_missing_provider(&raw, provider, principal)
{
return Ok(CommandWait::ProviderUnloaded);
}
}
}
fn capsules_loaded_missing_provider(
raw: &serde_json::Value,
provider: &str,
principal: &str,
) -> bool {
if raw_frame_principal(raw).is_some_and(|frame_principal| frame_principal != principal) {
return false;
}
let Some(capsules) = capsules_loaded_capsules(raw) else {
return false;
};
let principal_aware = capsules
.iter()
.any(|capsule| capsule_entry_principal(capsule).is_some());
!capsules
.iter()
.filter(|capsule| !principal_aware || capsule_entry_matches_principal(capsule, principal))
.any(|capsule| capsule_entry_name(capsule).is_some_and(|name| name == provider))
}
fn raw_frame_principal(raw: &serde_json::Value) -> Option<&str> {
raw.get("principal").and_then(serde_json::Value::as_str)
}
fn capsule_entry_principal(capsule: &serde_json::Value) -> Option<&str> {
capsule.get("principal").and_then(serde_json::Value::as_str)
}
fn capsule_entry_name(capsule: &serde_json::Value) -> Option<&str> {
capsule.get("name").and_then(serde_json::Value::as_str)
}
fn capsule_entry_matches_principal(capsule: &serde_json::Value, principal: &str) -> bool {
capsule_entry_principal(capsule).is_some_and(|entry_principal| entry_principal == principal)
}
fn capsules_loaded_capsules(raw: &serde_json::Value) -> Option<&Vec<serde_json::Value>> {
let payload = raw.get("payload")?;
payload
.get("capsules")
.and_then(serde_json::Value::as_array)
.or_else(|| {
payload
.get("value")
.and_then(|value| value.get("capsules"))
.and_then(serde_json::Value::as_array)
})
}
fn render_result(provider: &str, raw: &serde_json::Value) -> ExitCode {
let Some(payload) = raw.get("payload") else {
eprintln!(
"{}",
Theme::error(&format!("Malformed result from '{provider}': no payload"))
);
return ExitCode::from(1);
};
let Some(exit_code) = payload.get("exit_code").and_then(serde_json::Value::as_i64) else {
eprintln!(
"{}",
Theme::error(&format!(
"Malformed result from '{provider}': missing/invalid exit_code"
))
);
return ExitCode::from(1);
};
if let Some(output) = payload.get("output").and_then(serde_json::Value::as_str)
&& !output.is_empty()
{
use std::io::Write;
print!("{output}");
let _ = std::io::stdout().flush();
}
if let Some(error) = payload.get("error").and_then(serde_json::Value::as_str)
&& !error.is_empty()
{
eprintln!("{error}");
}
let code = u8::try_from(exit_code).unwrap_or(1);
ExitCode::from(code)
}
fn print_available(commands: &[CommandInfo]) {
let verbs: Vec<&CommandInfo> = commands
.iter()
.filter(|c| c.kind == CommandKind::Cli)
.collect();
if verbs.is_empty() {
eprintln!("No capsule CLI commands are currently available.");
return;
}
eprintln!("Available capsule commands:");
for c in verbs {
eprintln!(
" {} — {} (provider: {})",
c.name, c.description, c.provider_capsule
);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cmd(name: &str, provider: &str, kind: CommandKind) -> CommandInfo {
CommandInfo {
name: name.to_string(),
description: format!("{name} desc"),
provider_capsule: provider.to_string(),
kind,
}
}
#[test]
fn match_verb_none_when_no_cli_provider() {
let cmds = vec![
cmd("deploy", "ops", CommandKind::Slash),
cmd("status", "ops", CommandKind::Cli),
];
assert_eq!(match_verb("deploy", &cmds), VerbMatch::None);
assert_eq!(match_verb("nope", &cmds), VerbMatch::None);
}
#[test]
fn match_verb_one_when_single_cli_provider() {
let cmds = vec![
cmd("status", "ops", CommandKind::Cli),
cmd("other", "misc", CommandKind::Cli),
];
assert_eq!(
match_verb("status", &cmds),
VerbMatch::One {
provider: "ops".to_string(),
description: "status desc".to_string(),
}
);
}
#[test]
fn match_verb_ambiguous_when_multiple_cli_providers() {
let cmds = vec![
cmd("deploy", "ops", CommandKind::Cli),
cmd("deploy", "infra", CommandKind::Cli),
cmd("deploy", "ui", CommandKind::Slash),
];
assert_eq!(
match_verb("deploy", &cmds),
VerbMatch::Ambiguous {
providers: vec!["ops".to_string(), "infra".to_string()],
}
);
}
#[test]
fn render_result_handles_valid_and_out_of_range_exit_codes() {
let frame = serde_json::json!({
"payload": { "req_id": "x", "exit_code": 5, "output": "" }
});
let _ = render_result("p", &frame);
let over = serde_json::json!({
"payload": { "req_id": "x", "exit_code": 99999, "output": "hi" }
});
let _ = render_result("p", &over);
let negative = serde_json::json!({
"payload": { "req_id": "x", "exit_code": -1, "output": "" }
});
let _ = render_result("p", &negative);
}
#[test]
fn render_result_handles_malformed_payloads() {
let no_code = serde_json::json!({ "payload": { "output": "hi" } });
let _ = render_result("p", &no_code);
let no_payload = serde_json::json!({ "topic": "x" });
let _ = render_result("p", &no_payload);
}
#[test]
fn capsules_loaded_missing_provider_detects_unload() {
let raw = serde_json::json!({
"topic": "astrid.v1.capsules_loaded",
"payload": {
"status": "ready",
"capsules": [
{ "name": "astrid-capsule-session", "meta": null }
]
}
});
assert!(capsules_loaded_missing_provider(
&raw,
"astrid-capsule-adversarial",
"alice"
));
assert!(!capsules_loaded_missing_provider(
&raw,
"astrid-capsule-session",
"alice"
));
}
#[test]
fn capsules_loaded_missing_provider_accepts_wrapped_raw_json() {
let raw = serde_json::json!({
"topic": "astrid.v1.capsules_loaded",
"payload": {
"type": "raw_json",
"value": {
"status": "ready",
"capsules": []
}
}
});
assert!(capsules_loaded_missing_provider(&raw, "provider", "alice"));
}
#[test]
fn capsules_loaded_missing_provider_ignores_unparseable_payload() {
let raw = serde_json::json!({
"topic": "astrid.v1.capsules_loaded",
"payload": { "status": "ready" }
});
assert!(!capsules_loaded_missing_provider(&raw, "provider", "alice"));
}
#[test]
fn capsules_loaded_missing_provider_filters_by_principal() {
let raw = serde_json::json!({
"topic": "astrid.v1.capsules_loaded",
"payload": {
"status": "ready",
"capsules": [
{
"principal": "bob",
"name": "astrid-capsule-adversarial",
"meta": null
}
]
}
});
assert!(
capsules_loaded_missing_provider(&raw, "astrid-capsule-adversarial", "alice"),
"alice's command should cancel when only bob still has the provider loaded"
);
assert!(
!capsules_loaded_missing_provider(&raw, "astrid-capsule-adversarial", "bob"),
"bob's command should not cancel while bob's provider remains loaded"
);
}
#[test]
fn capsules_loaded_missing_provider_ignores_other_frame_principal() {
let raw = serde_json::json!({
"topic": "astrid.v1.capsules_loaded",
"principal": "bob",
"payload": {
"status": "ready",
"capsules": []
}
});
assert!(
!capsules_loaded_missing_provider(&raw, "astrid-capsule-adversarial", "alice"),
"alice must not cancel on another principal's capsules_loaded frame"
);
}
#[test]
fn capsules_loaded_missing_provider_detects_current_principal_entry() {
let raw = serde_json::json!({
"topic": "astrid.v1.capsules_loaded",
"principal": "alice",
"payload": {
"status": "ready",
"capsules": [
{
"principal": "alice",
"name": "astrid-capsule-adversarial",
"meta": null
},
{
"principal": "bob",
"name": "astrid-capsule-session",
"meta": null
}
]
}
});
assert!(!capsules_loaded_missing_provider(
&raw,
"astrid-capsule-adversarial",
"alice"
));
}
}