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::{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 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    /// Directory holding the instruction ownership ledger.
68    ///
69    /// Global: `~/.amp/`. Local: `<root>/.amp/` so the ledger sits next to
70    /// the existing MCP/skills sidecars instead of cluttering the project
71    /// root, even though the host file (`AGENTS.md`) lives at the root.
72    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}