1use std::env;
2use std::fs;
3use std::io::{self, IsTerminal, Write as _};
4use std::path::Path;
5use std::process::Command;
6
7use anyhow::{Context, Result};
8
9use crate::config::Config;
10
11#[derive(Debug, Clone)]
13struct AgentPreset {
14 name: &'static str,
16 run: &'static str,
18 plan: &'static str,
20 version_cmd: &'static str,
22 binary: &'static str,
24}
25
26const PRESETS: &[AgentPreset] = &[
27 AgentPreset {
28 name: "pi",
29 run: "pi run {id}",
30 plan: "pi plan {id}",
31 version_cmd: "pi --version",
32 binary: "pi",
33 },
34 AgentPreset {
35 name: "claude",
36 run: "claude -p 'Implement unit {id}. Read unit with mana show {id}. Read referenced files with mana context {id}. When done run mana close {id}.'",
37 plan: "claude -p 'Decompose unit {id}. Read unit with mana show {id}. Break into child units with mana create --parent {id}.'",
38 version_cmd: "claude --version",
39 binary: "claude",
40 },
41 AgentPreset {
42 name: "aider",
43 run: "aider --message 'Implement unit {id}. Read unit with mana show {id}. Read referenced files with mana context {id}. When done run mana close {id}.'",
44 plan: "aider --message 'Decompose unit {id}. Read unit with mana show {id}. Break into child units with mana create --parent {id}.'",
45 version_cmd: "aider --version",
46 binary: "aider",
47 },
48];
49
50#[derive(Debug, Default)]
52pub struct InitArgs {
53 pub project_name: Option<String>,
54 pub agent: Option<String>,
55 pub run: Option<String>,
56 pub plan: Option<String>,
57 pub setup: bool,
58 pub no_agent: bool,
59}
60
61fn find_preset(name: &str) -> Option<&'static AgentPreset> {
63 let lower = name.to_lowercase();
64 PRESETS.iter().find(|p| p.name == lower)
65}
66
67fn binary_exists(name: &str) -> Option<String> {
69 Command::new("which")
70 .arg(name)
71 .output()
72 .ok()
73 .filter(|o| o.status.success())
74 .and_then(|o| String::from_utf8(o.stdout).ok())
75 .map(|s| s.trim().to_string())
76}
77
78fn detect_agents() -> Vec<(&'static AgentPreset, Option<String>)> {
81 PRESETS
82 .iter()
83 .map(|p| (p, binary_exists(p.binary)))
84 .collect()
85}
86
87fn verify_agent(preset: &AgentPreset) -> Option<String> {
89 let parts: Vec<&str> = preset.version_cmd.split_whitespace().collect();
90 if parts.is_empty() {
91 return None;
92 }
93 Command::new(parts[0])
94 .args(&parts[1..])
95 .output()
96 .ok()
97 .filter(|o| o.status.success())
98 .and_then(|o| {
99 String::from_utf8(o.stdout)
100 .or_else(|_| String::from_utf8(o.stderr.clone()))
101 .ok()
102 })
103 .map(|s| s.trim().to_string())
104}
105
106fn interactive_agent_setup() -> Result<Option<(String, String)>> {
109 let detected = detect_agents();
110
111 eprintln!("Agent setup");
112 eprintln!(" Checking for agent CLIs...");
113
114 for (preset, path) in &detected {
115 if let Some(p) = path {
116 eprintln!(" ✓ {} found ({})", preset.name, p);
117 } else {
118 eprintln!(" ✗ {} not found", preset.name);
119 }
120 }
121 eprintln!();
122
123 let mut options: Vec<String> = Vec::new();
125 for (i, (preset, path)) in detected.iter().enumerate() {
126 let marker = if path.is_some() { "✓" } else { " " };
127 options.push(format!("[{}] {} {}", i + 1, marker, preset.name));
128 }
129 options.push(format!("[{}] custom", PRESETS.len() + 1));
130 options.push(format!("[{}] skip", PRESETS.len() + 2));
131
132 eprintln!("Which agent? {}", options.join(" "));
133 eprint!("> ");
134 io::stderr().flush()?;
135
136 let mut input = String::new();
137 io::stdin().read_line(&mut input)?;
138 let input = input.trim();
139
140 let choice: usize = match input.parse() {
142 Ok(n) => n,
143 Err(_) => {
144 if let Some(preset) = find_preset(input) {
146 return finish_preset_selection(preset);
147 }
148 eprintln!("Skipping agent setup.");
149 return Ok(None);
150 }
151 };
152
153 if choice == 0 || choice > PRESETS.len() + 2 {
154 eprintln!("Skipping agent setup.");
155 return Ok(None);
156 }
157
158 if choice == PRESETS.len() + 2 {
160 return Ok(None);
161 }
162
163 if choice == PRESETS.len() + 1 {
165 eprint!("Run command template (use {{id}} for unit ID): ");
166 io::stderr().flush()?;
167 let mut run_input = String::new();
168 io::stdin().read_line(&mut run_input)?;
169 let run_cmd = run_input.trim().to_string();
170
171 eprint!("Plan command template (use {{id}} for unit ID, Enter to skip): ");
172 io::stderr().flush()?;
173 let mut plan_input = String::new();
174 io::stdin().read_line(&mut plan_input)?;
175 let plan_cmd = plan_input.trim().to_string();
176
177 if run_cmd.is_empty() {
178 eprintln!("No run command provided. Skipping agent setup.");
179 return Ok(None);
180 }
181
182 let plan = if plan_cmd.is_empty() {
183 run_cmd.clone()
184 } else {
185 plan_cmd
186 };
187
188 return Ok(Some((run_cmd, plan)));
189 }
190
191 let preset = &PRESETS[choice - 1];
193 finish_preset_selection(preset)
194}
195
196fn finish_preset_selection(preset: &AgentPreset) -> Result<Option<(String, String)>> {
198 eprintln!();
199 eprintln!("Verifying {}...", preset.name);
200 match verify_agent(preset) {
201 Some(version) => eprintln!(" ✓ {} → {}", preset.version_cmd, version),
202 None => eprintln!(
203 " ⚠ {} not responding (you can still configure it)",
204 preset.name
205 ),
206 }
207
208 Ok(Some((preset.run.to_string(), preset.plan.to_string())))
209}
210
211pub fn cmd_init(path: Option<&Path>, args: InitArgs) -> Result<()> {
215 let cwd = if let Some(p) = path {
216 p.to_path_buf()
217 } else {
218 env::current_dir()?
219 };
220 let mana_dir = cwd.join(".mana");
221 let already_exists = mana_dir.exists() && mana_dir.is_dir();
222
223 if already_exists && !args.setup && args.agent.is_none() && args.run.is_none() {
225 if let Ok(config) = Config::load(&mana_dir) {
226 eprintln!("Project: {}", config.project);
227 match &config.run {
228 Some(run) => eprintln!("Run: {}", run),
229 None => eprintln!("Run: (not configured)"),
230 }
231 match &config.plan {
232 Some(plan) => eprintln!("Plan: {}", plan),
233 None => eprintln!("Plan: (not configured)"),
234 }
235 eprintln!();
236 eprintln!("To reconfigure: mana init --setup");
237 return Ok(());
238 }
239 }
241
242 if !mana_dir.exists() {
244 fs::create_dir(&mana_dir).with_context(|| {
245 format!("Failed to create .mana directory at {}", mana_dir.display())
246 })?;
247 } else if !mana_dir.is_dir() {
248 anyhow::bail!(".mana exists but is not a directory");
249 }
250
251 let project = if let Some(ref name) = args.project_name {
253 name.clone()
254 } else if already_exists {
255 Config::load(&mana_dir)
257 .map(|c| c.project)
258 .unwrap_or_else(|_| auto_detect_project_name(&cwd))
259 } else {
260 auto_detect_project_name(&cwd)
261 };
262
263 let next_id = if already_exists {
265 Config::load(&mana_dir).map(|c| c.next_id).unwrap_or(1)
266 } else {
267 1
268 };
269
270 let (run, plan) = resolve_agent_config(&args)?;
272
273 let config = Config {
275 project: project.clone(),
276 next_id,
277 auto_close_parent: true,
278 run,
279 plan,
280 max_loops: 10,
281 max_concurrent: 4,
282 poll_interval: 30,
283 extends: vec![],
284 rules_file: None,
285 file_locking: false,
286 worktree: false,
287 on_close: None,
288 on_fail: None,
289 post_plan: None,
290 verify_timeout: None,
291 review: None,
292 user: None,
293 user_email: None,
294 auto_commit: false,
295 commit_template: None,
296 research: None,
297 run_model: None,
298 plan_model: None,
299 review_model: None,
300 research_model: None,
301 batch_verify: false,
302 memory_reserve_mb: 0,
303 notify: None,
304 };
305
306 config.save(&mana_dir)?;
307
308 let rules_path = mana_dir.join("RULES.md");
310 if !rules_path.exists() {
311 fs::write(
312 &rules_path,
313 "\
314# Project Rules
315
316<!-- These rules are automatically injected into every agent context.
317 Define coding standards, conventions, and constraints here.
318 Delete these comments and add your own rules. -->
319
320<!-- Example rules:
321
322## Code Style
323- Use `snake_case` for functions and variables
324- Maximum line length: 100 characters
325- All public functions must have doc comments
326
327## Architecture
328- No direct database access outside the `db` module
329- All errors must use the `anyhow` crate
330
331## Forbidden Patterns
332- No `.unwrap()` in production code
333- No `println!` for logging (use `tracing` instead)
334-->
335",
336 )
337 .with_context(|| format!("Failed to create RULES.md at {}", rules_path.display()))?;
338 }
339
340 let gitignore_path = mana_dir.join(".gitignore");
342 if !gitignore_path.exists() {
343 fs::write(
344 &gitignore_path,
345 "# Regenerable cache — rebuilt automatically by mana sync\nindex.yaml\narchive.yaml\n\n# File lock\nindex.lock\n",
346 )
347 .with_context(|| format!("Failed to create .gitignore at {}", gitignore_path.display()))?;
348 }
349
350 if already_exists && args.setup {
351 eprintln!("Reconfigured units in .mana/");
352 } else if !already_exists {
353 eprintln!("Initialized units in .mana/");
354 }
355
356 if config.run.is_some() {
358 eprintln!();
359 eprintln!("Next steps:");
360 eprintln!(" mana create \"my first task\" --verify \"test command\"");
361 } else {
362 eprintln!();
363 eprintln!("Next steps:");
364 eprintln!(" mana init --setup # configure an agent");
365 eprintln!(" mana config set run \"...\" # or set run command directly");
366 eprintln!(" mana create \"task\" --verify \"test command\"");
367 }
368
369 Ok(())
370}
371
372fn auto_detect_project_name(cwd: &Path) -> String {
374 cwd.file_name()
375 .and_then(|n| n.to_str())
376 .map(|s| s.to_string())
377 .unwrap_or_else(|| "project".to_string())
378}
379
380fn resolve_agent_config(args: &InitArgs) -> Result<(Option<String>, Option<String>)> {
384 if args.no_agent {
386 return Ok((None, None));
387 }
388
389 if args.run.is_some() || args.plan.is_some() {
391 return Ok((args.run.clone(), args.plan.clone()));
392 }
393
394 if let Some(ref agent_name) = args.agent {
396 let preset = find_preset(agent_name).ok_or_else(|| {
397 anyhow::anyhow!(
398 "Unknown agent '{}'. Known agents: {}",
399 agent_name,
400 PRESETS
401 .iter()
402 .map(|p| p.name)
403 .collect::<Vec<_>>()
404 .join(", ")
405 )
406 })?;
407
408 eprintln!("Verifying {}...", preset.name);
409 match verify_agent(preset) {
410 Some(version) => eprintln!(" ✓ {} → {}", preset.version_cmd, version),
411 None => eprintln!(" ⚠ {} not responding (configured anyway)", preset.name),
412 }
413
414 return Ok((Some(preset.run.to_string()), Some(preset.plan.to_string())));
415 }
416
417 if io::stderr().is_terminal() && (args.setup || !args.no_agent) {
419 if let Some((run, plan)) = interactive_agent_setup()? {
420 return Ok((Some(run), Some(plan)));
421 }
422 }
423
424 Ok((None, None))
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431 use std::fs;
432 use tempfile::TempDir;
433
434 fn default_args() -> InitArgs {
436 InitArgs {
437 project_name: None,
438 agent: None,
439 run: None,
440 plan: None,
441 setup: false,
442 no_agent: true, }
444 }
445
446 #[test]
447 fn init_creates_mana_dir() {
448 let dir = TempDir::new().unwrap();
449 let result = cmd_init(Some(dir.path()), default_args());
450
451 assert!(result.is_ok());
452 assert!(dir.path().join(".mana").exists());
453 assert!(dir.path().join(".mana").is_dir());
454 }
455
456 #[test]
457 fn init_creates_config_with_explicit_name() {
458 let dir = TempDir::new().unwrap();
459 let mut args = default_args();
460 args.project_name = Some("my-project".to_string());
461 let result = cmd_init(Some(dir.path()), args);
462
463 assert!(result.is_ok());
464
465 let config = Config::load(&dir.path().join(".mana")).unwrap();
466 assert_eq!(config.project, "my-project");
467 assert_eq!(config.next_id, 1);
468 }
469
470 #[test]
471 fn init_auto_detects_project_name_from_dir() {
472 let dir = TempDir::new().unwrap();
473 let result = cmd_init(Some(dir.path()), default_args());
474
475 assert!(result.is_ok());
476
477 let config = Config::load(&dir.path().join(".mana")).unwrap();
478 let dir_name = dir
479 .path()
480 .file_name()
481 .and_then(|n| n.to_str())
482 .unwrap_or("project");
483 assert_eq!(config.project, dir_name);
484 }
485
486 #[test]
487 fn init_idempotent() {
488 let dir = TempDir::new().unwrap();
489
490 let mut args1 = default_args();
491 args1.project_name = Some("test-project".to_string());
492 let result1 = cmd_init(Some(dir.path()), args1);
493 assert!(result1.is_ok());
494
495 let mut args2 = default_args();
497 args2.project_name = Some("test-project".to_string());
498 args2.setup = true;
499 let result2 = cmd_init(Some(dir.path()), args2);
500 assert!(result2.is_ok());
501
502 let config = Config::load(&dir.path().join(".mana")).unwrap();
503 assert_eq!(config.project, "test-project");
504 }
505
506 #[test]
507 fn init_config_is_valid_yaml() {
508 let dir = TempDir::new().unwrap();
509 let mut args = default_args();
510 args.project_name = Some("yaml-test".to_string());
511 let result = cmd_init(Some(dir.path()), args);
512
513 assert!(result.is_ok());
514
515 let config_path = dir.path().join(".mana").join("config.yaml");
516 assert!(config_path.exists());
517
518 let contents = fs::read_to_string(&config_path).unwrap();
519 assert!(contents.contains("project: yaml-test"));
520 assert!(contents.contains("next_id: 1"));
521 }
522
523 #[test]
524 fn init_with_agent_pi_sets_run_and_plan() {
525 let dir = TempDir::new().unwrap();
526 let mut args = default_args();
527 args.agent = Some("pi".to_string());
528 args.no_agent = false;
529 let result = cmd_init(Some(dir.path()), args);
530
531 assert!(result.is_ok());
532
533 let config = Config::load(&dir.path().join(".mana")).unwrap();
534 assert!(config.run.is_some());
535 assert!(config.plan.is_some());
536 assert!(config.run.unwrap().contains("pi"));
537 assert!(config.plan.unwrap().contains("pi"));
538 }
539
540 #[test]
541 fn init_with_agent_claude_sets_run_and_plan() {
542 let dir = TempDir::new().unwrap();
543 let mut args = default_args();
544 args.agent = Some("claude".to_string());
545 args.no_agent = false;
546 let result = cmd_init(Some(dir.path()), args);
547
548 assert!(result.is_ok());
549
550 let config = Config::load(&dir.path().join(".mana")).unwrap();
551 assert!(config.run.is_some());
552 assert!(config.plan.is_some());
553 assert!(config.run.unwrap().contains("claude"));
554 assert!(config.plan.unwrap().contains("claude"));
555 }
556
557 #[test]
558 fn init_with_agent_aider_sets_run_and_plan() {
559 let dir = TempDir::new().unwrap();
560 let mut args = default_args();
561 args.agent = Some("aider".to_string());
562 args.no_agent = false;
563 let result = cmd_init(Some(dir.path()), args);
564
565 assert!(result.is_ok());
566
567 let config = Config::load(&dir.path().join(".mana")).unwrap();
568 assert!(config.run.is_some());
569 assert!(config.plan.is_some());
570 assert!(config.run.unwrap().contains("aider"));
571 assert!(config.plan.unwrap().contains("aider"));
572 }
573
574 #[test]
575 fn init_with_unknown_agent_errors() {
576 let dir = TempDir::new().unwrap();
577 let mut args = default_args();
578 args.agent = Some("unknown-agent".to_string());
579 args.no_agent = false;
580 let result = cmd_init(Some(dir.path()), args);
581
582 assert!(result.is_err());
583 let err = format!("{}", result.unwrap_err());
584 assert!(err.contains("Unknown agent"));
585 assert!(err.contains("unknown-agent"));
586 }
587
588 #[test]
589 fn init_with_custom_run_and_plan() {
590 let dir = TempDir::new().unwrap();
591 let mut args = default_args();
592 args.run = Some("my-agent run {id}".to_string());
593 args.plan = Some("my-agent plan {id}".to_string());
594 args.no_agent = false;
595 let result = cmd_init(Some(dir.path()), args);
596
597 assert!(result.is_ok());
598
599 let config = Config::load(&dir.path().join(".mana")).unwrap();
600 assert_eq!(config.run, Some("my-agent run {id}".to_string()));
601 assert_eq!(config.plan, Some("my-agent plan {id}".to_string()));
602 }
603
604 #[test]
605 fn init_with_run_only() {
606 let dir = TempDir::new().unwrap();
607 let mut args = default_args();
608 args.run = Some("my-agent {id}".to_string());
609 args.no_agent = false;
610 let result = cmd_init(Some(dir.path()), args);
611
612 assert!(result.is_ok());
613
614 let config = Config::load(&dir.path().join(".mana")).unwrap();
615 assert_eq!(config.run, Some("my-agent {id}".to_string()));
616 assert_eq!(config.plan, None);
617 }
618
619 #[test]
620 fn init_with_no_agent_skips_setup() {
621 let dir = TempDir::new().unwrap();
622 let mut args = default_args();
623 args.no_agent = true;
624 let result = cmd_init(Some(dir.path()), args);
625
626 assert!(result.is_ok());
627
628 let config = Config::load(&dir.path().join(".mana")).unwrap();
629 assert_eq!(config.run, None);
630 assert_eq!(config.plan, None);
631 }
632
633 #[test]
634 fn init_setup_on_existing_reconfigures() {
635 let dir = TempDir::new().unwrap();
636
637 let mut args1 = default_args();
639 args1.project_name = Some("my-project".to_string());
640 cmd_init(Some(dir.path()), args1).unwrap();
641
642 let config1 = Config::load(&dir.path().join(".mana")).unwrap();
643 assert_eq!(config1.run, None);
644
645 let mut config_modified = config1;
647 config_modified.next_id = 5;
648 config_modified.save(&dir.path().join(".mana")).unwrap();
649
650 let mut args2 = default_args();
652 args2.setup = true;
653 args2.agent = Some("pi".to_string());
654 args2.no_agent = false;
655 cmd_init(Some(dir.path()), args2).unwrap();
656
657 let config2 = Config::load(&dir.path().join(".mana")).unwrap();
658 assert!(config2.run.is_some());
660 assert!(config2.run.unwrap().contains("pi"));
661 assert_eq!(config2.project, "my-project");
663 assert_eq!(config2.next_id, 5);
664 }
665
666 #[test]
667 fn reinit_without_setup_shows_config() {
668 let dir = TempDir::new().unwrap();
669
670 let mut args1 = default_args();
672 args1.project_name = Some("show-test".to_string());
673 cmd_init(Some(dir.path()), args1).unwrap();
674
675 let args2 = default_args();
677 let result = cmd_init(Some(dir.path()), args2);
678 assert!(result.is_ok());
679
680 let config = Config::load(&dir.path().join(".mana")).unwrap();
682 assert_eq!(config.project, "show-test");
683 }
684
685 #[test]
686 fn find_preset_is_case_insensitive() {
687 assert!(find_preset("Pi").is_some());
688 assert!(find_preset("PI").is_some());
689 assert!(find_preset("pi").is_some());
690 assert!(find_preset("Claude").is_some());
691 assert!(find_preset("AIDER").is_some());
692 assert!(find_preset("unknown").is_none());
693 }
694
695 #[test]
696 fn detect_agents_returns_all_presets() {
697 let agents = detect_agents();
698 assert_eq!(agents.len(), PRESETS.len());
699 for (preset, _) in &agents {
701 assert!(PRESETS.iter().any(|p| p.name == preset.name));
702 }
703 }
704
705 #[test]
706 fn init_creates_rules_md_stub() {
707 let dir = TempDir::new().unwrap();
708 cmd_init(Some(dir.path()), default_args()).unwrap();
709
710 let rules_path = dir.path().join(".mana").join("RULES.md");
711 assert!(rules_path.exists(), "RULES.md should be created by init");
712
713 let content = fs::read_to_string(&rules_path).unwrap();
714 assert!(content.contains("# Project Rules"));
715 }
716
717 #[test]
718 fn init_does_not_overwrite_existing_rules_md() {
719 let dir = TempDir::new().unwrap();
720 cmd_init(Some(dir.path()), default_args()).unwrap();
721
722 let rules_path = dir.path().join(".mana").join("RULES.md");
724 fs::write(&rules_path, "# Custom rules\nNo panics allowed.").unwrap();
725
726 let mut args = default_args();
728 args.setup = true;
729 cmd_init(Some(dir.path()), args).unwrap();
730
731 let content = fs::read_to_string(&rules_path).unwrap();
733 assert!(content.contains("No panics allowed."));
734 }
735
736 #[test]
737 fn init_preserves_next_id_on_setup() {
738 let dir = TempDir::new().unwrap();
739
740 let mut args1 = default_args();
742 args1.project_name = Some("preserve-test".to_string());
743 cmd_init(Some(dir.path()), args1).unwrap();
744
745 let mana_dir = dir.path().join(".mana");
746 let mut config = Config::load(&mana_dir).unwrap();
747 config.next_id = 42;
748 config.save(&mana_dir).unwrap();
749
750 let mut args2 = default_args();
752 args2.setup = true;
753 cmd_init(Some(dir.path()), args2).unwrap();
754
755 let config2 = Config::load(&mana_dir).unwrap();
756 assert_eq!(config2.next_id, 42);
757 }
758}