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