Skip to main content

agent_config/agents/
forge.rs

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