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