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