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 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
169const 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}