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::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    // Pin the local identity as a trusted root for KEL-native verification (Epic B):
378    // the committed `<repo>/.auths/roots` is the root of trust — no allowed_signers file.
379    if let Ok(output) = crate::subprocess::git_command(&["rev-parse", "--show-toplevel"]).output()
380        && output.status.success()
381    {
382        let root = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim());
383        let root_did = result.identity_did.to_string();
384        match auths_sdk::workflows::roots::add_pinned_root(&root.join(".auths"), &root_did) {
385            Ok(()) => out.println(&format!("  Pinned trusted root: {}", root_did)),
386            Err(e) => out.println(&format!("  Note: could not pin trusted root ({e})")),
387        }
388    }
389
390    // REGISTRATION & DISPLAY
391    guide.section("Registration & Summary");
392    let registered = submit_registration(
393        &get_auths_repo_path()?,
394        &cmd.registry,
395        proof_url,
396        !cmd.register, // skip unless --register is explicitly passed
397        out,
398    );
399    display_developer_result(out, &result, registered.as_deref());
400
401    Ok(())
402}
403
404fn run_ci_setup(out: &Output, ctx: &CliConfig) -> Result<()> {
405    let mut guide = GuidedSetup::new(out, guided::ci_steps(), false);
406
407    // GATHER
408    guide.section("CI Environment Detection");
409    let (ci_env, config, keychain, passphrase_str) =
410        gather_ci_config(out, ctx.repo_path.as_deref())?;
411    let registry_path = config.registry_path.clone();
412    ensure_registry_dir(&registry_path)?;
413
414    // EXECUTE
415    guide.section("Creating CI Identity");
416    // gather_ci_config set the file-backend env vars; read them fresh so the SDK
417    // context's keychain matches the one we just built (ctx.env_config predates them).
418    let ci_env_config = auths_sdk::core_config::EnvironmentConfig::from_env();
419    let sdk_ctx = build_auths_context(&registry_path, &ci_env_config, None)?;
420    let keychain_arc: Arc<dyn KeyStorage + Send + Sync> = Arc::from(keychain);
421    let signer = StorageSigner::new(Arc::clone(&keychain_arc));
422    let provider = PrefilledPassphraseProvider::new(&passphrase_str);
423    let result = initialize(
424        IdentityConfig::Ci(config),
425        &sdk_ctx,
426        keychain_arc,
427        &signer,
428        &provider,
429        None,
430    )?;
431    let result = match result {
432        InitializeResult::Ci(r) => r,
433        _ => unreachable!(),
434    };
435
436    // DISPLAY
437    guide.section("Summary");
438    display_ci_result(out, &result, ci_env.as_deref());
439
440    Ok(())
441}
442
443fn run_agent_setup(
444    interactive: bool,
445    out: &Output,
446    cmd: &InitCommand,
447    ctx: &CliConfig,
448) -> Result<()> {
449    let mut guide = GuidedSetup::new(out, guided::agent_steps(), interactive);
450
451    // GATHER
452    guide.section("Agent Configuration");
453    let (keychain, config) = gather_agent_config(interactive, out, cmd)?;
454    let registry_path = config.registry_path.clone();
455
456    if config.dry_run {
457        display_agent_dry_run(out, &config);
458        return Ok(());
459    }
460
461    // EXECUTE
462    guide.section("Creating Agent Identity");
463    ensure_registry_dir(&registry_path)?;
464    let sdk_ctx = build_auths_context(&registry_path, &ctx.env_config, None)?;
465    let keychain_arc: Arc<dyn KeyStorage + Send + Sync> = Arc::from(keychain);
466    let signer = StorageSigner::new(Arc::clone(&keychain_arc));
467    let result = initialize(
468        IdentityConfig::Agent(config),
469        &sdk_ctx,
470        keychain_arc,
471        &signer,
472        ctx.passphrase_provider.as_ref(),
473        None,
474    )?;
475    let result = match result {
476        InitializeResult::Agent(r) => r,
477        _ => unreachable!(),
478    };
479
480    // DISPLAY
481    guide.section("Summary");
482    display_agent_result(out, &result);
483
484    Ok(())
485}
486
487impl crate::commands::executable::ExecutableCommand for InitCommand {
488    #[allow(clippy::disallowed_methods)]
489    fn execute(&self, ctx: &CliConfig) -> anyhow::Result<()> {
490        handle_init(self.clone(), ctx, chrono::Utc::now())
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use auths_sdk::types::CiEnvironment;
498    use gather::map_ci_environment;
499
500    #[test]
501    fn test_setup_profile_display() {
502        assert_eq!(InitProfile::Developer.to_string(), "developer");
503        assert_eq!(InitProfile::Ci.to_string(), "ci");
504        assert_eq!(InitProfile::Agent.to_string(), "agent");
505    }
506
507    #[test]
508    fn test_setup_command_defaults() {
509        let cmd = InitCommand {
510            interactive: false,
511            non_interactive: false,
512            profile: None,
513            key_alias: DEFAULT_KEY_ALIAS.to_string(),
514            force: false,
515            dry_run: false,
516            registry: DEFAULT_REGISTRY_URL.to_string(),
517            register: false,
518            github_action: false,
519            device_count: 1,
520            signing_threshold: None,
521            rotation_threshold: None,
522        };
523        assert!(!cmd.interactive);
524        assert!(!cmd.non_interactive);
525        assert!(cmd.profile.is_none());
526        assert_eq!(cmd.key_alias, "main");
527        assert!(!cmd.force);
528        assert!(!cmd.dry_run);
529        assert_eq!(cmd.registry, "https://registry.auths.dev");
530        assert!(!cmd.register);
531        assert!(!cmd.github_action);
532    }
533
534    #[test]
535    fn test_setup_command_with_profile() {
536        let cmd = InitCommand {
537            interactive: false,
538            non_interactive: true,
539            profile: Some(InitProfile::Ci),
540            key_alias: "ci-key".to_string(),
541            force: true,
542            dry_run: false,
543            registry: DEFAULT_REGISTRY_URL.to_string(),
544            register: false,
545            github_action: false,
546            device_count: 1,
547            signing_threshold: None,
548            rotation_threshold: None,
549        };
550        assert!(cmd.non_interactive);
551        assert!(matches!(cmd.profile, Some(InitProfile::Ci)));
552        assert_eq!(cmd.key_alias, "ci-key");
553        assert!(cmd.force);
554    }
555
556    #[test]
557    fn test_map_ci_environment() {
558        assert!(matches!(
559            map_ci_environment(&Some("GitHub Actions".into())),
560            CiEnvironment::GitHubActions
561        ));
562        assert!(matches!(
563            map_ci_environment(&Some("GitLab CI".into())),
564            CiEnvironment::GitLabCi
565        ));
566        assert!(matches!(map_ci_environment(&None), CiEnvironment::Unknown));
567    }
568
569    #[test]
570    fn test_resolve_interactive_non_interactive_flag() {
571        let cmd = InitCommand {
572            interactive: false,
573            non_interactive: true,
574            profile: None,
575            key_alias: DEFAULT_KEY_ALIAS.to_string(),
576            force: false,
577            dry_run: false,
578            registry: DEFAULT_REGISTRY_URL.to_string(),
579            register: false,
580            github_action: false,
581            device_count: 1,
582            signing_threshold: None,
583            rotation_threshold: None,
584        };
585        assert!(!resolve_interactive(&cmd).unwrap());
586    }
587
588    #[test]
589    fn test_resolve_interactive_auto_detect() {
590        let cmd = InitCommand {
591            interactive: false,
592            non_interactive: false,
593            profile: None,
594            key_alias: DEFAULT_KEY_ALIAS.to_string(),
595            force: false,
596            dry_run: false,
597            registry: DEFAULT_REGISTRY_URL.to_string(),
598            register: false,
599            github_action: false,
600            device_count: 1,
601            signing_threshold: None,
602            rotation_threshold: None,
603        };
604        // Auto-detect returns is_terminal() — result depends on environment
605        let result = resolve_interactive(&cmd).unwrap();
606        assert_eq!(result, std::io::stdin().is_terminal());
607    }
608}