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 #[serde(skip_serializing_if = "Option::is_none")]
97 pub this_device: Option<String>,
98 pub linked: usize,
99 pub revoked: usize,
100 pub unanchored: usize,
101 pub expiring_soon: Vec<ExpiringDevice>,
102 pub devices_detail: Vec<DeviceStatus>,
103}
104
105#[derive(Debug, Serialize)]
107pub struct DeviceStatus {
108 pub device_did: String,
109 pub status: String,
110 pub anchored: bool,
111 pub revoked_at: Option<chrono::DateTime<chrono::Utc>>,
112 pub expires_at: Option<DateTime<Utc>>,
113 pub expires_in: Option<i64>,
115}
116
117#[derive(Debug, Serialize)]
119pub struct ExpiringDevice {
120 pub device_did: String,
121 pub expires_in: i64,
123}
124
125#[allow(clippy::disallowed_methods)]
127pub fn handle_status(
128 _cmd: StatusCommand,
129 repo: Option<PathBuf>,
130 env_config: &EnvironmentConfig,
131) -> Result<()> {
132 let now = Utc::now();
133 let repo_path = resolve_repo_path(repo)?;
134 let identity = load_identity_status(&repo_path, env_config);
135 let agent = get_agent_status();
136 let devices = load_devices_summary(&repo_path, env_config);
137
138 let next_steps = compute_next_steps(&identity, &agent, &devices);
139
140 let report = StatusReport {
141 identity,
142 agent,
143 devices,
144 next_steps,
145 };
146
147 if is_json_mode() {
148 JsonResponse::success("status", report).print()?;
149 } else {
150 print_status(&report, now);
151 }
152
153 Ok(())
154}
155
156fn print_status(report: &StatusReport, now: DateTime<Utc>) {
158 let out = Output::new();
159
160 if let Some(warning) = maybe_format_duplicity_warning(report) {
163 out.println(&warning);
164 out.newline();
165 }
166
167 if let Some(ref id) = report.identity {
169 out.println(&format!("Identity: {}", out.info(&id.controller_did)));
170 if let Some(ref alias) = id.alias {
171 out.println(&format!("Alias: {}", alias));
172 }
173 if id.key_aliases.is_empty() {
174 out.println(&format!("Key aliases: {}", out.dim("none")));
175 } else {
176 out.println(&format!("Key aliases: {}", id.key_aliases.join(", ")));
177 }
178 match &id.witnesses {
179 Some(w) => out.println(&format!(
180 "Witnesses: {} designated, threshold {}",
181 w.designated, w.threshold
182 )),
183 None => out.println(&format!("Witnesses: {}", out.dim("none designated"))),
184 }
185 } else {
186 out.println(&format!("Identity: {}", out.dim("not initialized")));
187 }
188
189 if report.agent.running {
191 let pid_str = report
192 .agent
193 .pid
194 .map(|p| format!("pid {}", p))
195 .unwrap_or_default();
196 let socket_str = report
197 .agent
198 .socket_path
199 .as_ref()
200 .map(|s| format!(", socket {}", s))
201 .unwrap_or_default();
202 out.println(&format!(
203 "Agent: {} ({}{})",
204 out.success("running"),
205 pid_str,
206 socket_str
207 ));
208 } else {
209 out.println(&format!("Agent: {}", out.warn("stopped")));
210 }
211
212 let mut parts = Vec::new();
214 if let Some(ref this_device) = report.devices.this_device {
215 parts.push(format!("this device ({})", out.dim(this_device)));
216 }
217 if report.devices.linked > 0 {
218 parts.push(format!("{} other linked", report.devices.linked));
219 }
220 if report.devices.revoked > 0 {
221 parts.push(format!("{} revoked", report.devices.revoked));
222 }
223 if !report.devices.expiring_soon.is_empty() {
224 let expiring_count = report.devices.expiring_soon.len();
225 let min_secs = report
226 .devices
227 .expiring_soon
228 .iter()
229 .map(|e| e.expires_in)
230 .min()
231 .unwrap_or(0);
232 parts.push(format!(
233 "{} expiring in {}",
234 expiring_count,
235 format_duration_human(min_secs)
236 ));
237 }
238
239 if parts.is_empty() {
240 out.println(&format!("Devices: {}", out.dim("none")));
241 } else {
242 out.println(&format!("Devices: {}", parts.join(", ")));
243 }
244
245 if !report.devices.devices_detail.is_empty() {
247 out.newline();
248 for device in &report.devices.devices_detail {
249 if device.revoked_at.is_some() {
250 continue;
251 }
252 out.println(&format!(" {}", out.dim(&device.device_did)));
253 display_device_expiry(device.expires_at, &out, now);
254 }
255 }
256
257 if !report.next_steps.is_empty() {
259 out.newline();
260 out.print_heading("Next steps:");
261 for step in &report.next_steps {
262 out.println(&format!(" • {}", step.summary));
263 out.println(&format!(" {}", out.dim(&format!("→ {}", step.command))));
264 }
265 }
266}
267
268fn maybe_format_duplicity_warning(_report: &StatusReport) -> Option<String> {
278 use auths_sdk::keri::copy::format_duplicity_warning;
279 use auths_sdk::verify::{DuplicityReport, KelEventRef, detect_duplicity};
280
281 let auths_dir = match auths_sdk::paths::auths_home() {
284 Ok(p) => p,
285 Err(_) => return None,
286 };
287 let repo = match git2::Repository::open(&auths_dir) {
288 Ok(r) => r,
289 Err(_) => return None,
290 };
291
292 let prefix_str = "refs/auths/shared-kel/";
298 let mut rows: Vec<(String, u64, String)> = Vec::new();
299 let refs = match repo.references() {
300 Ok(r) => r,
301 Err(_) => return None,
302 };
303 for r in refs.filter_map(|r| r.ok()) {
304 let Some(name) = r.name() else { continue };
305 let Some(rest) = name.strip_prefix(prefix_str) else {
306 continue;
307 };
308 let mut parts = rest.splitn(3, '/');
309 let Some(prefix) = parts.next() else { continue };
310 let Some(seq_str) = parts.next() else {
311 continue;
312 };
313 let Ok(seq) = seq_str.parse::<u64>() else {
314 continue;
315 };
316 let said = r.target().map(|oid| oid.to_string()).unwrap_or_default();
317 if said.is_empty() {
318 continue;
319 }
320 rows.push((prefix.to_string(), seq, said));
321 }
322
323 if rows.is_empty() {
324 return None;
325 }
326
327 let events: Vec<KelEventRef<'_>> = rows
330 .iter()
331 .map(|(prefix, seq, said)| KelEventRef {
332 prefix: prefix.as_str(),
333 seq: *seq,
334 said: said.as_str(),
335 })
336 .collect();
337
338 match detect_duplicity(&events) {
339 DuplicityReport::Clean => None,
340 DuplicityReport::Diverging { seq, .. } => Some(format_duplicity_warning(seq)),
341 }
342}
343
344fn format_duration_human(secs: i64) -> String {
346 if secs < 0 {
347 return "expired".to_string();
348 }
349 let days = secs / 86400;
350 let hours = (secs % 86400) / 3600;
351 let mins = (secs % 3600) / 60;
352 let remaining_secs = secs % 60;
353
354 if days > 0 {
355 format!("{}d {}h", days, hours)
356 } else if hours > 0 {
357 format!("{}h {}m", hours, mins)
358 } else if mins > 0 {
359 format!("{}m {}s", mins, remaining_secs)
360 } else {
361 format!("{}s", remaining_secs)
362 }
363}
364
365fn display_device_expiry(expires_at: Option<DateTime<Utc>>, out: &Output, now: DateTime<Utc>) {
367 let Some(expires_at) = expires_at else {
368 out.println(&format!(" Expires: {}", out.info("never")));
369 return;
370 };
371
372 let remaining_secs = (expires_at - now).num_seconds();
373
374 let (label, color_fn): (&str, fn(&Output, &str) -> String) = match remaining_secs {
375 s if s < 0 => ("EXPIRED", Output::error),
376 0..=604_799 => ("expiring soon", Output::warn),
377 604_800..=2_591_999 => ("expiring", Output::warn),
378 _ => ("active", Output::success),
379 };
380
381 let display = format!(
382 "{} ({}, {} remaining)",
383 expires_at.format("%Y-%m-%d"),
384 label,
385 format_duration_human(remaining_secs)
386 );
387 out.println(&format!(" Expires: {}", color_fn(out, &display)));
388
389 if (0..=604_800).contains(&remaining_secs) {
390 out.print_warn(" Run `auths device extend` to renew.");
391 }
392}
393
394fn load_identity_status(
396 repo_path: &PathBuf,
397 env_config: &EnvironmentConfig,
398) -> Option<IdentityStatus> {
399 if crate::factories::storage::open_git_repo(repo_path).is_err() {
400 return None;
401 }
402
403 let storage = RegistryIdentityStorage::new(repo_path);
404 match storage.load_identity() {
405 Ok(identity) => {
406 let key_aliases = auths_sdk::keychain::get_platform_keychain_with_config(env_config)
407 .ok()
408 .and_then(|keychain| {
409 keychain
410 .list_aliases_for_identity(&identity.controller_did)
411 .ok()
412 })
413 .map(|aliases| aliases.iter().map(|a| a.as_str().to_string()).collect())
414 .unwrap_or_default();
415
416 let witnesses = identity
417 .metadata
418 .as_ref()
419 .and_then(|m| m.get("witness_config"))
420 .and_then(|wc| {
421 serde_json::from_value::<auths_sdk::witness::WitnessConfig>(wc.clone()).ok()
422 })
423 .filter(|c| !c.witnesses.is_empty())
424 .map(|c| WitnessSummary {
425 designated: c.witnesses.len(),
426 threshold: c.threshold,
427 });
428
429 Some(IdentityStatus {
430 controller_did: identity.controller_did.to_string(),
431 alias: None,
432 key_aliases,
433 witnesses,
434 })
435 }
436 Err(_) => None,
437 }
438}
439
440fn get_agent_status() -> AgentStatusInfo {
442 let auths_dir = match get_auths_dir() {
443 Ok(dir) => dir,
444 Err(_) => {
445 return AgentStatusInfo {
446 running: false,
447 pid: None,
448 socket_path: None,
449 };
450 }
451 };
452
453 let pid_path = auths_dir.join("agent.pid");
454 let socket_path = auths_dir.join("agent.sock");
455
456 let pid = fs::read_to_string(&pid_path)
458 .ok()
459 .and_then(|content| content.trim().parse::<u32>().ok());
460
461 let running = pid.map(is_process_running).unwrap_or(false);
463 let socket_exists = socket_path.exists();
464
465 AgentStatusInfo {
466 running: running && socket_exists,
467 pid: if running { pid } else { None },
468 socket_path: if socket_exists && running {
469 Some(socket_path.to_string_lossy().to_string())
470 } else {
471 None
472 },
473 }
474}
475
476fn load_devices_summary(repo_path: &Path, env_config: &EnvironmentConfig) -> DevicesSummary {
478 let empty = DevicesSummary {
479 this_device: None,
480 linked: 0,
481 revoked: 0,
482 unanchored: 0,
483 expiring_soon: Vec::new(),
484 devices_detail: Vec::new(),
485 };
486
487 let ctx = match crate::factories::storage::build_auths_context(repo_path, env_config, None) {
488 Ok(ctx) => ctx,
489 Err(_) => return empty,
490 };
491 let this_device = auths_sdk::domains::identity::local::resolve_local_signer(&ctx)
492 .ok()
493 .map(|signer| signer.signer_did.to_string());
494 let devices = match auths_sdk::domains::device::list_delegated_devices(&ctx) {
495 Ok(devices) => devices,
496 Err(_) => {
497 return DevicesSummary {
498 this_device,
499 ..empty
500 };
501 }
502 };
503
504 let mut linked = 0;
505 let mut revoked = 0;
506 let mut devices_detail = Vec::new();
507 for device in devices {
508 if device.revoked {
509 revoked += 1;
510 } else {
511 linked += 1;
512 }
513 devices_detail.push(DeviceStatus {
514 device_did: device.device_did,
515 status: if device.revoked {
516 "revoked".to_string()
517 } else {
518 "active".to_string()
519 },
520 anchored: true,
521 revoked_at: None,
522 expires_at: None,
523 expires_in: None,
524 });
525 }
526
527 DevicesSummary {
530 this_device,
531 linked,
532 revoked,
533 unanchored: 0,
534 expiring_soon: Vec::new(),
535 devices_detail,
536 }
537}
538
539fn get_auths_dir() -> Result<PathBuf> {
541 auths_sdk::paths::auths_home().map_err(|e| anyhow!(e))
542}
543
544fn resolve_repo_path(repo_arg: Option<PathBuf>) -> Result<PathBuf> {
546 layout::resolve_repo_path(repo_arg).map_err(|e| anyhow!(e))
547}
548
549fn compute_next_steps(
551 identity: &Option<IdentityStatus>,
552 agent: &AgentStatusInfo,
553 devices: &DevicesSummary,
554) -> Vec<NextStep> {
555 let mut steps = Vec::new();
556
557 if identity.is_none() {
559 steps.push(NextStep {
560 summary: "Initialize your identity".to_string(),
561 command: "auths init".to_string(),
562 });
563 return steps;
564 }
565
566 if devices.linked == 0 {
569 let summary = if devices.this_device.is_some() {
570 "Add another device"
571 } else {
572 "Link your first device"
573 };
574 steps.push(NextStep {
575 summary: summary.to_string(),
576 command: "auths pair".to_string(),
577 });
578 }
579
580 if !agent.running {
582 steps.push(NextStep {
583 summary: "Start the agent service".to_string(),
584 command: "auths agent start".to_string(),
585 });
586 }
587
588 if !devices.expiring_soon.is_empty() {
590 steps.push(NextStep {
591 summary: "Renew devices expiring soon".to_string(),
592 command: "auths device extend".to_string(),
593 });
594 }
595
596 steps
597}
598
599#[cfg(unix)]
601fn is_process_running(pid: u32) -> bool {
602 signal::kill(Pid::from_raw(pid as i32), None).is_ok()
603}
604
605#[cfg(not(unix))]
606fn is_process_running(_pid: u32) -> bool {
607 false
608}
609
610impl crate::commands::executable::ExecutableCommand for StatusCommand {
611 fn execute(&self, ctx: &crate::config::CliConfig) -> anyhow::Result<()> {
612 handle_status(self.clone(), ctx.repo_path.clone(), &ctx.env_config)
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619 use chrono::Duration;
620 use chrono::TimeZone;
621
622 #[test]
623 fn test_get_auths_dir() {
624 let dir = get_auths_dir().unwrap();
625 assert!(dir.ends_with(".auths"));
626 }
627
628 #[test]
629 fn status_json_snapshot() {
630 let now = Utc.with_ymd_and_hms(2025, 6, 15, 12, 0, 0).unwrap();
631
632 let report = StatusReport {
633 identity: Some(IdentityStatus {
634 controller_did: "did:keri:ETestController123".to_string(),
635 alias: Some("dev-machine".to_string()),
636 key_aliases: vec!["main".to_string()],
637 witnesses: None,
638 }),
639 agent: AgentStatusInfo {
640 running: true,
641 pid: Some(12345),
642 socket_path: Some("/tmp/agent.sock".to_string()),
643 },
644 devices: DevicesSummary {
645 this_device: Some("did:keri:EThisDevice".to_string()),
646 linked: 2,
647 revoked: 1,
648 unanchored: 0,
649 expiring_soon: vec![ExpiringDevice {
650 device_did: "did:key:zExpiringSoon".to_string(),
651 expires_in: 259_200,
652 }],
653 devices_detail: vec![
654 DeviceStatus {
655 device_did: "did:key:zActiveDevice".to_string(),
656 status: "active".to_string(),
657 anchored: true,
658 revoked_at: None,
659 expires_at: Some(now + Duration::days(90)),
660 expires_in: Some(7_776_000),
661 },
662 DeviceStatus {
663 device_did: "did:key:zExpiringSoon".to_string(),
664 status: "expiring_soon".to_string(),
665 anchored: true,
666 revoked_at: None,
667 expires_at: Some(now + Duration::days(3)),
668 expires_in: Some(259_200),
669 },
670 DeviceStatus {
671 device_did: "did:key:zRevokedDevice".to_string(),
672 status: "revoked".to_string(),
673 anchored: true,
674 revoked_at: Some(now - Duration::days(10)),
675 expires_at: Some(now + Duration::days(50)),
676 expires_in: None,
677 },
678 ],
679 },
680 next_steps: vec![],
681 };
682
683 insta::assert_json_snapshot!(report);
684 }
685
686 #[test]
687 fn status_shows_witness_set() {
688 let id = IdentityStatus {
689 controller_did: "did:keri:E1".to_string(),
690 alias: None,
691 key_aliases: vec![],
692 witnesses: Some(WitnessSummary {
693 designated: 3,
694 threshold: 2,
695 }),
696 };
697 let json = serde_json::to_string(&id).unwrap();
698 assert!(json.contains("\"designated\":3"));
699 assert!(json.contains("\"threshold\":2"));
700 }
701}