Skip to main content

auths_cli/commands/init/
mod.rs

1//! One-command guided setup wizard for Auths.
2//!
3//! Applies Gather → Execute → Display for each profile, delegating all
4//! business logic to `auths-sdk`.
5
6mod display;
7mod gather;
8mod guided;
9mod helpers;
10mod prompts;
11
12use anyhow::{Result, anyhow};
13use clap::{Args, ValueEnum};
14use std::io::IsTerminal;
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use auths_sdk::domains::identity::registration::DEFAULT_REGISTRY_URL;
19use auths_sdk::domains::identity::service::initialize;
20use auths_sdk::domains::identity::types::IdentityConfig;
21use auths_sdk::domains::identity::types::InitializeResult;
22use auths_sdk::domains::signing::types::GitSigningScope;
23use auths_sdk::keychain::KeyStorage;
24use auths_sdk::ports::git_config::GitConfigProvider;
25use auths_sdk::signing::PrefilledPassphraseProvider;
26use auths_sdk::signing::StorageSigner;
27
28use crate::adapters::git_config::SystemGitConfigProvider;
29use crate::config::CliConfig;
30use crate::factories::storage::build_auths_context;
31use crate::ux::format::Output;
32
33use display::{
34    display_agent_dry_run, display_agent_result, display_ci_result, display_developer_result,
35};
36use gather::{
37    ensure_registry_dir, gather_agent_config, gather_ci_config, gather_developer_config,
38    submit_registration,
39};
40use guided::GuidedSetup;
41use helpers::{get_auths_repo_path, offer_shell_completions};
42use prompts::{prompt_platform_verification, prompt_profile};
43
44const DEFAULT_KEY_ALIAS: &str = "main";
45
46/// Setup profile for identity initialization.
47#[derive(Debug, Clone, Copy, ValueEnum)]
48pub enum InitProfile {
49    /// Full local development setup with keychain, identity, device linking, and git signing
50    Developer,
51    /// Temporary signing identity for CI/CD pipelines
52    Ci,
53    /// Restricted signing identity for AI agents
54    Agent,
55}
56
57impl std::fmt::Display for InitProfile {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        match self {
60            InitProfile::Developer => write!(f, "developer"),
61            InitProfile::Ci => write!(f, "ci"),
62            InitProfile::Agent => write!(f, "agent"),
63        }
64    }
65}
66
67/// Initializes Auths identity with a guided setup wizard.
68///
69/// Supports three profiles (developer, ci, agent) covering the most common
70/// deployment scenarios. Interactive by default on TTY; pass `--non-interactive`
71/// for scripted or CI use, or `--interactive` to force prompts.
72///
73/// Usage:
74/// ```ignore
75/// // auths init
76/// // auths init --profile developer --non-interactive
77/// // auths init --interactive --profile developer
78/// ```
79#[derive(Args, Debug, Clone)]
80#[command(
81    name = "init",
82    about = "Create your signing identity and configure Git",
83    after_help = "Examples:
84  auths init                              # Interactive setup wizard
85  auths init --profile developer          # Developer profile with prompts
86  auths init --profile ci --non-interactive # Automated CI setup
87
88Profiles:
89  developer — Local setup: keychain, Git signing, platform identity
90  ci        — Temporary signing identity for CI/CD runners
91  agent     — Restricted signing identity for AI agents
92
93Related:
94  auths status  — Check setup completion
95  auths doctor  — Run health checks"
96)]
97pub struct InitCommand {
98    /// Force interactive prompts (errors if not a TTY)
99    #[clap(long, conflicts_with = "non_interactive")]
100    pub interactive: bool,
101
102    /// Skip interactive prompts and use sensible defaults
103    #[clap(long, conflicts_with = "interactive")]
104    pub non_interactive: bool,
105
106    /// Preset profile: developer, ci, or agent
107    #[clap(long, value_enum)]
108    pub profile: Option<InitProfile>,
109
110    /// Key alias for the identity key (default: main)
111    #[clap(long, default_value = DEFAULT_KEY_ALIAS)]
112    pub key_alias: String,
113
114    /// Force overwrite if identity already exists
115    #[clap(long)]
116    pub force: bool,
117
118    /// Preview agent configuration without creating files or identities
119    #[clap(long)]
120    pub dry_run: bool,
121
122    /// Registry URL for identity registration
123    #[clap(long, env = "AUTHS_REGISTRY_URL", default_value = DEFAULT_REGISTRY_URL)]
124    pub registry: String,
125
126    /// Register identity with the Auths Registry after creation
127    #[clap(long)]
128    pub register: bool,
129
130    /// Scaffold a GitHub Actions workflow using the auths attest-action
131    #[clap(long)]
132    pub github_action: bool,
133
134    /// Number of device slots for a multi-key KEL (default 1).
135    ///
136    /// Values > 1 require `--signing-threshold` and `--rotation-threshold`.
137    /// Multi-device init today runs a single-device inception and points the
138    /// operator at `auths id expand` for the device-expansion rotation; the
139    /// full atomic multi-device inception path is wired through later.
140    #[clap(long, default_value_t = 1)]
141    pub device_count: u8,
142
143    /// Signing threshold: scalar integer (e.g. `"2"`) or fraction list
144    /// (e.g. `"1/2,1/2,1/2"`). Required when `--device-count > 1`.
145    #[clap(long)]
146    pub signing_threshold: Option<String>,
147
148    /// Rotation (next) threshold, same format as `--signing-threshold`.
149    #[clap(long)]
150    pub rotation_threshold: Option<String>,
151}
152
153/// Parse a threshold argument from the CLI.
154///
155/// Accepts either a plain integer (`"2"`, `"a"` hex) for `Threshold::Simple`
156/// or a comma-separated fraction list (`"1/2,1/2,1/2"`) for
157/// `Threshold::Weighted`. Rejects mixed shapes (`"2,3"` with no `/`) with
158/// a clear error.
159pub fn parse_threshold_cli(
160    s: &str,
161    key_count: usize,
162) -> Result<auths_keri::Threshold, anyhow::Error> {
163    let trimmed = s.trim();
164    if trimmed.is_empty() {
165        return Err(anyhow!("threshold value is empty"));
166    }
167
168    if trimmed.contains(',') || trimmed.contains('/') {
169        // Fraction list path.
170        let fractions: Result<Vec<auths_keri::Fraction>, _> = trimmed
171            .split(',')
172            .map(|part| part.trim().parse::<auths_keri::Fraction>())
173            .collect();
174        let fractions = fractions.map_err(|e| {
175            anyhow!(
176                "invalid fraction in threshold {:?}: {e}. Provide either an integer count (e.g. \"2\") or a comma-separated fraction list (e.g. \"1/2,1/2,1/2\").",
177                trimmed
178            )
179        })?;
180        if fractions.len() != key_count {
181            return Err(anyhow!(
182                "threshold fraction list has {} entries for device_count {}",
183                fractions.len(),
184                key_count
185            ));
186        }
187        Ok(auths_keri::Threshold::Weighted(vec![fractions]))
188    } else {
189        let n = u64::from_str_radix(trimmed, 16).map_err(|_| {
190            anyhow!(
191                "invalid scalar threshold {:?}: expected hex integer (e.g. \"2\") or fraction list (e.g. \"1/2,1/2,1/2\")",
192                trimmed
193            )
194        })?;
195        if (n as usize) > key_count {
196            return Err(anyhow!(
197                "threshold {} exceeds device count {}",
198                n,
199                key_count
200            ));
201        }
202        if n == 0 && key_count > 0 {
203            return Err(anyhow!(
204                "threshold 0 is unsatisfiable for non-empty device set"
205            ));
206        }
207        Ok(auths_keri::Threshold::Simple(n))
208    }
209}
210
211fn resolve_interactive(cmd: &InitCommand) -> Result<bool> {
212    if cmd.interactive {
213        if !std::io::stdin().is_terminal() {
214            return Err(anyhow!(
215                "--interactive requires a TTY (stdin is not a terminal)"
216            ));
217        }
218        Ok(true)
219    } else if cmd.non_interactive {
220        Ok(false)
221    } else {
222        Ok(std::io::stdin().is_terminal())
223    }
224}
225
226/// Handle the `init` command with Gather → Execute → Display pattern.
227///
228/// Args:
229/// * `cmd`: Parsed [`InitCommand`] from the CLI.
230/// * `ctx`: CLI configuration with passphrase provider and repo path.
231///
232/// Usage:
233/// ```ignore
234/// handle_init(cmd, &ctx)?;
235/// ```
236pub fn handle_init(
237    cmd: InitCommand,
238    ctx: &CliConfig,
239    now: chrono::DateTime<chrono::Utc>,
240) -> Result<()> {
241    let out = Output::new();
242
243    if cmd.github_action {
244        return helpers::scaffold_github_action(&out);
245    }
246
247    // Validate multi-device flag combinations at CLI parse time.
248    let device_count = cmd.device_count.max(1) as usize;
249    if device_count > 1 {
250        let kt_str = cmd
251            .signing_threshold
252            .as_deref()
253            .ok_or_else(|| anyhow!("--signing-threshold is required when --device-count > 1"))?;
254        let nt_str = cmd
255            .rotation_threshold
256            .as_deref()
257            .ok_or_else(|| anyhow!("--rotation-threshold is required when --device-count > 1"))?;
258        let _kt = parse_threshold_cli(kt_str, device_count)?;
259        let _nt = parse_threshold_cli(nt_str, device_count)?;
260        return Err(anyhow!(
261            "multi-device init (--device-count > 1) is not yet wired through the developer setup flow. \
262             Run `auths init` for a single-device identity, then `auths id expand --add-device <CURVE>` \
263             (repeatable) with the desired thresholds to convert into a multi-device KEL."
264        ));
265    }
266    if cmd.signing_threshold.is_some() && device_count == 1 {
267        let kt = parse_threshold_cli(cmd.signing_threshold.as_deref().unwrap_or("1"), 1)?;
268        if !matches!(kt, auths_keri::Threshold::Simple(1)) {
269            return Err(anyhow!(
270                "single-device init requires --signing-threshold to be 1 or omitted"
271            ));
272        }
273    }
274
275    let interactive = resolve_interactive(&cmd)?;
276
277    let profile = match cmd.profile {
278        Some(p) => p,
279        None if !interactive => {
280            out.println("No profile specified in non-interactive mode, defaulting to developer.");
281            InitProfile::Developer
282        }
283        None => prompt_profile(&out)?,
284    };
285
286    out.print_heading(&format!("Auths Setup ({})", profile));
287    out.println("=".repeat(40).as_str());
288
289    match profile {
290        InitProfile::Developer => run_developer_setup(interactive, &out, &cmd, ctx, now)?,
291        InitProfile::Ci => run_ci_setup(&out, ctx)?,
292        InitProfile::Agent => run_agent_setup(interactive, &out, &cmd, ctx)?,
293    }
294
295    Ok(())
296}
297
298fn run_developer_setup(
299    interactive: bool,
300    out: &Output,
301    cmd: &InitCommand,
302    ctx: &CliConfig,
303    now: chrono::DateTime<chrono::Utc>,
304) -> Result<()> {
305    let mut guide = GuidedSetup::new(out, guided::developer_steps(), interactive);
306
307    // GATHER
308    guide.section("Prerequisites & Configuration");
309    let (keychain, mut config) = gather_developer_config(interactive, out, cmd)?;
310    let registry_path = get_auths_repo_path()?;
311    ensure_registry_dir(&registry_path)?;
312
313    let sign_binary_path = which::which("auths-sign").ok();
314    if let Some(ref path) = sign_binary_path {
315        config.sign_binary_path = Some(path.clone());
316    }
317    let git_config_provider: Option<Box<dyn GitConfigProvider>> = match &config.git_signing_scope {
318        GitSigningScope::Skip => None,
319        GitSigningScope::Global => Some(Box::new(SystemGitConfigProvider::global())),
320        GitSigningScope::Local { repo_path } => {
321            Some(Box::new(SystemGitConfigProvider::local(repo_path.clone())))
322        }
323    };
324
325    // EXECUTE
326    guide.section("Creating Identity");
327    let sdk_ctx = build_auths_context(&registry_path, &ctx.env_config, None)?;
328    let keychain_arc: Arc<dyn KeyStorage + Send + Sync> = Arc::from(keychain);
329    let signer = StorageSigner::new(Arc::clone(&keychain_arc));
330    let result = initialize(
331        IdentityConfig::Developer(config),
332        &sdk_ctx,
333        keychain_arc,
334        &signer,
335        ctx.passphrase_provider.as_ref(),
336        git_config_provider.as_deref(),
337    )?;
338    let result = match result {
339        InitializeResult::Developer(r) => r,
340        _ => unreachable!(),
341    };
342
343    out.print_success(&format!("Identity created: {}", &result.identity_did));
344    out.print_success(&format!(
345        "This device authorized: {}",
346        result.device_did.as_str()
347    ));
348
349    // PLATFORM VERIFICATION
350    guide.section("Platform Verification");
351    let proof_url = if interactive && cmd.register {
352        out.print_info("Link your GitHub account");
353        out.newline();
354        match prompt_platform_verification(
355            out,
356            Arc::clone(&ctx.passphrase_provider),
357            &ctx.env_config,
358            now,
359        )? {
360            Some((url, _username)) => {
361                out.print_success(&format!("GitHub identity linked: {}", url));
362                Some(url)
363            }
364            None => {
365                out.println("  Continuing as anonymous identity");
366                None
367            }
368        }
369    } else {
370        None
371    };
372
373    // POST-SETUP
374    guide.section("Shell & Signing Setup");
375    offer_shell_completions(interactive, out)?;
376
377    // Commit-time trailers: install the prepare-commit-msg hook and point
378    // core.hooksPath at it, so a plain `git commit` carries the Auths-Id /
379    // Auths-Device trailers `auths verify` replays — zero extra commands.
380    // Trailer values come from the signer resolver (same source as `auths sign`):
381    // on the root machine Auths-Device is the root `did:keri:` itself, on a
382    // delegate it is the device's delegated AID — always a replayable KEL.
383    if let Some(git_config) = git_config_provider.as_deref() {
384        match auths_sdk::domains::identity::local::resolve_local_signer(&sdk_ctx) {
385            Ok(signer) => {
386                match auths_sdk::workflows::commit_hooks::enable_commit_trailers(
387                    &registry_path,
388                    &signer.root_did,
389                    &signer.signer_did,
390                    git_config,
391                ) {
392                    Ok(()) => {
393                        // Refresh stamps the current KEL position (Auths-Anchor-Seq)
394                        // into the trailer file the hook reads.
395                        let _ = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(
396                            &sdk_ctx,
397                            &registry_path,
398                        );
399                        out.println("  Commit trailers enabled (prepare-commit-msg hook)");
400                    }
401                    Err(e) => out.println(&format!("  Note: could not install commit hook ({e})")),
402                }
403            }
404            Err(e) => out.println(&format!("  Note: could not resolve signer for hook ({e})")),
405        }
406    }
407
408    // Pin the local identity as a trusted root for KEL-native verification (Epic B):
409    // the committed `<repo>/.auths/roots` is the root of trust — no allowed_signers file.
410    // The hook seeds the same pin into every repo on first signed commit; this covers
411    // the repo the user is standing in right now.
412    if let Ok(output) = crate::subprocess::git_command(&["rev-parse", "--show-toplevel"]).output()
413        && output.status.success()
414    {
415        let root = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
416        let root_did = result.identity_did.to_string();
417        match auths_sdk::workflows::roots::add_pinned_root(
418            &crate::adapters::config_store::FileConfigStore,
419            &root.join(".auths"),
420            &root_did,
421        ) {
422            Ok(()) => out.println(&format!("  Pinned trusted root: {}", root_did)),
423            Err(e) => out.println(&format!("  Note: could not pin trusted root ({e})")),
424        }
425    }
426
427    // REGISTRATION & DISPLAY
428    guide.section("Registration & Summary");
429    let registered = submit_registration(
430        &get_auths_repo_path()?,
431        &cmd.registry,
432        proof_url,
433        !cmd.register, // skip unless --register is explicitly passed
434        out,
435    );
436    display_developer_result(out, &result, registered.as_deref());
437
438    Ok(())
439}
440
441fn run_ci_setup(out: &Output, ctx: &CliConfig) -> Result<()> {
442    let mut guide = GuidedSetup::new(out, guided::ci_steps(), false);
443
444    // GATHER
445    guide.section("CI Environment Detection");
446    let (ci_env, config, keychain, passphrase_str) =
447        gather_ci_config(out, ctx.repo_path.as_deref())?;
448    let registry_path = config.registry_path.clone();
449    ensure_registry_dir(&registry_path)?;
450
451    // EXECUTE
452    guide.section("Creating CI Identity");
453    // gather_ci_config set the file-backend env vars; read them fresh so the SDK
454    // context's keychain matches the one we just built (ctx.env_config predates them).
455    let ci_env_config = auths_sdk::core_config::EnvironmentConfig::from_env();
456    let sdk_ctx = build_auths_context(&registry_path, &ci_env_config, None)?;
457    let keychain_arc: Arc<dyn KeyStorage + Send + Sync> = Arc::from(keychain);
458    let signer = StorageSigner::new(Arc::clone(&keychain_arc));
459    let provider = PrefilledPassphraseProvider::new(&passphrase_str);
460    let result = initialize(
461        IdentityConfig::Ci(config),
462        &sdk_ctx,
463        keychain_arc,
464        &signer,
465        &provider,
466        None,
467    )?;
468    let result = match result {
469        InitializeResult::Ci(r) => r,
470        _ => unreachable!(),
471    };
472
473    // DISPLAY
474    guide.section("Summary");
475    display_ci_result(out, &result, ci_env.as_deref());
476
477    Ok(())
478}
479
480fn run_agent_setup(
481    interactive: bool,
482    out: &Output,
483    cmd: &InitCommand,
484    ctx: &CliConfig,
485) -> Result<()> {
486    let mut guide = GuidedSetup::new(out, guided::agent_steps(), interactive);
487
488    // GATHER
489    guide.section("Agent Configuration");
490    let (keychain, config) = gather_agent_config(interactive, out, cmd)?;
491    let registry_path = config.registry_path.clone();
492
493    if config.dry_run {
494        display_agent_dry_run(out, &config);
495        return Ok(());
496    }
497
498    // The user said "I want an agent" — deliver one. An agent is a KERI
499    // delegated identifier under an existing root, so when a root identity
500    // exists, route straight into the delegation flow (the same machinery as
501    // `auths id agent add`), reusing the capabilities just selected.
502    if interactive && delegate_agent_interactively(out, cmd, ctx, &config, &registry_path)? {
503        return Ok(());
504    }
505
506    // EXECUTE — no root identity (or non-interactive): the SDK returns the
507    // actionable "delegate via `auths id agent add`" guidance.
508    guide.section("Creating Agent Identity");
509    ensure_registry_dir(&registry_path)?;
510    let sdk_ctx = build_auths_context(&registry_path, &ctx.env_config, None)?;
511    let keychain_arc: Arc<dyn KeyStorage + Send + Sync> = Arc::from(keychain);
512    let signer = StorageSigner::new(Arc::clone(&keychain_arc));
513    let result = initialize(
514        IdentityConfig::Agent(config),
515        &sdk_ctx,
516        keychain_arc,
517        &signer,
518        ctx.passphrase_provider.as_ref(),
519        None,
520    )?;
521    let result = match result {
522        InitializeResult::Agent(r) => r,
523        _ => unreachable!(),
524    };
525
526    // DISPLAY
527    guide.section("Summary");
528    display_agent_result(out, &result);
529
530    Ok(())
531}
532
533/// Delegate an agent under the existing root identity, interactively. Returns
534/// `Ok(false)` when no root identity exists (the caller falls through to the
535/// guidance path).
536fn delegate_agent_interactively(
537    out: &Output,
538    cmd: &InitCommand,
539    ctx: &CliConfig,
540    config: &auths_sdk::types::CreateAgentIdentityConfig,
541    registry_path: &Path,
542) -> Result<bool> {
543    let sdk_ctx = build_auths_context(
544        registry_path,
545        &ctx.env_config,
546        Some(Arc::clone(&ctx.passphrase_provider)),
547    )?;
548    if sdk_ctx.identity_storage.load_identity().is_err() {
549        out.print_info("No root identity found — an agent is delegated under a root identity.");
550        out.println("  Run `auths init` (developer profile) first, then choose Agent again.");
551        return Ok(false);
552    }
553
554    let label: String = dialoguer::Input::new()
555        .with_prompt("Agent label (also the keychain alias for its key)")
556        .default("agent".to_string())
557        .interact_text()
558        .unwrap_or_else(|_| "agent".to_string());
559
560    let root_alias = auths_sdk::keychain::KeyAlias::new_unchecked(&cmd.key_alias);
561    let agent_alias = auths_sdk::keychain::KeyAlias::new_unchecked(&label);
562    #[allow(clippy::disallowed_methods)] // CLI boundary: clock injected here
563    let expires_at = config
564        .expires_in
565        .map(|secs| chrono::Utc::now().timestamp() + secs as i64);
566
567    let result = auths_sdk::domains::agents::add_scoped(
568        &sdk_ctx,
569        &root_alias,
570        &agent_alias,
571        auths_crypto::CurveType::default(),
572        &config.capabilities,
573        expires_at,
574    )
575    .map_err(anyhow::Error::new)?;
576    let _ = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(&sdk_ctx, registry_path);
577
578    out.newline();
579    out.print_success("Agent delegated as a KERI delegated identifier:");
580    out.println(&format!("  {}", out.info(&result.agent_did)));
581    let cap_display: Vec<String> = config.capabilities.iter().map(|c| c.to_string()).collect();
582    if !cap_display.is_empty() {
583        out.println(&format!("  Capabilities: {}", cap_display.join(", ")));
584    }
585    out.newline();
586    out.println("  Manage it: auths id agent list / auths id agent revoke");
587    Ok(true)
588}
589
590impl crate::commands::executable::ExecutableCommand for InitCommand {
591    #[allow(clippy::disallowed_methods)]
592    fn execute(&self, ctx: &CliConfig) -> anyhow::Result<()> {
593        handle_init(self.clone(), ctx, chrono::Utc::now())
594    }
595}
596
597#[cfg(test)]
598mod tests {
599    use super::*;
600    use auths_sdk::types::CiEnvironment;
601    use gather::map_ci_environment;
602
603    #[test]
604    fn test_setup_profile_display() {
605        assert_eq!(InitProfile::Developer.to_string(), "developer");
606        assert_eq!(InitProfile::Ci.to_string(), "ci");
607        assert_eq!(InitProfile::Agent.to_string(), "agent");
608    }
609
610    #[test]
611    fn test_setup_command_defaults() {
612        let cmd = InitCommand {
613            interactive: false,
614            non_interactive: false,
615            profile: None,
616            key_alias: DEFAULT_KEY_ALIAS.to_string(),
617            force: false,
618            dry_run: false,
619            registry: DEFAULT_REGISTRY_URL.to_string(),
620            register: false,
621            github_action: false,
622            device_count: 1,
623            signing_threshold: None,
624            rotation_threshold: None,
625        };
626        assert!(!cmd.interactive);
627        assert!(!cmd.non_interactive);
628        assert!(cmd.profile.is_none());
629        assert_eq!(cmd.key_alias, "main");
630        assert!(!cmd.force);
631        assert!(!cmd.dry_run);
632        assert_eq!(cmd.registry, "https://registry.auths.dev");
633        assert!(!cmd.register);
634        assert!(!cmd.github_action);
635    }
636
637    #[test]
638    fn test_setup_command_with_profile() {
639        let cmd = InitCommand {
640            interactive: false,
641            non_interactive: true,
642            profile: Some(InitProfile::Ci),
643            key_alias: "ci-key".to_string(),
644            force: true,
645            dry_run: false,
646            registry: DEFAULT_REGISTRY_URL.to_string(),
647            register: false,
648            github_action: false,
649            device_count: 1,
650            signing_threshold: None,
651            rotation_threshold: None,
652        };
653        assert!(cmd.non_interactive);
654        assert!(matches!(cmd.profile, Some(InitProfile::Ci)));
655        assert_eq!(cmd.key_alias, "ci-key");
656        assert!(cmd.force);
657    }
658
659    #[test]
660    fn test_map_ci_environment() {
661        assert!(matches!(
662            map_ci_environment(&Some("GitHub Actions".into())),
663            CiEnvironment::GitHubActions
664        ));
665        assert!(matches!(
666            map_ci_environment(&Some("GitLab CI".into())),
667            CiEnvironment::GitLabCi
668        ));
669        assert!(matches!(map_ci_environment(&None), CiEnvironment::Unknown));
670    }
671
672    #[test]
673    fn test_resolve_interactive_non_interactive_flag() {
674        let cmd = InitCommand {
675            interactive: false,
676            non_interactive: true,
677            profile: None,
678            key_alias: DEFAULT_KEY_ALIAS.to_string(),
679            force: false,
680            dry_run: false,
681            registry: DEFAULT_REGISTRY_URL.to_string(),
682            register: false,
683            github_action: false,
684            device_count: 1,
685            signing_threshold: None,
686            rotation_threshold: None,
687        };
688        assert!(!resolve_interactive(&cmd).unwrap());
689    }
690
691    #[test]
692    fn test_resolve_interactive_auto_detect() {
693        let cmd = InitCommand {
694            interactive: false,
695            non_interactive: false,
696            profile: None,
697            key_alias: DEFAULT_KEY_ALIAS.to_string(),
698            force: false,
699            dry_run: false,
700            registry: DEFAULT_REGISTRY_URL.to_string(),
701            register: false,
702            github_action: false,
703            device_count: 1,
704            signing_threshold: None,
705            rotation_threshold: None,
706        };
707        // Auto-detect returns is_terminal() — result depends on environment
708        let result = resolve_interactive(&cmd).unwrap();
709        assert_eq!(result, std::io::stdin().is_terminal());
710    }
711}