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