1use 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#[derive(Debug, Clone, Copy, Default)]
33pub struct ForgeAgent {
34 _private: (),
35}
36
37impl ForgeAgent {
38 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 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}