Skip to main content

agent_config/agents/
hermes.rs

1//! Hermes Agent integration.
2//!
3//! Implemented surfaces:
4//!
5//! 1. **Prompt rules**: project-local fenced blocks in `.hermes.md`.
6//! 2. **Skills**: global category-scoped folders under
7//!    `~/.hermes/skills/agent-config/<name>`.
8//! 3. **MCP servers**: global YAML config at `~/.hermes/config.yaml`, under
9//!    `mcp_servers.<name>`.
10
11use 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/// Hermes Agent file-backed installer.
33#[derive(Debug, Clone, Copy, Default)]
34pub struct HermesAgent {
35    _private: (),
36}
37
38impl HermesAgent {
39    /// Construct an instance. Stateless.
40    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    /// Directory holding the instruction ownership ledger. Local-only;
93    /// uses a `<root>/.hermes/` subdirectory so the sidecar does not clutter
94    /// the project root next to the user-visible `.hermes.md` host file.
95    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        let rules = spec
182            .rules
183            .as_ref()
184            .ok_or(AgentConfigError::MissingSpecField {
185                id: "hermes",
186                field: "rules",
187            })?;
188        let path = Self::prompt_path(scope)?;
189        let mut report = InstallReport::default();
190        scope.ensure_contained(&path)?;
191        file_lock::with_lock(&path, || {
192            let host = fs_atomic::read_to_string_or_empty(&path)?;
193            let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
194            let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
195            if outcome.no_change {
196                report.already_installed = true;
197            } else if outcome.existed {
198                report.patched.push(outcome.path.clone());
199            } else {
200                report.created.push(outcome.path.clone());
201            }
202            if let Some(b) = outcome.backup {
203                report.backed_up.push(b);
204            }
205            Ok::<(), AgentConfigError>(())
206        })?;
207        Ok(report)
208    }
209
210    fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
211        HookSpec::validate_tag(tag)?;
212        let path = Self::prompt_path(scope)?;
213        let mut report = UninstallReport::default();
214        scope.ensure_contained(&path)?;
215        file_lock::with_lock(&path, || {
216            let host = fs_atomic::read_to_string_or_empty(&path)?;
217            let (stripped, removed) = md_block::remove(&host, tag);
218
219            if !removed {
220                report.not_installed = true;
221                return Ok(());
222            }
223
224            if stripped.trim().is_empty() {
225                if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
226                    report.restored.push(path.clone());
227                } else {
228                    safe_fs::remove_file(scope, &path)?;
229                    report.removed.push(path.clone());
230                }
231            } else {
232                safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
233                report.patched.push(path.clone());
234            }
235            Ok::<(), AgentConfigError>(())
236        })?;
237        Ok(report)
238    }
239}
240
241impl McpSurface for HermesAgent {
242    fn id(&self) -> &'static str {
243        "hermes"
244    }
245
246    fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
247        &[ScopeKind::Global]
248    }
249
250    fn mcp_status(
251        &self,
252        scope: &Scope,
253        name: &str,
254        expected_owner: &str,
255    ) -> Result<StatusReport, AgentConfigError> {
256        McpSpec::validate_name(name)?;
257        let cfg = Self::mcp_path(scope)?;
258        let ledger = ownership::mcp_ledger_for(&cfg);
259        let presence = yaml_mcp_map::config_presence(&cfg, &["mcp_servers"], name)?;
260        let recorded = ownership::owner_of(&ledger, name)?;
261        Ok(StatusReport::for_mcp(
262            name,
263            cfg,
264            ledger,
265            presence,
266            expected_owner,
267            recorded,
268        ))
269    }
270
271    fn plan_install_mcp(
272        &self,
273        scope: &Scope,
274        spec: &McpSpec,
275    ) -> Result<InstallPlan, AgentConfigError> {
276        agent_planning::mcp_yaml_install(
277            McpSurface::id(self),
278            scope,
279            spec,
280            Self::mcp_path(scope),
281            &["mcp_servers"],
282            hermes_mcp_value,
283        )
284    }
285
286    fn plan_uninstall_mcp(
287        &self,
288        scope: &Scope,
289        name: &str,
290        owner_tag: &str,
291    ) -> Result<UninstallPlan, AgentConfigError> {
292        agent_planning::mcp_yaml_uninstall(
293            McpSurface::id(self),
294            scope,
295            name,
296            owner_tag,
297            Self::mcp_path(scope),
298            &["mcp_servers"],
299        )
300    }
301
302    fn is_mcp_installed(&self, scope: &Scope, name: &str) -> Result<bool, AgentConfigError> {
303        McpSpec::validate_name(name)?;
304        let cfg = Self::mcp_path(scope)?;
305        let ledger = ownership::mcp_ledger_for(&cfg);
306        yaml_mcp_map::is_installed(&ledger, name)
307    }
308
309    fn install_mcp(
310        &self,
311        scope: &Scope,
312        spec: &McpSpec,
313    ) -> Result<InstallReport, AgentConfigError> {
314        spec.validate()?;
315        let cfg = Self::mcp_path(scope)?;
316        spec.validate_local_secret_policy(scope)?;
317        scope.ensure_contained(&cfg)?;
318        Self::install_mcp_config(&cfg, spec)
319    }
320
321    fn uninstall_mcp(
322        &self,
323        scope: &Scope,
324        name: &str,
325        owner_tag: &str,
326    ) -> Result<UninstallReport, AgentConfigError> {
327        McpSpec::validate_name(name)?;
328        HookSpec::validate_tag(owner_tag)?;
329        let cfg = Self::mcp_path(scope)?;
330        scope.ensure_contained(&cfg)?;
331        Self::uninstall_mcp_config(&cfg, name, owner_tag)
332    }
333}
334
335impl SkillSurface for HermesAgent {
336    fn id(&self) -> &'static str {
337        "hermes"
338    }
339
340    fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
341        &[ScopeKind::Global]
342    }
343
344    fn skill_status(
345        &self,
346        scope: &Scope,
347        name: &str,
348        expected_owner: &str,
349    ) -> Result<StatusReport, AgentConfigError> {
350        SkillSpec::validate_name(name)?;
351        let root = Self::skills_root(scope)?;
352        let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
353        let recorded = ownership::owner_of(&ledger, name)?;
354        Ok(StatusReport::for_skill(
355            name,
356            dir,
357            manifest,
358            ledger,
359            expected_owner,
360            recorded,
361        ))
362    }
363
364    fn plan_install_skill(
365        &self,
366        scope: &Scope,
367        spec: &SkillSpec,
368    ) -> Result<InstallPlan, AgentConfigError> {
369        agent_planning::skill_install(
370            SkillSurface::id(self),
371            scope,
372            spec,
373            Self::skills_root(scope),
374        )
375    }
376
377    fn plan_uninstall_skill(
378        &self,
379        scope: &Scope,
380        name: &str,
381        owner_tag: &str,
382    ) -> Result<UninstallPlan, AgentConfigError> {
383        agent_planning::skill_uninstall(
384            SkillSurface::id(self),
385            scope,
386            name,
387            owner_tag,
388            Self::skills_root(scope),
389        )
390    }
391
392    fn is_skill_installed(&self, scope: &Scope, name: &str) -> Result<bool, AgentConfigError> {
393        let root = Self::skills_root(scope)?;
394        skills_dir::is_installed(&root, name)
395    }
396
397    fn install_skill(
398        &self,
399        scope: &Scope,
400        spec: &SkillSpec,
401    ) -> Result<InstallReport, AgentConfigError> {
402        let root = Self::skills_root(scope)?;
403        scope.ensure_contained(&root)?;
404        skills_dir::install(&root, spec)
405    }
406
407    fn uninstall_skill(
408        &self,
409        scope: &Scope,
410        name: &str,
411        owner_tag: &str,
412    ) -> Result<UninstallReport, AgentConfigError> {
413        let root = Self::skills_root(scope)?;
414        skills_dir::uninstall(&root, name, owner_tag)
415    }
416}
417
418impl HermesAgent {
419    fn inline_layout(
420        &self,
421        scope: &Scope,
422    ) -> Result<instructions_dir::InlineLayout, AgentConfigError> {
423        Ok(instructions_dir::InlineLayout {
424            config_dir: Self::instruction_config_dir(scope)?,
425            host_file: Self::prompt_path(scope)?,
426        })
427    }
428}
429
430impl InstructionSurface for HermesAgent {
431    fn id(&self) -> &'static str {
432        "hermes"
433    }
434
435    fn supported_instruction_scopes(&self) -> &'static [ScopeKind] {
436        &[ScopeKind::Local]
437    }
438
439    fn instruction_status(
440        &self,
441        scope: &Scope,
442        name: &str,
443        expected_owner: &str,
444    ) -> Result<StatusReport, AgentConfigError> {
445        instructions_dir::inline_status(self.inline_layout(scope)?, name, expected_owner)
446    }
447
448    fn plan_install_instruction(
449        &self,
450        scope: &Scope,
451        spec: &InstructionSpec,
452    ) -> Result<InstallPlan, AgentConfigError> {
453        instructions_dir::inline_plan_install(
454            InstructionSurface::id(self),
455            scope,
456            self.inline_layout(scope),
457            spec,
458        )
459    }
460
461    fn plan_uninstall_instruction(
462        &self,
463        scope: &Scope,
464        name: &str,
465        owner_tag: &str,
466    ) -> Result<UninstallPlan, AgentConfigError> {
467        instructions_dir::inline_plan_uninstall(
468            InstructionSurface::id(self),
469            scope,
470            self.inline_layout(scope),
471            name,
472            owner_tag,
473        )
474    }
475
476    fn install_instruction(
477        &self,
478        scope: &Scope,
479        spec: &InstructionSpec,
480    ) -> Result<InstallReport, AgentConfigError> {
481        instructions_dir::inline_install(scope, self.inline_layout(scope)?, spec)
482    }
483
484    fn uninstall_instruction(
485        &self,
486        scope: &Scope,
487        name: &str,
488        owner_tag: &str,
489    ) -> Result<UninstallReport, AgentConfigError> {
490        instructions_dir::inline_uninstall(scope, self.inline_layout(scope)?, name, owner_tag)
491    }
492}
493
494fn hermes_mcp_value(spec: &McpSpec) -> Value {
495    let mut obj = Map::new();
496    match &spec.transport {
497        McpTransport::Stdio { command, args, env } => {
498            obj.insert("command".into(), Value::String(command.clone()));
499            obj.insert(
500                "args".into(),
501                Value::Array(args.iter().cloned().map(Value::String).collect()),
502            );
503            if !env.is_empty() {
504                obj.insert("env".into(), string_map_value(env));
505            }
506        }
507        McpTransport::Http { url, headers } | McpTransport::Sse { url, headers } => {
508            obj.insert("url".into(), Value::String(url.clone()));
509            if !headers.is_empty() {
510                obj.insert("headers".into(), string_map_value(headers));
511            }
512        }
513    }
514    Value::Object(obj)
515}
516
517fn string_map_value(map: &BTreeMap<String, String>) -> Value {
518    let mut obj = Map::new();
519    for (k, v) in map {
520        obj.insert(k.clone(), Value::String(v.clone()));
521    }
522    Value::Object(obj)
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528    use serde_json::json;
529    use tempfile::tempdir;
530
531    fn rules_spec(tag: &str, rules: &str) -> HookSpec {
532        HookSpec::builder(tag)
533            .command_program("noop", [] as [&str; 0])
534            .rules(rules)
535            .build()
536    }
537
538    fn skill(name: &str, owner: &str) -> SkillSpec {
539        SkillSpec::builder(name)
540            .owner(owner)
541            .description("A test Hermes skill.")
542            .body("## Goal\nDo the thing.\n")
543            .build()
544    }
545
546    fn mcp_spec(name: &str, owner: &str) -> McpSpec {
547        McpSpec::builder(name)
548            .owner(owner)
549            .stdio("npx", ["-y", "@example/server"])
550            .env("FOO", "bar")
551            .build()
552    }
553
554    #[test]
555    fn install_rules_writes_dot_hermes_md_block() {
556        let dir = tempdir().unwrap();
557        let agent = HermesAgent::new();
558        let scope = Scope::Local(dir.path().to_path_buf());
559
560        agent
561            .install(&scope, &rules_spec("alpha", "Use Hermes project rules."))
562            .unwrap();
563
564        let body = std::fs::read_to_string(dir.path().join(".hermes.md")).unwrap();
565        assert!(body.contains("BEGIN AGENT-CONFIG:alpha"));
566        assert!(body.contains("Use Hermes project rules."));
567        assert!(agent.is_installed(&scope, "alpha").unwrap());
568    }
569
570    #[test]
571    fn rules_install_is_idempotent() {
572        let dir = tempdir().unwrap();
573        let agent = HermesAgent::new();
574        let scope = Scope::Local(dir.path().to_path_buf());
575        let spec = rules_spec("alpha", "rules");
576
577        agent.install(&scope, &spec).unwrap();
578        let second = agent.install(&scope, &spec).unwrap();
579        assert!(second.already_installed);
580    }
581
582    #[test]
583    fn uninstall_rules_round_trip() {
584        let dir = tempdir().unwrap();
585        let agent = HermesAgent::new();
586        let scope = Scope::Local(dir.path().to_path_buf());
587
588        agent
589            .install(&scope, &rules_spec("alpha", "rules"))
590            .unwrap();
591        let report = agent.uninstall(&scope, "alpha").unwrap();
592        assert!(!report.removed.is_empty());
593        assert!(!dir.path().join(".hermes.md").exists());
594    }
595
596    #[test]
597    fn global_skill_root_uses_agent_config_category() {
598        let home = PathBuf::from("/tmp/home");
599        assert_eq!(
600            HermesAgent::skills_root_from_home(&home),
601            PathBuf::from("/tmp/home/.hermes/skills/agent-config")
602        );
603    }
604
605    #[test]
606    fn install_skill_under_category_helper_round_trip() {
607        let dir = tempdir().unwrap();
608        let root = HermesAgent::skills_root_from_home(dir.path());
609        skills_dir::install(&root, &skill("alpha-skill", "myapp")).unwrap();
610
611        assert!(dir
612            .path()
613            .join(".hermes/skills/agent-config/alpha-skill/SKILL.md")
614            .exists());
615        let second = skills_dir::install(&root, &skill("alpha-skill", "myapp")).unwrap();
616        assert!(second.already_installed);
617        skills_dir::uninstall(&root, "alpha-skill", "myapp").unwrap();
618        assert!(!dir
619            .path()
620            .join(".hermes/skills/agent-config/alpha-skill")
621            .exists());
622    }
623
624    #[test]
625    fn local_skill_scope_is_rejected() {
626        let dir = tempdir().unwrap();
627        let agent = HermesAgent::new();
628        let scope = Scope::Local(dir.path().to_path_buf());
629        let err = agent
630            .install_skill(&scope, &skill("alpha-skill", "myapp"))
631            .unwrap_err();
632        assert!(matches!(
633            err,
634            AgentConfigError::UnsupportedScope {
635                scope: ScopeKind::Local,
636                ..
637            }
638        ));
639    }
640
641    #[test]
642    fn install_mcp_preserves_unrelated_yaml_keys() {
643        let dir = tempdir().unwrap();
644        let cfg = dir.path().join("config.yaml");
645        std::fs::write(
646            &cfg,
647            "model: anthropic/claude\nterminal:\n  backend: local\n",
648        )
649        .unwrap();
650
651        HermesAgent::install_mcp_config(&cfg, &mcp_spec("github", "myapp")).unwrap();
652
653        let parsed: Value = yaml_serde::from_str(&std::fs::read_to_string(&cfg).unwrap()).unwrap();
654        assert_eq!(parsed["model"], json!("anthropic/claude"));
655        assert_eq!(parsed["terminal"]["backend"], json!("local"));
656        assert_eq!(parsed["mcp_servers"]["github"]["command"], json!("npx"));
657        assert_eq!(parsed["mcp_servers"]["github"]["env"]["FOO"], json!("bar"));
658    }
659
660    #[test]
661    fn install_mcp_is_idempotent() {
662        let dir = tempdir().unwrap();
663        let cfg = dir.path().join("config.yaml");
664        let spec = mcp_spec("github", "myapp");
665
666        HermesAgent::install_mcp_config(&cfg, &spec).unwrap();
667        let second = HermesAgent::install_mcp_config(&cfg, &spec).unwrap();
668        assert!(second.already_installed);
669    }
670
671    #[test]
672    fn uninstall_mcp_round_trip() {
673        let dir = tempdir().unwrap();
674        let cfg = dir.path().join("config.yaml");
675
676        HermesAgent::install_mcp_config(&cfg, &mcp_spec("github", "myapp")).unwrap();
677        let report = HermesAgent::uninstall_mcp_config(&cfg, "github", "myapp").unwrap();
678        assert!(!report.removed.is_empty());
679        assert!(!cfg.exists());
680    }
681
682    #[test]
683    fn uninstall_mcp_owner_mismatch_is_refused() {
684        let dir = tempdir().unwrap();
685        let cfg = dir.path().join("config.yaml");
686
687        HermesAgent::install_mcp_config(&cfg, &mcp_spec("github", "app-a")).unwrap();
688        let err = HermesAgent::uninstall_mcp_config(&cfg, "github", "app-b").unwrap_err();
689        assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
690    }
691
692    #[test]
693    fn local_mcp_scope_is_rejected() {
694        let dir = tempdir().unwrap();
695        let agent = HermesAgent::new();
696        let scope = Scope::Local(dir.path().to_path_buf());
697        let err = agent
698            .install_mcp(&scope, &mcp_spec("github", "myapp"))
699            .unwrap_err();
700        assert!(matches!(
701            err,
702            AgentConfigError::UnsupportedScope {
703                scope: ScopeKind::Local,
704                ..
705            }
706        ));
707    }
708
709    #[test]
710    fn remote_mcp_mapping_uses_url_and_headers() {
711        let spec = McpSpec::builder("docs")
712            .owner("myapp")
713            .http("https://example.com/mcp")
714            .header("Authorization", "Bearer token")
715            .build();
716
717        let value = hermes_mcp_value(&spec);
718        assert_eq!(value["url"], json!("https://example.com/mcp"));
719        assert_eq!(value["headers"]["Authorization"], json!("Bearer token"));
720        assert!(value.get("transport").is_none());
721    }
722
723    #[test]
724    fn mcp_config_path_from_home_uses_hermes_home() {
725        let home = PathBuf::from("/tmp/home");
726        assert_eq!(
727            HermesAgent::mcp_config_path_from_home(&home),
728            PathBuf::from("/tmp/home/.hermes/config.yaml")
729        );
730    }
731}