Skip to main content

auths_cli/commands/
status.rs

1//! Status overview command for Auths.
2
3use 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/// Show identity and agent status overview.
23#[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/// Full status report.
44#[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/// Suggested next action for the user.
54#[derive(Debug, Serialize)]
55pub struct NextStep {
56    pub summary: String,
57    pub command: String,
58}
59
60/// Identity status information.
61#[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    /// The identity's designated witness set (D.9), when configured.
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub witnesses: Option<WitnessSummary>,
70}
71
72/// Designated witness set for the identity (presentation of `WitnessConfig`).
73#[derive(Debug, Serialize)]
74pub struct WitnessSummary {
75    /// Number of designated witnesses (`b[]` size).
76    pub designated: usize,
77    /// Required receipts threshold (`bt`).
78    pub threshold: usize,
79}
80
81/// Agent status information.
82#[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/// Devices summary.
92#[derive(Debug, Serialize)]
93pub struct DevicesSummary {
94    /// The device the user is on right now (the root signing device). Always
95    /// counted — "Devices: none" seconds after init authorized this machine
96    /// reads as "setup didn't take".
97    #[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/// Per-device status for expiry display.
107#[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    /// Duration in seconds until expiration (per RFC 6749).
115    pub expires_in: Option<i64>,
116}
117
118/// Device that is expiring soon.
119#[derive(Debug, Serialize)]
120pub struct ExpiringDevice {
121    pub device_did: String,
122    /// Duration in seconds until expiration (per RFC 6749).
123    pub expires_in: i64,
124}
125
126/// Handle the status command.
127#[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    // Next steps come from the one SDK-owned rule set (which includes the recovery
140    // single-point-of-failure signpost); the CLI maps each device to its readiness and renders.
141    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
174/// Print status in human-readable format.
175fn print_status(report: &StatusReport, now: DateTime<Utc>) {
176    let out = Output::new();
177
178    // Shared-identity duplicity surfaces at the top so users see it
179    // before anything else. Fail-open: exit code stays 0 regardless.
180    if let Some(warning) = maybe_format_duplicity_warning(report) {
181        out.println(&warning);
182        out.newline();
183    }
184
185    // Identity
186    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    // Agent
208    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    // Devices — the machine the user is on always counts.
231    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    // Per-device expiry detail
264    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    // Next steps
276    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
286/// Render the pinned duplicity warning when the local KEL stream
287/// contains a diverging rotation.
288///
289/// Walks `refs/auths/shared-kel/*` via git2, turns each matching ref
290/// into a [`KelEventRef`] (prefix + sequence + SAID), and asks the
291/// duplicity detector whether any same-prefix same-seq events carry
292/// differing SAIDs. Fail-open: returns `None` if no shared KEL is
293/// replicated locally or if the scan errors (a missing shared KEL is
294/// the pre-first-pair norm, not an error state).
295fn 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    // Resolve the auths home repo. Any failure → None (pre-first-pair
300    // case is the common one; not worth a log line).
301    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    // Scan refs under the shared-KEL namespace. Ref names have the
311    // shape `refs/auths/shared-kel/<prefix>/<seq>/<said-or-role>`;
312    // we extract prefix + seq and treat the ref target OID as the
313    // SAID for divergence purposes (two refs at the same (prefix,
314    // seq) with different OIDs indicates a fork in the local replica).
315    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    // Build KelEventRefs on borrowed storage. `detect_duplicity`
346    // returns the first divergence it finds.
347    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
362/// Format seconds into a human-readable duration string.
363fn 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
383/// Display color-coded device expiry information.
384fn 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
412/// Load identity status from the repository, including key aliases from the keychain.
413fn 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
458/// Get agent status by checking PID file and socket.
459fn 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    // Read PID file
475    let pid = fs::read_to_string(&pid_path)
476        .ok()
477        .and_then(|content| content.trim().parse::<u32>().ok());
478
479    // Check if process is running
480    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
494/// Load the devices summary from the delegation set (live = delegated − revoked).
495fn 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    // KERI delegation carries no timestamps: no expiry / expiring-soon set, and a
546    // delegated device is inherently anchored.
547    DevicesSummary {
548        this_device,
549        linked,
550        revoked,
551        unanchored: 0,
552        expiring_soon: Vec::new(),
553        devices_detail,
554    }
555}
556
557/// Get the auths directory path (~/.auths), respecting AUTHS_HOME.
558fn get_auths_dir() -> Result<PathBuf> {
559    auths_sdk::paths::auths_home().map_err(|e| anyhow!(e))
560}
561
562/// Resolve the repository path from optional argument or default (~/.auths).
563fn resolve_repo_path(repo_arg: Option<PathBuf>) -> Result<PathBuf> {
564    layout::resolve_repo_path(repo_arg).map_err(|e| anyhow!(e))
565}
566
567/// Compute suggested next steps based on current state.
568/// Check if a process with the given PID is running.
569#[cfg(unix)]
570fn is_process_running(pid: u32) -> bool {
571    signal::kill(Pid::from_raw(pid as i32), None).is_ok()
572}
573
574/// Windows: a PID is running if we can open it and `GetExitCodeProcess`
575/// reports `STILL_ACTIVE` (259). `OpenProcess` failing (no such PID, or access
576/// denied) is treated as not-running, matching the Unix `kill(pid, 0)` check.
577#[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    // SAFETY: standard Win32 open/query/close of a process handle; the handle is
585    // always closed, and all pointers point to live stack locals.
586    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}