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