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 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}