1use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13
14use serde_json::{Map, Value};
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, McpTransport, SkillSpec};
25use crate::status::StatusReport;
26use crate::util::{
27 file_lock, fs_atomic, instructions_dir, md_block, ownership, safe_fs, skills_dir, yaml_mcp_map,
28};
29
30const SKILL_CATEGORY: &str = "agent-config";
31
32#[derive(Debug, Clone, Copy, Default)]
34pub struct HermesAgent {
35 _private: (),
36}
37
38impl HermesAgent {
39 pub const fn new() -> Self {
41 Self { _private: () }
42 }
43
44 fn require_local(scope: &Scope) -> Result<&Path, AgentConfigError> {
45 match scope {
46 Scope::Local(p) => Ok(p),
47 Scope::Global => Err(AgentConfigError::UnsupportedScope {
48 id: "hermes",
49 scope: ScopeKind::Global,
50 }),
51 }
52 }
53
54 fn prompt_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
55 Ok(Self::require_local(scope)?.join(".hermes.md"))
56 }
57
58 fn hermes_home_from_home(home: &Path) -> PathBuf {
59 home.join(".hermes")
60 }
61
62 fn mcp_config_path_from_home(home: &Path) -> PathBuf {
63 Self::hermes_home_from_home(home).join("config.yaml")
64 }
65
66 fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
67 match scope {
68 Scope::Global => Ok(Self::mcp_config_path_from_home(&paths::home_dir()?)),
69 Scope::Local(_) => Err(AgentConfigError::UnsupportedScope {
70 id: "hermes",
71 scope: ScopeKind::Local,
72 }),
73 }
74 }
75
76 fn skills_root_from_home(home: &Path) -> PathBuf {
77 Self::hermes_home_from_home(home)
78 .join("skills")
79 .join(SKILL_CATEGORY)
80 }
81
82 fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
83 match scope {
84 Scope::Global => Ok(Self::skills_root_from_home(&paths::home_dir()?)),
85 Scope::Local(_) => Err(AgentConfigError::UnsupportedScope {
86 id: "hermes",
87 scope: ScopeKind::Local,
88 }),
89 }
90 }
91
92 fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
96 Ok(Self::require_local(scope)?.join(".hermes"))
97 }
98
99 fn install_mcp_config(
100 config_path: &Path,
101 spec: &McpSpec,
102 ) -> Result<InstallReport, AgentConfigError> {
103 let ledger = ownership::mcp_ledger_for(config_path);
104 yaml_mcp_map::install(
105 config_path,
106 &ledger,
107 spec,
108 &["mcp_servers"],
109 hermes_mcp_value,
110 )
111 }
112
113 fn uninstall_mcp_config(
114 config_path: &Path,
115 name: &str,
116 owner_tag: &str,
117 ) -> Result<UninstallReport, AgentConfigError> {
118 let ledger = ownership::mcp_ledger_for(config_path);
119 yaml_mcp_map::uninstall(
120 config_path,
121 &ledger,
122 name,
123 owner_tag,
124 "mcp server",
125 &["mcp_servers"],
126 )
127 }
128}
129
130impl Integration for HermesAgent {
131 fn id(&self) -> &'static str {
132 "hermes"
133 }
134
135 fn display_name(&self) -> &'static str {
136 "Hermes Agent"
137 }
138
139 fn supported_scopes(&self) -> &'static [ScopeKind] {
140 &[ScopeKind::Local]
141 }
142
143 fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
144 HookSpec::validate_tag(tag)?;
145 let path = Self::prompt_path(scope)?;
146 StatusReport::for_markdown_block_hook(tag, path)
147 }
148
149 fn plan_install(
150 &self,
151 scope: &Scope,
152 spec: &HookSpec,
153 ) -> Result<InstallPlan, AgentConfigError> {
154 agent_planning::markdown_install(
155 Integration::id(self),
156 scope,
157 spec,
158 Self::prompt_path(scope),
159 true,
160 )
161 }
162
163 fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
164 agent_planning::markdown_uninstall(
165 Integration::id(self),
166 scope,
167 tag,
168 Self::prompt_path(scope),
169 )
170 }
171
172 fn is_installed(&self, scope: &Scope, tag: &str) -> Result<bool, AgentConfigError> {
173 HookSpec::validate_tag(tag)?;
174 let path = Self::prompt_path(scope)?;
175 let host = fs_atomic::read_to_string_or_empty(&path)?;
176 Ok(md_block::contains(&host, tag))
177 }
178
179 fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
180 HookSpec::validate_tag(&spec.tag)?;
181 agent_planning::validate_prompt_only_event(Integration::id(self), &spec.event)?;
182 let rules = spec
183 .rules
184 .as_ref()
185 .ok_or(AgentConfigError::MissingSpecField {
186 id: "hermes",
187 field: "rules",
188 })?;
189 let path = Self::prompt_path(scope)?;
190 let mut report = InstallReport::default();
191 scope.ensure_contained(&path)?;
192 file_lock::with_lock(&path, || {
193 let host = fs_atomic::read_to_string_or_empty(&path)?;
194 let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
195 let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
196 if outcome.no_change {
197 report.already_installed = true;
198 } else if outcome.existed {
199 report.patched.push(outcome.path.clone());
200 } else {
201 report.created.push(outcome.path.clone());
202 }
203 if let Some(b) = outcome.backup {
204 report.backed_up.push(b);
205 }
206 Ok::<(), AgentConfigError>(())
207 })?;
208 Ok(report)
209 }
210
211 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
212 HookSpec::validate_tag(tag)?;
213 let path = Self::prompt_path(scope)?;
214 let mut report = UninstallReport::default();
215 scope.ensure_contained(&path)?;
216 file_lock::with_lock(&path, || {
217 let host = fs_atomic::read_to_string_or_empty(&path)?;
218 let (stripped, removed) = md_block::remove(&host, tag);
219
220 if !removed {
221 report.not_installed = true;
222 return Ok(());
223 }
224
225 if stripped.trim().is_empty() {
226 if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
227 report.restored.push(path.clone());
228 } else {
229 safe_fs::remove_file(scope, &path)?;
230 report.removed.push(path.clone());
231 }
232 } else {
233 safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
234 report.patched.push(path.clone());
235 }
236 Ok::<(), AgentConfigError>(())
237 })?;
238 Ok(report)
239 }
240}
241
242impl McpSurface for HermesAgent {
243 fn id(&self) -> &'static str {
244 "hermes"
245 }
246
247 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
248 &[ScopeKind::Global]
249 }
250
251 fn mcp_status(
252 &self,
253 scope: &Scope,
254 name: &str,
255 expected_owner: &str,
256 ) -> Result<StatusReport, AgentConfigError> {
257 McpSpec::validate_name(name)?;
258 let cfg = Self::mcp_path(scope)?;
259 let ledger = ownership::mcp_ledger_for(&cfg);
260 let presence = yaml_mcp_map::config_presence(&cfg, &["mcp_servers"], name)?;
261 let recorded = ownership::owner_of(&ledger, name)?;
262 Ok(StatusReport::for_mcp(
263 name,
264 cfg,
265 ledger,
266 presence,
267 expected_owner,
268 recorded,
269 ))
270 }
271
272 fn plan_install_mcp(
273 &self,
274 scope: &Scope,
275 spec: &McpSpec,
276 ) -> Result<InstallPlan, AgentConfigError> {
277 agent_planning::mcp_yaml_install(
278 McpSurface::id(self),
279 scope,
280 spec,
281 Self::mcp_path(scope),
282 &["mcp_servers"],
283 hermes_mcp_value,
284 )
285 }
286
287 fn plan_uninstall_mcp(
288 &self,
289 scope: &Scope,
290 name: &str,
291 owner_tag: &str,
292 ) -> Result<UninstallPlan, AgentConfigError> {
293 agent_planning::mcp_yaml_uninstall(
294 McpSurface::id(self),
295 scope,
296 name,
297 owner_tag,
298 Self::mcp_path(scope),
299 &["mcp_servers"],
300 )
301 }
302
303 fn is_mcp_installed(&self, scope: &Scope, name: &str) -> Result<bool, AgentConfigError> {
304 McpSpec::validate_name(name)?;
305 let cfg = Self::mcp_path(scope)?;
306 let ledger = ownership::mcp_ledger_for(&cfg);
307 yaml_mcp_map::is_installed(&ledger, name)
308 }
309
310 fn install_mcp(
311 &self,
312 scope: &Scope,
313 spec: &McpSpec,
314 ) -> Result<InstallReport, AgentConfigError> {
315 spec.validate()?;
316 let cfg = Self::mcp_path(scope)?;
317 spec.validate_local_secret_policy(scope)?;
318 scope.ensure_contained(&cfg)?;
319 Self::install_mcp_config(&cfg, spec)
320 }
321
322 fn uninstall_mcp(
323 &self,
324 scope: &Scope,
325 name: &str,
326 owner_tag: &str,
327 ) -> Result<UninstallReport, AgentConfigError> {
328 McpSpec::validate_name(name)?;
329 HookSpec::validate_tag(owner_tag)?;
330 let cfg = Self::mcp_path(scope)?;
331 scope.ensure_contained(&cfg)?;
332 Self::uninstall_mcp_config(&cfg, name, owner_tag)
333 }
334}
335
336impl SkillSurface for HermesAgent {
337 fn id(&self) -> &'static str {
338 "hermes"
339 }
340
341 fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
342 &[ScopeKind::Global]
343 }
344
345 fn skill_status(
346 &self,
347 scope: &Scope,
348 name: &str,
349 expected_owner: &str,
350 ) -> Result<StatusReport, AgentConfigError> {
351 SkillSpec::validate_name(name)?;
352 let root = Self::skills_root(scope)?;
353 let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
354 let recorded = ownership::owner_of(&ledger, name)?;
355 Ok(StatusReport::for_skill(
356 name,
357 dir,
358 manifest,
359 ledger,
360 expected_owner,
361 recorded,
362 ))
363 }
364
365 fn plan_install_skill(
366 &self,
367 scope: &Scope,
368 spec: &SkillSpec,
369 ) -> Result<InstallPlan, AgentConfigError> {
370 agent_planning::skill_install(
371 SkillSurface::id(self),
372 scope,
373 spec,
374 Self::skills_root(scope),
375 )
376 }
377
378 fn plan_uninstall_skill(
379 &self,
380 scope: &Scope,
381 name: &str,
382 owner_tag: &str,
383 ) -> Result<UninstallPlan, AgentConfigError> {
384 agent_planning::skill_uninstall(
385 SkillSurface::id(self),
386 scope,
387 name,
388 owner_tag,
389 Self::skills_root(scope),
390 )
391 }
392
393 fn is_skill_installed(&self, scope: &Scope, name: &str) -> Result<bool, AgentConfigError> {
394 let root = Self::skills_root(scope)?;
395 skills_dir::is_installed(&root, name)
396 }
397
398 fn install_skill(
399 &self,
400 scope: &Scope,
401 spec: &SkillSpec,
402 ) -> Result<InstallReport, AgentConfigError> {
403 let root = Self::skills_root(scope)?;
404 scope.ensure_contained(&root)?;
405 skills_dir::install(&root, spec)
406 }
407
408 fn uninstall_skill(
409 &self,
410 scope: &Scope,
411 name: &str,
412 owner_tag: &str,
413 ) -> Result<UninstallReport, AgentConfigError> {
414 let root = Self::skills_root(scope)?;
415 skills_dir::uninstall(&root, name, owner_tag)
416 }
417}
418
419impl HermesAgent {
420 fn inline_layout(
421 &self,
422 scope: &Scope,
423 ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
424 Ok(instructions_dir::InlineLayout {
425 config_dir: Self::instruction_config_dir(scope)?,
426 host_file: Self::prompt_path(scope)?,
427 })
428 }
429}
430
431impl InstructionSurface for HermesAgent {
432 fn id(&self) -> &'static str {
433 "hermes"
434 }
435
436 fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
437 &[ScopeKind::Local]
438 }
439
440 fn instruction_status(
441 &self,
442 scope: &Scope,
443 name: &str,
444 expected_owner: &str,
445 ) -> Result<StatusReport, AgentConfigError> {
446 instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
447 }
448
449 fn plan_install_instruction(
450 &self,
451 scope: &Scope,
452 spec: &InstructionSpec,
453 ) -> Result<InstallPlan, AgentConfigError> {
454 instructions_dir::inline_plan_install(
455 InstructionSurface::id(self),
456 scope,
457 self.inline_layout(scope),
458 spec,
459 )
460 }
461
462 fn plan_uninstall_instruction(
463 &self,
464 scope: &Scope,
465 name: &str,
466 owner_tag: &str,
467 ) -> Result<UninstallPlan, AgentConfigError> {
468 instructions_dir::inline_plan_uninstall(
469 InstructionSurface::id(self),
470 scope,
471 self.inline_layout(scope),
472 name,
473 owner_tag,
474 )
475 }
476
477 fn install_instruction(
478 &self,
479 scope: &Scope,
480 spec: &InstructionSpec,
481 ) -> Result<InstallReport, AgentConfigError> {
482 instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
483 }
484
485 fn uninstall_instruction(
486 &self,
487 scope: &Scope,
488 name: &str,
489 owner_tag: &str,
490 ) -> Result<UninstallReport, AgentConfigError> {
491 instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
492 }
493}
494
495fn hermes_mcp_value(spec: &McpSpec) -> Value {
496 let mut obj = Map::new();
497 match &spec.transport {
498 McpTransport::Stdio { command, args, env } => {
499 obj.insert("command".into(), Value::String(command.clone()));
500 obj.insert(
501 "args".into(),
502 Value::Array(args.iter().cloned().map(Value::String).collect()),
503 );
504 if !env.is_empty() {
505 obj.insert("env".into(), string_map_value(env));
506 }
507 }
508 McpTransport::Http { url, headers } | McpTransport::Sse { url, headers } => {
509 obj.insert("url".into(), Value::String(url.clone()));
510 if !headers.is_empty() {
511 obj.insert("headers".into(), string_map_value(headers));
512 }
513 }
514 }
515 Value::Object(obj)
516}
517
518fn string_map_value(map: &BTreeMap<String, String>) -> Value {
519 let mut obj = Map::new();
520 for (k, v) in map {
521 obj.insert(k.clone(), Value::String(v.clone()));
522 }
523 Value::Object(obj)
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use serde_json::json;
530 use tempfile::tempdir;
531
532 fn rules_spec(tag: &str, rules: &str) -> HookSpec {
533 HookSpec::builder(tag)
534 .command_program("noop", [] as [&str; 0])
535 .rules(rules)
536 .build()
537 }
538
539 fn skill(name: &str, owner: &str) -> SkillSpec {
540 SkillSpec::builder(name)
541 .owner(owner)
542 .description("A test Hermes skill.")
543 .body("## Goal\nDo the thing.\n")
544 .build()
545 }
546
547 fn mcp_spec(name: &str, owner: &str) -> McpSpec {
548 McpSpec::builder(name)
549 .owner(owner)
550 .stdio("npx", ["-y", "@example/server"])
551 .env("FOO", "bar")
552 .build()
553 }
554
555 #[test]
556 fn install_rules_writes_dot_hermes_md_block() {
557 let dir = tempdir().unwrap();
558 let agent = HermesAgent::new();
559 let scope = Scope::Local(dir.path().to_path_buf());
560
561 agent
562 .install(&scope, &rules_spec("alpha", "Use Hermes project rules."))
563 .unwrap();
564
565 let body = std::fs::read_to_string(dir.path().join(".hermes.md")).unwrap();
566 assert!(body.contains("BEGIN AGENT-CONFIG:alpha"));
567 assert!(body.contains("Use Hermes project rules."));
568 assert!(agent.is_installed(&scope, "alpha").unwrap());
569 }
570
571 #[test]
572 fn rules_install_is_idempotent() {
573 let dir = tempdir().unwrap();
574 let agent = HermesAgent::new();
575 let scope = Scope::Local(dir.path().to_path_buf());
576 let spec = rules_spec("alpha", "rules");
577
578 agent.install(&scope, &spec).unwrap();
579 let second = agent.install(&scope, &spec).unwrap();
580 assert!(second.already_installed);
581 }
582
583 #[test]
584 fn uninstall_rules_round_trip() {
585 let dir = tempdir().unwrap();
586 let agent = HermesAgent::new();
587 let scope = Scope::Local(dir.path().to_path_buf());
588
589 agent
590 .install(&scope, &rules_spec("alpha", "rules"))
591 .unwrap();
592 let report = agent.uninstall(&scope, "alpha").unwrap();
593 assert!(!report.removed.is_empty());
594 assert!(!dir.path().join(".hermes.md").exists());
595 }
596
597 #[test]
598 fn global_skill_root_uses_agent_config_category() {
599 let home = PathBuf::from("/tmp/home");
600 assert_eq!(
601 HermesAgent::skills_root_from_home(&home),
602 PathBuf::from("/tmp/home/.hermes/skills/agent-config")
603 );
604 }
605
606 #[test]
607 fn install_skill_under_category_helper_round_trip() {
608 let dir = tempdir().unwrap();
609 let root = HermesAgent::skills_root_from_home(dir.path());
610 skills_dir::install(&root, &skill("alpha-skill", "myapp")).unwrap();
611
612 assert!(dir
613 .path()
614 .join(".hermes/skills/agent-config/alpha-skill/SKILL.md")
615 .exists());
616 let second = skills_dir::install(&root, &skill("alpha-skill", "myapp")).unwrap();
617 assert!(second.already_installed);
618 skills_dir::uninstall(&root, "alpha-skill", "myapp").unwrap();
619 assert!(!dir
620 .path()
621 .join(".hermes/skills/agent-config/alpha-skill")
622 .exists());
623 }
624
625 #[test]
626 fn local_skill_scope_is_rejected() {
627 let dir = tempdir().unwrap();
628 let agent = HermesAgent::new();
629 let scope = Scope::Local(dir.path().to_path_buf());
630 let err = agent
631 .install_skill(&scope, &skill("alpha-skill", "myapp"))
632 .unwrap_err();
633 assert!(matches!(
634 err,
635 AgentConfigError::UnsupportedScope {
636 scope: ScopeKind::Local,
637 ..
638 }
639 ));
640 }
641
642 #[test]
643 fn install_mcp_preserves_unrelated_yaml_keys() {
644 let dir = tempdir().unwrap();
645 let cfg = dir.path().join("config.yaml");
646 std::fs::write(
647 &cfg,
648 "model: anthropic/claude\nterminal:\n backend: local\n",
649 )
650 .unwrap();
651
652 HermesAgent::install_mcp_config(&cfg, &mcp_spec("github", "myapp")).unwrap();
653
654 let parsed: Value = yaml_serde::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap();
655 assert_eq!(parsed["model"], json!("anthropic/claude"));
656 assert_eq!(parsed["terminal"]["backend"], json!("local"));
657 assert_eq!(parsed["mcp_servers"]["github"]["command"], json!("npx"));
658 assert_eq!(parsed["mcp_servers"]["github"]["env"]["FOO"], json!("bar"));
659 }
660
661 #[test]
662 fn install_mcp_is_idempotent() {
663 let dir = tempdir().unwrap();
664 let cfg = dir.path().join("config.yaml");
665 let spec = mcp_spec("github", "myapp");
666
667 HermesAgent::install_mcp_config(&cfg, &spec).unwrap();
668 let second = HermesAgent::install_mcp_config(&cfg, &spec).unwrap();
669 assert!(second.already_installed);
670 }
671
672 #[test]
673 fn uninstall_mcp_round_trip() {
674 let dir = tempdir().unwrap();
675 let cfg = dir.path().join("config.yaml");
676
677 HermesAgent::install_mcp_config(&cfg, &mcp_spec("github", "myapp")).unwrap();
678 let report = HermesAgent::uninstall_mcp_config(&cfg, "github", "myapp").unwrap();
679 assert!(!report.removed.is_empty());
680 assert!(!cfg.exists());
681 }
682
683 #[test]
684 fn uninstall_mcp_owner_mismatch_is_refused() {
685 let dir = tempdir().unwrap();
686 let cfg = dir.path().join("config.yaml");
687
688 HermesAgent::install_mcp_config(&cfg, &mcp_spec("github", "app-a")).unwrap();
689 let err = HermesAgent::uninstall_mcp_config(&cfg, "github", "app-b").unwrap_err();
690 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
691 }
692
693 #[test]
694 fn local_mcp_scope_is_rejected() {
695 let dir = tempdir().unwrap();
696 let agent = HermesAgent::new();
697 let scope = Scope::Local(dir.path().to_path_buf());
698 let err = agent
699 .install_mcp(&scope, &mcp_spec("github", "myapp"))
700 .unwrap_err();
701 assert!(matches!(
702 err,
703 AgentConfigError::UnsupportedScope {
704 scope: ScopeKind::Local,
705 ..
706 }
707 ));
708 }
709
710 #[test]
711 fn remote_mcp_mapping_uses_url_and_headers() {
712 let spec = McpSpec::builder("docs")
713 .owner("myapp")
714 .http("https://example.com/mcp")
715 .header("Authorization", "Bearer token")
716 .build();
717
718 let value = hermes_mcp_value(&spec);
719 assert_eq!(value["url"], json!("https://example.com/mcp"));
720 assert_eq!(value["headers"]["Authorization"], json!("Bearer token"));
721 assert!(value.get("transport").is_none());
722 }
723
724 #[test]
725 fn mcp_config_path_from_home_uses_hermes_home() {
726 let home = PathBuf::from("/tmp/home");
727 assert_eq!(
728 HermesAgent::mcp_config_path_from_home(&home),
729 PathBuf::from("/tmp/home/.hermes/config.yaml")
730 );
731 }
732}