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::service::initialize;
19use auths_sdk::domains::identity::types::IdentityConfig;
20use auths_sdk::domains::identity::types::InitializeResult;
21use auths_sdk::domains::signing::types::GitSigningScope;
22use auths_sdk::keychain::KeyStorage;
23use auths_sdk::ports::git_config::GitConfigProvider;
24use auths_sdk::signing::PrefilledPassphraseProvider;
25use auths_sdk::signing::StorageSigner;
26use auths_sdk::workflows::roots::RootPinOutcome;
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::{git_remote_is_github, 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
57/// Where `auths init` writes git signing configuration.
58#[derive(Debug, Clone, Copy, ValueEnum)]
59pub enum GitScopeArg {
60    /// This repository only (`git config --local`).
61    Local,
62    /// Every repository on this machine (`git config --global`).
63    Global,
64    /// Do not touch git configuration at all.
65    Skip,
66}
67
68impl GitScopeArg {
69    /// Resolve to the SDK scope, reading the working directory for `Local`.
70    pub(crate) fn resolve(self) -> Result<GitSigningScope> {
71        Ok(match self {
72            Self::Local => GitSigningScope::Local {
73                repo_path: std::env::current_dir()
74                    .map_err(|e| anyhow!("Failed to determine current working directory: {e}"))?,
75            },
76            Self::Global => GitSigningScope::Global,
77            Self::Skip => GitSigningScope::Skip,
78        })
79    }
80}
81
82impl std::fmt::Display for InitProfile {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        match self {
85            InitProfile::Developer => write!(f, "developer"),
86            InitProfile::Ci => write!(f, "ci"),
87            InitProfile::Agent => write!(f, "agent"),
88        }
89    }
90}
91
92/// Initializes Auths identity with a guided setup wizard.
93///
94/// Supports three profiles (developer, ci, agent) covering the most common
95/// deployment scenarios. Interactive by default on TTY; pass `--non-interactive`
96/// for scripted or CI use, or `--interactive` to force prompts.
97///
98/// Usage:
99/// ```ignore
100/// // auths init
101/// // auths init --profile developer --non-interactive
102/// // auths init --interactive --profile developer
103/// ```
104#[derive(Args, Debug, Clone)]
105#[command(
106    name = "init",
107    about = "Create your signing identity and configure Git",
108    after_help = "Examples:
109  auths init                              # Interactive setup wizard
110  auths init --profile developer          # Developer profile with prompts
111  auths init --profile ci --non-interactive # Automated CI setup
112
113Profiles:
114  developer — Local setup: keychain, Git signing, platform identity
115  ci        — Temporary signing identity for CI/CD runners
116  agent     — Restricted signing identity for AI agents
117
118Related:
119  auths status  — Check setup completion
120  auths doctor  — Run health checks"
121)]
122pub struct InitCommand {
123    /// Force interactive prompts (errors if not a TTY)
124    #[clap(long, conflicts_with = "non_interactive")]
125    pub interactive: bool,
126
127    /// Skip interactive prompts and use sensible defaults
128    #[clap(long, conflicts_with = "interactive")]
129    pub non_interactive: bool,
130
131    /// Preset profile: developer, ci, or agent
132    #[clap(long, value_enum)]
133    pub profile: Option<InitProfile>,
134
135    /// Key alias for the identity key (default: main)
136    #[clap(long, default_value = DEFAULT_KEY_ALIAS)]
137    pub key_alias: String,
138
139    /// Force overwrite if identity already exists
140    #[clap(long)]
141    pub force: bool,
142
143    /// Confirm replacing an existing root identity when running non-interactively. Required with
144    /// `--force` over an existing identity when there is no TTY to confirm at.
145    #[clap(long)]
146    pub confirm_replace: bool,
147
148    /// Preview agent configuration without creating files or identities
149    #[clap(long)]
150    pub dry_run: bool,
151
152    /// Registry URL for identity registration. No default: auths needs no
153    /// registry, and registration is opt-in via `--register`.
154    #[clap(long, env = "AUTHS_REGISTRY_URL")]
155    pub registry: Option<String>,
156
157    /// Register identity with the Auths Registry after creation
158    #[clap(long)]
159    pub register: bool,
160
161    /// Link your GitHub account and upload your SSH signing key, so your commits
162    /// show as Verified on github.com.
163    ///
164    /// Defaults to on when this repo has a GitHub remote and there is a TTY to
165    /// confirm at. Unrelated to `--register`, which publishes to the Auths
166    /// Registry.
167    #[clap(long)]
168    pub github: bool,
169
170    /// Skip linking GitHub, even when this repo has a GitHub remote.
171    #[clap(long = "no-github", conflicts_with = "github")]
172    pub no_github: bool,
173
174    /// Where to write git signing config: local (this repo), global (every repo
175    /// on this machine), or skip.
176    ///
177    /// Interactive runs ask. Non-interactive runs default to `local` — a scripted
178    /// or CI init should not silently rewrite your `~/.gitconfig`.
179    #[clap(long, value_enum)]
180    pub git_scope: Option<GitScopeArg>,
181
182    /// Scaffold a GitHub Actions workflow using the auths attest-action
183    #[clap(long)]
184    pub github_action: bool,
185
186    /// Number of device slots for a multi-key KEL (default 1).
187    ///
188    /// Values > 1 require `--signing-threshold` and `--rotation-threshold`.
189    /// Multi-device init today runs a single-device inception; add further
190    /// devices with `auths device pair` (each is a delegated identity under
191    /// the root). The full atomic multi-device inception path is wired later.
192    #[clap(long, default_value_t = 1)]
193    pub device_count: u8,
194
195    /// Signing threshold: scalar integer (e.g. `"2"`) or fraction list
196    /// (e.g. `"1/2,1/2,1/2"`). Required when `--device-count > 1`.
197    #[clap(long)]
198    pub signing_threshold: Option<String>,
199
200    /// Rotation (next) threshold, same format as `--signing-threshold`.
201    #[clap(long)]
202    pub rotation_threshold: Option<String>,
203}
204
205/// Parse a threshold argument from the CLI.
206///
207/// Accepts either a plain integer (`"2"`, `"a"` hex) for `Threshold::Simple`
208/// or a comma-separated fraction list (`"1/2,1/2,1/2"`) for
209/// `Threshold::Weighted`. Rejects mixed shapes (`"2,3"` with no `/`) with
210/// a clear error.
211pub fn parse_threshold_cli(
212    s: &str,
213    key_count: usize,
214) -> Result<auths_keri::Threshold, anyhow::Error> {
215    let trimmed = s.trim();
216    if trimmed.is_empty() {
217        return Err(anyhow!("threshold value is empty"));
218    }
219
220    if trimmed.contains(',') || trimmed.contains('/') {
221        // Fraction list path.
222        let fractions: Result<Vec<auths_keri::Fraction>, _> = trimmed
223            .split(',')
224            .map(|part| part.trim().parse::<auths_keri::Fraction>())
225            .collect();
226        let fractions = fractions.map_err(|e| {
227            anyhow!(
228                "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\").",
229                trimmed
230            )
231        })?;
232        if fractions.len() != key_count {
233            return Err(anyhow!(
234                "threshold fraction list has {} entries for device_count {}",
235                fractions.len(),
236                key_count
237            ));
238        }
239        Ok(auths_keri::Threshold::Weighted(vec![fractions]))
240    } else {
241        let n = u64::from_str_radix(trimmed, 16).map_err(|_| {
242            anyhow!(
243                "invalid scalar threshold {:?}: expected hex integer (e.g. \"2\") or fraction list (e.g. \"1/2,1/2,1/2\")",
244                trimmed
245            )
246        })?;
247        if (n as usize) > key_count {
248            return Err(anyhow!(
249                "threshold {} exceeds device count {}",
250                n,
251                key_count
252            ));
253        }
254        if n == 0 && key_count > 0 {
255            return Err(anyhow!(
256                "threshold 0 is unsatisfiable for non-empty device set"
257            ));
258        }
259        Ok(auths_keri::Threshold::Simple(n))
260    }
261}
262
263fn resolve_interactive(cmd: &InitCommand) -> Result<bool> {
264    // `--json` is a machine-readable contract: never prompt, so a parser on the
265    // other end gets a single JSON object on stdout and nothing blocks on input.
266    if crate::ux::format::is_json_mode() {
267        if cmd.interactive {
268            return Err(anyhow!("--interactive cannot be combined with --json"));
269        }
270        return Ok(false);
271    }
272    if cmd.interactive {
273        if !std::io::stdin().is_terminal() {
274            return Err(anyhow!(
275                "--interactive requires a TTY (stdin is not a terminal)"
276            ));
277        }
278        Ok(true)
279    } else if cmd.non_interactive {
280        Ok(false)
281    } else {
282        Ok(std::io::stdin().is_terminal())
283    }
284}
285
286/// Handle the `init` command with Gather → Execute → Display pattern.
287///
288/// Args:
289/// * `cmd`: Parsed [`InitCommand`] from the CLI.
290/// * `ctx`: CLI configuration with passphrase provider and repo path.
291///
292/// Usage:
293/// ```ignore
294/// handle_init(cmd, &ctx)?;
295/// ```
296pub fn handle_init(
297    cmd: InitCommand,
298    ctx: &CliConfig,
299    now: chrono::DateTime<chrono::Utc>,
300) -> Result<()> {
301    let out = Output::new();
302
303    if cmd.github_action {
304        return helpers::scaffold_github_action(&out);
305    }
306
307    // Validate multi-device flag combinations at CLI parse time.
308    let device_count = cmd.device_count.max(1) as usize;
309    if device_count > 1 {
310        let kt_str = cmd
311            .signing_threshold
312            .as_deref()
313            .ok_or_else(|| anyhow!("--signing-threshold is required when --device-count > 1"))?;
314        let nt_str = cmd
315            .rotation_threshold
316            .as_deref()
317            .ok_or_else(|| anyhow!("--rotation-threshold is required when --device-count > 1"))?;
318        let _kt = parse_threshold_cli(kt_str, device_count)?;
319        let _nt = parse_threshold_cli(nt_str, device_count)?;
320        return Err(anyhow!(
321            "multi-device init (--device-count > 1) is not yet wired through the developer setup flow. \
322             Run `auths init` for a single-device identity, then add devices with `auths device pair` \
323             (each device is a delegated identity under your root)."
324        ));
325    }
326    if cmd.signing_threshold.is_some() && device_count == 1 {
327        let kt = parse_threshold_cli(cmd.signing_threshold.as_deref().unwrap_or("1"), 1)?;
328        if !matches!(kt, auths_keri::Threshold::Simple(1)) {
329            return Err(anyhow!(
330                "single-device init requires --signing-threshold to be 1 or omitted"
331            ));
332        }
333    }
334
335    let interactive = resolve_interactive(&cmd)?;
336
337    let profile = match cmd.profile {
338        Some(p) => p,
339        None if !interactive => {
340            out.println("No profile specified in non-interactive mode, defaulting to developer.");
341            InitProfile::Developer
342        }
343        None => prompt_profile(&out)?,
344    };
345
346    out.print_heading(&format!("Auths Setup ({})", profile));
347    out.println("=".repeat(40).as_str());
348
349    match profile {
350        InitProfile::Developer => run_developer_setup(interactive, &out, &cmd, ctx, now)?,
351        InitProfile::Ci => run_ci_setup(&out, ctx)?,
352        InitProfile::Agent => run_agent_setup(interactive, &out, &cmd, ctx)?,
353    }
354
355    Ok(())
356}
357
358fn run_developer_setup(
359    interactive: bool,
360    out: &Output,
361    cmd: &InitCommand,
362    ctx: &CliConfig,
363    now: chrono::DateTime<chrono::Utc>,
364) -> Result<()> {
365    let mut guide = GuidedSetup::new(out, guided::developer_steps(), interactive);
366
367    // GATHER
368    guide.section("Prerequisites & Configuration");
369    let registry_path = auths_sdk::paths::resolve_registry_path(ctx.repo_path.clone())?;
370    let (keychain, mut config) = gather_developer_config(interactive, out, cmd, &registry_path)?;
371    ensure_registry_dir(&registry_path)?;
372
373    let sign_binary_path = which::which("auths-sign").ok();
374    if let Some(ref path) = sign_binary_path {
375        config.sign_binary_path = Some(path.clone());
376    }
377    let git_config_provider: Option<Box<dyn GitConfigProvider>> = match &config.git_signing_scope {
378        GitSigningScope::Skip => None,
379        GitSigningScope::Global => Some(Box::new(SystemGitConfigProvider::global())),
380        GitSigningScope::Local { repo_path } => {
381            Some(Box::new(SystemGitConfigProvider::local(repo_path.clone())))
382        }
383    };
384
385    // EXECUTE
386    guide.section("Creating Identity");
387    let sdk_ctx = build_auths_context(&registry_path, &ctx.env_config, None)?;
388    let keychain_arc: Arc<dyn KeyStorage + Send + Sync> = Arc::from(keychain);
389    let signer = StorageSigner::new(Arc::clone(&keychain_arc));
390    let result = initialize(
391        IdentityConfig::Developer(config),
392        &sdk_ctx,
393        keychain_arc,
394        &signer,
395        ctx.passphrase_provider.as_ref(),
396        git_config_provider.as_deref(),
397    )?;
398    let result = match result {
399        InitializeResult::Developer(r) => r,
400        _ => unreachable!(),
401    };
402
403    out.print_success(&format!(
404        "Identity created: {}",
405        crate::ux::product_id(&result.identity_did)
406    ));
407    out.print_success(&format!(
408        "This device authorized: {}",
409        crate::ux::product_id(result.device_did.as_str())
410    ));
411
412    // PLATFORM VERIFICATION
413    //
414    // Linking GitHub is what uploads the SSH signing key, and that is the only
415    // thing that makes a user's commits render as "Verified" to everyone else.
416    // It was previously gated behind `--register` — a flag whose documented job is
417    // publishing to the Auths Registry, an unrelated (and dead) host. So the
418    // default first run left commits Unverified on github.com while the code to
419    // fix that sat one boolean away.
420    //
421    // Research puts a number on the cost: 73.73% of commit-signature verification
422    // failures in the wild are `unknown_key` — developers sign and never register
423    // the key ("a usability problem, not a cryptographic one", arXiv:2604.14014).
424    // Auths mints the key, signs with it, and can upload it; withholding that by
425    // default forfeits the product's clearest advantage over gitsign, whose certs
426    // GitHub structurally cannot verify.
427    let link_github = if cmd.no_github {
428        false
429    } else if cmd.github {
430        true
431    } else {
432        interactive && git_remote_is_github()
433    };
434    guide.section("Platform Verification");
435    let proof_url = if link_github {
436        out.print_info("Link your GitHub account");
437        out.newline();
438        match prompt_platform_verification(
439            out,
440            Arc::clone(&ctx.passphrase_provider),
441            &ctx.env_config,
442            now,
443        )? {
444            Some((url, _username)) => {
445                out.print_success(&format!("GitHub identity linked: {}", url));
446                Some(url)
447            }
448            None => {
449                out.println("  Continuing as anonymous identity");
450                None
451            }
452        }
453    } else {
454        None
455    };
456
457    // POST-SETUP
458    guide.section("Shell & Signing Setup");
459    offer_shell_completions(interactive, out)?;
460
461    // Commit-time trailers: install the prepare-commit-msg hook and point
462    // core.hooksPath at it, so a plain `git commit` carries the Auths-Id /
463    // Auths-Device trailers `auths verify` replays — zero extra commands.
464    // Trailer values come from the signer resolver (same source as `auths sign`):
465    // on the root machine Auths-Device is the root `did:keri:` itself, on a
466    // delegate it is the device's delegated AID — always a replayable KEL.
467    if let Some(git_config) = git_config_provider.as_deref() {
468        match auths_sdk::domains::identity::local::resolve_local_signer(&sdk_ctx) {
469            Ok(signer) => {
470                match auths_sdk::workflows::commit_hooks::enable_commit_trailers(
471                    &registry_path,
472                    &signer.root_did,
473                    &signer.signer_did,
474                    git_config,
475                ) {
476                    Ok(()) => {
477                        // Refresh stamps the current KEL position (Auths-Anchor-Seq)
478                        // into the trailer file the hook reads.
479                        let _ = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(
480                            &sdk_ctx,
481                            &registry_path,
482                        );
483                        out.println("  Commit trailers enabled (prepare-commit-msg hook)");
484                    }
485                    Err(e) => out.println(&format!("  Note: could not install commit hook ({e})")),
486                }
487            }
488            Err(e) => out.println(&format!("  Note: could not resolve signer for hook ({e})")),
489        }
490    }
491
492    // Pin the local identity as a trusted root for KEL-native verification (Epic B):
493    // the committed `<repo>/.auths/roots` is the root of trust — no allowed_signers file.
494    // Staging is the load-bearing half: a pin that is written but never staged never
495    // reaches a cloner, so every third-party verification path dead-ends on it. We do
496    // not author a commit on the user's behalf — the pin rides their next one.
497    if let Ok(output) = crate::subprocess::git_command(&["rev-parse", "--show-toplevel"]).output()
498        && output.status.success()
499    {
500        let root = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
501        let root_did = result.identity_did.to_string();
502        let index = crate::adapters::git_index::SystemRepoIndex::at(root.clone());
503        match auths_sdk::workflows::roots::pin_root_in_repo(
504            &crate::adapters::config_store::FileConfigStore,
505            &index,
506            &root,
507            &root_did,
508        ) {
509            Ok(RootPinOutcome::AlreadyTracked) => out.println(&format!(
510                "  Trusted root already pinned: {}",
511                crate::ux::product_id(&root_did)
512            )),
513            Ok(_) => {
514                out.println(&format!(
515                    "  Pinned trusted root: {}",
516                    crate::ux::product_id(&root_did)
517                ));
518                out.println("  Staged .auths/roots — your next commit lets clones verify you");
519            }
520            Err(e) => out.println(&format!("  Note: could not pin trusted root ({e})")),
521        }
522    }
523
524    // REGISTRATION & DISPLAY
525    guide.section("Registration & Summary");
526    let registered = submit_registration(
527        &registry_path,
528        cmd.registry.as_deref(),
529        proof_url,
530        !cmd.register, // skip unless --register is explicitly passed
531        out,
532    );
533    display_developer_result(out, &result, registered.as_deref());
534
535    Ok(())
536}
537
538fn run_ci_setup(out: &Output, ctx: &CliConfig) -> Result<()> {
539    let mut guide = GuidedSetup::new(out, guided::ci_steps(), false);
540
541    // GATHER
542    guide.section("CI Environment Detection");
543    let (ci_env, config, keychain, passphrase_str) =
544        gather_ci_config(out, ctx.repo_path.as_deref())?;
545    let registry_path = config.registry_path.clone();
546    ensure_registry_dir(&registry_path)?;
547
548    // EXECUTE
549    guide.section("Creating CI Identity");
550    // gather_ci_config set the file-backend env vars; read them fresh so the SDK
551    // context's keychain matches the one we just built (ctx.env_config predates them).
552    let ci_env_config = auths_sdk::core_config::EnvironmentConfig::from_env();
553    let sdk_ctx = build_auths_context(&registry_path, &ci_env_config, None)?;
554    let keychain_arc: Arc<dyn KeyStorage + Send + Sync> = Arc::from(keychain);
555    let signer = StorageSigner::new(Arc::clone(&keychain_arc));
556    let provider = PrefilledPassphraseProvider::new(&passphrase_str);
557    let result = initialize(
558        IdentityConfig::Ci(config),
559        &sdk_ctx,
560        keychain_arc,
561        &signer,
562        &provider,
563        None,
564    )?;
565    let result = match result {
566        InitializeResult::Ci(r) => r,
567        _ => unreachable!(),
568    };
569
570    // DISPLAY
571    guide.section("Summary");
572    display_ci_result(out, &result, ci_env.as_deref());
573
574    Ok(())
575}
576
577fn run_agent_setup(
578    interactive: bool,
579    out: &Output,
580    cmd: &InitCommand,
581    ctx: &CliConfig,
582) -> Result<()> {
583    let mut guide = GuidedSetup::new(out, guided::agent_steps(), interactive);
584
585    // GATHER
586    guide.section("Agent Configuration");
587    let (keychain, config) = gather_agent_config(interactive, out, cmd)?;
588    let registry_path = config.registry_path.clone();
589
590    if config.dry_run {
591        display_agent_dry_run(out, &config);
592        return Ok(());
593    }
594
595    // The user said "I want an agent" — deliver one. An agent is a KERI
596    // delegated identifier under an existing root, so when a root identity
597    // exists, route straight into the delegation flow (the same machinery as
598    // `auths id agent add`), reusing the capabilities just selected.
599    if interactive && delegate_agent_interactively(out, cmd, ctx, &config, &registry_path)? {
600        return Ok(());
601    }
602
603    // EXECUTE — no root identity (or non-interactive): the SDK returns the
604    // actionable "delegate via `auths id agent add`" guidance.
605    guide.section("Creating Agent Identity");
606    ensure_registry_dir(&registry_path)?;
607    let sdk_ctx = build_auths_context(&registry_path, &ctx.env_config, None)?;
608    let keychain_arc: Arc<dyn KeyStorage + Send + Sync> = Arc::from(keychain);
609    let signer = StorageSigner::new(Arc::clone(&keychain_arc));
610    let result = initialize(
611        IdentityConfig::Agent(config),
612        &sdk_ctx,
613        keychain_arc,
614        &signer,
615        ctx.passphrase_provider.as_ref(),
616        None,
617    )?;
618    let result = match result {
619        InitializeResult::Agent(r) => r,
620        _ => unreachable!(),
621    };
622
623    // DISPLAY
624    guide.section("Summary");
625    display_agent_result(out, &result);
626
627    Ok(())
628}
629
630/// Delegate an agent under the existing root identity, interactively. Returns
631/// `Ok(false)` when no root identity exists (the caller falls through to the
632/// guidance path).
633fn delegate_agent_interactively(
634    out: &Output,
635    cmd: &InitCommand,
636    ctx: &CliConfig,
637    config: &auths_sdk::types::CreateAgentIdentityConfig,
638    registry_path: &Path,
639) -> Result<bool> {
640    let sdk_ctx = build_auths_context(
641        registry_path,
642        &ctx.env_config,
643        Some(Arc::clone(&ctx.passphrase_provider)),
644    )?;
645    if sdk_ctx.identity_storage.load_identity().is_err() {
646        out.print_info("No root identity found — an agent is delegated under a root identity.");
647        out.println("  Run `auths init` (developer profile) first, then choose Agent again.");
648        return Ok(false);
649    }
650
651    let label: String = dialoguer::Input::new()
652        .with_prompt("Agent label (also the keychain alias for its key)")
653        .default("agent".to_string())
654        .interact_text()
655        .unwrap_or_else(|_| "agent".to_string());
656
657    let root_alias = auths_sdk::keychain::KeyAlias::new_unchecked(&cmd.key_alias);
658    let agent_alias = auths_sdk::keychain::KeyAlias::new_unchecked(&label);
659    #[allow(clippy::disallowed_methods)] // CLI boundary: clock injected here
660    let expires_at = config
661        .expires_in
662        .map(|secs| chrono::Utc::now().timestamp() + secs as i64);
663
664    let result = auths_sdk::domains::agents::add_scoped(
665        &sdk_ctx,
666        &root_alias,
667        &agent_alias,
668        auths_crypto::CurveType::default(),
669        &config.capabilities,
670        expires_at,
671    )
672    .map_err(anyhow::Error::new)?;
673    let _ = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(&sdk_ctx, registry_path);
674
675    out.newline();
676    out.print_success("Agent delegated under your identity:");
677    out.println(&format!(
678        "  {}",
679        out.info(&crate::ux::product_id(&result.agent_did))
680    ));
681    let cap_display: Vec<String> = config.capabilities.iter().map(|c| c.to_string()).collect();
682    if !cap_display.is_empty() {
683        out.println(&format!("  Capabilities: {}", cap_display.join(", ")));
684    }
685    out.newline();
686    out.println("  Manage it: auths id agent list / auths id agent revoke");
687    Ok(true)
688}
689
690impl crate::commands::executable::ExecutableCommand for InitCommand {
691    #[allow(clippy::disallowed_methods)]
692    fn execute(&self, ctx: &CliConfig) -> anyhow::Result<()> {
693        handle_init(self.clone(), ctx, chrono::Utc::now())
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use auths_sdk::types::CiEnvironment;
701    use gather::map_ci_environment;
702
703    #[test]
704    fn test_setup_profile_display() {
705        assert_eq!(InitProfile::Developer.to_string(), "developer");
706        assert_eq!(InitProfile::Ci.to_string(), "ci");
707        assert_eq!(InitProfile::Agent.to_string(), "agent");
708    }
709
710    #[test]
711    fn parse_threshold_cli_rejects_unsatisfiable_thresholds() {
712        // A threshold larger than the device count can never be met — it would brick recovery,
713        // so it is rejected at creation.
714        assert!(parse_threshold_cli("3", 2).is_err());
715        // Zero is unsatisfiable for a non-empty device set.
716        assert!(parse_threshold_cli("0", 2).is_err());
717        // A satisfiable threshold parses: 2-of-2.
718        assert_eq!(
719            parse_threshold_cli("2", 2).unwrap(),
720            auths_keri::Threshold::Simple(2)
721        );
722        // 1-of-N (the any-device default) is satisfiable.
723        assert!(parse_threshold_cli("1", 3).is_ok());
724    }
725
726    #[test]
727    fn test_setup_command_defaults() {
728        let cmd = InitCommand {
729            interactive: false,
730            non_interactive: false,
731            profile: None,
732            key_alias: DEFAULT_KEY_ALIAS.to_string(),
733            force: false,
734            confirm_replace: false,
735            dry_run: false,
736            registry: None,
737            register: false,
738            github: false,
739            no_github: false,
740            git_scope: None,
741            github_action: false,
742            device_count: 1,
743            signing_threshold: None,
744            rotation_threshold: None,
745        };
746        assert!(!cmd.interactive);
747        assert!(!cmd.non_interactive);
748        assert!(cmd.profile.is_none());
749        assert_eq!(cmd.key_alias, "main");
750        assert!(!cmd.force);
751        assert!(!cmd.dry_run);
752        // No default: auths needs no registry, and the one that used to be baked
753        // in returned 404. Registration is opt-in and must name its registry.
754        assert_eq!(cmd.registry, None);
755        assert!(!cmd.register);
756        assert!(!cmd.github_action);
757    }
758
759    #[test]
760    fn test_setup_command_with_profile() {
761        let cmd = InitCommand {
762            interactive: false,
763            non_interactive: true,
764            profile: Some(InitProfile::Ci),
765            key_alias: "ci-key".to_string(),
766            force: true,
767            confirm_replace: false,
768            dry_run: false,
769            registry: None,
770            register: false,
771            github: false,
772            no_github: false,
773            git_scope: None,
774            github_action: false,
775            device_count: 1,
776            signing_threshold: None,
777            rotation_threshold: None,
778        };
779        assert!(cmd.non_interactive);
780        assert!(matches!(cmd.profile, Some(InitProfile::Ci)));
781        assert_eq!(cmd.key_alias, "ci-key");
782        assert!(cmd.force);
783    }
784
785    #[test]
786    fn test_map_ci_environment() {
787        assert!(matches!(
788            map_ci_environment(&Some("GitHub Actions".into())),
789            CiEnvironment::GitHubActions
790        ));
791        assert!(matches!(
792            map_ci_environment(&Some("GitLab CI".into())),
793            CiEnvironment::GitLabCi
794        ));
795        assert!(matches!(map_ci_environment(&None), CiEnvironment::Unknown));
796    }
797
798    #[test]
799    fn test_resolve_interactive_non_interactive_flag() {
800        let cmd = InitCommand {
801            interactive: false,
802            non_interactive: true,
803            profile: None,
804            key_alias: DEFAULT_KEY_ALIAS.to_string(),
805            force: false,
806            confirm_replace: false,
807            dry_run: false,
808            registry: None,
809            register: false,
810            github: false,
811            no_github: false,
812            git_scope: None,
813            github_action: false,
814            device_count: 1,
815            signing_threshold: None,
816            rotation_threshold: None,
817        };
818        assert!(!resolve_interactive(&cmd).unwrap());
819    }
820
821    #[test]
822    fn test_resolve_interactive_auto_detect() {
823        let cmd = InitCommand {
824            interactive: false,
825            non_interactive: false,
826            profile: None,
827            key_alias: DEFAULT_KEY_ALIAS.to_string(),
828            force: false,
829            confirm_replace: false,
830            dry_run: false,
831            registry: None,
832            register: false,
833            github: false,
834            no_github: false,
835            git_scope: None,
836            github_action: false,
837            device_count: 1,
838            signing_threshold: None,
839            rotation_threshold: None,
840        };
841        // Auto-detect returns is_terminal() — result depends on environment
842        let result = resolve_interactive(&cmd).unwrap();
843        assert_eq!(result, std::io::stdin().is_terminal());
844    }
845}