1use std::path::{Path, PathBuf};
14
15use crate::agents::planning as agent_planning;
16use crate::error::AgentConfigError;
17use crate::integration::{
18 InstallReport, InstructionSurface, Integration, McpSurface, SkillSurface, UninstallReport,
19};
20use crate::paths;
21use crate::plan::{InstallPlan, UninstallPlan};
22use crate::scope::{Scope, ScopeKind};
23use crate::spec::{HookSpec, InstructionSpec, McpSpec, SkillSpec};
24use crate::status::StatusReport;
25use crate::util::{
26 file_lock, fs_atomic, instructions_dir, mcp_json_object, md_block, ownership, safe_fs,
27 skills_dir,
28};
29
30#[derive(Debug, Clone, Copy, Default)]
32pub struct AmpAgent {
33 _private: (),
34}
35
36impl AmpAgent {
37 pub const fn new() -> Self {
39 Self { _private: () }
40 }
41
42 fn amp_home_from_home(home: &Path) -> PathBuf {
43 home.join(".amp")
44 }
45
46 fn rules_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
47 Ok(match scope {
48 Scope::Global => Self::amp_home_from_home(&paths::home_dir()?).join("AGENTS.md"),
49 Scope::Local(p) => p.join("AGENTS.md"),
50 })
51 }
52
53 fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
54 Ok(match scope {
55 Scope::Global => Self::amp_home_from_home(&paths::home_dir()?).join("settings.json"),
56 Scope::Local(p) => p.join(".amp").join("settings.json"),
57 })
58 }
59
60 fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
61 Ok(match scope {
62 Scope::Global => Self::amp_home_from_home(&paths::home_dir()?).join("skills"),
63 Scope::Local(p) => p.join(".amp").join("skills"),
64 })
65 }
66
67 fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
73 Ok(match scope {
74 Scope::Global => Self::amp_home_from_home(&paths::home_dir()?),
75 Scope::Local(p) => p.join(".amp"),
76 })
77 }
78}
79
80impl Integration for AmpAgent {
81 fn id(&self) -> &'static str {
82 "amp"
83 }
84
85 fn display_name(&self) -> &'static str {
86 "Amp"
87 }
88
89 fn supported_scopes(&self) -> &'static [ScopeKind] {
90 &[ScopeKind::Global, ScopeKind::Local]
91 }
92
93 fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
94 HookSpec::validate_tag(tag)?;
95 let path = Self::rules_path(scope)?;
96 StatusReport::for_markdown_block_hook(tag, path)
97 }
98
99 fn plan_install(
100 &self,
101 scope: &Scope,
102 spec: &HookSpec,
103 ) -> Result<InstallPlan, AgentConfigError> {
104 agent_planning::markdown_install(
105 Integration::id(self),
106 scope,
107 spec,
108 Self::rules_path(scope),
109 true,
110 )
111 }
112
113 fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
114 agent_planning::markdown_uninstall(
115 Integration::id(self),
116 scope,
117 tag,
118 Self::rules_path(scope),
119 )
120 }
121
122 fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
123 HookSpec::validate_tag(&spec.tag)?;
124 agent_planning::validate_prompt_only_event(Integration::id(self), &spec.event)?;
125 let rules = spec
126 .rules
127 .as_ref()
128 .ok_or(AgentConfigError::MissingSpecField {
129 id: "amp",
130 field: "rules",
131 })?;
132 let path = Self::rules_path(scope)?;
133 let mut report = InstallReport::default();
134 scope.ensure_contained(&path)?;
135 file_lock::with_lock(&path, || {
136 let host = fs_atomic::read_to_string_or_empty(&path)?;
137 let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
138 let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
139 if outcome.no_change {
140 report.already_installed = true;
141 } else if outcome.existed {
142 report.patched.push(outcome.path.clone());
143 } else {
144 report.created.push(outcome.path.clone());
145 }
146 if let Some(b) = outcome.backup {
147 report.backed_up.push(b);
148 }
149 Ok::<(), AgentConfigError>(())
150 })?;
151 Ok(report)
152 }
153
154 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
155 HookSpec::validate_tag(tag)?;
156 let path = Self::rules_path(scope)?;
157 let mut report = UninstallReport::default();
158 scope.ensure_contained(&path)?;
159 file_lock::with_lock(&path, || {
160 let host = fs_atomic::read_to_string_or_empty(&path)?;
161 let (stripped, removed) = md_block::remove(&host, tag);
162
163 if !removed {
164 report.not_installed = true;
165 return Ok(());
166 }
167
168 if stripped.trim().is_empty() {
169 if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
170 report.restored.push(path.clone());
171 } else {
172 safe_fs::remove_file(scope, &path)?;
173 report.removed.push(path.clone());
174 }
175 } else {
176 safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
177 report.patched.push(path.clone());
178 }
179 Ok::<(), AgentConfigError>(())
180 })?;
181 Ok(report)
182 }
183}
184
185impl McpSurface for AmpAgent {
186 fn id(&self) -> &'static str {
187 "amp"
188 }
189
190 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
191 &[ScopeKind::Global, ScopeKind::Local]
192 }
193
194 fn mcp_status(
195 &self,
196 scope: &Scope,
197 name: &str,
198 expected_owner: &str,
199 ) -> Result<StatusReport, AgentConfigError> {
200 McpSpec::validate_name(name)?;
201 let cfg = Self::mcp_path(scope)?;
202 let ledger = ownership::mcp_ledger_for(&cfg);
203 let presence = mcp_json_object::config_presence(&cfg, name)?;
204 let recorded = ownership::owner_of(&ledger, name)?;
205 Ok(StatusReport::for_mcp(
206 name,
207 cfg,
208 ledger,
209 presence,
210 expected_owner,
211 recorded,
212 ))
213 }
214
215 fn plan_install_mcp(
216 &self,
217 scope: &Scope,
218 spec: &McpSpec,
219 ) -> Result<InstallPlan, AgentConfigError> {
220 agent_planning::mcp_json_object_install(
221 McpSurface::id(self),
222 scope,
223 spec,
224 Self::mcp_path(scope),
225 )
226 }
227
228 fn plan_uninstall_mcp(
229 &self,
230 scope: &Scope,
231 name: &str,
232 owner_tag: &str,
233 ) -> Result<UninstallPlan, AgentConfigError> {
234 agent_planning::mcp_json_object_uninstall(
235 McpSurface::id(self),
236 scope,
237 name,
238 owner_tag,
239 Self::mcp_path(scope),
240 )
241 }
242
243 fn install_mcp(
244 &self,
245 scope: &Scope,
246 spec: &McpSpec,
247 ) -> Result<InstallReport, AgentConfigError> {
248 spec.validate()?;
249 let cfg = Self::mcp_path(scope)?;
250 spec.validate_local_secret_policy(scope)?;
251 scope.ensure_contained(&cfg)?;
252 let ledger = ownership::mcp_ledger_for(&cfg);
253 mcp_json_object::install(&cfg, &ledger, spec)
254 }
255
256 fn uninstall_mcp(
257 &self,
258 scope: &Scope,
259 name: &str,
260 owner_tag: &str,
261 ) -> Result<UninstallReport, AgentConfigError> {
262 McpSpec::validate_name(name)?;
263 HookSpec::validate_tag(owner_tag)?;
264 let cfg = Self::mcp_path(scope)?;
265 scope.ensure_contained(&cfg)?;
266 let ledger = ownership::mcp_ledger_for(&cfg);
267 mcp_json_object::uninstall(&cfg, &ledger, name, owner_tag, "mcp server")
268 }
269}
270
271impl SkillSurface for AmpAgent {
272 fn id(&self) -> &'static str {
273 "amp"
274 }
275
276 fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
277 &[ScopeKind::Global, ScopeKind::Local]
278 }
279
280 fn skill_status(
281 &self,
282 scope: &Scope,
283 name: &str,
284 expected_owner: &str,
285 ) -> Result<StatusReport, AgentConfigError> {
286 SkillSpec::validate_name(name)?;
287 let root = Self::skills_root(scope)?;
288 let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
289 let recorded = ownership::owner_of(&ledger, name)?;
290 Ok(StatusReport::for_skill(
291 name,
292 dir,
293 manifest,
294 ledger,
295 expected_owner,
296 recorded,
297 ))
298 }
299
300 fn plan_install_skill(
301 &self,
302 scope: &Scope,
303 spec: &SkillSpec,
304 ) -> Result<InstallPlan, AgentConfigError> {
305 agent_planning::skill_install(
306 SkillSurface::id(self),
307 scope,
308 spec,
309 Self::skills_root(scope),
310 )
311 }
312
313 fn plan_uninstall_skill(
314 &self,
315 scope: &Scope,
316 name: &str,
317 owner_tag: &str,
318 ) -> Result<UninstallPlan, AgentConfigError> {
319 agent_planning::skill_uninstall(
320 SkillSurface::id(self),
321 scope,
322 name,
323 owner_tag,
324 Self::skills_root(scope),
325 )
326 }
327
328 fn install_skill(
329 &self,
330 scope: &Scope,
331 spec: &SkillSpec,
332 ) -> Result<InstallReport, AgentConfigError> {
333 let root = Self::skills_root(scope)?;
334 scope.ensure_contained(&root)?;
335 skills_dir::install(&root, spec)
336 }
337
338 fn uninstall_skill(
339 &self,
340 scope: &Scope,
341 name: &str,
342 owner_tag: &str,
343 ) -> Result<UninstallReport, AgentConfigError> {
344 let root = Self::skills_root(scope)?;
345 scope.ensure_contained(&root)?;
346 skills_dir::uninstall(&root, name, owner_tag)
347 }
348}
349
350impl AmpAgent {
351 fn inline_layout(
352 &self,
353 scope: &Scope,
354 ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
355 Ok(instructions_dir::InlineLayout {
356 config_dir: Self::instruction_config_dir(scope)?,
357 host_file: Self::rules_path(scope)?,
358 })
359 }
360}
361
362impl InstructionSurface for AmpAgent {
363 fn id(&self) -> &'static str {
364 "amp"
365 }
366
367 fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
368 &[ScopeKind::Global, ScopeKind::Local]
369 }
370
371 fn instruction_status(
372 &self,
373 scope: &Scope,
374 name: &str,
375 expected_owner: &str,
376 ) -> Result<StatusReport, AgentConfigError> {
377 instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
378 }
379
380 fn plan_install_instruction(
381 &self,
382 scope: &Scope,
383 spec: &InstructionSpec,
384 ) -> Result<InstallPlan, AgentConfigError> {
385 instructions_dir::inline_plan_install(
386 InstructionSurface::id(self),
387 scope,
388 self.inline_layout(scope),
389 spec,
390 )
391 }
392
393 fn plan_uninstall_instruction(
394 &self,
395 scope: &Scope,
396 name: &str,
397 owner_tag: &str,
398 ) -> Result<UninstallPlan, AgentConfigError> {
399 instructions_dir::inline_plan_uninstall(
400 InstructionSurface::id(self),
401 scope,
402 self.inline_layout(scope),
403 name,
404 owner_tag,
405 )
406 }
407
408 fn install_instruction(
409 &self,
410 scope: &Scope,
411 spec: &InstructionSpec,
412 ) -> Result<InstallReport, AgentConfigError> {
413 instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
414 }
415
416 fn uninstall_instruction(
417 &self,
418 scope: &Scope,
419 name: &str,
420 owner_tag: &str,
421 ) -> Result<UninstallReport, AgentConfigError> {
422 instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use serde_json::{json, Value};
430 use tempfile::tempdir;
431
432 fn rules_spec(tag: &str, body: &str) -> HookSpec {
433 HookSpec::builder(tag)
434 .command_program("noop", [] as [&str; 0])
435 .rules(body)
436 .build()
437 }
438
439 fn mcp_spec(name: &str, owner: &str) -> McpSpec {
440 McpSpec::builder(name)
441 .owner(owner)
442 .stdio("npx", ["-y", "@example/server"])
443 .build()
444 }
445
446 fn skill(name: &str, owner: &str) -> SkillSpec {
447 SkillSpec::builder(name)
448 .owner(owner)
449 .description("Test Amp skill.")
450 .body("## Goal\nDo it.\n")
451 .build()
452 }
453
454 fn read_json(p: &Path) -> Value {
455 serde_json::from_slice(&std::fs::read(p).unwrap()).unwrap()
456 }
457
458 #[test]
459 fn install_writes_agents_md_block() {
460 let dir = tempdir().unwrap();
461 let agent = AmpAgent::new();
462 let scope = Scope::Local(dir.path().to_path_buf());
463 agent
464 .install(&scope, &rules_spec("alpha", "Use Amp."))
465 .unwrap();
466 let body = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
467 assert!(body.contains("Use Amp."));
468 assert!(body.contains("AGENT-CONFIG:alpha"));
469 }
470
471 #[test]
472 fn install_uninstall_round_trip() {
473 let dir = tempdir().unwrap();
474 let agent = AmpAgent::new();
475 let scope = Scope::Local(dir.path().to_path_buf());
476 agent.install(&scope, &rules_spec("alpha", "x")).unwrap();
477 agent.uninstall(&scope, "alpha").unwrap();
478 assert!(!dir.path().join("AGENTS.md").exists());
479 }
480
481 #[test]
482 fn install_mcp_writes_settings_json() {
483 let dir = tempdir().unwrap();
484 let agent = AmpAgent::new();
485 let scope = Scope::Local(dir.path().to_path_buf());
486 agent
487 .install_mcp(&scope, &mcp_spec("github", "myapp"))
488 .unwrap();
489 let v = read_json(&dir.path().join(".amp/settings.json"));
490 assert_eq!(v["mcpServers"]["github"]["command"], json!("npx"));
491 }
492
493 #[test]
494 fn install_mcp_idempotent() {
495 let dir = tempdir().unwrap();
496 let agent = AmpAgent::new();
497 let scope = Scope::Local(dir.path().to_path_buf());
498 let s = mcp_spec("github", "myapp");
499 agent.install_mcp(&scope, &s).unwrap();
500 let r = agent.install_mcp(&scope, &s).unwrap();
501 assert!(r.already_installed);
502 }
503
504 #[test]
505 fn install_skill_writes_skills_dir() {
506 let dir = tempdir().unwrap();
507 let agent = AmpAgent::new();
508 let scope = Scope::Local(dir.path().to_path_buf());
509 agent
510 .install_skill(&scope, &skill("alpha-skill", "myapp"))
511 .unwrap();
512 assert!(dir.path().join(".amp/skills/alpha-skill/SKILL.md").exists());
513 }
514
515 #[test]
516 fn plan_install_does_not_write() {
517 let dir = tempdir().unwrap();
518 let agent = AmpAgent::new();
519 let scope = Scope::Local(dir.path().to_path_buf());
520 let _plan = agent
521 .plan_install(&scope, &rules_spec("alpha", "rules"))
522 .unwrap();
523 assert!(!dir.path().join("AGENTS.md").exists());
524 }
525}