1use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18use serde_json::{Map, Value};
19
20use crate::agents::planning as agent_planning;
21use crate::error::AgentConfigError;
22use crate::integration::{
23 InstallReport, InstructionSurface, Integration, McpSurface, SkillSurface, UninstallReport,
24};
25use crate::paths;
26use crate::plan::{InstallPlan, UninstallPlan};
27use crate::scope::{Scope, ScopeKind};
28use crate::spec::{HookSpec, InstructionSpec, McpSpec, McpTransport, SkillSpec};
29use crate::status::StatusReport;
30use crate::util::{
31 file_lock, fs_atomic, instructions_dir, mcp_json_map, md_block, ownership, safe_fs, skills_dir,
32};
33
34#[derive(Debug, Clone, Copy, Default)]
36pub struct OpenClawAgent {
37 _private: (),
38}
39
40impl OpenClawAgent {
41 pub const fn new() -> Self {
43 Self { _private: () }
44 }
45
46 fn require_local(scope: &Scope) -> Result<&Path, AgentConfigError> {
47 match scope {
48 Scope::Local(p) => Ok(p),
49 Scope::Global => Err(AgentConfigError::UnsupportedScope {
50 id: "openclaw",
51 scope: ScopeKind::Global,
52 }),
53 }
54 }
55
56 fn prompt_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
57 Ok(Self::require_local(scope)?.join("AGENTS.md"))
58 }
59
60 fn openclaw_home_from_home(home: &Path) -> PathBuf {
61 home.join(".openclaw")
62 }
63
64 fn mcp_config_path_from_home(home: &Path) -> PathBuf {
65 Self::openclaw_home_from_home(home).join("openclaw.json")
66 }
67
68 fn mcp_path(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
69 match scope {
70 Scope::Global => Ok(Self::mcp_config_path_from_home(&paths::home_dir()?)),
71 Scope::Local(_) => Err(AgentConfigError::UnsupportedScope {
72 id: "openclaw",
73 scope: ScopeKind::Local,
74 }),
75 }
76 }
77
78 fn skills_root_from_home(home: &Path) -> PathBuf {
79 Self::openclaw_home_from_home(home).join("skills")
80 }
81
82 fn skills_root(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
83 Ok(match scope {
84 Scope::Global => Self::skills_root_from_home(&paths::home_dir()?),
85 Scope::Local(p) => p.join(".agents").join("skills"),
86 })
87 }
88
89 fn instruction_config_dir(scope: &Scope) -> Result<PathBuf, AgentConfigError> {
93 Ok(Self::require_local(scope)?.join(".agents"))
94 }
95
96 fn install_mcp_config(
97 config_path: &Path,
98 spec: &McpSpec,
99 ) -> Result<InstallReport, AgentConfigError> {
100 let ledger = ownership::mcp_ledger_for(config_path);
101 mcp_json_map::install(
102 config_path,
103 &ledger,
104 spec,
105 &["mcp", "servers"],
106 openclaw_mcp_value,
107 mcp_json_map::ConfigFormat::Json5,
108 )
109 }
110
111 fn uninstall_mcp_config(
112 config_path: &Path,
113 name: &str,
114 owner_tag: &str,
115 ) -> Result<UninstallReport, AgentConfigError> {
116 let ledger = ownership::mcp_ledger_for(config_path);
117 mcp_json_map::uninstall(
118 config_path,
119 &ledger,
120 name,
121 owner_tag,
122 "mcp server",
123 &["mcp", "servers"],
124 mcp_json_map::ConfigFormat::Json5,
125 )
126 }
127}
128
129impl Integration for OpenClawAgent {
130 fn id(&self) -> &'static str {
131 "openclaw"
132 }
133
134 fn display_name(&self) -> &'static str {
135 "OpenClaw"
136 }
137
138 fn supported_scopes(&self) -> &'static [ScopeKind] {
139 &[ScopeKind::Local]
140 }
141
142 fn status(&self, scope: &Scope, tag: &str) -> Result<StatusReport, AgentConfigError> {
143 HookSpec::validate_tag(tag)?;
144 let path = Self::prompt_path(scope)?;
145 StatusReport::for_markdown_block_hook(tag, path)
146 }
147
148 fn plan_install(
149 &self,
150 scope: &Scope,
151 spec: &HookSpec,
152 ) -> Result<InstallPlan, AgentConfigError> {
153 agent_planning::markdown_install(
154 Integration::id(self),
155 scope,
156 spec,
157 Self::prompt_path(scope),
158 true,
159 )
160 }
161
162 fn plan_uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallPlan, AgentConfigError> {
163 agent_planning::markdown_uninstall(
164 Integration::id(self),
165 scope,
166 tag,
167 Self::prompt_path(scope),
168 )
169 }
170
171 fn install(&self, scope: &Scope, spec: &HookSpec) -> Result<InstallReport, AgentConfigError> {
172 HookSpec::validate_tag(&spec.tag)?;
173 agent_planning::validate_prompt_only_event(Integration::id(self), &spec.event)?;
174 let rules = spec
175 .rules
176 .as_ref()
177 .ok_or(AgentConfigError::MissingSpecField {
178 id: "openclaw",
179 field: "rules",
180 })?;
181 let path = Self::prompt_path(scope)?;
182 let mut report = InstallReport::default();
183 scope.ensure_contained(&path)?;
184 file_lock::with_lock(&path, || {
185 let host = fs_atomic::read_to_string_or_empty(&path)?;
186 let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
187 let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
188 if outcome.no_change {
189 report.already_installed = true;
190 } else if outcome.existed {
191 report.patched.push(outcome.path.clone());
192 } else {
193 report.created.push(outcome.path.clone());
194 }
195 if let Some(b) = outcome.backup {
196 report.backed_up.push(b);
197 }
198 Ok::<(), AgentConfigError>(())
199 })?;
200 Ok(report)
201 }
202
203 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
204 HookSpec::validate_tag(tag)?;
205 let path = Self::prompt_path(scope)?;
206 let mut report = UninstallReport::default();
207 scope.ensure_contained(&path)?;
208 file_lock::with_lock(&path, || {
209 let host = fs_atomic::read_to_string_or_empty(&path)?;
210 let (stripped, removed) = md_block::remove(&host, tag);
211
212 if !removed {
213 report.not_installed = true;
214 return Ok(());
215 }
216
217 if stripped.trim().is_empty() {
218 if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
219 report.restored.push(path.clone());
220 } else {
221 safe_fs::remove_file(scope, &path)?;
222 report.removed.push(path.clone());
223 }
224 } else {
225 safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
226 report.patched.push(path.clone());
227 }
228 Ok::<(), AgentConfigError>(())
229 })?;
230 Ok(report)
231 }
232}
233
234impl McpSurface for OpenClawAgent {
235 fn id(&self) -> &'static str {
236 "openclaw"
237 }
238
239 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
240 &[ScopeKind::Global]
241 }
242
243 fn mcp_status(
244 &self,
245 scope: &Scope,
246 name: &str,
247 expected_owner: &str,
248 ) -> Result<StatusReport, AgentConfigError> {
249 McpSpec::validate_name(name)?;
250 let cfg = Self::mcp_path(scope)?;
251 let ledger = ownership::mcp_ledger_for(&cfg);
252 let presence = mcp_json_map::config_presence(
253 &cfg,
254 &["mcp", "servers"],
255 name,
256 mcp_json_map::ConfigFormat::Json5,
257 )?;
258 let recorded = ownership::owner_of(&ledger, name)?;
259 Ok(StatusReport::for_mcp(
260 name,
261 cfg,
262 ledger,
263 presence,
264 expected_owner,
265 recorded,
266 ))
267 }
268
269 fn plan_install_mcp(
270 &self,
271 scope: &Scope,
272 spec: &McpSpec,
273 ) -> Result<InstallPlan, AgentConfigError> {
274 agent_planning::mcp_json_map_install(
275 McpSurface::id(self),
276 scope,
277 spec,
278 Self::mcp_path(scope),
279 &["mcp", "servers"],
280 openclaw_mcp_value,
281 mcp_json_map::ConfigFormat::Json5,
282 )
283 }
284
285 fn plan_uninstall_mcp(
286 &self,
287 scope: &Scope,
288 name: &str,
289 owner_tag: &str,
290 ) -> Result<UninstallPlan, AgentConfigError> {
291 agent_planning::mcp_json_map_uninstall(
292 McpSurface::id(self),
293 scope,
294 name,
295 owner_tag,
296 Self::mcp_path(scope),
297 &["mcp", "servers"],
298 mcp_json_map::ConfigFormat::Json5,
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 mcp_json_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 OpenClawAgent {
336 fn id(&self) -> &'static str {
337 "openclaw"
338 }
339
340 fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
341 &[ScopeKind::Global, ScopeKind::Local]
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 scope.ensure_contained(&root)?;
415 skills_dir::uninstall(&root, name, owner_tag)
416 }
417}
418
419impl OpenClawAgent {
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 OpenClawAgent {
432 fn id(&self) -> &'static str {
433 "openclaw"
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 openclaw_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 } => {
509 obj.insert("url".into(), Value::String(url.clone()));
510 obj.insert("transport".into(), Value::String("streamable-http".into()));
511 if !headers.is_empty() {
512 obj.insert("headers".into(), string_map_value(headers));
513 }
514 }
515 McpTransport::Sse { url, headers } => {
516 obj.insert("url".into(), Value::String(url.clone()));
517 obj.insert("transport".into(), Value::String("sse".into()));
518 if !headers.is_empty() {
519 obj.insert("headers".into(), string_map_value(headers));
520 }
521 }
522 }
523 Value::Object(obj)
524}
525
526fn string_map_value(map: &BTreeMap<String, String>) -> Value {
527 let mut obj = Map::new();
528 for (k, v) in map {
529 obj.insert(k.clone(), Value::String(v.clone()));
530 }
531 Value::Object(obj)
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537 use serde_json::json;
538 use tempfile::tempdir;
539
540 fn rules_spec(tag: &str, rules: &str) -> HookSpec {
541 HookSpec::builder(tag)
542 .command_program("noop", [] as [&str; 0])
543 .rules(rules)
544 .build()
545 }
546
547 fn skill(name: &str, owner: &str) -> SkillSpec {
548 SkillSpec::builder(name)
549 .owner(owner)
550 .description("A test OpenClaw skill.")
551 .body("## Goal\nDo the thing.\n")
552 .build()
553 }
554
555 fn mcp_spec(name: &str, owner: &str) -> McpSpec {
556 McpSpec::builder(name)
557 .owner(owner)
558 .stdio("npx", ["-y", "@example/server"])
559 .env("FOO", "bar")
560 .build()
561 }
562
563 #[test]
564 fn install_rules_writes_agents_md_block() {
565 let dir = tempdir().unwrap();
566 let agent = OpenClawAgent::new();
567 let scope = Scope::Local(dir.path().to_path_buf());
568
569 agent
570 .install(&scope, &rules_spec("alpha", "Use the test rules."))
571 .unwrap();
572
573 let body = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
574 assert!(body.contains("BEGIN AGENT-CONFIG:alpha"));
575 assert!(body.contains("Use the test rules."));
576 assert!(agent.is_installed(&scope, "alpha").unwrap());
577 }
578
579 #[test]
580 fn rules_install_is_idempotent() {
581 let dir = tempdir().unwrap();
582 let agent = OpenClawAgent::new();
583 let scope = Scope::Local(dir.path().to_path_buf());
584 let spec = rules_spec("alpha", "rules");
585
586 agent.install(&scope, &spec).unwrap();
587 let second = agent.install(&scope, &spec).unwrap();
588 assert!(second.already_installed);
589 }
590
591 #[test]
592 fn uninstall_rules_round_trip() {
593 let dir = tempdir().unwrap();
594 let agent = OpenClawAgent::new();
595 let scope = Scope::Local(dir.path().to_path_buf());
596
597 agent
598 .install(&scope, &rules_spec("alpha", "rules"))
599 .unwrap();
600 let report = agent.uninstall(&scope, "alpha").unwrap();
601 assert!(!report.removed.is_empty());
602 assert!(!dir.path().join("AGENTS.md").exists());
603 }
604
605 #[test]
606 fn install_skill_writes_local_agents_skills() {
607 let dir = tempdir().unwrap();
608 let agent = OpenClawAgent::new();
609 let scope = Scope::Local(dir.path().to_path_buf());
610
611 agent
612 .install_skill(&scope, &skill("alpha-skill", "myapp"))
613 .unwrap();
614
615 assert!(dir
616 .path()
617 .join(".agents/skills/alpha-skill/SKILL.md")
618 .exists());
619 }
620
621 #[test]
622 fn global_skill_root_uses_openclaw_skills() {
623 let home = PathBuf::from("/tmp/home");
624 assert_eq!(
625 OpenClawAgent::skills_root_from_home(&home),
626 PathBuf::from("/tmp/home/.openclaw/skills")
627 );
628 }
629
630 #[test]
631 fn skill_owner_mismatch_is_refused() {
632 let dir = tempdir().unwrap();
633 let agent = OpenClawAgent::new();
634 let scope = Scope::Local(dir.path().to_path_buf());
635
636 agent
637 .install_skill(&scope, &skill("alpha-skill", "app-a"))
638 .unwrap();
639 let err = agent
640 .install_skill(&scope, &skill("alpha-skill", "app-b"))
641 .unwrap_err();
642 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
643 }
644
645 #[test]
646 fn install_mcp_reads_json5_and_preserves_user_entries() {
647 let dir = tempdir().unwrap();
648 let cfg = dir.path().join("openclaw.json");
649 std::fs::write(
650 &cfg,
651 r#"{
652 // existing OpenClaw config
653 mcp: {
654 servers: {
655 user: { url: 'https://example.com/mcp' },
656 },
657 },
658 plugins: { enabled: true },
659}
660"#,
661 )
662 .unwrap();
663
664 OpenClawAgent::install_mcp_config(&cfg, &mcp_spec("github", "myapp")).unwrap();
665
666 let parsed: Value = serde_json::from_slice(&std::fs::read(&cfg).unwrap()).unwrap();
667 assert_eq!(parsed["plugins"]["enabled"], json!(true));
668 assert_eq!(
669 parsed["mcp"]["servers"]["user"]["url"],
670 json!("https://example.com/mcp")
671 );
672 assert_eq!(parsed["mcp"]["servers"]["github"]["command"], json!("npx"));
673 assert_eq!(
674 parsed["mcp"]["servers"]["github"]["env"]["FOO"],
675 json!("bar")
676 );
677 }
678
679 #[test]
680 fn install_mcp_is_idempotent() {
681 let dir = tempdir().unwrap();
682 let cfg = dir.path().join("openclaw.json");
683 let spec = mcp_spec("github", "myapp");
684
685 OpenClawAgent::install_mcp_config(&cfg, &spec).unwrap();
686 let second = OpenClawAgent::install_mcp_config(&cfg, &spec).unwrap();
687 assert!(second.already_installed);
688 }
689
690 #[test]
691 fn uninstall_mcp_round_trip() {
692 let dir = tempdir().unwrap();
693 let cfg = dir.path().join("openclaw.json");
694
695 OpenClawAgent::install_mcp_config(&cfg, &mcp_spec("github", "myapp")).unwrap();
696 let report = OpenClawAgent::uninstall_mcp_config(&cfg, "github", "myapp").unwrap();
697 assert!(!report.removed.is_empty());
698 assert!(!cfg.exists());
699 }
700
701 #[test]
702 fn uninstall_mcp_owner_mismatch_is_refused() {
703 let dir = tempdir().unwrap();
704 let cfg = dir.path().join("openclaw.json");
705
706 OpenClawAgent::install_mcp_config(&cfg, &mcp_spec("github", "app-a")).unwrap();
707 let err = OpenClawAgent::uninstall_mcp_config(&cfg, "github", "app-b").unwrap_err();
708 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
709 }
710
711 #[test]
712 fn local_mcp_scope_is_rejected() {
713 let dir = tempdir().unwrap();
714 let agent = OpenClawAgent::new();
715 let scope = Scope::Local(dir.path().to_path_buf());
716 let err = agent
717 .install_mcp(&scope, &mcp_spec("github", "myapp"))
718 .unwrap_err();
719 assert!(matches!(
720 err,
721 AgentConfigError::UnsupportedScope {
722 scope: ScopeKind::Local,
723 ..
724 }
725 ));
726 }
727
728 #[test]
729 fn remote_mcp_mapping_uses_openclaw_transport_names() {
730 let http = McpSpec::builder("docs")
731 .owner("myapp")
732 .http("https://example.com/mcp")
733 .build();
734 let sse = McpSpec::builder("events")
735 .owner("myapp")
736 .sse("https://example.com/sse")
737 .build();
738
739 assert_eq!(
740 openclaw_mcp_value(&http)["transport"],
741 json!("streamable-http")
742 );
743 assert_eq!(openclaw_mcp_value(&sse)["transport"], json!("sse"));
744 }
745}