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::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 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 guide.section("Registration & Summary");
392 let registered = submit_registration(
393 &get_auths_repo_path()?,
394 &cmd.registry,
395 proof_url,
396 !cmd.register, 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 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(®istry_path)?;
413
414 guide.section("Creating CI Identity");
416 let ci_env_config = auths_sdk::core_config::EnvironmentConfig::from_env();
419 let sdk_ctx = build_auths_context(®istry_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 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 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 guide.section("Creating Agent Identity");
463 ensure_registry_dir(®istry_path)?;
464 let sdk_ctx = build_auths_context(®istry_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 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 let result = resolve_interactive(&cmd).unwrap();
606 assert_eq!(result, std::io::stdin().is_terminal());
607 }
608}