1use crate::ux::format::{JsonResponse, Output, is_json_mode};
4use anyhow::{Result, anyhow};
5use auths_sdk::core_config::EnvironmentConfig;
6use auths_sdk::keychain::KeyStorage;
7use auths_sdk::ports::IdentityStorage;
8use auths_sdk::storage::RegistryIdentityStorage;
9use auths_sdk::storage_layout::layout;
10use chrono::{DateTime, Utc};
11use clap::Parser;
12use serde::Serialize;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16#[cfg(unix)]
17use nix::sys::signal;
18#[cfg(unix)]
19use nix::unistd::Pid;
20
21#[derive(Parser, Debug, Clone)]
23#[command(
24 name = "status",
25 about = "Show identity and agent status overview",
26 after_help = "Output:
27 Shows your identity DID, linked devices, key aliases, and agent status.
28 Recommended after auths init to verify setup.
29
30Next Steps:
31 If no identity: run `auths init`
32 If no devices: run `auths pair` to link this machine
33 If agent not running: run `auths agent start`
34
35Related:
36 auths init — Initialize your identity
37 auths doctor — Run comprehensive health checks
38 auths --json status — Machine-readable output"
39)]
40pub struct StatusCommand {}
41
42#[derive(Debug, Serialize)]
44pub struct StatusReport {
45 pub identity: Option<IdentityStatus>,
46 pub agent: AgentStatusInfo,
47 pub devices: DevicesSummary,
48 #[serde(skip_serializing_if = "Vec::is_empty")]
49 pub next_steps: Vec<NextStep>,
50}
51
52#[derive(Debug, Serialize)]
54pub struct NextStep {
55 pub summary: String,
56 pub command: String,
57}
58
59#[derive(Debug, Serialize)]
61pub struct IdentityStatus {
62 pub controller_did: String,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub alias: Option<String>,
65 pub key_aliases: Vec<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
68 pub witnesses: Option<WitnessSummary>,
69}
70
71#[derive(Debug, Serialize)]
73pub struct WitnessSummary {
74 pub designated: usize,
76 pub threshold: usize,
78}
79
80#[derive(Debug, Serialize)]
82pub struct AgentStatusInfo {
83 pub running: bool,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub pid: Option<u32>,
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub socket_path: Option<String>,
88}
89
90#[derive(Debug, Serialize)]
92pub struct DevicesSummary {
93 pub linked: usize,
94 pub revoked: usize,
95 pub unanchored: usize,
96 pub expiring_soon: Vec<ExpiringDevice>,
97 pub devices_detail: Vec<DeviceStatus>,
98}
99
100#[derive(Debug, Serialize)]
102pub struct DeviceStatus {
103 pub device_did: String,
104 pub status: String,
105 pub anchored: bool,
106 pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
107 pub expires_at: Option<DateTime<Utc>>,
108 pub expires_in: Option<i64>,
110}
111
112#[derive(Debug, Serialize)]
114pub struct ExpiringDevice {
115 pub device_did: String,
116 pub expires_in: i64,
118}
119
120#[allow(clippy::disallowed_methods)]
122pub fn handle_status(
123 _cmd: StatusCommand,
124 repo: Option<PathBuf>,
125 env_config: &EnvironmentConfig,
126) -> Result<()> {
127 let now = Utc::now();
128 let repo_path = resolve_repo_path(repo)?;
129 let identity = load_identity_status(&repo_path, env_config);
130 let agent = get_agent_status();
131 let devices = load_devices_summary(&repo_path, env_config);
132
133 let next_steps = compute_next_steps(&identity, &agent, &devices);
134
135 let report = StatusReport {
136 identity,
137 agent,
138 devices,
139 next_steps,
140 };
141
142 if is_json_mode() {
143 JsonResponse::success("status", report).print()?;
144 } else {
145 print_status(&report, now);
146 }
147
148 Ok(())
149}
150
151fn print_status(report: &StatusReport, now: DateTime<Utc>) {
153 let out = Output::new();
154
155 if let Some(warning) = maybe_format_duplicity_warning(report) {
158 out.println(&warning);
159 out.newline();
160 }
161
162 if let Some(ref id) = report.identity {
164 out.println(&format!("Identity: {}", out.info(&id.controller_did)));
165 if let Some(ref alias) = id.alias {
166 out.println(&format!("Alias: {}", alias));
167 }
168 if id.key_aliases.is_empty() {
169 out.println(&format!("Key aliases: {}", out.dim("none")));
170 } else {
171 out.println(&format!("Key aliases: {}", id.key_aliases.join(", ")));
172 }
173 match &id.witnesses {
174 Some(w) => out.println(&format!(
175 "Witnesses: {} designated, threshold {}",
176 w.designated, w.threshold
177 )),
178 None => out.println(&format!("Witnesses: {}", out.dim("none designated"))),
179 }
180 } else {
181 out.println(&format!("Identity: {}", out.dim("not initialized")));
182 }
183
184 if report.agent.running {
186 let pid_str = report
187 .agent
188 .pid
189 .map(|p| format!("pid {}", p))
190 .unwrap_or_default();
191 let socket_str = report
192 .agent
193 .socket_path
194 .as_ref()
195 .map(|s| format!(", socket {}", s))
196 .unwrap_or_default();
197 out.println(&format!(
198 "Agent: {} ({}{})",
199 out.success("running"),
200 pid_str,
201 socket_str
202 ));
203 } else {
204 out.println(&format!("Agent: {}", out.warn("stopped")));
205 }
206
207 let mut parts = Vec::new();
209 if report.devices.linked > 0 {
210 parts.push(format!("{} linked", report.devices.linked));
211 }
212 if report.devices.revoked > 0 {
213 parts.push(format!("{} revoked", report.devices.revoked));
214 }
215 if !report.devices.expiring_soon.is_empty() {
216 let expiring_count = report.devices.expiring_soon.len();
217 let min_secs = report
218 .devices
219 .expiring_soon
220 .iter()
221 .map(|e| e.expires_in)
222 .min()
223 .unwrap_or(0);
224 parts.push(format!(
225 "{} expiring in {}",
226 expiring_count,
227 format_duration_human(min_secs)
228 ));
229 }
230
231 if parts.is_empty() {
232 out.println(&format!("Devices: {}", out.dim("none")));
233 } else {
234 out.println(&format!("Devices: {}", parts.join(", ")));
235 }
236
237 if !report.devices.devices_detail.is_empty() {
239 out.newline();
240 for device in &report.devices.devices_detail {
241 if device.revoked_at.is_some() {
242 continue;
243 }
244 out.println(&format!(" {}", out.dim(&device.device_did)));
245 display_device_expiry(device.expires_at, &out, now);
246 }
247 }
248
249 if !report.next_steps.is_empty() {
251 out.newline();
252 out.print_heading("Next steps:");
253 for step in &report.next_steps {
254 out.println(&format!(" • {}", step.summary));
255 out.println(&format!(" {}", out.dim(&format!("→ {}", step.command))));
256 }
257 }
258}
259
260fn maybe_format_duplicity_warning(_report: &StatusReport) -> Option<String> {
270 use auths_sdk::keri::copy::format_duplicity_warning;
271 use auths_sdk::verify::{DuplicityReport, KelEventRef, detect_duplicity};
272
273 let auths_dir = match auths_sdk::paths::auths_home() {
276 Ok(p) => p,
277 Err(_) => return None,
278 };
279 let repo = match git2::Repository::open(&auths_dir) {
280 Ok(r) => r,
281 Err(_) => return None,
282 };
283
284 let prefix_str = "refs/auths/shared-kel/";
290 let mut rows: Vec<(String, u64, String)> = Vec::new();
291 let refs = match repo.references() {
292 Ok(r) => r,
293 Err(_) => return None,
294 };
295 for r in refs.filter_map(|r| r.ok()) {
296 let Some(name) = r.name() else { continue };
297 let Some(rest) = name.strip_prefix(prefix_str) else {
298 continue;
299 };
300 let mut parts = rest.splitn(3, '/');
301 let Some(prefix) = parts.next() else { continue };
302 let Some(seq_str) = parts.next() else {
303 continue;
304 };
305 let Ok(seq) = seq_str.parse::<u64>() else {
306 continue;
307 };
308 let said = r.target().map(|oid| oid.to_string()).unwrap_or_default();
309 if said.is_empty() {
310 continue;
311 }
312 rows.push((prefix.to_string(), seq, said));
313 }
314
315 if rows.is_empty() {
316 return None;
317 }
318
319 let events: Vec<KelEventRef<'_>> = rows
322 .iter()
323 .map(|(prefix, seq, said)| KelEventRef {
324 prefix: prefix.as_str(),
325 seq: *seq,
326 said: said.as_str(),
327 })
328 .collect();
329
330 match detect_duplicity(&events) {
331 DuplicityReport::Clean => None,
332 DuplicityReport::Diverging { seq, .. } => Some(format_duplicity_warning(seq)),
333 }
334}
335
336fn format_duration_human(secs: i64) -> String {
338 if secs < 0 {
339 return "expired".to_string();
340 }
341 let days = secs / 86400;
342 let hours = (secs % 86400) / 3600;
343 let mins = (secs % 3600) / 60;
344 let remaining_secs = secs % 60;
345
346 if days > 0 {
347 format!("{}d {}h", days, hours)
348 } else if hours > 0 {
349 format!("{}h {}m", hours, mins)
350 } else if mins > 0 {
351 format!("{}m {}s", mins, remaining_secs)
352 } else {
353 format!("{}s", remaining_secs)
354 }
355}
356
357fn display_device_expiry(expires_at: Option<DateTime<Utc>>, out: &Output, now: DateTime<Utc>) {
359 let Some(expires_at) = expires_at else {
360 out.println(&format!(" Expires: {}", out.info("never")));
361 return;
362 };
363
364 let remaining_secs = (expires_at - now).num_seconds();
365
366 let (label, color_fn): (&str, fn(&Output, &str) -> String) = match remaining_secs {
367 s if s < 0 => ("EXPIRED", Output::error),
368 0..=604_799 => ("expiring soon", Output::warn),
369 604_800..=2_591_999 => ("expiring", Output::warn),
370 _ => ("active", Output::success),
371 };
372
373 let display = format!(
374 "{} ({}, {} remaining)",
375 expires_at.format("%Y-%m-%d"),
376 label,
377 format_duration_human(remaining_secs)
378 );
379 out.println(&format!(" Expires: {}", color_fn(out, &display)));
380
381 if (0..=604_800).contains(&remaining_secs) {
382 out.print_warn(" Run `auths device extend` to renew.");
383 }
384}
385
386fn load_identity_status(
388 repo_path: &PathBuf,
389 env_config: &EnvironmentConfig,
390) -> Option<IdentityStatus> {
391 if crate::factories::storage::open_git_repo(repo_path).is_err() {
392 return None;
393 }
394
395 let storage = RegistryIdentityStorage::new(repo_path);
396 match storage.load_identity() {
397 Ok(identity) => {
398 let key_aliases = auths_sdk::keychain::get_platform_keychain_with_config(env_config)
399 .ok()
400 .and_then(|keychain| {
401 keychain
402 .list_aliases_for_identity(&identity.controller_did)
403 .ok()
404 })
405 .map(|aliases| aliases.iter().map(|a| a.as_str().to_string()).collect())
406 .unwrap_or_default();
407
408 let witnesses = identity
409 .metadata
410 .as_ref()
411 .and_then(|m| m.get("witness_config"))
412 .and_then(|wc| {
413 serde_json::from_value::<auths_sdk::witness::WitnessConfig>(wc.clone()).ok()
414 })
415 .filter(|c| !c.witnesses.is_empty())
416 .map(|c| WitnessSummary {
417 designated: c.witnesses.len(),
418 threshold: c.threshold,
419 });
420
421 Some(IdentityStatus {
422 controller_did: identity.controller_did.to_string(),
423 alias: None,
424 key_aliases,
425 witnesses,
426 })
427 }
428 Err(_) => None,
429 }
430}
431
432fn get_agent_status() -> AgentStatusInfo {
434 let auths_dir = match get_auths_dir() {
435 Ok(dir) => dir,
436 Err(_) => {
437 return AgentStatusInfo {
438 running: false,
439 pid: None,
440 socket_path: None,
441 };
442 }
443 };
444
445 let pid_path = auths_dir.join("agent.pid");
446 let socket_path = auths_dir.join("agent.sock");
447
448 let pid = fs::read_to_string(&pid_path)
450 .ok()
451 .and_then(|content| content.trim().parse::<u32>().ok());
452
453 let running = pid.map(is_process_running).unwrap_or(false);
455 let socket_exists = socket_path.exists();
456
457 AgentStatusInfo {
458 running: running && socket_exists,
459 pid: if running { pid } else { None },
460 socket_path: if socket_exists && running {
461 Some(socket_path.to_string_lossy().to_string())
462 } else {
463 None
464 },
465 }
466}
467
468fn load_devices_summary(repo_path: &Path, env_config: &EnvironmentConfig) -> DevicesSummary {
470 let empty = DevicesSummary {
471 linked: 0,
472 revoked: 0,
473 unanchored: 0,
474 expiring_soon: Vec::new(),
475 devices_detail: Vec::new(),
476 };
477
478 let ctx = match crate::factories::storage::build_auths_context(repo_path, env_config, None) {
479 Ok(ctx) => ctx,
480 Err(_) => return empty,
481 };
482 let devices = match auths_sdk::domains::device::list_delegated_devices(&ctx) {
483 Ok(devices) => devices,
484 Err(_) => return empty,
485 };
486
487 let mut linked = 0;
488 let mut revoked = 0;
489 let mut devices_detail = Vec::new();
490 for device in devices {
491 if device.revoked {
492 revoked += 1;
493 } else {
494 linked += 1;
495 }
496 devices_detail.push(DeviceStatus {
497 device_did: device.device_did,
498 status: if device.revoked {
499 "revoked".to_string()
500 } else {
501 "active".to_string()
502 },
503 anchored: true,
504 revoked_at: None,
505 expires_at: None,
506 expires_in: None,
507 });
508 }
509
510 DevicesSummary {
513 linked,
514 revoked,
515 unanchored: 0,
516 expiring_soon: Vec::new(),
517 devices_detail,
518 }
519}
520
521fn get_auths_dir() -> Result<PathBuf> {
523 auths_sdk::paths::auths_home().map_err(|e| anyhow!(e))
524}
525
526fn resolve_repo_path(repo_arg: Option<PathBuf>) -> Result<PathBuf> {
528 layout::resolve_repo_path(repo_arg).map_err(|e| anyhow!(e))
529}
530
531fn compute_next_steps(
533 identity: &Option<IdentityStatus>,
534 agent: &AgentStatusInfo,
535 devices: &DevicesSummary,
536) -> Vec<NextStep> {
537 let mut steps = Vec::new();
538
539 if identity.is_none() {
541 steps.push(NextStep {
542 summary: "Initialize your identity".to_string(),
543 command: "auths init".to_string(),
544 });
545 return steps;
546 }
547
548 if devices.linked == 0 {
550 steps.push(NextStep {
551 summary: "Link your first device".to_string(),
552 command: "auths pair".to_string(),
553 });
554 }
555
556 if !agent.running {
558 steps.push(NextStep {
559 summary: "Start the agent service".to_string(),
560 command: "auths agent start".to_string(),
561 });
562 }
563
564 if !devices.expiring_soon.is_empty() {
566 steps.push(NextStep {
567 summary: "Renew devices expiring soon".to_string(),
568 command: "auths device extend".to_string(),
569 });
570 }
571
572 steps
573}
574
575#[cfg(unix)]
577fn is_process_running(pid: u32) -> bool {
578 signal::kill(Pid::from_raw(pid as i32), None).is_ok()
579}
580
581#[cfg(not(unix))]
582fn is_process_running(_pid: u32) -> bool {
583 false
584}
585
586impl crate::commands::executable::ExecutableCommand for StatusCommand {
587 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
588 handle_status(self.clone(), ctx.repo_path.clone(), &ctx.env_config)
589 }
590}
591
592#[cfg(test)]
593mod tests {
594 use super::*;
595 use chrono::Duration;
596 use chrono::TimeZone;
597
598 #[test]
599 fn test_get_auths_dir() {
600 let dir = get_auths_dir().unwrap();
601 assert!(dir.ends_with(".auths"));
602 }
603
604 #[test]
605 fn status_json_snapshot() {
606 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
607
608 let report = StatusReport {
609 identity: Some(IdentityStatus {
610 controller_did: "did:keri:ETestController123".to_string(),
611 alias: Some("dev-machine".to_string()),
612 key_aliases: vec!["main".to_string()],
613 witnesses: None,
614 }),
615 agent: AgentStatusInfo {
616 running: true,
617 pid: Some(12345),
618 socket_path: Some("/tmp/agent.sock".to_string()),
619 },
620 devices: DevicesSummary {
621 linked: 2,
622 revoked: 1,
623 unanchored: 0,
624 expiring_soon: vec![ExpiringDevice {
625 device_did: "did:key:zExpiringSoon".to_string(),
626 expires_in: 259_200,
627 }],
628 devices_detail: vec![
629 DeviceStatus {
630 device_did: "did:key:zActiveDevice".to_string(),
631 status: "active".to_string(),
632 anchored: true,
633 revoked_at: None,
634 expires_at: Some(now + Duration::days(90)),
635 expires_in: Some(7_776_000),
636 },
637 DeviceStatus {
638 device_did: "did:key:zExpiringSoon".to_string(),
639 status: "expiring_soon".to_string(),
640 anchored: true,
641 revoked_at: None,
642 expires_at: Some(now + Duration::days(3)),
643 expires_in: Some(259_200),
644 },
645 DeviceStatus {
646 device_did: "did:key:zRevokedDevice".to_string(),
647 status: "revoked".to_string(),
648 anchored: true,
649 revoked_at: Some(now - Duration::days(10)),
650 expires_at: Some(now + Duration::days(50)),
651 expires_in: None,
652 },
653 ],
654 },
655 next_steps: vec![],
656 };
657
658 insta::assert_json_snapshot!(report);
659 }
660
661 #[test]
662 fn status_shows_witness_set() {
663 let id = IdentityStatus {
664 controller_did: "did:keri:E1".to_string(),
665 alias: None,
666 key_aliases: vec![],
667 witnesses: Some(WitnessSummary {
668 designated: 3,
669 threshold: 2,
670 }),
671 };
672 let json = serde_json::to_string(&id).unwrap();
673 assert!(json.contains("\"designated\":3"));
674 assert!(json.contains("\"threshold\":2"));
675 }
676}