Skip to main content

agent_config/agents/
amp.rs

1//! Sourcegraph Amp CLI integration.
2//!
3//! Surfaces:
4//!
5//! 1. **Prompt rules**: fenced HTML-comment block in `AGENTS.md` (Amp falls
6//!    back to `CLAUDE.md` when `AGENTS.md` is absent; this crate writes the
7//!    canonical `AGENTS.md`).
8//! 2. **MCP servers**: `mcpServers` JSON map in `settings.json`.
9//! 3. **Skills**: directory-scoped `SKILL.md` folders.
10//!
11//! Hooks are not part of Amp's documented file-config surface.
12
13use 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/// Amp CLI installer.
31#[derive(Debug, Clone, Copy, Default)]
32pub struct AmpAgent {
33    _private: (),
34}
35
36impl AmpAgent {
37    /// Construct an instance. Stateless.
38    pub const fn new() -> Self {
39        Self { _private: () }
40    }
41
42    fn rules_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
43        Ok(match scope {
44            // Amp reads AGENTS.md from cwd up to $HOME; the documented global
45            // personal file is ~/.config/AGENTS.md (shared AGENTS.md convention).
46            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            // Amp's native project skill directory is `.agents/skills/`.
62            Scope::Local(p) => p.join(".agents").join("skills"),
63        })
64    }
65
66    /// Directory holding the instruction ownership ledger.
67    ///
68    /// Global: `~/.config/amp/`. Local: `<root>/.amp/` so the ledger sits next
69    /// to the existing MCP sidecar instead of cluttering the project root, even
70    /// though the host file (`AGENTS.md`) lives at the root.
71    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}