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