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 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/// Show identity and agent status overview.
22#[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/// Full status report.
43#[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/// Suggested next action for the user.
53#[derive(Debug, Serialize)]
54pub struct NextStep {
55    pub summary: String,
56    pub command: String,
57}
58
59/// Identity status information.
60#[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    /// The identity's designated witness set (D.9), when configured.
67    #[serde(skip_serializing_if = "Option::is_none")]
68    pub witnesses: Option<WitnessSummary>,
69}
70
71/// Designated witness set for the identity (presentation of `WitnessConfig`).
72#[derive(Debug, Serialize)]
73pub struct WitnessSummary {
74    /// Number of designated witnesses (`b[]` size).
75    pub designated: usize,
76    /// Required receipts threshold (`bt`).
77    pub threshold: usize,
78}
79
80/// Agent status information.
81#[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/// Devices summary.
91#[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/// Per-device status for expiry display.
101#[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    /// Duration in seconds until expiration (per RFC 6749).
109    pub expires_in: Option<i64>,
110}
111
112/// Device that is expiring soon.
113#[derive(Debug, Serialize)]
114pub struct ExpiringDevice {
115    pub device_did: String,
116    /// Duration in seconds until expiration (per RFC 6749).
117    pub expires_in: i64,
118}
119
120/// Handle the status command.
121#[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
151/// Print status in human-readable format.
152fn print_status(report: &StatusReport, now: DateTime<Utc>) {
153    let out = Output::new();
154
155    // Shared-identity duplicity surfaces at the top so users see it
156    // before anything else. Fail-open: exit code stays 0 regardless.
157    if let Some(warning) = maybe_format_duplicity_warning(report) {
158        out.println(&warning);
159        out.newline();
160    }
161
162    // Identity
163    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    // Agent
185    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    // Devices
208    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    // Per-device expiry detail
238    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    // Next steps
250    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
260/// Render the pinned duplicity warning when the local KEL stream
261/// contains a diverging rotation.
262///
263/// Walks `refs/auths/shared-kel/*` via git2, turns each matching ref
264/// into a [`KelEventRef`] (prefix + sequence + SAID), and asks the
265/// duplicity detector whether any same-prefix same-seq events carry
266/// differing SAIDs. Fail-open: returns `None` if no shared KEL is
267/// replicated locally or if the scan errors (a missing shared KEL is
268/// the pre-first-pair norm, not an error state).
269fn 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    // Resolve the auths home repo. Any failure → None (pre-first-pair
274    // case is the common one; not worth a log line).
275    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    // Scan refs under the shared-KEL namespace. Ref names have the
285    // shape `refs/auths/shared-kel/<prefix>/<seq>/<said-or-role>`;
286    // we extract prefix + seq and treat the ref target OID as the
287    // SAID for divergence purposes (two refs at the same (prefix,
288    // seq) with different OIDs indicates a fork in the local replica).
289    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    // Build KelEventRefs on borrowed storage. `detect_duplicity`
320    // returns the first divergence it finds.
321    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
336/// Format seconds into a human-readable duration string.
337fn 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
357/// Display color-coded device expiry information.
358fn 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
386/// Load identity status from the repository, including key aliases from the keychain.
387fn 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
432/// Get agent status by checking PID file and socket.
433fn 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    // Read PID file
449    let pid = fs::read_to_string(&pid_path)
450        .ok()
451        .and_then(|content| content.trim().parse::<u32>().ok());
452
453    // Check if process is running
454    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
468/// Load the devices summary from the delegation set (live = delegated − revoked).
469fn 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    // KERI delegation carries no timestamps: no expiry / expiring-soon set, and a
511    // delegated device is inherently anchored.
512    DevicesSummary {
513        linked,
514        revoked,
515        unanchored: 0,
516        expiring_soon: Vec::new(),
517        devices_detail,
518    }
519}
520
521/// Get the auths directory path (~/.auths), respecting AUTHS_HOME.
522fn get_auths_dir() -> Result<PathBuf> {
523    auths_sdk::paths::auths_home().map_err(|e| anyhow!(e))
524}
525
526/// Resolve the repository path from optional argument or default (~/.auths).
527fn resolve_repo_path(repo_arg: Option<PathBuf>) -> Result<PathBuf> {
528    layout::resolve_repo_path(repo_arg).map_err(|e| anyhow!(e))
529}
530
531/// Compute suggested next steps based on current state.
532fn compute_next_steps(
533    identity: &Option<IdentityStatus>,
534    agent: &AgentStatusInfo,
535    devices: &DevicesSummary,
536) -> Vec<NextStep> {
537    let mut steps = Vec::new();
538
539    // No identity
540    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    // No devices linked
549    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    // Agent not running
557    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    // Devices expiring soon
565    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/// Check if a process with the given PID is running.
576#[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}