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) -> 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
18    // Check if git_host is configured
19    let has_git_host = {
20        let config_path = root.join(".apm/config.toml");
21        config_path.exists() && apm_core::config::Config::load(root)
22            .map(|cfg| cfg.git_host.provider.is_some())
23            .unwrap_or(false)
24    };
25    let local_toml = root.join(".apm/local.toml");
26
27    let username = if !has_git_host && !local_toml.exists() && is_tty {
28        let gh_default = apm_core::github::gh_username();
29        prompt_username(gh_default.as_deref())?
30    } else {
31        String::new()
32    };
33
34    let default_name = root.file_name().and_then(|n| n.to_str()).unwrap_or("project").to_string();
35    let (name, description) = if is_tty && !root.join(".apm/config.toml").exists() {
36        prompt_project_info(&default_name)?
37    } else {
38        (String::new(), String::new())
39    };
40
41    let name_opt = if name.is_empty() { None } else { Some(name.as_str()) };
42    let desc_opt = if description.is_empty() { None } else { Some(description.as_str()) };
43    let user_opt = if username.is_empty() { None } else { Some(username.as_str()) };
44
45    let setup_out = apm_core::init::setup(root, name_opt, desc_opt, user_opt)?;
46    for msg in &setup_out.messages {
47        println!("{msg}");
48    }
49
50    if with_docker {
51        let docker_out = apm_core::init::setup_docker(root)?;
52        for msg in &docker_out.messages {
53            if msg.is_empty() {
54                println!();
55            } else {
56                println!("{msg}");
57            }
58        }
59    }
60    update_claude_settings(root, no_claude)?;
61    update_user_claude_settings()?;
62    warn_if_settings_untracked(root);
63    println!("apm initialized.");
64    if std::io::stdout().is_terminal() && !quiet {
65        println!();
66        println!("Next steps:");
67        println!("  * Commit the config:   git add .apm/ && git commit -m 'chore: init apm'");
68        println!("  * Create a ticket:     apm new");
69        println!("  * Open the web UI:     apm-server");
70        println!("  * Full CLI reference:  apm --help");
71    }
72    Ok(())
73}
74
75fn prompt_username(default: Option<&str>) -> Result<String> {
76    let mut stdout = std::io::stdout();
77    let stdin = std::io::stdin();
78    match default {
79        Some(d) => print!("Username [{}]: ", d),
80        None => print!("Username []: "),
81    }
82    stdout.flush()?;
83    let mut input = String::new();
84    stdin.lock().read_line(&mut input)?;
85    let trimmed = input.trim();
86    if trimmed.is_empty() {
87        Ok(default.unwrap_or("").to_string())
88    } else {
89        Ok(trimmed.to_string())
90    }
91}
92
93fn prompt_project_info(default_name: &str) -> Result<(String, String)> {
94    let mut stdout = std::io::stdout();
95    let stdin = std::io::stdin();
96
97    print!("Project name [{}]: ", default_name);
98    stdout.flush()?;
99    let mut name_input = String::new();
100    stdin.lock().read_line(&mut name_input)?;
101    let name = {
102        let trimmed = name_input.trim();
103        if trimmed.is_empty() {
104            default_name.to_string()
105        } else {
106            trimmed.to_string()
107        }
108    };
109
110    print!("Project description []: ");
111    stdout.flush()?;
112    let mut desc_input = String::new();
113    stdin.lock().read_line(&mut desc_input)?;
114    let description = desc_input.trim().to_string();
115
116    Ok((name, description))
117}
118
119fn warn_if_settings_untracked(root: &Path) {
120    let settings = root.join(".claude/settings.json");
121    if !settings.exists() {
122        return;
123    }
124    let tracked = Command::new("git")
125        .args(["ls-files", "--error-unmatch", ".claude/settings.json"])
126        .current_dir(root)
127        .output()
128        .map(|o| o.status.success())
129        .unwrap_or(false);
130    if !tracked {
131        eprintln!(
132            "Warning: .claude/settings.json exists but is not committed. \
133Agent worktrees won't have it — run: git add .claude/settings.json && git commit"
134        );
135    }
136}
137
138const APM_ALLOW_ENTRIES: &[&str] = &[
139    "Bash(apm sync*)",
140    "Bash(apm next*)",
141    "Bash(apm list*)",
142    "Bash(apm show*)",
143    "Bash(apm set *)",
144    "Bash(apm state *)",
145    "Bash(apm start *)",
146    "Bash(apm spec *)",
147    "Bash(apm agents*)",
148    "Bash(apm _hook *)",
149    "Bash(apm verify*)",
150    "Bash(apm new *)",
151    "Bash(apm worktrees*)",
152    "Bash(apm help*)",
153    "Bash(apm review *)",
154    "Bash(apm close *)",
155    "Bash(apm assign *)",
156    "Bash(apm validate*)",
157    "Bash(apm work *)",
158    "Bash(apm move *)",
159    "Bash(apm archive *)",
160    "Bash(apm clean *)",
161    "Bash(apm workers*)",
162    "Bash(apm epic *)",
163    "Bash(apm register *)",
164    "Bash(apm sessions *)",
165    "Bash(apm revoke *)",
166    "Bash(apm version*)",
167];
168
169/// Entries added to ~/.claude/settings.json so subagents running in isolated
170/// worktrees (which don't inherit project settings) can use git and apm.
171const APM_USER_ALLOW_ENTRIES: &[&str] = &[
172    "Bash(git add*)",
173    "Bash(git commit*)",
174    "Bash(git -C*)",
175    "Bash(apm sync*)",
176    "Bash(apm next*)",
177    "Bash(apm list*)",
178    "Bash(apm show*)",
179    "Bash(apm set *)",
180    "Bash(apm state *)",
181    "Bash(apm start *)",
182    "Bash(apm spec *)",
183    "Bash(apm agents*)",
184    "Bash(apm verify*)",
185    "Bash(apm new *)",
186    "Bash(apm worktrees*)",
187    "Bash(apm help*)",
188    "Bash(apm review *)",
189    "Bash(apm close *)",
190    "Bash(apm assign *)",
191    "Bash(apm validate*)",
192    "Bash(apm work *)",
193    "Bash(apm move *)",
194    "Bash(apm archive *)",
195    "Bash(apm clean *)",
196    "Bash(apm workers*)",
197    "Bash(apm epic *)",
198    "Bash(apm register *)",
199    "Bash(apm sessions *)",
200    "Bash(apm revoke *)",
201    "Bash(apm version*)",
202];
203
204fn update_settings_json(
205    path: &Path,
206    entries: &[&str],
207    prompt_header: &str,
208    prompt_confirm: &str,
209    updated_msg: &str,
210    create_if_missing: bool,
211) -> Result<()> {
212    let mut val: Value = if path.exists() {
213        let raw = std::fs::read_to_string(path)?;
214        serde_json::from_str(&raw).unwrap_or(Value::Object(Default::default()))
215    } else if create_if_missing {
216        Value::Object(Default::default())
217    } else {
218        return Ok(());
219    };
220
221    let allow = val.pointer_mut("/permissions/allow").and_then(|v| v.as_array_mut());
222    let missing: Vec<&str> = if let Some(arr) = allow {
223        entries.iter().filter(|&&e| !arr.iter().any(|v| v.as_str() == Some(e))).copied().collect()
224    } else {
225        entries.to_vec()
226    };
227
228    if missing.is_empty() {
229        return Ok(());
230    }
231
232    println!("{prompt_header}");
233    for e in &missing {
234        println!("  {e}");
235    }
236    print!("{prompt_confirm} [y/N] ");
237    io::stdout().flush()?;
238
239    let mut line = String::new();
240    io::stdin().lock().read_line(&mut line)?;
241    if !line.trim().eq_ignore_ascii_case("y") {
242        println!("Skipped.");
243        return Ok(());
244    }
245
246    if val.pointer("/permissions/allow").is_none() {
247        let perms = val
248            .as_object_mut()
249            .ok_or_else(|| anyhow::anyhow!("settings.json root is not an object"))?
250            .entry("permissions")
251            .or_insert_with(|| Value::Object(Default::default()));
252        perms.as_object_mut().unwrap()
253            .entry("allow")
254            .or_insert_with(|| Value::Array(vec![]));
255    }
256
257    let arr = val.pointer_mut("/permissions/allow")
258        .and_then(|v| v.as_array_mut())
259        .unwrap();
260    for e in missing {
261        arr.push(Value::String(e.to_string()));
262    }
263
264    if let Some(parent) = path.parent() {
265        std::fs::create_dir_all(parent)?;
266    }
267    let updated = serde_json::to_string_pretty(&val)?;
268    std::fs::write(path, updated + "\n")?;
269    println!("{updated_msg}");
270    Ok(())
271}
272
273fn update_claude_settings(root: &Path, skip: bool) -> Result<()> {
274    if skip {
275        return Ok(());
276    }
277    update_settings_json(
278        &root.join(".claude/settings.json"),
279        APM_ALLOW_ENTRIES,
280        "The following entries will be added to .claude/settings.json permissions.allow:",
281        "Add apm commands to Claude allow list?",
282        "Updated .claude/settings.json",
283        false,
284    )
285}
286
287fn update_user_claude_settings() -> Result<()> {
288    let home = match std::env::var("HOME") {
289        Ok(h) if !h.is_empty() => h,
290        _ => return Ok(()),
291    };
292    update_settings_json(
293        &PathBuf::from(&home).join(".claude/settings.json"),
294        APM_USER_ALLOW_ENTRIES,
295        "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):",
296        "Add to ~/.claude/settings.json?",
297        "Updated ~/.claude/settings.json",
298        true,
299    )
300}