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::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#[derive(Debug, Clone, Copy, ValueEnum)]
48pub enum InitProfile {
49 Developer,
51 Ci,
53 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#[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 #[clap(long, conflicts_with = "non_interactive")]
100 pub interactive: bool,
101
102 #[clap(long, conflicts_with = "interactive")]
104 pub non_interactive: bool,
105
106 #[clap(long, value_enum)]
108 pub profile: Option<InitProfile>,
109
110 #[clap(long, default_value = DEFAULT_KEY_ALIAS)]
112 pub key_alias: String,
113
114 #[clap(long)]
116 pub force: bool,
117
118 #[clap(long)]
120 pub dry_run: bool,
121
122 #[clap(long, env = "AUTHS_REGISTRY_URL", default_value = DEFAULT_REGISTRY_URL)]
124 pub registry: String,
125
126 #[clap(long)]
128 pub register: bool,
129
130 #[clap(long)]
132 pub github_action: bool,
133
134 #[clap(long, default_value_t = 1)]
141 pub device_count: u8,
142
143 #[clap(long)]
146 pub signing_threshold: Option<String>,
147
148 #[clap(long)]
150 pub rotation_threshold: Option<String>,
151}
152
153pub 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 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
226pub 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 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 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(®istry_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 guide.section("Creating Identity");
327 let sdk_ctx = build_auths_context(®istry_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 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 guide.section("Shell & Signing Setup");
375 offer_shell_completions(interactive, out)?;
376
377 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 ®istry_path,
388 &signer.root_did,
389 &signer.signer_did,
390 git_config,
391 ) {
392 Ok(()) => {
393 let _ = auths_sdk::workflows::commit_hooks::refresh_commit_trailers(
396 &sdk_ctx,
397 ®istry_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 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 guide.section("Registration & Summary");
429 let registered = submit_registration(
430 &get_auths_repo_path()?,
431 &cmd.registry,
432 proof_url,
433 !cmd.register, 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 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(®istry_path)?;
450
451 guide.section("Creating CI Identity");
453 let ci_env_config = auths_sdk::core_config::EnvironmentConfig::from_env();
456 let sdk_ctx = build_auths_context(®istry_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 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 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 if interactive && delegate_agent_interactively(out, cmd, ctx, &config, ®istry_path)? {
503 return Ok(());
504 }
505
506 guide.section("Creating Agent Identity");
509 ensure_registry_dir(®istry_path)?;
510 let sdk_ctx = build_auths_context(®istry_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 guide.section("Summary");
528 display_agent_result(out, &result);
529
530 Ok(())
531}
532
533fn 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)] 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 let result = resolve_interactive(&cmd).unwrap();
709 assert_eq!(result, std::io::stdin().is_terminal());
710 }
711}