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 let rules = spec
174 .rules
175 .as_ref()
176 .ok_or(AgentConfigError::MissingSpecField {
177 id: "openclaw",
178 field: "rules",
179 })?;
180 let path = Self::prompt_path(scope)?;
181 let mut report = InstallReport::default();
182 scope.ensure_contained(&path)?;
183 file_lock::with_lock(&path, || {
184 let host = fs_atomic::read_to_string_or_empty(&path)?;
185 let new_host = md_block::upsert(&host, &spec.tag, &rules.content);
186 let outcome = safe_fs::write(scope, &path, new_host.as_bytes(), true)?;
187 if outcome.no_change {
188 report.already_installed = true;
189 } else if outcome.existed {
190 report.patched.push(outcome.path.clone());
191 } else {
192 report.created.push(outcome.path.clone());
193 }
194 if let Some(b) = outcome.backup {
195 report.backed_up.push(b);
196 }
197 Ok::<(), AgentConfigError>(())
198 })?;
199 Ok(report)
200 }
201
202 fn uninstall(&self, scope: &Scope, tag: &str) -> Result<UninstallReport, AgentConfigError> {
203 HookSpec::validate_tag(tag)?;
204 let path = Self::prompt_path(scope)?;
205 let mut report = UninstallReport::default();
206 scope.ensure_contained(&path)?;
207 file_lock::with_lock(&path, || {
208 let host = fs_atomic::read_to_string_or_empty(&path)?;
209 let (stripped, removed) = md_block::remove(&host, tag);
210
211 if !removed {
212 report.not_installed = true;
213 return Ok(());
214 }
215
216 if stripped.trim().is_empty() {
217 if safe_fs::restore_backup_if_matches(scope, &path, stripped.as_bytes())? {
218 report.restored.push(path.clone());
219 } else {
220 safe_fs::remove_file(scope, &path)?;
221 report.removed.push(path.clone());
222 }
223 } else {
224 safe_fs::write(scope, &path, stripped.as_bytes(), false)?;
225 report.patched.push(path.clone());
226 }
227 Ok::<(), AgentConfigError>(())
228 })?;
229 Ok(report)
230 }
231}
232
233impl McpSurface for OpenClawAgent {
234 fn id(&self) -> &'static str {
235 "openclaw"
236 }
237
238 fn supported_mcp_scopes(&self) -> &'static [ScopeKind] {
239 &[ScopeKind::Global]
240 }
241
242 fn mcp_status(
243 &self,
244 scope: &Scope,
245 name: &str,
246 expected_owner: &str,
247 ) -> Result<StatusReport, AgentConfigError> {
248 McpSpec::validate_name(name)?;
249 let cfg = Self::mcp_path(scope)?;
250 let ledger = ownership::mcp_ledger_for(&cfg);
251 let presence = mcp_json_map::config_presence(
252 &cfg,
253 &["mcp", "servers"],
254 name,
255 mcp_json_map::ConfigFormat::Json5,
256 )?;
257 let recorded = ownership::owner_of(&ledger, name)?;
258 Ok(StatusReport::for_mcp(
259 name,
260 cfg,
261 ledger,
262 presence,
263 expected_owner,
264 recorded,
265 ))
266 }
267
268 fn plan_install_mcp(
269 &self,
270 scope: &Scope,
271 spec: &McpSpec,
272 ) -> Result<InstallPlan, AgentConfigError> {
273 agent_planning::mcp_json_map_install(
274 McpSurface::id(self),
275 scope,
276 spec,
277 Self::mcp_path(scope),
278 &["mcp", "servers"],
279 openclaw_mcp_value,
280 mcp_json_map::ConfigFormat::Json5,
281 )
282 }
283
284 fn plan_uninstall_mcp(
285 &self,
286 scope: &Scope,
287 name: &str,
288 owner_tag: &str,
289 ) -> Result<UninstallPlan, AgentConfigError> {
290 agent_planning::mcp_json_map_uninstall(
291 McpSurface::id(self),
292 scope,
293 name,
294 owner_tag,
295 Self::mcp_path(scope),
296 &["mcp", "servers"],
297 mcp_json_map::ConfigFormat::Json5,
298 )
299 }
300
301 fn is_mcp_installed(&self, scope: &Scope, name: &str) -> Result<bool, AgentConfigError> {
302 McpSpec::validate_name(name)?;
303 let cfg = Self::mcp_path(scope)?;
304 let ledger = ownership::mcp_ledger_for(&cfg);
305 mcp_json_map::is_installed(&ledger, name)
306 }
307
308 fn install_mcp(
309 &self,
310 scope: &Scope,
311 spec: &McpSpec,
312 ) -> Result<InstallReport, AgentConfigError> {
313 spec.validate()?;
314 let cfg = Self::mcp_path(scope)?;
315 spec.validate_local_secret_policy(scope)?;
316 scope.ensure_contained(&cfg)?;
317 Self::install_mcp_config(&cfg, spec)
318 }
319
320 fn uninstall_mcp(
321 &self,
322 scope: &Scope,
323 name: &str,
324 owner_tag: &str,
325 ) -> Result<UninstallReport, AgentConfigError> {
326 McpSpec::validate_name(name)?;
327 HookSpec::validate_tag(owner_tag)?;
328 let cfg = Self::mcp_path(scope)?;
329 scope.ensure_contained(&cfg)?;
330 Self::uninstall_mcp_config(&cfg, name, owner_tag)
331 }
332}
333
334impl SkillSurface for OpenClawAgent {
335 fn id(&self) -> &'static str {
336 "openclaw"
337 }
338
339 fn supported_skill_scopes(&self) -> &'static [ScopeKind] {
340 &[ScopeKind::Global, ScopeKind::Local]
341 }
342
343 fn skill_status(
344 &self,
345 scope: &Scope,
346 name: &str,
347 expected_owner: &str,
348 ) -> Result<StatusReport, AgentConfigError> {
349 SkillSpec::validate_name(name)?;
350 let root = Self::skills_root(scope)?;
351 let (dir, manifest, ledger) = skills_dir::paths_for_status(&root, name);
352 let recorded = ownership::owner_of(&ledger, name)?;
353 Ok(StatusReport::for_skill(
354 name,
355 dir,
356 manifest,
357 ledger,
358 expected_owner,
359 recorded,
360 ))
361 }
362
363 fn plan_install_skill(
364 &self,
365 scope: &Scope,
366 spec: &SkillSpec,
367 ) -> Result<InstallPlan, AgentConfigError> {
368 agent_planning::skill_install(
369 SkillSurface::id(self),
370 scope,
371 spec,
372 Self::skills_root(scope),
373 )
374 }
375
376 fn plan_uninstall_skill(
377 &self,
378 scope: &Scope,
379 name: &str,
380 owner_tag: &str,
381 ) -> Result<UninstallPlan, AgentConfigError> {
382 agent_planning::skill_uninstall(
383 SkillSurface::id(self),
384 scope,
385 name,
386 owner_tag,
387 Self::skills_root(scope),
388 )
389 }
390
391 fn is_skill_installed(&self, scope: &Scope, name: &str) -> Result<bool, AgentConfigError> {
392 let root = Self::skills_root(scope)?;
393 skills_dir::is_installed(&root, name)
394 }
395
396 fn install_skill(
397 &self,
398 scope: &Scope,
399 spec: &SkillSpec,
400 ) -> Result<InstallReport, AgentConfigError> {
401 let root = Self::skills_root(scope)?;
402 scope.ensure_contained(&root)?;
403 skills_dir::install(&root, spec)
404 }
405
406 fn uninstall_skill(
407 &self,
408 scope: &Scope,
409 name: &str,
410 owner_tag: &str,
411 ) -> Result<UninstallReport, AgentConfigError> {
412 let root = Self::skills_root(scope)?;
413 scope.ensure_contained(&root)?;
414 skills_dir::uninstall(&root, name, owner_tag)
415 }
416}
417
418impl OpenClawAgent {
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 OpenClawAgent {
431 fn id(&self) -> &'static str {
432 "openclaw"
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 openclaw_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 } => {
508 obj.insert("url".into(), Value::String(url.clone()));
509 obj.insert("transport".into(), Value::String("streamable-http".into()));
510 if !headers.is_empty() {
511 obj.insert("headers".into(), string_map_value(headers));
512 }
513 }
514 McpTransport::Sse { url, headers } => {
515 obj.insert("url".into(), Value::String(url.clone()));
516 obj.insert("transport".into(), Value::String("sse".into()));
517 if !headers.is_empty() {
518 obj.insert("headers".into(), string_map_value(headers));
519 }
520 }
521 }
522 Value::Object(obj)
523}
524
525fn string_map_value(map: &BTreeMap<String, String>) -> Value {
526 let mut obj = Map::new();
527 for (k, v) in map {
528 obj.insert(k.clone(), Value::String(v.clone()));
529 }
530 Value::Object(obj)
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use serde_json::json;
537 use tempfile::tempdir;
538
539 fn rules_spec(tag: &str, rules: &str) -> HookSpec {
540 HookSpec::builder(tag)
541 .command_program("noop", [] as [&str; 0])
542 .rules(rules)
543 .build()
544 }
545
546 fn skill(name: &str, owner: &str) -> SkillSpec {
547 SkillSpec::builder(name)
548 .owner(owner)
549 .description("A test OpenClaw skill.")
550 .body("## Goal\nDo the thing.\n")
551 .build()
552 }
553
554 fn mcp_spec(name: &str, owner: &str) -> McpSpec {
555 McpSpec::builder(name)
556 .owner(owner)
557 .stdio("npx", ["-y", "@example/server"])
558 .env("FOO", "bar")
559 .build()
560 }
561
562 #[test]
563 fn install_rules_writes_agents_md_block() {
564 let dir = tempdir().unwrap();
565 let agent = OpenClawAgent::new();
566 let scope = Scope::Local(dir.path().to_path_buf());
567
568 agent
569 .install(&scope, &rules_spec("alpha", "Use the test rules."))
570 .unwrap();
571
572 let body = std::fs::read_to_string(dir.path().join("AGENTS.md")).unwrap();
573 assert!(body.contains("BEGIN AGENT-CONFIG:alpha"));
574 assert!(body.contains("Use the test rules."));
575 assert!(agent.is_installed(&scope, "alpha").unwrap());
576 }
577
578 #[test]
579 fn rules_install_is_idempotent() {
580 let dir = tempdir().unwrap();
581 let agent = OpenClawAgent::new();
582 let scope = Scope::Local(dir.path().to_path_buf());
583 let spec = rules_spec("alpha", "rules");
584
585 agent.install(&scope, &spec).unwrap();
586 let second = agent.install(&scope, &spec).unwrap();
587 assert!(second.already_installed);
588 }
589
590 #[test]
591 fn uninstall_rules_round_trip() {
592 let dir = tempdir().unwrap();
593 let agent = OpenClawAgent::new();
594 let scope = Scope::Local(dir.path().to_path_buf());
595
596 agent
597 .install(&scope, &rules_spec("alpha", "rules"))
598 .unwrap();
599 let report = agent.uninstall(&scope, "alpha").unwrap();
600 assert!(!report.removed.is_empty());
601 assert!(!dir.path().join("AGENTS.md").exists());
602 }
603
604 #[test]
605 fn install_skill_writes_local_agents_skills() {
606 let dir = tempdir().unwrap();
607 let agent = OpenClawAgent::new();
608 let scope = Scope::Local(dir.path().to_path_buf());
609
610 agent
611 .install_skill(&scope, &skill("alpha-skill", "myapp"))
612 .unwrap();
613
614 assert!(dir
615 .path()
616 .join(".agents/skills/alpha-skill/SKILL.md")
617 .exists());
618 }
619
620 #[test]
621 fn global_skill_root_uses_openclaw_skills() {
622 let home = PathBuf::from("/tmp/home");
623 assert_eq!(
624 OpenClawAgent::skills_root_from_home(&home),
625 PathBuf::from("/tmp/home/.openclaw/skills")
626 );
627 }
628
629 #[test]
630 fn skill_owner_mismatch_is_refused() {
631 let dir = tempdir().unwrap();
632 let agent = OpenClawAgent::new();
633 let scope = Scope::Local(dir.path().to_path_buf());
634
635 agent
636 .install_skill(&scope, &skill("alpha-skill", "app-a"))
637 .unwrap();
638 let err = agent
639 .install_skill(&scope, &skill("alpha-skill", "app-b"))
640 .unwrap_err();
641 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
642 }
643
644 #[test]
645 fn install_mcp_reads_json5_and_preserves_user_entries() {
646 let dir = tempdir().unwrap();
647 let cfg = dir.path().join("openclaw.json");
648 std::fs::write(
649 &cfg,
650 r#"{
651 // existing OpenClaw config
652 mcp: {
653 servers: {
654 user: { url: 'https://example.com/mcp' },
655 },
656 },
657 plugins: { enabled: true },
658}
659"#,
660 )
661 .unwrap();
662
663 OpenClawAgent::install_mcp_config(&cfg, &mcp_spec("github", "myapp")).unwrap();
664
665 let parsed: Value = serde_json::from_slice(&std::fs::read(&cfg).unwrap()).unwrap();
666 assert_eq!(parsed["plugins"]["enabled"], json!(true));
667 assert_eq!(
668 parsed["mcp"]["servers"]["user"]["url"],
669 json!("https://example.com/mcp")
670 );
671 assert_eq!(parsed["mcp"]["servers"]["github"]["command"], json!("npx"));
672 assert_eq!(
673 parsed["mcp"]["servers"]["github"]["env"]["FOO"],
674 json!("bar")
675 );
676 }
677
678 #[test]
679 fn install_mcp_is_idempotent() {
680 let dir = tempdir().unwrap();
681 let cfg = dir.path().join("openclaw.json");
682 let spec = mcp_spec("github", "myapp");
683
684 OpenClawAgent::install_mcp_config(&cfg, &spec).unwrap();
685 let second = OpenClawAgent::install_mcp_config(&cfg, &spec).unwrap();
686 assert!(second.already_installed);
687 }
688
689 #[test]
690 fn uninstall_mcp_round_trip() {
691 let dir = tempdir().unwrap();
692 let cfg = dir.path().join("openclaw.json");
693
694 OpenClawAgent::install_mcp_config(&cfg, &mcp_spec("github", "myapp")).unwrap();
695 let report = OpenClawAgent::uninstall_mcp_config(&cfg, "github", "myapp").unwrap();
696 assert!(!report.removed.is_empty());
697 assert!(!cfg.exists());
698 }
699
700 #[test]
701 fn uninstall_mcp_owner_mismatch_is_refused() {
702 let dir = tempdir().unwrap();
703 let cfg = dir.path().join("openclaw.json");
704
705 OpenClawAgent::install_mcp_config(&cfg, &mcp_spec("github", "app-a")).unwrap();
706 let err = OpenClawAgent::uninstall_mcp_config(&cfg, "github", "app-b").unwrap_err();
707 assert!(matches!(err, AgentConfigError::NotOwnedByCaller { .. }));
708 }
709
710 #[test]
711 fn local_mcp_scope_is_rejected() {
712 let dir = tempdir().unwrap();
713 let agent = OpenClawAgent::new();
714 let scope = Scope::Local(dir.path().to_path_buf());
715 let err = agent
716 .install_mcp(&scope, &mcp_spec("github", "myapp"))
717 .unwrap_err();
718 assert!(matches!(
719 err,
720 AgentConfigError::UnsupportedScope {
721 scope: ScopeKind::Local,
722 ..
723 }
724 ));
725 }
726
727 #[test]
728 fn remote_mcp_mapping_uses_openclaw_transport_names() {
729 let http = McpSpec::builder("docs")
730 .owner("myapp")
731 .http("https://example.com/mcp")
732 .build();
733 let sse = McpSpec::builder("events")
734 .owner("myapp")
735 .sse("https://example.com/sse")
736 .build();
737
738 assert_eq!(
739 openclaw_mcp_value(&http)["transport"],
740 json!("streamable-http")
741 );
742 assert_eq!(openclaw_mcp_value(&sse)["transport"], json!("sse"));
743 }
744}