acp/commands/
init.rs

1//! @acp:module "Init Command"
2//! @acp:summary "Initialize a new ACP project"
3//! @acp:domain cli
4//! @acp:layer handler
5//!
6//! Implements `acp init` command for project initialization.
7
8use std::io::IsTerminal;
9use std::path::PathBuf;
10
11use anyhow::Result;
12use console::style;
13use dialoguer::{theme::ColorfulTheme, Confirm, Input, MultiSelect};
14
15use crate::config::Config;
16use crate::scan::scan_project;
17use crate::sync::{SyncExecutor, Tool as SyncTool};
18
19/// Options for the init command
20#[derive(Debug, Clone, Default)]
21pub struct InitOptions {
22    /// Force overwrite existing config
23    pub force: bool,
24    /// File patterns to include
25    pub include: Vec<String>,
26    /// File patterns to exclude
27    pub exclude: Vec<String>,
28    /// Config file output path (default: .acp.config.json)
29    pub output: Option<PathBuf>,
30    /// Cache file output path
31    pub cache_path: Option<PathBuf>,
32    /// Vars file output path
33    pub vars_path: Option<PathBuf>,
34    /// Number of parallel workers
35    pub workers: Option<usize>,
36    /// Skip interactive prompts
37    pub yes: bool,
38    /// Skip AI tool bootstrap
39    pub no_bootstrap: bool,
40}
41
42/// Execute the init command
43pub fn execute_init(options: InitOptions) -> Result<()> {
44    let config_path = options
45        .output
46        .clone()
47        .unwrap_or_else(|| PathBuf::from(".acp.config.json"));
48
49    if config_path.exists() && !options.force {
50        eprintln!(
51            "{} Config file already exists. Use --force to overwrite.",
52            style("✗").red()
53        );
54        std::process::exit(1);
55    }
56
57    let mut config = Config::default();
58
59    // Interactive mode if stdin is TTY, no CLI options, and not using --yes
60    let interactive = !options.yes
61        && std::io::stdin().is_terminal()
62        && options.include.is_empty()
63        && options.exclude.is_empty()
64        && options.output.is_none()
65        && options.cache_path.is_none()
66        && options.vars_path.is_none()
67        && options.workers.is_none();
68
69    if interactive {
70        run_interactive_init(&mut config)?;
71    } else {
72        apply_cli_options(&mut config, &options);
73    }
74
75    // Create .acp directory
76    let acp_dir = PathBuf::from(".acp");
77    if !acp_dir.exists() {
78        std::fs::create_dir(&acp_dir)?;
79        println!("{} Created .acp/ directory", style("✓").green());
80    }
81
82    // Write config
83    config.save(&config_path)?;
84    println!("{} Created {}", style("✓").green(), config_path.display());
85
86    // Bootstrap AI tool files
87    if !options.no_bootstrap {
88        bootstrap_ai_tools(interactive)?;
89    }
90
91    // Print next steps
92    println!("\n{}", style("Next steps:").bold());
93    println!(
94        "  1. Run {} to index your codebase",
95        style("acp index").cyan()
96    );
97    println!("  2. AI tools will read context from generated files");
98
99    Ok(())
100}
101
102fn run_interactive_init(config: &mut Config) -> Result<()> {
103    println!("{} ACP Project Setup\n", style("→").cyan());
104
105    // Scan project to detect languages
106    println!("{} Scanning project...", style("→").dim());
107    let scan = scan_project(".");
108
109    if scan.languages.is_empty() {
110        println!("{} No supported languages detected\n", style("⚠").yellow());
111    } else {
112        println!("{} Detected languages:", style("✓").green());
113        for lang in &scan.languages {
114            println!(
115                "    {} ({} files)",
116                style(lang.name).cyan(),
117                lang.file_count
118            );
119        }
120        println!();
121
122        // Auto-populate include patterns from detected languages
123        let mut include_patterns: Vec<String> = vec![];
124        for lang in &scan.languages {
125            include_patterns.extend(lang.patterns.iter().map(|s| s.to_string()));
126        }
127        config.include = include_patterns;
128
129        // Ask to confirm or modify
130        let use_detected = Confirm::with_theme(&ColorfulTheme::default())
131            .with_prompt("Use detected languages?")
132            .default(true)
133            .interact()?;
134
135        if !use_detected {
136            select_languages_manually(config)?;
137        }
138    }
139
140    // Custom excludes
141    let add_excludes = Confirm::with_theme(&ColorfulTheme::default())
142        .with_prompt("Add custom exclude patterns? (node_modules, dist, etc. already excluded)")
143        .default(false)
144        .interact()?;
145
146    if add_excludes {
147        let custom: String = Input::with_theme(&ColorfulTheme::default())
148            .with_prompt("Enter patterns (comma-separated)")
149            .interact_text()?;
150        config
151            .exclude
152            .extend(custom.split(',').map(|s| s.trim().to_string()));
153    }
154
155    Ok(())
156}
157
158fn select_languages_manually(config: &mut Config) -> Result<()> {
159    let all_languages = [
160        ("TypeScript/TSX", vec!["**/*.ts", "**/*.tsx"]),
161        ("JavaScript/JSX", vec!["**/*.js", "**/*.jsx", "**/*.mjs"]),
162        ("Rust", vec!["**/*.rs"]),
163        ("Python", vec!["**/*.py"]),
164        ("Go", vec!["**/*.go"]),
165        ("Java", vec!["**/*.java"]),
166    ];
167
168    let items: Vec<&str> = all_languages.iter().map(|(name, _)| *name).collect();
169    let selections = MultiSelect::with_theme(&ColorfulTheme::default())
170        .with_prompt("Select languages to index")
171        .items(&items)
172        .interact()?;
173
174    config.include = selections
175        .iter()
176        .flat_map(|&idx| all_languages[idx].1.iter().map(|s| s.to_string()))
177        .collect();
178
179    Ok(())
180}
181
182fn apply_cli_options(config: &mut Config, options: &InitOptions) {
183    if !options.include.is_empty() {
184        config.include = options.include.clone();
185    }
186    if !options.exclude.is_empty() {
187        config.exclude.extend(options.exclude.iter().cloned());
188    }
189    // Output paths are handled via Config helper methods
190    // cache_path and vars_path can be passed to commands directly
191}
192
193fn bootstrap_ai_tools(interactive: bool) -> Result<()> {
194    let sync = SyncExecutor::new();
195    let project_root = PathBuf::from(".");
196    let detected = sync.detect_tools(&project_root);
197
198    if !detected.is_empty() {
199        println!("\n{} Detected AI tools:", style("✓").green());
200        for tool in &detected {
201            println!("    {} ({})", style(tool.name()).cyan(), tool.output_path());
202        }
203
204        // In interactive mode, confirm; in non-interactive, just do it
205        let should_bootstrap = if interactive {
206            Confirm::with_theme(&ColorfulTheme::default())
207                .with_prompt("Bootstrap detected tools with ACP context?")
208                .default(true)
209                .interact()?
210        } else {
211            true
212        };
213
214        if should_bootstrap {
215            println!();
216            for tool in detected {
217                match sync.bootstrap_tool(tool, &project_root) {
218                    Ok(result) => {
219                        let action = match result.action {
220                            crate::sync::BootstrapAction::Created => "Created",
221                            crate::sync::BootstrapAction::Merged => "Updated",
222                            crate::sync::BootstrapAction::Skipped => "Skipped",
223                        };
224                        println!(
225                            "{} {} {}",
226                            style("✓").green(),
227                            action,
228                            result.output_path.display()
229                        );
230                    }
231                    Err(e) => {
232                        eprintln!("{} Failed {}: {}", style("✗").red(), tool.output_path(), e);
233                    }
234                }
235            }
236        }
237    }
238
239    // Always create AGENTS.md as fallback if it doesn't exist
240    let agents_md = project_root.join("AGENTS.md");
241    if !agents_md.exists() {
242        match sync.bootstrap_tool(SyncTool::Generic, &project_root) {
243            Ok(result) => {
244                println!(
245                    "{} Created {} (universal fallback)",
246                    style("✓").green(),
247                    result.output_path.display()
248                );
249            }
250            Err(e) => {
251                eprintln!("{} Failed to create AGENTS.md: {}", style("✗").red(), e);
252            }
253        }
254    }
255
256    Ok(())
257}