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 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 "Edit",
172 "Write",
173 "Read",
175 "Glob",
176 "Grep",
177 "Bash(git -C *)",
179 "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 "Bash(sed *)",
194 "Bash(awk *)",
195 "Bash(mv *)",
197 "Bash(cp *)",
198 "Bash(rm /tmp/*)",
199 "Bash(mkdir -p /tmp/*)",
200 "Bash(echo *)",
202 "Bash(test *)",
203 "Bash(true)",
204 "Bash(false)",
205];
206
207const 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 "Edit",
243 "Write",
244 "Read",
246 "Glob",
247 "Grep",
248 "Bash(git -C *)",
250 "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 "Bash(sed *)",
265 "Bash(awk *)",
266 "Bash(mv *)",
268 "Bash(cp *)",
269 "Bash(rm /tmp/*)",
270 "Bash(mkdir -p /tmp/*)",
271 "Bash(echo *)",
273 "Bash(test *)",
274 "Bash(true)",
275 "Bash(false)",
276 "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}