Skip to main content

ai_memory/cli/
identity.rs

1// Copyright 2026 AlphaOne LLC
2// SPDX-License-Identifier: Apache-2.0
3
4//! `ai-memory identity` subcommand — per-agent Ed25519 keypair lifecycle
5//! (Track H, Task H1).
6//!
7//! See [`crate::identity::keypair`] for the underlying lifecycle. This
8//! module is the thin clap wrapper that turns command-line input into
9//! the four verbs (`generate / import / list / export-pub`) and prints
10//! the result via the standard [`CliOutput`] writer pair.
11//!
12//! ## Hardware-backed key storage is OUT of OSS scope
13//!
14//! TPM 2.0, PKCS#11 HSMs, Apple Secure Enclave, and cloud KMS adapters
15//! are intentionally not in this subcommand. See the module-level
16//! comment on [`crate::identity::keypair`] and `ROADMAP.md` —
17//! AgenticMem™ is the commercial home for those backends.
18
19use std::path::{Path, PathBuf};
20
21use anyhow::{Context, Result, bail};
22use clap::{Args, Subcommand};
23use ed25519_dalek::SigningKey;
24
25use crate::cli::CliOutput;
26use crate::identity::{self, keypair};
27use crate::validate;
28
29/// JSON output field for the base64 public key (#1558 batch 6).
30const PUBLIC_KEY_B64_FIELD: &str = "public_key_b64";
31
32#[derive(Args)]
33pub struct IdentityArgs {
34    /// Override the default key storage directory.
35    /// Default by platform:
36    ///   Linux:   `~/.config/ai-memory/keys/`,
37    ///   macOS:   `~/Library/Application Support/ai-memory/keys/`,
38    ///   Windows: `%APPDATA%\ai-memory\keys\`.
39    /// Honors `AI_MEMORY_KEY_DIR` env var when this flag is omitted.
40    #[arg(long, value_name = "PATH", global = true)]
41    pub key_dir: Option<PathBuf>,
42    #[command(subcommand)]
43    pub action: IdentityAction,
44}
45
46#[derive(Subcommand)]
47pub enum IdentityAction {
48    /// Generate a fresh Ed25519 keypair for `--agent-id` (or the
49    /// NHI-hardened default if omitted) and persist it under the key
50    /// storage directory with strict 0600/0644 modes on Unix.
51    Generate {
52        /// Agent identifier. Defaults to the same NHI-hardened id the
53        /// rest of the CLI synthesizes (e.g. `host:<host>:pid-<pid>-<uuid8>`).
54        #[arg(long)]
55        agent_id: Option<String>,
56        /// Allow overwriting an existing keypair for `--agent-id`.
57        /// Without this flag, `generate` refuses on an existing id —
58        /// the safe default to prevent a typo from silently rotating
59        /// (and irrecoverably destroying) a daemon or peer key. Pass
60        /// `--force` only when you intend to rotate.
61        #[arg(long, default_value_t = false)]
62        force: bool,
63        /// Deprecated alias retained for backward compatibility with
64        /// the v0.7.0 pre-Round-4 flag surface. The default behavior
65        /// is now refuse-on-existing; this flag is a no-op. Use
66        /// `--force` to opt INTO overwrite.
67        #[arg(long, default_value_t = false, hide = true)]
68        no_overwrite: bool,
69    },
70    /// Import a keypair from on-disk files written by another tool.
71    /// `--pub` is required; `--priv` is optional (omit it to import a
72    /// public-only handle for verification, e.g., a peer's allowlist
73    /// entry).
74    Import {
75        /// Agent identifier the imported material will be saved under.
76        #[arg(long)]
77        agent_id: String,
78        /// Path to a 32-byte raw Ed25519 public key file.
79        #[arg(long = "pub", value_name = "PATH")]
80        public: PathBuf,
81        /// Optional path to a 32-byte raw Ed25519 private key file.
82        #[arg(long = "priv", value_name = "PATH")]
83        private: Option<PathBuf>,
84    },
85    /// List every keypair stored under the key storage directory.
86    /// Private keys are never loaded — `list` is safe to wire into
87    /// dashboards and shell autocompletion.
88    List,
89    /// Print a base64-encoded public key for `--agent-id` to stdout.
90    /// Stable URL-safe-no-padding form so the output can be pasted
91    /// into a Slack message or a peer allowlist file without binary
92    /// hazards.
93    ExportPub {
94        /// Agent identifier whose public key should be exported.
95        #[arg(long)]
96        agent_id: String,
97    },
98}
99
100/// Resolve the key storage directory from `--key-dir` (caller override)
101/// or the OSS default at `<config>/ai-memory/keys`.
102fn resolve_key_dir(override_dir: Option<&Path>) -> Result<PathBuf> {
103    if let Some(p) = override_dir {
104        return Ok(p.to_path_buf());
105    }
106    keypair::default_key_dir()
107}
108
109/// Resolve the agent_id for a CLI invocation: explicit `--agent-id`
110/// wins, otherwise fall back to the NHI default. We pass `None` for
111/// the MCP client so the resolution stops at the host-or-anonymous
112/// branch (CLI is not an MCP handshake).
113fn resolve_id(explicit: Option<&str>) -> Result<String> {
114    identity::resolve_agent_id(explicit, None)
115}
116
117/// `identity` handler.
118///
119/// Returns `Ok(())` on success, propagates errors otherwise. The
120/// caller is `daemon_runtime::dispatch_command` which prints the error
121/// + exits non-zero in the standard way.
122pub fn run(args: IdentityArgs, json_out: bool, out: &mut CliOutput<'_>) -> Result<()> {
123    let dir = resolve_key_dir(args.key_dir.as_deref())?;
124    match args.action {
125        IdentityAction::Generate {
126            agent_id,
127            force,
128            no_overwrite: _,
129        } => generate(&dir, agent_id.as_deref(), force, json_out, out),
130        IdentityAction::Import {
131            agent_id,
132            public,
133            private,
134        } => import(&dir, &agent_id, &public, private.as_deref(), json_out, out),
135        IdentityAction::List => list(&dir, json_out, out),
136        IdentityAction::ExportPub { agent_id } => export_pub(&dir, &agent_id, json_out, out),
137    }
138}
139
140fn generate(
141    dir: &Path,
142    explicit_agent_id: Option<&str>,
143    force: bool,
144    json_out: bool,
145    out: &mut CliOutput<'_>,
146) -> Result<()> {
147    // `identity generate` is an internal-bootstrap surface that must
148    // legitimately accept [`validate::RESERVED_AGENT_IDS`] sentinels
149    // (the daemon's own self-signing keypair label
150    // `DAEMON_KEYPAIR_LABEL` = "daemon", the federation-catchup label,
151    // etc.). Use [`validate::validate_agent_id_shape`] (shape-only,
152    // doesn't reject reserved sentinels) for the explicit caller path,
153    // bypassing the strict [`validate::validate_agent_id`] that
154    // `resolve_id` → `identity::resolve_agent_id` applies. The
155    // wire-side ingress boundaries (HTTP body, MCP tool param) still
156    // strict-validate at their own boundary — this carve-out is
157    // CLI-key-generation-only. Closes #1234 (RCA: this site was
158    // missed when #977 introduced RESERVED_AGENT_IDS + the
159    // shape/wire split).
160    let id = match explicit_agent_id {
161        Some(explicit) if !explicit.is_empty() => {
162            validate::validate_agent_id_shape(explicit)?;
163            explicit.to_string()
164        }
165        _ => resolve_id(explicit_agent_id)?,
166    };
167    let pub_path = dir.join(format!("{id}.pub"));
168    // Round-4 — refuse-by-default. The pre-Round-4 default was OVERWRITE,
169    // which let a typo silently rotate (and destroy) a daemon or peer
170    // keypair. Now `generate` refuses if a key already exists; the
171    // operator must pass `--force` to opt into rotation. The legacy
172    // `--no-overwrite` flag is preserved as a hidden no-op for
173    // backward compatibility with scripts that invoked it.
174    if !force && pub_path.exists() {
175        bail!(
176            "keypair for {id} already exists at {} (pass --force to rotate; refused by default to prevent accidental key overwrite)",
177            pub_path.display()
178        );
179    }
180    let kp = keypair::generate(&id)?;
181    keypair::save(&kp, dir)?;
182    if json_out {
183        writeln!(
184            out.stdout,
185            "{}",
186            serde_json::json!({
187                "generated": true,
188                "agent_id": id,
189                "key_dir": dir,
190                (PUBLIC_KEY_B64_FIELD): kp.public_base64(),
191            })
192        )?;
193    } else {
194        writeln!(out.stdout, "generated keypair for {id}")?;
195        writeln!(out.stdout, "  key_dir = {}", dir.display())?;
196        writeln!(out.stdout, "  pub_b64 = {}", kp.public_base64())?;
197    }
198    Ok(())
199}
200
201fn import(
202    dir: &Path,
203    agent_id: &str,
204    pub_path: &Path,
205    priv_path: Option<&Path>,
206    json_out: bool,
207    out: &mut CliOutput<'_>,
208) -> Result<()> {
209    crate::validate::validate_agent_id(agent_id)?;
210    let pub_bytes = keypair::read_raw_key_file(pub_path)
211        .with_context(|| format!("reading --pub {}", pub_path.display()))?;
212    let public = ed25519_dalek::VerifyingKey::from_bytes(&pub_bytes)
213        .with_context(|| "decoding imported public key".to_string())?;
214
215    let private = if let Some(p) = priv_path {
216        let priv_bytes = keypair::read_raw_key_file(p)
217            .with_context(|| format!("reading --priv {}", p.display()))?;
218        let signing = SigningKey::from_bytes(&priv_bytes);
219        // Cross-check before persisting — refuse mismatched pairs.
220        if signing.verifying_key().to_bytes() != public.to_bytes() {
221            bail!(
222                "imported --priv {} does not match --pub {}",
223                p.display(),
224                pub_path.display()
225            );
226        }
227        Some(signing)
228    } else {
229        None
230    };
231
232    let kp = keypair::AgentKeypair {
233        agent_id: agent_id.to_string(),
234        public,
235        private,
236    };
237    if kp.private.is_some() {
238        keypair::save(&kp, dir)?;
239    } else {
240        keypair::save_public_only(&kp, dir)?;
241    }
242
243    if json_out {
244        writeln!(
245            out.stdout,
246            "{}",
247            serde_json::json!({
248                "imported": true,
249                "agent_id": agent_id,
250                "key_dir": dir,
251                "private_imported": kp.private.is_some(),
252                (PUBLIC_KEY_B64_FIELD): kp.public_base64(),
253            })
254        )?;
255    } else {
256        writeln!(
257            out.stdout,
258            "imported keypair for {agent_id} (private={})",
259            if kp.private.is_some() { "yes" } else { "no" }
260        )?;
261        writeln!(out.stdout, "  key_dir = {}", dir.display())?;
262        writeln!(out.stdout, "  pub_b64 = {}", kp.public_base64())?;
263    }
264    Ok(())
265}
266
267fn list(dir: &Path, json_out: bool, out: &mut CliOutput<'_>) -> Result<()> {
268    let keys = keypair::list(dir)?;
269    if json_out {
270        let entries: Vec<_> = keys
271            .iter()
272            .map(|k| {
273                serde_json::json!({
274                    "agent_id": k.agent_id,
275                    (PUBLIC_KEY_B64_FIELD): k.public_base64(),
276                })
277            })
278            .collect();
279        writeln!(
280            out.stdout,
281            "{}",
282            serde_json::json!({
283                "count": entries.len(),
284                "key_dir": dir,
285                "keys": entries,
286            })
287        )?;
288    } else if keys.is_empty() {
289        writeln!(out.stdout, "no keypairs in {}", dir.display())?;
290    } else {
291        for k in &keys {
292            writeln!(out.stdout, "{}  {}", k.agent_id, k.public_base64())?;
293        }
294        writeln!(out.stdout, "{} keypair(s) in {}", keys.len(), dir.display())?;
295    }
296    Ok(())
297}
298
299fn export_pub(dir: &Path, agent_id: &str, json_out: bool, out: &mut CliOutput<'_>) -> Result<()> {
300    let kp = keypair::load(agent_id, dir)?;
301    if json_out {
302        writeln!(
303            out.stdout,
304            "{}",
305            serde_json::json!({
306                "agent_id": agent_id,
307                (PUBLIC_KEY_B64_FIELD): kp.public_base64(),
308            })
309        )?;
310    } else {
311        // Plain text path: just the base64 — pipe-friendly for
312        // `ai-memory identity export-pub --agent-id alice | xclip`.
313        writeln!(out.stdout, "{}", kp.public_base64())?;
314    }
315    Ok(())
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::cli::test_utils::TestEnv;
322
323    fn fresh_env() -> (TestEnv, tempfile::TempDir) {
324        let env = TestEnv::fresh();
325        let dir = tempfile::TempDir::new().unwrap();
326        (env, dir)
327    }
328
329    #[test]
330    fn generate_then_list_then_export() {
331        let (mut env, dir) = fresh_env();
332        let dir_path = dir.path().to_path_buf();
333
334        // generate
335        {
336            let mut out = env.output();
337            run(
338                IdentityArgs {
339                    key_dir: Some(dir_path.clone()),
340                    action: IdentityAction::Generate {
341                        agent_id: Some("alice".to_string()),
342                        force: false,
343                        no_overwrite: false,
344                    },
345                },
346                false,
347                &mut out,
348            )
349            .unwrap();
350        }
351        let stdout = env.stdout_str().to_string();
352        assert!(
353            stdout.contains("generated keypair for alice"),
354            "got: {stdout}"
355        );
356
357        // list
358        env.stdout.clear();
359        env.stderr.clear();
360        {
361            let mut out = env.output();
362            run(
363                IdentityArgs {
364                    key_dir: Some(dir_path.clone()),
365                    action: IdentityAction::List,
366                },
367                false,
368                &mut out,
369            )
370            .unwrap();
371        }
372        let stdout = env.stdout_str().to_string();
373        assert!(stdout.contains("alice"), "got: {stdout}");
374        assert!(stdout.contains("1 keypair(s)"), "got: {stdout}");
375
376        // export-pub (text mode prints just the base64)
377        env.stdout.clear();
378        env.stderr.clear();
379        {
380            let mut out = env.output();
381            run(
382                IdentityArgs {
383                    key_dir: Some(dir_path),
384                    action: IdentityAction::ExportPub {
385                        agent_id: "alice".to_string(),
386                    },
387                },
388                false,
389                &mut out,
390            )
391            .unwrap();
392        }
393        let stdout = env.stdout_str().trim().to_string();
394        // Should round-trip through the keypair decoder.
395        let decoded = keypair::decode_public_base64(&stdout).expect("decode");
396        assert_eq!(decoded.to_bytes().len(), 32);
397    }
398
399    #[test]
400    fn generate_refuses_existing_without_force() {
401        // Round-4 — refuse-by-default semantics. A second `generate`
402        // for an existing agent_id MUST fail unless `--force` is
403        // passed. The legacy `--no-overwrite` flag is preserved as a
404        // hidden no-op for backward compatibility with v0.7.0
405        // pre-Round-4 scripts.
406        let (mut env, dir) = fresh_env();
407        let dir_path = dir.path().to_path_buf();
408        // First generate
409        {
410            let mut out = env.output();
411            run(
412                IdentityArgs {
413                    key_dir: Some(dir_path.clone()),
414                    action: IdentityAction::Generate {
415                        agent_id: Some("alice".to_string()),
416                        force: false,
417                        no_overwrite: false,
418                    },
419                },
420                false,
421                &mut out,
422            )
423            .unwrap();
424        }
425        env.stdout.clear();
426        env.stderr.clear();
427        // Second generate WITHOUT --force should error (refuse-by-default).
428        let result = {
429            let mut out = env.output();
430            run(
431                IdentityArgs {
432                    key_dir: Some(dir_path.clone()),
433                    action: IdentityAction::Generate {
434                        agent_id: Some("alice".to_string()),
435                        force: false,
436                        no_overwrite: false,
437                    },
438                },
439                false,
440                &mut out,
441            )
442        };
443        let err = result.unwrap_err();
444        let msg = format!("{err:#}");
445        assert!(msg.contains("already exists"), "got: {msg}");
446        assert!(
447            msg.contains("--force"),
448            "error message should guide operator toward --force, got: {msg}"
449        );
450
451        // Third generate WITH --force should succeed (intentional rotation).
452        env.stdout.clear();
453        env.stderr.clear();
454        {
455            let mut out = env.output();
456            run(
457                IdentityArgs {
458                    key_dir: Some(dir_path),
459                    action: IdentityAction::Generate {
460                        agent_id: Some("alice".to_string()),
461                        force: true,
462                        no_overwrite: false,
463                    },
464                },
465                false,
466                &mut out,
467            )
468            .unwrap();
469        }
470        let stdout = env.stdout_str().to_string();
471        assert!(
472            stdout.contains("generated keypair for alice"),
473            "rotation with --force did not succeed: {stdout}"
474        );
475    }
476
477    #[test]
478    fn list_json_emits_keys_array() {
479        let (mut env, dir) = fresh_env();
480        let dir_path = dir.path().to_path_buf();
481        {
482            let mut out = env.output();
483            run(
484                IdentityArgs {
485                    key_dir: Some(dir_path.clone()),
486                    action: IdentityAction::Generate {
487                        agent_id: Some("alice".to_string()),
488                        force: false,
489                        no_overwrite: false,
490                    },
491                },
492                true,
493                &mut out,
494            )
495            .unwrap();
496        }
497        env.stdout.clear();
498        env.stderr.clear();
499        {
500            let mut out = env.output();
501            run(
502                IdentityArgs {
503                    key_dir: Some(dir_path),
504                    action: IdentityAction::List,
505                },
506                true,
507                &mut out,
508            )
509            .unwrap();
510        }
511        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
512        assert_eq!(v["count"].as_u64().unwrap(), 1);
513        assert_eq!(v["keys"][0]["agent_id"].as_str().unwrap(), "alice");
514        assert!(v["keys"][0]["public_key_b64"].as_str().unwrap().len() > 10);
515    }
516
517    #[test]
518    fn import_round_trip_through_raw_files() {
519        let (mut env, dir) = fresh_env();
520        let dir_path = dir.path().to_path_buf();
521
522        // Create a fresh keypair, dump raw bytes to disk, then `import`.
523        let kp = keypair::generate("alice").unwrap();
524        let pub_bytes = kp.public.to_bytes();
525        let priv_bytes = kp.private.as_ref().unwrap().to_bytes();
526        let staging = tempfile::TempDir::new().unwrap();
527        let pub_file = staging.path().join("a.pub");
528        let priv_file = staging.path().join("a.priv");
529        std::fs::write(&pub_file, pub_bytes).unwrap();
530        std::fs::write(&priv_file, priv_bytes).unwrap();
531
532        {
533            let mut out = env.output();
534            run(
535                IdentityArgs {
536                    key_dir: Some(dir_path.clone()),
537                    action: IdentityAction::Import {
538                        agent_id: "alice".to_string(),
539                        public: pub_file.clone(),
540                        private: Some(priv_file.clone()),
541                    },
542                },
543                false,
544                &mut out,
545            )
546            .unwrap();
547        }
548        let stdout = env.stdout_str().to_string();
549        assert!(
550            stdout.contains("imported keypair for alice"),
551            "got: {stdout}"
552        );
553        // Round-trip through load.
554        let loaded = keypair::load("alice", &dir_path).unwrap();
555        assert_eq!(loaded.public.to_bytes(), pub_bytes);
556        assert!(loaded.can_sign());
557    }
558
559    #[test]
560    fn import_refuses_priv_pub_mismatch() {
561        let (mut env, dir) = fresh_env();
562        let dir_path = dir.path().to_path_buf();
563        let alice = keypair::generate("alice").unwrap();
564        let bob = keypair::generate("bob").unwrap();
565        let staging = tempfile::TempDir::new().unwrap();
566        let pub_file = staging.path().join("alice.pub");
567        let priv_file = staging.path().join("bob.priv");
568        std::fs::write(&pub_file, alice.public.to_bytes()).unwrap();
569        std::fs::write(&priv_file, bob.private.as_ref().unwrap().to_bytes()).unwrap();
570
571        let result = {
572            let mut out = env.output();
573            run(
574                IdentityArgs {
575                    key_dir: Some(dir_path),
576                    action: IdentityAction::Import {
577                        agent_id: "alice".to_string(),
578                        public: pub_file,
579                        private: Some(priv_file),
580                    },
581                },
582                false,
583                &mut out,
584            )
585        };
586        let err = result.unwrap_err();
587        let msg = format!("{err:#}");
588        assert!(msg.contains("does not match"), "got: {msg}");
589    }
590
591    // ------------------------------------------------------------------
592    // L0.7-3 chunk-e2 — coverage uplift to ≥95%.
593    // ------------------------------------------------------------------
594
595    #[test]
596    fn generate_json_mode_emits_payload() {
597        // json_out=true on generate exercises the JSON emission branch
598        // (lines 159-169) that the existing happy-path test skipped.
599        let (mut env, dir) = fresh_env();
600        let dir_path = dir.path().to_path_buf();
601        {
602            let mut out = env.output();
603            run(
604                IdentityArgs {
605                    key_dir: Some(dir_path),
606                    action: IdentityAction::Generate {
607                        agent_id: Some("carol".to_string()),
608                        force: false,
609                        no_overwrite: false,
610                    },
611                },
612                true,
613                &mut out,
614            )
615            .unwrap();
616        }
617        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
618        assert_eq!(v["generated"], true);
619        assert_eq!(v["agent_id"].as_str().unwrap(), "carol");
620        assert!(v["public_key_b64"].as_str().unwrap().len() > 10);
621        assert!(v["key_dir"].is_string());
622    }
623
624    #[test]
625    fn import_public_only_text_mode() {
626        // Public-only import (priv=None) covers the `private = None`
627        // branch (line 206), the `save_public_only` branch (217), and
628        // the text-mode "(private=no)" emission (lines 233-239).
629        let (mut env, dir) = fresh_env();
630        let dir_path = dir.path().to_path_buf();
631        let kp = keypair::generate("dave").unwrap();
632        let staging = tempfile::TempDir::new().unwrap();
633        let pub_file = staging.path().join("d.pub");
634        std::fs::write(&pub_file, kp.public.to_bytes()).unwrap();
635        {
636            let mut out = env.output();
637            run(
638                IdentityArgs {
639                    key_dir: Some(dir_path.clone()),
640                    action: IdentityAction::Import {
641                        agent_id: "dave".to_string(),
642                        public: pub_file,
643                        private: None,
644                    },
645                },
646                false,
647                &mut out,
648            )
649            .unwrap();
650        }
651        let stdout = env.stdout_str().to_string();
652        assert!(
653            stdout.contains("imported keypair for dave"),
654            "got: {stdout}"
655        );
656        assert!(stdout.contains("(private=no)"), "got: {stdout}");
657        // Round-trip — load should succeed and report no signing key.
658        let loaded = keypair::load("dave", &dir_path).unwrap();
659        assert!(!loaded.can_sign());
660    }
661
662    #[test]
663    fn import_public_only_json_mode() {
664        // JSON emission covers lines 221-231 with `private_imported=false`.
665        let (mut env, dir) = fresh_env();
666        let dir_path = dir.path().to_path_buf();
667        let kp = keypair::generate("eve").unwrap();
668        let staging = tempfile::TempDir::new().unwrap();
669        let pub_file = staging.path().join("e.pub");
670        std::fs::write(&pub_file, kp.public.to_bytes()).unwrap();
671        {
672            let mut out = env.output();
673            run(
674                IdentityArgs {
675                    key_dir: Some(dir_path),
676                    action: IdentityAction::Import {
677                        agent_id: "eve".to_string(),
678                        public: pub_file,
679                        private: None,
680                    },
681                },
682                true,
683                &mut out,
684            )
685            .unwrap();
686        }
687        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
688        assert_eq!(v["imported"], true);
689        assert_eq!(v["agent_id"].as_str().unwrap(), "eve");
690        assert_eq!(v["private_imported"], false);
691        assert!(v["public_key_b64"].as_str().unwrap().len() > 10);
692    }
693
694    #[test]
695    fn import_with_priv_json_mode_reports_private_imported_true() {
696        // Mirrors the existing private import test but in json mode to
697        // cover lines 220-231 with `private_imported=true`.
698        let (mut env, dir) = fresh_env();
699        let dir_path = dir.path().to_path_buf();
700        let kp = keypair::generate("frank").unwrap();
701        let staging = tempfile::TempDir::new().unwrap();
702        let pub_file = staging.path().join("f.pub");
703        let priv_file = staging.path().join("f.priv");
704        std::fs::write(&pub_file, kp.public.to_bytes()).unwrap();
705        std::fs::write(&priv_file, kp.private.as_ref().unwrap().to_bytes()).unwrap();
706        {
707            let mut out = env.output();
708            run(
709                IdentityArgs {
710                    key_dir: Some(dir_path),
711                    action: IdentityAction::Import {
712                        agent_id: "frank".to_string(),
713                        public: pub_file,
714                        private: Some(priv_file),
715                    },
716                },
717                true,
718                &mut out,
719            )
720            .unwrap();
721        }
722        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
723        assert_eq!(v["private_imported"], true);
724    }
725
726    #[test]
727    fn list_empty_text_mode_emits_no_keypairs() {
728        // Empty list in text mode (line 266).
729        let (mut env, dir) = fresh_env();
730        let dir_path = dir.path().to_path_buf();
731        {
732            let mut out = env.output();
733            run(
734                IdentityArgs {
735                    key_dir: Some(dir_path),
736                    action: IdentityAction::List,
737                },
738                false,
739                &mut out,
740            )
741            .unwrap();
742        }
743        assert!(env.stdout_str().contains("no keypairs in"));
744    }
745
746    #[test]
747    fn list_empty_json_mode_emits_count_zero() {
748        // JSON list with zero entries — covers the json branch with the
749        // empty `entries` collection (line 264).
750        let (mut env, dir) = fresh_env();
751        let dir_path = dir.path().to_path_buf();
752        {
753            let mut out = env.output();
754            run(
755                IdentityArgs {
756                    key_dir: Some(dir_path),
757                    action: IdentityAction::List,
758                },
759                true,
760                &mut out,
761            )
762            .unwrap();
763        }
764        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
765        assert_eq!(v["count"].as_u64().unwrap(), 0);
766        assert!(v["keys"].as_array().unwrap().is_empty());
767    }
768
769    #[test]
770    fn export_pub_json_mode_emits_payload() {
771        // JSON-mode export-pub (lines 278-286).
772        let (mut env, dir) = fresh_env();
773        let dir_path = dir.path().to_path_buf();
774        // First generate so there is something to export.
775        {
776            let mut out = env.output();
777            run(
778                IdentityArgs {
779                    key_dir: Some(dir_path.clone()),
780                    action: IdentityAction::Generate {
781                        agent_id: Some("grace".to_string()),
782                        force: false,
783                        no_overwrite: false,
784                    },
785                },
786                false,
787                &mut out,
788            )
789            .unwrap();
790        }
791        env.stdout.clear();
792        env.stderr.clear();
793        {
794            let mut out = env.output();
795            run(
796                IdentityArgs {
797                    key_dir: Some(dir_path),
798                    action: IdentityAction::ExportPub {
799                        agent_id: "grace".to_string(),
800                    },
801                },
802                true,
803                &mut out,
804            )
805            .unwrap();
806        }
807        let v: serde_json::Value = serde_json::from_str(env.stdout_str().trim()).unwrap();
808        assert_eq!(v["agent_id"].as_str().unwrap(), "grace");
809        assert!(v["public_key_b64"].as_str().unwrap().len() > 10);
810    }
811
812    #[test]
813    fn resolve_key_dir_falls_through_to_default() {
814        // No override path → falls through to `keypair::default_key_dir()`
815        // (line 102). We don't assert on the contents (HOME-dependent),
816        // only that we reach the call and get a `Result`.
817        let r = resolve_key_dir(None);
818        // The default-key-dir resolution depends on dirs::config_dir(),
819        // which is generally available on macOS/Linux test hosts. Tolerate
820        // both Ok (typical) and Err (CI without HOME).
821        match r {
822            Ok(p) => assert!(p.as_os_str().len() > 0),
823            Err(_) => {}
824        }
825    }
826}