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 bean {id}. Read bean with bn show {id}. Read referenced files with bn context {id}. When done run bn close {id}.'",
37 plan: "claude -p 'Decompose bean {id}. Read bean with bn show {id}. Break into child beans with bn create --parent {id}.'",
38 version_cmd: "claude --version",
39 binary: "claude",
40 },
41 AgentPreset {
42 name: "aider",
43 run: "aider --message 'Implement bean {id}. Read bean with bn show {id}. Read referenced files with bn context {id}. When done run bn close {id}.'",
44 plan: "aider --message 'Decompose bean {id}. Read bean with bn show {id}. Break into child beans with bn 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 bean 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 bean 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 beans_dir = cwd.join(".beans");
221 let already_exists = beans_dir.exists() && beans_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(&beans_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: bn init --setup");
237 return Ok(());
238 }
239 }
241
242 if !beans_dir.exists() {
244 fs::create_dir(&beans_dir).with_context(|| {
245 format!(
246 "Failed to create .beans directory at {}",
247 beans_dir.display()
248 )
249 })?;
250 } else if !beans_dir.is_dir() {
251 anyhow::bail!(".beans exists but is not a directory");
252 }
253
254 let project = if let Some(ref name) = args.project_name {
256 name.clone()
257 } else if already_exists {
258 Config::load(&beans_dir)
260 .map(|c| c.project)
261 .unwrap_or_else(|_| auto_detect_project_name(&cwd))
262 } else {
263 auto_detect_project_name(&cwd)
264 };
265
266 let next_id = if already_exists {
268 Config::load(&beans_dir).map(|c| c.next_id).unwrap_or(1)
269 } else {
270 1
271 };
272
273 let (run, plan) = resolve_agent_config(&args)?;
275
276 let config = Config {
278 project: project.clone(),
279 next_id,
280 auto_close_parent: true,
281 max_tokens: 30000,
282 run,
283 plan,
284 max_loops: 10,
285 max_concurrent: 4,
286 poll_interval: 30,
287 extends: vec![],
288 rules_file: None,
289 file_locking: false,
290 on_close: None,
291 on_fail: None,
292 post_plan: None,
293 verify_timeout: None,
294 review: None,
295 };
296
297 config.save(&beans_dir)?;
298
299 let rules_path = beans_dir.join("RULES.md");
301 if !rules_path.exists() {
302 fs::write(
303 &rules_path,
304 "\
305# Project Rules
306
307<!-- These rules are automatically injected into every agent context.
308 Define coding standards, conventions, and constraints here.
309 Delete these comments and add your own rules. -->
310
311<!-- Example rules:
312
313## Code Style
314- Use `snake_case` for functions and variables
315- Maximum line length: 100 characters
316- All public functions must have doc comments
317
318## Architecture
319- No direct database access outside the `db` module
320- All errors must use the `anyhow` crate
321
322## Forbidden Patterns
323- No `.unwrap()` in production code
324- No `println!` for logging (use `tracing` instead)
325-->
326",
327 )
328 .with_context(|| format!("Failed to create RULES.md at {}", rules_path.display()))?;
329 }
330
331 let gitignore_path = beans_dir.join(".gitignore");
333 if !gitignore_path.exists() {
334 fs::write(
335 &gitignore_path,
336 "# Regenerable cache — rebuilt automatically by bn sync\nindex.yaml\n\n# File lock\nindex.lock\n",
337 )
338 .with_context(|| format!("Failed to create .gitignore at {}", gitignore_path.display()))?;
339 }
340
341 if already_exists && args.setup {
342 eprintln!("Reconfigured beans in .beans/");
343 } else if !already_exists {
344 eprintln!("Initialized beans in .beans/");
345 }
346
347 if config.run.is_some() {
349 eprintln!();
350 eprintln!("Next steps:");
351 eprintln!(" bn create \"my first task\" --verify \"test command\"");
352 } else {
353 eprintln!();
354 eprintln!("Next steps:");
355 eprintln!(" bn init --setup # configure an agent");
356 eprintln!(" bn config set run \"...\" # or set run command directly");
357 eprintln!(" bn create \"task\" --verify \"test command\"");
358 }
359
360 Ok(())
361}
362
363fn auto_detect_project_name(cwd: &Path) -> String {
365 cwd.file_name()
366 .and_then(|n| n.to_str())
367 .map(|s| s.to_string())
368 .unwrap_or_else(|| "project".to_string())
369}
370
371fn resolve_agent_config(args: &InitArgs) -> Result<(Option<String>, Option<String>)> {
375 if args.no_agent {
377 return Ok((None, None));
378 }
379
380 if args.run.is_some() || args.plan.is_some() {
382 return Ok((args.run.clone(), args.plan.clone()));
383 }
384
385 if let Some(ref agent_name) = args.agent {
387 let preset = find_preset(agent_name).ok_or_else(|| {
388 anyhow::anyhow!(
389 "Unknown agent '{}'. Known agents: {}",
390 agent_name,
391 PRESETS
392 .iter()
393 .map(|p| p.name)
394 .collect::<Vec<_>>()
395 .join(", ")
396 )
397 })?;
398
399 eprintln!("Verifying {}...", preset.name);
400 match verify_agent(preset) {
401 Some(version) => eprintln!(" ✓ {} → {}", preset.version_cmd, version),
402 None => eprintln!(" ⚠ {} not responding (configured anyway)", preset.name),
403 }
404
405 return Ok((Some(preset.run.to_string()), Some(preset.plan.to_string())));
406 }
407
408 if io::stderr().is_terminal() && (args.setup || !args.no_agent) {
410 if let Some((run, plan)) = interactive_agent_setup()? {
411 return Ok((Some(run), Some(plan)));
412 }
413 }
414
415 Ok((None, None))
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use std::fs;
423 use tempfile::TempDir;
424
425 fn default_args() -> InitArgs {
427 InitArgs {
428 project_name: None,
429 agent: None,
430 run: None,
431 plan: None,
432 setup: false,
433 no_agent: true, }
435 }
436
437 #[test]
438 fn init_creates_beans_dir() {
439 let dir = TempDir::new().unwrap();
440 let result = cmd_init(Some(dir.path()), default_args());
441
442 assert!(result.is_ok());
443 assert!(dir.path().join(".beans").exists());
444 assert!(dir.path().join(".beans").is_dir());
445 }
446
447 #[test]
448 fn init_creates_config_with_explicit_name() {
449 let dir = TempDir::new().unwrap();
450 let mut args = default_args();
451 args.project_name = Some("my-project".to_string());
452 let result = cmd_init(Some(dir.path()), args);
453
454 assert!(result.is_ok());
455
456 let config = Config::load(&dir.path().join(".beans")).unwrap();
457 assert_eq!(config.project, "my-project");
458 assert_eq!(config.next_id, 1);
459 }
460
461 #[test]
462 fn init_auto_detects_project_name_from_dir() {
463 let dir = TempDir::new().unwrap();
464 let result = cmd_init(Some(dir.path()), default_args());
465
466 assert!(result.is_ok());
467
468 let config = Config::load(&dir.path().join(".beans")).unwrap();
469 let dir_name = dir
470 .path()
471 .file_name()
472 .and_then(|n| n.to_str())
473 .unwrap_or("project");
474 assert_eq!(config.project, dir_name);
475 }
476
477 #[test]
478 fn init_idempotent() {
479 let dir = TempDir::new().unwrap();
480
481 let mut args1 = default_args();
482 args1.project_name = Some("test-project".to_string());
483 let result1 = cmd_init(Some(dir.path()), args1);
484 assert!(result1.is_ok());
485
486 let mut args2 = default_args();
488 args2.project_name = Some("test-project".to_string());
489 args2.setup = true;
490 let result2 = cmd_init(Some(dir.path()), args2);
491 assert!(result2.is_ok());
492
493 let config = Config::load(&dir.path().join(".beans")).unwrap();
494 assert_eq!(config.project, "test-project");
495 }
496
497 #[test]
498 fn init_config_is_valid_yaml() {
499 let dir = TempDir::new().unwrap();
500 let mut args = default_args();
501 args.project_name = Some("yaml-test".to_string());
502 let result = cmd_init(Some(dir.path()), args);
503
504 assert!(result.is_ok());
505
506 let config_path = dir.path().join(".beans").join("config.yaml");
507 assert!(config_path.exists());
508
509 let contents = fs::read_to_string(&config_path).unwrap();
510 assert!(contents.contains("project: yaml-test"));
511 assert!(contents.contains("next_id: 1"));
512 }
513
514 #[test]
515 fn init_with_agent_pi_sets_run_and_plan() {
516 let dir = TempDir::new().unwrap();
517 let mut args = default_args();
518 args.agent = Some("pi".to_string());
519 args.no_agent = false;
520 let result = cmd_init(Some(dir.path()), args);
521
522 assert!(result.is_ok());
523
524 let config = Config::load(&dir.path().join(".beans")).unwrap();
525 assert!(config.run.is_some());
526 assert!(config.plan.is_some());
527 assert!(config.run.unwrap().contains("pi"));
528 assert!(config.plan.unwrap().contains("pi"));
529 }
530
531 #[test]
532 fn init_with_agent_claude_sets_run_and_plan() {
533 let dir = TempDir::new().unwrap();
534 let mut args = default_args();
535 args.agent = Some("claude".to_string());
536 args.no_agent = false;
537 let result = cmd_init(Some(dir.path()), args);
538
539 assert!(result.is_ok());
540
541 let config = Config::load(&dir.path().join(".beans")).unwrap();
542 assert!(config.run.is_some());
543 assert!(config.plan.is_some());
544 assert!(config.run.unwrap().contains("claude"));
545 assert!(config.plan.unwrap().contains("claude"));
546 }
547
548 #[test]
549 fn init_with_agent_aider_sets_run_and_plan() {
550 let dir = TempDir::new().unwrap();
551 let mut args = default_args();
552 args.agent = Some("aider".to_string());
553 args.no_agent = false;
554 let result = cmd_init(Some(dir.path()), args);
555
556 assert!(result.is_ok());
557
558 let config = Config::load(&dir.path().join(".beans")).unwrap();
559 assert!(config.run.is_some());
560 assert!(config.plan.is_some());
561 assert!(config.run.unwrap().contains("aider"));
562 assert!(config.plan.unwrap().contains("aider"));
563 }
564
565 #[test]
566 fn init_with_unknown_agent_errors() {
567 let dir = TempDir::new().unwrap();
568 let mut args = default_args();
569 args.agent = Some("unknown-agent".to_string());
570 args.no_agent = false;
571 let result = cmd_init(Some(dir.path()), args);
572
573 assert!(result.is_err());
574 let err = format!("{}", result.unwrap_err());
575 assert!(err.contains("Unknown agent"));
576 assert!(err.contains("unknown-agent"));
577 }
578
579 #[test]
580 fn init_with_custom_run_and_plan() {
581 let dir = TempDir::new().unwrap();
582 let mut args = default_args();
583 args.run = Some("my-agent run {id}".to_string());
584 args.plan = Some("my-agent plan {id}".to_string());
585 args.no_agent = false;
586 let result = cmd_init(Some(dir.path()), args);
587
588 assert!(result.is_ok());
589
590 let config = Config::load(&dir.path().join(".beans")).unwrap();
591 assert_eq!(config.run, Some("my-agent run {id}".to_string()));
592 assert_eq!(config.plan, Some("my-agent plan {id}".to_string()));
593 }
594
595 #[test]
596 fn init_with_run_only() {
597 let dir = TempDir::new().unwrap();
598 let mut args = default_args();
599 args.run = Some("my-agent {id}".to_string());
600 args.no_agent = false;
601 let result = cmd_init(Some(dir.path()), args);
602
603 assert!(result.is_ok());
604
605 let config = Config::load(&dir.path().join(".beans")).unwrap();
606 assert_eq!(config.run, Some("my-agent {id}".to_string()));
607 assert_eq!(config.plan, None);
608 }
609
610 #[test]
611 fn init_with_no_agent_skips_setup() {
612 let dir = TempDir::new().unwrap();
613 let mut args = default_args();
614 args.no_agent = true;
615 let result = cmd_init(Some(dir.path()), args);
616
617 assert!(result.is_ok());
618
619 let config = Config::load(&dir.path().join(".beans")).unwrap();
620 assert_eq!(config.run, None);
621 assert_eq!(config.plan, None);
622 }
623
624 #[test]
625 fn init_setup_on_existing_reconfigures() {
626 let dir = TempDir::new().unwrap();
627
628 let mut args1 = default_args();
630 args1.project_name = Some("my-project".to_string());
631 cmd_init(Some(dir.path()), args1).unwrap();
632
633 let config1 = Config::load(&dir.path().join(".beans")).unwrap();
634 assert_eq!(config1.run, None);
635
636 let mut config_modified = config1;
638 config_modified.next_id = 5;
639 config_modified.save(&dir.path().join(".beans")).unwrap();
640
641 let mut args2 = default_args();
643 args2.setup = true;
644 args2.agent = Some("pi".to_string());
645 args2.no_agent = false;
646 cmd_init(Some(dir.path()), args2).unwrap();
647
648 let config2 = Config::load(&dir.path().join(".beans")).unwrap();
649 assert!(config2.run.is_some());
651 assert!(config2.run.unwrap().contains("pi"));
652 assert_eq!(config2.project, "my-project");
654 assert_eq!(config2.next_id, 5);
655 }
656
657 #[test]
658 fn reinit_without_setup_shows_config() {
659 let dir = TempDir::new().unwrap();
660
661 let mut args1 = default_args();
663 args1.project_name = Some("show-test".to_string());
664 cmd_init(Some(dir.path()), args1).unwrap();
665
666 let args2 = default_args();
668 let result = cmd_init(Some(dir.path()), args2);
669 assert!(result.is_ok());
670
671 let config = Config::load(&dir.path().join(".beans")).unwrap();
673 assert_eq!(config.project, "show-test");
674 }
675
676 #[test]
677 fn find_preset_is_case_insensitive() {
678 assert!(find_preset("Pi").is_some());
679 assert!(find_preset("PI").is_some());
680 assert!(find_preset("pi").is_some());
681 assert!(find_preset("Claude").is_some());
682 assert!(find_preset("AIDER").is_some());
683 assert!(find_preset("unknown").is_none());
684 }
685
686 #[test]
687 fn detect_agents_returns_all_presets() {
688 let agents = detect_agents();
689 assert_eq!(agents.len(), PRESETS.len());
690 for (preset, _) in &agents {
692 assert!(PRESETS.iter().any(|p| p.name == preset.name));
693 }
694 }
695
696 #[test]
697 fn init_creates_rules_md_stub() {
698 let dir = TempDir::new().unwrap();
699 cmd_init(Some(dir.path()), default_args()).unwrap();
700
701 let rules_path = dir.path().join(".beans").join("RULES.md");
702 assert!(rules_path.exists(), "RULES.md should be created by init");
703
704 let content = fs::read_to_string(&rules_path).unwrap();
705 assert!(content.contains("# Project Rules"));
706 }
707
708 #[test]
709 fn init_does_not_overwrite_existing_rules_md() {
710 let dir = TempDir::new().unwrap();
711 cmd_init(Some(dir.path()), default_args()).unwrap();
712
713 let rules_path = dir.path().join(".beans").join("RULES.md");
715 fs::write(&rules_path, "# Custom rules\nNo panics allowed.").unwrap();
716
717 let mut args = default_args();
719 args.setup = true;
720 cmd_init(Some(dir.path()), args).unwrap();
721
722 let content = fs::read_to_string(&rules_path).unwrap();
724 assert!(content.contains("No panics allowed."));
725 }
726
727 #[test]
728 fn init_preserves_next_id_on_setup() {
729 let dir = TempDir::new().unwrap();
730
731 let mut args1 = default_args();
733 args1.project_name = Some("preserve-test".to_string());
734 cmd_init(Some(dir.path()), args1).unwrap();
735
736 let beans_dir = dir.path().join(".beans");
737 let mut config = Config::load(&beans_dir).unwrap();
738 config.next_id = 42;
739 config.save(&beans_dir).unwrap();
740
741 let mut args2 = default_args();
743 args2.setup = true;
744 cmd_init(Some(dir.path()), args2).unwrap();
745
746 let config2 = Config::load(&beans_dir).unwrap();
747 assert_eq!(config2.next_id, 42);
748 }
749}