Skip to main content

edict/commands/
init.rs

1use std::fs;
2use std::io::IsTerminal;
3use std::path::{Path, PathBuf};
4
5use anyhow::{Context, Result};
6use clap::Args;
7
8use crate::config::{
9    self, AgentsConfig, Config, DevAgentConfig, MissionsConfig, ProjectConfig, ReviewConfig,
10    ReviewerAgentConfig, ToolsConfig, WorkerAgentConfig,
11};
12use crate::error::ExitError;
13use crate::subprocess::{Tool, run_command};
14use crate::template::render_agents_md;
15
16const PROJECT_TYPES: &[&str] = &["api", "cli", "frontend", "library", "monorepo", "tui"];
17const AVAILABLE_TOOLS: &[&str] = &["bones", "maw", "crit", "botbus", "vessel"];
18const REVIEWER_ROLES: &[&str] = &["security"];
19const LANGUAGES: &[&str] = &["rust", "python", "node", "go", "typescript", "java"];
20const CONFIG_VERSION: &str = "1.0.16";
21
22/// Validate that a name (project, reviewer role) matches [a-z0-9][a-z0-9-]* and is ≤64 chars.
23/// Prevents command injection and path traversal via user-supplied names.
24/// Infer project name from the current directory (or ws/default parent).
25fn infer_project_name() -> Option<String> {
26    let cwd = std::env::current_dir().ok()?;
27    // If we're in ws/default, go up two levels
28    let dir = if cwd.ends_with("ws/default") {
29        cwd.parent()?.parent()?
30    } else {
31        &cwd
32    };
33    let name = dir.file_name()?.to_str()?;
34    // Lowercase and replace non-alphanumeric with hyphens
35    let sanitized: String = name
36        .chars()
37        .map(|c| {
38            if c.is_ascii_alphanumeric() {
39                c.to_ascii_lowercase()
40            } else {
41                '-'
42            }
43        })
44        .collect();
45    let trimmed = sanitized.trim_matches('-').to_string();
46    if trimmed.is_empty() || validate_name(&trimmed, "project name").is_err() {
47        return None;
48    }
49    Some(trimmed)
50}
51
52fn validate_name(name: &str, label: &str) -> Result<()> {
53    if name.is_empty() || name.len() > 64 {
54        anyhow::bail!("{label} must be 1-64 characters, got {}", name.len());
55    }
56    if !name
57        .bytes()
58        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
59    {
60        anyhow::bail!("{label} must match [a-z0-9-], got {name:?}");
61    }
62    if name.starts_with('-') || name.ends_with('-') {
63        anyhow::bail!("{label} must not start or end with '-', got {name:?}");
64    }
65    Ok(())
66}
67
68#[derive(Debug, Args)]
69pub struct InitArgs {
70    /// Project name
71    #[arg(long)]
72    pub name: Option<String>,
73    /// Project types (comma-separated: api, cli, frontend, library, monorepo, tui)
74    #[arg(long, value_delimiter = ',')]
75    pub r#type: Vec<String>,
76    /// Tools to enable (comma-separated: bones, maw, crit, botbus, vessel)
77    #[arg(long, value_delimiter = ',')]
78    pub tools: Vec<String>,
79    /// Reviewer roles (comma-separated: security)
80    #[arg(long, value_delimiter = ',')]
81    pub reviewers: Vec<String>,
82    /// Languages for .gitignore generation (comma-separated: rust, python, node, go, typescript, java)
83    #[arg(long, value_delimiter = ',')]
84    pub language: Vec<String>,
85    /// Install command (e.g., "just install")
86    #[arg(long)]
87    pub install_command: Option<String>,
88    /// Check command run before merging (e.g., "just check", "cargo check")
89    #[arg(long)]
90    pub check_command: Option<String>,
91    /// Non-interactive mode
92    #[arg(long)]
93    pub no_interactive: bool,
94    /// Skip bones initialization
95    #[arg(long, alias = "no-init-beads")]
96    pub no_init_bones: bool,
97    /// Skip seeding initial work bones
98    #[arg(long)]
99    pub no_seed_work: bool,
100    /// Force overwrite existing config
101    #[arg(long)]
102    pub force: bool,
103    /// Skip auto-commit
104    #[arg(long)]
105    pub no_commit: bool,
106    /// Project root directory
107    #[arg(long)]
108    pub project_root: Option<PathBuf>,
109}
110
111/// Collected user choices for init
112struct InitChoices {
113    name: String,
114    types: Vec<String>,
115    tools: Vec<String>,
116    reviewers: Vec<String>,
117    languages: Vec<String>,
118    install_command: Option<String>,
119    check_command: Option<String>,
120    init_bones: bool,
121    seed_work: bool,
122}
123
124impl InitArgs {
125    pub fn execute(&self) -> Result<()> {
126        let project_dir = self
127            .project_root
128            .clone()
129            .unwrap_or_else(|| std::env::current_dir().expect("Failed to get current dir"));
130
131        // Canonicalize project root and verify it contains config or is a new init target
132        let project_dir = project_dir.canonicalize().unwrap_or(project_dir);
133
134        // Detect maw v2 bare repo
135        let ws_default = project_dir.join("ws/default");
136        if config::find_config(&ws_default).is_some()
137            || (ws_default.exists() && !project_dir.join(".agents/edict").exists())
138        {
139            return self.handle_bare_repo(&project_dir);
140        }
141
142        let agents_dir = project_dir.join(".agents/edict");
143        let agents_md_path = project_dir.join("AGENTS.md");
144        let is_reinit = agents_dir.exists();
145
146        // Detect existing config from AGENTS.md on re-init
147        let detected = if is_reinit && agents_md_path.exists() {
148            let content = fs::read_to_string(&agents_md_path)?;
149            detect_from_agents_md(&content)
150        } else {
151            DetectedConfig::default()
152        };
153
154        let interactive = !self.no_interactive && std::io::stdin().is_terminal();
155        let choices = self.gather_choices(interactive, &detected)?;
156
157        // Create .agents/edict/
158        fs::create_dir_all(&agents_dir)?;
159        println!("Created .agents/edict/");
160
161        // Run sync to copy workflow docs, prompts, design docs, hooks
162        // We create config first so sync can read it
163        let config = build_config(&choices);
164
165        // Write .edict.toml
166        let config_path = project_dir.join(config::CONFIG_TOML);
167        if !config_path.exists() || self.force {
168            let toml_str = config.to_toml()?;
169            fs::write(&config_path, &toml_str)?;
170            println!("Generated {}", config::CONFIG_TOML);
171        }
172
173        // Copy workflow docs (reuse sync logic)
174        sync_workflow_docs(&agents_dir)?;
175        println!("Copied workflow docs");
176
177        // Copy prompt templates
178        sync_prompts(&agents_dir)?;
179        println!("Copied prompt templates");
180
181        // Copy design docs
182        sync_design_docs(&agents_dir)?;
183        println!("Copied design docs");
184
185        // Install global agent hooks (idempotent)
186        crate::commands::hooks::HooksCommand::Install {
187            project_root: Some(project_dir.clone()),
188        }
189        .execute()
190        .unwrap_or_else(|e| eprintln!("Warning: failed to install global hooks: {e}"));
191
192        // Generate AGENTS.md
193        if agents_md_path.exists() && !self.force {
194            tracing::warn!(
195                "AGENTS.md already exists. Use --force to overwrite, or run `edict sync` to update."
196            );
197        } else {
198            let content = render_agents_md(&config)?;
199            fs::write(&agents_md_path, content)?;
200            println!("Generated AGENTS.md");
201        }
202
203        // Initialize bones
204        if choices.init_bones && choices.tools.contains(&"bones".to_string()) {
205            match run_command("bn", &["init"], Some(&project_dir)) {
206                Ok(_) => println!("Initialized bones"),
207                Err(_) => tracing::warn!("bn init failed (is bones installed?)"),
208            }
209        }
210
211        // Initialize maw
212        if choices.tools.contains(&"maw".to_string()) {
213            match run_command("maw", &["init"], Some(&project_dir)) {
214                Ok(_) => println!("Initialized maw"),
215                Err(_) => tracing::warn!("maw init failed (is maw installed?)"),
216            }
217        }
218
219        // Initialize crit
220        if choices.tools.contains(&"crit".to_string()) {
221            match run_command("crit", &["init"], Some(&project_dir)) {
222                Ok(_) => println!("Initialized crit"),
223                Err(_) => tracing::warn!("crit init failed (is crit installed?)"),
224            }
225
226            // Create .critignore
227            let critignore_path = project_dir.join(".critignore");
228            if !critignore_path.exists() {
229                fs::write(
230                    &critignore_path,
231                    "# Ignore edict-managed files (prompts, scripts, hooks, journals)\n\
232                     .agents/edict/\n\
233                     \n\
234                     # Ignore tool config and data files\n\
235                     .crit/\n\
236                     .maw.toml\n\
237                     .edict.toml\n\
238                     .botbox.json\n\
239                     .claude/\n\
240                     opencode.json\n",
241                )?;
242                println!("Created .critignore");
243            }
244        }
245
246        // Register project on #projects channel (skip on re-init)
247        if choices.tools.contains(&"botbus".to_string()) && !is_reinit {
248            let abs_path = project_dir
249                .canonicalize()
250                .unwrap_or_else(|_| project_dir.clone());
251            let tools_list = choices.tools.join(", ");
252            let agent = format!("{}-dev", choices.name);
253            let msg = format!(
254                "project: {}  repo: {}  lead: {}  tools: {}",
255                choices.name,
256                abs_path.display(),
257                agent,
258                tools_list
259            );
260            match Tool::new("bus")
261                .args(&[
262                    "send",
263                    "--agent",
264                    &agent,
265                    "projects",
266                    &msg,
267                    "-L",
268                    "project-registry",
269                ])
270                .run()
271            {
272                Ok(output) if output.success() => {
273                    println!("Registered project on #projects channel")
274                }
275                _ => tracing::warn!("failed to register on #projects (is bus installed?)"),
276            }
277        }
278
279        // Seed initial work bones
280        if choices.seed_work && choices.tools.contains(&"bones".to_string()) {
281            let count = seed_initial_bones(&project_dir, &choices.name, &choices.types);
282            if count > 0 {
283                let suffix = if count > 1 { "s" } else { "" };
284                println!("Created {count} seed bone{suffix}");
285            }
286        }
287
288        // Register botbus hooks
289        if choices.tools.contains(&"botbus".to_string()) {
290            register_spawn_hooks(&project_dir, &choices.name, &choices.reviewers, &config);
291        }
292
293        // Generate .gitignore
294        if !choices.languages.is_empty() {
295            let gitignore_path = project_dir.join(".gitignore");
296            if !gitignore_path.exists() {
297                match fetch_gitignore(&choices.languages) {
298                    Ok(content) => {
299                        fs::write(&gitignore_path, content)?;
300                        println!("Generated .gitignore for: {}", choices.languages.join(", "));
301                    }
302                    Err(e) => tracing::warn!("failed to generate .gitignore: {e}"),
303                }
304            } else {
305                println!(".gitignore already exists, skipping generation");
306            }
307        }
308
309        // Auto-commit
310        if !is_reinit && !self.no_commit {
311            auto_commit(&project_dir, &config)?;
312        }
313
314        println!("Done.");
315        Ok(())
316    }
317
318    fn handle_bare_repo(&self, project_dir: &Path) -> Result<()> {
319        let project_dir = project_dir
320            .canonicalize()
321            .context("canonicalizing project root")?;
322
323        // Gather interactive choices HERE (where stdin is a terminal) so that
324        // the inner `maw exec` invocation can run non-interactively.
325        let ws_default = project_dir.join("ws/default");
326        let agents_md_path = ws_default.join("AGENTS.md");
327        let detected = if agents_md_path.exists() {
328            let content = fs::read_to_string(&agents_md_path)?;
329            detect_from_agents_md(&content)
330        } else {
331            DetectedConfig::default()
332        };
333
334        let interactive = !self.no_interactive && std::io::stdin().is_terminal();
335        let choices = self.gather_choices(interactive, &detected)?;
336
337        let mut args: Vec<String> = vec!["exec", "default", "--", "edict", "init"]
338            .into_iter()
339            .map(Into::into)
340            .collect();
341
342        // Always pass gathered choices as explicit args so inner invocation
343        // doesn't need interactive input.
344        args.push("--name".into());
345        args.push(choices.name.clone());
346        args.push("--type".into());
347        args.push(choices.types.join(","));
348        args.push("--tools".into());
349        args.push(choices.tools.join(","));
350        if !choices.reviewers.is_empty() {
351            args.push("--reviewers".into());
352            args.push(choices.reviewers.join(","));
353        }
354        if !choices.languages.is_empty() {
355            args.push("--language".into());
356            args.push(choices.languages.join(","));
357        }
358        if let Some(ref cmd) = choices.install_command {
359            args.push("--install-command".into());
360            args.push(cmd.clone());
361        }
362        if let Some(ref cmd) = choices.check_command {
363            args.push("--check-command".into());
364            args.push(cmd.clone());
365        }
366        if self.force {
367            args.push("--force".into());
368        }
369        // Inner invocation is always non-interactive (stdin piped by maw exec)
370        args.push("--no-interactive".into());
371        if self.no_commit {
372            args.push("--no-commit".into());
373        }
374        if !choices.init_bones {
375            args.push("--no-init-bones".into());
376        }
377        if !choices.seed_work {
378            args.push("--no-seed-work".into());
379        }
380
381        let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
382        run_command("maw", &arg_refs, Some(&project_dir))?;
383
384        // Create bare root stubs
385        let stub_content = "**Do not edit the root AGENTS.md for memories or instructions. Use the AGENTS.md in ws/default/.**\n@ws/default/AGENTS.md\n";
386        let stub_agents = project_dir.join("AGENTS.md");
387        if !stub_agents.exists() {
388            fs::write(&stub_agents, stub_content)?;
389            println!("Created bare-root AGENTS.md stub");
390        }
391
392        // Symlink .claude → ws/default/.claude
393        let root_claude_dir = project_dir.join(".claude");
394        let ws_claude_dir = project_dir.join("ws/default/.claude");
395        if ws_claude_dir.exists() {
396            let needs_symlink = match fs::read_link(&root_claude_dir) {
397                Ok(target) => target != Path::new("ws/default/.claude"),
398                Err(_) => true,
399            };
400            if needs_symlink {
401                let tmp_link = project_dir.join(".claude.tmp");
402                let _ = fs::remove_file(&tmp_link);
403                #[cfg(unix)]
404                std::os::unix::fs::symlink("ws/default/.claude", &tmp_link)?;
405                #[cfg(windows)]
406                std::os::windows::fs::symlink_dir("ws/default/.claude", &tmp_link)?;
407                if let Err(e) = fs::rename(&tmp_link, &root_claude_dir) {
408                    let _ = fs::remove_file(&tmp_link);
409                    return Err(e).context("creating .claude symlink");
410                }
411                println!("Symlinked .claude → ws/default/.claude");
412            }
413        }
414
415        // Symlink .pi → ws/default/.pi
416        let root_pi_dir = project_dir.join(".pi");
417        let ws_pi_dir = project_dir.join("ws/default/.pi");
418        if ws_pi_dir.exists() {
419            let needs_symlink = match fs::read_link(&root_pi_dir) {
420                Ok(target) => target != Path::new("ws/default/.pi"),
421                Err(_) => true,
422            };
423            if needs_symlink {
424                let tmp_link = project_dir.join(".pi.tmp");
425                let _ = fs::remove_file(&tmp_link);
426                #[cfg(unix)]
427                std::os::unix::fs::symlink("ws/default/.pi", &tmp_link)?;
428                #[cfg(windows)]
429                std::os::windows::fs::symlink_dir("ws/default/.pi", &tmp_link)?;
430                if let Err(e) = fs::rename(&tmp_link, &root_pi_dir) {
431                    let _ = fs::remove_file(&tmp_link);
432                    return Err(e).context("creating .pi symlink");
433                }
434                println!("Symlinked .pi → ws/default/.pi");
435            }
436        }
437
438        Ok(())
439    }
440
441    fn gather_choices(&self, interactive: bool, detected: &DetectedConfig) -> Result<InitChoices> {
442        // Project name
443        let name = if let Some(ref n) = self.name {
444            validate_name(n, "project name")?;
445            n.clone()
446        } else if interactive {
447            let n = prompt_input("Project name", detected.name.as_deref())?;
448            validate_name(&n, "project name")?;
449            n
450        } else {
451            let n = detected
452                .name
453                .clone()
454                .or_else(|| infer_project_name())
455                .ok_or_else(|| {
456                    ExitError::Other("--name is required in non-interactive mode".into())
457                })?;
458            validate_name(&n, "project name")?;
459            n
460        };
461
462        // Project types
463        let types = if !self.r#type.is_empty() {
464            validate_values(&self.r#type, PROJECT_TYPES, "project type")?;
465            self.r#type.clone()
466        } else if interactive {
467            let defaults: Vec<bool> = PROJECT_TYPES
468                .iter()
469                .map(|t| detected.types.contains(&t.to_string()))
470                .collect();
471            prompt_multi_select(
472                "Project type (select one or more)",
473                PROJECT_TYPES,
474                &defaults,
475            )?
476        } else {
477            if detected.types.is_empty() {
478                vec!["cli".to_string()]
479            } else {
480                detected.types.clone()
481            }
482        };
483
484        // Tools
485        let tools = if !self.tools.is_empty() {
486            validate_values(&self.tools, AVAILABLE_TOOLS, "tool")?;
487            self.tools.clone()
488        } else if interactive {
489            let defaults: Vec<bool> = AVAILABLE_TOOLS
490                .iter()
491                .map(|t| {
492                    if detected.tools.is_empty() {
493                        true // all enabled by default
494                    } else {
495                        detected.tools.contains(&t.to_string())
496                    }
497                })
498                .collect();
499            prompt_multi_select("Tools to enable", AVAILABLE_TOOLS, &defaults)?
500        } else if detected.tools.is_empty() {
501            AVAILABLE_TOOLS.iter().map(|s| s.to_string()).collect()
502        } else {
503            detected.tools.clone()
504        };
505
506        // Reviewers
507        let reviewers = if !self.reviewers.is_empty() {
508            validate_values(&self.reviewers, REVIEWER_ROLES, "reviewer role")?;
509            for r in &self.reviewers {
510                validate_name(r, "reviewer role")?;
511            }
512            self.reviewers.clone()
513        } else if interactive {
514            let defaults: Vec<bool> = REVIEWER_ROLES
515                .iter()
516                .map(|r| detected.reviewers.contains(&r.to_string()))
517                .collect();
518            prompt_multi_select("Reviewer roles", REVIEWER_ROLES, &defaults)?
519        } else {
520            detected.reviewers.clone()
521        };
522
523        // Languages
524        let languages = if !self.language.is_empty() {
525            validate_values(&self.language, LANGUAGES, "language")?;
526            self.language.clone()
527        } else if interactive {
528            prompt_multi_select(
529                "Languages/frameworks (for .gitignore generation)",
530                LANGUAGES,
531                &vec![false; LANGUAGES.len()],
532            )?
533        } else {
534            Vec::new()
535        };
536
537        // Init bones
538        let init_bones = if self.no_init_bones {
539            false
540        } else if interactive {
541            prompt_confirm("Initialize bones?", true)?
542        } else {
543            false
544        };
545
546        // Seed work
547        let seed_work = if self.no_seed_work {
548            false
549        } else if interactive {
550            prompt_confirm("Seed initial work bones?", false)?
551        } else {
552            false
553        };
554
555        // Install command
556        let install_command = if let Some(ref cmd) = self.install_command {
557            Some(cmd.clone())
558        } else if interactive {
559            if prompt_confirm("Install locally after releases? (for CLI tools)", false)? {
560                Some(prompt_input("Install command", Some("just install"))?)
561            } else {
562                None
563            }
564        } else {
565            None
566        };
567
568        // Check command (auto-detect from language, allow override)
569        let check_command = if let Some(ref cmd) = self.check_command {
570            Some(cmd.clone())
571        } else {
572            let auto = detect_check_command(&languages);
573            if interactive {
574                let default = auto.as_deref().unwrap_or("just check");
575                Some(prompt_input(
576                    "Build/check command (run before merging)",
577                    Some(default),
578                )?)
579            } else {
580                auto
581            }
582        };
583
584        Ok(InitChoices {
585            name,
586            types,
587            tools,
588            reviewers,
589            languages,
590            install_command,
591            check_command,
592            init_bones,
593            seed_work,
594        })
595    }
596}
597
598// --- Interactive prompts using dialoguer ---
599
600fn prompt_input(prompt: &str, default: Option<&str>) -> Result<String> {
601    let mut builder = dialoguer::Input::<String>::new().with_prompt(prompt);
602    if let Some(d) = default {
603        builder = builder.default(d.to_string());
604    }
605    builder.interact_text().context("reading user input")
606}
607
608fn prompt_multi_select(prompt: &str, items: &[&str], defaults: &[bool]) -> Result<Vec<String>> {
609    let selections = dialoguer::MultiSelect::new()
610        .with_prompt(prompt)
611        .items(items)
612        .defaults(defaults)
613        .interact()
614        .context("reading user selection")?;
615
616    Ok(selections
617        .into_iter()
618        .map(|i| items[i].to_string())
619        .collect())
620}
621
622fn prompt_confirm(prompt: &str, default: bool) -> Result<bool> {
623    dialoguer::Confirm::new()
624        .with_prompt(prompt)
625        .default(default)
626        .interact()
627        .context("reading user confirmation")
628}
629
630// --- Validation ---
631
632fn validate_values(values: &[String], valid: &[&str], label: &str) -> Result<()> {
633    let invalid: Vec<&String> = values
634        .iter()
635        .filter(|v| !valid.contains(&v.as_str()))
636        .collect();
637    if !invalid.is_empty() {
638        let inv = invalid
639            .iter()
640            .map(|s| s.as_str())
641            .collect::<Vec<_>>()
642            .join(", ");
643        let val = valid.join(", ");
644        return Err(ExitError::Other(format!("Unknown {label}: {inv}. Valid: {val}")).into());
645    }
646    Ok(())
647}
648
649// --- Config detection from AGENTS.md header ---
650
651#[derive(Debug, Default)]
652struct DetectedConfig {
653    name: Option<String>,
654    types: Vec<String>,
655    tools: Vec<String>,
656    reviewers: Vec<String>,
657}
658
659fn detect_from_agents_md(content: &str) -> DetectedConfig {
660    let mut config = DetectedConfig::default();
661
662    for line in content.lines().take(20) {
663        if line.starts_with("# ") && config.name.is_none() {
664            config.name = Some(line[2..].trim().to_string());
665        } else if let Some(rest) = line.strip_prefix("Project type: ") {
666            config.types = rest.split(',').map(|s| s.trim().to_string()).collect();
667        } else if let Some(rest) = line.strip_prefix("Tools: ") {
668            config.tools = rest
669                .split(',')
670                .map(|s| s.trim().trim_matches('`').to_string())
671                .collect();
672        } else if let Some(rest) = line.strip_prefix("Reviewer roles: ") {
673            config.reviewers = rest.split(',').map(|s| s.trim().to_string()).collect();
674        }
675    }
676
677    config
678}
679
680// --- Language detection ---
681
682/// Auto-detect the appropriate check command from the selected languages.
683fn detect_check_command(languages: &[String]) -> Option<String> {
684    for lang in languages {
685        match lang.as_str() {
686            "rust" => return Some("cargo clippy && cargo test".to_string()),
687            "typescript" | "node" => return Some("npm run build && npm test".to_string()),
688            "python" => return Some("python -m pytest".to_string()),
689            "go" => return Some("go vet ./... && go test ./...".to_string()),
690            "java" => return Some("mvn verify".to_string()),
691            _ => {}
692        }
693    }
694    None
695}
696
697// --- Config building ---
698
699fn build_config(choices: &InitChoices) -> Config {
700    Config {
701        version: CONFIG_VERSION.to_string(),
702        project: ProjectConfig {
703            name: choices.name.clone(),
704            project_type: choices.types.clone(),
705            languages: choices.languages.clone(),
706            default_agent: Some(format!("{}-dev", choices.name)),
707            channel: Some(choices.name.clone()),
708            install_command: choices.install_command.clone(),
709            check_command: choices.check_command.clone(),
710            critical_approvers: None,
711        },
712        tools: ToolsConfig {
713            bones: choices.tools.contains(&"bones".to_string()),
714            maw: choices.tools.contains(&"maw".to_string()),
715            crit: choices.tools.contains(&"crit".to_string()),
716            botbus: choices.tools.contains(&"botbus".to_string()),
717            vessel: choices.tools.contains(&"vessel".to_string()),
718        },
719        review: ReviewConfig {
720            enabled: !choices.reviewers.is_empty(),
721            reviewers: choices.reviewers.clone(),
722        },
723        push_main: false,
724        agents: AgentsConfig {
725            dev: Some(DevAgentConfig {
726                model: "strong".into(),
727                max_loops: 100,
728                pause: 2,
729                timeout: 3600,
730                missions: Some(MissionsConfig {
731                    enabled: true,
732                    max_workers: 4,
733                    max_children: 12,
734                    checkpoint_interval_sec: 30,
735                }),
736                multi_lead: None,
737                memory_limit: None,
738            }),
739            worker: Some(WorkerAgentConfig {
740                model: "fast".into(),
741                timeout: 900,
742                memory_limit: None,
743            }),
744            reviewer: Some(ReviewerAgentConfig {
745                model: "strong".into(),
746                max_loops: 100,
747                pause: 2,
748                timeout: 900,
749                memory_limit: None,
750            }),
751            responder: None,
752        },
753        models: Default::default(),
754        env: build_default_env(&choices.languages),
755    }
756}
757
758/// Build default [env] vars based on project languages.
759fn build_default_env(languages: &[String]) -> std::collections::HashMap<String, String> {
760    let mut env = std::collections::HashMap::new();
761    let has_rust = languages.iter().any(|l| l.eq_ignore_ascii_case("rust"));
762    if has_rust {
763        env.insert("CARGO_BUILD_JOBS".into(), "2".into());
764        env.insert("RUSTC_WRAPPER".into(), "sccache".into());
765        env.insert("SCCACHE_DIR".into(), "$HOME/.cache/sccache".into());
766    }
767    env
768}
769
770// --- Sync helpers (reuse embedded content from sync.rs) ---
771
772// Re-embed the same workflow docs as sync.rs
773use crate::commands::sync::{DESIGN_DOCS, REVIEWER_PROMPTS, WORKFLOW_DOCS};
774
775fn sync_workflow_docs(agents_dir: &Path) -> Result<()> {
776    for (name, content) in WORKFLOW_DOCS {
777        let path = agents_dir.join(name);
778        fs::write(&path, content).with_context(|| format!("writing {}", path.display()))?;
779    }
780
781    // Write version marker
782    use sha2::{Digest, Sha256};
783    let mut hasher = Sha256::new();
784    for (name, content) in WORKFLOW_DOCS {
785        hasher.update(name.as_bytes());
786        hasher.update(content.as_bytes());
787    }
788    let version = format!("{:x}", hasher.finalize());
789    fs::write(agents_dir.join(".version"), &version[..32])?;
790
791    Ok(())
792}
793
794fn sync_prompts(agents_dir: &Path) -> Result<()> {
795    let prompts_dir = agents_dir.join("prompts");
796    fs::create_dir_all(&prompts_dir)?;
797
798    for (name, content) in REVIEWER_PROMPTS {
799        let path = prompts_dir.join(name);
800        fs::write(&path, content).with_context(|| format!("writing {}", path.display()))?;
801    }
802
803    use sha2::{Digest, Sha256};
804    let mut hasher = Sha256::new();
805    for (name, content) in REVIEWER_PROMPTS {
806        hasher.update(name.as_bytes());
807        hasher.update(content.as_bytes());
808    }
809    let version = format!("{:x}", hasher.finalize());
810    fs::write(prompts_dir.join(".prompts-version"), &version[..32])?;
811
812    Ok(())
813}
814
815fn sync_design_docs(agents_dir: &Path) -> Result<()> {
816    let design_dir = agents_dir.join("design");
817    fs::create_dir_all(&design_dir)?;
818
819    for (name, content) in DESIGN_DOCS {
820        let path = design_dir.join(name);
821        fs::write(&path, content).with_context(|| format!("writing {}", path.display()))?;
822    }
823
824    use sha2::{Digest, Sha256};
825    let mut hasher = Sha256::new();
826    for (name, content) in DESIGN_DOCS {
827        hasher.update(name.as_bytes());
828        hasher.update(content.as_bytes());
829    }
830    let version = format!("{:x}", hasher.finalize());
831    fs::write(design_dir.join(".design-docs-version"), &version[..32])?;
832
833    Ok(())
834}
835
836// sync_hooks removed — hooks are now installed globally via `edict hooks install`
837
838// --- Hook registration ---
839
840fn register_spawn_hooks(project_dir: &Path, name: &str, reviewers: &[String], config: &Config) {
841    let abs_path = project_dir
842        .canonicalize()
843        .unwrap_or_else(|_| project_dir.to_path_buf());
844    let agent = format!("{name}-dev");
845
846    // Detect maw v2 workspace context
847    let (hook_cwd, spawn_cwd) = detect_hook_paths(&abs_path);
848
849    // Check if bus supports hooks
850    if Tool::new("bus").arg("hooks").arg("list").run().is_err() {
851        return;
852    }
853
854    // Register router hook
855    let responder_memory_limit = config
856        .agents
857        .responder
858        .as_ref()
859        .and_then(|r| r.memory_limit.as_deref());
860    register_router_hook(&hook_cwd, &spawn_cwd, name, &agent, responder_memory_limit);
861
862    // Register reviewer hooks
863    let reviewer_memory_limit = config
864        .agents
865        .reviewer
866        .as_ref()
867        .and_then(|r| r.memory_limit.as_deref());
868    for role in reviewers {
869        let reviewer_agent = format!("{name}-{role}");
870        register_reviewer_hook(&hook_cwd, &spawn_cwd, name, &agent, &reviewer_agent, reviewer_memory_limit);
871    }
872}
873
874fn detect_hook_paths(abs_path: &Path) -> (String, String) {
875    // In maw v2, if we're inside ws/default/, use the bare root
876    let abs_str = abs_path.display().to_string();
877    if let Some(parent) = abs_path.parent()
878        && parent.file_name().is_some_and(|n| n == "ws")
879        && let Some(bare_root) = parent.parent()
880        && bare_root.join(".manifold").exists()
881    {
882        let bare_str = bare_root.display().to_string();
883        return (bare_str.clone(), bare_str);
884    }
885    (abs_str.clone(), abs_str)
886}
887
888pub(super) fn register_router_hook(
889    hook_cwd: &str,
890    spawn_cwd: &str,
891    name: &str,
892    agent: &str,
893    memory_limit: Option<&str>,
894) {
895    let env_inherit = "BOTBUS_CHANNEL,BOTBUS_MESSAGE_ID,BOTBUS_HOOK_ID,SSH_AUTH_SOCK,OTEL_EXPORTER_OTLP_ENDPOINT,TRACEPARENT";
896    let claim_uri = format!("agent://{name}-dev");
897    let spawn_name = format!("{name}-responder");
898    let description = format!("edict:{name}:responder");
899
900    let mut args: Vec<&str> = vec![
901        "--agent",
902        agent,
903        "--channel",
904        name,
905        "--claim",
906        &claim_uri,
907        "--claim-owner",
908        agent,
909        "--cwd",
910        hook_cwd,
911        "--ttl",
912        "600",
913        "--",
914        "vessel",
915        "spawn",
916        "--env-inherit",
917        env_inherit,
918    ];
919    if let Some(limit) = memory_limit {
920        args.push("--memory-limit");
921        args.push(limit);
922    }
923    args.extend_from_slice(&[
924        "--name",
925        &spawn_name,
926        "--cwd",
927        spawn_cwd,
928        "--",
929        "edict",
930        "run",
931        "responder",
932    ]);
933
934    match crate::subprocess::ensure_bus_hook(&description, &args) {
935        Ok((action, _id)) => println!("Router hook {action} for #{name}"),
936        Err(e) => eprintln!("Warning: Failed to register router hook: {e}"),
937    }
938}
939
940pub(super) fn register_reviewer_hook(
941    hook_cwd: &str,
942    spawn_cwd: &str,
943    name: &str,
944    agent: &str,
945    reviewer_agent: &str,
946    memory_limit: Option<&str>,
947) {
948    let env_inherit = "BOTBUS_CHANNEL,BOTBUS_MESSAGE_ID,BOTBUS_HOOK_ID,SSH_AUTH_SOCK,OTEL_EXPORTER_OTLP_ENDPOINT,TRACEPARENT";
949    let claim_uri = format!("agent://{reviewer_agent}");
950    // Extract role suffix from reviewer_agent (e.g., "myproject-security" → "security")
951    let role = reviewer_agent
952        .strip_prefix(&format!("{name}-"))
953        .unwrap_or(reviewer_agent);
954    let description = format!("edict:{name}:reviewer-{role}");
955
956    let mut args: Vec<&str> = vec![
957        "--agent",
958        agent,
959        "--channel",
960        name,
961        "--mention",
962        reviewer_agent,
963        "--claim",
964        &claim_uri,
965        "--claim-owner",
966        reviewer_agent,
967        "--ttl",
968        "600",
969        "--priority",
970        "1",
971        "--cwd",
972        hook_cwd,
973        "--",
974        "vessel",
975        "spawn",
976        "--env-inherit",
977        env_inherit,
978    ];
979    if let Some(limit) = memory_limit {
980        args.push("--memory-limit");
981        args.push(limit);
982    }
983    args.extend_from_slice(&[
984        "--name",
985        reviewer_agent,
986        "--cwd",
987        spawn_cwd,
988        "--",
989        "edict",
990        "run",
991        "reviewer-loop",
992        "--agent",
993        reviewer_agent,
994    ]);
995
996    match crate::subprocess::ensure_bus_hook(&description, &args) {
997        Ok((action, _id)) => println!("Reviewer hook for @{reviewer_agent} {action}"),
998        Err(e) => eprintln!("Warning: Failed to register mention hook for @{reviewer_agent}: {e}"),
999    }
1000}
1001
1002// --- Seed bones ---
1003
1004fn seed_initial_bones(project_dir: &Path, _name: &str, types: &[String]) -> usize {
1005    let mut count = 0;
1006
1007    let create_bone = |title: &str, description: &str, urgency: &str| -> bool {
1008        Tool::new("bn")
1009            .args(&[
1010                "create",
1011                &format!("--title={title}"),
1012                &format!("--description={description}"),
1013                "--kind=task",
1014                &format!("--urgency={urgency}"),
1015            ])
1016            .run()
1017            .is_ok_and(|o| o.success())
1018    };
1019
1020    // Scout for spec files
1021    for spec in ["spec.md", "SPEC.md", "specification.md", "design.md"] {
1022        if project_dir.join(spec).exists()
1023            && create_bone(
1024                &format!("Review {spec} and create implementation bones"),
1025                &format!(
1026                    "Read {spec}, understand requirements, and break down into actionable bones with acceptance criteria."
1027                ),
1028                "urgent",
1029            )
1030        {
1031            count += 1;
1032        }
1033    }
1034
1035    // Scout for README
1036    if project_dir.join("README.md").exists()
1037        && create_bone(
1038            "Review README and align project setup",
1039            "Read README.md for project goals, architecture decisions, and setup requirements. Create bones for any gaps.",
1040            "default",
1041        )
1042    {
1043        count += 1;
1044    }
1045
1046    // Scout for source structure
1047    if !project_dir.join("src").exists()
1048        && create_bone(
1049            "Create initial source structure",
1050            &format!(
1051                "Set up src/ directory and project scaffolding for project type: {}.",
1052                types.join(", ")
1053            ),
1054            "default",
1055        )
1056    {
1057        count += 1;
1058    }
1059
1060    // Fallback
1061    if count == 0
1062        && create_bone(
1063            "Scout project and create initial bones",
1064            "Explore the repository, understand the project goals, and create actionable bones for initial implementation work.",
1065            "urgent",
1066        )
1067    {
1068        count += 1;
1069    }
1070
1071    count
1072}
1073
1074// --- .gitignore ---
1075
1076fn fetch_gitignore(languages: &[String]) -> Result<String> {
1077    // Validate all language names against the allowlist before constructing the URL
1078    // to prevent SSRF via crafted language names (e.g., "../admin" or URL fragments)
1079    for lang in languages {
1080        if !LANGUAGES.contains(&lang.as_str()) {
1081            anyhow::bail!("unknown language for .gitignore: {lang:?}. Valid: {LANGUAGES:?}");
1082        }
1083    }
1084    let langs = languages.join(",");
1085    let url = format!("https://www.toptal.com/developers/gitignore/api/{langs}");
1086    let body = ureq::get(&url).call()?.into_body().read_to_string()?;
1087    Ok(body)
1088}
1089
1090// --- Auto-commit ---
1091
1092fn auto_commit(project_dir: &Path, config: &Config) -> Result<()> {
1093    let message = format!("chore: initialize edict v{}", config.version);
1094
1095    // Try git first, fall back to jj for legacy projects
1096    if project_dir.join(".git").exists()
1097        || project_dir
1098            .ancestors()
1099            .any(|p| p.join(".git").exists() || p.join("repo.git").exists())
1100    {
1101        let _ = run_command("git", &["add", "-A"], Some(project_dir));
1102        match run_command("git", &["commit", "-m", &message], Some(project_dir)) {
1103            Ok(_) => println!("Committed: {message}"),
1104            Err(_) => eprintln!("Warning: Failed to auto-commit (git error)"),
1105        }
1106    } else if project_dir.join(".jj").exists() {
1107        match run_command("jj", &["describe", "-m", &message], Some(project_dir)) {
1108            Ok(_) => println!("Committed: {message}"),
1109            Err(_) => eprintln!("Warning: Failed to auto-commit (jj error)"),
1110        }
1111    }
1112
1113    Ok(())
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119
1120    #[test]
1121    fn test_detect_from_agents_md() {
1122        let content = "# myproject\n\nProject type: cli, api\nTools: `bones`, `maw`, `crit`\nReviewer roles: security\n";
1123        let detected = detect_from_agents_md(content);
1124        assert_eq!(detected.name, Some("myproject".to_string()));
1125        assert_eq!(detected.types, vec!["cli", "api"]);
1126        assert_eq!(detected.tools, vec!["bones", "maw", "crit"]);
1127        assert_eq!(detected.reviewers, vec!["security"]);
1128    }
1129
1130    #[test]
1131    fn test_detect_from_empty_agents_md() {
1132        let detected = detect_from_agents_md("");
1133        assert!(detected.name.is_none());
1134        assert!(detected.types.is_empty());
1135        assert!(detected.tools.is_empty());
1136        assert!(detected.reviewers.is_empty());
1137    }
1138
1139    #[test]
1140    fn test_validate_values_ok() {
1141        let values = vec!["bones".to_string(), "maw".to_string()];
1142        assert!(validate_values(&values, AVAILABLE_TOOLS, "tool").is_ok());
1143    }
1144
1145    #[test]
1146    fn test_validate_values_invalid() {
1147        let values = vec!["bones".to_string(), "invalid".to_string()];
1148        let result = validate_values(&values, AVAILABLE_TOOLS, "tool");
1149        assert!(result.is_err());
1150        let err = result.unwrap_err().to_string();
1151        assert!(err.contains("invalid"));
1152    }
1153
1154    #[test]
1155    fn test_build_config() {
1156        let choices = InitChoices {
1157            name: "test".to_string(),
1158            types: vec!["cli".to_string()],
1159            tools: vec!["bones".to_string(), "maw".to_string()],
1160            reviewers: vec!["security".to_string()],
1161            languages: vec!["rust".to_string()],
1162            install_command: Some("just install".to_string()),
1163            check_command: Some("cargo test && cargo clippy -- -D warnings".to_string()),
1164            init_bones: true,
1165            seed_work: false,
1166        };
1167
1168        let config = build_config(&choices);
1169        assert_eq!(config.project.name, "test");
1170        assert_eq!(config.project.default_agent, Some("test-dev".to_string()));
1171        assert_eq!(config.project.channel, Some("test".to_string()));
1172        assert!(config.tools.bones);
1173        assert!(config.tools.maw);
1174        assert!(!config.tools.crit);
1175        assert!(config.review.enabled);
1176        assert_eq!(config.review.reviewers, vec!["security"]);
1177        assert_eq!(
1178            config.project.install_command,
1179            Some("just install".to_string())
1180        );
1181        assert_eq!(config.project.languages, vec!["rust"]);
1182
1183        let dev = config.agents.dev.unwrap();
1184        assert_eq!(dev.model, "strong");
1185        assert_eq!(dev.max_loops, 100);
1186        assert!(dev.missions.is_some());
1187
1188        // Rust project should seed [env] with cargo/sccache defaults
1189        assert_eq!(config.env.get("CARGO_BUILD_JOBS").unwrap(), "2");
1190        assert_eq!(config.env.get("RUSTC_WRAPPER").unwrap(), "sccache");
1191        assert_eq!(
1192            config.env.get("SCCACHE_DIR").unwrap(),
1193            "$HOME/.cache/sccache"
1194        );
1195    }
1196
1197    #[test]
1198    fn test_build_config_no_env_for_non_rust() {
1199        let choices = InitChoices {
1200            name: "jsapp".to_string(),
1201            types: vec!["web".to_string()],
1202            tools: vec!["bones".to_string()],
1203            reviewers: vec![],
1204            languages: vec!["typescript".to_string()],
1205            install_command: None,
1206            check_command: None,
1207            init_bones: false,
1208            seed_work: false,
1209        };
1210        let config = build_config(&choices);
1211        assert!(config.env.is_empty());
1212    }
1213
1214    #[test]
1215    fn test_config_version_matches() {
1216        // Ensure CONFIG_VERSION is a valid semver-ish string
1217        assert!(CONFIG_VERSION.starts_with("1.0."));
1218    }
1219
1220    #[test]
1221    fn test_validate_name_valid() {
1222        assert!(validate_name("myproject", "test").is_ok());
1223        assert!(validate_name("my-project", "test").is_ok());
1224        assert!(validate_name("project123", "test").is_ok());
1225        assert!(validate_name("a", "test").is_ok());
1226    }
1227
1228    #[test]
1229    fn test_validate_name_invalid() {
1230        assert!(validate_name("", "test").is_err()); // empty
1231        assert!(validate_name("-starts-dash", "test").is_err()); // leading dash
1232        assert!(validate_name("ends-dash-", "test").is_err()); // trailing dash
1233        assert!(validate_name("Has Uppercase", "test").is_err()); // uppercase
1234        assert!(validate_name("has space", "test").is_err()); // space
1235        assert!(validate_name("path/../traversal", "test").is_err()); // path chars
1236        assert!(validate_name("a;rm -rf /", "test").is_err()); // injection
1237        assert!(validate_name(&"a".repeat(65), "test").is_err()); // too long
1238    }
1239
1240    #[test]
1241    fn test_fetch_gitignore_validates_languages() {
1242        // Unknown language should be rejected before URL construction
1243        let result = fetch_gitignore(&["malicious/../../etc".to_string()]);
1244        assert!(result.is_err());
1245        assert!(result.unwrap_err().to_string().contains("unknown language"));
1246    }
1247}