Skip to main content

apm/cmd/
init.rs

1use anyhow::Result;
2use serde_json::Value;
3use std::io::{self, BufRead, IsTerminal, Write};
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7pub fn run(root: &Path, no_claude: bool, migrate: bool, with_docker: bool, quiet: bool, yes: bool) -> Result<()> {
8    if migrate {
9        let msgs = apm_core::init::migrate(root)?;
10        for msg in msgs {
11            println!("{msg}");
12        }
13        return Ok(());
14    }
15
16    let is_tty = std::io::stdin().is_terminal();
17    let yes = yes || !is_tty;
18
19    // Check if git_host is configured
20    let has_git_host = {
21        let config_path = root.join(".apm/config.toml");
22        config_path.exists() && apm_core::config::Config::load(root)
23            .map(|cfg| cfg.git_host.provider.is_some())
24            .unwrap_or(false)
25    };
26    let local_toml = root.join(".apm/local.toml");
27
28    let username = if !has_git_host && !local_toml.exists() && is_tty {
29        let gh_default = apm_core::github::gh_username();
30        prompt_username(gh_default.as_deref())?
31    } else {
32        String::new()
33    };
34
35    let default_name = root.file_name().and_then(|n| n.to_str()).unwrap_or("project").to_string();
36    let (name, description) = if is_tty && !root.join(".apm/config.toml").exists() {
37        prompt_project_info(&default_name)?
38    } else {
39        (String::new(), String::new())
40    };
41
42    let name_opt = if name.is_empty() { None } else { Some(name.as_str()) };
43    let desc_opt = if description.is_empty() { None } else { Some(description.as_str()) };
44    let user_opt = if username.is_empty() { None } else { Some(username.as_str()) };
45
46    let workers_default = if no_claude { Some("debug/worker") } else { None };
47    let setup_out = apm_core::init::setup(root, name_opt, desc_opt, user_opt, workers_default)?;
48    for msg in &setup_out.messages {
49        println!("{msg}");
50    }
51
52    if with_docker {
53        let docker_out = apm_core::init::setup_docker(root)?;
54        for msg in &docker_out.messages {
55            if msg.is_empty() {
56                println!();
57            } else {
58                println!("{msg}");
59            }
60        }
61    }
62    update_claude_settings(root, no_claude, yes)?;
63    update_user_claude_settings(yes)?;
64    warn_if_settings_untracked(root);
65    println!("apm initialized.");
66    if std::io::stdout().is_terminal() && !quiet {
67        println!();
68        println!("Next steps:");
69        println!("  * Commit the config:   git add .apm/ && git commit -m 'chore: init apm'");
70        println!("  * Create a ticket:     apm new");
71        println!("  * Open the web UI:     apm-server");
72        println!("  * Full CLI reference:  apm --help");
73    }
74    Ok(())
75}
76
77fn prompt_username(default: Option<&str>) -> Result<String> {
78    let mut stdout = std::io::stdout();
79    let stdin = std::io::stdin();
80    match default {
81        Some(d) => print!("Username [{}]: ", d),
82        None => print!("Username []: "),
83    }
84    stdout.flush()?;
85    let mut input = String::new();
86    stdin.lock().read_line(&mut input)?;
87    let trimmed = input.trim();
88    if trimmed.is_empty() {
89        Ok(default.unwrap_or("").to_string())
90    } else {
91        Ok(trimmed.to_string())
92    }
93}
94
95fn prompt_project_info(default_name: &str) -> Result<(String, String)> {
96    let mut stdout = std::io::stdout();
97    let stdin = std::io::stdin();
98
99    print!("Project name [{}]: ", default_name);
100    stdout.flush()?;
101    let mut name_input = String::new();
102    stdin.lock().read_line(&mut name_input)?;
103    let name = {
104        let trimmed = name_input.trim();
105        if trimmed.is_empty() {
106            default_name.to_string()
107        } else {
108            trimmed.to_string()
109        }
110    };
111
112    print!("Project description []: ");
113    stdout.flush()?;
114    let mut desc_input = String::new();
115    stdin.lock().read_line(&mut desc_input)?;
116    let description = desc_input.trim().to_string();
117
118    Ok((name, description))
119}
120
121fn warn_if_settings_untracked(root: &Path) {
122    let settings = root.join(".claude/settings.json");
123    if !settings.exists() {
124        return;
125    }
126    let tracked = Command::new("git")
127        .args(["ls-files", "--error-unmatch", ".claude/settings.json"])
128        .current_dir(root)
129        .output()
130        .map(|o| o.status.success())
131        .unwrap_or(false);
132    if !tracked {
133        eprintln!(
134            "Warning: .claude/settings.json exists but is not committed. \
135Agent worktrees won't have it — run: git add .claude/settings.json && git commit"
136        );
137    }
138}
139
140const APM_ALLOW_ENTRIES: &[&str] = &[
141    "Bash(apm sync*)",
142    "Bash(apm next*)",
143    "Bash(apm list*)",
144    "Bash(apm show*)",
145    "Bash(apm set *)",
146    "Bash(apm state *)",
147    "Bash(apm start *)",
148    "Bash(apm spec *)",
149    "Bash(apm agents*)",
150    "Bash(apm _hook *)",
151    "Bash(apm verify*)",
152    "Bash(apm new *)",
153    "Bash(apm worktrees*)",
154    "Bash(apm help*)",
155    "Bash(apm review *)",
156    "Bash(apm close *)",
157    "Bash(apm assign *)",
158    "Bash(apm validate*)",
159    "Bash(apm work *)",
160    "Bash(apm move *)",
161    "Bash(apm archive *)",
162    "Bash(apm clean *)",
163    "Bash(apm workers*)",
164    "Bash(apm epic *)",
165    "Bash(apm register *)",
166    "Bash(apm sessions *)",
167    "Bash(apm revoke *)",
168    "Bash(apm version*)",
169    "Bash(apm instructions*)",
170    // code editing
171    "Edit",
172    "Write",
173    // read-only tools
174    "Read",
175    "Glob",
176    "Grep",
177    // git ops in worktree
178    "Bash(git -C *)",
179    // read helpers
180    "Bash(ls *)",
181    "Bash(rg *)",
182    "Bash(grep *)",
183    "Bash(find *)",
184    "Bash(cat *)",
185    "Bash(head *)",
186    "Bash(tail *)",
187    "Bash(wc *)",
188    "Bash(sort *)",
189    "Bash(uniq *)",
190    "Bash(diff *)",
191    "Bash(which *)",
192    // text manipulation
193    "Bash(sed *)",
194    "Bash(awk *)",
195    // file ops (safe areas)
196    "Bash(mv *)",
197    "Bash(cp *)",
198    "Bash(rm /tmp/*)",
199    "Bash(mkdir -p /tmp/*)",
200    // shell building blocks
201    "Bash(echo *)",
202    "Bash(test *)",
203    "Bash(true)",
204    "Bash(false)",
205];
206
207/// Entries added to ~/.claude/settings.json so subagents running in isolated
208/// worktrees (which don't inherit project settings) can use git and apm.
209const APM_USER_ALLOW_ENTRIES: &[&str] = &[
210    "Bash(git add*)",
211    "Bash(git commit*)",
212    "Bash(git -C*)",
213    "Bash(apm sync*)",
214    "Bash(apm next*)",
215    "Bash(apm list*)",
216    "Bash(apm show*)",
217    "Bash(apm set *)",
218    "Bash(apm state *)",
219    "Bash(apm start *)",
220    "Bash(apm spec *)",
221    "Bash(apm agents*)",
222    "Bash(apm verify*)",
223    "Bash(apm new *)",
224    "Bash(apm worktrees*)",
225    "Bash(apm help*)",
226    "Bash(apm review *)",
227    "Bash(apm close *)",
228    "Bash(apm assign *)",
229    "Bash(apm validate*)",
230    "Bash(apm work *)",
231    "Bash(apm move *)",
232    "Bash(apm archive *)",
233    "Bash(apm clean *)",
234    "Bash(apm workers*)",
235    "Bash(apm epic *)",
236    "Bash(apm register *)",
237    "Bash(apm sessions *)",
238    "Bash(apm revoke *)",
239    "Bash(apm version*)",
240    "Bash(apm instructions*)",
241    // code editing
242    "Edit",
243    "Write",
244    // read-only tools
245    "Read",
246    "Glob",
247    "Grep",
248    // git ops in worktree
249    "Bash(git -C *)",
250    // read helpers
251    "Bash(ls *)",
252    "Bash(rg *)",
253    "Bash(grep *)",
254    "Bash(find *)",
255    "Bash(cat *)",
256    "Bash(head *)",
257    "Bash(tail *)",
258    "Bash(wc *)",
259    "Bash(sort *)",
260    "Bash(uniq *)",
261    "Bash(diff *)",
262    "Bash(which *)",
263    // text manipulation
264    "Bash(sed *)",
265    "Bash(awk *)",
266    // file ops (safe areas)
267    "Bash(mv *)",
268    "Bash(cp *)",
269    "Bash(rm /tmp/*)",
270    "Bash(mkdir -p /tmp/*)",
271    // shell building blocks
272    "Bash(echo *)",
273    "Bash(test *)",
274    "Bash(true)",
275    "Bash(false)",
276    // language toolchains (unconditional at user level)
277    "Bash(cargo *)",
278    "Bash(npm *)",
279    "Bash(npx *)",
280    "Bash(python3 *)",
281];
282
283fn update_settings_json(
284    path: &Path,
285    entries: &[&str],
286    prompt_header: &str,
287    prompt_confirm: &str,
288    updated_msg: &str,
289    create_if_missing: bool,
290    yes: bool,
291) -> Result<()> {
292    let mut val: Value = if path.exists() {
293        let raw = std::fs::read_to_string(path)?;
294        serde_json::from_str(&raw).unwrap_or(Value::Object(Default::default()))
295    } else if create_if_missing {
296        Value::Object(Default::default())
297    } else {
298        return Ok(());
299    };
300
301    let allow = val.pointer_mut("/permissions/allow").and_then(|v| v.as_array_mut());
302    let missing: Vec<&str> = if let Some(arr) = allow {
303        entries.iter().filter(|&&e| !arr.iter().any(|v| v.as_str() == Some(e))).copied().collect()
304    } else {
305        entries.to_vec()
306    };
307
308    if missing.is_empty() {
309        return Ok(());
310    }
311
312    if !yes {
313        println!("{prompt_header}");
314        for e in &missing {
315            println!("  {e}");
316        }
317        print!("{prompt_confirm} [y/N] ");
318        io::stdout().flush()?;
319
320        let mut line = String::new();
321        io::stdin().lock().read_line(&mut line)?;
322        if !line.trim().eq_ignore_ascii_case("y") {
323            println!("Skipped.");
324            return Ok(());
325        }
326    }
327
328    if val.pointer("/permissions/allow").is_none() {
329        let perms = val
330            .as_object_mut()
331            .ok_or_else(|| anyhow::anyhow!("settings.json root is not an object"))?
332            .entry("permissions")
333            .or_insert_with(|| Value::Object(Default::default()));
334        perms.as_object_mut().unwrap()
335            .entry("allow")
336            .or_insert_with(|| Value::Array(vec![]));
337    }
338
339    let arr = val.pointer_mut("/permissions/allow")
340        .and_then(|v| v.as_array_mut())
341        .unwrap();
342    for e in missing {
343        arr.push(Value::String(e.to_string()));
344    }
345
346    if let Some(parent) = path.parent() {
347        std::fs::create_dir_all(parent)?;
348    }
349    let updated = serde_json::to_string_pretty(&val)?;
350    std::fs::write(path, updated + "\n")?;
351    println!("{updated_msg}");
352    Ok(())
353}
354
355fn detected_toolchain_entries(root: &Path) -> Vec<&'static str> {
356    let mut entries = Vec::new();
357    if root.join("Cargo.toml").exists() {
358        entries.push("Bash(cargo *)");
359    }
360    if root.join("package.json").exists() {
361        entries.extend_from_slice(&["Bash(npm *)", "Bash(npx *)"]);
362    }
363    if root.join("pyproject.toml").exists() || root.join("requirements.txt").exists() {
364        entries.push("Bash(python3 *)");
365    }
366    entries
367}
368
369fn update_claude_settings(root: &Path, skip: bool, yes: bool) -> Result<()> {
370    if skip {
371        return Ok(());
372    }
373    let claude_dir = root.join(".claude");
374    if !claude_dir.exists() {
375        return Ok(());
376    }
377    let mut entries: Vec<&str> = APM_ALLOW_ENTRIES.to_vec();
378    entries.extend(detected_toolchain_entries(root));
379    update_settings_json(
380        &claude_dir.join("settings.json"),
381        &entries,
382        "The following entries will be added to .claude/settings.json permissions.allow:",
383        "Add apm commands to Claude allow list?",
384        "Updated .claude/settings.json",
385        true,
386        yes,
387    )
388}
389
390fn update_user_claude_settings(yes: bool) -> Result<()> {
391    let home = match std::env::var("HOME") {
392        Ok(h) if !h.is_empty() => h,
393        _ => return Ok(()),
394    };
395    update_settings_json(
396        &PathBuf::from(&home).join(".claude/settings.json"),
397        APM_USER_ALLOW_ENTRIES,
398        "The following entries will be added to ~/.claude/settings.json (user-level,\nrequired so apm subagents in isolated worktrees can run git and apm commands):",
399        "Add to ~/.claude/settings.json?",
400        "Updated ~/.claude/settings.json",
401        true,
402        yes,
403    )
404}