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