1mod 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#[derive(Debug, Clone, Copy, ValueEnum)]
48pub enum InitProfile {
49 Developer,
51 Ci,
53 Agent,
55}
56
57#[derive(Debug, Clone, Copy, ValueEnum)]
59pub enum GitScopeArg {
60 Local,
62 Global,
64 Skip,
66}
67
68impl GitScopeArg {
69 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#[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 #[clap(long, conflicts_with = "non_interactive")]
125 pub interactive: bool,
126
127 #[clap(long, conflicts_with = "interactive")]
129 pub non_interactive: bool,
130
131 #[clap(long, value_enum)]
133 pub profile: Option<InitProfile>,
134
135 #[clap(long, default_value = DEFAULT_KEY_ALIAS)]
137 pub key_alias: String,
138
139 #[clap(long)]
141 pub force: bool,
142
143 #[clap(long)]
146 pub confirm_replace: bool,
147
148 #[clap(long)]
150 pub dry_run: bool,
151
152 #[clap(long, env = "AUTHS_REGISTRY_URL")]
155 pub registry: Option<String>,
156
157 #[clap(long)]
159 pub register: bool,
160
161 #[clap(long)]
168 pub github: bool,
169
170 #[clap(long = "no-github", conflicts_with = "github")]
172 pub no_github: bool,
173
174 #[clap(long, value_enum)]
180 pub git_scope: Option<GitScopeArg>,
181
182 #[clap(long)]
184 pub github_action: bool,
185
186 #[clap(long, default_value_t = 1)]
193 pub device_count: u8,
194
195 #[clap(long)]
198 pub signing_threshold: Option<String>,
199
200 #[clap(long)]
202 pub rotation_threshold: Option<String>,
203}
204
205pub 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 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 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
286pub 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 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 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, ®istry_path)?;
371 ensure_registry_dir(®istry_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 guide.section("Creating Identity");
387 let sdk_ctx = build_auths_context(®istry_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 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 guide.section("Shell & Signing Setup");
459 offer_shell_completions(interactive, out)?;
460
461 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 ®istry_path,
472 &signer.root_did,
473 &signer.signer_did,
474 git_config,
475 ) {
476 Ok(()) => {
477 let _ = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(
480 &sdk_ctx,
481 ®istry_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 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 guide.section("Registration & Summary");
526 let registered = submit_registration(
527 ®istry_path,
528 cmd.registry.as_deref(),
529 proof_url,
530 !cmd.register, 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 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(®istry_path)?;
547
548 guide.section("Creating CI Identity");
550 let ci_env_config = auths_sdk::core_config::EnvironmentConfig::from_env();
553 let sdk_ctx = build_auths_context(®istry_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 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 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 if interactive && delegate_agent_interactively(out, cmd, ctx, &config, ®istry_path)? {
600 return Ok(());
601 }
602
603 guide.section("Creating Agent Identity");
606 ensure_registry_dir(®istry_path)?;
607 let sdk_ctx = build_auths_context(®istry_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 guide.section("Summary");
625 display_agent_result(out, &result);
626
627 Ok(())
628}
629
630fn 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)] 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 assert!(parse_threshold_cli("3", 2).is_err());
715 assert!(parse_threshold_cli("0", 2).is_err());
717 assert_eq!(
719 parse_threshold_cli("2", 2).unwrap(),
720 auths_keri::Threshold::Simple(2)
721 );
722 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 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 let result = resolve_interactive(&cmd).unwrap();
843 assert_eq!(result, std::io::stdin().is_terminal());
844 }
845}