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