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    /// The device the user is on right now (the root signing device). Always
94    /// counted — "Devices: none" seconds after init authorized this machine
95    /// reads as "setup didn't take".
96    #[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/// Per-device status for expiry display.
106#[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    /// Duration in seconds until expiration (per RFC 6749).
114    pub expires_in: Option<i64>,
115}
116
117/// Device that is expiring soon.
118#[derive(Debug, Serialize)]
119pub struct ExpiringDevice {
120    pub device_did: String,
121    /// Duration in seconds until expiration (per RFC 6749).
122    pub expires_in: i64,
123}
124
125/// Handle the status command.
126#[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
156/// Print status in human-readable format.
157fn print_status(report: &StatusReport, now: DateTime<Utc>) {
158    let out = Output::new();
159
160    // Shared-identity duplicity surfaces at the top so users see it
161    // before anything else. Fail-open: exit code stays 0 regardless.
162    if let Some(warning) = maybe_format_duplicity_warning(report) {
163        out.println(&warning);
164        out.newline();
165    }
166
167    // Identity
168    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    // Agent
190    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    // Devices — the machine the user is on always counts.
213    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    // Per-device expiry detail
246    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    // Next steps
258    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
268/// Render the pinned duplicity warning when the local KEL stream
269/// contains a diverging rotation.
270///
271/// Walks `refs/auths/shared-kel/*` via git2, turns each matching ref
272/// into a [`KelEventRef`] (prefix + sequence + SAID), and asks the
273/// duplicity detector whether any same-prefix same-seq events carry
274/// differing SAIDs. Fail-open: returns `None` if no shared KEL is
275/// replicated locally or if the scan errors (a missing shared KEL is
276/// the pre-first-pair norm, not an error state).
277fn 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    // Resolve the auths home repo. Any failure → None (pre-first-pair
282    // case is the common one; not worth a log line).
283    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    // Scan refs under the shared-KEL namespace. Ref names have the
293    // shape `refs/auths/shared-kel/<prefix>/<seq>/<said-or-role>`;
294    // we extract prefix + seq and treat the ref target OID as the
295    // SAID for divergence purposes (two refs at the same (prefix,
296    // seq) with different OIDs indicates a fork in the local replica).
297    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    // Build KelEventRefs on borrowed storage. `detect_duplicity`
328    // returns the first divergence it finds.
329    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
344/// Format seconds into a human-readable duration string.
345fn 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
365/// Display color-coded device expiry information.
366fn 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
394/// Load identity status from the repository, including key aliases from the keychain.
395fn 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
440/// Get agent status by checking PID file and socket.
441fn 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    // Read PID file
457    let pid = fs::read_to_string(&pid_path)
458        .ok()
459        .and_then(|content| content.trim().parse::<u32>().ok());
460
461    // Check if process is running
462    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
476/// Load the devices summary from the delegation set (live = delegated − revoked).
477fn 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    // KERI delegation carries no timestamps: no expiry / expiring-soon set, and a
528    // delegated device is inherently anchored.
529    DevicesSummary {
530        this_device,
531        linked,
532        revoked,
533        unanchored: 0,
534        expiring_soon: Vec::new(),
535        devices_detail,
536    }
537}
538
539/// Get the auths directory path (~/.auths), respecting AUTHS_HOME.
540fn get_auths_dir() -> Result<PathBuf> {
541    auths_sdk::paths::auths_home().map_err(|e| anyhow!(e))
542}
543
544/// Resolve the repository path from optional argument or default (~/.auths).
545fn resolve_repo_path(repo_arg: Option<PathBuf>) -> Result<PathBuf> {
546    layout::resolve_repo_path(repo_arg).map_err(|e| anyhow!(e))
547}
548
549/// Compute suggested next steps based on current state.
550fn compute_next_steps(
551    identity: &Option<IdentityStatus>,
552    agent: &AgentStatusInfo,
553    devices: &DevicesSummary,
554) -> Vec<NextStep> {
555    let mut steps = Vec::new();
556
557    // No identity
558    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    // The current machine counts as a device — only suggest pairing as an
567    // *addition*, never contradict the init success message with "none".
568    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    // Agent not running
581    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    // Devices expiring soon
589    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/// Check if a process with the given PID is running.
600#[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}